@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The processes doing a run's work, by the worktree they run in, and the runner's own processes. A quiet
|
|
3
|
+
// compile is a busy process, and no runner where there was one is a restart, which ends every live
|
|
4
|
+
// session. Windows reads the process table through PowerShell's CIM query, anything else through `ps`.
|
|
5
|
+
// Read-only.
|
|
6
|
+
//
|
|
7
|
+
// node witness-procs.mjs [--key <worktree-folder-name>] [--pattern <regex>] [--config <path>]
|
|
8
|
+
// -> one line per process: `RUNNER|<pid>` for the runner, `<pid>|<name>` for a match
|
|
9
|
+
// node witness-procs.mjs --self-test
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import { flag, isMain, loadConfig, report } from './witness-lib.mjs';
|
|
14
|
+
|
|
15
|
+
// Every process with its id, its name, its command line and its executable path.
|
|
16
|
+
export function listProcesses() {
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
const json = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
19
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,Name,CommandLine,ExecutablePath | ConvertTo-Json -Compress'],
|
|
20
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
21
|
+
const rows = JSON.parse(json);
|
|
22
|
+
return (Array.isArray(rows) ? rows : [rows]).map((r) => ({ pid: r.ProcessId, name: String(r.Name ?? ''), cmd: String(r.CommandLine ?? ''), exe: String(r.ExecutablePath ?? '') }));
|
|
23
|
+
}
|
|
24
|
+
const out = execFileSync('ps', ['-eo', 'pid=,comm=,args='], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
25
|
+
return out.split('\n').filter((l) => l.trim()).map((l) => {
|
|
26
|
+
const m = l.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
|
27
|
+
return m ? { pid: Number(m[1]), name: m[2], cmd: m[3], exe: '' } : null;
|
|
28
|
+
}).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The runner is a node process whose command line names periscope; a match is a process of the
|
|
32
|
+
// configured pattern running in the worktree named by `key`. This tool never reports itself.
|
|
33
|
+
export function classify(processes, { key = '', pattern = null } = {}) {
|
|
34
|
+
const re = pattern ? new RegExp(pattern, 'i') : null;
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const p of processes) {
|
|
37
|
+
const text = `${p.cmd} ${p.exe}`;
|
|
38
|
+
if (/witness-procs/.test(text)) continue;
|
|
39
|
+
if (/^node(\.exe)?$/i.test(p.name) && /periscope/i.test(p.cmd)) { out.push(`RUNNER|${p.pid}`); continue; }
|
|
40
|
+
if (key && re && text.includes(key) && re.test(text)) out.push(`${p.pid}|${p.name}`);
|
|
41
|
+
}
|
|
42
|
+
return out.sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function selfTest() {
|
|
46
|
+
const synthetic = [
|
|
47
|
+
{ pid: 10, name: 'node.exe', cmd: 'node C:/npm/node_modules/@scope/periscope/dist/bin/periscope.js serve', exe: '' },
|
|
48
|
+
{ pid: 11, name: 'dotnet', cmd: 'dotnet test --project tests/App.Tests', exe: 'C:/work/pipeline-7/x' },
|
|
49
|
+
{ pid: 12, name: 'dotnet', cmd: 'dotnet test --project tests/App.Tests', exe: 'C:/work/pipeline-9/x' },
|
|
50
|
+
{ pid: 13, name: 'node.exe', cmd: 'node vitest run C:/work/pipeline-7', exe: '' },
|
|
51
|
+
{ pid: 14, name: 'node.exe', cmd: 'node witness-procs.mjs --key pipeline-7', exe: '' },
|
|
52
|
+
];
|
|
53
|
+
const lines = classify(synthetic, { key: 'pipeline-7', pattern: 'dotnet.*test|vitest' });
|
|
54
|
+
let live = [];
|
|
55
|
+
let liveError = '';
|
|
56
|
+
try { live = listProcesses(); } catch (e) { liveError = String(e.message ?? e).slice(0, 120); }
|
|
57
|
+
return report('witness-procs', [
|
|
58
|
+
{ name: 'the runner and the work in this worktree are reported', ok: JSON.stringify(lines) === JSON.stringify(['11|dotnet', '13|node.exe', 'RUNNER|10']), detail: JSON.stringify(lines) },
|
|
59
|
+
{ name: 'CONTROL: the same work in another worktree, and this tool itself, are not', ok: !lines.some((l) => l.startsWith('12|') || l.startsWith('14|')), detail: JSON.stringify(lines) },
|
|
60
|
+
{ name: 'the process table is read on this machine, this process in it', ok: live.some((p) => p.pid === process.pid), detail: liveError || `${live.length} processes, ${process.pid} absent` },
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (isMain(import.meta.url)) {
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
if (argv.includes('--self-test')) process.exit(selfTest());
|
|
67
|
+
const config = loadConfig(argv);
|
|
68
|
+
const key = flag(argv, '--key') ?? (config.worktree ? basename(config.worktree) : '');
|
|
69
|
+
const pattern = flag(argv, '--pattern') ?? config.testProcessPattern ?? null;
|
|
70
|
+
for (const line of classify(listProcesses(), { key, pattern })) console.log(line);
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Each `seat_spawn` call's task exactly as the coordinator sent it, one file per spawn, and the plane's
|
|
3
|
+
// answer: what a seat was told, in the words that reached it. Writes only into <outDir>.
|
|
4
|
+
//
|
|
5
|
+
// node witness-spawn-task.mjs <file.jsonl> <outDir> [--plane <server>] [--config <path>]
|
|
6
|
+
// node witness-spawn-task.mjs --self-test
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { contentOf, entries, isMain, loadConfig, planePrefix, positionals, report, resultText, scratch, toolResult, toolUse, writeTranscript } from './witness-lib.mjs';
|
|
11
|
+
|
|
12
|
+
export async function spawnTasks(file, prefix, outDir) {
|
|
13
|
+
mkdirSync(outDir, { recursive: true });
|
|
14
|
+
const pending = new Set();
|
|
15
|
+
const out = [];
|
|
16
|
+
for await (const { entry } of entries(file)) {
|
|
17
|
+
for (const c of contentOf(entry)) {
|
|
18
|
+
if (c.type === 'tool_use' && c.name === `${prefix}seat_spawn`) {
|
|
19
|
+
pending.add(c.id);
|
|
20
|
+
const input = c.input ?? {};
|
|
21
|
+
const path = join(outDir, `spawn-${input.seatId}-task-as-sent.txt`);
|
|
22
|
+
writeFileSync(path, `${entry.timestamp}\n${JSON.stringify({ ...input, task: undefined })}\n\n${input.task ?? ''}`);
|
|
23
|
+
out.push(`seat ${input.seatId} task ${String(input.task ?? '').length} chars -> ${path}`);
|
|
24
|
+
} else if (c.type === 'tool_result' && pending.has(c.tool_use_id)) {
|
|
25
|
+
pending.delete(c.tool_use_id);
|
|
26
|
+
out.push(` result: ${resultText(c)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function selfTest() {
|
|
34
|
+
const dir = scratch('witness-spawn-task');
|
|
35
|
+
try {
|
|
36
|
+
const task = 'You are a BUILD SEAT.\nYour spec: 01-a-slice.md\n\tindented line, kept as sent';
|
|
37
|
+
const file = writeTranscript(dir, 'coordinator.jsonl', [
|
|
38
|
+
toolUse(1, 'a', 'mcp__plane__seat_spawn', { seatId: 12, task }),
|
|
39
|
+
toolResult(2, 'a', 'spawned; act 4321'),
|
|
40
|
+
toolUse(3, 'b', 'mcp__plane__seat_read', { seatId: 12 }),
|
|
41
|
+
toolResult(3, 'b', 'seat 12: planning'),
|
|
42
|
+
]);
|
|
43
|
+
const lines = await spawnTasks(file, 'mcp__plane__', join(dir, 'out'));
|
|
44
|
+
const saved = readFileSync(join(dir, 'out', 'spawn-12-task-as-sent.txt'), 'utf8');
|
|
45
|
+
return report('witness-spawn-task', [
|
|
46
|
+
{ name: 'the task is saved exactly as sent', ok: saved.endsWith(`\n\n${task}`), detail: JSON.stringify(saved.slice(-80)) },
|
|
47
|
+
{ name: 'the plane\'s answer is reported', ok: lines.some((l) => l.includes('result: spawned; act 4321')), detail: lines.join(' | ') },
|
|
48
|
+
{ name: 'CONTROL: another plane call is not a spawn', ok: lines.length === 2, detail: `${lines.length} lines` },
|
|
49
|
+
]);
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isMain(import.meta.url)) {
|
|
56
|
+
const argv = process.argv.slice(2);
|
|
57
|
+
if (argv.includes('--self-test')) process.exit(await selfTest());
|
|
58
|
+
const [file, outDir] = positionals(argv, ['--plane', '--config']);
|
|
59
|
+
if (!file || !outDir) { console.log('usage: witness-spawn-task.mjs <file.jsonl> <outDir> [--plane <server>]'); process.exit(2); }
|
|
60
|
+
for (const line of await spawnTasks(file, planePrefix(argv, loadConfig(argv)), outDir)) console.log(line);
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|