@erclx/aitk 0.91.2 → 0.93.0

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