@erclx/aitk 3.29.0 → 3.30.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-dispatch.md +30 -8
- package/claude/skills/claude-worktree/SKILL.md +10 -3
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/sessions.md +6 -4
- package/docs/agents/standards-audit.md +38 -0
- package/docs/operating-model.md +1 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +17 -0
- package/src/commands/indexes.ts +2 -0
- package/src/commands/sessions.ts +27 -9
- package/src/commands/standards.ts +180 -2
- package/src/sessions/claim.ts +26 -7
- package/src/standards/audit.ts +132 -0
- package/src/sync/git.ts +17 -2
- package/src/worktree.ts +53 -0
- package/standards/design.md +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Orchestrator dispatch runbook
|
|
3
|
-
description: The collision check before a self-dispatch, the worker cap, the launch
|
|
3
|
+
description: The collision check before a self-dispatch, the worker cap, the branch and model the launch names, and the loop's stopping condition
|
|
4
4
|
---
|
|
5
5
|
|
|
6
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.
|
|
@@ -9,15 +9,27 @@ Run this at loop step 4, for a `## Run now` row whose plan is verified and whose
|
|
|
9
9
|
|
|
10
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
11
|
|
|
12
|
+
This is the branch the worker takes, not a guess at one it will derive for itself. Carry the exact string into the launch below. Both halves of that derivation have already disagreed in production: one run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, and a later one checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`. A check against a branch nobody uses verifies nothing, and the run dies anyway, since `claude-autoship` resolves its plan from the slug the worker actually took.
|
|
13
|
+
|
|
12
14
|
## Check the branch is unclaimed
|
|
13
15
|
|
|
14
16
|
Run `aitk sessions list --branch <type>/<slug> --json` and read `claimed` off the record.
|
|
15
17
|
|
|
16
|
-
- `claimed: true`: the row is not free. Report what holds it, `worktree` when it names a path
|
|
17
|
-
- `claimed: false` and `
|
|
18
|
-
- `claimed: false`
|
|
18
|
+
- `claimed: true`: the row is not free. Report what holds it, `worktree` when it names a path, `sessions` when it carries a row, and `refs` when the branch already exists. Move to the next candidate rather than colliding.
|
|
19
|
+
- `claimed: false`, `sessionsReadable: true`, and `refsReadable: true`: proceed to the cap check.
|
|
20
|
+
- `claimed: false` with either flag 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 which reading could not be taken 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.
|
|
21
|
+
|
|
22
|
+
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, the live session roster, and the refs that name the branch, so nothing here re-derives the OR.
|
|
23
|
+
|
|
24
|
+
`refs` is the reading that catches a shipped row. A branch behind a merged pull request has no worktree and no session, so the check answered clear on one until a worker refused the instruction and named the consequences: a second pull request against a head GitHub already shows merged, a row whose pull-request line points at two numbers, and the `ambiguous` refusal `aitk tasks archive` documents.
|
|
25
|
+
|
|
26
|
+
What the ref read cannot see is a branch pushed from another machine since the last fetch, because it reads the remote-tracking ref rather than the remote. Nobody has hit that, and a `git ls-remote` per dispatch costs 0.438s against 0.001s, so the gap is recorded rather than closed.
|
|
27
|
+
|
|
28
|
+
## Hold what this pass already launched
|
|
29
|
+
|
|
30
|
+
A worker registers with `branch: main` and the main worktree as its `cwd` until `claude-autoship` Step 0 moves it, which took several seconds on both measured runs. Neither the roster nor the refs name the candidate during that window, so a second check inside it reads clear.
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
Keep the branch of every row this pass has launched and treat a candidate matching one as claimed, without re-running the check. That closes the window for this dispatcher and only for it. A second dispatcher in another session reads git and the roster alone, sees none of this record, and can still take the same row. Say so when reporting, rather than implying the window is shut.
|
|
21
33
|
|
|
22
34
|
## Check the worker cap
|
|
23
35
|
|
|
@@ -25,15 +37,25 @@ Run `aitk sessions list --json` with no `--branch`, then count entries whose `na
|
|
|
25
37
|
|
|
26
38
|
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
39
|
|
|
40
|
+
## Pick the model
|
|
41
|
+
|
|
42
|
+
A `claude --bg` session inherits the model of whatever launched it rather than reading the machine's configured default. That was measured on 2026-08-27, with `~/.claude/settings.json` set to `sonnet` while both dispatched workers ran `claude-opus-5`. An orchestrator on the larger model therefore spends it on every worker it launches, and the operator who set the default never sees the override.
|
|
43
|
+
|
|
44
|
+
Name `<model>` on the launch, and pick it against the task rather than copying whatever this session happens to run. Sizing the model to the row is the dispatcher's call, the same call it already makes on the branch. A mechanical row moving files under a written plan is not the row that needs the largest model, and one whose plan carries an open judgment is.
|
|
45
|
+
|
|
28
46
|
## Dispatch
|
|
29
47
|
|
|
30
48
|
```bash
|
|
31
|
-
claude --bg -n "orchestrator-<slug>" "/aitk:claude-autoship .claude/tasks/<task-file>.md"
|
|
49
|
+
claude --bg --model <model> -n "orchestrator-<slug>" "Run /aitk:claude-worktree <type>/<slug>, then /aitk:claude-autoship .claude/tasks/<task-file>.md"
|
|
32
50
|
```
|
|
33
51
|
|
|
34
|
-
|
|
52
|
+
`--bg, --background` starts the session as a background agent and returns immediately, `-n, --name` sets the display name `aitk sessions list` reads back for the worker cap, and `--model` overrides the inheritance the section above measured.
|
|
53
|
+
|
|
54
|
+
The worktree call comes first and carries the branch as its argument, which is tier 0 of `claude-worktree` Step 2 and the only tier a caller can reach. `claude-autoship` Step 0 then finds the session already in a linked worktree and continues, which is a path it already documents. Autoship still resolves its plan, since the slug inside a dispatcher-named `<type>/<slug>` is the slug the dispatcher read off that plan.
|
|
55
|
+
|
|
56
|
+
Naming the branch in prose instead was tried and closes nothing, because no tier of that ladder reads the prompt. A worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. Four workers took the right branch that way, by inference rather than by contract, which is the same judgment both live disagreements came from.
|
|
35
57
|
|
|
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.
|
|
58
|
+
Report the dispatch as loudly as the human-launch line it replaces: name the branch, the model, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
|
|
37
59
|
|
|
38
60
|
## Fall back to the human
|
|
39
61
|
|
|
@@ -34,6 +34,7 @@ The directory test separates the defect from a genuinely bare repository, which
|
|
|
34
34
|
|
|
35
35
|
Try each source in order. Stop at the first match.
|
|
36
36
|
|
|
37
|
+
0. **Caller-supplied.** The invocation carried an argument. Take it as the name and infer nothing further. Accept `<name>` or `<type>/<name>`, where a leading segment matching a type in `${CLAUDE_SKILL_DIR}/../../standards/branch.md` sets `<type>` and the rest is `<name>`. A bare `<name>` falls to the default type below.
|
|
37
38
|
1. **Plan matched to current branch.** Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. An empty result falls through to the next source here rather than becoming `latest`, since the slug is one candidate among several. If `<main-root>/.claude/plans/feature-<slug>.md` exists, use `<slug>`.
|
|
38
39
|
2. **Single plan file.** List `<main-root>/.claude/plans/feature-*.md`. If exactly one match, derive `<slug>` from the filename.
|
|
39
40
|
3. **Multiple plan files, no branch match.** Ask the user which plan. Show the candidate slugs as a numbered list. Do not pick.
|
|
@@ -41,22 +42,28 @@ Try each source in order. Stop at the first match.
|
|
|
41
42
|
4b. **Session context.** When on `main` or `master` with no matching plan, read the current conversation to infer a kebab slug from the topic being discussed. Propose it: `Infer: <slug>. Confirm or rename?` Do not enter the worktree until the user confirms or provides a corrected name.
|
|
42
43
|
5. **Ask.** None of the above applies. Ask the user for a name. Do not invent one.
|
|
43
44
|
|
|
45
|
+
Tier 0 sits ahead of the inference because every tier below it answers from state the caller cannot set, and a caller that already knows the name has no way to say so. That gap is what a dispatched worker meets. It starts on `main`, so tier 1 cannot match, and a board carrying more than one plan puts tier 2 out of reach too, which lands the run on tier 3 and its instruction to ask somebody who is not there. A prompt naming the branch does not reach any of them, since no tier reads the prompt.
|
|
46
|
+
|
|
44
47
|
Validate the result: letters, digits, dots, underscores, dashes only, max 64 chars (`/` separators are also allowed). If the derived name violates the rule, sanitize by replacing invalid chars with `-` and truncating. Show the sanitized name in the preview before invoking.
|
|
45
48
|
|
|
46
|
-
Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it. A name from a plan takes the type that plan's own work carries, read off its `## Summary` and `**Files to touch:**` lines. Every other case takes `feat`, which covers a name from a branch, a name from the user, and a plan whose lines settle nothing.
|
|
49
|
+
Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it, drawing the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. A type the caller spelled in tier 0 wins outright and no reading overrides it. A name from a plan takes the type that plan's own work carries, read off its `## Summary` and `**Files to touch:**` lines. Every other case takes `feat`, which covers a name from a branch, a bare name from the user, and a plan whose lines settle nothing.
|
|
50
|
+
|
|
51
|
+
The caller's type wins because reading it off a plan is the half that has already disagreed in production. One dispatch checked `fix/path-form-hook` and the worker took `feat/path-form-hook`, both sides reading the same plan and grading it differently.
|
|
47
52
|
|
|
48
53
|
A wrong type is cheap. `git-branch` renames to conventional format later in the same chain and runs ahead of `git-pr`, so a `feat/` written over a fix is corrected before any pull request opens.
|
|
49
54
|
|
|
50
55
|
Then test both names the entry is about to claim. Neither read needs a worktree, and a stop after Step 4 leaves one built with the session sitting inside it, so both belong here rather than beside the rename:
|
|
51
56
|
|
|
52
|
-
- Branch. `git
|
|
57
|
+
- Branch. `git for-each-ref --format='%(refname)' refs/heads/<type>/<name> refs/remotes/origin/<type>/<name>` printing any ref means the name is taken. Stop: `❌ Branch <type>/<name> already exists. Resolve manually before continuing.` A non-zero exit is a read that failed rather than a free name, so stop on that too and say the read failed.
|
|
53
58
|
- Directory. `<main-root>/.claude/worktrees/<name>/` existing means an earlier entry claimed the name. Stop: `❌ Worktree .claude/worktrees/<name>/ already exists. Resolve manually before continuing.`
|
|
54
59
|
|
|
55
60
|
Leave both in place. Resolving either automatically risks the wrong one.
|
|
56
61
|
|
|
57
62
|
The two tests catch different collisions. The branch test misses the one `${CLAUDE_SKILL_DIR}/../../standards/slug.md` records, where two branches differing only in type collapse onto one name: `feat/foo` and `fix/foo` are distinct refs and reach one directory. The directory test is the only read that sees it.
|
|
58
63
|
|
|
59
|
-
The branch test fires on the tier 1 and tier 4 sources whenever the branch the session started on is already conventional, since a name derived from that branch resolves back onto it. Stopping is the answer there. The concern already has a branch, git refuses a second under the same name, and the bare-name rename this replaces only carried the collision forward to the `git-branch` step.
|
|
64
|
+
The branch test fires on the tier 1 and tier 4 sources whenever the branch the session started on is already conventional, since a name derived from that branch resolves back onto it. Stopping is the answer there. The concern already has a branch, git refuses a second under the same name, and the bare-name rename this replaces only carried the collision forward to the `git-branch` step. It fires on tier 0 as well, where a caller handed a name something already holds.
|
|
65
|
+
|
|
66
|
+
It reads both ref spaces rather than the local head alone, and it reads them the way `checkClaim` does, so a name this skill clears and a branch a dispatcher cleared are one answer. `git show-ref --verify` is what that replaces. It sees no remote-tracking ref, so a branch pushed from elsewhere passed the test and collided at the first push, and its exit code cannot separate an absent ref from a tree it could not read, which reports a failed read as a free name.
|
|
60
67
|
|
|
61
68
|
## Step 3: preview
|
|
62
69
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -46,6 +46,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
46
46
|
| `aitk context audit` | Report required sections, length, cited paths, reference form, catalog tables, provenance, superseded-decision narration, and index drift |
|
|
47
47
|
| `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
|
48
48
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
49
|
+
| `aitk standards audit` | Report the corpus against the `## Success criterion` gate, failing only on a standard new to the branch (`--json`, `--arrivals-only`) |
|
|
49
50
|
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
|
|
50
51
|
| `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
|
|
51
52
|
| `aitk claude skills rank` | Score the shipped catalog's descriptions against a hand-authored case corpus by TF-IDF cosine similarity, reporting rank-one and top-three |
|
|
@@ -83,7 +84,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
83
84
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
85
|
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
85
86
|
| `snippets` | `list`, `create` |
|
|
86
|
-
| `standards` | `list`, `<name>`
|
|
87
|
+
| `standards` | `list`, `audit`, `<name>` |
|
|
87
88
|
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
|
|
88
89
|
| `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `skills rank`, `setup [dest]` |
|
|
89
90
|
| `demo` | `compile`, `run` |
|
package/docs/agents/index.md
CHANGED
|
@@ -33,6 +33,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
33
33
|
- [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
|
|
34
34
|
- [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
|
|
35
35
|
- [Citation reach](skills-reach.md): Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
|
|
36
|
+
- [Standard success criteria](standards-audit.md): Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
|
|
36
37
|
- [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
|
|
37
38
|
- [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
|
|
38
39
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
package/docs/agents/sessions.md
CHANGED
|
@@ -36,13 +36,15 @@ The match can return more than one session. Read the count rather than the first
|
|
|
36
36
|
|
|
37
37
|
## Whether a branch is already claimed
|
|
38
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
|
|
39
|
+
With `--branch`, the JSON record also carries `worktree` (the path of any worktree already checked out to it, or `null`), `refs` (the refs that already name the branch, the local head and the `origin` remote-tracking ref alike), and `claimed` (`true` when any of the three holds it).
|
|
40
40
|
|
|
41
|
-
Read `claimed` rather than composing the
|
|
41
|
+
Read `claimed` rather than composing the three fields by hand. A worktree can outlive the session that made it, and a session can hold a branch before any worktree exists for it. A branch sitting behind a merged pull request has neither and is taken all the same, which is the reading that was missing when a dispatcher cleared a merged branch and told a worker to build on it.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
The ref read covers the local head and the remote-tracking ref, which means it sees the remote at whatever the last fetch left behind. A branch pushed from another machine since then reads absent. Closing that gap needs `git ls-remote`, and it is left open deliberately: the remote read costs 0.438s against 0.001s for the local one, on a check that runs before every dispatch.
|
|
44
44
|
|
|
45
|
-
`
|
|
45
|
+
`worktree`, `refs`, and `claimed` are `null` on a bare run with no `--branch`, since none of the three questions has a branch to answer about. A refusal (`no-registry` or `no-repository`) carries none of the keys at all, which a caller should read as unverified rather than as clear.
|
|
46
|
+
|
|
47
|
+
Two flags say which reading came up short. `sessionsReadable` is `false` when the session roster could not be read, and `refsReadable` is `false` when the ref read failed. Either one leaves `claimed` covering the readings around it alone, so a `false` there is a report that ran short of evidence rather than a report that the branch is clear. They stay separate fields because a caller told the roster failed goes and looks at the roster, and folding both into one flag would send it to the wrong place.
|
|
46
48
|
|
|
47
49
|
## Why the verb exists
|
|
48
50
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Standard success criteria
|
|
3
|
+
description: Reading the corpus against the Success criterion gate, why the check scopes to arrival rather than the whole corpus, and the exit codes it sets
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Standard success criteria
|
|
7
|
+
|
|
8
|
+
`aitk standards audit` reads the corpus at `standards/` and reports which files carry a `## Success criterion` section against which do not, per `standards/standard.md`. It fails only on a standard new to the current branch, never on one already short the section.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk standards audit
|
|
12
|
+
aitk standards audit --json
|
|
13
|
+
aitk standards audit --arrivals-only
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| ----------------- | ------------------------------------------------------------ |
|
|
18
|
+
| `[path]` | Project root, defaulting to the current directory |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
20
|
+
| `--arrivals-only` | Run the gating check alone, printing nothing on a clean pass |
|
|
21
|
+
|
|
22
|
+
## Why arrival rather than the corpus
|
|
23
|
+
|
|
24
|
+
`standards/standard.md` states that a criterion is added to an existing standard when that standard is next exercised, not in a sweep: a criterion written with no failure to point at is the taste-based edit the rule exists to prevent. Gating the whole corpus would fail every push until every standard already short the section was closed at once, which is the sweep that rule forbids. The check reads the whole corpus and fails only on a file present in the working tree and absent at the branch's merge base, treating a rename into the corpus the same as a standard authored fresh.
|
|
25
|
+
|
|
26
|
+
## Exit codes and refusals
|
|
27
|
+
|
|
28
|
+
| Code | Meaning |
|
|
29
|
+
| ---- | --------------------------------------------------------------- |
|
|
30
|
+
| `0` | every arriving standard carries the section |
|
|
31
|
+
| `1` | refused, with `reason` naming the cause |
|
|
32
|
+
| `2` | a standard new to this branch carries no `## Success criterion` |
|
|
33
|
+
|
|
34
|
+
A project authoring no standards refuses with `no-corpus`, the ordinary state of most targets, the same absence `aitk claude skills audit` reads as its own `no-corpus`.
|
|
35
|
+
|
|
36
|
+
## What it does not measure
|
|
37
|
+
|
|
38
|
+
Presence of the heading is the whole check. The section's content, the questions it must answer or the task it must let a reader complete, is a judgment `aitk standards audit` cannot read, so a standard carrying an empty or token section still passes.
|
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. 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.
|
|
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, naming the branch and the model on the launch rather than leaving the worker to derive either. The branch travels as the argument to the worker's own worktree call, which is the one place the name is read rather than inferred. 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
package/scripts/core/verify.sh
CHANGED
|
@@ -460,6 +460,23 @@ main() {
|
|
|
460
460
|
run_check "cd $PROJECT_ROOT && bun src/cli.ts claude skills audit --requirements-only" "A skill folder carries no REQUIREMENT.md. Run bun src/cli.ts claude skills audit."
|
|
461
461
|
log_info "Skill requirements present"
|
|
462
462
|
|
|
463
|
+
# Scoped to arrival rather than the corpus, since standards/standard.md
|
|
464
|
+
# forbids writing a criterion into an existing standard outside the change
|
|
465
|
+
# that exercises it. Gating the 26 known gaps would fail every push until
|
|
466
|
+
# someone closed them all, which is the sweep that rule exists to prevent.
|
|
467
|
+
log_step "Standard success criteria"
|
|
468
|
+
local standards_output standards_status=0
|
|
469
|
+
standards_output=$(cd "$PROJECT_ROOT" && bun src/cli.ts standards audit --arrivals-only 2>&1) || standards_status=$?
|
|
470
|
+
if [ "$standards_status" -eq 0 ]; then
|
|
471
|
+
log_info "Arriving standards carry a success criterion"
|
|
472
|
+
elif [ "$standards_status" -eq 2 ]; then
|
|
473
|
+
echo "$standards_output" | pipe_output
|
|
474
|
+
log_error "A standard new to this branch carries no ## Success criterion section. Run bun src/cli.ts standards audit."
|
|
475
|
+
else
|
|
476
|
+
echo "$standards_output" | pipe_output
|
|
477
|
+
log_error "aitk standards audit could not read which standards arrived on this branch. Run bun src/cli.ts standards audit --json to see why."
|
|
478
|
+
fi
|
|
479
|
+
|
|
463
480
|
# `aitk sandbox coverage` moves only when a person runs it, so a scenario added
|
|
464
481
|
# with no expectation ships unnoticed. The gate is an absolute count of
|
|
465
482
|
# undeclared scenarios rather than a ratio or a floor under the declared count.
|
package/src/commands/indexes.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
5
|
import { exitCodeFor, type RegenResult, regenOne } from '@/indexes/regen'
|
|
6
|
+
import { gitEnv } from '@/git-env'
|
|
6
7
|
import { findIndexedAncestor, isIgnored, listIndexes } from '@/indexes/walk'
|
|
7
8
|
import { intro, logAdd, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
8
9
|
|
|
@@ -164,6 +165,7 @@ async function stage(path: string, rel: string, root: string): Promise<void> {
|
|
|
164
165
|
if (await isIgnored(root, path)) return
|
|
165
166
|
|
|
166
167
|
const staged = await $`git -C ${root} add -- ${path}`
|
|
168
|
+
.env(gitEnv())
|
|
167
169
|
.quiet()
|
|
168
170
|
.nothrow()
|
|
169
171
|
.then((result) => result.exitCode === 0)
|
package/src/commands/sessions.ts
CHANGED
|
@@ -60,17 +60,23 @@ export function register(program: Command): void {
|
|
|
60
60
|
'so a caller filtering by hand has something that identifies one.',
|
|
61
61
|
'',
|
|
62
62
|
'With --branch, the JSON also carries "worktree" (the path of any',
|
|
63
|
-
'worktree already checked out to it, or null)
|
|
64
|
-
'
|
|
65
|
-
'
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
63
|
+
'worktree already checked out to it, or null), "refs" (the refs that',
|
|
64
|
+
'already name it, local head and origin remote-tracking alike), and',
|
|
65
|
+
'"claimed" (true when a worktree, a live session, or an existing ref',
|
|
66
|
+
'holds it). A dispatcher reads "claimed" rather than composing the',
|
|
67
|
+
'three fields itself, since each alone misses a real claim: a worktree',
|
|
68
|
+
'can outlive the session that made it, a session can hold a branch',
|
|
69
|
+
'before a worktree exists for it, and a branch behind a merged pull',
|
|
70
|
+
'request has neither while still being taken.',
|
|
69
71
|
'',
|
|
70
|
-
'"sessionsReadable" is false when the session roster could not be read
|
|
71
|
-
'
|
|
72
|
+
'"sessionsReadable" is false when the session roster could not be read',
|
|
73
|
+
'and "refsReadable" is false when the ref read failed. Either one',
|
|
74
|
+
'leaves "claimed" covering the readings around it alone. Treat that',
|
|
72
75
|
'case as unverified rather than as a clean "false".',
|
|
73
76
|
'',
|
|
77
|
+
'The ref read sees the remote at whatever the last fetch left, so a',
|
|
78
|
+
'branch pushed from another machine since then reads absent here.',
|
|
79
|
+
'',
|
|
74
80
|
'The match can return more than one session. Read the count rather than',
|
|
75
81
|
'the first row, since two sessions can hold one branch.',
|
|
76
82
|
'',
|
|
@@ -166,8 +172,10 @@ async function runList(opts: ListCommandOptions): Promise<number> {
|
|
|
166
172
|
branch: opts.branch ?? null,
|
|
167
173
|
repository,
|
|
168
174
|
worktree: claim?.worktree ?? null,
|
|
175
|
+
refs: claim?.refs ?? null,
|
|
169
176
|
claimed: claim?.claimed ?? null,
|
|
170
177
|
sessionsReadable: claim?.sessionsReadable ?? null,
|
|
178
|
+
refsReadable: claim?.refsReadable ?? null,
|
|
171
179
|
sessions: shown,
|
|
172
180
|
})}\n`,
|
|
173
181
|
)
|
|
@@ -261,11 +269,21 @@ function reportClaim(claim: ClaimReport): void {
|
|
|
261
269
|
logInfo(`Worktree: ${claim.worktree}`)
|
|
262
270
|
}
|
|
263
271
|
|
|
272
|
+
if (claim.refs.length > 0) {
|
|
273
|
+
logInfo(`Refs: ${claim.refs.join(', ')}`)
|
|
274
|
+
}
|
|
275
|
+
|
|
264
276
|
logInfo(claim.claimed ? 'Claimed.' : 'Unclaimed.')
|
|
265
277
|
|
|
266
278
|
if (!claim.sessionsReadable) {
|
|
267
279
|
logWarn(
|
|
268
|
-
'The session roster could not be read, so this
|
|
280
|
+
'The session roster could not be read, so this leaves the session half out. Treat it as unverified rather than clear.',
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!claim.refsReadable) {
|
|
285
|
+
logWarn(
|
|
286
|
+
'The ref read failed, so this cannot say whether the branch already exists. Treat it as unverified rather than clear.',
|
|
269
287
|
)
|
|
270
288
|
}
|
|
271
289
|
}
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { Command } from 'commander'
|
|
2
3
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
4
|
+
import {
|
|
5
|
+
auditExitCode,
|
|
6
|
+
auditStandards,
|
|
7
|
+
type StandardsAudit,
|
|
8
|
+
} from '@/standards/audit'
|
|
3
9
|
import { listStandards, readStandard, resolveStandard } from '@/standards/read'
|
|
4
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
frameError,
|
|
12
|
+
intro,
|
|
13
|
+
logError,
|
|
14
|
+
logInfo,
|
|
15
|
+
logStep,
|
|
16
|
+
logWarn,
|
|
17
|
+
outro,
|
|
18
|
+
pipeOutput,
|
|
19
|
+
plural,
|
|
20
|
+
} from '@/ui'
|
|
21
|
+
|
|
22
|
+
interface StandardsAuditOptions {
|
|
23
|
+
readonly json?: boolean
|
|
24
|
+
readonly arrivalsOnly?: boolean
|
|
25
|
+
}
|
|
5
26
|
|
|
6
27
|
export function register(program: Command): void {
|
|
7
28
|
const standards = program
|
|
8
29
|
.command('standards')
|
|
9
|
-
.description('Standards commands (list, <name>)')
|
|
30
|
+
.description('Standards commands (list, audit, <name>)')
|
|
10
31
|
.argument('[name]', 'Standard to print, by name with or without .md')
|
|
11
32
|
.helpOption('-h, --help', 'Show this help message')
|
|
12
33
|
.addHelpText(
|
|
@@ -37,6 +58,41 @@ export function register(program: Command): void {
|
|
|
37
58
|
})
|
|
38
59
|
|
|
39
60
|
registerPassThroughVerbs(standards, 'standards', ['list'])
|
|
61
|
+
|
|
62
|
+
standards
|
|
63
|
+
.command('audit')
|
|
64
|
+
.description(
|
|
65
|
+
'Report the corpus against the `## Success criterion` gate in standards/standard.md',
|
|
66
|
+
)
|
|
67
|
+
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
68
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
69
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
70
|
+
.option(
|
|
71
|
+
'--arrivals-only',
|
|
72
|
+
'Run the gating check for standards new on this branch alone',
|
|
73
|
+
)
|
|
74
|
+
.addHelpText(
|
|
75
|
+
'after',
|
|
76
|
+
[
|
|
77
|
+
'',
|
|
78
|
+
'Exit codes:',
|
|
79
|
+
' 0 the audit completed with every arriving standard carrying the section',
|
|
80
|
+
' 1 refused, with the reason on stderr',
|
|
81
|
+
' 2 a standard new to this branch carries no ## Success criterion section',
|
|
82
|
+
'',
|
|
83
|
+
'A standard already in the corpus without the section is a known gap',
|
|
84
|
+
'standards/standard.md names, not a violation, so only an arrival fails.',
|
|
85
|
+
'',
|
|
86
|
+
'Examples:',
|
|
87
|
+
' aitk standards audit',
|
|
88
|
+
' aitk standards audit --json',
|
|
89
|
+
' aitk standards audit --arrivals-only',
|
|
90
|
+
'',
|
|
91
|
+
].join('\n'),
|
|
92
|
+
)
|
|
93
|
+
.action(async (path: string | undefined, opts: StandardsAuditOptions) => {
|
|
94
|
+
process.exitCode = await runStandardsAudit(path, opts)
|
|
95
|
+
})
|
|
40
96
|
}
|
|
41
97
|
|
|
42
98
|
/**
|
|
@@ -67,3 +123,125 @@ function print(name: string): number {
|
|
|
67
123
|
outro()
|
|
68
124
|
return 0
|
|
69
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Measures the corpus at the cwd rather than the toolkit root the catalog
|
|
129
|
+
* reads, so a linked worktree audits its own branch instead of `main`.
|
|
130
|
+
*/
|
|
131
|
+
async function runStandardsAudit(
|
|
132
|
+
path: string | undefined,
|
|
133
|
+
opts: StandardsAuditOptions,
|
|
134
|
+
): Promise<number> {
|
|
135
|
+
const root = resolve(path ?? process.cwd())
|
|
136
|
+
const gateOnly = opts.arrivalsOnly ?? false
|
|
137
|
+
const audit = await auditStandards(root)
|
|
138
|
+
|
|
139
|
+
if (audit.kind === 'refused') {
|
|
140
|
+
const message =
|
|
141
|
+
audit.reason === 'no-corpus'
|
|
142
|
+
? `No standards/ under ${root}.`
|
|
143
|
+
: audit.reason === 'no-base'
|
|
144
|
+
? 'No merge base against main resolved.'
|
|
145
|
+
: 'Could not read which standards arrived on this branch.'
|
|
146
|
+
|
|
147
|
+
if (gateOnly) {
|
|
148
|
+
frameError(message)
|
|
149
|
+
} else {
|
|
150
|
+
intro('aitk standards audit')
|
|
151
|
+
logStep('Refused')
|
|
152
|
+
logWarn(message)
|
|
153
|
+
outro()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
process.stdout.write(
|
|
158
|
+
`${JSON.stringify({ root, reason: audit.reason, message })}\n`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return auditExitCode(audit)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (gateOnly) {
|
|
166
|
+
reportArrivalGate(audit)
|
|
167
|
+
} else {
|
|
168
|
+
intro('aitk standards audit')
|
|
169
|
+
reportCorpus(audit)
|
|
170
|
+
outro()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
process.stdout.write(
|
|
175
|
+
`${JSON.stringify({
|
|
176
|
+
root,
|
|
177
|
+
base: audit.base,
|
|
178
|
+
standards: audit.standards,
|
|
179
|
+
withCriterion: audit.withCriterion,
|
|
180
|
+
withoutCriterion: audit.withoutCriterion,
|
|
181
|
+
arrivals: audit.arrivals,
|
|
182
|
+
arrivalsWithoutCriterion: audit.arrivalsWithoutCriterion,
|
|
183
|
+
})}\n`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return auditExitCode(audit)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Prints nothing when every arriving standard carries the section.
|
|
192
|
+
*
|
|
193
|
+
* `--arrivals-only` is what `verify.sh` runs on every push, and that script
|
|
194
|
+
* pipes a stage's whole output into its own frame. A passing gate that
|
|
195
|
+
* printed its frame would nest one inside the other on every contributor's
|
|
196
|
+
* push.
|
|
197
|
+
*/
|
|
198
|
+
function reportArrivalGate(
|
|
199
|
+
audit: Extract<StandardsAudit, { kind: 'measured' }>,
|
|
200
|
+
): void {
|
|
201
|
+
const missing = audit.arrivalsWithoutCriterion
|
|
202
|
+
if (missing.length === 0) return
|
|
203
|
+
|
|
204
|
+
intro('aitk standards audit')
|
|
205
|
+
logError(
|
|
206
|
+
missing.length === 1
|
|
207
|
+
? '1 standard new to this branch carries no ## Success criterion section'
|
|
208
|
+
: `${missing.length} standards new to this branch carry no ## Success criterion section`,
|
|
209
|
+
)
|
|
210
|
+
pipeOutput(missing.join('\n'))
|
|
211
|
+
outro()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function reportCorpus(
|
|
215
|
+
audit: Extract<StandardsAudit, { kind: 'measured' }>,
|
|
216
|
+
): void {
|
|
217
|
+
logStep('Corpus')
|
|
218
|
+
logInfo(`${plural(audit.standards.length, 'standard')} at standards/`)
|
|
219
|
+
logInfo(
|
|
220
|
+
`${plural(audit.withCriterion.length, 'standard')} carrying ## Success criterion`,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
logStep('Known gaps')
|
|
224
|
+
if (audit.withoutCriterion.length === 0) {
|
|
225
|
+
logInfo('None. Every standard carries the section.')
|
|
226
|
+
} else {
|
|
227
|
+
pipeOutput(audit.withoutCriterion.join('\n'))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
logStep('Arrivals since main')
|
|
231
|
+
if (audit.arrivals.length === 0) {
|
|
232
|
+
logInfo('No standard new to this branch.')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (audit.arrivalsWithoutCriterion.length === 0) {
|
|
237
|
+
logInfo(
|
|
238
|
+
`${plural(audit.arrivals.length, 'standard')} arrived, every one carrying the section.`,
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
logError(
|
|
244
|
+
`${plural(audit.arrivalsWithoutCriterion.length, 'standard')} arrived carrying no ## Success criterion section`,
|
|
245
|
+
)
|
|
246
|
+
pipeOutput(audit.arrivalsWithoutCriterion.join('\n'))
|
|
247
|
+
}
|
package/src/sessions/claim.ts
CHANGED
|
@@ -4,26 +4,41 @@ import {
|
|
|
4
4
|
type ResolvedSession,
|
|
5
5
|
type SessionReport,
|
|
6
6
|
} from '@/sessions/resolve'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
branchRefs,
|
|
9
|
+
listWorktrees,
|
|
10
|
+
type RefReport,
|
|
11
|
+
type WorktreeEntry,
|
|
12
|
+
} from '@/worktree'
|
|
8
13
|
|
|
9
14
|
export interface ClaimReport {
|
|
10
15
|
readonly claimed: boolean
|
|
11
16
|
readonly worktree: string | null
|
|
12
17
|
readonly sessions: readonly ResolvedSession[]
|
|
13
|
-
/**
|
|
18
|
+
/** The refs already naming the branch, local head and `origin` remote-tracking alike. */
|
|
19
|
+
readonly refs: readonly string[]
|
|
20
|
+
/** False when the session roster could not be read, so `claimed` covers the readings around it alone and cannot be trusted as a clean "unclaimed". */
|
|
14
21
|
readonly sessionsReadable: boolean
|
|
22
|
+
/** False when the ref read failed, which is the same false clean under a different reading. */
|
|
23
|
+
readonly refsReadable: boolean
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export interface ClaimOptions {
|
|
18
27
|
readonly cwd?: string
|
|
19
28
|
readonly resolve?: () => Promise<SessionReport>
|
|
20
29
|
readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
|
|
30
|
+
readonly branchRefs?: (branch: string, cwd: string) => Promise<RefReport>
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/**
|
|
24
|
-
* Answers whether a branch is already claimed, composing the
|
|
25
|
-
*
|
|
26
|
-
*
|
|
34
|
+
* Answers whether a branch is already claimed, composing the three readings no
|
|
35
|
+
* one surface can answer alone: a worktree can outlive the session that made
|
|
36
|
+
* it, a session can hold a branch before a worktree exists for it, and a branch
|
|
37
|
+
* behind a merged pull request has neither while still being taken.
|
|
38
|
+
*
|
|
39
|
+
* The two readable flags stay separate because a caller told the roster failed
|
|
40
|
+
* looks at the roster. Folding either into `claimed` would report the failure
|
|
41
|
+
* as a clean "unclaimed", which is the answer this exists to stop giving.
|
|
27
42
|
*/
|
|
28
43
|
export async function checkClaim(
|
|
29
44
|
branch: string,
|
|
@@ -32,11 +47,13 @@ export async function checkClaim(
|
|
|
32
47
|
const cwd = opts.cwd ?? process.cwd()
|
|
33
48
|
const resolve = opts.resolve ?? resolveSessions
|
|
34
49
|
const listAll = opts.listWorktrees ?? listWorktrees
|
|
50
|
+
const readRefs = opts.branchRefs ?? branchRefs
|
|
35
51
|
|
|
36
|
-
const [repository, worktrees, report] = await Promise.all([
|
|
52
|
+
const [repository, worktrees, report, refs] = await Promise.all([
|
|
37
53
|
repositoryOf(cwd),
|
|
38
54
|
listAll(cwd),
|
|
39
55
|
resolve(),
|
|
56
|
+
readRefs(branch, cwd),
|
|
40
57
|
])
|
|
41
58
|
|
|
42
59
|
const worktree =
|
|
@@ -51,9 +68,11 @@ export async function checkClaim(
|
|
|
51
68
|
: []
|
|
52
69
|
|
|
53
70
|
return {
|
|
54
|
-
claimed: worktree !== null || sessions.length > 0,
|
|
71
|
+
claimed: worktree !== null || sessions.length > 0 || refs.refs.length > 0,
|
|
55
72
|
worktree,
|
|
56
73
|
sessions,
|
|
74
|
+
refs: refs.refs,
|
|
57
75
|
sessionsReadable: report.kind === 'resolved',
|
|
76
|
+
refsReadable: refs.readable,
|
|
58
77
|
}
|
|
59
78
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { gitEnv } from '@/git-env'
|
|
5
|
+
import { resolveBaseRef } from '@/git-files'
|
|
6
|
+
import { INDEX_FILE, standardsSourceDir } from '@/standards/read'
|
|
7
|
+
|
|
8
|
+
/** Returned when a standard new to this branch carries no `## Success criterion` section, the gating check. */
|
|
9
|
+
export const EXIT_MISSING_CRITERION = 2
|
|
10
|
+
|
|
11
|
+
/** Matched at any casing, level-2 only, per the heading `standards/standard.md` itself uses. */
|
|
12
|
+
const CRITERION_HEADING = /^##\s+success criterion\s*$/im
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The reasons an audit produces no reading. `no-corpus` is the ordinary state
|
|
16
|
+
* of a target that authors no standards of its own, the same absence the
|
|
17
|
+
* skills audit reads as its own `no-corpus`. The other two are a broken git
|
|
18
|
+
* invocation rather than a project stating nothing.
|
|
19
|
+
*/
|
|
20
|
+
export type StandardsAuditRefusal =
|
|
21
|
+
| 'no-corpus'
|
|
22
|
+
| 'no-base'
|
|
23
|
+
| 'unreadable-arrivals'
|
|
24
|
+
|
|
25
|
+
export type StandardsAudit =
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: 'measured'
|
|
28
|
+
readonly base: string
|
|
29
|
+
readonly standards: readonly string[]
|
|
30
|
+
readonly withCriterion: readonly string[]
|
|
31
|
+
readonly withoutCriterion: readonly string[]
|
|
32
|
+
readonly arrivals: readonly string[]
|
|
33
|
+
readonly arrivalsWithoutCriterion: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
| { readonly kind: 'refused'; readonly reason: StandardsAuditRefusal }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Measures the corpus authored at `standards/` under `root` against the
|
|
39
|
+
* `## Success criterion` gate `standards/standard.md` states, and names which
|
|
40
|
+
* of those files are new since the branch's merge base.
|
|
41
|
+
*
|
|
42
|
+
* Reads the working-root corpus alone, never the packaged fallback
|
|
43
|
+
* `src/standards/read.ts` falls through to for a name lookup, since a target
|
|
44
|
+
* with no authored standards of its own has nothing here to gate.
|
|
45
|
+
*/
|
|
46
|
+
export async function auditStandards(root: string): Promise<StandardsAudit> {
|
|
47
|
+
const dir = standardsSourceDir(root)
|
|
48
|
+
if (!existsSync(dir)) return { kind: 'refused', reason: 'no-corpus' }
|
|
49
|
+
|
|
50
|
+
const standards = readdirSync(dir, { withFileTypes: true })
|
|
51
|
+
.filter(
|
|
52
|
+
(entry) =>
|
|
53
|
+
entry.isFile() &&
|
|
54
|
+
entry.name.endsWith('.md') &&
|
|
55
|
+
entry.name !== INDEX_FILE,
|
|
56
|
+
)
|
|
57
|
+
.map((entry) => entry.name)
|
|
58
|
+
.sort()
|
|
59
|
+
|
|
60
|
+
const withCriterion: string[] = []
|
|
61
|
+
const withoutCriterion: string[] = []
|
|
62
|
+
for (const name of standards) {
|
|
63
|
+
const body = readFileSync(join(dir, name), 'utf8')
|
|
64
|
+
;(CRITERION_HEADING.test(body) ? withCriterion : withoutCriterion).push(
|
|
65
|
+
name,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const base = await resolveBaseRef(root)
|
|
70
|
+
if (base === undefined) return { kind: 'refused', reason: 'no-base' }
|
|
71
|
+
|
|
72
|
+
const arrived = await arrivedStandards(root, base)
|
|
73
|
+
if (arrived === undefined) {
|
|
74
|
+
return { kind: 'refused', reason: 'unreadable-arrivals' }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const arrivals = standards.filter((name) => arrived.has(name))
|
|
78
|
+
const arrivalsWithoutCriterion = arrivals.filter((name) =>
|
|
79
|
+
withoutCriterion.includes(name),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
kind: 'measured',
|
|
84
|
+
base,
|
|
85
|
+
standards,
|
|
86
|
+
withCriterion,
|
|
87
|
+
withoutCriterion,
|
|
88
|
+
arrivals,
|
|
89
|
+
arrivalsWithoutCriterion,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Only an arrival missing the section sets a failing code. Every other
|
|
95
|
+
* standard without one is a known gap `standards/standard.md` names rather
|
|
96
|
+
* than a violation, so failing the push on the 26 already there teaches
|
|
97
|
+
* contributors to route around the stage.
|
|
98
|
+
*/
|
|
99
|
+
export function auditExitCode(audit: StandardsAudit): number {
|
|
100
|
+
if (audit.kind === 'refused') return 1
|
|
101
|
+
return audit.arrivalsWithoutCriterion.length > 0 ? EXIT_MISSING_CRITERION : 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Filenames under `standards/` present in the working tree and absent at
|
|
106
|
+
* `base`: a plain add, with rename detection forced off so a standard moved
|
|
107
|
+
* into the corpus from elsewhere counts the same as one authored fresh.
|
|
108
|
+
*/
|
|
109
|
+
async function arrivedStandards(
|
|
110
|
+
root: string,
|
|
111
|
+
base: string,
|
|
112
|
+
): Promise<Set<string> | undefined> {
|
|
113
|
+
const [added, untracked] = await Promise.all([
|
|
114
|
+
$`git -C ${root} diff --no-renames --name-only --diff-filter=A ${base} -- standards`
|
|
115
|
+
.env(gitEnv())
|
|
116
|
+
.quiet()
|
|
117
|
+
.nothrow(),
|
|
118
|
+
$`git -C ${root} ls-files --others --exclude-standard -- standards`
|
|
119
|
+
.env(gitEnv())
|
|
120
|
+
.quiet()
|
|
121
|
+
.nothrow(),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
if (added.exitCode !== 0 || untracked.exitCode !== 0) return undefined
|
|
125
|
+
|
|
126
|
+
const paths = [
|
|
127
|
+
...added.text().split('\n'),
|
|
128
|
+
...untracked.text().split('\n'),
|
|
129
|
+
].filter(Boolean)
|
|
130
|
+
|
|
131
|
+
return new Set(paths.map((path) => basename(path)))
|
|
132
|
+
}
|
package/src/sync/git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
+
import { gitEnv } from '@/git-env'
|
|
3
4
|
import type { SyncDomain } from '@/sync/target'
|
|
4
5
|
import { pipeOutput } from '@/ui'
|
|
5
6
|
|
|
@@ -186,13 +187,27 @@ export async function hasGh(): Promise<boolean> {
|
|
|
186
187
|
return result.exitCode === 0
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Both seams strip git's repository-resolution variables, since a hook exports
|
|
192
|
+
* `GIT_DIR` into every process it runs and it beats `-C`. A sync driven from
|
|
193
|
+
* inside one would otherwise read the hook's repository and, through `mutate`,
|
|
194
|
+
* commit and push into it.
|
|
195
|
+
*/
|
|
189
196
|
async function read(target: string, args: readonly string[]): Promise<string> {
|
|
190
|
-
const result = await execa('git', ['-C', target, ...args], {
|
|
197
|
+
const result = await execa('git', ['-C', target, ...args], {
|
|
198
|
+
reject: false,
|
|
199
|
+
env: gitEnv(),
|
|
200
|
+
extendEnv: false,
|
|
201
|
+
})
|
|
191
202
|
return result.exitCode === 0 ? result.stdout : ''
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
async function mutate(target: string, args: readonly string[]): Promise<void> {
|
|
195
|
-
const result = await execa('git', ['-C', target, ...args], {
|
|
206
|
+
const result = await execa('git', ['-C', target, ...args], {
|
|
207
|
+
reject: false,
|
|
208
|
+
env: gitEnv(),
|
|
209
|
+
extendEnv: false,
|
|
210
|
+
})
|
|
196
211
|
|
|
197
212
|
if (result.exitCode !== 0) {
|
|
198
213
|
throw new GitCommandError(
|
package/src/worktree.ts
CHANGED
|
@@ -50,6 +50,59 @@ export interface WorktreeEntry {
|
|
|
50
50
|
readonly branch: string | null
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export interface RefReport {
|
|
54
|
+
/** False when the ref read itself failed, so an empty `refs` says nothing about whether the branch exists. */
|
|
55
|
+
readonly readable: boolean
|
|
56
|
+
readonly refs: readonly string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reports which of the local head and the `origin` remote-tracking ref already
|
|
61
|
+
* name a branch, separating an absent branch from a read that failed.
|
|
62
|
+
*
|
|
63
|
+
* `git for-each-ref` is what carries that separation. `git show-ref --verify`
|
|
64
|
+
* handed the same two ref paths exits 128 when one of them is missing, which is
|
|
65
|
+
* the code it also exits when the directory is no repository, so a caller
|
|
66
|
+
* cannot tell a half match from an unreadable tree. `for-each-ref` exits zero
|
|
67
|
+
* with empty output for absent and non-zero only for a failed read.
|
|
68
|
+
*
|
|
69
|
+
* A ref path is a pattern here rather than an exact name, so `refs/heads/x`
|
|
70
|
+
* also matches `refs/heads/x/y`. Git forbids both existing at once, so the
|
|
71
|
+
* over-match names the branch that blocks the candidate rather than a wrong
|
|
72
|
+
* one.
|
|
73
|
+
*
|
|
74
|
+
* The remote-tracking ref is read at whatever the last fetch left, so a branch
|
|
75
|
+
* pushed from another machine since then is invisible here. Closing that needs
|
|
76
|
+
* `git ls-remote`, measured at 0.438s against 0.001s for a local read.
|
|
77
|
+
*/
|
|
78
|
+
export async function branchRefs(
|
|
79
|
+
branch: string,
|
|
80
|
+
cwd: string = process.cwd(),
|
|
81
|
+
): Promise<RefReport> {
|
|
82
|
+
// Every argument is interpolated rather than written inline, since Bun's
|
|
83
|
+
// shell parses the bare parentheses in `%(refname)` as syntax of its own.
|
|
84
|
+
const args = [
|
|
85
|
+
'--format=%(refname)',
|
|
86
|
+
`refs/heads/${branch}`,
|
|
87
|
+
`refs/remotes/origin/${branch}`,
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const result = await $`git -C ${cwd} for-each-ref ${args}`
|
|
91
|
+
.env(gitEnv())
|
|
92
|
+
.quiet()
|
|
93
|
+
.nothrow()
|
|
94
|
+
if (result.exitCode !== 0) return { readable: false, refs: [] }
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
readable: true,
|
|
98
|
+
refs: result.stdout
|
|
99
|
+
.toString()
|
|
100
|
+
.split('\n')
|
|
101
|
+
.map((line) => line.trim())
|
|
102
|
+
.filter((line) => line.length > 0),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
53
106
|
/**
|
|
54
107
|
* Parses `git worktree list --porcelain`, which emits one block per worktree
|
|
55
108
|
* separated by a blank line. A detached worktree carries no `branch` line,
|
package/standards/design.md
CHANGED
|
@@ -18,14 +18,14 @@ Does not govern:
|
|
|
18
18
|
|
|
19
19
|
## What goes in
|
|
20
20
|
|
|
21
|
-
-
|
|
21
|
+
- A token's exact value, anchored to the surface it was read from and tagged per `## The uncertainty tag` when unconfirmed. Fall back to intent language ("mid gray, muted text") only where no source exists yet to anchor from.
|
|
22
22
|
- Layout constraints and sizing rules not obvious from wireframes
|
|
23
23
|
- Visual rules a developer could get wrong without guidance
|
|
24
24
|
- Non-obvious omissions ("no motion", "no custom icons") that prevent scope creep
|
|
25
25
|
|
|
26
26
|
## What does not go in
|
|
27
27
|
|
|
28
|
-
- CSS classes
|
|
28
|
+
- CSS classes and prop names. Those live in code.
|
|
29
29
|
- Anything that needs updating every time the code is refactored
|
|
30
30
|
|
|
31
31
|
## Format
|