@naswerks/engineering-loop 0.7.1 → 0.9.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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +199 -0
  4. package/README.md +12 -7
  5. package/bin/verify-staged.mjs +136 -23
  6. package/bin/witness-acts.mjs +64 -0
  7. package/bin/witness-bodycheck.mjs +78 -0
  8. package/bin/witness-edits.mjs +60 -0
  9. package/bin/witness-extract-cmd.mjs +57 -0
  10. package/bin/witness-extract-seed.mjs +65 -0
  11. package/bin/witness-file-ledger.mjs +57 -0
  12. package/bin/witness-gaps.mjs +57 -0
  13. package/bin/witness-latency.mjs +55 -0
  14. package/bin/witness-lib.mjs +124 -0
  15. package/bin/witness-plane-window.mjs +57 -0
  16. package/bin/witness-procs.mjs +71 -0
  17. package/bin/witness-spawn-task.mjs +61 -0
  18. package/bin/witness-tx.mjs +60 -0
  19. package/bin/witness-watch.mjs +405 -0
  20. package/bin/witness-writes.mjs +64 -0
  21. package/package.json +1 -1
  22. package/references/docs-workflow.md +44 -24
  23. package/references/engineering-loop.md +42 -24
  24. package/skills/docs-audit-full/SKILL.md +2 -1
  25. package/skills/docs-process/SKILL.md +13 -7
  26. package/skills/docs-write/SKILL.md +1 -1
  27. package/skills/init/scripts/doctor.mjs +43 -5
  28. package/skills/init/scripts/owned.mjs +10 -0
  29. package/skills/spec-build/SKILL.md +12 -4
  30. package/skills/spec-coordinator/SKILL.md +149 -80
  31. package/skills/spec-ignite/SKILL.md +25 -21
  32. package/skills/spec-pipeline/SKILL.md +151 -82
  33. package/skills/spec-retro/SKILL.md +14 -9
  34. package/skills/spec-review/SKILL.md +32 -14
  35. package/skills/spec-seat/SKILL.md +107 -48
  36. package/skills/spec-witness/SKILL.md +267 -212
  37. package/templates/docs/readme-research.md +2 -2
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "naswerks",
12
12
  "description": "The engineering loop: docs-* knowledge loop, spec-* execution loop, and init for a fresh repository.",
13
- "version": "0.7.1",
13
+ "version": "0.9.0",
14
14
  "source": "./"
15
15
  }
16
16
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naswerks",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "The engineering loop: a knowledge loop (docs-*) and an execution loop (spec-*) for repositories driven by hosted agent sessions, plus init to make a repository ready for both.",
5
5
  "author": { "name": "naswerks" },
6
6
  "license": "MIT",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,205 @@ version is the one in `package.json` and `.claude-plugin/plugin.json`, which mus
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.0] - 2026-11-14
9
+
10
+ A minor: the skills act differently, the doctor can newly fail a brief, and a repository's copy of the loop text
11
+ takes one paragraph (§ Upgrading from 0.8). A pipeline that writes one repository runs as it did.
12
+
13
+ ### Added
14
+
15
+ - **A seat may also write a second repository: the `Repository` column.** A sequence row names the repositories its
16
+ seat also writes in, in an optional `Repository` column: one or more names, as the runner serves them. The column
17
+ never moves a seat: its home is the pipeline's own repository, where its spec, its board and the tracker are, and
18
+ one pull request still closes the pipeline. `spec-pipeline` states the column. `init doctor` reads it the way a
19
+ launcher does, and fails a cell holding a kind word, `step`, a name that is not one path segment of at most 200
20
+ characters, a ninth name, or a name twice. **A pipeline that writes one repository has no such column and is
21
+ unchanged.**
22
+ - **What a seat does in a second repository** (`spec-seat` § Git shapes, `spec-build`). Its home stays this
23
+ repository and nothing of the pipeline's record is written in the other one. Its code goes in its own tree of
24
+ that repository, never the owner's checkout: hosted, at the path the variable its seed names holds; by hand, at
25
+ the path its prompt names. Then a staging list, a `verify-staged` receipt, a commit and a push per repository;
26
+ that repository's whole gate, run in its tree; its own rules for commit and pull-request text; a draft pull
27
+ request where it checks them, its checks read after every push; no merge, tag or publish. Its commit there goes
28
+ on an `ALSO WRITING` line beneath BELIEFS, and its FINAL gives a list and a receipt per repository. A pin holds
29
+ every clause, because a launcher that knows this version says only where the tree is.
30
+ - **The push in a second repository has its own form:** `cd` into the tree, then the push, and nothing else on the
31
+ line. `spec-seat` said a leading `cd` disqualifies every push; that is the rule for the home repository, and it
32
+ now says so.
33
+ - **The coordinator's and the review's side of a second repository.** `spec-coordinator` tells such a seat where
34
+ its tree is and nothing else (by hand; hosted, the seed says where), diffs its `ALSO WRITING` line, checks two
35
+ receipts at the FINAL, reads a pull request's checks at every seat report in whichever repository the branch is,
36
+ and hands the review one diff range per repository (by release tags when the run released it mid-run).
37
+ `spec-review` reads each range and the seam between them. A pin holds every template line for a further
38
+ repository under a line saying when it applies.
39
+ - **A conditional fixit that must write a further repository is listed as a row, directly after the review row**
40
+ (`spec-pipeline`). Listed anywhere else, a launcher reads a second fixit beside the one the review implies, and
41
+ the doctor names it.
42
+ - **The model and the effort a run starts on are named in the brief and read back.** `spec-pipeline`'s `## Birth`
43
+ names both, each in backticks, because the launcher reads them from that section; a brief that names neither
44
+ leaves both to the launcher's default. A seat's BELIEFS line states the model and effort its session is running
45
+ on, as it can read them, and `spec-coordinator` settles a seat on a pair the brief does not name before it rules
46
+ that seat's plan.
47
+
48
+ ### Changed
49
+
50
+ - **Pipelines never intertwine** replaces "one pipeline at a time", in `spec-coordinator`'s hard rules and in
51
+ `references/engineering-loop.md`. A brief's GATE still checks only that its predecessor merged, and no seat waits
52
+ on another pipeline; independent pipelines may run at the same time, each on its own branch. The coordinator takes
53
+ a moved base branch in between seats, by a merge. A repository's `docs/_meta/` copy takes the paragraph by hand.
54
+ - **A review's diff range reads the remote's copy of the base branch** (`origin/{base}...{branch}`), in
55
+ `spec-coordinator`'s REVIEW manifest, `spec-review` and `spec-pipeline`: a local base branch can sit behind it and
56
+ pull another effort's commits into the range.
57
+ - **`spec-coordinator`'s Step 0 is two numbered reads:** the seven floor docs, then every doc the repository's role
58
+ reading lists carry a path for. The second read was a trailing clause, and coordinators read the floor and stopped.
59
+ The status readback opens with how many docs each read took.
60
+ - **The coordinator edits its tracker and a seat's board with file tools, never a shell heredoc or an interpreter
61
+ one-liner** (`spec-coordinator`): one collapses backslashes, and one whose text quotes a publish is held.
62
+
63
+ ### Fixed
64
+
65
+ - `spec-coordinator` said a backticked sha is fine for the plan verdict's workspace HEAD. The verb takes only the
66
+ bare 40-hex sha and refuses a backticked or quoted one, recording nothing, so a coordinator that followed the skill
67
+ spent a refused call on every plan ruling. It now says the bare sha; a seat's board may still fence its own.
68
+ - `spec-coordinator` wrote an amount as a dollar sign before a digit, which a harness that substitutes a skill's
69
+ arguments by position rewrote at load: the line about uncosted turns read with the invocation's first argument in
70
+ the amount's place. The amount is spelled out, and a pin holds every skill's text free of a dollar sign before a
71
+ digit and of the arguments token.
72
+ - `spec-seat`, `spec-coordinator` and `spec-retro` said the gate matches text and told a seat to keep git words out
73
+ of echo labels, section headers and searches. The gate reads the command the shell would run: what parks is the
74
+ operation, at any depth, and an echo label, a branch listing and a quoted search are data unless something on the
75
+ line could run them. Six passages say so now, in fewer lines, and a pin keeps the old rule out.
76
+ - `spec-seat` said the HEAD check takes the first `HEAD <sha>` it meets. It reads the newest line that carries
77
+ `BELIEFS`, so a seat whose HEAD moved appends a new BELIEFS line and never edits the old one.
78
+
79
+ ### Upgrading from 0.8
80
+
81
+ In this order:
82
+
83
+ 1. **Take the release where every session loads it:** `claude plugin marketplace update engineering-loop`, then
84
+ `claude plugin update naswerks@engineering-loop --scope <scope>` for each scope that runs sessions, and a new
85
+ session. A runner takes `@naswerks/engineering-loop@0.9.0` in the folder its plugin directories name, and is
86
+ restarted by its owner with no seat live.
87
+ 2. **Replace one paragraph of loop text.** In `docs/_meta/engineering-loop.md`, the paragraph that opens
88
+ **One pipeline at a time.** becomes:
89
+
90
+ > **Pipelines never intertwine.** A pipeline's GATE is that its predecessor merged — never a seat of another
91
+ > pipeline — and no seat waits on another pipeline. Independent pipelines may run at the same time, each on
92
+ > its own branch, merged one after the other.
93
+
94
+ It is the only loop text that changed. `docs/_meta/docs-workflow.md` takes nothing but its stamp, and the four
95
+ repository-owned sections are yours and are not touched.
96
+ 3. **Restamp and check:** `init refresh` (with `--write`) restamps both copies and leaves every other line as it
97
+ is; then `init doctor`. A repository that read clean on 0.8.0 reads clean, unless a brief lists a `fixit` row
98
+ away from its `review` row or holds a `Repository` cell that is not a list of names.
99
+
100
+ No pipeline folder changes shape, and a sequence with no `Repository` column reads as it always has.
101
+
102
+ ## [0.8.0] - 2026-10-21
103
+
104
+ A minor: the skills act differently, and a repository must act — its method docs take new text, and every
105
+ pipeline folder in the old shape is carried over before it runs on this version (§ Upgrading from 0.7).
106
+
107
+ ### Changed
108
+
109
+ - **One brief per pipeline.** A pipeline folder is `00-ignition-brief.md` — the coordinator seat's spec and
110
+ the charter every seat reads, never a session — and `01..NN-{slice}.md`, each one build seat's spec. The
111
+ umbrella `01-{topic}.md` is gone: its vision, settled decisions, hard constraints and sources live in the
112
+ brief's `## Why`, `## The owner's rulings`, `## Law`, `## What this effort does NOT do` and
113
+ `## Where it came from`. The brief's sections down to § Platform state bind every seat; its beliefs,
114
+ close-out and kick are the coordinator's. Its H1 is the pipeline's title. The sequence is written once, in
115
+ the brief; the tracker's status lines name its rows by id and copy nothing, and its `## Settled decisions`
116
+ is `## Rulings made during the run`. `spec-pipeline`'s template, the coordinator's reading order and its
117
+ one-comparison shape check, `spec-ignite`'s stage card, the build seat's reading list, the review's
118
+ inventory (the brief down to § Platform state, read as claims), `docs-process` and `docs-write` (the
119
+ brief's `Archive:` line) all say so. No skill carries a fallback for the old shape.
120
+ - **`step` rows and a role cell.** A sequence row that is not a seat — a publish, a merge, a human sitting
121
+ — says `step` in its `Kind` cell, and is shown on the stage card and never staged. A misspelled kind is
122
+ still refused by name, because no misspelling can equal `step`. A seat row may carry `backend`,
123
+ `frontend` or `mixed` in a `Role` column. A human step files `sessions/{step-slug}/receipt.md`.
124
+ - **The hand-cranked relay.** A seat posts its PLAN TLDR and questions in the chat; the coordinator
125
+ answers in one message of notes; on the human's approval the seat writes `plan.md` and `tldr.md` with
126
+ the notes transcribed verbatim and runs to done without a stop — build, suites, `docs-write`, commits with
127
+ `verify-staged`, its branch pushes — then posts its FINAL TLDR. The coordinator writes its acceptance into
128
+ the seat's `tldr.md` after the push. The hosted ceremony is unchanged.
129
+ - **Live checks are the owner's.** `live-checks.md` is created with no rows and holds only the rows the
130
+ owner dictates; the coordinator writes a row only on the owner's word and never aggregates seats' checks
131
+ into it; a seat's FINAL TLDR section (e) is "what the owner could try", in the chat; the close offers the
132
+ owner a short, ordered quick pass; `docs-process` adds no live check to a living doc.
133
+ - **The owner's standing rules:** one pipeline at a time (a GATE checks only that the predecessor merged);
134
+ approved work includes its commit, its push and the coordinator's PR, and nobody asks for them again; the
135
+ floor is cited from the brief's `## Baselines` and measured only for a lane the brief does not name.
136
+ - **What three watched runs taught the ceremony.** `spec-coordinator`: a ruling that binds a running seat
137
+ goes onto its board in the same act as the tracker entry; a ruling counts only when its act lands;
138
+ append at the end of a seat's file; stop instead of sleep-polling when the plane's calls fail; never advise
139
+ a runner restart while a seat is live; an escalation is an act; keep each seed as sent; the hosted seed
140
+ stops restating the skills and the role; the commit call no longer stages the tracker. `spec-seat`: write
141
+ the board with the file tools, never a heredoc; the BELIEFS line near the top of both files; never route
142
+ around a refusal by disguising a path. `spec-review`: its inventory wins over a narrower brief, and the
143
+ report says whether it was cold or warm. `docs-process`: its board lives in `sessions/docs-process/`, and
144
+ every input arrives as a list. `spec-retro`: a witness ledger is an input.
145
+ - **`spec-witness` is the outside watcher's playbook:** a session outside the control plane, usually on
146
+ the runner's machine, never a declared seat, holding no credential — the packet, pre-registered
147
+ expectations, the identity map, the instruments that need no credential (git, the transcripts, the board
148
+ files, the process table, the plane's rows as another session's transcript shows them), what is and is
149
+ not an event, human touches, the watch, the ledger and the control-plane incident ledger.
150
+ - `references/docs-workflow.md` takes the retire path for research the platform moved past
151
+ (`archive/retired/`) and the provenance exception to the archive's frozen links.
152
+
153
+ ### Added
154
+
155
+ - `bin/witness-*.mjs`, the witness's tools: a config-driven watcher (`witness-watch`) and the transcript,
156
+ git and process probes `spec-witness` names, each with a `--self-test` that `npm test` runs. A run's own
157
+ facts — its worktree, its folders, the plane's tool prefix, the process pattern, the noise paths — live in
158
+ a `witness.config.json` beside the witness's folder.
159
+ - `init doctor` reads `step` rows and the role cell, and a pipeline folder whose `01-` file no sequence row
160
+ names reads DEGRADE: "old shape — fold `01-{topic}.md` into the brief (CHANGELOG 0.8.0 § Upgrading)".
161
+ - Pins: `pins/shape.test.mjs` (a `step` row and its misspelled-kind control, the role cell, the old-shape
162
+ row and its control, one `## The sequence` in the template, no umbrella in any skill) and
163
+ `pins/witness-tools.test.mjs`; the content census reads `bin/` too.
164
+
165
+ ### Fixed
166
+
167
+ - `verify-staged` on a large repository: every listing is read for the expected paths only, and an output
168
+ past the buffer is an ERROR by name. A full listing over Node's default 1 MiB buffer made
169
+ `--against head` print "HEAD is unreadable" and call a committed file never-added, and made the index
170
+ mode fail the same way.
171
+
172
+ ### Upgrading from 0.7
173
+
174
+ In this order:
175
+
176
+ 1. **Take the release where every session loads it:** `claude plugin marketplace update engineering-loop`,
177
+ then `claude plugin update naswerks@engineering-loop --scope <scope>`, and a new session. A runner that
178
+ loads the pack from an npm install takes `@naswerks/engineering-loop@0.8.0` there (its pinned
179
+ `package.json`, or a global install) and is restarted by its owner with no seat live — a runner left on
180
+ 0.7 serves the 0.7 skills, which read the old shape.
181
+ 2. **Re-copy the loop text** of both references into `docs/_meta/`. The four repository-owned sections are
182
+ yours, so neither the re-copy nor `init refresh` touches them; three carry changed text, offered as
183
+ replacements you take or fit to your own patterns: § The archive convention (the `research/` retire
184
+ path, the provenance exception and the `archive/retired/` home, the `specs/` line
185
+ `00-ignition-brief · 01..NN slices`, the sessions map, the brief in a follow-on's back-link),
186
+ § Doc metadata (the ignition brief goes in `Idea`), § Templates (the spec-doc preamble and its
187
+ `Source: [brief / research]`). Then `init refresh` restamps both copies, and `init doctor` lists the
188
+ folders to carry.
189
+ 3. **Carry every old-shape folder over before it runs on 0.8.0** — each one the doctor marks "old shape". A
190
+ pipeline that is mid-run finishes on its old pack first; never carry a live one. Per folder:
191
+ - **Fold the umbrella into the brief, section by section, losing nothing:** its vision into `## Why`,
192
+ its settled decisions into `## The owner's rulings`, its hard constraints into `## Law` and
193
+ `## What this effort does NOT do`, its research sources into `## Where it came from` (with its
194
+ `Archive:` line), and anything else into the section that fits or a `## Reference`. The brief's H1
195
+ becomes the pipeline's title — the umbrella's H1 — and its blockquote says every seat reads it.
196
+ - **One sequence:** reconcile the brief's `## The sequence` with the umbrella's row table and the
197
+ tracker, keep it in the brief alone, and write each prose step (a publish, a merge, a human sitting) as
198
+ a `step` row in its place.
199
+ - **The tracker stops copying:** its status lines name the brief's rows by id, and
200
+ `## Settled decisions` becomes `## Rulings made during the run`, holding only what the run ruled.
201
+ - **Delete the umbrella**, and repair every link to it outside `docs/archive/` and `sessions/**` to point
202
+ at the brief. Archives and testimony are never edited.
203
+ - **Keep the specs' numbers.** A spec that was `02-` stays `02-`; the gap at `01` is the umbrella's.
204
+ - **Check it:** every heading of the old umbrella is found in the brief or named where it merged, and
205
+ `init doctor` reads the folder with no "old shape" row and no FAIL.
206
+
8
207
  ## [0.7.1] - 2026-10-04
9
208
 
10
209
  ### Changed
package/README.md CHANGED
@@ -74,7 +74,7 @@ and never touches the rest of the text.
74
74
  | `spec-build` | spec | the build job for one spec |
75
75
  | `spec-review` | spec | the outside look before the retrospective |
76
76
  | `spec-retro` | spec | the coordinator's close-out audit |
77
- | `spec-witness` | spec | a read-only verifier |
77
+ | `spec-witness` | spec | the outside watcher: read-only, no credential, never a declared seat |
78
78
  | `spec-ignite` | spec | the ignition copilot for a hosted run |
79
79
  | `init` | — | make a repository ready for both loops; `init doctor` checks one, `init refresh` takes a newer pack's repository-owned sections |
80
80
 
@@ -82,7 +82,9 @@ and never touches the rest of the text.
82
82
  rewrites where they say (repository-owned);
83
83
  `bin/verify-staged` is the staging receipt the seats run between `git add` and `git commit`. A
84
84
  marketplace install puts nothing on PATH: `npm i -g @naswerks/engineering-loop` does, and the skills
85
- carry the fallback `node <pack root>/bin/verify-staged.mjs`.
85
+ carry the fallback `node <pack root>/bin/verify-staged.mjs`. `bin/witness-*.mjs` are the witness's tools —
86
+ a config-driven watcher and the transcript, git and process probes `spec-witness` names — run by path,
87
+ each with a `--self-test`; a run's own facts live in a `witness.config.json` beside the witness's folder.
86
88
 
87
89
  Running a skill through a child `claude -p` from inside another Claude Code session: scrub the
88
90
  `CLAUDE_CODE_*` variables from the child's environment and name the repository root in the prompt,
@@ -92,11 +94,14 @@ or the child may resolve `docs/` paths against the parent session's workspace.
92
94
 
93
95
  `npm test` runs the pins: one version across the package, the plugin manifest, the marketplace entry and
94
96
  both method documents' stamps; every skill directory carries a `SKILL.md` named for its folder; no
95
- byte-order marks; the vocabulary and content censuses; no skill states a format a repository owns, and
96
- every skill that needs one carries the same find-format line; exactly four repository-owned sections, found
97
- by the one grep; the loop's own preferences stay stated in the method documents; the menu's two shapes; `init refresh` leaves
98
- every line outside those sections byte for byte. `node bin/verify-staged.mjs --self-test` drives the
99
- receipt against a throwaway repository and reports each case.
97
+ byte-order marks; the vocabulary and content censuses, `bin/` included; no skill states a format a
98
+ repository owns, and every skill that needs one carries the same find-format line; exactly four
99
+ repository-owned sections, found by the one grep; the loop's own preferences stay stated in the method
100
+ documents; the menu's two shapes; `init refresh` leaves every line outside those sections byte for byte;
101
+ the pipeline folder's shape (a `step` row reads ok and a misspelled kind still fails, a role cell, the
102
+ old-shape row, one sequence, no umbrella); and every witness tool's self-test.
103
+ `node bin/verify-staged.mjs --self-test` drives the receipt against throwaway repositories, one of them
104
+ past Node's default 1 MiB listing buffer, and reports each case.
100
105
 
101
106
  ## Releasing
102
107
 
@@ -16,6 +16,10 @@
16
16
  // Deliberately not a fix for .gitignore: widening or anchoring a repository's ignore rules is a
17
17
  // repository decision, and force-adding against one is a ruling, not a default.
18
18
  //
19
+ // Every listing is read for the expected paths only, never for the whole tree: a large repository's
20
+ // full listing is megabytes, and a listing cut short by a buffer reads as files that were never added.
21
+ // An output that still overflows is an ERROR by name, never a partial listing.
22
+ //
19
23
  // Paths with embedded newlines are not supported (line-delimited git output, core.quotepath=false).
20
24
 
21
25
  import { execFileSync } from 'node:child_process';
@@ -24,20 +28,49 @@ import { tmpdir } from 'node:os';
24
28
  import { isAbsolute, join, sep } from 'node:path';
25
29
 
26
30
  const DIR_EXPANSION_CAP = 2000;
31
+ const MAX_BUFFER = 256 * 1024 * 1024;
32
+ // Paths per git call, so one call stays well under the Windows command-line limit.
33
+ const PATHSPEC_BATCH = 100;
27
34
 
28
- function git(root, args) {
35
+ function git(root, args, maxBuffer = MAX_BUFFER) {
29
36
  try {
30
37
  const out = execFileSync('git', ['-C', root, '-c', 'core.quotepath=false', ...args], {
31
38
  encoding: 'utf8',
32
39
  stdio: ['ignore', 'pipe', 'pipe'],
40
+ maxBuffer,
33
41
  });
34
- return { ok: true, exit: 0, lines: out.split(/\r?\n/).filter((l) => l.length > 0) };
42
+ return { ok: true, exit: 0, overflow: false, lines: out.split(/\r?\n/).filter((l) => l.length > 0) };
35
43
  } catch (e) {
36
44
  const out = typeof e.stdout === 'string' ? e.stdout : '';
37
- return { ok: false, exit: typeof e.status === 'number' ? e.status : 1, lines: out.split(/\r?\n/).filter((l) => l.length > 0) };
45
+ return {
46
+ ok: false,
47
+ exit: typeof e.status === 'number' ? e.status : 1,
48
+ overflow: e.code === 'ENOBUFS',
49
+ lines: out.split(/\r?\n/).filter((l) => l.length > 0),
50
+ };
38
51
  }
39
52
  }
40
53
 
54
+ // One listing, read for the given paths only, in batches. Each path is a literal pathspec (case-insensitive
55
+ // when the repository is), so a path holding `*` or `?` names itself and nothing else.
56
+ function gitFor(root, args, paths, caseInsensitive, maxBuffer) {
57
+ const lines = [];
58
+ const magic = caseInsensitive ? ':(literal,icase)' : ':(literal)';
59
+ for (let i = 0; i < paths.length; i += PATHSPEC_BATCH) {
60
+ const batch = paths.slice(i, i + PATHSPEC_BATCH).map((p) => magic + p);
61
+ const r = git(root, [...args, '--', ...batch], maxBuffer);
62
+ if (!r.ok) return { ...r, lines: [...lines, ...r.lines] };
63
+ lines.push(...r.lines);
64
+ }
65
+ return { ok: true, exit: 0, overflow: false, lines };
66
+ }
67
+
68
+ const overflowFinding = (what) => ({
69
+ file: what,
70
+ state: 'ERROR',
71
+ detail: 'git output exceeded the buffer, so this listing is incomplete and nothing is judged against it',
72
+ });
73
+
41
74
  function toRepoRelative(root, input) {
42
75
  let p = input.trim().replace(/\\/g, '/').replace(/\/+$/, '');
43
76
  if (isAbsolute(p) || /^[A-Za-z]:\//.test(p)) {
@@ -48,33 +81,21 @@ function toRepoRelative(root, input) {
48
81
  return p.length === 0 ? null : p;
49
82
  }
50
83
 
51
- export function testStagedList(root, paths, mode = 'index') {
84
+ export function testStagedList(root, paths, mode = 'index', options = {}) {
85
+ const maxBuffer = options.maxBuffer ?? MAX_BUFFER;
52
86
  const findings = [];
53
87
  const notes = [];
54
- const top = git(root, ['rev-parse', '--show-toplevel']);
88
+ const top = git(root, ['rev-parse', '--show-toplevel'], maxBuffer);
55
89
  if (!top.ok || top.lines.length === 0) {
56
90
  findings.push({ file: root, state: 'ERROR', detail: 'not inside a git repository (rev-parse --show-toplevel failed)' });
57
91
  return { ok: false, findings, notes, expected: 0, green: 0 };
58
92
  }
59
93
  const repoTop = top.lines[0];
60
- const igc = git(root, ['config', 'core.ignorecase']);
94
+ const igc = git(root, ['config', 'core.ignorecase'], maxBuffer);
61
95
  const caseInsensitive = igc.ok && igc.lines[0]?.trim() === 'true';
62
96
  const key = (s) => (caseInsensitive ? s.toLowerCase() : s);
63
97
  const setOf = (lines) => new Set(lines.map(key));
64
98
 
65
- const tracked = setOf(git(root, ['ls-files', '--cached']).lines);
66
- let head = null;
67
- let staged = null;
68
- let dirty = null;
69
- if (mode === 'head') {
70
- const lsTree = git(root, ['ls-tree', '-r', '--name-only', 'HEAD']);
71
- head = setOf(lsTree.lines);
72
- if (!lsTree.ok) notes.push('HEAD is unreadable (empty repository?) - every expected file will read as missing from HEAD');
73
- } else {
74
- staged = setOf(git(root, ['diff', '--cached', '--no-renames', '--name-only']).lines);
75
- dirty = setOf(git(root, ['diff', '--name-only']).lines);
76
- }
77
-
78
99
  const expected = [];
79
100
  const seen = new Set();
80
101
  for (const raw of paths) {
@@ -87,12 +108,19 @@ export function testStagedList(root, paths, mode = 'index') {
87
108
  const onDisk = join(repoTop, rel.split('/').join(sep));
88
109
  if (existsSync(onDisk) && statSync(onDisk).isDirectory()) {
89
110
  const inDir = [];
111
+ let overflowed = false;
90
112
  for (const spec of [
91
113
  ['ls-files', '--cached', '--', rel],
92
114
  ['ls-files', '--others', '--exclude-standard', '--', rel],
93
115
  ['ls-files', '--others', '--ignored', '--exclude-standard', '--', rel],
94
116
  ]) {
95
- inDir.push(...git(root, spec).lines);
117
+ const r = git(root, spec, maxBuffer);
118
+ if (r.overflow) overflowed = true;
119
+ inDir.push(...r.lines);
120
+ }
121
+ if (overflowed) {
122
+ findings.push(overflowFinding(rel));
123
+ continue;
96
124
  }
97
125
  if (inDir.length > DIR_EXPANSION_CAP) {
98
126
  findings.push({ file: rel, state: 'TOO-BROAD', detail: `directory expands to ${inDir.length} files (cap ${DIR_EXPANSION_CAP}) - name explicit paths` });
@@ -109,6 +137,35 @@ export function testStagedList(root, paths, mode = 'index') {
109
137
  }
110
138
  }
111
139
 
140
+ // The listings, for the expected paths only.
141
+ const read = (args) => gitFor(root, args, expected, caseInsensitive, maxBuffer);
142
+ const lsFiles = read(['ls-files', '--cached']);
143
+ const listings = [lsFiles];
144
+ let head = null;
145
+ let staged = null;
146
+ let dirty = null;
147
+ if (mode === 'head') {
148
+ // `ls-tree` takes no `icase` magic, so on a case-insensitive repository each expected path is read in
149
+ // the case the index spells it (after the commit, the index and HEAD agree on every committed path).
150
+ const spelled = new Map(lsFiles.lines.map((l) => [key(l), l]));
151
+ const asCommitted = expected.map((p) => spelled.get(key(p)) ?? p);
152
+ const lsTree = gitFor(root, ['ls-tree', '-r', '--name-only', 'HEAD'], asCommitted, false, maxBuffer);
153
+ listings.push(lsTree);
154
+ head = setOf(lsTree.lines);
155
+ if (!lsTree.ok && !lsTree.overflow) notes.push('HEAD is unreadable (empty repository?) - every expected file will read as missing from HEAD');
156
+ } else {
157
+ const cached = read(['diff', '--cached', '--no-renames', '--name-only']);
158
+ const worktree = read(['diff', '--name-only']);
159
+ listings.push(cached, worktree);
160
+ staged = setOf(cached.lines);
161
+ dirty = setOf(worktree.lines);
162
+ }
163
+ if (listings.some((l) => l.overflow)) {
164
+ findings.push(overflowFinding(`${expected.length} expected file(s)`));
165
+ return { ok: false, findings, notes, expected: expected.length, green: 0 };
166
+ }
167
+ const tracked = setOf(lsFiles.lines);
168
+
112
169
  let green = 0;
113
170
  for (const f of expected) {
114
171
  const onDisk = join(repoTop, f.split('/').join(sep));
@@ -157,6 +214,16 @@ function report(result, mode, pathCount) {
157
214
  console.log(' git add -f is a ruling, not a default, and .gitignore is never edited from a seat.');
158
215
  }
159
216
 
217
+ // A throwaway tree's removal. Windows can still hold files git just wrote, so the removal retries; one that
218
+ // still fails is a warning naming the folder, never a failed self-test — the verdict is the cases'.
219
+ function cleanUp(dir) {
220
+ try {
221
+ rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
222
+ } catch (e) {
223
+ console.log(` warning: could not remove the self-test folder ${dir} (${e.code ?? e.message}); remove it by hand`);
224
+ }
225
+ }
226
+
160
227
  function selfTest() {
161
228
  const tmp = mkdtempSync(join(tmpdir(), 'verify-staged-selftest-'));
162
229
  const cases = [];
@@ -179,11 +246,14 @@ function selfTest() {
179
246
  git(tmp, ['-c', 'user.name=selftest', '-c', 'user.email=selftest@local', 'commit', '-q', '-m', 'selftest']);
180
247
  cases.push({ name: 'positive control: committed-clean is a note, not a red', shouldFail: false, result: testStagedList(tmp, ['docs/session/a.md']) });
181
248
  cases.push({ name: 'head mode: the eaten receipt is absent from the commit', shouldFail: true, result: testStagedList(tmp, ['docs/session'], 'head'), expect: 'IGNORED', detail: '' });
249
+ cases.push(...largeTreeCases());
182
250
 
183
251
  let failed = 0;
184
252
  for (const c of cases) {
185
253
  let verdict;
186
- if (c.shouldFail) {
254
+ if (c.verdict !== undefined) {
255
+ verdict = c.verdict;
256
+ } else if (c.shouldFail) {
187
257
  const hit = c.result.findings.filter((f) => f.state === c.expect);
188
258
  if (c.result.ok) verdict = 'DID NOT FIRE - the detector is broken';
189
259
  else if (hit.length === 0) verdict = `fired with the WRONG state (wanted ${c.expect})`;
@@ -196,13 +266,56 @@ function selfTest() {
196
266
  console.log(` [${verdict.startsWith('PASS') ? 'PASS' : 'FAIL'}] ${c.name}: ${verdict}`);
197
267
  }
198
268
  if (failed === 0) {
199
- console.log('SELF-TEST PASSED: the detector fires on both ignore sightings, discriminates never-added from ignored, and stays silent on good input.');
269
+ console.log('SELF-TEST PASSED: the detector fires on both ignore sightings, discriminates never-added from ignored, stays silent on good input, and reads a repository whose listing is over 1 MiB.');
200
270
  return 0;
201
271
  }
202
272
  console.log(`SELF-TEST FAILED: ${failed} case(s) - do not trust this detector until it fires correctly.`);
203
273
  return 1;
204
274
  } finally {
205
- rmSync(tmp, { recursive: true, force: true });
275
+ cleanUp(tmp);
276
+ }
277
+ }
278
+
279
+ // A repository whose full listing is over 1 MiB: twelve thousand paths written to the index alone (no files
280
+ // on disk) and committed, plus one real file that sorts after all of them, so a listing cut short at the
281
+ // buffer is exactly the listing that loses it. The CONTROL reads the full listing at Node's default buffer
282
+ // and must overflow; if it does not, the fixture is too small to prove anything about a large repository.
283
+ function largeTreeCases() {
284
+ const tmp = mkdtempSync(join(tmpdir(), 'verify-staged-large-'));
285
+ try {
286
+ git(tmp, ['init', '-q']);
287
+ writeFileSync(join(tmp, 'zz-kept.md'), 'committed\n');
288
+ git(tmp, ['add', 'zz-kept.md']);
289
+ const blob = execFileSync('git', ['-C', tmp, 'hash-object', '-w', '--stdin'], { input: 'padding\n', encoding: 'utf8' }).trim();
290
+ const folder = `padding/${'p'.repeat(80)}`;
291
+ const entries = [];
292
+ for (let i = 0; i < 12000; i++) entries.push(`100644 ${blob}\t${folder}/${String(i).padStart(5, '0')}.txt`);
293
+ execFileSync('git', ['-C', tmp, 'update-index', '--index-info'], { input: `${entries.join('\n')}\n` });
294
+ git(tmp, ['-c', 'user.name=selftest', '-c', 'user.email=selftest@local', 'commit', '-q', '-m', 'large']);
295
+
296
+ let overflowed = false;
297
+ try {
298
+ execFileSync('git', ['-C', tmp, 'ls-tree', '-r', '--name-only', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
299
+ } catch (e) {
300
+ overflowed = e.code === 'ENOBUFS';
301
+ }
302
+ return [
303
+ {
304
+ name: 'CONTROL: the large fixture\'s full listing overflows the default buffer',
305
+ verdict: overflowed ? 'PASS - the fixture is over 1 MiB' : 'the full listing fit the default buffer - the fixture proves nothing',
306
+ },
307
+ { name: 'large repository, head mode: a committed file reads IN-HEAD', shouldFail: false, result: testStagedList(tmp, ['zz-kept.md'], 'head') },
308
+ { name: 'large repository, index mode: thousands of index-only paths do not hide a clean file', shouldFail: false, result: testStagedList(tmp, ['zz-kept.md']) },
309
+ {
310
+ name: 'an overflowing listing is an ERROR by name, never a partial listing',
311
+ shouldFail: true,
312
+ result: testStagedList(tmp, ['padding'], 'index', { maxBuffer: 64 * 1024 }),
313
+ expect: 'ERROR',
314
+ detail: 'exceeded the buffer',
315
+ },
316
+ ];
317
+ } finally {
318
+ cleanUp(tmp);
206
319
  }
207
320
  }
208
321
 
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Every control-plane write in a transcript, and every plane call that failed, with its act number and
3
+ // outcome: the plane's rows as one session's tool results show them. Name them "rows via transcript" in a
4
+ // ledger, never a row you read. Read-only.
5
+ //
6
+ // node witness-acts.mjs <label> <file.jsonl> [--plane <server>] [--config <path>]
7
+ // -> TSV: time, label, verb, seat, act, ok|ERROR, detail
8
+ // node witness-acts.mjs --self-test
9
+
10
+ import { rmSync } from 'node:fs';
11
+ import { PLANE_READS, clip, contentOf, entries, isMain, loadConfig, planePrefix, positionals, report, resultText, scratch, toolResult, toolUse, writeTranscript } from './witness-lib.mjs';
12
+
13
+ export async function acts(file, prefix, label) {
14
+ const uses = new Map();
15
+ const out = [];
16
+ for await (const { entry } of entries(file)) {
17
+ for (const c of contentOf(entry)) {
18
+ if (c.type === 'tool_use' && typeof c.name === 'string' && c.name.startsWith(prefix)) {
19
+ uses.set(c.id, { ts: entry.timestamp ?? '', verb: c.name.slice(prefix.length), input: c.input ?? {} });
20
+ } else if (c.type === 'tool_result' && uses.has(c.tool_use_id)) {
21
+ const use = uses.get(c.tool_use_id);
22
+ uses.delete(c.tool_use_id);
23
+ if (PLANE_READS.test(use.verb) && !c.is_error) continue;
24
+ const body = resultText(c);
25
+ const act = body.match(/"spawnedActId":(\d+)/)?.[1] ?? body.match(/\bact (\d{3,})/)?.[1] ?? '';
26
+ const detail = c.is_error ? body : (use.input.verdict ?? use.input.ruling ?? use.input.basis ?? use.input.path ?? '');
27
+ out.push([`${String(use.ts).slice(0, 19)}Z`, label, use.verb, use.input.seatId ?? '', act, c.is_error ? 'ERROR' : 'ok', clip(detail, 170)].join('\t'));
28
+ }
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ async function selfTest() {
35
+ const dir = scratch('witness-acts');
36
+ try {
37
+ const file = writeTranscript(dir, 'coordinator.jsonl', [
38
+ toolUse(1, 'a', 'mcp__plane__accept_plan', { seatId: 7, verdict: 'accept-with-riders' }),
39
+ toolResult(2, 'a', 'Accepted. Recorded as act 1234; delivery: delivered'),
40
+ toolUse(3, 'b', 'mcp__plane__seat_read', { seatId: 7 }),
41
+ toolResult(3, 'b', 'seat 7 is building'),
42
+ toolUse(4, 'c', 'mcp__plane__seat_acts', { seatId: 7 }),
43
+ toolResult(4, 'c', 'HTTP 404 from the plane', true),
44
+ toolUse(5, 'd', 'Read', { file_path: 'tldr.md' }),
45
+ toolResult(5, 'd', 'the board says act 9999 landed'),
46
+ ]);
47
+ const lines = await acts(file, 'mcp__plane__', 'coord');
48
+ return report('witness-acts', [
49
+ { name: 'a plane write is a line, with its act number', ok: lines.some((l) => l.includes('\taccept_plan\t7\t1234\tok\taccept-with-riders')), detail: lines.join(' | ') },
50
+ { name: 'a failed plane read is a line', ok: lines.some((l) => l.includes('\tseat_acts\t7\t\tERROR\tHTTP 404')), detail: lines.join(' | ') },
51
+ { name: 'CONTROL: a read that answered, and a Read of a file that mentions an act, are not', ok: lines.length === 2, detail: `${lines.length} lines` },
52
+ ]);
53
+ } finally {
54
+ rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ }
57
+
58
+ if (isMain(import.meta.url)) {
59
+ const argv = process.argv.slice(2);
60
+ if (argv.includes('--self-test')) process.exit(await selfTest());
61
+ const [label, file] = positionals(argv, ['--plane', '--config']);
62
+ if (!label || !file) { console.log('usage: witness-acts.mjs <label> <file.jsonl> [--plane <server>]'); process.exit(2); }
63
+ for (const line of await acts(file, planePrefix(argv, loadConfig(argv)), label)) console.log(line);
64
+ }