@dougharness/cli 1.1.0 → 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/bin.js +10 -3
- package/dist/bin.js.map +1 -1
- package/dist/board-serve.js +45 -10
- package/dist/board-serve.js.map +1 -1
- package/dist/board.d.ts +1 -1
- package/dist/board.js +33 -8
- package/dist/board.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 +75 -48
- 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/lead.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +2 -2
- 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 +107 -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 +44 -11
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +147 -4
- package/node_modules/@dougharness/flow/lib/land.mjs +33 -9
- package/node_modules/@dougharness/flow/lib/memory.mjs +96 -33
- package/node_modules/@dougharness/flow/lib/plan.mjs +34 -5
- 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 +128 -32
- package/node_modules/@dougharness/flow/scripts/context.mjs +4 -2
- package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
- package/node_modules/@dougharness/flow/scripts/memory.mjs +29 -14
- package/node_modules/@dougharness/flow/scripts/plan.mjs +122 -30
- package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +10 -4
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +34 -12
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +1 -1
- package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +2 -2
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +12 -10
- package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +33 -21
- 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 +217 -20
- package/node_modules/@dougharness/gates/lib/gate-lock.mjs +126 -0
- 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 +148 -97
- package/node_modules/@dougharness/gates/scripts/verify.mjs +64 -5
- package/package.json +4 -4
- package/templates/board-app.js +48 -1
|
@@ -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,11 +88,12 @@ 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";
|
|
95
95
|
import { docsGateDecision } from "../lib/docs-gate.mjs";
|
|
96
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
96
97
|
import { resolveCommands, computeTree, readRecord, writeRecord as writeTreeRecord, removeRecord as removeTreeRecord, creditable } from "../lib/verified-tree.mjs";
|
|
97
98
|
|
|
98
99
|
// Only the last this many characters of last_assistant_message are ever scanned for a partial claim
|
|
@@ -491,7 +492,24 @@ function agentMadeNoChanges(dir, cfg, sessionId, subagent) {
|
|
|
491
492
|
// readAgentStart) and it equals the current one (same HEAD, same dirty set, same identities), and the agent's
|
|
492
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
|
|
493
494
|
// agentMadeNoChanges counts as a change. No record, no git, any failure -> false (run the gate).
|
|
494
|
-
|
|
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) {
|
|
495
513
|
try {
|
|
496
514
|
if (!subagent || !currentFingerprint) return false;
|
|
497
515
|
if (redByDesignClaim(input.last_assistant_message) ?? redByDesignClaim(lastHandbackMessage(input.agent_transcript_path))) return false;
|
|
@@ -502,6 +520,24 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
502
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;
|
|
503
521
|
}
|
|
504
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
|
+
}
|
|
505
541
|
return sameTree(start, currentFingerprint);
|
|
506
542
|
} catch {
|
|
507
543
|
return false;
|
|
@@ -511,7 +547,8 @@ function treeUnchangedSinceStart(dir, cfg, sessionId, subagent, input, currentFi
|
|
|
511
547
|
// card worktree-agent-gate, card worktree-gate-mixed-tree-agent: the trees a SubagentStop verifies, in
|
|
512
548
|
// first-touched order. A worktree-isolated subagent edits under <dir>/.claude/worktrees/<wt>/ (input.cwd inside
|
|
513
549
|
// it, or its own successful Edit-type trace lines carry absolute paths there); a path inside <dir> but outside
|
|
514
|
-
// .claude/worktrees names the main checkout
|
|
550
|
+
// .claude/worktrees names the main checkout, except <dir>/.doug/.state and anything under it (untracked state,
|
|
551
|
+
// never project code) which names nothing; any other path names nothing. The set is the cwd's worktree plus
|
|
515
552
|
// every tree its trace paths name (a worktree that no longer exists as a directory is skipped). Empty, a main
|
|
516
553
|
// Stop, or any failure resolves to [dir]. Only the verification target varies per tree: state, trace,
|
|
517
554
|
// gate.lock, the ledger, and evidence stay under `dir`. Never throws.
|
|
@@ -527,8 +564,11 @@ function verifyDirs(dir, cfg, sessionId, input, subagent) {
|
|
|
527
564
|
};
|
|
528
565
|
const inMain = (p) => {
|
|
529
566
|
if (typeof p !== "string" || !isAbsolute(p)) return false;
|
|
530
|
-
const
|
|
531
|
-
|
|
567
|
+
const abs = resolve(p);
|
|
568
|
+
const rel = relative(dir, abs);
|
|
569
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
570
|
+
const st = relative(join(dir, ".doug/.state"), abs);
|
|
571
|
+
return !(st === "" || (!st.startsWith("..") && !isAbsolute(st)));
|
|
532
572
|
};
|
|
533
573
|
const trees = [];
|
|
534
574
|
const add = (t) => {
|
|
@@ -716,6 +756,7 @@ function contextNotice(dir, cfg, state, sessionId, boundary) {
|
|
|
716
756
|
const anchorFile = join(dir, ".doug/anchor.md");
|
|
717
757
|
const section = snapshotSection({
|
|
718
758
|
plan: readPlanSummary(dir),
|
|
759
|
+
plans: readPlanSummaries(dir),
|
|
719
760
|
decisions: readDecisions(dir),
|
|
720
761
|
state,
|
|
721
762
|
now: new Date(),
|
|
@@ -967,70 +1008,6 @@ function verifiedUnchangedAllow(dir, cfg, state, input, subagent, capNote) {
|
|
|
967
1008
|
return notice ? systemMessage(notice) : allow();
|
|
968
1009
|
}
|
|
969
1010
|
|
|
970
|
-
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
971
|
-
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
972
|
-
// 2026-09-16 process storm). `.doug/.state/gate.lock` holds `{ pid, session, at }` while a gate's commands
|
|
973
|
-
// run.
|
|
974
|
-
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
975
|
-
function gateLockPath(dir) {
|
|
976
|
-
return join(dir, GATE_LOCK_RELPATH);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
|
|
980
|
-
// content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
|
|
981
|
-
// caller treats exactly like a stale one.
|
|
982
|
-
function readGateLock(dir) {
|
|
983
|
-
try {
|
|
984
|
-
const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
|
|
985
|
-
// A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
|
|
986
|
-
// group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
|
|
987
|
-
// throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
|
|
988
|
-
return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
989
|
-
} catch {
|
|
990
|
-
return null;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
function pidIsLive(pid) {
|
|
995
|
-
try {
|
|
996
|
-
process.kill(pid, 0);
|
|
997
|
-
return true;
|
|
998
|
-
} catch (err) {
|
|
999
|
-
// EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
|
|
1000
|
-
// that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
|
|
1001
|
-
// anything else) reads as dead.
|
|
1002
|
-
return err && err.code === "EPERM";
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
1007
|
-
// caller decides what a failure means.
|
|
1008
|
-
function writeGateLock(dir, sessionId) {
|
|
1009
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
1010
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
1014
|
-
// simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
|
|
1015
|
-
// it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
|
|
1016
|
-
// does not hold).
|
|
1017
|
-
function takeOverGateLock(dir, sessionId) {
|
|
1018
|
-
try {
|
|
1019
|
-
mkdirSync(join(dir, ".doug/.state"), { recursive: true });
|
|
1020
|
-
writeFileSync(gateLockPath(dir), JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
1021
|
-
} catch {
|
|
1022
|
-
// best-effort takeover only
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
1027
|
-
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
1028
|
-
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
1029
|
-
function resolveHookTimeoutSec(cfg) {
|
|
1030
|
-
const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
|
|
1031
|
-
return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
1011
|
// Computed once per gate run: the wall-clock instant past which the platform itself would cancel this
|
|
1035
1012
|
// hook (F1 — a timed-out hook is killed, fails open, and its output, including a block message, is
|
|
1036
1013
|
// discarded), less a margin so the gate's own block reaches Claude Code before that happens. gateStart
|
|
@@ -1049,26 +1026,8 @@ function gateBudget(cfg, inherited) {
|
|
|
1049
1026
|
return { hookTimeoutSec, hookTimeoutMs, deadline };
|
|
1050
1027
|
}
|
|
1051
1028
|
|
|
1052
|
-
// A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
|
|
1053
|
-
// legitimate holder can still be running that long, since the platform itself would have killed its hook
|
|
1054
|
-
// by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
|
|
1055
|
-
function lockStaleByAge(lock, hookTimeoutMs) {
|
|
1056
|
-
return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Card stop-gate-timeout-normalise: cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout
|
|
1060
|
-
// and the wait bound below, so the two never disagree on what a garbage or fractional value means. A finite
|
|
1061
|
-
// number above 0 floors to an integer, clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a
|
|
1062
|
-
// timeout of 0 as none at all, letting a command run past the budget unclamped; 2.5 must not reach spawnSync
|
|
1063
|
-
// un-floored — it throws ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else
|
|
1064
|
-
// (non-number, NaN, Infinity, 0, negative, null) is "unset" (null), read by each call site as its own default.
|
|
1065
|
-
function normalizedTimeoutMs(cfg) {
|
|
1066
|
-
const t = cfg.stopGate.timeoutMs;
|
|
1067
|
-
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
1029
|
// Card stop-gate-deferral-cap (R2): cfg.stopGate.maxDeferrals normalised the same shape as
|
|
1071
|
-
// normalizedTimeoutMs
|
|
1030
|
+
// normalizedTimeoutMs (lib/gate-lock.mjs) — a hook reader must never throw (F6/F10), so a finite number >= 1 is floored
|
|
1072
1031
|
// and used; anything else (missing, 0, negative, NaN, a string, null, an object) reads as the default, 10.
|
|
1073
1032
|
function normalizedMaxDeferrals(cfg) {
|
|
1074
1033
|
const m = cfg && cfg.stopGate && cfg.stopGate.maxDeferrals;
|
|
@@ -1085,10 +1044,99 @@ function normalizedDeferrals(state) {
|
|
|
1085
1044
|
return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
|
|
1086
1045
|
}
|
|
1087
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
|
+
|
|
1088
1135
|
const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
|
|
1089
1136
|
|
|
1090
1137
|
runHook("stop-gate", async (input) => {
|
|
1091
|
-
if (input.stop_hook_active) return
|
|
1138
|
+
if (input.stop_hook_active) return allowRaw();
|
|
1139
|
+
if (input.hook_event_name !== "SubagentStop") landingInput = input;
|
|
1092
1140
|
const dir = projectDir(input);
|
|
1093
1141
|
const cfg = loadConfig(dir);
|
|
1094
1142
|
let state = loadState(dir, input.session_id);
|
|
@@ -1471,8 +1519,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1471
1519
|
// Draft and rejected plans are not contracts yet, so they are ignored.
|
|
1472
1520
|
let scope = null;
|
|
1473
1521
|
if (Array.isArray(changed) && cfg.stopGate.planScope !== false) {
|
|
1474
|
-
const { plan, reason, error } = loadScopePlan(dir);
|
|
1475
|
-
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`);
|
|
1476
1524
|
if (plan) {
|
|
1477
1525
|
const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
|
|
1478
1526
|
scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
|
|
@@ -1526,7 +1574,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1526
1574
|
state.deferrals = n;
|
|
1527
1575
|
state.lastDeferral = { ids, fingerprint: currentFingerprint };
|
|
1528
1576
|
saveState(dir, input.session_id, state);
|
|
1529
|
-
if (repeat)
|
|
1577
|
+
if (repeat) {
|
|
1578
|
+
if (landingInput) sweepLandingMarkers(landingInput);
|
|
1579
|
+
return allowRaw();
|
|
1580
|
+
}
|
|
1530
1581
|
return systemMessage(
|
|
1531
1582
|
`[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
|
|
1532
1583
|
);
|
|
@@ -1575,7 +1626,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1575
1626
|
};
|
|
1576
1627
|
const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
|
|
1577
1628
|
? "no-agent-changes"
|
|
1578
|
-
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
|
|
1629
|
+
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
|
|
1579
1630
|
? "tree-unchanged"
|
|
1580
1631
|
: readOnlyTurn
|
|
1581
1632
|
? "read-only-turn"
|
|
@@ -1665,7 +1716,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1665
1716
|
|
|
1666
1717
|
// card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
|
|
1667
1718
|
// project (the 2026-09-16 process storm: four concurrent SubagentStops each ran the full suite, each
|
|
1668
|
-
// 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;
|
|
1669
1720
|
// a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
|
|
1670
1721
|
// reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
|
|
1671
1722
|
// this gate needs, letting it skip instead of also running the full suite.
|
|
@@ -1709,7 +1760,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1709
1760
|
// timeoutMs no longer defaults to a flat 300000ms here — the budget deadline below is the real bound
|
|
1710
1761
|
// either way, so an unset value waits only for what remains of it, unmultiplied.
|
|
1711
1762
|
const normalizedTimeout = normalizedTimeoutMs(cfg);
|
|
1712
|
-
const waitBoundMs = normalizedTimeout !== null ? normalizedTimeout
|
|
1763
|
+
const waitBoundMs = normalizedTimeout !== null ? lockWaitBoundMs(normalizedTimeout, resolveCommands(cfg).length) : budgetDeadline - Date.now();
|
|
1713
1764
|
const waitDeadline = Date.now() + waitBoundMs;
|
|
1714
1765
|
// Card stop-gate-budget-under-hook-timeout: waitDeadline alone can outrun the platform's own hook
|
|
1715
1766
|
// timeout (F1), so a waiter also stops at budgetDeadline — but there it refuses the stop instead of
|
|
@@ -4,11 +4,13 @@
|
|
|
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
|
|
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
|
+
import { unlinkSync } from "node:fs";
|
|
10
11
|
import { resolve } from "node:path";
|
|
11
12
|
import { loadConfig } from "../lib/config.mjs";
|
|
13
|
+
import { gateLockPath, readGateLock, pidIsLive, writeGateLock, takeOverGateLock, resolveHookTimeoutSec, lockStaleByAge, normalizedTimeoutMs, lockWaitBoundMs } from "../lib/gate-lock.mjs";
|
|
12
14
|
import { runCommand, tail } from "../lib/run.mjs";
|
|
13
15
|
import { parseTotals, readSummaries, sumTotals } from "../lib/test-totals.mjs";
|
|
14
16
|
import { computeTree, readRecord, writeRecord, removeRecord, creditable, resolveCommands } from "../lib/verified-tree.mjs";
|
|
@@ -75,8 +77,11 @@ function main() {
|
|
|
75
77
|
process.stderr.write("[doug] verify: no commands (stopGate.commands is empty and no --command given)\n");
|
|
76
78
|
return 2;
|
|
77
79
|
}
|
|
78
|
-
|
|
80
|
+
let treeBefore = computeTree(cwd);
|
|
81
|
+
let lockWaitMs = 0;
|
|
79
82
|
|
|
83
|
+
// Credits a verified-tree record of the same tree when one exists; returns the exit code, or null.
|
|
84
|
+
const tryCredit = () => {
|
|
80
85
|
if (!o.noCredit && treeBefore) {
|
|
81
86
|
const wanted = commands.map((c) => c.command);
|
|
82
87
|
let record = null;
|
|
@@ -94,7 +99,7 @@ function main() {
|
|
|
94
99
|
const entries = record.commands.map((c) => ({ name: c.name, exit: c.exit, passed: passed(record.totals[c.command]) }));
|
|
95
100
|
if (o.json) {
|
|
96
101
|
const results = record.commands.map((c) => ({ name: c.name, command: c.command, ok: true, status: c.exit, timedOut: false, durationMs: c.durationMs, outputTail: "" }));
|
|
97
|
-
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");
|
|
98
103
|
} else {
|
|
99
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`);
|
|
100
105
|
process.stdout.write(gateLine(entries) + "\n");
|
|
@@ -102,8 +107,62 @@ function main() {
|
|
|
102
107
|
return 0;
|
|
103
108
|
}
|
|
104
109
|
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
const first = tryCredit();
|
|
113
|
+
if (first !== null) return first;
|
|
105
114
|
|
|
106
|
-
const timeoutMs = o.timeoutMs ? o.timeoutMs :
|
|
115
|
+
const timeoutMs = o.timeoutMs ? o.timeoutMs : (cfg.stopGate && normalizedTimeoutMs(cfg)) ?? 600000;
|
|
116
|
+
const waitBoundMs = lockWaitBoundMs(timeoutMs, commands.length);
|
|
117
|
+
|
|
118
|
+
// Card verify-takes-gate-lock: one full suite at a time per checkout, the same gate.lock the Stop gate takes
|
|
119
|
+
// (lib/gate-lock.mjs). A live holder is waited on for up to timeoutMs x command count (the Stop gate's bound), then the record is re-checked (the
|
|
120
|
+
// holder's green run may have just written it); a lock still held after the bound is taken over and the
|
|
121
|
+
// commands run. Released only by the exit handler below, so every return path (green, red, throw) frees it.
|
|
122
|
+
let lockOwned = false;
|
|
123
|
+
process.on("exit", () => {
|
|
124
|
+
try {
|
|
125
|
+
if (!lockOwned) return;
|
|
126
|
+
const lock = readGateLock(dir);
|
|
127
|
+
if (lock && lock.pid === process.pid) unlinkSync(gateLockPath(dir));
|
|
128
|
+
} catch {
|
|
129
|
+
// never throw from an exit handler
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
writeGateLock(dir, o.by || "verify");
|
|
134
|
+
lockOwned = true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (!err || err.code !== "EEXIST") {
|
|
137
|
+
lockOwned = false; // an unwritable .doug/.state: run unlocked rather than hang
|
|
138
|
+
} else {
|
|
139
|
+
const hookTimeoutMs = resolveHookTimeoutSec(cfg) * 1000;
|
|
140
|
+
const stale = (l) => !l || !pidIsLive(l.pid) || lockStaleByAge(l, hookTimeoutMs);
|
|
141
|
+
const held = readGateLock(dir);
|
|
142
|
+
if (stale(held)) {
|
|
143
|
+
takeOverGateLock(dir, o.by || "verify");
|
|
144
|
+
lockOwned = true;
|
|
145
|
+
} else {
|
|
146
|
+
const waitStart = Date.now();
|
|
147
|
+
const deadline = waitStart + waitBoundMs;
|
|
148
|
+
let live = held;
|
|
149
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
150
|
+
while (true) {
|
|
151
|
+
live = readGateLock(dir);
|
|
152
|
+
if (stale(live) || Date.now() >= deadline) break;
|
|
153
|
+
Atomics.wait(nap, 0, 0, 200);
|
|
154
|
+
}
|
|
155
|
+
lockWaitMs = Date.now() - waitStart;
|
|
156
|
+
if (!o.json && lockWaitMs > 0) process.stdout.write(`[doug] verify: waited ${lockWaitMs} ms on gate.lock\n`);
|
|
157
|
+
treeBefore = computeTree(cwd);
|
|
158
|
+
const again = tryCredit();
|
|
159
|
+
if (again !== null) return again;
|
|
160
|
+
if (!stale(live)) process.stderr.write(`[doug] verify: gate.lock still held by pid ${live.pid} after ${waitBoundMs} ms; running the commands anyway.\n`);
|
|
161
|
+
takeOverGateLock(dir, o.by || "verify");
|
|
162
|
+
lockOwned = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
107
166
|
const results = [];
|
|
108
167
|
const recCommands = [];
|
|
109
168
|
const totals = {};
|
|
@@ -131,7 +190,7 @@ function main() {
|
|
|
131
190
|
} else {
|
|
132
191
|
removeRecord(recordDir);
|
|
133
192
|
}
|
|
134
|
-
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");
|
|
135
194
|
else process.stdout.write(gateLine(entries) + "\n");
|
|
136
195
|
return ok ? 0 : 1;
|
|
137
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"doug": "
|
|
16
|
+
"doug": "dist/bin.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
@@ -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>
|
|
@@ -339,7 +382,8 @@ function app() {
|
|
|
339
382
|
});
|
|
340
383
|
if (res.status === 200) {
|
|
341
384
|
board = await res.json();
|
|
342
|
-
|
|
385
|
+
const warning = res.headers.get("x-doug-warning");
|
|
386
|
+
say("Saved to " + config.record + "." + (warning ? " " + warning : ""));
|
|
343
387
|
} else {
|
|
344
388
|
let message = "The move was rejected (" + res.status + ").";
|
|
345
389
|
try { const body = await res.json(); if (body && body.error) message = body.error; } catch (_) {}
|
|
@@ -508,6 +552,8 @@ function app() {
|
|
|
508
552
|
readOnly = false;
|
|
509
553
|
document.body.classList.remove("ro");
|
|
510
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(() => {});
|
|
511
557
|
// Once served, follow the record live: another writer's move (or this tab's own, echoed
|
|
512
558
|
// back) re-renders in place, or reloads when the columns or filters themselves changed.
|
|
513
559
|
connectLive({
|
|
@@ -515,6 +561,7 @@ function app() {
|
|
|
515
561
|
url: "/api/events",
|
|
516
562
|
setTimeout: window.setTimeout.bind(window),
|
|
517
563
|
clearTimeout: window.clearTimeout.bind(window),
|
|
564
|
+
onAutopilot: (view) => applyAutopilot(view),
|
|
518
565
|
onBoard: (next) => {
|
|
519
566
|
const updated = applyLiveBoard(board, next, config);
|
|
520
567
|
if (updated) {
|