feat(engineering): add tc-tracker skill
Self-contained skill for tracking technical changes 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. Includes: - 5 stdlib-only Python scripts (init, create, update, status, validator) all supporting --help and --json - 3 reference docs (lifecycle state machine, JSON schema, handoff format) - /tc dispatcher in commands/tc.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
146
commands/tc.md
Normal file
146
commands/tc.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: tc
|
||||
description: Track technical changes with structured records, a state machine, and session handoff. Usage: /tc <init|create|update|status|resume|close|export|dashboard> [args]
|
||||
---
|
||||
|
||||
# /tc — Technical Change Tracker
|
||||
|
||||
Dispatch a TC (Technical Change) command. Arguments: `$ARGUMENTS`.
|
||||
|
||||
If `$ARGUMENTS` is empty, print this menu and stop:
|
||||
|
||||
```
|
||||
/tc init Initialize TC tracking in this project
|
||||
/tc create <name> Create a new TC record
|
||||
/tc update <tc-id> [...] Update fields, status, files, handoff
|
||||
/tc status [tc-id] Show one TC or the registry summary
|
||||
/tc resume <tc-id> Resume a TC from a previous session
|
||||
/tc close <tc-id> Transition a TC to deployed
|
||||
/tc export Re-render derived artifacts
|
||||
/tc dashboard Re-render the registry summary
|
||||
```
|
||||
|
||||
Otherwise, parse `$ARGUMENTS` as `<subcommand> <rest>` and dispatch to the matching protocol below. All scripts live at `engineering/tc-tracker/scripts/`.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `init`
|
||||
|
||||
1. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_init.py --root . --json
|
||||
```
|
||||
2. If status is `already_initialized`, report current statistics and stop.
|
||||
3. Otherwise report what was created and suggest `/tc create <name>` as the next step.
|
||||
|
||||
### `create <name>`
|
||||
|
||||
1. Parse `<name>` as a kebab-case slug. If missing, ask the user for one.
|
||||
2. Prompt the user (one question at a time) for:
|
||||
- Title (5-120 chars)
|
||||
- Scope: `feature | bugfix | refactor | infrastructure | documentation | hotfix | enhancement`
|
||||
- Priority: `critical | high | medium | low` (default `medium`)
|
||||
- Summary (10+ chars)
|
||||
- Motivation
|
||||
3. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_create.py --root . \
|
||||
--name "<slug>" --title "<title>" --scope <scope> --priority <priority> \
|
||||
--summary "<summary>" --motivation "<motivation>" --json
|
||||
```
|
||||
4. Report the new TC ID and the path to the record.
|
||||
|
||||
### `update <tc-id> [intent]`
|
||||
|
||||
1. If `<tc-id>` is missing, list active TCs (status `in_progress` or `blocked`) from `tc_status.py --all` and ask which one.
|
||||
2. Determine the user's intent from natural language:
|
||||
- **Status change** → `--set-status <state>` with `--reason "<why>"`
|
||||
- **Add files** → one or more `--add-file path[:action]`
|
||||
- **Add a test** → `--add-test "<title>" --test-procedure "<step>" --test-expected "<result>"`
|
||||
- **Update handoff** → any combination of `--handoff-progress`, `--handoff-next`, `--handoff-blocker`, `--handoff-context`
|
||||
- **Add a note** → `--note "<text>"`
|
||||
- **Add a tag** → `--tag <tag>`
|
||||
3. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_update.py --root . --tc-id <tc-id> [flags] --json
|
||||
```
|
||||
4. If exit code is non-zero, surface the error verbatim. The state machine and validator will reject invalid moves — do not retry blindly.
|
||||
|
||||
### `status [tc-id]`
|
||||
|
||||
- If `<tc-id>` is provided:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_status.py --root . --tc-id <tc-id>
|
||||
```
|
||||
- Otherwise:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_status.py --root . --all
|
||||
```
|
||||
|
||||
### `resume <tc-id>`
|
||||
|
||||
1. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_status.py --root . --tc-id <tc-id> --json
|
||||
```
|
||||
2. Display the handoff block prominently: `progress_summary`, `next_steps` (numbered), `blockers`, `key_context`.
|
||||
3. Ask: "Resume <tc-id> and pick up at next step 1? (y/n)"
|
||||
4. If yes, run an update to record the resumption:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_update.py --root . --tc-id <tc-id> \
|
||||
--note "Session resumed" --reason "session handoff"
|
||||
```
|
||||
5. Begin executing the first item in `next_steps`. Do NOT re-derive context — trust the handoff.
|
||||
|
||||
### `close <tc-id>`
|
||||
|
||||
1. Read the record via `tc_status.py --tc-id <tc-id> --json`.
|
||||
2. Verify the current status is `tested`. If not, refuse and tell the user which transitions are still required.
|
||||
3. Check `test_cases`: warn if any are `pending`, `fail`, or `blocked`.
|
||||
4. Ask the user:
|
||||
- "Who is approving? (your name, or 'self')"
|
||||
- "Approval notes (optional):"
|
||||
- "Test coverage status: none / partial / full"
|
||||
5. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_update.py --root . --tc-id <tc-id> \
|
||||
--set-status deployed --reason "Approved by <approver>" --note "Approval: <approver> — <notes>"
|
||||
```
|
||||
Then directly edit the `approval` block via a follow-up update if your script version supports it; otherwise instruct the user to record approval in `notes`.
|
||||
6. Report: "TC-NNN closed and deployed."
|
||||
|
||||
### `export`
|
||||
|
||||
There is no automatic HTML export in this skill. Re-validate everything instead:
|
||||
|
||||
1. Read the registry.
|
||||
2. For each record, run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_validator.py --record <path> --json
|
||||
```
|
||||
3. Run:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_validator.py --registry docs/TC/tc_registry.json --json
|
||||
```
|
||||
4. Report: total records validated, any errors, paths to anything invalid.
|
||||
|
||||
### `dashboard`
|
||||
|
||||
Run the all-records summary:
|
||||
```bash
|
||||
python3 engineering/tc-tracker/scripts/tc_status.py --root . --all
|
||||
```
|
||||
|
||||
## Iron Rules
|
||||
|
||||
1. **Never edit `tc_record.json` by hand.** Always use `tc_update.py` so revision history is appended and validation runs.
|
||||
2. **Never skip the state machine.** Walk forward through states even if it feels redundant.
|
||||
3. **Never delete a TC.** History is append-only — add a final revision and tag it `[CANCELLED]`.
|
||||
4. **Background bookkeeping.** When mid-task, spawn a background subagent to update the TC. Do not pause coding to do paperwork.
|
||||
5. **Validate before reporting success.** If a script exits non-zero, surface the error and stop.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `engineering/tc-tracker` — Full SKILL.md with schema reference, lifecycle diagrams, and the handoff format.
|
||||
- `engineering/changelog-generator` — Pair with TC tracker: TCs for the per-change audit trail, changelog for user-facing release notes.
|
||||
- `engineering/tech-debt-tracker` — For tracking long-lived debt rather than discrete code changes.
|
||||
72
engineering/tc-tracker/README.md
Normal file
72
engineering/tc-tracker/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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 . --all
|
||||
```
|
||||
|
||||
## Included Scripts
|
||||
|
||||
- `scripts/tc_init.py` — Initialize `docs/TC/` in a project (idempotent)
|
||||
- `scripts/tc_create.py` — Create a new TC record with sequential ID
|
||||
- `scripts/tc_update.py` — Update fields, status, files, handoff, with atomic writes
|
||||
- `scripts/tc_status.py` — View a single TC or the full registry
|
||||
- `scripts/tc_validator.py` — Validate a record or registry against schema + state machine
|
||||
|
||||
All scripts:
|
||||
- Use Python stdlib only
|
||||
- Support `--help` and `--json`
|
||||
- Use exit codes 0 (ok) / 1 (warnings) / 2 (errors)
|
||||
|
||||
## References
|
||||
|
||||
- `references/tc-schema.md` — JSON schema reference
|
||||
- `references/lifecycle.md` — State machine and transitions
|
||||
- `references/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
|
||||
|
||||
```bash
|
||||
cp -R engineering/tc-tracker ~/.claude/skills/tc-tracker
|
||||
```
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
```bash
|
||||
cp -R engineering/tc-tracker ~/.codex/skills/tc-tracker
|
||||
```
|
||||
207
engineering/tc-tracker/SKILL.md
Normal file
207
engineering/tc-tracker/SKILL.md
Normal file
@@ -0,0 +1,207 @@
|
||||
---
|
||||
name: "tc-tracker"
|
||||
description: "Use when the user asks to track technical changes, create change records, manage TC lifecycles, or hand off work between AI sessions. Covers init/create/update/status/resume/close/export workflows for structured code change documentation."
|
||||
---
|
||||
|
||||
# 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, screenshots
|
||||
```
|
||||
|
||||
## TC ID Convention
|
||||
|
||||
- **Parent TC:** `TC-NNN-MM-DD-YY-functionality-slug` (e.g., `TC-001-04-05-26-user-authentication`)
|
||||
- **Sub-TC:** `TC-NNN.A` or `TC-NNN.A.1` (letter = revision, digit = sub-revision)
|
||||
- `NNN` is sequential, `MM-DD-YY` is the creation date, slug is kebab-case.
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
planned -> in_progress -> implemented -> tested -> deployed
|
||||
| | | | |
|
||||
+-> blocked -+ +- in_progress <-------+
|
||||
| (rework / hotfix)
|
||||
+-> planned
|
||||
```
|
||||
|
||||
> See [references/lifecycle.md](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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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 --json
|
||||
```
|
||||
|
||||
### 5. Validate a record or registry
|
||||
|
||||
```bash
|
||||
python3 scripts/tc_validator.py --record docs/TC/records/TC-001-.../tc_record.json
|
||||
python3 scripts/tc_validator.py --registry docs/TC/tc_registry.json
|
||||
```
|
||||
|
||||
Validator 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](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 done
|
||||
- `next_steps` — ordered list of remaining actions
|
||||
- `blockers` — anything preventing progress
|
||||
- `key_context` — critical decisions, gotchas, patterns the next bot must know
|
||||
- `files_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](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-fix` first then capture the result as a TC.
|
||||
- `project-management/decision-log` — Architectural decisions made inside a TC's `decisions_made` block can also be promoted to a project-wide decision log.
|
||||
- `engineering-team/code-reviewer` — Pre-merge review fits naturally into the `tested -> deployed` transition; capture the reviewer in `approval.approved_by`.
|
||||
|
||||
## References in This Skill
|
||||
|
||||
- [references/tc-schema.md](references/tc-schema.md) — Full JSON schema for TC records and the registry.
|
||||
- [references/lifecycle.md](references/lifecycle.md) — State machine, valid transitions, and recovery flows.
|
||||
- [references/handoff-format.md](references/handoff-format.md) — Session handoff structure and best practices.
|
||||
139
engineering/tc-tracker/references/handoff-format.md
Normal file
139
engineering/tc-tracker/references/handoff-format.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Session 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
|
||||
|
||||
```json
|
||||
{
|
||||
"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_auth` module is being phased out — do NOT extend it. New code goes in `src/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 `User` from `models.user` instead of `models`. Always use `from 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.
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
> "Read `docs/TC/records/<TC-ID>/tc_record.json`. Update the handoff section with: progress_summary='...'; add next_step '...'; add blocker '...'. Use `tc_update.py` so revision history is appended. Then update `last_active` and 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_session` into `session_history` with an `ended` timestamp and a summary.
|
||||
- Create a new `current_session` for the new bot.
|
||||
- Append a revision: "Session resumed by <platform/model>".
|
||||
- Walk through `next_steps` in 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` |
|
||||
98
engineering/tc-tracker/references/lifecycle.md
Normal file
98
engineering/tc-tracker/references/lifecycle.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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 `status` ONLY through `tc_update.py --set-status`. Never edit JSON by hand.
|
||||
- Every status change creates a new revision entry with `field` = `status`, `action` = `changed`, and `reason` populated.
|
||||
- The registry's `statistics.by_status` is 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` |
|
||||
204
engineering/tc-tracker/references/tc-schema.md
Normal file
204
engineering/tc-tracker/references/tc-schema.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# 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 | For sub-TCs only |
|
||||
| `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
|
||||
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```json
|
||||
{
|
||||
"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`)
|
||||
|
||||
```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.
|
||||
277
engineering/tc-tracker/scripts/tc_create.py
Normal file
277
engineering/tc-tracker/scripts/tc_create.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/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())
|
||||
196
engineering/tc-tracker/scripts/tc_init.py
Normal file
196
engineering/tc-tracker/scripts/tc_init.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/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())
|
||||
200
engineering/tc-tracker/scripts/tc_status.py
Normal file
200
engineering/tc-tracker/scripts/tc_status.py
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/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())
|
||||
361
engineering/tc-tracker/scripts/tc_update.py
Normal file
361
engineering/tc-tracker/scripts/tc_update.py
Normal file
@@ -0,0 +1,361 @@
|
||||
#!/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())
|
||||
347
engineering/tc-tracker/scripts/tc_validator.py
Normal file
347
engineering/tc-tracker/scripts/tc_validator.py
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user