@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
@@ -4,6 +4,7 @@ import {
4
4
  type EntryReport,
5
5
  governsContent,
6
6
  LENGTH_CHECKPOINT,
7
+ matchesSiblings,
7
8
  measureFolders,
8
9
  missingSections,
9
10
  PROVENANCE_FOLDER,
@@ -60,7 +61,7 @@ export function register(program: Command): void {
60
61
  context
61
62
  .command('audit')
62
63
  .description(
63
- 'Report required sections, entry length, citations, catalog tables, provenance, superseded-decision narration, and index drift',
64
+ 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, and index drift',
64
65
  )
65
66
  .argument('[path]', 'Project root, defaulting to the current directory')
66
67
  .helpOption('-h, --help', 'Show this help message')
@@ -85,8 +86,9 @@ export function register(program: Command): void {
85
86
  '',
86
87
  'An unresolved citation always gates. --gate widens the gate to the',
87
88
  'other two findings that are facts rather than judgments: a missing',
88
- 'required section and index drift. Length, table, provenance, and',
89
- 'narration findings are judgments and stay advisory under both.',
89
+ 'required section and index drift. Length, reference form, table,',
90
+ 'provenance, and narration findings are judgments and stay advisory',
91
+ 'under both.',
90
92
  '',
91
93
  'Depth and bullet weight are stated over every markdown file rather',
92
94
  'than over a context entry, so `aitk markdown audit` measures them.',
@@ -205,6 +207,7 @@ async function runAudit(
205
207
  intro('aitk context audit')
206
208
  reportScope(folders, unresolved)
207
209
  reportCitations(citations, cited)
210
+ reportReferenceForm(entries, folders)
208
211
  reportSections(sections, folders)
209
212
  reportLength(entries)
210
213
  reportTables(entries)
@@ -370,6 +373,65 @@ function reportCitations(
370
373
  )
371
374
  }
372
375
 
376
+ /**
377
+ * Reports the references naming a sibling entry by bare filename.
378
+ *
379
+ * This prints beside the citation check rather than among the readability
380
+ * measures, since the two read the same thing: one resolves a path a reference
381
+ * spells and this one finds the references that spell none. The reach line
382
+ * names the split folders rather than the governed folder alone, because a run
383
+ * whose only context folder is flat measures nothing here and would otherwise
384
+ * print the same clean line as a run that measured every split.
385
+ */
386
+ function reportReferenceForm(
387
+ entries: readonly EntryReport[],
388
+ folders: readonly AuditedFolder[],
389
+ ): void {
390
+ logStep('Reference form')
391
+
392
+ const scoped = folders.filter(matchesSiblings)
393
+ if (scoped.length === 0) {
394
+ logInfo(
395
+ `Out of scope. A bare name is matched against the siblings of a domain split into a folder, and no audited folder under .claude/${PROVENANCE_FOLDER}/ is one.`,
396
+ )
397
+ return
398
+ }
399
+
400
+ logInfo(
401
+ `Covers ${plural(scoped.length, 'split folder')} under .claude/${PROVENANCE_FOLDER}/, whose standard asks a reference to spell its path.`,
402
+ )
403
+ logInfo(
404
+ 'The flat folder is out of reach, since a domain filename there is shared by seeds and other trees.',
405
+ )
406
+
407
+ const carrying = entries
408
+ .filter((entry) => entry.bareReferences.length > 0)
409
+ .sort((a, b) => b.bareReferences.length - a.bareReferences.length)
410
+
411
+ if (carrying.length === 0) {
412
+ logInfo('Every reference to a sibling entry spells its path.')
413
+ return
414
+ }
415
+
416
+ const total = carrying.reduce(
417
+ (sum, entry) => sum + entry.bareReferences.length,
418
+ 0,
419
+ )
420
+ logWarn(
421
+ `${plural(total, 'bare name')} across ${carrying.length} ${carrying.length === 1 ? 'entry' : 'entries'}`,
422
+ )
423
+ pipeOutput(
424
+ carrying
425
+ .map(
426
+ (entry) =>
427
+ `${entry.rel} ${plural(entry.bareReferences.length, 'bare name')}\n${entry.bareReferences
428
+ .map((found) => ` :${found.line} ${found.name}`)
429
+ .join('\n')}`,
430
+ )
431
+ .join('\n'),
432
+ )
433
+ }
434
+
373
435
  /**
374
436
  * Names the path each finding belongs to, which is an entry in the folder named
375
437
  * under `.claude/` and the folder itself in a domain split across one. States
@@ -1,4 +1,11 @@
1
1
  import type { Command } from 'commander'
2
+ import {
3
+ BACKED_FOLDERS,
4
+ type PullOutcome,
5
+ pullRecords,
6
+ type PushOutcome,
7
+ pushRecords,
8
+ } from '@/records/backup'
2
9
  import {
3
10
  type Finding,
4
11
  isRecordKind,
@@ -6,7 +13,15 @@ import {
6
13
  type ValidateOutcome,
7
14
  validateRecords,
8
15
  } from '@/records/validate'
9
- import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
16
+ import {
17
+ intro,
18
+ logError,
19
+ logInfo,
20
+ logStep,
21
+ logWarn,
22
+ outro,
23
+ pipeOutput,
24
+ } from '@/ui'
10
25
  import { mainWorktreeRoot } from '@/worktree'
11
26
 
12
27
  /** Returned when a record carries a finding, which is the gating result. */
@@ -17,10 +32,14 @@ interface ValidateCommandOptions {
17
32
  readonly root?: string
18
33
  }
19
34
 
35
+ type BackupCommandOptions = ValidateCommandOptions
36
+
20
37
  export function register(program: Command): void {
21
38
  const records = program
22
39
  .command('records')
23
- .description('Check the gitignored session records under .claude/')
40
+ .description(
41
+ 'Check and back up the gitignored session records under .claude/',
42
+ )
24
43
  .helpOption('-h, --help', 'Show this help message')
25
44
 
26
45
  records
@@ -58,6 +77,145 @@ export function register(program: Command): void {
58
77
  .action(async (kind: string, opts: ValidateCommandOptions) => {
59
78
  process.exitCode = await runValidate(kind, opts)
60
79
  })
80
+
81
+ records
82
+ .command('push')
83
+ .description(
84
+ 'Commit the backed record folders and push them to the records remote',
85
+ )
86
+ .helpOption('-h, --help', 'Show this help message')
87
+ .option('--json', 'Add a machine-readable record on stdout')
88
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
89
+ .addHelpText('after', backupHelp('push'))
90
+ .action(async (opts: BackupCommandOptions) => {
91
+ process.exitCode = await runPush(opts)
92
+ })
93
+
94
+ records
95
+ .command('pull')
96
+ .description(
97
+ 'Fetch the records remote and write it into the backed record folders',
98
+ )
99
+ .helpOption('-h, --help', 'Show this help message')
100
+ .option('--json', 'Add a machine-readable record on stdout')
101
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
102
+ .addHelpText('after', backupHelp('pull'))
103
+ .action(async (opts: BackupCommandOptions) => {
104
+ process.exitCode = await runPull(opts)
105
+ })
106
+ }
107
+
108
+ function backupHelp(verb: 'push' | 'pull'): string {
109
+ const direction =
110
+ verb === 'push'
111
+ ? [
112
+ 'It commits nothing outside the folders above and leaves the project working',
113
+ 'tree untouched. The push runs even when this call committed nothing, since a',
114
+ 'previous run can have committed and then failed to reach the network.',
115
+ ]
116
+ : [
117
+ 'It refuses rather than discarding local records that never reached the remote,',
118
+ 'so a machine holding uncommitted or unpushed records is told to push first.',
119
+ ]
120
+
121
+ return [
122
+ '',
123
+ 'Backed folders under .claude/:',
124
+ ` ${BACKED_FOLDERS.join(', ')}`,
125
+ '',
126
+ 'Exit codes:',
127
+ ' 0 the records remote and this machine agree',
128
+ ' 1 refused, with the reason on stderr or in the JSON record',
129
+ '',
130
+ ...direction,
131
+ '',
132
+ 'The history lives in a second git directory at .claude/.records.git, pointed at a',
133
+ 'private repository a person creates once. The verbs refuse with the setup command',
134
+ 'when it is absent, and refuse when its origin is also a remote of this project.',
135
+ '',
136
+ 'Examples:',
137
+ ` aitk records ${verb}`,
138
+ ` aitk records ${verb} --json`,
139
+ '',
140
+ ].join('\n')
141
+ }
142
+
143
+ async function runPush(opts: BackupCommandOptions): Promise<number> {
144
+ const root = opts.root ?? (await mainWorktreeRoot())
145
+ const outcome = await pushRecords(root)
146
+
147
+ if (!outcome.ok)
148
+ return reportRefusal('aitk records push', outcome, opts.json ?? false)
149
+
150
+ if (opts.json ?? false) {
151
+ process.stdout.write(`${JSON.stringify(outcome)}\n`)
152
+ return 0
153
+ }
154
+
155
+ intro('aitk records push')
156
+ logStep('Staged')
157
+ logInfo(
158
+ `${outcome.folders.length} folder(s), ${outcome.changed} path(s) changed`,
159
+ )
160
+ logStep(outcome.pushed ? 'Pushed' : 'Nothing to push')
161
+ logInfo(
162
+ outcome.commit
163
+ ? `records at ${outcome.commit}`
164
+ : 'no records committed yet',
165
+ )
166
+ outro()
167
+ return 0
168
+ }
169
+
170
+ async function runPull(opts: BackupCommandOptions): Promise<number> {
171
+ const root = opts.root ?? (await mainWorktreeRoot())
172
+ const outcome = await pullRecords(root)
173
+
174
+ if (!outcome.ok)
175
+ return reportRefusal('aitk records pull', outcome, opts.json ?? false)
176
+
177
+ if (opts.json ?? false) {
178
+ process.stdout.write(`${JSON.stringify(outcome)}\n`)
179
+ return 0
180
+ }
181
+
182
+ intro('aitk records pull')
183
+ logStep('Fetched')
184
+ logInfo(`records at ${outcome.commit}`)
185
+ logStep('Written')
186
+ logInfo(`${outcome.files} file(s) across ${outcome.folders.length} folder(s)`)
187
+ outro()
188
+ return 0
189
+ }
190
+
191
+ function reportRefusal(
192
+ banner: string,
193
+ outcome: Extract<PushOutcome | PullOutcome, { ok: false }>,
194
+ emitJson: boolean,
195
+ ): number {
196
+ if (emitJson) {
197
+ process.stderr.write(`${outcome.message}\n`)
198
+ process.stdout.write(
199
+ `${JSON.stringify({
200
+ ok: false,
201
+ reason: outcome.reason,
202
+ message: outcome.message,
203
+ })}\n`,
204
+ )
205
+ return 1
206
+ }
207
+
208
+ // The setup refusals carry the commands to run on their own lines. A marker
209
+ // beside a command reads as a result rather than as something to copy, so the
210
+ // remainder goes out unmarked.
211
+ const [reason, ...rest] = outcome.message.split('\n')
212
+
213
+ intro(banner)
214
+ logStep('Refused')
215
+ logError(reason)
216
+ if (rest.length > 0) pipeOutput(rest.join('\n'))
217
+ outro()
218
+ return 1
61
219
  }
62
220
 
63
221
  async function runValidate(
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
- import { relative } from 'node:path'
2
+ import { basename, relative } from 'node:path'
3
+ import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
3
4
  import type { AuditedFolder } from '@/context/folders'
4
5
  import type { NarrationTerms } from '@/context/narration'
5
6
  import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
@@ -109,6 +110,12 @@ export interface ProvenanceFinding {
109
110
  readonly text: string
110
111
  }
111
112
 
113
+ export interface BareReferenceFinding {
114
+ readonly line: number
115
+ /** The name as written, so a report says which reference to respell. */
116
+ readonly name: string
117
+ }
118
+
112
119
  export interface NarrationFinding {
113
120
  readonly line: number
114
121
  /** The opening that points back at the bullet above. */
@@ -141,6 +148,12 @@ export interface EntryReport {
141
148
  * since an empty list here is silent about which one produced it.
142
149
  */
143
150
  readonly narration: readonly NarrationFinding[]
151
+ /**
152
+ * References naming a sibling entry by bare filename, and empty for a caller
153
+ * that passed no sibling names. Which entries have siblings worth matching is
154
+ * the caller's judgment, stated where it builds the list.
155
+ */
156
+ readonly bareReferences: readonly BareReferenceFinding[]
144
157
  /**
145
158
  * Required sections this entry declares, in the standard's order, and empty
146
159
  * outside the folder whose standard names them. What the folder is short of
@@ -397,18 +410,62 @@ function declaredSections(lines: readonly BodyLine[]): string[] {
397
410
  return REQUIRED_SECTIONS.filter((section) => found.has(section))
398
411
  }
399
412
 
413
+ /**
414
+ * Finds the references naming a sibling entry by bare filename.
415
+ *
416
+ * The standard asks for the path because a bare name resolves against whichever
417
+ * folder the reader is in, so a folder split strands every inbound reference
418
+ * and nothing reads the break. A path is checkable and a bare name is not,
419
+ * which is what makes this the one form rule worth measuring.
420
+ *
421
+ * Matching stops at the sibling set, which reaches less than the rule does. The
422
+ * standard governs a reference to any other entry, so a split entry naming one
423
+ * that sits in a different folder is a violation this never sees. What the set
424
+ * buys is that a name resolving inside the folder is a reference by
425
+ * construction, where a bare filename matched anywhere would report every
426
+ * sentence that happens to name a file. The entry's own name is out of the set
427
+ * on separate grounds, since naming itself points at nothing a split can strand.
428
+ *
429
+ * Fenced blocks are skipped for the reason the scans above skip them, and a line
430
+ * carrying the citation ignore marker is skipped because that marker already
431
+ * means the line displays a name rather than pointing at one.
432
+ */
433
+ function bareReferences(
434
+ lines: readonly BodyLine[],
435
+ siblings: readonly string[],
436
+ ): BareReferenceFinding[] {
437
+ if (siblings.length === 0) return []
438
+
439
+ const named = new Set(siblings)
440
+ const findings: BareReferenceFinding[] = []
441
+
442
+ for (const line of lines) {
443
+ if (line.fenced || line.text.includes(IGNORE_MARKER)) continue
444
+
445
+ for (const match of line.text.matchAll(BARE_NAME)) {
446
+ if (named.has(match[1])) {
447
+ findings.push({ line: line.number, name: match[1] })
448
+ }
449
+ }
450
+ }
451
+
452
+ return findings
453
+ }
454
+
400
455
  /**
401
456
  * Measures one entry, scanning for provenance only when a standard claims it.
402
457
  *
403
458
  * The caller passes jurisdiction rather than deriving it from `rel`, because a
404
459
  * path prefix hardcodes what `--folder` exists to override and misses a domain
405
- * split into `context/<sub-area>/`.
460
+ * split into `context/<sub-area>/`. Sibling names arrive the same way and for
461
+ * the same reason, since the folder an entry sits in is what holds them.
406
462
  */
407
463
  export function measureEntry(
408
464
  rel: string,
409
465
  source: string,
410
466
  governsContent = true,
411
467
  terms?: NarrationTerms,
468
+ siblings: readonly string[] = [],
412
469
  ): EntryReport {
413
470
  const lines = bodyLines(source)
414
471
 
@@ -421,6 +478,7 @@ export function measureEntry(
421
478
  catalogTables: catalogTables(lines),
422
479
  provenance: governsContent ? provenance(lines) : [],
423
480
  narration: governsContent && terms ? narration(lines, terms) : [],
481
+ bareReferences: bareReferences(lines, siblings),
424
482
  sections: governsContent ? declaredSections(lines) : [],
425
483
  stub: isStubSeed(source),
426
484
  }
@@ -442,13 +500,20 @@ export async function measureFolders(
442
500
  const reports: EntryReport[] = []
443
501
 
444
502
  for (const folder of folders) {
503
+ const names = matchesSiblings(folder)
504
+ ? folder.entries.map((path) => basename(path))
505
+ : []
506
+
445
507
  for (const path of folder.entries) {
508
+ const self = basename(path)
509
+
446
510
  reports.push(
447
511
  measureEntry(
448
512
  relative(root, path),
449
513
  await readFile(path, 'utf8'),
450
514
  governsContent(folder),
451
515
  terms,
516
+ names.filter((name) => name !== self),
452
517
  ),
453
518
  )
454
519
  }
@@ -462,6 +527,26 @@ export function governsContent(folder: AuditedFolder): boolean {
462
527
  return folder.name === PROVENANCE_FOLDER
463
528
  }
464
529
 
530
+ /**
531
+ * Reports whether a bare sibling name here is a reference by construction.
532
+ *
533
+ * A split folder's entries are named for sub-areas of one domain, so a bare
534
+ * name matching one of them points at it and nothing else. The folder named
535
+ * under `.claude/` is where that stops holding, since its entries are named for
536
+ * whole domains and a domain name is a common noun that a seed, a script, or
537
+ * another tree spells the same way. Both false positives this measure was tuned
538
+ * against sat there, naming a seed that shares a filename with the entry beside
539
+ * them, and no signal in the name separates the two.
540
+ *
541
+ * What the exemption costs is the references a future split of the named folder
542
+ * would strand, which are the ones this measure would most like to hold. It is
543
+ * taken because a report firing on correct prose is what teaches a reader to
544
+ * stop reading the section.
545
+ */
546
+ export function matchesSiblings(folder: AuditedFolder): boolean {
547
+ return governsContent(folder) && folder.nested
548
+ }
549
+
465
550
  /**
466
551
  * Names what does not declare the sections the standard requires.
467
552
  *
@@ -49,6 +49,23 @@ export function citationPattern(folders: readonly string[]): RegExp {
49
49
  )
50
50
  }
51
51
 
52
+ /**
53
+ * A backticked filename carrying no folder, the form a reference takes when it
54
+ * names a sibling rather than a path.
55
+ *
56
+ * `citationPattern` spells the `.claude/` prefix and cannot see this shape at
57
+ * all, which is the reason the form rule exists. Widening that expression to
58
+ * admit a bare name was the alternative and it puts one match in the position of
59
+ * answering two questions, since a spelled path is a reference by construction
60
+ * and a bare name is a candidate whichever caller found it still has to test
61
+ * against the folder it sits in.
62
+ *
63
+ * The backticks are required rather than incidental. A filename written into
64
+ * running prose without them is not a reference a reader follows, and matching
65
+ * one would report every sentence that happens to name a file.
66
+ */
67
+ export const BARE_NAME = /`([A-Za-z0-9._-]+\.md)`/g
68
+
52
69
  /**
53
70
  * Pulls the cited paths out of one file's text.
54
71
  *