@pmelab/gtd 24.3.0 → 24.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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -4
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-B9pG93Fc.mjs} +52 -12
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +779 -812
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +663 -158
  9. package/dist/workflow-kit.mjs +9 -7
  10. package/dist/workflows/feature.mjs +152 -35
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +223 -54
  14. package/package.json +1 -1
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/steering/FORMATS.md +1 -1
  23. package/src/steering/SteeringFormat.test.ts +9 -1
  24. package/src/steering/SteeringFormat.ts +7 -0
  25. package/src/steering/contract.test.ts +3 -0
  26. package/src/steering/index.ts +1 -0
  27. package/src/steering/review.test.ts +30 -0
  28. package/src/steering/review.ts +8 -2
  29. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  30. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  31. package/src/testkit/EmittedScripts.test.ts +166 -0
  32. package/src/testkit/FlowScripts.test.ts +242 -0
  33. package/src/testkit/FlowScripts.ts +326 -0
  34. package/src/testkit/GitTiers.ts +140 -49
  35. package/src/testkit/InMemRepo.ts +35 -2
  36. package/src/testkit/Layers.ts +10 -2
  37. package/src/testkit/TestRepo.ts +4 -1
  38. package/src/testkit/index.ts +1 -1
  39. package/src/workflow/discovery.test.ts +147 -0
  40. package/src/workflow/discovery.ts +5 -0
  41. package/src/workflow/fingerprint.test.ts +20 -4
  42. package/src/workflow/fingerprint.ts +8 -9
  43. package/src/workflow/index.ts +1 -6
  44. package/src/workflow/load.test.ts +5 -19
  45. package/src/workflow/load.ts +94 -10
  46. package/src/workflow-kit/review.test.ts +84 -0
  47. package/src/workflow-kit/review.ts +9 -6
  48. package/src/workflows/feature/review.test.ts +238 -12
  49. package/src/workflows/feature/review.ts +172 -31
  50. package/src/workflows/feature/steps.test.ts +12 -0
  51. package/src/workflows/feature/steps.ts +8 -1
  52. package/src/workflows/shared/access.test.ts +4 -2
  53. package/src/workflows/shared/access.ts +1 -0
  54. package/src/workflows/shared/options.test.ts +8 -0
  55. package/src/workflows/shared/options.ts +1 -0
  56. package/src/workflows/shared/steps.ts +2 -0
  57. package/src/workflows/shared/tail.ts +3 -11
  58. package/src/workflows/shared/text.test.ts +106 -0
  59. package/src/workflows/shared/text.ts +57 -1
  60. package/src/workflows/shared/vars.ts +1 -0
@@ -174,6 +174,7 @@ describe("review notes, the review → fix hand-off", () => {
174
174
  anchor: "Parser accepts CRLF",
175
175
  before: "**Line endings are normalized before the split.**",
176
176
  text: "**Line endings are normalized before the split.** Also normalize in the writer.",
177
+ forced: false,
177
178
  },
178
179
  {
179
180
  id: "note-2",
@@ -181,6 +182,7 @@ describe("review notes, the review → fix hand-off", () => {
181
182
  anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
182
183
  before: "Risk: a lone `\\r` is still kept as content",
183
184
  text: "Risk: a lone `\\r` is still kept as content. Treat it as a line break too.",
185
+ forced: false,
184
186
  },
185
187
  {
186
188
  id: "note-3",
@@ -188,6 +190,7 @@ describe("review notes, the review → fix hand-off", () => {
188
190
  anchor: "- [x] ./src/parse.test.ts#1-40 — one case per line ending[^fn1]",
189
191
  before: "",
190
192
  text: "Add a mixed-endings case.",
193
+ forced: false,
191
194
  },
192
195
  ])
193
196
  })
@@ -5,6 +5,7 @@ import type {
5
5
  SteeringOutlineNode,
6
6
  SteeringPointer,
7
7
  } from "./SteeringFormat.js"
8
+ export { formatFinding } from "./SteeringFormat.js"
8
9
  import { qaDescriptor } from "./qa.js"
9
10
  import { reviewDescriptor } from "./review.js"
10
11
  export { reviewNotes, reviewRisks, type ReviewNote } from "./review.js"
@@ -1733,6 +1733,7 @@ describe("reviewNotes", async () => {
1733
1733
  anchor: "Chunk one ./src/a.ts#1-3",
1734
1734
  before: "",
1735
1735
  text: "rename this",
1736
+ forced: false,
1736
1737
  },
1737
1738
  ])
1738
1739
  })
@@ -1753,6 +1754,7 @@ describe("reviewNotes", async () => {
1753
1754
  anchor: "- [ ] ./src/a.ts#1-3[^n1]",
1754
1755
  before: "",
1755
1756
  text: "please split",
1757
+ forced: false,
1756
1758
  },
1757
1759
  ])
1758
1760
  })
@@ -1774,6 +1776,34 @@ describe("reviewNotes", async () => {
1774
1776
  expect(reviewNotes(answered, more).map((n) => n.kind)).toEqual(["footnote"])
1775
1777
  })
1776
1778
 
1779
+ it("a leading ! forces a pointer note and keeps its text", () => {
1780
+ const [n] = reviewNotes(base, base.replace("#1-3", "#1-3 ! rename this"))
1781
+ expect(n).toMatchObject({ kind: "pointer", text: "! rename this", forced: true })
1782
+ })
1783
+
1784
+ it("a leading ! forces a footnote note", () => {
1785
+ const after = base.replace("#1-3", "#1-3[^n1]") + "\n[^n1]: ! please split\n"
1786
+ expect(reviewNotes(base, after)[0]).toMatchObject({ text: "! please split", forced: true })
1787
+ })
1788
+
1789
+ it("a leading ! forces chunk prose", () => {
1790
+ const [n] = reviewNotes(base, doc("! Prose is wrong."))
1791
+ expect(n).toMatchObject({ kind: "chunk", text: "! Prose is wrong.", forced: true })
1792
+ })
1793
+
1794
+ it("a ! glued to the next word is not a marker", () => {
1795
+ const [n] = reviewNotes(base, base.replace("#1-3", "#1-3 != should be ==="))
1796
+ expect(n).toMatchObject({ text: "!= should be ===", forced: false })
1797
+ })
1798
+
1799
+ it("a mid-text ! or a ! in the pointer path does not force", () => {
1800
+ const [n] = reviewNotes(base, base.replace("#1-3", "#1-3 rename this!"))
1801
+ expect(n).toMatchObject({ text: "rename this!", forced: false })
1802
+ const [m] = reviewNotes(base, base.replace("./src/a.ts#1-3", "./src/a!b.ts#1-3 x"))
1803
+ expect(m).toMatchObject({ forced: false })
1804
+ expect(m!.anchor).toContain("a!b.ts")
1805
+ })
1806
+
1777
1807
  it("ids run in document order", () => {
1778
1808
  const after = base.replace("#1-3", "#1-3 a[^n1]") + "\n[^n1]: b\n"
1779
1809
  expect(reviewNotes(base, after).map((n) => [n.id, n.kind])).toEqual([
@@ -1085,12 +1085,14 @@ export interface ReviewNote {
1085
1085
  readonly before: string
1086
1086
  /** The human's text. */
1087
1087
  readonly text: string
1088
+ /** The human opened `text` with `! ` (kept in `text`; the feature review strips it). */
1089
+ readonly forced?: boolean
1088
1090
  }
1089
1091
 
1090
1092
  const pointerLabel = (f: ReviewFile): string =>
1091
1093
  `${f.path}${f.line === undefined ? "" : `#${f.line}${f.rangeEnd === undefined ? "" : `-${f.rangeEnd}`}`}`
1092
1094
 
1093
- type Found = { line: number; note: Omit<ReviewNote, "id"> }
1095
+ type Found = { line: number; note: Omit<ReviewNote, "id" | "forced"> }
1094
1096
 
1095
1097
  const chunkNote = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
1096
1098
  chunk.description === (prior?.description ?? "")
@@ -1162,7 +1164,11 @@ export const reviewNotes = (before: string, after: string): readonly ReviewNote[
1162
1164
  }),
1163
1165
  ...footnoteNotes(before, after),
1164
1166
  ]
1165
- return found.sort((a, b) => a.line - b.line).map((f, i) => ({ id: `note-${i + 1}`, ...f.note }))
1167
+ return found
1168
+ .sort((a, b) => a.line - b.line)
1169
+ .map((f, i) => {
1170
+ return { id: `note-${i + 1}`, ...f.note, forced: /^!(\s|$)/.test(f.note.text) }
1171
+ })
1166
1172
  }
1167
1173
 
1168
1174
  /** Pointer notes the reviewer opened with `Risk:`, in document order — the ones the workflow fixes before the human gate. */
@@ -42,7 +42,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
42
42
  const result = applyEmittedScript(repo, NO_COMMANDS, commitAll("gtd(agent): building"))
43
43
  twin.commitAllWithPrefix("gtd(agent): building")
44
44
 
45
- expect(result).toEqual({ ok: true })
45
+ expect(result).toMatchObject({ ok: true })
46
46
  expect(snapshot(repo)).toEqual(snapshot(twin))
47
47
  })
48
48
 
@@ -60,7 +60,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
60
60
 
61
61
  const result = applyEmittedScript(repo, NO_COMMANDS, commitAll(message))
62
62
 
63
- expect(result).toEqual({ ok: true })
63
+ expect(result).toMatchObject({ ok: true })
64
64
  expect(repo.lastCommitMessage()).toBe(message)
65
65
  })
66
66
 
@@ -77,7 +77,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
77
77
  const result = applyEmittedScript(repo, NO_COMMANDS, commitAsIs("gtd(agent): as-is"))
78
78
  twin.commitAsIs("gtd(agent): as-is")
79
79
 
80
- expect(result).toEqual({ ok: true })
80
+ expect(result).toMatchObject({ ok: true })
81
81
  expect(snapshot(repo)).toEqual(snapshot(twin))
82
82
  })
83
83
 
@@ -97,7 +97,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
97
97
  const result = applyEmittedScript(repo, NO_COMMANDS, softResetTo(first))
98
98
  twin.softResetTo(first)
99
99
 
100
- expect(result).toEqual({ ok: true })
100
+ expect(result).toMatchObject({ ok: true })
101
101
  expect(snapshot(repo)).toEqual(snapshot(twin))
102
102
  expect(repo.resolveRef("HEAD")).toBe(first)
103
103
  })
@@ -118,7 +118,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
118
118
  const result = applyEmittedScript(repo, NO_COMMANDS, mixedResetTo(first))
119
119
  twin.mixedResetTo(first)
120
120
 
121
- expect(result).toEqual({ ok: true })
121
+ expect(result).toMatchObject({ ok: true })
122
122
  expect(snapshot(repo)).toEqual(snapshot(twin))
123
123
  })
124
124
 
@@ -138,7 +138,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
138
138
  const result = applyEmittedScript(repo, NO_COMMANDS, hardResetTo(first))
139
139
  twin.hardResetTo(first)
140
140
 
141
- expect(result).toEqual({ ok: true })
141
+ expect(result).toMatchObject({ ok: true })
142
142
  expect(snapshot(repo)).toEqual(snapshot(twin))
143
143
  expect(repo.hasPath("b.txt")).toBe(false)
144
144
  })
@@ -158,7 +158,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
158
158
  const result = applyEmittedScript(repo, NO_COMMANDS, discardPending())
159
159
  twin.discardPending()
160
160
 
161
- expect(result).toEqual({ ok: true })
161
+ expect(result).toMatchObject({ ok: true })
162
162
  expect(snapshot(repo)).toEqual(snapshot(twin))
163
163
  expect(repo.readFile("a.txt")).toBe("1")
164
164
  expect(repo.hasPath("untracked.txt")).toBe(false)
@@ -176,7 +176,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
176
176
  const result = applyEmittedScript(repo, NO_COMMANDS, updateRef("refs/gtd/base", hash))
177
177
  twin.updateRef("refs/gtd/base", hash)
178
178
 
179
- expect(result).toEqual({ ok: true })
179
+ expect(result).toMatchObject({ ok: true })
180
180
  expect(repo.resolveRef("refs/gtd/base")).toBe(hash)
181
181
  expect(snapshot(repo)).toEqual(snapshot(twin))
182
182
  })
@@ -194,7 +194,7 @@ describe("applyEmittedScript — the 8 GitScript builders", () => {
194
194
  const result = applyEmittedScript(repo, NO_COMMANDS, deleteRef("refs/gtd/base"))
195
195
  twin.deleteRef("refs/gtd/base")
196
196
 
197
- expect(result).toEqual({ ok: true })
197
+ expect(result).toMatchObject({ ok: true })
198
198
  expect(repo.resolveRef("refs/gtd/base")).toBeNull()
199
199
  expect(snapshot(repo)).toEqual(snapshot(twin))
200
200
  })
@@ -210,7 +210,7 @@ describe("applyEmittedScript — gtd check <mode> <file>", () => {
210
210
 
211
211
  const result = applyEmittedScript(repo, NO_COMMANDS, "gtd check qa '.gtd/TODO.md'")
212
212
 
213
- expect(result).toEqual({ ok: true })
213
+ expect(result).toMatchObject({ ok: true })
214
214
  })
215
215
 
216
216
  it("fails on invalid qa-mode content (a bare ### heading with no question text)", () => {
@@ -220,7 +220,7 @@ describe("applyEmittedScript — gtd check <mode> <file>", () => {
220
220
  const result = applyEmittedScript(repo, NO_COMMANDS, "gtd check qa '.gtd/TODO.md'")
221
221
 
222
222
  expect(result.ok).toBe(false)
223
- expect(result.error).toContain("gtd check qa")
223
+ expect(result.output).toContain('.gtd/TODO.md is not valid under mode "qa"')
224
224
  })
225
225
  })
226
226
 
@@ -231,7 +231,7 @@ describe("applyEmittedScript — Emit.ts's fileExistsGuard", () => {
231
231
 
232
232
  const result = applyEmittedScript(repo, NO_COMMANDS, fileExistsGuard(".gtd/PLAN.md"))
233
233
 
234
- expect(result).toEqual({ ok: true })
234
+ expect(result).toMatchObject({ ok: true })
235
235
  })
236
236
 
237
237
  it("stops the whole script (successfully) when the file is absent — unlike every other guard, it does not fail", () => {
@@ -240,7 +240,7 @@ describe("applyEmittedScript — Emit.ts's fileExistsGuard", () => {
240
240
  const script = [fileExistsGuard(".gtd/PLAN.md"), "gtd check qa '.gtd/PLAN.md'"].join("\n\n")
241
241
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
242
242
 
243
- expect(result).toEqual({ ok: true })
243
+ expect(result).toMatchObject({ ok: true })
244
244
  })
245
245
 
246
246
  it("a subsequent block after the guard trips is never applied", () => {
@@ -262,7 +262,7 @@ describe("applyEmittedScript — ModeContradiction.ts's skip notice", () => {
262
262
 
263
263
  const result = applyEmittedScript(repo, NO_COMMANDS, modeContradictionSkipNotice("adr"))
264
264
 
265
- expect(result).toEqual({ ok: true })
265
+ expect(result).toMatchObject({ ok: true })
266
266
  })
267
267
  })
268
268
 
@@ -284,7 +284,7 @@ describe("applyEmittedScript — ModeContradiction.ts's contradiction round-trip
284
284
 
285
285
  const result = applyEmittedScript(repo, commands, block)
286
286
 
287
- expect(result).toEqual({ ok: true })
287
+ expect(result).toMatchObject({ ok: true })
288
288
  expect(repo.readFile(samplePath)).toBeUndefined()
289
289
  })
290
290
 
@@ -359,7 +359,7 @@ describe("applyEmittedScript — the scripted-command table", () => {
359
359
 
360
360
  const result = applyEmittedScript(repo, commands, "./run-check.sh")
361
361
 
362
- expect(result).toEqual({ ok: true })
362
+ expect(result).toMatchObject({ ok: true })
363
363
  })
364
364
 
365
365
  it("an 'exit' hit with a non-zero status fails the script", () => {
@@ -370,8 +370,7 @@ describe("applyEmittedScript — the scripted-command table", () => {
370
370
 
371
371
  const result = applyEmittedScript(repo, commands, "./run-check.sh")
372
372
 
373
- expect(result.ok).toBe(false)
374
- expect(result.error).toContain("./run-check.sh")
373
+ expect(result).toMatchObject({ ok: false, status: 1, output: "boom\n" })
375
374
  })
376
375
 
377
376
  it("a 'rewrite' hit mutates the repo's file", () => {
@@ -383,7 +382,7 @@ describe("applyEmittedScript — the scripted-command table", () => {
383
382
 
384
383
  const result = applyEmittedScript(repo, commands, "./format-feedback.sh")
385
384
 
386
- expect(result).toEqual({ ok: true })
385
+ expect(result).toMatchObject({ ok: true })
387
386
  expect(repo.readFile("FEEDBACK.md")).toBe("new")
388
387
  })
389
388
  })
@@ -434,7 +433,7 @@ describe("applyEmittedScript — a tripped precondition stops the script", () =>
434
433
 
435
434
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
436
435
 
437
- expect(result).toEqual({ ok: true })
436
+ expect(result).toMatchObject({ ok: true })
438
437
  expect(repo.lastCommitMessage()).toBe("gtd(agent): lands")
439
438
  })
440
439
  })
@@ -455,7 +454,7 @@ describe("applyEmittedScript — a realistic assembled multi-block script", () =
455
454
 
456
455
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
457
456
 
458
- expect(result).toEqual({ ok: true })
457
+ expect(result).toMatchObject({ ok: true })
459
458
  expect(repo.lastCommitMessage()).toBe("gtd(agent): second")
460
459
  expect(repo.resolveRef("refs/gtd/base")).toBe(first)
461
460
  })
@@ -479,7 +478,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
479
478
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
480
479
  twin.commitAllWithPrefix("gtd(agent): building")
481
480
 
482
- expect(result).toEqual({ ok: true })
481
+ expect(result).toMatchObject({ ok: true })
483
482
  expect(snapshot(repo)).toEqual(snapshot(twin))
484
483
  })
485
484
 
@@ -505,7 +504,7 @@ describe("applyEmittedScript — a realistic src/emit/Emit.ts-assembled script",
505
504
  const { required } = emitScripts(steps)
506
505
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
507
506
 
508
- expect(result).toEqual({ ok: true })
507
+ expect(result).toMatchObject({ ok: true })
509
508
  expect(repo.resolveRef("refs/worktree/gtd/history")).toBe(tip)
510
509
  expect(repo.lastCommitMessage()).toBe("chore: squashed")
511
510
  expect(repo.hasPath(".gtd/TODO.md")).toBe(false)
@@ -520,7 +519,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
520
519
 
521
520
  const result = applyEmittedScript(repo, NO_COMMANDS, transitionOutcome("idle", "working"))
522
521
 
523
- expect(result).toEqual({ ok: true })
522
+ expect(result).toMatchObject({ ok: true })
524
523
  expect(snapshot(repo)).toEqual(before)
525
524
  })
526
525
 
@@ -531,7 +530,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
531
530
 
532
531
  const result = applyEmittedScript(repo, NO_COMMANDS, commitOutcome("gtd(agent): x"))
533
532
 
534
- expect(result).toEqual({ ok: true })
533
+ expect(result).toMatchObject({ ok: true })
535
534
  expect(snapshot(repo)).toEqual(before)
536
535
  })
537
536
 
@@ -553,7 +552,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
553
552
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
554
553
  twin.commitAllWithPrefix("gtd(agent): building")
555
554
 
556
- expect(result).toEqual({ ok: true })
555
+ expect(result).toMatchObject({ ok: true })
557
556
  expect(snapshot(repo)).toEqual(snapshot(twin))
558
557
  })
559
558
 
@@ -567,7 +566,7 @@ describe("applyEmittedScript — outcome blocks are inert (no git effect to miss
567
566
  ])
568
567
  const result = applyEmittedScript(repo, NO_COMMANDS, required)
569
568
 
570
- expect(result).toEqual({ ok: true })
569
+ expect(result).toMatchObject({ ok: true })
571
570
  expect(snapshot(repo)).toEqual(before)
572
571
  })
573
572
  })
@@ -583,7 +582,7 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
583
582
  const script = combinedScript(required, "")
584
583
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
585
584
 
586
- expect(result).toEqual({ ok: true })
585
+ expect(result).toMatchObject({ ok: true })
587
586
  expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
588
587
  })
589
588
 
@@ -605,7 +604,7 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
605
604
 
606
605
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
607
606
 
608
- expect(result).toEqual({ ok: true })
607
+ expect(result).toMatchObject({ ok: true })
609
608
  expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
610
609
  expect(repo.resolveRef("refs/gtd/marker")).toBe(repo.resolveRef("HEAD"))
611
610
  })
@@ -625,7 +624,7 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
625
624
 
626
625
  const result = applyEmittedScript(repo, NO_COMMANDS, script)
627
626
 
628
- expect(result).toEqual({ ok: true })
627
+ expect(result).toMatchObject({ ok: true })
629
628
  expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
630
629
  })
631
630