@erclx/aitk 0.91.1 → 0.92.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": "0.91.1",
4
+ "version": "0.92.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -39,7 +39,7 @@ Poll GitHub for pull request movement by running <POLL_SCRIPT>, then act on what
39
39
  - A line starting `poll:`: report it verbatim and treat that pull request as unread this run. It is a failed query, not a state.
40
40
  - Nothing changed: say exactly "No movement." and nothing else.
41
41
 
42
- After any pass that posts a critical or should-fix finding, tell the session holding that branch to run the aitk:claude-address-review skill, resolving it from a session listing taken at that moment. Name the worktree and branch you believe the reader holds and ask to be corrected. Report the invocation for me instead when no live session holds it. A pass carrying only minor findings dispatches nobody.
42
+ After any pass that posts a critical or should-fix finding, tell the session holding that branch to run the aitk:claude-address-review skill. Resolve the target by running `aitk sessions list --branch <branch> --json` at that moment, which scopes the match to this repository, then route on how many sessions it returned. Zero: report the invocation for me and dispatch nobody. Exactly one, with the confidence field reading "confirmed": address that name directly. Any other count, any other confidence, or a command that is missing or refuses: fall back to picking from a session listing, open by naming the worktree and branch you believe the reader holds, and ask to be corrected. Two sessions can hold one branch, so read the count rather than the first row. A pass carrying only minor findings dispatches nobody.
43
43
  ```
44
44
 
45
45
  ## Reading the output
@@ -15,7 +15,9 @@ The third failure is duplication. A front door that answers everything itself re
15
15
 
16
16
  A fourth failure sits between the router and what it runs. This skill executes the install and sync commands whose effect on an existing file is invisible from the command line, and the reference stating that effect per surface is named in the boundary below, which Claude Code never loads. The router therefore overwrites a customized golden config with the answer sitting one skill away and unread.
17
17
 
18
- The last failure is writing without re-checking. A domain sync brought a stricter standard into a target and left ten of its eleven context entries non-compliant in the same moment, and the run reported success because reporting the command was where its work ended. A person found the breakage afterwards by running an audit by hand. The audit that would have caught it is built and reachable from nothing the router offers, so one session held both the defect and its detector and connected neither to the other.
18
+ The fifth failure is writing without re-checking. A domain sync brought a stricter standard into a target and left ten of its eleven context entries non-compliant in the same moment, and the run reported success because reporting the command was where its work ended. A person found the breakage afterwards by running an audit by hand. The audit that would have caught it is built and reachable from nothing the router offers, so one session held both the defect and its detector and connected neither to the other.
19
+
20
+ The last failure is a section no route reaches. `## Route` maps an intent or a diagnostic finding to one lifecycle phase, so audit offers sitting below that table with no row naming them are unreachable by a correct reading. Two runs asked what the toolkit could measure and both took the scaffold handoff, the row the table declares. A route in settles half of it, since a project carrying a context folder and no installed domain fires the scaffold row and two audit conditions at once, and a body ranking neither leaves the choice to whichever the session read last.
19
21
 
20
22
  ## Must
21
23
 
@@ -26,6 +28,8 @@ The last failure is writing without re-checking. A domain sync brought a stricte
26
28
  - Read the overwrite contract from the body before any install or sync, and name each surface it lists as overwritten before running one
27
29
  - Read the reverse walk beside the forward sections, so a folder the toolkit stopped shipping and a migration with a live case both reach the user
28
30
  - Hand off a named migration to the skill the report names, the way an unmigrated domain reaches `migration-standards`
31
+ - Route a measurement intent to the audit offers from the route table itself, rather than leaving the section reachable only by reading past the table
32
+ - Rank a lifecycle row against the audit offers where the route table itself states it, so a session acting on the lifecycle row reads the ranking without opening the section being ranked
29
33
  - Offer every audit whose surface the target carries, and offer none whose surface it lacks
30
34
  - Re-check the target after any operation that wrote, and report what those writes changed against the state read before acting
31
35
 
@@ -52,3 +56,5 @@ The last failure is writing without re-checking. A domain sync brought a stricte
52
56
  - Seed and preamble drift in installed files: `claude-seed-sync`
53
57
  - Governance rule install and index bootstrap: `setup-gov` and `setup-indexes`
54
58
  - What a given sync overwrites once it runs: `toolkit-cli`
59
+
60
+ That last contract carries no row for the governance install this skill routes to, so the overwrite `Must` above reads nothing at one of its own destinations. The row belongs to the skill owning the table. This file records the silence rather than answering it, since a rule written here for a silent contract teaches a session that silence means safe.
@@ -38,7 +38,9 @@ A `reverse` key the report does not carry at all is a CLI predating the field ra
38
38
 
39
39
  ## Route
40
40
 
41
- Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then act:
41
+ Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then act.
42
+
43
+ The two can name different rows, and a reply answers both rather than picking one. Run or hand off what the finding names, then answer the intent in the same reply. A measurement intent therefore reaches the audit offers below even on a target whose diagnostic found scaffold work, which is the case a session leaving from the scaffold row would otherwise never read.
42
44
 
43
45
  - A domain in `unmigrated`: hand off to `migration-standards`
44
46
  - An entry in `migrations`: hand off to the skill its `skill` field names, spelled as the report spells it. Both of them propose without writing, so the handoff is where this stops
@@ -52,9 +54,12 @@ Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then
52
54
  - Install one snippet, standard, or rule: run the domain `install` command
53
55
  - Sync one domain or every installed domain: run `aitk <domain> sync` or `aitk sync`
54
56
  - Fix only the ignore entries of the installed stack: run `aitk tooling inject --gitignore <stack>`
57
+ - Measure a surface without changing it: offer the audits under `### Audits`
55
58
  - Browse what is available: run `aitk <domain> list`
56
59
 
57
- That row runs on a measured tooling report alone, so `measured` decides before `chain` is read at all. A false one splits three ways. Report tooling as unmeasured in each, name the cause, and run nothing, since the zero counts underneath are unmeasured rather than clean:
60
+ ### The ignore-only row
61
+
62
+ The ignore-only row runs on a measured tooling report alone, so `measured` decides before `chain` is read at all. A false one splits three ways. Report tooling as unmeasured in each, name the cause, and run nothing, since the zero counts underneath are unmeasured rather than clean:
58
63
 
59
64
  - An empty `chain` at a workspace root, which carries `pnpm-workspace.yaml` or a `workspaces` key in `package.json`: no chain is recorded there by design, since one would guess at what the packages hold. Name `aitk tooling sync <stack> <path>` against a package.
60
65
  - An empty `chain` anywhere else: no tooling install is recorded, so name `aitk tooling sync` as the command that records one rather than asking the user for a stack.
@@ -66,6 +71,8 @@ On a measured report, take `<stack>` from the first name in `tooling.chain`, whi
66
71
 
67
72
  Four audits measure a surface without changing it. Offer the ones whose surface the target carries, list them together, and let the user pick. Run none of them unasked, and never treat a finding as a reason to abandon the operation the user asked for, since each reports judgments beside facts.
68
73
 
74
+ A lifecycle row and these offers fire together on a project carrying a context folder and no installed domain, which is the ranking the preamble states. Scaffold work on a target with nothing installed is real work, so an audit offered instead of it answers a question nobody asked, while an audit dropped in favor of it loses the one the user did.
75
+
69
76
  - `.claude/context/` present: offer `aitk context audit`
70
77
  - A record folder present under `.claude/`, one of `plans`, `groundwork`, `intake`, or `memory`: offer `aitk records validate <kind>` for each one found
71
78
  - Markdown that git lists: offer `aitk markdown audit`
@@ -17,6 +17,8 @@ aitk capture assets --out .claude/review/captures
17
17
 
18
18
  Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `aitk capture assets/hero.html` when the check reports the HTML changed.
19
19
 
20
+ Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. Nothing hand-edits it, and a capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
21
+
20
22
  | Option | Behavior |
21
23
  | ------------------ | ------------------------------------------------- |
22
24
  | `--out <dir>` | Write every PNG here instead of beside its source |
@@ -35,6 +35,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
35
35
  | `aitk records validate` | Report a session record against the standard governing it, per kind (`--json`) |
36
36
  | `aitk records push` | Commit the eight backed record folders and push them to a private records remote (`--json`) |
37
37
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
38
+ | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
38
39
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
39
40
  | `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
40
41
  | `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
@@ -23,5 +23,6 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
23
23
  - [Records](records.md): Validating the gitignored session records under .claude/, the per-kind checks, the refusal reasons, backing the folders to a private remote, and why the root defaults to the main worktree
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
+ - [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
26
27
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, and the requirement gate that is the only failing one
27
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
@@ -0,0 +1,67 @@
1
+ ---
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
4
+ ---
5
+
6
+ # Sessions
7
+
8
+ ## List
9
+
10
+ `aitk sessions list` reports every live Claude Code session on the machine with the working directory and branch it holds.
11
+
12
+ ```bash
13
+ aitk sessions list
14
+ aitk sessions list --json
15
+ aitk sessions list --branch feat/parser --json
16
+ ```
17
+
18
+ | Option | Behavior |
19
+ | ----------------- | -------------------------------------------- |
20
+ | `--json` | Add a machine-readable record on stdout |
21
+ | `--branch <name>` | Report the sessions holding this branch here |
22
+
23
+ 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.
24
+
25
+ Exit codes: `0` the roster was read, `1` refused. The refusal carries a `reason` of `no-registry` or `no-repository`.
26
+
27
+ ## Scope and count
28
+
29
+ `--branch` scopes the match to the repository the command runs in, and refuses outside one. A branch name identifies a branch inside a repository and nothing across a machine, so an unscoped match reaches a session working in a different project, and `main` collides on every machine running two of them.
30
+
31
+ A bare run reports every repository and carries a `repository` field on each row, holding the shared git directory that a main checkout and all its linked worktrees agree on. That is what a caller filters on when it wants a scope of its own.
32
+
33
+ The match can return more than one session. Read the count rather than the first row, since nothing stops two sessions holding one branch, and a caller that treats the result as singular picks among candidates without knowing it.
34
+
35
+ ## Why the verb exists
36
+
37
+ 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.
38
+
39
+ Each session writes its own record on disk carrying its working directory beside its own name. This verb reads those records, so a name joins to a branch by an exact match on one file rather than by a guess across two orderings.
40
+
41
+ ## What a row carries
42
+
43
+ Every row names the session, the process holding it, its working directory, and the branch checked out there. The status field repeats what the session listing reports, so a caller picking a target reads one output instead of two.
44
+
45
+ A field the record did not carry is reported as null rather than as a value, so an absent start time never reads as a session launched in 1970 and an absent identifier never reads as an empty one.
46
+
47
+ A row whose branch cannot be read is kept and marked rather than dropped. A caller has to be able to tell a session holding no branch from one the resolver never saw, and the second is the failure the verb replaces. The `unresolved` field carries the reason:
48
+
49
+ - `detached-head`: the session holds a worktree with no branch name
50
+ - `not-a-repository`: the session is working outside any git repository
51
+ - `git-unavailable`: git is not on the path, so nothing could be read
52
+
53
+ ## The confidence field
54
+
55
+ Every report states how liveness was decided, on a pass as well as a failure.
56
+
57
+ `confirmed` means each row's process was matched against the start time its own record stamped at launch, so the process holding the pid is the session that wrote the file. A caller can address a row of that kind directly.
58
+
59
+ `unverified` means only that the pid answers a probe. That cannot separate the original session from an unrelated process that inherited its pid after the session ended, so the roster is a candidate list rather than an identity. Treat the mapping as inferred and open the message by naming the branch the reader is believed to hold, asking to be corrected.
60
+
61
+ The registry holds one record per session and is never pruned, so it accumulates thousands of entries. On the `unverified` path a stale record whose pid has been reused reads as live, which is why the field is reported rather than assumed.
62
+
63
+ ## What the read depends on
64
+
65
+ The records live under the Claude Code configuration directory, which the verb resolves from `CLAUDE_CONFIG_DIR` and falls back to `~/.claude`. Their location, their filenames, and the fields inside them are a client implementation detail rather than a published interface, so a client change can move them. The verb reports an absent registry as a refusal rather than as a machine running no sessions, which is what surfaces the move instead of burying it in an empty roster.
66
+
67
+ The start-time comparison reads the process filesystem and exists only on some platforms. Where it does not, the verb still answers and marks the confidence, so a target without it keeps a working roster rather than losing the command.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.91.1",
4
+ "version": "0.92.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -5,7 +5,9 @@
5
5
  # bytes move with the browser version, so asserting it in verify.sh would fail
6
6
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
7
7
  # image with `aitk capture assets/hero.html` after this script reports a change.
8
- # The frame carries no version. `package.json` is bumped on main by the release
8
+ # That capture also writes assets/hero.stamp, which records the digest of the
9
+ # markup it rendered and is what the Hero stage compares, so all three files
10
+ # commit together. The frame carries no version. `package.json` is bumped on main by the release
9
11
  # tooling, so embedding it drifts every open branch on the next release and the
10
12
  # stage then fails for work that touched nothing.
11
13
  #
@@ -111,21 +111,90 @@ collect_plugin_manifests() {
111
111
  } | sort -u
112
112
  }
113
113
 
114
+ # sha256 of a file under the coreutils name and the macOS one. `aitk capture`
115
+ # writes the same digest into the stamp through node's crypto, so the two sides
116
+ # agree on an algorithm rather than on a tool being installed.
117
+ #
118
+ # Neither tool present returns 1 and says so. Falling through to an empty digest
119
+ # reports a mismatch against a blank value, which tells the reader the image is
120
+ # wrong when the truth is that the checker never ran.
121
+ #
122
+ # The refusal goes to stderr because every caller reads this through `$(...)`,
123
+ # which captures stdout into the digest variable and would swallow the message.
124
+ # `run_check` folds stderr into what it pipes, so the reader still sees it.
125
+ file_sha256() {
126
+ local digest
127
+ if command -v sha256sum >/dev/null 2>&1; then
128
+ digest=$(sha256sum "$1")
129
+ elif command -v shasum >/dev/null 2>&1; then
130
+ digest=$(shasum -a 256 "$1")
131
+ else
132
+ echo "Neither sha256sum nor shasum is installed, so $1 cannot be hashed." >&2
133
+ return 1
134
+ fi
135
+ printf '%s\n' "${digest%% *}"
136
+ }
137
+
138
+ # One digest the stamp recorded against the file it was taken over. An absent
139
+ # field reports itself rather than comparing against an empty string, so a stamp
140
+ # predating the current format is distinguishable from a file that moved.
141
+ #
142
+ # Both names in the message come off the paths rather than from arguments. The
143
+ # caller passes absolute paths and the reader wants repository-relative ones, and
144
+ # deriving them here is what keeps a name from disagreeing with the file it
145
+ # labels once a second capture source calls this.
146
+ assert_stamp_field() {
147
+ local stamp=$1 field=$2 file=$3
148
+ local stamp_label=${stamp#"$PROJECT_ROOT"/}
149
+ local file_label=${file#"$PROJECT_ROOT"/}
150
+ local recorded actual
151
+ recorded=$(awk -v key="$field:" '$1 == key { print $2; exit }' "$stamp")
152
+ if [ -z "$recorded" ]; then
153
+ echo "$stamp_label carries no $field line, so it predates the capture that writes one."
154
+ return 1
155
+ fi
156
+ actual=$(file_sha256 "$file") || return 1
157
+ if [ "$recorded" != "$actual" ]; then
158
+ echo "$stamp_label records $field $recorded"
159
+ echo "$file_label hashes to $actual"
160
+ return 1
161
+ fi
162
+ }
163
+
114
164
  # The drift assert covers the HTML because the PNG is a chromium render whose
115
165
  # bytes move with the browser. That leaves the artifact a visitor actually sees
116
166
  # asserted nowhere, so a branch that regenerates the HTML and never runs the
117
167
  # capture passes every stage while shipping an image with the old counts.
118
168
  #
119
- # The two files move together or the image is stale, so their last-touching
120
- # commit is the same commit. Comparing the branch's file list instead would pass
121
- # any branch that touched both anywhere, including one that regenerated the HTML
122
- # alone in a later commit. Both absent resolves to two empty strings and passes,
123
- # which is correct for a tree that carries neither.
124
- assert_hero_pair() {
125
- local html_commit png_commit
126
- html_commit=$(git -C "$PROJECT_ROOT" log -1 --format=%H -- assets/hero.html)
127
- png_commit=$(git -C "$PROJECT_ROOT" log -1 --format=%H -- assets/hero.png)
128
- [ "$html_commit" = "$png_commit" ]
169
+ # `aitk capture` records a digest of the markup it rendered and one of the image
170
+ # it wrote, so this reads provenance rather than timing. Comparing the commit
171
+ # that last touched each file passes any pair that moved together whatever the
172
+ # two files hold, which is what a binary conflict resolved by taking either side
173
+ # produces. All three absent passes, which is correct for a tree that carries
174
+ # none of them.
175
+ #
176
+ # Both digests are checked because either file can move alone. The markup side
177
+ # catches an edit committed with no capture, and the image side catches a PNG
178
+ # replaced under markup that never changed, which is the case the timing read
179
+ # caught by accident and a markup-only digest would drop.
180
+ assert_hero_stamp() {
181
+ local html="$PROJECT_ROOT/assets/hero.html"
182
+ local png="$PROJECT_ROOT/assets/hero.png"
183
+ local stamp="$PROJECT_ROOT/assets/hero.stamp"
184
+
185
+ if [ ! -f "$html" ] && [ ! -f "$png" ] && [ ! -f "$stamp" ]; then return 0; fi
186
+
187
+ local missing=""
188
+ [ -f "$html" ] || missing="$missing assets/hero.html"
189
+ [ -f "$png" ] || missing="$missing assets/hero.png"
190
+ [ -f "$stamp" ] || missing="$missing assets/hero.stamp"
191
+ if [ -n "$missing" ]; then
192
+ echo "Missing from the hero set:$missing"
193
+ return 1
194
+ fi
195
+
196
+ assert_stamp_field "$stamp" source-sha256 "$html" || return 1
197
+ assert_stamp_field "$stamp" image-sha256 "$png" || return 1
129
198
  }
130
199
 
131
200
  # Entries the audit actually measured, summed across the folders it resolved.
@@ -188,8 +257,8 @@ main() {
188
257
  # machine whose chromium differs rather than on a stale count.
189
258
  log_step "Hero"
190
259
  run_check "bash $PROJECT_ROOT/scripts/core/regen-hero.sh" "Hero regen failed"
191
- assert_no_drift "assets/hero.html" "Hero counts drifted. Run bun run check, then aitk capture assets/hero.html, and commit assets/hero.html with assets/hero.png."
192
- run_check "assert_hero_pair" "Hero HTML and image last moved in different commits, so the image may be stale. Run aitk capture assets/hero.html and commit both files together."
260
+ assert_no_drift "assets/hero.html" "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."
261
+ run_check "assert_hero_stamp" "The hero set disagrees with the stamp written when the image was captured. Run aitk capture assets/hero.html and commit all three files together."
193
262
  log_info "Hero clean"
194
263
 
195
264
  log_step "Skill references"
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import { register as comments } from '@/commands/comments'
25
25
  import { register as context } from '@/commands/context'
26
26
  import { register as markdown } from '@/commands/markdown'
27
27
  import { register as records } from '@/commands/records'
28
+ import { register as sessions } from '@/commands/sessions'
28
29
  import { PROJECT_ROOT } from '@/project-root'
29
30
 
30
31
  const GREY = '\x1b[0;90m'
@@ -59,6 +60,7 @@ function showHelp(): void {
59
60
  `${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
60
61
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
61
62
  `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
63
+ `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
62
64
  `${GREY}│${NC}`,
63
65
  `${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
64
66
  `${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
@@ -95,6 +97,7 @@ function showHelp(): void {
95
97
  `${GREY}│${NC} aitk markdown audit .claude/rules --json`,
96
98
  `${GREY}│${NC} aitk records validate plans`,
97
99
  `${GREY}│${NC} aitk records push --json`,
100
+ `${GREY}│${NC} aitk sessions list --json`,
98
101
  `${GREY}└${NC}`,
99
102
  ]
100
103
  console.log(lines.join('\n'))
@@ -151,5 +154,6 @@ comments(program)
151
154
  context(program)
152
155
  markdown(program)
153
156
  records(program)
157
+ sessions(program)
154
158
 
155
159
  program.parse()
@@ -0,0 +1,231 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ repositoryOf,
4
+ type ResolvedSession,
5
+ resolveSessions,
6
+ type SessionReport,
7
+ } from '@/sessions/resolve'
8
+ import {
9
+ intro,
10
+ logInfo,
11
+ logStep,
12
+ logWarn,
13
+ outro,
14
+ pipeOutput,
15
+ plural,
16
+ } from '@/ui'
17
+
18
+ interface ListCommandOptions {
19
+ readonly json?: boolean
20
+ readonly branch?: string
21
+ }
22
+
23
+ const REASONS: Record<string, string> = {
24
+ 'not-a-repository': 'working outside any git repository',
25
+ 'detached-head': 'detached HEAD, so the worktree holds no branch name',
26
+ 'git-unavailable': 'git is not on the path, so nothing could be read',
27
+ }
28
+
29
+ export function register(program: Command): void {
30
+ const sessions = program
31
+ .command('sessions')
32
+ .description(
33
+ 'Resolve live peer sessions to the worktree and branch each holds',
34
+ )
35
+ .helpOption('-h, --help', 'Show this help message')
36
+
37
+ sessions
38
+ .command('list')
39
+ .description(
40
+ 'Report every live session with its working directory and branch',
41
+ )
42
+ .helpOption('-h, --help', 'Show this help message')
43
+ .option('--json', 'Add a machine-readable record on stdout')
44
+ .option(
45
+ '--branch <name>',
46
+ 'Report only the sessions holding this branch in this repository',
47
+ )
48
+ .addHelpText(
49
+ 'after',
50
+ [
51
+ '',
52
+ 'Exit codes:',
53
+ ' 0 the roster was read',
54
+ ' 1 refused, with the reason on stderr',
55
+ '',
56
+ '--branch scopes the match to the repository the command runs in, since',
57
+ 'a branch name identifies a branch there and nothing across a machine.',
58
+ 'A bare run reports every repository and carries the repository field,',
59
+ 'so a caller filtering by hand has something that identifies one.',
60
+ '',
61
+ 'The match can return more than one session. Read the count rather than',
62
+ 'the first row, since two sessions can hold one branch.',
63
+ '',
64
+ 'Each session writes its own working directory beside its own name, so a',
65
+ 'name from a session listing joins to a branch by an exact match rather',
66
+ 'than by ordering the roster on start time.',
67
+ '',
68
+ 'The confidence field says how the liveness of a row was decided.',
69
+ '"confirmed" matched the running process against the start time the',
70
+ 'record stamped. "unverified" means only that the pid answers, which',
71
+ 'cannot rule out a pid handed to an unrelated process, so a roster',
72
+ 'reported that way is a candidate list rather than an identity.',
73
+ '',
74
+ 'Examples:',
75
+ ' aitk sessions list',
76
+ ' aitk sessions list --json',
77
+ ' aitk sessions list --branch feat/parser --json',
78
+ '',
79
+ ].join('\n'),
80
+ )
81
+ .action(async (opts: ListCommandOptions) => {
82
+ process.exitCode = await runList(opts)
83
+ })
84
+ }
85
+
86
+ async function runList(opts: ListCommandOptions): Promise<number> {
87
+ const report = await resolveSessions()
88
+
89
+ if (report.kind === 'absent') {
90
+ intro('aitk sessions list')
91
+ logStep('Refused')
92
+ logWarn(
93
+ `No session registry at ${report.dir}. Nothing was read, so this is not a machine with no sessions.`,
94
+ )
95
+ outro()
96
+
97
+ if (opts.json) {
98
+ process.stdout.write(
99
+ `${JSON.stringify({ dir: report.dir, reason: 'no-registry', sessions: [] })}\n`,
100
+ )
101
+ }
102
+
103
+ return 1
104
+ }
105
+
106
+ // A branch name identifies a branch inside one repository and nothing across
107
+ // a machine, so an unscoped match reaches a session working in a different
108
+ // project. `main` is the name that collides on every machine running two.
109
+ const repository = opts.branch ? await repositoryOf(process.cwd()) : null
110
+
111
+ if (opts.branch && repository === null) {
112
+ intro('aitk sessions list')
113
+ logStep('Refused')
114
+ logWarn(
115
+ '--branch scopes the match to the repository this command runs in, and no repository resolved here. Run it inside one, or read the whole roster and filter on the repository field.',
116
+ )
117
+ outro()
118
+
119
+ if (opts.json) {
120
+ process.stdout.write(
121
+ `${JSON.stringify({ dir: report.dir, reason: 'no-repository', sessions: [] })}\n`,
122
+ )
123
+ }
124
+
125
+ return 1
126
+ }
127
+
128
+ const shown = opts.branch
129
+ ? report.sessions.filter(
130
+ (session) =>
131
+ session.branch === opts.branch && session.repository === repository,
132
+ )
133
+ : report.sessions
134
+
135
+ intro('aitk sessions list')
136
+ reportConfidence(report)
137
+ reportSessions(shown, opts.branch, repository)
138
+ outro()
139
+
140
+ if (opts.json) {
141
+ process.stdout.write(
142
+ `${JSON.stringify({
143
+ dir: report.dir,
144
+ confidence: report.confidence,
145
+ branch: opts.branch ?? null,
146
+ repository,
147
+ sessions: shown,
148
+ })}\n`,
149
+ )
150
+ }
151
+
152
+ return 0
153
+ }
154
+
155
+ /**
156
+ * States how liveness was decided on every run, including the run that decided
157
+ * it the strong way.
158
+ *
159
+ * A caller reading a roster has no other way to tell a confirmed identity from
160
+ * a pid that merely answered, and the two support different actions: the first
161
+ * addresses a session directly and the second opens by asking to be corrected.
162
+ */
163
+ function reportConfidence(
164
+ report: Extract<SessionReport, { kind: 'resolved' }>,
165
+ ): void {
166
+ logStep('Liveness')
167
+
168
+ // A roster of none decided nothing, so the confirmed line would claim a check
169
+ // over rows that do not exist. The registry is never pruned and holds a record
170
+ // per session ever run, which is what makes the empty result worth stating.
171
+ if (report.sessions.length === 0) {
172
+ logInfo(
173
+ 'No row to decide. Every record belongs to a session that has ended.',
174
+ )
175
+ return
176
+ }
177
+
178
+ if (report.confidence === 'confirmed') {
179
+ logInfo(
180
+ 'Every row matched a running process against the start time its record stamped.',
181
+ )
182
+ return
183
+ }
184
+
185
+ logWarn(
186
+ 'Start times could not be read, so rows rest on a pid answering alone. Treat the mapping as inferred and open by asking to be corrected.',
187
+ )
188
+ }
189
+
190
+ function reportSessions(
191
+ sessions: readonly ResolvedSession[],
192
+ branch: string | undefined,
193
+ repository: string | null,
194
+ ): void {
195
+ logStep('Sessions')
196
+
197
+ if (branch) {
198
+ logInfo(
199
+ `Scoped to ${repository}, since a branch name identifies one there.`,
200
+ )
201
+ }
202
+
203
+ if (sessions.length === 0) {
204
+ logInfo(
205
+ branch
206
+ ? `No live session in this repository holds ${branch}.`
207
+ : 'No live session. Every record in the registry belongs to a session that has ended.',
208
+ )
209
+ return
210
+ }
211
+
212
+ // The count is what a dispatch turns on. One row is a target and several are
213
+ // candidates, and the caller cannot tell them apart from a roster alone.
214
+ if (branch && sessions.length > 1) {
215
+ logWarn(
216
+ `${sessions.length} sessions hold ${branch}. Confirm which one before addressing it.`,
217
+ )
218
+ }
219
+
220
+ logInfo(plural(sessions.length, 'live session'))
221
+ pipeOutput(
222
+ sessions
223
+ .map((session) => {
224
+ const held =
225
+ session.branch ??
226
+ `unresolved: ${REASONS[session.unresolved ?? ''] ?? 'unknown'}`
227
+ return `${session.name} ${session.status} ${held}\n ${session.cwd}`
228
+ })
229
+ .join('\n'),
230
+ )
231
+ }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import type { SessionRecord } from '@/sessions/registry'
3
+
4
+ /**
5
+ * How far the liveness answer can be trusted.
6
+ *
7
+ * `confirmed` means the running process was matched against the start time the
8
+ * record stamped, so the pid belongs to the session that wrote the file.
9
+ * `unverified` means the pid answers a signal probe and nothing more, which
10
+ * cannot separate the original session from whatever inherited its pid.
11
+ */
12
+ export type Confidence = 'confirmed' | 'unverified'
13
+
14
+ export interface Liveness {
15
+ readonly alive: boolean
16
+ readonly confidence: Confidence
17
+ }
18
+
19
+ export interface LivenessProbes {
20
+ /** The start time of a running process, or null where it cannot be read. */
21
+ readonly procStartOf: (pid: number) => string | null
22
+ /** Whether the pid answers at all, ignoring which process holds it. */
23
+ readonly responds: (pid: number) => boolean
24
+ }
25
+
26
+ /**
27
+ * Reads a running process's start time from the process filesystem.
28
+ *
29
+ * The comm field is parenthesised and may itself contain spaces, so the split
30
+ * runs from the last `)` rather than over the whole line. Fields resume at the
31
+ * third, which puts the twenty-second at offset nineteen.
32
+ */
33
+ function procStartOf(pid: number): string | null {
34
+ try {
35
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
36
+ const tail = stat.slice(stat.lastIndexOf(')') + 1).trim()
37
+ return tail.split(/\s+/)[19] ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** Signal zero performs the permission and existence checks and delivers nothing. */
44
+ function responds(pid: number): boolean {
45
+ try {
46
+ process.kill(pid, 0)
47
+ return true
48
+ } catch (error) {
49
+ // A live process owned by another user answers EPERM, which is an answer.
50
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
51
+ }
52
+ }
53
+
54
+ export const SYSTEM_PROBES: LivenessProbes = { procStartOf, responds }
55
+
56
+ /**
57
+ * Decides whether a record describes a session that is still running.
58
+ *
59
+ * The two paths are not interchangeable and the caller is told which one ran.
60
+ * Where the process filesystem answers, a start-time mismatch is a dead session
61
+ * whose pid was reused and the record is discarded. Where it does not, the
62
+ * probe alone stands, and every surviving row is marked so a caller never reads
63
+ * a recycled pid as a confirmed identity.
64
+ */
65
+ export function liveness(
66
+ record: SessionRecord,
67
+ probes: LivenessProbes = SYSTEM_PROBES,
68
+ ): Liveness {
69
+ const started = record.procStart
70
+
71
+ if (started !== undefined && started.length > 0) {
72
+ const running = probes.procStartOf(record.pid)
73
+ if (running !== null) {
74
+ return { alive: running === started, confidence: 'confirmed' }
75
+ }
76
+ }
77
+
78
+ return { alive: probes.responds(record.pid), confidence: 'unverified' }
79
+ }
@@ -0,0 +1,120 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * One session's own record of itself, as the client writes it.
7
+ *
8
+ * Only the fields this domain reads are declared. The client writes several
9
+ * more, and naming them here would put a second copy of its schema in a
10
+ * repository that does not own it.
11
+ */
12
+ export interface SessionRecord {
13
+ readonly pid: number
14
+ readonly cwd: string
15
+ readonly name: string
16
+ /**
17
+ * Optional because the guard admitting a record checks the three fields above
18
+ * and no more. A client predating any of these writes a record the roster
19
+ * still wants, so the type says what the guard actually proved.
20
+ */
21
+ readonly sessionId: string | undefined
22
+ readonly kind: string | undefined
23
+ readonly status: string | undefined
24
+ readonly startedAt: number | undefined
25
+ /**
26
+ * The process start time the client stamped at launch, compared against the
27
+ * running process to separate a live session from a record whose pid has
28
+ * been handed to something else.
29
+ */
30
+ readonly procStart: string | undefined
31
+ }
32
+
33
+ /**
34
+ * Resolves the folder holding one file per session.
35
+ *
36
+ * `CLAUDE_CONFIG_DIR` comes first because a client honouring it writes its
37
+ * records nowhere near the home directory, and a read that ignored it would
38
+ * report an empty roster on a machine running sessions.
39
+ */
40
+ export function registryDir(): string {
41
+ const configured = process.env.CLAUDE_CONFIG_DIR
42
+ const base =
43
+ configured && configured.length > 0
44
+ ? configured
45
+ : join(homedir(), '.claude')
46
+ return join(base, 'sessions')
47
+ }
48
+
49
+ /**
50
+ * Fields whose absence leaves a row unable to answer the question asked of it.
51
+ *
52
+ * The pid has to be positive rather than merely numeric. Signal zero addresses
53
+ * the caller's own process group rather than a process, so a record carrying
54
+ * zero would answer the liveness probe and enter the roster as a live session.
55
+ */
56
+ function isUsable(value: Partial<SessionRecord>): value is SessionRecord {
57
+ return (
58
+ typeof value.pid === 'number' &&
59
+ Number.isInteger(value.pid) &&
60
+ value.pid > 0 &&
61
+ typeof value.cwd === 'string' &&
62
+ value.cwd.length > 0 &&
63
+ typeof value.name === 'string' &&
64
+ value.name.length > 0
65
+ )
66
+ }
67
+
68
+ /**
69
+ * An absent folder and an empty one are separate answers.
70
+ *
71
+ * The first means no client ever wrote a record here, so the read never ran and
72
+ * a roster of none would report a machine with no sessions when the truth is a
73
+ * lookup that failed. The second is a machine whose sessions have all ended.
74
+ */
75
+ export type Registry =
76
+ | { readonly kind: 'absent'; readonly dir: string }
77
+ | {
78
+ readonly kind: 'read'
79
+ readonly dir: string
80
+ readonly records: SessionRecord[]
81
+ }
82
+
83
+ /**
84
+ * Reads every session record in the folder, newest first.
85
+ *
86
+ * A file that does not parse, or that parses without the fields a row is built
87
+ * from, is dropped rather than reported. The folder accumulates a record per
88
+ * session and is never pruned, so it holds thousands of entries from clients
89
+ * spanning many versions, and a finding per stale shape would bury the live
90
+ * sessions this exists to name.
91
+ */
92
+ export function readRegistry(dir: string = registryDir()): Registry {
93
+ let names: string[]
94
+ try {
95
+ names = readdirSync(dir)
96
+ } catch {
97
+ return { kind: 'absent', dir }
98
+ }
99
+
100
+ const records: SessionRecord[] = []
101
+
102
+ for (const name of names) {
103
+ if (!name.endsWith('.json')) continue
104
+
105
+ try {
106
+ const parsed: unknown = JSON.parse(readFileSync(join(dir, name), 'utf8'))
107
+ if (typeof parsed !== 'object' || parsed === null) continue
108
+ const record = parsed as Partial<SessionRecord>
109
+ if (isUsable(record)) records.push(record)
110
+ } catch {
111
+ // Unreadable or malformed. See the note above.
112
+ }
113
+ }
114
+
115
+ return {
116
+ kind: 'read',
117
+ dir,
118
+ records: records.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)),
119
+ }
120
+ }
@@ -0,0 +1,180 @@
1
+ import { $ } from 'bun'
2
+ import {
3
+ type Confidence,
4
+ liveness,
5
+ type LivenessProbes,
6
+ SYSTEM_PROBES,
7
+ } from '@/sessions/live'
8
+ import { readRegistry, type SessionRecord } from '@/sessions/registry'
9
+
10
+ /** Why a row carries no branch, stated rather than left as an absent key. */
11
+ export type Unresolved =
12
+ | 'not-a-repository'
13
+ | 'detached-head'
14
+ | 'git-unavailable'
15
+
16
+ export interface ResolvedSession {
17
+ readonly name: string
18
+ readonly pid: number
19
+ readonly sessionId: string | null
20
+ readonly cwd: string
21
+ readonly kind: string
22
+ readonly status: string
23
+ readonly startedAt: string | null
24
+ /**
25
+ * The shared git directory every worktree of one repository resolves to,
26
+ * which is what identifies the repository a row belongs to. A branch name is
27
+ * unique inside one and says nothing across a machine.
28
+ */
29
+ readonly repository: string | null
30
+ readonly worktree: string | null
31
+ readonly branch: string | null
32
+ /** Null exactly when `branch` is set. The two are written together. */
33
+ readonly unresolved: Unresolved | null
34
+ }
35
+
36
+ export type SessionReport =
37
+ | { readonly kind: 'absent'; readonly dir: string }
38
+ | {
39
+ readonly kind: 'resolved'
40
+ readonly dir: string
41
+ /**
42
+ * The weakest confidence any surviving row was decided at, so one read
43
+ * tells a caller whether the roster can be trusted as an identity.
44
+ */
45
+ readonly confidence: Confidence
46
+ readonly sessions: readonly ResolvedSession[]
47
+ }
48
+
49
+ export interface ResolveOptions {
50
+ readonly dir?: string
51
+ readonly probes?: LivenessProbes
52
+ readonly locate?: (cwd: string) => Promise<Located>
53
+ }
54
+
55
+ export interface Located {
56
+ readonly repository: string | null
57
+ readonly worktree: string | null
58
+ readonly branch: string | null
59
+ readonly unresolved: Unresolved | null
60
+ }
61
+
62
+ /**
63
+ * Asks git what a directory is checked out to.
64
+ *
65
+ * A detached HEAD and a directory outside any repository are separated here
66
+ * rather than collapsed into one empty answer, because the first is a session
67
+ * that has a worktree and no branch and the second has neither.
68
+ */
69
+ async function locate(cwd: string): Promise<Located> {
70
+ const top = await $`git -C ${cwd} rev-parse --show-toplevel`.quiet().nothrow()
71
+
72
+ if (top.exitCode !== 0) {
73
+ // git absent and git refusing the directory are both non-zero here. The
74
+ // first is the platform report the plan asked for and the second is an
75
+ // ordinary answer, so the distinguishing read is whether git ran at all.
76
+ const version = await $`git --version`.quiet().nothrow()
77
+ return {
78
+ repository: null,
79
+ worktree: null,
80
+ branch: null,
81
+ unresolved:
82
+ version.exitCode === 0 ? 'not-a-repository' : 'git-unavailable',
83
+ }
84
+ }
85
+
86
+ const worktree = top.stdout.toString().trim()
87
+ const repository = await repositoryOf(cwd)
88
+ const head = await $`git -C ${cwd} branch --show-current`.quiet().nothrow()
89
+ const branch = head.stdout.toString().trim()
90
+
91
+ if (head.exitCode !== 0 || branch.length === 0) {
92
+ return { repository, worktree, branch: null, unresolved: 'detached-head' }
93
+ }
94
+
95
+ return { repository, worktree, branch, unresolved: null }
96
+ }
97
+
98
+ /**
99
+ * Resolves the shared git directory a working directory belongs to.
100
+ *
101
+ * The common directory is what a linked worktree and its main checkout agree
102
+ * on, so two rows in one repository match here while the toplevel would place
103
+ * every worktree in a repository of its own.
104
+ */
105
+ export async function repositoryOf(cwd: string): Promise<string | null> {
106
+ const dir =
107
+ await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`
108
+ .quiet()
109
+ .nothrow()
110
+
111
+ if (dir.exitCode !== 0) return null
112
+
113
+ const resolved = dir.stdout.toString().trim()
114
+ return resolved.length > 0 ? resolved : null
115
+ }
116
+
117
+ /**
118
+ * An absent field is rendered as an absence rather than as a value.
119
+ *
120
+ * A missing start time formatted from zero reads as a session launched in 1970
121
+ * and a missing identifier as an empty one, both of which a caller would take
122
+ * for data. Null says the record did not carry it, which is the same
123
+ * distinction the registry draws between an absent folder and an empty one.
124
+ */
125
+ function present(record: SessionRecord, located: Located): ResolvedSession {
126
+ return {
127
+ name: record.name,
128
+ pid: record.pid,
129
+ sessionId: record.sessionId ?? null,
130
+ cwd: record.cwd,
131
+ kind: record.kind ?? 'unknown',
132
+ status: record.status ?? 'unknown',
133
+ startedAt:
134
+ record.startedAt === undefined
135
+ ? null
136
+ : new Date(record.startedAt).toISOString(),
137
+ repository: located.repository,
138
+ worktree: located.worktree,
139
+ branch: located.branch,
140
+ unresolved: located.unresolved,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolves every live session to the worktree and branch it holds.
146
+ *
147
+ * The registry is the whole source. Each session writes its own working
148
+ * directory beside its own name, so a caller matching a listing row to a branch
149
+ * reads both from one record instead of ordering the roster by start time and
150
+ * hoping the order holds.
151
+ *
152
+ * A row whose branch cannot be read is kept and marked. Dropping it would leave
153
+ * a caller unable to tell a session that holds no branch from one the resolver
154
+ * never saw, and the second is the failure this replaces.
155
+ */
156
+ export async function resolveSessions(
157
+ opts: ResolveOptions = {},
158
+ ): Promise<SessionReport> {
159
+ const probes = opts.probes ?? SYSTEM_PROBES
160
+ const find = opts.locate ?? locate
161
+ const registry = readRegistry(opts.dir)
162
+
163
+ if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
164
+
165
+ const live: SessionRecord[] = []
166
+ let confidence: Confidence = 'confirmed'
167
+
168
+ for (const record of registry.records) {
169
+ const state = liveness(record, probes)
170
+ if (!state.alive) continue
171
+ if (state.confidence === 'unverified') confidence = 'unverified'
172
+ live.push(record)
173
+ }
174
+
175
+ const sessions = await Promise.all(
176
+ live.map(async (record) => present(record, await find(record.cwd))),
177
+ )
178
+
179
+ return { kind: 'resolved', dir: registry.dir, confidence, sessions }
180
+ }