@erclx/aitk 3.12.0 → 3.13.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-groundwork/REQUIREMENT.md +1 -0
- package/claude/skills/claude-groundwork/SKILL.md +12 -12
- package/claude/skills/claude-intake/REQUIREMENT.md +1 -0
- package/claude/skills/claude-intake/SKILL.md +7 -7
- package/docs/agents/install-and-sync.md +55 -28
- package/docs/agents/intake.md +3 -1
- package/docs/ai-workflow.md +2 -2
- package/docs/target-projects.md +7 -5
- package/governance/rules/claude/556-groundwork.md +5 -1
- package/governance/rules/claude/557-intake.md +5 -1
- package/governance/rules/lang/120-bash.md +18 -0
- package/governance/rules/lib/305-e2e-reliability.md +34 -0
- package/governance/rules/lib/306-test-scope.md +25 -0
- package/governance/rules/lib/350-security-web.md +1 -0
- package/governance/rules/ui/450-link-behavior.md +19 -0
- package/governance/stacks/astro.toml +1 -1
- package/governance/stacks/base.toml +1 -1
- package/governance/stacks/react.toml +1 -1
- package/package.json +1 -1
- package/src/commands/gov.ts +10 -1
- package/src/commands/sync.ts +18 -5
- package/src/demo/compile.ts +1 -1
- package/src/gov/adapter.ts +29 -0
- package/src/gov/install.ts +22 -1
- package/src/gov/stacks.ts +33 -1
- package/src/intake/folder.ts +49 -9
- package/src/sync/check.ts +53 -15
- package/src/sync/engine.ts +25 -0
- package/src/sync/stamp.ts +73 -24
- package/src/tooling/stamp.ts +1 -1
- package/standards/groundwork.md +10 -3
- package/standards/index.md +2 -2
- package/standards/intake.md +10 -3
- package/tooling/web/manifest.toml +1 -0
- package/tooling/web/reference.md +1 -0
package/src/demo/compile.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Beat, Draft } from '@/demo/beats'
|
|
|
5
5
|
* separate artifacts on purpose: a beat carries no target, no wait condition,
|
|
6
6
|
* and no timing, and putting those four fields on every beat would destroy the
|
|
7
7
|
* property the draft was designed around. See
|
|
8
|
-
* `.claude/groundwork/demo-recorder/06-decision.md`.
|
|
8
|
+
* `.claude/groundwork/38-demo-recorder/06-decision.md`.
|
|
9
9
|
*
|
|
10
10
|
* A compiled plan is committed rather than scratch, because the timing below is
|
|
11
11
|
* a starting point the operator tunes and the draft cannot reproduce a tuned
|
package/src/gov/adapter.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { resolveMissingRules } from '@/gov/stacks'
|
|
3
4
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
5
|
+
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
4
6
|
|
|
5
7
|
const RETIRED_GOV_FILE = join('.claude', 'GOV.md')
|
|
6
8
|
|
|
@@ -54,11 +56,38 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
54
56
|
locateSource: (file: InstalledFile) =>
|
|
55
57
|
index.get(basename(file.path, '.md')),
|
|
56
58
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
59
|
+
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
57
60
|
projectSubdir: 'project',
|
|
58
61
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Rules the target's recorded chain entitles it to and its tree does not
|
|
67
|
+
* hold. Reports as `notice` text through the same shape `collectRetired`
|
|
68
|
+
* already returns, since both are surfaces the file walk cannot see: one an
|
|
69
|
+
* absence to remove, this one an absence to add.
|
|
70
|
+
*/
|
|
71
|
+
function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
72
|
+
const chain = stampedChain(readStamp(target), 'governance')
|
|
73
|
+
|
|
74
|
+
return resolveMissingRules(root, target, chain).map((source) => {
|
|
75
|
+
const dest = join(
|
|
76
|
+
target,
|
|
77
|
+
'.claude',
|
|
78
|
+
'rules',
|
|
79
|
+
source.subdir,
|
|
80
|
+
`${source.rule}.md`,
|
|
81
|
+
)
|
|
82
|
+
const rel = relative(target, dest)
|
|
83
|
+
return {
|
|
84
|
+
path: dest,
|
|
85
|
+
rel,
|
|
86
|
+
notice: `${rel} (listed by ${chain[0]}, not installed. Run aitk gov install ${chain[0]} to add it.)`,
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
62
91
|
function collectRetiredGov(target: string): RetiredSurface[] {
|
|
63
92
|
const path = join(target, RETIRED_GOV_FILE)
|
|
64
93
|
if (!existsSync(path)) return []
|
package/src/gov/install.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { dirname, join, relative } from 'node:path'
|
|
2
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
3
3
|
import { copyPreservingMode } from '@/copy'
|
|
4
4
|
|
|
5
5
|
export interface RuleSource {
|
|
@@ -21,6 +21,27 @@ export function installedRulesDir(target: string): string {
|
|
|
21
21
|
return join(target, '.claude', 'rules')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Rule names a target already holds, read off the installed tree by basename
|
|
26
|
+
* rather than off a recorded stack, since a target may hold rules `--add`
|
|
27
|
+
* layered on that no stack lists.
|
|
28
|
+
*/
|
|
29
|
+
export function installedRuleNames(target: string): Set<string> {
|
|
30
|
+
const dir = installedRulesDir(target)
|
|
31
|
+
const names = new Set<string>()
|
|
32
|
+
if (!existsSync(dir)) return names
|
|
33
|
+
|
|
34
|
+
for (const rel of new Bun.Glob('**/*.md').scanSync({
|
|
35
|
+
cwd: dir,
|
|
36
|
+
onlyFiles: true,
|
|
37
|
+
dot: true,
|
|
38
|
+
})) {
|
|
39
|
+
names.add(basename(rel, '.md'))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return names
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/**
|
|
25
46
|
* Mirrors `rule_subdir` in `scripts/lib/gov.sh`, which stays in bash for the
|
|
26
47
|
* sandbox loops. A rule sitting directly under `governance/rules/` has no
|
package/src/gov/stacks.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
installedRuleNames,
|
|
5
|
+
listRuleSourcePaths,
|
|
6
|
+
lookupRules,
|
|
7
|
+
type RuleSource,
|
|
8
|
+
rulesSourceDir,
|
|
9
|
+
} from '@/gov/install'
|
|
4
10
|
|
|
5
11
|
export interface GovStack {
|
|
6
12
|
readonly name: string
|
|
@@ -151,6 +157,32 @@ export function unreferencedRules(root: string): string[] {
|
|
|
151
157
|
.sort()
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Rules the target's recorded chain entitles it to that its installed tree
|
|
162
|
+
* does not hold. `resolveRules` already walks a stack's `extends` ancestors,
|
|
163
|
+
* so reading its leaf entry is enough; no second walk resolves the chain
|
|
164
|
+
* itself. A stack the toolkit no longer ships resolves to nothing rather than
|
|
165
|
+
* throwing, the same way `readNewRules`'s band fallback already treats it.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveMissingRules(
|
|
168
|
+
root: string,
|
|
169
|
+
target: string,
|
|
170
|
+
chain: readonly string[],
|
|
171
|
+
): readonly RuleSource[] {
|
|
172
|
+
const stack = chain[0]
|
|
173
|
+
if (stack === undefined) return []
|
|
174
|
+
|
|
175
|
+
const resolution = resolveRules(root, stack)
|
|
176
|
+
if (!resolution.ok) return []
|
|
177
|
+
|
|
178
|
+
const { found } = lookupRules(root, resolution.rules)
|
|
179
|
+
const held = installedRuleNames(target)
|
|
180
|
+
|
|
181
|
+
return found
|
|
182
|
+
.filter((source) => !held.has(source.rule))
|
|
183
|
+
.sort((left, right) => left.rule.localeCompare(right.rule))
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
/**
|
|
155
187
|
* Layers `--add` names on top of a resolved stack. The bash trimmed a single
|
|
156
188
|
* leading and trailing space per entry; trimming fully is the same result for
|
package/src/intake/folder.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative } from 'node:path'
|
|
3
|
+
import { basename, join, relative } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
INDEX_FILE,
|
|
6
6
|
type IntakeItem,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
export const INTAKE_REFUSALS = [
|
|
14
14
|
'no-intake',
|
|
15
15
|
'no-folder',
|
|
16
|
+
'ambiguous-slug',
|
|
16
17
|
'no-cluster',
|
|
17
18
|
'no-item',
|
|
18
19
|
'answered',
|
|
@@ -110,6 +111,36 @@ async function listSlugs(dir: string): Promise<string[]> {
|
|
|
110
111
|
.sort()
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
type SlugMatch =
|
|
115
|
+
| { readonly kind: 'matched'; readonly name: string }
|
|
116
|
+
| { readonly kind: 'ambiguous'; readonly names: readonly string[] }
|
|
117
|
+
| { readonly kind: 'none' }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A folder carries a `<nn>-<slug>` name, but a caller names the topic alone.
|
|
121
|
+
* An exact match wins first, since it is what a name with no ordinal, or one
|
|
122
|
+
* already copied in full from a listing, resolves against. Otherwise the one
|
|
123
|
+
* entry whose name is an ordinal ahead of the given slug wins, which is what
|
|
124
|
+
* lets a topic keep working as its folder's identity gains a prefix. Two or
|
|
125
|
+
* more such entries is a collision the caller needs told apart from a typo,
|
|
126
|
+
* not a folder silently picked or silently missing.
|
|
127
|
+
*/
|
|
128
|
+
function matchSlug(names: readonly string[], slug: string): SlugMatch {
|
|
129
|
+
if (names.includes(slug)) return { kind: 'matched', name: slug }
|
|
130
|
+
|
|
131
|
+
const suffixed = names.filter(
|
|
132
|
+
(name) => name === `${extractOrdinal(name)}-${slug}`,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if (suffixed.length === 1) return { kind: 'matched', name: suffixed[0] }
|
|
136
|
+
if (suffixed.length > 1) return { kind: 'ambiguous', names: suffixed }
|
|
137
|
+
return { kind: 'none' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractOrdinal(name: string): string {
|
|
141
|
+
return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
async function openFolder(
|
|
114
145
|
root: string,
|
|
115
146
|
slug: string,
|
|
@@ -120,17 +151,22 @@ async function openFolder(
|
|
|
120
151
|
return refuse('no-intake', `No intake at ${relative(root, dir)}.`)
|
|
121
152
|
}
|
|
122
153
|
|
|
123
|
-
const
|
|
154
|
+
const names = await listSlugs(dir)
|
|
155
|
+
const match = matchSlug(names, slug)
|
|
156
|
+
|
|
157
|
+
if (match.kind === 'none') {
|
|
158
|
+
return refuse('no-folder', `No intake folder named ${slug}.`, names)
|
|
159
|
+
}
|
|
124
160
|
|
|
125
|
-
if (
|
|
161
|
+
if (match.kind === 'ambiguous') {
|
|
126
162
|
return refuse(
|
|
127
|
-
'
|
|
128
|
-
`
|
|
129
|
-
|
|
163
|
+
'ambiguous-slug',
|
|
164
|
+
`More than one intake folder matches ${slug}.`,
|
|
165
|
+
match.names,
|
|
130
166
|
)
|
|
131
167
|
}
|
|
132
168
|
|
|
133
|
-
return
|
|
169
|
+
return join(dir, match.name)
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
/** Counts per folder, which is what a session picks a folder to work from. */
|
|
@@ -180,7 +216,11 @@ export async function readFolder(
|
|
|
180
216
|
const opened = await openFolder(root, slug)
|
|
181
217
|
if (typeof opened !== 'string') return opened
|
|
182
218
|
|
|
183
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
slug: basename(opened),
|
|
222
|
+
clusters: await readClusters(opened),
|
|
223
|
+
}
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
/**
|
|
@@ -272,7 +312,7 @@ export async function answerItems(
|
|
|
272
312
|
|
|
273
313
|
return {
|
|
274
314
|
ok: true,
|
|
275
|
-
slug,
|
|
315
|
+
slug: basename(opened),
|
|
276
316
|
cluster: name,
|
|
277
317
|
path,
|
|
278
318
|
answered: selections,
|
package/src/sync/check.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, join, sep } from 'node:path'
|
|
|
3
3
|
import { execa } from 'execa'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
5
|
import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
|
|
6
|
-
import { loadGovStack } from '@/gov/stacks'
|
|
6
|
+
import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
|
|
7
7
|
import { createSnippetsAdapter } from '@/snippets/adapter'
|
|
8
8
|
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
9
9
|
import {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from '@/sync/reverse'
|
|
20
20
|
import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
|
|
21
21
|
import {
|
|
22
|
+
isLegacyStamped,
|
|
22
23
|
readStamp,
|
|
23
24
|
type Stamp,
|
|
24
25
|
stampedChain,
|
|
@@ -71,6 +72,7 @@ export interface StateCounts {
|
|
|
71
72
|
readonly drifted: number
|
|
72
73
|
readonly orphaned: number
|
|
73
74
|
readonly stranded: number
|
|
75
|
+
readonly missing: number
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export interface DomainReport {
|
|
@@ -139,6 +141,12 @@ const UNMEASURED_TOOLING: ToolingReport = {
|
|
|
139
141
|
|
|
140
142
|
export interface CheckReport {
|
|
141
143
|
readonly covers: readonly StampDomain[]
|
|
144
|
+
/**
|
|
145
|
+
* True when the stamp `readStamp` found sits at the retired
|
|
146
|
+
* `.claude/aitk.json` path rather than the current one. Read only: nothing
|
|
147
|
+
* in the check migrates a target's config as a side effect of reporting it.
|
|
148
|
+
*/
|
|
149
|
+
readonly stampAtLegacyPath: boolean
|
|
142
150
|
/** False when the target is not a toolkit project, so every section stays empty. */
|
|
143
151
|
readonly managed: boolean
|
|
144
152
|
readonly domains: readonly DomainReport[]
|
|
@@ -204,7 +212,7 @@ export function buildToolingReport(
|
|
|
204
212
|
target: string,
|
|
205
213
|
stamp: Stamp | undefined,
|
|
206
214
|
): ToolingReport {
|
|
207
|
-
const chain = stampedChain(stamp)
|
|
215
|
+
const chain = stampedChain(stamp, 'tooling')
|
|
208
216
|
const manifests = chain
|
|
209
217
|
.map((name) => loadManifest(toolkitRoot, name))
|
|
210
218
|
.filter((manifest) => manifest !== undefined)
|
|
@@ -270,6 +278,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
270
278
|
drifted: count(entries, 'drifted'),
|
|
271
279
|
orphaned: count(entries, 'orphaned'),
|
|
272
280
|
stranded: count(entries, 'stranded'),
|
|
281
|
+
missing: count(entries, 'missing'),
|
|
273
282
|
}
|
|
274
283
|
}
|
|
275
284
|
|
|
@@ -293,6 +302,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
293
302
|
* a file the project may own. `detectUnmigrated` already shipped that exact
|
|
294
303
|
* false positive once, failing a push with no action that cleared it, and a
|
|
295
304
|
* walk that reports `unattributed` by design would repeat it.
|
|
305
|
+
*
|
|
306
|
+
* `missing` is excluded on the same grounds `newRules` already reports on: a
|
|
307
|
+
* sync that adds a rule silently changes what a project is governed by, and
|
|
308
|
+
* nobody chose that, so gating CI on the count would pressure a target into
|
|
309
|
+
* adopting a rule nobody picked.
|
|
296
310
|
*/
|
|
297
311
|
export function hasDrift(report: CheckReport): boolean {
|
|
298
312
|
if (report.unmigrated.length > 0) return true
|
|
@@ -338,6 +352,7 @@ export async function buildCheckReport(
|
|
|
338
352
|
if (!managed) {
|
|
339
353
|
return {
|
|
340
354
|
covers: [],
|
|
355
|
+
stampAtLegacyPath: isLegacyStamped(target),
|
|
341
356
|
managed,
|
|
342
357
|
domains: [],
|
|
343
358
|
tooling: UNMEASURED_TOOLING,
|
|
@@ -353,6 +368,7 @@ export async function buildCheckReport(
|
|
|
353
368
|
|
|
354
369
|
return {
|
|
355
370
|
covers: stamp?.covers ?? [],
|
|
371
|
+
stampAtLegacyPath: isLegacyStamped(target),
|
|
356
372
|
managed,
|
|
357
373
|
domains,
|
|
358
374
|
tooling: buildToolingReport(toolkitRoot, target, stamp),
|
|
@@ -360,11 +376,7 @@ export async function buildCheckReport(
|
|
|
360
376
|
superseded: collectSuperseded(target),
|
|
361
377
|
unmigrated,
|
|
362
378
|
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
363
|
-
newRules: await readNewRules(
|
|
364
|
-
toolkitRoot,
|
|
365
|
-
target,
|
|
366
|
-
stampedCommit(stamp, 'governance'),
|
|
367
|
-
),
|
|
379
|
+
newRules: await readNewRules(toolkitRoot, target, stamp),
|
|
368
380
|
reverse: buildReverseReport(toolkitRoot, target),
|
|
369
381
|
skew: await skewRead,
|
|
370
382
|
}
|
|
@@ -529,14 +541,23 @@ export function baseBands(root: string): Set<string> {
|
|
|
529
541
|
}
|
|
530
542
|
|
|
531
543
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
544
|
+
* A recorded chain answers this without the anchor at all: `resolveMissingRules`
|
|
545
|
+
* compares the entitled set against what the target holds right now, so a rule
|
|
546
|
+
* that shipped before the target's anchor is not a permanent blind spot the
|
|
547
|
+
* way the diff below leaves it. This is the primary path, and it is also what
|
|
548
|
+
* `collectMissing` reports per file through the domain scan, so a rule the
|
|
549
|
+
* chain names and the target lacks reaches both surfaces the same way.
|
|
550
|
+
*
|
|
551
|
+
* The diff-and-bands path stays as the fallback for a target stamped before
|
|
552
|
+
* governance recorded a chain. Rules are domain-scoped there too, so it
|
|
553
|
+
* measures from governance's own anchor rather than from the oldest anchor
|
|
554
|
+
* across domains the way `readNewSkills` does. A shared anchor would let a
|
|
555
|
+
* snippets sync move the revision rules are measured from and drop a rule out
|
|
556
|
+
* of the read.
|
|
536
557
|
*
|
|
537
|
-
* A target carrying no governance anchor reports nothing. It has
|
|
538
|
-
* measure against, and diffing from the beginning of history would
|
|
539
|
-
* rule the toolkit ships as new.
|
|
558
|
+
* A target carrying no chain and no governance anchor reports nothing. It has
|
|
559
|
+
* no date to measure against, and diffing from the beginning of history would
|
|
560
|
+
* read every rule the toolkit ships as new.
|
|
540
561
|
*
|
|
541
562
|
* An anchor this clone cannot resolve reports nothing by a different route and
|
|
542
563
|
* says so nowhere. `read` yields an empty string on a non-zero exit, so a stamp
|
|
@@ -544,12 +565,29 @@ export function baseBands(root: string): Set<string> {
|
|
|
544
565
|
* as a target holding everything. `readNewSkills` carries the same gap, and
|
|
545
566
|
* neither has the `historyUnavailable` flag the per-domain scan uses to tell an
|
|
546
567
|
* unmeasured result from a clean one.
|
|
568
|
+
*
|
|
569
|
+
* A recorded chain naming a stack the toolkit no longer ships falls through to
|
|
570
|
+
* the band-based path below rather than reporting the empty list an
|
|
571
|
+
* unresolved chain would otherwise produce. That empty list reads exactly
|
|
572
|
+
* like a target holding everything, which is the same failure this function
|
|
573
|
+
* exists to close, so a retired stack name is read the same as no chain at
|
|
574
|
+
* all instead of reintroducing it.
|
|
547
575
|
*/
|
|
548
576
|
export async function readNewRules(
|
|
549
577
|
root: string,
|
|
550
578
|
target: string,
|
|
551
|
-
|
|
579
|
+
stamp: Stamp | undefined,
|
|
552
580
|
): Promise<string[]> {
|
|
581
|
+
const chain = stampedChain(stamp, 'governance')
|
|
582
|
+
const stack = chain[0]
|
|
583
|
+
|
|
584
|
+
if (stack !== undefined && resolveRules(root, stack).ok) {
|
|
585
|
+
return resolveMissingRules(root, target, chain)
|
|
586
|
+
.map((source) => source.rule)
|
|
587
|
+
.sort()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const since = stampedCommit(stamp, 'governance')
|
|
553
591
|
if (since === undefined) return []
|
|
554
592
|
|
|
555
593
|
const paths = await read(root, [
|
package/src/sync/engine.ts
CHANGED
|
@@ -61,6 +61,10 @@ export type SyncChange =
|
|
|
61
61
|
* separate because they need opposite treatment. A project-authored file is
|
|
62
62
|
* orphaned and stays that way forever. A stamped file the toolkit no longer
|
|
63
63
|
* installs to is stranded, which is a relocation waiting on a decision.
|
|
64
|
+
*
|
|
65
|
+
* `missing` is the one state the walk cannot produce on its own, since the
|
|
66
|
+
* walk only iterates files that exist. It comes from `collectMissing`
|
|
67
|
+
* instead, an adapter naming an entitled file the target does not hold.
|
|
64
68
|
*/
|
|
65
69
|
export type EntryState =
|
|
66
70
|
| 'matching'
|
|
@@ -69,12 +73,19 @@ export type EntryState =
|
|
|
69
73
|
| 'drifted'
|
|
70
74
|
| 'orphaned'
|
|
71
75
|
| 'stranded'
|
|
76
|
+
| 'missing'
|
|
72
77
|
|
|
73
78
|
export interface ScanEntry {
|
|
74
79
|
readonly state: EntryState
|
|
75
80
|
readonly rel: string
|
|
76
81
|
/** Toolkit revision this file's content came from, when history proved it. */
|
|
77
82
|
readonly since?: string
|
|
83
|
+
/**
|
|
84
|
+
* Overrides `report`'s generic text for this entry's state. `collectMissing`
|
|
85
|
+
* is the one producer: a stack name is only known to the adapter that
|
|
86
|
+
* resolved it, and the generic `missing` line cannot carry one.
|
|
87
|
+
*/
|
|
88
|
+
readonly notice?: string
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
export interface SyncPlan {
|
|
@@ -119,6 +130,12 @@ export interface SyncAdapter {
|
|
|
119
130
|
locateSource(file: InstalledFile): string | undefined
|
|
120
131
|
/** Surfaces the file walk cannot see, such as a retired doc to delete. */
|
|
121
132
|
collectRetired?(target: string): RetiredSurface[]
|
|
133
|
+
/**
|
|
134
|
+
* Entitled files the walk cannot see because they do not exist yet.
|
|
135
|
+
* Reported as `missing` and queued as no change, since installing one
|
|
136
|
+
* changes what the project is governed by and stays a separate command.
|
|
137
|
+
*/
|
|
138
|
+
collectMissing?(target: string): RetiredSurface[]
|
|
122
139
|
/** Dropped from the walk, so neither matching nor orphaned. */
|
|
123
140
|
isExcluded?(file: InstalledFile): boolean
|
|
124
141
|
/**
|
|
@@ -210,6 +227,10 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
210
227
|
|
|
211
228
|
entries.push(...strandedByRelocation(target, hashes, walked))
|
|
212
229
|
|
|
230
|
+
for (const surface of adapter.collectMissing?.(target) ?? []) {
|
|
231
|
+
entries.push({ state: 'missing', rel: surface.rel, notice: surface.notice })
|
|
232
|
+
}
|
|
233
|
+
|
|
213
234
|
const retired = adapter.collectRetired?.(target) ?? []
|
|
214
235
|
for (const surface of retired) {
|
|
215
236
|
changes.push({ kind: 'delete', dest: surface.path, rel: surface.rel })
|
|
@@ -394,6 +415,10 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
394
415
|
logWarn(`${entry.rel} (locally customized)`)
|
|
395
416
|
else if (entry.state === 'stranded')
|
|
396
417
|
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
418
|
+
else if (entry.state === 'missing')
|
|
419
|
+
logWarn(
|
|
420
|
+
entry.notice ?? `${entry.rel} (listed by the stack, not installed)`,
|
|
421
|
+
)
|
|
397
422
|
else logWarn(`${entry.rel} (not in toolkit source, skipping)`)
|
|
398
423
|
}
|
|
399
424
|
|
package/src/sync/stamp.ts
CHANGED
|
@@ -38,10 +38,12 @@ export interface DomainStamp {
|
|
|
38
38
|
readonly syncedAt: string
|
|
39
39
|
readonly files: DomainHashes
|
|
40
40
|
/**
|
|
41
|
-
* Stack names the install resolved
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* Stack names the install resolved. Tooling records the full ancestor chain,
|
|
42
|
+
* nearest stack first, because a stack that extends another cannot be
|
|
43
|
+
* reinstalled from its leaf alone, and a `--skip` run installs fewer layers
|
|
44
|
+
* than the leaf's own chain would reproduce. Governance records the single
|
|
45
|
+
* stack `aitk gov install` was given, since `resolveRules` walks its
|
|
46
|
+
* ancestors internally and a reader needs only the leaf to ask it again.
|
|
45
47
|
*/
|
|
46
48
|
readonly chain?: readonly string[]
|
|
47
49
|
}
|
|
@@ -56,6 +58,24 @@ export function stampPath(target: string): string {
|
|
|
56
58
|
return join(target, '.claude', 'aitk', 'config.json')
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Where `106115ba` moved the stamp from. No migration shipped with that move,
|
|
63
|
+
* so a target stamped before it still carries its config here, and `readStamp`
|
|
64
|
+
* falls back to this path when the current one is absent.
|
|
65
|
+
*/
|
|
66
|
+
export function legacyStampPath(target: string): string {
|
|
67
|
+
return join(target, '.claude', 'aitk.json')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `readStamp` would resolve to the retired path, so a caller can
|
|
72
|
+
* report that a target's config still sits there. False when neither path
|
|
73
|
+
* exists, since there is nothing to migrate off of.
|
|
74
|
+
*/
|
|
75
|
+
export function isLegacyStamped(target: string): boolean {
|
|
76
|
+
return !existsSync(stampPath(target)) && existsSync(legacyStampPath(target))
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
export function hashContent(content: Buffer | string): string {
|
|
60
80
|
return `sha256:${createHash('sha256').update(content).digest('hex')}`
|
|
61
81
|
}
|
|
@@ -75,9 +95,22 @@ export function toStampKey(rel: string): string {
|
|
|
75
95
|
/**
|
|
76
96
|
* A missing or corrupt stamp reads as absent rather than failing, which is what
|
|
77
97
|
* keeps every unstamped target on the existing unattributed path.
|
|
98
|
+
*
|
|
99
|
+
* Falls back to the retired path only when the current one does not exist,
|
|
100
|
+
* read only: nothing here migrates a target's config as a side effect of a
|
|
101
|
+
* report. The check is existence rather than a successful parse, so a
|
|
102
|
+
* corrupt current stamp reads as absent rather than silently serving the
|
|
103
|
+
* retired one beside it. `isLegacyStamped` tests the same existence check,
|
|
104
|
+
* which is what keeps the two agreeing on which path a corrupt current file
|
|
105
|
+
* was read from.
|
|
78
106
|
*/
|
|
79
107
|
export function readStamp(target: string): Stamp | undefined {
|
|
80
|
-
|
|
108
|
+
return existsSync(stampPath(target))
|
|
109
|
+
? readStampFile(stampPath(target))
|
|
110
|
+
: readStampFile(legacyStampPath(target))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readStampFile(path: string): Stamp | undefined {
|
|
81
114
|
if (!existsSync(path)) return undefined
|
|
82
115
|
|
|
83
116
|
try {
|
|
@@ -105,17 +138,22 @@ export function stampedHashes(
|
|
|
105
138
|
}
|
|
106
139
|
|
|
107
140
|
/**
|
|
108
|
-
* The stack chain
|
|
109
|
-
* target predating
|
|
110
|
-
* unmeasured rather than as clean.
|
|
141
|
+
* The stack chain a domain's install last recorded. An empty result is the
|
|
142
|
+
* state every target predating that domain's chain recording sits in, and a
|
|
143
|
+
* reader treats it as unmeasured rather than as clean.
|
|
111
144
|
*/
|
|
112
|
-
export function stampedChain(
|
|
113
|
-
|
|
145
|
+
export function stampedChain(
|
|
146
|
+
stamp: Stamp | undefined,
|
|
147
|
+
domain: StampDomain,
|
|
148
|
+
): readonly string[] {
|
|
149
|
+
return stamp?.domains[domain]?.chain ?? []
|
|
114
150
|
}
|
|
115
151
|
|
|
116
152
|
/**
|
|
117
|
-
* Replaces one domain's
|
|
118
|
-
* install and sync independently but
|
|
153
|
+
* Replaces one domain's file hashes and leaves the others, including that
|
|
154
|
+
* domain's own chain, untouched. Domains install and sync independently but
|
|
155
|
+
* share the one file, and a chain an install recorded is a separate fact a
|
|
156
|
+
* later file-only sync must not erase.
|
|
119
157
|
*/
|
|
120
158
|
export async function writeStamp(
|
|
121
159
|
target: string,
|
|
@@ -127,37 +165,35 @@ export async function writeStamp(
|
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
/**
|
|
130
|
-
* Records
|
|
131
|
-
*
|
|
132
|
-
* the chain is
|
|
168
|
+
* Records the stack chain an install resolved and leaves that domain's file
|
|
169
|
+
* hashes untouched. Tooling calls this with no files ever recorded, since
|
|
170
|
+
* `src/tooling/` never runs the sync engine and the chain is its whole record.
|
|
171
|
+
* Governance calls it alongside `writeStamp`, since it records both.
|
|
133
172
|
*/
|
|
134
173
|
export async function writeChainStamp(
|
|
135
174
|
target: string,
|
|
136
|
-
|
|
175
|
+
source: StampSource,
|
|
137
176
|
chain: readonly string[],
|
|
138
177
|
now: Date,
|
|
139
178
|
): Promise<void> {
|
|
140
|
-
await putDomain(
|
|
141
|
-
target,
|
|
142
|
-
{ domain: 'tooling', toolkitRoot },
|
|
143
|
-
{ files: {}, chain: [...chain] },
|
|
144
|
-
now,
|
|
145
|
-
)
|
|
179
|
+
await putDomain(target, source, { chain: [...chain] }, now)
|
|
146
180
|
}
|
|
147
181
|
|
|
148
182
|
async function putDomain(
|
|
149
183
|
target: string,
|
|
150
184
|
source: StampSource,
|
|
151
|
-
payload: Pick<DomainStamp, 'files' | 'chain'
|
|
185
|
+
payload: Partial<Pick<DomainStamp, 'files' | 'chain'>>,
|
|
152
186
|
now: Date,
|
|
153
187
|
): Promise<void> {
|
|
154
188
|
const previous = readStamp(target)
|
|
189
|
+
const previousRecord = previous?.domains[source.domain]
|
|
155
190
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
156
191
|
|
|
157
192
|
const record: DomainStamp = {
|
|
158
193
|
...(commit === undefined ? {} : { commit }),
|
|
159
194
|
syncedAt: now.toISOString(),
|
|
160
|
-
|
|
195
|
+
files: payload.files ?? previousRecord?.files ?? {},
|
|
196
|
+
...resolveChainField(payload.chain, previousRecord?.chain),
|
|
161
197
|
}
|
|
162
198
|
|
|
163
199
|
const domains = sortDomains({
|
|
@@ -203,6 +239,19 @@ async function readCommit(root: string): Promise<string | undefined> {
|
|
|
203
239
|
: undefined
|
|
204
240
|
}
|
|
205
241
|
|
|
242
|
+
/**
|
|
243
|
+
* A write naming no chain keeps the domain's previous one rather than dropping
|
|
244
|
+
* it, since `writeStamp` and `writeChainStamp` each touch one half of a
|
|
245
|
+
* governance record and neither should erase what the other wrote.
|
|
246
|
+
*/
|
|
247
|
+
function resolveChainField(
|
|
248
|
+
chain: readonly string[] | undefined,
|
|
249
|
+
previous: readonly string[] | undefined,
|
|
250
|
+
): Pick<DomainStamp, 'chain'> {
|
|
251
|
+
const resolved = chain ?? previous
|
|
252
|
+
return resolved === undefined ? {} : { chain: resolved }
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
/** Deterministic key order keeps a re-sync diff empty and a merge conflict local. */
|
|
207
256
|
function sortKeys(hashes: DomainHashes): DomainHashes {
|
|
208
257
|
return Object.fromEntries(
|
package/src/tooling/stamp.ts
CHANGED
package/standards/groundwork.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Groundwork reference
|
|
3
|
-
description: Folder layout, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
|
|
3
|
+
description: Folder layout, ordinal naming, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Groundwork reference
|
|
7
7
|
|
|
8
|
-
Applies to a groundwork track at `.claude/groundwork/<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
|
|
8
|
+
Applies to a groundwork track at `.claude/groundwork/<nn>-<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering inside the folder is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
|
|
9
9
|
|
|
10
10
|
The folder is gitignored and unbacked. No check reaches its contents and no history recovers a deleted one, so every rule here holds only while a session reads it, and the handoff file has to be self-contained.
|
|
11
11
|
|
|
12
12
|
## Scope
|
|
13
13
|
|
|
14
|
-
Governs a groundwork track under `.claude/groundwork/<slug>/`: folder layout, reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps.
|
|
14
|
+
Governs a groundwork track under `.claude/groundwork/<nn>-<slug>/`: folder layout, ordinal naming, reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps.
|
|
15
15
|
|
|
16
16
|
Does not govern:
|
|
17
17
|
|
|
@@ -22,6 +22,13 @@ Does not govern:
|
|
|
22
22
|
- Headings, punctuation, word choice, and file references: `markdown.md`
|
|
23
23
|
- When a project opens a track at all, and the procedure that runs one, which belong to the surface driving it
|
|
24
24
|
|
|
25
|
+
## Folder name
|
|
26
|
+
|
|
27
|
+
- Name the folder `<nn>-<slug>`, a two-digit zero-padded ordinal followed by a kebab-case slug.
|
|
28
|
+
- Take the ordinal from the highest one already present across both `.claude/groundwork/` and `.claude/intake/`, incremented. A listing then sorts by when each folder opened rather than alphabetically, and the count includes both kinds because the two share one creation-order line.
|
|
29
|
+
- With neither folder holding an entry, the first one opened takes `01`. Do not read this off the numbering inside a track, which starts at `00` on a large one and disagrees with intake's own first file.
|
|
30
|
+
- Never renumber an existing folder. The ordinal is the order it opened, and a later reader cites it by that name.
|
|
31
|
+
|
|
25
32
|
## What a working track looks like
|
|
26
33
|
|
|
27
34
|
A track works when a session that has never seen it re-enters from the folder alone and can answer each of these:
|