@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +199 -0
- package/README.md +12 -7
- package/bin/verify-staged.mjs +136 -23
- package/bin/witness-acts.mjs +64 -0
- package/bin/witness-bodycheck.mjs +78 -0
- package/bin/witness-edits.mjs +60 -0
- package/bin/witness-extract-cmd.mjs +57 -0
- package/bin/witness-extract-seed.mjs +65 -0
- package/bin/witness-file-ledger.mjs +57 -0
- package/bin/witness-gaps.mjs +57 -0
- package/bin/witness-latency.mjs +55 -0
- package/bin/witness-lib.mjs +124 -0
- package/bin/witness-plane-window.mjs +57 -0
- package/bin/witness-procs.mjs +71 -0
- package/bin/witness-spawn-task.mjs +61 -0
- package/bin/witness-tx.mjs +60 -0
- package/bin/witness-watch.mjs +405 -0
- package/bin/witness-writes.mjs +64 -0
- package/package.json +1 -1
- package/references/docs-workflow.md +44 -24
- package/references/engineering-loop.md +42 -24
- package/skills/docs-audit-full/SKILL.md +2 -1
- package/skills/docs-process/SKILL.md +13 -7
- package/skills/docs-write/SKILL.md +1 -1
- package/skills/init/scripts/doctor.mjs +43 -5
- package/skills/init/scripts/owned.mjs +10 -0
- package/skills/spec-build/SKILL.md +12 -4
- package/skills/spec-coordinator/SKILL.md +149 -80
- package/skills/spec-ignite/SKILL.md +25 -21
- package/skills/spec-pipeline/SKILL.md +151 -82
- package/skills/spec-retro/SKILL.md +14 -9
- package/skills/spec-review/SKILL.md +32 -14
- package/skills/spec-seat/SKILL.md +107 -48
- package/skills/spec-witness/SKILL.md +267 -212
- package/templates/docs/readme-research.md +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naswerks",
|
|
3
|
-
"version": "0.
|
|
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 |
|
|
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
|
|
96
|
-
every skill that needs one carries the same find-format line; exactly four
|
|
97
|
-
by the one grep; the loop's own preferences stay stated in the method
|
|
98
|
-
every line outside those sections byte for byte
|
|
99
|
-
|
|
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
|
|
package/bin/verify-staged.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|