@erclx/aitk 3.46.0 → 3.47.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.
@@ -2,10 +2,13 @@ import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { checkClaim, type ClaimReport } from '@/sessions/claim'
4
4
  import {
5
+ callerIdentity,
5
6
  repositoryOf,
6
7
  type ResolvedSession,
7
8
  resolveSessions,
9
+ type SelfReport,
8
10
  type SessionReport,
11
+ selfOf,
9
12
  } from '@/sessions/resolve'
10
13
  import {
11
14
  intro,
@@ -21,6 +24,7 @@ interface ListCommandOptions {
21
24
  readonly json?: boolean
22
25
  readonly branch?: string
23
26
  readonly repository?: string
27
+ readonly self?: boolean
24
28
  }
25
29
 
26
30
  const REASONS: Record<string, string> = {
@@ -52,6 +56,10 @@ export function register(program: Command): void {
52
56
  '--repository <path>',
53
57
  'Answer about this project rather than the working one',
54
58
  )
59
+ .option(
60
+ '--self',
61
+ "Report the caller's own row, and refuse where the roster holds none",
62
+ )
55
63
  .addHelpText(
56
64
  'after',
57
65
  [
@@ -93,6 +101,19 @@ export function register(program: Command): void {
93
101
  'The match can return more than one session. Read the count rather than',
94
102
  'the first row, since two sessions can hold one branch.',
95
103
  '',
104
+ "--self narrows the report to the caller's own row, which is what a",
105
+ 'dispatcher reads to learn the sessionId it carries into a launch. It',
106
+ 'joins on CLAUDE_CODE_SESSION_ID first, falls back to CLAUDE_PID, and',
107
+ 'falls back again to the pid the messaging socket path spells. It never',
108
+ 'reads CLAUDE_CODE_HOST_SESSION_ID, which holds a value from another',
109
+ 'namespace that matches no row.',
110
+ '',
111
+ 'It refuses with reason "no-self-identity" when the environment states',
112
+ 'none of the three, and "no-self-row" when it states one and no live',
113
+ 'row carries it. The second is the ordinary answer for a session',
114
+ 'driving from Remote Control, which is addressable on the message',
115
+ 'channel and holds no local process record for the roster to report.',
116
+ '',
96
117
  'Each session writes its own working directory beside its own name, so a',
97
118
  'name from a session listing joins to a branch by an exact match rather',
98
119
  'than by ordering the roster on start time.',
@@ -108,6 +129,7 @@ export function register(program: Command): void {
108
129
  ' aitk sessions list --json',
109
130
  ' aitk sessions list --branch feat/parser --json',
110
131
  ' aitk sessions list --branch chore/agents --repository ../caret --json',
132
+ ' aitk sessions list --self --json',
111
133
  '',
112
134
  ].join('\n'),
113
135
  )
@@ -136,6 +158,34 @@ async function runList(opts: ListCommandOptions): Promise<number> {
136
158
  return 1
137
159
  }
138
160
 
161
+ // The roster read returns every row and marks none of them as the caller, so
162
+ // the join runs here, ahead of any scope. Resolving it after the branch
163
+ // filter would answer "no row" for a caller whose row was merely filtered
164
+ // out, which is a different failure wearing the same reason.
165
+ const own = opts.self ? selfOf(report.sessions, callerIdentity()) : null
166
+
167
+ if (own?.kind === 'unresolved') {
168
+ intro('aitk sessions list')
169
+ logStep('Refused')
170
+ logWarn(selfRefusal(own))
171
+ outro()
172
+
173
+ if (opts.json) {
174
+ process.stdout.write(
175
+ `${JSON.stringify({
176
+ dir: report.dir,
177
+ reason:
178
+ own.reason === 'no-identity' ? 'no-self-identity' : 'no-self-row',
179
+ sessions: [],
180
+ })}\n`,
181
+ )
182
+ }
183
+
184
+ return 1
185
+ }
186
+
187
+ const pool = own === null ? report.sessions : [own.session]
188
+
139
189
  // A branch name identifies a branch inside one repository and nothing across
140
190
  // a machine, so an unscoped match reaches a session working in a different
141
191
  // project. `main` is the name that collides on every machine running two.
@@ -164,11 +214,11 @@ async function runList(opts: ListCommandOptions): Promise<number> {
164
214
  }
165
215
 
166
216
  const shown = opts.branch
167
- ? report.sessions.filter(
217
+ ? pool.filter(
168
218
  (session) =>
169
219
  session.branch === opts.branch && session.repository === repository,
170
220
  )
171
- : report.sessions
221
+ : pool
172
222
 
173
223
  const claim = opts.branch
174
224
  ? await checkClaim(opts.branch, { cwd: at, resolve: async () => report })
@@ -176,7 +226,7 @@ async function runList(opts: ListCommandOptions): Promise<number> {
176
226
 
177
227
  intro('aitk sessions list')
178
228
  reportConfidence(report)
179
- reportSessions(shown, opts.branch, repository)
229
+ reportSessions(shown, opts.branch, repository, own !== null)
180
230
  if (claim) reportClaim(claim)
181
231
  outro()
182
232
 
@@ -200,6 +250,22 @@ async function runList(opts: ListCommandOptions): Promise<number> {
200
250
  return 0
201
251
  }
202
252
 
253
+ /**
254
+ * Separates a client that states no identity from a roster holding no row for
255
+ * one it does state, since the two send a reader to different places.
256
+ */
257
+ function selfRefusal(own: Extract<SelfReport, { kind: 'unresolved' }>): string {
258
+ if (own.reason === 'no-identity') {
259
+ return 'Nothing in the environment identifies this session, so --self has nothing to match against. A client setting none of CLAUDE_CODE_SESSION_ID, CLAUDE_PID, or CLAUDE_CODE_MESSAGING_SOCKET cannot be located on the roster at all.'
260
+ }
261
+
262
+ const held =
263
+ own.identity.sessionId ??
264
+ (own.identity.pid === null ? 'nothing' : `pid ${own.identity.pid}`)
265
+
266
+ return `The environment identifies this session as ${held}, and no live row carries it. The roster holds local process records alone, so a session driving through Remote Control never appears here, and a record whose session has ended is dropped ahead of the match.`
267
+ }
268
+
203
269
  /**
204
270
  * States how liveness was decided on every run, including the run that decided
205
271
  * it the strong way.
@@ -239,6 +305,7 @@ function reportSessions(
239
305
  sessions: readonly ResolvedSession[],
240
306
  branch: string | undefined,
241
307
  repository: string | null,
308
+ scoped: boolean,
242
309
  ): void {
243
310
  logStep('Sessions')
244
311
 
@@ -248,11 +315,21 @@ function reportSessions(
248
315
  )
249
316
  }
250
317
 
318
+ // An empty result under --self says nothing about the roster, since the pool
319
+ // was narrowed to one row before the branch filter ran. Reporting the wider
320
+ // answer there would claim a reading this run never took.
251
321
  if (sessions.length === 0) {
322
+ if (branch) {
323
+ logInfo(
324
+ scoped
325
+ ? `This session does not hold ${branch}.`
326
+ : `No live session in this repository holds ${branch}.`,
327
+ )
328
+ return
329
+ }
330
+
252
331
  logInfo(
253
- branch
254
- ? `No live session in this repository holds ${branch}.`
255
- : 'No live session. Every record in the registry belongs to a session that has ended.',
332
+ 'No live session. Every record in the registry belongs to a session that has ended.',
256
333
  )
257
334
  return
258
335
  }
@@ -15,11 +15,33 @@ import { listRepositoryFiles } from '@/git-files'
15
15
  */
16
16
  export const SUPERSEDED_MARKER = 'aitk-allow-superseded'
17
17
 
18
+ /**
19
+ * What matched at one column.
20
+ *
21
+ * `literal` is the superseded value itself. The other three are the stem
22
+ * followed by a glob, by an angle-bracket placeholder, or by nothing that
23
+ * continues a name, which are the three forms this corpus writes a family in.
24
+ * A literal comparison reaches none of them, which is why a rename running the
25
+ * verb once per name reported clean over seven stale citations.
26
+ */
27
+ export type SupersededMatch = 'literal' | 'glob' | 'placeholder' | 'prefix'
28
+
18
29
  export interface SupersededHit {
19
30
  readonly file: string
20
31
  /** One-based, matching the `file:line` form a reader clicks. */
21
32
  readonly line: number
22
33
  readonly column: number
34
+ readonly match: SupersededMatch
35
+ /**
36
+ * The nearest heading above the hit in a markdown file, absent elsewhere and
37
+ * above the first heading.
38
+ *
39
+ * A line reads differently under the section holding it. `Use the aitk-*
40
+ * prefix on an internal skill` is a prohibition under `## Must not` and an
41
+ * instruction anywhere else, and a reviewer reading the line alone made
42
+ * exactly that misreading against this tree.
43
+ */
44
+ readonly heading: string | undefined
23
45
  /**
24
46
  * Whether the replacement sits on the same line, outside the superseded
25
47
  * occurrences themselves.
@@ -45,11 +67,22 @@ export interface SupersededOptions {
45
67
  readonly replacement: string
46
68
  }
47
69
 
70
+ /**
71
+ * The segment pair a templated citation is matched on, reported so a run states
72
+ * the net it cast rather than only what the net caught.
73
+ */
74
+ export interface SupersededStems {
75
+ readonly superseded: string
76
+ readonly replacement: string
77
+ }
78
+
48
79
  export type SupersededReport =
49
80
  | {
50
81
  readonly kind: 'measured'
51
82
  readonly superseded: string
52
83
  readonly replacement: string
84
+ /** Absent when the two values yield no bounded stem to match on. */
85
+ readonly stems: SupersededStems | undefined
53
86
  /** Everything git listed, so the report states its own bound. */
54
87
  readonly listed: number
55
88
  /** Files opened, which is what the verdict actually covers. */
@@ -68,22 +101,181 @@ export type SupersededReport =
68
101
  */
69
102
  const PREVIEW_LIMIT = 200
70
103
 
104
+ /** The separator a family name is built from across this corpus. */
105
+ const SEGMENT_SEPARATOR = '-'
106
+
107
+ /**
108
+ * A character that continues a name, so `aitk-` inside `aitk-cli` is read as
109
+ * one name rather than as the family prefix written bare.
110
+ */
111
+ const NAME_CHARACTER = /[A-Za-z0-9]/
112
+
71
113
  /**
72
- * Whether `replacement` appears on the line somewhere other than inside the
73
- * superseded occurrences, which is what makes the flag mean anything when one
74
- * value contains the other.
114
+ * A character that puts the stem mid-name when it sits directly before it, so
115
+ * a stem is only read where a name starts.
116
+ *
117
+ * The separator is here and not in `NAME_CHARACTER` because it decides one side
118
+ * only. `aitk-check-toolkit-` is a temp-directory prefix and matched the stem
119
+ * `toolkit` on four fixtures before this, where `claude/skills/toolkit-*` is a
120
+ * path and has to keep matching.
121
+ */
122
+ const SEGMENT_CONTINUATION = /[A-Za-z0-9_-]/
123
+
124
+ const HEADING = /^#{1,6}\s+\S/
125
+
126
+ /**
127
+ * A fence opening or closing a code block, tracked so a shell comment inside
128
+ * one is not read as the section a hit below it sits under. A `# Install` line
129
+ * in a bash block is the ordinary shape of that.
130
+ */
131
+ const FENCE = /^\s*(?:```|~~~)/
132
+
133
+ /**
134
+ * The segment the two values differ on, carried with everything they share
135
+ * ahead of it.
136
+ *
137
+ * The shared prefix alone is what this exists against. `aitk-cli` and
138
+ * `aitk-feedback-file` share `aitk`, so a stem cut there matches every sibling
139
+ * and reports the whole family on a rename of one folder. Including the
140
+ * differing segment bounds the net to what actually changed, which leaves
141
+ * `aitk-cli` to `aitk-shell` matching neither sibling and `toolkit-operator` to
142
+ * `aitk-operator` matching the family prefix that did move.
143
+ *
144
+ * An empty replacement yields nothing. Retiring a value outright leaves no
145
+ * second value to diverge from, so every stem would run to the first segment
146
+ * and match the family the retirement never touched.
147
+ */
148
+ export function deriveStems(
149
+ options: SupersededOptions,
150
+ ): SupersededStems | undefined {
151
+ if (options.replacement === '') return undefined
152
+
153
+ const supersededSegments = options.superseded.split(SEGMENT_SEPARATOR)
154
+ const replacementSegments = options.replacement.split(SEGMENT_SEPARATOR)
155
+
156
+ let index = 0
157
+ while (
158
+ index < supersededSegments.length &&
159
+ index < replacementSegments.length &&
160
+ supersededSegments[index] === replacementSegments[index]
161
+ ) {
162
+ index += 1
163
+ }
164
+
165
+ const superseded = trimSeparators(
166
+ supersededSegments.slice(0, index + 1).join(SEGMENT_SEPARATOR),
167
+ )
168
+ const replacement = trimSeparators(
169
+ replacementSegments.slice(0, index + 1).join(SEGMENT_SEPARATOR),
170
+ )
171
+
172
+ if (superseded === '' || replacement === '') return undefined
173
+ if (superseded === replacement) return undefined
174
+
175
+ return { superseded, replacement }
176
+ }
177
+
178
+ function trimSeparators(value: string): string {
179
+ let end = value.length
180
+ while (end > 0 && value[end - 1] === SEGMENT_SEPARATOR) end -= 1
181
+ return value.slice(0, end)
182
+ }
183
+
184
+ /**
185
+ * Which templated form sits at `column`, or nothing when the stem there
186
+ * continues into an ordinary name.
187
+ *
188
+ * The character after `<stem>-` decides all three, so one scan reads every
189
+ * form. A name character means a sibling spelled out, which the literal
190
+ * comparison already answers for or correctly ignores.
191
+ */
192
+ function classifyStem(
193
+ line: string,
194
+ column: number,
195
+ stem: string,
196
+ ): SupersededMatch | undefined {
197
+ const before = column === 0 ? '' : line[column - 1]
198
+ if (before !== '' && SEGMENT_CONTINUATION.test(before)) return undefined
199
+
200
+ const after = line[column + stem.length + 1] ?? ''
201
+ if (after === '*') return 'glob'
202
+ if (after === '<') return 'placeholder'
203
+ if (after !== '' && NAME_CHARACTER.test(after)) return undefined
204
+ return 'prefix'
205
+ }
206
+
207
+ /**
208
+ * Whether the value replacing whatever matched appears on the line somewhere
209
+ * other than inside the matched occurrences, which is what makes the flag mean
210
+ * anything when one value contains the other.
75
211
  *
76
212
  * An empty replacement carries nothing, so it answers false rather than the
77
213
  * true every line returns from a containment test against the empty string.
78
214
  * Retiring a value outright is what passes one, and reporting every finding as
79
215
  * carrying its replacement there says the opposite of what happened.
216
+ *
217
+ * A templated hit reads the stem pair instead, since the line repairing
218
+ * `toolkit-*` carries `aitk-*` and never the full name either value spells.
80
219
  */
81
- function carriesReplacement(line: string, options: SupersededOptions): boolean {
82
- if (options.replacement === '') return false
83
- return line.split(options.superseded).join('').includes(options.replacement)
220
+ function carriesReplacement(
221
+ line: string,
222
+ options: SupersededOptions,
223
+ stems: SupersededStems | undefined,
224
+ match: SupersededMatch,
225
+ ): boolean {
226
+ if (match === 'literal') {
227
+ if (options.replacement === '') return false
228
+ return line.split(options.superseded).join('').includes(options.replacement)
229
+ }
230
+
231
+ if (stems === undefined) return false
232
+ const matched = `${stems.superseded}${SEGMENT_SEPARATOR}`
233
+ return line
234
+ .split(matched)
235
+ .join('')
236
+ .includes(`${stems.replacement}${SEGMENT_SEPARATOR}`)
84
237
  }
85
238
 
86
- /** Every occurrence of `superseded` in one file's text, exemptions separated. */
239
+ /**
240
+ * Every column in one line carrying the superseded value or its family stem,
241
+ * ordered left to right.
242
+ *
243
+ * A stem occurrence sharing a column with a literal one is dropped. The stem is
244
+ * a prefix of the value it derives from whenever only the last segment moved,
245
+ * so the same text would otherwise report twice under two kinds.
246
+ */
247
+ function matchLine(
248
+ line: string,
249
+ options: SupersededOptions,
250
+ stems: SupersededStems | undefined,
251
+ ): { column: number; match: SupersededMatch }[] {
252
+ const matches: { column: number; match: SupersededMatch }[] = []
253
+
254
+ let column = line.indexOf(options.superseded)
255
+ while (column !== -1) {
256
+ matches.push({ column, match: 'literal' })
257
+ column = line.indexOf(options.superseded, column + 1)
258
+ }
259
+
260
+ if (stems !== undefined) {
261
+ const pattern = `${stems.superseded}${SEGMENT_SEPARATOR}`
262
+ let at = line.indexOf(pattern)
263
+ while (at !== -1) {
264
+ const form = classifyStem(line, at, stems.superseded)
265
+ if (form !== undefined && !matches.some((hit) => hit.column === at)) {
266
+ matches.push({ column: at, match: form })
267
+ }
268
+ at = line.indexOf(pattern, at + 1)
269
+ }
270
+ }
271
+
272
+ return matches.sort((first, second) => first.column - second.column)
273
+ }
274
+
275
+ /**
276
+ * Every occurrence of `superseded` or of its family stem in one file's text,
277
+ * exemptions separated.
278
+ */
87
279
  export function sweepText(
88
280
  file: string,
89
281
  text: string,
@@ -92,10 +284,19 @@ export function sweepText(
92
284
  const lines = text.split('\n')
93
285
  const findings: SupersededHit[] = []
94
286
  const exempt: SupersededHit[] = []
287
+ const stems = deriveStems(options)
288
+ const sectioned = file.endsWith('.md')
289
+ let heading: string | undefined
290
+ let fenced = false
95
291
 
96
292
  for (const [index, line] of lines.entries()) {
97
- let column = line.indexOf(options.superseded)
98
- if (column === -1) continue
293
+ if (sectioned) {
294
+ if (FENCE.test(line)) fenced = !fenced
295
+ else if (!fenced && HEADING.test(line)) heading = line.trim()
296
+ }
297
+
298
+ const matches = matchLine(line, options, stems)
299
+ if (matches.length === 0) continue
99
300
 
100
301
  const muted = isMarked(lines, index, SUPERSEDED_MARKER)
101
302
  const bucket = muted ? exempt : findings
@@ -104,17 +305,17 @@ export function sweepText(
104
305
  trimmed.length > PREVIEW_LIMIT
105
306
  ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
106
307
  : trimmed
107
- const alsoReplacement = carriesReplacement(line, options)
108
308
 
109
- while (column !== -1) {
309
+ for (const { column, match } of matches) {
110
310
  bucket.push({
111
311
  file,
112
312
  line: index + 1,
113
313
  column: column + 1,
114
- carriesReplacement: alsoReplacement,
314
+ match,
315
+ heading,
316
+ carriesReplacement: carriesReplacement(line, options, stems, match),
115
317
  preview,
116
318
  })
117
- column = line.indexOf(options.superseded, column + 1)
118
319
  }
119
320
  }
120
321
 
@@ -135,9 +336,15 @@ export function sweepText(
135
336
  * what was listed, opened, and skipped are what keep it from reading as a
136
337
  * verdict over the whole tree.
137
338
  *
339
+ * Beside the literal comparison it matches the family stem the two values
340
+ * differ on, so a citation writing the family as a pattern enters the report.
341
+ * The trade is false positives, bounded at one across a sixteen-folder rename,
342
+ * which is why this reports rather than gates.
343
+ *
138
344
  * What it cannot see is a prose reference that went stale without carrying the
139
- * value, such as a declaration citing the wrong standard for the transform. A
140
- * value sweep closes most of this class and no part of that one.
345
+ * value, such as a declaration citing the wrong standard for the transform, and
346
+ * a family written in a form neither the value nor the three stem shapes reach.
347
+ * A value sweep closes most of this class and no part of either one.
141
348
  */
142
349
  export async function readSuperseded(
143
350
  root: string,
@@ -198,6 +405,7 @@ export async function readSuperseded(
198
405
  kind: 'measured',
199
406
  superseded: options.superseded,
200
407
  replacement: options.replacement,
408
+ stems: deriveStems(options),
201
409
  listed: listed.length,
202
410
  files,
203
411
  skipped,
@@ -0,0 +1,167 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { PROJECT_ROOT } from '@/project-root'
5
+ import { stateDir } from '@/targets/registry'
6
+
7
+ const CLI = join(PROJECT_ROOT, 'src/cli.ts')
8
+
9
+ /** No case has ever needed longer, and a blocked verb should fail fast. */
10
+ const DEFAULT_TIMEOUT_MS = 10_000
11
+
12
+ export interface ProcessRun {
13
+ readonly status: number | null
14
+ readonly stdout: string
15
+ readonly stderr: string
16
+ readonly json: unknown
17
+ }
18
+
19
+ export interface RunCliOptions {
20
+ readonly cwd: string
21
+ readonly env?: NodeJS.ProcessEnv
22
+ readonly timeoutMs?: number
23
+ }
24
+
25
+ /**
26
+ * Thrown when a case reaches past its declared temporary directory into this
27
+ * machine's real toolkit state. `stateDir()` in `src/targets/registry.ts`
28
+ * holds both the target registry `gov install` and `gov sync` record into and
29
+ * the sandbox tree `aitk sandbox` provisions into, so a case that inherits the
30
+ * real `HOME` unmodified writes into whichever of the two a verb touches, and
31
+ * nothing but this check would ever say so.
32
+ */
33
+ export class ContainmentViolation extends Error {}
34
+
35
+ /**
36
+ * Compares two snapshots of this machine's real toolkit state directory and
37
+ * reports whether a spawn changed it. A pure comparison over the two reads
38
+ * rather than the read itself, so the detection logic is testable without
39
+ * touching the filesystem or spawning anything.
40
+ */
41
+ export function detectStateLeak(
42
+ before: string | undefined,
43
+ after: string | undefined,
44
+ ): boolean {
45
+ return before !== after
46
+ }
47
+
48
+ /**
49
+ * A sorted `path:size` listing of every file under this machine's real
50
+ * `stateDir()`, walked recursively rather than read one level deep, so a
51
+ * write nested inside an existing folder, such as a file the sandbox tree
52
+ * already holds, shows up the same as a new top-level entry. Reading a single
53
+ * known file, such as the target registry alone, would miss every sibling
54
+ * `stateDir()` grows, which is what left the sandbox tree unwatched.
55
+ */
56
+ export function snapshotStateDir(): string {
57
+ const root = stateDir()
58
+ const rows: string[] = []
59
+
60
+ function walk(dir: string): void {
61
+ let names: string[]
62
+ try {
63
+ names = readdirSync(dir)
64
+ } catch {
65
+ return
66
+ }
67
+
68
+ for (const name of names.sort()) {
69
+ const full = join(dir, name)
70
+ let info: ReturnType<typeof statSync>
71
+ try {
72
+ info = statSync(full)
73
+ } catch {
74
+ continue
75
+ }
76
+ if (info.isDirectory()) walk(full)
77
+ else rows.push(`${full}:${info.size}`)
78
+ }
79
+ }
80
+
81
+ walk(root)
82
+ return rows.join('\n')
83
+ }
84
+
85
+ /**
86
+ * Spawns the real entry point rather than calling a command's action function
87
+ * in-process, so a case answers whether a verb is registered, whether it
88
+ * exits the way its own contract states, and whether its `--json` record
89
+ * parses off stdout alone, none of which an in-process call can misreport.
90
+ *
91
+ * A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
92
+ * reads against this checkout instead of the temporary directory a case
93
+ * builds, so every spawn drops the `GIT_` prefix before adding the headless
94
+ * flag every case needs to avoid a picker blocking on stdin.
95
+ *
96
+ * `AITK_STATE_DIR` and `AITK_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
97
+ * each pointed at a folder under the case's own `cwd` rather than dropped,
98
+ * since dropping either alone would still resolve through the inherited
99
+ * `HOME` to this machine's real `~/.local/state/aitk`. `stateDir()` and
100
+ * `sandboxTree()` resolve the same three ways and share that parent, so both
101
+ * overrides move together. A case explicitly passing its own value through
102
+ * `options.env` still wins, matching `AITK_NON_INTERACTIVE` below.
103
+ *
104
+ * The `stateDir()` snapshot before and after the spawn is what actually
105
+ * catches an escape past that redirection, since a default can be wrong in a
106
+ * way a case never asserts on its own, and it is what `AITK_SANDBOX_DIR`
107
+ * rides for free: the sandbox tree already sits under `stateDir()`, so
108
+ * walking the whole directory catches a leak there with no override of its
109
+ * own to add. `ContainmentViolation` fails loud rather than leaving a dead
110
+ * row for a reviewer to find on a real machine.
111
+ */
112
+ export function runCli(
113
+ args: readonly string[],
114
+ options: RunCliOptions,
115
+ ): ProcessRun {
116
+ const inherited = Object.fromEntries(
117
+ Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')),
118
+ )
119
+
120
+ const before = snapshotStateDir()
121
+
122
+ const result = spawnSync('bun', [CLI, ...args], {
123
+ cwd: options.cwd,
124
+ encoding: 'utf8',
125
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
126
+ env: {
127
+ ...inherited,
128
+ AITK_NON_INTERACTIVE: '1',
129
+ AITK_STATE_DIR: join(options.cwd, '.aitk-state'),
130
+ AITK_SANDBOX_DIR: join(options.cwd, '.aitk-state', 'sandbox'),
131
+ ...options.env,
132
+ },
133
+ })
134
+
135
+ const after = snapshotStateDir()
136
+ if (detectStateLeak(before, after)) {
137
+ throw new ContainmentViolation(
138
+ `A case wrote into this machine's real toolkit state at ${stateDir()}. ` +
139
+ 'Every process-tier case must stay inside the directory it declared.',
140
+ )
141
+ }
142
+
143
+ return {
144
+ status: result.status,
145
+ stdout: result.stdout,
146
+ stderr: result.stderr,
147
+ json: parseJson(result.stdout),
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Data goes to stdout and framing to stderr, so a harness reading `--json`
153
+ * off the merged output would assert against a record no verb ever wrote. A
154
+ * command that emits no JSON, or fails before it gets there, leaves the
155
+ * field `undefined` rather than throwing, so a case asserting the exit code
156
+ * of a refusal is not also forced to guard a parse.
157
+ */
158
+ function parseJson(stdout: string): unknown {
159
+ const trimmed = stdout.trim()
160
+ if (trimmed === '') return undefined
161
+
162
+ try {
163
+ return JSON.parse(trimmed)
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }