@pmelab/gtd 62.1.0 → 64.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +8 -3
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
  6. package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4023 -1326
  9. package/dist/gtd.core.mjs +120 -119
  10. package/dist/steering.mjs +1 -1
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +10 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +36 -45
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +98 -13
  23. package/src/edge/index.ts +1 -0
  24. package/src/emit/SteeringMode.test.ts +4 -2
  25. package/src/emit/SteeringMode.ts +10 -9
  26. package/src/gherkin/messages.test.ts +1 -1
  27. package/src/gherkin/module-config.feature +1 -1
  28. package/src/gherkin/testkit.feature +2 -2
  29. package/src/install/Version.ts +14 -13
  30. package/src/platform/Host.ts +75 -22
  31. package/src/platform/NodePinFile.ts +44 -0
  32. package/src/platform/NodeRuntime.ts +19 -0
  33. package/src/platform/NodeSync.ts +64 -0
  34. package/src/platform/PinFile.ts +0 -50
  35. package/src/platform/Sha.ts +121 -0
  36. package/src/platform/SyncIo.ts +35 -0
  37. package/src/platform/Workspace.test.ts +8 -3
  38. package/src/platform/Workspace.ts +82 -123
  39. package/src/platform/WorktreeState.test.ts +8 -4
  40. package/src/platform/WorktreeState.ts +21 -19
  41. package/src/platform/index.ts +18 -5
  42. package/src/replay/Format.test.ts +32 -10
  43. package/src/replay/Format.ts +5 -2
  44. package/src/replay/Replay.test.ts +110 -3
  45. package/src/replay/Replay.ts +120 -28
  46. package/src/replay/Trailers.test.ts +10 -10
  47. package/src/replay/Trailers.ts +32 -1
  48. package/src/replay/index.ts +2 -0
  49. package/src/step/planStart.test.ts +2 -2
  50. package/src/step/planStep.test.ts +4 -4
  51. package/src/testkit/TestRepo.test.ts +1 -1
  52. package/src/testkit/TestRepo.ts +4 -18
  53. package/src/twins/Host.test.ts +151 -0
  54. package/src/twins/Layers.test.ts +2 -2
  55. package/src/twins/Layers.ts +44 -18
  56. package/src/twins/Path.test.ts +52 -0
  57. package/src/twins/SyncIo.test.ts +153 -0
  58. package/src/twins/SyncIo.ts +159 -0
  59. package/src/twins/cliIo.ts +8 -1
  60. package/src/twins/index.ts +5 -0
  61. package/src/workflow/discovery.test.ts +6 -5
  62. package/src/workflow/discovery.ts +43 -32
  63. package/src/workflow/fingerprint.test.ts +23 -3
  64. package/src/workflow/fingerprint.ts +9 -11
  65. package/src/workflow/load.test.ts +16 -6
  66. package/src/workflow/load.ts +107 -69
  67. package/src/workflow/pin.test.ts +3 -2
  68. package/src/workflow/pin.ts +17 -11
package/dist/steering.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { A as parseThreads, C as FREE_TEXT_PLACEHOLDER, D as findingEntries, E as NOISE_HEADING, N as formatFinding, O as openThreadFindings, S as unansweredNotes, T as FIX_BOX, _ as reviewChunks, a as sections, b as reviewRisks, c as openThreads, d as freeFormFormat, f as applySteeringEdits, g as REVIEW_SECTIONS, h as ANSWER_WONT_FIX, i as sectionBodies, j as parseThreadsWithFindings, k as parseFootnotes, l as steeringFormatFor, m as ANSWER_LEAD, n as stripCodeThreads, o as threads, p as ANSWER_FIX, r as openQuestions, t as parseCodeThreads, v as reviewDecisions, w as isAnswered, x as reviewSections, y as reviewNotes } from "./chunks/steering-DF_BMadH.mjs";
1
+ import { A as parseThreads, C as FREE_TEXT_PLACEHOLDER, D as findingEntries, E as NOISE_HEADING, N as formatFinding, O as openThreadFindings, S as unansweredNotes, T as FIX_BOX, _ as reviewChunks, a as sections, b as reviewRisks, c as openThreads, d as freeFormFormat, f as applySteeringEdits, g as REVIEW_SECTIONS, h as ANSWER_WONT_FIX, i as sectionBodies, j as parseThreadsWithFindings, k as parseFootnotes, l as steeringFormatFor, m as ANSWER_LEAD, n as stripCodeThreads, o as threads, p as ANSWER_FIX, r as openQuestions, t as parseCodeThreads, v as reviewDecisions, w as isAnswered, x as reviewSections, y as reviewNotes } from "./chunks/steering-DkA51qDa.mjs";
2
2
  export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
@@ -1,6 +1,6 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-CPzWnl3P.mjs";
2
- import { resolve } from "node:path";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-zGjNeQNL.mjs";
3
2
  import { pathToFileURL } from "node:url";
3
+ import { resolve } from "node:path";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
5
5
  import assert from "node:assert";
6
6
  //#region src/gherkin/messages.ts
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
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-CPzWnl3P.mjs";
1
+ import { H as installContext } from "./chunks/flows-B7ZRCe-B.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-zGjNeQNL.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
@@ -1,5 +1,5 @@
1
- import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-DENt0uWV.mjs";
2
- import { A as parseThreads, S as unansweredNotes, b as reviewRisks, n as stripCodeThreads, v as reviewDecisions, y as reviewNotes } from "./chunks/steering-DF_BMadH.mjs";
1
+ import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-B7ZRCe-B.mjs";
2
+ import { A as parseThreads, S as unansweredNotes, b as reviewRisks, n as stripCodeThreads, v as reviewDecisions, y as reviewNotes } from "./chunks/steering-DkA51qDa.mjs";
3
3
  //#region src/workflow-kit/health.ts
4
4
  /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
5
5
  const escalation = async (count, steps) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "62.1.0",
3
+ "version": "64.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": {
@@ -25,7 +25,7 @@ import {
25
25
  import { EXIT_CODES, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
26
26
  import { Host } from "../platform/index.js"
27
27
  import { type CommandRequirements } from "./Cli.js"
28
- import { InMemRepo, makeCapturingCliIo, testLayers } from "../twins/index.js"
28
+ import { InMemRepo, inMemoryHost, makeCapturingCliIo, testLayers } from "../twins/index.js"
29
29
 
30
30
  const FLAG_NAMES = [
31
31
  "--json",
@@ -1342,13 +1342,10 @@ describe("runCli — a garbled GTD_FLAG_* value", () => {
1342
1342
  it("exits 1 naming the variable", async () => {
1343
1343
  const { io, captured } = capturingIo(
1344
1344
  () =>
1345
- Layer.succeed(Host, {
1346
- root: "/",
1347
- home: "/",
1348
- scratchDir: "/",
1349
- realPath: (p: string) => Effect.succeed(p),
1350
- env: { GTD_FLAG_BETA: "ture" },
1351
- }) as unknown as Layer.Layer<CommandRequirements>,
1345
+ Layer.succeed(
1346
+ Host,
1347
+ inMemoryHost("/", "/", { GTD_FLAG_BETA: "ture" }),
1348
+ ) as unknown as Layer.Layer<CommandRequirements>,
1352
1349
  )
1353
1350
  await Effect.runPromise(
1354
1351
  runCli(["node", "gtd.js", "next"], {
package/src/cli/Cli.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  Workspace,
10
10
  CommandRunner,
11
11
  NodeServices,
12
+ nodeStdio,
13
+ type Stdio,
12
14
  } from "../platform/index.js"
13
15
  import {
14
16
  flagSettings,
@@ -455,7 +457,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
455
457
  arity: "none",
456
458
  group: "Start and stop",
457
459
  summary: "Pin the process to the live gtd and workflow",
458
- details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. Refuses when no process is underway, when the next beat is a pin or unpin beat already, or when the live workflow does not replay the process.
460
+ details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. When the live workflow no longer replays the process, the commit continues from the divergence: a Gtd-Anchor trailer names the last commit it replays, the later commits stay as files only, and the process rests at the step the live workflow reached there. Refuses when no process is underway, when the next beat is a pin or unpin beat already, when an unpinned process diverges, or when the live workflow would continue at the default workflow's first step.
459
461
 
460
462
  s=$(gtd repin --json=script) && sh -c "$s"`,
461
463
  },
@@ -1334,10 +1336,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1334
1336
  // runCli
1335
1337
  // ---------------------------------------------------------------------------
1336
1338
 
1337
- export interface CliIo {
1338
- readonly stdout: (chunk: string | Uint8Array) => void
1339
- readonly stderr: (chunk: string) => void
1340
- readonly exit: (code: number) => void
1339
+ export interface CliIo extends Stdio {
1341
1340
  /**
1342
1341
  * Deferred thunk — a layer is built only for a resolved `Command`, never
1343
1342
  * for `--version`/`--help`/a usage error. `verbose` (`--verbose`/`-v`,
@@ -1349,13 +1348,10 @@ export interface CliIo {
1349
1348
  readonly flags?: readonly FlagDeclaration[]
1350
1349
  }
1351
1350
 
1352
- // The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
1353
- // and `layers`'s `Narrator` (narration) — same sink, so a real invocation's
1354
- // narration and its remediation interleave on the same fd exactly as
1355
- // written.
1356
- const writeStderr = (chunk: string): void => {
1357
- process.stderr.write(chunk)
1358
- }
1351
+ // `nodeCliIo` hands this one sink to both `stderr` (errors) and `layers`'s
1352
+ // `Narrator` (narration), so a real invocation's narration and its
1353
+ // remediation interleave on the same fd exactly as written.
1354
+ const stdio = nodeStdio()
1359
1355
 
1360
1356
  export type LiveToolCommands = Layer.Layer<
1361
1357
  ToolCommands,
@@ -1364,21 +1360,7 @@ export type LiveToolCommands = Layer.Layer<
1364
1360
  >
1365
1361
 
1366
1362
  export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1367
- // `process.stdout.write` is asynchronous whenever stdout is a pipe or
1368
- // socket — passing a completion callback (and never force-closing, see
1369
- // `exit` below) keeps a queued remainder from being discarded, so a large
1370
- // artifact still reaches a slow reader even after a non-zero exit.
1371
- stdout: (chunk) => {
1372
- process.stdout.write(chunk, () => {})
1373
- },
1374
- stderr: writeStderr,
1375
- exit: (code) => {
1376
- // Never `process.exit(code)`: that tears the process down out from under
1377
- // an undrained `stdout.write` above and truncates the artifact mid-pipe.
1378
- // Setting `exitCode` lets the event loop run to completion — draining
1379
- // any queued write — before Node exits gracefully with this code.
1380
- process.exitCode = code
1381
- },
1363
+ ...stdio,
1382
1364
  // A Layer is a lazy description, not a running service — building this
1383
1365
  // object touches no filesystem/git; only `Effect.provide` running the
1384
1366
  // result does. `runCli` calls this thunk exactly when `plan.kind ===
@@ -1394,7 +1376,7 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1394
1376
  ConfigDiscovery.Live,
1395
1377
  EngineCore.Live,
1396
1378
  Workspace.Live,
1397
- Narrator.layer(writeStderr, verbose),
1379
+ Narrator.layer(stdio.stderr, verbose),
1398
1380
  CommandRunner.Live,
1399
1381
  ).pipe(
1400
1382
  Layer.provideMerge(GitService.Live),
package/src/cli/Output.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect"
1
+ import { Effect, type Path } from "effect"
2
2
  import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
3
3
  import {
4
4
  renderScriptJson,
@@ -70,6 +70,7 @@ export type CommandRequirements =
70
70
  | EngineCore
71
71
  | Workspace
72
72
  | Host
73
+ | Path.Path
73
74
  | Narrator
74
75
  | CommandRunner
75
76
  | ToolCommands
@@ -5,8 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Cause, Context, Effect, Exit } from "effect"
8
- import { PassThrough } from "node:stream"
9
- import { afterEach, describe, expect, it, vi } from "vitest"
8
+ import { describe, expect, it, vi } from "vitest"
10
9
 
11
10
  // `gtd ui`'s bind-host lookup reads the real `os.networkInterfaces()` — a host
12
11
  // on a tailnet would make the "no Tailscale interface" refusal below fail.
@@ -24,6 +23,8 @@ import {
24
23
  testLayers,
25
24
  applyEmittedScript,
26
25
  takePinBeat,
26
+ IN_MEMORY_PID,
27
+ IN_MEMORY_SCRATCH,
27
28
  } from "../twins/index.js"
28
29
  import {
29
30
  commitAll,
@@ -110,7 +111,7 @@ export const hotfix = workflow(
110
111
  expect(exitCode).toBe(0)
111
112
  expect(stdout).toContain(
112
113
  commitAll(
113
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
114
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 4`,
114
115
  ),
115
116
  )
116
117
  expect(repo.commitHistory()).toHaveLength(before)
@@ -197,7 +198,7 @@ export const hotfix = workflow(
197
198
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
198
199
  expect(repo.lastCommitSubject()).toBe("gtd(human): working")
199
200
  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(pin).toMatchObject({ kind: "script", state: "gtd:pin", actor: "check", pin: "add" })
201
202
  expect(await takePinBeat(repo, "add")).toBe(true)
202
203
  const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
203
204
  expect(next.kind).toBe("prompt")
@@ -673,7 +674,7 @@ describe("gtd validate — the mode-contradiction round-trip", () => {
673
674
  const { stdout, exitCode } = await runEnv(repo, { TMPDIR: "/fixture-scratch" }, "validate")
674
675
  expect(exitCode).toBe(0)
675
676
 
676
- const samplePath = `/fixture-scratch/gtd-mode-sample-qa-${process.pid}.md`
677
+ const samplePath = `/fixture-scratch/gtd-mode-sample-qa-${IN_MEMORY_PID}.md`
677
678
  const roundTripIndex = stdout.indexOf(`printf '%s' `)
678
679
  const guardIndex = stdout.indexOf(`[ -f '.gtd/PLAN.md' ] || exit 0`)
679
680
  // The real format command, distinct from the round-trip's own copy
@@ -724,11 +725,11 @@ describe("gtd validate — the mode-contradiction round-trip", () => {
724
725
  expect(stdout).not.toContain("skipping")
725
726
  })
726
727
 
727
- it("resolves the scratch dir from node:os's tmpdir() when TMPDIR is unset or empty", async () => {
728
+ it("falls back to the host's own scratch dir when TMPDIR is unset", async () => {
728
729
  const repo = await atWorking(`{ qa: { format: "my-formatter $GTD_FILE" } }`)
729
730
  const { stdout, exitCode } = await runEnv(repo, {}, "validate")
730
731
  expect(exitCode).toBe(0)
731
- expect(stdout).toContain(`gtd-mode-sample-qa-${process.pid}.md`)
732
+ expect(stdout).toContain(`${IN_MEMORY_SCRATCH}/gtd-mode-sample-qa-${IN_MEMORY_PID}.md`)
732
733
  })
733
734
 
734
735
  it("gtd validate the COMMAND exits 0 even when the emitted SCRIPT would fail if run — gtd only prints it", async () => {
@@ -791,14 +792,14 @@ describe("gtd next — refuses when history diverges from what replay reaches",
791
792
 
792
793
  describe("a history written in a format this gtd does not read", () => {
793
794
  // The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
794
- const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
795
+ const FOREIGN = "is written in history format 5, but this gtd reads formats 1 to 4"
795
796
 
796
797
  /** A step commit that would also diverge, so only the format gate can explain the refusal. */
797
798
  const foreignStep = async (): Promise<InMemRepo> => {
798
799
  const repo = seed(IDLE_THEN_WORKING)
799
800
  await landTurn(repo, { "NOTE.md": "a note\n" })
800
801
  repo.writeFile("WORK.md", "done\n")
801
- repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
802
+ repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 5")
802
803
  return repo
803
804
  }
804
805
 
@@ -823,7 +824,7 @@ describe("a history written in a format this gtd does not read", () => {
823
824
  it("refuses even when a foreign subject hides the commit from the episode", async () => {
824
825
  const repo = seed(IDLE_THEN_WORKING)
825
826
  repo.writeFile("NOTE.md", "a note\n")
826
- repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
827
+ repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 5")
827
828
  for (const command of ["next", "abandon"]) {
828
829
  const { exitCode, stderr } = await run(repo, command)
829
830
  expect(exitCode).toBe(1)
@@ -834,7 +835,7 @@ describe("a history written in a format this gtd does not read", () => {
834
835
  it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
835
836
  const repo = seed(IDLE_THEN_WORKING)
836
837
  repo.writeFile("NOTE.md", "a note\n")
837
- repo.seedCommit("feat: x\n\nGtd-Format: 3")
838
+ repo.seedCommit("feat: x\n\nGtd-Format: 5")
838
839
  expect((await run(repo, "next")).exitCode).toBe(0)
839
840
  })
840
841
 
@@ -842,7 +843,7 @@ describe("a history written in a format this gtd does not read", () => {
842
843
  const repo = seed(IDLE_THEN_WORKING)
843
844
  const base = repo.resolveRef("HEAD")!
844
845
  repo.writeFile("NOTE.md", "a note\n")
845
- repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
846
+ repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 5")
846
847
  repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
847
848
  repo.hardResetTo(base)
848
849
  const { exitCode, stderr } = await run(repo, "restore")
@@ -1403,7 +1404,7 @@ export default async () => {
1403
1404
  it("a turn that finishes a pinned flow targets the unpin", async () => {
1404
1405
  const repo = await atWorking()
1405
1406
  repo.writeFile("REVIEW.md", "an updated review\n")
1406
- expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
1407
+ expect(await nextOf(repo)).toMatchObject({ target: "gtd:unpin" })
1407
1408
  })
1408
1409
 
1409
1410
  it("plain gtd next shows the same preview as a `Next:` line", async () => {
@@ -1495,7 +1496,7 @@ export default async () => {
1495
1496
  expect(script).not.toContain("git reset --mixed")
1496
1497
  expect(script).not.toContain("nothing to retain")
1497
1498
  expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
1498
- expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
1499
+ expect(repo.lastCommitSubject()).toBe("gtd(check): checking → gtd:unpin")
1499
1500
  })
1500
1501
 
1501
1502
  it("the same rest with a pending change lands an ordinary commit too", async () => {
@@ -1544,7 +1545,7 @@ export default async () => {
1544
1545
  }
1545
1546
  expect(parsed.settled).toBe(false)
1546
1547
  expect(parsed.idle).toBe(false)
1547
- expect(parsed.state).toBe("unpin")
1548
+ expect(parsed.state).toBe("gtd:unpin")
1548
1549
  })
1549
1550
 
1550
1551
  it("gtd land --json an ordinary commit reports settled:false", async () => {
@@ -1693,7 +1694,7 @@ export default async () => {
1693
1694
  const { exitCode, script } = await landAndApply(repo)
1694
1695
  expect(exitCode).toBe(0)
1695
1696
  expect(script).not.toContain("fmt-notes")
1696
- expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
1697
+ expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → gtd:unpin")
1697
1698
  })
1698
1699
 
1699
1700
  it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
@@ -1973,7 +1974,7 @@ export default async () => {
1973
1974
  it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
1974
1975
  const repo = await atDeciding()
1975
1976
  await landTurn(repo)
1976
- expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
1977
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
1977
1978
 
1978
1979
  const { exitCode, stdout, stderr } = await run(repo, "base")
1979
1980
  expect(exitCode).toBe(1)
@@ -2186,41 +2187,32 @@ export default workflow(
2186
2187
  expect(first.stdout).toBe(second.stdout)
2187
2188
  })
2188
2189
 
2189
- let savedStdin: PropertyDescriptor | undefined
2190
- afterEach(() => {
2191
- if (savedStdin) {
2192
- Object.defineProperty(process, "stdin", savedStdin)
2193
- savedStdin = undefined
2194
- }
2195
- })
2196
-
2197
- const withStdin = async <T>(content: string, fn: () => Promise<T>): Promise<T> => {
2198
- savedStdin = Object.getOwnPropertyDescriptor(process, "stdin")
2199
- const stdin = new PassThrough()
2200
- Object.defineProperty(process, "stdin", { value: stdin, configurable: true })
2201
- stdin.end(content)
2202
- try {
2203
- return await fn()
2204
- } finally {
2205
- if (savedStdin) Object.defineProperty(process, "stdin", savedStdin)
2206
- savedStdin = undefined
2207
- }
2190
+ const runWithStdin = async (
2191
+ stdin: string,
2192
+ repo: InMemRepo,
2193
+ ...args: string[]
2194
+ ): Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }> => {
2195
+ const { io, result } = makeCapturingCliIo(repo, {}, {}, stdin)
2196
+ await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
2197
+ return result()
2208
2198
  }
2209
2199
 
2210
2200
  const VERDICT = JSON.stringify([{ id: "q1", answer: true, p: 0.97 }])
2211
2201
 
2212
2202
  it("gtd judge answer decodes a verdict on stdin against the pending question ids and succeeds", async () => {
2213
2203
  const repo = seededRepo()
2214
- const { exitCode, stdout } = await withStdin(VERDICT, () => run(repo, "judge", "answer"))
2204
+ const { exitCode, stdout } = await runWithStdin(VERDICT, repo, "judge", "answer")
2215
2205
  expect(exitCode).toBe(0)
2216
2206
  expect(stdout.length).toBeGreaterThan(0)
2217
2207
  })
2218
2208
 
2219
2209
  it("gtd judge answer refuses a verdict naming a question id the pending judgment never declared, with the usage-error exit code", async () => {
2220
2210
  const repo = seededRepo()
2221
- const { exitCode, stderr } = await withStdin(
2211
+ const { exitCode, stderr } = await runWithStdin(
2222
2212
  JSON.stringify([{ id: "not-a-real-question", answer: true, p: 0.97 }]),
2223
- () => run(repo, "judge", "answer"),
2213
+ repo,
2214
+ "judge",
2215
+ "answer",
2224
2216
  )
2225
2217
  expect(exitCode).toBe(2)
2226
2218
  expect(stderr).toContain("does not match the pending questions")
@@ -2228,16 +2220,18 @@ export default workflow(
2228
2220
 
2229
2221
  it("gtd judge answer refuses when stdin is not valid JSON at all, with the usage-error exit code", async () => {
2230
2222
  const repo = seededRepo()
2231
- const { exitCode, stderr } = await withStdin("not json", () => run(repo, "judge", "answer"))
2223
+ const { exitCode, stderr } = await runWithStdin("not json", repo, "judge", "answer")
2232
2224
  expect(exitCode).toBe(2)
2233
2225
  expect(stderr).toContain("stdin is not valid JSON")
2234
2226
  })
2235
2227
 
2236
2228
  it("gtd judge answer refuses a verdict whose p is above 1 — an unvalidated p would clear every minP", async () => {
2237
2229
  const repo = seededRepo()
2238
- const { exitCode, stderr } = await withStdin(
2230
+ const { exitCode, stderr } = await runWithStdin(
2239
2231
  JSON.stringify([{ id: "q1", answer: true, p: 5 }]),
2240
- () => run(repo, "judge", "answer"),
2232
+ repo,
2233
+ "judge",
2234
+ "answer",
2241
2235
  )
2242
2236
  expect(exitCode).toBe(2)
2243
2237
  expect(stderr).toContain("does not match the pending questions")
@@ -2245,9 +2239,11 @@ export default workflow(
2245
2239
 
2246
2240
  it("gtd judge answer refuses a verdict whose p is negative", async () => {
2247
2241
  const repo = seededRepo()
2248
- const { exitCode, stderr } = await withStdin(
2242
+ const { exitCode, stderr } = await runWithStdin(
2249
2243
  JSON.stringify([{ id: "q1", answer: true, p: -0.1 }]),
2250
- () => run(repo, "judge", "answer"),
2244
+ repo,
2245
+ "judge",
2246
+ "answer",
2251
2247
  )
2252
2248
  expect(exitCode).toBe(2)
2253
2249
  expect(stderr).toContain("does not match the pending questions")
@@ -2255,15 +2251,19 @@ export default workflow(
2255
2251
 
2256
2252
  it("gtd judge answer refuses through the ordinary error envelope when the rest is no judge step", async () => {
2257
2253
  const repo = seededRepoWithoutJudge()
2258
- const { exitCode, stderr } = await withStdin("[]", () => run(repo, "judge", "answer"))
2254
+ const { exitCode, stderr } = await runWithStdin("[]", repo, "judge", "answer")
2259
2255
  expect(exitCode).toBe(1)
2260
2256
  expect(stderr).toContain('declares no "judge:"')
2261
2257
  })
2262
2258
 
2263
2259
  it("gtd judge answer --json=script emits a POSIX sh script carrying a Gtd-Judge: trailer on the step commit", async () => {
2264
2260
  const repo = seededLandingRepo()
2265
- const { stdout, exitCode } = await withStdin(VERDICT, () =>
2266
- run(repo, "judge", "answer", "--json=script"),
2261
+ const { stdout, exitCode } = await runWithStdin(
2262
+ VERDICT,
2263
+ repo,
2264
+ "judge",
2265
+ "answer",
2266
+ "--json=script",
2267
2267
  )
2268
2268
  expect(exitCode).toBe(0)
2269
2269
  expect(stdout).toContain(DID_NOT_RUN_COMMENT)
@@ -2276,16 +2276,14 @@ export default workflow(
2276
2276
 
2277
2277
  it("gtd judge answer --json=script opens with the same leading comment as land's script", async () => {
2278
2278
  const repo = seededLandingRepo()
2279
- const { stdout: script } = await withStdin(VERDICT, () =>
2280
- run(repo, "judge", "answer", "--json=script"),
2281
- )
2279
+ const { stdout: script } = await runWithStdin(VERDICT, repo, "judge", "answer", "--json=script")
2282
2280
  const { stdout: landScript } = await run(repo, "land", "--json=script")
2283
2281
  expect(script.split("\n")[0]).toBe(landScript.split("\n")[0])
2284
2282
  })
2285
2283
 
2286
2284
  it("plain gtd judge answer (no --json) names the commit and points at --json=script, never the script itself", async () => {
2287
2285
  const repo = seededLandingRepo()
2288
- const { stdout, exitCode } = await withStdin(VERDICT, () => run(repo, "judge", "answer"))
2286
+ const { stdout, exitCode } = await runWithStdin(VERDICT, repo, "judge", "answer")
2289
2287
  expect(exitCode).toBe(0)
2290
2288
  expect(stdout).toContain("--json=script")
2291
2289
  expect(stdout).not.toContain(DID_NOT_RUN_COMMENT)
@@ -2305,8 +2303,12 @@ export default workflow(
2305
2303
 
2306
2304
  it("gtd judge answer records no truncation on the landing commit — replay recomputes the cut from the budget", async () => {
2307
2305
  const repo = seededTruncatingLandingRepo("20", "aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc\n")
2308
- const { stdout, exitCode } = await withStdin(VERDICT, () =>
2309
- run(repo, "judge", "answer", "--json=script"),
2306
+ const { stdout, exitCode } = await runWithStdin(
2307
+ VERDICT,
2308
+ repo,
2309
+ "judge",
2310
+ "answer",
2311
+ "--json=script",
2310
2312
  )
2311
2313
  expect(exitCode).toBe(0)
2312
2314
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
@@ -1,4 +1,4 @@
1
- import { Effect, Option, Schema } from "effect"
1
+ import { Effect, Option, type Path, Schema } from "effect"
2
2
  import {
3
3
  GtdError,
4
4
  GtdUsageError,
@@ -51,6 +51,7 @@ import {
51
51
  } from "../wire/index.js"
52
52
  import {
53
53
  currentRest,
54
+ repinAnchor,
54
55
  startRefusal,
55
56
  currentRun,
56
57
  renderRest,
@@ -98,7 +99,7 @@ import {
98
99
  restoredOutcome,
99
100
  } from "../emit/index.js"
100
101
  import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
101
- import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
102
+ import { ToolCommands } from "./ToolCommands.js"
102
103
  import {
103
104
  normalizeScriptNewline,
104
105
  SelectorUsageError,
@@ -209,14 +210,12 @@ export type Command =
209
210
  */
210
211
  export type Needs = "pure" | "removed" | "none" | "fs" | "state"
211
212
 
212
- const selfGtd = (): SelfGtd => {
213
- const script = process.argv[1]
214
- return script === undefined ? ["gtd"] : [process.execPath, script]
215
- }
216
-
217
213
  /** `needs: "none"` skips the repo-root guard — the server is keyed on file name, not workflow state. */
218
- const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
219
- Effect.flatMap(ToolCommands, (tools) => tools.lsp(selfGtd()))
214
+ const runLspCommand = (): Effect.Effect<void, Error, ToolCommands | Host> =>
215
+ Effect.gen(function* () {
216
+ const { self } = yield* Host
217
+ return yield* (yield* ToolCommands).lsp(self)
218
+ })
220
219
 
221
220
  /**
222
221
  * Prints the briefing, emitted by the binary itself so it's always current.
@@ -245,7 +244,7 @@ const runBundleCommand = (
245
244
  ): Effect.Effect<
246
245
  void,
247
246
  Error,
248
- ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
247
+ ConfigService | ConfigDiscovery | EngineCore | Host | Path.Path | Narrator | Workspace
249
248
  > =>
250
249
  Effect.gen(function* () {
251
250
  yield* (yield* ConfigService).load
@@ -429,23 +428,6 @@ const parseJudgeDocument = (rendered: string): Effect.Effect<JudgeDocument, Erro
429
428
  ),
430
429
  })
431
430
 
432
- /**
433
- * The first stdin-consuming command outside `src/lsp/Lsp.ts` (which reads LSP's
434
- * own length-prefixed frames off `process.stdin`, not a one-shot read) —
435
- * reads stdin to completion and decodes it as UTF-8 text, no framing.
436
- */
437
- const readStdin = (): Effect.Effect<string, Error> =>
438
- Effect.tryPromise({
439
- try: async () => {
440
- const chunks: Buffer[] = []
441
- for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
442
- chunks.push(chunk)
443
- }
444
- return Buffer.concat(chunks).toString("utf8")
445
- },
446
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
447
- })
448
-
449
431
  /** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
450
432
  const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
451
433
  let doc: unknown
@@ -469,7 +451,7 @@ const runJudgeRunCommand = (
469
451
  const host = yield* Host
470
452
  const tools = yield* ToolCommands
471
453
  yield* refuseLegacyRc
472
- const input = yield* readStdin()
454
+ const input = yield* host.stdin
473
455
  yield* judgmentShape(input)
474
456
  const verdict = yield* Effect.tryPromise({
475
457
  try: () =>
@@ -534,7 +516,7 @@ const runJudgeAnswerCommand = (
534
516
  const document = yield* parseJudgeDocument(rendered.judge)
535
517
  const ids = document.questions.map((q) => q.id)
536
518
 
537
- const raw = yield* readStdin()
519
+ const raw = yield* (yield* Host).stdin
538
520
  const parsed = yield* Effect.try({
539
521
  try: () => JSON.parse(raw) as unknown,
540
522
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
@@ -867,36 +849,45 @@ const runRestoreCommand = (
867
849
  yield* writeScript(json, out, combinedScript(required, ""))
868
850
  })
869
851
 
852
+ /** The step an ordinary repin rests at — the one the process rests at now. */
853
+ const repinTarget = Effect.gen(function* () {
854
+ const rest = yield* currentRest
855
+ const refusal =
856
+ rest.pin === "add"
857
+ ? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
858
+ : rest.pin === "remove"
859
+ ? "the flow has ended — its unpin beat is next, so there is nothing to repin"
860
+ : noProcessUnderway(rest)
861
+ ? "no gtd process is underway — nothing to repin"
862
+ : undefined
863
+ if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
864
+ return rest.step.name
865
+ })
866
+
870
867
  /**
871
- * `gtd repin`: the pin beat's script and landing, on demand. It runs live,
872
- * so reading the rest first refuses a live workflow that cannot replay the
873
- * process before any pin is written.
868
+ * `gtd repin`: the pin beat's script and landing, on demand. It runs live: a
869
+ * pinned process the live workflow no longer replays is anchored where it
870
+ * diverged, and any other failure to read the rest refuses before any pin is
871
+ * written.
874
872
  */
875
873
  const runRepinCommand = (
876
874
  json: JsonMode,
877
875
  out: ArtifactOut,
878
876
  ): Effect.Effect<void, Error, CommandRequirements> =>
879
877
  Effect.gen(function* () {
880
- const rest = yield* currentRest
881
- const refusal =
882
- rest.pin === "add"
883
- ? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
884
- : rest.pin === "remove"
885
- ? "the flow has ended — its unpin beat is next, so there is nothing to repin"
886
- : noProcessUnderway(rest)
887
- ? "no gtd process is underway — nothing to repin"
888
- : undefined
889
- if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
878
+ const anchor = yield* repinAnchor
879
+ const to = anchor?.to ?? (yield* repinTarget)
890
880
  const message = formatCommitMessage({
891
881
  actor: "check",
892
882
  from: PIN_STATES.add,
893
- to: rest.step.name,
883
+ to,
894
884
  pin: "add",
885
+ ...(anchor !== undefined ? { anchor: anchor.at } : {}),
895
886
  })
896
887
  const steps: EmitStep[] = [
897
888
  { kind: "gitWrite", command: repinScript(PIN_PATH) },
898
889
  { kind: "gitWrite", command: commitPath(PIN_PATH, message) },
899
- { kind: "outcome", command: transitionOutcome(PIN_STATES.add, rest.step.name) },
890
+ { kind: "outcome", command: transitionOutcome(PIN_STATES.add, to) },
900
891
  ]
901
892
  yield* writeScript(json, out, combinedScript(emitScripts(steps).required, ""))
902
893
  })
@@ -936,7 +927,7 @@ const resolveValidateScript = (
936
927
  ): Effect.Effect<
937
928
  { readonly file: string; readonly mode: StateMode; readonly script: string } | undefined,
938
929
  Error,
939
- Host
930
+ Host | Path.Path
940
931
  > =>
941
932
  Effect.gen(function* () {
942
933
  const file = rest.hints.file
@@ -1,9 +1,7 @@
1
1
  import { Context } from "effect"
2
2
  import type { Effect } from "effect"
3
3
  import type { UiSettings } from "../flows/index.js"
4
-
5
- /** How to run THIS gtd — a tool (the LSP) never reads a different version's state than the one it ships with. */
6
- export type SelfGtd = readonly [string, ...string[]]
4
+ import type { HostOps } from "../platform/index.js"
7
5
 
8
6
  export interface UiRequest {
9
7
  readonly flags: {
@@ -36,7 +34,8 @@ export class ToolCommands extends Context.Service<
36
34
  request: UiRequest,
37
35
  out: { readonly write: (chunk: string) => void; readonly flush: () => void },
38
36
  ) => Effect.Effect<void, Error>
39
- readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
37
+ /** `gtd` runs THIS gtd, so the LSP never reads a different version's state than the one it ships with. */
38
+ readonly lsp: (gtd: HostOps["self"]) => Effect.Effect<void, Error>
40
39
  readonly judge: (request: JudgeRequest) => Promise<string>
41
40
  }
42
41
  >()("ToolCommands") {}