@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.
Files changed (24) hide show
  1. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +1 -1
  2. package/node_modules/@dougharness/flow/agents/implementer.md +1 -1
  3. package/node_modules/@dougharness/flow/agents/planner.md +1 -1
  4. package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
  5. package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
  6. package/node_modules/@dougharness/flow/hooks/register.tsx +50 -31
  7. package/node_modules/@dougharness/flow/lib/card-loop.mjs +35 -3
  8. package/node_modules/@dougharness/flow/lib/coordinator.mjs +25 -0
  9. package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
  10. package/node_modules/@dougharness/flow/lib/memory.mjs +358 -64
  11. package/node_modules/@dougharness/flow/lib/plan.mjs +1 -1
  12. package/node_modules/@dougharness/flow/package.json +1 -1
  13. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +37 -4
  14. package/node_modules/@dougharness/flow/scripts/memory.mjs +13 -1
  15. package/node_modules/@dougharness/flow/scripts/plan.mjs +5 -1
  16. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +1 -1
  17. package/node_modules/@dougharness/flow/skills/research/SKILL.md +1 -1
  18. package/node_modules/@dougharness/flow/workflows/card-queen.js +43 -4
  19. package/node_modules/@dougharness/flow/workflows/doug-implement.js +56 -16
  20. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  21. package/node_modules/@dougharness/gates/package.json +1 -1
  22. package/node_modules/@dougharness/gates/scripts/research-cap.mjs +119 -15
  23. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +18 -4
  24. 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, a
31
- // backslash or !; an unterminated quote is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-]
32
- // and a redirect's > are allowed, so & | ; ( ) { } [ ] * ? ~ # $ ` \ ! < and a newline are never exempt.
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. Anything doubtful counts: a compound line, a pipe, a wrapper, a mixed local and
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 (const ch of text) {
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 === '"' && "$`\\!".includes(ch)) return null;
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
- // Returns one verb ("curl" or "wget") per split command of a Bash command line that resolves to a fetch;
372
- // non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
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 (isLoopbackFetch) is not a fetch to count.
479
+ // loopback fetch, alone or piped to a safe consumer (isLoopbackCommand), is not a fetch to count.
376
480
  function bashFetchVerbs(command) {
377
- if (isLoopbackFetch(command)) return [];
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 = state.testTotals && typeof state.testTotals === "object" ? state.testTotals : null;
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.2.0",
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.2.0",
27
- "@dougharness/gates": "1.2.0"
26
+ "@dougharness/flow": "1.3.0",
27
+ "@dougharness/gates": "1.3.0"
28
28
  },
29
29
  "bundleDependencies": [
30
30
  "@dougharness/flow",