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.
- package/README.md +347 -0
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.js +191 -0
- package/dist/adapters/codex.d.ts +8 -0
- package/dist/adapters/codex.js +257 -0
- package/dist/adapters/cursor-agent.d.ts +4 -0
- package/dist/adapters/cursor-agent.js +62 -0
- package/dist/adapters/fake.d.ts +45 -0
- package/dist/adapters/fake.js +115 -0
- package/dist/adapters/grok.d.ts +4 -0
- package/dist/adapters/grok.js +122 -0
- package/dist/adapters/model-lints.d.ts +6 -0
- package/dist/adapters/model-lints.js +130 -0
- package/dist/adapters/opencode.d.ts +3 -0
- package/dist/adapters/opencode.js +108 -0
- package/dist/adapters/pi.d.ts +3 -0
- package/dist/adapters/pi.js +191 -0
- package/dist/adapters/prompt.d.ts +6 -0
- package/dist/adapters/prompt.js +84 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.js +165 -0
- package/dist/adapters/types.d.ts +98 -0
- package/dist/adapters/types.js +56 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +72 -0
- package/dist/cli/commands/compile.d.ts +1 -0
- package/dist/cli/commands/compile.js +23 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +103 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +32 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +101 -0
- package/dist/cli/commands/profile.d.ts +1 -0
- package/dist/cli/commands/profile.js +56 -0
- package/dist/cli/commands/report.d.ts +4 -0
- package/dist/cli/commands/report.js +306 -0
- package/dist/cli/commands/resume.d.ts +1 -0
- package/dist/cli/commands/resume.js +11 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/scope.d.ts +3 -0
- package/dist/cli/commands/scope.js +22 -0
- package/dist/cli/commands/status.d.ts +9 -0
- package/dist/cli/commands/status.js +162 -0
- package/dist/cli/commands/unlock.d.ts +1 -0
- package/dist/cli/commands/unlock.js +11 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +65 -0
- package/dist/compile/collateral.d.ts +6 -0
- package/dist/compile/collateral.js +128 -0
- package/dist/compile/common.d.ts +12 -0
- package/dist/compile/common.js +56 -0
- package/dist/compile/gsd.d.ts +3 -0
- package/dist/compile/gsd.js +222 -0
- package/dist/compile/index.d.ts +4 -0
- package/dist/compile/index.js +33 -0
- package/dist/compile/native.d.ts +4 -0
- package/dist/compile/native.js +196 -0
- package/dist/compile/prd.d.ts +2 -0
- package/dist/compile/prd.js +64 -0
- package/dist/compile/speckit.d.ts +2 -0
- package/dist/compile/speckit.js +66 -0
- package/dist/config/config.d.ts +184 -0
- package/dist/config/config.js +329 -0
- package/dist/drivers/herdr.d.ts +44 -0
- package/dist/drivers/herdr.js +394 -0
- package/dist/drivers/index.d.ts +3 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/subprocess.d.ts +25 -0
- package/dist/drivers/subprocess.js +106 -0
- package/dist/drivers/types.d.ts +61 -0
- package/dist/drivers/types.js +77 -0
- package/dist/gates/acceptance.d.ts +19 -0
- package/dist/gates/acceptance.js +92 -0
- package/dist/gates/baseline.d.ts +12 -0
- package/dist/gates/baseline.js +97 -0
- package/dist/gates/evidence.d.ts +4 -0
- package/dist/gates/evidence.js +12 -0
- package/dist/gates/llm.d.ts +25 -0
- package/dist/gates/llm.js +101 -0
- package/dist/gates/review.d.ts +12 -0
- package/dist/gates/review.js +80 -0
- package/dist/gates/run-gates.d.ts +34 -0
- package/dist/gates/run-gates.js +105 -0
- package/dist/gates/scope.d.ts +8 -0
- package/dist/gates/scope.js +32 -0
- package/dist/gates/types.d.ts +6 -0
- package/dist/gates/types.js +1 -0
- package/dist/graph/graph.d.ts +20 -0
- package/dist/graph/graph.js +148 -0
- package/dist/graph/schema.d.ts +185 -0
- package/dist/graph/schema.js +141 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plan/prompt.d.ts +4 -0
- package/dist/plan/prompt.js +31 -0
- package/dist/plan/scope.d.ts +17 -0
- package/dist/plan/scope.js +141 -0
- package/dist/report/cost.d.ts +24 -0
- package/dist/report/cost.js +89 -0
- package/dist/route/preference.d.ts +27 -0
- package/dist/route/preference.js +49 -0
- package/dist/route/profile.d.ts +65 -0
- package/dist/route/profile.js +197 -0
- package/dist/route/router.d.ts +26 -0
- package/dist/route/router.js +200 -0
- package/dist/run/consult.d.ts +23 -0
- package/dist/run/consult.js +80 -0
- package/dist/run/daemon.d.ts +23 -0
- package/dist/run/daemon.js +728 -0
- package/dist/run/git.d.ts +12 -0
- package/dist/run/git.js +56 -0
- package/dist/run/journal.d.ts +91 -0
- package/dist/run/journal.js +287 -0
- package/dist/run/lock.d.ts +29 -0
- package/dist/run/lock.js +182 -0
- package/dist/run/merge.d.ts +12 -0
- package/dist/run/merge.js +44 -0
- package/dist/run/reconcile.d.ts +4 -0
- package/dist/run/reconcile.js +95 -0
- package/package.json +39 -0
- package/schema/rungraph.schema.json +306 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { TIERS, validateGraph } from "../graph/schema.js";
|
|
5
|
+
import { CompileError, assertWriteScope, inferShape, sha256 } from "./common.js";
|
|
6
|
+
// GSD artifact front-end (spec v1.3): one GSD *plan* is one drovr *task* — a plan is
|
|
7
|
+
// worktree-sized; its inner <task> steps stay in the worker prompt via context[0] = the plan file.
|
|
8
|
+
// Artifact-level only: parses .planning/ markdown, never GSD repo/command internals.
|
|
9
|
+
const PLAN_SUFFIX = "-PLAN.md";
|
|
10
|
+
export function isGsdPhaseDir(dir) {
|
|
11
|
+
try {
|
|
12
|
+
return readdirSync(dir).some((f) => f.endsWith(PLAN_SUFFIX));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const strings = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
19
|
+
const WRITE_DIRECTIVE = /\b(?:Create|Write|Add|Emit|Generate)\s+`([^`\s]+)`/gi;
|
|
20
|
+
const isPathish = (s) => /^[\w./*-]+$/.test(s) && /\.[a-z]{2,4}$/i.test(s);
|
|
21
|
+
const stripFences = (s) => s.replace(/^```[\s\S]*?^```/gm, "");
|
|
22
|
+
// ponytail: grammar matches GSD SUMMARY boilerplate only; if that boilerplate drifts the check
|
|
23
|
+
// goes vacuous — the v1.9-29 pin in tests/compile/scope-seam.test.ts fails loudly when it does.
|
|
24
|
+
function extractWriteDirectives(body, storedPath) {
|
|
25
|
+
const phaseDir = dirname(storedPath);
|
|
26
|
+
const writes = [];
|
|
27
|
+
for (const m of stripFences(body).matchAll(WRITE_DIRECTIVE)) {
|
|
28
|
+
const raw = m[1];
|
|
29
|
+
if (!isPathish(raw))
|
|
30
|
+
continue;
|
|
31
|
+
// HARD-09: a bare name (no "/") joins against the plan's CURRENT on-disk dir, which goes stale
|
|
32
|
+
// the moment the dir is archive-relocated. Carry the bare name through so assertWriteScope's
|
|
33
|
+
// fallback can resolve it in a relocation-invariant way (basename suffix against files_modified).
|
|
34
|
+
const bare = !raw.includes("/");
|
|
35
|
+
const path = bare ? join(phaseDir, raw) : raw;
|
|
36
|
+
writes.push({ path, directive: m[0], ...(bare ? { bare: raw } : {}) });
|
|
37
|
+
}
|
|
38
|
+
return writes;
|
|
39
|
+
}
|
|
40
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
41
|
+
// schema-legal, branch-safe id segment: dots etc. → dashes; no "--" runs (task-branch separator)
|
|
42
|
+
// or trailing dash — the id schema rejects both
|
|
43
|
+
const sanitize = (s) => s
|
|
44
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
45
|
+
.replace(/-{2,}/g, "-")
|
|
46
|
+
.replace(/-+$/, "");
|
|
47
|
+
// filename is the canonical id source — frontmatter `plan: 06` YAML-parses to the number 6,
|
|
48
|
+
// losing the zero-padding that depends_on entries ("07-01") reference
|
|
49
|
+
const planKey = (file) => sanitize(basename(file).replace(/-PLAN\.md$/, ""));
|
|
50
|
+
// "06" and 6 must resolve to the same plan: alias each key by its zero-stripped segments
|
|
51
|
+
const unpad = (key) => key
|
|
52
|
+
.split("-")
|
|
53
|
+
.map((seg) => (/^\d+$/.test(seg) ? String(Number(seg)) : seg))
|
|
54
|
+
.join("-");
|
|
55
|
+
// numeric-aware order: 2-PLAN.md before 10-PLAN.md even without zero-padding
|
|
56
|
+
function byPlanOrder(a, b) {
|
|
57
|
+
const as = a.split("-");
|
|
58
|
+
const bs = b.split("-");
|
|
59
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
60
|
+
const x = as[i] ?? "";
|
|
61
|
+
const y = bs[i] ?? "";
|
|
62
|
+
if (/^\d+$/.test(x) && /^\d+$/.test(y) && Number(x) !== Number(y))
|
|
63
|
+
return Number(x) - Number(y);
|
|
64
|
+
if (x !== y)
|
|
65
|
+
return x < y ? -1 : 1;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
function compileOne(file, storedPath) {
|
|
70
|
+
const content = readFileSync(file, "utf8");
|
|
71
|
+
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
72
|
+
let fm = {};
|
|
73
|
+
if (fmMatch) {
|
|
74
|
+
try {
|
|
75
|
+
fm = (parseYaml(fmMatch[1]) ?? {});
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
// fail closed: a swallowed parse error would silently drop autonomous:false (a human gate)
|
|
79
|
+
throw new CompileError(`${file} has malformed YAML frontmatter: ${e instanceof Error ? e.message.split("\n")[0] : e}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const body = content.slice(fmMatch ? fmMatch[0].length : 0);
|
|
83
|
+
const key = planKey(file);
|
|
84
|
+
const objective = /<objective>\s*([\s\S]*?)<\/objective>/.exec(body)?.[1].trim() ?? "";
|
|
85
|
+
// sentence split that survives common abbreviations and version dots
|
|
86
|
+
const firstSentence = objective
|
|
87
|
+
.split(/(?<!\be\.g\.)(?<!\bi\.e\.)(?<!\bvs\.)(?<!\betc\.)(?<=\.)\s/)[0]
|
|
88
|
+
?.replace(/\s+/g, " ")
|
|
89
|
+
.trim();
|
|
90
|
+
const title = firstSentence || key;
|
|
91
|
+
const dones = [...body.matchAll(/<done>\s*([\s\S]*?)\s*<\/done>/g)].map((m) => m[1].replace(/\s+/g, " ").trim());
|
|
92
|
+
const truths = strings(fm.must_haves?.truths);
|
|
93
|
+
const acceptance = [...dones, ...truths].filter(Boolean);
|
|
94
|
+
if (!acceptance.length) {
|
|
95
|
+
throw new CompileError(`${file} has no acceptance criteria — every drovr task needs them.\n` +
|
|
96
|
+
`GSD source: add <done> lines to the plan's tasks, or must_haves.truths in its frontmatter.`);
|
|
97
|
+
}
|
|
98
|
+
// @-refs from the <context> block only; keep repo-relative paths (no $VAR, ~, absolute, or ..-escape)
|
|
99
|
+
const contextBlock = /<context>([\s\S]*?)<\/context>/.exec(body)?.[1] ?? "";
|
|
100
|
+
const refs = [...contextBlock.matchAll(/^@(\S+)$/gm)]
|
|
101
|
+
.map((m) => m[1])
|
|
102
|
+
.filter((p) => !p.includes("$") && !p.startsWith("~") && !p.startsWith("/") && !p.split("/").includes(".."));
|
|
103
|
+
const taskCount = [...body.matchAll(/<task[\s>]/g)].length;
|
|
104
|
+
const humanGate = fm.autonomous === false || /<task type="checkpoint:/.test(body);
|
|
105
|
+
const done = existsSync(join(dirname(file), `${basename(file).replace(/-PLAN\.md$/, "")}-SUMMARY.md`));
|
|
106
|
+
const files = strings(fm.files_modified).map((f) => f.replace(/^\.\//, ""));
|
|
107
|
+
assertWriteScope(file, `P${key}`, files, extractWriteDirectives(body, storedPath));
|
|
108
|
+
// fail closed (D-03): a silently dropped floor/pin routes the task cheap instead of erroring
|
|
109
|
+
let routingHints;
|
|
110
|
+
if (fm.routing !== undefined) {
|
|
111
|
+
if (!isPlainObject(fm.routing)) {
|
|
112
|
+
throw new CompileError(`${file} has a routing frontmatter key that must be an object with floor and/or pin`);
|
|
113
|
+
}
|
|
114
|
+
const hints = { source: basename(file) };
|
|
115
|
+
if (fm.routing.floor !== undefined) {
|
|
116
|
+
if (!TIERS.includes(fm.routing.floor)) {
|
|
117
|
+
throw new CompileError(`${file} has routing.floor "${fm.routing.floor}" — valid tiers are ${TIERS.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
hints.floor = fm.routing.floor;
|
|
120
|
+
}
|
|
121
|
+
if (fm.routing.pin !== undefined) {
|
|
122
|
+
const pin = fm.routing.pin;
|
|
123
|
+
const via = isPlainObject(pin) ? pin.via : undefined;
|
|
124
|
+
const model = isPlainObject(pin) ? pin.model : undefined;
|
|
125
|
+
if (typeof via !== "string" || typeof model !== "string") {
|
|
126
|
+
throw new CompileError(`${file} has a routing.pin missing a string "via" and/or "model"`);
|
|
127
|
+
}
|
|
128
|
+
hints.pin = { via, model };
|
|
129
|
+
}
|
|
130
|
+
if (hints.floor !== undefined || hints.pin !== undefined)
|
|
131
|
+
routingHints = hints;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
task: {
|
|
135
|
+
id: `P${key}`,
|
|
136
|
+
title,
|
|
137
|
+
goal: title,
|
|
138
|
+
// title only: files_modified nearly always lists test mirrors, which mis-shaped every plan as "tests"
|
|
139
|
+
shape: inferShape(title),
|
|
140
|
+
complexity: Math.max(1, Math.min(10, 2 * taskCount + truths.length)),
|
|
141
|
+
deps: strings(fm.depends_on).map((d) => sanitize(d)),
|
|
142
|
+
files,
|
|
143
|
+
context: [storedPath, ...refs],
|
|
144
|
+
acceptance,
|
|
145
|
+
...(humanGate ? { humanGate: true } : {}),
|
|
146
|
+
...(done ? { status: "done" } : {}),
|
|
147
|
+
...(routingHints ? { routingHints } : {}),
|
|
148
|
+
},
|
|
149
|
+
content,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export function compileGsd(src, root) {
|
|
153
|
+
if (!existsSync(src))
|
|
154
|
+
throw new CompileError(`${src} does not exist — point drovr compile at a GSD phase directory or a *-PLAN.md`);
|
|
155
|
+
const isDir = statSync(src).isDirectory();
|
|
156
|
+
const files = isDir
|
|
157
|
+
? readdirSync(src)
|
|
158
|
+
.filter((f) => f.endsWith(PLAN_SUFFIX))
|
|
159
|
+
.sort(byPlanOrder)
|
|
160
|
+
.map((f) => join(src, f))
|
|
161
|
+
: [src];
|
|
162
|
+
if (!files.length) {
|
|
163
|
+
throw new CompileError(`no *-PLAN.md files in ${src} — point drovr compile at a GSD phase directory`);
|
|
164
|
+
}
|
|
165
|
+
// context[0] must be readable from an isolated worktree: store repo-relative, never absolute
|
|
166
|
+
const rel = (f) => {
|
|
167
|
+
if (root && isAbsolute(f)) {
|
|
168
|
+
const r = relative(root, f);
|
|
169
|
+
if (!r.startsWith(".."))
|
|
170
|
+
return r;
|
|
171
|
+
}
|
|
172
|
+
return f;
|
|
173
|
+
};
|
|
174
|
+
const compiled = files.map((f) => compileOne(f, rel(f)));
|
|
175
|
+
const tasks = compiled.map((c) => c.task);
|
|
176
|
+
// deps reference plan keys; YAML may have stripped zero-padding ("06" → 6) — resolve via aliases.
|
|
177
|
+
// Phase compile: an unresolvable dep is a lost edge → fail closed. Single-plan compile: deps
|
|
178
|
+
// point at plans outside this graph (already executed by GSD) → drop them.
|
|
179
|
+
const byAlias = new Map();
|
|
180
|
+
for (const t of tasks) {
|
|
181
|
+
const key = t.id.slice(1);
|
|
182
|
+
byAlias.set(key, t.id);
|
|
183
|
+
byAlias.set(unpad(key), t.id);
|
|
184
|
+
}
|
|
185
|
+
if (isDir) {
|
|
186
|
+
const bareAliases = new Map();
|
|
187
|
+
const claimBareAlias = (alias, id) => {
|
|
188
|
+
const existing = bareAliases.get(alias);
|
|
189
|
+
if (existing === undefined && bareAliases.has(alias))
|
|
190
|
+
return;
|
|
191
|
+
if (existing !== undefined && existing !== id) {
|
|
192
|
+
bareAliases.set(alias, undefined);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
bareAliases.set(alias, id);
|
|
196
|
+
};
|
|
197
|
+
for (const t of tasks) {
|
|
198
|
+
const bare = t.id.slice(1).split("-").at(-1) ?? "";
|
|
199
|
+
claimBareAlias(bare, t.id);
|
|
200
|
+
claimBareAlias(unpad(bare), t.id);
|
|
201
|
+
}
|
|
202
|
+
for (const [alias, id] of bareAliases) {
|
|
203
|
+
if (id !== undefined && !byAlias.has(alias))
|
|
204
|
+
byAlias.set(alias, id);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [i, t] of tasks.entries()) {
|
|
208
|
+
t.deps = t.deps.flatMap((d) => {
|
|
209
|
+
const id = byAlias.get(d) ?? byAlias.get(unpad(d));
|
|
210
|
+
if (id)
|
|
211
|
+
return [id];
|
|
212
|
+
if (!isDir)
|
|
213
|
+
return []; // external to this single-plan graph
|
|
214
|
+
throw new CompileError(`${files[i]} depends on "${d}" but no such plan exists in ${src}`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return validateGraph({
|
|
218
|
+
version: 1,
|
|
219
|
+
spec: { source: "gsd", paths: files, hash: sha256(compiled.map((c) => c.content).join("\n")) },
|
|
220
|
+
tasks,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CompileError } from "./common.js";
|
|
4
|
+
import { compileGsd, isGsdPhaseDir } from "./gsd.js";
|
|
5
|
+
import { compileNative, NATIVE_MARKER } from "./native.js";
|
|
6
|
+
import { compilePrd } from "./prd.js";
|
|
7
|
+
import { compileSpecKit } from "./speckit.js";
|
|
8
|
+
function detect(src) {
|
|
9
|
+
if (existsSync(src) && statSync(src).isDirectory()) {
|
|
10
|
+
if (existsSync(join(src, "tasks.md")))
|
|
11
|
+
return "speckit";
|
|
12
|
+
if (isGsdPhaseDir(src))
|
|
13
|
+
return "gsd"; // gsd.ts owns the *-PLAN.md rule; unreadable dirs fall to null
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (src.endsWith("-PLAN.md"))
|
|
17
|
+
return "gsd"; // before the generic .md → prd rule
|
|
18
|
+
if (src.endsWith(".md"))
|
|
19
|
+
return existsSync(src) && NATIVE_MARKER.test(readFileSync(src, "utf8")) ? "native" : "prd";
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function compileSource(src, type, root) {
|
|
23
|
+
const kind = type ?? detect(src);
|
|
24
|
+
if (kind === "speckit")
|
|
25
|
+
return compileSpecKit(src);
|
|
26
|
+
if (kind === "gsd")
|
|
27
|
+
return compileGsd(src, root);
|
|
28
|
+
if (kind === "native")
|
|
29
|
+
return compileNative(src);
|
|
30
|
+
if (kind === "prd")
|
|
31
|
+
return compilePrd(src);
|
|
32
|
+
throw new CompileError(`cannot detect spec type for ${src} — pass a Spec Kit feature dir (with tasks.md), a GSD phase dir (with *-PLAN.md), or a marked native/generic PRD .md file, or use --type speckit|prd|gsd|native`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { GATE_NAMES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
3
|
+
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
4
|
+
export const NATIVE_MARKER = /^<!--\s*drovr:spec(?:\s+v1)?\s*-->\s*$/m;
|
|
5
|
+
const HEAD_RE = /^## (T\d+):\s*(.+)$/;
|
|
6
|
+
const FIELD_RE = /^- (\w+):\s*(.*)$/;
|
|
7
|
+
const NESTED_RE = /^\s+- (.+)$/;
|
|
8
|
+
// v1.19: a typed acceptance oracle line — "command: ...", "test: ...", or "judge: ...". Anything
|
|
9
|
+
// without one of these prefixes is a plain-string judge criterion (compat path, emits a warning).
|
|
10
|
+
const ORACLE_RE = new RegExp(`^(${ORACLES.join("|")}):\\s*(.*)$`);
|
|
11
|
+
const FIELDS = new Set(["goal", "shape", "deps", "files", "context", "complexity", "humangate", "pin", "floor", "gates", "acceptance"]);
|
|
12
|
+
function invalid(task, field, detail) {
|
|
13
|
+
throw new CompileError(`Task ${task} field "${field}" ${detail}`);
|
|
14
|
+
}
|
|
15
|
+
export function compileNative(file) {
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
throw new CompileError(`no such native spec file: ${file}`);
|
|
18
|
+
const content = readFileSync(file, "utf8");
|
|
19
|
+
const drafts = [];
|
|
20
|
+
let plainCount = 0; // v1.19: plain-string acceptance items compiled as judge oracles (compat) — warn once
|
|
21
|
+
for (const line of content.split("\n")) {
|
|
22
|
+
const heading = line.match(HEAD_RE);
|
|
23
|
+
if (heading) {
|
|
24
|
+
drafts.push({ id: heading[1], title: heading[2].trim(), fields: {}, acceptance: [], gates: [], hasGates: false, list: null });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const draft = drafts.at(-1);
|
|
28
|
+
if (!draft)
|
|
29
|
+
continue;
|
|
30
|
+
const field = line.match(FIELD_RE);
|
|
31
|
+
if (field) {
|
|
32
|
+
const name = field[1].toLowerCase();
|
|
33
|
+
if (!FIELDS.has(name))
|
|
34
|
+
invalid(draft.id, field[1], "is unknown");
|
|
35
|
+
const value = field[2].trim();
|
|
36
|
+
if (name === "acceptance" || name === "gates") {
|
|
37
|
+
if (value)
|
|
38
|
+
invalid(draft.id, field[1], "must be a nested list");
|
|
39
|
+
draft.list = name;
|
|
40
|
+
if (name === "gates")
|
|
41
|
+
draft.hasGates = true;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (name === "goal" && !value)
|
|
45
|
+
invalid(draft.id, field[1], "must not be empty");
|
|
46
|
+
draft.fields[name] = value;
|
|
47
|
+
draft.list = null;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const nested = line.match(NESTED_RE);
|
|
52
|
+
if (nested && draft.list) {
|
|
53
|
+
const value = nested[1].trim();
|
|
54
|
+
if (!value)
|
|
55
|
+
invalid(draft.id, draft.list, "must not contain empty entries");
|
|
56
|
+
// v1.19: only the acceptance list parses typed oracle prefixes; gates stay plain names.
|
|
57
|
+
if (draft.list === "acceptance") {
|
|
58
|
+
const typed = value.match(ORACLE_RE);
|
|
59
|
+
if (typed) {
|
|
60
|
+
const [, kind, body] = typed;
|
|
61
|
+
if (!body.trim())
|
|
62
|
+
invalid(draft.id, "acceptance", `${kind} oracle must carry a value`);
|
|
63
|
+
draft.acceptance.push(kind === "command" ? { oracle: "command", command: body.trim() }
|
|
64
|
+
: kind === "test" ? { oracle: "test", test: body.trim() }
|
|
65
|
+
: { oracle: "judge", text: body.trim() });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
plainCount++;
|
|
69
|
+
draft.acceptance.push(value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
draft[draft.list].push(value);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (line.startsWith("- "))
|
|
78
|
+
invalid(draft.id, "field", `bullet is malformed: ${JSON.stringify(line)}`);
|
|
79
|
+
if (line.trim() && !line.startsWith(" "))
|
|
80
|
+
draft.list = null;
|
|
81
|
+
}
|
|
82
|
+
if (!drafts.length) {
|
|
83
|
+
throw new CompileError(`${file} has no task sections. Expected "## T1: Title" headings with field bullets.`);
|
|
84
|
+
}
|
|
85
|
+
const missing = drafts.filter((draft) => !draft.acceptance.length).map((draft) => draft.id);
|
|
86
|
+
if (missing.length) {
|
|
87
|
+
throw new CompileError(`acceptance criteria are required on every task. Missing on: ${missing.join(", ")}.\n` +
|
|
88
|
+
`Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
|
|
89
|
+
}
|
|
90
|
+
const csv = (value) => (value && value.toLowerCase() !== "none" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
|
|
91
|
+
const tasks = drafts.map((draft) => {
|
|
92
|
+
const { fields } = draft;
|
|
93
|
+
const shape = fields.shape ?? inferShape(draft.title);
|
|
94
|
+
if (!SHAPES.includes(shape))
|
|
95
|
+
invalid(draft.id, "shape", `must be one of ${SHAPES.join(", ")}`);
|
|
96
|
+
const complexity = fields.complexity === undefined ? 5 : Number(fields.complexity);
|
|
97
|
+
if (!Number.isInteger(complexity) || complexity < 1 || complexity > 10)
|
|
98
|
+
invalid(draft.id, "complexity", "must be an integer from 1 to 10");
|
|
99
|
+
if (fields.humangate !== undefined && fields.humangate !== "true" && fields.humangate !== "false") {
|
|
100
|
+
invalid(draft.id, "humanGate", "must be literally true or false");
|
|
101
|
+
}
|
|
102
|
+
const pin = fields.pin?.split(/\s+/);
|
|
103
|
+
if (pin && (pin.length !== 2 || pin.some((part) => !part)))
|
|
104
|
+
invalid(draft.id, "pin", "must be exactly 'via model'");
|
|
105
|
+
if (fields.floor !== undefined && !TIERS.includes(fields.floor)) {
|
|
106
|
+
invalid(draft.id, "floor", `must be one of ${TIERS.join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
const badGate = draft.gates.find((gate) => !GATE_NAMES.includes(gate));
|
|
109
|
+
if (badGate)
|
|
110
|
+
invalid(draft.id, "gates", `contains invalid gate "${badGate}"`);
|
|
111
|
+
const routingHints = pin || fields.floor ? {
|
|
112
|
+
...(pin ? { pin: { via: pin[0], model: pin[1] } } : {}),
|
|
113
|
+
...(fields.floor ? { floor: fields.floor } : {}),
|
|
114
|
+
} : undefined;
|
|
115
|
+
return {
|
|
116
|
+
id: draft.id,
|
|
117
|
+
title: draft.title,
|
|
118
|
+
goal: fields.goal ?? draft.title,
|
|
119
|
+
shape,
|
|
120
|
+
complexity,
|
|
121
|
+
deps: csv(fields.deps),
|
|
122
|
+
files: csv(fields.files),
|
|
123
|
+
context: csv(fields.context),
|
|
124
|
+
acceptance: draft.acceptance,
|
|
125
|
+
...(fields.humangate === "true" ? { humanGate: true } : {}),
|
|
126
|
+
...(routingHints ? { routingHints } : {}),
|
|
127
|
+
...(draft.hasGates ? { gates: draft.gates } : {}),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const result = validateGraph({
|
|
131
|
+
version: 1,
|
|
132
|
+
spec: { source: "native", paths: [file], hash: sha256(content) },
|
|
133
|
+
tasks,
|
|
134
|
+
});
|
|
135
|
+
// v1.19 read-old/write-new: a plain-string acceptance item compiles as a judge oracle. This is the
|
|
136
|
+
// one-time nudge toward typed oracles (command/test/judge); PRD/Spec Kit/GSD stay silent (untouched).
|
|
137
|
+
if (plainCount > 0) {
|
|
138
|
+
console.warn(`drovr: ${plainCount} acceptance item${plainCount === 1 ? "" : "s"} in ${file} ${plainCount === 1 ? "is a plain string" : "are plain strings"} — compiled as judge oracle${plainCount === 1 ? "" : "s"}. Prefix with command:/test:/judge: to make the oracle explicit.`);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
// Commented native spec written to drovr.spec.md by `drovr init`. Documented via HTML comments (which the
|
|
143
|
+
// parser ignores) so the template itself round-trips through compileSource() unchanged. Every field is
|
|
144
|
+
// documented; the two example tasks illustrate plain vs deps+routing-hint, each with acceptance[].
|
|
145
|
+
export function specTemplate() {
|
|
146
|
+
return `<!-- drovr:spec -->
|
|
147
|
+
# drovr native spec
|
|
148
|
+
|
|
149
|
+
Your starting point for a drovr native spec. Edit this file, then run:
|
|
150
|
+
drovr compile drovr.spec.md && drovr plan && drovr run
|
|
151
|
+
|
|
152
|
+
Each task is a "## Tn: Title" heading with "- field: value" bullets.
|
|
153
|
+
acceptance is required on every task (a nested list of observable outcomes).
|
|
154
|
+
|
|
155
|
+
<!--
|
|
156
|
+
Fields available per task:
|
|
157
|
+
goal: outcome the task must achieve (defaults to the title if omitted)
|
|
158
|
+
shape: plan | spec | implement | tests | docs | migration | ui | refactor | chore
|
|
159
|
+
(auto-inferred from the title if omitted)
|
|
160
|
+
deps: comma-separated task ids this depends on, or "none"
|
|
161
|
+
files: comma-separated repo paths this task may touch
|
|
162
|
+
context: comma-separated paths the task should read for background
|
|
163
|
+
complexity: integer 1 to 10 (default 5)
|
|
164
|
+
humanGate: true | false — pauses for a human review before merging this task
|
|
165
|
+
pin: "via model" — pin an exact channel, e.g. "claude-code opus"
|
|
166
|
+
floor: cheap | mid | frontier — minimum capability tier for routing
|
|
167
|
+
gates: nested list, any of build | test | lint | evidence | scope | acceptance | review
|
|
168
|
+
acceptance: nested list (REQUIRED, non-empty). Each item is either a typed oracle or plain text:
|
|
169
|
+
- command: <shell> (oracle: command — exit code)
|
|
170
|
+
- test: <name> (oracle: test — named test)
|
|
171
|
+
- judge: <rubric> (oracle: judge — LLM-judged, free text)
|
|
172
|
+
- <plain text> (compat: compiles as judge oracle, warns)
|
|
173
|
+
-->
|
|
174
|
+
|
|
175
|
+
## T1: Scaffold the feature
|
|
176
|
+
- goal: Lay the groundwork for the feature
|
|
177
|
+
- shape: implement
|
|
178
|
+
- files: src/feature.ts
|
|
179
|
+
- context: docs/design.md
|
|
180
|
+
- complexity: 4
|
|
181
|
+
- acceptance:
|
|
182
|
+
- feature module exists and exports its entry point
|
|
183
|
+
- npm test stays green
|
|
184
|
+
|
|
185
|
+
## T2: Cover it with tests
|
|
186
|
+
- goal: Add tests for the feature
|
|
187
|
+
- shape: tests
|
|
188
|
+
- deps: T1
|
|
189
|
+
- files: tests/feature.test.ts
|
|
190
|
+
- complexity: 3
|
|
191
|
+
- floor: cheap
|
|
192
|
+
- acceptance:
|
|
193
|
+
- new tests pass
|
|
194
|
+
- coverage floor holds
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { validateGraph } from "../graph/schema.js";
|
|
3
|
+
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
4
|
+
const HEAD_RE = /^## (T\d+):\s*(.+)$/;
|
|
5
|
+
const FIELD_RE = /^- (\w+):\s*(.*)$/;
|
|
6
|
+
const NESTED_RE = /^\s+- (.+)$/;
|
|
7
|
+
export function compilePrd(file) {
|
|
8
|
+
if (!existsSync(file))
|
|
9
|
+
throw new CompileError(`no such PRD file: ${file}`);
|
|
10
|
+
const content = readFileSync(file, "utf8");
|
|
11
|
+
const drafts = [];
|
|
12
|
+
for (const line of content.split("\n")) {
|
|
13
|
+
const h = line.match(HEAD_RE);
|
|
14
|
+
if (h) {
|
|
15
|
+
drafts.push({ id: h[1], title: h[2].trim(), fields: {}, acceptance: [], inAcceptance: false });
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const d = drafts.at(-1);
|
|
19
|
+
if (!d)
|
|
20
|
+
continue;
|
|
21
|
+
const f = line.match(FIELD_RE);
|
|
22
|
+
if (f) {
|
|
23
|
+
d.inAcceptance = f[1].toLowerCase() === "acceptance";
|
|
24
|
+
if (!d.inAcceptance)
|
|
25
|
+
d.fields[f[1].toLowerCase()] = f[2].trim();
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const n = line.match(NESTED_RE);
|
|
29
|
+
if (n && d.inAcceptance)
|
|
30
|
+
d.acceptance.push(n[1].trim());
|
|
31
|
+
else if (line.trim() && !line.startsWith(" "))
|
|
32
|
+
d.inAcceptance = false;
|
|
33
|
+
}
|
|
34
|
+
if (!drafts.length) {
|
|
35
|
+
throw new CompileError(`${file} has no task sections. Expected "## T1: Title" headings with field bullets (goal/shape/deps/files/acceptance).`);
|
|
36
|
+
}
|
|
37
|
+
const missing = drafts.filter((d) => !d.acceptance.length).map((d) => d.id);
|
|
38
|
+
if (missing.length) {
|
|
39
|
+
throw new CompileError(`acceptance criteria are required on every task. Missing on: ${missing.join(", ")}.\n` +
|
|
40
|
+
`Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
|
|
41
|
+
}
|
|
42
|
+
const csv = (v) => (v && v.toLowerCase() !== "none" ? v.split(",").map((s) => s.trim()).filter(Boolean) : []);
|
|
43
|
+
const tasks = drafts.map((d) => {
|
|
44
|
+
const pin = d.fields.pin?.split(/\s+/);
|
|
45
|
+
return {
|
|
46
|
+
id: d.id,
|
|
47
|
+
title: d.title,
|
|
48
|
+
goal: d.fields.goal ?? d.title,
|
|
49
|
+
shape: d.fields.shape ?? inferShape(d.title),
|
|
50
|
+
complexity: d.fields.complexity ? Number(d.fields.complexity) : 5,
|
|
51
|
+
deps: csv(d.fields.deps),
|
|
52
|
+
files: csv(d.fields.files),
|
|
53
|
+
context: csv(d.fields.context),
|
|
54
|
+
acceptance: d.acceptance,
|
|
55
|
+
...(d.fields.humangate === "true" ? { humanGate: true } : {}),
|
|
56
|
+
...(pin && pin.length === 2 ? { routingHints: { pin: { via: pin[0], model: pin[1] } } } : {}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
return validateGraph({
|
|
60
|
+
version: 1,
|
|
61
|
+
spec: { source: "prd", paths: [file], hash: sha256(content) },
|
|
62
|
+
tasks,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { validateGraph } from "../graph/schema.js";
|
|
4
|
+
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
5
|
+
const TASK_RE = /^- \[( |x)\] (T\d+)\s*(\[P\])?\s*(.+)$/;
|
|
6
|
+
const SUB_RE = /^\s+- (\w+):\s*(.+)$/;
|
|
7
|
+
export function compileSpecKit(dir) {
|
|
8
|
+
const path = join(dir, "tasks.md");
|
|
9
|
+
if (!existsSync(path)) {
|
|
10
|
+
throw new CompileError(`no tasks.md in ${dir} — point drovr compile at a Spec Kit feature directory`);
|
|
11
|
+
}
|
|
12
|
+
const content = readFileSync(path, "utf8");
|
|
13
|
+
const drafts = [];
|
|
14
|
+
for (const line of content.split("\n")) {
|
|
15
|
+
const t = line.match(TASK_RE);
|
|
16
|
+
if (t) {
|
|
17
|
+
drafts.push({ id: t[2], title: t[4].trim(), parallel: !!t[3], done: t[1] === "x", fields: {} });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const s = line.match(SUB_RE);
|
|
21
|
+
if (s && drafts.length) {
|
|
22
|
+
const d = drafts[drafts.length - 1];
|
|
23
|
+
(d.fields[s[1].toLowerCase()] ??= []).push(s[2].trim());
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!drafts.length)
|
|
27
|
+
throw new CompileError(`${path} contains no task lines ("- [ ] T001 ...")`);
|
|
28
|
+
const missing = drafts.filter((d) => !d.fields.acceptance?.length).map((d) => d.id);
|
|
29
|
+
if (missing.length) {
|
|
30
|
+
throw new CompileError(`acceptance criteria are required on every task (spec rule). Missing on: ${missing.join(", ")}.\n` +
|
|
31
|
+
`Annotate each task in ${path} with an indented sub-bullet:\n - acceptance: <observable outcome>`);
|
|
32
|
+
}
|
|
33
|
+
// [P] semantics → dependency edges
|
|
34
|
+
let barrier = null;
|
|
35
|
+
let sinceBarrier = [];
|
|
36
|
+
const csv = (v) => (v ? v.flatMap((x) => x.split(",")).map((s) => s.trim()).filter(Boolean) : []);
|
|
37
|
+
const tasks = drafts.map((d) => {
|
|
38
|
+
const deps = d.parallel
|
|
39
|
+
? barrier ? [barrier] : []
|
|
40
|
+
: sinceBarrier.length ? [...sinceBarrier] : barrier ? [barrier] : [];
|
|
41
|
+
if (d.parallel) {
|
|
42
|
+
sinceBarrier.push(d.id);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
barrier = d.id;
|
|
46
|
+
sinceBarrier = [];
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
id: d.id,
|
|
50
|
+
title: d.title,
|
|
51
|
+
goal: d.fields.goal?.[0] ?? d.title,
|
|
52
|
+
shape: d.fields.shape?.[0] ?? inferShape(d.title),
|
|
53
|
+
complexity: d.fields.complexity ? Number(d.fields.complexity[0]) : 5,
|
|
54
|
+
deps,
|
|
55
|
+
files: csv(d.fields.files),
|
|
56
|
+
context: csv(d.fields.context),
|
|
57
|
+
acceptance: d.fields.acceptance,
|
|
58
|
+
...(d.done ? { status: "done" } : {}),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return validateGraph({
|
|
62
|
+
version: 1,
|
|
63
|
+
spec: { source: "speckit", paths: [path], hash: sha256(content) },
|
|
64
|
+
tasks,
|
|
65
|
+
});
|
|
66
|
+
}
|