
Tc Tracker
- 540 installs
- 23.5k repo stars
- Updated July 17, 2026
- alirezarezvani/claude-skills
tc-tracker is a Claude Code skill that tracks technical changes with a strict lifecycle, file-level audit trail, and AI session handoffs for developers who pause and resume agent work without losing context.
About
tc-tracker is a technical change management skill backed by Python CLI scripts that enforce a strict state machine, append-only revision history, and session-handoff blocks for AI coding agents. Initialize tracking with tc_init.py, create changes via tc_create.py with scope and priority flags, and advance states through tc_update.py while recording file-level edits. Developers reach for tc-tracker when long-running agent sessions risk losing context between pauses, when multiple agents touch the same feature, or when an audit trail of what changed and why is required. The handoff block lets a fresh session resume in-progress work with full TC state.
- Strict TC state machine with append-only revision history
- Four CLI scripts: tc_init, tc_create, tc_update, tc_status under docs/TC/
- Atomic updates for status, touched files, and structured session handoff blocks
- Sequential TC IDs and scope/priority metadata on create
- Idempotent project init for docs/TC/ layout
Tc Tracker by the numbers
- 540 all-time installs (skills.sh)
- Ranked #721 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/alirezarezvani/claude-skills --skill tc-trackerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 540 |
|---|---|
| repo stars | ★ 23.5k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 17, 2026 |
| Repository | alirezarezvani/claude-skills ↗ |
How do you hand off in-progress AI coding sessions?
Track technical changes (TCs) with a strict lifecycle, file-level audit trail, and AI session handoffs so you can pause and resume agent work without losing context.
Who is it for?
Developers running multi-session Claude Code or Cursor agents on features that need tracked state and clean handoffs.
Skip if: Skip tc-tracker for one-shot prompts or when Git commits alone provide sufficient change history without agent session continuity.
When should I use this skill?
An agent session needs to pause, resume, or track a technical change with structured lifecycle states and handoff context.
What you get
TC records with state history, file-level audit trail, and session-handoff blocks for agent resume.
- TC state records
- session-handoff block
- file-level audit log
By the numbers
- Bundles 3 Python CLI scripts: tc_init.py, tc_create.py, and tc_update.py
Files
TC Tracker
Track every code change with structured JSON records, an enforced state machine, and a session handoff format that lets a new AI session resume work cleanly when a previous one expires.
Overview
A Technical Change (TC) is a structured record that captures what changed, why it changed, who changed it, when it changed, how it was tested, and where work stands for the next session. Records live as JSON in docs/TC/ inside the target project, validated against a strict schema and a state machine.
Use this skill when the user:
- Asks to "track this change" or wants an audit trail for code modifications
- Wants to hand off in-progress work to a future AI session
- Needs structured release notes that go beyond commit messages
- Onboards an existing project and wants retroactive change documentation
- Asks for
/tc init,/tc create,/tc update,/tc status,/tc resume, or/tc close
Do NOT use this skill when:
- The user only wants a changelog from git history (use
engineering/changelog-generator) - The user only wants to track tech debt items (use
engineering/tech-debt-tracker) - The change is trivial (typo, formatting) and won't affect behavior
Storage Layout
Each project stores TCs at {project_root}/docs/TC/:
docs/TC/
├── tc_config.json # Project settings
├── tc_registry.json # Master index + statistics
├── records/
│ └── TC-001-04-05-26-user-auth/
│ └── tc_record.json # Source of truth
└── evidence/
└── TC-001/ # Log snippets, command output, screenshotsTC ID Convention
- Parent TC:
TC-NNN-MM-DD-YY-functionality-slug(e.g.,TC-001-04-05-26-user-authentication) - Sub-TC:
TC-NNN.AorTC-NNN.A.1(letter = revision, digit = sub-revision) NNNis sequential,MM-DD-YYis the creation date, slug is kebab-case.
State Machine
planned -> in_progress -> implemented -> tested -> deployed
| | | | |
+-> blocked -+ +- in_progress <-------+
| (rework / hotfix)
+-> plannedSee references/lifecycle.md for the full transition table and recovery flows.
Workflow Commands
The skill ships five Python scripts that perform deterministic, stdlib-only operations on TC records. Each one supports --help and --json.
1. Initialize tracking in a project
python3 scripts/tc_init.py --project "My Project" --root .Creates docs/TC/, docs/TC/records/, docs/TC/evidence/, tc_config.json, and tc_registry.json. Idempotent — re-running reports "already initialized" with current stats.
2. Create a new TC record
python3 scripts/tc_create.py \
--root . \
--name "user-authentication" \
--title "Add JWT-based user authentication" \
--scope feature \
--priority high \
--summary "Adds JWT login + middleware" \
--motivation "Required for protected endpoints"Generates the next sequential TC ID, creates the record directory, writes a fully populated tc_record.json (status planned, R1 creation revision), and updates the registry.
3. Update a TC record
# Status transition (validated against the state machine)
python3 scripts/tc_update.py --root . --tc-id TC-001-04-05-26-user-auth \
--set-status in_progress --reason "Starting implementation"
# Add a file
python3 scripts/tc_update.py --root . --tc-id TC-001-04-05-26-user-auth \
--add-file src/auth.py:created
# Append handoff data
python3 scripts/tc_update.py --root . --tc-id TC-001-04-05-26-user-auth \
--handoff-progress "JWT middleware wired up" \
--handoff-next "Write integration tests" \
--handoff-next "Update README"Every change appends a sequential R<n> revision entry, refreshes updated, and re-validates against the schema before writing atomically (.tmp then rename).
4. View status
# Single TC
python3 scripts/tc_status.py --root . --tc-id TC-001-04-05-26-user-auth
# All TCs (registry summary)
python3 scripts/tc_status.py --root . --all --json5. Validate a record or registry
python3 scripts/tc_validator.py --record docs/TC/records/TC-001-.../tc_record.json
python3 scripts/tc_validator.py --registry docs/TC/tc_registry.jsonValidator enforces the schema, checks state-machine legality, verifies sequential R<n> and T<n> IDs, and asserts approval consistency (approved=true requires approved_by and approved_date).
See references/tc-schema.md for the full schema.
Slash-Command Dispatcher
The repo ships a /tc slash command at commands/tc.md that dispatches to these scripts based on subcommand:
| Command | Action |
|---|---|
/tc init | Run tc_init.py for the current project |
/tc create <name> | Prompt for fields, run tc_create.py |
/tc update <tc-id> | Apply user-described changes via tc_update.py |
/tc status [tc-id] | Run tc_status.py |
/tc resume <tc-id> | Display handoff, archive prior session, start a new one |
/tc close <tc-id> | Transition to deployed, set approval |
/tc export | Re-render all derived artifacts |
/tc dashboard | Re-render the registry summary |
The slash command is the user interface; the Python scripts are the engine.
Session Handoff Format
The handoff block lives at session_context.handoff inside each TC and is the single most important field for AI continuity. It contains:
progress_summary— what has been donenext_steps— ordered list of remaining actionsblockers— anything preventing progresskey_context— critical decisions, gotchas, patterns the next bot must knowfiles_in_progress— files being edited and their state (editing,needs_review,partially_done,ready)decisions_made— architectural decisions with rationale and timestamp
See references/handoff-format.md for the full structure and fill-out rules.
Validation Rules (Always Enforced)
1. State machine — only valid transitions are allowed. 2. Sequential IDs — revision_history uses R1, R2, R3...; test_cases uses T1, T2, T3.... 3. Append-only history — revision entries are never modified or deleted. 4. Approval consistency — approved=true requires approved_by and approved_date. 5. TC ID format — must match TC-NNN-MM-DD-YY-slug. 6. Sub-TC ID format — must match TC-NNN.A or TC-NNN.A.N. 7. Atomic writes — JSON is written to .tmp then renamed. 8. Registry stats — recomputed on every registry write.
Non-Blocking Bookkeeping Pattern
TC tracking must NOT interrupt the main workflow.
- Never stop to update TC records inline. Keep coding.
- At natural milestones, spawn a background subagent to update the record.
- Surface questions only when genuinely needed ("This work doesn't match any active TC — create one?"), and ask once per session, not per file.
- At session end, write a final handoff block before closing.
Retroactive Bulk Creation
For onboarding an existing project with undocumented history, build a retro_changelog.json (one entry per logical change) and feed it to tc_create.py in a loop, or extend the script for batch mode. Group commits by feature, not by file.
Anti-Patterns
| Anti-pattern | Why it's bad | Do this instead |
|---|---|---|
Editing revision_history to "fix" a typo | History is append-only — tampering destroys the audit trail | Add a new revision that corrects the field |
| Skipping the state machine ("just set status to deployed") | Bypasses validation and hides skipped phases | Walk through in_progress -> implemented -> tested -> deployed |
| Creating one TC per file changed | Fragments related work and explodes the registry | One TC per logical unit (feature, fix, refactor) |
| Updating TC inline between every code edit | Slows the main agent, wastes context | Spawn a background subagent at milestones |
Marking approved=true without approved_by | Validator will reject; misleading audit trail | Always set approved_by and approved_date together |
Overwriting tc_record.json directly with a text editor | Risks corruption mid-write and skips validation | Use tc_update.py (atomic write + schema check) |
Putting secrets in notes or evidence | Records are committed to the repo | Reference an env var or external secret store |
| Reusing TC IDs after deletion | Breaks the sequential guarantee and confuses history | Increment forward only — never recycle |
Letting next_steps go stale | Defeats the purpose of handoff | Update on every milestone, even if it's "nothing changed" |
Cross-References
engineering/changelog-generator— Generates Keep-a-Changelog release notes from Conventional Commits. Pair it with TC tracker: TC for the granular per-change audit trail, changelog for user-facing release notes.engineering/tech-debt-tracker— For tracking long-lived debt items rather than discrete code changes.engineering/focused-fix— When a bug fix needs systematic feature-wide repair, run/focused-fixfirst then capture the result as a TC.project-management/decision-log— Architectural decisions made inside a TC'sdecisions_madeblock can also be promoted to a project-wide decision log.engineering-team/code-reviewer— Pre-merge review fits naturally into thetested -> deployedtransition; capture the reviewer inapproval.approved_by.
References in This Skill
- references/tc-schema.md — Full JSON schema for TC records and the registry.
- references/lifecycle.md — State machine, valid transitions, and recovery flows.
- references/handoff-format.md — Session handoff structure and best practices.
TC Tracker
Structured tracking for technical changes (TCs) with a strict state machine, append-only revision history, and a session-handoff block that lets a new AI session resume in-progress work cleanly.
Quick Start
# 1. Initialize tracking in your project
python3 scripts/tc_init.py --project "My Project" --root .
# 2. Create a new TC
python3 scripts/tc_create.py --root . \
--name "user-auth" \
--title "Add JWT authentication" \
--scope feature --priority high \
--summary "Adds JWT login + middleware" \
--motivation "Required for protected endpoints"
# 3. Move it to in_progress and record some work
python3 scripts/tc_update.py --root . --tc-id <TC-ID> \
--set-status in_progress --reason "Starting implementation"
python3 scripts/tc_update.py --root . --tc-id <TC-ID> \
--add-file src/auth.py:created \
--add-file src/middleware.py:modified
# 4. Write a session handoff before stopping
python3 scripts/tc_update.py --root . --tc-id <TC-ID> \
--handoff-progress "JWT middleware wired up" \
--handoff-next "Write integration tests" \
--handoff-blocker "Waiting on test fixtures"
# 5. Check status
python3 scripts/tc_status.py --root . --allIncluded Scripts
scripts/tc_init.py— Initializedocs/TC/in a project (idempotent)scripts/tc_create.py— Create a new TC record with sequential IDscripts/tc_update.py— Update fields, status, files, handoff, with atomic writesscripts/tc_status.py— View a single TC or the full registryscripts/tc_validator.py— Validate a record or registry against schema + state machine
All scripts:
- Use Python stdlib only
- Support
--helpand--json - Use exit codes 0 (ok) / 1 (warnings) / 2 (errors)
References
references/tc-schema.md— JSON schema referencereferences/lifecycle.md— State machine and transitionsreferences/handoff-format.md— Session handoff structure
Slash Command
When installed with the rest of this repo, the /tc <subcommand> slash command (defined at commands/tc.md) dispatches to these scripts.
Installation
Claude Code
cp -R engineering/tc-tracker ~/.claude/skills/tc-trackerOpenAI Codex
cp -R engineering/tc-tracker ~/.codex/skills/tc-trackerSession Handoff Format
The handoff block is the most important part of a TC for AI continuity. When a session expires, the next session reads this block to resume work cleanly without re-deriving context.
Where it lives
session_context.handoff inside tc_record.json.
Structure
{
"progress_summary": "string",
"next_steps": ["string", "..."],
"blockers": ["string", "..."],
"key_context": ["string", "..."],
"files_in_progress": [
{
"path": "src/foo.py",
"state": "editing|needs_review|partially_done|ready",
"notes": "string|null"
}
],
"decisions_made": [
{
"decision": "string",
"rationale": "string",
"timestamp": "ISO 8601"
}
]
}Field-by-field rules
progress_summary (string)
A 1-3 sentence narrative of what has been done. Past tense. Concrete.
GOOD:
"Implemented JWT signing with HS256, wired the auth middleware into the main router, and added two passing unit tests for the happy path."
BAD:
"Working on auth." (too vague)
"Wrote a bunch of code." (no specifics)
next_steps (array of strings)
Ordered list of remaining actions. Each step should be small enough to complete in 5-15 minutes. Use imperative mood.
GOOD:
- "Add integration test for invalid token (401)"
- "Update README with the new POST /login endpoint"
- "Run
pytest tests/auth/and capture output as evidence T2"
BAD:
- "Finish the feature" (not actionable)
- "Make it better" (no measurable outcome)
blockers (array of strings)
Things preventing progress RIGHT NOW. If empty, the TC should not be in blocked status.
GOOD:
- "Test fixtures for the user model do not exist; need to create
tests/fixtures/user.py" - "Waiting for product to confirm whether refresh tokens are in scope (asked in #product channel)"
BAD:
- "It's hard." (not a blocker)
- "I'm tired." (not a blocker)
key_context (array of strings)
Critical decisions, gotchas, patterns, or constraints the next session MUST know. Things that took the current session significant effort to discover.
GOOD:
- "The
legacy_authmodule is being phased out — do NOT extend it. New code goes insrc/auth/." - "We use HS256 (not RS256) because the secret rotation tooling does not support asymmetric keys yet."
- "There is a hidden import cycle if you import
Userfrommodels.userinstead ofmodels. Always usefrom models import User."
BAD:
- "Be careful." (not specific)
- "There might be bugs." (not actionable)
files_in_progress (array of objects)
Files currently mid-edit or partially complete. Include the state so the next session knows whether to read, edit, or review.
| state | meaning |
|---|---|
editing | Actively being modified, may not compile |
needs_review | Changes complete but unverified |
partially_done | Some functions done, others stubbed |
ready | Complete and tested |
decisions_made (array of objects)
Architectural decisions taken during the current session, with rationale and timestamp. These should also be promoted to a project-wide decision log when significant.
{
"decision": "Use HS256 instead of RS256 for JWT signing",
"rationale": "Secret rotation tooling does not support asymmetric keys; we accept the tradeoff because token lifetime is 15 minutes",
"timestamp": "2026-04-05T14:32:00+00:00"
}Handoff Lifecycle
When to write the handoff
- At every natural milestone (feature complete, tests passing, EOD)
- BEFORE the session is likely to expire
- Whenever a blocker is hit
- Whenever a non-obvious decision is made
How to write it (non-blocking)
Spawn a background subagent so the main agent doesn't pause:
"Readdocs/TC/records/<TC-ID>/tc_record.json. Update the handoff section with: progress_summary='...'; add next_step '...'; add blocker '...'. Usetc_update.pyso revision history is appended. Then updatelast_activeand write atomically."
How the next session reads it
1. Read docs/TC/tc_registry.json and find TCs with status in_progress or blocked. 2. Read tc_record.json for each. 3. Display the handoff block to the user. 4. Ask: "Resume <TC-ID>? (y/n)" 5. If yes:
- Archive the previous session's
current_sessionintosession_historywith anendedtimestamp and a summary. - Create a new
current_sessionfor the new bot. - Append a revision: "Session resumed by <platform/model>".
- Walk through
next_stepsin order.
Quality Bar
A handoff is "good" if a fresh AI session, with no other context, can pick up the work and make progress within 5 minutes of reading the record. If the next session has to ask "what was I doing?" or "what does this code do?", the previous handoff failed.
Anti-patterns
| Anti-pattern | Why it's bad |
|---|---|
| Empty handoff at session end | Defeats the entire purpose |
next_steps: ["continue"] | Not actionable |
| Handoff written but never updated as work progresses | Goes stale within an hour |
Decisions buried in notes instead of decisions_made | Loses the rationale |
Files mid-edit but not listed in files_in_progress | Next session reads stale code |
Blockers in notes instead of blockers array | TC status cannot be set to blocked |
TC Lifecycle and State Machine
A TC moves through six implementation states. Transitions are validated on every write — invalid moves are rejected with a clear error.
State Diagram
+-----------+
| planned |
+-----------+
| ^
v |
+-------------+
+-----> | in_progress | <-----+
| +-------------+ |
| | | |
v | v |
+---------+ | +-------------+ |
| blocked |<---+ | implemented | |
+---------+ +-------------+ |
| | |
v v |
+---------+ +--------+ |
| planned | | tested |-----+
+---------+ +--------+
|
v
+----------+
| deployed |
+----------+
|
v
in_progress (rework / hotfix)Transition Table
| From | Allowed Transitions |
|---|---|
planned | in_progress, blocked |
in_progress | blocked, implemented |
blocked | in_progress, planned |
implemented | tested, in_progress |
tested | deployed, in_progress |
deployed | in_progress |
Same-status transitions are no-ops and always allowed. Anything else is an error.
State Definitions
| State | Meaning | Required Before Moving Forward |
|---|---|---|
planned | TC has been created with description and motivation | Decide implementation approach |
in_progress | Active development | Code changes captured in files_affected |
blocked | Cannot proceed (dependency, decision needed) | At least one entry in handoff.blockers |
implemented | Code complete, awaiting tests | All target files in files_affected |
tested | Test cases executed, results recorded | At least one test_case with status pass (or explicit skip with rationale) |
deployed | Approved and shipped | approval.approved=true with approved_by and approved_date |
Recovery Flows
"I committed before testing"
1. Status is implemented. 2. Write tests, run them, set test_cases[*].status = pass. 3. Transition implemented -> tested.
"Production bug in a deployed TC"
1. Open the deployed TC. 2. Transition deployed -> in_progress. 3. Add a new revision summarizing the rework. 4. Walk forward through implemented -> tested -> deployed again.
"Blocked, then unblocked"
1. From in_progress, transition to blocked. Add blockers to handoff.blockers. 2. When unblocked, transition blocked -> in_progress and clear/move blockers to notes.
"Cancelled work"
There is no cancelled state. If a TC is abandoned: 1. Add a final revision: "Cancelled — reason: ...". 2. Move to blocked. 3. Add a [CANCELLED] tag. 4. Leave the record in place — never delete it (history is append-only).
Status Field Discipline
- Update
statusONLY throughtc_update.py --set-status. Never edit JSON by hand. - Every status change creates a new revision entry with
field=status,action=changed, andreasonpopulated. - The registry's
statistics.by_statusis recomputed on every write.
Anti-patterns
| Anti-pattern | Why it's wrong |
|---|---|
Skipping tested and going straight to deployed | Bypasses validation; misleads downstream consumers |
| Deleting a record to "cancel" a TC | History is append-only; deletion breaks the audit trail |
| Re-using a TC ID after deletion | Sequential numbering must be preserved |
Changing status without a --reason | Future maintainers cannot reconstruct intent |
Long-lived in_progress TCs (weeks+) | Either too big — split into sub-TCs — or stalled and should be marked blocked |
TC Record Schema
A TC record is a JSON object stored at docs/TC/records/<TC-ID>/tc_record.json. Every record is validated against this schema and a state machine on every write.
Top-Level Fields
| Field | Type | Required | Notes |
|---|---|---|---|
tc_id | string | yes | Pattern: TC-NNN-MM-DD-YY-slug |
parent_tc | string \ | null | no |
title | string | yes | 5-120 characters |
status | enum | yes | One of: planned, in_progress, blocked, implemented, tested, deployed |
priority | enum | yes | critical, high, medium, low |
created | ISO 8601 | yes | UTC timestamp |
updated | ISO 8601 | yes | UTC timestamp, refreshed on every write |
created_by | string | yes | Author identifier (e.g., user:micha, ai:claude-opus) |
project | string | yes | Project name (denormalized from registry) |
description | object | yes | See below |
files_affected | array | yes | See below |
revision_history | array | yes | Append-only, sequential R<n> IDs |
sub_tcs | array | no | Child TCs |
test_cases | array | yes | Sequential T<n> IDs |
approval | object | yes | See below |
session_context | object | yes | See below |
tags | array<string> | yes | Freeform tags |
related_tcs | array<string> | yes | Cross-references |
notes | string | yes | Freeform notes |
metadata | object | yes | See below |
description
{
"summary": "string (10+ chars)",
"motivation": "string (1+ chars)",
"scope": "feature|bugfix|refactor|infrastructure|documentation|hotfix|enhancement",
"detailed_design": "string|null",
"breaking_changes": ["string", "..."],
"dependencies": ["string", "..."]
}files_affected (array of objects)
{
"path": "src/auth.py",
"action": "created|modified|deleted|renamed",
"description": "string|null",
"lines_added": "integer|null",
"lines_removed": "integer|null"
}revision_history (array of objects, append-only)
{
"revision_id": "R1",
"timestamp": "2026-04-05T12:34:56+00:00",
"author": "ai:claude-opus",
"summary": "Created TC record",
"field_changes": [
{
"field": "status",
"action": "set|changed|added|removed",
"old_value": "planned",
"new_value": "in_progress",
"reason": "Starting implementation"
}
]
}Rules:
- IDs are sequential: R1, R2, R3, ... no gaps allowed.
- The first entry is always the creation event.
- Existing entries are NEVER modified or deleted.
test_cases (array of objects)
{
"test_id": "T1",
"title": "Login returns JWT for valid credentials",
"procedure": ["POST /login", "with valid creds"],
"expected_result": "200 + token in body",
"actual_result": "string|null",
"status": "pending|pass|fail|skip|blocked",
"evidence": [
{
"type": "log_snippet|screenshot|file_reference|command_output",
"description": "string",
"content": "string|null",
"path": "string|null",
"timestamp": "ISO|null"
}
],
"tested_by": "string|null",
"tested_date": "ISO|null"
}approval
{
"approved": false,
"approved_by": "string|null",
"approved_date": "ISO|null",
"approval_notes": "string",
"test_coverage_status": "none|partial|full"
}Consistency rule: if approved=true, both approved_by and approved_date MUST be set.
session_context
{
"current_session": {
"session_id": "string",
"platform": "claude_code|claude_web|api|other",
"model": "string",
"started": "ISO",
"last_active": "ISO|null"
},
"handoff": {
"progress_summary": "string",
"next_steps": ["string", "..."],
"blockers": ["string", "..."],
"key_context": ["string", "..."],
"files_in_progress": [
{
"path": "src/foo.py",
"state": "editing|needs_review|partially_done|ready",
"notes": "string|null"
}
],
"decisions_made": [
{
"decision": "string",
"rationale": "string",
"timestamp": "ISO"
}
]
},
"session_history": [
{
"session_id": "string",
"platform": "string",
"model": "string",
"started": "ISO",
"ended": "ISO",
"summary": "string",
"changes_made": ["string", "..."]
}
]
}metadata
{
"project": "string",
"created_by": "string",
"last_modified_by": "string",
"last_modified": "ISO",
"estimated_effort": "trivial|small|medium|large|epic|null"
}Registry Schema (tc_registry.json)
{
"project_name": "string",
"created": "ISO",
"updated": "ISO",
"next_tc_number": 1,
"records": [
{
"tc_id": "TC-001-...",
"title": "string",
"status": "enum",
"scope": "enum",
"priority": "enum",
"created": "ISO",
"updated": "ISO",
"path": "records/TC-001-.../tc_record.json"
}
],
"statistics": {
"total": 0,
"by_status": { "planned": 0, "in_progress": 0, "blocked": 0, "implemented": 0, "tested": 0, "deployed": 0 },
"by_scope": { "feature": 0, "bugfix": 0, "refactor": 0, "infrastructure": 0, "documentation": 0, "hotfix": 0, "enhancement": 0 },
"by_priority": { "critical": 0, "high": 0, "medium": 0, "low": 0 }
}
}Statistics are recomputed on every registry write. Never edit them by hand.
#!/usr/bin/env python3
"""TC Create — Create a new Technical Change record.
Generates the next sequential TC ID, scaffolds the record directory, writes a
fully populated tc_record.json (status=planned, R1 creation revision), and
appends a registry entry with recomputed statistics.
Usage:
python3 tc_create.py --root . --name user-auth \\
--title "Add JWT authentication" --scope feature --priority high \\
--summary "Adds JWT login + middleware" \\
--motivation "Required for protected endpoints"
Exit codes:
0 = created
1 = warnings (e.g. validation soft warnings)
2 = critical error (registry missing, bad args, schema invalid)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
VALID_STATUSES = ("planned", "in_progress", "blocked", "implemented", "tested", "deployed")
VALID_SCOPES = ("feature", "bugfix", "refactor", "infrastructure", "documentation", "hotfix", "enhancement")
VALID_PRIORITIES = ("critical", "high", "medium", "low")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def date_slug(dt: datetime) -> str:
return dt.strftime("%m-%d-%y")
def write_json_atomic(path: Path, data: dict) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
def compute_stats(records: list) -> dict:
stats = {
"total": len(records),
"by_status": {s: 0 for s in VALID_STATUSES},
"by_scope": {s: 0 for s in VALID_SCOPES},
"by_priority": {p: 0 for p in VALID_PRIORITIES},
}
for rec in records:
for key, bucket in (("status", "by_status"), ("scope", "by_scope"), ("priority", "by_priority")):
v = rec.get(key, "")
if v in stats[bucket]:
stats[bucket][v] += 1
return stats
def build_record(tc_id: str, title: str, scope: str, priority: str, summary: str,
motivation: str, project_name: str, author: str, session_id: str,
platform: str, model: str) -> dict:
ts = now_iso()
return {
"tc_id": tc_id,
"parent_tc": None,
"title": title,
"status": "planned",
"priority": priority,
"created": ts,
"updated": ts,
"created_by": author,
"project": project_name,
"description": {
"summary": summary,
"motivation": motivation,
"scope": scope,
"detailed_design": None,
"breaking_changes": [],
"dependencies": [],
},
"files_affected": [],
"revision_history": [
{
"revision_id": "R1",
"timestamp": ts,
"author": author,
"summary": "TC record created",
"field_changes": [
{"field": "status", "action": "set", "new_value": "planned", "reason": "initial creation"},
],
}
],
"sub_tcs": [],
"test_cases": [],
"approval": {
"approved": False,
"approved_by": None,
"approved_date": None,
"approval_notes": "",
"test_coverage_status": "none",
},
"session_context": {
"current_session": {
"session_id": session_id,
"platform": platform,
"model": model,
"started": ts,
"last_active": ts,
},
"handoff": {
"progress_summary": "",
"next_steps": [],
"blockers": [],
"key_context": [],
"files_in_progress": [],
"decisions_made": [],
},
"session_history": [],
},
"tags": [],
"related_tcs": [],
"notes": "",
"metadata": {
"project": project_name,
"created_by": author,
"last_modified_by": author,
"last_modified": ts,
"estimated_effort": None,
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Create a new TC record.")
parser.add_argument("--root", default=".", help="Project root (default: current directory)")
parser.add_argument("--name", required=True, help="Functionality slug (kebab-case, e.g. user-auth)")
parser.add_argument("--title", required=True, help="Human-readable title (5-120 chars)")
parser.add_argument("--scope", required=True, choices=VALID_SCOPES, help="Change category")
parser.add_argument("--priority", default="medium", choices=VALID_PRIORITIES, help="Priority level")
parser.add_argument("--summary", required=True, help="Concise summary (10+ chars)")
parser.add_argument("--motivation", required=True, help="Why this change is needed")
parser.add_argument("--author", default=None, help="Author identifier (defaults to config default_author)")
parser.add_argument("--session-id", default=None, help="Session identifier (default: auto)")
parser.add_argument("--platform", default="claude_code", choices=("claude_code", "claude_web", "api", "other"))
parser.add_argument("--model", default="unknown", help="AI model identifier")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
root = Path(args.root).resolve()
tc_dir = root / "docs" / "TC"
config_path = tc_dir / "tc_config.json"
registry_path = tc_dir / "tc_registry.json"
if not config_path.exists() or not registry_path.exists():
msg = f"TC tracking not initialized at {tc_dir}. Run tc_init.py first."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
registry = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Failed to read config/registry: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
project_name = config.get("project_name", "Unknown Project")
author = args.author or config.get("default_author", "Claude")
session_id = args.session_id or f"session-{int(datetime.now().timestamp())}-{os.getpid()}"
if len(args.title) < 5 or len(args.title) > 120:
msg = "Title must be 5-120 characters."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
if len(args.summary) < 10:
msg = "Summary must be at least 10 characters."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
name_slug = slugify(args.name)
if not name_slug:
msg = "Invalid name slug."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
next_num = registry.get("next_tc_number", 1)
today = datetime.now()
tc_id = f"TC-{next_num:03d}-{date_slug(today)}-{name_slug}"
record_dir = tc_dir / "records" / tc_id
if record_dir.exists():
msg = f"Record directory already exists: {record_dir}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
record = build_record(
tc_id=tc_id,
title=args.title,
scope=args.scope,
priority=args.priority,
summary=args.summary,
motivation=args.motivation,
project_name=project_name,
author=author,
session_id=session_id,
platform=args.platform,
model=args.model,
)
try:
record_dir.mkdir(parents=True, exist_ok=False)
(tc_dir / "evidence" / tc_id).mkdir(parents=True, exist_ok=True)
write_json_atomic(record_dir / "tc_record.json", record)
except OSError as e:
msg = f"Failed to write record: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
registry_entry = {
"tc_id": tc_id,
"title": args.title,
"status": "planned",
"scope": args.scope,
"priority": args.priority,
"created": record["created"],
"updated": record["updated"],
"path": f"records/{tc_id}/tc_record.json",
}
registry["records"].append(registry_entry)
registry["next_tc_number"] = next_num + 1
registry["updated"] = now_iso()
registry["statistics"] = compute_stats(registry["records"])
try:
write_json_atomic(registry_path, registry)
except OSError as e:
msg = f"Failed to update registry: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
result = {
"status": "created",
"tc_id": tc_id,
"title": args.title,
"scope": args.scope,
"priority": args.priority,
"record_path": str(record_dir / "tc_record.json"),
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Created {tc_id}")
print(f" Title: {args.title}")
print(f" Scope: {args.scope}")
print(f" Priority: {args.priority}")
print(f" Record: {record_dir / 'tc_record.json'}")
print()
print(f"Next: tc_update.py --root {args.root} --tc-id {tc_id} --set-status in_progress")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""TC Init — Initialize TC tracking inside a project.
Creates docs/TC/ with tc_config.json, tc_registry.json, records/, and evidence/.
Idempotent: re-running on an already-initialized project reports current stats
and exits cleanly.
Usage:
python3 tc_init.py --project "My Project" --root .
python3 tc_init.py --project "My Project" --root /path/to/project --json
Exit codes:
0 = initialized OR already initialized
1 = warnings (e.g. partial state)
2 = bad CLI args / I/O error
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
VALID_STATUSES = ("planned", "in_progress", "blocked", "implemented", "tested", "deployed")
VALID_SCOPES = ("feature", "bugfix", "refactor", "infrastructure", "documentation", "hotfix", "enhancement")
VALID_PRIORITIES = ("critical", "high", "medium", "low")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def detect_project_name(root: Path) -> str:
"""Try CLAUDE.md heading, package.json name, pyproject.toml name, then directory basename."""
claude_md = root / "CLAUDE.md"
if claude_md.exists():
try:
for line in claude_md.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
except OSError:
pass
pkg = root / "package.json"
if pkg.exists():
try:
data = json.loads(pkg.read_text(encoding="utf-8"))
name = data.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
except (OSError, json.JSONDecodeError):
pass
pyproject = root / "pyproject.toml"
if pyproject.exists():
try:
for line in pyproject.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("name") and "=" in stripped:
value = stripped.split("=", 1)[1].strip().strip('"').strip("'")
if value:
return value
except OSError:
pass
return root.resolve().name
def build_config(project_name: str) -> dict:
return {
"project_name": project_name,
"tc_root": "docs/TC",
"created": now_iso(),
"auto_track": True,
"default_author": "Claude",
"categories": list(VALID_SCOPES),
}
def build_registry(project_name: str) -> dict:
return {
"project_name": project_name,
"created": now_iso(),
"updated": now_iso(),
"next_tc_number": 1,
"records": [],
"statistics": {
"total": 0,
"by_status": {s: 0 for s in VALID_STATUSES},
"by_scope": {s: 0 for s in VALID_SCOPES},
"by_priority": {p: 0 for p in VALID_PRIORITIES},
},
}
def write_json_atomic(path: Path, data: dict) -> None:
"""Write JSON to a temp file and rename, to avoid partial writes."""
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
def main() -> int:
parser = argparse.ArgumentParser(description="Initialize TC tracking in a project.")
parser.add_argument("--root", default=".", help="Project root directory (default: current directory)")
parser.add_argument("--project", help="Project name (auto-detected if omitted)")
parser.add_argument("--force", action="store_true", help="Re-initialize even if config exists (preserves registry)")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
root = Path(args.root).resolve()
if not root.exists() or not root.is_dir():
msg = f"Project root does not exist or is not a directory: {root}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
tc_dir = root / "docs" / "TC"
config_path = tc_dir / "tc_config.json"
registry_path = tc_dir / "tc_registry.json"
if config_path.exists() and not args.force:
try:
cfg = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Existing tc_config.json is unreadable: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
stats = {}
if registry_path.exists():
try:
reg = json.loads(registry_path.read_text(encoding="utf-8"))
stats = reg.get("statistics", {})
except (OSError, json.JSONDecodeError):
stats = {}
result = {
"status": "already_initialized",
"project_name": cfg.get("project_name"),
"tc_root": str(tc_dir),
"statistics": stats,
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"TC tracking already initialized for project '{cfg.get('project_name')}'.")
print(f" TC root: {tc_dir}")
if stats:
print(f" Total TCs: {stats.get('total', 0)}")
return 0
project_name = args.project or detect_project_name(root)
try:
tc_dir.mkdir(parents=True, exist_ok=True)
(tc_dir / "records").mkdir(exist_ok=True)
(tc_dir / "evidence").mkdir(exist_ok=True)
write_json_atomic(config_path, build_config(project_name))
if not registry_path.exists() or args.force:
write_json_atomic(registry_path, build_registry(project_name))
except OSError as e:
msg = f"Failed to create TC directories or files: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
result = {
"status": "initialized",
"project_name": project_name,
"tc_root": str(tc_dir),
"files_created": [
str(config_path),
str(registry_path),
str(tc_dir / "records"),
str(tc_dir / "evidence"),
],
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Initialized TC tracking for project '{project_name}'")
print(f" TC root: {tc_dir}")
print(f" Config: {config_path}")
print(f" Registry: {registry_path}")
print(f" Records: {tc_dir / 'records'}")
print(f" Evidence: {tc_dir / 'evidence'}")
print()
print("Next: python3 tc_create.py --root . --name <slug> --title <title> --scope <scope> ...")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""TC Status — Show TC status for one record or the entire registry.
Usage:
# Single TC
python3 tc_status.py --root . --tc-id <TC-ID>
python3 tc_status.py --root . --tc-id <TC-ID> --json
# All TCs (registry summary)
python3 tc_status.py --root . --all
python3 tc_status.py --root . --all --json
Exit codes:
0 = ok
1 = warnings (e.g. validation issues found while reading)
2 = critical error (file missing, parse error, bad args)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def find_record_path(tc_dir: Path, tc_id: str) -> Path | None:
direct = tc_dir / "records" / tc_id / "tc_record.json"
if direct.exists():
return direct
for entry in (tc_dir / "records").glob("*"):
if entry.is_dir() and entry.name.startswith(tc_id):
candidate = entry / "tc_record.json"
if candidate.exists():
return candidate
return None
def render_single(record: dict) -> str:
lines = []
lines.append(f"TC: {record.get('tc_id')}")
lines.append(f" Title: {record.get('title')}")
lines.append(f" Status: {record.get('status')}")
lines.append(f" Priority: {record.get('priority')}")
desc = record.get("description", {}) or {}
lines.append(f" Scope: {desc.get('scope')}")
lines.append(f" Created: {record.get('created')}")
lines.append(f" Updated: {record.get('updated')}")
lines.append(f" Author: {record.get('created_by')}")
lines.append("")
summary = desc.get("summary") or ""
if summary:
lines.append(f" Summary: {summary}")
motivation = desc.get("motivation") or ""
if motivation:
lines.append(f" Motivation: {motivation}")
lines.append("")
files = record.get("files_affected", []) or []
lines.append(f" Files affected: {len(files)}")
for f in files[:10]:
lines.append(f" - {f.get('path')} ({f.get('action')})")
if len(files) > 10:
lines.append(f" ... and {len(files) - 10} more")
lines.append("")
tests = record.get("test_cases", []) or []
pass_count = sum(1 for t in tests if t.get("status") == "pass")
fail_count = sum(1 for t in tests if t.get("status") == "fail")
lines.append(f" Tests: {pass_count} pass / {fail_count} fail / {len(tests)} total")
lines.append("")
revs = record.get("revision_history", []) or []
lines.append(f" Revisions: {len(revs)}")
if revs:
latest = revs[-1]
lines.append(f" Latest: {latest.get('revision_id')} {latest.get('timestamp')}")
lines.append(f" {latest.get('author')}: {latest.get('summary')}")
lines.append("")
handoff = (record.get("session_context", {}) or {}).get("handoff", {}) or {}
if any(handoff.get(k) for k in ("progress_summary", "next_steps", "blockers", "key_context")):
lines.append(" Handoff:")
if handoff.get("progress_summary"):
lines.append(f" Progress: {handoff['progress_summary']}")
if handoff.get("next_steps"):
lines.append(" Next steps:")
for s in handoff["next_steps"]:
lines.append(f" - {s}")
if handoff.get("blockers"):
lines.append(" Blockers:")
for b in handoff["blockers"]:
lines.append(f" ! {b}")
if handoff.get("key_context"):
lines.append(" Key context:")
for c in handoff["key_context"]:
lines.append(f" * {c}")
appr = record.get("approval", {}) or {}
lines.append("")
lines.append(f" Approved: {appr.get('approved')} ({appr.get('test_coverage_status')} coverage)")
if appr.get("approved"):
lines.append(f" By: {appr.get('approved_by')} on {appr.get('approved_date')}")
return "\n".join(lines)
def render_registry(registry: dict) -> str:
lines = []
lines.append(f"Project: {registry.get('project_name')}")
lines.append(f"Updated: {registry.get('updated')}")
stats = registry.get("statistics", {}) or {}
lines.append(f"Total TCs: {stats.get('total', 0)}")
by_status = stats.get("by_status", {}) or {}
lines.append("By status:")
for status, count in by_status.items():
if count:
lines.append(f" {status:12} {count}")
lines.append("")
records = registry.get("records", []) or []
if records:
lines.append(f"{'TC ID':40} {'Status':14} {'Scope':14} {'Priority':10} Title")
lines.append("-" * 100)
for rec in records:
lines.append("{:40} {:14} {:14} {:10} {}".format(
rec.get("tc_id", "")[:40],
rec.get("status", "")[:14],
rec.get("scope", "")[:14],
rec.get("priority", "")[:10],
rec.get("title", ""),
))
else:
lines.append("No TC records yet. Run tc_create.py to add one.")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Show TC status.")
parser.add_argument("--root", default=".", help="Project root (default: current directory)")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--tc-id", help="Show this single TC")
group.add_argument("--all", action="store_true", help="Show registry summary for all TCs")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
root = Path(args.root).resolve()
tc_dir = root / "docs" / "TC"
registry_path = tc_dir / "tc_registry.json"
if not registry_path.exists():
msg = f"TC tracking not initialized at {tc_dir}. Run tc_init.py first."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
try:
registry = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Failed to read registry: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
if args.all:
if args.json:
print(json.dumps({
"status": "ok",
"project_name": registry.get("project_name"),
"updated": registry.get("updated"),
"statistics": registry.get("statistics", {}),
"records": registry.get("records", []),
}, indent=2))
else:
print(render_registry(registry))
return 0
record_path = find_record_path(tc_dir, args.tc_id)
if record_path is None:
msg = f"TC not found: {args.tc_id}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
try:
record = json.loads(record_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Failed to read record: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
if args.json:
print(json.dumps({"status": "ok", "record": record}, indent=2))
else:
print(render_single(record))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""TC Update — Update an existing TC record.
Each invocation appends a sequential R<n> revision entry, refreshes the
`updated` timestamp, validates the resulting record, and writes atomically.
Usage:
# Status transition (validated against state machine)
python3 tc_update.py --root . --tc-id <TC-ID> \\
--set-status in_progress --reason "Starting implementation"
# Add files
python3 tc_update.py --root . --tc-id <TC-ID> \\
--add-file src/auth.py:created \\
--add-file src/middleware.py:modified
# Add a test case
python3 tc_update.py --root . --tc-id <TC-ID> \\
--add-test "Login returns JWT" \\
--test-procedure "POST /login with valid creds" \\
--test-expected "200 + token in body"
# Append handoff data
python3 tc_update.py --root . --tc-id <TC-ID> \\
--handoff-progress "JWT middleware wired up" \\
--handoff-next "Write integration tests" \\
--handoff-next "Update README" \\
--handoff-blocker "Waiting on test fixtures"
# Append a freeform note
python3 tc_update.py --root . --tc-id <TC-ID> --note "Decision: use HS256"
Exit codes:
0 = updated
1 = warnings (e.g. validation produced errors but write skipped)
2 = critical error (file missing, invalid transition, parse error)
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
VALID_STATUSES = ("planned", "in_progress", "blocked", "implemented", "tested", "deployed")
VALID_TRANSITIONS = {
"planned": ["in_progress", "blocked"],
"in_progress": ["blocked", "implemented"],
"blocked": ["in_progress", "planned"],
"implemented": ["tested", "in_progress"],
"tested": ["deployed", "in_progress"],
"deployed": ["in_progress"],
}
VALID_FILE_ACTIONS = ("created", "modified", "deleted", "renamed")
VALID_TEST_STATUSES = ("pending", "pass", "fail", "skip", "blocked")
VALID_SCOPES = ("feature", "bugfix", "refactor", "infrastructure", "documentation", "hotfix", "enhancement")
VALID_PRIORITIES = ("critical", "high", "medium", "low")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def write_json_atomic(path: Path, data: dict) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
def find_record_path(tc_dir: Path, tc_id: str) -> Path | None:
direct = tc_dir / "records" / tc_id / "tc_record.json"
if direct.exists():
return direct
for entry in (tc_dir / "records").glob("*"):
if entry.is_dir() and entry.name.startswith(tc_id):
candidate = entry / "tc_record.json"
if candidate.exists():
return candidate
return None
def validate_transition(current: str, new: str) -> str | None:
if current == new:
return None
allowed = VALID_TRANSITIONS.get(current, [])
if new not in allowed:
return f"Invalid transition '{current}' -> '{new}'. Allowed: {', '.join(allowed) or 'none'}"
return None
def next_revision_id(record: dict) -> str:
return f"R{len(record.get('revision_history', [])) + 1}"
def next_test_id(record: dict) -> str:
return f"T{len(record.get('test_cases', [])) + 1}"
def compute_stats(records: list) -> dict:
stats = {
"total": len(records),
"by_status": {s: 0 for s in VALID_STATUSES},
"by_scope": {s: 0 for s in VALID_SCOPES},
"by_priority": {p: 0 for p in VALID_PRIORITIES},
}
for rec in records:
for key, bucket in (("status", "by_status"), ("scope", "by_scope"), ("priority", "by_priority")):
v = rec.get(key, "")
if v in stats[bucket]:
stats[bucket][v] += 1
return stats
def parse_file_arg(spec: str) -> tuple[str, str]:
"""Parse 'path:action' or just 'path' (default action: modified)."""
if ":" in spec:
path, action = spec.rsplit(":", 1)
action = action.strip()
if action not in VALID_FILE_ACTIONS:
raise ValueError(f"Invalid file action '{action}'. Must be one of {VALID_FILE_ACTIONS}")
return path.strip(), action
return spec.strip(), "modified"
def main() -> int:
parser = argparse.ArgumentParser(description="Update an existing TC record.")
parser.add_argument("--root", default=".", help="Project root (default: current directory)")
parser.add_argument("--tc-id", required=True, help="Target TC ID (full or prefix)")
parser.add_argument("--author", default=None, help="Author for this revision (defaults to config)")
parser.add_argument("--reason", default="", help="Reason for the change (recorded in revision)")
parser.add_argument("--set-status", choices=VALID_STATUSES, help="Transition status (state machine enforced)")
parser.add_argument("--add-file", action="append", default=[], metavar="path[:action]",
help="Add a file. Action defaults to 'modified'. Repeatable.")
parser.add_argument("--add-test", help="Add a test case with this title")
parser.add_argument("--test-procedure", action="append", default=[],
help="Procedure step for the test being added. Repeatable.")
parser.add_argument("--test-expected", help="Expected result for the test being added")
parser.add_argument("--handoff-progress", help="Set progress_summary in handoff")
parser.add_argument("--handoff-next", action="append", default=[], help="Append to next_steps. Repeatable.")
parser.add_argument("--handoff-blocker", action="append", default=[], help="Append to blockers. Repeatable.")
parser.add_argument("--handoff-context", action="append", default=[], help="Append to key_context. Repeatable.")
parser.add_argument("--note", help="Append a freeform note (with timestamp)")
parser.add_argument("--tag", action="append", default=[], help="Add a tag. Repeatable.")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
root = Path(args.root).resolve()
tc_dir = root / "docs" / "TC"
config_path = tc_dir / "tc_config.json"
registry_path = tc_dir / "tc_registry.json"
if not config_path.exists() or not registry_path.exists():
msg = f"TC tracking not initialized at {tc_dir}. Run tc_init.py first."
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
record_path = find_record_path(tc_dir, args.tc_id)
if record_path is None:
msg = f"TC not found: {args.tc_id}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
registry = json.loads(registry_path.read_text(encoding="utf-8"))
record = json.loads(record_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
msg = f"Failed to read JSON: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
author = args.author or config.get("default_author", "Claude")
ts = now_iso()
field_changes = []
summary_parts = []
if args.set_status:
current = record.get("status")
new = args.set_status
err = validate_transition(current, new)
if err:
print(json.dumps({"status": "error", "error": err}) if args.json else f"ERROR: {err}")
return 2
if current != new:
record["status"] = new
field_changes.append({
"field": "status", "action": "changed",
"old_value": current, "new_value": new, "reason": args.reason or None,
})
summary_parts.append(f"status: {current} -> {new}")
for spec in args.add_file:
try:
path, action = parse_file_arg(spec)
except ValueError as e:
print(json.dumps({"status": "error", "error": str(e)}) if args.json else f"ERROR: {e}")
return 2
record.setdefault("files_affected", []).append({
"path": path, "action": action, "description": None,
"lines_added": None, "lines_removed": None,
})
field_changes.append({
"field": "files_affected", "action": "added",
"new_value": {"path": path, "action": action},
"reason": args.reason or None,
})
summary_parts.append(f"+file {path} ({action})")
if args.add_test:
if not args.test_procedure or not args.test_expected:
msg = "--add-test requires at least one --test-procedure and --test-expected"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
test_id = next_test_id(record)
new_test = {
"test_id": test_id,
"title": args.add_test,
"procedure": list(args.test_procedure),
"expected_result": args.test_expected,
"actual_result": None,
"status": "pending",
"evidence": [],
"tested_by": None,
"tested_date": None,
}
record.setdefault("test_cases", []).append(new_test)
field_changes.append({
"field": "test_cases", "action": "added",
"new_value": test_id, "reason": args.reason or None,
})
summary_parts.append(f"+test {test_id}: {args.add_test}")
handoff = record.setdefault("session_context", {}).setdefault("handoff", {
"progress_summary": "", "next_steps": [], "blockers": [],
"key_context": [], "files_in_progress": [], "decisions_made": [],
})
if args.handoff_progress is not None:
old = handoff.get("progress_summary", "")
handoff["progress_summary"] = args.handoff_progress
field_changes.append({
"field": "session_context.handoff.progress_summary",
"action": "changed", "old_value": old, "new_value": args.handoff_progress,
"reason": args.reason or None,
})
summary_parts.append("handoff: updated progress_summary")
for step in args.handoff_next:
handoff.setdefault("next_steps", []).append(step)
field_changes.append({
"field": "session_context.handoff.next_steps",
"action": "added", "new_value": step, "reason": args.reason or None,
})
summary_parts.append(f"handoff: +next_step '{step}'")
for blk in args.handoff_blocker:
handoff.setdefault("blockers", []).append(blk)
field_changes.append({
"field": "session_context.handoff.blockers",
"action": "added", "new_value": blk, "reason": args.reason or None,
})
summary_parts.append(f"handoff: +blocker '{blk}'")
for ctx in args.handoff_context:
handoff.setdefault("key_context", []).append(ctx)
field_changes.append({
"field": "session_context.handoff.key_context",
"action": "added", "new_value": ctx, "reason": args.reason or None,
})
summary_parts.append(f"handoff: +context")
if args.note:
existing = record.get("notes", "") or ""
addition = f"[{ts}] {args.note}"
record["notes"] = (existing + "\n" + addition).strip() if existing else addition
field_changes.append({
"field": "notes", "action": "added",
"new_value": args.note, "reason": args.reason or None,
})
summary_parts.append("note appended")
for tag in args.tag:
if tag not in record.setdefault("tags", []):
record["tags"].append(tag)
field_changes.append({
"field": "tags", "action": "added",
"new_value": tag, "reason": args.reason or None,
})
summary_parts.append(f"+tag {tag}")
if not field_changes:
msg = "No changes specified. Use --set-status, --add-file, --add-test, --handoff-*, --note, or --tag."
print(json.dumps({"status": "noop", "message": msg}) if args.json else msg)
return 0
revision = {
"revision_id": next_revision_id(record),
"timestamp": ts,
"author": author,
"summary": "; ".join(summary_parts) if summary_parts else "TC updated",
"field_changes": field_changes,
}
record.setdefault("revision_history", []).append(revision)
record["updated"] = ts
meta = record.setdefault("metadata", {})
meta["last_modified"] = ts
meta["last_modified_by"] = author
cs = record.setdefault("session_context", {}).setdefault("current_session", {})
cs["last_active"] = ts
try:
write_json_atomic(record_path, record)
except OSError as e:
msg = f"Failed to write record: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
for entry in registry.get("records", []):
if entry.get("tc_id") == record["tc_id"]:
entry["status"] = record["status"]
entry["updated"] = ts
break
registry["updated"] = ts
registry["statistics"] = compute_stats(registry.get("records", []))
try:
write_json_atomic(registry_path, registry)
except OSError as e:
msg = f"Failed to update registry: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
result = {
"status": "updated",
"tc_id": record["tc_id"],
"revision": revision["revision_id"],
"summary": revision["summary"],
"current_status": record["status"],
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Updated {record['tc_id']} ({revision['revision_id']})")
print(f" {revision['summary']}")
print(f" Status: {record['status']}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""TC Validator — Validate a TC record or registry against the schema and state machine.
Enforces:
* Schema shape (required fields, types, enum values)
* State machine transitions (planned -> in_progress -> implemented -> tested -> deployed)
* Sequential R<n> revision IDs and T<n> test IDs
* TC ID format (TC-NNN-MM-DD-YY-slug)
* Sub-TC ID format (TC-NNN.A or TC-NNN.A.N)
* Approval consistency (approved=true requires approved_by + approved_date)
Usage:
python3 tc_validator.py --record path/to/tc_record.json
python3 tc_validator.py --registry path/to/tc_registry.json
python3 tc_validator.py --record path/to/tc_record.json --json
Exit codes:
0 = valid
1 = validation errors
2 = file not found / JSON parse error / bad CLI args
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
VALID_STATUSES = ("planned", "in_progress", "blocked", "implemented", "tested", "deployed")
VALID_TRANSITIONS = {
"planned": ["in_progress", "blocked"],
"in_progress": ["blocked", "implemented"],
"blocked": ["in_progress", "planned"],
"implemented": ["tested", "in_progress"],
"tested": ["deployed", "in_progress"],
"deployed": ["in_progress"],
}
VALID_SCOPES = ("feature", "bugfix", "refactor", "infrastructure", "documentation", "hotfix", "enhancement")
VALID_PRIORITIES = ("critical", "high", "medium", "low")
VALID_FILE_ACTIONS = ("created", "modified", "deleted", "renamed")
VALID_TEST_STATUSES = ("pending", "pass", "fail", "skip", "blocked")
VALID_EVIDENCE_TYPES = ("log_snippet", "screenshot", "file_reference", "command_output")
VALID_FIELD_CHANGE_ACTIONS = ("set", "changed", "added", "removed")
VALID_PLATFORMS = ("claude_code", "claude_web", "api", "other")
VALID_COVERAGE = ("none", "partial", "full")
VALID_FILE_IN_PROGRESS_STATES = ("editing", "needs_review", "partially_done", "ready")
TC_ID_PATTERN = re.compile(r"^TC-\d{3}-\d{2}-\d{2}-\d{2}-[a-z0-9]+(-[a-z0-9]+)*$")
SUB_TC_PATTERN = re.compile(r"^TC-\d{3}\.[A-Z](\.\d+)?$")
REVISION_ID_PATTERN = re.compile(r"^R(\d+)$")
TEST_ID_PATTERN = re.compile(r"^T(\d+)$")
def _enum(value, valid, name):
if value not in valid:
return [f"Field '{name}' has invalid value '{value}'. Must be one of: {', '.join(str(v) for v in valid)}"]
return []
def _string(value, name, min_length=0, max_length=None):
errors = []
if not isinstance(value, str):
return [f"Field '{name}' must be a string, got {type(value).__name__}"]
if len(value) < min_length:
errors.append(f"Field '{name}' must be at least {min_length} characters, got {len(value)}")
if max_length is not None and len(value) > max_length:
errors.append(f"Field '{name}' must be at most {max_length} characters, got {len(value)}")
return errors
def _iso(value, name):
if value is None:
return []
if not isinstance(value, str):
return [f"Field '{name}' must be an ISO 8601 datetime string"]
try:
datetime.fromisoformat(value)
except ValueError:
return [f"Field '{name}' is not a valid ISO 8601 datetime: '{value}'"]
return []
def _required(record, fields, prefix=""):
errors = []
for f in fields:
if f not in record:
path = f"{prefix}.{f}" if prefix else f
errors.append(f"Missing required field: '{path}'")
return errors
def validate_tc_id(tc_id):
"""Validate a TC identifier."""
if not isinstance(tc_id, str):
return [f"tc_id must be a string, got {type(tc_id).__name__}"]
if not TC_ID_PATTERN.match(tc_id):
return [f"tc_id '{tc_id}' does not match pattern TC-NNN-MM-DD-YY-slug"]
return []
def validate_state_transition(current, new):
"""Validate a state machine transition. Same-status is a no-op."""
errors = []
if current not in VALID_STATUSES:
errors.append(f"Current status '{current}' is invalid")
if new not in VALID_STATUSES:
errors.append(f"New status '{new}' is invalid")
if errors:
return errors
if current == new:
return []
allowed = VALID_TRANSITIONS.get(current, [])
if new not in allowed:
return [f"Invalid transition '{current}' -> '{new}'. Allowed from '{current}': {', '.join(allowed) or 'none'}"]
return []
def validate_tc_record(record):
"""Validate a TC record dict against the schema."""
errors = []
if not isinstance(record, dict):
return [f"TC record must be a JSON object, got {type(record).__name__}"]
top_required = [
"tc_id", "title", "status", "priority", "created", "updated",
"created_by", "project", "description", "files_affected",
"revision_history", "test_cases", "approval", "session_context",
"tags", "related_tcs", "notes", "metadata",
]
errors.extend(_required(record, top_required))
if "tc_id" in record:
errors.extend(validate_tc_id(record["tc_id"]))
if "title" in record:
errors.extend(_string(record["title"], "title", 5, 120))
if "status" in record:
errors.extend(_enum(record["status"], VALID_STATUSES, "status"))
if "priority" in record:
errors.extend(_enum(record["priority"], VALID_PRIORITIES, "priority"))
for ts in ("created", "updated"):
if ts in record:
errors.extend(_iso(record[ts], ts))
if "created_by" in record:
errors.extend(_string(record["created_by"], "created_by", 1))
if "project" in record:
errors.extend(_string(record["project"], "project", 1))
desc = record.get("description")
if isinstance(desc, dict):
errors.extend(_required(desc, ["summary", "motivation", "scope"], "description"))
if "summary" in desc:
errors.extend(_string(desc["summary"], "description.summary", 10))
if "motivation" in desc:
errors.extend(_string(desc["motivation"], "description.motivation", 1))
if "scope" in desc:
errors.extend(_enum(desc["scope"], VALID_SCOPES, "description.scope"))
elif "description" in record:
errors.append("Field 'description' must be an object")
files = record.get("files_affected")
if isinstance(files, list):
for i, f in enumerate(files):
prefix = f"files_affected[{i}]"
if not isinstance(f, dict):
errors.append(f"{prefix} must be an object")
continue
errors.extend(_required(f, ["path", "action"], prefix))
if "action" in f:
errors.extend(_enum(f["action"], VALID_FILE_ACTIONS, f"{prefix}.action"))
elif "files_affected" in record:
errors.append("Field 'files_affected' must be an array")
revs = record.get("revision_history")
if isinstance(revs, list):
if len(revs) < 1:
errors.append("revision_history must have at least 1 entry")
for i, rev in enumerate(revs):
prefix = f"revision_history[{i}]"
if not isinstance(rev, dict):
errors.append(f"{prefix} must be an object")
continue
errors.extend(_required(rev, ["revision_id", "timestamp", "author", "summary"], prefix))
rid = rev.get("revision_id")
if isinstance(rid, str):
m = REVISION_ID_PATTERN.match(rid)
if not m:
errors.append(f"{prefix}.revision_id '{rid}' must match R<n>")
elif int(m.group(1)) != i + 1:
errors.append(f"{prefix}.revision_id is '{rid}' but expected 'R{i + 1}' (must be sequential)")
if "timestamp" in rev:
errors.extend(_iso(rev["timestamp"], f"{prefix}.timestamp"))
elif "revision_history" in record:
errors.append("Field 'revision_history' must be an array")
tests = record.get("test_cases")
if isinstance(tests, list):
for i, tc in enumerate(tests):
prefix = f"test_cases[{i}]"
if not isinstance(tc, dict):
errors.append(f"{prefix} must be an object")
continue
errors.extend(_required(tc, ["test_id", "title", "procedure", "expected_result", "status"], prefix))
tid = tc.get("test_id")
if isinstance(tid, str):
m = TEST_ID_PATTERN.match(tid)
if not m:
errors.append(f"{prefix}.test_id '{tid}' must match T<n>")
elif int(m.group(1)) != i + 1:
errors.append(f"{prefix}.test_id is '{tid}' but expected 'T{i + 1}' (must be sequential)")
if "status" in tc:
errors.extend(_enum(tc["status"], VALID_TEST_STATUSES, f"{prefix}.status"))
appr = record.get("approval")
if isinstance(appr, dict):
errors.extend(_required(appr, ["approved", "test_coverage_status"], "approval"))
if appr.get("approved") is True:
if not appr.get("approved_by"):
errors.append("approval.approved_by is required when approval.approved is true")
if not appr.get("approved_date"):
errors.append("approval.approved_date is required when approval.approved is true")
if "test_coverage_status" in appr:
errors.extend(_enum(appr["test_coverage_status"], VALID_COVERAGE, "approval.test_coverage_status"))
elif "approval" in record:
errors.append("Field 'approval' must be an object")
ctx = record.get("session_context")
if isinstance(ctx, dict):
errors.extend(_required(ctx, ["current_session"], "session_context"))
cs = ctx.get("current_session")
if isinstance(cs, dict):
errors.extend(_required(cs, ["session_id", "platform", "model", "started"], "session_context.current_session"))
if "platform" in cs:
errors.extend(_enum(cs["platform"], VALID_PLATFORMS, "session_context.current_session.platform"))
if "started" in cs:
errors.extend(_iso(cs["started"], "session_context.current_session.started"))
meta = record.get("metadata")
if isinstance(meta, dict):
errors.extend(_required(meta, ["project", "created_by", "last_modified_by", "last_modified"], "metadata"))
if "last_modified" in meta:
errors.extend(_iso(meta["last_modified"], "metadata.last_modified"))
return errors
def validate_registry(registry):
"""Validate a TC registry dict."""
errors = []
if not isinstance(registry, dict):
return [f"Registry must be an object, got {type(registry).__name__}"]
errors.extend(_required(registry, ["project_name", "created", "updated", "next_tc_number", "records", "statistics"]))
if "next_tc_number" in registry:
v = registry["next_tc_number"]
if not isinstance(v, int) or v < 1:
errors.append(f"next_tc_number must be a positive integer, got {v}")
if isinstance(registry.get("records"), list):
for i, rec in enumerate(registry["records"]):
prefix = f"records[{i}]"
if not isinstance(rec, dict):
errors.append(f"{prefix} must be an object")
continue
errors.extend(_required(rec, ["tc_id", "title", "status", "scope", "priority", "created", "updated", "path"], prefix))
if "status" in rec:
errors.extend(_enum(rec["status"], VALID_STATUSES, f"{prefix}.status"))
if "scope" in rec:
errors.extend(_enum(rec["scope"], VALID_SCOPES, f"{prefix}.scope"))
if "priority" in rec:
errors.extend(_enum(rec["priority"], VALID_PRIORITIES, f"{prefix}.priority"))
return errors
def slugify(text):
"""Convert text to a kebab-case slug."""
text = text.lower().strip()
text = re.sub(r"[^a-z0-9\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def compute_registry_statistics(records):
"""Recompute registry statistics from the records array."""
stats = {
"total": len(records),
"by_status": {s: 0 for s in VALID_STATUSES},
"by_scope": {s: 0 for s in VALID_SCOPES},
"by_priority": {p: 0 for p in VALID_PRIORITIES},
}
for rec in records:
for key, bucket in (("status", "by_status"), ("scope", "by_scope"), ("priority", "by_priority")):
v = rec.get(key, "")
if v in stats[bucket]:
stats[bucket][v] += 1
return stats
def main():
parser = argparse.ArgumentParser(description="Validate a TC record or registry.")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--record", help="Path to tc_record.json")
group.add_argument("--registry", help="Path to tc_registry.json")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
args = parser.parse_args()
target = args.record or args.registry
path = Path(target)
if not path.exists():
msg = f"File not found: {path}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
msg = f"Invalid JSON in {path}: {e}"
print(json.dumps({"status": "error", "error": msg}) if args.json else f"ERROR: {msg}")
return 2
errors = validate_registry(data) if args.registry else validate_tc_record(data)
if args.json:
result = {
"status": "valid" if not errors else "invalid",
"file": str(path),
"kind": "registry" if args.registry else "record",
"error_count": len(errors),
"errors": errors,
}
print(json.dumps(result, indent=2))
else:
if errors:
print(f"VALIDATION ERRORS ({len(errors)}):")
for i, err in enumerate(errors, 1):
print(f" {i}. {err}")
else:
print("VALID")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
Related skills
FAQ
What scripts does tc-tracker include?
tc-tracker ships Python CLI scripts tc_init.py to initialize project tracking, tc_create.py to open new technical changes with metadata, and tc_update.py to advance states and log file-level work.
How does tc-tracker help AI session handoffs?
tc-tracker writes append-only revision history and a session-handoff block per technical change so a new AI session can read TC state and resume in-progress work without lost context.
Is Tc Tracker safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.