@dougharness/cli 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/implementer.md +1 -1
- package/node_modules/@dougharness/flow/agents/planner.md +1 -1
- package/node_modules/@dougharness/flow/agents/researcher.md +2 -1
- package/node_modules/@dougharness/flow/agents/reviewer.md +3 -1
- 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 +94 -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 +778 -0
- package/node_modules/@dougharness/flow/lib/coordinator.mjs +393 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
- package/node_modules/@dougharness/flow/lib/decisions.mjs +4 -2
- 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 +406 -76
- package/node_modules/@dougharness/flow/lib/plan.mjs +33 -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 +440 -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 +33 -10
- package/node_modules/@dougharness/flow/scripts/plan.mjs +104 -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/research/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 +846 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +63 -23
- 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/research-cap.mjs +119 -15
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +156 -14
- package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
- package/package.json +3 -3
- package/templates/board-app.js +46 -0
|
@@ -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.3.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(),
|
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
// Loopback exemption (card research-cap-localhost, whole-command grammar, fix pass 5): a local model probe is not
|
|
28
28
|
// web research, so it is neither counted nor denied. The exemption applies only when the entire raw command
|
|
29
29
|
// string, before any splitting, is ONE simple fetch (isLoopbackFetch):
|
|
30
|
-
// 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick
|
|
31
|
-
// backslash or
|
|
32
|
-
//
|
|
30
|
+
// 1. Trimmed, it tokenizes into words. Single quotes are literal; double quotes may not hold $, a backtick or !,
|
|
31
|
+
// and a backslash only as \" or \\ (a literal char; any other backslash is not exempt); an unterminated quote
|
|
32
|
+
// is not exempt. Outside quotes only space, tab, [A-Za-z0-9._:/=@%+,-], a redirect's >, the exact word 2>&1
|
|
33
|
+
// and a backslash-newline continuation (removed, no word break) are allowed, so & | ; ( ) { } [ ] * ? ~ # $
|
|
34
|
+
// ` \ ! < and a bare newline are otherwise never exempt (2>&1>x, a&b, 2>&1; are not the exact word).
|
|
33
35
|
// 2. The first word is exactly curl or wget: no path, wrapper, assignment or other case.
|
|
34
36
|
// 3. Every other word is an allowlisted option (value-less -s -S -f -L -i -v -g or --silent and similar,
|
|
35
37
|
// combinable in one short token; value-taking -o -H -d -X -w -m for curl, -O -T and --header for wget,
|
|
@@ -41,11 +43,18 @@
|
|
|
41
43
|
// exactly /dev/null or does not start with /dev/ (bash opens /dev/tcp and /dev/udp as sockets).
|
|
42
44
|
// A command outside the grammar is not an error: it is counted by bashFetchVerbs exactly as at HEAD, one count
|
|
43
45
|
// per split command whose first resolved fetch form is curl or wget. Only Bash curl/wget is affected; WebFetch
|
|
44
|
-
// and WebSearch always count.
|
|
45
|
-
// remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
|
|
46
|
+
// and WebSearch always count.
|
|
46
47
|
// Accepted limits of the exemption: an env proxy (https_proxy=http://evil.com in the environment, not on the
|
|
47
48
|
// command) routes the request out; -L, and wget by default, follow a redirect a local server can point at a
|
|
48
49
|
// remote host; a ~/.curlrc or ~/.wgetrc can add a URL or a proxy.
|
|
50
|
+
// Anything doubtful counts: a compound line, a pipe that is not a safe pipe consumer (below), a wrapper, a mixed
|
|
51
|
+
// local and remote command, no URL such as curl --version, other 127.x addresses, 0.0.0.0, host.docker.internal.
|
|
52
|
+
//
|
|
53
|
+
// Pipe consumers (card research-cap-loopback-post): the same exemption holds for a pipeline of unquoted single "|"
|
|
54
|
+
// (never ||, |& or an empty segment) whose first segment is a loopback fetch unchanged and whose every later
|
|
55
|
+
// segment is, with no redirect, head/tail with only a count (-c N, -n N, -cN, -nN, -N) or exactly
|
|
56
|
+
// python3|python -m json.tool. Anything else (jq, sh, curl, python -c) counts as at HEAD. Each counted Bash
|
|
57
|
+
// counter line also carries hosts, the URL hosts of that split command, so a miss is diagnosable.
|
|
49
58
|
//
|
|
50
59
|
// Known limit: several calls issued in one message can each read the same count before any of them appends, so
|
|
51
60
|
// a single burst can push an agent's count past the cap by the number of calls it issues at once.
|
|
@@ -305,14 +314,29 @@ function tokenize(text) {
|
|
|
305
314
|
gt = false;
|
|
306
315
|
open = false;
|
|
307
316
|
};
|
|
308
|
-
for (
|
|
317
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
318
|
+
const ch = text[i];
|
|
309
319
|
if (quote) {
|
|
320
|
+
if (quote === '"' && ch === "\\") {
|
|
321
|
+
// Only \" and \\ are escapes inside double quotes; both give a literal char. Any other backslash
|
|
322
|
+
// (\$, \`, \!, backslash-newline) leaves the grammar.
|
|
323
|
+
const next = text[i + 1];
|
|
324
|
+
if (next !== '"' && next !== "\\") return null;
|
|
325
|
+
raw += ch + next;
|
|
326
|
+
val += next;
|
|
327
|
+
i += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
310
330
|
raw += ch;
|
|
311
331
|
if (ch === quote) quote = "";
|
|
312
|
-
else if (quote === '"' && "
|
|
332
|
+
else if (quote === '"' && "$`!".includes(ch)) return null;
|
|
313
333
|
else val += ch;
|
|
314
334
|
} else if (ch === " " || ch === "\t") end();
|
|
315
|
-
else
|
|
335
|
+
else if (ch === "\\" && text[i + 1] === "\n") i += 1; // a line continuation: removed, no word break
|
|
336
|
+
else if (!open && text.startsWith("2>&1", i) && (i + 4 === text.length || " \t".includes(text[i + 4]))) {
|
|
337
|
+
words.push({ raw: "2>&1", val: "2>&1", gt: false, dup: true }); // the exact word only, never a prefix
|
|
338
|
+
i += 3;
|
|
339
|
+
} else {
|
|
316
340
|
open = true;
|
|
317
341
|
raw += ch;
|
|
318
342
|
if (ch === "'" || ch === '"') quote = ch;
|
|
@@ -337,7 +361,8 @@ function isLoopbackFetch(command) {
|
|
|
337
361
|
let urls = 0;
|
|
338
362
|
let wantValue = false;
|
|
339
363
|
for (let k = 1; k < words.length; k += 1) {
|
|
340
|
-
const { raw, val, gt } = words[k];
|
|
364
|
+
const { raw, val, gt, dup } = words[k];
|
|
365
|
+
if (dup) continue;
|
|
341
366
|
if (gt) {
|
|
342
367
|
const m = REDIRECT.exec(raw);
|
|
343
368
|
if (!m) return false;
|
|
@@ -368,19 +393,98 @@ function isLoopbackFetch(command) {
|
|
|
368
393
|
return urls > 0;
|
|
369
394
|
}
|
|
370
395
|
|
|
371
|
-
//
|
|
372
|
-
//
|
|
396
|
+
// Splits a raw command on unquoted single "|" (card research-cap-loopback-post). Null when the text has a "||", a
|
|
397
|
+
// "|&", an empty segment, or no pipe at all, so only a plain pipeline is ever split.
|
|
398
|
+
function splitPipeline(command) {
|
|
399
|
+
const text = String(command);
|
|
400
|
+
const segs = [];
|
|
401
|
+
let quote = "";
|
|
402
|
+
let cur = "";
|
|
403
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
404
|
+
const ch = text[i];
|
|
405
|
+
if (quote) {
|
|
406
|
+
if (quote === '"' && ch === "\\" && i + 1 < text.length) {
|
|
407
|
+
cur += ch + text[i + 1];
|
|
408
|
+
i += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (ch === quote) quote = "";
|
|
412
|
+
cur += ch;
|
|
413
|
+
} else if (ch === "'" || ch === '"') {
|
|
414
|
+
quote = ch;
|
|
415
|
+
cur += ch;
|
|
416
|
+
} else if (ch === "|") {
|
|
417
|
+
if (text[i + 1] === "|" || text[i + 1] === "&" || cur.trim() === "") return null;
|
|
418
|
+
segs.push(cur);
|
|
419
|
+
cur = "";
|
|
420
|
+
} else cur += ch;
|
|
421
|
+
}
|
|
422
|
+
if (quote || cur.trim() === "" || segs.length === 0) return null;
|
|
423
|
+
segs.push(cur);
|
|
424
|
+
return segs;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// One pipe consumer that cannot fetch: head or tail with only a count, or python[3] -m json.tool. No redirect.
|
|
428
|
+
function isSafeConsumer(segment) {
|
|
429
|
+
const words = tokenize(segment.trim());
|
|
430
|
+
if (!words || words.length === 0 || words.some((w) => w.gt || w.raw !== w.val)) return false;
|
|
431
|
+
const v = words.map((w) => w.val);
|
|
432
|
+
if (v[0] === "head" || v[0] === "tail") {
|
|
433
|
+
const rest = v.slice(1);
|
|
434
|
+
if (rest.length === 0) return false;
|
|
435
|
+
if (rest.length === 1) return /^-(?:[cn]?\d+)$/.test(rest[0]);
|
|
436
|
+
return rest.length === 2 && /^-[cn]$/.test(rest[0]) && /^\d+$/.test(rest[1]);
|
|
437
|
+
}
|
|
438
|
+
return (v[0] === "python3" || v[0] === "python") && v.length === 3 && v[1] === "-m" && v[2] === "json.tool";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// True when the whole command is one loopback fetch, or one followed by safe consumers (see the header).
|
|
442
|
+
function isLoopbackCommand(command) {
|
|
443
|
+
if (isLoopbackFetch(command)) return true;
|
|
444
|
+
const segs = splitPipeline(command);
|
|
445
|
+
return !!segs && isLoopbackFetch(segs[0]) && segs.slice(1).every(isSafeConsumer);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// URL hosts of one split command, lower-cased, unique, in order (card research-cap-loopback-post). Diagnostic
|
|
449
|
+
// only: never throws and never returns the command text.
|
|
450
|
+
function urlHosts(cmd) {
|
|
451
|
+
const hosts = [];
|
|
452
|
+
try {
|
|
453
|
+
for (const w of String(cmd).match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) {
|
|
454
|
+
const word = w.replace(/["']/g, "");
|
|
455
|
+
if (word === "" || word.startsWith("-")) continue;
|
|
456
|
+
let host = "";
|
|
457
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(word);
|
|
458
|
+
if (scheme) {
|
|
459
|
+
const auth = scheme[1].slice(scheme[1].lastIndexOf("@") + 1);
|
|
460
|
+
const m = /^(\[[^\]]*\]|[^:]*)/.exec(auth);
|
|
461
|
+
host = m ? m[1] : "";
|
|
462
|
+
} else {
|
|
463
|
+
const m = /^(\[::1\]|[a-z0-9.-]+)(:\d+)?([/?#]|$)/i.exec(word);
|
|
464
|
+
if (m && (/^(?:localhost|127\.0\.0\.1|\[::1\])$/i.test(m[1]) || m[2] || m[3])) host = m[1];
|
|
465
|
+
}
|
|
466
|
+
host = host.toLowerCase();
|
|
467
|
+
if (host && !hosts.includes(host)) hosts.push(host);
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// diagnostic only
|
|
471
|
+
}
|
|
472
|
+
return hosts;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Returns one {verb, hosts} per split command of a Bash command line that resolves to a fetch (verb "curl" or
|
|
476
|
+
// "wget"); non-fetch split commands contribute nothing. A non-string command (missing, or e.g. a number) is zero
|
|
373
477
|
// fetches. commandForms runs on each split command's original text with its quotes intact (it does its own
|
|
374
478
|
// quote parsing for a shell -c script), never on stripped or reconstructed text. A command that is one simple
|
|
375
|
-
// loopback fetch (
|
|
479
|
+
// loopback fetch, alone or piped to a safe consumer (isLoopbackCommand), is not a fetch to count.
|
|
376
480
|
function bashFetchVerbs(command) {
|
|
377
|
-
if (
|
|
481
|
+
if (isLoopbackCommand(command)) return [];
|
|
378
482
|
const verbs = [];
|
|
379
483
|
for (const cmd of splitTopLevel(command)) {
|
|
380
484
|
for (const form of commandForms(cmd)) {
|
|
381
485
|
const verb = firstWordBasename(form);
|
|
382
486
|
if (FETCH_VERBS.has(verb)) {
|
|
383
|
-
verbs.push(verb);
|
|
487
|
+
verbs.push({ verb, hosts: urlHosts(cmd) });
|
|
384
488
|
break;
|
|
385
489
|
}
|
|
386
490
|
}
|
|
@@ -401,7 +505,7 @@ runHook("research-cap", async (input) => {
|
|
|
401
505
|
if (input.tool_name === "Bash") {
|
|
402
506
|
const verbs = bashFetchVerbs(input.tool_input && input.tool_input.command);
|
|
403
507
|
if (verbs.length === 0) return allow(); // an ordinary Bash command is never counted or denied here
|
|
404
|
-
entries = verbs.map((verb) => ({ tool: "Bash", verb }));
|
|
508
|
+
entries = verbs.map(({ verb, hosts }) => ({ tool: "Bash", verb, hosts }));
|
|
405
509
|
} else {
|
|
406
510
|
entries = [{ tool: input.tool_name }];
|
|
407
511
|
}
|
|
@@ -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(),
|
|
@@ -766,10 +802,24 @@ function waiverSources(dir, baselineHead) {
|
|
|
766
802
|
return sources;
|
|
767
803
|
}
|
|
768
804
|
|
|
805
|
+
// A tree's own last-green record, read live; null when absent or unreadable (card worktree-count-baseline).
|
|
806
|
+
function readLastGreen(dir) {
|
|
807
|
+
try {
|
|
808
|
+
const parsed = JSON.parse(readFileSync(join(dir, LAST_GREEN_RELPATH), "utf8"));
|
|
809
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
810
|
+
} catch {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
769
815
|
// The count check for the commands that ran green by exit code. Returns { problems, parsed }: `problems` are
|
|
770
816
|
// block reasons, `parsed` the per-command summary lists of every run that was counted (for the last-green
|
|
771
817
|
// record). A missing or unparseable summary, or no baseline, is a trace line and no check.
|
|
772
|
-
function countCheck(dir, cfg, state, input, ran) {
|
|
818
|
+
function countCheck(dir, cfg, state, input, ran, vdir) {
|
|
819
|
+
// card worktree-count-baseline: a worktree run compares against its own earlier green runs, never main's record
|
|
820
|
+
// (a worktree can lack the dist build main has, so its skipped count differs). No record yet means no check.
|
|
821
|
+
const inWorktree = typeof vdir === "string" && vdir !== dir;
|
|
822
|
+
const baseline = inWorktree ? readLastGreen(vdir) : state.testTotals;
|
|
773
823
|
const problems = [];
|
|
774
824
|
const parsed = {};
|
|
775
825
|
// The note carries its own event name, never the stop event's: a decision-less SubagentStop line would read to
|
|
@@ -793,7 +843,7 @@ function countCheck(dir, cfg, state, input, ran) {
|
|
|
793
843
|
note(`count check did not run for \`${command}\`: no test summary in its output`);
|
|
794
844
|
continue;
|
|
795
845
|
}
|
|
796
|
-
const all =
|
|
846
|
+
const all = baseline && typeof baseline === "object" ? baseline : null;
|
|
797
847
|
const baseList = all ? readSummaries(all[command]) : null;
|
|
798
848
|
if (!baseList) {
|
|
799
849
|
parsed[command] = summaries;
|
|
@@ -1008,10 +1058,99 @@ function normalizedDeferrals(state) {
|
|
|
1008
1058
|
return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0;
|
|
1009
1059
|
}
|
|
1010
1060
|
|
|
1061
|
+
// Card landing-block-enforced: board.mjs record/summary leave .doug/.state/reports/<id>/landing-block.txt and an
|
|
1062
|
+
// empty landing-block.pending (move <id> done re-arms it). A main Stop that would otherwise allow checks each
|
|
1063
|
+
// pending block against the lead's final message and blocks, once, until every line of it is there verbatim. The
|
|
1064
|
+
// marker is deleted the first time it is checked, so the block happens at most once per landing. Every exit that
|
|
1065
|
+
// lets a main Stop end plainly goes through allow() below. The systemMessage exits (the block-cap and turn-budget
|
|
1066
|
+
// stand-downs, the deferral notice, the repeat-deferral exit) return their text unchanged and never block (card
|
|
1067
|
+
// landing-block-shown-on-deferral): systemMessage() below first sweeps quietly, deleting a marker whose lines are
|
|
1068
|
+
// all in the final message and leaving any other, so a block printed on a deferral stop is not demanded again at
|
|
1069
|
+
// a later plain stop. A red gate (blockStop) keeps its own reason. A marker holds the session that landed (empty =
|
|
1070
|
+
// any); another session's marker is left untouched. Fails open: any throw, an unreadable block
|
|
1071
|
+
// file, or a final message that is not a string allows with one stderr line.
|
|
1072
|
+
let landingInput = null;
|
|
1073
|
+
// The pending markers this session owns, as [id, root] pairs; null when there is no reports directory.
|
|
1074
|
+
function ownedLandingMarkers(input) {
|
|
1075
|
+
const root = join(projectDir(input), ".doug/.state/reports");
|
|
1076
|
+
if (!existsSync(root)) return { root, pending: [] };
|
|
1077
|
+
const mine = (id) => {
|
|
1078
|
+
try {
|
|
1079
|
+
const owner = readFileSync(join(root, id, "landing-block.pending"), "utf8").trim();
|
|
1080
|
+
return owner === "" || owner === input.session_id;
|
|
1081
|
+
} catch {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
return { root, pending: readdirSync(root).filter((id) => existsSync(join(root, id, "landing-block.pending")) && mine(id)).sort() };
|
|
1086
|
+
}
|
|
1087
|
+
function landingBlockLines(root, id) {
|
|
1088
|
+
return readFileSync(join(root, id, "landing-block.txt"), "utf8").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1089
|
+
}
|
|
1090
|
+
function landingBlockReason(input) {
|
|
1091
|
+
try {
|
|
1092
|
+
const { root, pending } = ownedLandingMarkers(input);
|
|
1093
|
+
if (!pending.length) return null;
|
|
1094
|
+
const message = input.last_assistant_message;
|
|
1095
|
+
if (typeof message !== "string") {
|
|
1096
|
+
// The markers stay, so a later Stop with a readable final message still checks them.
|
|
1097
|
+
process.stderr.write("[doug] landing-block check skipped: the final message is unreadable\n");
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
const missing = [];
|
|
1101
|
+
for (const id of pending) {
|
|
1102
|
+
const marker = join(root, id, "landing-block.pending");
|
|
1103
|
+
try {
|
|
1104
|
+
const lines = landingBlockLines(root, id);
|
|
1105
|
+
if (lines.length && !lines.every((l) => message.includes(l))) missing.push({ id, lines });
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
|
|
1108
|
+
}
|
|
1109
|
+
try {
|
|
1110
|
+
unlinkSync(marker);
|
|
1111
|
+
} catch {}
|
|
1112
|
+
}
|
|
1113
|
+
if (!missing.length) return null;
|
|
1114
|
+
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");
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// Quiet sweep for the systemMessage exits: delete each owned marker whose lines are all in the final message,
|
|
1121
|
+
// keep the rest. Never blocks, never changes the exit's text.
|
|
1122
|
+
function sweepLandingMarkers(input) {
|
|
1123
|
+
try {
|
|
1124
|
+
const message = input.last_assistant_message;
|
|
1125
|
+
if (typeof message !== "string") return;
|
|
1126
|
+
const { root, pending } = ownedLandingMarkers(input);
|
|
1127
|
+
for (const id of pending) {
|
|
1128
|
+
try {
|
|
1129
|
+
const lines = landingBlockLines(root, id);
|
|
1130
|
+
if (lines.length && lines.every((l) => message.includes(l))) unlinkSync(join(root, id, "landing-block.pending"));
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
process.stderr.write(`[doug] landing-block check skipped: ${id}: ${e && e.message ? e.message : String(e)}\n`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
process.stderr.write(`[doug] landing-block check skipped: ${e && e.message ? e.message : String(e)}\n`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
function systemMessage(message) {
|
|
1140
|
+
if (landingInput) sweepLandingMarkers(landingInput);
|
|
1141
|
+
return systemMessageRaw(message);
|
|
1142
|
+
}
|
|
1143
|
+
function allow() {
|
|
1144
|
+
const reason = landingInput ? landingBlockReason(landingInput) : null;
|
|
1145
|
+
if (reason) return blockStop(reason);
|
|
1146
|
+
return allowRaw();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1011
1149
|
const READ_ONLY_LINE = "[doug] Read-only turn: verification skipped; the working tree holds unverified changes from other agents.";
|
|
1012
1150
|
|
|
1013
1151
|
runHook("stop-gate", async (input) => {
|
|
1014
|
-
if (input.stop_hook_active) return
|
|
1152
|
+
if (input.stop_hook_active) return allowRaw();
|
|
1153
|
+
if (input.hook_event_name !== "SubagentStop") landingInput = input;
|
|
1015
1154
|
const dir = projectDir(input);
|
|
1016
1155
|
const cfg = loadConfig(dir);
|
|
1017
1156
|
let state = loadState(dir, input.session_id);
|
|
@@ -1394,8 +1533,8 @@ runHook("stop-gate", async (input) => {
|
|
|
1394
1533
|
// Draft and rejected plans are not contracts yet, so they are ignored.
|
|
1395
1534
|
let scope = null;
|
|
1396
1535
|
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`);
|
|
1536
|
+
const { plan, reason, error, file } = loadScopePlan(dir);
|
|
1537
|
+
if (reason === "unreadable") process.stderr.write(`[doug] ${file || PLAN_RELPATH} is unreadable (${error}); scope not checked\n`);
|
|
1399
1538
|
if (plan) {
|
|
1400
1539
|
const violations = scopeViolations(changed, plan, { ignore: cfg.stopGate.ignoreChangedPaths || [] });
|
|
1401
1540
|
scope = { plan: plan.title, status: plan.status, outOfScope: violations.map((v) => v.file) };
|
|
@@ -1449,7 +1588,10 @@ runHook("stop-gate", async (input) => {
|
|
|
1449
1588
|
state.deferrals = n;
|
|
1450
1589
|
state.lastDeferral = { ids, fingerprint: currentFingerprint };
|
|
1451
1590
|
saveState(dir, input.session_id, state);
|
|
1452
|
-
if (repeat)
|
|
1591
|
+
if (repeat) {
|
|
1592
|
+
if (landingInput) sweepLandingMarkers(landingInput);
|
|
1593
|
+
return allowRaw();
|
|
1594
|
+
}
|
|
1453
1595
|
return systemMessage(
|
|
1454
1596
|
`[doug] Verification deferred: waits for ${names} to stop; the full gate runs at the first Stop after they stop.`,
|
|
1455
1597
|
);
|
|
@@ -1498,7 +1640,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1498
1640
|
};
|
|
1499
1641
|
const skipReason = subagent && agentMadeNoChanges(dir, cfg, input.session_id, subagent)
|
|
1500
1642
|
? "no-agent-changes"
|
|
1501
|
-
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint)
|
|
1643
|
+
: subagent && treeUnchangedSinceStart(dir, cfg, input.session_id, subagent, input, currentFingerprint, vdir, forced)
|
|
1502
1644
|
? "tree-unchanged"
|
|
1503
1645
|
: readOnlyTurn
|
|
1504
1646
|
? "read-only-turn"
|
|
@@ -1588,7 +1730,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1588
1730
|
|
|
1589
1731
|
// card stop-gate-process-storm, case 3: only one gate runs the verification commands at a time per
|
|
1590
1732
|
// 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
|
|
1733
|
+
// 300s timeout orphaning ten vitest workers). A gate holds gate.lock (gateLockPath: under the git common dir) while its commands run;
|
|
1592
1734
|
// a second gate that finds a live lock waits for it, bounded by stopGate.timeoutMs, then reloads state and
|
|
1593
1735
|
// reapplies the skip above — the holder's own green run may have just recorded the very `verified` tree
|
|
1594
1736
|
// this gate needs, letting it skip instead of also running the full suite.
|
|
@@ -1863,7 +2005,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1863
2005
|
const evidenceProblemAdded = problems.length > postCommandProblems;
|
|
1864
2006
|
|
|
1865
2007
|
// 3c. Test-count ratchet (card test-count-ratchet): its own kind of problem, not a failed command.
|
|
1866
|
-
const counted = countCheck(dir, cfg, state, input, ranGreen);
|
|
2008
|
+
const counted = countCheck(dir, cfg, state, input, ranGreen, vdir);
|
|
1867
2009
|
problems.push(...counted.problems);
|
|
1868
2010
|
const countProblemAdded = counted.problems.length > 0;
|
|
1869
2011
|
|
|
@@ -1884,7 +2026,7 @@ runHook("stop-gate", async (input) => {
|
|
|
1884
2026
|
// A read-only-turn skip verified nothing: it leaves lastRed (and stopBlocks, below) as they were.
|
|
1885
2027
|
if (skipReason !== "read-only-turn") state.lastRed = null; // card stop-gate-no-change-restop: re-set at the final block below when it qualifies
|
|
1886
2028
|
if (problems.length === 0 && !skipVerification && !targeted) state.verified = verifiedCandidate;
|
|
1887
|
-
if (problems.length === 0 && !skipVerification) writeLastGreen(dir, counted.parsed); // empty when nothing really ran
|
|
2029
|
+
if (problems.length === 0 && !skipVerification) writeLastGreen(vdir || dir, counted.parsed); // empty when nothing really ran
|
|
1888
2030
|
if (problems.length === 0 && subagent) {
|
|
1889
2031
|
// A subagent's green gate: its counter resets; the checkpoint, if any, belongs to the session's own Stop.
|
|
1890
2032
|
state.agentBlocks = { ...(state.agentBlocks || {}), [subagent.id]: 0 };
|