@warren-dean/code-kit 0.7.0 → 0.8.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 +25 -0
- package/README.md +14 -3
- package/bin/code-kit.mjs +94 -0
- package/hooks/guard-bash.mjs +24 -2
- package/hooks/lib/adapters/context-graph.mjs +18 -19
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/plan.mjs +8 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +469 -34
- package/hooks/mod/register.mjs +196 -2
- package/hooks/mod/register.test.ts +436 -18
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +320 -110
- package/package.json +1 -1
- package/skills/init/SKILL.md +3 -3
|
@@ -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.8.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.8.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,31 @@
|
|
|
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.8.0 (2026-10-11)
|
|
6
|
+
|
|
7
|
+
You decide what's built and the rules; the lead delivers it. Only real decisions reach you, each with the story behind it, and the whole build reads as one story from your request to now.
|
|
8
|
+
|
|
9
|
+
**Who decides what**
|
|
10
|
+
|
|
11
|
+
- **No first merge.** Setting up commits the rules you approved straight onto `main`, so the lead can merge reviewed work from the start. Under autonomy, setup also drafts limits within which the lead merges and approves new packages itself; you adjust them before approving.
|
|
12
|
+
- **Lane work is the lead's.** While the loop runs, a lane's finished work never waits for you: the lead reviews, sends back and merges it. With the loop off, Review and Merge are offered to you as before.
|
|
13
|
+
- **Decisions come with their story.** Each one above the prompt says which story it's for ("for ST-4 Booking form (BOOK-1)"), and **d** opens it as a card in the new Decisions tab: what you asked for, the story and its requirements, what the agent was doing, what's already settled, why the lead couldn't settle it (the rule, and the limit it missed), and what each answer does. Usage and pauses are labelled "note", not "you".
|
|
14
|
+
- **The Journal.** A Journal tab, and `code-kit journal`, tell the build by day: your request and the decisions in your brief, the specification, each story handed out, sent back and merged, each approval and who gave it, each milestone finished, and the loop's steps this session.
|
|
15
|
+
|
|
16
|
+
**Seeing the build**
|
|
17
|
+
|
|
18
|
+
- **The mission band.** Above the prompt, while the build moves: the milestone being built and how far along it is, what the lead is doing, the loop's next step, and each agent at work with its current tool.
|
|
19
|
+
- **The Lanes pane** opens on the loop as a card (its mode, its steps, the next one), keeps each lane on fixed columns, shows a lane's stories as cards, and reads "orchestrating" while the lead works.
|
|
20
|
+
- **The code map.** A story's files in a column per layer with the imports between them; a file fills with a lane's colour while that lane reads or edits it, and says whether its why is written. `code-kit map <story>`.
|
|
21
|
+
- **The trail.** Each requirement drawn to its stories, code and tests, with a missing link in red. `code-kit trail`.
|
|
22
|
+
- Tabs are now Lanes 1, Decisions 2, Journal 3, Code map 4, Trail 5, Queue 6, Usage 7.
|
|
23
|
+
|
|
24
|
+
**Fixes**
|
|
25
|
+
|
|
26
|
+
- Pausing the loop is recorded (`code-kit loop`), so Context Graph's background work pauses with it. A new session starts unpaused.
|
|
27
|
+
- The stall check no longer nudges or restarts agents whose work is done, and until you choose how hands-off the loop is, a restart is offered, not taken.
|
|
28
|
+
- With Context Graph, agents can add the guidance they find without your approval; only its settings stay yours.
|
|
29
|
+
|
|
5
30
|
## 0.7.0 (2026-10-10)
|
|
6
31
|
|
|
7
32
|
The rest of the harness, in the mod (Claude Code 2.1.287 or later), and fixes from building a whole app with it.
|
package/README.md
CHANGED
|
@@ -17,6 +17,12 @@ It also carries the work from idea to merged code: spec an idea into requirement
|
|
|
17
17
|
<img src="https://raw.githubusercontent.com/warrendeanlangeveldt/code-kit/main/assets/workflow.svg" alt="How code-kit works: the specs create the agents, skills and rules; the lead agent delegates stories to lane agents and takes decisions and approvals to you; lanes send scope and dependency requests back to the lead; hooks enforce ownership, layers, approvals and spec-check while each lane implements, tests and commits; the lead reviews against the requirements and CI runs verify." width="820">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
|
+
<p align="center">
|
|
21
|
+
<a href="https://warrendeanlangeveldt.github.io"><img src="https://warrendeanlangeveldt.github.io/assets/media/harness.gif" alt="code-kit and Context Graph in a Claude Code session: the band above the prompt, the lanes, the code map lighting up as agents read and edit, and the module graph" width="900"></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
**See it in action at [warrendeanlangeveldt.github.io](https://warrendeanlangeveldt.github.io)**, with a live replay of a real build.
|
|
25
|
+
|
|
20
26
|
## Quick start
|
|
21
27
|
|
|
22
28
|
You need Claude Code, Node 22 or later, and git. Then, in Claude Code:
|
|
@@ -266,9 +272,9 @@ Editing an agent file can't loosen a rule. And the agent files are kit files (`.
|
|
|
266
272
|
|
|
267
273
|
With Claude Code 2.1.287 or later, code-kit's mod shows the project in the session, in the terminal (VS Code's terminal included) and the Desktop app. Older versions skip it and keep enforcing everything through the hooks.
|
|
268
274
|
|
|
269
|
-
- **The band** sits above the prompt
|
|
275
|
+
- **The band** sits above the prompt. While the build is moving (the lead mid-turn, an agent at work, the loop started) it opens with the mission: **goal**, the milestone being built and how many of its stories are done; **now**, what the lead is doing and the loop's state, with **Pause** or **Resume**; **next**, the step the loop takes next; and **agents**, a line per agent at work with its current tool and how long since. With Context Graph installed, a line says when a lane edited files without understanding them, and only then. Beneath, labelled **you**, what only you can decide, each with the story it's for ("for ST-4 Booking form (BOOK-1)"), and **d** ("Why, and what each choice does") opens it as a card in the Decisions tab; usage and the plan's pause are labelled **note**. While the loop runs, a lane's finished work is the lead's to review and merge, so it never waits here; with the loop off, Review and Merge are offered to you. With nothing happening and nothing waiting, the band is gone. It shows:
|
|
270
276
|
- an agent's call held for you: installing a new package, writing or committing a protected file, or a lane changing the kit. Rather than stopping the agent, the call waits while the band asks ("web wants to install dayjs · 1:53"). **Approve** opens the confirmation and, once you confirm, the same call runs; **Refuse**, or no answer within `harness.hold.minutes` (2 unless set), refuses it as before. Under `autonomy: autonomous`, a call within `approvals.delegate` is approved as delegated without asking. Nothing is held under `claude -p` or the SDK, where nobody can answer;
|
|
271
|
-
- the lead loop, under `harness.autonomy`. `/code-kit:init` asks you how hands-off you want it. Until you've chosen, the loop's first step waits in the band ("code-kit can keep the build moving between your prompts") with **Keep going**, **Ask each time** and **Off**, and your answer is recorded as your setting. Once the lead has finished a turn, the loop keeps the build moving: with the lead idle and nothing typed in the prompt, it asks `code-kit next` and prompts the lead to dispatch the ready stories, review a finished one, or build its own, never the same step twice while nothing has changed. A step the lead was prompted with twice in a row and that is still next goes to you instead, in the band: "Merge lead/st-1: still next after 2 prompts. Ask the lead again?" with **Go**. **Pause** and **Resume** stop and start it. Under `propose`, each step waits in the band for **Go**; under `off`, the loop does nothing. A lane agent with no tool call for `harness.stall.nudgeMinutes` (5) is messaged; at `stall.restartMinutes` (10) the lead is asked to stop it and dispatch its story again, at most `stall.maxRestarts` (2) times, and then the band flags the lane. When every story is done, the band says so and the loop stops. With `harness.agents.reviewer.on`, a finished branch is first reviewed in the background by code-kit's reviewer agent, which the lead starts: it runs `verify` and the review checklist, read-only, and grades each finding blocker, concern or nit. The lead's review prompt then carries the findings, and a blocker means the branch is sent back. The pane shows each review, running with its clock or what it found. If the reviewer hasn't reported within 15 minutes, or the plan's 5-hour use is past `harness.background.pauseAtPercent`, the lead reviews without it. A branch the lead's review passes joins the merge queue (`code-kit queue`). Branches merge one at a time, in the order they passed, each verified against the base as it is then: the lead merges where `approvals.delegate.merge` is on under `autonomous`, otherwise the band shows the head with **Merge** for you. Who may merge is read from the config committed on the base, so a branch can't grant itself merges
|
|
277
|
+
- the lead loop, under `harness.autonomy`. `/code-kit:init` asks you how hands-off you want it. Until you've chosen, the loop's first step waits in the band ("code-kit can keep the build moving between your prompts") with **Keep going**, **Ask each time** and **Off**, and your answer is recorded as your setting. Once the lead has finished a turn, the loop keeps the build moving: with the lead idle and nothing typed in the prompt, it asks `code-kit next` and prompts the lead to dispatch the ready stories, review a finished one, or build its own, never the same step twice while nothing has changed. A step the lead was prompted with twice in a row and that is still next goes to you instead, in the band: "Merge lead/st-1: still next after 2 prompts. Ask the lead again?" with **Go**. **Pause** and **Resume** stop and start it. Under `propose`, each step waits in the band for **Go**; under `off`, the loop does nothing. A lane agent with no tool call for `harness.stall.nudgeMinutes` (5) is messaged, unless its work is done (it handed back, or its branch is in review or the merge queue); at `stall.restartMinutes` (10) the lead is asked to stop it and dispatch its story again, at most `stall.maxRestarts` (2) times, and then the band flags the lane. When every story is done, the band says so and the loop stops. With `harness.agents.reviewer.on`, a finished branch is first reviewed in the background by code-kit's reviewer agent, which the lead starts: it runs `verify` and the review checklist, read-only, and grades each finding blocker, concern or nit. The lead's review prompt then carries the findings, and a blocker means the branch is sent back. The pane shows each review, running with its clock or what it found. If the reviewer hasn't reported within 15 minutes, or the plan's 5-hour use is past `harness.background.pauseAtPercent`, the lead reviews without it. A branch the lead's review passes joins the merge queue (`code-kit queue`). Branches merge one at a time, in the order they passed, each verified against the base as it is then: the lead merges where `approvals.delegate.merge` is on under `autonomous`, otherwise the band shows the head with **Merge** for you. Who may merge is read from the config committed on the base, so a branch can't grant itself merges. There's no first merge for you: setting up commits the rules you approved straight onto the base. A merged lane's worktree is removed when it holds nothing uncommitted. A head that conflicts or fails verify goes back to its lane. The pane lists the queue, and lanes whose branch waits in it read `queued`;
|
|
272
278
|
- approvals agents have asked for: **Approve…** opens a confirmation with a reason prefilled from the request, which you keep or rewrite;
|
|
273
279
|
- stories finished and waiting for review: **Review** asks the lead to run `/code-kit:review` on the branch, and **Merge** runs `verify`, checks included, then merges if it passes;
|
|
274
280
|
- a finish check failing in this session: **Lanes** opens the pane;
|
|
@@ -277,7 +283,7 @@ With Claude Code 2.1.287 or later, code-kit's mod shows the project in the sessi
|
|
|
277
283
|
|
|
278
284
|
Each button has a digit: type it in an empty prompt to press it.
|
|
279
285
|
|
|
280
|
-
- **`/lanes`** opens the Lanes pane. Its first line counts the stories by state (building, in review, queued, sent back, blocked, ready, merged), with the loop's state and the plan's 5-hour use; press a count to show only those lanes. Beneath it, whatever needs you (held calls, approvals, reviews, merges, stalled lanes, outliers) is pinned with its key. Tabs on **1** to **
|
|
286
|
+
- **`/lanes`** opens the Lanes pane. Its first line counts the stories by state (building, in review, queued, sent back, blocked, ready, merged), with the loop's state and the plan's 5-hour use; press a count to show only those lanes. Beneath it, whatever needs you (held calls, approvals, reviews, merges, stalled lanes, outliers) is pinned with its key. Tabs on **1** to **7**: **Lanes** opens on the loop as a card: its mode, the steps it took, the one running and the next. Then each lane on fixed columns: its name, state and story, its agent's current or last tool call with the time since beneath (amber once quiet, red when stalled), and its tokens; the lead reads orchestrating while its turn runs. A selected lane shows its stories as cards (state, requirements, what each waits on or its branch), then its agent and the paths it owns. **Decisions**, each decision that needs you as a card: what you asked for, the story and its requirements, what was happening, what's already settled, why the lead couldn't settle it, and each option with what it does; **Journal**, the build from your request to now in plain sentences, by day: the request and the decisions in the brief, the specification, each story handed out, sent back and merged, each approval and who gave it, each milestone finished, and the loop's steps this session, yours in yellow. Each lane has its own small pixel character, tinted in the lane's colour, whose pose shows its state: working (animated while its agent works), waiting on you, quiet, stalled, done or resting; the lead's wears a crown. Under each lane, its story's timeline on a shared time axis: building, then in review, with send-backs (↩), approvals (◆) and the merge (✓) marked where they fell; **Code map**, a story's files in a column per layer with the imports between them: a file fills with a lane's colour while that lane reads or edits it, and says whether its why (its Context Graph card) is written, out of date or not written yet; **j**/**k** select a file to see its card and what it imports and what imports it, and **o** opens it in Context Graph (box-drawing text in the terminal, an SVG on Desktop); **Trail**, every requirement in the specs as a cell by spec, its glyph and colour saying whether it's done and tested, done but untested, in progress, todo, without a story or removed, with a legend counting each. **h**/**l** and **j**/**k** move between cells and specs (or pick one from the list), showing the requirement's stories, their branches, the code they changed and the tests that name it; beneath the cells, the spec's trail drawn as a flow, each requirement to its stories, code and tests, with a missing link dashed in red; **Queue**, the merge queue; and **Usage**. **j** and **k** move between lanes, and the selected lane's acts are on **a** (approve), **r** (review), **s** (send back, with your reason), **m** (merge), **n** (nudge its agent) and **x** (stop it); **p** pauses or resumes the loop, and **f** filters the lanes by name, agent or story. **Enter** (or **o**) opens the selected lane's story: its requirements beside its spec-check report, the reviewer's findings by severity (each a way into the diff), `verify`'s problems, its usage and loop steps, and its diff against the base, file by file, with the acts its state allows. **b** or Escape goes back to the lanes. Inline (when the terminal can't dock it beside the transcript), it shows the counts, what needs you and a line per lane. It stays current within 2 seconds of a commit, a branch change, a plan edit or an agent starting or stopping. `/lanes` again, or Escape, closes it.
|
|
281
287
|
- **Refusals** a code-kit hook gives an agent are drawn as cards: what was refused, the rule, what to do, and Approve… where your approval would allow it. The full text is a press away.
|
|
282
288
|
- **`/harness`** opens the harness settings: how the lead loop acts, how long a call waiting on your approval is held, when a stalled lane is nudged and restarted, the reviewer agent and its model, and the use at which background agents pause. Changing one asks your reason, writes `.claude/code-kit.json` and logs a `kit` approval. Lanes, paths and rules still change through `/code-kit:init`.
|
|
283
289
|
- **`/approvals`** lists the requests waiting and the approvals in force, with minutes left. **`/verify-branch`** runs `code-kit verify` on this branch. Neither calls the model.
|
|
@@ -341,8 +347,13 @@ code-kit settings set <key> <value> --reason "…" # change one, a
|
|
|
341
347
|
code-kit queue [--json] # the merge queue: branches whose review passed, in order
|
|
342
348
|
code-kit queue add|drop <branch> # the lead queues a branch its review passed, or takes it out
|
|
343
349
|
code-kit queue merge --delegated|--person # merge the head, verified against the base as it is now; a conflict or failed verify sends it back to its lane
|
|
350
|
+
code-kit loop [--json] # whether the person paused the lead loop; other tools' background work pauses with it
|
|
351
|
+
code-kit loop pause|resume # the person's act, as Pause and Resume in the band
|
|
344
352
|
code-kit story <id> [--json] # one story's requirements, spec-check report, diff against the base and verify (without checks), for the drill-down
|
|
345
353
|
code-kit timeline [--json] # when each story's work happened: its branch made, last commit, merge, send-backs and approvals
|
|
354
|
+
code-kit journal [--json] # the build's lineage: the request, its decisions, the spec, each story handed out, sent back and merged, each approval and who gave it
|
|
355
|
+
code-kit map <story> [--json] # a story's code: its files by layer, the imports between them, and what adapters know of each
|
|
356
|
+
code-kit trail [--json] # each requirement to its stories, the code they changed and the tests that name it
|
|
346
357
|
code-kit adapters [--json --session id] # the adapters in use; with --json, what they know of each lane's work in a session
|
|
347
358
|
code-kit hold <id> [--minutes N] # used by the mod: a held call waits here for the band's answer (--answer approved|refused)
|
|
348
359
|
code-kit approve <name>... --reason "…" [--lane name] [--delegated | --via pane|<tool>] # record a person's approval from chat, or (--delegated) grant one within the delegated rules; a package name stands for its dep- approval
|
package/bin/code-kit.mjs
CHANGED
|
@@ -53,8 +53,11 @@ import { traceFile } from '../hooks/lib/trace.mjs';
|
|
|
53
53
|
import { buildStatus, specCheckRows } from '../hooks/lib/plan.mjs';
|
|
54
54
|
import { recordSentBack } from '../hooks/lib/reviews.mjs';
|
|
55
55
|
import { storyTimes } from '../hooks/lib/timeline.mjs';
|
|
56
|
+
import { storyMap, trail } from '../hooks/lib/storymap.mjs';
|
|
57
|
+
import { journal } from '../hooks/lib/journal.mjs';
|
|
56
58
|
import { dequeue, enqueue, lastMerged, readQueue, settle, waiting } from '../hooks/lib/queue.mjs';
|
|
57
59
|
import { SETTINGS, parseSetting, settingOf, withSetting } from '../hooks/lib/harness.mjs';
|
|
60
|
+
import { readLoop, writeLoop } from '../hooks/lib/loop-state.mjs';
|
|
58
61
|
import {
|
|
59
62
|
APPROVAL_MINUTES,
|
|
60
63
|
approvalLifetime,
|
|
@@ -568,6 +571,69 @@ function queue() {
|
|
|
568
571
|
left.forEach((e, i) => out(` ${i + 1}. ${e.branch} (passed review ${e.passed})`));
|
|
569
572
|
}
|
|
570
573
|
|
|
574
|
+
/**
|
|
575
|
+
* MAP-1: a story's code: the files it changed by layer, with the imports between them, and what the
|
|
576
|
+
* active adapters know of each (its card, its rules). `map <id> --json` is what the pane draws.
|
|
577
|
+
*/
|
|
578
|
+
function mapCommand(id) {
|
|
579
|
+
const { config, raw } = load();
|
|
580
|
+
const { specs, plan } = config.docs ?? {};
|
|
581
|
+
if (!specs || !plan)
|
|
582
|
+
die('map needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
583
|
+
const from = base(config, false);
|
|
584
|
+
const { stories } = buildStatus('.', config, from, tracked());
|
|
585
|
+
const s = stories.find((x) => x.id === id.toUpperCase());
|
|
586
|
+
if (!s) die(`There is no story ${id} in ${plan}.`);
|
|
587
|
+
const map = storyMap(resolve('.'), s, config, from);
|
|
588
|
+
const facts = adapterFacts(raw, resolve('.'), { paths: map.files.map((f) => f.path) });
|
|
589
|
+
for (const f of map.files)
|
|
590
|
+
f.adapters = Object.fromEntries(
|
|
591
|
+
facts.filter((a) => a.files?.[f.path]).map((a) => [a.name, a.files[f.path]]),
|
|
592
|
+
);
|
|
593
|
+
map.open = facts.find((a) => a.open)?.open ?? null;
|
|
594
|
+
if (flag('--json')) return out(JSON.stringify(map, null, 2));
|
|
595
|
+
out(`${s.id} ${s.title} (${map.files.length} file(s)):`);
|
|
596
|
+
for (const layer of map.layers) {
|
|
597
|
+
out(` ${layer}`);
|
|
598
|
+
for (const f of map.files.filter((x) => x.layer === layer))
|
|
599
|
+
out(` ${f.path}${f.imports.length ? ` → ${f.imports.join(', ')}` : ''}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** JOURNAL-1: the build's lineage, from the request to each merge, in plain sentences. */
|
|
604
|
+
function journalCommand() {
|
|
605
|
+
const { config } = load();
|
|
606
|
+
const { specs, plan } = config.docs ?? {};
|
|
607
|
+
if (!specs || !plan)
|
|
608
|
+
die('journal needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
609
|
+
const from = base(config, false);
|
|
610
|
+
const events = journal(
|
|
611
|
+
resolve('.'),
|
|
612
|
+
buildStatus('.', config, from, tracked()),
|
|
613
|
+
from,
|
|
614
|
+
config.docs,
|
|
615
|
+
);
|
|
616
|
+
if (flag('--json')) return out(JSON.stringify({ base: from, events }, null, 2));
|
|
617
|
+
for (const e of events)
|
|
618
|
+
out(`${new Date(e.at).toISOString().slice(0, 16).replace('T', ' ')} ${e.text}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** TRAIL-1: each requirement to its stories, the code they changed and the tests that name it. */
|
|
622
|
+
function trailCommand() {
|
|
623
|
+
const { config } = load();
|
|
624
|
+
const { specs, plan } = config.docs ?? {};
|
|
625
|
+
if (!specs || !plan)
|
|
626
|
+
die('trail needs docs.specs and docs.plan in the config. The spec-design skill writes both.');
|
|
627
|
+
const from = base(config, false);
|
|
628
|
+
const status = buildStatus('.', config, from, tracked());
|
|
629
|
+
const rows = trail(resolve('.'), status, from);
|
|
630
|
+
if (flag('--json')) return out(JSON.stringify({ base: from, requirements: rows }, null, 2));
|
|
631
|
+
for (const r of rows)
|
|
632
|
+
out(
|
|
633
|
+
`${pad(r.id, 10)} ${pad(r.stories.map((s) => s.id).join(', ') || '-', 14)} ${pad(`${r.files.length} file(s)`, 10)} ${r.tests.length ? r.tests.join(', ') : 'no test names it'}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
571
637
|
/** When each story's work happened, for the mod's timelines (VIEW-2). */
|
|
572
638
|
function timeline() {
|
|
573
639
|
const { config } = load();
|
|
@@ -1069,8 +1135,31 @@ async function approve(given) {
|
|
|
1069
1135
|
);
|
|
1070
1136
|
}
|
|
1071
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* The lead loop's pause (LOOP-6): `loop` says whether the person paused it; `loop pause|resume`, the
|
|
1140
|
+
* person's act from the code-kit band, records it, so other tools' background work pauses with it.
|
|
1141
|
+
*/
|
|
1142
|
+
function loopCommand() {
|
|
1143
|
+
const root = resolve('.');
|
|
1144
|
+
const [sub] = rest;
|
|
1145
|
+
if (sub === 'pause' || sub === 'resume') {
|
|
1146
|
+
writeLoop(root, sub === 'pause');
|
|
1147
|
+
return out(sub === 'pause' ? 'The lead loop is paused.' : 'The lead loop is running.');
|
|
1148
|
+
}
|
|
1149
|
+
if (sub !== undefined) die('code-kit loop [--json] | loop pause | loop resume');
|
|
1150
|
+
const state = readLoop(root);
|
|
1151
|
+
out(
|
|
1152
|
+
flag('--json')
|
|
1153
|
+
? JSON.stringify(state)
|
|
1154
|
+
: state.paused
|
|
1155
|
+
? `The lead loop is paused (since ${state.at}).`
|
|
1156
|
+
: 'The lead loop is running.',
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1072
1160
|
const commands = {
|
|
1073
1161
|
check,
|
|
1162
|
+
loop: loopCommand,
|
|
1074
1163
|
approve: () => (rest.length ? approve(rest) : usage()),
|
|
1075
1164
|
merge: () => (rest.length === 1 ? merge(rest[0]) : usage()),
|
|
1076
1165
|
requests,
|
|
@@ -1079,6 +1168,9 @@ const commands = {
|
|
|
1079
1168
|
hold: () => (rest.length === 1 ? hold(rest[0]) : usage()),
|
|
1080
1169
|
queue,
|
|
1081
1170
|
story: () => (rest.length === 1 ? story(rest[0]) : usage()),
|
|
1171
|
+
map: () => (rest.length === 1 ? mapCommand(rest[0]) : usage()),
|
|
1172
|
+
trail: trailCommand,
|
|
1173
|
+
journal: journalCommand,
|
|
1082
1174
|
timeline,
|
|
1083
1175
|
'sent-back': () => (rest.length === 1 ? sentBackCommand(rest[0]) : usage()),
|
|
1084
1176
|
trace: () => (rest.length ? trace(rest) : usage()),
|
|
@@ -1103,6 +1195,8 @@ function usage() {
|
|
|
1103
1195
|
' | settings [--json] | settings set <key> <value> --reason "…" [--via pane]\n' +
|
|
1104
1196
|
' | hold <id> [--minutes N] | hold <id> --answer approved|refused\n' +
|
|
1105
1197
|
' | queue [--json] | queue add|drop <branch> | queue merge --delegated|--person\n' +
|
|
1198
|
+
' | loop [--json] | loop pause|resume\n' +
|
|
1199
|
+
' | map <story> [--json] | trail [--json] | journal [--json]\n' +
|
|
1106
1200
|
' | story <id> [--json] | timeline [--json]',
|
|
1107
1201
|
);
|
|
1108
1202
|
}
|
package/hooks/guard-bash.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { join, resolve } from 'node:path';
|
|
|
11
11
|
import { addedPackages, dependencyApproval, isDependencyFile } from './lib/dependencies.mjs';
|
|
12
12
|
import { CODE_KIT, GIT, codeOf, commandsOf, runs } from './lib/shell.mjs';
|
|
13
13
|
import { block, start } from './lib/hook.mjs';
|
|
14
|
+
import { CONFIG_FILE } from './lib/config.mjs';
|
|
14
15
|
import { noteBefore } from './lane-audit.mjs';
|
|
15
16
|
import {
|
|
16
17
|
APPROVAL_LOG,
|
|
@@ -89,6 +90,14 @@ const branches = config.branches.protected
|
|
|
89
90
|
// The branch is read before the command runs, so a command that switches to a protected branch and then
|
|
90
91
|
// commits or merges in the same breath is judged by what it does, not by where it starts. Moving a
|
|
91
92
|
// protected branch directly (branch -f, update-ref) is a commit on it by another name.
|
|
93
|
+
/** Whether `file` is committed at HEAD in the repository at `root`. */
|
|
94
|
+
function committedOnHead(root, file) {
|
|
95
|
+
return (
|
|
96
|
+
spawnSync('git', ['-C', root, 'cat-file', '-e', `HEAD:${file}`], { stdio: 'ignore' }).status ===
|
|
97
|
+
0
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
const writesHistory = runs(
|
|
93
102
|
cmd,
|
|
94
103
|
new RegExp(String.raw`^${GIT}(commit|merge|cherry-pick|rebase|revert|am)\b`),
|
|
@@ -151,6 +160,11 @@ if ((delegatedMerge && /\s--person\b/.test(cmd)) || (approves && /\s--via\b/.tes
|
|
|
151
160
|
block(
|
|
152
161
|
`Blocked: merging or approving as the person is the person's own act, from the code-kit pane or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
153
162
|
);
|
|
163
|
+
// Pausing and resuming the lead loop is the person's, from the band or their terminal.
|
|
164
|
+
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+loop\s+(pause|resume)\b`)))
|
|
165
|
+
block(
|
|
166
|
+
`Blocked: pausing or resuming the lead loop is the person's own act, from the code-kit band or their terminal. Ask them, and stop.\nCommand: ${cmd}`,
|
|
167
|
+
);
|
|
154
168
|
// The harness settings are the person's to change, from the pane or their terminal.
|
|
155
169
|
if (runs(cmd, new RegExp(String.raw`^${CODE_KIT}\s+settings\s+set\b`)))
|
|
156
170
|
block(
|
|
@@ -233,9 +247,17 @@ try {
|
|
|
233
247
|
} catch {
|
|
234
248
|
// not a git repository yet
|
|
235
249
|
}
|
|
236
|
-
|
|
250
|
+
// SETUP-1: the one exception is setting up. Until the protected branch has the rules committed, the
|
|
251
|
+
// lead commits the setup the person approved straight onto it, so no first merge needs anyone; from
|
|
252
|
+
// that commit on, the branch is protected by the rules it now holds.
|
|
253
|
+
const settingUp =
|
|
254
|
+
actor.kind === 'lead' &&
|
|
255
|
+
runs(cmd, new RegExp(String.raw`^${GIT}commit\b`)) &&
|
|
256
|
+
!runs(cmd, new RegExp(String.raw`^${GIT}(merge|cherry-pick|rebase|revert|am)\b`)) &&
|
|
257
|
+
!committedOnHead(root, CONFIG_FILE);
|
|
258
|
+
if (writesHistory && config.branches.protected.includes(branch) && !settingUp) {
|
|
237
259
|
block(
|
|
238
|
-
`Blocked: never commit or merge on ${branch}. Work on a branch;
|
|
260
|
+
`Blocked: never commit or merge on ${branch}. Work on a branch; the lead merges it after review (or the person, where merges aren't delegated).`,
|
|
239
261
|
);
|
|
240
262
|
}
|
|
241
263
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Context Graph (ctx) keeps a project's engineering context in `.ctx/`:
|
|
2
|
-
//
|
|
3
|
-
// each file's card (its why) in cards.ctx.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// lead act on what the person says in chat (`approvals.lead`). A
|
|
8
|
-
// `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under
|
|
9
|
-
// rules: only the lead may write that trailer
|
|
1
|
+
// Context Graph (ctx) keeps a project's engineering context in `.ctx/`: rules and concepts in graph.ctx
|
|
2
|
+
// (proposals in proposals.ctx), settings in config.toml, the decisions agents record as they work in
|
|
3
|
+
// decisions.ctx, and each file's card (its why) in cards.ctx. It runs in the background, so every agent
|
|
4
|
+
// writes its cards, its decisions and the guidance it finds; the settings are the lead's, and a change
|
|
5
|
+
// to them goes through the approval log. A rule that blocks edits, or a concept, needs a person's
|
|
6
|
+
// signature, a commit trailer, and Context Graph's own gate checks it, so no Claude actor may write that
|
|
7
|
+
// trailer, unless the project lets the lead act on what the person says in chat (`approvals.lead`). A
|
|
8
|
+
// delegated ratification, signed `Ctx-Ratified-By: <ratifier> (delegated)`, is the lead's own act under
|
|
9
|
+
// the repository's [delegate] rules: only the lead may write that trailer.
|
|
10
10
|
// Ratifying with `--commit`, dropping a proposal and changing the harness settings are the person's acts
|
|
11
11
|
// in ctx too, so no actor runs
|
|
12
12
|
// them; only a command the line runs counts, so a heredoc or quoted text that mentions one isn't refused.
|
|
@@ -41,16 +41,12 @@ export default {
|
|
|
41
41
|
detect: (root) => existsSync(join(root, '.ctx')),
|
|
42
42
|
config: {
|
|
43
43
|
lead: ['.ctx/**'],
|
|
44
|
-
|
|
44
|
+
// Context Graph runs in the background: every agent writes its cards and decisions, and the guidance
|
|
45
|
+
// rules it finds (which inform and never block). A rule that blocks edits, or a concept, still needs
|
|
46
|
+
// a person's signed commit, and Context Graph's own gate checks that on every pull request.
|
|
47
|
+
anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx', '.ctx/graph.ctx', '.ctx/proposals.ctx'],
|
|
45
48
|
protected: [
|
|
46
|
-
|
|
47
|
-
glob: '.ctx/graph.ctx',
|
|
48
|
-
approval: 'ctx',
|
|
49
|
-
why: "Context Graph's rules and concepts",
|
|
50
|
-
// A person's ratification (`ctx ratify --commit`, `ctx drop --commit`) is signed, not logged.
|
|
51
|
-
signedBy: PERSON_SIGNED,
|
|
52
|
-
},
|
|
53
|
-
// Setting Context Graph up is ratified the same way: the person commits it from their terminal.
|
|
49
|
+
// Its settings are the person's: a change is logged, or signed when they commit it themselves.
|
|
54
50
|
{
|
|
55
51
|
glob: '.ctx/config.toml',
|
|
56
52
|
approval: 'ctx',
|
|
@@ -100,7 +96,7 @@ export default {
|
|
|
100
96
|
}
|
|
101
97
|
return byType;
|
|
102
98
|
},
|
|
103
|
-
/** Per file: the rules that apply to it, agreed and proposed. */
|
|
99
|
+
/** Per file: the rules that apply to it, agreed and proposed, and its card: current, stale or missing, with its text. */
|
|
104
100
|
files: (root, paths) =>
|
|
105
101
|
Object.fromEntries(
|
|
106
102
|
paths.map((path) => {
|
|
@@ -108,6 +104,9 @@ export default {
|
|
|
108
104
|
return [
|
|
109
105
|
path,
|
|
110
106
|
{
|
|
107
|
+
card: f?.card
|
|
108
|
+
? { state: f.card.fresh === false ? 'stale' : 'current', text: f.card.text ?? '' }
|
|
109
|
+
: { state: 'missing', text: '' },
|
|
111
110
|
rules: (f?.rules ?? []).map((r) => ({
|
|
112
111
|
id: r.id,
|
|
113
112
|
text: r.text,
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The journal (docs/specs/15-decisions-and-journal.md, JOURNAL-1): the build's lineage in plain
|
|
2
|
+
// sentences, oldest first: what the person asked for, the decisions that shaped it, the specification,
|
|
3
|
+
// the rules being switched on, each story handed out, sent back and merged, each approval and who gave
|
|
4
|
+
// it, and each milestone finished. Read from git and the project's own records, so it's the same for
|
|
5
|
+
// every session and survives a restart.
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { APPROVAL_LOG } from './rules.mjs';
|
|
10
|
+
import { sendBacks } from './reviews.mjs';
|
|
11
|
+
import { storyTimes } from './timeline.mjs';
|
|
12
|
+
|
|
13
|
+
const gitIn =
|
|
14
|
+
(root) =>
|
|
15
|
+
(...args) => {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
20
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** When a file was first committed, in ms, or null. */
|
|
28
|
+
const addedAt = (git, file) => {
|
|
29
|
+
const t = git('log', '--diff-filter=A', '--format=%ct', '--', file)
|
|
30
|
+
.split('\n')
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.at(-1);
|
|
33
|
+
return t ? Number(t) * 1000 : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The brief's title and its problem, in a sentence or two: what the person asked for. */
|
|
37
|
+
export function briefRequest(text) {
|
|
38
|
+
const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
39
|
+
const section = (name) => {
|
|
40
|
+
const m = text.match(
|
|
41
|
+
new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, 'm'),
|
|
42
|
+
);
|
|
43
|
+
return m ? m[1].trim() : '';
|
|
44
|
+
};
|
|
45
|
+
const firstParagraph = (s) =>
|
|
46
|
+
s
|
|
47
|
+
.split(/\n\s*\n/)[0]
|
|
48
|
+
.replace(/\s+/g, ' ')
|
|
49
|
+
.trim();
|
|
50
|
+
const problem = firstParagraph(section('Problem')) || firstParagraph(section('Outcome'));
|
|
51
|
+
return { title, problem: problem || null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The brief's decision log: [{ date, decision, why }], from its `## Decision log` table. */
|
|
55
|
+
export function briefDecisions(text) {
|
|
56
|
+
const m = text.match(/^##\s+Decision log\s*\n([\s\S]*?)(?=^##\s|$(?![\s\S]))/m);
|
|
57
|
+
if (!m) return [];
|
|
58
|
+
return m[1]
|
|
59
|
+
.split('\n')
|
|
60
|
+
.filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l) && !/^\|\s*Date\s*\|/i.test(l))
|
|
61
|
+
.map((l) => l.split('|').map((c) => c.trim()))
|
|
62
|
+
.map((c) => ({ date: c[1], decision: c[2], why: c[3] ?? '' }))
|
|
63
|
+
.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d.date) && d.decision);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Who gave an approval, from the reason its log entry carries: the person, or the lead within limits. */
|
|
67
|
+
export function approverOf(entry) {
|
|
68
|
+
const reason = String(entry.reason ?? '');
|
|
69
|
+
if (/approved by the lead within the delegated rules/.test(reason)) return 'lead';
|
|
70
|
+
if (entry.approval === 'lead') return 'lead';
|
|
71
|
+
return 'person';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The words an approval's name stands for: "dep-pdfjs-dist" → "adding the package pdfjs-dist". */
|
|
75
|
+
export function approvalWords(name) {
|
|
76
|
+
if (name.startsWith('dep-')) return `adding the package ${name.slice(4)}`;
|
|
77
|
+
if (name === 'dependency') return 'adding a new package';
|
|
78
|
+
if (name === 'kit') return "a change to code-kit's own rules or agents";
|
|
79
|
+
if (name === 'merge') return 'a merge';
|
|
80
|
+
return `a change to the protected ${name.replace(/-/g, ' ')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The person's own words, without the note the hooks add about how it was given. */
|
|
84
|
+
const ownWords = (reason) =>
|
|
85
|
+
String(reason ?? '')
|
|
86
|
+
.replace(
|
|
87
|
+
/\s*\((given in chat, recorded by the lead|approved in the [\w ]+ pane|approved by the lead within the delegated rules[^)]*)\)\s*$/,
|
|
88
|
+
'',
|
|
89
|
+
)
|
|
90
|
+
.trim();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* JOURNAL-1: the events, oldest first: [{ at, kind, text, story? }]. Kinds: request, decision, spec,
|
|
94
|
+
* setup, dispatch, sentBack, merge, approval, milestone. `status` is buildStatus's; `base` the branch
|
|
95
|
+
* stories merge into; `docs` the config's docs (brief beside the specs).
|
|
96
|
+
*/
|
|
97
|
+
export function journal(root, status, base, docs = {}) {
|
|
98
|
+
const git = gitIn(root);
|
|
99
|
+
const events = [];
|
|
100
|
+
const at = (iso) => (iso ? Date.parse(`${iso}T12:00:00Z`) : null);
|
|
101
|
+
// What the person asked for, and the decisions the brief records.
|
|
102
|
+
const briefPath = ['docs/brief.md', docs.specs ? join(docs.specs, '..', 'brief.md') : null]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.find((p) => existsSync(join(root, p)));
|
|
105
|
+
if (briefPath) {
|
|
106
|
+
const text = readFileSync(join(root, briefPath), 'utf8');
|
|
107
|
+
const { title, problem } = briefRequest(text);
|
|
108
|
+
const when = addedAt(git, briefPath);
|
|
109
|
+
if (title || problem)
|
|
110
|
+
events.push({
|
|
111
|
+
at: when,
|
|
112
|
+
kind: 'request',
|
|
113
|
+
text: `You asked for ${title ? `“${title}”` : 'a build'}${problem ? `: ${problem}` : ''}`,
|
|
114
|
+
});
|
|
115
|
+
// The decisions were made while the brief was written: they follow the request, in the log's order.
|
|
116
|
+
briefDecisions(text).forEach((d, n) =>
|
|
117
|
+
events.push({
|
|
118
|
+
at: (when ?? at(d.date)) + n + 1,
|
|
119
|
+
kind: 'decision',
|
|
120
|
+
text: `Decided: ${d.decision}${d.why ? `, because ${d.why.replace(/\.$/, '').replace(/^./, (c) => c.toLowerCase())}` : ''}.`,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// The specification and the plan.
|
|
125
|
+
const reqs = status.requirements.filter((r) => !r.removed).length;
|
|
126
|
+
const milestones = [...new Set(status.stories.map((s) => s.milestone).filter(Boolean))];
|
|
127
|
+
if (docs.plan && status.stories.length)
|
|
128
|
+
events.push({
|
|
129
|
+
at: addedAt(git, docs.plan),
|
|
130
|
+
kind: 'spec',
|
|
131
|
+
text: `The specification was written: ${reqs} requirement${reqs === 1 ? '' : 's'}, split into ${status.stories.length} stor${status.stories.length === 1 ? 'y' : 'ies'}${milestones.length ? ` across ${milestones.length} milestone${milestones.length === 1 ? '' : 's'}` : ''}.`,
|
|
132
|
+
});
|
|
133
|
+
const switchedOn = addedAt(git, '.claude/code-kit.json');
|
|
134
|
+
if (switchedOn)
|
|
135
|
+
events.push({
|
|
136
|
+
at: switchedOn,
|
|
137
|
+
kind: 'setup',
|
|
138
|
+
text: "code-kit's rules were switched on, as you approved them.",
|
|
139
|
+
});
|
|
140
|
+
// Each story: handed out, sent back, merged.
|
|
141
|
+
const times = storyTimes(root, status.stories, base);
|
|
142
|
+
const byId = new Map(status.stories.map((s) => [s.id, s]));
|
|
143
|
+
const backs = sendBacks(root);
|
|
144
|
+
for (const t of times) {
|
|
145
|
+
const s = byId.get(t.id);
|
|
146
|
+
const name = `${t.id} ${s?.title ?? ''}`.trim();
|
|
147
|
+
const reqText = s?.requirements?.length ? ` (${s.requirements.join(', ')})` : '';
|
|
148
|
+
if (t.started)
|
|
149
|
+
events.push({
|
|
150
|
+
at: t.started,
|
|
151
|
+
kind: 'dispatch',
|
|
152
|
+
story: t.id,
|
|
153
|
+
text:
|
|
154
|
+
t.lane === 'lead'
|
|
155
|
+
? `The lead started ${name}${reqText} itself.`
|
|
156
|
+
: `${name}${reqText} was handed to the ${t.lane} lane.`,
|
|
157
|
+
});
|
|
158
|
+
for (const b of backs.filter((x) => x.branch === t.branch))
|
|
159
|
+
events.push({
|
|
160
|
+
at: Date.parse(b.at),
|
|
161
|
+
kind: 'sentBack',
|
|
162
|
+
story: t.id,
|
|
163
|
+
text:
|
|
164
|
+
t.lane === 'lead'
|
|
165
|
+
? `${t.id} needed more work from the lead${b.reason ? `: ${b.reason}` : '.'}`
|
|
166
|
+
: `The lead sent ${t.id} back to the ${t.lane} lane${b.reason ? `: ${b.reason}` : '.'}`,
|
|
167
|
+
});
|
|
168
|
+
if (t.merged)
|
|
169
|
+
events.push({
|
|
170
|
+
at: t.merged,
|
|
171
|
+
kind: 'merge',
|
|
172
|
+
story: t.id,
|
|
173
|
+
text: `${name} was merged into ${base}.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Approvals on the base, once each (one approval can log several files), and who gave them.
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const line of git('show', `${base}:${APPROVAL_LOG}`).split('\n').filter(Boolean)) {
|
|
179
|
+
let e;
|
|
180
|
+
try {
|
|
181
|
+
e = JSON.parse(line);
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!e.approval || e.approval === 'lead') continue;
|
|
186
|
+
const key = `${e.approval}|${e.grantedAt ?? e.at}`;
|
|
187
|
+
if (seen.has(key)) continue;
|
|
188
|
+
seen.add(key);
|
|
189
|
+
const who = approverOf(e);
|
|
190
|
+
const words = ownWords(e.reason);
|
|
191
|
+
events.push({
|
|
192
|
+
at: Date.parse(e.grantedAt ?? e.at),
|
|
193
|
+
kind: 'approval',
|
|
194
|
+
who,
|
|
195
|
+
text:
|
|
196
|
+
who === 'lead'
|
|
197
|
+
? `The lead approved ${approvalWords(e.approval)}, within the limits you set${words ? `: ${words}` : '.'}`
|
|
198
|
+
: `You approved ${approvalWords(e.approval)}${words ? `: “${words}”` : '.'}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Milestones finished: when the last of their stories merged.
|
|
202
|
+
for (const m of milestones) {
|
|
203
|
+
const mine = status.stories.filter((s) => s.milestone === m);
|
|
204
|
+
if (!mine.every((s) => s.state === 'done')) continue;
|
|
205
|
+
const last = Math.max(...mine.map((s) => times.find((t) => t.id === s.id)?.merged ?? 0));
|
|
206
|
+
if (last)
|
|
207
|
+
events.push({
|
|
208
|
+
at: last + 1,
|
|
209
|
+
kind: 'milestone',
|
|
210
|
+
text: `${m} was finished: all ${mine.length} of its stories merged.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// The request, its decisions, the spec and the switch-on come first, whatever the commits' times: work
|
|
214
|
+
// can start on a branch before the brief that asked for it is committed.
|
|
215
|
+
const ORDER = ['request', 'decision', 'spec', 'setup'];
|
|
216
|
+
const opening = events.filter((e) => ORDER.includes(e.kind));
|
|
217
|
+
const rest = events.filter((e) => !ORDER.includes(e.kind) && e.at);
|
|
218
|
+
const firstWork = rest.length ? Math.min(...rest.map((e) => e.at)) : Infinity;
|
|
219
|
+
const opened = opening
|
|
220
|
+
.filter((e) => e.at)
|
|
221
|
+
.sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind) || a.at - b.at)
|
|
222
|
+
.map((e, n, all) => ({ ...e, at: Math.min(e.at, firstWork - (all.length - n)) }));
|
|
223
|
+
return [...opened, ...rest.sort((a, b) => a.at - b.at)];
|
|
224
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Whether the person has paused the lead loop (spec 05, LOOP-6), kept beside the merge queue so other
|
|
2
|
+
// tools sharing the project can hold their own background work while it's paused. The mod records it
|
|
3
|
+
// through the CLI; a session that starts begins unpaused.
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { projectWorktrees } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const LOOP_FILE = '.claude/state/loop.json';
|
|
9
|
+
|
|
10
|
+
const loopFile = (root) => join(projectWorktrees(root)[0] ?? root, LOOP_FILE);
|
|
11
|
+
|
|
12
|
+
/** { paused, at }: not paused when nothing was recorded. */
|
|
13
|
+
export function readLoop(root) {
|
|
14
|
+
try {
|
|
15
|
+
const state = JSON.parse(readFileSync(loopFile(root), 'utf8'));
|
|
16
|
+
return { paused: state?.paused === true, at: state?.at ?? null };
|
|
17
|
+
} catch {
|
|
18
|
+
return { paused: false, at: null };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeLoop(root, paused, now = Date.now()) {
|
|
23
|
+
const file = loopFile(root);
|
|
24
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
25
|
+
writeFileSync(file, `${JSON.stringify({ paused, at: new Date(now).toISOString() })}\n`);
|
|
26
|
+
}
|