@pmelab/gtd 28.1.0 → 29.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -17
  3. package/claude/hooks/assist.ts +1 -1
  4. package/claude/hooks/register.tsx +1 -1
  5. package/claude/hooks/ship.ts +963 -143
  6. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  7. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  8. package/dist/testkit.mjs +2 -1
  9. package/dist/workflow-kit.mjs +51 -21
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +247 -96
  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 +528 -262
  16. package/package.json +1 -1
  17. package/src/testkit/TestRepo.ts +3 -0
  18. package/src/testkit/index.ts +1 -1
  19. package/src/testkit/steps.ts +44 -0
  20. package/src/workflow-kit/review.test.ts +366 -8
  21. package/src/workflow-kit/review.ts +109 -26
  22. package/src/workflows/feature/feature.test.ts +313 -1
  23. package/src/workflows/feature/feature.ts +48 -43
  24. package/src/workflows/feature/one-step-land.feature +4 -0
  25. package/src/workflows/feature/refactor-first.feature +54 -24
  26. package/src/workflows/feature/refactor-review.feature +1706 -0
  27. package/src/workflows/feature/refactor.test.ts +592 -2
  28. package/src/workflows/feature/refactor.ts +160 -42
  29. package/src/workflows/feature/review.test.ts +322 -5
  30. package/src/workflows/feature/review.ts +77 -10
  31. package/src/workflows/feature/seam-rejection.feature +4 -0
  32. package/src/workflows/feature/seam.feature +3 -4
  33. package/src/workflows/feature/seam.test.ts +196 -1
  34. package/src/workflows/feature/seam.ts +23 -9
  35. package/src/workflows/feature/steps.test.ts +54 -2
  36. package/src/workflows/feature/steps.ts +45 -9
  37. package/src/workflows/feature/tier.test.ts +4 -0
  38. package/src/workflows/feature/tier.ts +2 -20
  39. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  40. package/src/workflows/shared/access.test.ts +31 -1
  41. package/src/workflows/shared/access.ts +7 -1
  42. package/src/workflows/shared/fences.ts +19 -0
  43. package/src/workflows/shared/health.ts +32 -12
  44. package/src/workflows/shared/options.test.ts +18 -0
  45. package/src/workflows/shared/options.ts +1 -0
  46. package/src/workflows/shared/prose.ts +4 -2
  47. package/src/workflows/shared/skills.test.ts +20 -1
  48. package/src/workflows/shared/skills.ts +43 -32
  49. package/src/workflows/shared/steps.ts +33 -19
  50. package/src/workflows/shared/tail.test.ts +84 -1
  51. package/src/workflows/shared/tail.ts +30 -12
  52. package/src/workflows/shared/text.test.ts +266 -4
  53. package/src/workflows/shared/text.ts +183 -70
  54. package/src/workflows/shared/vars.ts +12 -2
@@ -29,16 +29,29 @@ export const REVIEW_ORIG = ".gtd/REVIEW.orig.md"
29
29
  * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
30
30
  * has any.
31
31
  */
32
- export const qualityLap = (guard?: Guard): Promise<"clean" | "findings"> =>
33
- kit.qualityLap(kit.lensesOf(vars.qualityReviews ?? ""), guarded(reviewQuality, guard), QUALITY)
32
+ export const qualityLap = (
33
+ guard?: Guard,
34
+ lenses?: readonly string[],
35
+ base?: string,
36
+ ): Promise<"clean" | "findings"> =>
37
+ kit.qualityLap(
38
+ lenses ?? kit.lensesOf(vars.qualityReviews ?? ""),
39
+ guarded((lens: string) => reviewQuality(lens, base), guard),
40
+ QUALITY,
41
+ )
34
42
 
35
43
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
36
- export const fixQualityFindings = (escalations: EscalationCount, guard?: Guard): Promise<boolean> =>
44
+ export const fixQualityFindings = (
45
+ escalations: EscalationCount,
46
+ guard?: Guard,
47
+ guardRests = true,
48
+ rules?: string,
49
+ ): Promise<boolean> =>
37
50
  kit.fixFindings({
38
- fix: guarded(fixQuality, guard),
51
+ fix: guarded(() => fixQuality(rules), guard),
39
52
  findings: QUALITY,
40
53
  cap: FIX_CAP,
41
- escalate: () => escalation(escalations, guard),
54
+ escalate: () => escalation(escalations, guard, guardRests, rules),
42
55
  })
43
56
 
44
57
  /** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
@@ -61,27 +74,32 @@ export const refusePendingHandOff = (): void => {
61
74
  * The build tail: fix (when entered red), keep green, the quality lap, then
62
75
  * `finish` — the human review, as the calling workflow runs it. `green`: the
63
76
  * caller just left the full suite green (the package loop), so no run opens it.
77
+ * `guardRests: false` leaves the escalation's human rests unguarded.
64
78
  */
65
79
  export const tail = <T>(
66
80
  fixFirst: boolean,
67
81
  finish: (escalations: EscalationCount) => Promise<T>,
68
82
  green = false,
69
83
  guard?: Guard,
84
+ lenses?: readonly string[],
85
+ guardRests = true,
86
+ lensBase?: string,
87
+ rules?: string,
70
88
  ): Promise<T> =>
71
89
  scope("build", async () => {
72
90
  const escalations: EscalationCount = { rounds: 0 }
73
- const fix = fixCheck
91
+ const fix = () => fixCheck(rules)
74
92
  const repair = async () => {
75
93
  await guarded(fix, guard)()
76
- await healthy(fix, { fixesSoFar: 1, escalations, guard })
94
+ await healthy(fix, { fixesSoFar: 1, escalations, guard, guardRests, rules })
77
95
  }
78
96
  // fixFirst's own fix + healthy below already is the full run.
79
97
  if (fixFirst) await repair()
80
- else if (!green) await healthy(fix, { escalations, guard })
98
+ else if (!green) await healthy(fix, { escalations, guard, guardRests, rules })
81
99
  // The lap runs once a tail: after its findings are fixed, review follows.
82
- if ((await qualityLap(guard)) === "findings") {
83
- if (await fixQualityFindings(escalations, guard)) {
84
- await healthy(fix, { escalations, guard })
100
+ if ((await qualityLap(guard, lenses, lensBase)) === "findings") {
101
+ if (await fixQualityFindings(escalations, guard, guardRests, rules)) {
102
+ await healthy(fix, { escalations, guard, guardRests, rules })
85
103
  } else await repair()
86
104
  }
87
105
  return finish(escalations)
@@ -99,7 +117,7 @@ export const handOffReview = (
99
117
  ): Promise<void> =>
100
118
  kit.reviewToHandOff(base, {
101
119
  file: REVIEW,
102
- reviewing: guarded(reviewing, guard),
120
+ reviewing: guarded((b, carry) => reviewing(b, carry), guard),
103
121
  fixRisks: guarded(fixRisks, guard),
104
122
  keepGreen: () => healthy(fixCheck, { escalations, guard }),
105
123
  awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
@@ -12,7 +12,12 @@ import {
12
12
  architectureGateAnswerMessage,
13
13
  buildFixPrompt,
14
14
  buildFixQualityPrompt,
15
+ buildReviewFixNitsPrompt,
16
+ buildReviewFixRisksPrompt,
17
+ healthDescribePrompt,
18
+ refactorRules,
15
19
  buildQualityReviewingPrompt,
20
+ specChallengeBrief,
16
21
  buildReviewAwaitReviewMessage,
17
22
  buildReviewCollectingPrompt,
18
23
  designGateAnswerMessage,
@@ -20,6 +25,9 @@ import {
20
25
  implementBuildingPrompt,
21
26
  implementFixPrompt,
22
27
  naivePassPrompt,
28
+ refactorBuildingPrompt,
29
+ refactorDroppedRefusal,
30
+ refactorFixPrompt,
23
31
  seamByHandMessage,
24
32
  seamSteerMessage,
25
33
  summaryPrompt,
@@ -259,6 +267,29 @@ describe("buildQualityReviewingPrompt", () => {
259
267
  "This lens's brief:\nBRIEF-TEXT",
260
268
  )
261
269
  })
270
+ it("reviews from the process start by default", () => {
271
+ const start = "START-SHA"
272
+ const text = renderText(() => buildQualityReviewingPrompt("x"), { start })
273
+ expect(text).toContain("from `START-SHA`")
274
+ })
275
+ it("reviews from a given base and still names the process start", () => {
276
+ const text = renderText(() => buildQualityReviewingPrompt("x", undefined, "BASE-SHA"), {
277
+ start: "START-SHA",
278
+ })
279
+ expect(text).toContain("from `BASE-SHA`")
280
+ expect(text).not.toContain("from `START-SHA`")
281
+ expect(text).toContain("started at `START-SHA`")
282
+ })
283
+ it("the spec-challenge brief looks up planning documents from the process start", () => {
284
+ const text = renderText(
285
+ () => buildQualityReviewingPrompt("x", specChallengeBrief, "BASE-SHA"),
286
+ {
287
+ start: "START-SHA",
288
+ },
289
+ )
290
+ expect(text).toContain("started at `START-SHA`")
291
+ expect(text).toContain("NOT the review base")
292
+ })
262
293
  })
263
294
 
264
295
  // These cases run real git and sh on purpose: they prove the command a person
@@ -294,7 +325,6 @@ const scratchRepo = () => {
294
325
  }
295
326
 
296
327
  describe("summaryPrompt", () => {
297
- // Pinned literal: claude/hooks/ship.ts regex-reads the plain prompt's prose.
298
328
  it("without a slice prints today's text byte for byte", () => {
299
329
  expect(
300
330
  summaryPrompt({
@@ -747,6 +777,18 @@ Print the closing message and stop — this writes nothing itself.
747
777
  })
748
778
  })
749
779
 
780
+ describe("buildReviewCollectingPrompt without a refactor", () => {
781
+ it("is the feature prompt verbatim", () => {
782
+ const prompt = renderText(() => buildReviewCollectingPrompt("capture-text"))
783
+ expect(prompt).toBe(
784
+ readFileSync(
785
+ new URL("./__fixtures__/feature-collecting-prompt.txt", import.meta.url),
786
+ "utf8",
787
+ ),
788
+ )
789
+ })
790
+ })
791
+
750
792
  describe("the thread rules", () => {
751
793
  it("are in all three answering prompts and no longer say a footnote is human input only", () => {
752
794
  const prompts = renderText(() => [
@@ -813,7 +855,7 @@ describe("code threads in prompts", () => {
813
855
 
814
856
  describe("buildReviewReviewingPrompt risk marking", () => {
815
857
  it("states the Risk: marker rule and the re-review rule", () => {
816
- const prompt = renderText(() => buildReviewReviewingPrompt("base"))
858
+ const prompt = renderText(() => buildReviewReviewingPrompt("base", "first"))
817
859
  expect(prompt).toContain("Open a hunk's note with `Risk:` only for a concrete defect")
818
860
  expect(prompt).toContain("never a style remark")
819
861
  expect(prompt).toContain("describe what the fix changed under the")
@@ -923,7 +965,7 @@ describe("deferNotesScript — the in-memory recognizer agrees with the real sh
923
965
  describe("buildReviewReviewingPrompt scenarios", () => {
924
966
  it("names each removed assertion and asks for a Weakened: note", () => {
925
967
  const prompt = renderText(() =>
926
- buildReviewReviewingPrompt("base", undefined, [
968
+ buildReviewReviewingPrompt("base", "first", undefined, [
927
969
  {
928
970
  path: "e2e/a.feature",
929
971
  lines: [{ scenario: "Scenario: shown", line: "Then it is shown" }],
@@ -935,7 +977,7 @@ describe("buildReviewReviewingPrompt scenarios", () => {
935
977
  })
936
978
 
937
979
  it("asks for no Weakened: note when nothing was removed", () => {
938
- const prompt = renderText(() => buildReviewReviewingPrompt("base"))
980
+ const prompt = renderText(() => buildReviewReviewingPrompt("base", "first"))
939
981
  expect(prompt).toContain("## Scenarios")
940
982
  expect(prompt).not.toContain("Weakened:")
941
983
  })
@@ -976,6 +1018,14 @@ describe("the seam phase's texts", () => {
976
1018
  expect(low).toContain("git show abc123")
977
1019
  })
978
1020
 
1021
+ it("drops the git show line when there is no reference", () => {
1022
+ const prompt = renderText(() => implementBuildingPrompt(undefined, "low"))
1023
+ expect(prompt).not.toContain("git show")
1024
+ expect(prompt).not.toContain("Reference only")
1025
+ expect(prompt).toContain("Add no release flag")
1026
+ expect(prompt).toContain("Implement them properly")
1027
+ })
1028
+
979
1029
  it("keeps a fix turn to the feedback file's rules", () => {
980
1030
  expect(renderText(() => implementFixPrompt())).toContain(".gtd/FEEDBACK.md")
981
1031
  })
@@ -1004,3 +1054,215 @@ describe("the seam phase's texts", () => {
1004
1054
  expect(message).not.toContain("a\nb.ts")
1005
1055
  })
1006
1056
  })
1057
+
1058
+ describe("the refactor prompts", () => {
1059
+ const strays = ["src/a.ts", "src/b.ts"]
1060
+ const pattern = "\\bGTD_FLAG_\\w+"
1061
+
1062
+ it("building and fix rules forbid every test file, with no stray exception", () => {
1063
+ for (const prompt of renderText(() => [
1064
+ refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern),
1065
+ refactorFixPrompt(strays, pattern),
1066
+ ])) {
1067
+ expect(prompt).toContain("never a test file")
1068
+ expect(prompt).not.toContain("only when it is")
1069
+ }
1070
+ })
1071
+
1072
+ it("building adds the act-on-review-feedback line only when asked", () => {
1073
+ const [plain, feedback] = renderText(() => [
1074
+ refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern),
1075
+ refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern, undefined, true),
1076
+ ])
1077
+ expect(plain).not.toContain("## Review feedback")
1078
+ expect(feedback).toContain("## Review feedback")
1079
+ })
1080
+
1081
+ describe("the review collecting prompt", () => {
1082
+ const prompt = renderText(() =>
1083
+ buildReviewCollectingPrompt("capture-text", { strays, round: "triaged" }),
1084
+ )
1085
+
1086
+ it("folds into .gtd/REFACTOR.md and never names .gtd/REQUIREMENTS.md", () => {
1087
+ expect(prompt).toContain(".gtd/REFACTOR.md")
1088
+ expect(prompt).not.toContain(".gtd/REQUIREMENTS.md")
1089
+ expect(prompt).toContain("capture-text")
1090
+ })
1091
+
1092
+ it("lists the strays and says a non-stray, test-file or flag concern cannot be built", () => {
1093
+ expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
1094
+ expect(prompt).toContain("`src/b.ts`") // gtd-path-exempt: fixture paths
1095
+ expect(prompt).toMatch(/cannot be built/)
1096
+ expect(prompt).toMatch(/test file/)
1097
+ expect(prompt).toMatch(/flag/)
1098
+ expect(prompt).toMatch(/behaviour/)
1099
+ expect(prompt).not.toMatch(/say in the reply/i)
1100
+ })
1101
+
1102
+ it("carries answered questions into the target's ## Answered Questions", () => {
1103
+ expect(prompt).toContain("`.gtd/REFACTOR.md`'s `## Answered Questions`")
1104
+ })
1105
+
1106
+ it("drops the reply rules and the closed review file's check", () => {
1107
+ expect(prompt).not.toContain("- A: <reply>")
1108
+ expect(prompt).not.toContain("gtd check review .gtd/REVIEW.md")
1109
+ expect(prompt).toContain("is already closed")
1110
+ })
1111
+
1112
+ it("says an edit note or hand-edit must write the package", () => {
1113
+ expect(prompt).toContain("MUST write")
1114
+ expect(prompt).toContain("gtd abandon")
1115
+ })
1116
+ })
1117
+ })
1118
+
1119
+ describe("refactorDroppedRefusal", () => {
1120
+ const message = refactorDroppedRefusal({
1121
+ notes: [{ id: "note-2", anchor: "./src/a.ts#1-1", text: "rename it" }] as never,
1122
+ paths: ["src/b.ts"], // gtd-path-exempt: fixture paths
1123
+ })
1124
+
1125
+ it("starts with the refactor-feedback landing refusal", () => {
1126
+ expect(message.startsWith("gtd land: refactor-feedback:")).toBe(true)
1127
+ })
1128
+
1129
+ it("lists each note and each hand-edited path", () => {
1130
+ expect(message).toContain("note-2 (./src/a.ts#1-1): rename it")
1131
+ expect(message).toContain("src/b.ts") // gtd-path-exempt: fixture paths
1132
+ })
1133
+
1134
+ it("names gtd abandon and what it throws away", () => {
1135
+ expect(message).toContain("gtd abandon")
1136
+ expect(message).toContain("the naive pass, the refactor and its review")
1137
+ })
1138
+ })
1139
+
1140
+ describe("the refactor collecting prompt", () => {
1141
+ const strays = ["src/a.ts"]
1142
+
1143
+ it("in-gate asks for one - A: per waiting thread, leaves one-shot footnotes and notes untouched, never says closed", () => {
1144
+ const prompt = renderText(() =>
1145
+ buildReviewCollectingPrompt("capture", { strays, round: "reply" }),
1146
+ )
1147
+ expect(prompt).toContain("exactly one `- A:` reply")
1148
+ expect(prompt).toContain("Fold each one-shot footnote and note")
1149
+ expect(prompt).toContain("gtd check review .gtd/REVIEW.md")
1150
+ expect(prompt).not.toContain("already closed")
1151
+ })
1152
+
1153
+ const closed = (round: "edits" | "notes" | "triaged", threads = false) =>
1154
+ renderText(() => buildReviewCollectingPrompt("capture", { strays, round }), {
1155
+ codeThreads: threads
1156
+ ? [{ path: "src/a.ts", line: 4, waitingOn: "agent", first: "why?", faults: [] }]
1157
+ : [],
1158
+ })
1159
+
1160
+ it('closed "edits" folds every hand-edit and every note the human left that round', () => {
1161
+ expect(closed("edits")).toContain(
1162
+ "Fold every hand-edit and every note the human left that round",
1163
+ )
1164
+ })
1165
+
1166
+ it('closed "notes" folds each note that asks for a change', () => {
1167
+ expect(closed("notes")).toContain("Fold each note that asks for a change")
1168
+ })
1169
+
1170
+ it('closed "triaged" folds each edit note the capture lists, no other note', () => {
1171
+ expect(closed("triaged")).toContain("Fold each `edit` note the capture lists, no other note")
1172
+ })
1173
+
1174
+ it.each(["edits", "notes", "triaged"] as const)(
1175
+ "closed %s keeps the fence and must-write rule, drops both reply rules",
1176
+ (round) => {
1177
+ const prompt = closed(round, true)
1178
+ expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
1179
+ expect(prompt).toContain("MUST write")
1180
+ expect(prompt).not.toContain("A one-shot footnote needs no reply")
1181
+ expect(prompt).not.toContain("Code threads waiting on you")
1182
+ expect(prompt).not.toContain("src/a.ts:4: why?")
1183
+ },
1184
+ )
1185
+
1186
+ it("the open variant and the feature prompt keep both reply rules", () => {
1187
+ const threads = [{ path: "src/a.ts", line: 4, waitingOn: "agent", first: "why?", faults: [] }]
1188
+ for (const prompt of renderText(
1189
+ () => [
1190
+ buildReviewCollectingPrompt("capture", { strays, round: "reply" }),
1191
+ buildReviewCollectingPrompt("capture"),
1192
+ ],
1193
+ { codeThreads: threads as never },
1194
+ )) {
1195
+ expect(prompt).toContain("A one-shot footnote needs no reply")
1196
+ expect(prompt).toContain("src/a.ts:4: why?")
1197
+ }
1198
+ })
1199
+
1200
+ it("end-of-round still says the file is closed and must not be recreated", () => {
1201
+ const prompt = renderText(() =>
1202
+ buildReviewCollectingPrompt("capture", { strays, round: "triaged" }),
1203
+ )
1204
+ expect(prompt).toContain("already closed")
1205
+ expect(prompt).toContain("do not\n recreate it")
1206
+ expect(prompt).not.toContain("exactly one `- A:` reply")
1207
+ })
1208
+ })
1209
+
1210
+ describe("the rules-taking fix prompts", () => {
1211
+ const strays = ["src/a.ts", "src/b.ts"]
1212
+ const pattern = "\\bGTD_FLAG_\\w+"
1213
+ const notes = [{ id: "n1", anchor: "src/a.ts#1-1", text: "Risk: x" }]
1214
+ const prompts: Record<string, (rules?: string) => string> = {
1215
+ fix: (rules) => buildFixPrompt(rules),
1216
+ quality: (rules) => buildFixQualityPrompt(rules),
1217
+ risks: (rules) => buildReviewFixRisksPrompt(notes, rules),
1218
+ nits: (rules) => buildReviewFixNitsPrompt(notes, rules),
1219
+ describe: (rules) => healthDescribePrompt(rules),
1220
+ }
1221
+
1222
+ it.each(Object.keys(prompts))(
1223
+ "%s renders without the refactor block when no rules are given",
1224
+ (k) => {
1225
+ const [bare] = renderText(() => [prompts[k]!()])
1226
+ expect(bare).not.toContain("Change only these paths")
1227
+ expect(bare).not.toContain("never a test file")
1228
+ },
1229
+ )
1230
+
1231
+ it.each(Object.keys(prompts))("%s lists the strays and both bans when rules are given", (k) => {
1232
+ const [rules, prompt] = renderText(() => {
1233
+ const r = refactorRules(strays, pattern)
1234
+ return [r, prompts[k]!(r)]
1235
+ })
1236
+ expect(prompt).toContain(rules)
1237
+ for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1238
+ expect(prompt).toContain("never a test file")
1239
+ expect(prompt).toContain("no new flag read")
1240
+ })
1241
+
1242
+ it("the risks prompt drops its add-a-test line when rules are given", () => {
1243
+ const [plain, ruled] = renderText(() => [
1244
+ buildReviewFixRisksPrompt(notes),
1245
+ buildReviewFixRisksPrompt(notes, refactorRules(strays, pattern)),
1246
+ ])
1247
+ expect(plain).toContain("add a test")
1248
+ expect(ruled).not.toContain("add a test")
1249
+ })
1250
+ })
1251
+
1252
+ describe("buildReviewReviewingPrompt lap wording", () => {
1253
+ const ranges = {
1254
+ first: "it covers everything since the base",
1255
+ feedback: "it covers only what is new since the previous review",
1256
+ refactor: "it covers the whole refactor since it started, on every round",
1257
+ } as const
1258
+ it.each(Object.keys(ranges) as (keyof typeof ranges)[])(
1259
+ "%s renders its own range wording and neither of the other two",
1260
+ (lap) => {
1261
+ const prompt = renderText(() => buildReviewReviewingPrompt("base", lap))
1262
+ for (const [other, wording] of Object.entries(ranges)) {
1263
+ if (other === lap) expect(prompt).toContain(wording)
1264
+ else expect(prompt).not.toContain(wording)
1265
+ }
1266
+ },
1267
+ )
1268
+ })