@dougharness/cli 1.2.0 → 1.3.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/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 +50 -31
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +35 -3
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- package/node_modules/@dougharness/flow/lib/memory.mjs +358 -64
- 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 +37 -4
- package/node_modules/@dougharness/flow/scripts/memory.mjs +13 -1
- package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/workflows/card-queen.js +43 -4
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +56 -16
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- 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 +18 -4
- package/package.json +3 -3
|
@@ -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
|
}
|
|
@@ -802,10 +802,24 @@ function waiverSources(dir, baselineHead) {
|
|
|
802
802
|
return sources;
|
|
803
803
|
}
|
|
804
804
|
|
|
805
|
+
// A tree's own last-green record, read live; null when absent or unreadable (card worktree-count-baseline).
|
|
806
|
+
function readLastGreen(dir) {
|
|
807
|
+
try {
|
|
808
|
+
const parsed = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
|
|
809
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
810
|
+
} catch {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
805
815
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
806
816
|
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
807
817
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
808
|
-
function countCheck(dir, cfg, state, input, ran) {
|
|
818
|
+
function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
819
|
+
// card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
|
|
820
|
+
// (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
|
|
821
|
+
const inWorktree = typeof vdir === "string" && vdir !== dir;
|
|
822
|
+
const baseline = inWorktree ? readLastGreen(vdir) : state.testTotals;
|
|
809
823
|
const problems = [];
|
|
810
824
|
const parsed = {};
|
|
811
825
|
// The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
|
|
@@ -829,7 +843,7 @@ function countCheck(dir, cfg, state, input, ran) {
|
|
|
829
843
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
830
844
|
continue;
|
|
831
845
|
}
|
|
832
|
-
const all =
|
|
846
|
+
const all = baseline && typeof baseline === "object" ? baseline : null;
|
|
833
847
|
const baseList = all ? readSummaries(all[command]) : null;
|
|
834
848
|
if (!baseList) {
|
|
835
849
|
parsed[command] = summaries;
|
|
@@ -1991,7 +2005,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1991
2005
|
const evidenceProblemAdded = problems.length > postCommandProblems;
|
|
1992
2006
|
|
|
1993
2007
|
// 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);
|
|
2008
|
+
const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
|
|
1995
2009
|
problems.push(...counted.problems);
|
|
1996
2010
|
const countProblemAdded = counted.problems.length > 0;
|
|
1997
2011
|
|
|
@@ -2012,7 +2026,7 @@ runHook("stop-gate", async (input) => {
|
|
|
2012
2026
|
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
2013
2027
|
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
2028
|
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
2015
|
-
if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
|
|
2029
|
+
if (problems.length === 0 && !skipVerification) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
|
|
2016
2030
|
if (problems.length === 0 && subagent) {
|
|
2017
2031
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
2018
2032
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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.3.0",
|
|
27
|
+
"@dougharness/gates": "1.3.0"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|