@erclx/aitk 3.51.1 → 3.52.1

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.
@@ -0,0 +1,412 @@
1
+ import {
2
+ auditSet,
3
+ heroStamp,
4
+ markdownBans,
5
+ type Measure,
6
+ pluginManifests,
7
+ sandboxCoverage,
8
+ seedStandards,
9
+ standardCriteria,
10
+ unreferencedRules,
11
+ } from '@/gate/measures'
12
+
13
+ /**
14
+ * One thing a stage does, as an argument vector rather than a shell line.
15
+ *
16
+ * The script this replaces held every check as text and handed it to `eval`,
17
+ * which made each stage a quoting question: whether the string wanted a shell
18
+ * or was an argument list that should never see one. Nothing in the set turned
19
+ * out to depend on expansion once the drift assertion stopped being a shell
20
+ * line, so the whole table is vectors and the hazard is gone rather than
21
+ * carried forward.
22
+ */
23
+ export type Check =
24
+ /** Any binary on PATH, or a script named by path under the project root. */
25
+ | {
26
+ readonly kind: 'command'
27
+ readonly argv: readonly string[]
28
+ readonly failure: string
29
+ }
30
+ /** This checkout's own CLI, so a worktree gate never reads the main tree. */
31
+ | {
32
+ readonly kind: 'cli'
33
+ readonly argv: readonly string[]
34
+ readonly failure: string
35
+ }
36
+ /**
37
+ * A regenerated surface asserted against the index and the working tree.
38
+ *
39
+ * Both halves are read, since a regen emitting a file nobody ever committed
40
+ * satisfies a diff and fails here. The pathspec is one argument rather than a
41
+ * shell word, which is what git already receives: `*` matches a slash in a
42
+ * pathspec, so `*index.md` reaches a nested index the way it always did.
43
+ */
44
+ | {
45
+ readonly kind: 'drift'
46
+ readonly pathspec: string
47
+ readonly failure: string
48
+ }
49
+ /** A reading whose verdict is a comparison rather than an exit code. */
50
+ | { readonly kind: 'measure'; readonly measure: Measure }
51
+
52
+ export interface Stage {
53
+ readonly id: string
54
+ /** The heading this stage prints, matching the frame it replaces. */
55
+ readonly label: string
56
+ /**
57
+ * The changed-file pattern this stage needs before it runs. Absent means the
58
+ * stage always runs, which is correct wherever the input is diffuse enough
59
+ * that no path predicts it.
60
+ */
61
+ readonly scope?: RegExp
62
+ /** What a scoped-out stage says, so a skip never reads as a pass. */
63
+ readonly skipped?: string
64
+ /**
65
+ * Whether this stage belongs in the run at all, read off the write grant
66
+ * rather than the changed set. Absent means it always belongs.
67
+ */
68
+ readonly when?: (options: { readonly write: boolean }) => boolean
69
+ readonly checks: readonly Check[]
70
+ /** The line a clean stage prints, where the checks emit none of their own. */
71
+ readonly success?: string
72
+ }
73
+
74
+ /**
75
+ * Corpora a `src/` test asserts over from outside `src/`, censused in
76
+ * `.claude/context/development/verification.md`. This list and that census are
77
+ * two copies of one set with nothing comparing them, so a corpus joining the
78
+ * census joins this list in the same change. The first four are directory
79
+ * prefixes because their tests walk the tree whole, which is what reaches a
80
+ * rule or a skill a branch adds rather than edits.
81
+ */
82
+ export const TEST_CORPORA_PATTERNS = [
83
+ '^claude/skills/',
84
+ '^governance/rules/',
85
+ '^\\.claude/hooks/',
86
+ '^tooling/claude/seeds/\\.claude/hooks/',
87
+ '^standards/markdown\\.md$',
88
+ '^tooling/base/reference\\.md$',
89
+ '^tooling/web/configs/scripts/worktree-port\\.sh$',
90
+ '^\\.cspell/banned-spellings\\.txt$',
91
+ '^scripts/lib/worktree\\.sh$',
92
+ '^scripts/core/check-ignore-parity\\.sh$',
93
+ ]
94
+
95
+ const TESTS_SCOPE = new RegExp(
96
+ [
97
+ '^src/',
98
+ '^vitest\\.config\\.ts$',
99
+ '^tsconfig\\.json$',
100
+ '^package\\.json$',
101
+ ...TEST_CORPORA_PATTERNS,
102
+ ].join('|'),
103
+ )
104
+
105
+ const script = (name: string, failure: string): Check => ({
106
+ kind: 'command',
107
+ argv: ['bash', `scripts/core/${name}`],
108
+ failure,
109
+ })
110
+
111
+ /**
112
+ * Every stage the gate runs, in the order it runs them.
113
+ *
114
+ * Order is the product here rather than an accident of the file. The four
115
+ * regenerating stages run before anything reads what they wrote, the stages
116
+ * calling this checkout's CLI sit together so a reader meets one group rather
117
+ * than the same kind of stage at intervals, and the three scoped stages sit
118
+ * last where a skip costs the run nothing it already paid for.
119
+ *
120
+ * A stage halts the run when one of its checks fails, so clearing a
121
+ * regenerate-then-assert stage reveals the next one behind it rather than the
122
+ * whole set at once.
123
+ */
124
+ export const STAGES: readonly Stage[] = [
125
+ {
126
+ id: 'format',
127
+ label: 'Formatting',
128
+ when: ({ write }) => write,
129
+ checks: [
130
+ {
131
+ kind: 'command',
132
+ argv: ['bun', 'run', 'format'],
133
+ failure: 'Format failed',
134
+ },
135
+ ],
136
+ success: 'Format applied',
137
+ },
138
+ {
139
+ id: 'format-check',
140
+ label: 'Format check',
141
+ when: ({ write }) => !write,
142
+ checks: [
143
+ {
144
+ kind: 'command',
145
+ argv: ['bun', 'run', 'check:format'],
146
+ failure: 'Format check failed',
147
+ },
148
+ ],
149
+ success: 'Format check passed',
150
+ },
151
+ {
152
+ id: 'indexes',
153
+ label: 'Indexes',
154
+ checks: [
155
+ script('regen-indexes.sh', 'Index regen failed'),
156
+ {
157
+ kind: 'drift',
158
+ pathspec: '*index.md',
159
+ failure:
160
+ 'Indexes drifted. Run bun run check and commit the updated index files.',
161
+ },
162
+ ],
163
+ success: 'Indexes clean',
164
+ },
165
+ {
166
+ id: 'consumed-copies',
167
+ label: 'Consumed copies',
168
+ checks: [
169
+ script('regen-claude-copies.sh', 'Consumed-copy regen failed'),
170
+ {
171
+ kind: 'drift',
172
+ pathspec: '.claude/rules',
173
+ failure:
174
+ 'Consumed copies drifted. Run bun run check and commit .claude/rules.',
175
+ },
176
+ ],
177
+ success: 'Consumed copies clean',
178
+ },
179
+ {
180
+ // Only the markup is asserted for drift. The image beside it is a chromium
181
+ // render whose bytes move with the browser version, so a drift check over it
182
+ // would fail on a machine whose chromium differs rather than on a stale
183
+ // count. The stamp measure below is what covers the image instead.
184
+ id: 'hero',
185
+ label: 'Hero',
186
+ checks: [
187
+ script('regen-hero.sh', 'Hero regen failed'),
188
+ {
189
+ kind: 'drift',
190
+ pathspec: 'assets/hero.html',
191
+ failure:
192
+ 'Hero counts drifted. Run bun run check, then aitk capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
193
+ },
194
+ { kind: 'measure', measure: heroStamp },
195
+ ],
196
+ success: 'Hero clean',
197
+ },
198
+ {
199
+ id: 'tooling-paths',
200
+ label: 'Tooling paths',
201
+ checks: [
202
+ script('regen-tooling-paths.sh', 'Tooling-path regen failed'),
203
+ {
204
+ kind: 'drift',
205
+ pathspec: 'claude/skills/aitk-cli/SKILL.md',
206
+ failure:
207
+ 'The overwrite contract drifted from what the stacks hold. Run bun run check and commit claude/skills/aitk-cli/SKILL.md.',
208
+ },
209
+ ],
210
+ success: 'Tooling paths clean',
211
+ },
212
+ {
213
+ // The claude manifest is the only route a target's ignore set travels, and
214
+ // it is hand-maintained beside this repository's own `.gitignore` with
215
+ // nothing comparing the two. A drift between them reaches every target on
216
+ // the next `aitk tooling sync` and surfaces to nobody, which is why this
217
+ // gates rather than reports. It is not a drift assert: no generator
218
+ // produces either list, so there is nothing to regenerate and diff.
219
+ id: 'ignore-parity',
220
+ label: 'Ignore parity',
221
+ checks: [
222
+ script(
223
+ 'check-ignore-parity.sh',
224
+ "The ignore set a target receives disagrees with this repository's own.",
225
+ ),
226
+ ],
227
+ success: 'Ignore parity clean',
228
+ },
229
+ {
230
+ id: 'skill-paths',
231
+ label: 'Skill paths',
232
+ checks: [
233
+ script(
234
+ 'check-skill-paths.sh',
235
+ 'Shipped skills reference a repo-local path.',
236
+ ),
237
+ ],
238
+ success: 'Skill paths clean',
239
+ },
240
+ {
241
+ id: 'plugin-boundary',
242
+ label: 'Plugin boundary',
243
+ checks: [
244
+ script(
245
+ 'check-plugin-boundary.sh',
246
+ 'Plugin ships toolkit-internal content.',
247
+ ),
248
+ ],
249
+ success: 'Plugin boundary clean',
250
+ },
251
+ {
252
+ // Seed prose is installed into every scaffolded project and read there as
253
+ // instruction about that project, so a line naming this repository's CLI
254
+ // hands a target a verb it may not be able to run. This gates for the
255
+ // reason the seed-standards stage below gates: a defect authored once
256
+ // propagates into every project scaffolded after it.
257
+ id: 'seed-independence',
258
+ label: 'Seed independence',
259
+ checks: [
260
+ script('check-seed-independence.sh', 'Seed prose cites the toolkit CLI.'),
261
+ ],
262
+ success: 'Seed prose cites no toolkit CLI',
263
+ },
264
+ {
265
+ // A stack entry naming a rule folder takes every rule in it, which is what
266
+ // stops a new rule from needing a second edit to reach a target. The
267
+ // failure it leaves open is a rule authored into a folder no stack names,
268
+ // which `aitk gov install` never reaches on its own.
269
+ id: 'unreferenced-rules',
270
+ label: 'Unreferenced rules',
271
+ checks: [{ kind: 'measure', measure: unreferencedRules }],
272
+ },
273
+ {
274
+ // Only the citation half of the audit gates. Length, depth, table, and
275
+ // index findings are judgment thresholds, and failing a push on one would
276
+ // make the stage something to route around.
277
+ id: 'context-citations',
278
+ label: 'Context citations',
279
+ checks: [
280
+ {
281
+ kind: 'cli',
282
+ argv: ['context', 'audit', '--citations-only'],
283
+ failure:
284
+ 'A cited context path does not resolve. Run bun src/cli.ts context audit.',
285
+ },
286
+ ],
287
+ success: 'Context citations resolve',
288
+ },
289
+ {
290
+ // A rule citing a file that moved fails silently. The consumed-copy drift
291
+ // stage passes an authored rule and its copy that are wrong together, and
292
+ // nothing else resolves the path until a session opens it. A rule whose
293
+ // frontmatter glob names a directory that moved fails the same way, by
294
+ // never firing again. The classes where absence is correct are separated
295
+ // inside the verb rather than left as a threshold here.
296
+ id: 'rule-citations',
297
+ label: 'Rule citations',
298
+ checks: [
299
+ {
300
+ kind: 'cli',
301
+ argv: ['gov', 'citations'],
302
+ failure:
303
+ 'A path a rule cites, or an internal frontmatter glob, does not resolve. Run bun src/cli.ts gov citations.',
304
+ },
305
+ ],
306
+ success: 'Rule citations resolve',
307
+ },
308
+ {
309
+ id: 'markdown-bans',
310
+ label: 'Markdown bans',
311
+ checks: [{ kind: 'measure', measure: markdownBans }],
312
+ },
313
+ {
314
+ id: 'seed-standards',
315
+ label: 'Seed standards',
316
+ checks: [{ kind: 'measure', measure: seedStandards }],
317
+ },
318
+ {
319
+ // Presence of a required file is a fact, so it gates. The name,
320
+ // description, folder, and requirement-section measures beside it report
321
+ // and are read from a bare run.
322
+ id: 'skill-requirements',
323
+ label: 'Skill requirements',
324
+ checks: [
325
+ {
326
+ kind: 'cli',
327
+ argv: ['claude', 'skills', 'audit', '--requirements-only'],
328
+ failure:
329
+ 'A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit.',
330
+ },
331
+ ],
332
+ success: 'Skill requirements present',
333
+ },
334
+ {
335
+ id: 'standard-criteria',
336
+ label: 'Standard success criteria',
337
+ checks: [{ kind: 'measure', measure: standardCriteria }],
338
+ },
339
+ {
340
+ id: 'sandbox-coverage',
341
+ label: 'Sandbox coverage',
342
+ checks: [{ kind: 'measure', measure: sandboxCoverage }],
343
+ },
344
+ {
345
+ id: 'audit-set',
346
+ label: 'Audit set',
347
+ checks: [{ kind: 'measure', measure: auditSet }],
348
+ },
349
+ {
350
+ id: 'plugin-manifests',
351
+ label: 'Plugin manifests',
352
+ checks: [{ kind: 'measure', measure: pluginManifests }],
353
+ },
354
+ {
355
+ id: 'spelling',
356
+ label: 'Spelling',
357
+ checks: [
358
+ {
359
+ kind: 'command',
360
+ argv: ['bun', 'run', 'check:spell'],
361
+ failure: 'Spell check failed',
362
+ },
363
+ ],
364
+ success: 'Spell check passed',
365
+ },
366
+ {
367
+ id: 'shell',
368
+ label: 'Shell',
369
+ scope: /\.sh$|^package\.json$/,
370
+ skipped: 'Skipped, no shell changes',
371
+ checks: [
372
+ {
373
+ kind: 'command',
374
+ argv: ['bun', 'run', 'check:shell'],
375
+ failure: 'Shell check failed',
376
+ },
377
+ script(
378
+ 'check-color-source.sh',
379
+ 'A color escape is defined outside scripts/lib/ui.sh.',
380
+ ),
381
+ ],
382
+ success: 'Shell check passed',
383
+ },
384
+ {
385
+ id: 'types',
386
+ label: 'Types',
387
+ scope: /^src\/|^tsconfig\.json$|^package\.json$/,
388
+ skipped: 'Skipped, no TypeScript changes',
389
+ checks: [
390
+ {
391
+ kind: 'command',
392
+ argv: ['bun', 'run', 'check:types'],
393
+ failure: 'Typecheck failed',
394
+ },
395
+ ],
396
+ success: 'Typecheck passed',
397
+ },
398
+ {
399
+ id: 'tests',
400
+ label: 'Tests',
401
+ scope: TESTS_SCOPE,
402
+ skipped: 'Skipped, no TypeScript or asserted-corpus changes',
403
+ checks: [
404
+ {
405
+ kind: 'command',
406
+ argv: ['bun', 'run', 'test'],
407
+ failure: 'Tests failed',
408
+ },
409
+ ],
410
+ success: 'Tests passed',
411
+ },
412
+ ]
@@ -21,6 +21,23 @@ export interface SessionRecord {
21
21
  readonly sessionId: string | undefined
22
22
  readonly kind: string | undefined
23
23
  readonly status: string | undefined
24
+ /**
25
+ * The epoch millisecond the client last changed `status`. Measured against
26
+ * the live registry, 23 of 341 usable records carry it, and the one record
27
+ * that has ever carried `status: "waiting"` is not among them, so its
28
+ * absence tracks a client version rather than a record's age alone.
29
+ * Declared here rather than read opportunistically off the parsed object,
30
+ * since this file is what states what the domain reads and an undeclared
31
+ * field read anyway is the drift this domain exists downstream of.
32
+ */
33
+ readonly statusUpdatedAt: number | undefined
34
+ /**
35
+ * The epoch millisecond the client last wrote the record at all, a coarser
36
+ * stamp than `statusUpdatedAt` that a client writes whether or not it also
37
+ * stamps the status change itself. Declared as the fallback dwell source
38
+ * for a record predating the narrower field, per the same reasoning above.
39
+ */
40
+ readonly updatedAt: number | undefined
24
41
  readonly startedAt: number | undefined
25
42
  /**
26
43
  * The process start time the client stamped at launch, compared against the
@@ -23,6 +23,25 @@ export interface ResolvedSession {
23
23
  readonly kind: string
24
24
  readonly status: string
25
25
  readonly startedAt: string | null
26
+ /**
27
+ * The stamp the record carries beside `status`, kept alongside the dwell it
28
+ * computes below for a caller comparing two sessions rather than reading the
29
+ * age of one.
30
+ */
31
+ readonly statusUpdatedAt: string | null
32
+ /**
33
+ * Elapsed milliseconds since `statusUpdatedAt`, falling back to the coarser
34
+ * `updatedAt` when the record predates the narrower field, computed once at
35
+ * read time so three callers do not each convert the raw stamp and each get
36
+ * the clock-skew case wrong. The one record ever measured carrying
37
+ * `status: "waiting"` has exactly this shape: no `statusUpdatedAt`, an
38
+ * `updatedAt` beside it, so a caller reading this field alone would report
39
+ * the single real instance as unmeasured rather than as stalled.
40
+ * Clamped at zero rather than reported negative: a record stamped
41
+ * by a clock running ahead of this one is a skew to absorb, not a session
42
+ * that has not started waiting yet.
43
+ */
44
+ readonly statusDwellMs: number | null
26
45
  /**
27
46
  * The shared git directory every worktree of one repository resolves to,
28
47
  * which is what identifies the repository a row belongs to. A branch name is
@@ -52,6 +71,8 @@ export interface ResolveOptions {
52
71
  readonly dir?: string
53
72
  readonly probes?: LivenessProbes
54
73
  readonly locate?: (cwd: string) => Promise<Located>
74
+ /** The instant the dwell is computed against. Defaults to `Date.now`. */
75
+ readonly now?: () => number
55
76
  }
56
77
 
57
78
  export interface Located {
@@ -123,6 +144,17 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
123
144
  return resolved.length > 0 ? resolved : null
124
145
  }
125
146
 
147
+ /**
148
+ * Falls back to `updatedAt` when `statusUpdatedAt` is absent, since the
149
+ * narrower field is the newer of the two and a record predating it still
150
+ * carries the coarser one. The one record measured with `status: "waiting"`
151
+ * takes exactly this path: no `statusUpdatedAt`, an `updatedAt` beside it.
152
+ */
153
+ function dwellMs(record: SessionRecord, now: number): number | null {
154
+ const stamp = record.statusUpdatedAt ?? record.updatedAt
155
+ return stamp === undefined ? null : Math.max(0, now - stamp)
156
+ }
157
+
126
158
  /**
127
159
  * An absent field is rendered as an absence rather than as a value.
128
160
  *
@@ -131,7 +163,11 @@ export async function repositoryOf(cwd: string): Promise<string | null> {
131
163
  * for data. Null says the record did not carry it, which is the same
132
164
  * distinction the registry draws between an absent folder and an empty one.
133
165
  */
134
- function present(record: SessionRecord, located: Located): ResolvedSession {
166
+ function present(
167
+ record: SessionRecord,
168
+ located: Located,
169
+ now: number,
170
+ ): ResolvedSession {
135
171
  return {
136
172
  name: record.name,
137
173
  pid: record.pid,
@@ -143,6 +179,11 @@ function present(record: SessionRecord, located: Located): ResolvedSession {
143
179
  record.startedAt === undefined
144
180
  ? null
145
181
  : new Date(record.startedAt).toISOString(),
182
+ statusUpdatedAt:
183
+ record.statusUpdatedAt === undefined
184
+ ? null
185
+ : new Date(record.statusUpdatedAt).toISOString(),
186
+ statusDwellMs: dwellMs(record, now),
146
187
  repository: located.repository,
147
188
  worktree: located.worktree,
148
189
  branch: located.branch,
@@ -167,6 +208,7 @@ export async function resolveSessions(
167
208
  ): Promise<SessionReport> {
168
209
  const probes = opts.probes ?? SYSTEM_PROBES
169
210
  const find = opts.locate ?? locate
211
+ const now = opts.now?.() ?? Date.now()
170
212
  const registry = readRegistry(opts.dir)
171
213
 
172
214
  if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
@@ -182,7 +224,7 @@ export async function resolveSessions(
182
224
  }
183
225
 
184
226
  const sessions = await Promise.all(
185
- live.map(async (record) => present(record, await find(record.cwd))),
227
+ live.map(async (record) => present(record, await find(record.cwd), now)),
186
228
  )
187
229
 
188
230
  return { kind: 'resolved', dir: registry.dir, confidence, sessions }