@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-handoff.md +7 -6
- 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/commands.md +17 -15
- package/docs/agents/index.md +2 -1
- package/docs/agents/sessions.md +67 -0
- package/docs/agents/skills-audit.md +34 -1
- package/package.json +1 -1
- package/src/claude/skills-drift.ts +156 -0
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +106 -4
- 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
|
@@ -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.
|
|
10
|
-
3.
|
|
11
|
-
4.
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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/commands.md
CHANGED
|
@@ -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
|
|
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
|
-
- [
|
|
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,
|
|
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
|
@@ -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()
|
package/src/commands/claude.ts
CHANGED
|
@@ -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 '
|
|
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 '
|
|
168
|
-
: `Unknown subcommand: ${subcommand}. Use 'list' or '
|
|
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
|
+
}
|