@erclx/aitk 0.24.0 → 0.25.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/setup-init/SKILL.md +1 -1
- package/docs/agents.md +11 -3
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/commands/sync.ts +5 -1
- package/src/sync/check.ts +6 -0
- package/src/sync/engine.ts +73 -3
- package/src/sync/history.ts +123 -0
|
@@ -63,7 +63,7 @@ Before executing, output:
|
|
|
63
63
|
|
|
64
64
|
## Execute
|
|
65
65
|
|
|
66
|
-
Run the chain in order. Each step's permission dialog is the confirmation gate. Run from the target project's current directory.
|
|
66
|
+
Run the chain in order, starting immediately after the preview. Each step's permission dialog is the confirmation gate. Do not pause for additional confirmation. Run from the target project's current directory.
|
|
67
67
|
|
|
68
68
|
Step 1: `aitk init` installs base tooling, claude seeds, governance rules, standards, snippets, and wiki.
|
|
69
69
|
|
package/docs/agents.md
CHANGED
|
@@ -163,8 +163,16 @@ matches what the toolkit installed, `customized` when the project edited it,
|
|
|
163
163
|
when the project authored it, or `drifted` when no stamp covers it. Use `--json`
|
|
164
164
|
for the machine-readable report and `--exit-code` to fail a CI job. Orphaned
|
|
165
165
|
files are excluded from that exit code, since a project-authored rule never
|
|
166
|
-
converges. Attribution
|
|
167
|
-
writes.
|
|
166
|
+
converges. Attribution reads `.claude/aitk.json`, which every install and sync
|
|
167
|
+
writes.
|
|
168
|
+
|
|
169
|
+
A target installed before stamping shipped has no such file, and the report
|
|
170
|
+
falls back to the toolkit's own git history. Installed content matching any
|
|
171
|
+
version that history ever published proves the file is untouched, so it reports
|
|
172
|
+
`stale` naming the commit it came from, and content matching no published
|
|
173
|
+
version stays `drifted`. A toolkit reached outside a git clone, which is what a
|
|
174
|
+
registry install is, cannot run that fallback and reports
|
|
175
|
+
`historyUnavailable` alongside the unattributed files.
|
|
168
176
|
|
|
169
177
|
Each domain carries its own toolkit anchor in that file, so syncing one domain
|
|
170
178
|
never advances the revision another measures from, and each reports the upstream
|
|
@@ -238,7 +246,7 @@ The record carries every scenario with the arms that declare, plus `totalScenari
|
|
|
238
246
|
|
|
239
247
|
`--skills` answers what the scenario count cannot, which is whether anything can fail a given skill. It adds `skills`, `totalSkills`, `asserted`, `shouldBeAsserted`, `exempt`, `staleExemptions`, and `supersededExemptions` to the record, and keeps the scenario view rather than replacing it. The two denominators disagree on purpose: an armed scenario under `infra/` or `tooling/` exercises a CLI domain and pairs with no skill at all.
|
|
240
248
|
|
|
241
|
-
A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/setup-init.sh` reaches the `setup-init` skill. `should-be-asserted` is the default and is the
|
|
249
|
+
A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/setup-init.sh` reaches the `setup-init` skill. `should-be-asserted` is the default rather than a queue to drain, and which of those skills earns an arm is a project decision the census does not make. `exempt` means no arm should be written and holds only with a reason, declared in `scripts/sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
|
|
242
250
|
|
|
243
251
|
`scripts/sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.claude/.tmp/sandbox-runs/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit. Stdout carries the same bytes it did before the record existed.
|
|
244
252
|
|
package/docs/target-projects.md
CHANGED
|
@@ -100,7 +100,9 @@ When the toolkit updates, target projects pull changes per domain. There is one
|
|
|
100
100
|
|
|
101
101
|
`aitk sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next. A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
|
|
102
102
|
|
|
103
|
-
That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes recording a hash per installed file. Each domain holds its own toolkit commit, so syncing governance today does not move the revision standards measures against, and each domain reports the upstream commits touching its own source path.
|
|
103
|
+
That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes recording a hash per installed file. Each domain holds its own toolkit commit, so syncing governance today does not move the revision standards measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
|
|
104
|
+
|
|
105
|
+
A project that has never synced under a toolkit new enough to write a stamp falls back to the toolkit's own git history. Installed content matching any version that history published proves the file untouched, so it reports `stale` naming the commit it came from, and content matching no published version stays `drifted`. That fallback needs the toolkit as a git checkout. Installed from the registry it ships source without history, and the report says attribution was unavailable rather than reading every file as a local edit.
|
|
104
106
|
|
|
105
107
|
Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code.
|
|
106
108
|
|
|
@@ -110,7 +112,7 @@ Tooling is not covered by the stamp. Reconcile those configs with `aitk tooling
|
|
|
110
112
|
|
|
111
113
|
`aitk sync <path>` runs every installed domain's sync in sequence. Safe to run on a cadence. It never touches user-owned seed files. Governance rules in `.claude/rules/`, tooling configs, and reference docs refresh in place. Stale `.claude/GOV.md` from earlier installs is removed.
|
|
112
114
|
|
|
113
|
-
Standards are the exception inside that run, and the stamp narrows it. A standard the project customized is reported and left alone rather than overwritten. A standard still matching what was installed carries no local edits to lose, so a headless run updates it.
|
|
115
|
+
Standards are the exception inside that run, and the stamp narrows it. A standard the project customized is reported and left alone rather than overwritten. A standard still matching what was installed carries no local edits to lose, so a headless run updates it. An unstamped project reaches the same split through the history fallback, so a headless run updates every standard it can prove untouched and refuses while any file resists attribution. To take the upstream version of a customized file, run `aitk standards sync <path>` interactively, or use `aitk:claude-seed-sync` below to merge section by section.
|
|
114
116
|
|
|
115
117
|
### Targeted
|
|
116
118
|
|
package/package.json
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -99,7 +99,11 @@ function renderCheck(report: CheckReport): void {
|
|
|
99
99
|
logStep(domain.domain)
|
|
100
100
|
|
|
101
101
|
if (domain.commit === undefined) {
|
|
102
|
-
logWarn(
|
|
102
|
+
logWarn(
|
|
103
|
+
domain.historyUnavailable
|
|
104
|
+
? 'Not stamped, and this toolkit has no git history. Drift below is reported unattributed.'
|
|
105
|
+
: 'Not stamped. Drift below is attributed from toolkit history.',
|
|
106
|
+
)
|
|
103
107
|
} else {
|
|
104
108
|
logInfo(`Synced from ${domain.commit} on ${domain.syncedAt}`)
|
|
105
109
|
}
|
package/src/sync/check.ts
CHANGED
|
@@ -54,6 +54,11 @@ export interface DomainReport {
|
|
|
54
54
|
readonly counts: StateCounts
|
|
55
55
|
readonly entries: readonly ScanEntry[]
|
|
56
56
|
readonly upstream: readonly UpstreamCommit[]
|
|
57
|
+
/**
|
|
58
|
+
* Separates a toolkit that could not attribute from one that attributed and
|
|
59
|
+
* found a local edit. Only the first is a capability the install lacks.
|
|
60
|
+
*/
|
|
61
|
+
readonly historyUnavailable: boolean
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export interface UpstreamCommit {
|
|
@@ -145,6 +150,7 @@ async function buildDomainReport(
|
|
|
145
150
|
syncedAt: record?.syncedAt,
|
|
146
151
|
counts: countStates(plan.entries),
|
|
147
152
|
entries: plan.entries,
|
|
153
|
+
historyUnavailable: plan.historyUnavailable,
|
|
148
154
|
upstream:
|
|
149
155
|
since === undefined
|
|
150
156
|
? []
|
package/src/sync/engine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/copy'
|
|
5
|
+
import { findInstalledOrigin, readHistoryIndex } from '@/sync/history'
|
|
5
6
|
import {
|
|
6
7
|
type DomainHashes,
|
|
7
8
|
hashFile,
|
|
@@ -74,12 +75,16 @@ export type EntryState =
|
|
|
74
75
|
export interface ScanEntry {
|
|
75
76
|
readonly state: EntryState
|
|
76
77
|
readonly rel: string
|
|
78
|
+
/** Toolkit revision this file's content came from, when history proved it. */
|
|
79
|
+
readonly since?: string
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
export interface SyncPlan {
|
|
80
83
|
readonly entries: readonly ScanEntry[]
|
|
81
84
|
readonly retired: readonly RetiredSurface[]
|
|
82
85
|
readonly changes: readonly SyncChange[]
|
|
86
|
+
/** Set when a file needed history to attribute it and this toolkit has none. */
|
|
87
|
+
readonly historyUnavailable: boolean
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
/**
|
|
@@ -156,6 +161,7 @@ export function listInstalled(root: string, target: string): InstalledFile[] {
|
|
|
156
161
|
export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
157
162
|
const entries: ScanEntry[] = []
|
|
158
163
|
const changes: SyncChange[] = []
|
|
164
|
+
const unattributed: UnattributedFile[] = []
|
|
159
165
|
|
|
160
166
|
const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
|
|
161
167
|
const walked = new Set<string>()
|
|
@@ -176,7 +182,12 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
176
182
|
continue
|
|
177
183
|
}
|
|
178
184
|
|
|
179
|
-
|
|
185
|
+
const state = attribute(hashes, file)
|
|
186
|
+
if (state === 'drifted') {
|
|
187
|
+
unattributed.push({ index: entries.length, source, path: file.path })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
entries.push({ state, rel: file.rel })
|
|
180
191
|
changes.push({
|
|
181
192
|
kind: 'copy',
|
|
182
193
|
source,
|
|
@@ -185,6 +196,8 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
185
196
|
})
|
|
186
197
|
}
|
|
187
198
|
|
|
199
|
+
const historyUnavailable = recoverAttribution(adapter, entries, unattributed)
|
|
200
|
+
|
|
188
201
|
entries.push(...strandedByRelocation(target, hashes, walked))
|
|
189
202
|
|
|
190
203
|
const retired = adapter.collectRetired?.(target) ?? []
|
|
@@ -192,7 +205,53 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
192
205
|
changes.push({ kind: 'delete', dest: surface.path, rel: surface.rel })
|
|
193
206
|
}
|
|
194
207
|
|
|
195
|
-
return { entries, retired, changes }
|
|
208
|
+
return { entries, retired, changes, historyUnavailable }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface UnattributedFile {
|
|
212
|
+
readonly index: number
|
|
213
|
+
readonly source: string
|
|
214
|
+
readonly path: string
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Second pass over the files the stamp could not attribute, which is every file
|
|
219
|
+
* in a target installed before stamping shipped. Matching the installed content
|
|
220
|
+
* against the toolkit's own history recovers the fact a stamp would have held,
|
|
221
|
+
* and a file matching no published version stays unattributed.
|
|
222
|
+
*
|
|
223
|
+
* Runs as one git call for the whole domain rather than one per file, and only
|
|
224
|
+
* when the first pass left something to attribute. Reports whether history was
|
|
225
|
+
* readable at all, so a registry install can say why it fell short instead of
|
|
226
|
+
* reporting every file as a local edit.
|
|
227
|
+
*/
|
|
228
|
+
function recoverAttribution(
|
|
229
|
+
adapter: SyncAdapter,
|
|
230
|
+
entries: ScanEntry[],
|
|
231
|
+
unattributed: readonly UnattributedFile[],
|
|
232
|
+
): boolean {
|
|
233
|
+
const toolkitRoot = adapter.stamp?.toolkitRoot
|
|
234
|
+
if (toolkitRoot === undefined || unattributed.length === 0) return false
|
|
235
|
+
|
|
236
|
+
const index = readHistoryIndex(
|
|
237
|
+
toolkitRoot,
|
|
238
|
+
unattributed.map((file) => relative(toolkitRoot, file.source)),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if (index === undefined) return true
|
|
242
|
+
|
|
243
|
+
for (const file of unattributed) {
|
|
244
|
+
const since = findInstalledOrigin(
|
|
245
|
+
index,
|
|
246
|
+
relative(toolkitRoot, file.source),
|
|
247
|
+
file.path,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if (since === undefined) continue
|
|
251
|
+
entries[file.index] = { ...entries[file.index], state: 'stale', since }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return false
|
|
196
255
|
}
|
|
197
256
|
|
|
198
257
|
export async function applyChanges(
|
|
@@ -314,7 +373,12 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
314
373
|
for (const entry of plan.entries) {
|
|
315
374
|
if (entry.state === 'matching') logInfo(entry.rel)
|
|
316
375
|
else if (entry.state === 'drifted') logWarn(entry.rel)
|
|
317
|
-
else if (entry.state === 'stale')
|
|
376
|
+
else if (entry.state === 'stale')
|
|
377
|
+
logWarn(
|
|
378
|
+
entry.since === undefined
|
|
379
|
+
? `${entry.rel} (toolkit updated)`
|
|
380
|
+
: `${entry.rel} (toolkit updated since ${entry.since.slice(0, 7)})`,
|
|
381
|
+
)
|
|
318
382
|
else if (entry.state === 'customized')
|
|
319
383
|
logWarn(`${entry.rel} (locally customized)`)
|
|
320
384
|
else if (entry.state === 'stranded')
|
|
@@ -325,6 +389,12 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
325
389
|
for (const surface of plan.retired) {
|
|
326
390
|
logWarn(surface.notice)
|
|
327
391
|
}
|
|
392
|
+
|
|
393
|
+
if (plan.historyUnavailable) {
|
|
394
|
+
logWarn(
|
|
395
|
+
'Attribution unavailable: this toolkit has no git history to match against.',
|
|
396
|
+
)
|
|
397
|
+
}
|
|
328
398
|
}
|
|
329
399
|
|
|
330
400
|
function sameContent(left: string, right: string): boolean {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { sep } from 'node:path'
|
|
4
|
+
import { execaSync } from 'execa'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
|
|
7
|
+
/** Blob SHA to the newest commit whose post-image for that path held it. */
|
|
8
|
+
export type PathHistory = ReadonlyMap<string, string>
|
|
9
|
+
|
|
10
|
+
/** Source path relative to the toolkit root, to that path's known blobs. */
|
|
11
|
+
export type HistoryIndex = ReadonlyMap<string, PathHistory>
|
|
12
|
+
|
|
13
|
+
const EMPTY_BLOB = '0000000000000000000000000000000000000000'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Git's own object name for a file's contents: sha1 over `blob <len>\0` and the
|
|
17
|
+
* bytes. Computed here rather than shelled out to `git hash-object`, which would
|
|
18
|
+
* cost a process per file inside the attribution pass.
|
|
19
|
+
*
|
|
20
|
+
* A repository normalizing line endings on checkout stores a blob the working
|
|
21
|
+
* tree never holds, so this returns a name that matches no historical version.
|
|
22
|
+
* The caller treats an unmatched file as unattributed, which is the safe label.
|
|
23
|
+
*/
|
|
24
|
+
export function gitBlobHash(content: Buffer): string {
|
|
25
|
+
const header = Buffer.from(`blob ${content.length}\0`, 'utf8')
|
|
26
|
+
return createHash('sha1').update(header).update(content).digest('hex')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads `--raw` log output into blob-to-commit maps, one per path.
|
|
31
|
+
*
|
|
32
|
+
* Reverse-chronological input means the first commit naming a blob is the
|
|
33
|
+
* newest that produced it, so an existing key is never overwritten. Content
|
|
34
|
+
* reverted and restored therefore reports the recent commit rather than the
|
|
35
|
+
* original, which is the one an operator can act on.
|
|
36
|
+
*/
|
|
37
|
+
export function parseRawLog(output: string): HistoryIndex {
|
|
38
|
+
const index = new Map<string, Map<string, string>>()
|
|
39
|
+
let commit = ''
|
|
40
|
+
|
|
41
|
+
for (const line of output.split('\n')) {
|
|
42
|
+
if (line === '') continue
|
|
43
|
+
|
|
44
|
+
if (!line.startsWith(':')) {
|
|
45
|
+
commit = line.trim()
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [meta, path] = line.split('\t')
|
|
50
|
+
if (path === undefined || commit === '') continue
|
|
51
|
+
|
|
52
|
+
const blob = meta.trim().split(/\s+/)[3]
|
|
53
|
+
if (blob === undefined || blob === EMPTY_BLOB) continue
|
|
54
|
+
|
|
55
|
+
const blobs = index.get(path) ?? new Map<string, string>()
|
|
56
|
+
if (!blobs.has(blob)) blobs.set(blob, commit)
|
|
57
|
+
index.set(path, blobs)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return index
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every historical version of the given toolkit-relative paths, or `undefined`
|
|
65
|
+
* when this toolkit has no history to read. A package installed from the
|
|
66
|
+
* registry ships source without `.git`, so absence is an ordinary state rather
|
|
67
|
+
* than a failure, and the caller degrades to the unattributed path.
|
|
68
|
+
*
|
|
69
|
+
* Renames are disabled so a raw line always carries exactly one path. Detection
|
|
70
|
+
* would emit a two-path form that the parser would read as an unknown blob.
|
|
71
|
+
*/
|
|
72
|
+
export function readHistoryIndex(
|
|
73
|
+
toolkitRoot: string,
|
|
74
|
+
paths: readonly string[],
|
|
75
|
+
): HistoryIndex | undefined {
|
|
76
|
+
if (paths.length === 0) return new Map()
|
|
77
|
+
|
|
78
|
+
const result = execaSync(
|
|
79
|
+
'git',
|
|
80
|
+
[
|
|
81
|
+
'-C',
|
|
82
|
+
toolkitRoot,
|
|
83
|
+
'log',
|
|
84
|
+
'--all',
|
|
85
|
+
'--raw',
|
|
86
|
+
'--no-renames',
|
|
87
|
+
'--no-abbrev',
|
|
88
|
+
'--relative',
|
|
89
|
+
'--format=%H',
|
|
90
|
+
'--',
|
|
91
|
+
...paths.map(toRepoPath),
|
|
92
|
+
],
|
|
93
|
+
{ reject: false, env: gitEnv(), extendEnv: false },
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return result.exitCode === 0 ? parseRawLog(result.stdout) : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Git prints and accepts posix separators on every platform, so a Windows
|
|
101
|
+
* caller's `relative()` output has to be converted before it can match a path
|
|
102
|
+
* the log emitted.
|
|
103
|
+
*/
|
|
104
|
+
function toRepoPath(path: string): string {
|
|
105
|
+
return path.split(sep).join('/')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The commit whose version of `sourceRel` matches what sits at `installedPath`.
|
|
110
|
+
* A match proves the file is untouched since it was installed, so the toolkit is
|
|
111
|
+
* what moved. No match means the content matches nothing this toolkit ever
|
|
112
|
+
* published, which is a local edit and stays unattributed.
|
|
113
|
+
*/
|
|
114
|
+
export function findInstalledOrigin(
|
|
115
|
+
index: HistoryIndex,
|
|
116
|
+
sourceRel: string,
|
|
117
|
+
installedPath: string,
|
|
118
|
+
): string | undefined {
|
|
119
|
+
const blobs = index.get(toRepoPath(sourceRel))
|
|
120
|
+
if (blobs === undefined) return undefined
|
|
121
|
+
|
|
122
|
+
return blobs.get(gitBlobHash(readFileSync(installedPath)))
|
|
123
|
+
}
|