@pmelab/gtd 27.5.0 → 27.7.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -2
  3. package/dist/chunks/{reserved-DM5W_LAt.mjs → reserved-Df8GeSHw.mjs} +37 -9
  4. package/dist/chunks/{tail-6gvLXXcg.mjs → tail-DmIfEAI4.mjs} +1 -1
  5. package/dist/flows.mjs +17 -1
  6. package/dist/gtd.bundle.mjs +56 -11
  7. package/dist/testkit.mjs +67 -26
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +61 -10
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +2 -2
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +110 -16
  14. package/package.json +1 -1
  15. package/src/cli/Cli.ts +2 -2
  16. package/src/edge/Edge.test.ts +111 -0
  17. package/src/edge/Edge.ts +15 -3
  18. package/src/flags/index.test.ts +13 -0
  19. package/src/flags/index.ts +1 -0
  20. package/src/flows/runtime.test.ts +42 -0
  21. package/src/flows/runtime.ts +36 -0
  22. package/src/replay/Replay.test.ts +43 -0
  23. package/src/replay/Replay.ts +10 -2
  24. package/src/replay/Trailers.test.ts +54 -0
  25. package/src/replay/Trailers.ts +15 -1
  26. package/src/testkit/EmittedScriptRecognizer.test.ts +41 -0
  27. package/src/testkit/EmittedScriptRecognizer.ts +16 -7
  28. package/src/testkit/TestRepo.test.ts +79 -1
  29. package/src/testkit/TestRepo.ts +17 -6
  30. package/src/testkit/steps.ts +44 -1
  31. package/src/testkit/testkit.feature +35 -0
  32. package/src/workflows/cleanup/cleanup-path.feature +94 -0
  33. package/src/workflows/cleanup/cleanup.feature +0 -29
  34. package/src/workflows/feature/fast-path.feature +114 -0
  35. package/src/workflows/feature/feature.ts +3 -1
  36. package/src/workflows/feature/one-step-land.feature +108 -0
  37. package/src/workflows/feature/refactor-first.feature +137 -0
  38. package/src/workflows/feature/risk-tier.feature +238 -0
  39. package/src/workflows/feature/seam-rejection.feature +146 -0
  40. package/src/workflows/feature/seam.feature +0 -67
  41. package/src/workflows/feature/seam.ts +5 -4
  42. package/src/workflows/feature/steps.ts +3 -2
  43. package/src/workflows/feature/tier.test.ts +164 -0
  44. package/src/workflows/feature/tier.ts +74 -0
  45. package/src/workflows/shared/options.test.ts +6 -1
  46. package/src/workflows/shared/text.test.ts +129 -0
  47. package/src/workflows/shared/text.ts +57 -9
  48. package/src/workflows/shared/vars.ts +2 -0
  49. package/src/workflows/shared/workflows.steps.ts +13 -4
@@ -10,6 +10,7 @@ import {
10
10
  type ScopeOptions,
11
11
  type StepRequest,
12
12
  type Flow,
13
+ type Mark,
13
14
  } from "../flows/index.js"
14
15
  import {
15
16
  headingSectionBodies,
@@ -114,12 +115,15 @@ export type ReplayOutcome =
114
115
  readonly trace: readonly ReachedStep[]
115
116
  /** The step the pending turn completed, when one was supplied. */
116
117
  readonly landed: ReachedStep | undefined
118
+ /** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
119
+ readonly marks: readonly Mark[]
117
120
  }
118
121
  | {
119
122
  readonly kind: "ended"
120
123
  readonly via: "return" | "restart"
121
124
  readonly trace: readonly ReachedStep[]
122
125
  readonly landed: ReachedStep | undefined
126
+ readonly marks: readonly Mark[]
123
127
  }
124
128
  | { readonly kind: "divergence"; readonly message: string }
125
129
  | { readonly kind: "refused"; readonly message: string }
@@ -292,6 +296,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
292
296
  { readonly hash: string; readonly before: TreeView; readonly after: TreeView }
293
297
  >()
294
298
  let landed: ReachedStep | undefined
299
+ const marks: Mark[] = []
295
300
  let outcome: ReplayOutcome | undefined
296
301
  let signal: (() => void) | undefined
297
302
  const settled = new Promise<void>((resolve) => {
@@ -553,7 +558,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
553
558
  const commit = commits[cursor]
554
559
  if (commit !== undefined) return consumeCommit(step, commit)
555
560
  if (input.pending === undefined || pendingUsed) {
556
- return finish({ kind: "rest", rest: step, trace, landed })
561
+ return finish({ kind: "rest", rest: step, trace, landed, marks })
557
562
  }
558
563
  return landPending(step, input.pending)
559
564
  }
@@ -598,7 +603,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
598
603
  kind: "divergence",
599
604
  message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
600
605
  }
601
- : { kind: "ended", via, trace, landed }
606
+ : { kind: "ended", via, trace, landed, marks }
602
607
 
603
608
  const changesSince = (hash: string): readonly Change[] => {
604
609
  const tree = treeAt(hash)
@@ -670,6 +675,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
670
675
  .map((f) => f.message),
671
676
  }))
672
677
  }),
678
+ mark: (name, value) => {
679
+ if (pendingUsed) marks.push({ name, value })
680
+ },
673
681
  kept: (name) => {
674
682
  const tip = keptTips.get(scoped(name))
675
683
  return tip === undefined
@@ -282,3 +282,57 @@ describe("isRecordableApproval", () => {
282
282
  expect(isRecordableApproval(of("Ada", "a@x.io", 0))).toBe(false)
283
283
  })
284
284
  })
285
+
286
+ describe("Gtd-Mark", () => {
287
+ const body = (...lines: string[]) => `gtd(human): a → b\n\n${lines.join("\n")}`
288
+
289
+ it("round-trips in order, names repeating", () => {
290
+ const message = formatCommitMessage({
291
+ actor: "human",
292
+ from: "a",
293
+ to: "b",
294
+ marks: [
295
+ { name: "risk-tier", value: "high" },
296
+ { name: "production-signal", value: "error rate = 0" },
297
+ { name: "production-signal", value: "p95" },
298
+ ],
299
+ })
300
+ expect(parseCommitMessage(message).marks).toEqual([
301
+ { name: "risk-tier", value: "high" },
302
+ { name: "production-signal", value: "error rate = 0" },
303
+ { name: "production-signal", value: "p95" },
304
+ ])
305
+ })
306
+
307
+ it("is written after Gtd-Judge and before Gtd-Approved, with no Gtd-Format bump", () => {
308
+ const lines = formatCommitMessage({
309
+ actor: "human",
310
+ to: "b",
311
+ judge: [{ id: "v", answer: "x", p: 1 }],
312
+ marks: [{ name: "risk-tier", value: "high" }],
313
+ approved: { step: { name: "s", occurrence: 1 }, name: "Ada", email: "a@x.io" },
314
+ }).split("\n")
315
+ const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
316
+ expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
317
+ expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
318
+ expect(lines).toContain("Gtd-Format: 1")
319
+ })
320
+
321
+ it("writes nothing without marks", () => {
322
+ expect(formatCommitMessage({ actor: "human", to: "b" })).not.toContain("Gtd-Mark")
323
+ expect(parseCommitMessage("gtd(human): a → b").marks).toEqual([])
324
+ })
325
+
326
+ it("skips a malformed line, a missing =, an empty value or a control character", () => {
327
+ const parsed = parseCommitMessage(
328
+ body(
329
+ "Gtd-Mark: no-equals",
330
+ "Gtd-Mark: Bad_Name=x",
331
+ "Gtd-Mark: empty=",
332
+ "Gtd-Mark: ctl=a\u001bb",
333
+ "Gtd-Mark: ok=fine",
334
+ ),
335
+ )
336
+ expect(parsed.marks).toEqual([{ name: "ok", value: "fine" }])
337
+ })
338
+ })
@@ -3,6 +3,7 @@
3
3
  // written by gtd) is skipped, not fatal — a corrupt line must never make every
4
4
  // command unusable.
5
5
 
6
+ import type { Mark } from "../flows/index.js"
6
7
  import { HISTORY_FORMAT } from "./Format.js"
7
8
 
8
9
  const TRANSITION_SEP = " → "
@@ -73,6 +74,8 @@ export interface CommitMessage {
73
74
  /** Written only on a process's opening commit. */
74
75
  readonly workflow: string | undefined
75
76
  readonly judge: readonly JudgeVerdict[]
77
+ /** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
78
+ readonly marks: readonly Mark[]
76
79
  readonly vars: Readonly<Record<string, string>>
77
80
  /** Written only on a process's first commit. */
78
81
  readonly config: Readonly<Record<string, string>>
@@ -141,6 +144,7 @@ export const isRecordableApproval = (approval: Approval): boolean => {
141
144
  const back = decodeApproval(encodeApproval(approval))
142
145
  return back !== undefined && encodeApproval(back) === encodeApproval(approval)
143
146
  }
147
+ const MARK_RE = /^([a-z][a-z0-9-]*)=(.+)$/
144
148
  const FORMAT_RE = /^[1-9][0-9]*$/
145
149
  const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
146
150
 
@@ -153,6 +157,7 @@ interface Trailers {
153
157
  kept: string | undefined
154
158
  approved: Approval | undefined
155
159
  readonly judge: JudgeVerdict[]
160
+ readonly marks: Mark[]
156
161
  readonly vars: Record<string, string>
157
162
  readonly config: Record<string, string>
158
163
  readonly cost: CostEntry[]
@@ -170,6 +175,11 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
170
175
  const parsed = parseJson(value)
171
176
  if (isVerdict(parsed)) into.judge.push(parsed)
172
177
  },
178
+ "Gtd-Mark": (value, into) => {
179
+ const kv = MARK_RE.exec(value)
180
+ if (kv !== null && !CONTROL_CHARACTER.test(value))
181
+ into.marks.push({ name: kv[1]!, value: kv[2]! })
182
+ },
173
183
  "Gtd-Var": (value, into) => {
174
184
  const kv = VAR_RE.exec(value)
175
185
  if (kv !== null) into.vars[kv[1]!] = kv[2]!
@@ -215,6 +225,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
215
225
  kept: undefined,
216
226
  approved: undefined,
217
227
  judge: [],
228
+ marks: [],
218
229
  vars: {},
219
230
  config: {},
220
231
  cost: [],
@@ -239,7 +250,9 @@ export interface CommitSpec {
239
250
  readonly config?: Readonly<Record<string, string>>
240
251
  readonly cost?: { readonly cost: number; readonly model?: string }
241
252
  readonly judge?: readonly JudgeVerdict[]
242
- /** Written after `Gtd-Judge`, before `Gtd-Branch`. */
253
+ /** Written after `Gtd-Judge`, before `Gtd-Approved`. */
254
+ readonly marks?: readonly Mark[]
255
+ /** Written after `Gtd-Mark`, before `Gtd-Branch`. */
243
256
  readonly approved?: Approval
244
257
  readonly branch?: string
245
258
  readonly version?: string
@@ -263,6 +276,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
263
276
  )
264
277
  }
265
278
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`)
279
+ for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`)
266
280
  if (spec.approved !== undefined) {
267
281
  lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
268
282
  }
@@ -630,6 +630,18 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
630
630
  expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
631
631
  })
632
632
 
633
+ it("fails an unrecognized optional block under git: false", () => {
634
+ const repo = new InMemRepo()
635
+ const result = applyEmittedScript(
636
+ repo,
637
+ NO_COMMANDS,
638
+ combinedScript("set -eu", "totally-unrecognized-command"),
639
+ { git: false },
640
+ )
641
+ expect(result.ok).toBe(false)
642
+ expect(result.error).toContain("totally-unrecognized-command")
643
+ })
644
+
633
645
  it("still fails loudly on an unrecognized block outside the presentation-only subshell", () => {
634
646
  const repo = new InMemRepo()
635
647
  const script = `${combinedScript("totally-unrecognized-required-command", "")}`
@@ -691,3 +703,32 @@ describe("applyEmittedScript — commitClean", () => {
691
703
  expect(snapshot(repo)).toEqual(before)
692
704
  })
693
705
  })
706
+
707
+ describe("applyEmittedScript — git: false reaches through the presentation subshell", () => {
708
+ it("refuses a git builder wrapped in combinedScript and changes nothing", () => {
709
+ const repo = new InMemRepo()
710
+ repo.writeFile("a.txt", "1")
711
+ const before = snapshot(repo)
712
+ const result = applyEmittedScript(
713
+ repo,
714
+ NO_COMMANDS,
715
+ combinedScript("set -eu", commitAll("chore: sneaky")),
716
+ { git: false },
717
+ )
718
+ expect(result.ok).toBe(false)
719
+ expect(result.error).toMatch(/unrecognized script block/)
720
+ expect(snapshot(repo)).toEqual(before)
721
+ })
722
+
723
+ it("still applies a non-git write-lines optional half", () => {
724
+ const repo = new InMemRepo()
725
+ const result = applyEmittedScript(
726
+ repo,
727
+ NO_COMMANDS,
728
+ combinedScript("set -eu", "mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'"),
729
+ { git: false },
730
+ )
731
+ expect(result).toMatchObject({ ok: true })
732
+ expect(repo.readFile("d/a.txt")).toBe("x\n")
733
+ })
734
+ })
@@ -563,7 +563,7 @@ const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | und
563
563
  const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
564
564
  block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
565
565
 
566
- /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
566
+ /** `combinedScript`'s optional half. Reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`. Exception: under `git: false` any unrecognized inner block fails the outer script (git or not), so the in-memory twin knowingly diverges from `sh` rather than run git unseen. */
567
567
  const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0]!.replace(/\\n$/, "")}\n`
568
568
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
569
569
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
@@ -572,6 +572,7 @@ const recognizePresentationSubshell = (
572
572
  repo: InMemRepo,
573
573
  commands: ReadonlyMap<string, ScriptedCommand>,
574
574
  block: string,
575
+ git: boolean,
575
576
  ): BlockOutcome | undefined => {
576
577
  if (
577
578
  !block.startsWith(PRESENTATION_SUBSHELL_PREFIX) ||
@@ -583,8 +584,11 @@ const recognizePresentationSubshell = (
583
584
  PRESENTATION_SUBSHELL_PREFIX.length,
584
585
  block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
585
586
  )
586
- const result = applyEmittedScript(repo, commands, inner)
587
+ const result = applyEmittedScript(repo, commands, inner, { git })
587
588
  if (result.ok) return { kind: "noop", output: result.output }
589
+ if (!git && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
590
+ return { kind: "failed", error: `${result.output}${result.error}` }
591
+ }
588
592
  return { kind: "noop", output: `${result.output}${PRESENTATION_FAILURE_TEXT}` }
589
593
  }
590
594
 
@@ -703,17 +707,18 @@ const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | und
703
707
  const recognizersFor = (
704
708
  repo: InMemRepo,
705
709
  commands: ReadonlyMap<string, ScriptedCommand>,
710
+ git: boolean,
706
711
  ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
707
712
  (block) => recognizeSetFlags(block),
708
713
  (block) => recognizeDidNotRunComment(block),
709
714
  (block) => recognizePrecondition(repo, block),
710
715
  (block) => recognizeFileExistsGuard(repo, block),
711
716
  (block) => recognizeBinaryGuard(commands, block),
712
- (block) => recognizeGitBuilders(repo, block),
717
+ (block) => (git ? recognizeGitBuilders(repo, block) : undefined),
713
718
  (block) => recognizeDeferNotes(repo, block),
714
719
  (block) => recognizeWriteLines(repo, block),
715
720
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
716
- (block) => recognizePresentationSubshell(repo, commands, block),
721
+ (block) => recognizePresentationSubshell(repo, commands, block, git),
717
722
  (block) => recognizeModeContradictionSkipNotice(block),
718
723
  (block) => recognizeModeContradictionCheck(repo, commands, block),
719
724
  (block) => recognizeGtdCheck(repo, block),
@@ -746,6 +751,7 @@ export const applyEmittedScript = (
746
751
  repo: InMemRepo,
747
752
  commands: ReadonlyMap<string, ScriptedCommand>,
748
753
  script: string,
754
+ { git = true }: { readonly git?: boolean } = {},
749
755
  ): AppliedScriptResult => {
750
756
  const flow = applyFlowScript(repo, commands, script)
751
757
  if (flow !== undefined) {
@@ -753,15 +759,16 @@ export const applyEmittedScript = (
753
759
  ? { ok: true, status: 0, output: flow.output }
754
760
  : { ok: false, status: 1, output: flow.output, error: flow.error ?? "" }
755
761
  }
756
- return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script)
762
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script, git)
757
763
  }
758
764
 
759
765
  const applyBlocks = (
760
766
  repo: InMemRepo,
761
767
  commands: ReadonlyMap<string, ScriptedCommand>,
762
768
  script: string,
769
+ git: boolean,
763
770
  ): AppliedScriptResult => {
764
- const recognizers = recognizersFor(repo, commands)
771
+ const recognizers = recognizersFor(repo, commands, git)
765
772
  let output = ""
766
773
  for (const block of splitBlocks(script)) {
767
774
  const outcome = recognizeBlock(recognizers, block)
@@ -786,9 +793,11 @@ const failedAt = (
786
793
  }
787
794
  }
788
795
 
796
+ const UNRECOGNIZED_PREFIX = "unrecognized script block: "
797
+
789
798
  const unrecognized = (block: string, output = ""): AppliedScriptResult => ({
790
799
  ok: false,
791
800
  status: 1,
792
801
  output,
793
- error: `unrecognized script block: ${block.split("\n")[0]}`,
802
+ error: `${UNRECOGNIZED_PREFIX}${block.split("\n")[0]}`,
794
803
  })
@@ -1,4 +1,17 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import {
3
+ commitAll,
4
+ combinedScript,
5
+ commitAsIs,
6
+ commitClean,
7
+ deleteRef,
8
+ discardPending,
9
+ hardResetTo,
10
+ keepTurn,
11
+ mixedResetTo,
12
+ softResetTo,
13
+ updateRef,
14
+ } from "../emit/index.js"
2
15
  import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
3
16
  import { runsItsScript, testRepo } from "./TestRepo.js"
4
17
 
@@ -93,7 +106,72 @@ describe("testRepo", () => {
93
106
 
94
107
  it("refuses to fake a script it cannot run", () => {
95
108
  const repo = testRepo({ workflow: { default: tiny } })
96
- expect(() => repo.runScript("npm test\n")).toThrow(/cannot run `npm test` in memory/)
109
+ expect(() => repo.runScript("npm test\n")).toThrow(/cannot run script in memory[\s\S]*npm test/)
110
+ })
111
+
112
+ it("carries the recognizer's error when a git-free fallback fails", () => {
113
+ const repo = testRepo({ workflow: { default: tiny } })
114
+ expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
115
+ "testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
116
+ )
117
+ })
118
+
119
+ it("names the block that failed, not an earlier line that ran", () => {
120
+ const repo = testRepo({ workflow: { default: tiny } })
121
+ let message = ""
122
+ try {
123
+ repo.runScript("mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'\n\nnpm test\n")
124
+ } catch (e) {
125
+ message = (e as Error).message
126
+ }
127
+ expect(message).toContain("unrecognized script block: npm test")
128
+ expect(message).not.toContain("printf")
129
+ })
130
+
131
+ it.each([
132
+ ["commitAll", commitAll("chore: sneaky")],
133
+ ["commitClean", commitClean("chore: sneaky")],
134
+ ["commitAsIs", commitAsIs("chore: sneaky")],
135
+ ["keepTurn", keepTurn("refs/x", "chore: sneaky")],
136
+ ["discardPending", discardPending()],
137
+ ["softResetTo", softResetTo("HEAD~1")],
138
+ ["mixedResetTo", mixedResetTo("HEAD~1")],
139
+ ["hardResetTo", hardResetTo("HEAD~1")],
140
+ ["updateRef", updateRef("refs/x", "HEAD")],
141
+ ["deleteRef", deleteRef("refs/x")],
142
+ ])("refuses a %s script instead of mutating git in memory", (_, script) => {
143
+ const repo = testRepo({ workflow: { default: tiny } })
144
+ repo.write("a.txt", "dirty\n")
145
+ const before = repo.subjects()
146
+ expect(() => repo.runScript(script)).toThrow(/unrecognized script block/)
147
+ expect(repo.subjects()).toEqual(before)
148
+ })
149
+
150
+ it.each([
151
+ ["commitAll", commitAll("chore: sneaky")],
152
+ ["commitClean", commitClean("chore: sneaky")],
153
+ ["commitAsIs", commitAsIs("chore: sneaky")],
154
+ ["keepTurn", keepTurn("refs/x", "chore: sneaky")],
155
+ ["discardPending", discardPending()],
156
+ ["softResetTo", softResetTo("HEAD~1")],
157
+ ["mixedResetTo", mixedResetTo("HEAD~1")],
158
+ ["hardResetTo", hardResetTo("HEAD~1")],
159
+ ["updateRef", updateRef("refs/x", "HEAD")],
160
+ ["deleteRef", deleteRef("refs/x")],
161
+ ])("refuses a wrapped %s script instead of mutating git in memory", (_, script) => {
162
+ const repo = testRepo({ workflow: { default: tiny } })
163
+ repo.write("a.txt", "dirty\n")
164
+ const before = repo.subjects()
165
+ expect(() => repo.runScript(combinedScript("set -eu", script))).toThrow(
166
+ /cannot run script in memory[\s\S]*unrecognized script block/,
167
+ )
168
+ expect(repo.subjects()).toEqual(before)
169
+ })
170
+
171
+ it("applies a write block `LINES` lacks through the git-free recognizers", () => {
172
+ const repo = testRepo({ workflow: { default: tiny } })
173
+ repo.runScript("mkdir -p d\nprintf '%s\\n' 'echo $(git rev-parse HEAD)' > 'd/a.txt'\n")
174
+ expect(repo.read("d/a.txt")).toBe("echo $(git rev-parse HEAD)\n")
97
175
  })
98
176
 
99
177
  it("reads a file as committed", () => {
@@ -150,12 +150,23 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
150
150
  ],
151
151
  ]
152
152
 
153
- const runLine = (repo: InMemRepo, line: string): void => {
154
- const known = LINES.find(([pattern]) => pattern.test(line))
155
- if (known === undefined) {
156
- throw new Error(`testkit: cannot run \`${line}\` in memory — give the step an actor instead`)
153
+ const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
154
+
155
+ const cannotRun = (error: string): Error =>
156
+ new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`)
157
+
158
+ // A script with a line `LINES` lacks goes to the contract-tested recognizers
159
+ // whole, minus the git builders: they would apply a commit/reset/update-ref in
160
+ // memory, so any git-mutating block is refused whatever its spelling.
161
+ const runLines = (repo: InMemRepo, script: string): void => {
162
+ const lines = script.split("\n").map((line) => line.trim())
163
+ const known = lines.map(knownLine)
164
+ if (known.every(Boolean)) {
165
+ known.forEach((k, i) => k![1](repo, words(lines[i]!)))
166
+ return
157
167
  }
158
- known[1](repo, words(line))
168
+ const applied = applyEmittedScript(repo, new Map(), script, { git: false })
169
+ if (!applied.ok) throw cannotRun(applied.error ?? "")
159
170
  }
160
171
 
161
172
  const failed = (what: string, result: CapturedCliResult): Error =>
@@ -251,7 +262,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
251
262
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
252
263
  runScript: (script) => {
253
264
  const flow = applyFlowScript(repo, new Map(), script)
254
- if (flow === undefined) for (const line of script.split("\n")) runLine(repo, line.trim())
265
+ if (flow === undefined) runLines(repo, script)
255
266
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
256
267
  },
257
268
  gtd,
@@ -70,7 +70,15 @@ export class TestkitWorld extends QuickPickleWorld {
70
70
  for (const action of actions) await action(beat, repo)
71
71
  }
72
72
  }
73
- for (const [step, verdict] of this.verdicts) actors[step] = () => verdict
73
+ for (const [step, verdict] of this.verdicts) {
74
+ // A verdict would overwrite the queued actions, silently skipping their checks.
75
+ if (this.actions.has(step)) {
76
+ throw new Error(
77
+ `step "${step}" has a judge verdict and queued actions; the actions would never run`,
78
+ )
79
+ }
80
+ actors[step] = () => verdict
81
+ }
74
82
  await this.repo().drive(actors)
75
83
  }
76
84
  }
@@ -120,6 +128,19 @@ Given(
120
128
  },
121
129
  )
122
130
 
131
+ // A standing assertion like any queued action: it applies on every later visit
132
+ // to the step until `from now on` clears the list.
133
+ Given(
134
+ "step {string} is prompted with {string}",
135
+ (world: TestkitWorld, step: string, text: string) => {
136
+ world.act(step, (beat) => {
137
+ if (!beat.content.includes(text)) {
138
+ throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
139
+ }
140
+ })
141
+ },
142
+ )
143
+
123
144
  Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
124
145
  world.act(step, runsItsScript)
125
146
  })
@@ -207,6 +228,28 @@ Then("the commit subjects end with:", (world: TestkitWorld, list: string) => {
207
228
  assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
208
229
  })
209
230
 
231
+ const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
232
+ const result = await world.repo().gtd(...args(list))
233
+ assert.strictEqual(result.exitCode, 0, result.stderr)
234
+ return result.stdout
235
+ }
236
+
237
+ Then(
238
+ "the output of gtd {string} contains {string}",
239
+ async (world: TestkitWorld, list: string, text: string) => {
240
+ const out = await outputOf(world, list)
241
+ assert.ok(out.includes(text), out)
242
+ },
243
+ )
244
+
245
+ Then(
246
+ "the output of gtd {string} does not contain {string}",
247
+ async (world: TestkitWorld, list: string, text: string) => {
248
+ const out = await outputOf(world, list)
249
+ assert.ok(!out.includes(text), out)
250
+ },
251
+ )
252
+
210
253
  Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
211
254
  assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
212
255
  })
@@ -1,3 +1,4 @@
1
+ @inmem
1
2
  Feature: testkit drives any workflow against the real engine
2
3
 
3
4
  A workflow the scenario writes itself, never a shipped one: scripted actors
@@ -47,6 +48,8 @@ Feature: testkit drives any workflow against the real engine
47
48
  gtd(judge): ready → ship
48
49
  """
49
50
  And "PLAN.md" contains "add two numbers"
51
+ And the output of gtd "next" contains "ship"
52
+ And the output of gtd "next" does not contain "rework"
50
53
 
51
54
  Scenario: a red check sends the process to repair, and an unscripted step is where it rests
52
55
  Given step "plan" writes "PLAN.md" with:
@@ -63,6 +66,38 @@ Feature: testkit drives any workflow against the real engine
63
66
  And the resting step's content contains "Make the suite pass."
64
67
  And the last commit subject is "gtd(check): check → repair"
65
68
 
69
+ Scenario: a step's beat content is asserted as a queued actor
70
+ Given step "plan" is prompted with "Write PLAN.md."
71
+ And step "plan" writes "PLAN.md" with:
72
+ """
73
+ add two numbers
74
+ """
75
+ And step "check" changes nothing
76
+ And judge step "ready" answers:
77
+ """
78
+ [{ "id": "ready", "answer": true, "p": 0.9 }]
79
+ """
80
+ When gtd starts workflow "tiny"
81
+ And the process is driven
82
+ Then the process rests at "ship"
83
+ And "PLAN.md" contains "add two numbers"
84
+
85
+ Scenario: a judge step with queued actions refuses the drive
86
+ Given step "plan" changes nothing
87
+ And step "check" changes nothing
88
+ And step "ready" is prompted with "never seen"
89
+ And judge step "ready" answers:
90
+ """
91
+ [{ "id": "ready", "answer": true, "p": 0.9 }]
92
+ """
93
+ When gtd starts workflow "tiny"
94
+ Then driving the process is refused with "queued actions"
95
+
96
+ Scenario: a beat that lacks the expected text refuses the drive
97
+ Given step "plan" is prompted with "Write a poem"
98
+ When gtd starts workflow "tiny"
99
+ Then driving the process is refused with 'was not prompted with "Write a poem"'
100
+
66
101
  Scenario: a start the workflow refuses is reported with its message
67
102
  Then starting workflow "picky" with "--var mode=bad" is refused with "picky refuses every start"
68
103
 
@@ -0,0 +1,94 @@
1
+ # One SDLC path per file, so a changed path reds exactly one fixture — do not merge.
2
+ @inmem
3
+ Feature: cleanup walks one fixed path from the opening commit to idle
4
+
5
+ A default-on release flag is cleaned up by an agent that removes the registry
6
+ entry and the off path; health, review and closing follow and the process
7
+ rests at idle. Cleanup reads no release flag, so no flag is pinned here and
8
+ every flag state takes the same path.
9
+
10
+ Background:
11
+ Given the shipped workflows, as gtd install scaffolds them
12
+ And a gtd config with:
13
+ """
14
+ export {
15
+ feature as default,
16
+ feature,
17
+ defaults,
18
+ envDefaults,
19
+ summary,
20
+ steering,
21
+ skills,
22
+ access,
23
+ } from "@pmelab/gtd/workflows/feature"
24
+ import { cleanup } from "@pmelab/gtd/workflows"
25
+ export { cleanup }
26
+ """
27
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
28
+ And a file "src/flags/index.ts" with:
29
+ """
30
+ export const FLAGS = [
31
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
32
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
33
+ ] as const
34
+ """
35
+ And a file "src/calc.ts" with:
36
+ """
37
+ export const on = process.env.GTD_FLAG_LIVE !== "0"
38
+ """
39
+ And the working tree is committed as "feat: registry"
40
+ And step "cleanup.removing" writes "src/flags/index.ts" with:
41
+ """
42
+ export const FLAGS = [
43
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
44
+ ] as const
45
+ """
46
+ And step "cleanup.removing" writes "src/calc.ts" with:
47
+ """
48
+ export const on = true
49
+ """
50
+ And step "build.health.check" changes nothing
51
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
52
+ """
53
+ # Review: abc1234
54
+
55
+ <!-- base: 0000000 -->
56
+
57
+ ## calc
58
+
59
+ - [ ] ./src/calc.ts#1-1
60
+ flag removed
61
+ """
62
+ And step "build.review.closing" runs its script
63
+
64
+ Scenario: removing the entry and the off path runs health, review and closing to idle
65
+ When gtd starts workflow "cleanup" with "--var flag=live"
66
+ And the process is driven
67
+ Then the process rests at "build.review.await-review"
68
+ Given a file ".gtd/REVIEW.md" with:
69
+ """
70
+ # Review: abc1234
71
+
72
+ <!-- base: 0000000 -->
73
+
74
+ ## calc
75
+
76
+ - [x] ./src/calc.ts#1-1
77
+ flag removed
78
+ """
79
+ When gtd lands
80
+ And the process is driven
81
+ Then the process rests at "idle"
82
+ And "src/flags/index.ts" does not contain "live"
83
+ And "src/calc.ts" does not contain "GTD_FLAG_LIVE"
84
+ And the commit subjects end with:
85
+ """
86
+ gtd(human): cleanup.removing
87
+ gtd(agent): cleanup.removing → build.health.check
88
+ gtd(check): build.health.check → build.review.reviewing
89
+ gtd(agent): build.review.reviewing → build.review.await-review
90
+ gtd(human): build.review.await-review → build.review.closing
91
+ gtd(check): build.review.closing → idle
92
+ """
93
+ And the commit "gtd(human): cleanup.removing" has a body containing "Gtd-Workflow: cleanup"
94
+ And the commit "gtd(human): cleanup.removing" has a body containing "Gtd-Var: flag=live"