@pmelab/gtd 63.0.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -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-DwypA7cH.mjs → testkit-zGjNeQNL.mjs} +1011 -313
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +3890 -1294
  9. package/dist/gtd.core.mjs +119 -118
  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 +9 -27
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +11 -30
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +30 -10
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +10 -9
  25. package/src/gherkin/messages.test.ts +1 -1
  26. package/src/gherkin/module-config.feature +1 -1
  27. package/src/gherkin/testkit.feature +2 -2
  28. package/src/install/Version.ts +14 -13
  29. package/src/platform/Host.ts +75 -22
  30. package/src/platform/NodePinFile.ts +44 -0
  31. package/src/platform/NodeRuntime.ts +19 -0
  32. package/src/platform/NodeSync.ts +64 -0
  33. package/src/platform/PinFile.ts +0 -50
  34. package/src/platform/Sha.ts +121 -0
  35. package/src/platform/SyncIo.ts +35 -0
  36. package/src/platform/Workspace.test.ts +8 -3
  37. package/src/platform/Workspace.ts +82 -123
  38. package/src/platform/WorktreeState.test.ts +8 -4
  39. package/src/platform/WorktreeState.ts +21 -19
  40. package/src/platform/index.ts +18 -5
  41. package/src/replay/Format.test.ts +16 -13
  42. package/src/replay/Format.ts +4 -2
  43. package/src/replay/Replay.test.ts +25 -5
  44. package/src/replay/Replay.ts +10 -6
  45. package/src/replay/Trailers.test.ts +10 -10
  46. package/src/replay/Trailers.ts +23 -1
  47. package/src/replay/index.ts +1 -0
  48. package/src/step/planStart.test.ts +2 -2
  49. package/src/step/planStep.test.ts +4 -4
  50. package/src/testkit/TestRepo.test.ts +1 -1
  51. package/src/testkit/TestRepo.ts +4 -18
  52. package/src/twins/Host.test.ts +151 -0
  53. package/src/twins/Layers.test.ts +2 -2
  54. package/src/twins/Layers.ts +44 -18
  55. package/src/twins/Path.test.ts +52 -0
  56. package/src/twins/SyncIo.test.ts +153 -0
  57. package/src/twins/SyncIo.ts +159 -0
  58. package/src/twins/cliIo.ts +8 -1
  59. package/src/twins/index.ts +5 -0
  60. package/src/workflow/discovery.test.ts +6 -5
  61. package/src/workflow/discovery.ts +43 -32
  62. package/src/workflow/fingerprint.test.ts +23 -3
  63. package/src/workflow/fingerprint.ts +9 -11
  64. package/src/workflow/load.test.ts +16 -6
  65. package/src/workflow/load.ts +107 -69
  66. package/src/workflow/pin.test.ts +3 -2
  67. 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-DwypA7cH.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-DwypA7cH.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": "63.0.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,
@@ -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: 3`,
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 4, but this gtd reads formats 1 to 3"
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: 4")
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: 4")
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: 4")
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: 4")
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,
@@ -99,7 +99,7 @@ import {
99
99
  restoredOutcome,
100
100
  } from "../emit/index.js"
101
101
  import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
102
- import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
102
+ import { ToolCommands } from "./ToolCommands.js"
103
103
  import {
104
104
  normalizeScriptNewline,
105
105
  SelectorUsageError,
@@ -210,14 +210,12 @@ export type Command =
210
210
  */
211
211
  export type Needs = "pure" | "removed" | "none" | "fs" | "state"
212
212
 
213
- const selfGtd = (): SelfGtd => {
214
- const script = process.argv[1]
215
- return script === undefined ? ["gtd"] : [process.execPath, script]
216
- }
217
-
218
213
  /** `needs: "none"` skips the repo-root guard — the server is keyed on file name, not workflow state. */
219
- const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
220
- 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
+ })
221
219
 
222
220
  /**
223
221
  * Prints the briefing, emitted by the binary itself so it's always current.
@@ -246,7 +244,7 @@ const runBundleCommand = (
246
244
  ): Effect.Effect<
247
245
  void,
248
246
  Error,
249
- ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
247
+ ConfigService | ConfigDiscovery | EngineCore | Host | Path.Path | Narrator | Workspace
250
248
  > =>
251
249
  Effect.gen(function* () {
252
250
  yield* (yield* ConfigService).load
@@ -430,23 +428,6 @@ const parseJudgeDocument = (rendered: string): Effect.Effect<JudgeDocument, Erro
430
428
  ),
431
429
  })
432
430
 
433
- /**
434
- * The first stdin-consuming command outside `src/lsp/Lsp.ts` (which reads LSP's
435
- * own length-prefixed frames off `process.stdin`, not a one-shot read) —
436
- * reads stdin to completion and decodes it as UTF-8 text, no framing.
437
- */
438
- const readStdin = (): Effect.Effect<string, Error> =>
439
- Effect.tryPromise({
440
- try: async () => {
441
- const chunks: Buffer[] = []
442
- for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
443
- chunks.push(chunk)
444
- }
445
- return Buffer.concat(chunks).toString("utf8")
446
- },
447
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
448
- })
449
-
450
431
  /** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
451
432
  const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
452
433
  let doc: unknown
@@ -470,7 +451,7 @@ const runJudgeRunCommand = (
470
451
  const host = yield* Host
471
452
  const tools = yield* ToolCommands
472
453
  yield* refuseLegacyRc
473
- const input = yield* readStdin()
454
+ const input = yield* host.stdin
474
455
  yield* judgmentShape(input)
475
456
  const verdict = yield* Effect.tryPromise({
476
457
  try: () =>
@@ -535,7 +516,7 @@ const runJudgeAnswerCommand = (
535
516
  const document = yield* parseJudgeDocument(rendered.judge)
536
517
  const ids = document.questions.map((q) => q.id)
537
518
 
538
- const raw = yield* readStdin()
519
+ const raw = yield* (yield* Host).stdin
539
520
  const parsed = yield* Effect.try({
540
521
  try: () => JSON.parse(raw) as unknown,
541
522
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
@@ -946,7 +927,7 @@ const resolveValidateScript = (
946
927
  ): Effect.Effect<
947
928
  { readonly file: string; readonly mode: StateMode; readonly script: string } | undefined,
948
929
  Error,
949
- Host
930
+ Host | Path.Path
950
931
  > =>
951
932
  Effect.gen(function* () {
952
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") {}