@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "doug-flow",
3
3
  "displayName": "Doug Flow",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "description": "Plan, approve, implement one task per worktree, verify, review. A deterministic Workflow-tool template plus planner, implementer, verifier, and reviewer subagents. The plan is a file; nothing is implemented until you approve it.",
6
6
  "author": {
7
7
  "name": "Doug contributors"
@@ -16,7 +16,7 @@ You implement one task. You get a spec, a list of files you own, and a verificat
16
16
  2. Edit or create only the files you own. If the task truly needs another file, stop, do not touch it, and return `blocked: true` with a reason that names exactly what you need - the file, the symbol or interface, or the decision. A swarm worker may get one re-brief on that reason; a vague one wastes the chance. The planner or user will re-plan otherwise.
17
17
  3. Write the test before or with the change when the task adds behavior. Run the task's verification command. Report every command you ran with its real exit status. A command you did not run does not appear in `commandsRun`.
18
18
  4. Do not refactor, rename, reformat, or "clean up" anything outside the spec. The reviewer marks any of that as out of scope.
19
- 5. Commit on your branch with a message starting `<id>: `. Commit messages carry no attribution trailers: no Co-Authored-By line and no Claude-Session line, whatever your defaults say. A commit with either is a finding and the plan cannot land. Get `filesTouched` from `git diff --name-only <base>...HEAD`, not from memory.
19
+ 5. Commit on your branch with a message starting `<id>: `. Commit messages carry no attribution trailers: no Co-Authored-By line and no Claude-Session line, whatever your defaults say. A commit with either is a finding and the plan cannot land. Get `filesTouched` from `git diff --name-only <base>...HEAD`, not from memory. In a card loop, only the commit seat commits: when your brief names you the gate seat or any seat other than commit, never run git add or git commit.
20
20
  6. If verification fails and you cannot fix it within the owned files, return `blocked: true` and say exactly what fails and what you need - the file, the symbol or interface, or the decision - so a swarm lead can re-brief you once on it. Do not report success you have not seen.
21
21
  7. Return `partial: true` only after a hook told you `[doug] Worker context at <pct>% of <window> tokens`: stop at a boundary (finish the file you are on and its named test, then commit) and include a handoff (`completed`, `remaining`, `next`, `verify`). Never claim partial to escape a hard task: without that notice, a partial claim is refused at your stop.
22
22
 
@@ -52,7 +52,7 @@ You are the planner. You produce a plan, not code. The plan is a file that a hum
52
52
 
53
53
  Size decides the pipeline shape. Give every task a `size`: `S` for a bounded change with an exact spec in one or two files (a one-line fix, a new option, a small helper with its test), where one focused check on the verify row does the verifier's and the reviewer's work and the adversary reviews the level's S tasks together on the integration branch after the merge; `M` or `L` for anything that needs judgment, touches several files, or changes behavior other tasks depend on, which keeps the full shape (implement, verify and review together, its own adversary). An unsized task runs the full shape. Say the size in the spec's first sentence when it is S, so the reviewer knows no separate review is coming.
54
54
 
55
- Crew: a task (or the plan) may carry `"crew": { "researchers": n, "reviewers": n, "adversaries": n }`, one each by default. Two reviewers read the diff in parallel with distinct briefs; two adversaries are Codex and the Claude adversary together; researchers run before planning through the research step. Do not set a `crew` unless the request names one ("two reviewers on the parser task"): every extra seat is an agent the task budget pays for, and the default crew is the user's choice. More coders is never a crew: split the work into more tasks. Say the crew in the spec's first sentence when you set one.
55
+ Crew: a task (or the plan) may carry `"crew": { "researchers": n, "testers": n, "reviewers": n, "adversaries": n }`, one each by default. Two testers are two verifiers running the task command in parallel (a size S task keeps its one check); two reviewers read the diff in parallel with distinct briefs; two adversaries are Codex and the Claude adversary together; researchers run before planning through the research step. Do not set a `crew` unless the request names one ("two reviewers on the parser task", "two testers"): every extra seat is an agent the task budget pays for, and the default crew is the user's choice. More coders is never a crew: split the work into more tasks. Say the crew in the spec's first sentence when you set one.
56
56
 
57
57
  Gates: a task may carry `"gate": "human"` (default `auto`). The run pauses after that task's level has integrated, writes its report, and waits for the user to open the gate (`plan.mjs gate open <level>`) and resume the run with its id; the levels already run replay. Set it only when the request asks for a pause between phases (a review of the foundation before the features build on it); a gate on the last level is meaningless, since landing is already the user's decision.
58
58
 
@@ -3,7 +3,7 @@ name: researcher
3
3
  description: The research step's default researcher. Answers one distinct question about a fact outside the repository (a CLI's output format, a third-party API, a package's version constraints) from primary sources, quoting each source with its URL or the command and its output, and marks anything it could not confirm unverified. Read-only; never writes to the checkout and never installs anything. Spawned by the research skill; Claude Code questions go to claude-code-guide instead.
4
4
  model: inherit
5
5
  effort: high
6
- maxTurns: 30
6
+ maxTurns: 40
7
7
  tools: Read, Grep, Glob, Bash, WebFetch, WebSearch
8
8
  disallowedTools: Edit, Write, MultiEdit, NotebookEdit
9
9
  memory: none
@@ -19,6 +19,7 @@ You are the researcher. You were given one question, and a plan will be built on
19
19
  4. Never install, never write, never change the machine: no package installs, no global config, no files in the checkout, no network calls beyond reading pages. If a fact needs an install to observe, mark it unverified and name the command that would show it.
20
20
  5. Date what you read. A documentation page carries the date you fetched it and, when the page shows one, its version.
21
21
  6. Budget: at most 6 WebSearch, WebFetch, curl, or wget calls for your question, counted together (research.maxFetches; a hook denies the next one). Try sources in the order the question lists them, and write your findings before the budget runs out, marking anything still unanswered unverified.
22
+ 7. Turns: you have at most 40 tool calls (maxTurns). Stop with at least 5 calls to spare and return what you have, marking every part you did not reach unverified: a capped run returns a partial report, never an empty one.
22
23
 
23
24
  ## Output
24
25
 
@@ -3,7 +3,7 @@ name: reviewer
3
3
  description: Two-stage reviewer for one task's diff: spec compliance first (does it do all of the spec and nothing else, within the owned files), then quality (correctness risks, missing tests, error handling). Never edits files. Spawned by the doug-implement workflow; also useful standalone on any branch.
4
4
  model: inherit
5
5
  effort: high
6
- maxTurns: 30
6
+ maxTurns: 40
7
7
  tools: Read, Grep, Glob, Bash
8
8
  disallowedTools: Edit, Write, MultiEdit, NotebookEdit
9
9
  memory: none
@@ -24,3 +24,5 @@ Review the diff, not the description of the diff. Get it with `git diff <base>..
24
24
  - Consistency: naming and error handling match the surrounding code.
25
25
 
26
26
  Severity: `blocker` fails the review; `major` should be fixed before merge but does not fail it alone; `minor` is advisory. `approve` is true only with no blockers, `specCompliant` true, and `inScope` true. Do not ask for work beyond the task.
27
+
28
+ Budget your turns: you have at most 40 tool calls (maxTurns). Read the diff and run the checks your prompt names first, then any deeper probes; stop with at least 5 calls to spare and return your review with what you found: a capped run returns a partial review, never an empty one.
@@ -1,5 +1,6 @@
1
1
  // The live autopilot pane (card autopilot-live-queue). It opens itself when a run starts (card autopilot-pane-auto-open); /doug-autopilot reopens it. It polls
2
2
  // .doug/.state/autopilot.json (written by scripts/autopilot.mjs) and shows the same lines the board's panel shows.
3
+ // It also refreshes on prompt submit, turn end, and after a Bash call, because a reload drops the session.start timer (card autopilot-pane-current).
3
4
  import { atom, read, update } from 'claude-code'
4
5
  import type { Register } from 'claude-code'
5
6
 
@@ -11,6 +12,35 @@ const lines = atom({ plugin: 'doug-flow', key: 'autopilot' } as const, [] as str
11
12
  // The startedAt of the run the pane was last opened for, so a pane the user closed stays closed for that run.
12
13
  const opened = atom({ plugin: 'doug-flow', key: 'autopilot-opened' } as const, null as string | null)
13
14
 
15
+ const refresh = async ($: any) => {
16
+ try {
17
+ const root = await $.session.root()
18
+ let text = ''
19
+ try {
20
+ text = await $.fs.read(`${root}/${AUTOPILOT_FILE}`)
21
+ } catch {
22
+ // no file: no run
23
+ }
24
+ const state = parseAutopilot(text)
25
+ const fresh = autopilotLines(autopilotView(state, new Date()))
26
+ await update($, lines, old => (JSON.stringify(old) === JSON.stringify(fresh) ? old : fresh))
27
+ // Only `autopilot.autoOpenPane: false` in .doug/config.json turns the auto-open off.
28
+ let autoOpen = true
29
+ try {
30
+ autoOpen = JSON.parse(await $.fs.read(`${root}/.doug/config.json`))?.autopilot?.autoOpenPane !== false
31
+ } catch {
32
+ // missing or unparsable config: flag on
33
+ }
34
+ const last = await read($, opened)
35
+ if (state && shouldOpenPane(state, last, autoOpen, new Date())) {
36
+ await update($, opened, () => state.startedAt as string)
37
+ await $.ui.open({ id: PANE, title: 'Autopilot' })
38
+ }
39
+ } catch {
40
+ // a failed poll keeps the last drawing
41
+ }
42
+ }
43
+
14
44
  export const register: Register = on => {
15
45
  on('session.start', async ($, e, next) => {
16
46
  await $.command.register({
@@ -18,43 +48,32 @@ export const register: Register = on => {
18
48
  description: 'Show the autopilot queue live in a pane',
19
49
  })
20
50
 
21
- const poll = async () => {
22
- try {
23
- const root = await $.session.root()
24
- let text = ''
25
- try {
26
- text = await $.fs.read(`${root}/${AUTOPILOT_FILE}`)
27
- } catch {
28
- // no file: no run
29
- }
30
- const state = parseAutopilot(text)
31
- const fresh = autopilotLines(autopilotView(state, new Date()))
32
- await update($, lines, old => (JSON.stringify(old) === JSON.stringify(fresh) ? old : fresh))
33
- // Only `autopilot.autoOpenPane: false` in .doug/config.json turns the auto-open off.
34
- let autoOpen = true
35
- try {
36
- autoOpen = JSON.parse(await $.fs.read(`${root}/.doug/config.json`))?.autopilot?.autoOpenPane !== false
37
- } catch {
38
- // missing or unparsable config: flag on
39
- }
40
- const last = await read($, opened)
41
- if (state && shouldOpenPane(state, last, autoOpen, new Date())) {
42
- await update($, opened, () => state.startedAt as string)
43
- await $.ui.open({ id: PANE, title: 'Autopilot' })
44
- }
45
- } catch {
46
- // a failed poll keeps the last drawing
47
- }
48
- }
49
- // A -p run or the SDK has nobody at the prompt: register the command, start no poll.
51
+ // The timer starts only in an interactive session; the event hooks run in any session (two small reads per prompt, turn end, and Bash call).
50
52
  if (e.isInteractive) {
51
- await poll()
52
- $.clock.every(POLL_MS, poll)
53
+ await refresh($)
54
+ $.clock.every(POLL_MS, () => refresh($))
53
55
  }
54
56
 
55
57
  return next(e)
56
58
  })
57
59
 
60
+ // A reload drops the session.start timer, so these hooks also refresh the pane (card autopilot-pane-current).
61
+ on('prompt.submit', async ($, e, next) => {
62
+ await refresh($)
63
+ return next(e)
64
+ })
65
+
66
+ on('turn.complete', async ($, e, next) => {
67
+ await refresh($)
68
+ return next(e)
69
+ })
70
+
71
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
72
+ const ran = await next(e)
73
+ await refresh($)
74
+ return ran
75
+ })
76
+
58
77
  on('command.run', { command: 'doug-autopilot' }, async $ => {
59
78
  await $.ui.open({ id: PANE, title: 'Autopilot' })
60
79
  return { text: 'Autopilot pane opened.' }
@@ -14,6 +14,7 @@ export const STOP_REASONS = [
14
14
  "setup-failed",
15
15
  "tester-red-claim-missing",
16
16
  "reviewer-blocker-open",
17
+ "reviewer-major-open",
17
18
  "gate-red-twice",
18
19
  ];
19
20
  // The Models-table row each seat runs on. setup, gate and commit have no sourced row; cheap is a choice.
@@ -86,6 +87,22 @@ export const BRIEF_RULES = {
86
87
  text: "Commit without Co-Authored-By or Claude-Session trailers.",
87
88
  seats: ["commit"],
88
89
  },
90
+ "commit-foreground": {
91
+ text: "Run git commit in the foreground with the Bash tool timeout set to 600000 ms: the pre-commit hook runs the full unit suite and takes over the 2-minute default.",
92
+ seats: ["commit"],
93
+ },
94
+ "commit-no-background": {
95
+ text: "Never use run_in_background, a Monitor, or a sleep loop for git commit: a background commit dies when your seat ends. Wait for the command to return.",
96
+ seats: ["commit"],
97
+ },
98
+ "commit-sha-from-git-log": {
99
+ text: "Report the sha only from git log -1 --format=%H after git commit has returned; never report a sha you did not read from it.",
100
+ seats: ["commit"],
101
+ },
102
+ "gate-no-git-writes": {
103
+ text: "Run the gate commands only. Never run git add, git commit, or any other command that writes to git; the commit seat is the only seat that commits.",
104
+ seats: ["gate"],
105
+ },
89
106
  "leave-plan-anchor-out": {
90
107
  text: "Leave .doug/plan.json and .doug/anchor.md out of the commit unless the card is about them.",
91
108
  seats: ["commit"],
@@ -516,6 +533,9 @@ function validate(state, hb) {
516
533
  }
517
534
  case "gate": {
518
535
  checkCmds(hb.commands, "commands", errs, true);
536
+ if (Array.isArray(hb.commands) && hb.commands.some((c) => c && isStr(c.command) && GIT_WRITE_RE.test(c.command))) {
537
+ errs.push("gate seat must not run git add or git commit");
538
+ }
519
539
  if (!nonBlank(hb.gateLine)) errs.push("gateLine must be a non-empty string");
520
540
  if (hb.lockWaitMs !== undefined && hb.lockWaitMs !== null && !Number.isInteger(hb.lockWaitMs)) {
521
541
  errs.push("lockWaitMs must be an integer or null");
@@ -535,6 +555,9 @@ function validate(state, hb) {
535
555
 
536
556
  // ---- next ----
537
557
 
558
+ // git add / git commit at the start of a command or after a shell separator, global options (-C <path>, -c k=v, --flag) allowed before the verb.
559
+ const GIT_WRITE_RE = /(?:^|[;&|(\n]\s*)git(?:\s+(?:-[Cc]\s+(?:"[^"]*"|'[^']*'|\S+)|--[\w-]+(?:=\S+)?))*\s+(?:add|commit)\b/;
560
+
538
561
  function stopWith(s, reason, detail) {
539
562
  const seat = s.pending;
540
563
  const state = {
@@ -654,12 +677,21 @@ export function next(state, handBack) {
654
677
  mutations: cp(H.mutationResults),
655
678
  };
656
679
  const blockers = findings.filter((f) => f.severity === "blocker");
657
- if (blockers.length === 0) return advance(s, "gate", "ok", { acc });
680
+ // An evidenced major gets the same one fix pass; a major without evidence and a minor stay advisory.
681
+ const majors = findings.filter((f) => f.severity === "major" && nonBlank(f.evidence));
682
+ if (blockers.length === 0 && majors.length === 0) return advance(s, "gate", "ok", { acc });
658
683
  if (s.counters.fixPasses >= 1) {
684
+ if (blockers.length > 0) {
685
+ return stopWith(
686
+ { ...s, acc },
687
+ "reviewer-blocker-open",
688
+ "blocker still open after one fix pass: " + blockers.map((f) => f.id).join(", "),
689
+ );
690
+ }
659
691
  return stopWith(
660
692
  { ...s, acc },
661
- "reviewer-blocker-open",
662
- "blocker still open after one fix pass: " + blockers.map((f) => f.id).join(", "),
693
+ "reviewer-major-open",
694
+ "major with evidence still open after one fix pass: " + majors.map((f) => f.id).join(", "),
663
695
  );
664
696
  }
665
697
  return advance(s, buildSeat(s), "blockers", {
@@ -366,3 +366,28 @@ export function capsReached(caps, used) {
366
366
  }
367
367
  return out;
368
368
  }
369
+
370
+ // The queen's short run label (card queen-run-name): the first two hyphen segments of the card id (the id itself
371
+ // with fewer), or the full id when another id in `others` (the card's own id excluded) shortens to the same form.
372
+ const shortForm = (id) => String(id).split("-").slice(0, 2).join("-");
373
+ export function queenLabel(id, others = []) {
374
+ const short = shortForm(id);
375
+ return others.some((o) => o !== id && shortForm(o) === short) ? String(id) : short;
376
+ }
377
+
378
+ // The queen script's source with only its meta literal renamed: name becomes "<label> card-queen" (hand) or
379
+ // "<label> flow-queen" (flow), description gets "<label>: " in front. Throws when either is missing from the meta.
380
+ export function renameQueenMeta(source, { kind, label }) {
381
+ const start = source.indexOf("export const meta = {");
382
+ if (start < 0) throw new Error("no meta literal in the queen script");
383
+ const e = source.indexOf("\n}", start);
384
+ const end = e < 0 ? source.length : e;
385
+ let meta = source.slice(start, end);
386
+ const nameRe = /^(\s*name: ')[^']*(',)/m;
387
+ const descRe = /^(\s*description: ')/m;
388
+ if (!nameRe.test(meta)) throw new Error("no meta name in the queen script");
389
+ if (!descRe.test(meta)) throw new Error("no meta description in the queen script");
390
+ const suffix = kind === "flow" ? "flow-queen" : "card-queen";
391
+ meta = meta.replace(nameRe, (_m, a, b) => `${a}${label} ${suffix}${b}`).replace(descRe, (_m, a) => `${a}${label}: `);
392
+ return source.slice(0, start) + meta + source.slice(end);
393
+ }
@@ -250,8 +250,9 @@ function commandsBlockText(text) {
250
250
  // writes it - only ever calls writeOne/writeProposals (learn.mjs), which writes under .doug/.state/learn/. Every
251
251
  // refusal returns { ok:false, reason } with nothing written; `edits` can be ANY value JSON.parse can produce
252
252
  // (null, an array, a string, a number, or an object whose fields are the wrong shape) and this never throws -
253
- // every field is type-checked before use (lesson 1ee7d426432aca82).
254
- export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
253
+ // every field is type-checked before use (lesson 1ee7d426432aca82). With `dryRun: true` every check still runs
254
+ // and the result is { ok:true, reason, diff } with nothing written (card reflect-offers-claude-md).
255
+ export function proposeClaudeMd({ dir, edits, now = new Date(), dryRun = false } = {}) {
255
256
  const target = join(dir, CLAUDE_MD_RELPATH);
256
257
  if (!existsSync(target)) return { ok: false, reason: `${CLAUDE_MD_RELPATH} does not exist under ${dir}` };
257
258
 
@@ -310,5 +311,6 @@ export function proposeClaudeMd({ dir, edits, now = new Date() } = {}) {
310
311
  lines: { before, after },
311
312
  };
312
313
  const proposal = { id: "01", kind: "claude-md", target: CLAUDE_MD_RELPATH, reason, rationale, diff: diffResult.diff };
314
+ if (dryRun) return { ok: true, reason, diff: diffResult.diff };
313
315
  return writeOne(proposal, { dir, now });
314
316
  }