@dougharness/cli 1.1.1 → 1.2.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/board-serve.js +40 -6
- package/dist/board-serve.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/memory-setup.d.ts +7 -0
- package/dist/memory-setup.js +70 -46
- package/dist/memory-setup.js.map +1 -1
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
- package/node_modules/@dougharness/flow/agents/coder.md +23 -0
- package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
- package/node_modules/@dougharness/flow/agents/tester.md +21 -0
- package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
- package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
- package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
- package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
- package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
- package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
- package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
- package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
- package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
- package/node_modules/@dougharness/flow/package.json +1 -1
- package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
- package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
- package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
- package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
- package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
- package/node_modules/@dougharness/gates/package.json +1 -1
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
// Fails closed exactly like subagentsInFlight: with trace.enabled false, or the trace file missing or
|
|
74
74
|
// unreadable, there is no evidence either way, so the gate runs as today.
|
|
75
75
|
|
|
76
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
|
|
76
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync, unlinkSync, openSync, closeSync, readSync } from "node:fs";
|
|
77
77
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
78
78
|
import { fileURLToPath } from "node:url";
|
|
79
79
|
import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
80
|
-
import { runHook, allow, blockStop, systemMessage } from "../lib/io.mjs";
|
|
80
|
+
import { runHook, allow as allowRaw, blockStop, systemMessage as systemMessageRaw } from "../lib/io.mjs";
|
|
81
81
|
import { loadConfig, projectDir } from "../lib/config.mjs";
|
|
82
82
|
import { loadState, saveState } from "../lib/state.mjs";
|
|
83
83
|
import { matchAny } from "../lib/glob.mjs";
|
|
@@ -88,7 +88,7 @@ import { verificationEvidence, describeMissingEvidence } from "../lib/evidence.m
|
|
|
88
88
|
import { changedFiles, captureBaseline, sinceBaseline, headAt, committedSince, walkProposalPathFiles, fileIdentity, treeFingerprint, sameTree, readAgentStart, readTurnStart, turnTreeStatus, 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
|
-
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection } from "../lib/anchor.mjs";
|
|
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
94
|
import { failingTestFilesFrom, nonTestTscPathsFrom, classifyFinalBlock, appendBlockLine } from "../lib/block-ledger.mjs";
|
|
@@ -492,7 +492,24 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
492
492
|
// readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
|
|
493
493
|
// final message carries no tests_red_by_design claim, and its trace shows no successful Edit-type call. Catches read-only agents that run Bash, which
|
|
494
494
|
// agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
|
|
495
|
-
|
|
495
|
+
// card stop-gate-worktree-agent-fingerprint: the start record is a fingerprint of the main checkout (the trace
|
|
496
|
+
// hook runs there), so for a worktree verification dir it is compared like with like instead: unchanged only if
|
|
497
|
+
// the record exists, every verification dir has no changed files, and every one's HEAD carries no commit of its
|
|
498
|
+
// own: it equals or is an ancestor of the record's HEAD, or of the main checkout's refs/remotes/origin/HEAD (Claude
|
|
499
|
+
// Code branches a fresh isolation worktree from the remote default branch, which local main may be ahead of or
|
|
500
|
+
// behind). Any git failure reads as changed. A verification set that mixes a worktree with `dir` reads as
|
|
501
|
+
// changed. A main-checkout agent keeps the fingerprint comparison.
|
|
502
|
+
function isAncestorIn(dir, a, b) {
|
|
503
|
+
try {
|
|
504
|
+
if (!/^[0-9a-f]{7,64}$/.test(String(a)) || !/^[0-9a-f]{7,64}$/.test(String(b))) return false;
|
|
505
|
+
execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: dir, stdio: "ignore", timeout: 5000 });
|
|
506
|
+
return true;
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFingerprint, vdir = dir, forced = null) {
|
|
496
513
|
try {
|
|
497
514
|
if (!subagent || !currentFingerprint) return false;
|
|
498
515
|
if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
|
|
@@ -503,6 +520,24 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
503
520
|
if (existsSync(file) && readTrace(file).some((l) => l.agent === subagent.id && l.event === "PostToolUse" && CHANGE_TOOLS.has(l.tool) && l.ok !== false)) return false;
|
|
504
521
|
}
|
|
505
522
|
const start = readAgentStart(dir, sessionId, subagent.id);
|
|
523
|
+
if (vdir !== dir) {
|
|
524
|
+
if (!start || typeof start !== "object" || !start.head) return false;
|
|
525
|
+
const dirs = forced ? [vdir] : verifyDirs(dir, cfg, sessionId, input, subagent);
|
|
526
|
+
if (!dirs.includes(vdir) || dirs.includes(dir)) return false;
|
|
527
|
+
let remoteHead;
|
|
528
|
+
try {
|
|
529
|
+
remoteHead = execFileSync("git", ["rev-parse", "--verify", "-q", "refs/remotes/origin/HEAD^{commit}"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim() || null;
|
|
530
|
+
} catch {
|
|
531
|
+
remoteHead = null;
|
|
532
|
+
}
|
|
533
|
+
return dirs.every((d) => {
|
|
534
|
+
const h = headAt(d);
|
|
535
|
+
if (!h) return false;
|
|
536
|
+
if (!isAncestorIn(dir, h, start.head) && !(remoteHead && isAncestorIn(dir, h, remoteHead))) return false;
|
|
537
|
+
const c = changedFiles(d);
|
|
538
|
+
return Array.isArray(c) && c.length === 0;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
506
541
|
return sameTree(start, currentFingerprint);
|
|
507
542
|
} catch {
|
|
508
543
|
return false;
|
|
@@ -721,6 +756,7 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
|
|
|
721
756
|
const anchorFile = join(dir, ".doug/anchor.md");
|
|
722
757
|
const section = snapshotSection({
|
|
723
758
|
plan: readPlanSummary(dir),
|
|
759
|
+
plans: readPlanSummaries(dir),
|
|
724
760
|
decisions: readDecisions(dir),
|
|
725
761
|
state,
|
|
726
762
|
now: new Date(),
|
|
@@ -1008,10 +1044,99 @@ function normalizedDeferrals(state) {
|
|
|
1008
1044
|
return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
|
|
1009
1045
|
}
|
|
1010
1046
|
|
|
1047
|
+
// Card landing-block-enforced: board.mjs record/summary leave .doug/.state/reports/<id>/landing-block.txt and an
|
|
1048
|
+
// empty landing-block.pending (move <id> done re-arms it). A main Stop that would otherwise allow checks each
|
|
1049
|
+
// pending block against the lead's final message and blocks, once, until every line of it is there verbatim. The
|
|
1050
|
+
// marker is deleted the first time it is checked, so the block happens at most once per landing. Every exit that
|
|
1051
|
+
// lets a main Stop end plainly goes through allow() below. The systemMessage exits (the block-cap and turn-budget
|
|
1052
|
+
// stand-downs, the deferral notice, the repeat-deferral exit) return their text unchanged and never block (card
|
|
1053
|
+
// landing-block-shown-on-deferral): systemMessage() below first sweeps quietly, deleting a marker whose lines are
|
|
1054
|
+
// all in the final message and leaving any other, so a block printed on a deferral stop is not demanded again at
|
|
1055
|
+
// a later plain stop. A red gate (blockStop) keeps its own reason. A marker holds the session that landed (empty =
|
|
1056
|
+
// any); another session's marker is left untouched. Fails open: any throw, an unreadable block
|
|
1057
|
+
// file, or a final message that is not a string allows with one stderr line.
|
|
1058
|
+
let landingInput = null;
|
|
1059
|
+
// The pending markers this session owns, as [id, root] pairs; null when there is no reports directory.
|
|
1060
|
+
function ownedLandingMarkers(input) {
|
|
1061
|
+
const root = join(projectDir(input), ".doug/.state/reports");
|
|
1062
|
+
if (!existsSync(root)) return { root, pending: [] };
|
|
1063
|
+
const mine = (id) => {
|
|
1064
|
+
try {
|
|
1065
|
+
const owner = readFileSync(join(root, id, "landing-block.pending"), "utf8").trim();
|
|
1066
|
+
return owner === "" || owner === input.session_id;
|
|
1067
|
+
} catch {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
return { root, pending: readdirSync(root).filter((id) => existsSync(join(root, id, "landing-block.pending")) && mine(id)).sort() };
|
|
1072
|
+
}
|
|
1073
|
+
function landingBlockLines(root, id) {
|
|
1074
|
+
return readFileSync(join(root, id, "landing-block.txt"), "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1075
|
+
}
|
|
1076
|
+
function landingBlockReason(input) {
|
|
1077
|
+
try {
|
|
1078
|
+
const { root, pending } = ownedLandingMarkers(input);
|
|
1079
|
+
if (!pending.length) return null;
|
|
1080
|
+
const message = input.last_assistant_message;
|
|
1081
|
+
if (typeof message !== "string") {
|
|
1082
|
+
// The markers stay, so a later Stop with a readable final message still checks them.
|
|
1083
|
+
process.stderr.write("[doug] landing-block check skipped: the final message is unreadable\n");
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
const missing = [];
|
|
1087
|
+
for (const id of pending) {
|
|
1088
|
+
const marker = join(root, id, "landing-block.pending");
|
|
1089
|
+
try {
|
|
1090
|
+
const lines = landingBlockLines(root, id);
|
|
1091
|
+
if (lines.length && !lines.every((l) => message.includes(l))) missing.push({ id, lines });
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
unlinkSync(marker);
|
|
1097
|
+
} catch {}
|
|
1098
|
+
}
|
|
1099
|
+
if (!missing.length) return null;
|
|
1100
|
+
return missing.map((m) => `Landing block missing from your final message for ${m.id}. Paste these lines verbatim:\n${m.lines.join("\n")}`).join("\n\n");
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// Quiet sweep for the systemMessage exits: delete each owned marker whose lines are all in the final message,
|
|
1107
|
+
// keep the rest. Never blocks, never changes the exit's text.
|
|
1108
|
+
function sweepLandingMarkers(input) {
|
|
1109
|
+
try {
|
|
1110
|
+
const message = input.last_assistant_message;
|
|
1111
|
+
if (typeof message !== "string") return;
|
|
1112
|
+
const { root, pending } = ownedLandingMarkers(input);
|
|
1113
|
+
for (const id of pending) {
|
|
1114
|
+
try {
|
|
1115
|
+
const lines = landingBlockLines(root, id);
|
|
1116
|
+
if (lines.length && lines.every((l) => message.includes(l))) unlinkSync(join(root, id, "landing-block.pending"));
|
|
1117
|
+
} catch (e) {
|
|
1118
|
+
process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
function systemMessage(message) {
|
|
1126
|
+
if (landingInput) sweepLandingMarkers(landingInput);
|
|
1127
|
+
return systemMessageRaw(message);
|
|
1128
|
+
}
|
|
1129
|
+
function allow() {
|
|
1130
|
+
const reason = landingInput ? landingBlockReason(landingInput) : null;
|
|
1131
|
+
if (reason) return blockStop(reason);
|
|
1132
|
+
return allowRaw();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1011
1135
|
const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
|
|
1012
1136
|
|
|
1013
1137
|
runHook("stop-gate", async (input) => {
|
|
1014
|
-
if (input.stop_hook_active) return
|
|
1138
|
+
if (input.stop_hook_active) return allowRaw();
|
|
1139
|
+
if (input.hook_event_name !== "SubagentStop") landingInput = input;
|
|
1015
1140
|
const dir = projectDir(input);
|
|
1016
1141
|
const cfg = loadConfig(dir);
|
|
1017
1142
|
let state = loadState(dir, input.session_id);
|
|
@@ -1394,8 +1519,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1394
1519
|
// Draft and rejected plans are not contracts yet, so they are ignored.
|
|
1395
1520
|
let scope = null;
|
|
1396
1521
|
if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
|
|
1397
|
-
const { plan, reason, error } = loadScopePlan(dir);
|
|
1398
|
-
if (reason === "unreadable") process.stderr.write(`[doug] ${PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
|
|
1522
|
+
const { plan, reason, error, file } = loadScopePlan(dir);
|
|
1523
|
+
if (reason === "unreadable") process.stderr.write(`[doug] ${file || PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
|
|
1399
1524
|
if (plan) {
|
|
1400
1525
|
const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
|
|
1401
1526
|
scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
|
|
@@ -1449,7 +1574,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1449
1574
|
state.deferrals = n;
|
|
1450
1575
|
state.lastDeferral = { ids, fingerprint: currentFingerprint };
|
|
1451
1576
|
saveState(dir, input.session_id, state);
|
|
1452
|
-
if (repeat)
|
|
1577
|
+
if (repeat) {
|
|
1578
|
+
if (landingInput) sweepLandingMarkers(landingInput);
|
|
1579
|
+
return allowRaw();
|
|
1580
|
+
}
|
|
1453
1581
|
return systemMessage(
|
|
1454
1582
|
`[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
|
|
1455
1583
|
);
|
|
@@ -1498,7 +1626,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1498
1626
|
};
|
|
1499
1627
|
const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
|
|
1500
1628
|
? "no-agent-changes"
|
|
1501
|
-
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
|
|
1629
|
+
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
|
|
1502
1630
|
? "tree-unchanged"
|
|
1503
1631
|
: readOnlyTurn
|
|
1504
1632
|
? "read-only-turn"
|
|
@@ -1588,7 +1716,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1588
1716
|
|
|
1589
1717
|
// card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
|
|
1590
1718
|
// project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
|
|
1591
|
-
// 300s timeout orphaning ten vitest workers). A gate holds
|
|
1719
|
+
// 300s timeout orphaning ten vitest workers). A gate holds gate.lock (gateLockPath: under the git common dir) while its commands run;
|
|
1592
1720
|
// a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
|
|
1593
1721
|
// reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
|
|
1594
1722
|
// this gate needs, letting it skip instead of also running the full suite.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// node .doug/hooks/scripts/verify.mjs [--no-credit] [--json] [--dir <project>] [--cwd <tree dir>] [--timeout-ms <n>]
|
|
5
5
|
// [--record-dir <dir>] [--credit-dir <dir>]... [--by <writer>] [--command <cmd>]...
|
|
6
6
|
// Exit: 0 all green or credited; 1 any command failed or timed out; 2 usage error or no commands.
|
|
7
|
-
// No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's
|
|
7
|
+
// No count ratchet and no scans here (no session baseline); the Stop gate does those. Takes the Stop gate's gate.lock (under the git common dir, shared by every worktree) around the commands (card verify-takes-gate-lock).
|
|
8
8
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { unlinkSync } from "node:fs";
|
|
@@ -78,6 +78,7 @@ function main() {
|
|
|
78
78
|
return 2;
|
|
79
79
|
}
|
|
80
80
|
let treeBefore = computeTree(cwd);
|
|
81
|
+
let lockWaitMs = 0;
|
|
81
82
|
|
|
82
83
|
// Credits a verified-tree record of the same tree when one exists; returns the exit code, or null.
|
|
83
84
|
const tryCredit = () => {
|
|
@@ -98,7 +99,7 @@ function main() {
|
|
|
98
99
|
const entries = record.commands.map((c) => ({ name: c.name, exit: c.exit, passed: passed(record.totals[c.command]) }));
|
|
99
100
|
if (o.json) {
|
|
100
101
|
const results = record.commands.map((c) => ({ name: c.name, command: c.command, ok: true, status: c.exit, timedOut: false, durationMs: c.durationMs, outputTail: "" }));
|
|
101
|
-
process.stdout.write(JSON.stringify({ ok: true, credited: "verified-tree", tree: treeBefore, record, results }) + "\n");
|
|
102
|
+
process.stdout.write(JSON.stringify({ ok: true, credited: "verified-tree", tree: treeBefore, record, results, lockWaitMs }) + "\n");
|
|
102
103
|
} else {
|
|
103
104
|
process.stdout.write(`[doug] verify: credited verified-tree record (tree ${treeBefore.slice(0, 7)}, by ${record.by || "unknown"}, ${record.at}, node ${record.node}); commands not re-run. --no-credit runs them.\n`);
|
|
104
105
|
process.stdout.write(gateLine(entries) + "\n");
|
|
@@ -142,7 +143,8 @@ function main() {
|
|
|
142
143
|
takeOverGateLock(dir, o.by || "verify");
|
|
143
144
|
lockOwned = true;
|
|
144
145
|
} else {
|
|
145
|
-
const
|
|
146
|
+
const waitStart = Date.now();
|
|
147
|
+
const deadline = waitStart + waitBoundMs;
|
|
146
148
|
let live = held;
|
|
147
149
|
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
148
150
|
while (true) {
|
|
@@ -150,6 +152,8 @@ function main() {
|
|
|
150
152
|
if (stale(live) || Date.now() >= deadline) break;
|
|
151
153
|
Atomics.wait(nap, 0, 0, 200);
|
|
152
154
|
}
|
|
155
|
+
lockWaitMs = Date.now() - waitStart;
|
|
156
|
+
if (!o.json && lockWaitMs > 0) process.stdout.write(`[doug] verify: waited ${lockWaitMs} ms on gate.lock\n`);
|
|
153
157
|
treeBefore = computeTree(cwd);
|
|
154
158
|
const again = tryCredit();
|
|
155
159
|
if (again !== null) return again;
|
|
@@ -186,7 +190,7 @@ function main() {
|
|
|
186
190
|
} else {
|
|
187
191
|
removeRecord(recordDir);
|
|
188
192
|
}
|
|
189
|
-
if (o.json) process.stdout.write(JSON.stringify({ ok, credited: null, tree: treeBefore, record: null, results }) + "\n");
|
|
193
|
+
if (o.json) process.stdout.write(JSON.stringify({ ok, credited: null, tree: treeBefore, record: null, results, lockWaitMs }) + "\n");
|
|
190
194
|
else process.stdout.write(gateLine(entries) + "\n");
|
|
191
195
|
return ok ? 0 : 1;
|
|
192
196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.2.0",
|
|
27
|
+
"@dougharness/gates": "1.2.0"
|
|
28
28
|
},
|
|
29
29
|
"bundleDependencies": [
|
|
30
30
|
"@dougharness/flow",
|
package/templates/board-app.js
CHANGED
|
@@ -45,6 +45,15 @@ details{margin-top:6px}summary{font-size:12px;color:var(--accent);cursor:pointer
|
|
|
45
45
|
.move{margin-top:8px;display:flex;gap:6px;align-items:center}.move select{font-size:12px;padding:3px 6px}
|
|
46
46
|
.ro .card{cursor:default}.ro .move{display:none}
|
|
47
47
|
footer{padding:0 24px 24px;font-size:12px;color:var(--ink-soft)}
|
|
48
|
+
#autopilot{margin:14px 24px 0;padding:10px 14px;background:var(--surface);border:1px solid var(--rule-strong)}
|
|
49
|
+
#autopilot[hidden]{display:none}
|
|
50
|
+
#autopilot h2{font-family:"IBM Plex Mono",Menlo,monospace;font-size:12px;letter-spacing:.06em;text-transform:uppercase;font-weight:500;margin:0 0 6px}
|
|
51
|
+
#autopilot ul{list-style:none;margin:4px 0;padding:0;font-size:13px}
|
|
52
|
+
#autopilot li{display:flex;gap:8px;flex-wrap:wrap;align-items:baseline}
|
|
53
|
+
#autopilot .st{font-family:"IBM Plex Mono",Menlo,monospace;font-size:11px;text-transform:uppercase;color:var(--ink-soft);min-width:5.5em}
|
|
54
|
+
#autopilot .st.running{color:var(--accent);font-weight:600}
|
|
55
|
+
#autopilot .st.stopped,#autopilot .st.interrupted{color:var(--c-decision)}
|
|
56
|
+
#autopilot .caps,#autopilot .filed{font-size:12px;color:var(--ink-soft);margin-top:4px}
|
|
48
57
|
@media (max-width:720px){.board{grid-auto-columns:minmax(85vw,1fr)}}
|
|
49
58
|
`;
|
|
50
59
|
|
|
@@ -163,6 +172,12 @@ function connectLive(opts) {
|
|
|
163
172
|
if (source !== es) return;
|
|
164
173
|
opts.onBoard(JSON.parse(e.data));
|
|
165
174
|
});
|
|
175
|
+
es.addEventListener("autopilot", (e) => {
|
|
176
|
+
if (source !== es || !opts.onAutopilot) return;
|
|
177
|
+
let view = null;
|
|
178
|
+
try { view = JSON.parse(e.data); } catch (_) {}
|
|
179
|
+
opts.onAutopilot(view);
|
|
180
|
+
});
|
|
166
181
|
}
|
|
167
182
|
open();
|
|
168
183
|
|
|
@@ -246,6 +261,33 @@ function cardMatches(card, filters) {
|
|
|
246
261
|
return true;
|
|
247
262
|
}
|
|
248
263
|
|
|
264
|
+
// The autopilot panel's inner HTML for a view from GET /api/autopilot (lib/autopilot-view.mjs autopilotView);
|
|
265
|
+
// "" for no view, which leaves the panel hidden.
|
|
266
|
+
function autopilotHtml(view) {
|
|
267
|
+
if (!view || typeof view !== "object") return "";
|
|
268
|
+
const cards = Array.isArray(view.cards) ? view.cards : [];
|
|
269
|
+
const filed = Array.isArray(view.filed) ? view.filed : [];
|
|
270
|
+
const caps = view.caps || {};
|
|
271
|
+
const items = cards.map((c) => {
|
|
272
|
+
const shape = [c.size, c.class].filter(Boolean).join("/");
|
|
273
|
+
return `<li><span class="st ${esc(c.state)}">${esc(c.state)}</span><code>${esc(c.id)}</code>${c.title ? `<span>${esc(c.title)}</span>` : ""}${shape ? `<span class="meta">${esc(shape)}</span>` : ""}${c.detail ? `<span class="meta">${esc(c.detail)}</span>` : ""}</li>`;
|
|
274
|
+
}).join("");
|
|
275
|
+
const filedHtml = filed.length
|
|
276
|
+
? `<div class="filed">filed: ${filed.map((f) => `<code>${esc(f.id)}</code> (${esc(f.kind)}${f.from ? " from " + esc(f.from) : ""}) ${esc(f.position || "")}`).join("; ")}</div>`
|
|
277
|
+
: "";
|
|
278
|
+
return `<h2>${esc(view.header || "Autopilot")}</h2><ul>${items}</ul><div class="caps">usd ${esc(caps.usd || "")} · wall ${esc(caps.wall || "")} · context ${esc(caps.context || "")}</div>${filedHtml}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Shows a view in the #autopilot container, or hides the container for none.
|
|
282
|
+
function applyAutopilot(view, doc) {
|
|
283
|
+
doc = doc !== undefined ? doc : (typeof document !== "undefined" ? document : null);
|
|
284
|
+
const el = doc && doc.getElementById("autopilot");
|
|
285
|
+
if (!el) return;
|
|
286
|
+
const html = autopilotHtml(view);
|
|
287
|
+
el.innerHTML = html;
|
|
288
|
+
el.hidden = html === "";
|
|
289
|
+
}
|
|
290
|
+
|
|
249
291
|
// The complete document for a given board state; `doug board serve` renders it from the record on
|
|
250
292
|
// every request.
|
|
251
293
|
function renderDoc(board, appSource, config) {
|
|
@@ -265,6 +307,7 @@ ${tagFilter ? tagFilter + "\n" : ""}<span class="status" id="filter-note"></span
|
|
|
265
307
|
<span class="status" id="status"></span>
|
|
266
308
|
</div>
|
|
267
309
|
</header>
|
|
310
|
+
<section id="autopilot" aria-label="Autopilot" hidden></section>
|
|
268
311
|
<main class="board" id="board">
|
|
269
312
|
${board.columns.map((col) => columnHtml(col, board.cards, board.columns)).join("\n")}
|
|
270
313
|
</main>
|
|
@@ -509,6 +552,8 @@ function app() {
|
|
|
509
552
|
readOnly = false;
|
|
510
553
|
document.body.classList.remove("ro");
|
|
511
554
|
say("");
|
|
555
|
+
// The autopilot panel: the run's view now, then live over the same event stream.
|
|
556
|
+
fetch("/api/autopilot").then((r) => (r && r.status === 200 ? r.json() : null)).then((view) => applyAutopilot(view)).catch(() => {});
|
|
512
557
|
// Once served, follow the record live: another writer's move (or this tab's own, echoed
|
|
513
558
|
// back) re-renders in place, or reloads when the columns or filters themselves changed.
|
|
514
559
|
connectLive({
|
|
@@ -516,6 +561,7 @@ function app() {
|
|
|
516
561
|
url: "/api/events",
|
|
517
562
|
setTimeout: window.setTimeout.bind(window),
|
|
518
563
|
clearTimeout: window.clearTimeout.bind(window),
|
|
564
|
+
onAutopilot: (view) => applyAutopilot(view),
|
|
519
565
|
onBoard: (next) => {
|
|
520
566
|
const updated = applyLiveBoard(board, next, config);
|
|
521
567
|
if (updated) {
|