@pmelab/gtd 24.2.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
@@ -92,6 +92,38 @@ describe("the commit-message codec", () => {
92
92
  expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
93
93
  })
94
94
 
95
+ it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
96
+ const message = formatCommitMessage({
97
+ actor: "agent",
98
+ to: "idle",
99
+ judge: [{ id: "q1", answer: true, p: 0.9 }],
100
+ branch: "feat/x",
101
+ version: "1.2.3",
102
+ })
103
+ const lines = message.split("\n")
104
+ const at = (prefix: string) => lines.findIndex((l) => l.startsWith(prefix))
105
+ expect(at("Gtd-Judge:")).toBeLessThan(at("Gtd-Branch:"))
106
+ expect(at("Gtd-Branch:")).toBeLessThan(at("Gtd-Version:"))
107
+ expect(at("Gtd-Version:")).toBeLessThan(at("Gtd-Format:"))
108
+ const parsed = parseCommitMessage(message)
109
+ expect(parsed.branch).toBe("feat/x")
110
+ expect(parsed.version).toBe("1.2.3")
111
+ })
112
+
113
+ it("omits Gtd-Branch and Gtd-Version when unset, and reads them as undefined", () => {
114
+ const message = formatCommitMessage({ actor: "agent", to: "idle" })
115
+ expect(message).not.toContain("Gtd-Branch")
116
+ expect(message).not.toContain("Gtd-Version")
117
+ const parsed = parseCommitMessage(message)
118
+ expect(parsed.branch).toBeUndefined()
119
+ expect(parsed.version).toBeUndefined()
120
+ })
121
+
122
+ it("keeps Gtd-Format at 1 when branch and version are written", () => {
123
+ const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
124
+ expect(message.endsWith("Gtd-Format: 1")).toBe(true)
125
+ })
126
+
95
127
  it("writes Gtd-Format last on every message, even one with no other trailer", () => {
96
128
  const message = formatCommitMessage({ actor: "agent", to: "building" })
97
129
  expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")
@@ -72,6 +72,9 @@ export interface CommitMessage {
72
72
  readonly config: Readonly<Record<string, string>>
73
73
  readonly reviewBase: string | undefined
74
74
  readonly cost: readonly CostEntry[]
75
+ /** Written only on a process's finishing landing. */
76
+ readonly branch: string | undefined
77
+ readonly version: string | undefined
75
78
  readonly format: number
76
79
  }
77
80
 
@@ -116,6 +119,8 @@ interface Trailers {
116
119
  step: StepId | undefined
117
120
  workflow: string | undefined
118
121
  reviewBase: string | undefined
122
+ branch: string | undefined
123
+ version: string | undefined
119
124
  readonly judge: JudgeVerdict[]
120
125
  readonly vars: Record<string, string>
121
126
  readonly config: Record<string, string>
@@ -146,6 +151,12 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
146
151
  "Gtd-Review-Base": (value, into) => {
147
152
  into.reviewBase ??= value.split(/\s/)[0]
148
153
  },
154
+ "Gtd-Branch": (value, into) => {
155
+ if (value !== "") into.branch ??= value
156
+ },
157
+ "Gtd-Version": (value, into) => {
158
+ if (value !== "") into.version ??= value
159
+ },
149
160
  "Gtd-Format": (value, into) => {
150
161
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
151
162
  },
@@ -162,6 +173,8 @@ export const parseCommitMessage = (message: string): CommitMessage => {
162
173
  step: undefined,
163
174
  workflow: undefined,
164
175
  reviewBase: undefined,
176
+ branch: undefined,
177
+ version: undefined,
165
178
  judge: [],
166
179
  vars: {},
167
180
  config: {},
@@ -187,6 +200,8 @@ export interface CommitSpec {
187
200
  readonly config?: Readonly<Record<string, string>>
188
201
  readonly cost?: { readonly cost: number; readonly model?: string }
189
202
  readonly judge?: readonly JudgeVerdict[]
203
+ readonly branch?: string
204
+ readonly version?: string
190
205
  }
191
206
 
192
207
  const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
@@ -207,6 +222,8 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
207
222
  )
208
223
  }
209
224
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
225
+ if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
226
+ if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
210
227
  lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
211
228
  const subject = formatSubject(spec.actor, spec.to, spec.from)
212
229
  return `${subject}\n\n${lines.join("\n")}`
@@ -47,7 +47,7 @@ Breaking — committed files, or files an older gtd must read, are affected:
47
47
  questions, options, chunks, hunks, ticks, threads or notes.
48
48
  - A writer (a tick, a note, a reply) produces text an older gtd cannot read.
49
49
  - Renaming or removing a marker: `## Open Questions`, `_your answer_`,
50
- `# Review:`, `<!-- base: -->`, `Risk:`, `H:` / `A:`.
50
+ `# Review:`, `<!-- base: -->`, `Risk:`, a leading `! `, `H:` / `A:`.
51
51
 
52
52
  ## Format changes bump `Gtd-Format`
53
53
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { steeringFormatFor } from "./index.js"
2
+ import { formatFinding, steeringFormatFor } from "./index.js"
3
3
  import type { SteeringFinding, SteeringFormat } from "./SteeringFormat.js"
4
4
 
5
5
  const QA_FORMAT = steeringFormatFor("qa")!
@@ -89,3 +89,11 @@ describe("SteeringFinding range invariants", () => {
89
89
  assertRangeInvariants(findings)
90
90
  })
91
91
  })
92
+
93
+ describe("formatFinding", () => {
94
+ it("a line-carrying finding with no range prints '<file>:<line>: <message>' — the flat-shape fallback no built-in format reaches", () => {
95
+ expect(formatFinding("FAKE.md", { message: "positioned but rangeless", line: 4 })).toBe(
96
+ "FAKE.md:5: positioned but rangeless",
97
+ )
98
+ })
99
+ })
@@ -41,6 +41,13 @@ export interface SteeringFinding {
41
41
  }
42
42
  }
43
43
 
44
+ /** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
45
+ export const formatFinding = (file: string, finding: SteeringFinding): string => {
46
+ if (finding.line === undefined) return finding.message
47
+ const col = finding.range !== undefined ? `:${finding.range.start.character + 1}` : ""
48
+ return `${file}:${finding.line + 1}${col}: ${finding.message}`
49
+ }
50
+
44
51
  /** A hunk pointer's document-link target: `range` covers the pointer token itself; `line` is 0-based, so a `#42` suffix resolves down by one and a bare `./path` lands at 0. */
45
52
  export interface SteeringLink {
46
53
  readonly range: {
@@ -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. */
@@ -6,7 +6,9 @@ import type { StateName } from "../workflow/index.js"
6
6
  * that module, which is what makes writing a live git call inside the core
7
7
  * impossible rather than merely discouraged.
8
8
  */
9
- export type GitWrite = { readonly kind: "commitAll"; readonly message: string }
9
+ export type GitWrite =
10
+ | { readonly kind: "commitAll"; readonly message: string }
11
+ | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
10
12
 
11
13
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
12
14
  export type Outcome =
@@ -11,7 +11,13 @@ export type Landing =
11
11
  | { readonly kind: "noop"; readonly settled: boolean }
12
12
  /** An empty agent turn: recorded, but the process stays at the step. */
13
13
  | { readonly kind: "attempt"; readonly subject: string }
14
- | { readonly kind: "commit"; readonly to: StateName; readonly spec: CommitSpec }
14
+ | {
15
+ readonly kind: "commit"
16
+ readonly to: StateName
17
+ readonly spec: CommitSpec
18
+ /** The full history ref a finishing landing points at its own commit. */
19
+ readonly keepHistory?: string
20
+ }
15
21
 
16
22
  /**
17
23
  * Everything `planStep` needs, already resolved
@@ -108,7 +108,8 @@ describe("planStart", () => {
108
108
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
109
109
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
110
110
  const write = plan.steps.find((s) => s.kind === "gitWrite")
111
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
111
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
112
+ throw new Error("expected a gitWrite step")
112
113
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
113
114
  })
114
115
 
@@ -124,7 +125,8 @@ describe("planStart", () => {
124
125
  const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
125
126
  if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
126
127
  const write = plan.steps.find((s) => s.kind === "gitWrite")
127
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
128
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
129
+ throw new Error("expected a gitWrite step")
128
130
  expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
129
131
  })
130
132
 
@@ -189,7 +191,8 @@ describe("planStart", () => {
189
191
  })
190
192
  if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
191
193
  const write = plan.steps.find((s) => s.kind === "gitWrite")
192
- if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
194
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
195
+ throw new Error("expected a gitWrite step")
193
196
  expect(write.write.message).toBe(
194
197
  "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 1",
195
198
  )
@@ -92,7 +92,8 @@ describe("planStep — commit", () => {
92
92
  })
93
93
  if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
94
94
  const write = outcome.steps.find((step) => step.kind === "gitWrite")
95
- if (write?.kind !== "gitWrite") throw new Error("expected a git write")
95
+ if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
96
+ throw new Error("expected a git write")
96
97
  const trailers = write.write.message.split("\n").slice(2)
97
98
  expect(trailers[0]).toBe("Gtd-Step: a#1")
98
99
  expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
@@ -102,3 +103,30 @@ describe("planStep — commit", () => {
102
103
  expect(trailers).toHaveLength(5)
103
104
  })
104
105
  })
106
+
107
+ describe("planStep — keepHistory", () => {
108
+ it("writes the history ref right after the commit, before the outcome", () => {
109
+ const s = snapshot({
110
+ state: "building",
111
+ stepDef: {},
112
+ landing: { ...commitTo("building", "done"), keepHistory: "refs/gtd/history/abc" },
113
+ })
114
+ const outcome = planStep(s)
115
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
116
+ expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "gitWrite", "outcome"])
117
+ expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitAll" } })
118
+ expect(outcome.steps[1]).toEqual({
119
+ kind: "gitWrite",
120
+ write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" },
121
+ })
122
+ })
123
+
124
+ it("without keepHistory no updateRef is emitted", () => {
125
+ const s = snapshot({ state: "building", stepDef: {}, landing: commitTo("building", "done") })
126
+ const outcome = planStep(s)
127
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
128
+ expect(
129
+ outcome.steps.some((step) => step.kind === "gitWrite" && step.write.kind === "updateRef"),
130
+ ).toBe(false)
131
+ })
132
+ })
@@ -83,6 +83,14 @@ export const planStep = (
83
83
  steps: [
84
84
  ...uncheckStep(snapshot),
85
85
  { kind: "gitWrite", write: { kind: "commitAll", message } },
86
+ ...(landing.keepHistory !== undefined
87
+ ? [
88
+ {
89
+ kind: "gitWrite",
90
+ write: { kind: "updateRef", ref: landing.keepHistory, target: "HEAD" },
91
+ } as const,
92
+ ]
93
+ : []),
86
94
  outcome,
87
95
  ],
88
96
  }
@@ -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