@erclx/aitk 3.15.0 → 3.16.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.15.0",
4
+ "version": "3.16.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -37,6 +37,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
37
37
  | `aitk teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
38
  | `aitk teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
39
39
  | `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
+ | `aitk records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
40
41
  | `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
41
42
  | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
42
43
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
@@ -24,7 +24,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
24
24
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
25
25
  - [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
26
26
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
27
- - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
27
+ - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
28
28
  - [Restated instructions](restated.md): Counting the instructions the always-loaded file shares with the seed and the shipped skill bodies, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
29
29
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
30
30
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Records
3
- description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
3
+ description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
4
4
  ---
5
5
 
6
6
  # Records
@@ -72,6 +72,28 @@ aitk records validate plans --json | jq -r '.findings[] | "\(.kind): \(.subject)
72
72
 
73
73
  For the shapes each check enforces, see `.claude/standards/plan.md`, `.claude/standards/groundwork.md`, `.claude/standards/intake.md`, `.claude/standards/memory.md`, and `.claude/standards/standard.md`.
74
74
 
75
+ ## Migrate
76
+
77
+ `aitk records migrate <kind>` rewrites the records a `validate` finding names a transform for. A standard that redefines its own required frontmatter breaks every record already written to the old shape, and this is the repair `validate` could only report until now.
78
+
79
+ ```bash
80
+ aitk records migrate memory
81
+ aitk records migrate memory --write
82
+ aitk records migrate memory --json
83
+ ```
84
+
85
+ | Option | Behavior |
86
+ | --------------- | ------------------------------------------------------------------- |
87
+ | `--json` | Add a machine-readable record on stdout |
88
+ | `--write` | Rewrite every record a transform can repair |
89
+ | `--root <path>` | Project root, defaulting to the main worktree except on `standards` |
90
+
91
+ It writes nothing until `--write` is passed, matching the write-flag contract `aitk tooling sync` carries: a session record has no history to undo a wrong repair from, so naming a kind is not consent to rewrite every record inside it. A dry run reports which records it would touch and exits non-zero either way, headless or not, since there is nothing to prompt for.
92
+
93
+ A transform runs only where the missing value is recoverable from the file itself. The one shipped today repairs a memory record missing `category` alone, deriving it from the same filename prefix `checkMemory` already reads it from. `title` and `description` are prose nobody wrote down, so a finding naming either carries no transform and stays for a session to fix by hand, and `validate` keeps reporting it. The transform re-reads the file rather than trusting a value captured at validate time, so a check and its repair cannot disagree about the same record.
94
+
95
+ Exit codes: `0` nothing carried a known transform, or `--write` repaired everything it found. `1` refused for a reason `validate` shares, every candidate it found failed to repair, or `--write` repaired only some of them. `2` a record carries a known transform and `--write` was not passed.
96
+
75
97
  ## Size
76
98
 
77
99
  `aitk records size` reports what each record folder holds and how much of it is recent. It reads the ten backed folders named under Push and pull, plus `.claude/.tmp`, and it gates nothing.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.15.0",
4
+ "version": "3.16.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,5 +1,8 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
1
3
  import type { Command } from 'commander'
2
4
  import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
5
+ import { migrateRecord } from '@/records/migrate'
3
6
  import {
4
7
  type FolderSize,
5
8
  formatBytes,
@@ -10,10 +13,12 @@ import {
10
13
  } from '@/records/size'
11
14
  import {
12
15
  type Finding,
16
+ type FindingRemedy,
13
17
  isRecordKind,
14
18
  isSharedScratch,
15
19
  RECORD_KINDS,
16
20
  type RecordKind,
21
+ recordsDir,
17
22
  type ValidateOutcome,
18
23
  validateRecords,
19
24
  } from '@/records/validate'
@@ -32,6 +37,9 @@ import { currentWorktreeRoot, mainWorktreeRoot } from '@/worktree'
32
37
  /** Returned when a record carries a finding, which is the gating result. */
33
38
  const EXIT_FINDINGS = 2
34
39
 
40
+ /** Returned when a record carries a known transform and `--write` was not passed. */
41
+ const EXIT_MIGRATABLE = 2
42
+
35
43
  interface ValidateCommandOptions {
36
44
  readonly json?: boolean
37
45
  readonly root?: string
@@ -39,6 +47,10 @@ interface ValidateCommandOptions {
39
47
 
40
48
  type BackupCommandOptions = ValidateCommandOptions
41
49
 
50
+ interface MigrateCommandOptions extends ValidateCommandOptions {
51
+ readonly write?: boolean
52
+ }
53
+
42
54
  export function register(program: Command): void {
43
55
  const records = program
44
56
  .command('records')
@@ -90,6 +102,44 @@ export function register(program: Command): void {
90
102
  process.exitCode = await runValidate(kind, opts)
91
103
  })
92
104
 
105
+ records
106
+ .command('migrate')
107
+ .description('Rewrite the records a validate finding names a transform for')
108
+ .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
109
+ .helpOption('-h, --help', 'Show this help message')
110
+ .option('--json', 'Add a machine-readable record on stdout')
111
+ .option('--write', 'Rewrite every record a transform can repair')
112
+ .option(
113
+ '--root <path>',
114
+ 'Project root, defaulting to the main worktree except on standards',
115
+ )
116
+ .addHelpText(
117
+ 'after',
118
+ [
119
+ '',
120
+ 'Exit codes:',
121
+ ' 0 nothing carries a known transform, or --write repaired every one',
122
+ ' 1 refused, with the reason on stderr or in the JSON record, every',
123
+ ' candidate it found failed to repair, or --write repaired only',
124
+ ' some of them',
125
+ ' 2 a record carries a known transform and --write was not passed',
126
+ '',
127
+ 'It reports and never writes without --write, matching aitk records',
128
+ 'validate: a session record has no history to undo a wrong repair from.',
129
+ 'A transform is only offered where the old value is recoverable from the',
130
+ 'file itself, so a finding with no transform is left for a session to fix.',
131
+ '',
132
+ 'Examples:',
133
+ ' aitk records migrate memory',
134
+ ' aitk records migrate memory --write',
135
+ ' aitk records migrate memory --json',
136
+ '',
137
+ ].join('\n'),
138
+ )
139
+ .action(async (kind: string, opts: MigrateCommandOptions) => {
140
+ process.exitCode = await runMigrate(kind, opts)
141
+ })
142
+
93
143
  records
94
144
  .command('size')
95
145
  .description('Report what each record folder holds and how much is recent')
@@ -456,3 +506,223 @@ function describe(found: Finding): string {
456
506
  const scope = found.record === found.subject ? '' : `${found.record}: `
457
507
  return `${scope}${found.subject} ${found.message}`
458
508
  }
509
+
510
+ export interface Repair {
511
+ readonly record: string
512
+ readonly remedy: FindingRemedy
513
+ readonly path: string
514
+ readonly text: string
515
+ }
516
+
517
+ export interface Refusal {
518
+ readonly record: string
519
+ readonly message: string
520
+ }
521
+
522
+ /** The message a rejected promise leaves, for a record whose read or write failed. */
523
+ function describeFailure(reason: unknown): string {
524
+ return reason instanceof Error ? reason.message : String(reason)
525
+ }
526
+
527
+ /**
528
+ * Runs every finding's transform, without writing anything back.
529
+ *
530
+ * `Promise.allSettled` rather than `Promise.all`, so a record whose read
531
+ * fails, such as one deleted between `validate`'s listing and this read,
532
+ * becomes a refusal for that one record rather than an unhandled rejection
533
+ * that `program.parse()` has no top-level catch for.
534
+ */
535
+ async function attemptMigrations(
536
+ dir: string,
537
+ findings: readonly Finding[],
538
+ ): Promise<{ repaired: Repair[]; refused: Refusal[] }> {
539
+ const candidates = findings.filter(
540
+ (found): found is Finding & { readonly remedy: FindingRemedy } =>
541
+ found.remedy !== undefined,
542
+ )
543
+
544
+ const settled = await Promise.allSettled(
545
+ candidates.map(async (found) => {
546
+ const path = join(dir, found.record)
547
+ const outcome = migrateRecord(
548
+ found.remedy,
549
+ found.record,
550
+ await readFile(path, 'utf8'),
551
+ )
552
+ return { record: found.record, remedy: found.remedy, path, outcome }
553
+ }),
554
+ )
555
+
556
+ const repaired: Repair[] = []
557
+ const refused: Refusal[] = []
558
+
559
+ settled.forEach((result, index) => {
560
+ if (result.status === 'rejected') {
561
+ refused.push({
562
+ record: candidates[index].record,
563
+ message: `could not be read: ${describeFailure(result.reason)}`,
564
+ })
565
+ return
566
+ }
567
+
568
+ const { record, remedy, path, outcome } = result.value
569
+
570
+ if (outcome.ok) {
571
+ repaired.push({ record, remedy, path, text: outcome.text })
572
+ } else {
573
+ refused.push({ record, message: outcome.message })
574
+ }
575
+ })
576
+
577
+ return { repaired, refused }
578
+ }
579
+
580
+ /**
581
+ * Writes every repair, independently. `Promise.allSettled` so one record's
582
+ * write failing does not abort the writes that would otherwise have
583
+ * succeeded, per the concurrency standard's rule on batched partial failure.
584
+ */
585
+ async function writeRepairs(
586
+ repaired: readonly Repair[],
587
+ ): Promise<{ written: Repair[]; failed: Refusal[] }> {
588
+ const settled = await Promise.allSettled(
589
+ repaired.map((entry) => writeFile(entry.path, entry.text, 'utf8')),
590
+ )
591
+
592
+ const written: Repair[] = []
593
+ const failed: Refusal[] = []
594
+
595
+ settled.forEach((result, index) => {
596
+ const entry = repaired[index]
597
+
598
+ if (result.status === 'fulfilled') {
599
+ written.push(entry)
600
+ } else {
601
+ failed.push({
602
+ record: entry.record,
603
+ message: `could not be written: ${describeFailure(result.reason)}`,
604
+ })
605
+ }
606
+ })
607
+
608
+ return { written, failed }
609
+ }
610
+
611
+ async function runMigrate(
612
+ kind: string,
613
+ opts: MigrateCommandOptions,
614
+ ): Promise<number> {
615
+ const emitJson = opts.json ?? false
616
+
617
+ if (!isRecordKind(kind)) {
618
+ return reportRefusal(
619
+ 'aitk records migrate',
620
+ {
621
+ reason: 'unknown-kind',
622
+ message: `Not a record kind: ${kind}. Expected one of: ${RECORD_KINDS.join(', ')}.`,
623
+ },
624
+ emitJson,
625
+ )
626
+ }
627
+
628
+ const root = opts.root ?? (await defaultRoot(kind))
629
+ const outcome = await validateRecords(root, kind)
630
+
631
+ if (!outcome.ok) {
632
+ return reportRefusal('aitk records migrate', outcome, emitJson)
633
+ }
634
+
635
+ const write = opts.write ?? false
636
+ const { repaired, refused } = await attemptMigrations(
637
+ recordsDir(root, kind),
638
+ outcome.findings,
639
+ )
640
+
641
+ if (!write) {
642
+ return reportMigrate(root, outcome.kind, repaired, refused, write, emitJson)
643
+ }
644
+
645
+ const { written, failed } = await writeRepairs(repaired)
646
+
647
+ return reportMigrate(
648
+ root,
649
+ outcome.kind,
650
+ written,
651
+ [...refused, ...failed],
652
+ write,
653
+ emitJson,
654
+ )
655
+ }
656
+
657
+ function reportMigrate(
658
+ root: string,
659
+ kind: RecordKind,
660
+ repaired: readonly Repair[],
661
+ refused: readonly Refusal[],
662
+ write: boolean,
663
+ emitJson: boolean,
664
+ ): number {
665
+ const total = repaired.length + refused.length
666
+
667
+ if (emitJson) {
668
+ process.stdout.write(
669
+ `${JSON.stringify({
670
+ ok: true,
671
+ root,
672
+ kind,
673
+ written: write,
674
+ migrated: repaired.map((entry) => entry.record),
675
+ refused,
676
+ })}\n`,
677
+ )
678
+ } else {
679
+ intro('aitk records migrate')
680
+
681
+ if (total === 0) {
682
+ logStep('Clean')
683
+ logInfo('no finding carries a known transform')
684
+ } else {
685
+ logStep(write ? 'Rewritten' : 'Would rewrite')
686
+ if (repaired.length === 0) {
687
+ logInfo('none, every candidate refused, see below')
688
+ } else {
689
+ for (const entry of repaired)
690
+ logInfo(`${entry.record}: ${entry.remedy}`)
691
+ }
692
+
693
+ if (refused.length > 0) {
694
+ logStep('Refused')
695
+ for (const entry of refused)
696
+ logWarn(`${entry.record}: ${entry.message}`)
697
+ }
698
+
699
+ if (!write && repaired.length > 0) {
700
+ logInfo('Re-run with --write to apply.')
701
+ }
702
+ }
703
+
704
+ outro()
705
+ }
706
+
707
+ return migrateExitCode(repaired, refused, write)
708
+ }
709
+
710
+ /**
711
+ * `repaired` carries only records a transform actually fixed, whether this
712
+ * is a dry run or the set `--write` wrote, so exit 2 (a write is available)
713
+ * never fires when every candidate refused. A dry run reaching that state
714
+ * previously returned the same code as a genuine offer to write, telling a
715
+ * caller `--write` would repair something when it would repair nothing.
716
+ */
717
+ export function migrateExitCode(
718
+ repaired: readonly Repair[],
719
+ refused: readonly Refusal[],
720
+ write: boolean,
721
+ ): number {
722
+ const total = repaired.length + refused.length
723
+
724
+ if (total === 0) return 0
725
+ if (repaired.length === 0) return 1
726
+ if (!write) return EXIT_MIGRATABLE
727
+ return refused.length > 0 ? 1 : 0
728
+ }
@@ -0,0 +1,78 @@
1
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
2
+ import {
3
+ CATEGORY_BY_TYPE,
4
+ type FindingRemedy,
5
+ MEMORY_NAME,
6
+ memoryType,
7
+ } from '@/records/validate'
8
+
9
+ export const MIGRATE_REFUSALS = ['field-unrecoverable'] as const
10
+
11
+ export type MigrateRefusal = (typeof MIGRATE_REFUSALS)[number]
12
+
13
+ export interface MigrateRepair {
14
+ readonly ok: true
15
+ readonly text: string
16
+ }
17
+
18
+ export interface MigrateRefused {
19
+ readonly ok: false
20
+ readonly reason: MigrateRefusal
21
+ readonly message: string
22
+ }
23
+
24
+ export type MigrateOutcome = MigrateRepair | MigrateRefused
25
+
26
+ function refuse(message: string): MigrateRefused {
27
+ return { ok: false, reason: 'field-unrecoverable', message }
28
+ }
29
+
30
+ /**
31
+ * Repairs the one recoverable shape of `frontmatter-incomplete`: a memory
32
+ * record missing `category` alone. The value sits in the filename's type
33
+ * prefix already, which is the same fact `checkMemory` derives it from, so a
34
+ * repair here writes back what the record already states rather than a value
35
+ * a transform invented. It re-reads the file rather than trusting a value
36
+ * carried on the `Finding`, so a transform and the check it repairs cannot
37
+ * drift apart.
38
+ */
39
+ function categoryFromName(name: string, text: string): MigrateOutcome {
40
+ const frontmatter = parseFrontmatter(text)
41
+ if (!frontmatter) {
42
+ return refuse(`${name} carries no frontmatter block to add category to.`)
43
+ }
44
+
45
+ if (readField(frontmatter, 'category')) {
46
+ return refuse(`${name} already carries a category.`)
47
+ }
48
+
49
+ const match = MEMORY_NAME.exec(name)
50
+ const type = match ? memoryType(match[1]) : undefined
51
+ if (!type) {
52
+ return refuse(
53
+ `${name} is not named <type>-<slug>.md, so category has no type to derive from.`,
54
+ )
55
+ }
56
+
57
+ const rewritten = frontmatter.raw.replace(
58
+ /\n---$/,
59
+ `\ncategory: ${CATEGORY_BY_TYPE[type]}\n---`,
60
+ )
61
+
62
+ return { ok: true, text: rewritten + text.slice(frontmatter.raw.length) }
63
+ }
64
+
65
+ const TRANSFORMS: Readonly<
66
+ Record<FindingRemedy, (name: string, text: string) => MigrateOutcome>
67
+ > = {
68
+ 'category-from-name': categoryFromName,
69
+ }
70
+
71
+ /** Applies the transform a finding's `remedy` names against its own text. */
72
+ export function migrateRecord(
73
+ remedy: FindingRemedy,
74
+ name: string,
75
+ text: string,
76
+ ): MigrateOutcome {
77
+ return TRANSFORMS[remedy](name, text)
78
+ }
@@ -72,12 +72,22 @@ export const FINDING_KINDS = [
72
72
 
73
73
  export type FindingKind = (typeof FINDING_KINDS)[number]
74
74
 
75
+ /**
76
+ * The transforms `migrate.ts` carries. Most finding kinds have none, since a
77
+ * transform is only safe where the old shape is recoverable from the file
78
+ * itself, so this stays optional on `Finding` rather than required.
79
+ */
80
+ export const FINDING_REMEDIES = ['category-from-name'] as const
81
+
82
+ export type FindingRemedy = (typeof FINDING_REMEDIES)[number]
83
+
75
84
  export interface Finding {
76
85
  readonly kind: FindingKind
77
86
  /** The record the finding sits in, relative to the validated folder. */
78
87
  readonly record: string
79
88
  readonly subject: string
80
89
  readonly message: string
90
+ readonly remedy?: FindingRemedy
81
91
  }
82
92
 
83
93
  export interface ValidateReport {
@@ -134,8 +144,9 @@ function finding(
134
144
  record: string,
135
145
  subject: string,
136
146
  message: string,
147
+ remedy?: FindingRemedy,
137
148
  ): Finding {
138
- return { kind, record, subject, message }
149
+ return { kind, record, subject, message, remedy }
139
150
  }
140
151
 
141
152
  async function listMarkdown(dir: string): Promise<string[]> {
@@ -748,26 +759,41 @@ const MEMORY_FIELDS = ['title', 'description', 'category'] as const
748
759
  * catches a prefix outside the set, a field disagreeing with the prefix, and a
749
760
  * casing drift that would open a second group in the catalog.
750
761
  */
751
- const CATEGORY_BY_TYPE = {
762
+ export const CATEGORY_BY_TYPE = {
752
763
  feedback: 'Feedback',
753
764
  project: 'Project',
754
765
  user: 'User',
755
766
  reference: 'Reference',
756
767
  } as const
757
768
 
758
- type MemoryType = keyof typeof CATEGORY_BY_TYPE
769
+ export type MemoryType = keyof typeof CATEGORY_BY_TYPE
759
770
 
760
771
  const MEMORY_TYPES = Object.keys(CATEGORY_BY_TYPE) as readonly MemoryType[]
761
772
 
762
- const MEMORY_NAME = /^([a-z]+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/
773
+ export const MEMORY_NAME = /^([a-z]+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/
763
774
 
764
775
  /** The two markers a rule-bearing body carries, on top of the rule line itself. */
765
776
  const MEMORY_MARKERS = ['**Why:**', '**How to apply:**'] as const
766
777
 
767
- function memoryType(value: string): MemoryType | undefined {
778
+ export function memoryType(value: string): MemoryType | undefined {
768
779
  return MEMORY_TYPES.find((type) => type === value)
769
780
  }
770
781
 
782
+ /**
783
+ * The one missing-field shape `migrate.ts` can repair: `category` alone,
784
+ * recoverable from the same filename prefix `checkMemory` already read it
785
+ * from. `title` and `description` are prose nobody wrote down, so a finding
786
+ * naming either carries no remedy.
787
+ */
788
+ function memoryRemedy(
789
+ missing: readonly string[],
790
+ named: MemoryType | undefined,
791
+ ): FindingRemedy | undefined {
792
+ return named && missing.length === 1 && missing[0] === 'category'
793
+ ? 'category-from-name'
794
+ : undefined
795
+ }
796
+
771
797
  export function checkMemory(name: string, text: string): Finding[] {
772
798
  const findings: Finding[] = []
773
799
  const match = MEMORY_NAME.exec(name)
@@ -796,6 +822,7 @@ export function checkMemory(name: string, text: string): Finding[] {
796
822
  name,
797
823
  name,
798
824
  `carries no ${missing.join(' and no ')}.`,
825
+ memoryRemedy(missing, named),
799
826
  ),
800
827
  )
801
828
  }