@naswerks/engineering-loop 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -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 +41 -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 +17 -5
- package/skills/init/scripts/owned.mjs +10 -0
- package/skills/spec-build/SKILL.md +8 -4
- package/skills/spec-coordinator/SKILL.md +103 -58
- package/skills/spec-ignite/SKILL.md +25 -21
- package/skills/spec-pipeline/SKILL.md +128 -80
- package/skills/spec-retro/SKILL.md +9 -4
- package/skills/spec-review/SKILL.md +22 -10
- package/skills/spec-seat/SKILL.md +63 -31
- 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.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,111 @@ 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
|
+
|
|
8
113
|
## [0.7.1] - 2026-10-04
|
|
9
114
|
|
|
10
115
|
### 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
|
+
}
|
|
@@ -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
|
+
}
|