@erclx/aitk 0.92.0 → 0.93.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-orchestrate/references/orchestrator-handoff.md +7 -6
- package/docs/agents/commands.md +16 -15
- package/docs/agents/index.md +1 -1
- package/docs/agents/skills-audit.md +34 -1
- package/package.json +1 -1
- package/src/claude/skills-drift.ts +156 -0
- package/src/commands/claude.ts +106 -4
|
@@ -6,9 +6,10 @@ description: Memory capture at the close of a session, what to write to .claude/
|
|
|
6
6
|
Capture what the session learned, then write the pre-compact handoff as orchestrator. Do both before a compaction, because a compaction keeps conclusions and drops the reasoning that produced them, and no other file in the repository carries that reasoning.
|
|
7
7
|
|
|
8
8
|
1. Invoke `aitk:claude-memory-capture` and tell it this session does not commit. Capture reads the session and this handoff summarizes it, so capturing first lets the handoff cite what was written instead of restating the same lesson in prose.
|
|
9
|
-
2.
|
|
10
|
-
3.
|
|
11
|
-
4.
|
|
9
|
+
2. Run `aitk claude skills drift <commit this session started from>` and re-read any body it names before writing anything below. Nothing on the machine records that commit, so recover it from how long the session has been running with `git log -1 --format=%H --before='<duration> ago'`, rounding the duration up rather than down. A ref older than the oldest load over-reports and confirming a name costs one read of the body, so the generous end is the safe one and a guess at the exact commit is not worth making. A skill body enters a session once and re-invoking the skill replays the held copy rather than the file, so the drift is worst at exactly this moment and a name here is a body this session has been following out of date. Record what it named under `## Standing cautions`. The verb answers where the working directory carries `claude/skills/` with history behind it, which is the toolkit repository itself, and refuses by naming the absent tree anywhere else. A project consuming the plugin from a marketplace cache is that second case, so read the refusal as the boundary rather than as a fault.
|
|
10
|
+
3. Resolve the main worktree root with `git worktree list --porcelain | grep -m 1 '^worktree ' | cut -d' ' -f2-`, falling back to `pwd`. Write `.claude/tasks/session.md` under it.
|
|
11
|
+
4. Write only what a compaction destroys and no other file already carries. The board holds the ordering and what each task waits on, a task file holds its own findings, and a groundwork folder holds its track.
|
|
12
|
+
5. Use this shape, resolving `${CLAUDE_SKILL_DIR}/references/orchestrator-resume.md` and `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` to absolute paths as you write it and pasting each in place of `<RESUME_RUNBOOK>` and `<POLL_RUNBOOK>`:
|
|
12
13
|
|
|
13
14
|
```markdown
|
|
14
15
|
---
|
|
@@ -41,10 +42,10 @@ Resume by loading the orchestrator skill and asking it to resume after a compact
|
|
|
41
42
|
That resume reads the board and stops. It restarts nothing, so the review poll is a second thing owed here, and <POLL_RUNBOOK> holds the prompt and the condition. Do not reach for `session-resume`, which reads tracked work and knows nothing about this board or the workers on it.
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
6. Cite a commit, a task, or a file and line for every claim, so the next session can tell a read from a recall.
|
|
46
|
+
7. Overwrite the previous handoff rather than appending to it. A stale entry read as current is worse than no handoff.
|
|
46
47
|
|
|
47
|
-
The substitution belongs in step
|
|
48
|
+
The substitution belongs in step 5 because step 7 ends the write. A reader who treats the list as finished there ships the literal placeholders, and the variable expands while this runbook renders rather than in the turn that reads the handoff back, so a path left unresolved reaches a session holding no skill as a string matching nothing. `orchestrator-poll.md` resolves its script at the same point and for the same reason.
|
|
48
49
|
|
|
49
50
|
Add a section only for content that fits none of the four and would otherwise be lost. Do not restate the board, and do not summarize the work that shipped, because git already carries it. The closing block is the one exception, and the paragraph below states why.
|
|
50
51
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -40,27 +40,28 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
40
40
|
| `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
|
|
41
41
|
| `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
|
42
42
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
43
|
+
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD` (`--json`) |
|
|
43
44
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
44
45
|
|
|
45
46
|
## Domain commands
|
|
46
47
|
|
|
47
48
|
Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
|
|
48
49
|
|
|
49
|
-
| Domain | Subcommands
|
|
50
|
-
| ----------- |
|
|
51
|
-
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore`
|
|
52
|
-
| `snippets` | `list`, `install`, `sync`, `create`
|
|
53
|
-
| `standards` | `list`, `install`, `sync`, `<name>`
|
|
54
|
-
| `gov` | `list`, `install`, `sync`, `build`
|
|
55
|
-
| `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `setup [dest]` |
|
|
56
|
-
| `wiki` | `init`
|
|
57
|
-
| `design` | `render`
|
|
58
|
-
| `slides` | `render`, `list`
|
|
59
|
-
| `tasks` | `archive`, `validate`
|
|
60
|
-
| `intake` | `list`, `answer`
|
|
61
|
-
| `comments` | `scan`
|
|
62
|
-
| `context` | `audit`
|
|
63
|
-
| `markdown` | `audit`
|
|
50
|
+
| Domain | Subcommands |
|
|
51
|
+
| ----------- | ------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
53
|
+
| `snippets` | `list`, `install`, `sync`, `create` |
|
|
54
|
+
| `standards` | `list`, `install`, `sync`, `<name>` |
|
|
55
|
+
| `gov` | `list`, `install`, `sync`, `build` |
|
|
56
|
+
| `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `setup [dest]` |
|
|
57
|
+
| `wiki` | `init` |
|
|
58
|
+
| `design` | `render` |
|
|
59
|
+
| `slides` | `render`, `list` |
|
|
60
|
+
| `tasks` | `archive`, `validate` |
|
|
61
|
+
| `intake` | `list`, `answer` |
|
|
62
|
+
| `comments` | `scan` |
|
|
63
|
+
| `context` | `audit` |
|
|
64
|
+
| `markdown` | `audit` |
|
|
64
65
|
|
|
65
66
|
Common patterns:
|
|
66
67
|
|
package/docs/agents/index.md
CHANGED
|
@@ -24,5 +24,5 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
24
24
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
25
25
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
26
26
|
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
|
|
27
|
-
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads,
|
|
27
|
+
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
28
28
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board checks validate runs, and why the board root defaults to the main worktree
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Skill audit
|
|
3
|
-
description: Measuring both skill corpora against standards/skill.md, the checks it reads,
|
|
3
|
+
description: Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Skill audit
|
|
@@ -53,3 +53,36 @@ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a skill folde
|
|
|
53
53
|
`--requirements-only` is the half wired into `bun run check`. Presence of a required file is a fact with no false positives, and the rule had nothing reading it, so a skill shipped without the sibling passed every stage while the standard required it.
|
|
54
54
|
|
|
55
55
|
The check is preventive rather than diagnostic. Every mechanical rule passed across both corpora the day it shipped, so what it buys is the regression it stops rather than a backlog it surfaces.
|
|
56
|
+
|
|
57
|
+
## Drift since a ref
|
|
58
|
+
|
|
59
|
+
`aitk claude skills drift <ref>` names the shipped skill bodies rewritten between that commit and the current `HEAD`.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
aitk claude skills drift HEAD~20
|
|
63
|
+
aitk claude skills drift 02d7b265 --json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Option | Behavior |
|
|
67
|
+
| -------- | ---------------------------------------------------------- |
|
|
68
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
69
|
+
|
|
70
|
+
A skill body enters a session once and stays. Re-invoking the skill replays the copy the session already holds rather than re-reading the file, so a session that outlives a merge touching a body it loaded keeps applying what it loaded and nothing compares the two. A compaction carries the held copy forward with the summary it writes, which makes the exposure the age of the oldest load rather than the age of the session.
|
|
71
|
+
|
|
72
|
+
The ref is required and carries no default. `HEAD` is the only value the command could supply for itself, and it answers every run with nothing moved, which is the silence this verb exists to break. A session passes the commit it started from.
|
|
73
|
+
|
|
74
|
+
Nothing on the machine records that commit, so a session recovers it from its own elapsed run time with `git log -1 --format=%H --before='<duration> ago'` and rounds the duration up. Over-reporting costs one read of a body while under-reporting costs the answer, so the generous end of the estimate is the correct one and no session needs to pin the exact commit.
|
|
75
|
+
|
|
76
|
+
### What drift reads
|
|
77
|
+
|
|
78
|
+
Bodies alone, one `SKILL.md` per folder, across `claude/skills/` and not the internal tree. A reference or a requirement beside the body is read by whoever opens it and holds no copy that outlives the read, and the internal corpus never loads into a target session at all.
|
|
79
|
+
|
|
80
|
+
The report names the newest commit that rewrote each body rather than the first, so a body rewritten twice in the range points at the version on disk now.
|
|
81
|
+
|
|
82
|
+
### What drift cannot answer
|
|
83
|
+
|
|
84
|
+
The report says a file moved, not that a session holds a stale copy. A session cannot read its own loaded body as bytes, so the comparison runs against history and a ref older than the oldest load over-reports. That is the safe direction, since confirming a name costs one read of the body while the failure being answered is silence. Every run states the bound, including the run that names nothing.
|
|
85
|
+
|
|
86
|
+
Two cases fall outside the range. An uncommitted edit in the working tree is not history yet, so a body changed and left unstaged reports as unmoved. A target project loads the plugin from a marketplace cache with no repository behind it, where the verb refuses and names the absent history rather than reporting a clean tree.
|
|
87
|
+
|
|
88
|
+
Exit codes are `0` when history was read, whether or not a body moved, and `1` when the question could not be answered. An absent shipped corpus, an absent repository, and a ref matching no commit each refuse with their own reason on stderr. A command exiting zero on a question it never answered is the failure `.claude/ARCHITECTURE.md` already records against two skills, which is why no path here reports an empty result in place of a refusal.
|
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { execaSync } from 'execa'
|
|
4
|
+
import { gitEnv } from '@/git-env'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shipped corpus alone. A session loads a body from the plugin root, which
|
|
8
|
+
* resolves to `claude/skills/`, so the internal tree carries no held copy that
|
|
9
|
+
* could go stale in one.
|
|
10
|
+
*
|
|
11
|
+
* Held with posix separators because it is passed to git as a pathspec. Git
|
|
12
|
+
* emits and accepts posix separators on every platform, so a `join()` form would
|
|
13
|
+
* reach a Windows checkout as `claude\skills`, match no path, and return an
|
|
14
|
+
* empty log that reads as a tree where nothing moved.
|
|
15
|
+
*/
|
|
16
|
+
const SHIPPED_SKILLS = 'claude/skills'
|
|
17
|
+
|
|
18
|
+
/** A `%H` line, which is 40 hex characters under sha1 and 64 under sha256. */
|
|
19
|
+
const COMMIT = /^[0-9a-f]{40,64}$/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A skill body, which is the only file in the folder a session loads. A
|
|
23
|
+
* reference or a requirement beside it is read by whoever opens it, so it holds
|
|
24
|
+
* no copy that outlives the read.
|
|
25
|
+
*/
|
|
26
|
+
const BODY = /^claude\/skills\/([^/]+)\/SKILL\.md$/
|
|
27
|
+
|
|
28
|
+
export interface MovedBody {
|
|
29
|
+
readonly name: string
|
|
30
|
+
/** The newest commit that rewrote this body, not the first. */
|
|
31
|
+
readonly commit: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DriftReport =
|
|
35
|
+
| {
|
|
36
|
+
readonly kind: 'measured'
|
|
37
|
+
readonly base: string
|
|
38
|
+
readonly head: string
|
|
39
|
+
readonly moved: readonly MovedBody[]
|
|
40
|
+
}
|
|
41
|
+
| { readonly kind: 'unreadable'; readonly reason: string }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads `--name-only` log output into one entry per skill whose body moved.
|
|
45
|
+
*
|
|
46
|
+
* Reverse-chronological input means the first commit naming a body is the
|
|
47
|
+
* newest that rewrote it, so an existing name is never overwritten. A body
|
|
48
|
+
* rewritten twice therefore reports the recent commit, which is the one a
|
|
49
|
+
* session can read to see what it is missing.
|
|
50
|
+
*/
|
|
51
|
+
export function parseMovedBodies(output: string): MovedBody[] {
|
|
52
|
+
const moved = new Map<string, string>()
|
|
53
|
+
let commit = ''
|
|
54
|
+
|
|
55
|
+
for (const line of output.split('\n')) {
|
|
56
|
+
const trimmed = line.trim()
|
|
57
|
+
if (trimmed === '') continue
|
|
58
|
+
|
|
59
|
+
if (COMMIT.test(trimmed)) {
|
|
60
|
+
commit = trimmed
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const match = BODY.exec(trimmed)
|
|
65
|
+
if (match === null || commit === '') continue
|
|
66
|
+
if (!moved.has(match[1])) moved.set(match[1], commit)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [...moved]
|
|
70
|
+
.map(([name, sha]) => ({ name, commit: sha }))
|
|
71
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Which shipped skill bodies were rewritten between `ref` and the current
|
|
76
|
+
* `HEAD` of the tree at `root`.
|
|
77
|
+
*
|
|
78
|
+
* This measures a file moving rather than a held copy differing. A session
|
|
79
|
+
* cannot read its own loaded body as bytes, so the comparison runs against
|
|
80
|
+
* history and a session passing a ref older than its oldest load over-reports.
|
|
81
|
+
* That is the safe direction, since confirming a name costs one read of the
|
|
82
|
+
* body while the failure this answers is silence.
|
|
83
|
+
*
|
|
84
|
+
* Every path that cannot answer returns `unreadable` with its own reason. A
|
|
85
|
+
* target project loads the plugin from a marketplace cache with no history
|
|
86
|
+
* behind it, where reporting an empty result would read as a clean tree.
|
|
87
|
+
*/
|
|
88
|
+
export function readDrift(root: string, ref: string): DriftReport {
|
|
89
|
+
if (!existsSync(join(root, SHIPPED_SKILLS))) {
|
|
90
|
+
return {
|
|
91
|
+
kind: 'unreadable',
|
|
92
|
+
reason: `No shipped skill corpus under ${root}. Looked for ${SHIPPED_SKILLS}.`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const head = revParse(root, 'HEAD')
|
|
97
|
+
if (head === undefined) {
|
|
98
|
+
return {
|
|
99
|
+
kind: 'unreadable',
|
|
100
|
+
reason: `No git history under ${root}. A plugin loaded from a marketplace cache carries none, so a ref cannot be resolved there.`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const base = revParse(root, ref)
|
|
105
|
+
if (base === undefined) {
|
|
106
|
+
return {
|
|
107
|
+
kind: 'unreadable',
|
|
108
|
+
reason: `Ref ${ref} resolves to no commit in ${root}. Pass a commit this tree carries.`,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = execaSync(
|
|
113
|
+
'git',
|
|
114
|
+
[
|
|
115
|
+
'-C',
|
|
116
|
+
root,
|
|
117
|
+
'log',
|
|
118
|
+
'--name-only',
|
|
119
|
+
'--no-renames',
|
|
120
|
+
'--format=%H',
|
|
121
|
+
`${base}..${head}`,
|
|
122
|
+
'--',
|
|
123
|
+
SHIPPED_SKILLS,
|
|
124
|
+
],
|
|
125
|
+
{ reject: false, env: gitEnv(), extendEnv: false },
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
return {
|
|
130
|
+
kind: 'unreadable',
|
|
131
|
+
reason: `Reading history between ${ref} and HEAD failed: ${result.stderr || result.stdout}`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
kind: 'measured',
|
|
137
|
+
base,
|
|
138
|
+
head,
|
|
139
|
+
moved: parseMovedBodies(result.stdout),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The commit a ref names, or `undefined` when the tree carries no repository or
|
|
145
|
+
* no such ref. `^{commit}` is what turns a tag or a tree into the commit behind
|
|
146
|
+
* it, so a caller never compares a ref against an object of another type.
|
|
147
|
+
*/
|
|
148
|
+
function revParse(root: string, ref: string): string | undefined {
|
|
149
|
+
const result = execaSync(
|
|
150
|
+
'git',
|
|
151
|
+
['-C', root, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
|
|
152
|
+
{ reject: false, env: gitEnv(), extendEnv: false },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return result.exitCode === 0 ? result.stdout.trim() : undefined
|
|
156
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type SkillFinding,
|
|
22
22
|
type SkillsAudit,
|
|
23
23
|
} from '@/claude/skills-audit'
|
|
24
|
+
import { type DriftReport, readDrift } from '@/claude/skills-drift'
|
|
24
25
|
import { listSkills } from '@/claude/skills-list'
|
|
25
26
|
import {
|
|
26
27
|
planSettings,
|
|
@@ -67,6 +68,10 @@ interface SkillsAuditOptions {
|
|
|
67
68
|
readonly requirementsOnly?: boolean
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
interface SkillsDriftOptions {
|
|
72
|
+
readonly json?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
const SEEDED_FILES: readonly string[] = [
|
|
71
76
|
'ARCHITECTURE.md',
|
|
72
77
|
'REQUIREMENTS.md',
|
|
@@ -157,15 +162,15 @@ export function register(program: Command): void {
|
|
|
157
162
|
|
|
158
163
|
const skills = claude
|
|
159
164
|
.command('skills')
|
|
160
|
-
.description('Plugin skill catalog (list, audit)')
|
|
161
|
-
.argument('[subcommand]', "One of 'list' or '
|
|
165
|
+
.description('Plugin skill catalog (list, audit, drift)')
|
|
166
|
+
.argument('[subcommand]', "One of 'list', 'audit', or 'drift'")
|
|
162
167
|
.helpOption('-h, --help', 'Show this help message')
|
|
163
168
|
.action((subcommand: string | undefined) => {
|
|
164
169
|
intro('aitk claude')
|
|
165
170
|
logError(
|
|
166
171
|
subcommand === undefined
|
|
167
|
-
? "Missing subcommand. Use 'list' or '
|
|
168
|
-
: `Unknown subcommand: ${subcommand}. Use 'list' or '
|
|
172
|
+
? "Missing subcommand. Use 'list', 'audit', or 'drift'."
|
|
173
|
+
: `Unknown subcommand: ${subcommand}. Use 'list', 'audit', or 'drift'.`,
|
|
169
174
|
)
|
|
170
175
|
outro()
|
|
171
176
|
process.exitCode = 1
|
|
@@ -225,6 +230,34 @@ export function register(program: Command): void {
|
|
|
225
230
|
.action(async (path: string | undefined, opts: SkillsAuditOptions) => {
|
|
226
231
|
process.exitCode = await runSkillsAudit(path, opts)
|
|
227
232
|
})
|
|
233
|
+
|
|
234
|
+
skills
|
|
235
|
+
.command('drift')
|
|
236
|
+
.description('Name the shipped skill bodies rewritten since a given ref')
|
|
237
|
+
.argument('<ref>', 'The commit a session started from')
|
|
238
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
239
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
240
|
+
.addHelpText(
|
|
241
|
+
'after',
|
|
242
|
+
[
|
|
243
|
+
'',
|
|
244
|
+
'Exit codes:',
|
|
245
|
+
' 0 history was read, whether or not a body moved',
|
|
246
|
+
' 1 the question could not be answered, with the reason on stderr',
|
|
247
|
+
'',
|
|
248
|
+
'A moved body means the file changed, not that a session holds a stale',
|
|
249
|
+
'copy. Passing a ref older than the oldest load over-reports, which is',
|
|
250
|
+
'the safe direction. Confirm a name by reading the body.',
|
|
251
|
+
'',
|
|
252
|
+
'Examples:',
|
|
253
|
+
' aitk claude skills drift HEAD~20',
|
|
254
|
+
' aitk claude skills drift 02d7b265 --json',
|
|
255
|
+
'',
|
|
256
|
+
].join('\n'),
|
|
257
|
+
)
|
|
258
|
+
.action((ref: string, opts: SkillsDriftOptions) => {
|
|
259
|
+
process.exitCode = runSkillsDrift(ref, opts)
|
|
260
|
+
})
|
|
228
261
|
}
|
|
229
262
|
|
|
230
263
|
function succeed(message: string): number {
|
|
@@ -467,6 +500,75 @@ function runSkillsList(opts: SkillsListOptions): number {
|
|
|
467
500
|
return 0
|
|
468
501
|
}
|
|
469
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Measures the cwd for the same reason the audit does, and takes the ref as a
|
|
505
|
+
* required argument with no default. `HEAD` would be the only defensible one and
|
|
506
|
+
* it answers every run with nothing moved, which is the silence this reports
|
|
507
|
+
* against.
|
|
508
|
+
*/
|
|
509
|
+
function runSkillsDrift(ref: string, opts: SkillsDriftOptions): number {
|
|
510
|
+
const root = process.cwd()
|
|
511
|
+
const report = readDrift(root, ref)
|
|
512
|
+
|
|
513
|
+
if (report.kind === 'measured') {
|
|
514
|
+
intro('aitk claude skills drift')
|
|
515
|
+
reportDrift(report, ref)
|
|
516
|
+
outro()
|
|
517
|
+
} else {
|
|
518
|
+
frameError(report.reason)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (opts.json) {
|
|
522
|
+
process.stdout.write(
|
|
523
|
+
`${JSON.stringify(
|
|
524
|
+
report.kind === 'measured'
|
|
525
|
+
? {
|
|
526
|
+
root,
|
|
527
|
+
ref,
|
|
528
|
+
base: report.base,
|
|
529
|
+
head: report.head,
|
|
530
|
+
moved: report.moved,
|
|
531
|
+
}
|
|
532
|
+
: { root, ref, unreadable: report.reason },
|
|
533
|
+
)}\n`,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return report.kind === 'measured' ? 0 : 1
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* States the bound on every run, including the run that names nothing. A report
|
|
542
|
+
* listing only what moved reads as a verdict on what a session holds, and the
|
|
543
|
+
* command has no access to that.
|
|
544
|
+
*/
|
|
545
|
+
function reportDrift(
|
|
546
|
+
report: Extract<DriftReport, { kind: 'measured' }>,
|
|
547
|
+
ref: string,
|
|
548
|
+
): void {
|
|
549
|
+
logStep('Range')
|
|
550
|
+
logInfo(`${ref} to HEAD, resolved as ${report.base}..${report.head}.`)
|
|
551
|
+
logInfo(
|
|
552
|
+
'A body here changed on disk. Whether a session still holds the old one is what reading it settles.',
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
logStep('Moved bodies')
|
|
556
|
+
if (report.moved.length === 0) {
|
|
557
|
+
logInfo('No shipped body changed in this range.')
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const count = report.moved.length
|
|
562
|
+
logWarn(
|
|
563
|
+
`${count} skill ${count === 1 ? 'body' : 'bodies'} rewritten since ${ref}`,
|
|
564
|
+
)
|
|
565
|
+
pipeOutput(
|
|
566
|
+
report.moved
|
|
567
|
+
.map((moved) => `${moved.name} ${moved.commit.slice(0, 8)}`)
|
|
568
|
+
.join('\n'),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
470
572
|
/**
|
|
471
573
|
* Measures the tree at the cwd rather than the toolkit root the catalog reads,
|
|
472
574
|
* so a linked worktree audits its own branch instead of reporting on `main`. A
|