@uchibeke/gallivant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/prd.mjs
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// The contract. It is the one gate: a brief that fails any rule cannot be marked ready and cannot start. There is no
|
|
2
|
+
// override flag. The rules are data so `ready --check` can print them as a checklist.
|
|
3
|
+
//
|
|
4
|
+
// `check` takes a Request (see lib/sources/) rather than a parsed file. Today the only Request comes from a PRD file,
|
|
5
|
+
// so every rule runs on every brief; the shape is what makes a second source possible without a second gate. One
|
|
6
|
+
// rule depends on where the brief lives rather than on what it says, and says so in its own data: `path` lists the
|
|
7
|
+
// source kinds it applies to. A rule with no `kinds` applies to every source.
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { HARNESSES, DEFAULT_HARNESS, parseContextPathsMeta, parseSourcesMeta } from "./config.mjs";
|
|
10
|
+
import { shortHash } from "./hash.mjs";
|
|
11
|
+
import { readText } from "./fs.mjs";
|
|
12
|
+
import { readRepositoryText } from "./repository_files.mjs";
|
|
13
|
+
import { OWNER_DELIVERY_SECTION, ownerDeliveryFromPrd } from "./owner_delivery.mjs";
|
|
14
|
+
|
|
15
|
+
export const RULES = Object.freeze([
|
|
16
|
+
{ id: "path", kinds: ["prd"], text: "lives under docs/prds/ with a lowercase-dash filename" },
|
|
17
|
+
{ id: "frontmatter", text: `frontmatter has status (draft|ready) and harness (${HARNESSES.join("|")})` },
|
|
18
|
+
{ id: "title", text: "one H1 title, 8 to 80 characters" },
|
|
19
|
+
{ id: "problem", text: "## Problem: at least 40 words on what is wrong today and for whom" },
|
|
20
|
+
{ id: "stories", text: "## User stories: every bullet reads 'As a <who>, I want <what>, so that <why>'" },
|
|
21
|
+
{ id: "criteria", text: "## Acceptance criteria: every bullet is Given / When / Then, at least one per story" },
|
|
22
|
+
{ id: "verify", text: "## Verify: at least one command the session must run green; no sudo, rm -rf or piped installers" },
|
|
23
|
+
{ id: "look", text: "## Where to look: files or directories to start from, or 'none'" },
|
|
24
|
+
{ id: "scope", text: "## Out of scope: at least one bullet" },
|
|
25
|
+
{ id: "placeholders", text: "no TODO, TBD, FIXME, ???, lorem or <angle-bracket> placeholders anywhere" },
|
|
26
|
+
{ id: "size", text: "under 1,500 words; a PRD is one pull request, split anything bigger" },
|
|
27
|
+
// Scoped to `prd` because it resolves paths against the checkout the brief lives in, and only a PRD source has one.
|
|
28
|
+
{ id: "sources", kinds: ["prd"], text: "every file in `sources` exists, is readable text, and is named inside the repository" },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** The source a Request came from, defaulting to the PRD file so anything built before the shape existed still works. */
|
|
32
|
+
export const kindOf = req => req?.source?.kind ?? "prd";
|
|
33
|
+
|
|
34
|
+
/** Whether a rule applies to a source kind. A rule with no `kinds` applies to all of them. */
|
|
35
|
+
export const appliesTo = (rule, kind) => !rule.kinds || rule.kinds.includes(kind);
|
|
36
|
+
|
|
37
|
+
/** The rules a source of this kind is held to, in the order `RULES` declares them. */
|
|
38
|
+
export const rulesFor = kind => RULES.filter(r => appliesTo(r, kind));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A PRD id: lowercase letters, digits and dashes, which is what the `path` rule accepts and therefore the only
|
|
42
|
+
* shape a brief's filename, its branch and its run folder can share. One implementation because there were three,
|
|
43
|
+
* in `commands/new.mjs`, `commands/register.mjs` and `run.mjs`, agreeing on every input anyone happened to try.
|
|
44
|
+
* That is the expensive kind of duplication: a fix to one is invisibly absent from the others. `max` is for the run
|
|
45
|
+
* folder, whose name is a filesystem path segment and so has a length to respect; a brief's id is not truncated,
|
|
46
|
+
* because silently shortening a name the operator typed makes the file they asked for not the file they get.
|
|
47
|
+
*/
|
|
48
|
+
export const toPrdId = (value, { max = 0 } = {}) => {
|
|
49
|
+
const slug = basename(String(value ?? ""), ".md").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
50
|
+
return max > 0 ? slug.slice(0, max) : slug;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const REQUIRED_SECTIONS = ["Problem", "User stories", "Acceptance criteria", "Verify", "Where to look", "Out of scope"];
|
|
54
|
+
|
|
55
|
+
const STORY = /^as an? .+?, i want .+?, so that .+/i;
|
|
56
|
+
const GWT = /\bgiven\b[\s\S]*\bwhen\b[\s\S]*\bthen\b/i;
|
|
57
|
+
// `<who>`, `<your repo>`: angle-bracketed words. Inline HTML a PRD might legitimately carry is not a placeholder.
|
|
58
|
+
const HTML_TAGS = new Set(["a", "b", "i", "em", "strong", "code", "pre", "kbd", "br", "p", "div", "span", "ul", "ol", "li", "table", "tr", "td", "th", "thead", "tbody", "details", "summary", "sub", "sup", "img", "hr", "blockquote"]);
|
|
59
|
+
const PLACEHOLDER = /\b(TODO|TBD|FIXME|lorem ipsum)\b|\?\?\?|<([a-z][a-z-]*(?: [a-z-]+)*)>/gi;
|
|
60
|
+
export function findPlaceholder(text) {
|
|
61
|
+
for (const m of String(text || "").matchAll(PLACEHOLDER)) {
|
|
62
|
+
if (m[2] !== undefined && HTML_TAGS.has(m[2].toLowerCase())) continue;
|
|
63
|
+
return m[0];
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const DANGEROUS = /\bsudo\b|rm\s+-rf|curl[^|\n]*\|\s*(ba)?sh|wget[^|\n]*\|\s*(ba)?sh/i;
|
|
68
|
+
export const MAX_WORDS = 1500;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `\r?\n` throughout, because a brief saved by an editor on Windows or touched by a `.gitattributes` rule has CRLF line
|
|
72
|
+
* endings and is the same brief. It used to match `\n` only, which meant a CRLF copy parsed as having NO frontmatter at
|
|
73
|
+
* all: `status` and `harness` fell back to their defaults and `ready` refused the file for a frontmatter block that was
|
|
74
|
+
* right there. That was survivable while `ready` was the only reader, because the operator was standing in front of it.
|
|
75
|
+
* It stopped being survivable when the brief started being compared against a running entry's snapshot: a line-ending
|
|
76
|
+
* rewrite read as the harness changing and the frontmatter rule failing, so a healthy run stopped and blamed the wrong
|
|
77
|
+
* thing entirely.
|
|
78
|
+
*/
|
|
79
|
+
export function parseFrontmatter(text) {
|
|
80
|
+
const m = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
81
|
+
if (!m) return { meta: {}, body: text, hasFrontmatter: false, raw: null };
|
|
82
|
+
const meta = {};
|
|
83
|
+
// One key per line, plus the block-list shape, because that is how a person writes a list of files:
|
|
84
|
+
//
|
|
85
|
+
// sources:
|
|
86
|
+
// - docs/a.md
|
|
87
|
+
// - docs/b.md
|
|
88
|
+
//
|
|
89
|
+
// Without the second half, `sources:` reads as the empty string and every path is silently dropped, which is worse
|
|
90
|
+
// than refusing the brief: the operator's list is gone and nothing says so. A key whose value is empty and whose
|
|
91
|
+
// following lines are `- ` items collects them into an array; anything else keeps the old one-line behaviour, so
|
|
92
|
+
// `context_paths: [a, b]` and every existing brief parse exactly as before.
|
|
93
|
+
const lines = m[1].split(/\r?\n/);
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const kv = lines[i].match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
96
|
+
if (!kv) continue;
|
|
97
|
+
const value = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
98
|
+
if (value === "") {
|
|
99
|
+
const items = [];
|
|
100
|
+
while (i + 1 < lines.length) {
|
|
101
|
+
const item = lines[i + 1].match(/^\s+-\s*(.*)$/);
|
|
102
|
+
if (!item) break;
|
|
103
|
+
const one = item[1].trim().replace(/^["']|["']$/g, "");
|
|
104
|
+
if (one) items.push(one);
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
if (items.length) { meta[kv[1]] = items; continue; }
|
|
108
|
+
}
|
|
109
|
+
meta[kv[1]] = value;
|
|
110
|
+
}
|
|
111
|
+
return { meta, body: text.slice(m[0].length), hasFrontmatter: true, raw: m[1] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One fence grammar for the whole file: backtick or tilde fences of three or more, any info string. */
|
|
115
|
+
const FENCE = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)^\1[ \t]*$/gm;
|
|
116
|
+
|
|
117
|
+
/** The body with fenced code blocks blanked out (line count preserved), so `#` lines inside them are not headings. */
|
|
118
|
+
export const withoutFences = body => body.replace(FENCE, m => m.split("\n").map(() => "").join("\n"));
|
|
119
|
+
|
|
120
|
+
/** The contents of every fenced block, in order. */
|
|
121
|
+
export const fencedBlocks = text => [...String(text || "").matchAll(FENCE)].map(m => m[2].replace(/\n$/, ""));
|
|
122
|
+
|
|
123
|
+
/** Headings and the text under each, so a check can tell an empty section from a missing one. */
|
|
124
|
+
export function sections(body) {
|
|
125
|
+
const out = {};
|
|
126
|
+
let current = null;
|
|
127
|
+
const masked = withoutFences(body).split("\n");
|
|
128
|
+
const lines = body.split("\n");
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const h = masked[i].match(/^##\s+(.+?)\s*$/);
|
|
131
|
+
if (h) { current = h[1]; out[current] = ""; continue; }
|
|
132
|
+
if (current !== null) out[current] += lines[i] + "\n";
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const bullets = text => (text || "").split("\n").filter(l => /^\s*[-*]\s+\S/.test(l)).map(l => l.replace(/^\s*[-*]\s+/, "").trim());
|
|
138
|
+
|
|
139
|
+
/** Bullets that span continuation lines (a Given on one line, When and Then indented below) joined into one. */
|
|
140
|
+
export function items(text) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const line of (text || "").split("\n")) {
|
|
143
|
+
if (/^\s*[-*]\s+\S/.test(line)) out.push(line.replace(/^\s*[-*]\s+/, "").trim());
|
|
144
|
+
else if (out.length && /^\s+\S/.test(line)) out[out.length - 1] += " " + line.trim();
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A PRD file as a Request. Kept as the PRD source's parser and as the shape the tick's snapshot reader builds, so
|
|
151
|
+
* everything that took a parsed PRD before the Request existed still works: a parsed PRD is a Request with
|
|
152
|
+
* `source.kind` "prd". `text` stays alongside `raw` because the prompts and the tick read it by that name.
|
|
153
|
+
*/
|
|
154
|
+
export function parsePrd(text, path) {
|
|
155
|
+
const { meta, body, hasFrontmatter } = parseFrontmatter(text);
|
|
156
|
+
const titles = [...withoutFences(body).matchAll(/^#\s+(.+?)\s*$/gm)].map(m => m[1]);
|
|
157
|
+
return {
|
|
158
|
+
path,
|
|
159
|
+
repo: null,
|
|
160
|
+
id: basename(path).replace(/\.md$/, ""),
|
|
161
|
+
status: meta.status || "draft",
|
|
162
|
+
harness: meta.harness || DEFAULT_HARNESS,
|
|
163
|
+
hasFrontmatter,
|
|
164
|
+
meta,
|
|
165
|
+
title: titles[0] || null,
|
|
166
|
+
titles,
|
|
167
|
+
body,
|
|
168
|
+
sections: sections(body),
|
|
169
|
+
text,
|
|
170
|
+
raw: text,
|
|
171
|
+
source: { kind: "prd", url: null, author: null },
|
|
172
|
+
words: body.split(/\s+/).filter(Boolean).length,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `[{ id: "ac-1", description }]` from the Acceptance criteria bullets, in order, ids stable by position.
|
|
178
|
+
*
|
|
179
|
+
* It lived in `deliverable.mjs` while the completion gate was its only reader, and it still carries the gate's own id
|
|
180
|
+
* shape because the receipt on every open PR names those ids. It is here now because the divergence check below
|
|
181
|
+
* compares this list and `verifyCommands` and nothing else, and those two ARE the contract: one of them cannot live in
|
|
182
|
+
* a module that imports this one. `deliverable.mjs` re-exports it, so every caller keeps the import it had.
|
|
183
|
+
*/
|
|
184
|
+
export const criteriaFromPrd = prd => items(prd.sections["Acceptance criteria"]).map((description, i) => ({ id: `ac-${i + 1}`, description }));
|
|
185
|
+
|
|
186
|
+
/** The `Verify` section as shell commands, from code blocks or backticked bullets. Takes any Request. */
|
|
187
|
+
export function verifyCommands(prd) {
|
|
188
|
+
const v = prd.sections["Verify"] || "";
|
|
189
|
+
const fromBlocks = fencedBlocks(v).flatMap(b => b.split("\n"));
|
|
190
|
+
const fromBullets = bullets(v).map(b => b.replace(/^`|`$/g, ""));
|
|
191
|
+
return [...fromBlocks, ...fromBullets].map(s => s.trim()).filter(s => s && !s.startsWith("#") && s.toLowerCase() !== "none");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every rule that applies to this Request's source, with its verdict. `ok` is true only when all pass. Details are
|
|
196
|
+
* written so the operator knows what to change, not just which rule fired. A rule this source cannot be held to is
|
|
197
|
+
* not reported at all, rather than reported as a failure it can do nothing about.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* The files a brief carries, as a list of repository-relative paths. One accessor so `start`, the prompt and the
|
|
201
|
+
* contract rule can never disagree about what a brief declared: the rule below refuses anything this returns that
|
|
202
|
+
* cannot be read, so every caller after it may assume the list is legal.
|
|
203
|
+
*/
|
|
204
|
+
export const sourcePathsOf = prd => parseSourcesMeta(prd?.meta?.sources).paths;
|
|
205
|
+
|
|
206
|
+
export function check(prd) {
|
|
207
|
+
const s = prd.sections;
|
|
208
|
+
const kind = kindOf(prd);
|
|
209
|
+
const has = name => name in s;
|
|
210
|
+
const results = [];
|
|
211
|
+
const rule = (id, ok, detail = "") => {
|
|
212
|
+
const r = RULES.find(x => x.id === id);
|
|
213
|
+
if (appliesTo(r, kind)) results.push({ id, text: r.text, ok, detail });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (typeof prd.path === "string") {
|
|
217
|
+
const posix = prd.path.replace(/\\/g, "/");
|
|
218
|
+
rule("path", /(^|\/)docs\/prds\/[a-z0-9][a-z0-9-]*\.md$/.test(posix),
|
|
219
|
+
/docs\/prds\//.test(posix) ? `filename "${basename(prd.path)}" must be lowercase letters, digits and dashes` : "move it to docs/prds/<id>.md in the target repo");
|
|
220
|
+
} else {
|
|
221
|
+
rule("path", false, "no path on this request; the PRD source supplies one");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const statusOk = ["draft", "ready"].includes(prd.meta.status);
|
|
225
|
+
const harnessOk = !("harness" in prd.meta) || HARNESSES.includes(prd.meta.harness);
|
|
226
|
+
rule("frontmatter", prd.hasFrontmatter && statusOk && harnessOk,
|
|
227
|
+
!prd.hasFrontmatter ? "add a --- block with status: draft and harness: claude" : !statusOk ? `status "${prd.meta.status}" must be draft or ready` : `harness "${prd.meta.harness}" must be one of ${HARNESSES.join(", ")}`);
|
|
228
|
+
|
|
229
|
+
rule("title", prd.titles.length === 1 && prd.title.length >= 8 && prd.title.length <= 80,
|
|
230
|
+
prd.titles.length !== 1 ? `${prd.titles.length} H1 headings; exactly one` : `title is ${prd.title.length} characters`);
|
|
231
|
+
|
|
232
|
+
const problemWords = (s["Problem"] || "").split(/\s+/).filter(Boolean).length;
|
|
233
|
+
rule("problem", has("Problem") && problemWords >= 40, !has("Problem") ? "section missing" : `${problemWords} words; say what is wrong today, for whom, and what changes when this ships`);
|
|
234
|
+
|
|
235
|
+
const stories = items(s["User stories"]);
|
|
236
|
+
const badStories = stories.filter(x => !STORY.test(x));
|
|
237
|
+
rule("stories", has("User stories") && stories.length >= 1 && badStories.length === 0,
|
|
238
|
+
!has("User stories") ? "section missing" : stories.length === 0 ? "no bullets" : `${badStories.length} bullet(s) do not follow the form: "${badStories[0]?.slice(0, 60)}"`);
|
|
239
|
+
|
|
240
|
+
const criteria = items(s["Acceptance criteria"]);
|
|
241
|
+
const badCriteria = criteria.filter(x => !GWT.test(x));
|
|
242
|
+
let ownershipError = ""; try { ownerDeliveryFromPrd(prd); } catch (error) { ownershipError = error.message; }
|
|
243
|
+
rule("criteria", has("Acceptance criteria") && criteria.length >= Math.max(1, stories.length) && badCriteria.length === 0 && !ownershipError,
|
|
244
|
+
ownershipError || (!has("Acceptance criteria") ? "section missing" : criteria.length < Math.max(1, stories.length) ? `${criteria.length} criteria for ${stories.length} stories; at least one each` : `${badCriteria.length} bullet(s) lack Given/When/Then: "${badCriteria[0]?.slice(0, 60)}"`));
|
|
245
|
+
|
|
246
|
+
const verify = has("Verify") ? verifyCommands(prd) : [];
|
|
247
|
+
const dangerous = verify.filter(c => DANGEROUS.test(c));
|
|
248
|
+
rule("verify", has("Verify") && verify.length >= 1 && dangerous.length === 0,
|
|
249
|
+
!has("Verify") ? "section missing" : verify.length === 0 ? "list at least one command in backticks or a code block" : `refusing: "${dangerous[0]}"`);
|
|
250
|
+
|
|
251
|
+
const look = bullets(s["Where to look"]);
|
|
252
|
+
rule("look", has("Where to look") && look.length >= 1, !has("Where to look") ? "section missing" : "list files or directories to start from, or a single bullet: none");
|
|
253
|
+
|
|
254
|
+
rule("scope", has("Out of scope") && bullets(s["Out of scope"]).length >= 1, !has("Out of scope") ? "section missing" : "at least one bullet naming what this PRD will not do");
|
|
255
|
+
|
|
256
|
+
const ph = findPlaceholder(prd.body);
|
|
257
|
+
rule("placeholders", !ph, ph ? `found "${ph}"` : "");
|
|
258
|
+
|
|
259
|
+
rule("size", prd.words <= MAX_WORDS, `${prd.words} words`);
|
|
260
|
+
|
|
261
|
+
// The files the brief carries. Checked here rather than at `start` for the reason `resolveBase` validates the base
|
|
262
|
+
// early: a mistyped path costs a second at the operator's terminal and thirty minutes inside a paid session. The read
|
|
263
|
+
// is what makes it worth checking at all, since a path that parses and does not exist is the common mistake.
|
|
264
|
+
const { paths: sourcePaths, problems: sourceProblems } = parseSourcesMeta(prd.meta?.sources);
|
|
265
|
+
if (sourcePaths.length || sourceProblems.length) {
|
|
266
|
+
const missing = [];
|
|
267
|
+
// Paths are relative to the repository root, and the brief's own path gives it: the `path` rule above requires
|
|
268
|
+
// `docs/prds/<id>.md`, so the root is two directories up. `prd.checkout` is the same answer when a Request has one,
|
|
269
|
+
// but `gallivant ready` deliberately builds a Request without git access (see lib/sources/prd.mjs), and a rule that
|
|
270
|
+
// could only run under the daemon would miss the moment the operator is standing there to fix it.
|
|
271
|
+
const root = prd.checkout ?? (typeof prd.path === "string" ? resolve(dirname(prd.path), "..", "..") : null);
|
|
272
|
+
for (const rel of root ? sourcePaths : []) {
|
|
273
|
+
try {
|
|
274
|
+
const text = readRepositoryText(root, rel);
|
|
275
|
+
if (text.includes("\u0000")) missing.push(`${rel} is not text`);
|
|
276
|
+
} catch (e) { missing.push(`${rel} is missing or unreadable: ${e.message}`); }
|
|
277
|
+
}
|
|
278
|
+
const detail = [...sourceProblems, ...missing];
|
|
279
|
+
if (!root && sourcePaths.length) detail.push("no path on this request, so the files could not be resolved");
|
|
280
|
+
rule("sources", detail.length === 0, detail.join("; "));
|
|
281
|
+
} else {
|
|
282
|
+
rule("sources", true, "none named");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { ok: results.every(r => r.ok), results };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function renderCheck(prd, { ok, results }) {
|
|
289
|
+
const lines = results.map(r => `${r.ok ? "ok " : "FAIL"} ${r.id.padEnd(13)} ${r.ok ? r.text : `${r.text}\n ${r.detail}`}`);
|
|
290
|
+
lines.push("", ok ? `${prd.id}: ready to start.` : `${prd.id}: ${results.filter(r => !r.ok).length} rule(s) failed; not ready.`);
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Set or replace one frontmatter key, creating the block when the file has none. Rebuilds from the parsed block. */
|
|
295
|
+
export function withFrontmatterKey(text, key, value) {
|
|
296
|
+
const { hasFrontmatter, raw, body } = parseFrontmatter(text);
|
|
297
|
+
if (!hasFrontmatter) return `---\n${key}: ${value}\n---\n\n${text}`;
|
|
298
|
+
const re = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:.*$`, "m");
|
|
299
|
+
const next = re.test(raw) ? raw.replace(re, `${key}: ${value}`) : `${raw}\n${key}: ${value}`;
|
|
300
|
+
return `---\n${next}\n---\n${body}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Flip status in the frontmatter, adding a default harness when the file had no frontmatter at all. */
|
|
304
|
+
export function withStatus(text, status) {
|
|
305
|
+
const { hasFrontmatter } = parseFrontmatter(text);
|
|
306
|
+
const base = hasFrontmatter ? text : withFrontmatterKey(text, "harness", DEFAULT_HARNESS);
|
|
307
|
+
return withFrontmatterKey(base, "status", status);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- The brief's contract, and what it means for it to change under a running entry --------------------------------
|
|
311
|
+
//
|
|
312
|
+
// `start` snapshots the brief into ~/.gallivant/prds/<id>.md and the completion gate reads only that, so a session
|
|
313
|
+
// cannot widen its own acceptance criteria. That stays. What was missing is that nothing compared the snapshot to the
|
|
314
|
+
// operator's own copy, so an operator who edited `docs/prds/thing.md` after starting got a PR certified against a brief
|
|
315
|
+
// they had abandoned, silently.
|
|
316
|
+
//
|
|
317
|
+
// The comparison is expressed in the contract's own terms and nothing else: `criteriaFromPrd` and `verifyCommands` are
|
|
318
|
+
// what the gate judges and what the prompts carry, and `context_paths` is what the snapshot's frontmatter grants a
|
|
319
|
+
// session to read. A brief where all three match is a brief the work in flight is still against, whatever else moved.
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The parts of a brief that decide what the work is and what the gate will judge. Everything else about the file
|
|
323
|
+
* (the Problem prose, the stories, Out of scope, the title, the word count) can be rewritten without changing what
|
|
324
|
+
* anyone is being asked to build or what will be checked, which is the whole reason this is a list and not the file.
|
|
325
|
+
*
|
|
326
|
+
* `harness` is in it because a brief that names a different harness cannot be honoured by a running entry at all: the
|
|
327
|
+
* session belongs to the old one. `context_paths` is in it because `resolveContextPaths` reads it off the snapshot to
|
|
328
|
+
* decide what a fix round may read, so changing it changes the session's reach, which is a contract change even though
|
|
329
|
+
* no criterion moved.
|
|
330
|
+
*/
|
|
331
|
+
export const contractOf = prd => ({
|
|
332
|
+
// Typed stage ownership changes what provider receipts must attest. Preserve
|
|
333
|
+
// legacy fingerprints exactly; new contracts include even malformed edits.
|
|
334
|
+
...(prd.sections[OWNER_DELIVERY_SECTION] !== undefined ? { ownerDelivery: prd.sections[OWNER_DELIVERY_SECTION].replaceAll("\r\n", "\n") } : {}),
|
|
335
|
+
// `id: description`, not the descriptions alone, and the id is the load-bearing half. `criteriaFromPrd` numbers
|
|
336
|
+
// criteria BY POSITION (`ac-1`, `ac-2`), and the completion receipt on the open pull request attests those ids: an
|
|
337
|
+
// attestation says `ac-1` is met, with evidence, and the gate matches it to whatever the brief's first criterion is
|
|
338
|
+
// NOW. So reordering two criteria without touching a word of either one swaps what both ids mean, and the receipt
|
|
339
|
+
// already on the PR then attests each criterion with the other's evidence, which the gate allows. Compared by
|
|
340
|
+
// description alone, that edit looked like no change at all.
|
|
341
|
+
criteria: criteriaFromPrd(prd).map(c => `${c.id}: ${c.description}`),
|
|
342
|
+
verify: verifyCommands(prd),
|
|
343
|
+
harness: prd.harness ?? null,
|
|
344
|
+
context_paths: parseContextPathsMeta(prd.meta?.context_paths),
|
|
345
|
+
// The files the brief carries, on the same reasoning as `context_paths` one line up: changing the list changes what
|
|
346
|
+
// the session is given, so it is a contract change even though no criterion moved. Adding a file hands the session a
|
|
347
|
+
// document it was not judged against; removing one takes away something a criterion may depend on.
|
|
348
|
+
//
|
|
349
|
+
// The list, not the contents. A carried file's bytes are compared separately by `sourceDivergence`, because a diff of
|
|
350
|
+
// a document belongs next to that document and not in a summary of the brief's contract.
|
|
351
|
+
sources: sourcePathsOf(prd),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Twelve hex characters over the contract, through the one `shortHash` the receipt cursor already uses. Stored on the
|
|
356
|
+
* entry at snapshot time so a later comparison is a string equality rather than a re-parse of two files, and so
|
|
357
|
+
* `status` can say "the brief moved" without reading the operator's tree at all when it is not there to read.
|
|
358
|
+
*
|
|
359
|
+
* The fingerprint is of the CONTRACT and not of the text, deliberately. A fingerprint of the file would fire on a
|
|
360
|
+
* typo fix, and an entry stopped for a typo fix is an entry whose operator learns to ignore this.
|
|
361
|
+
*/
|
|
362
|
+
export const prdFingerprint = prd => (prd ? shortHash(JSON.stringify(contractOf(prd))) : null);
|
|
363
|
+
|
|
364
|
+
/** The `aport_decision` the brief carries, which `ready` writes when it flips a brief to ready. */
|
|
365
|
+
export const prdDecision = prd => prd?.meta?.aport_decision ?? null;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether two briefs have the same contract. The fingerprint is the authority on this, and `contractDiff` below is only
|
|
369
|
+
* the explanation of it.
|
|
370
|
+
*
|
|
371
|
+
* Keeping the two jobs apart is not tidiness, it is the fix for a whole class of false negative. A diff has to describe
|
|
372
|
+
* a change in a way a person can act on, which means grouping and set arithmetic, and set arithmetic loses information:
|
|
373
|
+
* it cannot see a duplicate. Add a second `- \`npm test\`` to the Verify section and every element of both lists is
|
|
374
|
+
* present in the other, so a set-based diff is empty while the list the session must run green has changed. The reorder
|
|
375
|
+
* case was the same shape, and it got a targeted fix (the ids in `contractOf`); a targeted fix per shape is a bug per
|
|
376
|
+
* shape nobody has thought of yet. So the verdict asks "is this the same contract", which is exactly what a hash of the
|
|
377
|
+
* whole ordered structure answers, and the diff is allowed to be lossy because nothing decides anything on it.
|
|
378
|
+
*/
|
|
379
|
+
export const sameContract = (before, after) => prdFingerprint(before) === prdFingerprint(after);
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* What changed between two briefs, in contract terms, as text an operator can act on.
|
|
383
|
+
*
|
|
384
|
+
* Each line names the specific difference rather than saying "the criteria changed", because the operator's next
|
|
385
|
+
* question is always which one, and the answer is cheap to compute here and expensive to go and find by hand.
|
|
386
|
+
*
|
|
387
|
+
* It is NOT what decides whether the contract moved: see `sameContract`. It can be empty for a contract that really did
|
|
388
|
+
* change (a duplicated Verify command is the case that proves it), which is why every caller that needs a verdict takes
|
|
389
|
+
* it from the fingerprint and uses this only to explain one. When it has nothing to say about a contract that moved, the
|
|
390
|
+
* caller says so in as many words rather than reporting no difference.
|
|
391
|
+
*/
|
|
392
|
+
export function contractDiff(before, after) {
|
|
393
|
+
const a = contractOf(before);
|
|
394
|
+
const b = contractOf(after);
|
|
395
|
+
const out = [];
|
|
396
|
+
if (a.ownerDelivery !== b.ownerDelivery) out.push("coordinator delivery ownership contract changed");
|
|
397
|
+
// Multiplicity-aware, so a duplicate that IS reported is reported once per copy rather than swallowed. It still cannot
|
|
398
|
+
// describe every change on its own, which is `sameContract`'s job; this only makes the description less lossy.
|
|
399
|
+
const listDiff = (was, now) => {
|
|
400
|
+
const count = xs => xs.reduce((m, x) => m.set(x, (m.get(x) ?? 0) + 1), new Map());
|
|
401
|
+
const [ca, cb] = [count(was), count(now)];
|
|
402
|
+
const gone = [], added = [];
|
|
403
|
+
for (const [x, n] of ca) for (let i = 0; i < n - (cb.get(x) ?? 0); i++) gone.push(x);
|
|
404
|
+
for (const [x, n] of cb) for (let i = 0; i < n - (ca.get(x) ?? 0); i++) added.push(x);
|
|
405
|
+
return { gone, added };
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// The criteria carry their positional ids, so a reorder shows up here as the two ids each gaining the other's text,
|
|
409
|
+
// which is exactly what happened and exactly what breaks a receipt that has already attested them. `id: text` is left
|
|
410
|
+
// in the line rather than stripped, because the id is what the receipt on the pull request names and therefore what
|
|
411
|
+
// the operator has to reason about.
|
|
412
|
+
const crit = listDiff(a.criteria, b.criteria);
|
|
413
|
+
for (const c of crit.gone) out.push(`acceptance criterion removed, reworded or renumbered: "${c}"`);
|
|
414
|
+
for (const c of crit.added) out.push(`acceptance criterion added, reworded or renumbered: "${c}"`);
|
|
415
|
+
const ver = listDiff(a.verify, b.verify);
|
|
416
|
+
for (const c of ver.gone) out.push(`Verify command removed: \`${c}\``);
|
|
417
|
+
for (const c of ver.added) out.push(`Verify command added: \`${c}\``);
|
|
418
|
+
if (a.harness !== b.harness) out.push(`harness changed from ${a.harness} to ${b.harness}; the session in flight belongs to ${a.harness}`);
|
|
419
|
+
const ctxp = listDiff(a.context_paths, b.context_paths);
|
|
420
|
+
for (const p of ctxp.gone) out.push(`context_paths entry removed: ${p}`);
|
|
421
|
+
for (const p of ctxp.added) out.push(`context_paths entry added: ${p}`);
|
|
422
|
+
const sources = listDiff(a.sources, b.sources);
|
|
423
|
+
for (const p of sources.gone) out.push(`source removed: ${p}`);
|
|
424
|
+
for (const p of sources.added) out.push(`source added: ${p}`);
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Compare the daemon's snapshot with the brief in the operator's own checkout, and say what should happen.
|
|
430
|
+
*
|
|
431
|
+
* `verdict` is one of four, and each of them is a different action because guessing between them is expensive in both
|
|
432
|
+
* directions:
|
|
433
|
+
*
|
|
434
|
+
* - `same`: the files are byte-identical, or they differ only outside the contract. Nothing has to happen. The caller
|
|
435
|
+
* may re-snapshot so the session's own reads follow the operator's prose edits, and no human is told, because
|
|
436
|
+
* stopping an entry for a reworded Problem section throws away paid work for nothing.
|
|
437
|
+
* - `changed`: the contract moved. The criteria set, a Verify command, the harness or `context_paths` is not what the
|
|
438
|
+
* work in flight was started against. The daemon cannot tell a brief that was TIGHTENED (a criterion added; the work
|
|
439
|
+
* is valid but incomplete) from one that was INVALIDATED (a criterion rewritten so what was built is no longer
|
|
440
|
+
* wanted), and it must not guess: finishing against the old brief certifies something nobody asked for, and
|
|
441
|
+
* restarting against the new one throws away work that may still be exactly right. So a person decides, with the
|
|
442
|
+
* difference named.
|
|
443
|
+
* - `illegal`: the operator's copy no longer passes the contract `start` holds every brief to. There is nothing safe to
|
|
444
|
+
* re-snapshot, because a snapshot is what the gate judges and an illegal brief behind the gate is worse than a stale
|
|
445
|
+
* one. Reported whether or not the contract itself moved: an edit that leaves the criteria alone and introduces a
|
|
446
|
+
* `TODO` is still a brief `start` would have refused.
|
|
447
|
+
* - `gone`: the brief is not in the operator's checkout any more, deleted or moved. Not a silent continue: a brief the
|
|
448
|
+
* operator deleted is a brief nobody is asking for. Not a re-snapshot either, since there is nothing to snapshot.
|
|
449
|
+
*
|
|
450
|
+
* `unknown` is the fifth answer and is not a verdict at all: no snapshot, no recorded checkout, or a checkout that is
|
|
451
|
+
* not on this machine. It means "no comparison was possible", and every caller treats it as "carry on", because an
|
|
452
|
+
* entry recovered from GitHub by `reconcile` never saw the operator's tree and must not be stopped for that.
|
|
453
|
+
*
|
|
454
|
+
* Pure but for one read: `readText` on the operator's file. It takes `readPrd` and `readText` from the caller so the
|
|
455
|
+
* whole thing runs under `node --test` with no filesystem.
|
|
456
|
+
*/
|
|
457
|
+
/**
|
|
458
|
+
* Which carried files have changed since the snapshot, as a list of relative paths.
|
|
459
|
+
*
|
|
460
|
+
* Separate from `prdDivergence` because the two answer different questions and a caller wants both. The brief's own
|
|
461
|
+
* divergence has four verdicts, because a brief can stop passing the contract; a carried document has two states, the
|
|
462
|
+
* snapshot's bytes or not, since nothing validates its shape. Folding them together would mean inventing a verdict for
|
|
463
|
+
* "the brief is fine and a document it cites moved", which is the case this returns plainly instead.
|
|
464
|
+
*
|
|
465
|
+
* The comparison is on bytes, not on a fingerprint, for the same reason `prdDivergence` compares text before parsing:
|
|
466
|
+
* there is nothing to parse, and a hash would only save reading a file that has to be read anyway to report the change.
|
|
467
|
+
*
|
|
468
|
+
* `missing` is kept apart from `changed` because they need different words from the operator: a file that moved is a
|
|
469
|
+
* decision about which version is the brief, and a file that is gone is a question about whether the work still stands.
|
|
470
|
+
*/
|
|
471
|
+
export function sourceDivergence({ id, entry, readSource, read = null }) {
|
|
472
|
+
const rel = sourcePathsOf({ meta: { sources: entry?.sources } });
|
|
473
|
+
const checkout = entry?.checkout ?? null;
|
|
474
|
+
if (!rel.length || !checkout || !readSource) return { changed: [], missing: [], checked: [] };
|
|
475
|
+
const changed = [];
|
|
476
|
+
const missing = [];
|
|
477
|
+
for (const one of rel) {
|
|
478
|
+
const snapshot = readSource(id, one);
|
|
479
|
+
if (snapshot == null) { missing.push(one); continue; }
|
|
480
|
+
let now = null;
|
|
481
|
+
try { now = read ? read(join(checkout, one)) : readRepositoryText(checkout, one); } catch { /* missing or unsafe */ }
|
|
482
|
+
if (now === null) missing.push(one);
|
|
483
|
+
else if (now !== snapshot) changed.push(one);
|
|
484
|
+
}
|
|
485
|
+
return { changed, missing, checked: rel };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export const sourceDiff = d => [...d.changed.map(p => `source changed: ${p}`), ...d.missing.map(p => `source missing or unreadable: ${p}`)];
|
|
489
|
+
|
|
490
|
+
/** The readiness and status views must compare the same brief, including its supporting files. */
|
|
491
|
+
export function briefDivergence(args) {
|
|
492
|
+
const prd = prdDivergence(args);
|
|
493
|
+
const diff = sourceDiff(sourceDivergence(args));
|
|
494
|
+
if (!diff.length) return prd;
|
|
495
|
+
if (divergenceReason(prd)) return { ...prd, diff: [...prd.diff, ...diff] };
|
|
496
|
+
return { ...prd, verdict: "changed", why: "carried files differ from the snapshot", diff };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function prdDivergence({ id, entry, readPrd, read = readText }) {
|
|
500
|
+
const snapshotText = readPrd(id);
|
|
501
|
+
const rel = entry?.prd ?? null;
|
|
502
|
+
const checkout = entry?.checkout ?? null;
|
|
503
|
+
if (!snapshotText) return { verdict: "unknown", why: "no PRD snapshot on disk", diff: [], path: null };
|
|
504
|
+
if (!checkout || !rel) return { verdict: "unknown", why: "no operator checkout recorded for this entry", diff: [], path: null };
|
|
505
|
+
|
|
506
|
+
const path = join(checkout, rel);
|
|
507
|
+
const repoText = read(path);
|
|
508
|
+
if (repoText === null) return { verdict: "gone", why: `${path} is not there any more`, diff: [`the brief is gone from ${path}`], path };
|
|
509
|
+
|
|
510
|
+
const snapshot = parsePrd(snapshotText, path);
|
|
511
|
+
const repo = parsePrd(repoText, path);
|
|
512
|
+
// Byte equality first, so the overwhelmingly common answer costs one string comparison and no parsing decisions.
|
|
513
|
+
if (repoText === snapshotText) return { verdict: "same", why: "identical to the snapshot", diff: [], path, repoText, snapshot, repo };
|
|
514
|
+
|
|
515
|
+
const diff = contractDiff(snapshot, repo);
|
|
516
|
+
const verdict = check(repo);
|
|
517
|
+
// The order matters. An illegal brief is reported as illegal even when the contract held, because the operator has to
|
|
518
|
+
// fix the file either way and `start` would have refused it; the contract lines come along so a change that did both
|
|
519
|
+
// says both.
|
|
520
|
+
if (!verdict.ok) {
|
|
521
|
+
const failed = verdict.results.filter(r => !r.ok);
|
|
522
|
+
return {
|
|
523
|
+
verdict: "illegal", path, repoText, snapshot, repo, rules: failed,
|
|
524
|
+
why: `${path} no longer passes the contract: ${failed.map(r => r.id).join(", ")}`,
|
|
525
|
+
diff: [...diff, ...failed.map(r => `contract rule ${r.id} now fails: ${r.detail || r.text}`)],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
// The verdict is the fingerprint's, never the diff's. A diff is a description for a person and descriptions are lossy:
|
|
529
|
+
// a duplicated Verify command leaves every element of both lists present in the other, so the diff is empty while the
|
|
530
|
+
// commands the session must run green have changed. Deciding on the diff let that through as `same` and re-snapshotted
|
|
531
|
+
// it silently, which is the very thing this function exists to prevent.
|
|
532
|
+
if (sameContract(snapshot, repo)) {
|
|
533
|
+
return { verdict: "same", why: "edited outside the contract: the criteria, the Verify commands, the harness and context_paths all still match", diff: [], path, repoText, snapshot, repo };
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
verdict: "changed", path, repoText, snapshot, repo,
|
|
537
|
+
why: `the contract in ${path} is not the one this run started against`,
|
|
538
|
+
// A contract that moved with nothing to show for it says so, rather than reporting no difference and leaving the
|
|
539
|
+
// operator to wonder why they were stopped. Reachable exactly when a list gained or lost a duplicate, which is the
|
|
540
|
+
// one change the diff cannot name, so the line names that.
|
|
541
|
+
diff: diff.length ? diff : [`the acceptance criteria or the Verify commands changed in a way a list of differences cannot show, which means a repeated entry was added or removed. Compare ${path} with the snapshot in the Gallivant home.`],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** One line per difference, indented, for `why` and for the log. Empty string when there is nothing to say. */
|
|
546
|
+
export const renderDivergence = d => (d.diff.length ? d.diff.map(l => ` - ${l}`).join("\n") : "");
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The reason a divergence stops an entry with, or null for one that does not. Kept here rather than spelled at each
|
|
550
|
+
* caller so the tick, `status`, `why` and `resume` cannot drift into three reasons for one condition.
|
|
551
|
+
*/
|
|
552
|
+
export const DIVERGENCE_REASONS = Object.freeze({ changed: "prd-changed", illegal: "prd-illegal", gone: "prd-gone" });
|
|
553
|
+
export const divergenceReason = d => DIVERGENCE_REASONS[d.verdict] ?? null;
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Take the daemon's own snapshot of a brief from the PR branch, unless it already has one. The snapshot is what fix
|
|
557
|
+
* rounds and the completion gate read, so an entry without one skips every acceptance criterion: `register` and
|
|
558
|
+
* `reconcile` both have to take it, and they take it the same way, from here.
|
|
559
|
+
*
|
|
560
|
+
* Never throws, because for both callers the snapshot is not what makes the PR watchable. `register` has already put
|
|
561
|
+
* the entry in the registry, and `reconcile` runs before the daemon's first tick and outside its catch, so an error
|
|
562
|
+
* escaping here would abort a registration that succeeded or take the daemon down before it watched anything. A path
|
|
563
|
+
* the branch does not carry, an unreachable GitHub and an unwritable home all come back as false with the reason in
|
|
564
|
+
* the log; the completion gate is skipped until a later boot takes it.
|
|
565
|
+
*
|
|
566
|
+
* The copy on the branch was writable by the session, which the log says.
|
|
567
|
+
*
|
|
568
|
+
* The refusal to overwrite is no longer absolute, and that is the change a divergence forces. It used to mean "the
|
|
569
|
+
* authoritative copy is taken once and never again", which is right against a session and wrong against the operator:
|
|
570
|
+
* a brief they have deliberately edited can only become the brief the gate judges by replacing the snapshot. So an
|
|
571
|
+
* overwrite is possible and it is an explicit act with an audit trail. `replace` is the operator's resolution, carried
|
|
572
|
+
* through `resolvePrdSnapshot` below rather than passed by hand, and it never arrives from a session or from the tick's
|
|
573
|
+
* own judgement.
|
|
574
|
+
*/
|
|
575
|
+
export async function ensurePrdSnapshot(ctx, { id, repo, path, ref }) {
|
|
576
|
+
const { gh, log, readPrd, writePrd } = ctx;
|
|
577
|
+
try {
|
|
578
|
+
if (!path || readPrd(id)) return false;
|
|
579
|
+
const text = await gh.fileAt(repo, path, ref);
|
|
580
|
+
if (!text) { log.warn(`${id}: ${path} not found on ${ref}; the completion gate is skipped until a snapshot exists`); return false; }
|
|
581
|
+
// A CLI start may have approved its own snapshot while this network read was in flight.
|
|
582
|
+
if (readPrd(id) || ctx.state?.startClaim?.(id)) return false;
|
|
583
|
+
writePrd(id, text);
|
|
584
|
+
log.warn(`${id}: PRD snapshot taken from the PR branch (${path}); the session could have edited it`);
|
|
585
|
+
return true;
|
|
586
|
+
} catch (e) {
|
|
587
|
+
log.warn(`${id}: PRD snapshot not taken: ${e.message}`);
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Replace the snapshot with the operator's own copy, and record the contract it now carries on the entry. The one
|
|
594
|
+
* writer of a snapshot that already exists.
|
|
595
|
+
*
|
|
596
|
+
* It refuses everything `prdDivergence` will not stand behind: there is nothing to resolve when the verdict is `same`
|
|
597
|
+
* or `unknown`, there is nothing to copy when the brief is `gone`, and an `illegal` brief must never reach the snapshot
|
|
598
|
+
* the gate judges. So the only verdict it accepts is `changed`, which is exactly the case a person has to decide and
|
|
599
|
+
* has now decided. `why` is what that person was told, so the log line says what the snapshot was replaced WITH and
|
|
600
|
+
* not merely that it was replaced; `by` names who asked, in the same spirit as the existing line that says the session
|
|
601
|
+
* could have edited the copy it took.
|
|
602
|
+
*
|
|
603
|
+
* The state write and the file write are one act from the caller's side, and the FILE goes first: a fingerprint that
|
|
604
|
+
* describes a snapshot nobody took would make every later comparison say `same` about a brief that never moved, which
|
|
605
|
+
* is the silent divergence this whole change exists to end. A file written with the fingerprint then unrecorded is the
|
|
606
|
+
* recoverable half, because the next comparison simply finds the difference again.
|
|
607
|
+
*/
|
|
608
|
+
export function resolvePrdSnapshot(ctx, { id, entry, divergence, by }) {
|
|
609
|
+
if (divergence.verdict !== "changed") throw new Error(`${id}: nothing to resolve (${divergence.verdict}: ${divergence.why})`);
|
|
610
|
+
const patch = retakePrdSnapshot(ctx, { id, divergence });
|
|
611
|
+
ctx.log.warn(`${id}: PRD snapshot REPLACED from ${divergence.path} by ${by}; the completion gate now judges the edited brief, not the one this run started against:\n${renderDivergence(divergence)}`);
|
|
612
|
+
return patch;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Follow an edit that left the contract alone: replace the snapshot, record the new fingerprint, say so at info level
|
|
617
|
+
* and restart nothing.
|
|
618
|
+
*
|
|
619
|
+
* Separate from `resolvePrdSnapshot` because the two are different acts with the same mechanics. That one is a person
|
|
620
|
+
* overruling a snapshot the daemon was refusing to replace, and it warns, names who asked and lists what moved. This one
|
|
621
|
+
* is the snapshot following prose the operator rewrote, where there is nothing to decide and nothing to tell anyone: the
|
|
622
|
+
* criteria, the Verify commands, the harness and `context_paths` all still match, so the work in flight is still the work
|
|
623
|
+
* that was asked for. Sharing the writes keeps `ensurePrdSnapshot`'s "one writer" property, which is what stops a third
|
|
624
|
+
* caller inventing its own way to put a file in `prds/`.
|
|
625
|
+
*
|
|
626
|
+
* Callers must have a `same` verdict in hand. It is not asserted here because both callers have just branched on it, and
|
|
627
|
+
* an assertion on a caller-local invariant is the kind of duplication that goes stale.
|
|
628
|
+
*/
|
|
629
|
+
export function retakePrdSnapshot(ctx, { id, divergence }) {
|
|
630
|
+
const { writePrd, state } = ctx;
|
|
631
|
+
writePrd(id, divergence.repoText);
|
|
632
|
+
const patch = { prd_fingerprint: prdFingerprint(divergence.repo), prd_decision: prdDecision(divergence.repo), prd_divergence: null };
|
|
633
|
+
if (state) state.update(id, patch);
|
|
634
|
+
return patch;
|
|
635
|
+
}
|