@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +28 -22
- package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +780 -813
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +666 -161
- package/dist/workflow-kit.mjs +55 -96
- package/dist/workflows/feature.mjs +171 -76
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +353 -310
- package/package.json +1 -2
- package/src/cli/Program.test.ts +3 -8
- package/src/cli/Program.ts +1 -8
- package/src/edge/Edge.test.ts +5 -2
- package/src/edge/Edge.ts +3 -4
- package/src/emit/index.ts +1 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/replay/Replay.ts +2 -1
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +148 -51
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +140 -49
- package/src/testkit/InMemRepo.ts +35 -2
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflow-kit/scenarios.test.ts +116 -0
- package/src/workflow-kit/scenarios.ts +53 -113
- package/src/workflows/feature/feature.ts +2 -5
- package/src/workflows/feature/packages.test.ts +17 -34
- package/src/workflows/feature/packages.ts +13 -52
- package/src/workflows/feature/planning.ts +1 -1
- package/src/workflows/feature/review.test.ts +233 -54
- package/src/workflows/feature/review.ts +178 -41
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/index.ts +0 -2
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/health.test.ts +1 -21
- package/src/workflows/shared/health.ts +4 -39
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -1
- package/src/workflows/shared/steps.ts +18 -11
- package/src/workflows/shared/tail.test.ts +1 -41
- package/src/workflows/shared/tail.ts +17 -41
- package/src/workflows/shared/text.fixture.ts +1 -0
- package/src/workflows/shared/text.test.ts +129 -41
- package/src/workflows/shared/text.ts +111 -75
- package/src/workflows/shared/vars.ts +1 -1
- package/src/workflows/shared/scenarios.test.ts +0 -256
- package/src/workflows/shared/scenarios.ts +0 -35
package/dist/workflows.mjs
CHANGED
|
@@ -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 = /^(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
394
|
-
for (const
|
|
395
|
-
const
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
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
|
|
446
|
-
let
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
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
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
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
|
-
|
|
16365
|
-
|
|
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
|
|
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\` —
|
|
17146
|
-
|
|
17147
|
-
|
|
17148
|
-
|
|
17149
|
-
|
|
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\`:
|
|
17176
|
-
|
|
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
|
|
17184
|
-
green
|
|
17185
|
-
|
|
17186
|
-
same files stay in one package unless
|
|
17187
|
-
an interface the earlier one creates.
|
|
17188
|
-
|
|
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
|
-
-
|
|
17195
|
-
|
|
17196
|
-
|
|
17197
|
-
|
|
17198
|
-
|
|
17199
|
-
|
|
17200
|
-
|
|
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
|
-
-
|
|
17250
|
-
|
|
17251
|
-
|
|
17252
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
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") =>
|
|
17793
|
+
const baseline = (name, label = "Checking the baseline") => check(name, env.testCommand ?? "", {
|
|
17818
17794
|
report: FEEDBACK,
|
|
17819
|
-
label
|
|
17820
|
-
|
|
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
|
-
|
|
17861
|
-
|
|
17862
|
-
|
|
17863
|
-
|
|
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:
|
|
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
|
|
37275
|
-
fix:
|
|
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,
|
|
37294
|
-
const frozen = built?.frozen;
|
|
37250
|
+
const tail = (fixFirst, finish, green = false) => scope("build", async () => {
|
|
37295
37251
|
const escalations = { rounds: 0 };
|
|
37296
|
-
const
|
|
37252
|
+
const fix = fixCheck;
|
|
37297
37253
|
const repair = async () => {
|
|
37298
|
-
await
|
|
37299
|
-
await healthy(
|
|
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(
|
|
37306
|
-
|
|
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
|
|
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(
|
|
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",
|
|
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
|
-
/**
|
|
37444
|
-
|
|
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
|
|
37451
|
-
await healthy(
|
|
37452
|
-
await run("closing", removeScript([
|
|
37453
|
-
return {
|
|
37403
|
+
await declared(() => build(pkg))();
|
|
37404
|
+
await healthy(declared(fixSuite));
|
|
37405
|
+
await run("closing", removeScript([
|
|
37454
37406
|
pkg,
|
|
37455
|
-
|
|
37456
|
-
|
|
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 = (
|
|
37464
|
-
let
|
|
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
|
|
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
|
-
*
|
|
37535
|
-
*
|
|
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
|
|
37540
|
-
const
|
|
37541
|
-
|
|
37542
|
-
|
|
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
|
-
|
|
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 }
|
|
37560
|
-
|
|
37561
|
-
|
|
37562
|
-
|
|
37563
|
-
|
|
37564
|
-
|
|
37565
|
-
|
|
37566
|
-
|
|
37567
|
-
|
|
37568
|
-
|
|
37569
|
-
|
|
37570
|
-
|
|
37571
|
-
|
|
37572
|
-
|
|
37573
|
-
|
|
37574
|
-
|
|
37575
|
-
|
|
37576
|
-
|
|
37577
|
-
|
|
37578
|
-
|
|
37579
|
-
|
|
37580
|
-
|
|
37581
|
-
|
|
37582
|
-
|
|
37583
|
-
|
|
37584
|
-
|
|
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
|
-
|
|
37616
|
-
|
|
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,
|
|
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 };
|