@naswerks/engineering-loop 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.
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 +125 -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 +41 -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 +17 -5
  28. package/skills/init/scripts/owned.mjs +10 -0
  29. package/skills/spec-build/SKILL.md +8 -4
  30. package/skills/spec-coordinator/SKILL.md +125 -66
  31. package/skills/spec-ignite/SKILL.md +25 -21
  32. package/skills/spec-pipeline/SKILL.md +128 -80
  33. package/skills/spec-retro/SKILL.md +9 -4
  34. package/skills/spec-review/SKILL.md +22 -10
  35. package/skills/spec-seat/SKILL.md +63 -31
  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.0",
13
+ "version": "0.8.0",
14
14
  "source": "./"
15
15
  }
16
16
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naswerks",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,131 @@ version is the one in `package.json` and `.claude-plugin/plugin.json`, which mus
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.8.0] - 2026-10-21
9
+
10
+ A minor: the skills act differently, and a repository must act — its method docs take new text, and every
11
+ pipeline folder in the old shape is carried over before it runs on this version (§ Upgrading from 0.7).
12
+
13
+ ### Changed
14
+
15
+ - **One brief per pipeline.** A pipeline folder is `00-ignition-brief.md` — the coordinator seat's spec and
16
+ the charter every seat reads, never a session — and `01..NN-{slice}.md`, each one build seat's spec. The
17
+ umbrella `01-{topic}.md` is gone: its vision, settled decisions, hard constraints and sources live in the
18
+ brief's `## Why`, `## The owner's rulings`, `## Law`, `## What this effort does NOT do` and
19
+ `## Where it came from`. The brief's sections down to § Platform state bind every seat; its beliefs,
20
+ close-out and kick are the coordinator's. Its H1 is the pipeline's title. The sequence is written once, in
21
+ the brief; the tracker's status lines name its rows by id and copy nothing, and its `## Settled decisions`
22
+ is `## Rulings made during the run`. `spec-pipeline`'s template, the coordinator's reading order and its
23
+ one-comparison shape check, `spec-ignite`'s stage card, the build seat's reading list, the review's
24
+ inventory (the brief down to § Platform state, read as claims), `docs-process` and `docs-write` (the
25
+ brief's `Archive:` line) all say so. No skill carries a fallback for the old shape.
26
+ - **`step` rows and a role cell.** A sequence row that is not a seat — a publish, a merge, a human sitting
27
+ — says `step` in its `Kind` cell, and is shown on the stage card and never staged. A misspelled kind is
28
+ still refused by name, because no misspelling can equal `step`. A seat row may carry `backend`,
29
+ `frontend` or `mixed` in a `Role` column. A human step files `sessions/{step-slug}/receipt.md`.
30
+ - **The hand-cranked relay.** A seat posts its PLAN TLDR and questions in the chat; the coordinator
31
+ answers in one message of notes; on the human's approval the seat writes `plan.md` and `tldr.md` with
32
+ the notes transcribed verbatim and runs to done without a stop — build, suites, `docs-write`, commits with
33
+ `verify-staged`, its branch pushes — then posts its FINAL TLDR. The coordinator writes its acceptance into
34
+ the seat's `tldr.md` after the push. The hosted ceremony is unchanged.
35
+ - **Live checks are the owner's.** `live-checks.md` is created with no rows and holds only the rows the
36
+ owner dictates; the coordinator writes a row only on the owner's word and never aggregates seats' checks
37
+ into it; a seat's FINAL TLDR section (e) is "what the owner could try", in the chat; the close offers the
38
+ owner a short, ordered quick pass; `docs-process` adds no live check to a living doc.
39
+ - **The owner's standing rules:** one pipeline at a time (a GATE checks only that the predecessor merged);
40
+ approved work includes its commit, its push and the coordinator's PR, and nobody asks for them again; the
41
+ floor is cited from the brief's `## Baselines` and measured only for a lane the brief does not name.
42
+ - **What three watched runs taught the ceremony.** `spec-coordinator`: a ruling that binds a running seat
43
+ goes onto its board in the same act as the tracker entry; a ruling counts only when its act lands;
44
+ append at the end of a seat's file; stop instead of sleep-polling when the plane's calls fail; never advise
45
+ a runner restart while a seat is live; an escalation is an act; keep each seed as sent; the hosted seed
46
+ stops restating the skills and the role; the commit call no longer stages the tracker. `spec-seat`: write
47
+ the board with the file tools, never a heredoc; the BELIEFS line near the top of both files; never route
48
+ around a refusal by disguising a path. `spec-review`: its inventory wins over a narrower brief, and the
49
+ report says whether it was cold or warm. `docs-process`: its board lives in `sessions/docs-process/`, and
50
+ every input arrives as a list. `spec-retro`: a witness ledger is an input.
51
+ - **`spec-witness` is the outside watcher's playbook:** a session outside the control plane, usually on
52
+ the runner's machine, never a declared seat, holding no credential — the packet, pre-registered
53
+ expectations, the identity map, the instruments that need no credential (git, the transcripts, the board
54
+ files, the process table, the plane's rows as another session's transcript shows them), what is and is
55
+ not an event, human touches, the watch, the ledger and the control-plane incident ledger.
56
+ - `references/docs-workflow.md` takes the retire path for research the platform moved past
57
+ (`archive/retired/`) and the provenance exception to the archive's frozen links.
58
+
59
+ ### Added
60
+
61
+ - `bin/witness-*.mjs`, the witness's tools: a config-driven watcher (`witness-watch`) and the transcript,
62
+ git and process probes `spec-witness` names, each with a `--self-test` that `npm test` runs. A run's own
63
+ facts — its worktree, its folders, the plane's tool prefix, the process pattern, the noise paths — live in
64
+ a `witness.config.json` beside the witness's folder.
65
+ - `init doctor` reads `step` rows and the role cell, and a pipeline folder whose `01-` file no sequence row
66
+ names reads DEGRADE: "old shape — fold `01-{topic}.md` into the brief (CHANGELOG 0.8.0 § Upgrading)".
67
+ - Pins: `pins/shape.test.mjs` (a `step` row and its misspelled-kind control, the role cell, the old-shape
68
+ row and its control, one `## The sequence` in the template, no umbrella in any skill) and
69
+ `pins/witness-tools.test.mjs`; the content census reads `bin/` too.
70
+
71
+ ### Fixed
72
+
73
+ - `verify-staged` on a large repository: every listing is read for the expected paths only, and an output
74
+ past the buffer is an ERROR by name. A full listing over Node's default 1 MiB buffer made
75
+ `--against head` print "HEAD is unreadable" and call a committed file never-added, and made the index
76
+ mode fail the same way.
77
+
78
+ ### Upgrading from 0.7
79
+
80
+ In this order:
81
+
82
+ 1. **Take the release where every session loads it:** `claude plugin marketplace update engineering-loop`,
83
+ then `claude plugin update naswerks@engineering-loop --scope <scope>`, and a new session. A runner that
84
+ loads the pack from an npm install takes `@naswerks/engineering-loop@0.8.0` there (its pinned
85
+ `package.json`, or a global install) and is restarted by its owner with no seat live — a runner left on
86
+ 0.7 serves the 0.7 skills, which read the old shape.
87
+ 2. **Re-copy the loop text** of both references into `docs/_meta/`. The four repository-owned sections are
88
+ yours, so neither the re-copy nor `init refresh` touches them; three carry changed text, offered as
89
+ replacements you take or fit to your own patterns: § The archive convention (the `research/` retire
90
+ path, the provenance exception and the `archive/retired/` home, the `specs/` line
91
+ `00-ignition-brief · 01..NN slices`, the sessions map, the brief in a follow-on's back-link),
92
+ § Doc metadata (the ignition brief goes in `Idea`), § Templates (the spec-doc preamble and its
93
+ `Source: [brief / research]`). Then `init refresh` restamps both copies, and `init doctor` lists the
94
+ folders to carry.
95
+ 3. **Carry every old-shape folder over before it runs on 0.8.0** — each one the doctor marks "old shape". A
96
+ pipeline that is mid-run finishes on its old pack first; never carry a live one. Per folder:
97
+ - **Fold the umbrella into the brief, section by section, losing nothing:** its vision into `## Why`,
98
+ its settled decisions into `## The owner's rulings`, its hard constraints into `## Law` and
99
+ `## What this effort does NOT do`, its research sources into `## Where it came from` (with its
100
+ `Archive:` line), and anything else into the section that fits or a `## Reference`. The brief's H1
101
+ becomes the pipeline's title — the umbrella's H1 — and its blockquote says every seat reads it.
102
+ - **One sequence:** reconcile the brief's `## The sequence` with the umbrella's row table and the
103
+ tracker, keep it in the brief alone, and write each prose step (a publish, a merge, a human sitting) as
104
+ a `step` row in its place.
105
+ - **The tracker stops copying:** its status lines name the brief's rows by id, and
106
+ `## Settled decisions` becomes `## Rulings made during the run`, holding only what the run ruled.
107
+ - **Delete the umbrella**, and repair every link to it outside `docs/archive/` and `sessions/**` to point
108
+ at the brief. Archives and testimony are never edited.
109
+ - **Keep the specs' numbers.** A spec that was `02-` stays `02-`; the gap at `01` is the umbrella's.
110
+ - **Check it:** every heading of the old umbrella is found in the brief or named where it merged, and
111
+ `init doctor` reads the folder with no "old shape" row and no FAIL.
112
+
113
+ ## [0.7.1] - 2026-10-04
114
+
115
+ ### Changed
116
+
117
+ - `spec-coordinator` learns the control plane's verbs of 2026-10-04: `seat_spawn` defaults its host to the one
118
+ carrying the coordinator's own session and takes `model` and `effort` overrides; `accept_plan` and `accept`
119
+ take `answeringActId`, so one act rules and closes a question — and an act that names no question closes
120
+ none; the coordinator reads for itself what the owner used to check (`seat_timeline`, `needs_you`, argless
121
+ `seat_usage`, `seat_parks` with a charge's `seatId` or `all: true`, the roster's host and `re-raises`); it
122
+ commits its tracker itself, after each reap and never in a seat's commit call; and, hosted, `finish` is its
123
+ last call — the pipeline moves to `reviewing` and the PR's merge closes it.
124
+
125
+ ### Upgrading from 0.7.0
126
+
127
+ Take the release (`claude plugin marketplace update engineering-loop`, then
128
+ `claude plugin update naswerks@engineering-loop --scope <scope>`, and a new session). A patch: no loop text
129
+ changed, nothing is restamped, and a hand-cranked repository's coordinator acts as before — every change but
130
+ the tracker's commit discipline is the hosted plane's. A control plane older than 2026-10-04 refuses the new
131
+ arguments and verbs by name.
132
+
8
133
  ## [0.7.0] - 2026-10-01
9
134
 
10
135
  ### 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
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // A moved or filed file's body, byte-identical to its version at the old path in HEAD, its header aside:
3
+ // the proof that a filing changed where a doc lives and its header, and nothing a reader relies on. The
4
+ // header is a leading frontmatter block and any line matching --header-line (by default the loop's own
5
+ // `<!-- meta: … -->` line). Read-only.
6
+ //
7
+ // node witness-bodycheck.mjs <worktree> <newRelDir> <oldRelDir> <relPath>... [--header-line <regex>]
8
+ // node witness-bodycheck.mjs --self-test
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { join } from 'node:path';
14
+ import { flag, isMain, positionals, report, scratch } from './witness-lib.mjs';
15
+
16
+ const DEFAULT_HEADER_LINE = '^<!-- meta: .*-->\\s*$';
17
+ const sha = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
18
+
19
+ // The body of a file: what is left after its leading frontmatter and its header lines.
20
+ export function bodyOf(text, headerLine = DEFAULT_HEADER_LINE) {
21
+ const fm = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
22
+ const rest = fm ? text.slice(fm[0].length) : text;
23
+ const header = new RegExp(headerLine);
24
+ const lines = rest.split(/(?<=\n)/);
25
+ return { header: (fm ? fm[0] : '') + lines.filter((l) => header.test(l)).join(''), body: lines.filter((l) => !header.test(l)).join('') };
26
+ }
27
+
28
+ export function bodycheck(worktree, newDir, oldDir, files, headerLine = DEFAULT_HEADER_LINE) {
29
+ const out = [];
30
+ let bad = 0;
31
+ for (const f of files) {
32
+ const now = readFileSync(join(worktree, newDir, f), 'utf8');
33
+ let head;
34
+ try { head = execFileSync('git', ['-C', worktree, 'show', `HEAD:${oldDir}/${f}`], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); }
35
+ catch { out.push(`? ${f}: no HEAD version at ${oldDir}/${f}`); bad++; continue; }
36
+ const n = bodyOf(now, headerLine);
37
+ const h = bodyOf(head, headerLine);
38
+ const same = n.body === h.body;
39
+ if (!same) bad++;
40
+ out.push(`${same ? 'SAME' : 'DIFF'} ${f}: body ${sha(n.body)} vs HEAD ${sha(h.body)}; header now ${n.header.length} chars, HEAD header ${h.header.length} chars`);
41
+ }
42
+ out.push(bad ? `${bad} file(s) NOT proven` : 'all bodies byte-identical to HEAD');
43
+ return { ok: bad === 0, out };
44
+ }
45
+
46
+ function selfTest() {
47
+ const dir = scratch('witness-bodycheck');
48
+ try {
49
+ const git = (...args) => execFileSync('git', ['-C', dir, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
50
+ git('init', '-q');
51
+ mkdirSync(join(dir, 'research'), { recursive: true });
52
+ const body = '# A doc\n\n> what it is\n\nThe body a reader relies on.\n';
53
+ writeFileSync(join(dir, 'research', 'a.md'), body.replace('\n\n> what', '\n<!-- meta: type=research; status=draft -->\n\n> what'));
54
+ writeFileSync(join(dir, 'research', 'b.md'), body);
55
+ git('add', 'research');
56
+ git('-c', 'user.name=t', '-c', 'user.email=t@local', 'commit', '-q', '-m', 'docs');
57
+ mkdirSync(join(dir, 'archive'), { recursive: true });
58
+ writeFileSync(join(dir, 'archive', 'a.md'), body.replace('\n\n> what', '\n<!-- meta: type=research; status=archived -->\n\n> what'));
59
+ writeFileSync(join(dir, 'archive', 'b.md'), `---\nstatus: deprecated\n---\n${body.replace('relies on', 'once relied on')}`);
60
+ const { out } = bodycheck(dir, 'archive', 'research', ['a.md', 'b.md']);
61
+ return report('witness-bodycheck', [
62
+ { name: 'a filed doc whose header alone changed reads SAME', ok: out.some((l) => l.startsWith('SAME a.md')), detail: out.join(' | ') },
63
+ { name: 'CONTROL: a filed doc whose body changed reads DIFF, header aside', ok: out.some((l) => l.startsWith('DIFF b.md')), detail: out.join(' | ') },
64
+ ]);
65
+ } finally {
66
+ rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ }
69
+
70
+ if (isMain(import.meta.url)) {
71
+ const argv = process.argv.slice(2);
72
+ if (argv.includes('--self-test')) process.exit(selfTest());
73
+ const [worktree, newDir, oldDir, ...files] = positionals(argv, ['--header-line']);
74
+ if (!worktree || !newDir || !oldDir || files.length === 0) { console.log('usage: witness-bodycheck.mjs <worktree> <newRelDir> <oldRelDir> <relPath>... [--header-line <regex>]'); process.exit(2); }
75
+ const { ok, out } = bodycheck(worktree, newDir, oldDir, files, flag(argv, '--header-line') ?? DEFAULT_HEADER_LINE);
76
+ for (const line of out) console.log(line);
77
+ process.exit(ok ? 0 : 1);
78
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Every edit and write a session made in a time window, the old and new text clipped, and its own words
3
+ // that match a filter: how to tell "fixed the code" from "loosened the test". Read-only.
4
+ //
5
+ // node witness-edits.mjs <file.jsonl> <fromIso> <toIso> [textFilterRegex] [--worktree-key <folder>]
6
+ // node witness-edits.mjs --self-test
7
+
8
+ import { rmSync } from 'node:fs';
9
+ import { assistantText, at, clip, contentOf, entries, flag, isMain, positionals, report, scratch, toolUse, writeTranscript } from './witness-lib.mjs';
10
+
11
+ export async function edits(file, from, to, filter = null, key = null) {
12
+ const textFilter = filter ? new RegExp(filter, 'i') : null;
13
+ const out = [];
14
+ for await (const { entry } of entries(file)) {
15
+ const ts = entry.timestamp ?? '';
16
+ if (ts < from || ts > to) continue;
17
+ for (const c of contentOf(entry)) {
18
+ if (c.type === 'tool_use' && /^(Edit|Write|MultiEdit)$/.test(c.name)) {
19
+ const full = String(c.input?.file_path ?? '');
20
+ const path = key ? (full.split(new RegExp(`${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\\\/]`))[1] ?? full) : full;
21
+ out.push(`${ts.slice(11, 19)} ${c.name} ${path}`);
22
+ if (c.name === 'Edit') {
23
+ out.push(` - ${clip(c.input.old_string, 240)}`);
24
+ out.push(` + ${clip(c.input.new_string, 240)}`);
25
+ }
26
+ } else if (c.type === 'text' && textFilter && textFilter.test(c.text ?? '')) {
27
+ out.push(`${ts.slice(11, 19)} TEXT ${clip(c.text, 420)}`);
28
+ }
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ async function selfTest() {
35
+ const dir = scratch('witness-edits');
36
+ try {
37
+ const file = writeTranscript(dir, 'seat.jsonl', [
38
+ toolUse(1, 'a', 'Edit', { file_path: 'C:/work/pipeline-7/src/a.ts', old_string: 'return 1', new_string: 'return 2' }),
39
+ toolUse(10, 'b', 'Edit', { file_path: 'C:/work/pipeline-7/tests/a.test.ts', old_string: 'assert.equal(x, 2)', new_string: 'assert.ok(x)' }),
40
+ assistantText(11, 'I loosened the assertion so the test passes'),
41
+ toolUse(30, 'c', 'Write', { file_path: 'C:/work/pipeline-7/src/b.ts' }),
42
+ ]);
43
+ const lines = await edits(file, at(5), at(20), 'loosened', 'pipeline-7');
44
+ return report('witness-edits', [
45
+ { name: 'an edit in the window is listed with its old and new text, path inside the worktree', ok: lines.some((l) => l.endsWith('Edit tests/a.test.ts')) && lines.some((l) => l.includes('- assert.equal(x, 2)')), detail: lines.join(' | ') },
46
+ { name: 'the session\'s own words matching the filter are listed', ok: lines.some((l) => l.includes('TEXT I loosened the assertion')), detail: lines.join(' | ') },
47
+ { name: 'CONTROL: edits outside the window are not', ok: !lines.some((l) => l.includes('src/a.ts') || l.includes('src/b.ts')), detail: lines.join(' | ') },
48
+ ]);
49
+ } finally {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ }
53
+
54
+ if (isMain(import.meta.url)) {
55
+ const argv = process.argv.slice(2);
56
+ if (argv.includes('--self-test')) process.exit(await selfTest());
57
+ const [file, from, to, filter] = positionals(argv, ['--worktree-key']);
58
+ if (!file || !from || !to) { console.log('usage: witness-edits.mjs <file.jsonl> <fromIso> <toIso> [textFilterRegex] [--worktree-key <folder>]'); process.exit(2); }
59
+ for (const line of await edits(file, from, to, filter ?? null, flag(argv, '--worktree-key') ?? null)) console.log(line);
60
+ }