@erclx/aitk 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +2 -0
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-feature/SKILL.md +8 -55
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/records.md +54 -0
- package/docs/ai-workflow.md +4 -0
- package/governance/rules/claude/558-plan.md +22 -0
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/records.ts +159 -0
- package/src/records/validate.ts +599 -0
- package/standards/groundwork.md +1 -0
- package/standards/index.md +1 -0
- package/standards/intake.md +1 -0
- package/standards/plan.md +145 -0
- package/standards/tasks.md +1 -0
|
@@ -41,6 +41,8 @@ If the two commands differ, the session is already in a linked worktree. Continu
|
|
|
41
41
|
|
|
42
42
|
Read `.claude/plans/feature-<slug>.md` at the main worktree root. This file is the scope for this run.
|
|
43
43
|
|
|
44
|
+
Its sections and its answer contract are fixed by `.claude/standards/plan.md`, or `${CLAUDE_SKILL_DIR}/../../standards/plan.md` when the project does not have it. A blank `- Answer:` accepts the `- Suggested:` line above it, so an unanswered question is a decision this run executes rather than a reason to stop.
|
|
45
|
+
|
|
44
46
|
## Step 2: implement
|
|
45
47
|
|
|
46
48
|
Implement only what the plan describes. Do not expand scope. Do not refactor neighbors. Do not touch files outside the plan's "Files to touch" list without reason.
|
|
@@ -234,7 +234,7 @@ The base lint-staged config runs `aitk indexes regen` on every committed `*.md`,
|
|
|
234
234
|
|
|
235
235
|
Sweep reviews this session consumed, and sweep plans across the whole board. Resolve all paths at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`.
|
|
236
236
|
|
|
237
|
-
**Plans.** Scan every file in `.claude/tasks/`, not only the ones this session touched. For each task file whose outcomes are now all `[x]`, check for a `Plan:` line directly under the title and parse the target. The line carries a markdown link, so read the target out of the parentheses rather than taking the rest of the line. A task still carrying the older bare-path form parses the same way once the link is absent, so accept both. Resolve the target against `.claude/tasks/` before routing on it, which lands `../plans/x.md` and `.claude/plans/x.md` on the same file. The bullets below name resolved locations, so an unresolved target falls to the last one and no plan is ever archived. Never delete a plan.
|
|
237
|
+
**Plans.** Scan every file in `.claude/tasks/`, not only the ones this session touched. For each task file whose outcomes are now all `[x]`, check for a `Plan:` line directly under the title and parse the target. The line carries a markdown link, so read the target out of the parentheses rather than taking the rest of the line. A task still carrying the older bare-path form parses the same way once the link is absent, so accept both. Resolve the target against `.claude/tasks/` before routing on it, which lands `../plans/x.md` and `.claude/plans/x.md` on the same file. The bullets below name resolved locations, so an unresolved target falls to the last one and no plan is ever archived. Never delete a plan. `.claude/standards/plan.md` owns the archive destination and why a shipped plan is moved rather than removed.
|
|
238
238
|
|
|
239
239
|
Board-wide scope is the one place this sweep reaches past Step 3's rule against touching task files the session did not change. A board carrying a task that closed while an earlier run missed its archive is the defect this exists to clear, and skipping those tasks would preserve it. Reaching them is safe because the archive moves the plan and points the task at the new path, so a task from unrelated work ends up with a working pointer rather than a broken one.
|
|
240
240
|
|
|
@@ -35,33 +35,14 @@ Measure against the tree rather than recall. Grep for each construct the plan wi
|
|
|
35
35
|
|
|
36
36
|
## Step 3: build the plan
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
The section list, what each section holds, the suggested-and-answer contract, and the lifecycle are fixed by `.claude/standards/plan.md`, or `${CLAUDE_SKILL_DIR}/../../standards/plan.md` when the project does not have it. Read it before writing the file and follow it rather than working the shape from memory.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
- **Constraints:** durable rules the work must respect (patterns to reuse, surfaces not to touch, platform limits). Optional. Include when an orchestrator or prior context supplies them, omit the section otherwise. A constraint naming a surface to leave alone carries the distinction under Constraints below.
|
|
42
|
-
- **Files to touch:** each file with a one-line reason
|
|
43
|
-
- **Risks:** conflicts, coupling, or tricky spots. When the plan establishes a resource with more than one consumer, list the consumers and mark each read or write, because a policy stated over that resource has to hold for the writers and not just the consumer that prompted it. If none, use `None identified.`
|
|
44
|
-
- **Questions:** numbered list of things to resolve before starting. Each carries a `- Suggested:` line and an `- Answer:` slot (see Suggestions below). If none, use `None identified.`
|
|
40
|
+
What this skill adds on top of the standard:
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
### Constraints
|
|
49
|
-
|
|
50
|
-
A constraint naming a surface to leave alone forbids two different acts. Name which one, since a constraint carrying only the surface leaves the executing session to guess.
|
|
51
|
-
|
|
52
|
-
- Forbid conforming the surface to whatever shape the change introduces. This is the act a scope constraint means, and it keeps the branch from growing a second concern.
|
|
53
|
-
- Never forbid retargeting a pointer the change breaks. A rename, a split, or a deletion that leaves a citation behind ships a dangling reference, so fixing it is required work rather than scope creep.
|
|
54
|
-
- Decide both acts for every surface the constraint names. Carving the distinction out for one file and leaving its siblings under the bare wording is how a plan ships one correct call beside one broken reference.
|
|
55
|
-
|
|
56
|
-
### Suggestions
|
|
57
|
-
|
|
58
|
-
Attach a `- Suggested:` line to every question, then an empty `- Answer:` slot below it. A blank answer means accept the suggestion at execution time. This makes the plan decision-ready in one pass, with no separate decision-help round.
|
|
59
|
-
|
|
60
|
-
- Apply senior judgment: pick the best option and state it in one line with its reason or main tradeoff. No padding, no alternatives unless they change the pick.
|
|
42
|
+
- Apply senior judgment to every `- Suggested:` line. Pick the best option and state it in one line with its reason or main tradeoff. No padding, no alternatives unless they change the pick.
|
|
61
43
|
- Suggest a real default when best practice, the codebase, or prior context points to one.
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
When three or more questions remain, keep chat output to the file pointer plus a short summary. Inline chat is fine when two or fewer remain.
|
|
44
|
+
- Prefer `None identified.` over low-signal fillers. A small feature should produce a short plan, not a padded one.
|
|
45
|
+
- When three or more questions remain, keep chat output to the file pointer plus a short summary. Inline chat is fine when two or fewer remain.
|
|
65
46
|
|
|
66
47
|
## Step 4: output
|
|
67
48
|
|
|
@@ -74,7 +55,7 @@ A consumer list is a `Risks` entry, which already forces Full. Establishing a re
|
|
|
74
55
|
|
|
75
56
|
### Small mode
|
|
76
57
|
|
|
77
|
-
Output the plan to chat. Do not write a plan file.
|
|
58
|
+
Output the plan to chat. Do not write a plan file. The markers match the standard's, so a plan that later grows into a file keeps the shape it was drafted in.
|
|
78
59
|
|
|
79
60
|
```markdown
|
|
80
61
|
**Files to touch:**
|
|
@@ -98,37 +79,9 @@ Omit empty sections. Do not print `None identified.` in chat.
|
|
|
98
79
|
|
|
99
80
|
Derive a 2-to-4-word kebab-case slug from the feature description. Write the full plan to `.claude/plans/feature-<slug>.md` at the main worktree root, not the current worktree. See Worktrees in `CLAUDE.md`. Create the directory if it does not exist.
|
|
100
81
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```markdown
|
|
104
|
-
# Feature: <short title>
|
|
105
|
-
|
|
106
|
-
<feature description>
|
|
107
|
-
|
|
108
|
-
## Summary
|
|
109
|
-
|
|
110
|
-
- <one-line bullet covering the goal>
|
|
111
|
-
- <one-line bullet covering the main deliverables>
|
|
112
|
-
- <one-line bullet covering the key decision or trade-off>
|
|
113
|
-
|
|
114
|
-
**Constraints:**
|
|
82
|
+
The file follows the template in `.claude/standards/plan.md`. Copy the shape from there rather than from this body, so one edit to the standard moves every plan.
|
|
115
83
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
**Files to touch:**
|
|
119
|
-
|
|
120
|
-
- `path/to/file`: reason
|
|
121
|
-
|
|
122
|
-
**Risks:**
|
|
123
|
-
|
|
124
|
-
- <risk>
|
|
125
|
-
|
|
126
|
-
**Questions:**
|
|
127
|
-
|
|
128
|
-
1. <question>
|
|
129
|
-
- Suggested: <pick>, <reason or tradeoff>
|
|
130
|
-
- Answer:
|
|
131
|
-
```
|
|
84
|
+
Run `aitk records validate plans` after writing the file when the CLI is on PATH. It reports a section, a filename, or an answer slot that does not hold, and it writes nothing.
|
|
132
85
|
|
|
133
86
|
Then output in chat:
|
|
134
87
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -28,6 +28,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
28
28
|
| `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
29
29
|
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
|
|
30
30
|
| `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
|
|
31
|
+
| `aitk records validate` | Report a plan, groundwork track, or intake dump against the standard governing it (`--json`) |
|
|
31
32
|
| `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
32
33
|
| `aitk context audit` | Report required sections, length, depth, bullet weight, cited paths, provenance, and drift |
|
|
33
34
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
package/docs/agents/index.md
CHANGED
|
@@ -18,6 +18,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
18
18
|
- [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
|
|
19
19
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
20
20
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
21
|
+
- [Records](records.md): Validating the gitignored session records under .claude/, the per-kind checks, the refusal reasons, and why the root defaults to the main worktree
|
|
21
22
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
22
23
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
23
24
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, and the requirement gate that is the only failing one
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Records
|
|
3
|
+
description: Validating the gitignored session records under .claude/, the per-kind checks, the refusal reasons, and why the root defaults to the main worktree
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Records
|
|
7
|
+
|
|
8
|
+
## Validate
|
|
9
|
+
|
|
10
|
+
`aitk records validate <kind>` reports where a session record and the standard governing it disagree. The three kinds are `plans`, `groundwork`, and `intake`, each a gitignored folder under `.claude/`.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
aitk records validate plans
|
|
14
|
+
aitk records validate intake --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Option | Behavior |
|
|
18
|
+
| --------------- | --------------------------------------------- |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout |
|
|
20
|
+
| `--root <path>` | Project root, defaulting to the main worktree |
|
|
21
|
+
|
|
22
|
+
It reads and never writes. Each folder is per-machine scratch with no history behind it, so a repair that guessed wrong could not be undone, and the report names the record for a session to fix.
|
|
23
|
+
|
|
24
|
+
Nothing fires it automatically. The folders are gitignored, so the standards-audit hook exits early on them and any check reading changed files from git never lists one. The verb runs at the moment a session claims the record is finished, which is the same placement `aitk tasks validate` takes over the board.
|
|
25
|
+
|
|
26
|
+
### What each kind checks
|
|
27
|
+
|
|
28
|
+
| Kind | What it reports |
|
|
29
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
30
|
+
| `plans` | A filename that is not `feature-<slug>.md`, a missing `# Feature:` heading, a missing required section, a files-to-touch entry naming no file or saying nothing about one, and a question carrying no suggestion or no answer slot |
|
|
31
|
+
| `groundwork` | A track with no `README.md` or no `01-current-state.md`, a file missing `title` or `description`, a `README.md` with no `date` as `YYYY-MM-DD`, an unnumbered file, and a track holding a decision without its handoff or the reverse |
|
|
32
|
+
| `intake` | A dump with no `00-overview.md`, the same frontmatter and numbering checks, an item missing any of `Problem`, `Fix`, `Worth it`, or `You`, and an item carrying `Open` with no `Suggested` |
|
|
33
|
+
|
|
34
|
+
The half-closed track is the groundwork check a reader cannot run by eye. A folder holding `06` without `07` reads as closed to anyone scanning filenames while the file a returning session actually opens is absent.
|
|
35
|
+
|
|
36
|
+
The item check skips `00-overview.md` and `99-next-session.md`, since neither holds items and running it over the handoff would report every heading it carries.
|
|
37
|
+
|
|
38
|
+
A plan section opens as a bold label or as an H2 and the check counts both, naming the standard's spelling when it reports one missing. The corpus splits roughly four to one between the two forms, so failing the variant would report nearly every plan on a rule that costs a reader nothing.
|
|
39
|
+
|
|
40
|
+
A section runs to the next marker-shaped line whatever it names, so a plan carrying a label of its own closes the section above it rather than collecting into it. Fenced blocks are dropped before any of this, since a plan showing the shape it writes puts real-looking bullets and headings inside a fence.
|
|
41
|
+
|
|
42
|
+
### Exit codes and refusals
|
|
43
|
+
|
|
44
|
+
Exit codes: `0` every check passed, `1` refused, `2` at least one record carries a finding. A `reason` field carries which gate fired: `no-folder` when the kind's directory does not exist, and `unknown-kind` when the argument names no published kind.
|
|
45
|
+
|
|
46
|
+
The folders are shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree validates the same records every other session reads.
|
|
47
|
+
|
|
48
|
+
Skills branch on the findings rather than on the exit code:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
aitk records validate plans --json | jq -r '.findings[] | "\(.kind): \(.subject)"'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For the shapes each check enforces, see `.claude/standards/plan.md`, `.claude/standards/groundwork.md`, and `.claude/standards/intake.md`.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -79,6 +79,10 @@ Before a handoff, the orchestrator checks the plan against the tree rather than
|
|
|
79
79
|
|
|
80
80
|
`.claude/plans/`, `.claude/review/`, and `.claude/memory/` all resolve at the main worktree root, so artifacts created in any session are visible from any sibling worktree. See [Claude Code and git worktrees](../wiki/claude/claude-worktrees.md) for the full rule and the domain-level fan-out guidance.
|
|
81
81
|
|
|
82
|
+
The plan's shape is fixed by `.claude/standards/plan.md`: the section list, the filename, the lifecycle, and the contract its questions keep. Every question carries a `- Suggested:` line and an empty `- Answer:` slot, and a blank answer accepts the suggestion at execution time. That default is what makes a plan decision-ready in one pass, and it is the opposite of the contract an intake folder keeps, where an empty slot means nobody reached the item.
|
|
83
|
+
|
|
84
|
+
`aitk records validate plans` reports where a plan and that standard disagree: a filename that is not `feature-<slug>.md`, a missing required section, a files-to-touch entry naming no file, and a question carrying a suggestion with no answer slot. The same verb takes `groundwork` and `intake`, which are governed the same way and were unreachable for the same reason. Nothing fires it automatically, because all three folders are gitignored and every check the repository runs reads changed files from git. It reports and never writes, since the folders are per-machine scratch with no history to recover a wrong repair from.
|
|
85
|
+
|
|
82
86
|
A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans-archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. Both folders are gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
|
|
83
87
|
|
|
84
88
|
The sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Route .claude/plans/ edits to the plan standard for sections, the answer contract, and the archive move
|
|
3
|
+
paths:
|
|
4
|
+
- '.claude/plans/**'
|
|
5
|
+
- '.claude/plans-archive/**'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Plan standards
|
|
9
|
+
|
|
10
|
+
## The answer contract
|
|
11
|
+
|
|
12
|
+
- Never fill an `- Answer:` slot on behalf of the person who owns it. A blank slot accepts the suggestion at execution time.
|
|
13
|
+
- Never ship a question without a `- Suggested:` line. Write `- Suggested: needs your call, <why>` where the answer turns on preference.
|
|
14
|
+
|
|
15
|
+
## Archiving
|
|
16
|
+
|
|
17
|
+
- Move a shipped plan to `.claude/plans-archive/`. Never delete one.
|
|
18
|
+
- Amend a plan in place when a decision changes. Do not append a second passage narrating the change.
|
|
19
|
+
|
|
20
|
+
## Authority
|
|
21
|
+
|
|
22
|
+
- Follow `.claude/standards/plan.md` for the filename and slug, the required sections, the suggested-and-answer contract, and the lifecycle. It is the single source.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { register as transcripts } from '@/commands/transcripts'
|
|
|
22
22
|
import { register as tasks } from '@/commands/tasks'
|
|
23
23
|
import { register as comments } from '@/commands/comments'
|
|
24
24
|
import { register as context } from '@/commands/context'
|
|
25
|
+
import { register as records } from '@/commands/records'
|
|
25
26
|
import { PROJECT_ROOT } from '@/exec'
|
|
26
27
|
|
|
27
28
|
const GREY = '\x1b[0;90m'
|
|
@@ -53,6 +54,7 @@ function showHelp(): void {
|
|
|
53
54
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
54
55
|
`${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
|
|
55
56
|
`${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
|
|
57
|
+
`${GREY}│${NC} records [cmd] ${GREY}# Check session records under .claude/ (validate)${NC}`,
|
|
56
58
|
`${GREY}│${NC}`,
|
|
57
59
|
`${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
|
|
58
60
|
`${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
|
|
@@ -85,6 +87,7 @@ function showHelp(): void {
|
|
|
85
87
|
`${GREY}│${NC} aitk tasks archive --pull-request 673 --json`,
|
|
86
88
|
`${GREY}│${NC} aitk comments scan src --json`,
|
|
87
89
|
`${GREY}│${NC} aitk context audit --json`,
|
|
90
|
+
`${GREY}│${NC} aitk records validate plans`,
|
|
88
91
|
`${GREY}└${NC}`,
|
|
89
92
|
]
|
|
90
93
|
console.log(lines.join('\n'))
|
|
@@ -138,5 +141,6 @@ transcripts(program)
|
|
|
138
141
|
tasks(program)
|
|
139
142
|
comments(program)
|
|
140
143
|
context(program)
|
|
144
|
+
records(program)
|
|
141
145
|
|
|
142
146
|
program.parse()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
type Finding,
|
|
5
|
+
isRecordKind,
|
|
6
|
+
RECORD_KINDS,
|
|
7
|
+
type ValidateOutcome,
|
|
8
|
+
validateRecords,
|
|
9
|
+
} from '@/records/validate'
|
|
10
|
+
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
11
|
+
|
|
12
|
+
/** Returned when a record carries a finding, which is the gating result. */
|
|
13
|
+
const EXIT_FINDINGS = 2
|
|
14
|
+
|
|
15
|
+
interface ValidateCommandOptions {
|
|
16
|
+
readonly json?: boolean
|
|
17
|
+
readonly root?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The session-record folders are shared scratch at the main worktree root, and
|
|
22
|
+
* `git worktree list` puts that root first. Trusting the working directory would
|
|
23
|
+
* validate a linked worktree's empty folder and report it clean.
|
|
24
|
+
*/
|
|
25
|
+
async function mainWorktreeRoot(): Promise<string> {
|
|
26
|
+
const result = await $`git worktree list --porcelain`.quiet().nothrow()
|
|
27
|
+
if (result.exitCode !== 0) return process.cwd()
|
|
28
|
+
|
|
29
|
+
const line = result.stdout
|
|
30
|
+
.toString()
|
|
31
|
+
.split('\n')
|
|
32
|
+
.find((entry) => entry.startsWith('worktree '))
|
|
33
|
+
|
|
34
|
+
return line ? line.slice('worktree '.length).trim() : process.cwd()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function register(program: Command): void {
|
|
38
|
+
const records = program
|
|
39
|
+
.command('records')
|
|
40
|
+
.description('Check the gitignored session records under .claude/')
|
|
41
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
42
|
+
|
|
43
|
+
records
|
|
44
|
+
.command('validate')
|
|
45
|
+
.description('Report where a record and the standard governing it disagree')
|
|
46
|
+
.argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
|
|
47
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
48
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
49
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
50
|
+
.addHelpText(
|
|
51
|
+
'after',
|
|
52
|
+
[
|
|
53
|
+
'',
|
|
54
|
+
'Checks:',
|
|
55
|
+
' plans filename, required sections, and the suggested-and-answer contract',
|
|
56
|
+
' groundwork README and current-state files, numbering, dating, and a half-closed track',
|
|
57
|
+
' intake overview file, numbering, dating, and the four bullets every item carries',
|
|
58
|
+
'',
|
|
59
|
+
'Exit codes:',
|
|
60
|
+
' 0 every check passed',
|
|
61
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
62
|
+
' 2 at least one record carries a finding',
|
|
63
|
+
'',
|
|
64
|
+
'It reports and never writes. Each folder is per-machine scratch with no',
|
|
65
|
+
'history behind it, so a session fixes the record the report names.',
|
|
66
|
+
'',
|
|
67
|
+
'Examples:',
|
|
68
|
+
' aitk records validate plans',
|
|
69
|
+
' aitk records validate intake --json',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
)
|
|
73
|
+
.action(async (kind: string, opts: ValidateCommandOptions) => {
|
|
74
|
+
process.exitCode = await runValidate(kind, opts)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runValidate(
|
|
79
|
+
kind: string,
|
|
80
|
+
opts: ValidateCommandOptions,
|
|
81
|
+
): Promise<number> {
|
|
82
|
+
const emitJson = opts.json ?? false
|
|
83
|
+
|
|
84
|
+
if (!isRecordKind(kind)) {
|
|
85
|
+
return report(
|
|
86
|
+
{
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'unknown-kind',
|
|
89
|
+
message: `Not a record kind: ${kind}. Expected one of: ${RECORD_KINDS.join(', ')}.`,
|
|
90
|
+
},
|
|
91
|
+
emitJson,
|
|
92
|
+
process.cwd(),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
97
|
+
|
|
98
|
+
return report(await validateRecords(root, kind), emitJson, root)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function report(
|
|
102
|
+
outcome: ValidateOutcome,
|
|
103
|
+
emitJson: boolean,
|
|
104
|
+
root: string,
|
|
105
|
+
): number {
|
|
106
|
+
if (!outcome.ok) {
|
|
107
|
+
// The framed branch below already reaches stderr through logError, so the
|
|
108
|
+
// bare write is what keeps the JSON mode from reporting the reason on
|
|
109
|
+
// stdout alone.
|
|
110
|
+
if (emitJson) {
|
|
111
|
+
process.stderr.write(`${outcome.message}\n`)
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
`${JSON.stringify({
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: outcome.reason,
|
|
116
|
+
message: outcome.message,
|
|
117
|
+
})}\n`,
|
|
118
|
+
)
|
|
119
|
+
return 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
intro('aitk records validate')
|
|
123
|
+
logStep('Refused')
|
|
124
|
+
logError(outcome.message)
|
|
125
|
+
outro()
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (emitJson) {
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
`${JSON.stringify({
|
|
132
|
+
ok: true,
|
|
133
|
+
root,
|
|
134
|
+
kind: outcome.kind,
|
|
135
|
+
records: outcome.records,
|
|
136
|
+
findings: outcome.findings,
|
|
137
|
+
})}\n`,
|
|
138
|
+
)
|
|
139
|
+
} else {
|
|
140
|
+
intro('aitk records validate')
|
|
141
|
+
logStep(outcome.kind)
|
|
142
|
+
logInfo(`${outcome.records} record(s) read`)
|
|
143
|
+
|
|
144
|
+
logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
|
|
145
|
+
if (outcome.findings.length === 0) {
|
|
146
|
+
logInfo('every record matches the shape its standard fixes')
|
|
147
|
+
} else {
|
|
148
|
+
for (const found of outcome.findings) logWarn(describe(found))
|
|
149
|
+
}
|
|
150
|
+
outro()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return outcome.findings.length > 0 ? EXIT_FINDINGS : 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function describe(found: Finding): string {
|
|
157
|
+
const scope = found.record === found.subject ? '' : `${found.record}: `
|
|
158
|
+
return `${scope}${found.subject} ${found.message}`
|
|
159
|
+
}
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
|
+
|
|
6
|
+
export const RECORD_KINDS = ['plans', 'groundwork', 'intake'] as const
|
|
7
|
+
|
|
8
|
+
export type RecordKind = (typeof RECORD_KINDS)[number]
|
|
9
|
+
|
|
10
|
+
const FOLDER_BY_KIND: Readonly<Record<RecordKind, string>> = {
|
|
11
|
+
plans: join('.claude', 'plans'),
|
|
12
|
+
groundwork: join('.claude', 'groundwork'),
|
|
13
|
+
intake: join('.claude', 'intake'),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `unknown-kind` is raised at the argument boundary rather than by the walk, and
|
|
18
|
+
* it sits here because both reach a caller through the same `reason` field. A
|
|
19
|
+
* union covering only what the walk returns would type a record the command can
|
|
20
|
+
* emit as impossible.
|
|
21
|
+
*/
|
|
22
|
+
export const VALIDATE_REFUSALS = ['no-folder', 'unknown-kind'] as const
|
|
23
|
+
|
|
24
|
+
export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
25
|
+
|
|
26
|
+
export const FINDING_KINDS = [
|
|
27
|
+
'name-malformed',
|
|
28
|
+
'title-missing',
|
|
29
|
+
'section-missing',
|
|
30
|
+
'entry-unreasoned',
|
|
31
|
+
'suggestion-missing',
|
|
32
|
+
'question-unanswerable',
|
|
33
|
+
'frontmatter-incomplete',
|
|
34
|
+
'date-malformed',
|
|
35
|
+
'index-missing',
|
|
36
|
+
'state-missing',
|
|
37
|
+
'closing-partial',
|
|
38
|
+
'item-incomplete',
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
42
|
+
|
|
43
|
+
export interface Finding {
|
|
44
|
+
readonly kind: FindingKind
|
|
45
|
+
/** The record the finding sits in, relative to the validated folder. */
|
|
46
|
+
readonly record: string
|
|
47
|
+
readonly subject: string
|
|
48
|
+
readonly message: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ValidateReport {
|
|
52
|
+
readonly ok: true
|
|
53
|
+
readonly kind: RecordKind
|
|
54
|
+
readonly records: number
|
|
55
|
+
readonly findings: readonly Finding[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ValidateRefused {
|
|
59
|
+
readonly ok: false
|
|
60
|
+
readonly reason: ValidateRefusal
|
|
61
|
+
readonly message: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
65
|
+
|
|
66
|
+
export function recordsDir(root: string, kind: RecordKind): string {
|
|
67
|
+
return join(root, FOLDER_BY_KIND[kind])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isRecordKind(value: string): value is RecordKind {
|
|
71
|
+
return (RECORD_KINDS as readonly string[]).includes(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NONE_IDENTIFIED = 'None identified.'
|
|
75
|
+
const NUMBERED_FILE = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
76
|
+
|
|
77
|
+
function finding(
|
|
78
|
+
kind: FindingKind,
|
|
79
|
+
record: string,
|
|
80
|
+
subject: string,
|
|
81
|
+
message: string,
|
|
82
|
+
): Finding {
|
|
83
|
+
return { kind, record, subject, message }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function listMarkdown(dir: string): Promise<string[]> {
|
|
87
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
88
|
+
|
|
89
|
+
return entries
|
|
90
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
91
|
+
.map((entry) => entry.name)
|
|
92
|
+
.sort()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function listFolders(dir: string): Promise<string[]> {
|
|
96
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
97
|
+
|
|
98
|
+
return entries
|
|
99
|
+
.filter((entry) => entry.isDirectory())
|
|
100
|
+
.map((entry) => entry.name)
|
|
101
|
+
.sort()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PLAN_NAME = /^feature-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
105
|
+
const PLAN_TITLE = /^#[ \t]+Feature:[ \t]+\S/
|
|
106
|
+
/**
|
|
107
|
+
* An entry names a file and says something about it. Both halves are tested as
|
|
108
|
+
* facts rather than as a syntax: a backticked span anywhere, and prose left over
|
|
109
|
+
* once the spans are removed.
|
|
110
|
+
*
|
|
111
|
+
* Requiring the path to lead and the reason to follow a colon was the first
|
|
112
|
+
* shape and it reported 80 of 178 archived plans. The corpus writes
|
|
113
|
+
* `- Label: prose naming a path` as often as `- path: reason`, and both name the
|
|
114
|
+
* file and say why, so the stricter rule measured a house style rather than a
|
|
115
|
+
* defect.
|
|
116
|
+
*/
|
|
117
|
+
function statesReason(entry: string): boolean {
|
|
118
|
+
if (!/`[^`]+`/.test(entry)) return false
|
|
119
|
+
|
|
120
|
+
const prose = entry.replace(/`[^`]*`/g, '').replace(/^-[ \t]*/, '')
|
|
121
|
+
return /[A-Za-z0-9]/.test(prose)
|
|
122
|
+
}
|
|
123
|
+
const QUESTION_ITEM = /^\d+[a-z]?\.[ \t]+\S/
|
|
124
|
+
|
|
125
|
+
const PLAN_SECTIONS = [
|
|
126
|
+
'Summary',
|
|
127
|
+
'Constraints',
|
|
128
|
+
'Files to touch',
|
|
129
|
+
'Risks',
|
|
130
|
+
'Questions',
|
|
131
|
+
] as const
|
|
132
|
+
|
|
133
|
+
type PlanSection = (typeof PLAN_SECTIONS)[number]
|
|
134
|
+
|
|
135
|
+
const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
136
|
+
'Summary',
|
|
137
|
+
'Files to touch',
|
|
138
|
+
'Risks',
|
|
139
|
+
'Questions',
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
const FENCE = /^(`{3,}|~{3,})/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drops every fenced block, so a quoted template is not read as content. A plan
|
|
146
|
+
* showing the shape it writes puts real-looking bullets and headings inside a
|
|
147
|
+
* fence, and scanning them reports the example rather than the plan.
|
|
148
|
+
*
|
|
149
|
+
* A closing fence has to match the opening character and be at least as long,
|
|
150
|
+
* which is what keeps a ```` block holding a ``` example from closing early. An
|
|
151
|
+
* unterminated fence swallows the rest of the document, which under-reports a
|
|
152
|
+
* malformed file rather than reporting its remainder as content.
|
|
153
|
+
*/
|
|
154
|
+
export function linesOutsideFences(text: string): string[] {
|
|
155
|
+
const kept: string[] = []
|
|
156
|
+
let fence: string | undefined
|
|
157
|
+
|
|
158
|
+
for (const line of text.split('\n')) {
|
|
159
|
+
const match = FENCE.exec(line.trim())
|
|
160
|
+
|
|
161
|
+
if (fence) {
|
|
162
|
+
const closes =
|
|
163
|
+
match && match[1][0] === fence[0] && match[1].length >= fence.length
|
|
164
|
+
if (closes) fence = undefined
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (match) {
|
|
169
|
+
fence = match[1]
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
kept.push(line)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return kept
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A line standing alone as a bold label or an H2, whatever it names. A plan is
|
|
181
|
+
* free to carry a section of its own, so the split has to see one to close the
|
|
182
|
+
* section above it.
|
|
183
|
+
*/
|
|
184
|
+
const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A section opens as a bold label or as an H2 and both count. The corpus writes
|
|
188
|
+
* `Summary` as a heading and the other four as bold labels, and roughly a fifth
|
|
189
|
+
* of it swaps one for the other. Reporting the variant would fail nearly every
|
|
190
|
+
* plan present on the rule a reader is least served by, which is what teaches
|
|
191
|
+
* them to skip the output.
|
|
192
|
+
*/
|
|
193
|
+
export function sectionMarker(line: string): PlanSection | undefined {
|
|
194
|
+
const match = MARKER_LINE.exec(line.trim())
|
|
195
|
+
if (!match) return undefined
|
|
196
|
+
|
|
197
|
+
const name = match[1] ?? match[2]
|
|
198
|
+
return PLAN_SECTIONS.find((entry) => entry === name)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The spelling a finding names, which is the one the standard's template ships. */
|
|
202
|
+
export function preferredMarker(section: PlanSection): string {
|
|
203
|
+
return section === 'Summary' ? '## Summary' : `**${section}:**`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function splitPlanSections(text: string): Map<string, string[]> {
|
|
207
|
+
const sections = new Map<string, string[]>()
|
|
208
|
+
let current: string | undefined
|
|
209
|
+
|
|
210
|
+
for (const line of linesOutsideFences(text)) {
|
|
211
|
+
// Any marker-shaped line closes the section above it, and only a recognized
|
|
212
|
+
// one opens a section. A plan carrying a label of its own would otherwise
|
|
213
|
+
// collect its bullets into whichever section came before.
|
|
214
|
+
if (MARKER_LINE.test(line.trim())) {
|
|
215
|
+
current = sectionMarker(line)
|
|
216
|
+
if (current) sections.set(current, [])
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (current) sections.get(current)?.push(line)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return sections
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface Question {
|
|
227
|
+
readonly label: string
|
|
228
|
+
readonly body: readonly string[]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function readQuestions(lines: readonly string[]): Question[] {
|
|
232
|
+
const questions: { label: string; body: string[] }[] = []
|
|
233
|
+
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
const trimmed = line.trim()
|
|
236
|
+
if (QUESTION_ITEM.test(trimmed)) {
|
|
237
|
+
questions.push({ label: trimmed, body: [] })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
questions.at(-1)?.body.push(trimmed)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return questions
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function shorten(label: string): string {
|
|
248
|
+
return label.length > 60 ? `${label.slice(0, 57)}...` : label
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function checkQuestionContract(name: string, lines: string[]): Finding[] {
|
|
252
|
+
if (lines.some((line) => line.trim() === NONE_IDENTIFIED)) return []
|
|
253
|
+
|
|
254
|
+
const findings: Finding[] = []
|
|
255
|
+
|
|
256
|
+
for (const question of readQuestions(lines)) {
|
|
257
|
+
const subject = shorten(question.label)
|
|
258
|
+
|
|
259
|
+
if (!question.body.some((line) => line.startsWith('- Suggested:'))) {
|
|
260
|
+
findings.push(
|
|
261
|
+
finding(
|
|
262
|
+
'suggestion-missing',
|
|
263
|
+
name,
|
|
264
|
+
subject,
|
|
265
|
+
'carries no Suggested line, so it arrives at execution as a stop.',
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!question.body.some((line) => line.startsWith('- Answer:'))) {
|
|
271
|
+
findings.push(
|
|
272
|
+
finding(
|
|
273
|
+
'question-unanswerable',
|
|
274
|
+
name,
|
|
275
|
+
subject,
|
|
276
|
+
'carries no Answer slot, so the blank-answer default has nowhere to sit.',
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return findings
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function checkPlan(name: string, text: string): Finding[] {
|
|
286
|
+
const findings: Finding[] = []
|
|
287
|
+
|
|
288
|
+
if (!PLAN_NAME.test(name)) {
|
|
289
|
+
findings.push(
|
|
290
|
+
finding(
|
|
291
|
+
'name-malformed',
|
|
292
|
+
name,
|
|
293
|
+
name,
|
|
294
|
+
'is not named feature-<slug>.md with a kebab-case slug.',
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const lines = linesOutsideFences(text)
|
|
300
|
+
|
|
301
|
+
if (!lines.some((line) => PLAN_TITLE.test(line))) {
|
|
302
|
+
findings.push(
|
|
303
|
+
finding('title-missing', name, name, 'opens with no # Feature: heading.'),
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sections = splitPlanSections(text)
|
|
308
|
+
|
|
309
|
+
for (const marker of PLAN_REQUIRED) {
|
|
310
|
+
if (!sections.has(marker)) {
|
|
311
|
+
findings.push(
|
|
312
|
+
finding(
|
|
313
|
+
'section-missing',
|
|
314
|
+
name,
|
|
315
|
+
preferredMarker(marker),
|
|
316
|
+
'is required and the plan carries no such section.',
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const line of sections.get('Files to touch') ?? []) {
|
|
323
|
+
const trimmed = line.trim()
|
|
324
|
+
if (!trimmed.startsWith('- ') || trimmed === `- ${NONE_IDENTIFIED}`)
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
if (!statesReason(trimmed)) {
|
|
328
|
+
findings.push(
|
|
329
|
+
finding(
|
|
330
|
+
'entry-unreasoned',
|
|
331
|
+
name,
|
|
332
|
+
shorten(trimmed),
|
|
333
|
+
'names no file, or names one and says nothing about it.',
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
findings.push(...checkQuestionContract(name, sections.get('Questions') ?? []))
|
|
340
|
+
|
|
341
|
+
return findings
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const DATE_FIELD = /^date:[ \t]*'?"?(\d{4}-\d{2}-\d{2})'?"?[ \t]*$/m
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Reads the opening date off the raw block rather than the parsed fields. A YAML
|
|
348
|
+
* parser resolves an unquoted `YYYY-MM-DD` to a date value on the core schema
|
|
349
|
+
* and to a string elsewhere, and a check keyed on the parsed type would report a
|
|
350
|
+
* conforming file on one runtime and not the other.
|
|
351
|
+
*/
|
|
352
|
+
function hasOpeningDate(raw: string): boolean {
|
|
353
|
+
return DATE_FIELD.test(raw)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function checkFolderFrontmatter(
|
|
357
|
+
dir: string,
|
|
358
|
+
slug: string,
|
|
359
|
+
files: readonly string[],
|
|
360
|
+
indexFile: string,
|
|
361
|
+
): Promise<Finding[]> {
|
|
362
|
+
const perFile = await Promise.all(
|
|
363
|
+
files.map(async (file) => {
|
|
364
|
+
const found: Finding[] = []
|
|
365
|
+
const frontmatter = parseFrontmatter(
|
|
366
|
+
await readFile(join(dir, file), 'utf8'),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
const missing = ['title', 'description'].filter(
|
|
370
|
+
(field) => !readField(frontmatter, field),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if (missing.length > 0) {
|
|
374
|
+
found.push(
|
|
375
|
+
finding(
|
|
376
|
+
'frontmatter-incomplete',
|
|
377
|
+
slug,
|
|
378
|
+
file,
|
|
379
|
+
`carries no ${missing.join(' and no ')}.`,
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (file === indexFile && !hasOpeningDate(frontmatter?.raw ?? '')) {
|
|
385
|
+
found.push(
|
|
386
|
+
finding(
|
|
387
|
+
'date-malformed',
|
|
388
|
+
slug,
|
|
389
|
+
file,
|
|
390
|
+
'carries no date field as YYYY-MM-DD, so the folder states no opening day.',
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (file !== indexFile && !NUMBERED_FILE.test(file)) {
|
|
396
|
+
found.push(
|
|
397
|
+
finding(
|
|
398
|
+
'name-malformed',
|
|
399
|
+
slug,
|
|
400
|
+
file,
|
|
401
|
+
'is not numbered NN-<name>.md, so the folder has no read order.',
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return found
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return perFile.flat()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const GROUNDWORK_INDEX = 'README.md'
|
|
414
|
+
const GROUNDWORK_STATE = '01-current-state.md'
|
|
415
|
+
const GROUNDWORK_DECISION = '06-'
|
|
416
|
+
const GROUNDWORK_HANDOFF = '07-'
|
|
417
|
+
|
|
418
|
+
async function checkTrack(dir: string, slug: string): Promise<Finding[]> {
|
|
419
|
+
const files = await listMarkdown(dir)
|
|
420
|
+
const findings: Finding[] = []
|
|
421
|
+
|
|
422
|
+
if (!files.includes(GROUNDWORK_INDEX)) {
|
|
423
|
+
findings.push(
|
|
424
|
+
finding(
|
|
425
|
+
'index-missing',
|
|
426
|
+
slug,
|
|
427
|
+
GROUNDWORK_INDEX,
|
|
428
|
+
'is absent, so the track carries no file map and no reason it is running.',
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!files.includes(GROUNDWORK_STATE)) {
|
|
434
|
+
findings.push(
|
|
435
|
+
finding(
|
|
436
|
+
'state-missing',
|
|
437
|
+
slug,
|
|
438
|
+
GROUNDWORK_STATE,
|
|
439
|
+
'is absent, so the track states no measured current state.',
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A track closes on the decision and the handoff together. One without the
|
|
445
|
+
// other reads as closed to anyone scanning filenames and strands the half a
|
|
446
|
+
// returning session actually opens.
|
|
447
|
+
const decided = files.some((file) => file.startsWith(GROUNDWORK_DECISION))
|
|
448
|
+
const handed = files.some((file) => file.startsWith(GROUNDWORK_HANDOFF))
|
|
449
|
+
|
|
450
|
+
if (decided !== handed) {
|
|
451
|
+
findings.push(
|
|
452
|
+
finding(
|
|
453
|
+
'closing-partial',
|
|
454
|
+
slug,
|
|
455
|
+
decided ? GROUNDWORK_HANDOFF : GROUNDWORK_DECISION,
|
|
456
|
+
`is absent while ${decided ? '06' : '07'} is present, so the track is neither live nor closed.`,
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
findings.push(
|
|
462
|
+
...(await checkFolderFrontmatter(dir, slug, files, GROUNDWORK_INDEX)),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return findings
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const INTAKE_INDEX = '00-overview.md'
|
|
469
|
+
const INTAKE_HANDOFF = '99-next-session.md'
|
|
470
|
+
|
|
471
|
+
const ITEM_HEADING = /^###[ \t]+\S/
|
|
472
|
+
const ITEM_REQUIRED = ['Problem', 'Fix', 'Worth it', 'You'] as const
|
|
473
|
+
|
|
474
|
+
function bulletLabel(line: string): string | undefined {
|
|
475
|
+
const match = /^-[ \t]+\*\*([^:*]+):\*\*/.exec(line.trim())
|
|
476
|
+
return match ? match[1].trim() : undefined
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function checkItems(
|
|
480
|
+
slug: string,
|
|
481
|
+
file: string,
|
|
482
|
+
text: string,
|
|
483
|
+
): Finding[] {
|
|
484
|
+
const findings: Finding[] = []
|
|
485
|
+
const items: { heading: string; labels: string[] }[] = []
|
|
486
|
+
|
|
487
|
+
for (const line of linesOutsideFences(text)) {
|
|
488
|
+
if (ITEM_HEADING.test(line)) {
|
|
489
|
+
items.push({ heading: line.trim().replace(/^###[ \t]+/, ''), labels: [] })
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const label = bulletLabel(line)
|
|
494
|
+
if (label) items.at(-1)?.labels.push(label)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const item of items) {
|
|
498
|
+
const missing = ITEM_REQUIRED.filter(
|
|
499
|
+
(label) => !item.labels.includes(label),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if (missing.length > 0) {
|
|
503
|
+
findings.push(
|
|
504
|
+
finding(
|
|
505
|
+
'item-incomplete',
|
|
506
|
+
slug,
|
|
507
|
+
`${file}: ${shorten(item.heading)}`,
|
|
508
|
+
`states no ${missing.join(', no ')}.`,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (item.labels.includes('Open') && !item.labels.includes('Suggested')) {
|
|
514
|
+
findings.push(
|
|
515
|
+
finding(
|
|
516
|
+
'suggestion-missing',
|
|
517
|
+
slug,
|
|
518
|
+
`${file}: ${shorten(item.heading)}`,
|
|
519
|
+
'asks an open question and suggests nothing, so a bare answer decides it.',
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return findings
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
529
|
+
const files = await listMarkdown(dir)
|
|
530
|
+
const findings: Finding[] = []
|
|
531
|
+
|
|
532
|
+
if (!files.includes(INTAKE_INDEX)) {
|
|
533
|
+
findings.push(
|
|
534
|
+
finding(
|
|
535
|
+
'index-missing',
|
|
536
|
+
slug,
|
|
537
|
+
INTAKE_INDEX,
|
|
538
|
+
'is absent, so the dump carries no cluster table and no verdict counts.',
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
findings.push(
|
|
544
|
+
...(await checkFolderFrontmatter(dir, slug, files, INTAKE_INDEX)),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
// The two reserved files hold no items. Running the item check over the
|
|
548
|
+
// handoff would report every heading it carries as a malformed item.
|
|
549
|
+
const clusters = files.filter(
|
|
550
|
+
(file) => file !== INTAKE_INDEX && file !== INTAKE_HANDOFF,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
const perCluster = await Promise.all(
|
|
554
|
+
clusters.map(async (file) =>
|
|
555
|
+
checkItems(slug, file, await readFile(join(dir, file), 'utf8')),
|
|
556
|
+
),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
return [...findings, ...perCluster.flat()]
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
563
|
+
return { ok: false, reason, message }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Reports what every record in one gitignored folder claims against the shape
|
|
568
|
+
* its standard fixes. It writes nothing: the folder is per-machine scratch with
|
|
569
|
+
* no history behind it, so a repair that guessed wrong could not be undone.
|
|
570
|
+
*/
|
|
571
|
+
export async function validateRecords(
|
|
572
|
+
root: string,
|
|
573
|
+
kind: RecordKind,
|
|
574
|
+
): Promise<ValidateOutcome> {
|
|
575
|
+
const dir = recordsDir(root, kind)
|
|
576
|
+
|
|
577
|
+
if (!existsSync(dir)) {
|
|
578
|
+
return refuse('no-folder', `No ${kind} folder at ${dir}.`)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (kind === 'plans') {
|
|
582
|
+
const files = await listMarkdown(dir)
|
|
583
|
+
const perFile = await Promise.all(
|
|
584
|
+
files.map(async (file) =>
|
|
585
|
+
checkPlan(file, await readFile(join(dir, file), 'utf8')),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
return { ok: true, kind, records: files.length, findings: perFile.flat() }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const folders = await listFolders(dir)
|
|
593
|
+
const check = kind === 'groundwork' ? checkTrack : checkDump
|
|
594
|
+
const perFolder = await Promise.all(
|
|
595
|
+
folders.map((slug) => check(join(dir, slug), slug)),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
return { ok: true, kind, records: folders.length, findings: perFolder.flat() }
|
|
599
|
+
}
|
package/standards/groundwork.md
CHANGED
|
@@ -16,6 +16,7 @@ Governs a groundwork track under `.claude/groundwork/<slug>/`: folder layout, re
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
18
18
|
- A dump of many findings filed by domain, each carrying its own verdict: `intake.md`
|
|
19
|
+
- The feature plan a closed track feeds, and the contract its answer slots keep: `plan.md`
|
|
19
20
|
- The task file a closing track writes, and the origin line pointing back at the folder: `tasks.md`
|
|
20
21
|
- Voice and word choice: `prose.md`
|
|
21
22
|
- Headings, punctuation, and file references: `markdown.md`
|
package/standards/index.md
CHANGED
|
@@ -14,6 +14,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
14
14
|
- [Groundwork reference](groundwork.md): Folder layout, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
|
|
15
15
|
- [Intake reference](intake.md): Folder layout, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
|
|
16
16
|
- [Markdown reference](markdown.md): Headings, paragraph and list structure, code spans, punctuation, emphasis, and file references
|
|
17
|
+
- [Plan reference](plan.md): Filename and slug, required sections, the suggested-and-answer contract, and the lifecycle from the live folder to the archive
|
|
17
18
|
- [Prose reference](prose.md): Voice, language, and frontmatter wording for reference markdown
|
|
18
19
|
- [Publish reference](publish.md): Scan run against finished text leaving through a channel no automated check covers
|
|
19
20
|
- [Readme reference](readme.md): Readme voice, structure, and content conventions
|
package/standards/intake.md
CHANGED
|
@@ -16,6 +16,7 @@ Governs an intake folder under `.claude/intake/<slug>/`: folder layout, the rese
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
18
18
|
- One question measured in depth before anyone can plan against it: `groundwork.md`
|
|
19
|
+
- The feature plan a promoted item feeds, and the inverted answer contract it keeps: `plan.md`
|
|
19
20
|
- The task file promoting an item onto the board, and the origin line pointing back at the folder: `tasks.md`
|
|
20
21
|
- Voice and word choice: `prose.md`
|
|
21
22
|
- Headings, punctuation, and file references: `markdown.md`
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Plan reference
|
|
3
|
+
description: Filename and slug, required sections, the suggested-and-answer contract, and the lifecycle from the live folder to the archive
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plan reference
|
|
7
|
+
|
|
8
|
+
Applies to a feature plan at `.claude/plans/feature-<slug>.md`. One file holds one concern, written before implementation starts and read by whatever executes it, so it has to carry the scope without the conversation that produced it.
|
|
9
|
+
|
|
10
|
+
The folder is gitignored and unbacked. Nothing recovers a deleted plan, which is why the archive step below is a move rather than a cleanup.
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
Governs a feature plan under `.claude/plans/feature-<slug>.md`: the filename, the required sections, what each holds, the suggested-and-answer contract, and the lifecycle from the live folder to the archive.
|
|
15
|
+
|
|
16
|
+
Does not govern:
|
|
17
|
+
|
|
18
|
+
- One question measured in depth before anyone can plan against it: `groundwork.md`
|
|
19
|
+
- A dump of many findings filed by domain, each carrying its own verdict: `intake.md`
|
|
20
|
+
- The task file a plan is linked from, and the origin line pointing back at it: `tasks.md`
|
|
21
|
+
- The transform from a branch name to a slug: `slug.md`
|
|
22
|
+
- Voice and word choice: `prose.md`
|
|
23
|
+
- Headings, punctuation, and file references: `markdown.md`
|
|
24
|
+
- When a plan is written at all, and whether a short one goes to a file or to the conversation, which belong to the surface driving it
|
|
25
|
+
|
|
26
|
+
## What a working plan looks like
|
|
27
|
+
|
|
28
|
+
A plan works when a session holding none of the conversation that produced it can execute from the file alone:
|
|
29
|
+
|
|
30
|
+
- What is being built, and which files does it change?
|
|
31
|
+
- Which decisions are already settled, and which are still open?
|
|
32
|
+
- For each open decision, what happens when nobody answers it?
|
|
33
|
+
- What is likely to go wrong, and where does the work collide with something already in flight?
|
|
34
|
+
|
|
35
|
+
A plan failing these is non-conforming even when it satisfies every shape rule below.
|
|
36
|
+
|
|
37
|
+
## Filename and slug
|
|
38
|
+
|
|
39
|
+
- Name the file `feature-<slug>.md`, with `<slug>` two to four kebab-case words naming the concern.
|
|
40
|
+
- Write one concern per file. A request spanning two independent concerns takes two plans rather than one bundling both, since a bundled plan cannot be executed by two sessions or abandoned by half.
|
|
41
|
+
- Derive the slug from the concern rather than from a branch, because the plan is written before the branch exists.
|
|
42
|
+
- Give the branch that executes the plan the same slug. A later surface finds the plan from the branch name and finds nothing when the two spellings differ.
|
|
43
|
+
|
|
44
|
+
A plan carries no frontmatter. Its filename is the whole of its identity, so a folder of plans is read by name rather than by a generated catalog.
|
|
45
|
+
|
|
46
|
+
## Sections
|
|
47
|
+
|
|
48
|
+
The document opens with `# Feature: <short title>` and one paragraph stating what is being built and why now. The sections below follow in this order.
|
|
49
|
+
|
|
50
|
+
| Section | Holds | Required |
|
|
51
|
+
| --------------------- | ------------------------------------------------------------------------- | ------------- |
|
|
52
|
+
| `## Summary` | three to five one-line bullets: the goal, the deliverables, the trade-off | Always |
|
|
53
|
+
| `**Constraints:**` | durable rules the work must respect | When supplied |
|
|
54
|
+
| `**Files to touch:**` | each file with a one-line reason | Always |
|
|
55
|
+
| `**Risks:**` | conflicts, coupling, and the tricky spots | Always |
|
|
56
|
+
| `**Questions:**` | numbered open decisions, each with a suggestion and an answer slot | Always |
|
|
57
|
+
|
|
58
|
+
- Write each marker as the table gives it. `Summary` opens the prose a reader scans, so it takes an H2, and the four that follow are labels over lists rather than sections of prose.
|
|
59
|
+
- A section written in the other spelling is still that section. `## Risks` and `**Risks:**` name one thing, and a plan carrying either has stated its risks, so neither reads as an omission. Write the table's form in a new plan and leave an existing plan's spelling alone.
|
|
60
|
+
- Write `None identified.` under a required section with nothing to report rather than dropping the marker. A dropped section and an unconsidered one read identically.
|
|
61
|
+
- Aim `## Summary` at a person scanning the plan, not at the session executing it. The other sections carry what execution needs.
|
|
62
|
+
- Give every `**Files to touch:**` entry a backticked path and something said about it. A bare path states scope and not intent, and the reason is what an executing session checks its edit against. Lead with the path or lead with a label carrying the path, whichever reads better for the entry.
|
|
63
|
+
- State every count and every claim about the tree as measured during the pass that wrote the plan. A figure carried in from a summary or an earlier session is the most common way a plan ships the wrong scope.
|
|
64
|
+
- Prefer a short plan over a padded one. A section filled to look thorough costs the reader the same attention as one that matters.
|
|
65
|
+
|
|
66
|
+
### Constraints
|
|
67
|
+
|
|
68
|
+
A constraint naming a surface to leave alone forbids two different acts, and it says which. A constraint carrying only the surface leaves the executing session to guess.
|
|
69
|
+
|
|
70
|
+
- Forbid conforming that surface to whatever shape the change introduces. This is what a scope constraint means, and it keeps the branch from growing a second concern.
|
|
71
|
+
- Never forbid retargeting a pointer the change breaks. A rename, a split, or a deletion that leaves a citation behind ships a dangling reference, so repairing it is required work rather than scope creep.
|
|
72
|
+
- Decide both acts for every surface the constraint names. Carving the distinction out for one file and leaving its siblings under the bare wording ships one correct call beside one broken reference.
|
|
73
|
+
|
|
74
|
+
### Risks
|
|
75
|
+
|
|
76
|
+
- Name the collision rather than the category. A risk a reader cannot act on is padding.
|
|
77
|
+
- Where the work establishes a resource with more than one consumer, list the consumers and mark each one read or write. A policy stated over that resource has to hold for the writers and not only for the consumer that prompted it.
|
|
78
|
+
|
|
79
|
+
## The suggested-and-answer contract
|
|
80
|
+
|
|
81
|
+
Every question carries a `- Suggested:` line and an empty `- Answer:` slot below it.
|
|
82
|
+
|
|
83
|
+
```markdown
|
|
84
|
+
1. <question>
|
|
85
|
+
- Suggested: <pick>, <reason or tradeoff>
|
|
86
|
+
- Answer:
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- A blank `- Answer:` means accept the suggestion at execution time. That default is what makes the plan decision-ready in one pass, with no separate round to collect answers.
|
|
90
|
+
- Never ship a question without a suggestion. A bare question defers the judgment the plan exists to supply, and it arrives at the executing session as a stop.
|
|
91
|
+
- State the pick in one line with its reason or its main tradeoff. List an alternative only where it changes the pick.
|
|
92
|
+
- Write `- Suggested: needs your call, <why>` where the answer turns on preference rather than on a technical default. Fabricating a default there hides an open question behind an accepted answer.
|
|
93
|
+
- Never fill an `- Answer:` slot on behalf of the person who owns it. Recording the pick as the suggestion is what the suggestion line is for.
|
|
94
|
+
- Answer in place when a question is settled in conversation, so the file and the decision do not disagree.
|
|
95
|
+
|
|
96
|
+
This contract inverts the one an intake folder keeps, where an empty slot means unread and acceptance is typed out. A plan is written and read in one sitting with every question already surfaced, so silence is a usable default here and is not one there. Both files state the inversion, since a contract read on only one side of a boundary is the one that gets applied to the wrong document.
|
|
97
|
+
|
|
98
|
+
## Lifecycle
|
|
99
|
+
|
|
100
|
+
- Write the plan before implementation starts, and treat it as the scope of the run that executes it.
|
|
101
|
+
- Keep every plan at one root. A plan copied into each parallel working tree forks, and the copies answer the same question differently.
|
|
102
|
+
- Amend the plan in place when a decision changes mid-flight. Do not append a second passage narrating the change, which leaves a reader to work out which of two answers is current.
|
|
103
|
+
- Move the plan to `.claude/plans-archive/` when the work it describes ships. Never delete it, because the plan is where the rejected alternative is written down and nothing else records it.
|
|
104
|
+
- Write the plan in the same session that opens the task it serves. The session executing it later inherits reasoning it would otherwise re-derive.
|
|
105
|
+
|
|
106
|
+
## Anti-patterns
|
|
107
|
+
|
|
108
|
+
- **The plan written before the measuring.** A subject that has to be measured before anyone can plan against it belongs in a measurement track, and every plan that skipped that step had to be superseded.
|
|
109
|
+
- **The bundled plan.** Two concerns under one slug cannot be split later without rewriting both halves.
|
|
110
|
+
- **The question with no suggestion.** It reads as thoroughness and lands as a blocked run.
|
|
111
|
+
- **The answer filled in by the author.** A slot holding the writer's own pick destroys the only signal that anyone else agreed.
|
|
112
|
+
- **The count carried in.** A figure quoted from an earlier session survives the change that invalidated it, and the scope built on it is quietly wrong.
|
|
113
|
+
- **The plan deleted on ship.** The considered-and-dropped reasoning goes with it, and the next session re-proposes what this one rejected.
|
|
114
|
+
|
|
115
|
+
## Template
|
|
116
|
+
|
|
117
|
+
```markdown
|
|
118
|
+
# Feature: <short title>
|
|
119
|
+
|
|
120
|
+
<One paragraph on what is being built and why now.>
|
|
121
|
+
|
|
122
|
+
## Summary
|
|
123
|
+
|
|
124
|
+
- <one-line bullet covering the goal>
|
|
125
|
+
- <one-line bullet covering the main deliverables>
|
|
126
|
+
- <one-line bullet covering the key decision or trade-off>
|
|
127
|
+
|
|
128
|
+
**Constraints:**
|
|
129
|
+
|
|
130
|
+
- <durable rule the work must respect>
|
|
131
|
+
|
|
132
|
+
**Files to touch:**
|
|
133
|
+
|
|
134
|
+
- `<path/to/file>`: <reason>
|
|
135
|
+
|
|
136
|
+
**Risks:**
|
|
137
|
+
|
|
138
|
+
- <conflict, coupling, or tricky spot>
|
|
139
|
+
|
|
140
|
+
**Questions:**
|
|
141
|
+
|
|
142
|
+
1. <question>
|
|
143
|
+
- Suggested: <pick>, <reason or tradeoff>
|
|
144
|
+
- Answer:
|
|
145
|
+
```
|
package/standards/tasks.md
CHANGED
|
@@ -15,6 +15,7 @@ Governs the task board under `.claude/tasks/`: folder layout, filenames, frontma
|
|
|
15
15
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
18
|
+
- The plan file a task cites, its sections, and its answer contract: `plan.md`
|
|
18
19
|
- Phase-label format and which surfaces a label may appear on: `versioning.md`
|
|
19
20
|
- Sequencing across versions and why the order is what it is: `roadmap.md`
|
|
20
21
|
- Architectural reasoning that outlives a task: `architecture.md`
|