tickmarkr 1.30.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.
Files changed (123) hide show
  1. package/README.md +347 -0
  2. package/dist/adapters/claude-code.d.ts +4 -0
  3. package/dist/adapters/claude-code.js +191 -0
  4. package/dist/adapters/codex.d.ts +8 -0
  5. package/dist/adapters/codex.js +257 -0
  6. package/dist/adapters/cursor-agent.d.ts +4 -0
  7. package/dist/adapters/cursor-agent.js +62 -0
  8. package/dist/adapters/fake.d.ts +45 -0
  9. package/dist/adapters/fake.js +115 -0
  10. package/dist/adapters/grok.d.ts +4 -0
  11. package/dist/adapters/grok.js +122 -0
  12. package/dist/adapters/model-lints.d.ts +6 -0
  13. package/dist/adapters/model-lints.js +130 -0
  14. package/dist/adapters/opencode.d.ts +3 -0
  15. package/dist/adapters/opencode.js +108 -0
  16. package/dist/adapters/pi.d.ts +3 -0
  17. package/dist/adapters/pi.js +191 -0
  18. package/dist/adapters/prompt.d.ts +6 -0
  19. package/dist/adapters/prompt.js +84 -0
  20. package/dist/adapters/registry.d.ts +31 -0
  21. package/dist/adapters/registry.js +165 -0
  22. package/dist/adapters/types.d.ts +98 -0
  23. package/dist/adapters/types.js +56 -0
  24. package/dist/cli/commands/approve.d.ts +1 -0
  25. package/dist/cli/commands/approve.js +72 -0
  26. package/dist/cli/commands/compile.d.ts +1 -0
  27. package/dist/cli/commands/compile.js +23 -0
  28. package/dist/cli/commands/doctor.d.ts +2 -0
  29. package/dist/cli/commands/doctor.js +103 -0
  30. package/dist/cli/commands/init.d.ts +1 -0
  31. package/dist/cli/commands/init.js +32 -0
  32. package/dist/cli/commands/plan.d.ts +2 -0
  33. package/dist/cli/commands/plan.js +101 -0
  34. package/dist/cli/commands/profile.d.ts +1 -0
  35. package/dist/cli/commands/profile.js +56 -0
  36. package/dist/cli/commands/report.d.ts +4 -0
  37. package/dist/cli/commands/report.js +306 -0
  38. package/dist/cli/commands/resume.d.ts +1 -0
  39. package/dist/cli/commands/resume.js +11 -0
  40. package/dist/cli/commands/run.d.ts +1 -0
  41. package/dist/cli/commands/run.js +35 -0
  42. package/dist/cli/commands/scope.d.ts +3 -0
  43. package/dist/cli/commands/scope.js +22 -0
  44. package/dist/cli/commands/status.d.ts +9 -0
  45. package/dist/cli/commands/status.js +162 -0
  46. package/dist/cli/commands/unlock.d.ts +1 -0
  47. package/dist/cli/commands/unlock.js +11 -0
  48. package/dist/cli/index.d.ts +8 -0
  49. package/dist/cli/index.js +65 -0
  50. package/dist/compile/collateral.d.ts +6 -0
  51. package/dist/compile/collateral.js +128 -0
  52. package/dist/compile/common.d.ts +12 -0
  53. package/dist/compile/common.js +56 -0
  54. package/dist/compile/gsd.d.ts +3 -0
  55. package/dist/compile/gsd.js +222 -0
  56. package/dist/compile/index.d.ts +4 -0
  57. package/dist/compile/index.js +33 -0
  58. package/dist/compile/native.d.ts +4 -0
  59. package/dist/compile/native.js +196 -0
  60. package/dist/compile/prd.d.ts +2 -0
  61. package/dist/compile/prd.js +64 -0
  62. package/dist/compile/speckit.d.ts +2 -0
  63. package/dist/compile/speckit.js +66 -0
  64. package/dist/config/config.d.ts +184 -0
  65. package/dist/config/config.js +329 -0
  66. package/dist/drivers/herdr.d.ts +44 -0
  67. package/dist/drivers/herdr.js +394 -0
  68. package/dist/drivers/index.d.ts +3 -0
  69. package/dist/drivers/index.js +13 -0
  70. package/dist/drivers/subprocess.d.ts +25 -0
  71. package/dist/drivers/subprocess.js +106 -0
  72. package/dist/drivers/types.d.ts +61 -0
  73. package/dist/drivers/types.js +77 -0
  74. package/dist/gates/acceptance.d.ts +19 -0
  75. package/dist/gates/acceptance.js +92 -0
  76. package/dist/gates/baseline.d.ts +12 -0
  77. package/dist/gates/baseline.js +97 -0
  78. package/dist/gates/evidence.d.ts +4 -0
  79. package/dist/gates/evidence.js +12 -0
  80. package/dist/gates/llm.d.ts +25 -0
  81. package/dist/gates/llm.js +101 -0
  82. package/dist/gates/review.d.ts +12 -0
  83. package/dist/gates/review.js +80 -0
  84. package/dist/gates/run-gates.d.ts +34 -0
  85. package/dist/gates/run-gates.js +105 -0
  86. package/dist/gates/scope.d.ts +8 -0
  87. package/dist/gates/scope.js +32 -0
  88. package/dist/gates/types.d.ts +6 -0
  89. package/dist/gates/types.js +1 -0
  90. package/dist/graph/graph.d.ts +20 -0
  91. package/dist/graph/graph.js +148 -0
  92. package/dist/graph/schema.d.ts +185 -0
  93. package/dist/graph/schema.js +141 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +1 -0
  96. package/dist/plan/prompt.d.ts +4 -0
  97. package/dist/plan/prompt.js +31 -0
  98. package/dist/plan/scope.d.ts +17 -0
  99. package/dist/plan/scope.js +141 -0
  100. package/dist/report/cost.d.ts +24 -0
  101. package/dist/report/cost.js +89 -0
  102. package/dist/route/preference.d.ts +27 -0
  103. package/dist/route/preference.js +49 -0
  104. package/dist/route/profile.d.ts +65 -0
  105. package/dist/route/profile.js +197 -0
  106. package/dist/route/router.d.ts +26 -0
  107. package/dist/route/router.js +200 -0
  108. package/dist/run/consult.d.ts +23 -0
  109. package/dist/run/consult.js +80 -0
  110. package/dist/run/daemon.d.ts +23 -0
  111. package/dist/run/daemon.js +728 -0
  112. package/dist/run/git.d.ts +12 -0
  113. package/dist/run/git.js +56 -0
  114. package/dist/run/journal.d.ts +91 -0
  115. package/dist/run/journal.js +287 -0
  116. package/dist/run/lock.d.ts +29 -0
  117. package/dist/run/lock.js +182 -0
  118. package/dist/run/merge.d.ts +12 -0
  119. package/dist/run/merge.js +44 -0
  120. package/dist/run/reconcile.d.ts +4 -0
  121. package/dist/run/reconcile.js +95 -0
  122. package/package.json +39 -0
  123. package/schema/rungraph.schema.json +306 -0
@@ -0,0 +1,162 @@
1
+ import { blockedTasks, loadGraph, pendingTasks } from "../../graph/graph.js";
2
+ import { GATE_NAMES } from "../../graph/schema.js";
3
+ import { Journal } from "../../run/journal.js";
4
+ // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
5
+ const REFRESH_MS = 2000;
6
+ // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
7
+ // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
8
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const attemptStartIdx = (events, taskId) => {
10
+ let idx = -1;
11
+ for (let i = 0; i < events.length; i++) {
12
+ const e = events[i];
13
+ if (e.taskId === taskId && (e.event === "task-dispatch" || e.event === "escalation"))
14
+ idx = i;
15
+ }
16
+ return idx;
17
+ };
18
+ /** Derive the live phase column for one task. Counts only that task's events since its last dispatch/escalation. */
19
+ export const derivePhase = (task, events, channel) => {
20
+ const startIdx = attemptStartIdx(events, task.id);
21
+ if (startIdx < 0)
22
+ return "";
23
+ // Scope to this task — concurrent tasks' events must not leak into the attempt window (D-03).
24
+ const attempt = events.slice(startIdx).filter((e) => e.taskId === task.id);
25
+ if (attempt.some((e) => e.event === "task-done" || e.event === "task-failed" || e.event === "task-human"))
26
+ return "";
27
+ if (!attempt.some((e) => e.event === "task-dispatch"))
28
+ return "retrying"; // escalation before re-dispatch
29
+ if (!attempt.some((e) => e.event === "worker-result"))
30
+ return channel ? `worker · ${channel}` : "worker";
31
+ const enabled = GATE_NAMES.filter((g) => task.gates.includes(g));
32
+ const n = enabled.length;
33
+ const gateResults = attempt.filter((e) => e.event === "gate-result");
34
+ if (gateResults.length > 0) {
35
+ const last = gateResults[gateResults.length - 1];
36
+ if (!last.data.pass)
37
+ return `gate ✗ ${last.data.gate}`;
38
+ }
39
+ const passed = new Set();
40
+ for (const e of gateResults)
41
+ if (e.data.pass)
42
+ passed.add(String(e.data.gate));
43
+ const k = passed.size;
44
+ // n===0 (no enabled gates) → merge window immediately after worker-result
45
+ if (n === 0 || k >= n) {
46
+ if (!attempt.some((e) => e.event === "merge" || e.event === "task-done"))
47
+ return "merging";
48
+ return "";
49
+ }
50
+ return `gate ${k + 1}/${n} ${enabled[k]}`;
51
+ };
52
+ // VIS-11 (v1.13): a liveness header for renderFrame — last journal event age + whether the recorded
53
+ // daemon pid is still alive. Honest about unknowns: a pre-v1.13 journal with no pid renders "unknown",
54
+ // never fabricated (garbage pid data fails toward unknown too). kill(pid,0) is a signal probe, not a
55
+ // write — VIS-07 purity holds (status stays a pure reader; the snapshot-purity test is the fence).
56
+ // The lock.ts:52-53 idiom: ESRCH ⇒ dead, EPERM/success ⇒ alive.
57
+ const fmtAge = (ms) => {
58
+ if (ms < 90_000)
59
+ return `${Math.floor(ms / 1000)}s`;
60
+ if (ms < 5_400_000)
61
+ return `${Math.floor(ms / 60_000)}m`;
62
+ return `${Math.floor(ms / 3_600_000)}h`;
63
+ };
64
+ // last valid recording wins: scan from the newest event for the most recent run-start/run-resume
65
+ // carrying a positive-integer pid. Anything else (absent, non-integer, ≤0) ⇒ undefined ⇒ unknown.
66
+ const daemonPid = (events) => {
67
+ for (let i = events.length - 1; i >= 0; i--) {
68
+ const e = events[i];
69
+ if ((e.event === "run-start" || e.event === "run-resume") && Number.isInteger(e.data.pid) && e.data.pid > 0) {
70
+ return e.data.pid;
71
+ }
72
+ }
73
+ return undefined;
74
+ };
75
+ const livenessLine = (events) => {
76
+ const last = events.at(-1);
77
+ if (!last)
78
+ return null; // nothing to be honest about
79
+ const age = fmtAge(Date.now() - Date.parse(last.ts));
80
+ const pid = daemonPid(events);
81
+ if (pid === undefined)
82
+ return ` last event ${age} ago · daemon pid unknown`;
83
+ let state;
84
+ try {
85
+ process.kill(pid, 0);
86
+ state = "alive";
87
+ } // no throw ⇒ alive
88
+ catch (k) {
89
+ state = k.code === "ESRCH" ? "dead" : "alive";
90
+ } // EPERM ⇒ alive
91
+ return ` last event ${age} ago · daemon pid ${pid} ${state}`;
92
+ };
93
+ const renderFrame = (cwd) => {
94
+ const g = loadGraph(cwd);
95
+ const runId = Journal.latestRunId(cwd);
96
+ const assignments = new Map();
97
+ const gates = new Map();
98
+ let replayed = null;
99
+ let events = [];
100
+ // v1.23 T2: latest known context tokens per task (from context-sample journal events). Informational
101
+ // only — never feeds status/phase/gate classification (derivePhase and replayStatuses ignore them).
102
+ const contexts = new Map();
103
+ if (runId) {
104
+ const j = Journal.open(cwd, runId);
105
+ events = j.read();
106
+ replayed = j.replayStatuses();
107
+ for (const e of events) {
108
+ if (e.event === "task-dispatch" && e.taskId) {
109
+ const a = e.data.assignment;
110
+ assignments.set(e.taskId, `${a.adapter}:${a.model}`);
111
+ }
112
+ if (e.event === "gate-result" && e.taskId) {
113
+ const prev = gates.get(e.taskId) ?? "";
114
+ gates.set(e.taskId, `${prev}${e.data.pass ? "✓" : "✗"}${e.data.gate} `);
115
+ }
116
+ if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
117
+ contexts.set(e.taskId, e.data.tokens); // last write wins
118
+ }
119
+ }
120
+ }
121
+ const effective = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: replayed?.get(t.id) ?? t.status })) };
122
+ const starved = new Set(blockedTasks(effective).map((t) => t.id));
123
+ const waiting = new Set(pendingTasks(effective).map((t) => t.id));
124
+ const rows = g.tasks.map((t) => {
125
+ const st = replayed?.get(t.id) ?? t.status;
126
+ const label = starved.has(t.id) ? " starved" : waiting.has(t.id) ? " dep-waiting" : "";
127
+ const phase = derivePhase(t, events, assignments.get(t.id)) || "-";
128
+ const base = assignments.get(t.id) ?? "-";
129
+ const ctx = contexts.get(t.id);
130
+ // Informational suffix only when known — never invents 0 from absence.
131
+ const assignCol = ctx !== undefined ? `${base} · ctx ${ctx}` : base;
132
+ return ` ${t.id.padEnd(6)} ${String(st).padEnd(8)} ${phase.padEnd(28)} ${assignCol.padEnd(28)} ${gates.get(t.id) ?? ""}${label}`;
133
+ });
134
+ return [
135
+ `drovr status${runId ? ` — run ${runId}` : " — no runs yet"}`,
136
+ ...(runId ? [livenessLine(events)].filter((l) => l !== null) : []),
137
+ " task status phase assignment gates",
138
+ ...rows,
139
+ ].join("\n");
140
+ };
141
+ export async function status(argv, cwd = process.cwd(), opts = {}) {
142
+ if (!argv.includes("--watch"))
143
+ return renderFrame(cwd);
144
+ const iterations = opts.iterations ?? Infinity;
145
+ const sleep = opts.sleep ?? defaultSleep;
146
+ const bounded = Number.isFinite(iterations);
147
+ const frames = [];
148
+ const sep = "\n---\n";
149
+ const tty = process.stdout.isTTY;
150
+ for (let i = 0; i < iterations; i++) {
151
+ const frame = renderFrame(cwd);
152
+ if (tty)
153
+ process.stdout.write(`\x1b[2J\x1b[H${frame}`);
154
+ else
155
+ process.stdout.write(frame + sep);
156
+ if (bounded)
157
+ frames.push(frame);
158
+ if (i + 1 < iterations)
159
+ await sleep(REFRESH_MS);
160
+ }
161
+ return bounded ? frames.join(sep) : "";
162
+ }
@@ -0,0 +1 @@
1
+ export declare function unlock(_argv: string[], cwd?: string): Promise<string>;
@@ -0,0 +1,11 @@
1
+ import { unlockRun } from "../../run/lock.js";
2
+ // LOCK-03: thin formatter over unlockRun. A live-holder refusal propagates as the throw — the
3
+ // dispatcher prints `drovr unlock: …` and exits 1 (src/cli/index.ts).
4
+ export async function unlock(_argv, cwd = process.cwd()) {
5
+ const r = unlockRun(cwd);
6
+ if (!r.held)
7
+ return "no lock held — nothing to remove";
8
+ if (r.garbage)
9
+ return "removed garbage lock (unparseable payload)";
10
+ return `removed stale lock — holder pid ${r.pid}${r.runId ? ` (run ${r.runId})` : ""} is dead`;
11
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ export type CommandMap = Record<string, (argv: string[]) => Promise<string>>;
3
+ export declare const COMMANDS: CommandMap;
4
+ export declare const USAGE = "drovr \u2014 spec-driven orchestration harness for AI coding agents\nusage: drovr <command>\n init guided setup + doctor\n doctor re-probe adapters, herdr, auth; print capability matrix\n compile <src> spec \u2192 .drovr/graph.json (fails without acceptance criteria)\n scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)\n plan dry-run routing table + cost estimate + floor lints\n run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)\n status live run state\n resume <id> continue a run from its journal\n report <id> cost/quality report (--md for committable execution record)\n profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)\n unlock remove a stale/garbage run lock (refuses if the holder is alive)\n approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume";
5
+ export declare function dispatch(cmd: string | undefined, argv: string[], commands?: CommandMap): Promise<{
6
+ out: string;
7
+ code: number;
8
+ }>;
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import { approve } from "./commands/approve.js";
5
+ import { compile } from "./commands/compile.js";
6
+ import { doctor } from "./commands/doctor.js";
7
+ import { init } from "./commands/init.js";
8
+ import { plan } from "./commands/plan.js";
9
+ import { profile } from "./commands/profile.js";
10
+ import { report } from "./commands/report.js";
11
+ import { resume } from "./commands/resume.js";
12
+ import { run } from "./commands/run.js";
13
+ import { scope } from "./commands/scope.js";
14
+ import { status } from "./commands/status.js";
15
+ import { unlock } from "./commands/unlock.js";
16
+ export const COMMANDS = {
17
+ init, doctor, compile, scope, plan, run, status, resume, report, profile, unlock, approve,
18
+ };
19
+ export const USAGE = `drovr — spec-driven orchestration harness for AI coding agents
20
+ usage: drovr <command>
21
+ init guided setup + doctor
22
+ doctor re-probe adapters, herdr, auth; print capability matrix
23
+ compile <src> spec → .drovr/graph.json (fails without acceptance criteria)
24
+ scope <intent> draft a compiled native spec beside an answered intent (--force to overwrite)
25
+ plan dry-run routing table + cost estimate + floor lints
26
+ run execute the graph (--concurrency N --driver herdr|subprocess --route-strict)
27
+ status live run state
28
+ resume <id> continue a run from its journal
29
+ report <id> cost/quality report (--md for committable execution record)
30
+ profile show learned routing profile (profile reset = forget history via cursor, keeps telemetry)
31
+ unlock remove a stale/garbage run lock (refuses if the holder is alive)
32
+ approve <id> <task> approve a parked human gate (--by <name> --reason <text>); takes effect on resume`;
33
+ // pure, testable dispatcher: resolves a command, forwards argv, shapes the result — no side effects.
34
+ // unknown/missing cmd → USAGE (exit 1 if a cmd was typed, 0 for bare `drovr`); a handler throw becomes
35
+ // a one-line `drovr <cmd>: <message>` (never a raw stack) at exit 1.
36
+ export async function dispatch(cmd, argv, commands = COMMANDS) {
37
+ const fn = cmd ? commands[cmd] : undefined;
38
+ if (!fn)
39
+ return { out: USAGE, code: cmd ? 1 : 0 };
40
+ try {
41
+ return { out: await fn(argv), code: 0 };
42
+ }
43
+ catch (err) {
44
+ return { out: `drovr ${cmd}: ${err.message}`, code: 1 };
45
+ }
46
+ }
47
+ /* v8 ignore start -- binary entry: printing + process.exit side effects, not unit-testable (ROADMAP crit 2) */
48
+ // node realpaths the main module (import.meta.url) but argv[1] keeps the symlink path —
49
+ // a globally-linked `drovr` bin silently no-oped here (OBS-10); compare realpaths
50
+ const argv1Real = (() => { try {
51
+ return process.argv[1] ? realpathSync(process.argv[1]) : "";
52
+ }
53
+ catch {
54
+ return "";
55
+ } })();
56
+ if (argv1Real && import.meta.url === pathToFileURL(argv1Real).href) {
57
+ const [cmd, ...argv] = process.argv.slice(2);
58
+ dispatch(cmd, argv).then(({ out, code }) => {
59
+ // byte-identical streams: usage + success → stdout; a handler throw → stderr (original behavior)
60
+ (out === USAGE || code === 0 ? console.log : console.error)(out);
61
+ if (code !== 0)
62
+ process.exit(code);
63
+ });
64
+ }
65
+ /* v8 ignore stop */
@@ -0,0 +1,6 @@
1
+ import type { Task } from "../graph/schema.js";
2
+ /**
3
+ * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
4
+ * Each line names the task id and at least one missing collateral test path.
5
+ */
6
+ export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
@@ -0,0 +1,128 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { extname, join, relative } from "node:path";
3
+ // Advisory plan-time scan only (OBS-12/13/14/21). NEVER expands files[], fails compile, or
4
+ // feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
5
+ /** Max test files walked under tests/ (sorted walk; rest ignored). */
6
+ const MAX_TEST_FILES = 400;
7
+ /** Max collateral test paths listed per task. */
8
+ const MAX_HITS_PER_TASK = 20;
9
+ /** Skip giant fixtures / snapshots. */
10
+ const MAX_READ_BYTES = 512 * 1024;
11
+ const SRC_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
12
+ const CODE_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
13
+ function isSrcPath(p) {
14
+ const n = p.replace(/^\.\//, "");
15
+ return n.startsWith("src/") && SRC_EXT.test(n) && !n.includes("node_modules/");
16
+ }
17
+ function needlesFor(srcPath) {
18
+ const n = srcPath.replace(/^\.\//, "");
19
+ const noExt = n.slice(0, n.length - extname(n).length); // src/adapters/codex
20
+ // path needles only (no bare basename — avoids prose/identifier false positives)
21
+ // repo-relative import forms that appear in tests (ESM often uses .js)
22
+ return [...new Set([noExt, `${noExt}.js`, `${noExt}.ts`, n])].filter((s) => s.length > 0);
23
+ }
24
+ function walkTests(repoRoot) {
25
+ const root = join(repoRoot, "tests");
26
+ const out = [];
27
+ const walk = (dir) => {
28
+ if (out.length >= MAX_TEST_FILES)
29
+ return;
30
+ let entries;
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return; // no tests/ or unreadable — zero lints
36
+ }
37
+ // deterministic order
38
+ entries.sort((a, b) => a.name.localeCompare(b.name));
39
+ for (const e of entries) {
40
+ if (out.length >= MAX_TEST_FILES)
41
+ return;
42
+ if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith("."))
43
+ continue;
44
+ const full = join(dir, e.name);
45
+ if (e.isDirectory()) {
46
+ walk(full);
47
+ continue;
48
+ }
49
+ if (!e.isFile() || !CODE_EXT.test(e.name))
50
+ continue;
51
+ const rel = relative(repoRoot, full).split("\\").join("/");
52
+ out.push(rel);
53
+ }
54
+ };
55
+ try {
56
+ if (!statSync(root).isDirectory())
57
+ return [];
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ walk(root);
63
+ return out;
64
+ }
65
+ function mentions(content, needles) {
66
+ for (const n of needles) {
67
+ if (n.length >= 2 && content.includes(n))
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ /**
73
+ * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
74
+ * Each line names the task id and at least one missing collateral test path.
75
+ */
76
+ export function collateralLints(tasks, repoRoot) {
77
+ const testFiles = walkTests(repoRoot);
78
+ if (!testFiles.length)
79
+ return [];
80
+ // cache file bodies (one read per test path)
81
+ const body = new Map();
82
+ const read = (rel) => {
83
+ if (body.has(rel))
84
+ return body.get(rel);
85
+ try {
86
+ const st = statSync(join(repoRoot, rel));
87
+ if (st.size > MAX_READ_BYTES) {
88
+ body.set(rel, null);
89
+ return null;
90
+ }
91
+ const text = readFileSync(join(repoRoot, rel), "utf8");
92
+ body.set(rel, text);
93
+ return text;
94
+ }
95
+ catch {
96
+ body.set(rel, null);
97
+ return null;
98
+ }
99
+ };
100
+ const lines = [];
101
+ for (const t of tasks) {
102
+ const scoped = new Set(t.files.map((f) => f.replace(/^\.\//, "")));
103
+ const srcFiles = t.files.map((f) => f.replace(/^\.\//, "")).filter(isSrcPath);
104
+ if (!srcFiles.length)
105
+ continue;
106
+ // needles unioned across all src files in this task
107
+ const needles = [...new Set(srcFiles.flatMap(needlesFor))];
108
+ const hits = [];
109
+ for (const tf of testFiles) {
110
+ if (scoped.has(tf))
111
+ continue;
112
+ const text = read(tf);
113
+ if (text === null)
114
+ continue;
115
+ if (mentions(text, needles))
116
+ hits.push(tf);
117
+ if (hits.length >= MAX_HITS_PER_TASK)
118
+ break;
119
+ }
120
+ if (!hits.length)
121
+ continue;
122
+ // deterministic: walk already sorted; stable list
123
+ const listed = hits.join(", ");
124
+ const tail = hits.length >= MAX_HITS_PER_TASK ? " (capped)" : "";
125
+ lines.push(`${t.id}: likely collateral tests not in files[]: ${listed}${tail}`);
126
+ }
127
+ return lines;
128
+ }
@@ -0,0 +1,12 @@
1
+ import type { Shape } from "../graph/schema.js";
2
+ export declare class CompileError extends Error {
3
+ constructor(msg: string);
4
+ }
5
+ export declare function sha256(content: string): string;
6
+ export declare function inferShape(description: string): Shape;
7
+ export interface WriteDirective {
8
+ path: string;
9
+ directive: string;
10
+ bare?: string;
11
+ }
12
+ export declare function assertWriteScope(source: string, taskId: string, files: string[], writes: WriteDirective[]): void;
@@ -0,0 +1,56 @@
1
+ import { createHash } from "node:crypto";
2
+ import picomatch from "picomatch";
3
+ export class CompileError extends Error {
4
+ constructor(msg) {
5
+ super(msg);
6
+ this.name = "CompileError";
7
+ }
8
+ }
9
+ export function sha256(content) {
10
+ return createHash("sha256").update(content).digest("hex");
11
+ }
12
+ export function inferShape(description) {
13
+ if (/\btests?\b|\btesting\b/i.test(description))
14
+ return "tests";
15
+ if (/\bdocs?\b|\bdocument(ation)?\b/i.test(description))
16
+ return "docs";
17
+ if (/migrat/i.test(description))
18
+ return "migration";
19
+ if (/\b(ui|screen|component)\b/i.test(description))
20
+ return "ui";
21
+ if (/refactor/i.test(description))
22
+ return "refactor";
23
+ return "implement";
24
+ }
25
+ // DOCUMENTED LIMIT: a write demand in freeform prose with no backticked path after a write verb
26
+ // is NOT caught here (zero such cases across 87 real acceptance criteria). The runtime scope gate
27
+ // is the backstop — weakened because it passes anything a worker declares as a deviation.
28
+ export function assertWriteScope(source, taskId, files, writes) {
29
+ // #1 over-fire landmine: empty files[] is unrestricted — mirrors scope.ts:12
30
+ if (!files.length)
31
+ return;
32
+ const inScope = picomatch(files, { dot: true }); // byte-identical options to src/gates/scope.ts:15
33
+ for (const { path, directive, bare } of writes) {
34
+ const normalized = path.replace(/^\.\//, "");
35
+ if (inScope(normalized))
36
+ continue;
37
+ // HARD-09: relocation-invariant self-write check. A bare-name directive is scoped iff SOME
38
+ // files_modified entry names that exact filename (literal suffix — no second matcher semantics;
39
+ // one matcher, per the v1.11 byte-identity decision with src/gates/scope.ts). v1.9-29's P30-02
40
+ // shape has NO entry with the SUMMARY's basename anywhere → still rejected. Moreover v1.9-29's
41
+ // four directives all carry a full slashed path (bare is never set), so it never reaches here.
42
+ //
43
+ // DOCUMENTED LIMIT A: a files_modified entry naming the same basename in a DIFFERENT directory
44
+ // satisfies the fallback although the write lands in the phase dir. Zero such cases in the real
45
+ // corpus (every observed self-write entry lives in the plan's own dir); the now-fixed runtime
46
+ // scope gate (src/gates/scope.ts) is the backstop.
47
+ // DOCUMENTED LIMIT B: a relocated plan whose self-write scope is a GLOB rather than a literal
48
+ // still over-fires. Zero such cases exist (all corpus files_modified entries are literal paths).
49
+ if (bare && files.some((f) => f === bare || f.endsWith(`/${bare}`)))
50
+ continue;
51
+ throw new CompileError(`${source}: task ${taskId} write directive orders a path outside files_modified.\n` +
52
+ ` directive: ${directive.trim()}\n` +
53
+ ` path: ${normalized}\n` +
54
+ ` remedy: add the path to files_modified, or drop the directive.`);
55
+ }
56
+ }
@@ -0,0 +1,3 @@
1
+ import { type RunGraph } from "../graph/schema.js";
2
+ export declare function isGsdPhaseDir(dir: string): boolean;
3
+ export declare function compileGsd(src: string, root?: string): RunGraph;