@dougharness/cli 1.0.1
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/LICENSE +21 -0
- package/dist/ablate.d.ts +96 -0
- package/dist/ablate.js +440 -0
- package/dist/ablate.js.map +1 -0
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +19 -0
- package/dist/apply.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +80 -0
- package/dist/bin.js.map +1 -0
- package/dist/board-page.d.ts +15 -0
- package/dist/board-page.js +27 -0
- package/dist/board-page.js.map +1 -0
- package/dist/board-serve-process.d.ts +24 -0
- package/dist/board-serve-process.js +227 -0
- package/dist/board-serve-process.js.map +1 -0
- package/dist/board-serve.d.ts +9 -0
- package/dist/board-serve.js +302 -0
- package/dist/board-serve.js.map +1 -0
- package/dist/board.d.ts +6 -0
- package/dist/board.js +515 -0
- package/dist/board.js.map +1 -0
- package/dist/detect/fs.d.ts +6 -0
- package/dist/detect/fs.js +50 -0
- package/dist/detect/fs.js.map +1 -0
- package/dist/detect/index.d.ts +3 -0
- package/dist/detect/index.js +13 -0
- package/dist/detect/index.js.map +1 -0
- package/dist/detect/node.d.ts +3 -0
- package/dist/detect/node.js +179 -0
- package/dist/detect/node.js.map +1 -0
- package/dist/detect/python.d.ts +2 -0
- package/dist/detect/python.js +388 -0
- package/dist/detect/python.js.map +1 -0
- package/dist/detect/repo.d.ts +2 -0
- package/dist/detect/repo.js +102 -0
- package/dist/detect/repo.js.map +1 -0
- package/dist/detect/types.d.ts +98 -0
- package/dist/detect/types.js +2 -0
- package/dist/detect/types.js.map +1 -0
- package/dist/diff.d.ts +12 -0
- package/dist/diff.js +105 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate/agents.d.ts +19 -0
- package/dist/generate/agents.js +184 -0
- package/dist/generate/agents.js.map +1 -0
- package/dist/generate/claude-md.d.ts +9 -0
- package/dist/generate/claude-md.js +111 -0
- package/dist/generate/claude-md.js.map +1 -0
- package/dist/generate/config.d.ts +51 -0
- package/dist/generate/config.js +155 -0
- package/dist/generate/config.js.map +1 -0
- package/dist/generate/proposal.d.ts +11 -0
- package/dist/generate/proposal.js +175 -0
- package/dist/generate/proposal.js.map +1 -0
- package/dist/generate/settings.d.ts +35 -0
- package/dist/generate/settings.js +213 -0
- package/dist/generate/settings.js.map +1 -0
- package/dist/generate/skills.d.ts +8 -0
- package/dist/generate/skills.js +264 -0
- package/dist/generate/skills.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +69 -0
- package/dist/init.js.map +1 -0
- package/dist/trace.d.ts +9 -0
- package/dist/trace.js +106 -0
- package/dist/trace.js.map +1 -0
- package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/flow/LICENSE +21 -0
- package/node_modules/@dougharness/flow/agents/adversary-claude.md +33 -0
- package/node_modules/@dougharness/flow/agents/adversary.md +29 -0
- package/node_modules/@dougharness/flow/agents/implementer.md +23 -0
- package/node_modules/@dougharness/flow/agents/lead.md +35 -0
- package/node_modules/@dougharness/flow/agents/planner.md +70 -0
- package/node_modules/@dougharness/flow/agents/researcher.md +31 -0
- package/node_modules/@dougharness/flow/agents/reviewer.md +26 -0
- package/node_modules/@dougharness/flow/agents/verifier.md +24 -0
- package/node_modules/@dougharness/flow/lib/board.mjs +840 -0
- package/node_modules/@dougharness/flow/lib/code-index.mjs +629 -0
- package/node_modules/@dougharness/flow/lib/cost.mjs +392 -0
- package/node_modules/@dougharness/flow/lib/decisions.mjs +314 -0
- package/node_modules/@dougharness/flow/lib/embeddings.mjs +539 -0
- package/node_modules/@dougharness/flow/lib/land.mjs +219 -0
- package/node_modules/@dougharness/flow/lib/learn.mjs +742 -0
- package/node_modules/@dougharness/flow/lib/memory-import.mjs +145 -0
- package/node_modules/@dougharness/flow/lib/memory.mjs +1641 -0
- package/node_modules/@dougharness/flow/lib/models.mjs +148 -0
- package/node_modules/@dougharness/flow/lib/plan.mjs +773 -0
- package/node_modules/@dougharness/flow/lib/rehearse.mjs +825 -0
- package/node_modules/@dougharness/flow/lib/worktrees.mjs +215 -0
- package/node_modules/@dougharness/flow/package.json +19 -0
- package/node_modules/@dougharness/flow/scripts/board.mjs +260 -0
- package/node_modules/@dougharness/flow/scripts/cost.mjs +66 -0
- package/node_modules/@dougharness/flow/scripts/learn.mjs +161 -0
- package/node_modules/@dougharness/flow/scripts/memory.mjs +1274 -0
- package/node_modules/@dougharness/flow/scripts/plan.mjs +547 -0
- package/node_modules/@dougharness/flow/scripts/rehearse.mjs +129 -0
- package/node_modules/@dougharness/flow/skills/core-next/SKILL.md +92 -0
- package/node_modules/@dougharness/flow/skills/doug-approve/SKILL.md +15 -0
- package/node_modules/@dougharness/flow/skills/doug-decide/SKILL.md +26 -0
- package/node_modules/@dougharness/flow/skills/doug-implement/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-learn/SKILL.md +20 -0
- package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +87 -0
- package/node_modules/@dougharness/flow/skills/doug-plan/SKILL.md +22 -0
- package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +55 -0
- package/node_modules/@dougharness/flow/skills/harness-fix/SKILL.md +85 -0
- package/node_modules/@dougharness/flow/skills/research/SKILL.md +41 -0
- package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +51 -0
- package/node_modules/@dougharness/flow/skills/swarm-launch/SKILL.md +27 -0
- package/node_modules/@dougharness/flow/workflows/doug-implement.js +2687 -0
- package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +10 -0
- package/node_modules/@dougharness/gates/LICENSE +21 -0
- package/node_modules/@dougharness/gates/hooks/hooks.json +184 -0
- package/node_modules/@dougharness/gates/lib/agent-context.mjs +111 -0
- package/node_modules/@dougharness/gates/lib/anchor.mjs +110 -0
- package/node_modules/@dougharness/gates/lib/baseline.mjs +160 -0
- package/node_modules/@dougharness/gates/lib/bash-rules.mjs +1522 -0
- package/node_modules/@dougharness/gates/lib/block-ledger.mjs +127 -0
- package/node_modules/@dougharness/gates/lib/checkpoint.mjs +97 -0
- package/node_modules/@dougharness/gates/lib/config.mjs +168 -0
- package/node_modules/@dougharness/gates/lib/evidence.mjs +59 -0
- package/node_modules/@dougharness/gates/lib/glob.mjs +86 -0
- package/node_modules/@dougharness/gates/lib/io.mjs +91 -0
- package/node_modules/@dougharness/gates/lib/paths.mjs +101 -0
- package/node_modules/@dougharness/gates/lib/proposals.mjs +201 -0
- package/node_modules/@dougharness/gates/lib/run.mjs +147 -0
- package/node_modules/@dougharness/gates/lib/scope.mjs +117 -0
- package/node_modules/@dougharness/gates/lib/secret-rules.mjs +268 -0
- package/node_modules/@dougharness/gates/lib/state.mjs +114 -0
- package/node_modules/@dougharness/gates/lib/trace.mjs +196 -0
- package/node_modules/@dougharness/gates/package.json +17 -0
- package/node_modules/@dougharness/gates/scripts/edit-loop.mjs +39 -0
- package/node_modules/@dougharness/gates/scripts/format-on-edit.mjs +45 -0
- package/node_modules/@dougharness/gates/scripts/guard-bash.mjs +32 -0
- package/node_modules/@dougharness/gates/scripts/protect-paths.mjs +49 -0
- package/node_modules/@dougharness/gates/scripts/reanchor.mjs +81 -0
- package/node_modules/@dougharness/gates/scripts/research-cap.mjs +295 -0
- package/node_modules/@dougharness/gates/scripts/secret-scan.mjs +26 -0
- package/node_modules/@dougharness/gates/scripts/session-start-baseline.mjs +57 -0
- package/node_modules/@dougharness/gates/scripts/statusline.mjs +133 -0
- package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +1422 -0
- package/node_modules/@dougharness/gates/scripts/trace.mjs +75 -0
- package/package.json +33 -0
- package/templates/board-app.js +533 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep, parse, dirname, basename } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
// Returns the path relative to the project dir with forward slashes,
|
|
6
|
+
// or null if the path escapes the project.
|
|
7
|
+
export function toProjectRelative(dir, p) {
|
|
8
|
+
if (!p) return null;
|
|
9
|
+
const abs = isAbsolute(p) ? p : resolve(dir, p);
|
|
10
|
+
const rel = relative(dir, abs);
|
|
11
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
12
|
+
return rel.split(sep).join("/");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Expands a leading "~" or "~/..." against HOME. "~" alone, or "~/" followed by any number of extra leading
|
|
16
|
+
// slashes, resolves under homedir() (the extra slashes are stripped so "~//etc" means "$HOME/etc", never
|
|
17
|
+
// "/etc" — resolve() would otherwise discard the home prefix when the second argument is absolute). Anything
|
|
18
|
+
// else starting with "~" (e.g. "~x", "~root/x") is not tilde-home syntax and is returned unchanged.
|
|
19
|
+
function expandTilde(entry) {
|
|
20
|
+
if (entry === "~") return homedir();
|
|
21
|
+
if (entry.startsWith("~/")) return resolve(homedir(), entry.slice(2).replace(/^[/\\]+/, ""));
|
|
22
|
+
return entry;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// True when a resolved path is exactly a filesystem root (e.g. "/" or "C:\").
|
|
26
|
+
function isFilesystemRoot(p) {
|
|
27
|
+
return parse(p).root === p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolves the real path of p by realpath-ing its deepest existing ancestor and re-appending any path
|
|
31
|
+
// segments that do not exist yet (e.g. a Write creating a new file), so containment checks are resistant to
|
|
32
|
+
// symlinks and filesystem case-aliases. Uses realpathSync.native (the OS's own realpath) rather than plain
|
|
33
|
+
// realpathSync: on a case-insensitive, case-preserving volume (APFS's default on macOS) plain
|
|
34
|
+
// realpathSync only resolves symlinks and echoes back whatever case the input used, so it does NOT canonicalize
|
|
35
|
+
// a case-variant alias to the same string — verified directly (fs.realpathSync of a same-directory alias with
|
|
36
|
+
// swapped case returns the swapped-case string unchanged, while fs.realpathSync.native returns the one true
|
|
37
|
+
// on-disk casing for both). Falls back to the lexical, resolved path if realpath cannot be determined for any
|
|
38
|
+
// reason (a missing/inaccessible ancestor all the way to the root) — never throws.
|
|
39
|
+
export function realpathDeepestExisting(p) {
|
|
40
|
+
const resolved = resolve(p);
|
|
41
|
+
const pending = [];
|
|
42
|
+
let current = resolved;
|
|
43
|
+
for (;;) {
|
|
44
|
+
try {
|
|
45
|
+
const real = realpathSync.native(current);
|
|
46
|
+
return pending.length ? resolve(real, ...pending.reverse()) : real;
|
|
47
|
+
} catch {
|
|
48
|
+
const parent = dirname(current);
|
|
49
|
+
if (parent === current) return resolved;
|
|
50
|
+
pending.push(basename(current));
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// True when absPath is equal to, or really contained inside, one of allowedOutsidePaths (each entry an
|
|
57
|
+
// absolute or ~-prefixed path naming a file or directory). Used by protect-paths.mjs to let a write outside
|
|
58
|
+
// the project directory through when it is explicitly allowlisted in .doug/config.json's allowedOutsidePaths.
|
|
59
|
+
// Entries that are empty or not strings are ignored rather than thrown on, as is any entry whose REAL path is a
|
|
60
|
+
// filesystem root — never a legitimate allowlist entry, and catastrophic if honored, since "/" as the prefix
|
|
61
|
+
// matches every absolute path and would disable this guard globally. The root check runs after realpath
|
|
62
|
+
// resolution as well as before it, because an entry that is a symlink to "/" resolves lexically to something
|
|
63
|
+
// harmless-looking and only becomes the root once followed. No globs: directory containment on resolved,
|
|
64
|
+
// `..`-free paths is the right primitive for an escape hatch, and is safer than glob matching here. Containment
|
|
65
|
+
// requires a real path-segment boundary (the resolved entry plus a trailing separator, or an exact match) so
|
|
66
|
+
// that an entry "/a/allowed" does not also match a sibling "/a/allowed-other". Both sides are resolved through
|
|
67
|
+
// realpathDeepestExisting so a symlink or filesystem-case alias cannot be used to make a path look contained
|
|
68
|
+
// (or not) when its real target says otherwise.
|
|
69
|
+
export function isAllowedOutside(absPath, allowedOutsidePaths) {
|
|
70
|
+
if (!absPath || !Array.isArray(allowedOutsidePaths)) return false;
|
|
71
|
+
const target = realpathDeepestExisting(absPath);
|
|
72
|
+
for (const entry of allowedOutsidePaths) {
|
|
73
|
+
if (!entry || typeof entry !== "string") continue;
|
|
74
|
+
const resolvedEntry = resolve(expandTilde(entry));
|
|
75
|
+
if (isFilesystemRoot(resolvedEntry)) {
|
|
76
|
+
process.stderr.write(`[doug] allowedOutsidePaths entry ${JSON.stringify(entry)} resolves to the filesystem root; ignoring it.\n`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const realEntry = realpathDeepestExisting(resolvedEntry);
|
|
80
|
+
// The entry's real path is the root: a symlink (or chain of them) pointing at "/". Same treatment.
|
|
81
|
+
if (isFilesystemRoot(realEntry)) {
|
|
82
|
+
process.stderr.write(`[doug] allowedOutsidePaths entry ${JSON.stringify(entry)} resolves to the filesystem root; ignoring it.\n`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (target === realEntry) return true;
|
|
86
|
+
if (target.startsWith(realEntry.endsWith(sep) ? realEntry : realEntry + sep)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Extracts file paths from a tool_input for Edit/Write/MultiEdit/NotebookEdit.
|
|
92
|
+
export function filePathsFromToolInput(toolInput) {
|
|
93
|
+
if (!toolInput || typeof toolInput !== "object") return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
if (typeof toolInput.file_path === "string") out.push(toolInput.file_path);
|
|
96
|
+
if (typeof toolInput.notebook_path === "string") out.push(toolInput.notebook_path);
|
|
97
|
+
if (Array.isArray(toolInput.edits)) {
|
|
98
|
+
for (const e of toolInput.edits) if (e && typeof e.file_path === "string") out.push(e.file_path);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Reader for the applied-proposal ledger, .doug/.state/proposals/applied.jsonl (cards memory-decisions,
|
|
2
|
+
// proposal-ledger-forgeable): one JSON line per successful `learn.mjs apply` to a proposal-only path
|
|
3
|
+
// (docs/decisions/**, .claude/rules/** by default - proposalPaths in .doug/config.json), written by
|
|
4
|
+
// plugins/doug-flow/lib/learn.mjs's applyProposal. Duplicated here rather than imported across the plugin
|
|
5
|
+
// boundary - no workspace dependency exists between doug-gates and doug-flow (lib/learn.mjs's own header
|
|
6
|
+
// comment states the same precedent for its local glob matcher). The line shape is a contract shared with the
|
|
7
|
+
// writer: { target, sha256, diffSha256, at, proposal }, where `proposal` is the diff file's path relative to
|
|
8
|
+
// `dir` (forward slashes) and `diffSha256` is the sha256 of that diff file's bytes. A row's sha256 alone is not
|
|
9
|
+
// enough to trust (card proposal-ledger-forgeable: a hand-written line can carry any sha256 it likes) - see
|
|
10
|
+
// isAppliedContent below for the re-derivation this module runs instead. A row is untrusted, agent-writable
|
|
11
|
+
// data read at Stop time: every field is treated as adversarial input (wrong type, wrong shape, a path that
|
|
12
|
+
// escapes containment), and no exception from checking one row may ever escape isAppliedContent - runHook
|
|
13
|
+
// (plugins/doug-gates/lib/io.mjs) fails a hook open on an uncaught exception, which would skip the whole Stop
|
|
14
|
+
// gate, not just this check (review BLOCKER 1).
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
export const PROPOSAL_LEDGER_RELPATH = ".doug/.state/proposals/applied.jsonl";
|
|
22
|
+
|
|
23
|
+
// Duplicated from plugins/doug-flow/lib/learn.mjs's own export of the same constant - no cross-plugin import
|
|
24
|
+
// (see this file's header comment). A genuine ledger line's `proposal` must resolve under here.
|
|
25
|
+
const LEARN_STATE_RELPATH = ".doug/.state/learn";
|
|
26
|
+
|
|
27
|
+
// review MINOR 7 / MINOR 10: each candidate row costs a `git show` and a `git apply` (~30ms); a ledger padded
|
|
28
|
+
// with many matching-but-bogus rows could push a single Stop check past the hook timeout. Only rows whose
|
|
29
|
+
// target and sha256 already match are ever verified (cheap, in-memory), deduped by `proposal` (below) so a
|
|
30
|
+
// pile of rows that all name the same diff costs one verification, not one per row, then capped to the most
|
|
31
|
+
// recently appended this many DISTINCT proposals. This does not close every way to exhaust the cap: an
|
|
32
|
+
// adversary who appends 20 or more rows naming 20 or more genuinely distinct bogus proposal paths can still
|
|
33
|
+
// push a real row out of the window - but that fails closed (a block), never open, so it costs availability,
|
|
34
|
+
// never lets a forgery through.
|
|
35
|
+
const MAX_VERIFIED_ROWS = 20;
|
|
36
|
+
|
|
37
|
+
// Every spawned git call gets a hard ceiling (ms) so a hung or adversarially slow git invocation cannot itself
|
|
38
|
+
// become the way to blow the Stop-gate timeout (review MINOR 7).
|
|
39
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
40
|
+
|
|
41
|
+
// Every well-formed line, tolerant of a missing file or a torn line (a crashed process mid-write) - never fatal.
|
|
42
|
+
export function readAppliedLedger(dir) {
|
|
43
|
+
const file = join(dir, PROPOSAL_LEDGER_RELPATH);
|
|
44
|
+
if (!existsSync(file)) return [];
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const line of text.split("\n")) {
|
|
53
|
+
if (!line.trim()) continue;
|
|
54
|
+
try {
|
|
55
|
+
const o = JSON.parse(line);
|
|
56
|
+
if (o && typeof o === "object" && o.target) out.push(o);
|
|
57
|
+
} catch {
|
|
58
|
+
// a torn line is skipped, never fatal
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sha256OfBytes(buf) {
|
|
65
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// sha256 of relPath's current bytes under dir, or the sha of empty content when it cannot be read (a deleted or
|
|
69
|
+
// never-created target) - matches applyProposal's own convention for a deleted target.
|
|
70
|
+
function currentSha256(dir, relPath) {
|
|
71
|
+
try {
|
|
72
|
+
return sha256OfBytes(readFileSync(join(dir, relPath)));
|
|
73
|
+
} catch {
|
|
74
|
+
return sha256OfBytes(Buffer.alloc(0));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// review MINOR 8: the writer (applyProposal) refuses a diff that touches more than one file, or whose one
|
|
79
|
+
// target differs from what the caller expected - a ledger row must not trust a diff that the writer itself
|
|
80
|
+
// would have refused. Every "+++ b/" AND "--- a/" header is parsed (the union of both sides' file names,
|
|
81
|
+
// mirroring learn.mjs's own parseDiffTargets - duplicated locally, not imported, for the same reason as
|
|
82
|
+
// everything else in this file's header comment); "/dev/null" never matches either pattern, so a creation or
|
|
83
|
+
// deletion diff's placeholder side is naturally excluded. True only when the diff names exactly one path and
|
|
84
|
+
// that path is relPath.
|
|
85
|
+
function diffNamesOnly(diffText, relPath) {
|
|
86
|
+
const targets = new Set();
|
|
87
|
+
for (const m of diffText.matchAll(/^\+\+\+ b\/(.+)$/gm)) targets.add(m[1]);
|
|
88
|
+
for (const m of diffText.matchAll(/^--- a\/(.+)$/gm)) targets.add(m[1]);
|
|
89
|
+
return targets.size === 1 && targets.has(relPath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The committed base of relPath at `ref` (default HEAD) in dir, as a Buffer, or null when the path is not at
|
|
93
|
+
// that ref (a new file), the ref does not exist at all (a repo with no commits yet, or a base sha git no
|
|
94
|
+
// longer knows), or the git call times out (GIT_TIMEOUT_MS) or fails.
|
|
95
|
+
function readCommittedBase(dir, relPath, ref) {
|
|
96
|
+
const target = typeof ref === "string" && ref ? ref : "HEAD";
|
|
97
|
+
const result = spawnSync("git", ["show", `${target}:${relPath}`], { cwd: dir, timeout: GIT_TIMEOUT_MS });
|
|
98
|
+
if (result.status !== 0) return null;
|
|
99
|
+
return result.stdout;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Re-derivation at one ref (card proposal-ledger-forgeable): write the committed base of `relPath` at
|
|
103
|
+
// `relPath`, read at `ref`, inside a fresh temp dir (omitted entirely when there is no base at that ref, so a
|
|
104
|
+
// `--- /dev/null` new-file diff can still create it - git apply refuses to create a file that already
|
|
105
|
+
// exists), `git apply` the diff there (git apply works as a plain patch tool outside a git repository; cwd is
|
|
106
|
+
// the temp dir, never `dir` itself, so nothing real is ever touched), and compare the resulting bytes (empty
|
|
107
|
+
// if the diff deleted the file) against `currentSha`. Any failure - a `git apply` that does not succeed or
|
|
108
|
+
// times out (GIT_TIMEOUT_MS), or an unexpected exception - means the diff does not reproduce what is on disk
|
|
109
|
+
// now from this particular ref, so this attempt does not satisfy the check. The temp dir is always removed.
|
|
110
|
+
function diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref) {
|
|
111
|
+
let tmp;
|
|
112
|
+
try {
|
|
113
|
+
tmp = mkdtempSync(join(tmpdir(), "doug-ledger-verify-"));
|
|
114
|
+
const committedBase = readCommittedBase(dir, relPath, ref);
|
|
115
|
+
const targetInTmp = join(tmp, relPath);
|
|
116
|
+
if (committedBase !== null) {
|
|
117
|
+
mkdirSync(dirname(targetInTmp), { recursive: true });
|
|
118
|
+
writeFileSync(targetInTmp, committedBase);
|
|
119
|
+
}
|
|
120
|
+
const applied = spawnSync("git", ["apply", diffAbsPath], { cwd: tmp, timeout: GIT_TIMEOUT_MS });
|
|
121
|
+
if (applied.status !== 0) return false;
|
|
122
|
+
let resultBytes;
|
|
123
|
+
try {
|
|
124
|
+
resultBytes = readFileSync(targetInTmp);
|
|
125
|
+
} catch {
|
|
126
|
+
resultBytes = Buffer.alloc(0);
|
|
127
|
+
}
|
|
128
|
+
return sha256OfBytes(resultBytes) === currentSha;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
} finally {
|
|
132
|
+
if (tmp) {
|
|
133
|
+
try {
|
|
134
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
135
|
+
} catch {
|
|
136
|
+
// best-effort cleanup; a leftover temp dir is harmless
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `base` (card stop-scan-committed-changes) is the session's baseline HEAD when known, tried first: a diff
|
|
143
|
+
// that created `relPath` after that baseline still applies onto "absent" even once the session has since
|
|
144
|
+
// committed `relPath` itself (HEAD would otherwise already equal the current bytes, and git apply refuses to
|
|
145
|
+
// create a file that already exists there). M1 fix: when the base attempt fails, HEAD is also tried before
|
|
146
|
+
// giving up, not only when `base` is null/omitted - a second approved apply on the same target, committed
|
|
147
|
+
// 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. Every other check
|
|
149
|
+
// (row shape, containment, diffSha256, diffNamesOnly) is unchanged; this only widens which ref the content
|
|
150
|
+
// re-derivation itself is allowed to succeed against.
|
|
151
|
+
function diffReproducesCurrentContent(dir, relPath, diffAbsPath, currentSha, base) {
|
|
152
|
+
const refs = typeof base === "string" && base && base !== "HEAD" ? [base, "HEAD"] : ["HEAD"];
|
|
153
|
+
return refs.some((ref) => diffAppliesAtRef(dir, relPath, diffAbsPath, currentSha, ref));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// One row's full verification, wrapped so that no exception - a malformed field of any type, an unreadable
|
|
157
|
+
// path, a git call throwing - can ever escape (review BLOCKER 1: an uncaught exception here would fail the
|
|
158
|
+
// whole Stop hook open, not just this row). Returns false, never throws.
|
|
159
|
+
function rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base) {
|
|
160
|
+
try {
|
|
161
|
+
if (typeof row.diffSha256 !== "string" || typeof row.proposal !== "string") return false;
|
|
162
|
+
const diffAbsPath = resolve(dir, row.proposal);
|
|
163
|
+
if (diffAbsPath !== learnDirAbs && !diffAbsPath.startsWith(`${learnDirAbs}${sep}`)) return false;
|
|
164
|
+
const diffBuf = readFileSync(diffAbsPath);
|
|
165
|
+
if (sha256OfBytes(diffBuf) !== row.diffSha256) return false;
|
|
166
|
+
if (!diffNamesOnly(diffBuf.toString("utf8"), relPath)) return false;
|
|
167
|
+
return diffReproducesCurrentContent(dir, relPath, diffAbsPath, sha, base);
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// True when relPath's current content sha256 matches an applied-ledger line for that same target AND that
|
|
174
|
+
// line's diff (a) is a real file under dir/.doug/.state/learn/, (b) has the sha256 the row claims for it, (c)
|
|
175
|
+
// names relPath and no other file, and (d) applied onto the committed base of relPath, reproduces exactly the
|
|
176
|
+
// bytes on disk now (card proposal-ledger-forgeable). Any row missing `diffSha256` or `proposal`, or carrying
|
|
177
|
+
// either as anything but a string - the old, pre-hardening line shape, or a forged/malformed one - never
|
|
178
|
+
// satisfies this: a hand-written sha256 alone is not proof of an approved `learn.mjs apply` any more, it is
|
|
179
|
+
// only what the ledger claims happened. `base` (card stop-scan-committed-changes) is the ref re-derivation
|
|
180
|
+
// tries first, forwarded to diffReproducesCurrentContent - the caller's session baseline HEAD when it has
|
|
181
|
+
// one, so a target the session itself committed since that baseline is still checked against what existed
|
|
182
|
+
// before the session started, not only against HEAD (which, once the target is committed, already equals the
|
|
183
|
+
// current bytes and would make a new-file diff fail to apply there). HEAD is always tried too (M1 review
|
|
184
|
+
// 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 `base` null
|
|
186
|
+
// or omitted, only HEAD is tried, exactly as before this card.
|
|
187
|
+
export function isAppliedContent(dir, relPath, { base = null } = {}) {
|
|
188
|
+
const sha = currentSha256(dir, relPath);
|
|
189
|
+
const learnDirAbs = resolve(dir, LEARN_STATE_RELPATH);
|
|
190
|
+
const matching = readAppliedLedger(dir).filter((row) => row && row.target === relPath && row.sha256 === sha);
|
|
191
|
+
// review MINOR 10: dedupe by `proposal` before capping to MAX_VERIFIED_ROWS - many rows naming the very same
|
|
192
|
+
// diff (repeated or near-repeated bogus lines) must cost one verification slot, not one per row, or a large
|
|
193
|
+
// enough pile of them could push a genuine row for this same target/sha256 out of the last-N window even
|
|
194
|
+
// though every one of those rows resolves to a single distinct diff file. A Map naturally keeps only the
|
|
195
|
+
// last-seen entry per key, and the ledger is append-only (later == newer), so the newest occurrence of each
|
|
196
|
+
// distinct `proposal` wins.
|
|
197
|
+
const byProposal = new Map();
|
|
198
|
+
for (const row of matching) byProposal.set(row.proposal, row);
|
|
199
|
+
const candidates = [...byProposal.values()].slice(-MAX_VERIFIED_ROWS).reverse(); // newest distinct proposal first
|
|
200
|
+
return candidates.some((row) => rowIsGenuine(dir, relPath, sha, learnDirAbs, row, base));
|
|
201
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// card gates-runner-no-color: NO_COLOR, not FORCE_COLOR — vitest's colour library (picocolors/tinyrainbow)
|
|
4
|
+
// treats any FORCE_COLOR key as colour on, even "0", which broke the stop gate's captured output and the
|
|
5
|
+
// block ledger's failing-file parse (same fact as land.mjs's commandEnv, card land-force-color-enables-color).
|
|
6
|
+
export function commandEnv(base = process.env) {
|
|
7
|
+
const env = { ...base, CI: base.CI ?? "1", NO_COLOR: "1" };
|
|
8
|
+
delete env.FORCE_COLOR;
|
|
9
|
+
return env;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Runs a shell command in the project dir with a timeout. Never throws.
|
|
13
|
+
export function runCommand(command, { cwd, timeoutMs = 300000, env = process.env } = {}) {
|
|
14
|
+
const started = Date.now();
|
|
15
|
+
const res = spawnSync(command, {
|
|
16
|
+
cwd,
|
|
17
|
+
shell: true,
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
timeout: timeoutMs,
|
|
20
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
21
|
+
env: commandEnv(env),
|
|
22
|
+
// 2026-09-16 process storm (card stop-gate-process-storm): detached makes the spawned shell the leader
|
|
23
|
+
// of its own process group, so a timeout can kill that whole group below, not just the direct child.
|
|
24
|
+
detached: true,
|
|
25
|
+
});
|
|
26
|
+
const timedOut = res.error && res.error.code === "ETIMEDOUT";
|
|
27
|
+
// spawnSync's own `timeout` (ETIMEDOUT) and `maxBuffer` (ENOBUFS) both signal only the direct child (the
|
|
28
|
+
// shell); anything that child spawned (e.g. a background test worker) is left running under pid 1 — four
|
|
29
|
+
// concurrent SubagentStops each doing this orphaned ten vitest workers apiece and downed the watchdog three
|
|
30
|
+
// times on 2026-09-16. Killed on ANY res.error with a pid (not just ETIMEDOUT; reviewer-found gap), since a
|
|
31
|
+
// maxBuffer overflow leaves the same orphaned-descendants shape as a timeout does. With `detached: true`
|
|
32
|
+
// above, res.pid is the process-group leader, so signalling the negative pid reaches every descendant still
|
|
33
|
+
// in that group. `timedOut` itself still reports only the ETIMEDOUT case, unchanged.
|
|
34
|
+
if (res.error && res.pid) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-res.pid, "SIGKILL");
|
|
37
|
+
} catch {
|
|
38
|
+
// group already gone
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
command,
|
|
43
|
+
ok: !timedOut && res.status === 0,
|
|
44
|
+
status: res.status,
|
|
45
|
+
timedOut: !!timedOut,
|
|
46
|
+
durationMs: Date.now() - started,
|
|
47
|
+
stdout: res.stdout || "",
|
|
48
|
+
stderr: res.stderr || (res.error ? String(res.error.message) : ""),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Keeps the last N lines of output so a block reason stays readable.
|
|
53
|
+
export function tail(text, lines = 30) {
|
|
54
|
+
const arr = String(text).trimEnd().split("\n");
|
|
55
|
+
return arr.slice(-lines).join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
59
|
+
const stripAnsi = (s) => s.replace(ANSI_RE, "");
|
|
60
|
+
|
|
61
|
+
// card gate-output-names-failures: `tail` alone buries a real vitest failure under dozens of passing lines,
|
|
62
|
+
// leaving no failing test name in a block reason (brief F1/F2). Leads with the failure-naming lines (`FAIL`,
|
|
63
|
+
// `×`, `✗`; a `×` line's `→` message right after it) and the final `Test Files`/`Tests` totals, in output
|
|
64
|
+
// order and each kept once, then fills the rest of `maxLines` with the plain tail of whatever wasn't already
|
|
65
|
+
// shown - skipping any OTHER totals-shaped line too (review H10: a `pnpm -r --stream` run prints one
|
|
66
|
+
// `Test Files`/`Tests` pair per package, so without this the tail filler pulls the earlier, per-package
|
|
67
|
+
// pairs back in and the totals show up more than once). No vitest-shaped lines at all -> identical to
|
|
68
|
+
// `tail(text, maxLines)`. When the failure-naming lines alone would overflow `maxLines`, keeps the first
|
|
69
|
+
// `maxLines - 3` of them plus the two totals lines plus one `… <n> more failing tests not shown` line (40
|
|
70
|
+
// total at the default cap) rather than growing past the cap. ANSI escapes are stripped from every kept
|
|
71
|
+
// line. `maxLines < 4` (too small to fit totals plus the overflow line at all) falls back to a plain tail
|
|
72
|
+
// (review m2). Never throws.
|
|
73
|
+
export function failureSummary(output, maxLines = 40) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = String(output ?? "").trimEnd();
|
|
77
|
+
} catch {
|
|
78
|
+
// review m1: `output`'s own toString threw; there is nothing safe left to summarise or tail.
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
if (raw === "") return "";
|
|
82
|
+
if (maxLines < 4) return tail(raw, maxLines);
|
|
83
|
+
try {
|
|
84
|
+
const rawLines = raw.split("\n");
|
|
85
|
+
const cleanLines = rawLines.map(stripAnsi);
|
|
86
|
+
const used = new Set();
|
|
87
|
+
const headIdx = [];
|
|
88
|
+
for (let i = 0; i < cleanLines.length; i++) {
|
|
89
|
+
if (used.has(i)) continue;
|
|
90
|
+
if (!/^\s*(FAIL|×|✗)\s/.test(cleanLines[i])) continue;
|
|
91
|
+
headIdx.push(i);
|
|
92
|
+
used.add(i);
|
|
93
|
+
// a `×` line's `→` message belongs with it, kept whole right after.
|
|
94
|
+
if (/^\s*×\s/.test(cleanLines[i]) && i + 1 < cleanLines.length && !used.has(i + 1) && /^\s*→/.test(cleanLines[i + 1])) {
|
|
95
|
+
headIdx.push(i + 1);
|
|
96
|
+
used.add(i + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (headIdx.length === 0) return tail(raw, maxLines);
|
|
100
|
+
|
|
101
|
+
// review MAJOR M-A: a `pnpm -r` run prints one `Test Files`/`Tests` pair per package, so scanning for
|
|
102
|
+
// every line matching the pattern (as before) could add thousands of totals lines and blow the cap wide
|
|
103
|
+
// open. Only the LAST `Test Files` line and the LAST `Tests` line are the overall run's own totals; kept
|
|
104
|
+
// (at most one of each, at most 2 lines total) instead, which also keeps the head budget non-negative
|
|
105
|
+
// for any input size.
|
|
106
|
+
let lastTestFilesIdx = -1;
|
|
107
|
+
let lastTestsIdx = -1;
|
|
108
|
+
for (let i = 0; i < cleanLines.length; i++) {
|
|
109
|
+
if (used.has(i)) continue;
|
|
110
|
+
if (/^\s*Test Files\s/.test(cleanLines[i])) lastTestFilesIdx = i;
|
|
111
|
+
else if (/^\s*Tests\s/.test(cleanLines[i])) lastTestsIdx = i;
|
|
112
|
+
}
|
|
113
|
+
const totalsIdx = [lastTestFilesIdx, lastTestsIdx].filter((i) => i !== -1).sort((a, b) => a - b);
|
|
114
|
+
for (const i of totalsIdx) used.add(i);
|
|
115
|
+
|
|
116
|
+
const headLines = headIdx.map((i) => cleanLines[i]);
|
|
117
|
+
const totalsLines = totalsIdx.map((i) => cleanLines[i]);
|
|
118
|
+
|
|
119
|
+
if (headLines.length + totalsLines.length > maxLines) {
|
|
120
|
+
const keepHeadCount = Math.max(0, maxLines - totalsLines.length - 1);
|
|
121
|
+
const kept = headLines.slice(0, keepHeadCount);
|
|
122
|
+
const dropped = headLines.slice(keepHeadCount);
|
|
123
|
+
const droppedNamed = dropped.filter((l) => /^\s*(FAIL|×|✗)\s/.test(l)).length;
|
|
124
|
+
return [...kept, ...totalsLines, `… ${droppedNamed} more failing tests not shown`].join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const resultLines = [...headLines, ...totalsLines];
|
|
128
|
+
const remaining = maxLines - resultLines.length;
|
|
129
|
+
if (remaining > 0) {
|
|
130
|
+
// review H10: an earlier per-package Test Files/Tests pair is not in `used` (only the LAST pair is
|
|
131
|
+
// kept as the real totals above), but it must still be skipped here, or the tail filler pulls it back
|
|
132
|
+
// in and the totals end up shown more than once.
|
|
133
|
+
const filler = [];
|
|
134
|
+
for (let i = cleanLines.length - 1; i >= 0 && filler.length < remaining; i--) {
|
|
135
|
+
if (used.has(i)) continue;
|
|
136
|
+
if (/^\s*(Test Files|Tests)\s/.test(cleanLines[i])) continue;
|
|
137
|
+
filler.push(cleanLines[i]);
|
|
138
|
+
}
|
|
139
|
+
filler.reverse();
|
|
140
|
+
resultLines.push(...filler);
|
|
141
|
+
}
|
|
142
|
+
return resultLines.join("\n");
|
|
143
|
+
} catch {
|
|
144
|
+
// review m1: `raw` is already a plain, safely-coerced string, so this fallback cannot itself throw.
|
|
145
|
+
return tail(raw, maxLines);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Plan-versus-diff scope: when .doug/plan.json is approved (or done and not yet landed), the union of
|
|
2
|
+
// the files its tasks own is the only thing a session may change.
|
|
3
|
+
// Pure functions plus one reader; the stop gate decides, this file only computes.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { globToRegExp, matchAny, normalizePath } from "./glob.mjs";
|
|
8
|
+
|
|
9
|
+
export const PLAN_RELPATH = ".doug/plan.json";
|
|
10
|
+
export const ENFORCED_STATUSES = ["approved", "done"];
|
|
11
|
+
// The flow's own files change during a run and are never out of scope. Per-session hook state too.
|
|
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"];
|
|
14
|
+
|
|
15
|
+
// Returns { plan, reason }. plan is set only when the file exists, parses, has tasks, and is in an
|
|
16
|
+
// enforced status; reason says why not otherwise ("no-plan", "unreadable", "status:draft", "invalid").
|
|
17
|
+
export function loadScopePlan(dir) {
|
|
18
|
+
const file = join(dir, PLAN_RELPATH);
|
|
19
|
+
if (!existsSync(file)) return { plan: null, reason: "no-plan" };
|
|
20
|
+
let plan;
|
|
21
|
+
try {
|
|
22
|
+
plan = JSON.parse(readFileSync(file, "utf8"));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return { plan: null, reason: "unreadable", error: String(err.message) };
|
|
25
|
+
}
|
|
26
|
+
if (!plan || typeof plan !== "object" || !Array.isArray(plan.tasks)) return { plan: null, reason: "invalid" };
|
|
27
|
+
if (!ENFORCED_STATUSES.includes(plan.status)) return { plan: null, reason: `status:${plan.status}` };
|
|
28
|
+
// plan.mjs land records `landed`; a landed plan has been merged and no longer holds scope.
|
|
29
|
+
if (plan.landed) return { plan: null, reason: "landed" };
|
|
30
|
+
return { plan, reason: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GLOB_CHARS = /[*?{]/;
|
|
34
|
+
|
|
35
|
+
// Does a plan ownership entry cover this path? Exact file, a directory (with or without a trailing
|
|
36
|
+
// slash), or a glob matched against the whole relative path. No basename matching: a task that owns
|
|
37
|
+
// "README.md" does not own "docs/README.md".
|
|
38
|
+
export function ownsPath(owned, file) {
|
|
39
|
+
if (typeof owned !== "string" || typeof file !== "string") return false;
|
|
40
|
+
const o = normalizePath(owned).replace(/\/+$/, "");
|
|
41
|
+
const f = normalizePath(file);
|
|
42
|
+
if (!o) return false;
|
|
43
|
+
if (GLOB_CHARS.test(o)) return globToRegExp(o).test(f);
|
|
44
|
+
return f === o || f.startsWith(o + "/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Sorted union of every path the plan's tasks own.
|
|
48
|
+
export function planScope(plan) {
|
|
49
|
+
const out = new Set();
|
|
50
|
+
for (const t of plan.tasks || []) for (const f of t.files || []) if (typeof f === "string") out.add(normalizePath(f));
|
|
51
|
+
return [...out].sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function segments(p) {
|
|
55
|
+
return normalizePath(p).split("/").filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sharedDepth(a, b) {
|
|
59
|
+
const x = segments(a).slice(0, -1); // directories only; the basename never counts
|
|
60
|
+
const y = segments(b).slice(0, -1);
|
|
61
|
+
let n = 0;
|
|
62
|
+
while (n < x.length && n < y.length && x[n] === y[n]) n++;
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Tasks whose owned files sit closest to this file in the tree: longest shared directory prefix,
|
|
67
|
+
// ties kept in plan order. Empty when no task owns anything in a directory the file shares.
|
|
68
|
+
export function nearestTasks(plan, file) {
|
|
69
|
+
let best = 0;
|
|
70
|
+
const hits = [];
|
|
71
|
+
for (const t of plan.tasks || []) {
|
|
72
|
+
let depth = 0;
|
|
73
|
+
let via = null;
|
|
74
|
+
for (const f of t.files || []) {
|
|
75
|
+
const d = sharedDepth(f, file);
|
|
76
|
+
if (d > depth || via === null) {
|
|
77
|
+
depth = d;
|
|
78
|
+
via = f;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (via === null || depth === 0) continue;
|
|
82
|
+
if (depth > best) {
|
|
83
|
+
best = depth;
|
|
84
|
+
hits.length = 0;
|
|
85
|
+
}
|
|
86
|
+
if (depth === best) hits.push({ id: t.id, via });
|
|
87
|
+
}
|
|
88
|
+
return hits;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Changed files the plan does not cover, after the ignore list and the flow's own files.
|
|
92
|
+
// Each entry: { file, nearest: [{ id, via }] }.
|
|
93
|
+
export function scopeViolations(changed, plan, { ignore = [] } = {}) {
|
|
94
|
+
const owned = planScope(plan);
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const raw of changed) {
|
|
97
|
+
const file = normalizePath(raw);
|
|
98
|
+
if (matchAny(ALWAYS_ALLOWED, file)) continue;
|
|
99
|
+
if (ignore.length && matchAny(ignore, file)) continue;
|
|
100
|
+
if (owned.some((o) => ownsPath(o, file))) continue;
|
|
101
|
+
out.push({ file, nearest: nearestTasks(plan, file) });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function describeViolations(violations, plan) {
|
|
107
|
+
const lines = [`Files changed outside the ${plan.status} plan "${plan.title}" (${PLAN_RELPATH}):`];
|
|
108
|
+
for (const v of violations) {
|
|
109
|
+
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
|
+
lines.push(` ${v.file} ${near}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push(
|
|
113
|
+
"The plan is the contract. Revert these files, or add them to the owning task's files and have the user re-approve the plan. " +
|
|
114
|
+
"Paths that legitimately change outside any task (lockfiles, generated files) belong in stopGate.ignoreChangedPaths in .doug/config.json.",
|
|
115
|
+
);
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|