@pmelab/gtd 60.0.0 → 61.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -2
  3. package/dist/chunks/testkit-C9sq0x07.mjs +30758 -0
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +12238 -20706
  6. package/dist/gtd.core.mjs +593 -0
  7. package/dist/testkit/gherkin.mjs +2 -2
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +7 -7
  11. package/src/cli/Cli.test.ts +2 -2
  12. package/src/cli/Cli.ts +68 -51
  13. package/src/cli/History.test.ts +10 -1
  14. package/src/cli/Metrics.test.ts +10 -1
  15. package/src/cli/Output.test.ts +1 -1
  16. package/src/cli/Output.ts +3 -2
  17. package/src/cli/Program.test.ts +44 -25
  18. package/src/cli/Program.ts +39 -11
  19. package/src/cli/Strays.ts +3 -2
  20. package/src/cli/ToolCommands.ts +2 -2
  21. package/src/edge/Edge.test.ts +86 -31
  22. package/src/edge/Edge.ts +164 -31
  23. package/src/emit/GitScript.ts +21 -3
  24. package/src/emit/index.ts +3 -0
  25. package/src/flows/runtime.ts +1 -1
  26. package/src/gherkin/messages.test.ts +1 -0
  27. package/src/gherkin/module-config.feature +1 -0
  28. package/src/gherkin/more-steps.feature +1 -0
  29. package/src/gherkin/testkit.feature +2 -1
  30. package/src/install/Install.ts +9 -2
  31. package/src/install/Version.ts +9 -3
  32. package/src/install/index.ts +1 -1
  33. package/src/platform/CommandRunner.ts +13 -11
  34. package/src/platform/Commentary.ts +2 -2
  35. package/src/platform/Git.test.ts +4 -5
  36. package/src/platform/Git.ts +42 -38
  37. package/src/platform/Host.ts +1 -1
  38. package/src/platform/NodeServices.ts +11 -0
  39. package/src/platform/Workspace.test.ts +4 -5
  40. package/src/platform/Workspace.ts +3 -3
  41. package/src/platform/WorktreeState.test.ts +2 -3
  42. package/src/platform/WorktreeState.ts +1 -1
  43. package/src/platform/index.ts +2 -1
  44. package/src/replay/Format.test.ts +35 -8
  45. package/src/replay/Format.ts +7 -4
  46. package/src/replay/Replay.test.ts +54 -0
  47. package/src/replay/Replay.ts +61 -17
  48. package/src/replay/Trailers.test.ts +12 -12
  49. package/src/replay/Trailers.ts +35 -20
  50. package/src/replay/Tree.ts +11 -0
  51. package/src/replay/index.ts +3 -0
  52. package/src/step/LandStep.ts +1 -0
  53. package/src/step/RepoSnapshot.ts +2 -0
  54. package/src/step/planStart.test.ts +2 -2
  55. package/src/step/planStart.ts +3 -3
  56. package/src/step/planStep.test.ts +4 -4
  57. package/src/step/planStep.ts +10 -8
  58. package/src/testkit/TestRepo.test.ts +4 -1
  59. package/src/testkit/TestRepo.ts +3 -1
  60. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  61. package/src/twins/EmittedScripts.test.ts +1 -1
  62. package/src/twins/GitTiers.ts +29 -7
  63. package/src/twins/InMemRepo.ts +14 -0
  64. package/src/twins/Layers.test.ts +1 -1
  65. package/src/twins/Layers.ts +10 -3
  66. package/src/twins/PinBeats.ts +33 -0
  67. package/src/twins/cliIo.ts +9 -4
  68. package/src/twins/index.ts +1 -0
  69. package/src/wire/BeatDocument.ts +26 -5
  70. package/src/wire/Demand.ts +16 -6
  71. package/src/wire/constants.ts +3 -0
  72. package/src/wire/types.ts +4 -0
  73. package/src/workflow/discovery.test.ts +24 -7
  74. package/src/workflow/discovery.ts +5 -6
  75. package/src/workflow/index.ts +1 -0
  76. package/src/workflow/load.test.ts +60 -3
  77. package/src/workflow/load.ts +316 -166
  78. package/src/workflow/pin.test.ts +159 -0
  79. package/src/workflow/pin.ts +72 -0
  80. package/src/workflow/vars.ts +3 -3
  81. package/dist/chunks/testkit-BvecVjQ0.mjs +0 -32355
@@ -18,7 +18,13 @@ vi.mock("node:os", async (importOriginal) => ({
18
18
  import { runCli, type Command } from "./index.js"
19
19
  import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
20
  import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
21
- import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
21
+ import {
22
+ InMemRepo,
23
+ makeCapturingCliIo,
24
+ testLayers,
25
+ applyEmittedScript,
26
+ takePinBeat,
27
+ } from "../twins/index.js"
22
28
  import {
23
29
  commitAll,
24
30
  DID_NOT_RUN_COMMENT,
@@ -39,13 +45,14 @@ const run = async (
39
45
  return result()
40
46
  }
41
47
 
42
- /** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
48
+ /** Lands via `gtd land --json` and applies the emitted script, then takes the pin or unpin beat it leads to, as a driver would. */
43
49
  const landAndApply = async (
44
50
  repo: InMemRepo,
45
51
  ): Promise<{ readonly exitCode: number; readonly script: string }> => {
46
52
  const { stdout, exitCode } = await run(repo, "land", "--json")
47
53
  const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
48
54
  if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
55
+ if (exitCode === 0) await takePinBeat(repo)
49
56
  return { exitCode, script }
50
57
  }
51
58
 
@@ -104,7 +111,7 @@ export const hotfix = workflow(
104
111
  expect(exitCode).toBe(0)
105
112
  expect(stdout).toContain(
106
113
  commitAll(
107
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 1`,
114
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 2`,
108
115
  ),
109
116
  )
110
117
  expect(repo.commitHistory()).toHaveLength(before)
@@ -190,6 +197,9 @@ export const hotfix = workflow(
190
197
  const { stdout } = await run(repo, "start", "hotfix")
191
198
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
192
199
  expect(repo.lastCommitSubject()).toBe("gtd(human): working")
200
+ const pin = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
201
+ expect(pin).toMatchObject({ kind: "script", state: "pin", actor: "check", pin: "add" })
202
+ expect(await takePinBeat(repo, "add")).toBe(true)
193
203
  const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
194
204
  expect(next.kind).toBe("prompt")
195
205
  expect(next.state).toBe("working")
@@ -496,6 +506,7 @@ export const resume = workflow(work)
496
506
  const repo = seed(WORKFLOW)
497
507
  const { stdout: entry } = await run(repo, "start", "resume")
498
508
  expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
509
+ await takePinBeat(repo, "add")
499
510
  const { stdout, exitCode } = await run(repo, "next", "--json")
500
511
  expect(exitCode).toBe(0)
501
512
  expect(JSON.parse(stdout).kind).toBe("prompt")
@@ -775,20 +786,20 @@ describe("gtd next — refuses when history diverges from what replay reaches",
775
786
  expect(repo.commitHistory()).toHaveLength(before)
776
787
 
777
788
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
778
- expect(repo.commitHistory()).toHaveLength(before - 2)
789
+ expect(repo.commitHistory()).toHaveLength(before - 3)
779
790
  })
780
791
  })
781
792
 
782
793
  describe("a history written in a format this gtd does not read", () => {
783
794
  // The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
784
- const FOREIGN = "is written in history format 2, but this gtd reads format 1"
795
+ const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
785
796
 
786
797
  /** A step commit that would also diverge, so only the format gate can explain the refusal. */
787
798
  const foreignStep = async (): Promise<InMemRepo> => {
788
799
  const repo = seed(IDLE_THEN_WORKING)
789
800
  await landTurn(repo, { "NOTE.md": "a note\n" })
790
801
  repo.writeFile("WORK.md", "done\n")
791
- repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
802
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
792
803
  return repo
793
804
  }
794
805
 
@@ -813,7 +824,7 @@ describe("a history written in a format this gtd does not read", () => {
813
824
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
814
825
  const repo = seed(IDLE_THEN_WORKING)
815
826
  repo.writeFile("NOTE.md", "a note\n")
816
- repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
827
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
817
828
  for (const command of ["next", "abandon"]) {
818
829
  const { exitCode, stderr } = await run(repo, command)
819
830
  expect(exitCode).toBe(1)
@@ -824,7 +835,7 @@ describe("a history written in a format this gtd does not read", () => {
824
835
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
825
836
  const repo = seed(IDLE_THEN_WORKING)
826
837
  repo.writeFile("NOTE.md", "a note\n")
827
- repo.seedCommit("feat: x\n\nGtd-Format: 2")
838
+ repo.seedCommit("feat: x\n\nGtd-Format: 3")
828
839
  expect((await run(repo, "next")).exitCode).toBe(0)
829
840
  })
830
841
 
@@ -832,7 +843,7 @@ describe("a history written in a format this gtd does not read", () => {
832
843
  const repo = seed(IDLE_THEN_WORKING)
833
844
  const base = repo.resolveRef("HEAD")!
834
845
  repo.writeFile("NOTE.md", "a note\n")
835
- repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
846
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
836
847
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
837
848
  repo.hardResetTo(base)
838
849
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1390,10 +1401,10 @@ export default async () => {
1390
1401
  expect(await nextOf(repo)).toEqual({ target: "accepted" })
1391
1402
  })
1392
1403
 
1393
- it("a turn that finishes the flow targets the initial step", async () => {
1404
+ it("a turn that finishes a pinned flow targets the unpin", async () => {
1394
1405
  const repo = await atWorking()
1395
1406
  repo.writeFile("REVIEW.md", "an updated review\n")
1396
- expect(await nextOf(repo)).toMatchObject({ target: "idle" })
1407
+ expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
1397
1408
  })
1398
1409
 
1399
1410
  it("plain gtd next shows the same preview as a `Next:` line", async () => {
@@ -1476,7 +1487,7 @@ export default async () => {
1476
1487
  expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
1477
1488
  })
1478
1489
 
1479
- it("a clean run that finishes the flow lands an ordinary commit re-entering the initial step", async () => {
1490
+ it("a clean run that finishes a pinned flow lands an ordinary commit at the unpin", async () => {
1480
1491
  const repo = await atChecking(REENTRY_WORKFLOW)
1481
1492
  const { stdout, exitCode } = await run(repo, "land", "--json")
1482
1493
  expect(exitCode).toBe(0)
@@ -1485,7 +1496,7 @@ export default async () => {
1485
1496
  expect(script).not.toContain("git reset --mixed")
1486
1497
  expect(script).not.toContain("nothing to retain")
1487
1498
  expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
1488
- expect(repo.lastCommitSubject()).toBe("gtd(check): checking → idle")
1499
+ expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
1489
1500
  })
1490
1501
 
1491
1502
  it("the same rest with a pending change lands an ordinary commit too", async () => {
@@ -1523,7 +1534,7 @@ export default async () => {
1523
1534
  expect(parsed.script).toContain("nothing to do")
1524
1535
  })
1525
1536
 
1526
- it("gtd land --json a decision that re-enters the initial step reports settled:false, idle:true, state:idle", async () => {
1537
+ it("gtd land --json a decision that ends a pinned flow reports settled:false, idle:false, state:unpin", async () => {
1527
1538
  const repo = await atChecking(REENTRY_WORKFLOW)
1528
1539
  const { stdout, exitCode } = await run(repo, "land", "--json")
1529
1540
  expect(exitCode).toBe(0)
@@ -1533,8 +1544,8 @@ export default async () => {
1533
1544
  readonly state: string
1534
1545
  }
1535
1546
  expect(parsed.settled).toBe(false)
1536
- expect(parsed.idle).toBe(true)
1537
- expect(parsed.state).toBe("idle")
1547
+ expect(parsed.idle).toBe(false)
1548
+ expect(parsed.state).toBe("unpin")
1538
1549
  })
1539
1550
 
1540
1551
  it("gtd land --json an ordinary commit reports settled:false", async () => {
@@ -1683,7 +1694,7 @@ export default async () => {
1683
1694
  const { exitCode, script } = await landAndApply(repo)
1684
1695
  expect(exitCode).toBe(0)
1685
1696
  expect(script).not.toContain("fmt-notes")
1686
- expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting → idle")
1697
+ expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
1687
1698
  })
1688
1699
 
1689
1700
  it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
@@ -1712,6 +1723,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1712
1723
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1713
1724
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1714
1725
  guide: { kind: "guide" },
1726
+ bundle: { kind: "bundle" },
1715
1727
  summary: { kind: "summary" },
1716
1728
  base: { kind: "base" },
1717
1729
  judge: { kind: "judge" },
@@ -1732,11 +1744,12 @@ describe("runCommand — refuses in a repository with no commits", () => {
1732
1744
  const NO_COMMITS_MESSAGE =
1733
1745
  "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1734
1746
 
1735
- it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
1747
+ it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1736
1748
  expect(stateKinds.sort()).toEqual(
1737
1749
  [
1738
1750
  "abandon",
1739
1751
  "base",
1752
+ "bundle",
1740
1753
  "start",
1741
1754
  "land",
1742
1755
  "next",
@@ -1761,7 +1774,10 @@ describe("runCommand — refuses in a repository with no commits", () => {
1761
1774
  const repo = new InMemRepo()
1762
1775
  const before = repo.commitHistory().length
1763
1776
  const written: string[] = []
1764
- const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1777
+ const out = {
1778
+ write: (chunk: string | Uint8Array) => written.push(String(chunk)),
1779
+ flush: () => {},
1780
+ }
1765
1781
 
1766
1782
  const exit = await Effect.runPromiseExit(
1767
1783
  runCommand(commandFor[kind], { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
@@ -1780,7 +1796,10 @@ describe("runCommand — refuses in a repository with no commits", () => {
1780
1796
  it("a repository with a commit passes the guard and dispatches normally", async () => {
1781
1797
  const repo = seedIdle()
1782
1798
  const written: string[] = []
1783
- const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1799
+ const out = {
1800
+ write: (chunk: string | Uint8Array) => written.push(String(chunk)),
1801
+ flush: () => {},
1802
+ }
1784
1803
 
1785
1804
  const exit = await Effect.runPromiseExit(
1786
1805
  runCommand({ kind: "next" }, { kind: "off" }, out).pipe(Effect.provide(testLayers(repo))),
@@ -1859,7 +1878,7 @@ export default workflow(
1859
1878
  it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
1860
1879
  const repo = await reviewed()
1861
1880
  const history = repo.commitHistory()
1862
- const entryHash = history[history.length - 3]!.hash
1881
+ const entryHash = history[history.length - 4]!.hash
1863
1882
  const tipHash = history[history.length - 1]!.hash
1864
1883
 
1865
1884
  const { exitCode, stdout } = await run(repo, "summary")
@@ -1953,7 +1972,7 @@ export default async () => {
1953
1972
  it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1954
1973
  const repo = await atDeciding()
1955
1974
  await landTurn(repo)
1956
- expect(repo.lastCommitSubject()).toBe("gtd(human): deciding → idle")
1975
+ expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
1957
1976
 
1958
1977
  const { exitCode, stdout, stderr } = await run(repo, "base")
1959
1978
  expect(exitCode).toBe(1)
@@ -2090,7 +2109,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
2090
2109
  )
2091
2110
  expect(Exit.isFailure(exit)).toBe(true)
2092
2111
  if (Exit.isFailure(exit)) {
2093
- const failure = Cause.failureOption(exit.cause)
2112
+ const failure = Cause.findErrorOption(exit.cause)
2094
2113
  expect(failure._tag).toBe("Some")
2095
2114
  if (failure._tag === "Some") {
2096
2115
  expect(failure.value).toBeInstanceOf(SelectorUsageError)
@@ -2297,8 +2316,8 @@ export default workflow(
2297
2316
  describe("runCommand — the repository-root guard's messages", () => {
2298
2317
  const guarded = async (
2299
2318
  override: (
2300
- git: Context.Tag.Service<GitService>,
2301
- host: Context.Tag.Service<Host>,
2319
+ git: Context.Service.Shape<typeof GitService>,
2320
+ host: Context.Service.Shape<typeof Host>,
2302
2321
  ) => Effect.Effect<unknown, Error, CommandRequirements>,
2303
2322
  ): Promise<string> => {
2304
2323
  const exit = await Effect.runPromiseExit(
@@ -1,5 +1,6 @@
1
1
  import { Effect, Option, Schema } from "effect"
2
2
  import {
3
+ GtdError,
3
4
  GtdUsageError,
4
5
  Narrator,
5
6
  GitService,
@@ -15,6 +16,8 @@ import {
15
16
  import {
16
17
  WORKFLOW_MODULE,
17
18
  ConfigDiscovery,
19
+ EngineCore,
20
+ pin,
18
21
  refuseLegacyRc,
19
22
  ConfigService,
20
23
  formatDiagnostic,
@@ -44,6 +47,7 @@ import {
44
47
  type StatusChange,
45
48
  selectPath,
46
49
  EXIT_OK,
50
+ PIN_PATH,
47
51
  } from "../wire/index.js"
48
52
  import {
49
53
  currentRest,
@@ -77,6 +81,7 @@ import {
77
81
  deleteRef,
78
82
  hardResetTo,
79
83
  mixedResetTo,
84
+ unpinScript,
80
85
  ScriptSurface,
81
86
  updateRef,
82
87
  type RunnableScript,
@@ -88,7 +93,7 @@ import {
88
93
  abandonNoopOutcome,
89
94
  restoredOutcome,
90
95
  } from "../emit/index.js"
91
- import { renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
96
+ import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
92
97
  import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
93
98
  import {
94
99
  normalizeScriptNewline,
@@ -119,7 +124,7 @@ import { runHistoryMetricsCommand } from "./Metrics.js"
119
124
  * SAME type structurally off `currentRest`'s own success type rather than
120
125
  * importing the name.
121
126
  */
122
- type Rest = Effect.Effect.Success<typeof currentRest>
127
+ type Rest = Effect.Success<typeof currentRest>
123
128
 
124
129
  export type Command =
125
130
  | { readonly kind: "lsp" }
@@ -161,6 +166,7 @@ export type Command =
161
166
  }
162
167
  | { readonly kind: "uncheck"; readonly file: string }
163
168
  | { readonly kind: "guide" }
169
+ | { readonly kind: "bundle" }
164
170
  | { readonly kind: "summary" }
165
171
  | { readonly kind: "base" }
166
172
  | { readonly kind: "judge" }
@@ -219,15 +225,34 @@ const runGuideCommand = (
219
225
  const briefing = renderBriefing()
220
226
  out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
221
227
  const host = yield* Host
222
- const topLevel = yield* Effect.either((yield* GitService).topLevel())
223
- if (topLevel._tag === "Left") return
224
- if ((yield* host.realPath(topLevel.right)) !== (yield* host.realPath(host.root))) return
228
+ const topLevel = yield* Effect.result((yield* GitService).topLevel())
229
+ if (topLevel._tag === "Failure") return
230
+ if ((yield* host.realPath(topLevel.success)) !== (yield* host.realPath(host.root))) return
225
231
  if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
226
232
  yield* (yield* Narrator).warn(
227
233
  `gtd guide: no ${WORKFLOW_MODULE} governs here, and gtd ships no workflow — get one from ${WORKFLOWS_REPO} or write your own (step 2 of the briefing above)`,
228
234
  )
229
235
  })
230
236
 
237
+ /** Loads the workflow first, so a pin is never made of one gtd would refuse. */
238
+ const runBundleCommand = (
239
+ out: ArtifactOut,
240
+ ): Effect.Effect<
241
+ void,
242
+ Error,
243
+ ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
244
+ > =>
245
+ Effect.gen(function* () {
246
+ yield* (yield* ConfigService).load
247
+ const host = yield* Host
248
+ const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
249
+ // Inside a pin, load read the pinned workflow, not a config on disk.
250
+ if (module === undefined) {
251
+ return yield* Effect.fail(new GtdError(`gtd bundle: no ${WORKFLOW_MODULE} here or above`))
252
+ }
253
+ out.write(yield* pin(module, host.root, GTD_VERSION))
254
+ })
255
+
231
256
  /**
232
257
  * `planLanding`'s result — a preview of what `gtd land` would do, plus the
233
258
  * combined `script` the driver runs to do it. `script` is
@@ -457,7 +482,7 @@ const runJudgeRunCommand = (
457
482
  })
458
483
 
459
484
  /** A verdict's own `answer` shape covers every `judge:` primitive (`noul` → boolean, `choice` → string, `score` → number) without gtd itself interpreting which one applies — that's a driver/judge-model concern, not this decode's. */
460
- const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
485
+ const VerdictAnswer = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
461
486
 
462
487
  /**
463
488
  * One `gtd judge answer` verdict entry per pending question, `{ id, answer,
@@ -470,14 +495,14 @@ const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
470
495
  const verdictSchemaFor = (ids: readonly string[]) =>
471
496
  Schema.Array(
472
497
  Schema.Struct({
473
- id: ids.length > 0 ? Schema.Literal(...(ids as [string, ...string[]])) : Schema.Never,
498
+ id: ids.length > 0 ? Schema.Literals(ids) : Schema.Never,
474
499
  answer: VerdictAnswer,
475
500
  // Bounded [0, 1] — every OTHER bound in the routes: matching path fails
476
501
  // CLOSED (a non-finite rendered minP/maxP never matches); an
477
502
  // unvalidated p wouldn't: a driver bug or a miscalibrated model
478
503
  // sending p > 1 would clear every minP a workflow declares,
479
504
  // unconditionally forcing the judged route on a live gate.
480
- p: Schema.Number.pipe(Schema.between(0, 1)),
505
+ p: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
481
506
  }),
482
507
  )
483
508
 
@@ -509,7 +534,7 @@ const runJudgeAnswerCommand = (
509
534
  try: () => JSON.parse(raw) as unknown,
510
535
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
511
536
  })
512
- const verdict = yield* Schema.decodeUnknown(verdictSchemaFor(ids))(parsed).pipe(
537
+ const verdict = yield* Schema.decodeUnknownEffect(verdictSchemaFor(ids))(parsed).pipe(
513
538
  Effect.mapError(
514
539
  (e) =>
515
540
  new SelectorUsageError(
@@ -776,6 +801,7 @@ const runAbandonCommand = (
776
801
  const steps: EmitStep[] = [
777
802
  { kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
778
803
  { kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
804
+ { kind: "gitWrite", command: unpinScript(PIN_PATH) },
779
805
  { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
780
806
  ]
781
807
  const required = emitScripts(steps).required
@@ -1037,7 +1063,7 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
1037
1063
  const workspace = yield* Workspace
1038
1064
  const changed = yield* git.changedPaths(rest.run.diffBase)
1039
1065
  const lines = changed
1040
- .filter((c) => c.status !== "D")
1066
+ .filter((c) => c.status !== "D" && c.path !== PIN_PATH)
1041
1067
  .flatMap(({ path }) => {
1042
1068
  const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
1043
1069
  return [
@@ -1137,7 +1163,7 @@ const gatherBeatDocument = (
1137
1163
  kind === "prompt" ? resolveSession(rendered.memory, rendered.memoryResumed) : undefined
1138
1164
  const resolvedValidate =
1139
1165
  kind === "prompt"
1140
- ? yield* resolveValidateScript(rest).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
1166
+ ? yield* resolveValidateScript(rest).pipe(Effect.catch(() => Effect.succeed(undefined)))
1141
1167
  : undefined
1142
1168
  const validate =
1143
1169
  resolvedValidate !== undefined && resolvedValidate.script.length > 0
@@ -1332,6 +1358,8 @@ const dispatchVoidCommand = (
1332
1358
  return runUncheckCommand(command.file)
1333
1359
  case "guide":
1334
1360
  return runGuideCommand(out)
1361
+ case "bundle":
1362
+ return runBundleCommand(out)
1335
1363
  case "summary":
1336
1364
  return runSummaryCommand(out)
1337
1365
  case "base":
package/src/cli/Strays.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
3
3
  import { strayPaths } from "../step/index.js"
4
- import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
4
+ import { EXIT_OK, EXIT_STRAYS_FOUND, PIN_PATH } from "../wire/index.js"
5
5
  import {
6
6
  SelectorUsageError,
7
7
  writeSelection,
@@ -31,7 +31,8 @@ export const runStraysCommand = (
31
31
  const narrator = yield* Narrator
32
32
  const base = yield* resolveSide(git, command.base)
33
33
  const tip = yield* resolveSide(git, command.tip)
34
- const touched = yield* git.diffPaths(base, tip)
34
+ // The pin is the engine's, never a turn's, so it is never a stray either.
35
+ const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH)
35
36
  const strays = strayPaths(touched, command.globs)
36
37
  const total = touched.length
37
38
 
@@ -29,7 +29,7 @@ interface JudgeRequest {
29
29
  * The subcommands backed by a driver or tool. Core may not import those
30
30
  * units, so the composition root provides them here.
31
31
  */
32
- export class ToolCommands extends Context.Tag("ToolCommands")<
32
+ export class ToolCommands extends Context.Service<
33
33
  ToolCommands,
34
34
  {
35
35
  readonly ui: (
@@ -39,4 +39,4 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
39
39
  readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
40
40
  readonly judge: (request: JudgeRequest) => Promise<string>
41
41
  }
42
- >() {}
42
+ >()("ToolCommands") {}