@pmelab/gtd 24.4.0 → 26.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 (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -18
  3. package/dist/chunks/{reserved-B9pG93Fc.mjs → reserved-6pAgFGgB.mjs} +69 -125
  4. package/dist/gtd.bundle.mjs +28 -3
  5. package/dist/testkit.mjs +30 -5
  6. package/dist/workflow-kit.mjs +47 -90
  7. package/dist/workflows/feature.mjs +25 -47
  8. package/dist/workflows/fix.mjs +1 -1
  9. package/dist/workflows/review.mjs +1 -1
  10. package/dist/workflows.mjs +135 -261
  11. package/package.json +2 -2
  12. package/src/cli/Cli.test.ts +25 -0
  13. package/src/cli/Cli.ts +12 -1
  14. package/src/flags/index.test.ts +67 -0
  15. package/src/flags/index.ts +39 -0
  16. package/src/replay/Replay.ts +2 -1
  17. package/src/workflow/vars.test.ts +16 -0
  18. package/src/workflow/vars.ts +4 -1
  19. package/src/workflow-kit/scenarios.test.ts +116 -0
  20. package/src/workflow-kit/scenarios.ts +53 -113
  21. package/src/workflows/feature/feature.ts +2 -5
  22. package/src/workflows/feature/packages.test.ts +17 -34
  23. package/src/workflows/feature/packages.ts +13 -52
  24. package/src/workflows/feature/planning.ts +1 -1
  25. package/src/workflows/feature/review.test.ts +0 -47
  26. package/src/workflows/feature/review.ts +6 -10
  27. package/src/workflows/index.ts +0 -2
  28. package/src/workflows/shared/health.test.ts +1 -21
  29. package/src/workflows/shared/health.ts +4 -39
  30. package/src/workflows/shared/options.ts +0 -1
  31. package/src/workflows/shared/steps.ts +16 -11
  32. package/src/workflows/shared/tail.test.ts +1 -41
  33. package/src/workflows/shared/tail.ts +14 -30
  34. package/src/workflows/shared/text.fixture.ts +1 -0
  35. package/src/workflows/shared/text.test.ts +23 -41
  36. package/src/workflows/shared/text.ts +54 -74
  37. package/src/workflows/shared/vars.ts +0 -1
  38. package/src/workflows/shared/scenarios.test.ts +0 -256
  39. package/src/workflows/shared/scenarios.ts +0 -35
@@ -67,102 +67,59 @@ const fixFindings = async (f) => {
67
67
  };
68
68
  //#endregion
69
69
  //#region src/workflow-kit/scenarios.ts
70
- const DOC_STRING = /^(\s*)("""|```)/;
71
- /**
72
- * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
73
- * dropped — except inside a doc string, whose content is test input: kept
74
- * whole, indented relative to its opening delimiter (as Gherkin reads it).
75
- */
76
- const scenarioText = (source) => {
70
+ const DOC_STRING = /^("""|```)/;
71
+ const THEN = /^Then\s/;
72
+ const CONTINUATION = /^(And|But|\*)\s/;
73
+ const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/;
74
+ /** Trimmed lines outside doc strings, whose content is test input, not steps. */
75
+ const stepLines = (source) => {
77
76
  const out = [];
78
- let doc;
79
- for (const line of source.split("\n")) {
80
- const delimiter = DOC_STRING.exec(line);
81
- if (doc === void 0 && delimiter !== null) {
82
- doc = {
83
- indent: delimiter[1].length,
84
- fence: delimiter[2]
85
- };
86
- out.push(line.trim());
87
- } else if (doc !== void 0 && line.trim().startsWith(doc.fence)) {
88
- doc = void 0;
89
- out.push(line.trim());
90
- } else if (doc !== void 0) {
91
- const lead = line.length - line.trimStart().length;
92
- out.push(line.slice(Math.min(lead, doc.indent)).trimEnd());
93
- } else {
94
- const trimmed = line.trim();
95
- if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed);
96
- }
97
- }
98
- return out.join("\n");
99
- };
100
- const textOf = (path) => {
101
- const source = read(path);
102
- return source === void 0 ? void 0 : scenarioText(source);
103
- };
104
- /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
105
- const freezeScenarios = (paths, previous) => {
106
- if (paths.length === 0) return previous;
107
- const texts = { ...previous?.texts };
108
- for (const path of paths) {
109
- const text = textOf(path);
110
- if (text !== void 0) texts[path] = text;
77
+ let fence;
78
+ for (const trimmed of source.split("\n").map((l) => l.trim())) {
79
+ const opening = fence === void 0 ? DOC_STRING.exec(trimmed)?.[1] : void 0;
80
+ if (opening !== void 0) fence = opening;
81
+ else if (fence === void 0) out.push(trimmed);
82
+ else if (trimmed.startsWith(fence)) fence = void 0;
111
83
  }
112
- return Object.keys(texts).length === 0 ? previous : {
113
- at: head(),
114
- texts
115
- };
116
- };
117
- /** Paths whose wording differs from the snapshot (a deleted file counts). */
118
- const driftedScenarios = (frozen) => Object.entries(frozen.texts).filter(([path, text]) => textOf(path) !== text).map(([path]) => path);
119
- const lines = (text) => text === void 0 || text === "" ? [] : text.split("\n");
120
- /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
121
- const lcsTable = (a, b) => {
122
- const table = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
123
- for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
124
- return table;
84
+ return out;
125
85
  };
126
- /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
127
- const lineDiff = (a, b) => {
128
- const table = lcsTable(a, b);
86
+ const neutral = (line) => line === "" || line.startsWith("#") || line.startsWith("|");
87
+ /** Trimmed `Then` step lines, an `And`/`But`/`*` continuing one included; doc strings skipped. */
88
+ const assertionLines = (source) => {
129
89
  const out = [];
130
- let i = 0;
131
- let j = 0;
132
- while (i < a.length && j < b.length) if (a[i] === b[j]) {
133
- i++;
134
- j++;
135
- } else if (table[i + 1][j] >= table[i][j + 1]) out.push(`- ${a[i++]}`);
136
- else out.push(`+ ${b[j++]}`);
137
- return [
138
- ...out,
139
- ...a.slice(i).map((l) => `- ${l}`),
140
- ...b.slice(j).map((l) => `+ ${l}`)
141
- ];
90
+ let inThen = false;
91
+ let scenario = "";
92
+ for (const line of stepLines(source)) {
93
+ if (HEADING.test(line)) scenario = line;
94
+ if (THEN.test(line)) inThen = true;
95
+ else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false;
96
+ if (inThen && !neutral(line)) out.push({
97
+ scenario,
98
+ line
99
+ });
100
+ }
101
+ return out;
142
102
  };
143
- const wordingDrift = (frozen, path) => ({
144
- path,
145
- lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path)))
146
- });
103
+ const key = (a) => `${a.scenario}\n${a.line}`;
147
104
  /**
148
- * After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
149
- * still differs after it is accepted and moves the snapshot; what was
150
- * restored stays frozen as it was.
105
+ * Assertions a `.feature` file that existed before `changes` lost — the one
106
+ * way rewording a scenario can weaken it. Matched within each scenario and
107
+ * counted, so a line moving to another scenario, or one of two identical
108
+ * `Then`s dropped, still shows. A renamed scenario reports all of its own.
151
109
  */
152
- const holdWording = async (frozen, stop) => {
153
- if (frozen === void 0) return;
154
- const drifted = driftedScenarios(frozen);
155
- if (drifted.length === 0) return;
156
- await stop(frozen.at, drifted.map((path) => wordingDrift(frozen, path)));
157
- const accepted = driftedScenarios(frozen);
158
- if (accepted.length === 0) return;
159
- for (const path of accepted) {
160
- const text = textOf(path);
161
- if (text === void 0) delete frozen.texts[path];
162
- else frozen.texts[path] = text;
163
- }
164
- frozen.at = head();
165
- };
110
+ const removedAssertions = (changes) => changes.filter((c) => c.path.endsWith(".feature") && c.before !== void 0).map((c) => {
111
+ const left = /* @__PURE__ */ new Map();
112
+ for (const a of assertionLines(c.after ?? "")) left.set(key(a), (left.get(key(a)) ?? 0) + 1);
113
+ const lines = assertionLines(c.before ?? "").filter((a) => {
114
+ const n = left.get(key(a)) ?? 0;
115
+ left.set(key(a), n - 1);
116
+ return n <= 0;
117
+ });
118
+ return {
119
+ path: c.path,
120
+ lines
121
+ };
122
+ }).filter((r) => r.lines.length > 0);
166
123
  //#endregion
167
124
  //#region src/workflow-kit/review.ts
168
125
  const collect = async (p, capture) => {
@@ -351,4 +308,4 @@ const reviewToHandOff = async (base, p) => {
351
308
  }
352
309
  };
353
310
  //#endregion
354
- export { driftedScenarios, escalation, fixFindings, freezeScenarios, gate, healthy, holdWording, lensScopes, lensesOf, qualityLap, review, reviewToHandOff, routeNotes, scenarioText, untilSettled, wordingDrift };
311
+ export { assertionLines, escalation, fixFindings, gate, healthy, lensScopes, lensesOf, qualityLap, removedAssertions, review, reviewToHandOff, routeNotes, untilSettled };
@@ -1,6 +1,6 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
2
- import { freezeScenarios, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as startGateBlockedMessage, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixCheck, D as reviewing, E as reviewMissing, F as buildReviewCollectingPrompt, G as idleMessage, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as reviewEditNotesCapture, K as packagesItemBuildingPrompt, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as reviewerSystem, R as builderSystem, S as coder, T as planner, V as designSystem, Y as reviewEditsCapture, Z as reviewNotesCapture, _ as ARCHITECTURE, b as REVIEW, c as refusePendingHandOff, d as shipped, et as unwindFailedMessage, f as defaults, g as healthy, h as gate, i as skills, j as architectureDecomposePrompt, k as architectSystem, l as tail, n as summary, o as closeReview, p as envDefaults, q as packagesItemFixSuitePrompt, r as access, t as steering, tt as unwindFailure, u as guarded, v as FEEDBACK, w as fixRisks, x as awaitReview, y as REQUIREMENTS, z as deferNotesScript } from "../chunks/reserved-B9pG93Fc.mjs";
2
+ import { review as review$1 } from "../workflow-kit.mjs";
3
+ import { $ as unwindFailedMessage, A as architectureDecomposePrompt, B as designSystem, C as fixRisks, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as packagesItemBuildingPrompt, I as buildReviewTriageMessage, J as reviewEditsCapture, K as packagesItemFixSuitePrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as startGateBlockedMessage, R as deferNotesScript, S as fixCheck, T as reviewMissing, V as designTriagePrompt, W as idleMessage, X as reviewNotesCapture, Z as reviewerSystem, _ as FEEDBACK, b as awaitReview, c as refusePendingHandOff, d as defaults, et as unwindFailure, f as envDefaults, g as ARCHITECTURE, h as healthy, i as skills, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as tail, m as gate, n as summary, o as closeReview, q as reviewEditNotesCapture, r as access, t as steering, u as shipped, v as REQUIREMENTS, w as planner, x as coder, y as REVIEW, z as designGateAnswerMessage } from "../chunks/reserved-6pAgFGgB.mjs";
4
4
  //#region src/workflows/feature/steps.ts
5
5
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
6
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -78,8 +78,6 @@ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes,
78
78
  });
79
79
  //#endregion
80
80
  //#region src/workflows/feature/packages.ts
81
- /** Package 0: the e2e scenarios, written red. */
82
- const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md";
83
81
  const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
84
82
  /** The `## Tests` entries of a package file; malformed lines are ignored. */
85
83
  const declaredTests = (packageText) => {
@@ -109,47 +107,29 @@ const requireDeclaredTests = (pkg, since) => {
109
107
  if (missing.length === 0) return;
110
108
  refuse(`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`);
111
109
  };
112
- /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
113
- const packageItem = async (pkg, frozen) => {
110
+ /**
111
+ * Build `pkg`, check its declared tests, keep the full suite green, and close
112
+ * it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
113
+ */
114
+ const packageItem = async (pkg) => {
114
115
  const from = head();
115
116
  const declared = (turn) => async () => {
116
117
  await turn();
117
118
  requireDeclaredTests(pkg, from);
118
119
  };
119
- await guarded(frozen, declared(() => build(pkg)))();
120
- await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" });
121
- await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), { label: "Closing out the package" });
122
- return {
120
+ await declared(() => build(pkg))();
121
+ await healthy(declared(fixSuite));
122
+ await run("closing", removeScript([
123
123
  pkg,
124
- from,
125
- to: head()
126
- };
124
+ ".gtd/SATISFIED.md",
125
+ ...glob(".gtd/packages/*.md").length === 1 ? [ARCHITECTURE] : []
126
+ ]), { label: "Closing out the package" });
127
127
  };
128
128
  /** The first queued package — the one a package step works on. */
129
129
  const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
130
- const outsideGtd = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
131
130
  /** Build every package file under `.gtd/packages/`, in name order. */
132
- const packages = (carried) => scope("packages", async () => {
133
- let frozen = carried;
134
- const ranges = [];
135
- let touched = [];
136
- for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) {
137
- const range = await scope("item", () => packageItem(pkg, pkg === ".gtd/packages/00-e2e-scenarios.md" ? void 0 : frozen));
138
- ranges.push(range);
139
- if (pkg === ".gtd/packages/00-e2e-scenarios.md") {
140
- touched = changesSince(range.from).filter((c) => outsideGtd(c.path) && c.status !== "deleted");
141
- frozen = freezeScenarios(touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path), frozen);
142
- }
143
- }
144
- const paths = (status) => touched.filter((c) => c.status === status).map((c) => c.path);
145
- return {
146
- ranges,
147
- scenarios: {
148
- added: paths("added"),
149
- changed: paths("modified")
150
- },
151
- frozen
152
- };
131
+ const packages = () => scope("packages", async () => {
132
+ for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
153
133
  });
154
134
  //#endregion
155
135
  //#region src/workflows/feature/planning.ts
@@ -182,7 +162,7 @@ const architecture = () => scope("architecture", async () => {
182
162
  } while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
183
163
  await decompose();
184
164
  if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
185
- if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green");
165
+ if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the packages are built from it; it is removed with the last package");
186
166
  });
187
167
  //#endregion
188
168
  //#region src/workflows/feature/review.ts
@@ -308,7 +288,7 @@ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
308
288
  * another planning lap, questions are answered in place, nits fixed in one
309
289
  * batch, praise dropped.
310
290
  */
311
- const review = (base, escalations = { rounds: 0 }, frozen) => {
291
+ const review = (base, escalations = { rounds: 0 }) => {
312
292
  const severities = /* @__PURE__ */ new Map();
313
293
  let deferred = [];
314
294
  return review$1(base, {
@@ -319,8 +299,8 @@ const review = (base, escalations = { rounds: 0 }, frozen) => {
319
299
  deferred = [];
320
300
  return reviewing(b, carry);
321
301
  },
322
- fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
323
- keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
302
+ fixRisks,
303
+ keepGreen: () => healthy(fixCheck, { escalations }),
324
304
  awaitReview: (b) => {
325
305
  const notes = deferred;
326
306
  deferred = [];
@@ -339,7 +319,7 @@ const review = (base, escalations = { rounds: 0 }, frozen) => {
339
319
  return deferNotes(deferred, head());
340
320
  },
341
321
  answerQuestions: answerReviewQuestions,
342
- fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
322
+ fixNits,
343
323
  close: closeReview,
344
324
  captures: {
345
325
  edits: reviewEditsCapture,
@@ -361,8 +341,8 @@ const review = (base, escalations = { rounds: 0 }, frozen) => {
361
341
  }
362
342
  });
363
343
  };
364
- /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
365
- const buildTail = (fixFirst, base, built) => tail(fixFirst, (escalations, frozen) => review(base, escalations, frozen), built);
344
+ /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
345
+ const buildTail = (fixFirst, base, green = false) => tail(fixFirst, (escalations) => review(base, escalations), green);
366
346
  //#endregion
367
347
  //#region src/workflows/feature/feature.ts
368
348
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
@@ -389,13 +369,11 @@ const reUnwind = async (feedback) => {
389
369
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
390
370
  const planAndBuild = async (firstBase) => {
391
371
  let base = firstBase;
392
- let carried;
393
372
  for (;;) {
394
373
  await design(base);
395
374
  await architecture();
396
- const built = await packages(carried);
397
- carried = built.frozen;
398
- const outcome = await buildTail(false, base, built);
375
+ await packages();
376
+ const outcome = await buildTail(false, base, true);
399
377
  if (outcome.verdict === "signoff") return;
400
378
  await reUnwind(outcome);
401
379
  base = outcome.base;
@@ -429,4 +407,4 @@ const feature = shipped(ordinaryStart);
429
407
  //#region src/workflows/feature/index.ts
430
408
  var feature_default = feature;
431
409
  //#endregion
432
- export { SCENARIO_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, requireDeclaredTests, skills, steering, summary, triage, unwind };
410
+ export { access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, requireDeclaredTests, skills, steering, summary, triage, unwind };
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { O as agentWithSkills, S as coder, U as finisherSystem, W as fixReviewNotesPrompt, a as REVIEW_ORIG, b as REVIEW, d as shipped, f as defaults, i as skills, m as baseline, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-B9pG93Fc.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, a as REVIEW_ORIG, d as defaults, f as envDefaults, i as skills, n as summary, p as baseline, r as access, s as handOffTail, t as steering, u as shipped, x as coder, y as REVIEW } from "../chunks/reserved-6pAgFGgB.mjs";
4
4
  //#region src/workflows/fix/fix.ts
5
5
  /**
6
6
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,5 +1,5 @@
1
1
  import { start } from "../flows.mjs";
2
- import { X as reviewGateBlockedMessage, c as refusePendingHandOff, d as shipped, f as defaults, h as gate, i as skills, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-B9pG93Fc.mjs";
2
+ import { Y as reviewGateBlockedMessage, c as refusePendingHandOff, d as defaults, f as envDefaults, i as skills, m as gate, n as summary, r as access, s as handOffTail, t as steering, u as shipped } from "../chunks/reserved-6pAgFGgB.mjs";
3
3
  //#region src/workflows/review/review.ts
4
4
  /**
5
5
  * Pure review of everything since `reviewBase`: a green gate, the quality