@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The gate.lock helpers shared by the Stop gate (scripts/stop-gate.mjs) and verify.mjs (card
|
|
2
|
+
// verify-takes-gate-lock). Moved verbatim from stop-gate.mjs; the Stop gate's behaviour is unchanged.
|
|
3
|
+
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
// Single-runner lock (card stop-gate-process-storm, goal 3): only one gate runs the verification commands
|
|
9
|
+
// at a time per project, so concurrent SubagentStops share one suite instead of each starting its own (the
|
|
10
|
+
// 2026-09-16 process storm). gate.lock holds `{ pid, session, at }` while a gate's commands run. Card
|
|
11
|
+
// gate-lock-shared (ADR 0015): it lives at `<git common dir>/doug/gate.lock`, a path every worktree of the
|
|
12
|
+
// repo shares, so gates in parallel worktrees queue instead of running full suites concurrently. It falls
|
|
13
|
+
// back to the per-checkout `.doug/.state/gate.lock` when git is unavailable, `dir` is not in a repo, or the
|
|
14
|
+
// common dir is not writable; resolution never throws.
|
|
15
|
+
const GATE_LOCK_RELPATH = ".doug/.state/gate.lock";
|
|
16
|
+
// Memoized per dir for the life of the process (a fallback result too): one gate run must see one path,
|
|
17
|
+
// or a poll that fell back under load would read no lock and take over a live holder's shared lock.
|
|
18
|
+
const lockPathMemo = new Map();
|
|
19
|
+
export function gateLockPath(dir) {
|
|
20
|
+
if (lockPathMemo.has(dir)) return lockPathMemo.get(dir);
|
|
21
|
+
const p = resolveGateLockPath(dir);
|
|
22
|
+
lockPathMemo.set(dir, p);
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
function resolveGateLockPath(dir) {
|
|
26
|
+
try {
|
|
27
|
+
const out = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
28
|
+
cwd: dir,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
}).trim();
|
|
33
|
+
if (out) {
|
|
34
|
+
const common = resolve(dir, out);
|
|
35
|
+
const lockDir = join(common, "doug");
|
|
36
|
+
accessSync(common, constants.W_OK);
|
|
37
|
+
if (existsSync(lockDir)) accessSync(lockDir, constants.W_OK);
|
|
38
|
+
return join(lockDir, "gate.lock");
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// fall through to the per-checkout path
|
|
42
|
+
}
|
|
43
|
+
return join(dir, GATE_LOCK_RELPATH);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Reads gate.lock, tolerating garbage (a hook reader must never throw): a missing file, unreadable file, or
|
|
47
|
+
// content that fails to parse or carries no numeric pid all read as null — the same as "no lock", which the
|
|
48
|
+
// caller treats exactly like a stale one.
|
|
49
|
+
export function readGateLock(dir) {
|
|
50
|
+
try {
|
|
51
|
+
const o = JSON.parse(readFileSync(gateLockPath(dir), "utf8"));
|
|
52
|
+
// A pid only counts when it is a positive integer (reviewer-found gap): 0 means "this process's own
|
|
53
|
+
// group" and -1 broadcasts to every process the caller may signal, so process.kill(pid, 0) does not
|
|
54
|
+
// throw for either and a liveness check that hands them straight to it reads a garbage lock as live.
|
|
55
|
+
return o && typeof o === "object" && Number.isInteger(o.pid) && o.pid > 0 ? o : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pidIsLive(pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// EPERM means the pid exists but is owned by another user (permission denied, not "no such process") —
|
|
67
|
+
// that holder is still alive, just not signallable by us. Every other error (ESRCH: no such process, or
|
|
68
|
+
// anything else) reads as dead.
|
|
69
|
+
return err && err.code === "EPERM";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Creates gate.lock exclusively (throws EEXIST when one is already there, or any other fs error). The
|
|
74
|
+
// caller decides what a failure means.
|
|
75
|
+
export function writeGateLock(dir, sessionId) {
|
|
76
|
+
const p = gateLockPath(dir);
|
|
77
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
78
|
+
writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }), { flag: "wx" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Overwrites gate.lock with this gate's own pid/session, whether it was stale (a dead pid) or its holder
|
|
82
|
+
// simply never let go inside the wait bound — either way, never lets a lock the gate cannot even write hang
|
|
83
|
+
// it (best-effort; a failure here just leaves lockOwned false to the caller, which never releases a lock it
|
|
84
|
+
// does not hold).
|
|
85
|
+
export function takeOverGateLock(dir, sessionId) {
|
|
86
|
+
try {
|
|
87
|
+
const p = gateLockPath(dir);
|
|
88
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
89
|
+
writeFileSync(p, JSON.stringify({ pid: process.pid, session: sessionId, at: Date.now() }));
|
|
90
|
+
} catch {
|
|
91
|
+
// best-effort takeover only
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Card stop-gate-budget-under-hook-timeout: cfg.stopGate.hookTimeoutSec must equal the platform's own
|
|
96
|
+
// Stop/SubagentStop hook `timeout` (hooks.json/settings.json), but a hook reader never throws (F6), so a
|
|
97
|
+
// missing, non-number, or non-positive value reads as the platform's own default, 600.
|
|
98
|
+
export function resolveHookTimeoutSec(cfg) {
|
|
99
|
+
const sec = cfg && cfg.stopGate && cfg.stopGate.hookTimeoutSec;
|
|
100
|
+
return typeof sec === "number" && Number.isFinite(sec) && sec > 0 ? sec : 600;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A lock whose numeric `at` is older than the hook-timeout budget is stale regardless of a live pid: no
|
|
104
|
+
// legitimate holder can still be running that long, since the platform itself would have killed its hook
|
|
105
|
+
// by then (F1). A missing or non-numeric `at` changes nothing here; the pid liveness check still decides.
|
|
106
|
+
export function lockStaleByAge(lock, hookTimeoutMs) {
|
|
107
|
+
return !!lock && typeof lock.at === "number" && Number.isFinite(lock.at) && Date.now() - lock.at > hookTimeoutMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Card stop-gate-timeout-normalise (moved here by card verify-lock-wait-bound so verify.mjs shares it):
|
|
111
|
+
// cfg.stopGate.timeoutMs normalised once, shared by the per-command timeout and the wait bound, so the two
|
|
112
|
+
// never disagree on what a garbage or fractional value means. A finite number above 0 floors to an integer,
|
|
113
|
+
// clamped up to at least 1ms (0.5 must not floor to 0 — spawnSync reads a timeout of 0 as none at all,
|
|
114
|
+
// letting a command run past the budget unclamped; 2.5 must not reach spawnSync un-floored — it throws
|
|
115
|
+
// ERR_OUT_OF_RANGE there, and a hook reader must never throw, F6). Anything else (non-number, NaN, Infinity,
|
|
116
|
+
// 0, negative, null) is "unset" (null), read by each call site as its own default.
|
|
117
|
+
export function normalizedTimeoutMs(cfg) {
|
|
118
|
+
const t = cfg.stopGate.timeoutMs;
|
|
119
|
+
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.max(1, Math.floor(t)) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// How long a gate waits on a live gate.lock: the per-command timeout times the command count, the longest a
|
|
123
|
+
// holder legitimately runs.
|
|
124
|
+
export function lockWaitBoundMs(perCommandMs, commandCount) {
|
|
125
|
+
return perCommandMs * Math.max(1, commandCount);
|
|
126
|
+
}
|
|
@@ -37,6 +37,11 @@ const MAX_VERIFIED_ROWS = 20;
|
|
|
37
37
|
// Every spawned git call gets a hard ceiling (ms) so a hung or adversarially slow git invocation cannot itself
|
|
38
38
|
// become the way to blow the Stop-gate timeout (review MINOR 7).
|
|
39
39
|
const GIT_TIMEOUT_MS = 5000;
|
|
40
|
+
// Most commits (newest first) that touched a target since the session baseline whose parent is tried as a
|
|
41
|
+
// re-derivation ref (card ledger-two-committed-applies): a bound, so a long session cannot make the walk costly.
|
|
42
|
+
const MAX_PARENT_REFS = 10;
|
|
43
|
+
// A git commit sha, abbreviated or full (same shape lib/baseline.mjs accepts for baselineHead).
|
|
44
|
+
const GIT_SHA_RE = /^[0-9a-f]{7,64}$/;
|
|
40
45
|
|
|
41
46
|
// Every well-formed line, tolerant of a missing file or a torn line (a crashed process mid-write) - never fatal.
|
|
42
47
|
export function readAppliedLedger(dir) {
|
|
@@ -139,18 +144,48 @@ function diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref) {
|
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
// The parents of the commits since `base` that touched relPath, newest first, capped at MAX_PARENT_REFS: each
|
|
148
|
+
// `<commit>^` holds relPath as it was just before that commit, which is where an in-session apply's preimage
|
|
149
|
+
// lives once a later apply is also committed (card ledger-two-committed-applies). Empty when `base` is not a
|
|
150
|
+
// usable sha, or the git call fails or times out (GIT_TIMEOUT_MS); never throws.
|
|
151
|
+
function parentRefsSince(dir, relPath, base) {
|
|
152
|
+
try {
|
|
153
|
+
const listed = spawnSync("git", ["rev-list", `--max-count=${MAX_PARENT_REFS}`, `${base}..HEAD`, "--", relPath], {
|
|
154
|
+
cwd: dir,
|
|
155
|
+
timeout: GIT_TIMEOUT_MS,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
});
|
|
158
|
+
if (listed.status !== 0 || typeof listed.stdout !== "string") return [];
|
|
159
|
+
return listed.stdout
|
|
160
|
+
.split("\n")
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter((line) => /^[0-9a-f]{7,64}$/.test(line))
|
|
163
|
+
.map((sha) => `${sha}^`);
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
// `base` (card stop-scan-committed-changes) is the session's baseline HEAD when known, tried first: a diff
|
|
143
170
|
// that created `relPath` after that baseline still applies onto "absent" even once the session has since
|
|
144
171
|
// committed `relPath` itself (HEAD would otherwise already equal the current bytes, and git apply refuses to
|
|
145
172
|
// create a file that already exists there). M1 fix: when the base attempt fails, HEAD is also tried before
|
|
146
173
|
// giving up, not only when `base` is null/omitted - a second approved apply on the same target, committed
|
|
147
174
|
// in-session after the first (so its preimage is the first version, not the pre-session base), re-derives
|
|
148
|
-
// correctly onto HEAD even though it cannot re-derive onto the pre-session base at all.
|
|
175
|
+
// correctly onto HEAD even though it cannot re-derive onto the pre-session base at all. Card
|
|
176
|
+
// ledger-two-committed-applies: once two or more applies on one target are committed in-session, the earlier
|
|
177
|
+
// ones' preimage is neither the base nor HEAD; when both fail and `base` is known, the parent of each commit
|
|
178
|
+
// since `base` that touched `relPath` (parentRefsSince, newest first, capped) is tried too. Every other check
|
|
149
179
|
// (row shape, containment, diffSha256, diffNamesOnly) is unchanged; this only widens which ref the content
|
|
150
180
|
// re-derivation itself is allowed to succeed against.
|
|
151
181
|
function diffReproducesCurrentContent(dir, relPath, diffAbsPath, currentSha, base) {
|
|
152
|
-
|
|
153
|
-
|
|
182
|
+
// `base` is agent-writable state (state.baselineHead): only a hex sha ever reaches git, so a value such as
|
|
183
|
+
// "--output=<path>" is dropped here and can neither be read as an option nor name a revision.
|
|
184
|
+
const hasBase = typeof base === "string" && GIT_SHA_RE.test(base);
|
|
185
|
+
const refs = hasBase ? [base, "HEAD"] : ["HEAD"];
|
|
186
|
+
if (refs.some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref))) return true;
|
|
187
|
+
if (!hasBase) return false;
|
|
188
|
+
return parentRefsSince(dir, relPath, base).some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref));
|
|
154
189
|
}
|
|
155
190
|
|
|
156
191
|
// One row's full verification, wrapped so that no exception - a malformed field of any type, an unreadable
|
|
@@ -182,8 +217,9 @@ function rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base) {
|
|
|
182
217
|
// before the session started, not only against HEAD (which, once the target is committed, already equals the
|
|
183
218
|
// current bytes and would make a new-file diff fail to apply there). HEAD is always tried too (M1 review
|
|
184
219
|
// fix) - a second approved apply on the same target, committed in-session after the first, has the first
|
|
185
|
-
// version as its preimage and can only re-derive onto HEAD, not onto the pre-session base. With
|
|
186
|
-
//
|
|
220
|
+
// version as its preimage and can only re-derive onto HEAD, not onto the pre-session base. With several applies
|
|
221
|
+
// committed in-session (card ledger-two-committed-applies), the parent of each commit since `base` that touched
|
|
222
|
+
// the target is tried as well. With `base` null or omitted, only HEAD is tried, exactly as before this card.
|
|
187
223
|
export function isAppliedContent(dir, relPath, { base = null } = {}) {
|
|
188
224
|
const sha = currentSha256(dir, relPath);
|
|
189
225
|
const learnDirAbs = resolve(dir, LEARN_STATE_RELPATH);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the files its tasks own is the only thing a session may change.
|
|
3
3
|
// Pure functions plus one reader; the stop gate decides, this file only computes.
|
|
4
4
|
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { globToRegExp, matchAny, normalizePath } from "./glob.mjs";
|
|
8
8
|
|
|
@@ -10,18 +10,56 @@ export const PLAN_RELPATH = ".doug/plan.json";
|
|
|
10
10
|
export const ENFORCED_STATUSES = ["approved", "done"];
|
|
11
11
|
// The flow's own files change during a run and are never out of scope. Per-session hook state too.
|
|
12
12
|
// board.json moves uncommitted with the live board server; it lands in the card's landing commit.
|
|
13
|
-
export const ALWAYS_ALLOWED = [".doug/plan.json", ".doug/anchor.md", ".doug/.state/**", ".doug/board.json"];
|
|
13
|
+
export const ALWAYS_ALLOWED = [".doug/plan.json", ".doug/plans/**", ".doug/anchor.md", ".doug/.state/**", ".doug/board.json"];
|
|
14
14
|
|
|
15
15
|
// Returns { plan, reason }. plan is set only when the file exists, parses, has tasks, and is in an
|
|
16
16
|
// enforced status; reason says why not otherwise ("no-plan", "unreadable", "status:draft", "invalid").
|
|
17
17
|
export function loadScopePlan(dir) {
|
|
18
|
+
const single = loadSinglePlan(dir);
|
|
19
|
+
if (single.plan) return single;
|
|
20
|
+
// card parallel-card-queens: with no enforced .doug/plan.json, the enforced, not-landed per-card plans
|
|
21
|
+
// under .doug/plans/ hold scope together.
|
|
22
|
+
const cards = loadCardPlans(dir);
|
|
23
|
+
if (cards.plan || cards.reason === "unreadable") return cards;
|
|
24
|
+
return single;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Union of every approved/done, not-landed .doug/plans/*.json. Never throws: an unreadable file is skipped,
|
|
28
|
+
// and reported as reason "unreadable" only when it leaves no plan holding scope.
|
|
29
|
+
function loadCardPlans(dir) {
|
|
30
|
+
let names = [];
|
|
31
|
+
try {
|
|
32
|
+
names = readdirSync(join(dir, ".doug/plans")).filter((f) => f.endsWith(".json")).sort();
|
|
33
|
+
} catch {
|
|
34
|
+
return { plan: null, reason: "no-plan" };
|
|
35
|
+
}
|
|
36
|
+
const plans = [];
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
try {
|
|
39
|
+
const p = JSON.parse(readFileSync(join(dir, ".doug/plans", name), "utf8"));
|
|
40
|
+
if (!p || typeof p !== "object" || !Array.isArray(p.tasks)) continue;
|
|
41
|
+
if (!ENFORCED_STATUSES.includes(p.status) || p.landed) continue;
|
|
42
|
+
plans.push(p);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// fail open: any unreadable per-card file means the union is unknown, so scope is not checked
|
|
45
|
+
return { plan: null, reason: "unreadable", error: String(err && err.message), file: `.doug/plans/${name}` };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!plans.length) return { plan: null, reason: "no-plan" };
|
|
49
|
+
const status = plans.some((p) => p.status === "approved") ? "approved" : "done";
|
|
50
|
+
const cards = plans.map((p) => p.card || p.title || "?");
|
|
51
|
+
const title = `per-card plans (${cards.join(", ")})`;
|
|
52
|
+
return { plan: { version: 1, title, status, perCard: cards, tasks: plans.flatMap((p) => p.tasks) }, reason: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadSinglePlan(dir) {
|
|
18
56
|
const file = join(dir, PLAN_RELPATH);
|
|
19
57
|
if (!existsSync(file)) return { plan: null, reason: "no-plan" };
|
|
20
58
|
let plan;
|
|
21
59
|
try {
|
|
22
60
|
plan = JSON.parse(readFileSync(file, "utf8"));
|
|
23
61
|
} catch (err) {
|
|
24
|
-
return { plan: null, reason: "unreadable", error: String(err.message) };
|
|
62
|
+
return { plan: null, reason: "unreadable", error: String(err.message), file: PLAN_RELPATH };
|
|
25
63
|
}
|
|
26
64
|
if (!plan || typeof plan !== "object" || !Array.isArray(plan.tasks)) return { plan: null, reason: "invalid" };
|
|
27
65
|
if (!ENFORCED_STATUSES.includes(plan.status)) return { plan: null, reason: `status:${plan.status}` };
|
|
@@ -104,7 +142,8 @@ export function scopeViolations(changed, plan, { ignore = [] } = {}) {
|
|
|
104
142
|
}
|
|
105
143
|
|
|
106
144
|
export function describeViolations(violations, plan) {
|
|
107
|
-
const
|
|
145
|
+
const source = Array.isArray(plan.perCard) ? `per-card plans ${plan.perCard.join(", ")} (.doug/plans/)` : `plan "${plan.title}" (${PLAN_RELPATH})`;
|
|
146
|
+
const lines = [`Files changed outside the ${plan.status} ${source}:`];
|
|
108
147
|
for (const v of violations) {
|
|
109
148
|
const near = v.nearest.length ? `nearest task${v.nearest.length > 1 ? "s" : ""}: ${v.nearest.map((n) => `${n.id} (owns ${n.via})`).join(", ")}` : "no task owns anything near it";
|
|
110
149
|
lines.push(` ${v.file} ${near}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougharness/gates",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Deterministic Claude Code hooks: protected paths, bash guard, secret scan on edits and commands, format on edit, edit-loop detection, stop gate, re-anchor after compaction.",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@ import { join } from "node:path";
|
|
|
19
19
|
import { runHook, allow, addContext, systemMessage } from "../lib/io.mjs";
|
|
20
20
|
import { loadConfig, projectDir } from "../lib/config.mjs";
|
|
21
21
|
import { loadState, saveState } from "../lib/state.mjs";
|
|
22
|
-
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, snapshotSection, splitSnapshot } from "../lib/anchor.mjs";
|
|
22
|
+
import { gitHead, mergeSnapshot, readDecisions, readPlanSummary, readPlanSummaries, snapshotSection, splitSnapshot } from "../lib/anchor.mjs";
|
|
23
23
|
|
|
24
24
|
runHook("reanchor", async (input) => {
|
|
25
25
|
const dir = projectDir(input);
|
|
@@ -30,6 +30,7 @@ runHook("reanchor", async (input) => {
|
|
|
30
30
|
const state = loadState(dir, input.session_id);
|
|
31
31
|
const section = snapshotSection({
|
|
32
32
|
plan: readPlanSummary(dir),
|
|
33
|
+
plans: readPlanSummaries(dir),
|
|
33
34
|
decisions: readDecisions(dir),
|
|
34
35
|
state,
|
|
35
36
|
now: new Date(),
|