@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,4 +1,3 @@
1
- import { dirname } from "node:path";
2
1
  //#region \0rolldown/runtime.js
3
2
  var __defProp = Object.defineProperty;
4
3
  var __exportAll = (all, no_symbols) => {
@@ -217,6 +216,13 @@ const removeScript = (paths) => [
217
216
  ...removal(paths),
218
217
  ""
219
218
  ].join("\n");
219
+ /** Write `path` as committed at `ref` to `to`. */
220
+ const showScript = (ref, path, to) => [
221
+ "#!/usr/bin/env sh",
222
+ "set -e",
223
+ `git show ${quote(`${ref}:${path}`)} > ${quote(to)}`,
224
+ ""
225
+ ].join("\n");
220
226
  //#endregion
221
227
  //#region src/flows/helpers.ts
222
228
  /** Whether a judge answered `expected` at a probability of at least `minP`. */
@@ -323,13 +329,6 @@ const escalation$1 = async (count, steps) => {
323
329
  await steps.describe();
324
330
  await steps.stop();
325
331
  };
326
- /** Run `check` again while `pending()` holds — a run then does not count — and resolve the first run that does. */
327
- const untilSettled = async (check, pending) => {
328
- for (;;) {
329
- const green = await check();
330
- if (!pending()) return green;
331
- }
332
- };
333
332
  const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/;
334
333
  const stripStamp = (text) => text.replace(STAMP, "\n");
335
334
  /**
@@ -382,102 +381,59 @@ const fixFindings = async (f) => {
382
381
  };
383
382
  //#endregion
384
383
  //#region src/workflow-kit/scenarios.ts
385
- const DOC_STRING = /^(\s*)("""|```)/;
386
- /**
387
- * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
388
- * dropped — except inside a doc string, whose content is test input: kept
389
- * whole, indented relative to its opening delimiter (as Gherkin reads it).
390
- */
391
- const scenarioText = (source) => {
384
+ const DOC_STRING = /^("""|```)/;
385
+ const THEN = /^Then\s/;
386
+ const CONTINUATION = /^(And|But|\*)\s/;
387
+ const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/;
388
+ /** Trimmed lines outside doc strings, whose content is test input, not steps. */
389
+ const stepLines = (source) => {
392
390
  const out = [];
393
- let doc;
394
- for (const line of source.split("\n")) {
395
- const delimiter = DOC_STRING.exec(line);
396
- if (doc === void 0 && delimiter !== null) {
397
- doc = {
398
- indent: delimiter[1].length,
399
- fence: delimiter[2]
400
- };
401
- out.push(line.trim());
402
- } else if (doc !== void 0 && line.trim().startsWith(doc.fence)) {
403
- doc = void 0;
404
- out.push(line.trim());
405
- } else if (doc !== void 0) {
406
- const lead = line.length - line.trimStart().length;
407
- out.push(line.slice(Math.min(lead, doc.indent)).trimEnd());
408
- } else {
409
- const trimmed = line.trim();
410
- if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed);
411
- }
391
+ let fence;
392
+ for (const trimmed of source.split("\n").map((l) => l.trim())) {
393
+ const opening = fence === void 0 ? DOC_STRING.exec(trimmed)?.[1] : void 0;
394
+ if (opening !== void 0) fence = opening;
395
+ else if (fence === void 0) out.push(trimmed);
396
+ else if (trimmed.startsWith(fence)) fence = void 0;
412
397
  }
413
- return out.join("\n");
414
- };
415
- const textOf = (path) => {
416
- const source = read(path);
417
- return source === void 0 ? void 0 : scenarioText(source);
418
- };
419
- /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
420
- const freezeScenarios = (paths, previous) => {
421
- if (paths.length === 0) return previous;
422
- const texts = { ...previous?.texts };
423
- for (const path of paths) {
424
- const text = textOf(path);
425
- if (text !== void 0) texts[path] = text;
426
- }
427
- return Object.keys(texts).length === 0 ? previous : {
428
- at: head$1(),
429
- texts
430
- };
398
+ return out;
431
399
  };
432
- /** Paths whose wording differs from the snapshot (a deleted file counts). */
433
- const driftedScenarios = (frozen) => Object.entries(frozen.texts).filter(([path, text]) => textOf(path) !== text).map(([path]) => path);
434
- const lines = (text) => text === void 0 || text === "" ? [] : text.split("\n");
435
- /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
436
- const lcsTable = (a, b) => {
437
- const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
438
- 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]);
439
- return table;
440
- };
441
- /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
442
- const lineDiff = (a, b) => {
443
- const table = lcsTable(a, b);
400
+ const neutral = (line) => line === "" || line.startsWith("#") || line.startsWith("|");
401
+ /** Trimmed `Then` step lines, an `And`/`But`/`*` continuing one included; doc strings skipped. */
402
+ const assertionLines = (source) => {
444
403
  const out = [];
445
- let i = 0;
446
- let j = 0;
447
- while (i < a.length && j < b.length) if (a[i] === b[j]) {
448
- i++;
449
- j++;
450
- } else if (table[i + 1][j] >= table[i][j + 1]) out.push(`- ${a[i++]}`);
451
- else out.push(`+ ${b[j++]}`);
452
- return [
453
- ...out,
454
- ...a.slice(i).map((l) => `- ${l}`),
455
- ...b.slice(j).map((l) => `+ ${l}`)
456
- ];
404
+ let inThen = false;
405
+ let scenario = "";
406
+ for (const line of stepLines(source)) {
407
+ if (HEADING.test(line)) scenario = line;
408
+ if (THEN.test(line)) inThen = true;
409
+ else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false;
410
+ if (inThen && !neutral(line)) out.push({
411
+ scenario,
412
+ line
413
+ });
414
+ }
415
+ return out;
457
416
  };
458
- const wordingDrift = (frozen, path) => ({
459
- path,
460
- lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path)))
461
- });
417
+ const key = (a) => `${a.scenario}\n${a.line}`;
462
418
  /**
463
- * After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
464
- * still differs after it is accepted and moves the snapshot; what was
465
- * restored stays frozen as it was.
419
+ * Assertions a `.feature` file that existed before `changes` lost — the one
420
+ * way rewording a scenario can weaken it. Matched within each scenario and
421
+ * counted, so a line moving to another scenario, or one of two identical
422
+ * `Then`s dropped, still shows. A renamed scenario reports all of its own.
466
423
  */
467
- const holdWording$1 = async (frozen, stop) => {
468
- if (frozen === void 0) return;
469
- const drifted = driftedScenarios(frozen);
470
- if (drifted.length === 0) return;
471
- await stop(frozen.at, drifted.map((path) => wordingDrift(frozen, path)));
472
- const accepted = driftedScenarios(frozen);
473
- if (accepted.length === 0) return;
474
- for (const path of accepted) {
475
- const text = textOf(path);
476
- if (text === void 0) delete frozen.texts[path];
477
- else frozen.texts[path] = text;
478
- }
479
- frozen.at = head$1();
480
- };
424
+ const removedAssertions = (changes) => changes.filter((c) => c.path.endsWith(".feature") && c.before !== void 0).map((c) => {
425
+ const left = /* @__PURE__ */ new Map();
426
+ for (const a of assertionLines(c.after ?? "")) left.set(key(a), (left.get(key(a)) ?? 0) + 1);
427
+ const lines = assertionLines(c.before ?? "").filter((a) => {
428
+ const n = left.get(key(a)) ?? 0;
429
+ left.set(key(a), n - 1);
430
+ return n <= 0;
431
+ });
432
+ return {
433
+ path: c.path,
434
+ lines
435
+ };
436
+ }).filter((r) => r.lines.length > 0);
481
437
  //#endregion
482
438
  //#region node_modules/mdast-util-to-string/lib/index.js
483
439
  /**
@@ -16360,10 +16316,13 @@ const reviewNotes = (before, after) => {
16360
16316
  return [...parseReviewDoc(after).changesets.flatMap((chunk) => {
16361
16317
  const prior = oldChunks.get(chunk.title);
16362
16318
  return [...chunkNote(chunk, prior), ...pointerNotes(chunk, prior)];
16363
- }), ...footnoteNotes(before, after)].sort((a, b) => a.line - b.line).map((f, i) => ({
16364
- id: `note-${i + 1}`,
16365
- ...f.note
16366
- }));
16319
+ }), ...footnoteNotes(before, after)].sort((a, b) => a.line - b.line).map((f, i) => {
16320
+ return {
16321
+ id: `note-${i + 1}`,
16322
+ ...f.note,
16323
+ forced: /^!(\s|$)/.test(f.note.text)
16324
+ };
16325
+ });
16367
16326
  };
16368
16327
  /** Pointer notes the reviewer opened with `Risk:`, in document order — the ones the workflow fixes before the human gate. */
16369
16328
  const reviewRisks = (content) => parseReviewDoc(content).changesets.flatMap((chunk) => chunk.files.flatMap((file) => file.note?.startsWith("Risk:") ? [{
@@ -16526,16 +16485,17 @@ const converse = async (p, base, collectedBefore) => {
16526
16485
  if (!hasThreadFor("human", p.file)) return collectedAt;
16527
16486
  }
16528
16487
  };
16529
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
16488
+ /** Judge every note, then run each verdict's route: answers, then nit fixes, then deferrals, then the edits' lap. */
16530
16489
  const routeNotes = async (p, notes, r) => {
16531
16490
  const verdicts = await p.triage(notes);
16532
16491
  const of = (v) => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v);
16533
- const [edits, questions, nits] = [
16492
+ const [edits, questions, nits, deferred] = [
16534
16493
  of("edit"),
16535
16494
  of("question"),
16536
- of("nit")
16495
+ of("nit"),
16496
+ of("defer")
16537
16497
  ];
16538
- if (edits.length + questions.length + nits.length === 0) return r.unfolded();
16498
+ if (edits.length + questions.length + nits.length + deferred.length === 0) return r.unfolded();
16539
16499
  let answeredAt;
16540
16500
  if (questions.length > 0) {
16541
16501
  await p.answerQuestions(questions);
@@ -16545,6 +16505,7 @@ const routeNotes = async (p, notes, r) => {
16545
16505
  await p.fixNits(nits);
16546
16506
  await p.keepGreen();
16547
16507
  }
16508
+ if (deferred.length > 0) await p.defer(deferred);
16548
16509
  if (edits.length > 0) {
16549
16510
  await r.close();
16550
16511
  return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)));
@@ -16558,7 +16519,7 @@ const routeNotes = async (p, notes, r) => {
16558
16519
  }
16559
16520
  return {
16560
16521
  next: "await",
16561
- reviewed: answeredAt
16522
+ reviewed: answeredAt ?? r.round
16562
16523
  };
16563
16524
  };
16564
16525
  /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
@@ -16987,6 +16948,10 @@ ${stateFileRules}
16987
16948
  ${footnoteFoldIn}${codeThreadReplies()}
16988
16949
  - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
16989
16950
  — no other files for notes or output
16951
+ - Lines of a \`## Deferred review notes\` section of \`.gtd/TODO.md\`
16952
+ that the start diff does not touch are parked by review, never folded;
16953
+ the human's edits to such a line (or its removal) are input like any
16954
+ other part of the start diff
16990
16955
  - \`.gtd/TODO.md\` is the likely home of the sketch that started
16991
16956
  this process — the human's input, folded into the concerns
16992
16957
  below like any other part of the start diff; never a state
@@ -17130,7 +17095,8 @@ ${footnoteFoldIn}${codeThreadReplies()}
17130
17095
  - Every open point here is TECHNICAL — triage already resolved
17131
17096
  the product ones, one phase earlier. PERMISSIVE: answer it
17132
17097
  yourself unless you genuinely cannot defend a default; a wrong
17133
- technical call is still caught by the full run and the quality lap
17098
+ technical call is still caught by each package's suite run and the
17099
+ quality lap
17134
17100
  - The narrow exception: a simplification that drops something
17135
17101
  \`.gtd/REQUIREMENTS.md\` mentions is an open question, not a
17136
17102
  silent default
@@ -17142,11 +17108,13 @@ ${footnoteFoldIn}${codeThreadReplies()}
17142
17108
  only, as fenced TypeScript (or the repository's language)
17143
17109
  3. \`## Call Stacks\` — seam level, entry point → module
17144
17110
  boundary; one line per hop, never per line of code
17145
- 4. \`## E2E Scenarios\` — Gherkin in fenced \`gherkin\` blocks
17146
- (whatever e2e framework the repository uses), each preceded
17147
- by a line \`- e2e: <path>\`. With no user-visible
17148
- behaviour change, the literal line \`No e2e change.\` plus a
17149
- one-line reason — never empty or missing
17111
+ 4. \`## E2E Scenarios\` — one line per user-visible behaviour,
17112
+ \`- e2e: <path> — <behaviour> (covers <concern>)\`, in plain
17113
+ words: the builder writes the scenario in the repository's
17114
+ own e2e framework, so never spell out Gherkin here. With no
17115
+ user-visible behaviour change, the literal line
17116
+ \`No e2e change.\` plus a one-line reason — never empty or
17117
+ missing
17150
17118
  5. \`## Unit Tests\` — one \`### <interface>\` per entry of
17151
17119
  \`## Interfaces\`, each test a line
17152
17120
  \`- unit: <path> — <behaviour> (covers <concern>)\`;
@@ -17172,35 +17140,31 @@ const architectureDecomposePrompt = () => `${styleBlock}
17172
17140
  ${stateFileRules}
17173
17141
  - The only state files this turn touches are the package files
17174
17142
  under \`.gtd/packages/\` — no other files for notes or output.
17175
- Never delete \`.gtd/ARCHITECTURE.md\`: the build tail's full run
17176
- still needs it, and package files may reference it
17143
+ Never delete \`.gtd/ARCHITECTURE.md\`: package files may
17144
+ reference it, and it is removed with the last package
17177
17145
  - Work from \`.gtd/ARCHITECTURE.md\` if you wrote it earlier this
17178
17146
  conversation, otherwise read it (the converged technical
17179
17147
  plan). It already lists the concerns, interfaces, e2e scenarios
17180
17148
  and unit tests — this turn groups them into packages. A
17181
17149
  \`## Merged Concerns\` heading there records merges, never a
17182
17150
  concern of its own: write no package file for it
17183
- - A package is a consecutive run of \`## Unit Tests\` entries that go
17184
- green together on the fast suite, at most ~8 unit-test entries
17185
- each. Keep the merge rule: concerns whose footprints centre on the
17186
- same files stay in one package unless the later one only consumes
17187
- an interface the earlier one creates. Prefer fewer, larger
17188
- packages. Order them so each stays green on the fast suite —
17189
- interface-introducing packages first
17151
+ - A package is one concern's slice, and each must leave the full
17152
+ suite green — every package boundary costs a full run, so write
17153
+ as few as the concerns allow. Keep the merge rule: concerns whose
17154
+ footprints centre on the same files stay in one package unless
17155
+ the later one only consumes an interface the earlier one creates.
17156
+ Order them interface-introducing packages first
17190
17157
  - Write the packages under \`.gtd/packages/\`, each carrying its
17191
17158
  requirement(s) — both, independently, if merged — its tasks, and
17192
17159
  each task's acceptance criteria as \`- [ ]\` checkboxes and
17193
17160
  relevant paths:
17194
- - \`.gtd/packages/00-e2e-scenarios.md\` — package 0: it writes
17195
- \`## E2E Scenarios\` as tests in the repository's own e2e
17196
- framework and leaves them red. Write no such file when
17197
- \`## E2E Scenarios\` says "No e2e change."
17198
- - \`01-…\` onward — the unit-test packages. A requirement no test
17199
- covers (docs, README, deletions, config) becomes a chore
17200
- package, or a task of one; chore packages declare no tests
17201
- - the last numbered package is the wiring package: CLI entry,
17202
- workflow composition, config. A red e2e after it is a bug, not
17203
- missing work
17161
+ - \`01-…\` onward — one vertical slice each: the unit tests,
17162
+ the code, the wiring (CLI entry, workflow composition, config)
17163
+ and every \`## E2E Scenarios\` entry its concern covers, so the
17164
+ slice ends with its scenarios passing. A requirement no test
17165
+ covers (docs, README, deletions, config) is a task of the slice
17166
+ it belongs to; only one no slice owns becomes a chore package,
17167
+ which declares no tests
17204
17168
  - Every package with tests has a \`## Tests\` section, one line per
17205
17169
  declared test, copied from the architecture entries:
17206
17170
  \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
@@ -17246,10 +17210,10 @@ const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
17246
17210
  - Write the tests the package declares under \`## Tests\` first,
17247
17211
  then the code that makes them pass; the turn is refused unless
17248
17212
  every declared test is in your diff
17249
- - Package 0 (\`.gtd/packages/00-e2e-scenarios.md\`) writes the scenarios verbatim
17250
- from \`## E2E Scenarios\` of \`.gtd/ARCHITECTURE.md\`, with only
17251
- step code that typechecks, and leaves them red — no
17252
- implementation
17213
+ - A declared e2e test is a scenario in the repository's own e2e
17214
+ framework for the behaviour its \`## E2E Scenarios\` entry names.
17215
+ Word it to fit the existing steps — reuse and generalise them
17216
+ rather than adding one-off ones
17253
17217
  - Implement every task the package describes, no more, no less,
17254
17218
  fanning independent ones out to parallel subagents where your
17255
17219
  harness supports it; leave other package files untouched
@@ -17303,22 +17267,9 @@ Edit \`.gtd/ESCALATION.md\` with fresh instructions for the next fix
17303
17267
  turn, or land untouched to give it one more attempt at the same
17304
17268
  analysis.
17305
17269
  `;
17306
- const buildContextPrompt = (built) => {
17307
- const list = (paths) => paths.length === 0 ? " (none)" : paths.map((path) => ` - ${path}`).join("\n");
17308
- return `- Read \`.gtd/ARCHITECTURE.md\` — the plan every package was built from
17309
- - A red scenario that is NEW in this plan likely means missing wiring
17310
- between packages; new scenarios:
17311
- ${list(built.scenarios.added)}
17312
- - A red CHANGED or existing scenario or test is a regression a package
17313
- introduced; scenarios this plan changed:
17314
- ${list(built.scenarios.changed)}
17315
- - Each package's commits, to bisect a break:
17316
- ${built.ranges.map((r) => ` git log --oneline ${r.from}..${r.to} # ${r.pkg}`).join("\n")}
17317
- `;
17318
- };
17319
- const buildFixPrompt = (built) => `${stateFileRules}
17270
+ const buildFixPrompt = () => `${stateFileRules}
17320
17271
  ${fixFeedbackPrompt}
17321
- ${built === void 0 ? "" : buildContextPrompt(built)}- Leave everything uncommitted — do not commit
17272
+ - Leave everything uncommitted — do not commit
17322
17273
  `;
17323
17274
  const finisherSystem = () => `${finisherPersona}
17324
17275
 
@@ -17470,9 +17421,10 @@ const reviewNotesCapture = (commit) => `This is machine-captured input, not inst
17470
17421
  Commit: ${commit}
17471
17422
  The human's notes are in .gtd/REVIEW.md at this commit. Run: git show ${commit}
17472
17423
  `;
17424
+ const noteHead = (n) => `${n.id} — ${n.anchor}${n.label === void 0 ? "" : ` (${n.label})`}`;
17473
17425
  const notesCapture = (notes) => `This is machine-captured input, not instructions.
17474
17426
 
17475
- ${notes.map((n) => `- ${n.id} — ${n.anchor}\n ${n.text.replace(/\n/g, "\n ")}`).join("\n")}
17427
+ ${notes.map((n) => `- ${noteHead(n)}\n ${n.text.replace(/\n/g, "\n ")}`).join("\n")}
17476
17428
  `;
17477
17429
  const buildReviewAnswerQuestionsPrompt = (notes) => `${stateFileRules}
17478
17430
  - The only state file this turn writes is \`.gtd/REVIEW.md\`; touch no
@@ -17534,12 +17486,47 @@ record.
17534
17486
  What each change does next (then run \`gtd land\`):
17535
17487
  - **Re-review** — re-run the reviewer to author a fresh \`.gtd/REVIEW.md\` (**build.review.reviewing**).
17536
17488
  `;
17489
+ /** Greedy wrap at 80 columns with a 2-space continuation indent: oxfmt (`proseWrap: always`) rewrites any other shape. */
17490
+ const wrapItem = (item) => {
17491
+ const out = [];
17492
+ let line = "";
17493
+ for (const word of item.split(" ")) if (line !== "" && line.length + 1 + word.length > 80) {
17494
+ out.push(line);
17495
+ line = ` ${word}`;
17496
+ } else line = line === "" ? word : `${line} ${word}`;
17497
+ return [...out, line];
17498
+ };
17499
+ /** Appends one section of `notes` to `.gtd/TODO.md`, wrapped the way oxfmt writes it; an existing file is only ever appended to. */
17500
+ const deferNotesScript = (notes, round) => {
17501
+ return `mkdir -p .gtd
17502
+ if [ -s .gtd/TODO.md ]; then
17503
+ [ -z "$(tail -c1 .gtd/TODO.md)" ] || printf '\\n' >> .gtd/TODO.md
17504
+ printf '\\n' >> .gtd/TODO.md
17505
+ fi
17506
+ printf '%s\\n' ${[
17507
+ `## Deferred review notes (${round.slice(0, 7)})`,
17508
+ "",
17509
+ ...notes.flatMap((n) => wrapItem(`- ${noteHead(n)}: ${n.text.replace(/\s+/g, " ")}`))
17510
+ ].map(quote).join(" ")} >> .gtd/TODO.md
17511
+ `;
17512
+ };
17513
+ /** The gate right after a deferral: landing clean signs off without these notes. */
17514
+ const buildReviewAwaitAfterDeferMessage = (base, deferred) => `${buildReviewAwaitReviewMessage(base)}
17515
+ Deferred to \`.gtd/TODO.md\` just now, NOT acted on in this process — landing
17516
+ clean signs off without them:
17517
+
17518
+ ${deferred.map((n) => `- ${noteHead(n)}: ${n.text.replace(/\s*\n\s*/g, " ")}`).join("\n")}
17519
+ `;
17537
17520
  const buildReviewTriageMessage = () => `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
17538
17521
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
17539
17522
  request), \`question\`, \`nit\` (a small fix needing no re-plan) or
17540
17523
  \`praise\`. Run \`gtd judge answer\` and pipe a choice per note — or land
17541
17524
  untouched, which treats every note as \`edit\` (the conservative
17542
17525
  default; a skipped judgment never dismisses a note).
17526
+
17527
+ Also judge each note's severity, one id per note: \`note-<n>.security\`,
17528
+ \`note-<n>.reversibility\`, \`note-<n>.blastRadius\` and
17529
+ \`note-<n>.newConcept\`.
17543
17530
  `;
17544
17531
  const buildReviewCollectingPrompt = (capture) => `${styleBlock}
17545
17532
 
@@ -17560,6 +17547,8 @@ ${footnoteFoldIn}${codeThreadReplies()}
17560
17547
  - When the capture names an answering commit, carry each answered
17561
17548
  question into \`.gtd/REQUIREMENTS.md\`'s \`## Answered Questions\` as
17562
17549
  \`### <the note>\` plus its answer — the lap replaces \`.gtd/REVIEW.md\`
17550
+ - Keep capture order, and carry each note's \`(tier: reason)\`
17551
+ label into the concern folded from it
17563
17552
  - Nit notes were already fixed: never re-raise a fixed nit as a concern
17564
17553
  - Finish by running \`gtd check review .gtd/REVIEW.md\` and fix
17565
17554
  what it reports
@@ -17601,7 +17590,18 @@ actionability, and never dismiss a real note or edit as approval.
17601
17590
  - If not: finish without writing anything — changing nothing is
17602
17591
  the sign-off
17603
17592
  `;
17604
- const buildReviewReviewingPrompt = (base, carry) => `${styleBlock}
17593
+ const scenariosPrompt = (removed) => `- When a scenario in a \`.feature\` file was added or changed since the
17594
+ base, the first chunk is \`## Scenarios\`: one pointer per such
17595
+ scenario, its note saying in plain words what behaviour it now pins
17596
+ down — the human signs off the scenarios here, nowhere else
17597
+ ${removed.length === 0 ? "" : `- These assertions of scenarios that existed at the base are gone:
17598
+ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenario} — ${a.line}\n`).join("")).join("")} Open the note of each scenario that lost one with \`Weakened:\`,
17599
+ naming the lost line and whether \`.gtd/REQUIREMENTS.md\` asked
17600
+ for it. A scenario that no longer exists has no pointer of its own:
17601
+ put its \`Weakened:\` note on a pointer of the chunk covering its
17602
+ removal
17603
+ `}`;
17604
+ const buildReviewReviewingPrompt = (base, carry, removed = []) => `${styleBlock}
17605
17605
 
17606
17606
  ${styleFormatContract}
17607
17607
 
@@ -17643,7 +17643,7 @@ review the changes:
17643
17643
  A note sitting entirely on the line(s) beneath the pointer is
17644
17644
  also valid. Either way, the note must never start with a bare \`./path\` token
17645
17645
  — that parses as a second pointer, not a note
17646
- No diff is given — read the changes yourself. The range runs
17646
+ ${scenariosPrompt(removed)}No diff is given — read the changes yourself. The range runs
17647
17647
  from \`${base}\` to the working tree (committed turns
17648
17648
  plus anything pending); on a feedback round that's the previous
17649
17649
  review's boundary, so it covers only what's new.
@@ -17690,20 +17690,6 @@ ${it.processCostByModel.map((m) => `- ${m.model}: ${m.cost}\n`).join("")}
17690
17690
  Print the closing message and stop — this writes nothing itself.
17691
17691
  `;
17692
17692
  };
17693
- const scenarioWordingMessage = (at, drifted) => `The e2e scenario wording was frozen when package 0 landed, and the last
17694
- turn changed it. Removed lines are marked \`-\`, added ones \`+\`:
17695
-
17696
- ${drifted.map(({ path, lines }) => `${path}\n${lines.join("\n")}`).join("\n\n")}
17697
-
17698
- To accept the change, land untouched.
17699
-
17700
- To reject it, run
17701
-
17702
- git checkout ${at} -- ${drifted.map(({ path }) => quote(path)).join(" ")}
17703
-
17704
- and land. Restoring only some of the files is a partial accept: every file
17705
- still differing from the frozen wording is accepted.
17706
- `;
17707
17693
  //#endregion
17708
17694
  //#region src/workflows/shared/steps.ts
17709
17695
  const FEEDBACK = ".gtd/FEEDBACK.md";
@@ -17714,7 +17700,7 @@ const REVIEW = ".gtd/REVIEW.md";
17714
17700
  const QUALITY = ".gtd/QUALITY.md";
17715
17701
  const planner = () => env.plannerModel ?? "";
17716
17702
  const coder = () => env.coderModel ?? "";
17717
- const fixCheck = (built) => agentWithSkills("fix", buildFixPrompt(built), {
17703
+ const fixCheck = () => agentWithSkills("fix", buildFixPrompt(), {
17718
17704
  label: "Fixing the check",
17719
17705
  file: FEEDBACK,
17720
17706
  model: coder(),
@@ -17770,7 +17756,7 @@ const fixQuality = () => agentWithSkills("fix.quality.fixing", buildFixQualityPr
17770
17756
  allowEmpty: true
17771
17757
  });
17772
17758
  /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
17773
- const reviewing = (base, carry) => agentWithSkills("review.reviewing", buildReviewReviewingPrompt(base, carry), {
17759
+ const reviewing = (base, carry) => agentWithSkills("review.reviewing", buildReviewReviewingPrompt(base, carry, removedAssertions(changesSince(base))), {
17774
17760
  label: "Reviewing",
17775
17761
  file: REVIEW,
17776
17762
  mode: "review",
@@ -17786,12 +17772,13 @@ const fixRisks = (notes) => agentWithSkills("review.fix.risks.fixing", buildRevi
17786
17772
  system: reviewerSystem(),
17787
17773
  allowEmpty: true
17788
17774
  });
17789
- const awaitReview = (base, message = buildReviewAwaitReviewMessage(base)) => human("review.await-review", {
17775
+ const awaitReview = (base, message = buildReviewAwaitReviewMessage(base), acceptClean = false) => human("review.await-review", {
17790
17776
  message,
17791
17777
  label: "Awaiting your review",
17792
17778
  file: REVIEW,
17793
17779
  mode: "review",
17794
- base
17780
+ base,
17781
+ ...acceptClean ? { acceptClean } : {}
17795
17782
  });
17796
17783
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
17797
17784
  const reviewMissing = (round) => human("review.review-missing", {
@@ -17800,25 +17787,13 @@ const reviewMissing = (round) => human("review.review-missing", {
17800
17787
  });
17801
17788
  //#endregion
17802
17789
  //#region src/workflows/shared/health.ts
17803
- /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
17804
- const SETUP = ".gtd/SETUP.md";
17805
- const SETUP_TEXT = "The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it as a workflow option in `gtd.config.ts` or as GTD_FASTTESTCOMMAND.";
17806
- /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
17807
- const fastSuiteGuard = () => (env.fastTestCommand ?? "").trim() === "" ? [
17808
- `mkdir -p ${quote(dirname(SETUP))}`,
17809
- `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
17810
- `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
17811
- "exit 0"
17812
- ] : [`rm -f ${quote(SETUP)}`];
17813
17790
  /** Fix turns a run of red checks gets before it escalates. */
17814
17791
  const FIX_CAP = 3;
17815
- const unconfigured = () => read(SETUP) !== void 0;
17816
17792
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
17817
- const baseline = (name, label = "Checking the baseline") => untilSettled(() => check(name, env.testCommand ?? "", {
17793
+ const baseline = (name, label = "Checking the baseline") => check(name, env.testCommand ?? "", {
17818
17794
  report: FEEDBACK,
17819
- label,
17820
- preamble: fastSuiteGuard()
17821
- }), unconfigured);
17795
+ label
17796
+ });
17822
17797
  const escalationSteps = {
17823
17798
  limit: 2,
17824
17799
  describe: describeEscalation,
@@ -17856,22 +17831,18 @@ const sameFailure = async (previous, current) => {
17856
17831
  });
17857
17832
  return comparable && truncated.length === 0 && answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity));
17858
17833
  };
17859
- const runSuite = (options) => {
17860
- const fast = options.suite === "fast";
17861
- return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
17862
- report: FEEDBACK,
17863
- label: "Running checks",
17864
- sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []],
17865
- ...fast ? { preamble: fastSuiteGuard() } : {}
17866
- });
17867
- };
17834
+ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
17835
+ report: FEEDBACK,
17836
+ label: "Running checks",
17837
+ sweepOnGreen: [ESCALATION, ...options.sweepOnGreen ?? []]
17838
+ });
17868
17839
  /**
17869
17840
  * Loop until the suite is green: check, and on red run `fix` — escalating
17870
17841
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
17871
17842
  * identical to the previous round's.
17872
17843
  */
17873
17844
  const healthy = (fix, options = {}) => healthy$1(fix, {
17874
- check: options.suite === "fast" ? () => untilSettled(() => runSuite(options), unconfigured) : () => runSuite(options),
17845
+ check: () => runSuite(options),
17875
17846
  report: FEEDBACK,
17876
17847
  fixCap: 3,
17877
17848
  fixesSoFar: options.fixesSoFar ?? 0,
@@ -37207,12 +37178,12 @@ const defaults = {
37207
37178
  judgeBudgetBytes: "32768",
37208
37179
  judgeIdenticalMinP: "0.7",
37209
37180
  reviewNoteActionable: "0.7",
37181
+ reviewSeverityMinP: "0.7",
37210
37182
  qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
37211
37183
  };
37212
37184
  /** Environment settings. */
37213
37185
  const envDefaults = {
37214
37186
  testCommand: "npm test",
37215
- fastTestCommand: "",
37216
37187
  plannerModel: "smart",
37217
37188
  coderModel: "base"
37218
37189
  };
@@ -37224,9 +37195,9 @@ const ShippedOptions = Struct({
37224
37195
  judgeBudgetBytes: setting,
37225
37196
  judgeIdenticalMinP: setting,
37226
37197
  reviewNoteActionable: setting,
37198
+ reviewSeverityMinP: setting,
37227
37199
  qualityReviews: setting,
37228
37200
  testCommand: setting,
37229
- fastTestCommand: setting,
37230
37201
  plannerModel: setting,
37231
37202
  coderModel: setting
37232
37203
  });
@@ -37246,21 +37217,6 @@ const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOp
37246
37217
  base
37247
37218
  }));
37248
37219
  //#endregion
37249
- //#region src/workflows/shared/scenarios.ts
37250
- /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
37251
- const holdWording = (frozen) => holdWording$1(frozen, (at, drifted) => human("scenario-wording", {
37252
- message: scenarioWordingMessage(at, drifted),
37253
- label: "Scenario wording changed",
37254
- acceptClean: true,
37255
- base: at
37256
- }));
37257
- /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
37258
- const guarded = (frozen, turn, within) => async () => {
37259
- await turn();
37260
- if (within === void 0) await holdWording(frozen);
37261
- else await scope(within, () => holdWording(frozen));
37262
- };
37263
- //#endregion
37264
37220
  //#region src/workflows/shared/tail.ts
37265
37221
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
37266
37222
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
@@ -37271,8 +37227,8 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
37271
37227
  */
37272
37228
  const qualityLap = () => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY);
37273
37229
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
37274
- const fixQualityFindings = (escalations, frozen) => fixFindings({
37275
- fix: guarded(frozen, fixQuality),
37230
+ const fixQualityFindings = (escalations) => fixFindings({
37231
+ fix: fixQuality,
37276
37232
  findings: QUALITY,
37277
37233
  cap: 3,
37278
37234
  escalate: () => escalation(escalations)
@@ -37288,34 +37244,25 @@ const refusePendingHandOff = () => {
37288
37244
  };
37289
37245
  /**
37290
37246
  * The build tail: fix (when entered red), keep green, the quality lap, then
37291
- * `finish` — the human review, as the calling workflow runs it.
37247
+ * `finish` — the human review, as the calling workflow runs it. `green`: the
37248
+ * caller just left the full suite green (the package loop), so no run opens it.
37292
37249
  */
37293
- const tail = (fixFirst, finish, built) => scope("build", async () => {
37294
- const frozen = built?.frozen;
37250
+ const tail = (fixFirst, finish, green = false) => scope("build", async () => {
37295
37251
  const escalations = { rounds: 0 };
37296
- const guardedFix = guarded(frozen, () => fixCheck());
37252
+ const fix = fixCheck;
37297
37253
  const repair = async () => {
37298
- await guardedFix();
37299
- await healthy(guardedFix, {
37254
+ await fix();
37255
+ await healthy(fix, {
37300
37256
  fixesSoFar: 1,
37301
37257
  escalations
37302
37258
  });
37303
37259
  };
37304
37260
  if (fixFirst) await repair();
37305
- else await healthy(guarded(frozen, () => fixCheck(built)), {
37306
- escalations,
37307
- sweepOnGreen: [ARCHITECTURE]
37308
- });
37309
- if (await qualityLap() === "findings") if (await fixQualityFindings(escalations, frozen)) await healthy(guardedFix, { escalations });
37261
+ else if (!green) await healthy(fix, { escalations });
37262
+ if (await qualityLap() === "findings") if (await fixQualityFindings(escalations)) await healthy(fix, { escalations });
37310
37263
  else await repair();
37311
- return finish(escalations, frozen);
37264
+ return finish(escalations);
37312
37265
  });
37313
- const handOffScript = (reviewed) => [
37314
- "#!/usr/bin/env sh",
37315
- "set -e",
37316
- `git show ${quote(`${reviewed}:${REVIEW}`)} > ${quote(REVIEW_ORIG)}`,
37317
- ""
37318
- ].join("\n");
37319
37266
  /**
37320
37267
  * The review and fix workflows' human review since `base`: a sign-off closes
37321
37268
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
@@ -37325,11 +37272,11 @@ const handOffReview = (base, escalations) => reviewToHandOff(base, {
37325
37272
  file: REVIEW,
37326
37273
  reviewing,
37327
37274
  fixRisks,
37328
- keepGreen: () => healthy(() => fixCheck(), { escalations }),
37275
+ keepGreen: () => healthy(fixCheck, { escalations }),
37329
37276
  awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
37330
37277
  reviewMissing,
37331
37278
  close: closeReview,
37332
- handOff: (reviewed) => run("review.handing-off", handOffScript(reviewed), { label: "Handing your notes to fix" }),
37279
+ handOff: (reviewed) => run("review.handing-off", showScript(reviewed, REVIEW, REVIEW_ORIG), { label: "Handing your notes to fix" }),
37333
37280
  threadsPending: "gtd land: open-threads: a thread in .gtd/REVIEW.md or an `H:` code comment waits on the agent — nothing answers it here; delete it, or turn it into a note for fix.",
37334
37281
  unparsed: "gtd land: review-doc: your edit to .gtd/REVIEW.md contains no note fix can read — restore the deleted chunk, or write the note as a line under its entry.",
37335
37282
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
@@ -37407,10 +37354,13 @@ const collecting = (capture) => agentWithSkills("review.collecting", buildReview
37407
37354
  system: reviewerSystem(),
37408
37355
  allowEmpty: true
37409
37356
  });
37357
+ /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
37358
+ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes, round), {
37359
+ label: "Deferring could-notes to TODO.md",
37360
+ file: ".gtd/TODO.md"
37361
+ });
37410
37362
  //#endregion
37411
37363
  //#region src/workflows/feature/packages.ts
37412
- /** Package 0: the e2e scenarios, written red. */
37413
- const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md";
37414
37364
  const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
37415
37365
  /** The `## Tests` entries of a package file; malformed lines are ignored. */
37416
37366
  const declaredTests = (packageText) => {
@@ -37440,47 +37390,29 @@ const requireDeclaredTests = (pkg, since) => {
37440
37390
  if (missing.length === 0) return;
37441
37391
  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")}`);
37442
37392
  };
37443
- /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
37444
- const packageItem = async (pkg, frozen) => {
37393
+ /**
37394
+ * Build `pkg`, check its declared tests, keep the full suite green, and close
37395
+ * it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
37396
+ */
37397
+ const packageItem = async (pkg) => {
37445
37398
  const from = head$1();
37446
37399
  const declared = (turn) => async () => {
37447
37400
  await turn();
37448
37401
  requireDeclaredTests(pkg, from);
37449
37402
  };
37450
- await guarded(frozen, declared(() => build(pkg)))();
37451
- await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" });
37452
- await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), { label: "Closing out the package" });
37453
- return {
37403
+ await declared(() => build(pkg))();
37404
+ await healthy(declared(fixSuite));
37405
+ await run("closing", removeScript([
37454
37406
  pkg,
37455
- from,
37456
- to: head$1()
37457
- };
37407
+ ".gtd/SATISFIED.md",
37408
+ ...glob(".gtd/packages/*.md").length === 1 ? [ARCHITECTURE] : []
37409
+ ]), { label: "Closing out the package" });
37458
37410
  };
37459
37411
  /** The first queued package — the one a package step works on. */
37460
37412
  const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
37461
- const outsideGtd = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
37462
37413
  /** Build every package file under `.gtd/packages/`, in name order. */
37463
- const packages = (carried) => scope("packages", async () => {
37464
- let frozen = carried;
37465
- const ranges = [];
37466
- let touched = [];
37467
- for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) {
37468
- const range = await scope("item", () => packageItem(pkg, pkg === ".gtd/packages/00-e2e-scenarios.md" ? void 0 : frozen));
37469
- ranges.push(range);
37470
- if (pkg === ".gtd/packages/00-e2e-scenarios.md") {
37471
- touched = changesSince(range.from).filter((c) => outsideGtd(c.path) && c.status !== "deleted");
37472
- frozen = freezeScenarios(touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path), frozen);
37473
- }
37474
- }
37475
- const paths = (status) => touched.filter((c) => c.status === status).map((c) => c.path);
37476
- return {
37477
- ranges,
37478
- scenarios: {
37479
- added: paths("added"),
37480
- changed: paths("modified")
37481
- },
37482
- frozen
37483
- };
37414
+ const packages = () => scope("packages", async () => {
37415
+ for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
37484
37416
  });
37485
37417
  //#endregion
37486
37418
  //#region src/workflows/feature/planning.ts
@@ -37513,7 +37445,7 @@ const architecture = () => scope("architecture", async () => {
37513
37445
  } while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
37514
37446
  await decompose();
37515
37447
  if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
37516
- 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");
37448
+ 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");
37517
37449
  });
37518
37450
  //#endregion
37519
37451
  //#region src/workflows/feature/review.ts
@@ -37530,23 +37462,106 @@ const verdictQuestion = (note) => ({
37530
37462
  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.`,
37531
37463
  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."
37532
37464
  });
37465
+ const SEVERITY_ANSWERS = {
37466
+ security: ["yes", "no"],
37467
+ reversibility: ["one-way", "two-way"],
37468
+ blastRadius: [
37469
+ "local",
37470
+ "module",
37471
+ "contract"
37472
+ ],
37473
+ newConcept: ["yes", "no"]
37474
+ };
37533
37475
  /**
37534
- * One judge rest, one verdict per note. Dismissing a note is the risky
37535
- * direction, so only a confident non-`edit` verdict on uncut evidence counts;
37536
- * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
37537
- * every note one.
37476
+ * Deferring is the risky direction: anything unanswered, unrecognised, cut or
37477
+ * below `minP` is `critical`, never `could`.
37538
37478
  */
37539
- const triage$1 = async (notes) => {
37540
- const { answers, truncated } = await judge("review.triage", {
37541
- questions: notes.map(verdictQuestion),
37542
- evidence: Object.fromEntries(notes.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
37479
+ const severityOf = (note, judgment, minP) => {
37480
+ const ans = (q) => judgment.answers[`${note.id}.${q}`];
37481
+ const a = (q) => ans(q)?.answer;
37482
+ const rank = () => a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2;
37483
+ if (note.forced) return {
37484
+ tier: "critical",
37485
+ reason: "critical: forced by `!`",
37486
+ rank: 2
37487
+ };
37488
+ if (judgment.truncated.includes(note.id) || Object.entries(SEVERITY_ANSWERS).some(([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP))) return {
37489
+ tier: "critical",
37490
+ reason: "critical: judge unsure",
37491
+ rank: 2
37492
+ };
37493
+ const critical = [...a("security") === "yes" ? ["security"] : [], ...a("reversibility") === "one-way" ? ["one-way door"] : []];
37494
+ if (critical.length > 0) return {
37495
+ tier: "critical",
37496
+ reason: `critical: ${critical.join(", ")}`,
37497
+ rank: rank()
37498
+ };
37499
+ const should = [...a("blastRadius") === "contract" ? ["contract"] : [], ...a("newConcept") === "yes" ? ["new concept"] : []];
37500
+ if (should.length > 0) return {
37501
+ tier: "should",
37502
+ reason: `should: ${should.join(", ")}`,
37503
+ rank: rank()
37504
+ };
37505
+ return {
37506
+ tier: "could",
37507
+ reason: "could",
37508
+ rank: 2
37509
+ };
37510
+ };
37511
+ const severityQuestions = (note) => [
37512
+ {
37513
+ id: `${note.id}.security`,
37514
+ primitive: "noul",
37515
+ 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?`,
37516
+ criteria: "yes: it changes or could weaken a security property. no: it does not."
37517
+ },
37518
+ {
37519
+ id: `${note.id}.reversibility`,
37520
+ primitive: "choice",
37521
+ instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
37522
+ 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."
37523
+ },
37524
+ {
37525
+ id: `${note.id}.blastRadius`,
37526
+ primitive: "choice",
37527
+ instructions: `How far does the change in note "${note.id}" reach?`,
37528
+ criteria: "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on."
37529
+ },
37530
+ {
37531
+ id: `${note.id}.newConcept`,
37532
+ primitive: "noul",
37533
+ 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.`,
37534
+ criteria: "yes: a genuinely new concept. no: reuses or extends an existing one."
37535
+ }
37536
+ ];
37537
+ /**
37538
+ * One judge rest: a verdict per unforced note (a forced note is asked
37539
+ * nothing) plus four severity answers. Dismissing a note is the risky
37540
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
37541
+ * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
37542
+ * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
37543
+ */
37544
+ const triage$1 = (severities) => async (notes) => {
37545
+ const asked = notes.filter((n) => !n.forced);
37546
+ const judgment = asked.length === 0 ? {
37547
+ answers: {},
37548
+ truncated: []
37549
+ } : await judge("review.triage", {
37550
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
37551
+ evidence: Object.fromEntries(asked.map((n) => [n.id, `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`])),
37543
37552
  message: buildReviewTriageMessage(),
37544
37553
  label: "Judging each note"
37545
37554
  });
37555
+ const { answers, truncated } = judgment;
37546
37556
  const minP = numeric(vars.reviewNoteActionable, Infinity);
37557
+ const severityMinP = numeric(vars.reviewSeverityMinP, Infinity);
37558
+ severities.clear();
37547
37559
  return new Map(notes.map((n) => {
37548
- const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
37549
- return [n.id, truncated.includes(n.id) || verdict === void 0 ? "edit" : verdict];
37560
+ const verdict = n.forced ? void 0 : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP));
37561
+ if (verdict !== void 0 && !truncated.includes(n.id)) return [n.id, verdict];
37562
+ const severity = severityOf(n, judgment, severityMinP);
37563
+ severities.set(n.id, severity);
37564
+ return [n.id, severity.tier === "could" ? "defer" : "edit"];
37550
37565
  }));
37551
37566
  };
37552
37567
  const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
@@ -37556,32 +37571,61 @@ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
37556
37571
  * another planning lap, questions are answered in place, nits fixed in one
37557
37572
  * batch, praise dropped.
37558
37573
  */
37559
- const review$1 = (base, escalations = { rounds: 0 }, frozen) => review$2(base, {
37560
- file: REVIEW,
37561
- feedback: REQUIREMENTS,
37562
- isCode,
37563
- reviewing,
37564
- fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
37565
- keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
37566
- awaitReview,
37567
- reviewMissing,
37568
- collecting,
37569
- triage: triage$1,
37570
- answerQuestions: answerReviewQuestions,
37571
- fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
37572
- close: closeReview,
37573
- captures: {
37574
- edits: reviewEditsCapture,
37575
- notes: reviewNotesCapture,
37576
- editNotes: reviewEditNotesCapture
37577
- },
37578
- refusals: {
37579
- deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
37580
- 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"
37581
- }
37582
- });
37583
- /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
37584
- const buildTail = (fixFirst, base, built) => tail(fixFirst, (escalations, frozen) => review$1(base, escalations, frozen), built);
37574
+ const review$1 = (base, escalations = { rounds: 0 }) => {
37575
+ const severities = /* @__PURE__ */ new Map();
37576
+ let deferred = [];
37577
+ return review$2(base, {
37578
+ file: REVIEW,
37579
+ feedback: REQUIREMENTS,
37580
+ isCode,
37581
+ reviewing: (b, carry) => {
37582
+ deferred = [];
37583
+ return reviewing(b, carry);
37584
+ },
37585
+ fixRisks,
37586
+ keepGreen: () => healthy(fixCheck, { escalations }),
37587
+ awaitReview: (b) => {
37588
+ const notes = deferred;
37589
+ deferred = [];
37590
+ return notes.length > 0 ? awaitReview(b, buildReviewAwaitAfterDeferMessage(b, notes), true) : awaitReview(b);
37591
+ },
37592
+ reviewMissing,
37593
+ collecting,
37594
+ triage: triage$1(severities),
37595
+ defer: (notes) => {
37596
+ deferred = notes.map((n) => ({
37597
+ id: n.id,
37598
+ anchor: n.anchor,
37599
+ text: n.text,
37600
+ label: severities.get(n.id).reason
37601
+ }));
37602
+ return deferNotes(deferred, head$1());
37603
+ },
37604
+ answerQuestions: answerReviewQuestions,
37605
+ fixNits,
37606
+ close: closeReview,
37607
+ captures: {
37608
+ edits: reviewEditsCapture,
37609
+ notes: reviewNotesCapture,
37610
+ editNotes: (round, edits, answeredAt) => reviewEditNotesCapture(round, edits.map((n, i) => ({
37611
+ n,
37612
+ i,
37613
+ s: severities.get(n.id)
37614
+ })).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 }) => ({
37615
+ id: n.id,
37616
+ anchor: n.anchor,
37617
+ text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
37618
+ ...s === void 0 ? {} : { label: s.reason }
37619
+ })), answeredAt)
37620
+ },
37621
+ refusals: {
37622
+ deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
37623
+ 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"
37624
+ }
37625
+ });
37626
+ };
37627
+ /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
37628
+ const buildTail = (fixFirst, base, green = false) => tail(fixFirst, (escalations) => review$1(base, escalations), green);
37585
37629
  //#endregion
37586
37630
  //#region src/workflows/feature/feature.ts
37587
37631
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
@@ -37608,13 +37652,11 @@ const reUnwind = async (feedback) => {
37608
37652
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
37609
37653
  const planAndBuild = async (firstBase) => {
37610
37654
  let base = firstBase;
37611
- let carried;
37612
37655
  for (;;) {
37613
37656
  await design(base);
37614
37657
  await architecture();
37615
- const built = await packages(carried);
37616
- carried = built.frozen;
37617
- const outcome = await buildTail(false, base, built);
37658
+ await packages();
37659
+ const outcome = await buildTail(false, base, true);
37618
37660
  if (outcome.verdict === "signoff") return;
37619
37661
  await reUnwind(outcome);
37620
37662
  base = outcome.base;
@@ -37673,6 +37715,7 @@ const access = (vars) => ({
37673
37715
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
37674
37716
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
37675
37717
  "build.review": { write: [".gtd/REVIEW.md"] },
37718
+ "build.review.defer": { write: [".gtd/TODO.md"] },
37676
37719
  "build.review.fix.nits": {},
37677
37720
  "build.review.fix.risks": {},
37678
37721
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] }))
@@ -37749,4 +37792,4 @@ var workflows_default = feature;
37749
37792
  */
37750
37793
  const doors = {};
37751
37794
  //#endregion
37752
- export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, SCENARIO_PACKAGE, SETUP, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, describeEscalation, design, doors, driftedScenarios, envDefaults, escalate, escalation, escalationExhausted, fastSuiteGuard, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, freezeScenarios, gate, guarded, handOffReview, handOffTail, healthy, holdWording, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, scenarioText, skills, steering, summary, tail, threads, triage, unwind, wordingDrift };
37795
+ export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, skills, steering, summary, tail, threads, triage, unwind };