@pmelab/gtd 24.3.0 → 25.0.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +780 -813
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +666 -161
  9. package/dist/workflow-kit.mjs +55 -96
  10. package/dist/workflows/feature.mjs +171 -76
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +353 -310
  14. package/package.json +1 -2
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/replay/Replay.ts +2 -1
  23. package/src/steering/FORMATS.md +1 -1
  24. package/src/steering/SteeringFormat.test.ts +9 -1
  25. package/src/steering/SteeringFormat.ts +7 -0
  26. package/src/steering/contract.test.ts +3 -0
  27. package/src/steering/index.ts +1 -0
  28. package/src/steering/review.test.ts +30 -0
  29. package/src/steering/review.ts +8 -2
  30. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  31. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  32. package/src/testkit/EmittedScripts.test.ts +166 -0
  33. package/src/testkit/FlowScripts.test.ts +242 -0
  34. package/src/testkit/FlowScripts.ts +326 -0
  35. package/src/testkit/GitTiers.ts +140 -49
  36. package/src/testkit/InMemRepo.ts +35 -2
  37. package/src/testkit/Layers.ts +10 -2
  38. package/src/testkit/TestRepo.ts +4 -1
  39. package/src/testkit/index.ts +1 -1
  40. package/src/workflow/discovery.test.ts +147 -0
  41. package/src/workflow/discovery.ts +5 -0
  42. package/src/workflow/fingerprint.test.ts +20 -4
  43. package/src/workflow/fingerprint.ts +8 -9
  44. package/src/workflow/index.ts +1 -6
  45. package/src/workflow/load.test.ts +5 -19
  46. package/src/workflow/load.ts +94 -10
  47. package/src/workflow-kit/review.test.ts +84 -0
  48. package/src/workflow-kit/review.ts +9 -6
  49. package/src/workflow-kit/scenarios.test.ts +116 -0
  50. package/src/workflow-kit/scenarios.ts +53 -113
  51. package/src/workflows/feature/feature.ts +2 -5
  52. package/src/workflows/feature/packages.test.ts +17 -34
  53. package/src/workflows/feature/packages.ts +13 -52
  54. package/src/workflows/feature/planning.ts +1 -1
  55. package/src/workflows/feature/review.test.ts +233 -54
  56. package/src/workflows/feature/review.ts +178 -41
  57. package/src/workflows/feature/steps.test.ts +12 -0
  58. package/src/workflows/feature/steps.ts +8 -1
  59. package/src/workflows/index.ts +0 -2
  60. package/src/workflows/shared/access.test.ts +4 -2
  61. package/src/workflows/shared/access.ts +1 -0
  62. package/src/workflows/shared/health.test.ts +1 -21
  63. package/src/workflows/shared/health.ts +4 -39
  64. package/src/workflows/shared/options.test.ts +8 -0
  65. package/src/workflows/shared/options.ts +1 -1
  66. package/src/workflows/shared/steps.ts +18 -11
  67. package/src/workflows/shared/tail.test.ts +1 -41
  68. package/src/workflows/shared/tail.ts +17 -41
  69. package/src/workflows/shared/text.fixture.ts +1 -0
  70. package/src/workflows/shared/text.test.ts +129 -41
  71. package/src/workflows/shared/text.ts +111 -75
  72. package/src/workflows/shared/vars.ts +1 -1
  73. package/src/workflows/shared/scenarios.test.ts +0 -256
  74. package/src/workflows/shared/scenarios.ts +0 -35
@@ -1,5 +1,5 @@
1
1
  import { changes, changesSince, hasThreadFor, head, read, refuse, requireReplies, requireThreadsClosed, scope } from "./flows.mjs";
2
- import { c as stripCodeThreads, d as reviewNotes, f as reviewRisks } from "./chunks/steering-D-PvRC5W.mjs";
2
+ import { c as stripCodeThreads, d as reviewNotes, f as reviewRisks } from "./chunks/steering-DVILanVf.mjs";
3
3
  //#region src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
@@ -67,102 +67,59 @@ const fixFindings = async (f) => {
67
67
  };
68
68
  //#endregion
69
69
  //#region src/workflow-kit/scenarios.ts
70
- const DOC_STRING = /^(\s*)("""|```)/;
71
- /**
72
- * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
73
- * dropped — except inside a doc string, whose content is test input: kept
74
- * whole, indented relative to its opening delimiter (as Gherkin reads it).
75
- */
76
- const scenarioText = (source) => {
70
+ const DOC_STRING = /^("""|```)/;
71
+ const THEN = /^Then\s/;
72
+ const CONTINUATION = /^(And|But|\*)\s/;
73
+ const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/;
74
+ /** Trimmed lines outside doc strings, whose content is test input, not steps. */
75
+ const stepLines = (source) => {
77
76
  const out = [];
78
- let doc;
79
- for (const line of source.split("\n")) {
80
- const delimiter = DOC_STRING.exec(line);
81
- if (doc === void 0 && delimiter !== null) {
82
- doc = {
83
- indent: delimiter[1].length,
84
- fence: delimiter[2]
85
- };
86
- out.push(line.trim());
87
- } else if (doc !== void 0 && line.trim().startsWith(doc.fence)) {
88
- doc = void 0;
89
- out.push(line.trim());
90
- } else if (doc !== void 0) {
91
- const lead = line.length - line.trimStart().length;
92
- out.push(line.slice(Math.min(lead, doc.indent)).trimEnd());
93
- } else {
94
- const trimmed = line.trim();
95
- if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed);
96
- }
97
- }
98
- return out.join("\n");
99
- };
100
- const textOf = (path) => {
101
- const source = read(path);
102
- return source === void 0 ? void 0 : scenarioText(source);
103
- };
104
- /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
105
- const freezeScenarios = (paths, previous) => {
106
- if (paths.length === 0) return previous;
107
- const texts = { ...previous?.texts };
108
- for (const path of paths) {
109
- const text = textOf(path);
110
- if (text !== void 0) texts[path] = text;
77
+ let fence;
78
+ for (const trimmed of source.split("\n").map((l) => l.trim())) {
79
+ const opening = fence === void 0 ? DOC_STRING.exec(trimmed)?.[1] : void 0;
80
+ if (opening !== void 0) fence = opening;
81
+ else if (fence === void 0) out.push(trimmed);
82
+ else if (trimmed.startsWith(fence)) fence = void 0;
111
83
  }
112
- return Object.keys(texts).length === 0 ? previous : {
113
- at: head(),
114
- texts
115
- };
116
- };
117
- /** Paths whose wording differs from the snapshot (a deleted file counts). */
118
- const driftedScenarios = (frozen) => Object.entries(frozen.texts).filter(([path, text]) => textOf(path) !== text).map(([path]) => path);
119
- const lines = (text) => text === void 0 || text === "" ? [] : text.split("\n");
120
- /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
121
- const lcsTable = (a, b) => {
122
- const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
123
- for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
124
- return table;
84
+ return out;
125
85
  };
126
- /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
127
- const lineDiff = (a, b) => {
128
- const table = lcsTable(a, b);
86
+ const neutral = (line) => line === "" || line.startsWith("#") || line.startsWith("|");
87
+ /** Trimmed `Then` step lines, an `And`/`But`/`*` continuing one included; doc strings skipped. */
88
+ const assertionLines = (source) => {
129
89
  const out = [];
130
- let i = 0;
131
- let j = 0;
132
- while (i < a.length && j < b.length) if (a[i] === b[j]) {
133
- i++;
134
- j++;
135
- } else if (table[i + 1][j] >= table[i][j + 1]) out.push(`- ${a[i++]}`);
136
- else out.push(`+ ${b[j++]}`);
137
- return [
138
- ...out,
139
- ...a.slice(i).map((l) => `- ${l}`),
140
- ...b.slice(j).map((l) => `+ ${l}`)
141
- ];
90
+ let inThen = false;
91
+ let scenario = "";
92
+ for (const line of stepLines(source)) {
93
+ if (HEADING.test(line)) scenario = line;
94
+ if (THEN.test(line)) inThen = true;
95
+ else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false;
96
+ if (inThen && !neutral(line)) out.push({
97
+ scenario,
98
+ line
99
+ });
100
+ }
101
+ return out;
142
102
  };
143
- const wordingDrift = (frozen, path) => ({
144
- path,
145
- lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path)))
146
- });
103
+ const key = (a) => `${a.scenario}\n${a.line}`;
147
104
  /**
148
- * After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
149
- * still differs after it is accepted and moves the snapshot; what was
150
- * restored stays frozen as it was.
105
+ * Assertions a `.feature` file that existed before `changes` lost — the one
106
+ * way rewording a scenario can weaken it. Matched within each scenario and
107
+ * counted, so a line moving to another scenario, or one of two identical
108
+ * `Then`s dropped, still shows. A renamed scenario reports all of its own.
151
109
  */
152
- const holdWording = async (frozen, stop) => {
153
- if (frozen === void 0) return;
154
- const drifted = driftedScenarios(frozen);
155
- if (drifted.length === 0) return;
156
- await stop(frozen.at, drifted.map((path) => wordingDrift(frozen, path)));
157
- const accepted = driftedScenarios(frozen);
158
- if (accepted.length === 0) return;
159
- for (const path of accepted) {
160
- const text = textOf(path);
161
- if (text === void 0) delete frozen.texts[path];
162
- else frozen.texts[path] = text;
163
- }
164
- frozen.at = head();
165
- };
110
+ const removedAssertions = (changes) => changes.filter((c) => c.path.endsWith(".feature") && c.before !== void 0).map((c) => {
111
+ const left = /* @__PURE__ */ new Map();
112
+ for (const a of assertionLines(c.after ?? "")) left.set(key(a), (left.get(key(a)) ?? 0) + 1);
113
+ const lines = assertionLines(c.before ?? "").filter((a) => {
114
+ const n = left.get(key(a)) ?? 0;
115
+ left.set(key(a), n - 1);
116
+ return n <= 0;
117
+ });
118
+ return {
119
+ path: c.path,
120
+ lines
121
+ };
122
+ }).filter((r) => r.lines.length > 0);
166
123
  //#endregion
167
124
  //#region src/workflow-kit/review.ts
168
125
  const collect = async (p, capture) => {
@@ -196,16 +153,17 @@ const converse = async (p, base, collectedBefore) => {
196
153
  if (!hasThreadFor("human", p.file)) return collectedAt;
197
154
  }
198
155
  };
199
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
156
+ /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
200
157
  const routeNotes = async (p, notes, r) => {
201
158
  const verdicts = await p.triage(notes);
202
159
  const of = (v) => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
203
- const [edits, questions, nits] = [
160
+ const [edits, questions, nits, deferred] = [
204
161
  of("edit"),
205
162
  of("question"),
206
- of("nit")
163
+ of("nit"),
164
+ of("defer")
207
165
  ];
208
- if (edits.length + questions.length + nits.length === 0) return r.unfolded();
166
+ if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded();
209
167
  let answeredAt;
210
168
  if (questions.length > 0) {
211
169
  await p.answerQuestions(questions);
@@ -215,6 +173,7 @@ const routeNotes = async (p, notes, r) => {
215
173
  await p.fixNits(nits);
216
174
  await p.keepGreen();
217
175
  }
176
+ if (deferred.length > 0) await p.defer(deferred);
218
177
  if (edits.length > 0) {
219
178
  await r.close();
220
179
  return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)));
@@ -228,7 +187,7 @@ const routeNotes = async (p, notes, r) => {
228
187
  }
229
188
  return {
230
189
  next: "await",
231
- reviewed: answeredAt
190
+ reviewed: answeredAt ?? r.round
232
191
  };
233
192
  };
234
193
  /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
@@ -349,4 +308,4 @@ const reviewToHandOff = async (base, p) => {
349
308
  }
350
309
  };
351
310
  //#endregion
352
- export { driftedScenarios, escalation, fixFindings, freezeScenarios, gate, healthy, holdWording, lensScopes, lensesOf, qualityLap, review, reviewToHandOff, scenarioText, untilSettled, wordingDrift };
311
+ export { assertionLines, escalation, fixFindings, gate, healthy, lensScopes, lensesOf, qualityLap, removedAssertions, review, reviewToHandOff, routeNotes, untilSettled };
@@ -1,6 +1,6 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
2
- import { freezeScenarios, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as unwindFailure, A as architectureAuthorPrompt, B as designTriagePrompt, C as fixCheck, D as reviewing, E as reviewMissing, F as buildReviewFixNitsPrompt, G as packagesItemFixSuitePrompt, I as buildReviewTriageMessage, K as reviewEditNotesCapture, L as builderSystem, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewCollectingPrompt, Q as unwindFailedMessage, R as designGateAnswerMessage, S as coder, T as planner, U as idleMessage, W as packagesItemBuildingPrompt, X as reviewerSystem, Y as reviewNotesCapture, Z as startGateBlockedMessage, _ as ARCHITECTURE, b as REVIEW, c as refusePendingHandOff, d as shipped, f as defaults, g as healthy, h as gate, i as skills, j as architectureDecomposePrompt, k as architectSystem, l as tail, n as summary, o as closeReview, p as envDefaults, q as reviewEditsCapture, r as access, t as steering, u as guarded, v as FEEDBACK, w as fixRisks, x as awaitReview, y as REQUIREMENTS, z as designSystem } from "../chunks/reserved-DJwU5C4z.mjs";
2
+ import { review as review$1 } from "../workflow-kit.mjs";
3
+ import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-6pAgFGgB.mjs";
4
4
  //#region src/workflows/feature/steps.ts
5
5
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
6
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -71,10 +71,13 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
71
71
  system: reviewerSystem(),
72
72
  allowEmpty: true
73
73
  });
74
+ /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
75
+ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes, round), {
76
+ label: "Deferring could-notes to TODO.md",
77
+ file: ".gtd/TODO.md"
78
+ });
74
79
  //#endregion
75
80
  //#region src/workflows/feature/packages.ts
76
- /** Package 0: the e2e scenarios, written red. */
77
- const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md";
78
81
  const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
79
82
  /** The `## Tests` entries of a package file; malformed lines are ignored. */
80
83
  const declaredTests = (packageText) => {
@@ -104,47 +107,29 @@ const requireDeclaredTests = (pkg, since) => {
104
107
  if (missing.length === 0) return;
105
108
  refuse(`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`);
106
109
  };
107
- /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
108
- const packageItem = async (pkg, frozen) => {
110
+ /**
111
+ * Build `pkg`, check its declared tests, keep the full suite green, and close
112
+ * it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
113
+ */
114
+ const packageItem = async (pkg) => {
109
115
  const from = head();
110
116
  const declared = (turn) => async () => {
111
117
  await turn();
112
118
  requireDeclaredTests(pkg, from);
113
119
  };
114
- await guarded(frozen, declared(() => build(pkg)))();
115
- await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" });
116
- await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), { label: "Closing out the package" });
117
- return {
120
+ await declared(() => build(pkg))();
121
+ await healthy(declared(fixSuite));
122
+ await run("closing", removeScript([
118
123
  pkg,
119
- from,
120
- to: head()
121
- };
124
+ ".gtd/SATISFIED.md",
125
+ ...glob(".gtd/packages/*.md").length === 1 ? [ARCHITECTURE] : []
126
+ ]), { label: "Closing out the package" });
122
127
  };
123
128
  /** The first queued package — the one a package step works on. */
124
129
  const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
125
- const outsideGtd = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
126
130
  /** Build every package file under `.gtd/packages/`, in name order. */
127
- const packages = (carried) => scope("packages", async () => {
128
- let frozen = carried;
129
- const ranges = [];
130
- let touched = [];
131
- for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) {
132
- const range = await scope("item", () => packageItem(pkg, pkg === ".gtd/packages/00-e2e-scenarios.md" ? void 0 : frozen));
133
- ranges.push(range);
134
- if (pkg === ".gtd/packages/00-e2e-scenarios.md") {
135
- touched = changesSince(range.from).filter((c) => outsideGtd(c.path) && c.status !== "deleted");
136
- frozen = freezeScenarios(touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path), frozen);
137
- }
138
- }
139
- const paths = (status) => touched.filter((c) => c.status === status).map((c) => c.path);
140
- return {
141
- ranges,
142
- scenarios: {
143
- added: paths("added"),
144
- changed: paths("modified")
145
- },
146
- frozen
147
- };
131
+ const packages = () => scope("packages", async () => {
132
+ for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
148
133
  });
149
134
  //#endregion
150
135
  //#region src/workflows/feature/planning.ts
@@ -177,7 +162,7 @@ const architecture = () => scope("architecture", async () => {
177
162
  } while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
178
163
  await decompose();
179
164
  if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
180
- if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green");
165
+ if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the packages are built from it; it is removed with the last package");
181
166
  });
182
167
  //#endregion
183
168
  //#region src/workflows/feature/review.ts
@@ -194,23 +179,106 @@ const verdictQuestion = (note) => ({
194
179
  instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
195
180
  criteria: "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do."
196
181
  });
182
+ const SEVERITY_ANSWERS = {
183
+ security: ["yes", "no"],
184
+ reversibility: ["one-way", "two-way"],
185
+ blastRadius: [
186
+ "local",
187
+ "module",
188
+ "contract"
189
+ ],
190
+ newConcept: ["yes", "no"]
191
+ };
197
192
  /**
198
- * One judge rest, one verdict per note. Dismissing a note is the risky
193
+ * Deferring is the risky direction: anything unanswered, unrecognised, cut or
194
+ * below `minP` is `critical`, never `could`.
195
+ */
196
+ const severityOf = (note, judgment, minP) => {
197
+ const ans = (q) => judgment.answers[`${note.id}.${q}`];
198
+ const a = (q) => ans(q)?.answer;
199
+ const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
200
+ if (note.forced) return {
201
+ tier: "critical",
202
+ reason: "critical: forced by `!`",
203
+ rank: 2
204
+ };
205
+ if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
206
+ tier: "critical",
207
+ reason: "critical: judge unsure",
208
+ rank: 2
209
+ };
210
+ const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
211
+ if (critical.length > 0) return {
212
+ tier: "critical",
213
+ reason: `critical: ${critical.join(", ")}`,
214
+ rank: rank()
215
+ };
216
+ const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
217
+ if (should.length > 0) return {
218
+ tier: "should",
219
+ reason: `should: ${should.join(", ")}`,
220
+ rank: rank()
221
+ };
222
+ return {
223
+ tier: "could",
224
+ reason: "could",
225
+ rank: 2
226
+ };
227
+ };
228
+ const severityQuestions = (note) => [
229
+ {
230
+ id: `${note.id}.security`,
231
+ primitive: "noul",
232
+ instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
233
+ criteria: "yes: it changes or could weaken a security property. no: it does not."
234
+ },
235
+ {
236
+ id: `${note.id}.reversibility`,
237
+ primitive: "choice",
238
+ instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
239
+ criteria: "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely."
240
+ },
241
+ {
242
+ id: `${note.id}.blastRadius`,
243
+ primitive: "choice",
244
+ instructions: `How far does the change in note "${note.id}" reach?`,
245
+ criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
246
+ },
247
+ {
248
+ id: `${note.id}.newConcept`,
249
+ primitive: "noul",
250
+ instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
251
+ criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
252
+ }
253
+ ];
254
+ /**
255
+ * One judge rest: a verdict per unforced note (a forced note is asked
256
+ * nothing) plus four severity answers. Dismissing a note is the risky
199
257
  * direction, so only a confident non-`edit` verdict on uncut evidence counts;
200
- * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
201
- * every note one.
258
+ * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
259
+ * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
202
260
  */
203
- const triage$1 = async (notes) => {
204
- const { answers, truncated } = await judge("review.triage", {
205
- questions: notes.map(verdictQuestion),
206
- evidence: Object.fromEntries(notes.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
261
+ const triage$1 = (severities) => async (notes) => {
262
+ const asked = notes.filter((n) => !n.forced);
263
+ const judgment = asked.length === 0 ? {
264
+ answers: {},
265
+ truncated: []
266
+ } : await judge("review.triage", {
267
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
268
+ evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
207
269
  message: buildReviewTriageMessage(),
208
270
  label: "Judging each note"
209
271
  });
272
+ const { answers, truncated } = judgment;
210
273
  const minP = numeric(vars.reviewNoteActionable, Infinity);
274
+ const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
275
+ severities.clear();
211
276
  return new Map(notes.map((n) => {
212
- const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
213
- return [n.id, truncated.includes(n.id) || verdict === void 0 ? "edit" : verdict];
277
+ const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
278
+ if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
279
+ const severity = severityOf(n, judgment, severityMinP);
280
+ severities.set(n.id, severity);
281
+ return [n.id, severity.tier === "could" ? "defer" : "edit"];
214
282
  }));
215
283
  };
216
284
  const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
@@ -220,32 +288,61 @@ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
220
288
  * another planning lap, questions are answered in place, nits fixed in one
221
289
  * batch, praise dropped.
222
290
  */
223
- const review = (base, escalations = { rounds: 0 }, frozen) => review$1(base, {
224
- file: REVIEW,
225
- feedback: REQUIREMENTS,
226
- isCode,
227
- reviewing,
228
- fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
229
- keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
230
- awaitReview,
231
- reviewMissing,
232
- collecting,
233
- triage: triage$1,
234
- answerQuestions: answerReviewQuestions,
235
- fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
236
- close: closeReview,
237
- captures: {
238
- edits: reviewEditsCapture,
239
- notes: reviewNotesCapture,
240
- editNotes: reviewEditNotesCapture
241
- },
242
- refusals: {
243
- deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
244
- unmatched: "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing"
245
- }
246
- });
247
- /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
248
- const buildTail = (fixFirst, base, built) => tail(fixFirst, (escalations, frozen) => review(base, escalations, frozen), built);
291
+ const review = (base, escalations = { rounds: 0 }) => {
292
+ const severities = /* @__PURE__ */ new Map();
293
+ let deferred = [];
294
+ return review$1(base, {
295
+ file: REVIEW,
296
+ feedback: REQUIREMENTS,
297
+ isCode,
298
+ reviewing: (b, carry) => {
299
+ deferred = [];
300
+ return reviewing(b, carry);
301
+ },
302
+ fixRisks,
303
+ keepGreen: () => healthy(fixCheck, { escalations }),
304
+ awaitReview: (b) => {
305
+ const notes = deferred;
306
+ deferred = [];
307
+ return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes), true) : awaitReview(b);
308
+ },
309
+ reviewMissing,
310
+ collecting,
311
+ triage: triage$1(severities),
312
+ defer: (notes) => {
313
+ deferred = notes.map((n) => ({
314
+ id: n.id,
315
+ anchor: n.anchor,
316
+ text: n.text,
317
+ label: severities.get(n.id).reason
318
+ }));
319
+ return deferNotes(deferred, head());
320
+ },
321
+ answerQuestions: answerReviewQuestions,
322
+ fixNits,
323
+ close: closeReview,
324
+ captures: {
325
+ edits: reviewEditsCapture,
326
+ notes: reviewNotesCapture,
327
+ editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
328
+ n,
329
+ i,
330
+ s: severities.get(n.id)
331
+ })).sort((x, y) => Number(x.s?.tier === "should") - Number(y.s?.tier === "should") || (x.s?.rank ?? 2) - (y.s?.rank ?? 2) || x.i - y.i).map(({ n, s }) => ({
332
+ id: n.id,
333
+ anchor: n.anchor,
334
+ text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
335
+ ...s === void 0 ? {} : { label: s.reason }
336
+ })), answeredAt)
337
+ },
338
+ refusals: {
339
+ deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
340
+ unmatched: "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing"
341
+ }
342
+ });
343
+ };
344
+ /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
345
+ const buildTail = (fixFirst, base, green = false) => tail(fixFirst, (escalations) => review(base, escalations), green);
249
346
  //#endregion
250
347
  //#region src/workflows/feature/feature.ts
251
348
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
@@ -272,13 +369,11 @@ const reUnwind = async (feedback) => {
272
369
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
273
370
  const planAndBuild = async (firstBase) => {
274
371
  let base = firstBase;
275
- let carried;
276
372
  for (;;) {
277
373
  await design(base);
278
374
  await architecture();
279
- const built = await packages(carried);
280
- carried = built.frozen;
281
- const outcome = await buildTail(false, base, built);
375
+ await packages();
376
+ const outcome = await buildTail(false, base, true);
282
377
  if (outcome.verdict === "signoff") return;
283
378
  await reUnwind(outcome);
284
379
  base = outcome.base;
@@ -312,4 +407,4 @@ const feature = shipped(ordinaryStart);
312
407
  //#region src/workflows/feature/index.ts
313
408
  var feature_default = feature;
314
409
  //#endregion
315
- export { SCENARIO_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, requireDeclaredTests, skills, steering, summary, triage, unwind };
410
+ export { access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, requireDeclaredTests, skills, steering, summary, triage, unwind };
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
- import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-D-PvRC5W.mjs";
3
- import { H as fixReviewNotesPrompt, O as agentWithSkills, S as coder, V as finisherSystem, a as REVIEW_ORIG, b as REVIEW, d as shipped, f as defaults, i as skills, m as baseline, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-DJwU5C4z.mjs";
2
+ import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-6pAgFGgB.mjs";
4
4
  //#region src/workflows/fix/fix.ts
5
5
  /**
6
6
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,5 +1,5 @@
1
1
  import { start } from "../flows.mjs";
2
- import { J as reviewGateBlockedMessage, c as refusePendingHandOff, d as shipped, f as defaults, h as gate, i as skills, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-DJwU5C4z.mjs";
2
+ import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-6pAgFGgB.mjs";
3
3
  //#region src/workflows/review/review.ts
4
4
  /**
5
5
  * Pure review of everything since `reviewBase`: a green gate, the quality