@pmelab/gtd 27.2.0 → 27.4.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
  4. package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
  5. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  6. package/dist/flows.mjs +24 -1
  7. package/dist/gtd.bundle.mjs +174 -23
  8. package/dist/testkit.mjs +327 -27
  9. package/dist/wire.mjs +1 -1
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +119 -6
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +146 -8
  16. package/package.json +1 -1
  17. package/src/cli/Cli.ts +5 -5
  18. package/src/cli/History.ts +5 -0
  19. package/src/cli/Program.ts +1 -0
  20. package/src/edge/Edge.test.ts +160 -0
  21. package/src/edge/Edge.ts +90 -8
  22. package/src/emit/GitScript.test.ts +39 -3
  23. package/src/emit/GitScript.ts +25 -1
  24. package/src/emit/index.ts +2 -0
  25. package/src/flows/helpers.ts +28 -0
  26. package/src/flows/runtime.ts +7 -0
  27. package/src/install/Install.ts +2 -1
  28. package/src/platform/Git.test.ts +25 -1
  29. package/src/platform/Git.ts +30 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +16 -1
  32. package/src/replay/Replay.test.ts +91 -0
  33. package/src/replay/Replay.ts +22 -2
  34. package/src/replay/Trailers.test.ts +110 -1
  35. package/src/replay/Trailers.ts +37 -1
  36. package/src/replay/index.ts +2 -0
  37. package/src/replay/seamGate.test.ts +62 -0
  38. package/src/step/LandStep.ts +1 -0
  39. package/src/step/planStep.test.ts +32 -0
  40. package/src/step/planStep.ts +3 -1
  41. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  42. package/src/testkit/EmittedScriptRecognizer.ts +33 -0
  43. package/src/testkit/EmittedScripts.test.ts +9 -0
  44. package/src/testkit/FakeGitOperations.ts +5 -0
  45. package/src/testkit/GitTiers.ts +158 -0
  46. package/src/testkit/InMemRepo.ts +38 -3
  47. package/src/wire/BeatDocument.test.ts +25 -0
  48. package/src/wire/BeatDocument.ts +4 -2
  49. package/src/wire/BeatPlain.test.ts +1 -0
  50. package/src/wire/BeatStatus.test.ts +2 -0
  51. package/src/wire/BeatStatus.ts +13 -2
  52. package/src/wire/WireSchema.test.ts +10 -0
  53. package/src/wire/types.ts +8 -0
  54. package/src/workflow/Workflow.ts +1 -0
  55. package/src/workflows/feature/index.ts +1 -0
  56. package/src/workflows/feature/refactor.test.ts +466 -0
  57. package/src/workflows/feature/refactor.ts +178 -0
  58. package/src/workflows/index.ts +1 -0
  59. package/src/workflows/shared/access.ts +2 -1
  60. package/src/workflows/shared/health.test.ts +67 -3
  61. package/src/workflows/shared/health.ts +5 -3
  62. package/src/workflows/shared/options.test.ts +8 -0
  63. package/src/workflows/shared/options.ts +1 -0
  64. package/src/workflows/shared/skills.test.ts +6 -0
  65. package/src/workflows/shared/skills.ts +5 -1
  66. package/src/workflows/shared/text.ts +35 -0
  67. package/src/workflows/shared/vars.ts +1 -0
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-BY1S-obW.mjs";
1
+ import { C as EXIT_SIGTERM, D as UNATTRIBUTED_MODEL, E as ACCESS_REFUSAL, O as WIRE_SCHEMA, S as EXIT_SIGINT, T as EXIT_USAGE_ERROR, _ as demandOf, a as landFields, b as EXIT_OK, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, h as BEAT_KINDS, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, o as landProseText, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches, u as DEMAND_BRIEFING, v as stallDiagnosis, w as EXIT_STRAYS_FOUND, x as EXIT_RUNTIME_ERROR, y as EXIT_CODES } from "./chunks/wire-C12dahrH.mjs";
2
2
  export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, resolveSession, selectPath, stallDiagnosis, statusOf };
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BnhIxCYv.mjs";
2
+ import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-iLAMoigl.mjs";
4
4
  import { i as requireFlag, r as registryEntry, t as flagReferences } from "../chunks/registry-CP3YuV9j.mjs";
5
5
  //#region src/workflows/cleanup/cleanup.ts
6
6
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
@@ -1,7 +1,7 @@
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";
1
+ import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
2
2
  import { review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as reviewerSystem, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as packagesItemFixSuitePrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as reviewNotesCapture, R as builderSystem, T as reviewMissing, V as designSystem, X as reviewEditsCapture, Y as reviewEditNotesCapture, _ as REQUIREMENTS, b as coder, c as envDefaults, d as gate, et as startGateBlockedMessage, h as FEEDBACK, i as skills, j as architectureDecomposePrompt, k as architectSystem, m as ARCHITECTURE, n as summary, nt as unwindFailure, o as shipped, p as healthy, q as packagesItemBuildingPrompt, r as access, s as defaults, t as steering, tt as unwindFailedMessage, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-B2QaImnN.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-BnhIxCYv.mjs";
3
+ import { A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as packagesItemFixSuitePrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as reviewEditsCapture, R as builderSystem, T as reviewMissing, V as designSystem, X as refactorFixPrompt, Y as refactorBuildingPrompt, Z as reviewEditNotesCapture, _ as REQUIREMENTS, b as coder, c as envDefaults, d as gate, et as reviewNotesCapture, h as FEEDBACK, i as skills, it as unwindFailure, j as architectureDecomposePrompt, k as architectSystem, m as ARCHITECTURE, n as summary, nt as startGateBlockedMessage, o as shipped, p as healthy, q as packagesItemBuildingPrompt, r as access, rt as unwindFailedMessage, s as defaults, t as steering, tt as reviewerSystem, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-BPdVZkgv.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-iLAMoigl.mjs";
5
5
  //#region src/workflows/feature/steps.ts
6
6
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
7
7
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -282,7 +282,7 @@ const triage$1 = (severities) => async (notes) => {
282
282
  return [n.id, severity.tier === "could" ? "defer" : "edit"];
283
283
  }));
284
284
  };
285
- const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
285
+ const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
286
286
  /**
287
287
  * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
288
288
  * reviews and signs off or comments, and each note is judged: edits go to
@@ -295,7 +295,7 @@ const review = (base, escalations = { rounds: 0 }) => {
295
295
  return review$1(base, {
296
296
  file: REVIEW,
297
297
  feedback: REQUIREMENTS,
298
- isCode,
298
+ isCode: isCode$1,
299
299
  reviewing: (b, carry) => {
300
300
  deferred = [];
301
301
  return reviewing(b, carry);
@@ -405,7 +405,120 @@ const ordinaryStart = async () => {
405
405
  };
406
406
  const feature = shipped(ordinaryStart);
407
407
  //#endregion
408
+ //#region src/workflows/feature/refactor.ts
409
+ const REFACTOR_PACKAGE = ".gtd/REFACTOR.md";
410
+ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
411
+ const requireWithin = (allowed) => {
412
+ const outside = changes().map((c) => c.path).filter((path) => isCode(path) && !allowed.includes(path));
413
+ if (outside.length === 0) return;
414
+ refuse(`gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`);
415
+ };
416
+ const flagExpression = (pattern) => {
417
+ if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
418
+ let expression;
419
+ try {
420
+ expression = new RegExp(pattern, "g");
421
+ } catch (error) {
422
+ throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`);
423
+ }
424
+ if (expression.test("")) throw new Error("gtd: refactorFlagPattern must not match the empty string");
425
+ return expression;
426
+ };
427
+ const countFlags = (text, expression, into) => {
428
+ let zeroLength = false;
429
+ for (const [name] of text.matchAll(expression)) if (name === "") zeroLength = true;
430
+ else into.set(name, (into.get(name) ?? 0) + 1);
431
+ return zeroLength;
432
+ };
433
+ const listPaths = (paths) => paths.map((path) => ` - ${path}`).join("\n");
434
+ const requireNoNewFlags = (pattern, since) => {
435
+ const expression = flagExpression(pattern);
436
+ const before = /* @__PURE__ */ new Map();
437
+ const after = /* @__PURE__ */ new Map();
438
+ const unreadable = [];
439
+ const zeroLength = [];
440
+ for (const { path, status, before: was, after: now } of changesSince(since)) {
441
+ if (!isCode(path)) continue;
442
+ const oldSide = status === "added" ? "" : was;
443
+ const newSide = status === "deleted" ? "" : now;
444
+ if (oldSide === void 0 || newSide === void 0) {
445
+ unreadable.push(path);
446
+ continue;
447
+ }
448
+ countFlags(oldSide, expression, before);
449
+ if (countFlags(newSide, expression, after)) zeroLength.push(path);
450
+ }
451
+ if (unreadable.length > 0) refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`);
452
+ if (zeroLength.length > 0) refuse(`gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`);
453
+ const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0));
454
+ if (raised.length === 0) return;
455
+ refuse(`gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`);
456
+ };
457
+ const packageLines = (strays, pattern, reference) => [
458
+ "# Behaviour-neutral refactor",
459
+ "",
460
+ "## Scope (read-only)",
461
+ "",
462
+ ...strays.map((path) => `- \`${path}\``),
463
+ "",
464
+ "This list is fixed when the package is derived: editing it here widens or narrows nothing.",
465
+ "",
466
+ "## Rules",
467
+ "",
468
+ "- The suite's outcome must not change",
469
+ "- Change only the paths above; a test file only when it is one of them",
470
+ `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
471
+ "",
472
+ "## Reference",
473
+ "",
474
+ reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
475
+ ""
476
+ ];
477
+ const deriveScript = (lines) => `mkdir -p .gtd
478
+ printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
479
+ `;
480
+ const refactorFirst = async (shape, options = {}) => {
481
+ if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false;
482
+ const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern;
483
+ const expression = flagExpression(pattern);
484
+ const { strays } = shape;
485
+ const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, /* @__PURE__ */ new Map()));
486
+ if (zeroLength.length > 0) throw new Error(`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`);
487
+ const reference = options.reference?.trim() ? options.reference : void 0;
488
+ return scope({
489
+ name: "refactor",
490
+ access: { write: [...strays, ".gtd/**"] }
491
+ }, async () => {
492
+ await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
493
+ label: "Deriving the refactor package",
494
+ file: REFACTOR_PACKAGE
495
+ });
496
+ const from = head();
497
+ const guard = () => {
498
+ requireWithin(strays);
499
+ requireNoNewFlags(pattern, from);
500
+ };
501
+ await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference), {
502
+ label: "Refactoring",
503
+ model: coder(),
504
+ system: builderSystem()
505
+ });
506
+ guard();
507
+ await healthy(async () => {
508
+ await agentWithSkills("fix.suite.fixing", refactorFixPrompt(strays, pattern), {
509
+ label: "Fixing the check",
510
+ file: FEEDBACK,
511
+ model: coder(),
512
+ system: builderSystem()
513
+ });
514
+ guard();
515
+ }, { describeGuard: guard });
516
+ await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
517
+ return true;
518
+ });
519
+ };
520
+ //#endregion
408
521
  //#region src/workflows/feature/index.ts
409
522
  var feature_default = feature;
410
523
  //#endregion
411
- 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 };
524
+ export { REFACTOR_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, refactorFirst, requireDeclaredTests, requireNoNewFlags, requireWithin, skills, steering, summary, triage, unwind };
@@ -1,7 +1,7 @@
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, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-B2QaImnN.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-BnhIxCYv.mjs";
3
+ import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-BPdVZkgv.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-iLAMoigl.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { Z as reviewGateBlockedMessage, c as envDefaults, d as gate, i as skills, n as summary, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BnhIxCYv.mjs";
2
+ import { $ as reviewGateBlockedMessage, c as envDefaults, d as gate, i as skills, n as summary, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-iLAMoigl.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, head, read, refuse, scope, vars } from "../flows.mjs";
2
- import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
2
+ import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-BPdVZkgv.mjs";
3
3
  import { i as requireFlag, n as flipProblems } from "../chunks/registry-CP3YuV9j.mjs";
4
4
  //#region src/workflows/rollout/rollout.ts
5
5
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
@@ -250,9 +250,9 @@ const check = async (name, command, options) => {
250
250
  }), { label });
251
251
  return !wrote(report);
252
252
  };
253
- const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
253
+ const isCode$2 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
254
254
  /** The last step's changes outside `.gtd/`. */
255
- const codeChanges = () => changes().filter((c) => isCode$1(c.path));
255
+ const codeChanges = () => changes().filter((c) => isCode$2(c.path));
256
256
  /** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
257
257
  const requireProgress = (file) => {
258
258
  const change = changes().get(file);
@@ -17235,6 +17235,26 @@ ${fixFeedbackPrompt}
17235
17235
  then legitimately report itself already satisfied later
17236
17236
  - Leave everything uncommitted and finish your turn — do not commit
17237
17237
  `;
17238
+ const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
17239
+ - Change only these paths, nothing else — a test file only when it is
17240
+ one of them:
17241
+ ${strays.map((path) => ` - \`${path}\``).join("\n")}
17242
+ - Introduce no new flag read and add no second read of an existing one:
17243
+ every match of \`${pattern}\` may move, never increase in number`;
17244
+ const refactorBuildingPrompt = (pkg, strays, pattern, reference) => `${stateFileRules}
17245
+ - The package to implement is \`${pkg}\`; never delete it
17246
+ ${refactorRules(strays, pattern)}
17247
+ ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
17248
+ earlier attempt at this change. Read it for ideas; never apply,
17249
+ cherry-pick or check it out — write a fresh refactor
17250
+ `}- Leave everything uncommitted and finish your turn — do not commit
17251
+ `;
17252
+ const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
17253
+ ${fixFeedbackPrompt}
17254
+ ${refactorRules(strays, pattern)}
17255
+ - Never do another package's work, even when it would green the suite
17256
+ - Leave everything uncommitted and finish your turn — do not commit
17257
+ `;
17238
17258
  const healthJudgeMessage = () => `The check is still red, and this isn't the first round —
17239
17259
  \`.gtd/FEEDBACK.md\` holds this round's output, and the previous round's
17240
17260
  is in history. Run \`gtd judge answer\` and pipe a
@@ -17809,9 +17829,9 @@ const guarded = (turn, guard) => async (...a) => {
17809
17829
  await turn(...a);
17810
17830
  guard?.();
17811
17831
  };
17812
- const guardedSteps = (guard) => ({
17832
+ const guardedSteps = (guard, describeGuard) => ({
17813
17833
  limit: escalationSteps.limit,
17814
- describe: guarded(escalationSteps.describe, guard),
17834
+ describe: guarded(guarded(escalationSteps.describe, guard), describeGuard),
17815
17835
  stop: guarded(escalationSteps.stop, guard),
17816
17836
  exhausted: guarded(escalationSteps.exhausted, guard)
17817
17837
  });
@@ -17863,7 +17883,7 @@ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
17863
17883
  fixesSoFar: options.fixesSoFar ?? 0,
17864
17884
  sameFailure,
17865
17885
  escalations: options.escalations ?? { rounds: 0 },
17866
- escalation: guardedSteps(options.guard)
17886
+ escalation: guardedSteps(options.guard, options.describeGuard)
17867
17887
  });
17868
17888
  /**
17869
17889
  * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
@@ -37198,6 +37218,7 @@ const defaults = {
37198
37218
  judgeIdenticalMinP: "0.7",
37199
37219
  reviewNoteActionable: "0.7",
37200
37220
  reviewSeverityMinP: "0.7",
37221
+ refactorFlagPattern: "\\bGTD_FLAG_\\w+",
37201
37222
  qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge"
37202
37223
  };
37203
37224
  /** Environment settings. */
@@ -37215,6 +37236,7 @@ const ShippedOptions = Struct({
37215
37236
  judgeIdenticalMinP: setting,
37216
37237
  reviewNoteActionable: setting,
37217
37238
  reviewSeverityMinP: setting,
37239
+ refactorFlagPattern: setting,
37218
37240
  qualityReviews: setting,
37219
37241
  testCommand: setting,
37220
37242
  plannerModel: setting,
@@ -37614,7 +37636,7 @@ const triage$1 = (severities) => async (notes) => {
37614
37636
  return [n.id, severity.tier === "could" ? "defer" : "edit"];
37615
37637
  }));
37616
37638
  };
37617
- const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
37639
+ const isCode$1 = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
37618
37640
  /**
37619
37641
  * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
37620
37642
  * reviews and signs off or comments, and each note is judged: edits go to
@@ -37627,7 +37649,7 @@ const review$1 = (base, escalations = { rounds: 0 }) => {
37627
37649
  return review$2(base, {
37628
37650
  file: REVIEW,
37629
37651
  feedback: REQUIREMENTS,
37630
- isCode,
37652
+ isCode: isCode$1,
37631
37653
  reviewing: (b, carry) => {
37632
37654
  deferred = [];
37633
37655
  return reviewing(b, carry);
@@ -37756,6 +37778,9 @@ const skills = (vars) => ({
37756
37778
  "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
37757
37779
  "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
37758
37780
  notes: ["incremental-implementation", "code-simplification"],
37781
+ refactor: ["ponytail"],
37782
+ "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
37783
+ "refactor.health": ["ponytail", "debugging-and-error-recovery"],
37759
37784
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), (lens) => builtInLenses[lens]?.skills ?? [lens])
37760
37785
  });
37761
37786
  //#endregion
@@ -37779,6 +37804,119 @@ const steering = {
37779
37804
  [REVIEW]: "review"
37780
37805
  };
37781
37806
  //#endregion
37807
+ //#region src/workflows/feature/refactor.ts
37808
+ const REFACTOR_PACKAGE = ".gtd/REFACTOR.md";
37809
+ const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
37810
+ const requireWithin = (allowed) => {
37811
+ const outside = changes().map((c) => c.path).filter((path) => isCode(path) && !allowed.includes(path));
37812
+ if (outside.length === 0) return;
37813
+ refuse(`gtd land: refactor-scope: the refactor may change only its stray paths — revert these:\n${outside.map((path) => ` - ${path}`).join("\n")}`);
37814
+ };
37815
+ const flagExpression = (pattern) => {
37816
+ if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
37817
+ let expression;
37818
+ try {
37819
+ expression = new RegExp(pattern, "g");
37820
+ } catch (error) {
37821
+ throw new Error(`gtd: refactorFlagPattern is not a valid regular expression: ${String(error)}`);
37822
+ }
37823
+ if (expression.test("")) throw new Error("gtd: refactorFlagPattern must not match the empty string");
37824
+ return expression;
37825
+ };
37826
+ const countFlags = (text, expression, into) => {
37827
+ let zeroLength = false;
37828
+ for (const [name] of text.matchAll(expression)) if (name === "") zeroLength = true;
37829
+ else into.set(name, (into.get(name) ?? 0) + 1);
37830
+ return zeroLength;
37831
+ };
37832
+ const listPaths = (paths) => paths.map((path) => ` - ${path}`).join("\n");
37833
+ const requireNoNewFlags = (pattern, since) => {
37834
+ const expression = flagExpression(pattern);
37835
+ const before = /* @__PURE__ */ new Map();
37836
+ const after = /* @__PURE__ */ new Map();
37837
+ const unreadable = [];
37838
+ const zeroLength = [];
37839
+ for (const { path, status, before: was, after: now } of changesSince(since)) {
37840
+ if (!isCode(path)) continue;
37841
+ const oldSide = status === "added" ? "" : was;
37842
+ const newSide = status === "deleted" ? "" : now;
37843
+ if (oldSide === void 0 || newSide === void 0) {
37844
+ unreadable.push(path);
37845
+ continue;
37846
+ }
37847
+ countFlags(oldSide, expression, before);
37848
+ if (countFlags(newSide, expression, after)) zeroLength.push(path);
37849
+ }
37850
+ if (unreadable.length > 0) refuse(`gtd land: refactor-flags: cannot compare flag reads in:\n${listPaths(unreadable)}`);
37851
+ if (zeroLength.length > 0) refuse(`gtd land: refactor-flags: refactorFlagPattern (${pattern}) produced a zero-length match in:\n${listPaths(zeroLength)}`);
37852
+ const raised = [...after].filter(([name, count]) => count > (before.get(name) ?? 0));
37853
+ if (raised.length === 0) return;
37854
+ refuse(`gtd land: refactor-flags: the refactor may not introduce or add reads of a flag (${pattern}):\n${raised.map(([name, count]) => ` - ${name}: ${before.get(name) ?? 0} → ${count}`).join("\n")}`);
37855
+ };
37856
+ const packageLines = (strays, pattern, reference) => [
37857
+ "# Behaviour-neutral refactor",
37858
+ "",
37859
+ "## Scope (read-only)",
37860
+ "",
37861
+ ...strays.map((path) => `- \`${path}\``),
37862
+ "",
37863
+ "This list is fixed when the package is derived: editing it here widens or narrows nothing.",
37864
+ "",
37865
+ "## Rules",
37866
+ "",
37867
+ "- The suite's outcome must not change",
37868
+ "- Change only the paths above; a test file only when it is one of them",
37869
+ `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
37870
+ "",
37871
+ "## Reference",
37872
+ "",
37873
+ reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
37874
+ ""
37875
+ ];
37876
+ const deriveScript = (lines) => `mkdir -p .gtd
37877
+ printf '%s\\n' ${lines.map(quote).join(" ")} > ${quote(REFACTOR_PACKAGE)}
37878
+ `;
37879
+ const refactorFirst = async (shape, options = {}) => {
37880
+ if (shape.choice !== "refactor-first" || shape.unreadable.length > 0) return false;
37881
+ const pattern = vars.refactorFlagPattern ?? defaults.refactorFlagPattern;
37882
+ const expression = flagExpression(pattern);
37883
+ const { strays } = shape;
37884
+ const zeroLength = strays.filter((path) => countFlags(read(path) ?? "", expression, /* @__PURE__ */ new Map()));
37885
+ if (zeroLength.length > 0) throw new Error(`gtd: refactorFlagPattern (${pattern}) produces a zero-length match in:\n${listPaths(zeroLength)}`);
37886
+ const reference = options.reference?.trim() ? options.reference : void 0;
37887
+ return scope({
37888
+ name: "refactor",
37889
+ access: { write: [...strays, ".gtd/**"] }
37890
+ }, async () => {
37891
+ await run("deriving", deriveScript(packageLines(strays, pattern, reference)), {
37892
+ label: "Deriving the refactor package",
37893
+ file: REFACTOR_PACKAGE
37894
+ });
37895
+ const from = head$1();
37896
+ const guard = () => {
37897
+ requireWithin(strays);
37898
+ requireNoNewFlags(pattern, from);
37899
+ };
37900
+ await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference), {
37901
+ label: "Refactoring",
37902
+ model: coder(),
37903
+ system: builderSystem()
37904
+ });
37905
+ guard();
37906
+ await healthy(async () => {
37907
+ await agentWithSkills("fix.suite.fixing", refactorFixPrompt(strays, pattern), {
37908
+ label: "Fixing the check",
37909
+ file: FEEDBACK,
37910
+ model: coder(),
37911
+ system: builderSystem()
37912
+ });
37913
+ guard();
37914
+ }, { describeGuard: guard });
37915
+ await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
37916
+ return true;
37917
+ });
37918
+ };
37919
+ //#endregion
37782
37920
  //#region src/workflows/review/review.ts
37783
37921
  /**
37784
37922
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -37996,4 +38134,4 @@ var workflows_default = feature;
37996
38134
  */
37997
38135
  const doors = {};
37998
38136
  //#endregion
37999
- export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, 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, guarded, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, rollout, rolloutFlow, skills, steering, summary, tail, threads, triage, unwind };
38137
+ export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, 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, guarded, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorFirst, requireDeclaredTests, requireNoNewFlags, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, rollout, rolloutFlow, skills, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.2.0",
3
+ "version": "27.4.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -705,11 +705,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
705
705
  arity: { name: "ref" },
706
706
  details: [
707
707
  "Replay one kept process under today's config and print its",
708
- "steps and judge verdicts. <ref> is a full ref, an entry hash",
709
- "(or unique prefix) or a branch name. Refuses (exit 1) when a",
710
- "config file differs from its content at the ref's tip. A",
711
- "version mismatch only warns on stderr. --json prints the",
712
- "trail as one document; writes nothing",
708
+ "steps, judge verdicts and approvals. <ref> is a full ref, an",
709
+ "entry hash (or unique prefix) or a branch name. Refuses",
710
+ "(exit 1) when a config file differs from its content at the",
711
+ "ref's tip. A version mismatch only warns on stderr. --json",
712
+ "prints the trail as one document; writes nothing",
713
713
  ],
714
714
  },
715
715
  {
@@ -128,6 +128,11 @@ export const runHistoryShowCommand = (
128
128
  for (const step of trail.steps) {
129
129
  lines.push(`${seven(step.hash)} ${step.subject}`)
130
130
  for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`)
131
+ if (step.approval !== undefined) {
132
+ lines.push(
133
+ ` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`,
134
+ )
135
+ }
131
136
  }
132
137
  lines.push(`ended (${trail.ended})`)
133
138
  out.write(`${lines.join("\n")}\n`)
@@ -1080,6 +1080,7 @@ const gatherBeatDocument = (
1080
1080
  next: yield* computeNextMatch(rest),
1081
1081
  cost: rest.cost.total,
1082
1082
  costByModel: rest.cost.byModel,
1083
+ approvals: rest.run.approvals,
1083
1084
  })
1084
1085
  return beatDocument(demand, status)
1085
1086
  })
@@ -151,6 +151,7 @@ describe("currentRun", () => {
151
151
  trace: [],
152
152
  costEntries: [],
153
153
  judgeVerdicts: [],
154
+ approvals: [],
154
155
  pinnedVars: {},
155
156
  headTurn: undefined,
156
157
  closingHash: undefined,
@@ -1306,6 +1307,28 @@ export default async () => {
1306
1307
  expect(trail.steps.filter((s) => s.verdicts.length > 0)).toHaveLength(1)
1307
1308
  })
1308
1309
 
1310
+ it("sets approval on the approval step only", async () => {
1311
+ const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
1312
+
1313
+ export default async () => {
1314
+ await human("idle")
1315
+ await human("gate", { message: "seam report", approve: true })
1316
+ await human("after")
1317
+ }
1318
+ `)
1319
+ await land(repo, { "src/a.ts": "a\n" })
1320
+ const approval = await land(repo)
1321
+ const tip = await land(repo, { "src/b.ts": "b\n" })
1322
+ const trail = await provide(historyTrail(tip), repo)
1323
+ expect(trail.steps.find((s) => s.hash === approval)?.approval).toEqual({
1324
+ step: "gate#1",
1325
+ name: "Test",
1326
+ email: "test@test.com",
1327
+ commit: approval,
1328
+ })
1329
+ expect(trail.steps.filter((s) => s.approval !== undefined)).toHaveLength(1)
1330
+ })
1331
+
1309
1332
  it("records the version and warns about none while they match", async () => {
1310
1333
  const { repo, tip } = await finished()
1311
1334
  const trail = await provide(historyTrail(tip), repo)
@@ -1369,3 +1392,140 @@ export default async () => {
1369
1392
  expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
1370
1393
  })
1371
1394
  })
1395
+
1396
+ describe("landing at an approve rest", () => {
1397
+ const GATED = `import { human } from "@pmelab/gtd/flows"
1398
+
1399
+ export default async () => {
1400
+ await human("idle")
1401
+ await human("gate", { message: "seam report", approve: true })
1402
+ await human("after")
1403
+ }
1404
+ `
1405
+
1406
+ const atGate = async (): Promise<InMemRepo> => {
1407
+ const repo = repoWith(GATED)
1408
+ await land(repo, { "src/a.ts": "a\n" })
1409
+ return repo
1410
+ }
1411
+
1412
+ const snapshotAt = async (repo: InMemRepo, git?: GitOperations) => {
1413
+ const rest = await provide(currentRest, repo)
1414
+ const effect = snapshotFromRest(rest)
1415
+ return git === undefined ? await provide(effect, repo) : await provideWithGit(effect, git, repo)
1416
+ }
1417
+
1418
+ it("rests at a message step carrying approve", async () => {
1419
+ const rest = await provide(currentRest, await atGate())
1420
+ expect(rest.state).toBe("gate")
1421
+ expect(rest.stepDef).toMatchObject({ kind: "message", content: "seam report", approve: true })
1422
+ })
1423
+
1424
+ it("refuses a dirty tree and commits nothing", async () => {
1425
+ const repo = await atGate()
1426
+ const head = headOf(repo)
1427
+ repo.writeFile("src/edited.ts", "edit\n")
1428
+ const { landing } = await snapshotAt(repo)
1429
+ expect(landing).toEqual({
1430
+ kind: "refusal",
1431
+ message:
1432
+ "gtd land: approval-gate: gate#1 approves exactly the judged diff — revert your edits and run `gtd land` on a clean tree to approve, or run `gtd abandon`",
1433
+ })
1434
+ expect(headOf(repo)).toBe(head)
1435
+ })
1436
+
1437
+ const stageThenRevert = (repo: InMemRepo): void => {
1438
+ repo.writeFile("src/a.ts", "edited\n")
1439
+ repo.stageAll()
1440
+ repo.writeFile("src/a.ts", "a\n")
1441
+ }
1442
+
1443
+ it("refuses an edit staged and then reverted in the worktree", async () => {
1444
+ const repo = await atGate()
1445
+ stageThenRevert(repo)
1446
+ expect((await snapshotAt(repo)).landing).toMatchObject({
1447
+ kind: "refusal",
1448
+ message: expect.stringContaining("approves exactly the judged diff"),
1449
+ })
1450
+ })
1451
+
1452
+ it("counts no index-only edit at a rest that approves nothing", async () => {
1453
+ const repo = await atGate()
1454
+ await land(repo)
1455
+ stageThenRevert(repo)
1456
+ const rest = await provide(currentRest, repo)
1457
+ expect(rest.state).toBe("after")
1458
+ expect(rest.changes).toEqual([])
1459
+ expect((await snapshotAt(repo)).landing).toEqual({ kind: "noop", settled: false })
1460
+ })
1461
+
1462
+ it("refuses a clean tree when git has no author identity", async () => {
1463
+ const repo = await atGate()
1464
+ const git = { ...fakeGitOperations(repo), authorIdent: () => Effect.succeed(Option.none()) }
1465
+ const { landing } = await snapshotAt(repo, git)
1466
+ expect(landing).toEqual({
1467
+ kind: "refusal",
1468
+ message:
1469
+ "gtd land: approval-gate: gate#1 records who approved it, but git has no author identity — set user.name and user.email",
1470
+ })
1471
+ })
1472
+
1473
+ it("refuses an identity a trailer cannot carry", async () => {
1474
+ const repo = await atGate()
1475
+ const git = {
1476
+ ...fakeGitOperations(repo),
1477
+ authorIdent: () => Effect.succeed(Option.some({ name: "Ada\u001b[31m", email: "a@x.io" })),
1478
+ }
1479
+ expect((await snapshotAt(repo, git)).landing).toEqual({
1480
+ kind: "refusal",
1481
+ message:
1482
+ "gtd land: approval-gate: gate#1 cannot record Ada\u001b[31m <a@x.io> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)",
1483
+ })
1484
+ })
1485
+
1486
+ it("approve implies acceptClean even when acceptClean is false", async () => {
1487
+ const repo = repoWith(GATED.replace("approve: true", "approve: true, acceptClean: false"))
1488
+ await land(repo, { "src/a.ts": "a\n" })
1489
+ expect((await snapshotAt(repo)).landing.kind).toBe("commit")
1490
+ })
1491
+
1492
+ it("lands a clean tree as a commit carrying the approver", async () => {
1493
+ const repo = await atGate()
1494
+ const { landing } = await snapshotAt(repo)
1495
+ if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
1496
+ expect(landing.to).toBe("after")
1497
+ expect(landing.spec.approved).toEqual({
1498
+ step: { name: "gate", occurrence: 1 },
1499
+ name: "Test",
1500
+ email: "test@test.com",
1501
+ })
1502
+ })
1503
+
1504
+ it("reads the author identity only at an approve rest", async () => {
1505
+ const repo = repoWith(GATED)
1506
+ repo.writeFile("src/a.ts", "a\n")
1507
+ const git = {
1508
+ ...fakeGitOperations(repo),
1509
+ authorIdent: () => Effect.die(new Error("authorIdent read at a plain rest")),
1510
+ }
1511
+ expect((await snapshotAt(repo, git)).landing.kind).toBe("commit")
1512
+ })
1513
+
1514
+ it("collects each approval of the current process, oldest first", async () => {
1515
+ const repo = await atGate()
1516
+ expect((await provide(currentRun, repo)).approvals).toEqual([])
1517
+ const approval = await land(repo)
1518
+ expect((await provide(currentRun, repo)).approvals).toEqual([
1519
+ { step: "gate#1", name: "Test", email: "test@test.com", commit: approval },
1520
+ ])
1521
+ })
1522
+
1523
+ it("resumes past the approval commit without asking again", async () => {
1524
+ const repo = await atGate()
1525
+ await land(repo)
1526
+ expect(repo.lastCommitMessage()).toContain(
1527
+ 'Gtd-Approved: {"step":"gate#1","name":"Test","email":"test@test.com"}',
1528
+ )
1529
+ expect((await provide(currentRest, repo)).state).toBe("after")
1530
+ })
1531
+ })