@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
- package/claude/skills/toolkit-operator/REQUIREMENT.md +7 -1
- package/claude/skills/toolkit-operator/SKILL.md +9 -2
- package/docs/agents/capture.md +2 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/sessions.md +67 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +3 -1
- package/scripts/core/verify.sh +81 -12
- package/src/cli.ts +4 -0
- package/src/commands/sessions.ts +231 -0
- package/src/sessions/live.ts +79 -0
- package/src/sessions/registry.ts +120 -0
- package/src/sessions/resolve.ts +180 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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`
|
package/docs/agents/capture.md
CHANGED
|
@@ -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 |
|
package/docs/agents/commands.md
CHANGED
|
@@ -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 |
|
package/docs/agents/index.md
CHANGED
|
@@ -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
|
@@ -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
|
-
#
|
|
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
|
#
|
package/scripts/core/verify.sh
CHANGED
|
@@ -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
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
# which is correct for a tree that carries
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 "
|
|
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
|
+
}
|