@dougharness/cli 1.2.0 → 1.4.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/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/board-page.d.ts +5 -1
- package/dist/board-page.js +19 -5
- package/dist/board-page.js.map +1 -1
- package/dist/board-serve.js +34 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +160 -13
- package/dist/board.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +67 -32
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +94 -7
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +31 -4
- package/node_modules/@dougharness/flow/lib/board.mjs +366 -21
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +229 -23
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +9 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +382 -65
- package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +228 -14
- package/node_modules/@dougharness/flow/scripts/board.mjs +122 -13
- package/node_modules/@dougharness/flow/scripts/cost.mjs +6 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +27 -8
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/scripts/session-recall.mjs +11 -4
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +9 -2
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/workflows/card-queen.js +238 -25
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +77 -34
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/baseline.mjs +23 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +41 -3
- package/node_modules/@dougharness/gates/lib/docs-gate.mjs +6 -2
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +107 -20
- package/package.json +3 -3
- package/templates/board-app.js +240 -30
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
// Loopback exemption (card research-cap-localhost, whole-command grammar, fix pass 5): a local model probe is not
|
|
28
28
|
// web research, so it is neither counted nor denied. The exemption applies only when the entire raw command
|
|
29
29
|
// string, before any splitting, is ONE simple fetch (isLoopbackFetch):
|
|
30
|
-
// 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick
|
|
31
|
-
// backslash or
|
|
32
|
-
//
|
|
30
|
+
// 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick or !,
|
|
31
|
+
// and a backslash only as \" or \\ (a literal char; any other backslash is not exempt); an unterminated quote
|
|
32
|
+
// is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-], a redirect's >, the exact word 2>&1
|
|
33
|
+
// and a backslash-newline continuation (removed, no word break) are allowed, so & | ; ( ) { } [ ] * ? ~ # $
|
|
34
|
+
// ` \ ! < and a bare newline are otherwise never exempt (2>&1>x, a&b, 2>&1; are not the exact word).
|
|
33
35
|
// 2. The first word is exactly curl or wget: no path, wrapper, assignment or other case.
|
|
34
36
|
// 3. Every other word is an allowlisted option (value-less -s -S -f -L -i -v -g or --silent and similar,
|
|
35
37
|
// combinable in one short token; value-taking -o -H -d -X -w -m for curl, -O -T and --header for wget,
|
|
@@ -41,11 +43,18 @@
|
|
|
41
43
|
// exactly /dev/null or does not start with /dev/ (bash opens /dev/tcp and /dev/udp as sockets).
|
|
42
44
|
// A command outside the grammar is not an error: it is counted by bashFetchVerbs exactly as at HEAD, one count
|
|
43
45
|
// per split command whose first resolved fetch form is curl or wget. Only Bash curl/wget is affected; WebFetch
|
|
44
|
-
// and WebSearch always count.
|
|
45
|
-
// remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
|
|
46
|
+
// and WebSearch always count.
|
|
46
47
|
// Accepted limits of the exemption: an env proxy (https_proxy=http://evil.com in the environment, not on the
|
|
47
48
|
// command) routes the request out; -L, and wget by default, follow a redirect a local server can point at a
|
|
48
49
|
// remote host; a ~/.curlrc or ~/.wgetrc can add a URL or a proxy.
|
|
50
|
+
// Anything doubtful counts: a compound line, a pipe that is not a safe pipe consumer (below), a wrapper, a mixed
|
|
51
|
+
// local and remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
|
|
52
|
+
//
|
|
53
|
+
// Pipe consumers (card research-cap-loopback-post): the same exemption holds for a pipeline of unquoted single "|"
|
|
54
|
+
// (never ||, |& or an empty segment) whose first segment is a loopback fetch unchanged and whose every later
|
|
55
|
+
// segment is, with no redirect, head/tail with only a count (-c N, -n N, -cN, -nN, -N) or exactly
|
|
56
|
+
// python3|python -m json.tool. Anything else (jq, sh, curl, python -c) counts as at HEAD. Each counted Bash
|
|
57
|
+
// counter line also carries hosts, the URL hosts of that split command, so a miss is diagnosable.
|
|
49
58
|
//
|
|
50
59
|
// Known limit: several calls issued in one message can each read the same count before any of them appends, so
|
|
51
60
|
// a single burst can push an agent's count past the cap by the number of calls it issues at once.
|
|
@@ -305,14 +314,29 @@ function tokenize(text) {
|
|
|
305
314
|
gt = false;
|
|
306
315
|
open = false;
|
|
307
316
|
};
|
|
308
|
-
for (
|
|
317
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
318
|
+
const ch = text[i];
|
|
309
319
|
if (quote) {
|
|
320
|
+
if (quote === '"' && ch === "\\") {
|
|
321
|
+
// Only \" and \\ are escapes inside double quotes; both give a literal char. Any other backslash
|
|
322
|
+
// (\$, \`, \!, backslash-newline) leaves the grammar.
|
|
323
|
+
const next = text[i + 1];
|
|
324
|
+
if (next !== '"' && next !== "\\") return null;
|
|
325
|
+
raw += ch + next;
|
|
326
|
+
val += next;
|
|
327
|
+
i += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
310
330
|
raw += ch;
|
|
311
331
|
if (ch === quote) quote = "";
|
|
312
|
-
else if (quote === '"' && "
|
|
332
|
+
else if (quote === '"' && "$`!".includes(ch)) return null;
|
|
313
333
|
else val += ch;
|
|
314
334
|
} else if (ch === " " || ch === "\t") end();
|
|
315
|
-
else
|
|
335
|
+
else if (ch === "\\" && text[i + 1] === "\n") i += 1; // a line continuation: removed, no word break
|
|
336
|
+
else if (!open && text.startsWith("2>&1", i) && (i + 4 === text.length || " \t".includes(text[i + 4]))) {
|
|
337
|
+
words.push({ raw: "2>&1", val: "2>&1", gt: false, dup: true }); // the exact word only, never a prefix
|
|
338
|
+
i += 3;
|
|
339
|
+
} else {
|
|
316
340
|
open = true;
|
|
317
341
|
raw += ch;
|
|
318
342
|
if (ch === "'" || ch === '"') quote = ch;
|
|
@@ -337,7 +361,8 @@ function isLoopbackFetch(command) {
|
|
|
337
361
|
let urls = 0;
|
|
338
362
|
let wantValue = false;
|
|
339
363
|
for (let k = 1; k < words.length; k += 1) {
|
|
340
|
-
const { raw, val, gt } = words[k];
|
|
364
|
+
const { raw, val, gt, dup } = words[k];
|
|
365
|
+
if (dup) continue;
|
|
341
366
|
if (gt) {
|
|
342
367
|
const m = REDIRECT.exec(raw);
|
|
343
368
|
if (!m) return false;
|
|
@@ -368,19 +393,98 @@ function isLoopbackFetch(command) {
|
|
|
368
393
|
return urls > 0;
|
|
369
394
|
}
|
|
370
395
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
396
|
+
// Splits a raw command on unquoted single "|" (card research-cap-loopback-post). Null when the text has a "||", a
|
|
397
|
+
// "|&", an empty segment, or no pipe at all, so only a plain pipeline is ever split.
|
|
398
|
+
function splitPipeline(command) {
|
|
399
|
+
const text = String(command);
|
|
400
|
+
const segs = [];
|
|
401
|
+
let quote = "";
|
|
402
|
+
let cur = "";
|
|
403
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
404
|
+
const ch = text[i];
|
|
405
|
+
if (quote) {
|
|
406
|
+
if (quote === '"' && ch === "\\" && i + 1 < text.length) {
|
|
407
|
+
cur += ch + text[i + 1];
|
|
408
|
+
i += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (ch === quote) quote = "";
|
|
412
|
+
cur += ch;
|
|
413
|
+
} else if (ch === "'" || ch === '"') {
|
|
414
|
+
quote = ch;
|
|
415
|
+
cur += ch;
|
|
416
|
+
} else if (ch === "|") {
|
|
417
|
+
if (text[i + 1] === "|" || text[i + 1] === "&" || cur.trim() === "") return null;
|
|
418
|
+
segs.push(cur);
|
|
419
|
+
cur = "";
|
|
420
|
+
} else cur += ch;
|
|
421
|
+
}
|
|
422
|
+
if (quote || cur.trim() === "" || segs.length === 0) return null;
|
|
423
|
+
segs.push(cur);
|
|
424
|
+
return segs;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// One pipe consumer that cannot fetch: head or tail with only a count, or python[3] -m json.tool. No redirect.
|
|
428
|
+
function isSafeConsumer(segment) {
|
|
429
|
+
const words = tokenize(segment.trim());
|
|
430
|
+
if (!words || words.length === 0 || words.some((w) => w.gt || w.raw !== w.val)) return false;
|
|
431
|
+
const v = words.map((w) => w.val);
|
|
432
|
+
if (v[0] === "head" || v[0] === "tail") {
|
|
433
|
+
const rest = v.slice(1);
|
|
434
|
+
if (rest.length === 0) return false;
|
|
435
|
+
if (rest.length === 1) return /^-(?:[cn]?\d+)$/.test(rest[0]);
|
|
436
|
+
return rest.length === 2 && /^-[cn]$/.test(rest[0]) && /^\d+$/.test(rest[1]);
|
|
437
|
+
}
|
|
438
|
+
return (v[0] === "python3" || v[0] === "python") && v.length === 3 && v[1] === "-m" && v[2] === "json.tool";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// True when the whole command is one loopback fetch, or one followed by safe consumers (see the header).
|
|
442
|
+
function isLoopbackCommand(command) {
|
|
443
|
+
if (isLoopbackFetch(command)) return true;
|
|
444
|
+
const segs = splitPipeline(command);
|
|
445
|
+
return !!segs && isLoopbackFetch(segs[0]) && segs.slice(1).every(isSafeConsumer);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// URL hosts of one split command, lower-cased, unique, in order (card research-cap-loopback-post). Diagnostic
|
|
449
|
+
// only: never throws and never returns the command text.
|
|
450
|
+
function urlHosts(cmd) {
|
|
451
|
+
const hosts = [];
|
|
452
|
+
try {
|
|
453
|
+
for (const w of String(cmd).match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) {
|
|
454
|
+
const word = w.replace(/["']/g, "");
|
|
455
|
+
if (word === "" || word.startsWith("-")) continue;
|
|
456
|
+
let host = "";
|
|
457
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(word);
|
|
458
|
+
if (scheme) {
|
|
459
|
+
const auth = scheme[1].slice(scheme[1].lastIndexOf("@") + 1);
|
|
460
|
+
const m = /^(\[[^\]]*\]|[^:]*)/.exec(auth);
|
|
461
|
+
host = m ? m[1] : "";
|
|
462
|
+
} else {
|
|
463
|
+
const m = /^(\[::1\]|[a-z0-9.-]+)(:\d+)?([/?#]|$)/i.exec(word);
|
|
464
|
+
if (m && (/^(?:localhost|127\.0\.0\.1|\[::1\])$/i.test(m[1]) || m[2] || m[3])) host = m[1];
|
|
465
|
+
}
|
|
466
|
+
host = host.toLowerCase();
|
|
467
|
+
if (host && !hosts.includes(host)) hosts.push(host);
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// diagnostic only
|
|
471
|
+
}
|
|
472
|
+
return hosts;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Returns one {verb, hosts} per split command of a Bash command line that resolves to a fetch (verb "curl" or
|
|
476
|
+
// "wget"); non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
|
|
373
477
|
// fetches. commandForms runs on each split command's original text with its quotes intact (it does its own
|
|
374
478
|
// quote parsing for a shell -c script), never on stripped or reconstructed text. A command that is one simple
|
|
375
|
-
// loopback fetch (
|
|
479
|
+
// loopback fetch, alone or piped to a safe consumer (isLoopbackCommand), is not a fetch to count.
|
|
376
480
|
function bashFetchVerbs(command) {
|
|
377
|
-
if (
|
|
481
|
+
if (isLoopbackCommand(command)) return [];
|
|
378
482
|
const verbs = [];
|
|
379
483
|
for (const cmd of splitTopLevel(command)) {
|
|
380
484
|
for (const form of commandForms(cmd)) {
|
|
381
485
|
const verb = firstWordBasename(form);
|
|
382
486
|
if (FETCH_VERBS.has(verb)) {
|
|
383
|
-
verbs.push(verb);
|
|
487
|
+
verbs.push({ verb, hosts: urlHosts(cmd) });
|
|
384
488
|
break;
|
|
385
489
|
}
|
|
386
490
|
}
|
|
@@ -401,7 +505,7 @@ runHook("research-cap", async (input) => {
|
|
|
401
505
|
if (input.tool_name === "Bash") {
|
|
402
506
|
const verbs = bashFetchVerbs(input.tool_input && input.tool_input.command);
|
|
403
507
|
if (verbs.length === 0) return allow(); // an ordinary Bash command is never counted or denied here
|
|
404
|
-
entries = verbs.map((verb) => ({ tool: "Bash", verb }));
|
|
508
|
+
entries = verbs.map(({ verb, hosts }) => ({ tool: "Bash", verb, hosts }));
|
|
405
509
|
} else {
|
|
406
510
|
entries = [{ tool: input.tool_name }];
|
|
407
511
|
}
|
|
@@ -85,13 +85,13 @@ import { runCommand, failureSummary } from "../lib/run.mjs";
|
|
|
85
85
|
import { loadScopePlan, scopeViolations, describeViolations, PLAN_RELPATH } from "../lib/scope.mjs";
|
|
86
86
|
import { checkpoint, describeCheckpoint } from "../lib/checkpoint.mjs";
|
|
87
87
|
import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.mjs";
|
|
88
|
-
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
|
|
88
|
+
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, turnTreeStatusHeadMoved, onlyNoTestPathsChanged, movedSinceVerified } from "../lib/baseline.mjs";
|
|
89
89
|
import { tracePath, readTrace, traceLine, appendTrace, agentLabel } from "../lib/trace.mjs";
|
|
90
90
|
import { isAppliedContent } from "../lib/proposals.mjs";
|
|
91
91
|
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection } from "../lib/anchor.mjs";
|
|
92
92
|
import { contextReading, recordAgentContext } from "../lib/agent-context.mjs";
|
|
93
93
|
import { parseTotals, readSummaries, sumTotals, compareTotals, deltaTotals, waiverCovers } from "../lib/test-totals.mjs";
|
|
94
|
-
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
|
|
94
|
+
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine, outputTail, redactText } from "../lib/block-ledger.mjs";
|
|
95
95
|
import { docsGateDecision } from "../lib/docs-gate.mjs";
|
|
96
96
|
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
97
97
|
import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
|
|
@@ -468,6 +468,21 @@ function countingInFlight(dir, cfg, sessionId, inFlight) {
|
|
|
468
468
|
// and whose ok is not exactly false (a null ok counts as success — conservative). No change found -> true.
|
|
469
469
|
//
|
|
470
470
|
// Without trace evidence, true only when subagent.type is in READ_ONLY_AGENT_TYPES.
|
|
471
|
+
// card ratchet-stale-session-baseline: true only when the trace vouches for the agent's whole life (its
|
|
472
|
+
// SubagentStart line is there) and shows no successful Edit-type PostToolUse of its own. Bash does not count.
|
|
473
|
+
function agentMadeNoEditCalls(dir, cfg, sessionId, subagent) {
|
|
474
|
+
try {
|
|
475
|
+
if (!subagent || !cfg.trace || cfg.trace.enabled === false) return false;
|
|
476
|
+
const file = tracePath(dir, sessionId);
|
|
477
|
+
if (!existsSync(file)) return false;
|
|
478
|
+
const lines = readTrace(file);
|
|
479
|
+
if (!lines.some((l) => l.event === "SubagentStart" && l.agent === subagent.id)) return false;
|
|
480
|
+
return !lines.some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false);
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
471
486
|
function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
472
487
|
try {
|
|
473
488
|
if (!subagent) return false;
|
|
@@ -802,10 +817,32 @@ function waiverSources(dir, baselineHead) {
|
|
|
802
817
|
return sources;
|
|
803
818
|
}
|
|
804
819
|
|
|
820
|
+
// A tree's own last-green record, read live; null when absent or unreadable (card worktree-count-baseline).
|
|
821
|
+
function readLastGreen(dir) {
|
|
822
|
+
try {
|
|
823
|
+
const parsed = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
|
|
824
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
825
|
+
} catch {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
805
830
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
806
831
|
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
807
832
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
808
|
-
function countCheck(dir, cfg, state, input, ran) {
|
|
833
|
+
function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
834
|
+
// card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
|
|
835
|
+
// (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
|
|
836
|
+
const inWorktree = typeof vdir === "string" && vdir !== dir;
|
|
837
|
+
// card ratchet-stale-session-baseline: a main-checkout run compares against the main tree's live last-green
|
|
838
|
+
// record too (per command), not the session-start copy; the copy is the fallback when the record has no
|
|
839
|
+
// readable entry for the command, so a deleted record does not remove the check.
|
|
840
|
+
const worktreeBaseline = inWorktree ? readLastGreen(vdir) : null;
|
|
841
|
+
const mainRecord = inWorktree ? null : readLastGreen(dir);
|
|
842
|
+
const baselineFor = (command) => {
|
|
843
|
+
if (inWorktree) return worktreeBaseline;
|
|
844
|
+
return mainRecord && readSummaries(mainRecord[command]) ? mainRecord : state.testTotals;
|
|
845
|
+
};
|
|
809
846
|
const problems = [];
|
|
810
847
|
const parsed = {};
|
|
811
848
|
// The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
|
|
@@ -829,7 +866,8 @@ function countCheck(dir, cfg, state, input, ran) {
|
|
|
829
866
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
830
867
|
continue;
|
|
831
868
|
}
|
|
832
|
-
const
|
|
869
|
+
const baseline = baselineFor(command);
|
|
870
|
+
const all = baseline && typeof baseline === "object" ? baseline : null;
|
|
833
871
|
const baseList = all ? readSummaries(all[command]) : null;
|
|
834
872
|
if (!baseList) {
|
|
835
873
|
parsed[command] = summaries;
|
|
@@ -1195,8 +1233,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1195
1233
|
traceSubagentDecision("allow", traceReason, extra);
|
|
1196
1234
|
return allow();
|
|
1197
1235
|
};
|
|
1198
|
-
const blockStopDecided = (reason) => {
|
|
1199
|
-
traceSubagentDecision("block", null);
|
|
1236
|
+
const blockStopDecided = (reason, extra) => {
|
|
1237
|
+
traceSubagentDecision("block", null, extra);
|
|
1200
1238
|
return blockStop(reason);
|
|
1201
1239
|
};
|
|
1202
1240
|
const systemMessageDecided = (message, traceReason, extra) => {
|
|
@@ -1206,8 +1244,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1206
1244
|
// Card stop-gate-block-ledger: one ledger line per block/stand-down below, written synchronously right
|
|
1207
1245
|
// before the blockStop/systemMessage call it belongs to (those call process.exit(0) themselves, so
|
|
1208
1246
|
// nothing queued after them ever runs). appendBlockLine never throws.
|
|
1209
|
-
const logBlock = (reason, cls) =>
|
|
1210
|
-
appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id });
|
|
1247
|
+
const logBlock = (reason, cls, record) =>
|
|
1248
|
+
appendBlockLine(dir, { hook: subagent ? "subagent-stop" : "stop-gate", agent: subagent ? subagent.type : null, reason, class: cls, session: input.session_id, record });
|
|
1249
|
+
// card block-ledger-failing-output: a main Stop verification block has no decision trace line of its own;
|
|
1250
|
+
// append one carrying the record. A SubagentStop's line comes from traceSubagentDecision. Never throws.
|
|
1251
|
+
const traceMainBlock = (record) => {
|
|
1252
|
+
if (subagent) return;
|
|
1253
|
+
try {
|
|
1254
|
+
if (!cfg.trace || cfg.trace.enabled === false) return;
|
|
1255
|
+
appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "Stop", decision: "block", reason: null, ...record });
|
|
1256
|
+
} catch {
|
|
1257
|
+
// never block the real exit on a trace failure
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1211
1260
|
// Card block-ledger-stood-down-repeats: a stood-down line is written at most once per session per
|
|
1212
1261
|
// cause, so a session standing down at its cap does not append a fresh identical line on every later
|
|
1213
1262
|
// Stop/SubagentStop past it. Cause key: "turn-budget" (lead only), or "block-cap:<lead|agent id>" — the
|
|
@@ -1404,7 +1453,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1404
1453
|
let readOnly = null;
|
|
1405
1454
|
try {
|
|
1406
1455
|
const start = readTurnStart(dir, input.session_id);
|
|
1407
|
-
const
|
|
1456
|
+
const currentFp = treeFingerprint(dir, cfg, changed);
|
|
1457
|
+
let status = turnTreeStatus(cfg, start, currentFp);
|
|
1458
|
+
// A turn that only committed noTestPaths files moved HEAD but changed no code (card stop-gate-read-only-turn-commits).
|
|
1459
|
+
if (status && !status.same) status = turnTreeStatusHeadMoved(dir, cfg, start, currentFp) || status;
|
|
1408
1460
|
if (status && status.same) {
|
|
1409
1461
|
let editedThisTurn = false;
|
|
1410
1462
|
if (cfg.trace && cfg.trace.enabled !== false) {
|
|
@@ -1709,8 +1761,14 @@ runHook("stop-gate", async (input) => {
|
|
|
1709
1761
|
[...treeProblems, lastRed.summary].join("\n\n");
|
|
1710
1762
|
const reuseClass = typeof lastRed.class === "string" ? lastRed.class : "real";
|
|
1711
1763
|
const capped = deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason;
|
|
1712
|
-
|
|
1713
|
-
|
|
1764
|
+
const reuseRecord = {
|
|
1765
|
+
failures: Array.isArray(lastRed.failures) ? lastRed.failures : [],
|
|
1766
|
+
cwd: typeof lastRed.cwd === "string" ? lastRed.cwd : null,
|
|
1767
|
+
tree: typeof lastRed.tree === "string" ? lastRed.tree : null,
|
|
1768
|
+
};
|
|
1769
|
+
logBlock(reason, reuseClass, reuseRecord);
|
|
1770
|
+
traceMainBlock(reuseRecord);
|
|
1771
|
+
return blockStopDecided(capped, reuseRecord);
|
|
1714
1772
|
}
|
|
1715
1773
|
}
|
|
1716
1774
|
|
|
@@ -1855,9 +1913,9 @@ runHook("stop-gate", async (input) => {
|
|
|
1855
1913
|
if (deferralCapNote && !subagent) return systemMessageDecided(deferralCapNote, traceReason, extra);
|
|
1856
1914
|
return allowDecided(traceReason, extra);
|
|
1857
1915
|
};
|
|
1858
|
-
const blockStopReleasing = (reason) => {
|
|
1916
|
+
const blockStopReleasing = (reason, extra) => {
|
|
1859
1917
|
releaseLock();
|
|
1860
|
-
return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason);
|
|
1918
|
+
return blockStopDecided(deferralCapNote && !subagent ? `${deferralCapNote}\n\n${reason}` : reason, extra);
|
|
1861
1919
|
};
|
|
1862
1920
|
const systemMessageReleasing = (message, traceReason, extra) => {
|
|
1863
1921
|
releaseLock();
|
|
@@ -1892,6 +1950,19 @@ runHook("stop-gate", async (input) => {
|
|
|
1892
1950
|
// One entry per failed command, `{ command, output }` (stderr + stdout, untruncated), for the
|
|
1893
1951
|
// tests_red_by_design coverage check below (design point 2, card subagent-stop-gate-tester-red).
|
|
1894
1952
|
const commandFailures = [];
|
|
1953
|
+
// card block-ledger-failing-output: the failing commands, the directory and the tree this block verified.
|
|
1954
|
+
const buildBlockRecord = () => {
|
|
1955
|
+
try {
|
|
1956
|
+
const failures = commandFailures.map((cf, i) => {
|
|
1957
|
+
const r = failedRuns[i] || {};
|
|
1958
|
+
return { command: redactText(cf.command, cfg.secrets), exit: typeof r.status === "number" ? r.status : null, timedOut: !!r.timedOut, tail: outputTail(cf.output, cfg.secrets) };
|
|
1959
|
+
});
|
|
1960
|
+
return { failures, cwd: vdir, tree: treeBefore ?? null };
|
|
1961
|
+
} catch {
|
|
1962
|
+
return { failures: [], cwd: vdir, tree: null }; // the line is written without the failures; the decision is unchanged
|
|
1963
|
+
}
|
|
1964
|
+
};
|
|
1965
|
+
const failedRuns = []; // parallel to commandFailures: { status, timedOut } of each failed run (card block-ledger-failing-output)
|
|
1895
1966
|
const ranGreen = []; // count check input: { command, stdout } of each command that really ran and exited 0, plus { command, result } of each credited pre-commit command whose recorded totals are readable
|
|
1896
1967
|
// card stop-gate-credits-pre-commit: a passing pre-commit record for this exact tree and command set
|
|
1897
1968
|
// stands in for actually running them — see preCommitCredit above. Every other check below (evidence,
|
|
@@ -1939,6 +2010,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1939
2010
|
// commandFailures below keeps the untruncated output for the tests_red_by_design coverage check.
|
|
1940
2011
|
problems.push(`\`${command}\` failed (${why}):\n${failureSummary(output, 40)}`);
|
|
1941
2012
|
commandFailures.push({ command, output });
|
|
2013
|
+
failedRuns.push({ status: r.status, timedOut: r.timedOut });
|
|
1942
2014
|
}
|
|
1943
2015
|
}
|
|
1944
2016
|
}
|
|
@@ -1991,9 +2063,18 @@ runHook("stop-gate", async (input) => {
|
|
|
1991
2063
|
const evidenceProblemAdded = problems.length > postCommandProblems;
|
|
1992
2064
|
|
|
1993
2065
|
// 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
|
|
1994
|
-
const counted = countCheck(dir, cfg, state, input, ranGreen);
|
|
1995
|
-
|
|
1996
|
-
|
|
2066
|
+
const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
|
|
2067
|
+
// card ratchet-stale-session-baseline: a seat with no Edit-type call is never blocked by a count change it did
|
|
2068
|
+
// not cause. Its count problems become one trace note, and that stop writes neither the record nor state.verified.
|
|
2069
|
+
const countExempt = counted.problems.length > 0 && !!subagent && agentMadeNoEditCalls(dir, cfg, input.session_id, subagent);
|
|
2070
|
+
if (countExempt) {
|
|
2071
|
+
try {
|
|
2072
|
+
if (cfg.trace && cfg.trace.enabled !== false) appendTrace(dir, { ...traceLine(input, { now: new Date() }), event: "CountCheck", reason: `count change not blocking a seat that made no Edit-type call: ${counted.problems[0].split("\n")[0].slice(0, 300)}` });
|
|
2073
|
+
} catch {
|
|
2074
|
+
// logging must never fail the gate
|
|
2075
|
+
}
|
|
2076
|
+
} else problems.push(...counted.problems);
|
|
2077
|
+
const countProblemAdded = !countExempt && counted.problems.length > 0;
|
|
1997
2078
|
|
|
1998
2079
|
// A main Stop's skip has no decision-tagged exit line like a SubagentStop's (allowReleasing folds the reason
|
|
1999
2080
|
// into that); record the reason here so the trace carries it (card stop-gate-no-test-paths).
|
|
@@ -2011,8 +2092,8 @@ runHook("stop-gate", async (input) => {
|
|
|
2011
2092
|
// (card stop-gate-read-only-subagent-skip): nothing was actually verified.
|
|
2012
2093
|
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
2013
2094
|
if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
|
|
2014
|
-
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
2015
|
-
if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
|
|
2095
|
+
if (problems.length === 0 && !skipVerification && !targeted && !countExempt) state.verified = verifiedCandidate;
|
|
2096
|
+
if (problems.length === 0 && !skipVerification && !countExempt) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
|
|
2016
2097
|
if (problems.length === 0 && subagent) {
|
|
2017
2098
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
2018
2099
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|
|
@@ -2112,6 +2193,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2112
2193
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
2113
2194
|
class: redClass,
|
|
2114
2195
|
who,
|
|
2196
|
+
...buildBlockRecord(),
|
|
2115
2197
|
};
|
|
2116
2198
|
}
|
|
2117
2199
|
} catch {
|
|
@@ -2150,6 +2232,8 @@ runHook("stop-gate", async (input) => {
|
|
|
2150
2232
|
let finalClass = "real";
|
|
2151
2233
|
try {
|
|
2152
2234
|
finalClass = classifyFinalBlock({
|
|
2235
|
+
countProblems: countExempt ? 0 : counted.problems.length,
|
|
2236
|
+
totalProblems: problems.length,
|
|
2153
2237
|
preVerificationProblems,
|
|
2154
2238
|
evidenceProblemAdded,
|
|
2155
2239
|
commandFailures,
|
|
@@ -2163,6 +2247,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2163
2247
|
} catch {
|
|
2164
2248
|
finalClass = "real";
|
|
2165
2249
|
}
|
|
2250
|
+
const blockRecord = buildBlockRecord();
|
|
2166
2251
|
// card stop-gate-no-change-restop: record this red for reuse only when every configured command really
|
|
2167
2252
|
// ran (not credited, none dropped for budget or timed out) and at least one failed, every failure by a
|
|
2168
2253
|
// numeric non-zero exit outside 129..192 (a signal kill has status null, a shell-reported one 128+signal; both load-suspect). Stored with the block's ledger
|
|
@@ -2186,14 +2271,16 @@ runHook("stop-gate", async (input) => {
|
|
|
2186
2271
|
summary: problems.slice(preVerificationProblems, postCommandProblems).join("\n\n"),
|
|
2187
2272
|
class: finalClass,
|
|
2188
2273
|
who,
|
|
2274
|
+
...blockRecord,
|
|
2189
2275
|
};
|
|
2190
2276
|
saveState(dir, input.session_id, state);
|
|
2191
2277
|
}
|
|
2192
2278
|
} catch {
|
|
2193
2279
|
state.lastRed = null;
|
|
2194
2280
|
}
|
|
2195
|
-
logBlock(reason, finalClass);
|
|
2196
|
-
|
|
2281
|
+
logBlock(reason, finalClass, blockRecord);
|
|
2282
|
+
traceMainBlock(blockRecord);
|
|
2283
|
+
return blockStopReleasing(reason, blockRecord);
|
|
2197
2284
|
};
|
|
2198
2285
|
|
|
2199
2286
|
return runVerification();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "doug: tailored, approval-gated harness install for Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"node": ">=22.16"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@dougharness/flow": "1.
|
|
27
|
-
"@dougharness/gates": "1.
|
|
26
|
+
"@dougharness/flow": "1.4.0",
|
|
27
|
+
"@dougharness/gates": "1.4.0"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|