@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.
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 +105 -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 +103 -58
  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
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // A transcript digest, one line per event: its line number, its time, its kind and a clipped payload. The
3
+ // fastest read of what a session did, without holding the transcript in context. Read-only.
4
+ //
5
+ // node witness-tx.mjs <file.jsonl> [fromLine] [maxText]
6
+ // node witness-tx.mjs --self-test
7
+
8
+ import { rmSync } from 'node:fs';
9
+ import { assistantText, at, clip, contentOf, entries, isMain, positionals, report, resultText, scratch, toolResult, toolUse, userText, writeTranscript } from './witness-lib.mjs';
10
+
11
+ export async function digest(file, from = 0, max = 400) {
12
+ const out = [];
13
+ let last = '?';
14
+ let count = 0;
15
+ for await (const { line, entry } of entries(file)) {
16
+ count = line;
17
+ last = entry.timestamp ?? last;
18
+ if (line < from) continue;
19
+ const ts = (entry.timestamp ?? '').replace('T', ' ').slice(0, 19);
20
+ if (!entry.message) { out.push(`${line} ${ts} ${entry.type ?? '?'} ${clip(entry.subtype ?? entry.content ?? '', max)}`); continue; }
21
+ for (const c of contentOf(entry)) {
22
+ if (c.type === 'text') out.push(`${line} ${ts} ${entry.message.role}:text ${clip(c.text, max)}`);
23
+ else if (c.type === 'tool_use') out.push(`${line} ${ts} tool_use ${c.name} ${clip(JSON.stringify(c.input), max)}`);
24
+ else if (c.type === 'tool_result') out.push(`${line} ${ts} tool_result${c.is_error ? ' ERROR' : ''} ${clip(resultText(c), max)}`);
25
+ else if (c.type === 'thinking') out.push(`${line} ${ts} thinking ${clip(c.thinking, max)}`);
26
+ else out.push(`${line} ${ts} ${c.type}`);
27
+ }
28
+ }
29
+ out.push(`-- ${count} lines; last ts ${last}`);
30
+ return out;
31
+ }
32
+
33
+ async function selfTest() {
34
+ const dir = scratch('witness-tx');
35
+ try {
36
+ const file = writeTranscript(dir, 'seat.jsonl', [
37
+ userText(0, 'the seed'),
38
+ toolUse(1, 'a', 'Bash', { command: 'git status' }),
39
+ toolResult(1, 'a', 'boom', true),
40
+ assistantText(2, 'x'.repeat(50)),
41
+ { type: 'system', subtype: 'compact_boundary', timestamp: at(3) },
42
+ ]);
43
+ const lines = await digest(file, 0, 20);
44
+ return report('witness-tx', [
45
+ { name: 'each event kind gets its line', ok: ['user:text the seed', 'tool_use Bash', 'tool_result ERROR boom', 'system compact_boundary'].every((k) => lines.some((l) => l.includes(k))), detail: lines.join(' | ') },
46
+ { name: 'a long text is clipped to the limit', ok: lines.some((l) => l.includes(`assistant:text ${'x'.repeat(20)}...`)), detail: lines.join(' | ') },
47
+ { name: 'CONTROL: lines before fromLine are skipped and the total still counts them', ok: (await digest(file, 4, 20)).every((l) => !l.includes('the seed')) && lines.at(-1).startsWith('-- 5 lines'), detail: lines.at(-1) },
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, max] = positionals(argv);
58
+ if (!file) { console.log('usage: witness-tx.mjs <file.jsonl> [fromLine] [maxText]'); process.exit(2); }
59
+ for (const line of await digest(file, Number(from ?? 0), Number(max ?? 400))) console.log(line);
60
+ }
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ // The witness's watcher, config-driven: one line per CHANGE in a running pipeline, read from git, the board
3
+ // files, the transcripts, the lane logs, the process table and the containers. Read-only on the run: it
4
+ // writes only its own state (`watch-state.json`), its event log (`events.log`) and its captures
5
+ // (`captures/`), all in the witness folder beside the config.
6
+ //
7
+ // Quiet by default: routine lane and process lines go to the event log only. A restart replays from the
8
+ // saved marks rather than re-baselining, so nothing written while the watcher was down is lost; a fresh
9
+ // baseline marks every existing transcript as already seeded, so a later first message there is a human
10
+ // touch. Commits are read by `--first-parent` and diffed against `{sha}~1`; lane output is read with its
11
+ // colour codes stripped; a flag fires only on a gated call's own result, never on text a seat read.
12
+ //
13
+ // node witness-watch.mjs [--config <path>] watch until stopped
14
+ // node witness-watch.mjs --once [--config <path>] one pass, then exit
15
+ // node witness-watch.mjs --self-test
16
+ //
17
+ // The config (`witness.config.json`): worktree, topicDir, transcriptDir, mainCheckout, branches, logParent
18
+ // and logPrefix, logScan [{ parent, prefix }], extraLogDirs [{ label, dir }], ownerContainers, planeServer,
19
+ // testProcessPattern, sourceDirs, generatedPaths, noisePaths, hooksDir, seatContainerPrefix,
20
+ // reaperContainerPrefix, watchedCommands (extra patterns), pollSeconds, probes { processes, docker }.
21
+
22
+ import { execFileSync } from 'node:child_process';
23
+ import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { basename, join } from 'node:path';
25
+ import { PLANE_READS, clip, isMain, loadConfig, report, scratch, at } from './witness-lib.mjs';
26
+ import { classify, listProcesses } from './witness-procs.mjs';
27
+
28
+ const STALL_STEPS_MIN = [20, 40, 60, 90];
29
+ const QUIET = /^(PROC |LANE |BOARD sessions\/[^ ]+\/plan\.md )/;
30
+ const WATCHED = String.raw`git\s+(push|stash|reset\s+--hard|clean\s+-[a-z]*f|checkout\s+--\s|rebase|merge(?!-base)|cherry-pick|config\s+(?!--get|--show|--list|-l\b|get\b|list\b))|Stop-Process|taskkill|docker\s+(rm|stop|kill)\b|--no-verify`;
31
+ const RESULT_FLAGS = [
32
+ [/This call was held for a person|nobody answered within \d+ ?s/i, 'PARK'],
33
+ [/hook error: hook_timed_out/i, 'HOOK-TIMEOUT'],
34
+ [/NOT DELIVERED|link-send-failed|no live link/i, 'UNDELIVERED'],
35
+ [/Dynamic Client Registration rejected|HTTP 5\d\d|"status":5\d\d|seat-already-has-live-session/i, 'PLANE-FAIL'],
36
+ [/hook error: shell-verb-unrecognised/i, 'GUARD-REFUSED'],
37
+ [/head-mismatch/i, 'HEAD-MISMATCH'],
38
+ ];
39
+ // Lane summaries, by the runners that print them. Non-ASCII is written as an escape.
40
+ const SUMMARIES = [
41
+ { re: /Test run summary:[^\n]*(\n\s+(total|failed|succeeded|skipped|duration):[^\n]*)*/, zero: /total:\s*0\b/ },
42
+ { re: /Test Files\s+[^\n]*\n\s*Tests\s+[^\n]*/, zero: /Tests\s+0\b/ },
43
+ { re: /\u2139 tests \d+[^\n]*(\n\u2139 [a-z]+ \d+[^\n]*)*/, zero: /\u2139 tests 0\b/ },
44
+ { re: /\u2716 \d+ problems? \([^)]*\)/, zero: null },
45
+ { re: /\d+ Warning\(s\)\s*\n\s*\d+ Error\(s\)/, zero: null },
46
+ ];
47
+ const FAILED = /Failed!|failed:\s*[1-9]|Tests\s+\d+ failed|Test Files\s+\d+ failed|\u2139 fail [1-9]|\(\s*[1-9]\d* errors?\b|\b[1-9]\d* Error\(s\)/;
48
+
49
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
+ const now = () => new Date().toTimeString().slice(0, 8);
51
+
52
+ function run(cmd, args) {
53
+ try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 }).trim(); }
54
+ catch { return null; }
55
+ }
56
+ const git = (dir, ...args) => run('git', ['--no-optional-locks', '-C', dir, ...args]);
57
+
58
+ function walk(dir, filter) {
59
+ if (!existsSync(dir)) return [];
60
+ const out = [];
61
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
62
+ const p = join(dir, e.name);
63
+ if (e.isDirectory()) out.push(...walk(p, filter));
64
+ else if (filter(e.name)) out.push(p);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function readRange(file, from) {
70
+ const size = statSync(file).size;
71
+ if (size <= from) return '';
72
+ const fd = openSync(file, 'r');
73
+ const buf = Buffer.alloc(size - from);
74
+ readSync(fd, buf, 0, size - from, from);
75
+ closeSync(fd);
76
+ return buf.toString('utf8');
77
+ }
78
+ const tail = (file, bytes) => readRange(file, Math.max(0, statSync(file).size - bytes)).replace(/\r/g, '').replace(/\x1b\[[0-9;]*m/g, '');
79
+
80
+ const matchesAny = (path, entries) => entries.some((e) => path.includes(e));
81
+
82
+ export class Watcher {
83
+ constructor(config, write = (line) => console.log(line)) {
84
+ if (!config.worktree || !config.topicDir || !config.transcriptDir) throw new Error('the config needs worktree, topicDir and transcriptDir');
85
+ this.c = config;
86
+ this.wt = config.worktree;
87
+ this.key = basename(config.worktree);
88
+ this.main = config.mainCheckout ?? config.worktree;
89
+ this.topic = join(config.worktree, config.topicDir);
90
+ this.prefix = config.planeServer ? `mcp__${config.planeServer}__` : null;
91
+ this.watched = new RegExp([WATCHED, ...config.watchedCommands].join('|'));
92
+ this.realTree = new RegExp(`${esc(this.key)}[\\\\/]+(${config.sourceDirs.map(esc).join('|')})[\\\\/]`);
93
+ this.stateFile = join(config.dir, 'watch-state.json');
94
+ this.events = join(config.dir, 'events.log');
95
+ this.write = write;
96
+ }
97
+
98
+ emit(msg) {
99
+ const line = `${now()} ${msg}`;
100
+ if (!QUIET.test(msg)) this.write(line);
101
+ appendFileSync(this.events, `${line}\n`);
102
+ }
103
+
104
+ logDirs() {
105
+ const scans = [...(this.c.logParent ? [{ parent: this.c.logParent, prefix: this.c.logPrefix ?? '' }] : []), ...this.c.logScan];
106
+ const found = scans.flatMap(({ parent, prefix }) => (existsSync(parent)
107
+ ? readdirSync(parent, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith(prefix)).map((e) => ({ label: e.name, dir: join(parent, e.name) }))
108
+ : []));
109
+ return [{ label: '', dir: join(this.wt, 'logs') }, ...this.c.extraLogDirs, ...found];
110
+ }
111
+
112
+ transcriptFiles() {
113
+ const dir = this.c.transcriptDir;
114
+ if (!existsSync(dir)) return [];
115
+ const top = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
116
+ const subagents = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(dir, e.name, 'subagents')))
117
+ .flatMap((e) => readdirSync(join(dir, e.name, 'subagents')).filter((n) => n.endsWith('.jsonl')).map((n) => `${e.name}/subagents/${n}`));
118
+ return [...top, ...subagents];
119
+ }
120
+
121
+ snapshot() {
122
+ const s = { board: {}, head: null, transcripts: {}, logs: {}, logAbs: {}, ends: [], reapers: 0, seatStack: [], owner: {}, testProcs: [], runner: [] };
123
+ for (const f of walk(this.topic, (n) => n.endsWith('.md'))) s.board[f.slice(this.topic.length + 1).replace(/\\/g, '/')] = statSync(f).size;
124
+ s.head = git(this.wt, 'rev-parse', '--short', 'HEAD');
125
+ for (const f of this.transcriptFiles()) s.transcripts[f] = statSync(join(this.c.transcriptDir, f)).size;
126
+ for (const { label, dir } of this.logDirs()) {
127
+ for (const f of walk(dir, (n) => n.endsWith('.txt') || n.endsWith('.log') || n.endsWith('.end'))) {
128
+ const rel = (label ? `${label}:` : '') + f.slice(dir.length + 1).replace(/\\/g, '/');
129
+ if (f.endsWith('.end')) s.ends.push(rel);
130
+ else { s.logs[rel] = statSync(f).size; s.logAbs[rel] = f; }
131
+ }
132
+ }
133
+ if (this.c.probes.docker) {
134
+ const names = (run('docker', ['ps', '--format', '{{.Names}}']) ?? '').split('\n').filter(Boolean);
135
+ for (const name of names) {
136
+ if (name.startsWith(this.c.reaperContainerPrefix)) s.reapers++;
137
+ if (this.c.seatContainerPrefix && name.startsWith(this.c.seatContainerPrefix)) s.seatStack.push(name);
138
+ }
139
+ for (const c of this.c.ownerContainers) s.owner[c] = names.includes(c);
140
+ }
141
+ if (this.c.probes.processes) {
142
+ let lines = [];
143
+ try { lines = classify(listProcesses(), { key: this.key, pattern: this.c.testProcessPattern ?? null }); } catch { lines = []; }
144
+ for (const l of lines) (l.startsWith('RUNNER|') ? s.runner : s.testProcs).push(l);
145
+ }
146
+ const porcelain = (git(this.wt, 'status', '--porcelain', '-uall', '--', ...this.c.sourceDirs) ?? '').split('\n').filter(Boolean);
147
+ s.noiseDirty = porcelain.filter((l) => matchesAny(l, this.c.noisePaths)).length;
148
+ s.srcDirty = porcelain.length - s.noiseDirty;
149
+ s.worktrees = (git(this.main, 'worktree', 'list', '--porcelain') ?? '').split('\n').filter((l) => l.startsWith('worktree ')).map((l) => l.slice(9)).sort();
150
+ s.hooksPath = git(this.main, 'config', '--get', 'core.hooksPath');
151
+ s.hooksWired = this.c.hooksDir ? existsSync(join(this.wt, this.c.hooksDir)) : null;
152
+ return s;
153
+ }
154
+
155
+ newestHeading(rel) {
156
+ try { return readFileSync(join(this.topic, rel), 'utf8').split('\n').filter((l) => l.startsWith('## ')).at(-1)?.slice(3, 120) ?? '(no heading)'; }
157
+ catch { return '?'; }
158
+ }
159
+
160
+ // New transcript bytes: plane writes and their results, the first source write, watch-listed commands,
161
+ // flags on gated calls, limits, compaction, and the seed / delivery / human turns.
162
+ scanTranscript(name, from, seen) {
163
+ const text = readRange(join(this.c.transcriptDir, name), from);
164
+ const complete = text.lastIndexOf('\n');
165
+ if (complete < 0) return from;
166
+ const sid = basename(name, '.jsonl').slice(0, 8);
167
+ for (const raw of text.slice(0, complete).split('\n')) {
168
+ if (!raw.trim()) continue;
169
+ let e;
170
+ try { e = JSON.parse(raw); } catch { continue; }
171
+ if (e.type === 'system' && /compact/i.test(e.subtype ?? '')) this.emit(`TX ${sid} COMPACTION (${e.subtype})`);
172
+ const msg = e.message;
173
+ if (!msg) continue;
174
+ const content = Array.isArray(msg.content) ? msg.content : [{ type: 'text', text: msg.content }];
175
+ for (const c of content) {
176
+ if (c.type === 'tool_use') {
177
+ const isPlane = this.prefix !== null && String(c.name).startsWith(this.prefix);
178
+ const verb = isPlane ? c.name.slice(this.prefix.length) : null;
179
+ if (isPlane && !PLANE_READS.test(verb)) {
180
+ this.emit(`PLANE ${sid} ${verb} ${clip(JSON.stringify(c.input), 160)}`);
181
+ seen.pending[c.id] = `${sid} ${verb}`;
182
+ }
183
+ seen.uses[c.id] = { name: c.name, read: isPlane && PLANE_READS.test(verb), label: `${c.name} ${clip(JSON.stringify(c.input), 90)}` };
184
+ const p = String(c.input?.file_path ?? '');
185
+ if (/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(c.name) && this.realTree.test(p)) {
186
+ seen.srcWrites[sid] = (seen.srcWrites[sid] ?? 0) + 1;
187
+ if (seen.srcWrites[sid] === 1) this.emit(`FIRST-WRITE ${sid} first source write at ${e.timestamp}: ${p.split(new RegExp(`${esc(this.key)}[\\\\/]+`))[1]} : check it against this seat's accept_plan`);
188
+ }
189
+ const cmd = String(c.input?.command ?? '');
190
+ if (this.watched.test(cmd)) this.emit(`CMD ${sid} watch-listed command: ${clip(cmd, 200)}`);
191
+ } else if (c.type === 'tool_result') {
192
+ const body = Array.isArray(c.content) ? c.content.map((x) => x.text ?? '').join(' ') : String(c.content ?? '');
193
+ if (seen.pending[c.tool_use_id]) {
194
+ this.emit(`PLANE ${seen.pending[c.tool_use_id]} -> ${c.is_error ? 'ERROR ' : ''}${clip(body, 200)}`);
195
+ delete seen.pending[c.tool_use_id];
196
+ }
197
+ // A flag fires on the gated call's own result, never on text a seat merely read.
198
+ const use = seen.uses[c.tool_use_id];
199
+ const echoes = !use || /^(Read|Grep|Glob|TodoWrite)$/.test(use.name) || (use.read && !c.is_error);
200
+ if (!echoes) {
201
+ for (const [re, tag] of RESULT_FLAGS) {
202
+ if (!re.test(body)) continue;
203
+ this.emit(`${tag} ${sid} at ${e.timestamp} on ${use.label}: ${clip(body.match(new RegExp(`.{0,80}${re.source}.{0,100}`, 'i'))?.[0] ?? body, 220)}`);
204
+ break;
205
+ }
206
+ }
207
+ delete seen.uses[c.tool_use_id];
208
+ } else if (c.type === 'text' && msg.role === 'assistant') {
209
+ if (/hit your (session|usage) limit/i.test(String(c.text ?? ''))) this.emit(`LIMIT ${sid} at ${e.timestamp}: ${clip(c.text, 160)}`);
210
+ } else if (c.type === 'text' && msg.role === 'user' && !e.isMeta && !e.toolUseResult) {
211
+ const t = String(c.text ?? '');
212
+ if (/hit your (session|usage) limit/i.test(t)) { this.emit(`LIMIT ${sid} at ${e.timestamp}: ${clip(t, 160)}`); continue; }
213
+ if (/^<(task-notification|system-reminder|command-|local-command)/.test(t.trim()) || t.startsWith('Base directory for this skill')) continue;
214
+ if (t.startsWith('This session is being continued from a previous conversation')) { this.emit(`TX ${sid} post-compaction summary at ${e.timestamp} (the session's own account of its beliefs: read it)`); continue; }
215
+ if (!seen.seeded[sid]) { seen.seeded[sid] = true; this.emit(`SEED ${sid} first prompt at ${e.timestamp} (the seed, not a human touch): ${clip(t, 120)}`); continue; }
216
+ if (/^\s*\[[^\]]*\bact \d+/.test(t)) { this.emit(`DELIVERY ${sid} plane-delivered turn at ${e.timestamp}: ${clip(t, 140)}`); continue; }
217
+ this.emit(`HUMAN? ${sid} user text at ${e.timestamp}: ${clip(t, 160)}`);
218
+ }
219
+ }
220
+ }
221
+ return from + Buffer.byteLength(text.slice(0, complete + 1), 'utf8');
222
+ }
223
+
224
+ diff(prev, cur) {
225
+ cur.txMark = { ...(prev.txMark ?? {}) };
226
+ cur.lastHeading = { ...(prev.lastHeading ?? {}) };
227
+ cur.seen = prev.seen;
228
+ for (const [f, size] of Object.entries(cur.board)) {
229
+ if (prev.board[f] === undefined) this.emit(`BOARD new file ${f} (${size} B), newest heading: ${this.newestHeading(f)}`);
230
+ else if (prev.board[f] !== size) {
231
+ const h = this.newestHeading(f);
232
+ if (cur.lastHeading[f] !== h) { cur.lastHeading[f] = h; this.emit(`BOARD ${f} ${prev.board[f]} -> ${size} B, newest heading: ${h}`); }
233
+ }
234
+ }
235
+ if (prev.head && cur.head && prev.head !== cur.head) {
236
+ const shas = (git(this.wt, 'log', '--first-parent', '--format=%h', `${prev.head}..${cur.head}`) ?? '').split('\n').filter(Boolean).reverse();
237
+ if (!shas.length) this.emit(`GIT HEAD moved ${prev.head} -> ${cur.head} (not a fast-forward: a reset or a checkout?)`);
238
+ for (const c of shas) {
239
+ const subject = git(this.wt, 'log', '-1', '--format=%s', c) ?? '';
240
+ const files = (git(this.wt, 'diff', '--name-only', `${c}~1`, c) ?? '').split('\n').filter(Boolean);
241
+ const src = files.filter((f) => this.c.sourceDirs.some((d) => f.startsWith(`${d}/`))).length;
242
+ const generated = files.filter((f) => matchesAny(f, this.c.generatedPaths)).length;
243
+ const noise = files.filter((f) => matchesAny(f, this.c.noisePaths)).length;
244
+ const parents = (git(this.wt, 'rev-list', '--parents', '-n', '1', c) ?? '').split(' ').length - 1;
245
+ const brought = parents > 1 ? ` (a merge: brings ${git(this.wt, 'rev-list', '--count', `${c}^1..${c}^2`)} commits)` : '';
246
+ this.emit(`GIT commit ${c} "${clip(subject, 100)}" : ${files.length} files (source ${src}, generated ${generated}, noise ${noise})${brought}`);
247
+ }
248
+ if (this.c.branches.length) {
249
+ const remote = git(this.wt, 'ls-remote', '--heads', 'origin', ...this.c.branches);
250
+ if (remote) this.emit(`GIT PUSHED? remote heads now: ${clip(remote, 200)}`);
251
+ }
252
+ }
253
+ for (const [f, size] of Object.entries(cur.transcripts)) {
254
+ if (prev.transcripts[f] === undefined) this.emit(`SESSION new transcript ${f} (a new seat or session)`);
255
+ const from = cur.txMark[f] ?? (prev.transcripts[f] ?? 0);
256
+ if (size > from) cur.txMark[f] = this.scanTranscript(f, from, cur.seen);
257
+ }
258
+ for (const end of cur.ends) if (!prev.ends.includes(end)) this.emit(`LANE finished: ${end}`);
259
+ cur.summaries = { ...(prev.summaries ?? {}) };
260
+ cur.exits = { ...(prev.exits ?? {}) };
261
+ for (const [f, size] of Object.entries(cur.logs)) {
262
+ if (prev.logs[f] === size) continue;
263
+ if (prev.logs[f] === undefined) this.emit(`LANE new log ${f}`);
264
+ const t = tail(cur.logAbs[f], 6000);
265
+ for (const { re, zero } of SUMMARIES) {
266
+ const m = t.match(re);
267
+ if (!m || cur.summaries[f + m[0]]) continue;
268
+ cur.summaries[f + m[0]] = true;
269
+ const flat = m[0].replace(/\s+/g, ' ');
270
+ this.emit(`${zero && zero.test(flat) ? 'ALERT ZERO-TESTS ' : FAILED.test(flat) ? 'FAIL ' : 'LANE '}${f}: ${flat}`);
271
+ }
272
+ const exit = t.match(/(EXIT|exit)[=: ]\s*\d+/g)?.at(-1);
273
+ if (exit && !cur.exits[f + exit + size]) {
274
+ cur.exits[f + exit + size] = true;
275
+ const failed = !/[=: ]\s*0$/.test(exit);
276
+ this.emit(`${failed ? 'FAIL ' : 'LANE '}${f}: ${exit}`);
277
+ if (failed) {
278
+ const cap = join(this.c.dir, 'captures');
279
+ mkdirSync(cap, { recursive: true });
280
+ copyFileSync(cur.logAbs[f], join(cap, `${now().replace(/:/g, '')}-${f.replace(/[\\/:]/g, '_')}`));
281
+ }
282
+ }
283
+ }
284
+ if ((prev.srcDirty ?? 0) === 0 && cur.srcDirty > 0) this.emit(`TREE first dirty source path since the last commit (${cur.srcDirty})`);
285
+ if ((prev.srcDirty ?? 0) >= 5 && cur.srcDirty <= prev.srcDirty / 2 && prev.head === cur.head) this.emit(`ALERT uncommitted source changes dropped ${prev.srcDirty} -> ${cur.srcDirty} with NO commit : a stash, a reset or a clean?`);
286
+ if (!prev.noiseDirty && cur.noiseDirty) this.emit('TREE a noise path is dirty (a build side effect or a real change: never sweep it into a commit)');
287
+ for (const f of Object.keys(cur.board)) if (prev.board[f] === undefined && f.includes('sessions/') && cur.srcDirty > 0) this.emit(`ORDER new board file ${f} while ${cur.srcDirty} uncommitted source path(s) remain`);
288
+ if ((prev.testProcs ?? []).join() !== cur.testProcs.join()) this.emit(`PROC work processes: ${cur.testProcs.length ? cur.testProcs.join(', ') : 'none'}`);
289
+ if (this.c.probes.processes && (prev.runner ?? []).join() !== cur.runner.join()) this.emit(`RUNNER processes: ${cur.runner.length ? cur.runner.join(', ') : 'NONE (a restart ends every live session)'}`);
290
+ for (const [c, up] of Object.entries(cur.owner)) if (prev.owner?.[c] && !up) this.emit(`OWNER container ${c} is no longer running : check whether the run touched it`);
291
+ const added = cur.worktrees.filter((w) => !(prev.worktrees ?? []).includes(w));
292
+ const gone = (prev.worktrees ?? []).filter((w) => !cur.worktrees.includes(w));
293
+ if (added.length || gone.length) this.emit(`WORKTREE list changed: +[${added.join(', ')}] -[${gone.join(', ')}]`);
294
+ if (prev.hooksPath !== cur.hooksPath) this.emit(`HOOKS shared core.hooksPath changed: ${prev.hooksPath} -> ${cur.hooksPath}`);
295
+ if (prev.hooksWired !== cur.hooksWired) this.emit(`HOOKS this worktree's ${this.c.hooksDir} ${cur.hooksWired ? 'now present (hooks fire)' : 'GONE (hooks silently off)'}`);
296
+ cur.reaperHighPolls = cur.reapers > 1 ? (prev.reaperHighPolls ?? 0) + 1 : 0;
297
+ if (cur.reaperHighPolls === 2) this.emit(`DOCKER ${cur.reapers} test sessions for two polls : more than one suite running`);
298
+ if (cur.seatStack.join() !== prev.seatStack.join()) this.emit(`DOCKER seat stack: ${cur.seatStack.length ? cur.seatStack.join(', ') : 'none running'}`);
299
+ const newest = Math.max(0, ...Object.keys(cur.transcripts).map((f) => statSync(join(this.c.transcriptDir, f)).mtimeMs));
300
+ const idleMin = newest ? Math.floor((Date.now() - newest) / 60000) : 0;
301
+ const level = STALL_STEPS_MIN.filter((m) => idleMin >= m).length;
302
+ cur.stallLevel = prev.stallLevel ?? 0;
303
+ if (level > cur.stallLevel) this.emit(`STALL no transcript activity in ${idleMin} min : check the process table before calling it stuck`);
304
+ if (level === 0 && cur.stallLevel > 0) this.emit('STALL cleared: transcripts moving again');
305
+ cur.stallLevel = level;
306
+ }
307
+
308
+ // Arm from the saved marks, or take a fresh baseline; then one pass, or a pass per poll.
309
+ async watch({ once = false } = {}) {
310
+ let prev = existsSync(this.stateFile) ? JSON.parse(readFileSync(this.stateFile, 'utf8')) : null;
311
+ if (!prev) {
312
+ prev = this.snapshot();
313
+ prev.summaries = {}; prev.exits = {}; prev.stallLevel = 0;
314
+ prev.seen = { pending: {}, uses: {}, srcWrites: {}, seeded: Object.fromEntries(Object.keys(prev.transcripts).map((f) => [basename(f, '.jsonl').slice(0, 8), true])) };
315
+ prev.txMark = { ...prev.transcripts };
316
+ writeFileSync(this.stateFile, JSON.stringify(prev));
317
+ this.emit(`ARMED baseline: head ${prev.head}, board files ${Object.keys(prev.board).length}, transcripts ${Object.keys(prev.transcripts).length} (marks at their current size), source dirty ${prev.srcDirty}, hooks wired ${prev.hooksWired}, runner ${prev.runner.length}`);
318
+ if (once) return;
319
+ } else {
320
+ prev.seen.uses ??= {}; prev.seen.seeded ??= {};
321
+ this.emit(`RE-ARMED from saved marks (head ${prev.head}); replaying anything since`);
322
+ }
323
+ for (;;) {
324
+ const cur = this.snapshot();
325
+ this.diff(prev, cur);
326
+ writeFileSync(this.stateFile, JSON.stringify(cur));
327
+ prev = cur;
328
+ if (once) return;
329
+ await new Promise((r) => setTimeout(r, this.c.pollSeconds * 1000));
330
+ }
331
+ }
332
+ }
333
+
334
+ async function selfTest() {
335
+ const root = scratch('witness-watch');
336
+ try {
337
+ const wt = join(root, 'pipeline-7');
338
+ const tx = join(root, 'transcripts');
339
+ const witness = join(root, 'witness');
340
+ for (const d of [join(wt, 'src'), join(wt, 'docs', 'research', 'demo', 'sessions', '01-a-slice'), join(wt, 'logs'), tx, witness]) mkdirSync(d, { recursive: true });
341
+ const g = (...args) => execFileSync('git', ['-C', wt, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
342
+ g('init', '-q');
343
+ writeFileSync(join(wt, 'src', 'a.ts'), 'export const a = 1;\n');
344
+ const board = join(wt, 'docs', 'research', 'demo', 'sessions', '01-a-slice', 'tldr.md');
345
+ writeFileSync(board, '## [seat·01-a-slice] PLAN TLDR\n');
346
+ g('add', '.');
347
+ g('-c', 'user.name=t', '-c', 'user.email=t@local', 'commit', '-q', '-m', 'baseline');
348
+ const line = (e) => `${JSON.stringify(e)}\n`;
349
+ const seat = join(tx, 'aaaaaaaa-seat.jsonl');
350
+ writeFileSync(seat, line({ type: 'user', timestamp: at(0), message: { role: 'user', content: 'You are a BUILD SEAT. The seed.' } }));
351
+ const config = { worktree: wt, topicDir: 'docs/research/demo', transcriptDir: tx, planeServer: 'plane', sourceDirs: ['src', 'tests'], noisePaths: ['package-lock.json'], generatedPaths: [], watchedCommands: [], branches: [], logScan: [], extraLogDirs: [], ownerContainers: [], reaperContainerPrefix: 'testcontainers-ryuk', pollSeconds: 20, probes: { processes: false, docker: false }, dir: witness };
352
+ const printed = [];
353
+ const watcher = new Watcher(config, (l) => printed.push(l));
354
+ await watcher.watch({ once: true });
355
+
356
+ // The run moves: a ruling lands on the board, a commit, a plane write, a park on a gated call and the
357
+ // same words in a file the seat read, a source write, a human message, a delivered turn, a new
358
+ // session, and a lane that ran no tests.
359
+ writeFileSync(board, '## [seat·01-a-slice] PLAN TLDR\n\n## COORDINATOR RULING (plan — accept)\n');
360
+ writeFileSync(join(wt, 'src', 'a.ts'), 'export const a = 2;\n');
361
+ writeFileSync(join(wt, 'package-lock.json'), '{}\n');
362
+ g('add', '.');
363
+ g('-c', 'user.name=t', '-c', 'user.email=t@local', 'commit', '-q', '-m', 'the slice');
364
+ const tu = (m, id, name, input) => ({ type: 'assistant', timestamp: at(m), message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
365
+ const tr = (m, id, text) => ({ type: 'user', timestamp: at(m), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }] }] } });
366
+ appendFileSync(seat, [
367
+ tu(1, 'p1', 'mcp__plane__post_tldr', { path: 'tldr.md' }), tr(1, 'p1', 'posted rev 2'),
368
+ tu(2, 'b1', 'Bash', { command: 'git push origin topic' }), tr(3, 'b1', 'This call was held for a person; nobody answered within 40 s'),
369
+ tu(4, 'r1', 'Read', { file_path: 'brief.md' }), tr(4, 'r1', 'the brief says: This call was held for a person'),
370
+ tu(5, 'e1', 'Edit', { file_path: join(wt, 'src', 'a.ts'), old_string: '1', new_string: '2' }), tr(5, 'e1', 'ok'),
371
+ { type: 'user', timestamp: at(6), message: { role: 'user', content: 'are you stuck?' } },
372
+ { type: 'user', timestamp: at(7), message: { role: 'user', content: '[coordinator act 4321] read your tldr' } },
373
+ ].map(line).join(''));
374
+ writeFileSync(join(tx, 'bbbbbbbb-review.jsonl'), line({ type: 'user', timestamp: at(8), message: { role: 'user', content: 'You are the REVIEW SEAT.' } }));
375
+ writeFileSync(join(wt, 'logs', 'unit.txt'), 'Test run summary: Passed!\n total: 0\n failed: 0\nEXIT=0\n');
376
+ await new Watcher(config, (l) => printed.push(l)).watch({ once: true });
377
+
378
+ const log = readFileSync(join(witness, 'events.log'), 'utf8');
379
+ const has = (re) => re.test(log);
380
+ const count = (re) => log.split('\n').filter((l) => re.test(l)).length;
381
+ await new Watcher(config, (l) => printed.push(l)).watch({ once: true });
382
+ const after = readFileSync(join(witness, 'events.log'), 'utf8').slice(log.length);
383
+ return report('witness-watch', [
384
+ { name: 'a fresh baseline arms and marks the existing transcript as seeded', ok: has(/ARMED baseline/) && !has(/SEED aaaaaaaa/), detail: log },
385
+ { name: 'the ruling\'s heading on the board is reported', ok: has(/BOARD sessions\/01-a-slice\/tldr\.md .*COORDINATOR RULING \(plan/), detail: log },
386
+ { name: 'the commit is reported by --first-parent with its file classes', ok: has(/GIT commit [0-9a-f]+ "the slice" : 3 files \(source 1, generated 0, noise 1\)/), detail: log },
387
+ { name: 'the plane write and its result are reported', ok: has(/PLANE aaaaaaaa post_tldr/) && has(/PLANE aaaaaaaa post_tldr -> posted rev 2/), detail: log },
388
+ { name: 'the park on the gated call is flagged, the watch-listed command too', ok: count(/^\S+ PARK /) === 1 && has(/PARK aaaaaaaa .* on Bash/) && has(/CMD aaaaaaaa watch-listed command: git push/), detail: log },
389
+ { name: 'CONTROL: the same words in a file the seat read are not a park', ok: !has(/PARK aaaaaaaa .* on Read/), detail: log },
390
+ { name: 'the first source write is reported', ok: has(/FIRST-WRITE aaaaaaaa first source write .*src[\\/]a\.ts/), detail: log },
391
+ { name: 'a later user message is a human touch, a delivered turn is a delivery, a new session\'s first message is its seed', ok: has(/HUMAN\? aaaaaaaa user text .*are you stuck/) && has(/DELIVERY aaaaaaaa/) && has(/SESSION new transcript bbbbbbbb-review\.jsonl/) && has(/SEED bbbbbbbb/), detail: log },
392
+ { name: 'a lane that ran no tests is an alert', ok: has(/ALERT ZERO-TESTS unit\.txt/), detail: log },
393
+ { name: 'CONTROL: a restart replays from the saved marks and reports nothing twice', ok: /RE-ARMED from saved marks/.test(after) && !/PARK|GIT commit|HUMAN\?/.test(after), detail: after },
394
+ { name: 'quiet mode: routine lane lines go to the log, not the terminal', ok: !printed.some((l) => / LANE new log/.test(l)) && has(/LANE new log unit\.txt/), detail: printed.join(' | ') },
395
+ ]);
396
+ } finally {
397
+ rmSync(root, { recursive: true, force: true });
398
+ }
399
+ }
400
+
401
+ if (isMain(import.meta.url)) {
402
+ const argv = process.argv.slice(2);
403
+ if (argv.includes('--self-test')) process.exit(await selfTest());
404
+ await new Watcher(loadConfig(argv, { required: true })).watch({ once: argv.includes('--once') });
405
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // When a session first wrote into the source folders, set against the plane verbs it called: the
3
+ // plan-before-build tripwire is a seat's first source write against its landed `accept_plan`. Caveats a
4
+ // ledger names with the result: a write made through a shell copy is not seen here (only the edit tools
5
+ // are), and a build's side effect can dirty a source folder with no seat edit at all. Read-only.
6
+ //
7
+ // node witness-writes.mjs <file.jsonl> <worktree-folder-name> [--source-dirs src,tests] [--plane <server>] [--config <path>]
8
+ // node witness-writes.mjs --self-test
9
+
10
+ import { rmSync } from 'node:fs';
11
+ import { contentOf, entries, flag, isMain, loadConfig, planePrefix, positionals, report, scratch, toolUse, writeTranscript } from './witness-lib.mjs';
12
+
13
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+
15
+ export async function writes(file, key, sourceDirs, prefix) {
16
+ const realTree = new RegExp(`${escape(key)}[\\\\/]+(${sourceDirs.map(escape).join('|')})[\\\\/]`);
17
+ let first = null;
18
+ let last = null;
19
+ let count = 0;
20
+ const plane = [];
21
+ for await (const { line, entry } of entries(file)) {
22
+ for (const c of contentOf(entry)) {
23
+ if (c.type !== 'tool_use') continue;
24
+ if (typeof c.name === 'string' && c.name.startsWith(prefix)) plane.push(`${line} ${entry.timestamp} ${c.name.slice(prefix.length)} ${JSON.stringify(c.input).slice(0, 120)}`);
25
+ const path = String(c.input?.file_path ?? c.input?.notebook_path ?? '');
26
+ if (!/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(c.name) || !realTree.test(path)) continue;
27
+ count++;
28
+ first ??= `${line} ${entry.timestamp} ${c.name} ${path}`;
29
+ last = `${line} ${entry.timestamp}`;
30
+ }
31
+ }
32
+ return [`first source write: ${first}`, `source writes: ${count}, last: ${last}`, 'plane calls:', ...plane];
33
+ }
34
+
35
+ async function selfTest() {
36
+ const dir = scratch('witness-writes');
37
+ try {
38
+ const file = writeTranscript(dir, 'seat.jsonl', [
39
+ toolUse(1, 'a', 'Write', { file_path: 'C:/work/pipeline-7/docs/notes.md' }),
40
+ toolUse(2, 'b', 'mcp__plane__post_plan', { path: 'plan.md' }),
41
+ toolUse(3, 'c', 'Bash', { command: 'cp draft.ts C:/work/pipeline-7/src/a.ts' }),
42
+ toolUse(4, 'd', 'Edit', { file_path: 'C:\\work\\pipeline-7\\src\\a.ts' }),
43
+ toolUse(5, 'e', 'Write', { file_path: 'C:/work/pipeline-7/tests/a.test.ts' }),
44
+ ]);
45
+ const lines = await writes(file, 'pipeline-7', ['src', 'tests'], 'mcp__plane__');
46
+ return report('witness-writes', [
47
+ { name: 'the first source write is the edit into src', ok: lines[0].startsWith('first source write: 4 ') && lines[0].includes('Edit'), detail: lines[0] },
48
+ { name: 'both source writes are counted, and the plane call is listed', ok: lines[1].startsWith('source writes: 2') && lines.some((l) => l.includes('post_plan')), detail: lines.join(' | ') },
49
+ { name: 'CONTROL: a docs write and a shell copy are not source writes', ok: !lines[0].includes('notes.md') && !lines[0].includes('cp '), detail: lines[0] },
50
+ ]);
51
+ } finally {
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ if (isMain(import.meta.url)) {
57
+ const argv = process.argv.slice(2);
58
+ if (argv.includes('--self-test')) process.exit(await selfTest());
59
+ const config = loadConfig(argv);
60
+ const [file, key] = positionals(argv, ['--plane', '--config', '--source-dirs']);
61
+ if (!file || !key) { console.log('usage: witness-writes.mjs <file.jsonl> <worktree-folder-name> [--source-dirs src,tests] [--plane <server>]'); process.exit(2); }
62
+ const sourceDirs = (flag(argv, '--source-dirs') ?? config.sourceDirs.join(',')).split(',').filter(Boolean);
63
+ for (const line of await writes(file, key, sourceDirs, planePrefix(argv, config))) console.log(line);
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naswerks/engineering-loop",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "The engineering loop as a Claude Code plugin: a knowledge loop (docs-*), an execution loop (spec-*), and init for a fresh repository. The package root is the plugin root.",
5
5
  "license": "MIT",
6
6
  "type": "module",