@erclx/aitk 3.46.0 → 3.47.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.46.0",
4
+ "version": "3.47.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -82,7 +82,9 @@ The session also records nothing of what it learns. Both other callers of memory
82
82
  - Check a candidate branch is unclaimed by an existing worktree or a live session before dispatching a background worker for it, since the measured failure this closes is a worker colliding with someone else's work already sitting in the row
83
83
  - Dispatch only a candidate whose file set is disjoint from every track in flight, compared at the file path rather than a folder above it, since a count knows nothing about what two workers write
84
84
  - Hold a candidate whose sets are disjoint when a stated reason serializes it, and write that reason on the hold, since disjointness is necessary and not sufficient
85
- - Name each self-dispatched worker with the `orchestrator-` prefix, since that is what separates one from an operator's own launch in a session listing
85
+ - Name each self-dispatched worker with the `worker-` prefix, since that is the role the name marks and no controlling session has ever carried it
86
+ - Pass `-n` explicitly on every self-dispatch, since a launch that omits it leaves the client to derive a name and the fallback is a fragment of the session's own identifier
87
+ - Carry the dispatcher's `sessionId` in the launch prompt, since a worker owed two messages has no property on the roster that resolves a controller and a name goes stale inside the window it builds in
86
88
  - Report each self-dispatch and the row it fired against loudly enough to follow, since a person no longer watches the launch step happen
87
89
  - Stop dispatching once `## Run now` is empty or every row in it reads claimed, rather than waking again to fire on a board nobody is clearing
88
90
 
@@ -54,15 +54,31 @@ Name `<model>` on the launch, and pick it against the task rather than copying w
54
54
  ## Dispatch
55
55
 
56
56
  ```bash
57
- claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship <plan>"
57
+ claude --bg --model <model> -n "worker-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship <plan>. Your controller is the session whose sessionId is <dispatcher-id>. Resolve its current name from that id at the moment you send, and never resolve an addressee by name prefix. Message it when the pull request opens, carrying the number, the branch, the head sha, the CI state, and every point you departed from the plan on, and message it again if you stop on a question."
58
58
  ```
59
59
 
60
- `--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name that tells a self-dispatched worker from an operator's own launch in `aitk sessions list`, and `--model` overrides the inheritance the section above measured. Keep the `orchestrator-` prefix. It outlives the count it was introduced for, since the roster read still needs a way to separate the two kinds of launch.
60
+ `--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name that tells a self-dispatched worker from an operator's own launch in `aitk sessions list`, and `--model` overrides the inheritance the section above measured. Pass `-n` on every dispatch rather than letting the client derive one. A launch that omits it leaves the session named for a fragment of its own identifier, which is both its address on the send channel and the whole of what the operator sees for it in agent view.
61
+
62
+ The prefix reads `worker-` because that is the role it marks. It read `orchestrator-` until 2026-08-31, and no controlling session ever carried it, so a worker filtering the roster for that string found a sibling or itself on every row. Nothing matches the prefix programmatically, which is what kept the rename down to three strings.
63
+
64
+ Read `<dispatcher-id>` with `aitk sessions list --self --json` and interpolate the `sessionId` that row carries. Carry the id rather than the name. A name is derived from whatever the session turned out to be doing, and across the 181 records stamping both fields, nine were renamed after launch at a median of 5.4 minutes and a maximum of 509. Three landed more than ten minutes in, which is inside the window a worker announces its pull request in, so a name written into the prompt is aimed at a send that happens after it goes stale.
65
+
66
+ Where the installed CLI answers `--self` with an unknown option, that flag is newer than the release the target holds. Read the `sessionId` from the record the client writes for this session under its configuration directory, and say which route answered so the reader knows whether the id was read or inferred.
61
67
 
62
68
  The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
63
69
 
64
70
  Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
65
71
 
72
+ ### What the brief may carry
73
+
74
+ The prompt carries pointers and standing context, and stops there. The branch and the plan stay arguments, because a skill resolves an argument through a documented ladder and reads no prose at all. What the prose reaches is the worker's judgment, so it holds only what a session has to weigh:
75
+
76
+ - Name the addressee and what it is owed, which the two message clauses above already do.
77
+ - Carry standing context this session holds that a cold one cannot derive, such as a constraint settled in conversation that never reached the plan.
78
+ - Leave out anything scope-shaped. A file list, a naming convention, or a check to run belongs in the plan, where the review reads it back against the diff.
79
+
80
+ The last bullet is the one under pressure, since the dispatch that first proved unattended work possible sent a prompt naming the task file, the likely files, the conventions, and the check to run. It shipped in 874 seconds and touched four files its task never named. Scope that arrives as prose is scope nothing verifies afterward.
81
+
66
82
  Report the dispatch as loudly as the human-launch line it replaces: name the branch, the model, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
67
83
 
68
84
  ## Fall back to the human
@@ -41,10 +41,47 @@ nothing else.
41
41
  - Send a block out as a message before it becomes an interactive prompt. A session already waiting on input never reaches the tool round that drains an inbound message, so a relayed answer arrives under the open question and changes nothing.
42
42
  - Send nothing on progress. A worker reporting progress rebuilds, on this side of the channel, the poll the announcement retired on the other.
43
43
 
44
- Address the session the launch named. Fall back to `aitk sessions list --json`
45
- and the `orchestrator-` prefix when the launch named nobody, which is what an
46
- operator's own launch looks like, and say the addressee was inferred so the
47
- reader can correct it.
44
+ Address the session the launch named. It names a `sessionId` rather than a name,
45
+ so read `aitk sessions list --json`, find the row carrying that id, and send to
46
+ the `name` on it. Resolve that name at the moment of sending rather than at
47
+ launch, since a name is derived from what a session turned out to be doing and
48
+ has gone stale inside the hour that a build takes.
49
+
50
+ Check that name against the agent listing before sending it. A name is not
51
+ unique, and the roster carries no field that separates two live sessions holding
52
+ one, so the resolution can end on a string that reaches the wrong session. Send
53
+ the name bare where the listing shows one row under it. Where it shows more than
54
+ one, complete the address with the `[ref]` that listing prints beside each row
55
+ rather than sending to the name alone, which lands on whichever row the channel
56
+ resolves first. Two sessions differing only by a trailing ` (3)` is the shape
57
+ this meets in practice, and one of them being a controller is what makes the
58
+ wrong pick silent.
59
+
60
+ Ask the operator when the launch named nobody and a person is there to answer.
61
+ Put the candidate rows to them through the structured question surface, so they
62
+ pick a row rather than recalling a name. The ask halts the build, and the halt
63
+ is the cheaper error: a worker that cannot reach its controller has nothing
64
+ useful to do with the message it owes, where sending to the wrong session
65
+ reports success and loses it. A halt is only as visible as whatever watches for
66
+ one, so say what you are waiting on in the same turn you stop, and expect a
67
+ controller running no stall detector to find the question only when it next
68
+ looks.
69
+
70
+ Infer only where no operator is present. Read `aitk sessions list --json` and
71
+ take the sessions holding no feature branch as the candidates, since a
72
+ controlling session holds none. Say the addressee was inferred so the reader can
73
+ correct it. Never filter that roster by name prefix: every self-dispatched
74
+ worker is named `worker-<slug>`, so a prefix scan returns a sibling or this
75
+ session itself, which is the defect that sent messages owed to a controller
76
+ somewhere else.
77
+
78
+ Report a resolution that returns nothing rather than falling back to a guess.
79
+ The roster and the send channel enumerate different populations in both
80
+ directions, measured at one moment: a live background session sat on the roster
81
+ that the agent listing did not carry, and nine sessions were addressable there
82
+ with no roster row at all. Those nine were driving through Remote Control, which
83
+ writes no local process record, so a controller working from a phone is exactly
84
+ the case this read answers nothing for.
48
85
 
49
86
  ## Refusing is part of the job
50
87
 
@@ -30,7 +30,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
30
30
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
31
31
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
32
32
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
33
- - [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
33
+ - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
34
34
  - [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
35
35
  - [Citation reach](skills-reach.md): Reporting the skill bodies that cite a path no target project receives, which corpus the verb reads, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
36
36
  - [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Sessions
3
- description: Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
3
+ description: Resolving live peer sessions to the worktree and branch each holds, reading which row is the caller, the liveness confidence field, and what each session surface can see
4
4
  ---
5
5
 
6
6
  # Sessions
@@ -14,6 +14,7 @@ aitk sessions list
14
14
  aitk sessions list --json
15
15
  aitk sessions list --branch feat/parser --json
16
16
  aitk sessions list --branch chore/agents --repository ../caret --json
17
+ aitk sessions list --self --json
17
18
  ```
18
19
 
19
20
  | Option | Behavior |
@@ -21,10 +22,11 @@ aitk sessions list --branch chore/agents --repository ../caret --json
21
22
  | `--json` | Add a machine-readable record on stdout |
22
23
  | `--branch <name>` | Report the sessions holding this branch |
23
24
  | `--repository <path>` | Answer about this project rather than the working one |
25
+ | `--self` | Report the caller's own row |
24
26
 
25
27
  It reads and never writes. The question it answers is which session to address when work has to reach the one holding a given branch, which a session listing cannot answer on its own.
26
28
 
27
- Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry` or `no-repository`.
29
+ Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry`, `no-repository`, `no-self-identity`, or `no-self-row`.
28
30
 
29
31
  An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this.
30
32
 
@@ -52,6 +54,29 @@ The ref read covers the local head and the remote-tracking ref, which means it s
52
54
 
53
55
  Two flags say which reading came up short. `sessionsReadable` is `false` when the session roster could not be read, and `refsReadable` is `false` when the ref read failed. Either one leaves `claimed` covering the readings around it alone, so a `false` there is a report that ran short of evidence rather than a report that the branch is clear. They stay separate fields because a caller told the roster failed goes and looks at the roster, and folding both into one flag would send it to the wrong place.
54
56
 
57
+ ## Which row is the caller
58
+
59
+ `--self` narrows the report to the row belonging to the session making the call. A dispatcher reads it to learn the `sessionId` it carries into a launch, because the roster returns every field on every row and marks none of them as the caller.
60
+
61
+ Three identifier namespaces reach a session and two of them join to a row. Four variables spell them:
62
+
63
+ - `CLAUDE_CODE_SESSION_ID` holds the roster's own `sessionId`, and is read first because it survives a rename.
64
+ - `CLAUDE_PID` holds the caller's process id, which every row already carries as `pid`.
65
+ - `CLAUDE_CODE_MESSAGING_SOCKET` spells that same pid in its basename. It is read last, since the spelling is a client convention rather than a published interface and a client that moves the socket drops this rung alone.
66
+ - `CLAUDE_CODE_HOST_SESSION_ID` is never read. It carries a `local_`-prefixed value from the harness namespace that matches no row, and it is the variable a reader searching the environment for a session id finds first.
67
+
68
+ The read refuses rather than returning an empty roster, on the same ground as `no-registry`. A `no-self-identity` refusal means the environment stated none of the three, which is a client identifying its sessions some other way. A `no-self-row` refusal means it stated one and no live row carries it, which is what a session running outside a local process gets.
69
+
70
+ ## What each surface can see
71
+
72
+ The roster this verb reads and the channel a session sends messages on enumerate different populations, and neither contains the other. Read at one moment, `aitk sessions list` returned 6 rows against the 13 peers plus caller the agent listing reported. The roster held a background session at confirmed confidence on two reads a minute apart that the listing never carried, so a live session sat here with no way to reach it. Nine Remote Control sessions ran the other way, reachable there with no row here at all, since they leave no local process for the registry to record.
73
+
74
+ That bounds what a carried identifier buys. Resolving a `sessionId` to a name here can produce a name the send channel rejects, and `--self` answers nothing for a controller driving from Remote Control, which is the operator working from their phone rather than an edge case. A caller that cannot resolve an addressee reports the failed resolution instead of falling back to a guess.
75
+
76
+ The `name` field is what both surfaces agree on, and that agreement is what makes the resolution work. `SendMessage` takes a name and carries no identifier parameter, so the carry runs id to name to send rather than sending an id anywhere.
77
+
78
+ Agreement is not uniqueness, and the last hop is where that bites. Two live sessions can hold one name, the roster carries no field that separates them, and a send to the bare name reaches whichever the channel resolves first. The agent listing prints a ref beside each row and the roster does not, so a name resolving to more than one live row is completed from that listing rather than from anything this verb returns.
79
+
55
80
  ## Why the verb exists
56
81
 
57
82
  A session listing reports a name, a kind, a status, and how long each session has been running. None of those names a branch. Resolving a branch to a session therefore meant ordering the roster by start time and matching it against the order the worktrees were created, which is an inference that fails whenever two sessions start inside the same minute.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.46.0",
4
+ "version": "3.47.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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
  }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { basename } from 'node:path'
1
2
  import { $ } from 'bun'
2
3
  import { gitEnv } from '@/git-env'
3
4
  import {
@@ -186,3 +187,105 @@ export async function resolveSessions(
186
187
 
187
188
  return { kind: 'resolved', dir: registry.dir, confidence, sessions }
188
189
  }
190
+
191
+ /** The environment slice the caller's own identity is read from. */
192
+ export type Env = Record<string, string | undefined>
193
+
194
+ /** Why the caller's own row could not be named, rather than an empty result. */
195
+ export type SelfUnresolved = 'no-identity' | 'no-row'
196
+
197
+ /**
198
+ * What the environment offers about the session making the call.
199
+ *
200
+ * Both fields are candidates rather than a pair, since a client sets them
201
+ * independently and a caller can arrive carrying either one alone.
202
+ */
203
+ export interface SelfIdentity {
204
+ readonly sessionId: string | null
205
+ readonly pid: number | null
206
+ }
207
+
208
+ export type SelfReport =
209
+ | { readonly kind: 'self'; readonly session: ResolvedSession }
210
+ | {
211
+ readonly kind: 'unresolved'
212
+ readonly reason: SelfUnresolved
213
+ readonly identity: SelfIdentity
214
+ }
215
+
216
+ /**
217
+ * A positive integer, which is what separates a pid from a socket named for
218
+ * something else. Signal zero addresses the caller's own process group, so a
219
+ * zero would match a row rather than failing to.
220
+ */
221
+ function pidOf(value: string | undefined): number | null {
222
+ if (value === undefined) return null
223
+ const pid = Number(value.trim())
224
+ return Number.isInteger(pid) && pid > 0 ? pid : null
225
+ }
226
+
227
+ /**
228
+ * Reads whatever the environment states about the calling session.
229
+ *
230
+ * Three identifier namespaces are in play and only two of them join to a
231
+ * roster row. `CLAUDE_CODE_SESSION_ID` is the roster's own `sessionId` and is
232
+ * read first because it survives a rename, where the name a session carries is
233
+ * derived from what it turned out to be doing and rotates while a build runs.
234
+ * `CLAUDE_CODE_HOST_SESSION_ID` is deliberately never read: it holds a
235
+ * `local_`-prefixed value from the harness namespace that matches no row, and
236
+ * it is the variable a reader searching the environment for a session id finds
237
+ * first.
238
+ *
239
+ * The socket path is the last rung because its basename is the caller's pid by
240
+ * a client convention rather than a published interface, so a client that
241
+ * moves it drops this rung while leaving the two above it standing.
242
+ */
243
+ export function callerIdentity(env: Env = process.env): SelfIdentity {
244
+ const stated = env.CLAUDE_CODE_SESSION_ID?.trim()
245
+ const socket = env.CLAUDE_CODE_MESSAGING_SOCKET
246
+
247
+ return {
248
+ sessionId: stated !== undefined && stated.length > 0 ? stated : null,
249
+ pid:
250
+ pidOf(env.CLAUDE_PID) ??
251
+ pidOf(
252
+ socket === undefined
253
+ ? undefined
254
+ : basename(socket).replace(/\.sock$/, ''),
255
+ ),
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Names which row of a roster belongs to the caller.
261
+ *
262
+ * The read that already returns every field marks none of them as the caller,
263
+ * so this performs the join rather than adding a source. An identity the
264
+ * environment does not carry and an identity no row matches are separated,
265
+ * because the first is a client that states nothing and the second is a
266
+ * session the roster cannot see. A session driving from Remote Control is the
267
+ * measured instance of the second: it is addressable on the message channel
268
+ * and holds no local process record for the roster to report.
269
+ */
270
+ export function selfOf(
271
+ sessions: readonly ResolvedSession[],
272
+ identity: SelfIdentity,
273
+ ): SelfReport {
274
+ if (identity.sessionId === null && identity.pid === null) {
275
+ return { kind: 'unresolved', reason: 'no-identity', identity }
276
+ }
277
+
278
+ const byId =
279
+ identity.sessionId === null
280
+ ? undefined
281
+ : sessions.find((session) => session.sessionId === identity.sessionId)
282
+ const byPid =
283
+ identity.pid === null
284
+ ? undefined
285
+ : sessions.find((session) => session.pid === identity.pid)
286
+ const match = byId ?? byPid
287
+
288
+ return match === undefined
289
+ ? { kind: 'unresolved', reason: 'no-row', identity }
290
+ : { kind: 'self', session: match }
291
+ }