@warren-dean/code-kit 0.16.0 → 0.17.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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +29 -27
- package/hooks/lib/harness.mjs +9 -0
- package/hooks/lib/rules.mjs +8 -0
- package/hooks/mod/loop.mjs +97 -9
- package/hooks/mod/register.mjs +133 -4
- package/hooks/mod/register.test.ts +134 -1
- package/hooks/mod/view.mjs +3 -0
- package/hooks/stop-check.mjs +11 -1
- package/package.json +1 -1
- package/skills/decide/SKILL.md +1 -1
- package/skills/dispatch/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -0
- package/templates/agent.md +3 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "code-kit",
|
|
10
10
|
"source": "./",
|
|
11
11
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.17.0",
|
|
13
13
|
"category": "development",
|
|
14
14
|
"tags": ["guardrails", "architecture", "lanes", "layers", "spec-driven", "ci", "multi-agent"],
|
|
15
15
|
"license": "MIT"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules, from idea to merged code: spec design, lanes, layer rules, protected paths with an approval log, spec-check and design gates, proof-before-finish checks, dispatch, review, build status and CI verification, configured per project from its docs",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Warren Langeveldt"
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in each release of code-kit, newest first. Versions follow the plugin manifest and the npm package `@warren-dean/code-kit`. Each release is also on [GitHub Releases](https://github.com/warrendeanlangeveldt/code-kit/releases).
|
|
4
4
|
|
|
5
|
+
## 0.17.0 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
The lead's context stays lean: lanes report to a file, and the lead's context is compacted when a milestone finishes.
|
|
8
|
+
|
|
9
|
+
- A lane on a story's branch now writes its full report to `.claude/state/reports/<branch>.md` before it finishes: what it did, the commands it ran with their output, what's left, and anything it needs decided. It hands the lead at most ten lines naming that file. The stop hook won't let a lane finish without the report, and the review, dispatch and decide skills read it when they need the detail. Before, every lane pasted its whole report, output included, into the lead's context; in one project that was about 236k of the lead's 780k tokens.
|
|
10
|
+
- Lanes don't message the lead while they work. They hand back once, when they finish or when they're blocked.
|
|
11
|
+
- When a milestone finishes during the session, the lead's context is compacted before the loop's next step, as the new `harness.compact.milestone` setting says: `ask` (the default) asks you in Claude Code's dialog, `auto` compacts at once, and `off` leaves it alone. The summary keeps the stories in flight and what waits on you, and drops the finished stories' detail. It's skipped when the context is under 25% full.
|
|
12
|
+
- Re-run `/code-kit:init` to update your lane agent files with the new reporting rules. Until then, the stop hook tells each lane what to do.
|
|
13
|
+
|
|
14
|
+
## 0.16.1 (2026-10-16)
|
|
15
|
+
|
|
16
|
+
The lead loop stops asking for stories that are waiting on other work.
|
|
17
|
+
|
|
18
|
+
- When the lead says a story isn't due yet because it waits on work still going, such as a paired story a lane is building, a rebuild or a check running, the loop parks that story. A parked story is left out of every step, so a step that names it with others prompts the lead only for the others. Before, the loop held a declined step only while the step stayed exactly the same, so when one more story became ready, or any lane committed, it prompted the same reviews again.
|
|
19
|
+
- A parked story wakes when what it waits on moves: its own branch or state, the stories the lead named, or the unfinished stories in the plan that depend on it. Other lanes' commits don't wake it. After 30 minutes with nothing moving, the lead is asked once more.
|
|
20
|
+
- The band's next row says what each parked story waits on. A step the lead declined for another reason no longer holds up every other step.
|
|
21
|
+
|
|
5
22
|
## 0.16.0 (2026-10-16)
|
|
6
23
|
|
|
7
24
|
The lead runs the team inside your guardrails, and lanes ask the lead, never you.
|
package/README.md
CHANGED
|
@@ -332,7 +332,9 @@ The loop keeps the build moving between your prompts, under `harness.autonomy`.
|
|
|
332
332
|
|
|
333
333
|
- **Each step.** Once the lead has finished a turn, with the lead idle and nothing typed in the prompt, the loop asks `code-kit next` and prompts the lead to dispatch the ready stories, review a finished one, or build its own. It never prompts the same step twice while nothing has changed, and never a story an agent is still on: one its lane agent is building, or a reviewer is reviewing, waits until that agent finishes ("waiting: ST-10 is still with its agent").
|
|
334
334
|
- **It reads the lead's reply first.** Before prompting again, the loop reads the lead's last reply with a side call over the lead's own conversation. A reply that waits on you (a question, a design to look at, an approval), or a turn you stopped, holds the loop until you answer or speak to the lead: the band says what the lead waits on, and its question is asked in Claude Code's own question dialog. A step the lead didn't take waits in the band with its reason and **Ask again**.
|
|
335
|
+
- **Stories waiting on other work.** When the lead says a story isn't due yet because it waits on work still going (a paired story a lane is building, a rebuild, a check running), the loop parks that story and leaves it out of every step, so a step naming it with others prompts only the others. It wakes when what it waits on moves: its own branch, the stories the lead named, or the unfinished stories that depend on it. Other lanes' commits don't wake it, and after 30 minutes with nothing moving the lead is asked once more.
|
|
335
336
|
- **A step that doesn't move.** One the lead was prompted with twice in a row that is still next goes to you instead: "Merge lead/st-1: still next after 2 prompts. Ask the lead again?" with **Ask again**.
|
|
337
|
+
- **A lean lead.** A lane writes its full report, with its commands and their output, to `.claude/state/reports/<branch>.md`, and hands the lead a few lines naming it; the stop hook won't let a lane on a story branch finish without it, and the lead reads the file when it reviews. Lanes don't message the lead while they work. When a milestone finishes, the lead's context is compacted before the next one (`harness.compact.milestone`: you're asked by default, or it's done at once): the summary keeps what's in flight and what waits on you, and drops the finished stories' detail.
|
|
336
338
|
- **Designs.** A design story is handed to the lead once a session; while you and the lead design it, the loop leaves it to you.
|
|
337
339
|
- **One session runs it.** The first session whose loop acts runs it for the project. Another session you open there, for any reason, never prompts its lead: its band reads "the loop runs in another session", with **Lead from here** to move the loop to it.
|
|
338
340
|
- **Modes.** **Pause** and **Resume** stop and start it. Under `propose`, each step waits for **Go**; under `off`, the loop does nothing. When every story is done, the band says so and the loop stops.
|
|
@@ -475,33 +477,33 @@ code-kit approve <name>... --reason "…" [--lane name] [--delegated [--story ST
|
|
|
475
477
|
|
|
476
478
|
`.claude/code-kit.json`. Globs use `*` (one segment), `**` (any depth), `**/` (zero or more folders), `?` and `{a,b}`. Patterns under `shell` are regular expressions.
|
|
477
479
|
|
|
478
|
-
| Field | Required | What it is
|
|
479
|
-
| ------------------------------ | ----------------- |
|
|
480
|
-
| `version` | yes | `1`
|
|
481
|
-
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch
|
|
482
|
-
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan
|
|
483
|
-
| `docs.decisions` | no | Where agreed intents are recorded as numbered decision records (`code-kit intend record`); default `docs/decisions`
|
|
484
|
-
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added.
|
|
485
|
-
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included.
|
|
486
|
-
| `anyActor` | no | Paths every writer may change (e.g. the lockfile), read-only agents included. `.claude/state/**` is always included. An entry may be `{ "glob", "reviewer" }` to name the lane (or `"lead"`) that reviews changes to it; otherwise the lead reviews them, and `who` and `trace` say so.
|
|
487
|
-
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude?, owners? }`: the agent name maps a subagent to its lane; `owners` (GitHub handles) are the people whose sessions work it, in a team (needs `team`). A worktree with a `.lane` file holding the lane name makes the main session that lane.
|
|
488
|
-
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs an approval: the lead's where `approvals.delegate.protected` names it, otherwise a person's (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`.
|
|
489
|
-
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them. Init sets it for every lane that owns screens; `/code-kit:design` adds approved designs
|
|
490
|
-
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself.
|
|
491
|
-
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths
|
|
492
|
-
| `shell.block[]` | no | `{ pattern, why, person?, command? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on. `command: true` matches the pattern from the start of each command the line runs, so a heredoc or quoted text that mentions it isn't refused
|
|
493
|
-
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes
|
|
494
|
-
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped.
|
|
495
|
-
| `tests[]` | no | Globs naming the test files, for the weakened-test and fix rules. Default: the usual conventions (`*.test.*`, `*.spec.*`, `__tests__/`, `test/`, `tests/`, `e2e/`, `*_test.go`, `test_*.py`).
|
|
496
|
-
| `regression.prepare` | no | A command run in a fresh checkout before a fix's tests run there without the fix, such as `npm ci`. Without it, the checkout's `node_modules` and virtualenvs are linked in.
|
|
497
|
-
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip the check, which `verify` and a merge list as not run).
|
|
498
|
-
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected; `true` says the project wants that tool, so until it's set up here `next` gives its setup before any code. See [Adapters](#adapters).
|
|
499
|
-
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals).
|
|
500
|
-
| `approvals.delegate` | no | `{ protected?: [approval names], merge?: true, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true \| false \| [kinds] } }`: what the lead decides, inside the guardrails you keep: the protected approvals it grants, and packages it approves for a story, with `code-kit approve --delegated`; with `merge`, merges into a protected branch with `code-kit merge --delegated`. Lanes ask the lead for these, never you. npm packages are also checked against the registry; the defaults are MIT, Apache-2.0, BSD and ISC, 10,000 weekly downloads, a release within 18 months, and no install scripts. See [Approvals](#approvals).
|
|
501
|
-
| `setup` | no | The version of init's setup the project has (3 now). Without it, or lower, `next` names what the project is missing (the design gate, design stories before screens, a QA lane, a security reviewer) and starts no new story until re-running init adds it
|
|
502
|
-
| `team` | no | Preview. For several people in one repository: `{ techLead, staleDays?, intents?, board?, provider? }`. `techLead` is the GitHub handle of the person who owns the plan, specs and contracts; `staleDays` is how many working days without a push make a claim stale (default 3); `intents` is the discussion category intents go in (default `Intents`; create it once in the repository's Settings › Discussions); `board` is `{ owner, number }`, a GitHub Project the plan is mirrored onto; `provider` is `"github"` (default) or `{ command, fallback? }` for another team provider, such as the code-kit teams hub; with `fallback: "github"`, claims go through GitHub whenever that provider can't be reached. See docs/specs/16-teams.md.
|
|
503
|
-
| `branches.protected` | no | Default `["main", "master"]`
|
|
504
|
-
| `harness` | no | What the mod's harness does in the session: `autonomy` (`autonomous`, `propose` or `off`; default `autonomous`), `hold.minutes` (how long a call waiting on your approval is held, 0 to 10; default 2), `stall.nudgeMinutes` and `stall.restartMinutes` (default 5 and 10), `stall.maxRestarts` (default 2), `agents.reviewer.on` (default `false`) and `agents.reviewer.model` (default the session's), `agents.lanes.model` (the model each lane agent starts on, unless the lead's call or the lane's agent file names one; default the session's), `background.pauseAtPercent` (default 80) and `report.on` (agents draft bug reports for code-kit, which you send or discard; default `false`). Change them with `/harness` or `code-kit settings set`. |
|
|
480
|
+
| Field | Required | What it is |
|
|
481
|
+
| ------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
482
|
+
| `version` | yes | `1` |
|
|
483
|
+
| `docs.specs` | no | Folder of specs. With it, a lane needs a spec-check report before writing code on a branch |
|
|
484
|
+
| `docs.principles`, `docs.plan` | no | The engineering principles and the implementation plan |
|
|
485
|
+
| `docs.decisions` | no | Where agreed intents are recorded as numbered decision records (`code-kit intend record`); default `docs/decisions` |
|
|
486
|
+
| `lead.paths` | yes | What the main session writes. `.claude/**` is always added. |
|
|
487
|
+
| `lead.outside` | no | Paths outside the repository the lead may write, e.g. `../shared-lib/**`. Claude's memory and scratchpad are always included. |
|
|
488
|
+
| `anyActor` | no | Paths every writer may change (e.g. the lockfile), read-only agents included. `.claude/state/**` is always included. An entry may be `{ "glob", "reviewer" }` to name the lane (or `"lead"`) that reviews changes to it; otherwise the lead reviews them, and `who` and `trace` say so. |
|
|
489
|
+
| `lanes.<name>` | yes (may be `{}`) | `{ agent, paths, exclude?, owners? }`: the agent name maps a subagent to its lane; `owners` (GitHub handles) are the people whose sessions work it, in a team (needs `team`). A worktree with a `.lane` file holding the lane name makes the main session that lane. |
|
|
490
|
+
| `protected[]` | no | `{ glob, approval, why }`: logged on every commit. Anyone but the lead needs an approval: the lead's where `approvals.delegate.protected` names it, otherwise a person's (see [Approvals](#approvals)). `.claude/**` is always protected as `kit`. |
|
|
491
|
+
| `designGate` | no | `{ register, screens: [{ glob, not? }], howTo }`: screen files wait until the register (`{ "screens": [{ "file": … }] }`) lists them. Init sets it for every lane that owns screens; `/code-kit:design` adds approved designs |
|
|
492
|
+
| `layers[]` | no | `{ name, paths, mayImport, denyPackages? }`: the first layer whose paths match a file owns it. A layer may always import itself. |
|
|
493
|
+
| `importAliases` | no | `{ "@app/domain": "packages/domain/src" }`: resolves non-relative imports to repository paths |
|
|
494
|
+
| `shell.block[]` | no | `{ pattern, why, person?, command? }`: never run. `person: true` marks a person's act, which the lead may do for them with `approvals.lead` on. `command: true` matches the pattern from the start of each command the line runs, so a heredoc or quoted text that mentions it isn't refused |
|
|
495
|
+
| `shell.restricted[]` | no | `{ pattern, lanes, why }`: only the lead and these lanes |
|
|
496
|
+
| `postEdit[]` | no | `{ files, exclude?, run }`: after each edit of a matching file. `{file}` is the file. Exit 127 (tool not installed) is skipped. |
|
|
497
|
+
| `tests[]` | no | Globs naming the test files, for the weakened-test and fix rules. Default: the usual conventions (`*.test.*`, `*.spec.*`, `__tests__/`, `test/`, `tests/`, `e2e/`, `*_test.go`, `test_*.py`). |
|
|
498
|
+
| `regression.prepare` | no | A command run in a fresh checkout before a fix's tests run there without the fix, such as `npm ci`. Without it, the checkout's `node_modules` and virtualenvs are linked in. |
|
|
499
|
+
| `checks[]` | no | `{ name, files, exclude?, run[], each?, owners?, ifMissing?, timeoutSeconds? }`: before finishing, when matching files changed. `each` runs the commands in every changed folder matching that glob. `owners` limits who may change the files (the lead always may). `ifMissing: "fail"` makes a missing tool an error (the default is to skip the check, which `verify` and a merge list as not run). |
|
|
500
|
+
| `adapters` | no | `{ "<adapter>": false }` switches off an adapter that would otherwise be detected; `true` says the project wants that tool, so until it's set up here `next` gives its setup before any code. See [Adapters](#adapters). |
|
|
501
|
+
| `approvals.lead` | no | `true` lets the lead act on what the person says in chat, for when `!` isn't available (on a phone, say): it records their approvals with `code-kit approve`, and runs `shell.block` commands marked `person`. Default `false`. See [Approvals](#approvals). |
|
|
502
|
+
| `approvals.delegate` | no | `{ protected?: [approval names], merge?: true, dependencies?: { licences?, minWeeklyDownloads?, maxMonthsSinceRelease?, allowInstallScripts?: true \| false \| [kinds] } }`: what the lead decides, inside the guardrails you keep: the protected approvals it grants, and packages it approves for a story, with `code-kit approve --delegated`; with `merge`, merges into a protected branch with `code-kit merge --delegated`. Lanes ask the lead for these, never you. npm packages are also checked against the registry; the defaults are MIT, Apache-2.0, BSD and ISC, 10,000 weekly downloads, a release within 18 months, and no install scripts. See [Approvals](#approvals). |
|
|
503
|
+
| `setup` | no | The version of init's setup the project has (3 now). Without it, or lower, `next` names what the project is missing (the design gate, design stories before screens, a QA lane, a security reviewer) and starts no new story until re-running init adds it |
|
|
504
|
+
| `team` | no | Preview. For several people in one repository: `{ techLead, staleDays?, intents?, board?, provider? }`. `techLead` is the GitHub handle of the person who owns the plan, specs and contracts; `staleDays` is how many working days without a push make a claim stale (default 3); `intents` is the discussion category intents go in (default `Intents`; create it once in the repository's Settings › Discussions); `board` is `{ owner, number }`, a GitHub Project the plan is mirrored onto; `provider` is `"github"` (default) or `{ command, fallback? }` for another team provider, such as the code-kit teams hub; with `fallback: "github"`, claims go through GitHub whenever that provider can't be reached. See docs/specs/16-teams.md. |
|
|
505
|
+
| `branches.protected` | no | Default `["main", "master"]` |
|
|
506
|
+
| `harness` | no | What the mod's harness does in the session: `autonomy` (`autonomous`, `propose` or `off`; default `autonomous`), `hold.minutes` (how long a call waiting on your approval is held, 0 to 10; default 2), `stall.nudgeMinutes` and `stall.restartMinutes` (default 5 and 10), `stall.maxRestarts` (default 2), `agents.reviewer.on` (default `false`) and `agents.reviewer.model` (default the session's), `agents.lanes.model` (the model each lane agent starts on, unless the lead's call or the lane's agent file names one; default the session's), `background.pauseAtPercent` (default 80), `compact.milestone` (when a milestone finishes, compact the lead's context: `ask`, `auto` or `off`; default `ask`) and `report.on` (agents draft bug reports for code-kit, which you send or discard; default `false`). Change them with `/harness` or `code-kit settings set`. |
|
|
505
507
|
|
|
506
508
|
Add `.claude/approvals/`, `.claude/state/` and `.claude/code-kit.draft.json` to `.gitignore`, and `.claude/approval-log.jsonl merge=union` to `.gitattributes`, so merging branches that both logged changes keeps every entry. Commit `.claude/code-kit.baseline.json` if the project has one.
|
|
507
509
|
|
package/hooks/lib/harness.mjs
CHANGED
|
@@ -6,6 +6,9 @@ export const AUTONOMY = ['autonomous', 'propose', 'off'];
|
|
|
6
6
|
export const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
7
7
|
|
|
8
8
|
/** Each setting by its dotted key: its default, and why a value is wrong (or null when it's right). */
|
|
9
|
+
/** LEAN-3: what the loop does with the lead's context when a milestone finishes. */
|
|
10
|
+
export const COMPACT = ['ask', 'auto', 'off'];
|
|
11
|
+
|
|
9
12
|
export const SETTINGS = {
|
|
10
13
|
autonomy: {
|
|
11
14
|
default: 'autonomous',
|
|
@@ -66,6 +69,12 @@ export const SETTINGS = {
|
|
|
66
69
|
Number.isFinite(v) && v > 0 && v <= 100 ? null : 'must be a percentage from 1 to 100',
|
|
67
70
|
about: "The plan's 5-hour use at which background agents pause",
|
|
68
71
|
},
|
|
72
|
+
'compact.milestone': {
|
|
73
|
+
default: 'ask',
|
|
74
|
+
problem: (v) => (COMPACT.includes(v) ? null : `must be one of ${COMPACT.join(', ')}`),
|
|
75
|
+
about:
|
|
76
|
+
"When a milestone finishes, compact the lead's context before the next: ask you, do it, or leave it",
|
|
77
|
+
},
|
|
69
78
|
'report.on': {
|
|
70
79
|
default: false,
|
|
71
80
|
problem: (v) => (typeof v === 'boolean' ? null : 'must be true or false'),
|
package/hooks/lib/rules.mjs
CHANGED
|
@@ -815,6 +815,14 @@ export function ownerOf(rel, config) {
|
|
|
815
815
|
return matchesAny(rel, config.lead.paths) ? 'lead' : null;
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
/**
|
|
819
|
+
* LEAN-1: a lane's full report for its branch: what it did, the commands it ran and their output, its
|
|
820
|
+
* questions. The lead's context gets a short hand-back naming this file, and reads it when it reviews.
|
|
821
|
+
*/
|
|
822
|
+
export function reportPath(root) {
|
|
823
|
+
return `.claude/state/reports/${currentBranch(root).replace(/[^\w.-]+/g, '_')}.md`;
|
|
824
|
+
}
|
|
825
|
+
|
|
818
826
|
// Lane work starts with the spec-check skill: its report for the branch must exist first.
|
|
819
827
|
export function specCheckPath(root) {
|
|
820
828
|
return `.claude/state/spec-check/${currentBranch(root).replace(/[^\w.-]+/g, '_')}.md`;
|
package/hooks/mod/loop.mjs
CHANGED
|
@@ -40,8 +40,8 @@ export function storiesInFlight(agents) {
|
|
|
40
40
|
* gives way to the next one `next` listed; with nothing left, the loop waits, saying for what, until an
|
|
41
41
|
* agent finishes (which moves the project on).
|
|
42
42
|
*/
|
|
43
|
-
export function withoutInFlight(step, inFlight) {
|
|
44
|
-
if (!step || !inFlight?.size) return step;
|
|
43
|
+
export function withoutInFlight(step, inFlight, parked = new Map()) {
|
|
44
|
+
if (!step || (!inFlight?.size && !parked?.size)) return step;
|
|
45
45
|
const chain = [step, ...(step.then ?? [])].map(({ then: _then, ...s }) => s);
|
|
46
46
|
const kept = [];
|
|
47
47
|
const busy = [];
|
|
@@ -51,19 +51,72 @@ export function withoutInFlight(step, inFlight) {
|
|
|
51
51
|
continue;
|
|
52
52
|
}
|
|
53
53
|
const ids = (s.args ?? '').split(/\s+/).filter(Boolean);
|
|
54
|
-
const
|
|
55
|
-
|
|
54
|
+
const held = (id) => inFlight?.has(id.toUpperCase()) || parked?.has(id.toUpperCase());
|
|
55
|
+
const free = ids.filter((id) => !held(id));
|
|
56
|
+
busy.push(...ids.filter(held));
|
|
56
57
|
if (free.length) kept.push({ ...s, args: free.join(' ') });
|
|
57
58
|
}
|
|
58
59
|
if (kept.length) return { ...kept[0], ...(kept.length > 1 ? { then: kept.slice(1) } : {}) };
|
|
59
|
-
const names = [...new Set(busy)];
|
|
60
|
+
const names = [...new Set(busy.map((id) => id.toUpperCase()))];
|
|
61
|
+
const working = names.filter((id) => inFlight?.has(id));
|
|
62
|
+
// LOOP-13: what each parked story waits on, as the lead said.
|
|
63
|
+
const waits = names
|
|
64
|
+
.filter((id) => !inFlight?.has(id))
|
|
65
|
+
.map(
|
|
66
|
+
(id) =>
|
|
67
|
+
`${id} waits${parked.get(id)?.on?.length ? ` on ${parked.get(id).on.join(', ')}` : ''}`,
|
|
68
|
+
);
|
|
60
69
|
return {
|
|
61
70
|
step: 'wait',
|
|
62
|
-
why:
|
|
71
|
+
why: [
|
|
72
|
+
working.length
|
|
73
|
+
? `${working.join(', ')} ${working.length === 1 ? 'is' : 'are'} still with ${working.length === 1 ? 'its agent' : 'their agents'}`
|
|
74
|
+
: null,
|
|
75
|
+
...waits,
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join('; ')
|
|
79
|
+
.concat('; the loop goes on when one moves'),
|
|
63
80
|
inFlight: names,
|
|
64
81
|
};
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
/** LOOP-13: how long a parked story waits with nothing it waits on moving, before the lead is asked again. */
|
|
85
|
+
export const PARK_MS = 30 * 60_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* LOOP-13: the stories to watch while `id` is parked: itself, the stories the lead said it waits on,
|
|
89
|
+
* and the stories that depend on it and aren't done (a pair the lead merges together).
|
|
90
|
+
*/
|
|
91
|
+
export function parkWatch(id, on = [], stories = []) {
|
|
92
|
+
const dependents = stories
|
|
93
|
+
.filter((s) => (s.dependsOn ?? []).includes(id) && s.state !== 'done')
|
|
94
|
+
.map((s) => s.id);
|
|
95
|
+
return [...new Set([id, ...on, ...dependents])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* LOOP-13: where the watched stories stand, as one string: each one's state, its branch's commit and
|
|
100
|
+
* whether an agent is at work on it. A parked story wakes when this changes.
|
|
101
|
+
*/
|
|
102
|
+
export function storiesSignature(ids, stories = [], heads = new Map(), inFlight = new Set()) {
|
|
103
|
+
return ids
|
|
104
|
+
.map((id) => {
|
|
105
|
+
const s = stories.find((x) => x.id === id);
|
|
106
|
+
return `${id}:${s?.state ?? '?'}:${s?.branch ? (heads.get(s.branch) ?? '') : ''}:${inFlight.has(id) ? 'busy' : ''}`;
|
|
107
|
+
})
|
|
108
|
+
.join('|');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The story ids a step names, upper-cased: `review ST-158 ST-172` gives ST-158 and ST-172. */
|
|
112
|
+
export const stepStories = (step) =>
|
|
113
|
+
STORY_STEPS.has(step?.stepKind ?? step?.kind ?? step?.step)
|
|
114
|
+
? String(step?.target ?? step?.args ?? '')
|
|
115
|
+
.split(/\s+/)
|
|
116
|
+
.filter((id) => /^ST-\d+$/i.test(id))
|
|
117
|
+
.map((id) => id.toUpperCase())
|
|
118
|
+
: [];
|
|
119
|
+
|
|
67
120
|
/**
|
|
68
121
|
* The prompt for a step from `code-kit next --json` (LOOP-1), or null when it isn't the lead's to
|
|
69
122
|
* take. Every ready story is named: the dispatch skill decides how many lanes go (LOOP-2). A prompt
|
|
@@ -157,7 +210,7 @@ export const nudgeText = (minutes) =>
|
|
|
157
210
|
`No tool call for ${minutes} minutes: report where you are, or carry on.`;
|
|
158
211
|
|
|
159
212
|
/** LOOP-11: what a lead turn left the build at, as the loop reads the lead's reply. */
|
|
160
|
-
export const OUTCOMES = new Set(['done', 'started', 'needs-person', 'blocked']);
|
|
213
|
+
export const OUTCOMES = new Set(['done', 'started', 'needs-person', 'blocked', 'waiting']);
|
|
161
214
|
const ANSWER_SHOWN = 4000;
|
|
162
215
|
|
|
163
216
|
/**
|
|
@@ -175,11 +228,12 @@ export function outcomePrompt({ prompt = null, answer = '' }) {
|
|
|
175
228
|
: "The person prompted you, and code-kit's lead loop is about to prompt you with the build's next step.",
|
|
176
229
|
`Your reply ended your turn. It read:\n"""\n${shown}\n"""`,
|
|
177
230
|
'Say where that reply left things, so the loop knows whether to prompt you again. Answer with one JSON object and nothing else:',
|
|
178
|
-
'{"outcome": "done" | "started" | "needs-person" | "blocked", "question": "…", "options": [{"label": "…", "effect": "…"}], "reason": "…"}',
|
|
231
|
+
'{"outcome": "done" | "started" | "needs-person" | "blocked" | "waiting", "question": "…", "options": [{"label": "…", "effect": "…"}], "reason": "…", "waits": [{"story": "ST-n", "on": ["ST-n"], "why": "…"}]}',
|
|
179
232
|
'- done: you finished what was asked, or there was nothing to do.',
|
|
180
233
|
'- started: you started work that carries on without you, such as lane agents.',
|
|
181
234
|
"- needs-person: you asked the person something, or wait on their answer, approval or feedback before you go on. question: what you wait on, as one question; options: 2 to 4 answers they could give (a label of at most five words, and what it does), or [] when it's theirs to put in words.",
|
|
182
|
-
"-
|
|
235
|
+
"- waiting: the step isn't due yet for some or all of its stories: they wait on other work still going (a lane building a story they merge with, a rebuild, a check running). waits: one entry per story that waits, with the stories it waits on (their ST ids, [] when it's other work) and why, in a few words. Use this, not done, when you said a story can't be taken yet.",
|
|
236
|
+
"- blocked: you didn't take the step, for a reason no answer from the person settles now and no work in flight will change. reason: why, in a sentence.",
|
|
183
237
|
].join('\n');
|
|
184
238
|
}
|
|
185
239
|
|
|
@@ -203,11 +257,21 @@ export function readOutcome(text) {
|
|
|
203
257
|
.map((o) => ({ label: words(o?.label, 40), effect: words(o?.effect, 200) }))
|
|
204
258
|
.filter((o) => o.label)
|
|
205
259
|
.slice(0, 4);
|
|
260
|
+
const story = (v) =>
|
|
261
|
+
typeof v === 'string' && /^ST-\d+$/i.test(v.trim()) ? v.trim().toUpperCase() : null;
|
|
262
|
+
const waits = (Array.isArray(parsed.waits) ? parsed.waits : [])
|
|
263
|
+
.map((w) => ({
|
|
264
|
+
story: story(w?.story),
|
|
265
|
+
on: (Array.isArray(w?.on) ? w.on : []).map(story).filter(Boolean),
|
|
266
|
+
why: words(w?.why, 200),
|
|
267
|
+
}))
|
|
268
|
+
.filter((w) => w.story);
|
|
206
269
|
return {
|
|
207
270
|
outcome: parsed.outcome,
|
|
208
271
|
question: words(parsed.question, 400),
|
|
209
272
|
options: options.length >= 2 ? options : [],
|
|
210
273
|
reason: words(parsed.reason, 300),
|
|
274
|
+
waits,
|
|
211
275
|
};
|
|
212
276
|
}
|
|
213
277
|
|
|
@@ -246,3 +310,27 @@ export function laneModel({ type, model = null, lanes = {}, setting = null, file
|
|
|
246
310
|
if (fileModel && fileModel !== 'inherit') return null;
|
|
247
311
|
return setting;
|
|
248
312
|
}
|
|
313
|
+
|
|
314
|
+
/** LEAN-3: compacting a context below this share of its window isn't worth the summary it costs. */
|
|
315
|
+
export const COMPACT_FLOOR = 25;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* LEAN-3: the milestones every story of which is done, in the plan's order. A milestone with no stories
|
|
319
|
+
* isn't one.
|
|
320
|
+
*/
|
|
321
|
+
export function milestonesDone(stories = []) {
|
|
322
|
+
const by = new Map();
|
|
323
|
+
for (const s of stories) {
|
|
324
|
+
if (!s.milestone) continue;
|
|
325
|
+
by.set(s.milestone, [...(by.get(s.milestone) ?? []), s]);
|
|
326
|
+
}
|
|
327
|
+
return [...by].filter(([, list]) => list.every((s) => s.state === 'done')).map(([m]) => m);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** LEAN-3: what the lead's summary keeps when its context is compacted after a milestone. */
|
|
331
|
+
export const compactInstructions = (milestone) =>
|
|
332
|
+
[
|
|
333
|
+
`${milestone} is done; the build goes on with the next milestone.`,
|
|
334
|
+
'Keep: the milestone just finished, in a line; every story in flight now, with its lane, its branch and where it stands (building, in review, in the merge queue, waiting and on what); the decisions and questions waiting on the person; what the person decided or asked for in this session that still applies; and the rules and preferences they set.',
|
|
335
|
+
"Drop: the finished stories' details, lane reports and hand-backs, command output, diffs and file contents. Each is in git, the plan or the lanes' report files, to read again when it's needed.",
|
|
336
|
+
].join('\n');
|
package/hooks/mod/register.mjs
CHANGED
|
@@ -58,12 +58,19 @@ import {
|
|
|
58
58
|
leadPrompt,
|
|
59
59
|
nudgeText,
|
|
60
60
|
outcomePrompt,
|
|
61
|
+
COMPACT_FLOOR,
|
|
62
|
+
PARK_MS,
|
|
63
|
+
compactInstructions,
|
|
64
|
+
milestonesDone,
|
|
65
|
+
parkWatch,
|
|
61
66
|
readOutcome,
|
|
62
67
|
restartPrompt,
|
|
63
68
|
stallDue,
|
|
64
69
|
stepKey,
|
|
65
70
|
stepLabel,
|
|
71
|
+
stepStories,
|
|
66
72
|
storiesInFlight,
|
|
73
|
+
storiesSignature,
|
|
67
74
|
withoutInFlight,
|
|
68
75
|
} from './loop.mjs';
|
|
69
76
|
import { goalOf, nextText } from './mission.mjs';
|
|
@@ -122,6 +129,10 @@ const loop = {
|
|
|
122
129
|
// LOOP-11, LOOP-12: what holds the loop: { kind: 'person' | 'stopped' | 'blocked' | 'design', key, label, prompt, question, options, reason }
|
|
123
130
|
waiting: null,
|
|
124
131
|
handedDesign: new Set(), // LOOP-12: design stories handed to the lead this session
|
|
132
|
+
// LOOP-13: stories the lead said wait on other work: id → { on, why, watch, sig, at }
|
|
133
|
+
parked: new Map(),
|
|
134
|
+
// LEAN-3: the milestones already done when this session first looked, and those finished since.
|
|
135
|
+
milestones: null,
|
|
125
136
|
};
|
|
126
137
|
// LOOP-10: whether this session runs the loop, the session that does when another does, and when this
|
|
127
138
|
// one last renewed its lease.
|
|
@@ -1099,6 +1110,94 @@ export function register(on) {
|
|
|
1099
1110
|
$.ui.invalidate('ui.render');
|
|
1100
1111
|
await act.step('resume');
|
|
1101
1112
|
},
|
|
1113
|
+
// LOOP-13: where the watched stories stand now: their states, branch commits, and agents at work.
|
|
1114
|
+
storiesNow: async () => {
|
|
1115
|
+
const refs = await $.process
|
|
1116
|
+
.run(['git', 'for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads'], { cwd })
|
|
1117
|
+
.catch(() => ({ stdout: '' }));
|
|
1118
|
+
const heads = new Map(
|
|
1119
|
+
String(refs.stdout ?? '')
|
|
1120
|
+
.split('\n')
|
|
1121
|
+
.map((l) => /^\s*(\S+)\s+(?:refs\/heads\/)?(\S+)/.exec(l))
|
|
1122
|
+
.filter(Boolean)
|
|
1123
|
+
.map((m) => [m[2], m[1]]),
|
|
1124
|
+
);
|
|
1125
|
+
const inFlight = storiesInFlight(await $.agent.list().catch(() => []));
|
|
1126
|
+
const stories = model.state?.stories ?? [];
|
|
1127
|
+
return {
|
|
1128
|
+
heads,
|
|
1129
|
+
inFlight,
|
|
1130
|
+
stories,
|
|
1131
|
+
sig: (ids) => storiesSignature(ids, stories, heads, inFlight),
|
|
1132
|
+
};
|
|
1133
|
+
},
|
|
1134
|
+
park: async (waits) => {
|
|
1135
|
+
if (!waits.length) return;
|
|
1136
|
+
const now = await $.clock.now();
|
|
1137
|
+
const seen = await act.storiesNow();
|
|
1138
|
+
for (const w of waits) {
|
|
1139
|
+
const watch = parkWatch(w.id, w.on, seen.stories);
|
|
1140
|
+
loop.parked.set(w.id, { on: w.on, why: w.why, watch, sig: seen.sig(watch), at: now });
|
|
1141
|
+
}
|
|
1142
|
+
$.ui.invalidate('ui.render');
|
|
1143
|
+
},
|
|
1144
|
+
// LOOP-13: a parked story wakes when anything it watches moves, or after PARK_MS with nothing moving.
|
|
1145
|
+
unpark: async () => {
|
|
1146
|
+
if (!loop.parked.size) return;
|
|
1147
|
+
const now = await $.clock.now();
|
|
1148
|
+
const seen = await act.storiesNow();
|
|
1149
|
+
for (const [id, p] of loop.parked)
|
|
1150
|
+
if (now - p.at >= PARK_MS || seen.sig(p.watch) !== p.sig) loop.parked.delete(id);
|
|
1151
|
+
},
|
|
1152
|
+
// LEAN-3: a milestone finished in this session: the lead's context is compacted before the next,
|
|
1153
|
+
// asked in Claude Code's dialog or done at once, as compact.milestone says. True when it took over
|
|
1154
|
+
// the turn's end, so the loop's step waits until the compaction is done or declined.
|
|
1155
|
+
compactAtMilestone: async () => {
|
|
1156
|
+
const done = milestonesDone(model.state?.stories ?? []);
|
|
1157
|
+
if (loop.milestones === null) {
|
|
1158
|
+
loop.milestones = new Set(done);
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
const fresh = done.filter((m) => !loop.milestones.has(m));
|
|
1162
|
+
if (!fresh.length) return false;
|
|
1163
|
+
const mode = harness().compact?.milestone ?? 'ask';
|
|
1164
|
+
if (mode === 'off' || offering || !where.interactive || (lease.other && !lease.mine)) {
|
|
1165
|
+
for (const m of fresh) loop.milestones.add(m);
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
const box = await $.prompt.read().catch(() => null);
|
|
1169
|
+
if (box?.text?.trim()) return false;
|
|
1170
|
+
for (const m of fresh) loop.milestones.add(m);
|
|
1171
|
+
const percent = (await $.session.usage().catch(() => null))?.context?.percent ?? 0;
|
|
1172
|
+
if (percent < COMPACT_FLOOR) return false;
|
|
1173
|
+
const milestone = fresh.at(-1);
|
|
1174
|
+
const compact = async () => {
|
|
1175
|
+
const r = await $.session
|
|
1176
|
+
.compact({ instructions: compactInstructions(milestone) })
|
|
1177
|
+
.catch((err) => ({ skip: String(err?.message ?? err) }));
|
|
1178
|
+
if (r?.skip) notice = `The lead's context wasn't compacted: ${r.skip}`;
|
|
1179
|
+
await act.record('compact', milestone);
|
|
1180
|
+
$.ui.invalidate('ui.render');
|
|
1181
|
+
await act.step('resume').catch(() => {});
|
|
1182
|
+
};
|
|
1183
|
+
if (mode === 'auto') {
|
|
1184
|
+
compact().catch(() => {});
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
offering = true;
|
|
1188
|
+
$.ui
|
|
1189
|
+
.ask(
|
|
1190
|
+
`${milestone} is done, and the lead's context is ${Math.round(percent)}% full. Compact it before the next milestone? The summary keeps what's in flight and what waits on you, and drops the finished stories' detail.`,
|
|
1191
|
+
{ options: ['Compact now', 'Not now'], header: 'Context' },
|
|
1192
|
+
)
|
|
1193
|
+
.catch(() => null)
|
|
1194
|
+
.then(async (answer) => {
|
|
1195
|
+
offering = false;
|
|
1196
|
+
if (answer === 'Compact now') await compact();
|
|
1197
|
+
else await act.step('resume').catch(() => {});
|
|
1198
|
+
});
|
|
1199
|
+
return true;
|
|
1200
|
+
},
|
|
1102
1201
|
// LOOP-11: the lead's last reply, read once before the loop prompts it again. A reply that waits on
|
|
1103
1202
|
// the person, or a turn they stopped, holds the loop until they speak; a step the lead didn't take
|
|
1104
1203
|
// holds that step while it's still next.
|
|
@@ -1145,6 +1244,21 @@ export function register(on) {
|
|
|
1145
1244
|
// Spec 05's data: what came of the step, once known.
|
|
1146
1245
|
const record = sent ? loop.steps.findLast((x) => x.prompt === sent.prompt) : null;
|
|
1147
1246
|
if (record) record.outcome = heard?.outcome ?? 'unread';
|
|
1247
|
+
// LOOP-13: stories the lead said wait on other work are parked, each until what it waits on moves.
|
|
1248
|
+
const ids = stepStories(sent);
|
|
1249
|
+
if (['waiting', 'blocked'].includes(heard?.outcome) && ids.length) {
|
|
1250
|
+
const named = new Map((heard.waits ?? []).map((w) => [w.story, w]));
|
|
1251
|
+
// A blocked step, or a reply that names none, parks all its stories; otherwise those it names.
|
|
1252
|
+
const waiting =
|
|
1253
|
+
heard.outcome === 'blocked' || !named.size ? ids : ids.filter((id) => named.has(id));
|
|
1254
|
+
await act.park(
|
|
1255
|
+
waiting.map((id) => ({
|
|
1256
|
+
id,
|
|
1257
|
+
on: named.get(id)?.on ?? [],
|
|
1258
|
+
why: named.get(id)?.why ?? heard.reason ?? null,
|
|
1259
|
+
})),
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1148
1262
|
if (heard?.outcome === 'needs-person')
|
|
1149
1263
|
loop.waiting = {
|
|
1150
1264
|
kind: 'person',
|
|
@@ -1154,7 +1268,7 @@ export function register(on) {
|
|
|
1154
1268
|
reason: heard.reason,
|
|
1155
1269
|
};
|
|
1156
1270
|
else if (heard?.outcome === 'blocked' && sent)
|
|
1157
|
-
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason };
|
|
1271
|
+
loop.waiting = { kind: 'blocked', ...step, reason: heard.reason, stories: ids };
|
|
1158
1272
|
if (loop.waiting) $.ui.invalidate('ui.render');
|
|
1159
1273
|
},
|
|
1160
1274
|
// LOOP-11: the person's choice from the lead's options, or their own words, told to the lead.
|
|
@@ -1181,11 +1295,13 @@ export function register(on) {
|
|
|
1181
1295
|
const w = loop.waiting;
|
|
1182
1296
|
if (!w?.prompt || leadTurn) return;
|
|
1183
1297
|
loop.waiting = null;
|
|
1298
|
+
for (const id of w.stories ?? []) loop.parked.delete(id);
|
|
1184
1299
|
loop.asked = { key: w.key, times: 0 };
|
|
1185
1300
|
await act.submit(w.prompt, w.stepKind, w.target, { key: w.key, label: w.label });
|
|
1186
1301
|
},
|
|
1187
1302
|
// LOOP-11: the person lets the loop go on.
|
|
1188
1303
|
carryOn: async () => {
|
|
1304
|
+
for (const id of loop.waiting?.stories ?? []) loop.parked.delete(id);
|
|
1189
1305
|
loop.waiting = null;
|
|
1190
1306
|
loop.lastKey = null;
|
|
1191
1307
|
$.ui.invalidate('ui.render');
|
|
@@ -1253,12 +1369,15 @@ export function register(on) {
|
|
|
1253
1369
|
return act.submit(prompt, 'restart', story);
|
|
1254
1370
|
}
|
|
1255
1371
|
const ran = await $.process.run(['node', cli, 'next', '--json'], { cwd });
|
|
1372
|
+
// LOOP-13: a parked story stays out of the step until what it waits on moves.
|
|
1373
|
+
await act.unpark();
|
|
1256
1374
|
// LOOP-9: never a story an agent is already at work on, building or reviewing it.
|
|
1257
1375
|
const step =
|
|
1258
1376
|
ran.exitCode === 0
|
|
1259
1377
|
? withoutInFlight(
|
|
1260
1378
|
parseJson(ran.stdout),
|
|
1261
1379
|
storiesInFlight(await $.agent.list().catch(() => [])),
|
|
1380
|
+
loop.parked,
|
|
1262
1381
|
)
|
|
1263
1382
|
: null;
|
|
1264
1383
|
// MISSION-1: the band shows what the loop takes next.
|
|
@@ -1294,9 +1413,17 @@ export function register(on) {
|
|
|
1294
1413
|
// LOOP-11, LOOP-12: a step the lead didn't take, or designs left to the person and the lead, hold
|
|
1295
1414
|
// that step while it's still next; any other step goes on.
|
|
1296
1415
|
if (loop.waiting) {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1416
|
+
// LOOP-13: a blocked step's stories are parked, so the line stays while any of them is, and
|
|
1417
|
+
// other steps go on; a step without stories holds while it's still next.
|
|
1418
|
+
const stories = loop.waiting.stories ?? [];
|
|
1419
|
+
const held = stories.length
|
|
1420
|
+
? stories.some((id) => loop.parked.has(id))
|
|
1421
|
+
: loop.waiting.key === key;
|
|
1422
|
+
if (held && !stories.length) return;
|
|
1423
|
+
if (!held) {
|
|
1424
|
+
loop.waiting = null;
|
|
1425
|
+
$.ui.invalidate('ui.render');
|
|
1426
|
+
}
|
|
1300
1427
|
}
|
|
1301
1428
|
if (!prompt) return;
|
|
1302
1429
|
if (designHandedOver(step, loop.handedDesign)) {
|
|
@@ -1908,6 +2035,8 @@ export function register(on) {
|
|
|
1908
2035
|
$.prompt.submit({ text }).catch(() => {});
|
|
1909
2036
|
return res;
|
|
1910
2037
|
}
|
|
2038
|
+
// LEAN-3: a milestone just finished: its compaction comes before the next step.
|
|
2039
|
+
if (await act?.compactAtMilestone().catch(() => false)) return res;
|
|
1911
2040
|
await act?.step('turn').catch(() => {});
|
|
1912
2041
|
// DECIDE-4: a decision waiting, asked in Claude Code's own dialog; not awaited, the person answers.
|
|
1913
2042
|
act?.offer().catch(() => {});
|
|
@@ -74,6 +74,7 @@ function project() {
|
|
|
74
74
|
refs: ' aaa refs/heads/main\n bbb refs/heads/web/st-4\n',
|
|
75
75
|
agents: [] as { type: string; status: string; id?: string; description?: string }[],
|
|
76
76
|
limits: [] as { kind: string; percentUsed: number }[],
|
|
77
|
+
contextPercent: 0, // LEAN-3: how full the lead's context is
|
|
77
78
|
approveExit: 0,
|
|
78
79
|
mergeExit: 0,
|
|
79
80
|
answer: 'Merge',
|
|
@@ -403,7 +404,9 @@ function stub(on: any, w: World) {
|
|
|
403
404
|
w.spawned.push(e);
|
|
404
405
|
return { model: e.model ?? 'claude-opus-5-5', agentId: `a-${w.spawned.length}` };
|
|
405
406
|
});
|
|
406
|
-
on('session.usage', () => ({
|
|
407
|
+
on('session.usage', () => ({
|
|
408
|
+
value: { startedAt: 0, context: { percent: w.contextPercent }, rateLimits: w.limits },
|
|
409
|
+
}));
|
|
407
410
|
on('session.measure', ($: any, e: any) => ({ changed: e.changed }));
|
|
408
411
|
// A model request, answered with the usage the test gave it.
|
|
409
412
|
on('turn.step', async function* ($: any, e: any) {
|
|
@@ -2510,6 +2513,136 @@ test('LOOP-11 dismissed, what the lead waits on stays in the band, and the loop
|
|
|
2510
2513
|
expect(w.prompts.at(-1)).toBe('Review ST-4: run /code-kit:review ST-4.');
|
|
2511
2514
|
});
|
|
2512
2515
|
|
|
2516
|
+
test("LEAN-3 when a milestone finishes, the person is asked to compact the lead's context first, and the loop goes on after", async ($, on) => {
|
|
2517
|
+
const w = project();
|
|
2518
|
+
w.stories = [
|
|
2519
|
+
{
|
|
2520
|
+
id: 'ST-1',
|
|
2521
|
+
title: 'Foundations',
|
|
2522
|
+
lane: 'web',
|
|
2523
|
+
state: 'in review',
|
|
2524
|
+
branch: 'web/st-1',
|
|
2525
|
+
milestone: 'M0',
|
|
2526
|
+
waitingOn: [],
|
|
2527
|
+
},
|
|
2528
|
+
...w.stories.map((s) => ({ ...s, milestone: 'M1' })),
|
|
2529
|
+
];
|
|
2530
|
+
const compacted: string[] = [];
|
|
2531
|
+
w.contextPercent = 62;
|
|
2532
|
+
on('session.compact', ($: any, e: any) => {
|
|
2533
|
+
compacted.push(e.instructions);
|
|
2534
|
+
// A compaction leaves the summary in the conversation's place.
|
|
2535
|
+
return {
|
|
2536
|
+
messages: [
|
|
2537
|
+
{ role: 'user', text: 'Summary: M0 done; ST-4 in progress on web/st-4.', toolUses: [] },
|
|
2538
|
+
],
|
|
2539
|
+
};
|
|
2540
|
+
});
|
|
2541
|
+
const clock = await start($, on, w);
|
|
2542
|
+
await leadTurn($);
|
|
2543
|
+
await settle(clock);
|
|
2544
|
+
expect(compacted).toEqual([]);
|
|
2545
|
+
const prompted = w.prompts.length;
|
|
2546
|
+
// ST-1 merges: M0 is done, and the next turn's end asks before the loop's next step.
|
|
2547
|
+
w.stories[0] = { ...w.stories[0], state: 'done' };
|
|
2548
|
+
w.refs += ' m1 refs/heads/main-moved\n';
|
|
2549
|
+
await clock.advance(2000);
|
|
2550
|
+
w.answer = 'Compact now';
|
|
2551
|
+
await leadTurn($, 't-2');
|
|
2552
|
+
await settle(clock);
|
|
2553
|
+
expect(w.asked.at(-1)?.question).toMatch(
|
|
2554
|
+
/^M0 is done, and the lead's context is 62% full\. Compact it/,
|
|
2555
|
+
);
|
|
2556
|
+
expect(w.asked.at(-1)?.options).toEqual(['Compact now', 'Not now']);
|
|
2557
|
+
expect(compacted).toHaveLength(1);
|
|
2558
|
+
expect(compacted[0]).toMatch(/^M0 is done/);
|
|
2559
|
+
expect(compacted[0]).toMatch(/every story in flight now/);
|
|
2560
|
+
// The loop's step follows the compaction.
|
|
2561
|
+
expect(w.prompts.length).toBeGreaterThan(prompted);
|
|
2562
|
+
// The same milestone isn't offered twice, and a context below the floor isn't worth compacting.
|
|
2563
|
+
w.contextPercent = 10;
|
|
2564
|
+
await leadTurn($, 't-3');
|
|
2565
|
+
await settle(clock);
|
|
2566
|
+
expect(compacted).toHaveLength(1);
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
test('LOOP-13 stories the lead says wait on other work are parked, each until what it waits on moves', async ($, on) => {
|
|
2570
|
+
const w = project();
|
|
2571
|
+
w.stories.push(
|
|
2572
|
+
{
|
|
2573
|
+
id: 'ST-158',
|
|
2574
|
+
title: 'Storage',
|
|
2575
|
+
lane: 'web',
|
|
2576
|
+
state: 'in review',
|
|
2577
|
+
branch: 'web/st-158',
|
|
2578
|
+
waitingOn: [],
|
|
2579
|
+
},
|
|
2580
|
+
{
|
|
2581
|
+
id: 'ST-164',
|
|
2582
|
+
title: 'Callers use the storage',
|
|
2583
|
+
lane: 'api',
|
|
2584
|
+
state: 'in progress',
|
|
2585
|
+
branch: 'api/st-164',
|
|
2586
|
+
dependsOn: ['ST-158'],
|
|
2587
|
+
waitingOn: [],
|
|
2588
|
+
},
|
|
2589
|
+
{
|
|
2590
|
+
id: 'ST-172',
|
|
2591
|
+
title: 'Silent tests',
|
|
2592
|
+
lane: 'core',
|
|
2593
|
+
state: 'in review',
|
|
2594
|
+
branch: 'core/st-172',
|
|
2595
|
+
waitingOn: [],
|
|
2596
|
+
},
|
|
2597
|
+
{
|
|
2598
|
+
id: 'ST-173',
|
|
2599
|
+
title: 'Verdicts',
|
|
2600
|
+
lane: 'lead',
|
|
2601
|
+
state: 'in review',
|
|
2602
|
+
branch: 'lead/st-173',
|
|
2603
|
+
waitingOn: [],
|
|
2604
|
+
},
|
|
2605
|
+
);
|
|
2606
|
+
w.refs += ' s1 refs/heads/web/st-158\n s2 refs/heads/api/st-164\n s3 refs/heads/core/st-172\n';
|
|
2607
|
+
w.next = { step: 'review', args: 'ST-158 ST-172', then: [] };
|
|
2608
|
+
w.sideReply = unread;
|
|
2609
|
+
const clock = await start($, on, w);
|
|
2610
|
+
await leadTurn($);
|
|
2611
|
+
await settle(clock);
|
|
2612
|
+
expect(w.prompts.at(-1)).toBe('Review ST-158, ST-172: run /code-kit:review.');
|
|
2613
|
+
// The lead says neither is due yet; next then names a third story as well.
|
|
2614
|
+
w.sideReply = outcome({
|
|
2615
|
+
outcome: 'waiting',
|
|
2616
|
+
waits: [
|
|
2617
|
+
{ story: 'ST-158', on: [], why: 'it merges with its paired branch' },
|
|
2618
|
+
{ story: 'ST-172', on: [], why: 'its merge checks are re-running' },
|
|
2619
|
+
],
|
|
2620
|
+
});
|
|
2621
|
+
w.next = { step: 'review', args: 'ST-158 ST-172 ST-173', then: [] };
|
|
2622
|
+
await leadSays(
|
|
2623
|
+
$,
|
|
2624
|
+
't-2',
|
|
2625
|
+
'None of these is due: ST-158 merges with ST-164; ST-172 re-runs its checks.',
|
|
2626
|
+
);
|
|
2627
|
+
await settle(clock);
|
|
2628
|
+
// Only the story that isn't waiting is asked for.
|
|
2629
|
+
expect(w.prompts.at(-1)).toBe('Review ST-173: run /code-kit:review ST-173.');
|
|
2630
|
+
const asked = w.prompts.length;
|
|
2631
|
+
// Lanes committing elsewhere don't wake them, and nothing is prompted while both still wait.
|
|
2632
|
+
w.sideReply = unread;
|
|
2633
|
+
w.next = { step: 'review', args: 'ST-158 ST-172', then: [] };
|
|
2634
|
+
w.refs += ' x1 refs/heads/core/st-9\n';
|
|
2635
|
+
await leadTurn($, 't-3');
|
|
2636
|
+
await settle(clock);
|
|
2637
|
+
expect(w.prompts).toHaveLength(asked);
|
|
2638
|
+
// The story building on ST-158 commits: ST-158 wakes, and ST-172 still waits.
|
|
2639
|
+
w.refs = w.refs.replace(' s2 refs/heads/api/st-164', ' s9 refs/heads/api/st-164');
|
|
2640
|
+
await leadTurn($, 't-4');
|
|
2641
|
+
await settle(clock);
|
|
2642
|
+
expect(w.prompts.at(-1)).toBe('Review ST-158: run /code-kit:review ST-158.');
|
|
2643
|
+
expect(w.prompts).toHaveLength(asked + 1);
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2513
2646
|
test('LOOP-11 a turn the person stopped holds the loop, with no dialog, until they speak or press Carry on', async ($, on) => {
|
|
2514
2647
|
const w = project();
|
|
2515
2648
|
w.sideReply = unread;
|
package/hooks/mod/view.mjs
CHANGED
|
@@ -101,6 +101,8 @@ export function projectState(check, status, next = null, atWork = new Set()) {
|
|
|
101
101
|
worktree: s.worktree ?? null,
|
|
102
102
|
requirements: s.requirements ?? [],
|
|
103
103
|
waitingOn: s.waitingOn ?? [],
|
|
104
|
+
// LOOP-13: a parked story watches the stories building on it.
|
|
105
|
+
dependsOn: s.dependsOn ?? [],
|
|
104
106
|
milestone: s.milestone ?? null,
|
|
105
107
|
})),
|
|
106
108
|
};
|
|
@@ -1511,6 +1513,7 @@ const CHOICES = {
|
|
|
1511
1513
|
autonomy: ['autonomous', 'propose', 'off'],
|
|
1512
1514
|
'agents.reviewer.model': ['', 'haiku', 'sonnet', 'opus'],
|
|
1513
1515
|
'agents.lanes.model': ['', 'haiku', 'sonnet', 'opus'],
|
|
1516
|
+
'compact.milestone': ['ask', 'auto', 'off'],
|
|
1514
1517
|
};
|
|
1515
1518
|
const shown = (v) =>
|
|
1516
1519
|
v === null || v === '' ? "the session's" : v === true ? 'on' : v === false ? 'off' : String(v);
|
package/hooks/stop-check.mjs
CHANGED
|
@@ -8,7 +8,8 @@ import { loadBaseline, newProblems } from './lib/baseline.mjs';
|
|
|
8
8
|
import { runChecks } from './lib/checks.mjs';
|
|
9
9
|
import { block, start } from './lib/hook.mjs';
|
|
10
10
|
import { layerProblems } from './lib/layers.mjs';
|
|
11
|
-
import { actorFor, actorRoot } from './lib/rules.mjs';
|
|
11
|
+
import { actorFor, actorRoot, currentBranch, reportPath } from './lib/rules.mjs';
|
|
12
|
+
import { storyOfBranch } from './lib/teamfacts.mjs';
|
|
12
13
|
import { auditProblems, changedFiles } from './lane-audit.mjs';
|
|
13
14
|
|
|
14
15
|
const { input, project, config, error } = start();
|
|
@@ -92,4 +93,13 @@ if (actor.kind === 'lane' && changed.length) {
|
|
|
92
93
|
changed.join('\n'),
|
|
93
94
|
);
|
|
94
95
|
}
|
|
96
|
+
|
|
97
|
+
// LEAN-1: the lead's context gets a short hand-back; the detail waits in a file it reads when it reviews.
|
|
98
|
+
if (actor.kind === 'lane' && storyOfBranch(currentBranch(root))) {
|
|
99
|
+
const report = reportPath(root);
|
|
100
|
+
if (!existsSync(join(root, report)))
|
|
101
|
+
blockCounted(
|
|
102
|
+
`Write your full report to ${report} before you finish: what you did and why, the spec section, the commands you ran with their output, what's left, and anything you need decided (with the choices, and any text you'd have pasted, such as a migration you can't write yet). Then finish with a short hand-back of at most ten lines: the story and branch, done or blocked, any decision the lead must make, and that report's path. Don't paste output or code into the hand-back; the lead reads the file when it reviews.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
95
105
|
passed();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warren-dean/code-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Keeps AI coding agents inside your project's rules: lanes, layer rules, protected paths and proof before finish, enforced by Claude Code hooks and in CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Warren Langeveldt",
|
package/skills/decide/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`.
|
|
|
13
13
|
|
|
14
14
|
`requests --json` lists each open request with its number (`n`), the lane, what it asked for (`names`, `what`), and whose decision it is (`by`). The argument, if given, names the numbers to take. Take every request `by: "lead"`. A request `by: "person"` is theirs: see step 4.
|
|
15
15
|
|
|
16
|
-
For each, read the lane's change request (its
|
|
16
|
+
For each, read the lane's change request (its report, in `.claude/state/reports/<branch>.md` in its worktree), its story in the plan, and the requirements the story cites, in the specs.
|
|
17
17
|
|
|
18
18
|
## 2. Judge it
|
|
19
19
|
|
package/skills/dispatch/SKILL.md
CHANGED
|
@@ -89,4 +89,4 @@ Give them the same brief. Claude Code opened in that worktree then acts as the l
|
|
|
89
89
|
|
|
90
90
|
- Set each dispatched story's `**Status:**` in the plan to `in progress`, and commit it on the lead's branch.
|
|
91
91
|
- Tell the person, in a few lines: what was dispatched, to which lane and branch, what's still blocked and on what, and what's ready next.
|
|
92
|
-
- As each agent finishes, read its report. If it came back with a change request or a question, decide it yourself with `/code-kit:decide`, and bring to the person only what crosses a guardrail. If it came back done, run the review skill on its branch.
|
|
92
|
+
- As each agent finishes, read its hand-back: a few lines saying done or blocked, and the path of its full report (`.claude/state/reports/<branch>.md`). Open the report only when you need its detail: in the review, or to decide what it asks. If it came back with a change request or a question, decide it yourself with `/code-kit:decide`, and bring to the person only what crosses a guardrail. If it came back done, run the review skill on its branch.
|
package/skills/review/SKILL.md
CHANGED
|
@@ -14,6 +14,7 @@ The CLI is `node "${CLAUDE_PLUGIN_ROOT}/bin/code-kit.mjs"`. `<base>` is the bran
|
|
|
14
14
|
- **The branch and its story.** `status --json` lists each story with its branch, its commits ahead of the base, and its worktree, if one is checked out. For a pull request, `gh pr view <n> --json headRefName,baseRefName` gives both.
|
|
15
15
|
- **Where to run things.** Use the branch's worktree if it has one. Otherwise check it out in a throwaway worktree: `git worktree add .claude/worktrees/review-st-<n> <branch>`, and remove it when you're done.
|
|
16
16
|
- **The spec-check report** is in that worktree, in `.claude/state/spec-check/<branch with / as _>.md`. The state folder isn't committed, so a branch pushed from elsewhere may not have one. Say so, and review against the specs directly.
|
|
17
|
+
- **The lane's report** is beside it, in `.claude/state/reports/<branch with / as _>.md`: what it did, the commands it ran with their output, and what's left. Its hand-back to you was a few lines naming this file; read the file here, not before, and only the parts the review needs.
|
|
17
18
|
|
|
18
19
|
## 2. Run the rules
|
|
19
20
|
|
package/templates/agent.md
CHANGED
|
@@ -29,4 +29,6 @@ Always:
|
|
|
29
29
|
- Fixing a bug: first write a test that fails because of the bug, through the code that's already there, then fix it. `verify` runs your test without the fix, and it must fail on an assertion, not because it calls something the fix adds.
|
|
30
30
|
- Never weaken a test to get to green: no deleting, skipping or focusing tests, cutting cases or assertions, or rewriting snapshots. The commit is refused without the person's approval. Fix the code; if the test itself is wrong, say why in a change request for the lead.
|
|
31
31
|
- Commit your work on your branch before finishing; the stop hook runs the checks and requires it.
|
|
32
|
-
-
|
|
32
|
+
- Don't message the lead while you work: it hears from you once, when you finish. If you're blocked on something only the lead or the person can settle, finish and say so.
|
|
33
|
+
- Before you finish, write your full report to `.claude/state/reports/<branch>.md` (the branch with `/` as `_`, as for spec-check): what you did and why, the spec section, the commands you ran with their output, what's left, and anything you need decided, with the choices and any text you'd otherwise paste. The stop hook requires it.
|
|
34
|
+
- Finish with a short hand-back, at most ten lines: the story and branch, done or blocked, any decision the lead must make, and the report's path. No output or code in it: the lead reads the report when it reviews.
|