@erclx/aitk 3.26.1 → 3.28.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/REQUIREMENT.md +8 -2
- package/claude/skills/claude-orchestrate/SKILL.md +5 -3
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +44 -0
- package/docs/agents/commands.md +12 -0
- package/docs/agents/sessions.md +10 -0
- package/docs/operating-model.md +1 -1
- package/package.json +1 -1
- package/src/browser/engine.ts +40 -0
- package/src/cli.ts +4 -0
- package/src/commands/demo.ts +2 -11
- package/src/commands/inventory.ts +256 -0
- package/src/commands/sessions.ts +40 -0
- package/src/demo/drive.ts +6 -8
- package/src/inventory/config.ts +117 -0
- package/src/inventory/group.ts +76 -0
- package/src/inventory/subjects.ts +114 -0
- package/src/inventory/walk.ts +129 -0
- package/src/sessions/claim.ts +59 -0
- package/src/sessions/resolve.ts +10 -2
- package/src/worktree.ts +53 -2
|
@@ -67,12 +67,16 @@ The session also records nothing of what it learns. Both other callers of memory
|
|
|
67
67
|
- Write each re-test into the row and its measurement into that task's Findings, since a result reported in chat is gone at the next compaction and the next pass measures the same thing
|
|
68
68
|
- Plan a row the re-test clears, since a cleared row carrying no plan is one the next pass looks at again
|
|
69
69
|
- Split a task whose file set collides with every other by construction, rather than re-measuring a scoping defect that reads as a blocker
|
|
70
|
+
- Check a candidate branch is unclaimed by an existing worktree or a live session before dispatching a background worker for it, since the measured failure this closes is a worker colliding with someone else's work already sitting in the row
|
|
71
|
+
- Cap concurrent self-dispatched workers at three, counted by a session name no human-launched worker carries, since the evidence behind self-dispatch is one task shipped once
|
|
72
|
+
- Report each self-dispatch and the row it fired against loudly enough to follow, since a person no longer watches the launch step happen
|
|
73
|
+
- Stop dispatching once `## Run now` is empty or every row in it reads claimed, rather than waking again to fire on a board nobody is clearing
|
|
70
74
|
|
|
71
75
|
## Must not
|
|
72
76
|
|
|
73
77
|
- Implement a feature or edit any tracked file from this session, at any size, since the ban offers no proportionality exception
|
|
74
78
|
- Merge. Recommend merge or changes and leave the gate to the human.
|
|
75
|
-
- Spawn worker
|
|
79
|
+
- Spawn a worker with the Agent tool, since an in-process subagent shares this session's context and cannot be steered or reached independently. A dispatched `claude --bg` process is not this: it is a separate session with its own worktree and its own PR, gated by the collision check and the worker cap.
|
|
76
80
|
- Hand a worker anything but a plan, because scope lives there
|
|
77
81
|
- Run a second orchestrator against the same board
|
|
78
82
|
- Promote a task to fill the queue when nothing qualifies. A thin queue is a real answer.
|
|
@@ -88,10 +92,12 @@ The session also records nothing of what it learns. Both other callers of memory
|
|
|
88
92
|
- Cross-version sequencing asked for: say no surface carries it, rather than asserting an active version the tree does not state
|
|
89
93
|
- This body dropped from a long session approaching a compaction: name the re-invocation and the runbook paths, since the routing lives in the body and a user-invoked skill routes nothing once it is gone
|
|
90
94
|
- Blocker only an operator can clear: record the row as untestable this pass and name the action owed, rather than re-measuring what no session can move
|
|
95
|
+
- Collision check refuses, with no session registry or no repository resolved: treat the candidate as unverified and fall back to the human-launch line, rather than reading a check that could not run as a clear one
|
|
96
|
+
- Worker cap already at three: stop dispatching for the pass and leave the row ready, rather than queueing past it
|
|
91
97
|
|
|
92
98
|
## Out of scope
|
|
93
99
|
|
|
94
100
|
- Writing the plan itself, which `claude-feature` owns and this session runs rather than reimplements
|
|
95
101
|
- Reviewing a worker's pull request, which `claude-pr-review` owns
|
|
96
|
-
- Entering the worktree a build runs in, which the human
|
|
102
|
+
- Entering the worktree a build runs in, which the worker opens for itself whether a human launched it or this session dispatched it
|
|
97
103
|
- The operating model this enacts, which the toolkit's own docs hold
|
|
@@ -10,7 +10,8 @@ This session is the orchestrator: the one warm session that holds the
|
|
|
10
10
|
cross-feature picture. It plans and reviews.
|
|
11
11
|
|
|
12
12
|
It does not build, and it does not merge. Building happens in cold worker
|
|
13
|
-
sessions
|
|
13
|
+
sessions, dispatched by this skill once the collision check clears or launched
|
|
14
|
+
by the human when it does not. Merging is the human's gate.
|
|
14
15
|
|
|
15
16
|
This skill holds the framing, the board procedure, and the dispatch. Every step
|
|
16
17
|
that builds something runs an existing skill. The queue rules below decide which
|
|
@@ -101,7 +102,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
|
|
|
101
102
|
1. Plan the next feature. Run `claude-feature` here, with the cross-feature context, to write a plan to `.claude/plans/`. Planning stays in this warm session so the plan front-loads reasoning a cold worker would otherwise re-derive. Every plan written from here also carries a constraint per track in flight, which the paragraph below this list states.
|
|
102
103
|
2. Decide parallelism and merge order. Note which plans touch a shared wiring seam so their PRs merge in sequence, not at once.
|
|
103
104
|
3. Verify the plan against the tree. Reading it is not enough, since a plan goes stale from whatever merged after it was written. Grep for each construct it names and count the sites against the count it claims. Check that every phase label it cites is still open. Open each file it describes rather than trusting its account of the contents. Correct the plan before handing it over.
|
|
104
|
-
4. Hand off.
|
|
105
|
+
4. Hand off. Read `${CLAUDE_SKILL_DIR}/references/orchestrator-dispatch.md` and follow it: check the branch is unclaimed, check the worker cap, then dispatch a background worker with `claude --bg`. Fall back to the human-launch line it replaces when the check refuses, the cap is reached, or the row fails Parallelism against something already in flight.
|
|
105
106
|
5. Review the PR. When a worker opens a PR, run `claude-pr-review` to post findings to it. This is the deep, independent pass. The worker's autoship self-review was only the green gate.
|
|
106
107
|
- Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and start the poll it carries on the first dispatch rather than checking the board by hand. That runbook holds the routing, and a summary of it here is a second source that drifts from it.
|
|
107
108
|
6. Dispatch the handback. A pass posting anything owed, a finding at any severity or a testing question, tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Re-review when the answer lands, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
|
|
@@ -126,7 +127,8 @@ Stamp the block with the commit this session read the tree at, which the same se
|
|
|
126
127
|
- Run one orchestrator at a time. The board is gitignored, so a second session sees none of this one's writes: two task files land minutes apart under different labels for the same work, one session archives a task mid-sweep in the other, and each archives a plan the other had retargeted. An Owner column does not fix this, since neither session can read the other's rows.
|
|
127
128
|
- Do not implement features in this session. Hand the plan to a worker.
|
|
128
129
|
- Do not merge. Recommend merge or changes. The human merges.
|
|
129
|
-
- Do not spawn worker
|
|
130
|
+
- Do not spawn a worker with the Agent tool. An in-process subagent shares this session's context and cannot be steered or reached independently, which breaks the property this boundary protects rather than the mechanism it names. The dispatch in `orchestrator-dispatch.md` is a separate `claude --bg` process with its own worktree and its own PR, so it preserves that property instead.
|
|
131
|
+
- Dispatch a background worker only once the collision check in `orchestrator-dispatch.md` clears and the worker cap still has room. Colliding with an existing worktree or session, or exceeding the cap, is what the check and the cap exist to catch, not a judgment call this session makes case by case.
|
|
130
132
|
- Do not edit tracked files from this session, at any size. The boundary offers no proportionality exception and nothing enforces it.
|
|
131
133
|
- Do not hand a worker anything but a plan, since scope lives there. A plan carries exact diffs only when they are already known, otherwise it states the scope and the open questions and lets the worker write the diff.
|
|
132
134
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Orchestrator dispatch runbook
|
|
3
|
+
description: The collision check before a self-dispatch, the worker cap, the launch command, and the loop's stopping condition
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run this at loop step 4, for a `## Run now` row whose plan is verified and whose file set has already cleared the Parallelism test against every track in flight, in place of handing the worktree to a human.
|
|
7
|
+
|
|
8
|
+
## Derive the candidate
|
|
9
|
+
|
|
10
|
+
Resolve `<slug>` from the row's plan the way `claude-worktree` Step 2 resolves a plan-matched name, per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Resolve `<type>` off that plan's `## Summary` and `**Files to touch:**` lines, per `${CLAUDE_SKILL_DIR}/../../standards/branch.md`, defaulting to `feat` when the lines settle nothing. The candidate branch is `<type>/<slug>`.
|
|
11
|
+
|
|
12
|
+
## Check the branch is unclaimed
|
|
13
|
+
|
|
14
|
+
Run `aitk sessions list --branch <type>/<slug> --json` and read `claimed` off the record.
|
|
15
|
+
|
|
16
|
+
- `claimed: true`: the row is not free. Report what holds it, `worktree` when it names a path and `sessions` when it carries a row, and move to the next candidate rather than colliding.
|
|
17
|
+
- `claimed: false` and `sessionsReadable: true`: proceed to the cap check.
|
|
18
|
+
- `claimed: false` and `sessionsReadable: false`, or the command refuses, or the record carries no `claimed` key (`reason` reads `no-registry` or `no-repository`): treat the candidate as unverified rather than clear. Report the refusal and fall back to the human-launch line below. Dispatching on a check that could not be read reproduces the exact collision this exists to prevent.
|
|
19
|
+
|
|
20
|
+
Reading `claimed` off the record is what keeps this a check rather than a rule a session can talk itself out of. The field is already the composed answer across the worktree listing and the live session listing, so nothing here re-derives the OR.
|
|
21
|
+
|
|
22
|
+
## Check the worker cap
|
|
23
|
+
|
|
24
|
+
Run `aitk sessions list --json` with no `--branch`, then count entries whose `name` starts with `orchestrator-` and whose `repository` matches this run's own (`git rev-parse --path-format=absolute --git-common-dir`). An unscoped listing spans every repository on the machine, so the repository match is what keeps a busy sibling project from binding this one's cap. Every dispatch below names its session that way for exactly this count, so a worker the human launched by hand carries no such name and is never counted against it.
|
|
25
|
+
|
|
26
|
+
Three already out: report the cap and stop dispatching for this pass, leaving the row ready for the next one. The cap binds the self-dispatch path alone, since the evidence behind it is one task shipped once and the operator's own launches stay uncapped by count.
|
|
27
|
+
|
|
28
|
+
## Dispatch
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude --bg -n "orchestrator-<slug>" "/aitk:claude-autoship .claude/tasks/<task-file>.md"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`claude-autoship`'s own Step 0 enters the worktree, so this session never does. `--bg, --background` starts the session as a background agent and returns immediately, and `-n, --name` sets the display name `aitk sessions list` reads back for the worker cap.
|
|
35
|
+
|
|
36
|
+
Report the dispatch as loudly as the human-launch line it replaces: name the branch, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
|
|
37
|
+
|
|
38
|
+
## Fall back to the human
|
|
39
|
+
|
|
40
|
+
Hand the row to the human-launch line in step 4 instead of dispatching when any of these hold, and name which one: the collision check refused, the cap is reached, or the row's file set failed the Parallelism test against something already out.
|
|
41
|
+
|
|
42
|
+
## Stop the loop
|
|
43
|
+
|
|
44
|
+
Wrapped in `/loop`, re-run the check against `## Run now` on each wake. Stop rather than firing again once the group is empty or every row in it reads `claimed: true`. Report that once, on the wake that finds it, and let the loop end rather than continuing to poll a board nobody is clearing. `orchestrator-poll.md` already carries this reasoning for the review trigger, and it binds a dispatcher the same way.
|
package/docs/agents/commands.md
CHANGED
|
@@ -59,11 +59,22 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
59
59
|
| `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
|
|
60
60
|
| `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
|
|
61
61
|
| `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
|
|
62
|
+
| `aitk inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
|
|
62
63
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
63
64
|
| `aitk upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
|
|
64
65
|
|
|
65
66
|
`aitk demo` is the second browser command and the one that ships, since its purpose is running in a target rather than regenerating what this repository commits. It needs a browser binary the package does not carry, installed once with `bunx playwright install chromium`.
|
|
66
67
|
|
|
68
|
+
`aitk inventory` is the third and takes the same answer for the same reason. It reads `inventory.toml` at the project root for its base URL, its routes, and the element query each subject runs over, so what it walks comes from the project rather than from the toolkit. It reports how many different answers a site gives for one property and never gates, because whether five focus rings across four routes is a defect is a judgment. A missing server and an unmatched query are both refusals rather than empty listings, since a listing with no rows reads as one consistent answer.
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
base-url = "http://localhost:4173"
|
|
72
|
+
routes = ["/", "/pricing", "/docs"]
|
|
73
|
+
|
|
74
|
+
[subjects.focus]
|
|
75
|
+
query = "button, a[href], input, select, textarea, [tabindex]"
|
|
76
|
+
```
|
|
77
|
+
|
|
67
78
|
## Domain commands
|
|
68
79
|
|
|
69
80
|
Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
|
|
@@ -76,6 +87,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
76
87
|
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
|
|
77
88
|
| `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
|
|
78
89
|
| `demo` | `compile`, `run` |
|
|
90
|
+
| `inventory` | `run` |
|
|
79
91
|
| `wiki` | `init` |
|
|
80
92
|
| `design` | `render` |
|
|
81
93
|
| `slides` | `render`, `list` |
|
package/docs/agents/sessions.md
CHANGED
|
@@ -34,6 +34,16 @@ A bare run reports every repository and carries a `repository` field on each row
|
|
|
34
34
|
|
|
35
35
|
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.
|
|
36
36
|
|
|
37
|
+
## Whether a branch is already claimed
|
|
38
|
+
|
|
39
|
+
With `--branch`, the JSON record also carries `worktree` (the path of any worktree already checked out to it, or `null`) and `claimed` (`true` when either a worktree or a live session already holds the branch).
|
|
40
|
+
|
|
41
|
+
Read `claimed` rather than composing the two fields by hand. A worktree can outlive the session that made it, and a session can hold a branch before a worktree exists for it, so either field alone can miss a real claim. A dispatcher deciding whether it is safe to start a build against a branch reads this one field instead of re-deriving the OR itself.
|
|
42
|
+
|
|
43
|
+
`worktree` and `claimed` are `null` on a bare run with no `--branch`, since neither question has a branch to answer about. A refusal (`no-registry` or `no-repository`) carries neither key at all, which a caller should read as unverified rather than as clear.
|
|
44
|
+
|
|
45
|
+
`sessionsReadable` is `false` when the session roster could not be read, which leaves `claimed` covering the worktree half alone. A `false` here is a report that ran short of evidence, not a report that the branch is clear, so a caller reads it as unverified alongside `claimed` rather than trusting `claimed` on its own.
|
|
46
|
+
|
|
37
47
|
## Why the verb exists
|
|
38
48
|
|
|
39
49
|
A session listing reports a name, a kind, a status, and how long each session has been running. None of those names a branch. Resolving a branch to a session therefore meant ordering the roster by start time and matching it against the order the worktrees were created, which is an inference that fails whenever two sessions start inside the same minute.
|
package/docs/operating-model.md
CHANGED
|
@@ -37,7 +37,7 @@ and no later session recovers that vantage.
|
|
|
37
37
|
One feature travels this path end to end.
|
|
38
38
|
|
|
39
39
|
1. Orchestrator plans the next feature with `claude-feature`, writing a plan to `.claude/plans/`. Planning stays in the warm session because good planning is cross-feature. It needs the contract other features consume and the shared wiring seam. A cold session would re-derive or guess.
|
|
40
|
-
2.
|
|
40
|
+
2. Orchestrator checks the branch is unclaimed and the worker cap has room, then dispatches a background worker with `claude --bg` against the plan. It falls back to naming the invocation for a human to run through `claude-worktree` and `claude-autoship` when the check refuses, the cap is reached, or the plan collides with a track already in flight. Either way, the worker enters its own worktree, builds, self-checks, opens a PR, and stops at the PR boundary.
|
|
41
41
|
3. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
|
|
42
42
|
4. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target then with `aitk sessions list --branch` and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches too, since the grade runs low often enough that a floor at should-fix loses fixes a worker would have made. `claude-pr-review` states that threshold and the heading follows it, so an open heading is itself the signal to send.
|
|
43
43
|
5. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, or the worker's response alone when the follow-up added none, and posts under `## Review` when it finds anything and under `## Review closed` when it finds nothing, so a reader learns from the heading whether work is still owed and takes the merge decision from the counts on the line under it. Repeat from step 4 until a pass closes the review.
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every browser-driving command needs to know before it can report a
|
|
3
|
+
* failure honestly: how the binary is installed, and how each of the two ways
|
|
4
|
+
* it can be absent reads when it is thrown.
|
|
5
|
+
*
|
|
6
|
+
* The two are different states with different remedies. A package that never
|
|
7
|
+
* resolved means the target installed the CLI without the engine, and a binary
|
|
8
|
+
* that was never downloaded means the engine is present and its browser is not.
|
|
9
|
+
* Both were spelled inside `src/demo/` when `demo` was the only command driving
|
|
10
|
+
* a browser, and a second command is what makes them shared rather than local.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Fetches the browser revision the pinned engine expects. */
|
|
14
|
+
export const INSTALL_BROWSER = 'bunx playwright install chromium'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Separates a browser binary that was never installed from every other launch
|
|
18
|
+
* failure, because the first is a setup step the operator has to run and the
|
|
19
|
+
* second is a defect. A target inherits that setup step, which is the stated
|
|
20
|
+
* cost of shipping a browser command outside the toolkit.
|
|
21
|
+
*/
|
|
22
|
+
export function isBrowserMissing(error: unknown): boolean {
|
|
23
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
24
|
+
return /executable doesn't exist|playwright install/i.test(text)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reports the engine package failing to resolve, which is the case a target
|
|
29
|
+
* hits before installing it. Any other import failure is a defect inside the
|
|
30
|
+
* module being loaded and propagates, rather than being reported as a missing
|
|
31
|
+
* dependency.
|
|
32
|
+
*/
|
|
33
|
+
export function isEngineMissing(error: unknown): boolean {
|
|
34
|
+
return (
|
|
35
|
+
typeof error === 'object' &&
|
|
36
|
+
error !== null &&
|
|
37
|
+
'code' in error &&
|
|
38
|
+
error.code === 'ERR_MODULE_NOT_FOUND'
|
|
39
|
+
)
|
|
40
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { register as design } from '@/commands/design'
|
|
|
16
16
|
import { register as slides } from '@/commands/slides'
|
|
17
17
|
import { register as capture } from '@/commands/capture'
|
|
18
18
|
import { register as demo } from '@/commands/demo'
|
|
19
|
+
import { register as inventory } from '@/commands/inventory'
|
|
19
20
|
import { register as feedback } from '@/commands/feedback'
|
|
20
21
|
import { register as transcripts } from '@/commands/transcripts'
|
|
21
22
|
import { register as tasks } from '@/commands/tasks'
|
|
@@ -59,6 +60,7 @@ function showHelp(): void {
|
|
|
59
60
|
`${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
|
|
60
61
|
`${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
|
|
61
62
|
`${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
|
|
63
|
+
`${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
|
|
62
64
|
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
|
|
63
65
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
64
66
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
@@ -102,6 +104,7 @@ function showHelp(): void {
|
|
|
102
104
|
`${GREY}│${NC} aitk slides render`,
|
|
103
105
|
`${GREY}│${NC} aitk slides list --json`,
|
|
104
106
|
`${GREY}│${NC} aitk capture assets/install.html`,
|
|
107
|
+
`${GREY}│${NC} aitk inventory focus --json`,
|
|
105
108
|
`${GREY}│${NC} pbpaste | aitk feedback`,
|
|
106
109
|
`${GREY}│${NC} aitk transcripts https://youtu.be/VIDEO_ID`,
|
|
107
110
|
`${GREY}│${NC} aitk tasks archive --pull-request 673 --json`,
|
|
@@ -153,6 +156,7 @@ design(program)
|
|
|
153
156
|
slides(program)
|
|
154
157
|
capture(program)
|
|
155
158
|
demo(program)
|
|
159
|
+
inventory(program)
|
|
156
160
|
feedback(program)
|
|
157
161
|
transcripts(program)
|
|
158
162
|
tasks(program)
|
package/src/commands/demo.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
4
5
|
import { parseDraft } from '@/demo/beats'
|
|
5
6
|
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
6
7
|
import { convertToMp4, INSTALL_CONVERTER } from '@/demo/container'
|
|
@@ -9,7 +10,6 @@ import { loadCursorTheme } from '@/demo/theme'
|
|
|
9
10
|
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
10
11
|
|
|
11
12
|
const DEFAULT_OUT = 'demos'
|
|
12
|
-
const INSTALL_BROWSER = 'bunx playwright install chromium'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Holds wiring only. Every browser reference sits behind `loadDriver`, because
|
|
@@ -359,20 +359,11 @@ async function loadDriver(): Promise<Driver | undefined> {
|
|
|
359
359
|
try {
|
|
360
360
|
return await import('@/demo/drive')
|
|
361
361
|
} catch (error) {
|
|
362
|
-
if (
|
|
362
|
+
if (isEngineMissing(error)) return undefined
|
|
363
363
|
throw error
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
-
function isModuleNotFound(error: unknown): boolean {
|
|
368
|
-
return (
|
|
369
|
-
typeof error === 'object' &&
|
|
370
|
-
error !== null &&
|
|
371
|
-
'code' in error &&
|
|
372
|
-
error.code === 'ERR_MODULE_NOT_FOUND'
|
|
373
|
-
)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
367
|
/**
|
|
377
368
|
* Resolves where one artifact lands. `--out` replaces the directory the plan
|
|
378
369
|
* names rather than acting as a root the plan's own directory hangs off, which
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_REL,
|
|
6
|
+
type ConfigRefusal,
|
|
7
|
+
readInventoryConfig,
|
|
8
|
+
} from '@/inventory/config'
|
|
9
|
+
import { abbreviate, groupByTreatment } from '@/inventory/group'
|
|
10
|
+
import { findSubject, SUBJECTS } from '@/inventory/subjects'
|
|
11
|
+
import type { WalkRefusal } from '@/inventory/walk'
|
|
12
|
+
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Holds wiring only. Every browser reference sits behind `loadWalker`, because
|
|
16
|
+
* `src/cli.ts` imports this module at startup and resolving the engine there
|
|
17
|
+
* would put a browser launch in front of every other command.
|
|
18
|
+
*/
|
|
19
|
+
type Walker = typeof import('@/inventory/walk')
|
|
20
|
+
|
|
21
|
+
interface RunOptions {
|
|
22
|
+
readonly root?: string
|
|
23
|
+
readonly baseUrl?: string
|
|
24
|
+
readonly json?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a reader does about each way the config produced no walk. */
|
|
28
|
+
const CONFIG_REFUSALS: Record<ConfigRefusal, string> = {
|
|
29
|
+
'no-config': `No ${CONFIG_REL} here, so no routes and no element query are declared.`,
|
|
30
|
+
'unreadable-config': `${CONFIG_REL} is not valid TOML, so no route could be read.`,
|
|
31
|
+
'no-routes': `${CONFIG_REL} carries no route under routes, so there is nothing to walk.`,
|
|
32
|
+
'no-base-url': `${CONFIG_REL} carries no base-url, so no route resolves to an address.`,
|
|
33
|
+
'no-subjects': `${CONFIG_REL} declares no subject, and the element query comes from this project rather than from the toolkit.`,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WALK_REFUSALS: Record<WalkRefusal, string> = {
|
|
37
|
+
'browser-missing': 'The browser binary is not installed in this project.',
|
|
38
|
+
'server-unreachable':
|
|
39
|
+
'Nothing answered at the base URL, so no route was read.',
|
|
40
|
+
'walk-failed': 'The walk failed against a running server.',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function register(program: Command): void {
|
|
44
|
+
const inventory = program
|
|
45
|
+
.command('inventory')
|
|
46
|
+
.description('Report one computed property across every route of a project')
|
|
47
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
48
|
+
|
|
49
|
+
inventory
|
|
50
|
+
.command('run', { isDefault: true })
|
|
51
|
+
.description(
|
|
52
|
+
'Walk every route and group its elements by the answer each gives',
|
|
53
|
+
)
|
|
54
|
+
.argument('[subject]', 'Treatment to read, defaulting to focus', 'focus')
|
|
55
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
56
|
+
.option('--root <path>', 'Project to read, defaulting to the cwd')
|
|
57
|
+
.option('--base-url <url>', 'Address to walk, overriding the config')
|
|
58
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
59
|
+
.addHelpText(
|
|
60
|
+
'after',
|
|
61
|
+
[
|
|
62
|
+
'',
|
|
63
|
+
`Reads ${CONFIG_REL} for the base URL, the routes, and the element query`,
|
|
64
|
+
'each subject runs over, so what gets walked comes from the project',
|
|
65
|
+
'rather than from the toolkit.',
|
|
66
|
+
'',
|
|
67
|
+
'It reports a listing and never a verdict. The value is seeing how many',
|
|
68
|
+
'different answers one site gives, and a gate collapses that to one bit.',
|
|
69
|
+
'',
|
|
70
|
+
'Needs a running server and a browser binary. Install the browser with:',
|
|
71
|
+
` ${INSTALL_BROWSER}`,
|
|
72
|
+
'',
|
|
73
|
+
'Subjects:',
|
|
74
|
+
...SUBJECTS.map((subject) => ` ${subject.name} ${subject.summary}`),
|
|
75
|
+
'',
|
|
76
|
+
'Exit codes:',
|
|
77
|
+
' 0 the walk read at least one element and reported its rows',
|
|
78
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
79
|
+
'',
|
|
80
|
+
'Examples:',
|
|
81
|
+
' aitk inventory focus',
|
|
82
|
+
' aitk inventory focus --json',
|
|
83
|
+
' aitk inventory focus --base-url http://localhost:3000',
|
|
84
|
+
'',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
.action(async (subject: string, opts: RunOptions) => {
|
|
88
|
+
process.exitCode = await runInventory(subject, opts)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function runInventory(
|
|
93
|
+
subjectName: string,
|
|
94
|
+
opts: RunOptions,
|
|
95
|
+
): Promise<number> {
|
|
96
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
97
|
+
const emitJson = opts.json ?? false
|
|
98
|
+
|
|
99
|
+
intro(`aitk inventory ${subjectName}`)
|
|
100
|
+
|
|
101
|
+
const subject = findSubject(subjectName)
|
|
102
|
+
if (!subject) {
|
|
103
|
+
const known = SUBJECTS.map((entry) => entry.name).join(', ')
|
|
104
|
+
return refuse(
|
|
105
|
+
emitJson,
|
|
106
|
+
root,
|
|
107
|
+
'unknown-subject',
|
|
108
|
+
`No reader named ${subjectName}. This build ships: ${known}.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const read = readInventoryConfig(root)
|
|
113
|
+
if (read.kind === 'refused') {
|
|
114
|
+
return refuse(emitJson, root, read.reason, CONFIG_REFUSALS[read.reason])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const declared = read.config.subjects.find(
|
|
118
|
+
(entry) => entry.name === subjectName,
|
|
119
|
+
)
|
|
120
|
+
if (!declared) {
|
|
121
|
+
const named = read.config.subjects.map((entry) => entry.name).join(', ')
|
|
122
|
+
return refuse(
|
|
123
|
+
emitJson,
|
|
124
|
+
root,
|
|
125
|
+
'undeclared-subject',
|
|
126
|
+
`${CONFIG_REL} declares no query for ${subjectName}. It declares: ${named}.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const baseUrl = opts.baseUrl ?? read.config.baseUrl
|
|
131
|
+
|
|
132
|
+
logStep('Scope')
|
|
133
|
+
logInfo(
|
|
134
|
+
`${plural(read.config.routes.length, 'route')} from ${baseUrl}, matching ${declared.query}`,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const walker = await loadWalker()
|
|
138
|
+
if (!walker) {
|
|
139
|
+
logStep('Browser')
|
|
140
|
+
logError('the browser engine is not installed in this project')
|
|
141
|
+
logWarn(`Install it with: ${INSTALL_BROWSER}`)
|
|
142
|
+
outro()
|
|
143
|
+
emit(emitJson, {
|
|
144
|
+
root,
|
|
145
|
+
subject: subjectName,
|
|
146
|
+
reason: 'engine-missing',
|
|
147
|
+
install: INSTALL_BROWSER,
|
|
148
|
+
})
|
|
149
|
+
return 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = await walker.walk({
|
|
153
|
+
baseUrl,
|
|
154
|
+
routes: read.config.routes,
|
|
155
|
+
subject,
|
|
156
|
+
query: declared.query,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (result.status === 'failed') {
|
|
160
|
+
logStep('Refused')
|
|
161
|
+
logWarn(WALK_REFUSALS[result.reason])
|
|
162
|
+
if (result.reason === 'server-unreachable') {
|
|
163
|
+
logWarn(`Start the project at ${baseUrl}, then run this again.`)
|
|
164
|
+
}
|
|
165
|
+
if (result.reason === 'browser-missing') {
|
|
166
|
+
logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
|
|
167
|
+
}
|
|
168
|
+
logWarn(result.message.split('\n')[0] ?? '')
|
|
169
|
+
outro()
|
|
170
|
+
emit(emitJson, {
|
|
171
|
+
root,
|
|
172
|
+
subject: subjectName,
|
|
173
|
+
baseUrl,
|
|
174
|
+
reason: result.reason,
|
|
175
|
+
message: result.message,
|
|
176
|
+
...(result.reason === 'browser-missing'
|
|
177
|
+
? { install: INSTALL_BROWSER }
|
|
178
|
+
: {}),
|
|
179
|
+
})
|
|
180
|
+
return 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const groups = groupByTreatment(result.readings)
|
|
184
|
+
|
|
185
|
+
logStep('Routes')
|
|
186
|
+
for (const route of result.routes) {
|
|
187
|
+
logInfo(`${route.route} ${plural(route.elements, 'element')}`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A walk that matched nothing refuses rather than printing an empty listing,
|
|
191
|
+
// because no rows and one row read the same to anything counting them, and
|
|
192
|
+
// the first says the query reached nothing while the second says the site
|
|
193
|
+
// gives one consistent answer.
|
|
194
|
+
if (groups.length === 0) {
|
|
195
|
+
return refuse(
|
|
196
|
+
emitJson,
|
|
197
|
+
root,
|
|
198
|
+
'no-elements',
|
|
199
|
+
`No element matched ${declared.query} on any of the ${plural(result.routes.length, 'route')} walked.`,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
logStep('Treatments')
|
|
204
|
+
logInfo(
|
|
205
|
+
`${plural(groups.length, 'answer')} across ${plural(result.readings.length, 'element')}`,
|
|
206
|
+
)
|
|
207
|
+
for (const group of groups) {
|
|
208
|
+
logInfo(` ${group.count}x ${group.treatment}`)
|
|
209
|
+
logInfo(
|
|
210
|
+
` ${abbreviate(group.samples, group.count)} on ${abbreviate(group.routes, group.routes.length)}`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
outro()
|
|
214
|
+
|
|
215
|
+
emit(emitJson, {
|
|
216
|
+
root,
|
|
217
|
+
subject: subjectName,
|
|
218
|
+
baseUrl,
|
|
219
|
+
routes: result.routes,
|
|
220
|
+
elements: result.readings.length,
|
|
221
|
+
treatments: groups,
|
|
222
|
+
durationMs: result.durationMs,
|
|
223
|
+
})
|
|
224
|
+
return 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Frames a refusal on stderr and puts the record on stdout, so an operator
|
|
229
|
+
* reading the terminal sees the reason rather than a command that appeared to
|
|
230
|
+
* do nothing.
|
|
231
|
+
*/
|
|
232
|
+
function refuse(
|
|
233
|
+
emitJson: boolean,
|
|
234
|
+
root: string,
|
|
235
|
+
reason: string,
|
|
236
|
+
message: string,
|
|
237
|
+
): number {
|
|
238
|
+
logStep('Refused')
|
|
239
|
+
logWarn(message)
|
|
240
|
+
outro()
|
|
241
|
+
emit(emitJson, { root, reason, message })
|
|
242
|
+
return 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function loadWalker(): Promise<Walker | undefined> {
|
|
246
|
+
try {
|
|
247
|
+
return await import('@/inventory/walk')
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (isEngineMissing(error)) return undefined
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function emit(json: boolean, record: unknown): void {
|
|
255
|
+
if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
256
|
+
}
|
package/src/commands/sessions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
|
+
import { checkClaim, type ClaimReport } from '@/sessions/claim'
|
|
2
3
|
import {
|
|
3
4
|
repositoryOf,
|
|
4
5
|
type ResolvedSession,
|
|
@@ -58,6 +59,18 @@ export function register(program: Command): void {
|
|
|
58
59
|
'A bare run reports every repository and carries the repository field,',
|
|
59
60
|
'so a caller filtering by hand has something that identifies one.',
|
|
60
61
|
'',
|
|
62
|
+
'With --branch, the JSON also carries "worktree" (the path of any',
|
|
63
|
+
'worktree already checked out to it, or null) and "claimed" (true when',
|
|
64
|
+
'either a worktree or a live session already holds it). A dispatcher',
|
|
65
|
+
'reads "claimed" rather than composing the two fields itself, since',
|
|
66
|
+
'either one alone can miss a real claim: a worktree can outlive the',
|
|
67
|
+
'session that made it, and a session can hold a branch before a',
|
|
68
|
+
'worktree exists for it.',
|
|
69
|
+
'',
|
|
70
|
+
'"sessionsReadable" is false when the session roster could not be read,',
|
|
71
|
+
'which leaves "claimed" covering the worktree half alone. Treat that',
|
|
72
|
+
'case as unverified rather than as a clean "false".',
|
|
73
|
+
'',
|
|
61
74
|
'The match can return more than one session. Read the count rather than',
|
|
62
75
|
'the first row, since two sessions can hold one branch.',
|
|
63
76
|
'',
|
|
@@ -132,9 +145,17 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
132
145
|
)
|
|
133
146
|
: report.sessions
|
|
134
147
|
|
|
148
|
+
const claim = opts.branch
|
|
149
|
+
? await checkClaim(opts.branch, {
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
resolve: async () => report,
|
|
152
|
+
})
|
|
153
|
+
: null
|
|
154
|
+
|
|
135
155
|
intro('aitk sessions list')
|
|
136
156
|
reportConfidence(report)
|
|
137
157
|
reportSessions(shown, opts.branch, repository)
|
|
158
|
+
if (claim) reportClaim(claim)
|
|
138
159
|
outro()
|
|
139
160
|
|
|
140
161
|
if (opts.json) {
|
|
@@ -144,6 +165,9 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
144
165
|
confidence: report.confidence,
|
|
145
166
|
branch: opts.branch ?? null,
|
|
146
167
|
repository,
|
|
168
|
+
worktree: claim?.worktree ?? null,
|
|
169
|
+
claimed: claim?.claimed ?? null,
|
|
170
|
+
sessionsReadable: claim?.sessionsReadable ?? null,
|
|
147
171
|
sessions: shown,
|
|
148
172
|
})}\n`,
|
|
149
173
|
)
|
|
@@ -229,3 +253,19 @@ function reportSessions(
|
|
|
229
253
|
.join('\n'),
|
|
230
254
|
)
|
|
231
255
|
}
|
|
256
|
+
|
|
257
|
+
function reportClaim(claim: ClaimReport): void {
|
|
258
|
+
logStep('Claim')
|
|
259
|
+
|
|
260
|
+
if (claim.worktree) {
|
|
261
|
+
logInfo(`Worktree: ${claim.worktree}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
logInfo(claim.claimed ? 'Claimed.' : 'Unclaimed.')
|
|
265
|
+
|
|
266
|
+
if (!claim.sessionsReadable) {
|
|
267
|
+
logWarn(
|
|
268
|
+
'The session roster could not be read, so this reads the worktree listing alone. Treat the session half as unverified rather than clear.',
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/demo/drive.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import type { Browser, BrowserContext, Page } from 'playwright-core'
|
|
6
|
+
import { isBrowserMissing } from '@/browser/engine'
|
|
6
7
|
import { deriveSteps } from '@/demo/compile'
|
|
7
8
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
8
9
|
import type { CursorSet } from '@/demo/pointer'
|
|
@@ -181,20 +182,17 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* Reads the launch through `@/browser/engine`, which is where the separation
|
|
186
|
+
* between a binary that was never installed and every other launch failure now
|
|
187
|
+
* lives. It moved out of this file when `aitk inventory` became the second
|
|
188
|
+
* command needing it, rather than being copied.
|
|
188
189
|
*/
|
|
189
190
|
async function launch(): Promise<Launch> {
|
|
190
191
|
try {
|
|
191
192
|
return { status: 'launched', value: await chromium.launch() }
|
|
192
193
|
} catch (error) {
|
|
193
|
-
const text = error instanceof Error ? error.message : String(error)
|
|
194
194
|
return failed(
|
|
195
|
-
|
|
196
|
-
? 'browser-missing'
|
|
197
|
-
: 'drive-failed',
|
|
195
|
+
isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
|
|
198
196
|
error,
|
|
199
197
|
)
|
|
200
198
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a project declares what an inventory walks, spelled once.
|
|
6
|
+
*
|
|
7
|
+
* It sits at the project root rather than under `.claude/`, because the routes
|
|
8
|
+
* and the element queries describe the application rather than the agent
|
|
9
|
+
* surface, and a target reads them the way it reads any other build input.
|
|
10
|
+
*/
|
|
11
|
+
export const CONFIG_REL = 'inventory.toml'
|
|
12
|
+
|
|
13
|
+
/** One measurable treatment and the elements that carry it, as the project spells them. */
|
|
14
|
+
export interface InventorySubject {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly query: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InventoryConfig {
|
|
20
|
+
readonly baseUrl: string
|
|
21
|
+
readonly routes: readonly string[]
|
|
22
|
+
readonly subjects: readonly InventorySubject[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why a config could not be used, which is never the same as a walk that read
|
|
27
|
+
* nothing.
|
|
28
|
+
*
|
|
29
|
+
* Every value here is a refusal rather than an empty listing, because a listing
|
|
30
|
+
* with no rows reads as a site giving one consistent answer, which is the
|
|
31
|
+
* report an unconfigured project would get for free and the exact wrong reading.
|
|
32
|
+
*/
|
|
33
|
+
export type ConfigRefusal =
|
|
34
|
+
| 'no-config'
|
|
35
|
+
| 'unreadable-config'
|
|
36
|
+
| 'no-base-url'
|
|
37
|
+
| 'no-routes'
|
|
38
|
+
| 'no-subjects'
|
|
39
|
+
|
|
40
|
+
export type ConfigRead =
|
|
41
|
+
| { readonly kind: 'config'; readonly config: InventoryConfig }
|
|
42
|
+
| { readonly kind: 'refused'; readonly reason: ConfigRefusal }
|
|
43
|
+
|
|
44
|
+
function readSubjects(table: unknown): InventorySubject[] {
|
|
45
|
+
if (typeof table !== 'object' || table === null || Array.isArray(table)) {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const subjects: InventorySubject[] = []
|
|
50
|
+
for (const [name, value] of Object.entries(table)) {
|
|
51
|
+
if (typeof value !== 'object' || value === null) continue
|
|
52
|
+
const query = (value as Record<string, unknown>).query
|
|
53
|
+
if (typeof query !== 'string' || query === '') continue
|
|
54
|
+
subjects.push({ name, query })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return subjects
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parses config text, so a caller holding the bytes skips the filesystem.
|
|
62
|
+
*
|
|
63
|
+
* A malformed route or subject is dropped rather than refused, matching the row
|
|
64
|
+
* handling in `@/labels/map`: one bad entry should not blind the walk to the
|
|
65
|
+
* other twenty. What that costs is a typo reading as an entry nobody wrote,
|
|
66
|
+
* which the listing surfaces from the other side as a route with no elements.
|
|
67
|
+
*/
|
|
68
|
+
export function parseInventoryConfig(source: string): ConfigRead {
|
|
69
|
+
let parsed: Record<string, unknown>
|
|
70
|
+
try {
|
|
71
|
+
parsed = Bun.TOML.parse(source) as Record<string, unknown>
|
|
72
|
+
} catch {
|
|
73
|
+
return { kind: 'refused', reason: 'unreadable-config' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseUrl = parsed['base-url']
|
|
77
|
+
if (typeof baseUrl !== 'string' || baseUrl === '') {
|
|
78
|
+
return { kind: 'refused', reason: 'no-base-url' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const routes = Array.isArray(parsed.routes)
|
|
82
|
+
? parsed.routes.filter(
|
|
83
|
+
(route): route is string => typeof route === 'string' && route !== '',
|
|
84
|
+
)
|
|
85
|
+
: []
|
|
86
|
+
if (routes.length === 0) return { kind: 'refused', reason: 'no-routes' }
|
|
87
|
+
|
|
88
|
+
const subjects = readSubjects(parsed.subjects)
|
|
89
|
+
if (subjects.length === 0) return { kind: 'refused', reason: 'no-subjects' }
|
|
90
|
+
|
|
91
|
+
return { kind: 'config', config: { baseUrl, routes, subjects } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reads the config a project declares at `root`, or says why it could not. */
|
|
95
|
+
export function readInventoryConfig(root: string): ConfigRead {
|
|
96
|
+
let source: string
|
|
97
|
+
try {
|
|
98
|
+
source = readFileSync(join(root, CONFIG_REL), 'utf8')
|
|
99
|
+
} catch {
|
|
100
|
+
return { kind: 'refused', reason: 'no-config' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return parseInventoryConfig(source)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Joins a base and a route into the address a walk opens.
|
|
108
|
+
*
|
|
109
|
+
* Concatenation rather than `new URL(route, base)`, because the resolver reads
|
|
110
|
+
* a leading slash as absolute and drops any path the base already carries, so a
|
|
111
|
+
* project served under a subdirectory would have every route walk the origin.
|
|
112
|
+
*/
|
|
113
|
+
export function routeUrl(baseUrl: string, route: string): string {
|
|
114
|
+
const base = baseUrl.replace(/\/+$/, '')
|
|
115
|
+
const path = route.startsWith('/') ? route : `/${route}`
|
|
116
|
+
return `${base}${path}`
|
|
117
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many elements a row names before it stops naming them. A row exists to
|
|
3
|
+
* say how many answers a site gives, and the elements under it are there to
|
|
4
|
+
* make one findable rather than to enumerate the set.
|
|
5
|
+
*/
|
|
6
|
+
export const SAMPLE_LIMIT = 3
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Names a bounded head of a list and says how many it left out, so a treatment
|
|
10
|
+
* carried by fifty routes reads as fifty rather than as the three named.
|
|
11
|
+
*
|
|
12
|
+
* `total` arrives separately because the two lists reach here differently. A
|
|
13
|
+
* row's samples are already capped when they are collected, so their own length
|
|
14
|
+
* cannot say how many elements the row covers, while its routes arrive whole.
|
|
15
|
+
*/
|
|
16
|
+
export function abbreviate(entries: readonly string[], total: number): string {
|
|
17
|
+
const head = entries.slice(0, SAMPLE_LIMIT)
|
|
18
|
+
const elided = total - head.length
|
|
19
|
+
return elided > 0 ? `${head.join(', ')} and ${elided} more` : head.join(', ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One element as the walk read it, on the route it was read from. */
|
|
23
|
+
export interface Reading {
|
|
24
|
+
readonly route: string
|
|
25
|
+
readonly selector: string
|
|
26
|
+
readonly treatment: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One answer the site gives, and what carries it. */
|
|
30
|
+
export interface TreatmentGroup {
|
|
31
|
+
readonly treatment: string
|
|
32
|
+
readonly count: number
|
|
33
|
+
readonly routes: readonly string[]
|
|
34
|
+
readonly samples: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Turns a per-element walk into a per-answer listing, which is the whole point
|
|
39
|
+
* of the command.
|
|
40
|
+
*
|
|
41
|
+
* Grouping by the component instead was the obvious shape and it reports what a
|
|
42
|
+
* reader already knows, that a site has buttons and links. Grouping by the
|
|
43
|
+
* computed answer reports the thing nobody can see while building, which is
|
|
44
|
+
* that five components resolved to five different rings.
|
|
45
|
+
*
|
|
46
|
+
* Order is heaviest first, then by treatment, so the dominant answer leads and
|
|
47
|
+
* two runs over one site report one order rather than whatever the walk hit.
|
|
48
|
+
*/
|
|
49
|
+
export function groupByTreatment(
|
|
50
|
+
readings: readonly Reading[],
|
|
51
|
+
): readonly TreatmentGroup[] {
|
|
52
|
+
const byTreatment = new Map<
|
|
53
|
+
string,
|
|
54
|
+
{ count: number; routes: string[]; samples: string[] }
|
|
55
|
+
>()
|
|
56
|
+
|
|
57
|
+
for (const { route, selector, treatment } of readings) {
|
|
58
|
+
const row = byTreatment.get(treatment) ?? {
|
|
59
|
+
count: 0,
|
|
60
|
+
routes: [],
|
|
61
|
+
samples: [],
|
|
62
|
+
}
|
|
63
|
+
row.count += 1
|
|
64
|
+
if (!row.routes.includes(route)) row.routes.push(route)
|
|
65
|
+
if (row.samples.length < SAMPLE_LIMIT) row.samples.push(selector)
|
|
66
|
+
byTreatment.set(treatment, row)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [...byTreatment.entries()]
|
|
70
|
+
.map(([treatment, row]) => ({ treatment, ...row }))
|
|
71
|
+
.sort(
|
|
72
|
+
(left, right) =>
|
|
73
|
+
right.count - left.count ||
|
|
74
|
+
left.treatment.localeCompare(right.treatment),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readers an inventory can run, one per treatment.
|
|
3
|
+
*
|
|
4
|
+
* A reader runs inside the page rather than here, so each one is serialized to
|
|
5
|
+
* source and evaluated by the browser. That is why every helper a reader needs
|
|
6
|
+
* is declared inside its own body: a reference to anything at module scope
|
|
7
|
+
* survives typechecking and throws once the page tries to call it.
|
|
8
|
+
*
|
|
9
|
+
* `focus` is the first and, for now, the only subject. The four sibling
|
|
10
|
+
* instruments this shape was lifted from differ only in their element query and
|
|
11
|
+
* the property they read, which is what makes a subject a row here rather than
|
|
12
|
+
* a command of its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One element as a reader saw it, before any grouping. */
|
|
16
|
+
export interface SubjectReading {
|
|
17
|
+
readonly selector: string
|
|
18
|
+
readonly treatment: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Subject {
|
|
22
|
+
readonly name: string
|
|
23
|
+
/** What the listing means, printed above the rows so a count reads correctly. */
|
|
24
|
+
readonly summary: string
|
|
25
|
+
readonly read: (query: string) => SubjectReading[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reports what each element changes about itself when it takes focus, which is
|
|
30
|
+
* the reading `governance/rules/ui/410-a11y.md` states three rules against and
|
|
31
|
+
* nothing measures.
|
|
32
|
+
*
|
|
33
|
+
* The difference between rest and focus is the treatment, rather than the
|
|
34
|
+
* focused style on its own. A card carrying a resting shadow computes a shadow
|
|
35
|
+
* either way, so reading only the focused state would report a ring on an
|
|
36
|
+
* element whose appearance never moves.
|
|
37
|
+
*
|
|
38
|
+
* An element the browser refuses to focus is named as such rather than folded
|
|
39
|
+
* into the no-treatment row, since a disabled control and a control with no
|
|
40
|
+
* ring are different findings with different remedies.
|
|
41
|
+
*/
|
|
42
|
+
function readFocusTreatments(query: string): SubjectReading[] {
|
|
43
|
+
const PROPERTIES = [
|
|
44
|
+
'outlineStyle',
|
|
45
|
+
'outlineWidth',
|
|
46
|
+
'outlineColor',
|
|
47
|
+
'outlineOffset',
|
|
48
|
+
'boxShadow',
|
|
49
|
+
'borderColor',
|
|
50
|
+
'backgroundColor',
|
|
51
|
+
'color',
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
const describe = (element: Element): string => {
|
|
55
|
+
const tag = element.tagName.toLowerCase()
|
|
56
|
+
if (element.id) return `${tag}#${element.id}`
|
|
57
|
+
const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
|
|
58
|
+
return className ? `${tag}.${className}` : tag
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const snapshot = (element: Element): Record<string, string> => {
|
|
62
|
+
const computed = getComputedStyle(element)
|
|
63
|
+
const values: Record<string, string> = {}
|
|
64
|
+
for (const property of PROPERTIES) values[property] = computed[property]
|
|
65
|
+
return values
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rows: SubjectReading[] = []
|
|
69
|
+
|
|
70
|
+
// The walk presses Tab before this runs, which leaves one element focused.
|
|
71
|
+
// Reading that element's rest state while it holds focus reports no
|
|
72
|
+
// difference and hides whatever ring it actually draws, so the page starts
|
|
73
|
+
// from nothing focused and every element is blurred again after its turn.
|
|
74
|
+
const entryFocus = document.activeElement
|
|
75
|
+
if (entryFocus instanceof HTMLElement) entryFocus.blur()
|
|
76
|
+
|
|
77
|
+
for (const element of Array.from(document.querySelectorAll(query))) {
|
|
78
|
+
if (!(element instanceof HTMLElement)) continue
|
|
79
|
+
|
|
80
|
+
const rest = snapshot(element)
|
|
81
|
+
element.focus()
|
|
82
|
+
if (document.activeElement !== element) {
|
|
83
|
+
rows.push({ selector: describe(element), treatment: 'not focusable' })
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const focused = snapshot(element)
|
|
88
|
+
const changed = PROPERTIES.filter(
|
|
89
|
+
(property) => rest[property] !== focused[property],
|
|
90
|
+
).map((property) => `${property} ${focused[property]}`)
|
|
91
|
+
|
|
92
|
+
rows.push({
|
|
93
|
+
selector: describe(element),
|
|
94
|
+
treatment:
|
|
95
|
+
changed.length === 0 ? 'no visible change' : changed.join(', '),
|
|
96
|
+
})
|
|
97
|
+
element.blur()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const FOCUS: Subject = {
|
|
104
|
+
name: 'focus',
|
|
105
|
+
summary: 'what each element changes about itself when it takes focus',
|
|
106
|
+
read: readFocusTreatments,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const SUBJECTS: readonly Subject[] = [FOCUS]
|
|
110
|
+
|
|
111
|
+
/** Resolves a subject by name, so an unknown one is the caller's to report. */
|
|
112
|
+
export function findSubject(name: string): Subject | undefined {
|
|
113
|
+
return SUBJECTS.find((subject) => subject.name === name)
|
|
114
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core'
|
|
2
|
+
import type { Browser } from 'playwright-core'
|
|
3
|
+
import { isBrowserMissing } from '@/browser/engine'
|
|
4
|
+
import { routeUrl } from '@/inventory/config'
|
|
5
|
+
import type { Reading } from '@/inventory/group'
|
|
6
|
+
import type { Subject } from '@/inventory/subjects'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walks a running application and reads one property off every element a
|
|
10
|
+
* subject names. Every browser reference the inventory feature adds lives here,
|
|
11
|
+
* and `src/commands/inventory.ts` reaches it through a dynamic import so no
|
|
12
|
+
* other command resolves the engine at startup.
|
|
13
|
+
*
|
|
14
|
+
* Like `@/demo/drive` and unlike `@/capture/render`, this module ships, because
|
|
15
|
+
* a command whose whole purpose is running inside someone else's project cannot
|
|
16
|
+
* stay toolkit-only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The reader and the query arrive apart because they come from different
|
|
21
|
+
* owners. The toolkit ships the reader and the project declares which elements
|
|
22
|
+
* it runs over, which is what keeps the walk answering to the target rather
|
|
23
|
+
* than to a fixed selector nobody there chose.
|
|
24
|
+
*/
|
|
25
|
+
export interface WalkOptions {
|
|
26
|
+
readonly baseUrl: string
|
|
27
|
+
readonly routes: readonly string[]
|
|
28
|
+
readonly subject: Subject
|
|
29
|
+
readonly query: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A route that answered, and the elements read off it. */
|
|
33
|
+
export interface RouteReading {
|
|
34
|
+
readonly route: string
|
|
35
|
+
readonly elements: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type WalkRefusal =
|
|
39
|
+
| 'browser-missing'
|
|
40
|
+
| 'server-unreachable'
|
|
41
|
+
| 'walk-failed'
|
|
42
|
+
|
|
43
|
+
export type WalkResult =
|
|
44
|
+
| {
|
|
45
|
+
readonly status: 'read'
|
|
46
|
+
readonly readings: readonly Reading[]
|
|
47
|
+
readonly routes: readonly RouteReading[]
|
|
48
|
+
readonly durationMs: number
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
readonly status: 'failed'
|
|
52
|
+
readonly reason: WalkRefusal
|
|
53
|
+
readonly message: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function failed(reason: WalkRefusal, error: unknown): WalkResult {
|
|
57
|
+
return {
|
|
58
|
+
status: 'failed',
|
|
59
|
+
reason,
|
|
60
|
+
message: error instanceof Error ? error.message : String(error),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Separates a server nobody started from a page that failed for its own
|
|
66
|
+
* reasons. The first is the precondition this command cannot create, and
|
|
67
|
+
* reporting it as an empty listing would say the site gives no answers when
|
|
68
|
+
* nothing was ever asked.
|
|
69
|
+
*/
|
|
70
|
+
function isServerUnreachable(error: unknown): boolean {
|
|
71
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
72
|
+
return /ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE/i.test(
|
|
73
|
+
text,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function walk(options: WalkOptions): Promise<WalkResult> {
|
|
78
|
+
const started = Date.now()
|
|
79
|
+
|
|
80
|
+
let browser: Browser
|
|
81
|
+
try {
|
|
82
|
+
browser = await chromium.launch()
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return failed(
|
|
85
|
+
isBrowserMissing(error) ? 'browser-missing' : 'walk-failed',
|
|
86
|
+
error,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const readings: Reading[] = []
|
|
91
|
+
const routes: RouteReading[] = []
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const page = await browser.newPage()
|
|
95
|
+
|
|
96
|
+
for (const route of options.routes) {
|
|
97
|
+
await page.goto(routeUrl(options.baseUrl, route), {
|
|
98
|
+
waitUntil: 'domcontentloaded',
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Puts the page in keyboard modality before anything is focused, because
|
|
102
|
+
// a `:focus-visible` ring is the treatment a pointer never reveals and
|
|
103
|
+
// programmatic focus alone does not match it.
|
|
104
|
+
await page.keyboard.press('Tab')
|
|
105
|
+
|
|
106
|
+
const rows = await page.evaluate(options.subject.read, options.query)
|
|
107
|
+
for (const row of rows) readings.push({ route, ...row })
|
|
108
|
+
routes.push({ route, elements: rows.length })
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return failed(
|
|
112
|
+
isServerUnreachable(error) ? 'server-unreachable' : 'walk-failed',
|
|
113
|
+
error,
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
// The rejection is dropped rather than propagated, because a close that
|
|
117
|
+
// fails beside a walk that already failed would replace the refusal the
|
|
118
|
+
// caller was about to receive with a reason about teardown. Nothing the
|
|
119
|
+
// caller does depends on the browser having closed cleanly.
|
|
120
|
+
await browser.close().catch(() => undefined)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
status: 'read',
|
|
125
|
+
readings,
|
|
126
|
+
routes,
|
|
127
|
+
durationMs: Date.now() - started,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
repositoryOf,
|
|
3
|
+
resolveSessions,
|
|
4
|
+
type ResolvedSession,
|
|
5
|
+
type SessionReport,
|
|
6
|
+
} from '@/sessions/resolve'
|
|
7
|
+
import { listWorktrees, type WorktreeEntry } from '@/worktree'
|
|
8
|
+
|
|
9
|
+
export interface ClaimReport {
|
|
10
|
+
readonly claimed: boolean
|
|
11
|
+
readonly worktree: string | null
|
|
12
|
+
readonly sessions: readonly ResolvedSession[]
|
|
13
|
+
/** False when the session roster could not be read, so `claimed` covers the worktree half alone and cannot be trusted as a clean "unclaimed". */
|
|
14
|
+
readonly sessionsReadable: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ClaimOptions {
|
|
18
|
+
readonly cwd?: string
|
|
19
|
+
readonly resolve?: () => Promise<SessionReport>
|
|
20
|
+
readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Answers whether a branch is already claimed, composing the two readings
|
|
25
|
+
* neither surface can answer alone: a worktree can outlive the session that
|
|
26
|
+
* made it, and a session can hold a branch before a worktree exists for it.
|
|
27
|
+
*/
|
|
28
|
+
export async function checkClaim(
|
|
29
|
+
branch: string,
|
|
30
|
+
opts: ClaimOptions = {},
|
|
31
|
+
): Promise<ClaimReport> {
|
|
32
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
33
|
+
const resolve = opts.resolve ?? resolveSessions
|
|
34
|
+
const listAll = opts.listWorktrees ?? listWorktrees
|
|
35
|
+
|
|
36
|
+
const [repository, worktrees, report] = await Promise.all([
|
|
37
|
+
repositoryOf(cwd),
|
|
38
|
+
listAll(cwd),
|
|
39
|
+
resolve(),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
const worktree =
|
|
43
|
+
worktrees.find((entry) => entry.branch === branch)?.path ?? null
|
|
44
|
+
|
|
45
|
+
const sessions =
|
|
46
|
+
report.kind === 'resolved'
|
|
47
|
+
? report.sessions.filter(
|
|
48
|
+
(session) =>
|
|
49
|
+
session.branch === branch && session.repository === repository,
|
|
50
|
+
)
|
|
51
|
+
: []
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
claimed: worktree !== null || sessions.length > 0,
|
|
55
|
+
worktree,
|
|
56
|
+
sessions,
|
|
57
|
+
sessionsReadable: report.kind === 'resolved',
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
2
3
|
import {
|
|
3
4
|
type Confidence,
|
|
4
5
|
liveness,
|
|
@@ -67,7 +68,10 @@ export interface Located {
|
|
|
67
68
|
* that has a worktree and no branch and the second has neither.
|
|
68
69
|
*/
|
|
69
70
|
async function locate(cwd: string): Promise<Located> {
|
|
70
|
-
const top = await $`git -C ${cwd} rev-parse --show-toplevel
|
|
71
|
+
const top = await $`git -C ${cwd} rev-parse --show-toplevel`
|
|
72
|
+
.env(gitEnv())
|
|
73
|
+
.quiet()
|
|
74
|
+
.nothrow()
|
|
71
75
|
|
|
72
76
|
if (top.exitCode !== 0) {
|
|
73
77
|
// git absent and git refusing the directory are both non-zero here. The
|
|
@@ -85,7 +89,10 @@ async function locate(cwd: string): Promise<Located> {
|
|
|
85
89
|
|
|
86
90
|
const worktree = top.stdout.toString().trim()
|
|
87
91
|
const repository = await repositoryOf(cwd)
|
|
88
|
-
const head = await $`git -C ${cwd} branch --show-current
|
|
92
|
+
const head = await $`git -C ${cwd} branch --show-current`
|
|
93
|
+
.env(gitEnv())
|
|
94
|
+
.quiet()
|
|
95
|
+
.nothrow()
|
|
89
96
|
const branch = head.stdout.toString().trim()
|
|
90
97
|
|
|
91
98
|
if (head.exitCode !== 0 || branch.length === 0) {
|
|
@@ -105,6 +112,7 @@ async function locate(cwd: string): Promise<Located> {
|
|
|
105
112
|
export async function repositoryOf(cwd: string): Promise<string | null> {
|
|
106
113
|
const dir =
|
|
107
114
|
await $`git -C ${cwd} rev-parse --path-format=absolute --git-common-dir`
|
|
115
|
+
.env(gitEnv())
|
|
108
116
|
.quiet()
|
|
109
117
|
.nothrow()
|
|
110
118
|
|
package/src/worktree.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Resolves the root of the checkout the caller is standing in, which is the
|
|
@@ -10,7 +11,10 @@ import { $ } from 'bun'
|
|
|
10
11
|
* subdirectory would resolve a root holding none of the trees a verb reads.
|
|
11
12
|
*/
|
|
12
13
|
export async function currentWorktreeRoot(): Promise<string> {
|
|
13
|
-
const result = await $`git rev-parse --show-toplevel
|
|
14
|
+
const result = await $`git rev-parse --show-toplevel`
|
|
15
|
+
.env(gitEnv())
|
|
16
|
+
.quiet()
|
|
17
|
+
.nothrow()
|
|
14
18
|
if (result.exitCode !== 0) return process.cwd()
|
|
15
19
|
|
|
16
20
|
return result.stdout.toString().trim() || process.cwd()
|
|
@@ -27,7 +31,10 @@ export async function currentWorktreeRoot(): Promise<string> {
|
|
|
27
31
|
* root in-process is the route a skill body has.
|
|
28
32
|
*/
|
|
29
33
|
export async function mainWorktreeRoot(): Promise<string> {
|
|
30
|
-
const result = await $`git worktree list --porcelain
|
|
34
|
+
const result = await $`git worktree list --porcelain`
|
|
35
|
+
.env(gitEnv())
|
|
36
|
+
.quiet()
|
|
37
|
+
.nothrow()
|
|
31
38
|
if (result.exitCode !== 0) return process.cwd()
|
|
32
39
|
|
|
33
40
|
const line = result.stdout
|
|
@@ -37,3 +44,47 @@ export async function mainWorktreeRoot(): Promise<string> {
|
|
|
37
44
|
|
|
38
45
|
return line ? line.slice('worktree '.length).trim() : process.cwd()
|
|
39
46
|
}
|
|
47
|
+
|
|
48
|
+
export interface WorktreeEntry {
|
|
49
|
+
readonly path: string
|
|
50
|
+
readonly branch: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parses `git worktree list --porcelain`, which emits one block per worktree
|
|
55
|
+
* separated by a blank line. A detached worktree carries no `branch` line,
|
|
56
|
+
* reported here as `null` rather than a guessed name.
|
|
57
|
+
*/
|
|
58
|
+
export async function listWorktrees(
|
|
59
|
+
cwd: string = process.cwd(),
|
|
60
|
+
): Promise<readonly WorktreeEntry[]> {
|
|
61
|
+
const result = await $`git -C ${cwd} worktree list --porcelain`
|
|
62
|
+
.env(gitEnv())
|
|
63
|
+
.quiet()
|
|
64
|
+
.nothrow()
|
|
65
|
+
if (result.exitCode !== 0) return []
|
|
66
|
+
|
|
67
|
+
const entries: WorktreeEntry[] = []
|
|
68
|
+
let path: string | undefined
|
|
69
|
+
let branch: string | null = null
|
|
70
|
+
|
|
71
|
+
for (const line of result.stdout.toString().split('\n')) {
|
|
72
|
+
if (line.startsWith('worktree ')) {
|
|
73
|
+
if (path !== undefined) entries.push({ path, branch })
|
|
74
|
+
path = line.slice('worktree '.length).trim()
|
|
75
|
+
branch = null
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (line.startsWith('branch ')) {
|
|
80
|
+
const ref = line.slice('branch '.length).trim()
|
|
81
|
+
branch = ref.startsWith('refs/heads/')
|
|
82
|
+
? ref.slice('refs/heads/'.length)
|
|
83
|
+
: ref
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (path !== undefined) entries.push({ path, branch })
|
|
88
|
+
|
|
89
|
+
return entries
|
|
90
|
+
}
|