@pmelab/gtd 60.1.0 → 62.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +991 -627
  6. package/dist/gtd.core.mjs +117 -117
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/Cli.ts +6 -1
  12. package/src/cli/History.test.ts +10 -1
  13. package/src/cli/Launch.test.ts +52 -0
  14. package/src/cli/Launch.ts +143 -0
  15. package/src/cli/Metrics.test.ts +10 -1
  16. package/src/cli/Program.test.ts +30 -20
  17. package/src/cli/Program.ts +4 -2
  18. package/src/cli/Strays.ts +3 -2
  19. package/src/cli/index.ts +1 -0
  20. package/src/edge/Edge.test.ts +79 -26
  21. package/src/edge/Edge.ts +178 -99
  22. package/src/emit/GitScript.ts +21 -3
  23. package/src/emit/index.ts +3 -0
  24. package/src/gherkin/messages.test.ts +1 -0
  25. package/src/gherkin/module-config.feature +1 -0
  26. package/src/gherkin/more-steps.feature +1 -0
  27. package/src/gherkin/testkit.feature +2 -1
  28. package/src/install/Install.ts +9 -2
  29. package/src/platform/PinFile.ts +61 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +35 -8
  32. package/src/replay/Format.ts +7 -4
  33. package/src/replay/Replay.test.ts +54 -0
  34. package/src/replay/Replay.ts +61 -17
  35. package/src/replay/Trailers.test.ts +11 -48
  36. package/src/replay/Trailers.ts +36 -42
  37. package/src/replay/Tree.ts +11 -0
  38. package/src/replay/index.ts +3 -0
  39. package/src/step/LandStep.ts +1 -0
  40. package/src/step/RepoSnapshot.ts +2 -0
  41. package/src/step/planStart.test.ts +2 -3
  42. package/src/step/planStart.ts +1 -5
  43. package/src/step/planStep.test.ts +4 -4
  44. package/src/step/planStep.ts +10 -8
  45. package/src/testkit/TestRepo.test.ts +4 -1
  46. package/src/testkit/TestRepo.ts +3 -1
  47. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  48. package/src/twins/GitTiers.ts +23 -0
  49. package/src/twins/InMemRepo.ts +14 -0
  50. package/src/twins/PinBeats.ts +33 -0
  51. package/src/twins/PinFile.test.ts +107 -0
  52. package/src/twins/PinFile.ts +12 -0
  53. package/src/twins/index.ts +2 -0
  54. package/src/wire/BeatDocument.ts +26 -5
  55. package/src/wire/Demand.ts +16 -6
  56. package/src/wire/constants.ts +3 -0
  57. package/src/wire/types.ts +4 -0
  58. package/src/workflow/fingerprint.ts +2 -2
  59. package/src/workflow/index.ts +2 -2
  60. package/src/workflow/load.ts +14 -1
  61. package/src/workflow/pin.ts +15 -7
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-BhTey1Do.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-XQeYCNMn.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-BhTey1Do.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-XQeYCNMn.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/dist/wire.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as WIRE_SCHEMA, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as renderFormat, k as UNATTRIBUTED_MODEL, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-DZ8R1RU9.mjs";
2
- export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
1
+ import { A as UNATTRIBUTED_MODEL, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, M as renderFormat, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as WIRE_SCHEMA, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-CPNSm_9N.mjs";
2
+ export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, PIN_PATH, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "60.1.0",
3
+ "version": "62.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -632,6 +632,7 @@ const flagHeader = (row: FlagRow): string => {
632
632
 
633
633
  const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
634
634
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
635
+ ["--live", "Run this gtd, not the process's pin; it may misread the process"],
635
636
  ["-h, --help", "Print help; gtd <command> --help for one command"],
636
637
  ["-V, --version", "Print the version"],
637
638
  ["--", "End the options: every word after it is an argument"],
@@ -643,7 +644,11 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
643
644
  "Print help for gtd or one command",
644
645
  "Print gtd's overview, or with <command> that command's own help.",
645
646
  ],
646
- ["version", "Print the version", "Print gtd's version."],
647
+ [
648
+ "version",
649
+ "Print the version",
650
+ "Print gtd's version, and the version of a pinned process's gtd.",
651
+ ],
647
652
  ]
648
653
 
649
654
  const EXAMPLES = ` $ gtd start list what can start
@@ -1,6 +1,12 @@
1
1
  import { Effect, Exit } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
3
+ import {
4
+ applyEmittedScript,
5
+ InMemRepo,
6
+ makeCapturingCliIo,
7
+ takePinBeat,
8
+ testLayers,
9
+ } from "../twins/index.js"
4
10
  import { runCli } from "./index.js"
5
11
  import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
6
12
 
@@ -12,7 +18,9 @@ export default async () => {
12
18
  }
13
19
  `
14
20
 
21
+ /** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
15
22
  const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
23
+ await takePinBeat(repo, "add")
16
24
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
17
25
  const { io, result } = makeCapturingCliIo(repo)
18
26
  await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
@@ -24,6 +32,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
24
32
  (JSON.parse(stdout) as { script: string }).script,
25
33
  )
26
34
  if (!outcome.ok) throw new Error(outcome.error)
35
+ await takePinBeat(repo, "remove")
27
36
  return repo.resolveRef("HEAD")!
28
37
  }
29
38
 
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { GTD_VERSION, PIN_KEY } from "../install/index.js"
3
+ import { launchFor } from "./Launch.js"
4
+
5
+ const pin =
6
+ (header = "gtdpin 1 gtd 1.2.3") =>
7
+ () => ({
8
+ path: "/repo/.gtd/process.gtdpin",
9
+ bytes: Buffer.from(`${header}\n`),
10
+ })
11
+ const argv = (...words: string[]) => ["node", "gtd", ...words]
12
+
13
+ describe("launchFor", () => {
14
+ afterEach(() => {
15
+ delete (globalThis as { [PIN_KEY]?: unknown })[PIN_KEY]
16
+ })
17
+
18
+ it("hands a state command over to the pin", () => {
19
+ expect(launchFor(argv("next"), pin())).toMatchObject({ kind: "pin", version: "1.2.3" })
20
+ })
21
+
22
+ it("never hands over from inside a pin", () => {
23
+ ;(globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] = { version: "1.2.3" }
24
+ expect(launchFor(argv("next"), pin())).toEqual({ kind: "live", argv: argv("next") })
25
+ })
26
+
27
+ it("strips --live, but keeps one after --", () => {
28
+ expect(launchFor(argv("--live", "start", "--", "--live"), pin())).toEqual({
29
+ kind: "live",
30
+ argv: argv("start", "--", "--live"),
31
+ })
32
+ expect(launchFor(argv("start", "--", "--live"), pin())).toMatchObject({
33
+ kind: "pin",
34
+ argv: argv("start", "--", "--live"),
35
+ })
36
+ })
37
+
38
+ it("leaves the version to this gtd without a pin, and for a file that is no pin", () => {
39
+ expect(launchFor(argv("-V"), () => undefined)).toEqual({ kind: "live", argv: argv("-V") })
40
+ expect(launchFor(argv("version"), pin("garbage"))).toEqual({
41
+ kind: "output",
42
+ stdout: `${GTD_VERSION}\n`,
43
+ })
44
+ })
45
+
46
+ it("names a newer pin's version even when it cannot run it", () => {
47
+ expect(launchFor(argv("--version"), pin("gtdpin 9 gtd 99.0.0"))).toEqual({
48
+ kind: "output",
49
+ stdout: `${GTD_VERSION} (this process is pinned to gtd 99.0.0)\n`,
50
+ })
51
+ })
52
+ })
@@ -0,0 +1,143 @@
1
+ import type { PinFile } from "../platform/index.js"
2
+ import { GTD_VERSION, PIN_KEY } from "../install/index.js"
3
+ import { PIN_FORMAT, pinHeader, readPin } from "../workflow/index.js"
4
+ import { EXIT_RUNTIME_ERROR } from "../wire/index.js"
5
+ import { parseArgv } from "./Cli.js"
6
+ import type { Command } from "./Program.js"
7
+
8
+ /** What the core a pin carries lacks, or what must read the live state: these never hand over. */
9
+ const LIVE_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
10
+ "ui",
11
+ "lsp",
12
+ "guide",
13
+ "bundle",
14
+ "historyList",
15
+ "historyShow",
16
+ "historyMetrics",
17
+ "historyJudges",
18
+ "historyFetch",
19
+ ])
20
+
21
+ export type Launch =
22
+ | { readonly kind: "live"; readonly argv: readonly string[] }
23
+ | {
24
+ readonly kind: "pin"
25
+ readonly argv: readonly string[]
26
+ readonly path: string
27
+ readonly version: string
28
+ /** Unpacked only when run: its failure is the hand-over's. */
29
+ readonly source: () => string
30
+ }
31
+ | { readonly kind: "output"; readonly stdout: string }
32
+ | { readonly kind: "refuse"; readonly message: string }
33
+
34
+ const LIVE_FLAG = "--live"
35
+
36
+ // Only before `--`: after it, `--live` is an argument.
37
+ const optionWords = (argv: readonly string[]): readonly string[] => {
38
+ const end = argv.indexOf("--", 2)
39
+ return argv.slice(2, end < 0 ? undefined : end)
40
+ }
41
+
42
+ const withoutLiveFlag = (argv: readonly string[]): readonly string[] => {
43
+ const end = argv.indexOf("--", 2)
44
+ return argv.filter((word, i) => word !== LIVE_FLAG || i < 2 || (end >= 0 && i > end))
45
+ }
46
+
47
+ /**
48
+ * Whether this gtd runs the command or hands it to the process's pin. A pin
49
+ * this gtd cannot run is refused, never guessed at; a usage error the live
50
+ * parser finds is still the pin's to judge, since its engine may read
51
+ * another CLI.
52
+ */
53
+ export const launchFor = (argv: readonly string[], pinFile: PinFile): Launch => {
54
+ // The engine a pin carries is this same code: once inside one, never hand over again.
55
+ if ((globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] !== undefined) return { kind: "live", argv }
56
+ const live = optionWords(argv).includes(LIVE_FLAG)
57
+ const rest = withoutLiveFlag(argv)
58
+ if (live) return { kind: "live", argv: rest }
59
+ const plan = parseArgv(rest)
60
+ const version = plan.kind === "output" && plan.stdout === `${GTD_VERSION}\n`
61
+ if (plan.kind === "output" && !version) return { kind: "live", argv: rest }
62
+ if (plan.kind === "command" && LIVE_KINDS.has(plan.command.kind))
63
+ return { kind: "live", argv: rest }
64
+ const found = pinFile()
65
+ if (found === undefined) return { kind: "live", argv: rest }
66
+ const header = pinHeader(found.bytes)
67
+ if (version)
68
+ return {
69
+ kind: "output",
70
+ stdout:
71
+ header === undefined
72
+ ? plan.stdout
73
+ : `${GTD_VERSION} (this process is pinned to gtd ${header.version})\n`,
74
+ }
75
+ if (header === undefined || header.format !== PIN_FORMAT) {
76
+ const what =
77
+ header === undefined
78
+ ? "is no pin this gtd can read"
79
+ : `is pin format ${header.format}, made by gtd ${header.version}`
80
+ return {
81
+ kind: "refuse",
82
+ message: `gtd: ${found.path} ${what}; gtd ${GTD_VERSION} runs pin format ${PIN_FORMAT} — upgrade gtd (\`gtd --live\` runs this gtd instead, and may misread the process)`,
83
+ }
84
+ }
85
+ return {
86
+ kind: "pin",
87
+ argv: rest,
88
+ path: found.path,
89
+ version: header.version,
90
+ source: () => readPin(found.bytes).source,
91
+ }
92
+ }
93
+
94
+ export interface Launcher {
95
+ readonly stdout: (chunk: string) => void
96
+ readonly stderr: (chunk: string) => void
97
+ readonly exit: (code: number) => void
98
+ readonly live: (argv: readonly string[]) => void | Promise<void>
99
+ readonly pin: (launch: Extract<Launch, { kind: "pin" }>) => Promise<void>
100
+ }
101
+
102
+ const isJsonFlag = (word: string): boolean => word === "--json" || word.startsWith("--json=")
103
+
104
+ // The CLI's own error envelope too, which a `--json` driver reads.
105
+ const refusalText = (argv: readonly string[], message: string): string =>
106
+ (optionWords(argv).some(isJsonFlag)
107
+ ? `${JSON.stringify({ state: "error", prompt: message })}\n`
108
+ : "") + `${message}\n`
109
+
110
+ const runPin = async (
111
+ launch: Extract<Launch, { kind: "pin" }>,
112
+ launcher: Launcher,
113
+ ): Promise<void> => {
114
+ const words = optionWords(launch.argv)
115
+ if (words.includes("--verbose") || words.includes("-v"))
116
+ launcher.stderr(`launcher: running the pin ${launch.path} (gtd ${launch.version})\n`)
117
+ try {
118
+ await launcher.pin(launch)
119
+ } catch (error) {
120
+ const reason = error instanceof Error ? error.message : String(error)
121
+ launcher.stderr(`gtd: could not run the pin ${launch.path}: ${reason}\n`)
122
+ launcher.exit(EXIT_RUNTIME_ERROR)
123
+ }
124
+ }
125
+
126
+ export const runLauncher = async (
127
+ argv: readonly string[],
128
+ pinFile: PinFile,
129
+ launcher: Launcher,
130
+ ): Promise<void> => {
131
+ const launch = launchFor(argv, pinFile)
132
+ switch (launch.kind) {
133
+ case "live":
134
+ return launcher.live(launch.argv)
135
+ case "output":
136
+ return launcher.stdout(launch.stdout)
137
+ case "refuse":
138
+ launcher.stderr(refusalText(argv, launch.message))
139
+ return launcher.exit(EXIT_RUNTIME_ERROR)
140
+ case "pin":
141
+ return runPin(launch, launcher)
142
+ }
143
+ }
@@ -1,6 +1,12 @@
1
1
  import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
- import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
3
+ import {
4
+ applyEmittedScript,
5
+ InMemRepo,
6
+ makeCapturingCliIo,
7
+ takePinBeat,
8
+ testLayers,
9
+ } from "../twins/index.js"
4
10
  import { runCli } from "./index.js"
5
11
  import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
6
12
 
@@ -213,7 +219,9 @@ export default async () => {
213
219
  }
214
220
  `
215
221
 
222
+ /** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
216
223
  const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
224
+ await takePinBeat(repo, "add")
217
225
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
218
226
  const { io, result } = makeCapturingCliIo(repo)
219
227
  await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
@@ -225,6 +233,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
225
233
  (JSON.parse(stdout) as { script: string }).script,
226
234
  )
227
235
  if (!outcome.ok) throw new Error(outcome.error)
236
+ await takePinBeat(repo, "remove")
228
237
  return repo.resolveRef("HEAD")!
229
238
  }
230
239
 
@@ -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,
@@ -27,7 +33,6 @@ import {
27
33
  restoredOutcome,
28
34
  } from "../emit/index.js"
29
35
  import { GitService, HISTORY_REF, Host } from "../platform/index.js"
30
- import { contentHash } from "../workflow/index.js"
31
36
  import { WORKFLOWS_REPO } from "../install/index.js"
32
37
 
33
38
  const run = async (
@@ -39,13 +44,14 @@ const run = async (
39
44
  return result()
40
45
  }
41
46
 
42
- /** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
47
+ /** 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
48
  const landAndApply = async (
44
49
  repo: InMemRepo,
45
50
  ): Promise<{ readonly exitCode: number; readonly script: string }> => {
46
51
  const { stdout, exitCode } = await run(repo, "land", "--json")
47
52
  const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
48
53
  if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
54
+ if (exitCode === 0) await takePinBeat(repo)
49
55
  return { exitCode, script }
50
56
  }
51
57
 
@@ -104,7 +110,7 @@ export const hotfix = workflow(
104
110
  expect(exitCode).toBe(0)
105
111
  expect(stdout).toContain(
106
112
  commitAll(
107
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 1`,
113
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
108
114
  ),
109
115
  )
110
116
  expect(repo.commitHistory()).toHaveLength(before)
@@ -190,6 +196,9 @@ export const hotfix = workflow(
190
196
  const { stdout } = await run(repo, "start", "hotfix")
191
197
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
192
198
  expect(repo.lastCommitSubject()).toBe("gtd(human): working")
199
+ const pin = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
200
+ expect(pin).toMatchObject({ kind: "script", state: "pin", actor: "check", pin: "add" })
201
+ expect(await takePinBeat(repo, "add")).toBe(true)
193
202
  const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
194
203
  expect(next.kind).toBe("prompt")
195
204
  expect(next.state).toBe("working")
@@ -496,6 +505,7 @@ export const resume = workflow(work)
496
505
  const repo = seed(WORKFLOW)
497
506
  const { stdout: entry } = await run(repo, "start", "resume")
498
507
  expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
508
+ await takePinBeat(repo, "add")
499
509
  const { stdout, exitCode } = await run(repo, "next", "--json")
500
510
  expect(exitCode).toBe(0)
501
511
  expect(JSON.parse(stdout).kind).toBe("prompt")
@@ -775,20 +785,20 @@ describe("gtd next — refuses when history diverges from what replay reaches",
775
785
  expect(repo.commitHistory()).toHaveLength(before)
776
786
 
777
787
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
778
- expect(repo.commitHistory()).toHaveLength(before - 2)
788
+ expect(repo.commitHistory()).toHaveLength(before - 3)
779
789
  })
780
790
  })
781
791
 
782
792
  describe("a history written in a format this gtd does not read", () => {
783
793
  // 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"
794
+ const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
785
795
 
786
796
  /** A step commit that would also diverge, so only the format gate can explain the refusal. */
787
797
  const foreignStep = async (): Promise<InMemRepo> => {
788
798
  const repo = seed(IDLE_THEN_WORKING)
789
799
  await landTurn(repo, { "NOTE.md": "a note\n" })
790
800
  repo.writeFile("WORK.md", "done\n")
791
- repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 2")
801
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
792
802
  return repo
793
803
  }
794
804
 
@@ -813,7 +823,7 @@ describe("a history written in a format this gtd does not read", () => {
813
823
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
814
824
  const repo = seed(IDLE_THEN_WORKING)
815
825
  repo.writeFile("NOTE.md", "a note\n")
816
- repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 2")
826
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
817
827
  for (const command of ["next", "abandon"]) {
818
828
  const { exitCode, stderr } = await run(repo, command)
819
829
  expect(exitCode).toBe(1)
@@ -824,7 +834,7 @@ describe("a history written in a format this gtd does not read", () => {
824
834
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
825
835
  const repo = seed(IDLE_THEN_WORKING)
826
836
  repo.writeFile("NOTE.md", "a note\n")
827
- repo.seedCommit("feat: x\n\nGtd-Format: 2")
837
+ repo.seedCommit("feat: x\n\nGtd-Format: 3")
828
838
  expect((await run(repo, "next")).exitCode).toBe(0)
829
839
  })
830
840
 
@@ -832,7 +842,7 @@ describe("a history written in a format this gtd does not read", () => {
832
842
  const repo = seed(IDLE_THEN_WORKING)
833
843
  const base = repo.resolveRef("HEAD")!
834
844
  repo.writeFile("NOTE.md", "a note\n")
835
- repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 2")
845
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
836
846
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
837
847
  repo.hardResetTo(base)
838
848
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1390,10 +1400,10 @@ export default async () => {
1390
1400
  expect(await nextOf(repo)).toEqual({ target: "accepted" })
1391
1401
  })
1392
1402
 
1393
- it("a turn that finishes the flow targets the initial step", async () => {
1403
+ it("a turn that finishes a pinned flow targets the unpin", async () => {
1394
1404
  const repo = await atWorking()
1395
1405
  repo.writeFile("REVIEW.md", "an updated review\n")
1396
- expect(await nextOf(repo)).toMatchObject({ target: "idle" })
1406
+ expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
1397
1407
  })
1398
1408
 
1399
1409
  it("plain gtd next shows the same preview as a `Next:` line", async () => {
@@ -1476,7 +1486,7 @@ export default async () => {
1476
1486
  expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
1477
1487
  })
1478
1488
 
1479
- it("a clean run that finishes the flow lands an ordinary commit re-entering the initial step", async () => {
1489
+ it("a clean run that finishes a pinned flow lands an ordinary commit at the unpin", async () => {
1480
1490
  const repo = await atChecking(REENTRY_WORKFLOW)
1481
1491
  const { stdout, exitCode } = await run(repo, "land", "--json")
1482
1492
  expect(exitCode).toBe(0)
@@ -1485,7 +1495,7 @@ export default async () => {
1485
1495
  expect(script).not.toContain("git reset --mixed")
1486
1496
  expect(script).not.toContain("nothing to retain")
1487
1497
  expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
1488
- expect(repo.lastCommitSubject()).toBe("gtd(check): checking → idle")
1498
+ expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
1489
1499
  })
1490
1500
 
1491
1501
  it("the same rest with a pending change lands an ordinary commit too", async () => {
@@ -1523,7 +1533,7 @@ export default async () => {
1523
1533
  expect(parsed.script).toContain("nothing to do")
1524
1534
  })
1525
1535
 
1526
- it("gtd land --json a decision that re-enters the initial step reports settled:false, idle:true, state:idle", async () => {
1536
+ it("gtd land --json a decision that ends a pinned flow reports settled:false, idle:false, state:unpin", async () => {
1527
1537
  const repo = await atChecking(REENTRY_WORKFLOW)
1528
1538
  const { stdout, exitCode } = await run(repo, "land", "--json")
1529
1539
  expect(exitCode).toBe(0)
@@ -1533,8 +1543,8 @@ export default async () => {
1533
1543
  readonly state: string
1534
1544
  }
1535
1545
  expect(parsed.settled).toBe(false)
1536
- expect(parsed.idle).toBe(true)
1537
- expect(parsed.state).toBe("idle")
1546
+ expect(parsed.idle).toBe(false)
1547
+ expect(parsed.state).toBe("unpin")
1538
1548
  })
1539
1549
 
1540
1550
  it("gtd land --json an ordinary commit reports settled:false", async () => {
@@ -1683,7 +1693,7 @@ export default async () => {
1683
1693
  const { exitCode, script } = await landAndApply(repo)
1684
1694
  expect(exitCode).toBe(0)
1685
1695
  expect(script).not.toContain("fmt-notes")
1686
- expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting → idle")
1696
+ expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
1687
1697
  })
1688
1698
 
1689
1699
  it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
@@ -1867,7 +1877,7 @@ export default workflow(
1867
1877
  it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
1868
1878
  const repo = await reviewed()
1869
1879
  const history = repo.commitHistory()
1870
- const entryHash = history[history.length - 3]!.hash
1880
+ const entryHash = history[history.length - 4]!.hash
1871
1881
  const tipHash = history[history.length - 1]!.hash
1872
1882
 
1873
1883
  const { exitCode, stdout } = await run(repo, "summary")
@@ -1961,7 +1971,7 @@ export default async () => {
1961
1971
  it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1962
1972
  const repo = await atDeciding()
1963
1973
  await landTurn(repo)
1964
- expect(repo.lastCommitSubject()).toBe("gtd(human): deciding → idle")
1974
+ expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
1965
1975
 
1966
1976
  const { exitCode, stdout, stderr } = await run(repo, "base")
1967
1977
  expect(exitCode).toBe(1)
@@ -47,6 +47,7 @@ import {
47
47
  type StatusChange,
48
48
  selectPath,
49
49
  EXIT_OK,
50
+ PIN_PATH,
50
51
  } from "../wire/index.js"
51
52
  import {
52
53
  currentRest,
@@ -80,6 +81,7 @@ import {
80
81
  deleteRef,
81
82
  hardResetTo,
82
83
  mixedResetTo,
84
+ unpinScript,
83
85
  ScriptSurface,
84
86
  updateRef,
85
87
  type RunnableScript,
@@ -617,7 +619,6 @@ const startWorkflow = (
617
619
  first: started,
618
620
  commandLabel,
619
621
  vars: varOverrides,
620
- config: rest.config,
621
622
  },
622
623
  )
623
624
  if (plan.kind === "refusal") {
@@ -799,6 +800,7 @@ const runAbandonCommand = (
799
800
  const steps: EmitStep[] = [
800
801
  { kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
801
802
  { kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
803
+ { kind: "gitWrite", command: unpinScript(PIN_PATH) },
802
804
  { kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
803
805
  ]
804
806
  const required = emitScripts(steps).required
@@ -1060,7 +1062,7 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
1060
1062
  const workspace = yield* Workspace
1061
1063
  const changed = yield* git.changedPaths(rest.run.diffBase)
1062
1064
  const lines = changed
1063
- .filter((c) => c.status !== "D")
1065
+ .filter((c) => c.status !== "D" && c.path !== PIN_PATH)
1064
1066
  .flatMap(({ path }) => {
1065
1067
  const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
1066
1068
  return [
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
 
package/src/cli/index.ts CHANGED
@@ -12,3 +12,4 @@ export {
12
12
  type LiveToolCommands,
13
13
  } from "./Cli.js"
14
14
  export { ToolCommands, type UiRequest } from "./ToolCommands.js"
15
+ export { runLauncher } from "./Launch.js"