@erclx/aitk 0.79.0 → 0.81.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.
Files changed (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -1
  3. package/claude/skills/claude-address-review/SKILL.md +8 -3
  4. package/claude/skills/claude-address-review/references/rebase-conflicts.md +3 -1
  5. package/claude/skills/claude-autoship/SKILL.md +3 -1
  6. package/claude/skills/claude-diagram/SKILL.md +3 -1
  7. package/claude/skills/claude-docs/SKILL.md +17 -4
  8. package/claude/skills/claude-feature/SKILL.md +6 -2
  9. package/claude/skills/claude-intake/REQUIREMENT.md +3 -1
  10. package/claude/skills/claude-memory-review/SKILL.md +6 -2
  11. package/claude/skills/claude-memory-review/references/receipt-format.md +3 -1
  12. package/claude/skills/claude-orchestrate/REQUIREMENT.md +6 -2
  13. package/claude/skills/claude-orchestrate/references/orchestrator-resume.md +3 -1
  14. package/claude/skills/claude-pr-review/SKILL.md +11 -3
  15. package/claude/skills/claude-seed-sync/SKILL.md +3 -1
  16. package/claude/skills/cli-script/REQUIREMENT.md +3 -1
  17. package/claude/skills/docs-sync/SKILL.md +3 -1
  18. package/claude/skills/git-followup/REQUIREMENT.md +3 -1
  19. package/claude/skills/git-followup/SKILL.md +10 -2
  20. package/claude/skills/git-ship/SKILL.md +3 -1
  21. package/claude/skills/migration-context/REQUIREMENT.md +3 -1
  22. package/claude/skills/project-commands/SKILL.md +3 -1
  23. package/claude/skills/session-resume/SKILL.md +3 -1
  24. package/claude/skills/setup-indexes/SKILL.md +3 -1
  25. package/claude/skills/setup-init/SKILL.md +3 -1
  26. package/claude/skills/toolkit-cli/SKILL.md +1 -1
  27. package/docs/agents/commands.md +29 -27
  28. package/docs/agents/comments.md +3 -1
  29. package/docs/agents/context-audit-checks.md +40 -6
  30. package/docs/agents/context-audit.md +2 -2
  31. package/docs/agents/index.md +1 -1
  32. package/docs/agents/indexes.md +3 -1
  33. package/docs/agents/install-and-sync.md +12 -6
  34. package/docs/agents/output-shape.md +3 -1
  35. package/docs/agents/records.md +45 -1
  36. package/docs/agents/sandbox.md +3 -1
  37. package/docs/ai-workflow.md +31 -8
  38. package/docs/operating-model.md +6 -3
  39. package/docs/target-projects.md +22 -6
  40. package/docs/visual-design-workflow.md +13 -3
  41. package/package.json +1 -1
  42. package/src/cli.ts +2 -1
  43. package/src/commands/context.ts +65 -3
  44. package/src/commands/records.ts +160 -2
  45. package/src/context/audit.ts +87 -2
  46. package/src/context/citations.ts +17 -0
  47. package/src/records/backup.ts +394 -0
  48. package/standards/context.md +1 -0
  49. package/standards/prose.md +3 -1
  50. package/standards/rule.md +3 -1
  51. package/standards/tasks.md +11 -3
  52. package/standards/versioning.md +3 -1
@@ -0,0 +1,394 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { $ } from 'bun'
4
+ import { gitEnv } from '@/git-env'
5
+
6
+ /**
7
+ * The folders a backup carries, relative to `.claude/`. They are the `# Claude`
8
+ * group in `.gitignore` minus `.claude/.tmp`, which is defined as deletable
9
+ * without loss, and `.claude/worktrees/`, whose contents belong to the
10
+ * enclosing repository already. The list is spelled out rather than read off
11
+ * that group so adding an ignore entry cannot silently enlarge the payload.
12
+ *
13
+ * `RECORD_KINDS` in `validate.ts` names four of these. The two lists differ on
14
+ * purpose: one is what a standard governs, this is what a disk loss would take.
15
+ */
16
+ export const BACKED_FOLDERS = [
17
+ 'groundwork',
18
+ 'intake',
19
+ 'memory',
20
+ 'plans',
21
+ 'plans-archive',
22
+ 'review',
23
+ 'task-archive',
24
+ 'tasks',
25
+ ] as const
26
+
27
+ /** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
28
+ const RECORDS_GIT_DIR = join('.claude', '.records.git')
29
+
30
+ const WORK_TREE = '.claude'
31
+
32
+ /** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
33
+ const RECORDS_BRANCH = 'main'
34
+
35
+ /**
36
+ * The records history is machine-written and nobody reads its authorship, so a
37
+ * fixed identity keeps `push` from failing inside a git hook on a machine where
38
+ * `user.email` was never configured.
39
+ */
40
+ const COMMIT_IDENTITY = ['-c', 'user.name=aitk', '-c', 'user.email=aitk@local']
41
+
42
+ export const BACKUP_REFUSALS = [
43
+ 'no-repository',
44
+ 'no-remote',
45
+ 'remote-unreadable',
46
+ 'remote-shared',
47
+ 'no-remote-records',
48
+ 'local-changes',
49
+ 'local-ahead',
50
+ 'git-failed',
51
+ ] as const
52
+
53
+ export type BackupRefusal = (typeof BACKUP_REFUSALS)[number]
54
+
55
+ export interface BackupRefused {
56
+ readonly ok: false
57
+ readonly reason: BackupRefusal
58
+ readonly message: string
59
+ }
60
+
61
+ export interface PushReport {
62
+ readonly ok: true
63
+ readonly root: string
64
+ readonly folders: readonly string[]
65
+ readonly changed: number
66
+ readonly commit?: string
67
+ readonly pushed: boolean
68
+ }
69
+
70
+ export interface PullReport {
71
+ readonly ok: true
72
+ readonly root: string
73
+ readonly folders: readonly string[]
74
+ readonly commit: string
75
+ readonly files: number
76
+ }
77
+
78
+ export type PushOutcome = PushReport | BackupRefused
79
+ export type PullOutcome = PullReport | BackupRefused
80
+
81
+ interface GitResult {
82
+ readonly ok: boolean
83
+ readonly text: string
84
+ readonly stderr: string
85
+ }
86
+
87
+ /**
88
+ * Runs one git command against the records history.
89
+ *
90
+ * Both flags go on every call. `git --git-dir=<path> init` writes
91
+ * `core.bare = true`, and an explicit `--work-tree` is what overrides it, so
92
+ * dropping the flag on a single call reads the enclosing project as the tree
93
+ * and stages everything in it.
94
+ */
95
+ async function records(root: string, args: string[]): Promise<GitResult> {
96
+ const gitDir = join(root, RECORDS_GIT_DIR)
97
+ const workTree = join(root, WORK_TREE)
98
+
99
+ const result =
100
+ await $`git --git-dir=${gitDir} --work-tree=${workTree} ${args}`
101
+ .env(gitEnv())
102
+ .quiet()
103
+ .nothrow()
104
+
105
+ return {
106
+ ok: result.exitCode === 0,
107
+ text: result.stdout.toString().trim(),
108
+ stderr: result.stderr.toString().trim(),
109
+ }
110
+ }
111
+
112
+ function refuse(reason: BackupRefusal, message: string): BackupRefused {
113
+ return { ok: false, reason, message }
114
+ }
115
+
116
+ function failed(action: string, result: GitResult): BackupRefused {
117
+ return refuse(
118
+ 'git-failed',
119
+ `git ${action} failed against the records history: ${result.stderr || 'no output'}.`,
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Reduces a remote URL to `host/path`, so every spelling of one repository
125
+ * compares equal.
126
+ *
127
+ * Transport is what the reduction drops. `git@github.com:owner/repo.git` and
128
+ * `https://github.com/owner/repo` name the same repository, and comparing them
129
+ * as written passes a records origin that publishes the payload through the
130
+ * other protocol.
131
+ */
132
+ function remoteIdentity(url: string): string {
133
+ return url
134
+ .trim()
135
+ .toLowerCase()
136
+ .replace(/^[a-z+]+:\/\//, '')
137
+ .replace(/^[^@/]+@/, '')
138
+ .replace(/^([^/:]+):/, '$1/')
139
+ .replace(/\/+$/, '')
140
+ .replace(/\.git$/, '')
141
+ .replace(/\/+$/, '')
142
+ }
143
+
144
+ /**
145
+ * Lists every remote of the enclosing project, or undefined when git cannot
146
+ * answer.
147
+ *
148
+ * The caller refuses on undefined rather than smoothing it into an empty list.
149
+ * An empty list clears the gate below for every URL, so a git that failed for
150
+ * any reason would publish the payload to whatever origin the records history
151
+ * happens to name. A project with no remotes answers `0` with an exit of zero,
152
+ * so the two states stay distinguishable.
153
+ */
154
+ async function enclosingRemoteUrls(
155
+ root: string,
156
+ ): Promise<string[] | undefined> {
157
+ const result = await $`git -C ${root} remote -v`
158
+ .env(gitEnv())
159
+ .quiet()
160
+ .nothrow()
161
+ if (result.exitCode !== 0) return undefined
162
+
163
+ return result.stdout
164
+ .toString()
165
+ .split('\n')
166
+ .filter(Boolean)
167
+ .map((line) => line.split(/\s+/)[1] ?? '')
168
+ .filter(Boolean)
169
+ .map(remoteIdentity)
170
+ }
171
+
172
+ /**
173
+ * Clears the four gates both verbs share and returns the records remote URL.
174
+ *
175
+ * The last two are the ones the payload depends on. This repository is public,
176
+ * so a records branch on any of its remotes serves the memory pen and the
177
+ * groundwork trails to anyone who fetches all refs. Comparing the configured
178
+ * URL against every remote of the enclosing repository is what keeps a
179
+ * misconfigured `origin` from publishing them, and refusing when that list
180
+ * cannot be read is what keeps a failed comparison from reading as a pass.
181
+ */
182
+ async function resolveRemote(root: string): Promise<string | BackupRefused> {
183
+ if (!existsSync(join(root, RECORDS_GIT_DIR))) {
184
+ return refuse(
185
+ 'no-repository',
186
+ [
187
+ `No records history at ${RECORDS_GIT_DIR}. Create it once, against a private repository:`,
188
+ ` git --git-dir=${join(root, RECORDS_GIT_DIR)} init`,
189
+ ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
190
+ ].join('\n'),
191
+ )
192
+ }
193
+
194
+ const remote = await records(root, ['remote', 'get-url', 'origin'])
195
+ if (!remote.ok || remote.text.length === 0) {
196
+ return refuse(
197
+ 'no-remote',
198
+ [
199
+ 'The records history has no origin. Point it at a private repository:',
200
+ ` git --git-dir=${join(root, RECORDS_GIT_DIR)} remote add origin <private-repo-url>`,
201
+ ].join('\n'),
202
+ )
203
+ }
204
+
205
+ const enclosing = await enclosingRemoteUrls(root)
206
+ if (!enclosing) {
207
+ return refuse(
208
+ 'remote-unreadable',
209
+ `Cannot read the remotes of the project at ${root}, so the records origin cannot be checked against them. Records carry the memory pen and the groundwork trails, and an unchecked origin risks publishing them.`,
210
+ )
211
+ }
212
+
213
+ const url = remoteIdentity(remote.text)
214
+ if (enclosing.includes(url)) {
215
+ return refuse(
216
+ 'remote-shared',
217
+ `The records origin ${remote.text} is a remote of this project. Records carry the memory pen and the groundwork trails, so they need a repository of their own.`,
218
+ )
219
+ }
220
+
221
+ return url
222
+ }
223
+
224
+ /**
225
+ * The subset of the eight a pathspec can name: on disk, or already in the
226
+ * records index.
227
+ *
228
+ * A pathspec matching neither fails the whole `add`, which is why the subset
229
+ * exists. The index half is what covers a folder deleted in full. Reading disk
230
+ * alone drops it from the pathspec, so its deletion never stages, the remote
231
+ * keeps it forever, and a later `pull` restores it past the gate that refuses
232
+ * every other unpushed deletion.
233
+ */
234
+ async function scopedFolders(root: string): Promise<string[]> {
235
+ const tracked = await records(root, ['ls-files'])
236
+ const indexed = new Set(
237
+ tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
238
+ )
239
+
240
+ return BACKED_FOLDERS.filter(
241
+ (folder) =>
242
+ existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
243
+ )
244
+ }
245
+
246
+ function topSegment(path: string): string {
247
+ return path.split('/')[0]
248
+ }
249
+
250
+ /** What a report names, which is the folders a reader can go and open. */
251
+ function presentFolders(root: string): string[] {
252
+ return BACKED_FOLDERS.filter((folder) =>
253
+ existsSync(join(root, WORK_TREE, folder)),
254
+ )
255
+ }
256
+
257
+ function countLines(text: string): number {
258
+ return text.split('\n').filter(Boolean).length
259
+ }
260
+
261
+ /**
262
+ * Stages the backed folders, commits when any of them changed, and pushes.
263
+ *
264
+ * The push runs whether or not this call committed, because a previous run can
265
+ * have committed and then failed to reach the network. Skipping it would leave
266
+ * that commit on one disk, which is the state the whole verb exists to end.
267
+ */
268
+ export async function pushRecords(root: string): Promise<PushOutcome> {
269
+ const remote = await resolveRemote(root)
270
+ if (typeof remote !== 'string') return remote
271
+
272
+ const scope = await scopedFolders(root)
273
+
274
+ if (scope.length > 0) {
275
+ // `-f` is what carries the payload: every backed folder is ignored by the
276
+ // enclosing repository, and the pathspecs are the whole list, so nothing
277
+ // outside them can enter the index however the ignore rules read.
278
+ const staged = await records(root, ['add', '-A', '-f', '--', ...scope])
279
+ if (!staged.ok) return failed('add', staged)
280
+ }
281
+
282
+ const diff = await records(root, [
283
+ 'diff',
284
+ '--cached',
285
+ '--name-only',
286
+ '--',
287
+ ...scope,
288
+ ])
289
+ if (!diff.ok) return failed('diff', diff)
290
+
291
+ const changed = countLines(diff.text)
292
+
293
+ if (changed > 0) {
294
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16)
295
+ const commit = await records(root, [
296
+ ...COMMIT_IDENTITY,
297
+ 'commit',
298
+ '--quiet',
299
+ '-m',
300
+ `records: ${changed} changed at ${stamp}`,
301
+ ])
302
+ if (!commit.ok) return failed('commit', commit)
303
+ }
304
+
305
+ const folders = presentFolders(root)
306
+ const head = await records(root, ['rev-parse', '--short', 'HEAD'])
307
+ if (!head.ok) {
308
+ return { ok: true, root, folders, changed, pushed: false }
309
+ }
310
+
311
+ const pushed = await records(root, [
312
+ 'push',
313
+ 'origin',
314
+ `HEAD:refs/heads/${RECORDS_BRANCH}`,
315
+ ])
316
+ if (!pushed.ok) return failed('push', pushed)
317
+
318
+ return { ok: true, root, folders, changed, commit: head.text, pushed: true }
319
+ }
320
+
321
+ /**
322
+ * Fetches the records history and writes it into the backed folders.
323
+ *
324
+ * The two directions are not symmetric. A push only ever adds, while a pull
325
+ * onto a machine holding work that never reached the remote would discard it,
326
+ * so both gates below refuse rather than choosing a merge strategy. A person
327
+ * resolves by pushing first or by moving the local folders aside.
328
+ */
329
+ export async function pullRecords(root: string): Promise<PullOutcome> {
330
+ const remote = await resolveRemote(root)
331
+ if (typeof remote !== 'string') return remote
332
+
333
+ const fetched = await records(root, [
334
+ 'fetch',
335
+ '--quiet',
336
+ 'origin',
337
+ `refs/heads/${RECORDS_BRANCH}`,
338
+ ])
339
+ if (!fetched.ok) {
340
+ // A missing branch and an unreachable remote both fail the fetch, and only
341
+ // the first is an ordinary state a person resolves by pushing once.
342
+ if (fetched.stderr.includes("couldn't find remote ref")) {
343
+ return refuse(
344
+ 'no-remote-records',
345
+ `The records origin carries no ${RECORDS_BRANCH} branch yet. Run aitk records push from the machine holding the records.`,
346
+ )
347
+ }
348
+ return failed('fetch', fetched)
349
+ }
350
+
351
+ const target = await records(root, ['rev-parse', 'FETCH_HEAD'])
352
+ if (!target.ok) return failed('rev-parse', target)
353
+
354
+ const scope = await scopedFolders(root)
355
+
356
+ if (scope.length > 0) {
357
+ const dirty = await records(root, ['status', '--porcelain', '--', ...scope])
358
+ if (!dirty.ok) return failed('status', dirty)
359
+
360
+ if (dirty.text.length > 0) {
361
+ return refuse(
362
+ 'local-changes',
363
+ `${countLines(dirty.text)} local record(s) are not in the records history. Run aitk records push first, or move them aside.`,
364
+ )
365
+ }
366
+ }
367
+
368
+ const head = await records(root, ['rev-parse', '--verify', '--quiet', 'HEAD'])
369
+ if (head.ok && head.text.length > 0) {
370
+ const ahead = await records(root, ['rev-list', `${target.text}..HEAD`])
371
+ if (!ahead.ok) return failed('rev-list', ahead)
372
+
373
+ if (ahead.text.length > 0) {
374
+ return refuse(
375
+ 'local-ahead',
376
+ `${countLines(ahead.text)} local commit(s) have not reached the records origin. Run aitk records push first.`,
377
+ )
378
+ }
379
+ }
380
+
381
+ const reset = await records(root, ['reset', '--hard', '--quiet', target.text])
382
+ if (!reset.ok) return failed('reset', reset)
383
+
384
+ const files = await records(root, ['ls-files'])
385
+ if (!files.ok) return failed('ls-files', files)
386
+
387
+ return {
388
+ ok: true,
389
+ root,
390
+ folders: presentFolders(root),
391
+ commit: target.text.slice(0, 7),
392
+ files: countLines(files.text),
393
+ }
394
+ }
@@ -83,6 +83,7 @@ Only the `development` entry carries this section. It is not a general-purpose h
83
83
  - Decisions specific to the domain. Broader cross-domain decisions belong in `.claude/ARCHITECTURE.md`.
84
84
  - Constraints, gotchas, things tried and rejected
85
85
  - Domain-specific conventions that do not fit a `paths:`-scoped rule
86
+ - A reference to another entry, spelled as the path that entry sits at rather than as its bare filename. A bare name resolves against whichever folder the reader is already in, so a domain that splits into subfolders strands every inbound reference and the break surfaces nowhere. A reference to a seed, a standard, or a file the project owns elsewhere keeps the form its own surface uses.
86
87
 
87
88
  ## What does not go in
88
89
 
@@ -5,7 +5,9 @@ description: Voice, language, and frontmatter wording for reference markdown
5
5
 
6
6
  # Prose reference
7
7
 
8
- Applies to markdown reference docs, READMEs, and inline documentation in repos. It is the default voice for `.md` files and yields to any surface with its own voice, such as blogs, emails, changelogs, or commit messages. It also yields wherever another standard states the voice for the surface it governs, which is how a surface claims the exemption without this file having to name it. The yield covers voice alone. The language rules below stay in force on every surface, including the surfaces no automated check reaches, as do the mechanics in `markdown.md`.
8
+ Applies to markdown reference docs, READMEs, and inline documentation in repos. It is the default voice for `.md` files and yields to any surface with its own voice, such as blogs, emails, changelogs, or commit messages. It also yields wherever another standard states the voice for the surface it governs, which is how a surface claims the exemption without this file having to name it.
9
+
10
+ The yield covers voice alone. The language rules below stay in force on every surface, including the surfaces no automated check reaches, as do the mechanics in `markdown.md`.
9
11
 
10
12
  ## Scope
11
13
 
package/standards/rule.md CHANGED
@@ -7,7 +7,9 @@ description: Rule frontmatter, body shape, and voice for .claude/rules files
7
7
 
8
8
  ## Overview
9
9
 
10
- Rules give Claude Code coding constraints scoped to file paths. Claude Code discovers `.claude/rules/**/*.md` at session start. A rule with no `paths:` field always applies, at the same priority as `CLAUDE.md`. A rule with `paths:` applies when Claude reads a file matching the glob. Author one rule per topic so the scope stays precise.
10
+ Rules give Claude Code coding constraints scoped to file paths. Claude Code discovers `.claude/rules/**/*.md` at session start.
11
+
12
+ A rule with no `paths:` field always applies, at the same priority as `CLAUDE.md`. A rule with `paths:` applies when Claude reads a file matching the glob. Author one rule per topic so the scope stays precise.
11
13
 
12
14
  ## Scope
13
15
 
@@ -5,7 +5,9 @@ description: Folder layout, filename convention, readiness groups, and content r
5
5
 
6
6
  # Tasks reference
7
7
 
8
- Applies to `.claude/tasks/`. Tracks what is being built and why, at the level of features and outcomes. One file per task. Update when a task starts, completes, or changes scope. When to open a task at all is project policy, not a shape rule, and lives in `CLAUDE.md`.
8
+ Applies to `.claude/tasks/`. Tracks what is being built and why, at the level of features and outcomes. One file per task.
9
+
10
+ Update when a task starts, completes, or changes scope. When to open a task at all is project policy, not a shape rule, and lives in `CLAUDE.md`.
9
11
 
10
12
  The folder is gitignored. Board state changes when work ships rather than when a branch is written, so committing it would put a claim about the future into the diff of an unrelated pull request. The git log records what shipped.
11
13
 
@@ -108,7 +110,9 @@ description: Record what a target installed and report the delta against the too
108
110
 
109
111
  ## File format
110
112
 
111
- Two headings, `## Outcomes` and `## Findings`. Outcomes are future and checkable, findings are past and factual, and as flat bullets at the same indent they are visually identical. A heading separates them at no cost. Add no third heading. Status stays inline on an outcome rather than becoming an "In progress" section.
113
+ Two headings, `## Outcomes` and `## Findings`. Outcomes are future and checkable, findings are past and factual, and as flat bullets at the same indent they are visually identical. A heading separates them at no cost.
114
+
115
+ Add no third heading. Status stays inline on an outcome rather than becoming an "In progress" section.
112
116
 
113
117
  Size the outcomes so one pull request closes all of them. A task whose outcomes span two pull requests ships the first half and leaves the rest open, with nothing recording which outcomes the merged work covered, so the board reads as in-progress work that no branch is carrying. Split the task before handing it off rather than after. This is what `## Archiving` below depends on, since a task closes whole or not at all.
114
118
 
@@ -156,7 +160,11 @@ A task with no origin is either lost context or work nobody decided to do. The i
156
160
 
157
161
  Phase-label format and where labels may appear are governed by `standards/versioning.md`.
158
162
 
159
- `Plan:` points at `../plans/feature-<slug>.md` while the task is open. Once the task ships and the plan is archived, it points at `../plans-archive/feature-<slug>.md`. Retarget both halves of the link rather than dropping it, so a completed task still leads to the reasoning behind it. A project that archived plans before the folder moved out of `.claude/.tmp/` holds closed tasks pointing at `../.tmp/plans-archive/`, and both forms resolve against the files each names, so leave those pointers where they are. Nothing migrates them, and a task retargeted without its plan moving leads nowhere. One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
163
+ `Plan:` points at `../plans/feature-<slug>.md` while the task is open. Once the task ships and the plan is archived, it points at `../plans-archive/feature-<slug>.md`. Retarget both halves of the link rather than dropping it, so a completed task still leads to the reasoning behind it.
164
+
165
+ A project that archived plans before the folder moved out of `.claude/.tmp/` holds closed tasks pointing at `../.tmp/plans-archive/`, and both forms resolve against the files each names, so leave those pointers where they are. Nothing migrates them, and a task retargeted without its plan moving leads nowhere.
166
+
167
+ One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
160
168
 
161
169
  `Groundwork:` points at `../groundwork/<slug>/`, the folder `claude-groundwork` fills. It names the surface it points at the way `Plan:` does. Use this key alone. `Research record` and `Decision record` are earlier spellings of the same thing and both convert to it.
162
170
 
@@ -66,4 +66,6 @@ The surface publishing the text is the last gate on it. Where no automated check
66
66
 
67
67
  ## Why
68
68
 
69
- Phase labels keep planning conversations efficient. They make `git log`, PR titles, and the tag list unreadable when they leak in. A future reader cannot reconstruct what an internal label meant without the matching task file, which is gitignored. Semver tags carry meaning independent of conversation state and survive in git history. Keeping the two namespaces apart preserves both.
69
+ Phase labels keep planning conversations efficient. They make `git log`, PR titles, and the tag list unreadable when they leak in. A future reader cannot reconstruct what an internal label meant without the matching task file, which is gitignored.
70
+
71
+ Semver tags carry meaning independent of conversation state and survive in git history. Keeping the two namespaces apart preserves both.