@pmelab/gtd 63.0.0 → 64.1.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 (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -11
  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-Cem5i6Xl.mjs} +422 -2
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4372 -1310
  9. package/dist/gtd.core.mjs +126 -118
  10. package/dist/steering.mjs +2 -2
  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 +22 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +34 -34
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +53 -19
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +31 -10
  25. package/src/flows/index.ts +1 -0
  26. package/src/flows/runtime.test.ts +2 -1
  27. package/src/flows/runtime.ts +8 -2
  28. package/src/gherkin/messages.test.ts +1 -1
  29. package/src/gherkin/module-config.feature +1 -1
  30. package/src/gherkin/testkit.feature +2 -2
  31. package/src/install/Version.ts +14 -13
  32. package/src/platform/Host.ts +75 -22
  33. package/src/platform/NodePinFile.ts +44 -0
  34. package/src/platform/NodeRuntime.ts +19 -0
  35. package/src/platform/NodeSync.ts +64 -0
  36. package/src/platform/PinFile.ts +0 -50
  37. package/src/platform/Sha.ts +121 -0
  38. package/src/platform/SyncIo.ts +35 -0
  39. package/src/platform/Workspace.test.ts +8 -3
  40. package/src/platform/Workspace.ts +82 -123
  41. package/src/platform/WorktreeState.test.ts +8 -4
  42. package/src/platform/WorktreeState.ts +21 -19
  43. package/src/platform/index.ts +18 -5
  44. package/src/replay/Format.test.ts +16 -13
  45. package/src/replay/Format.ts +4 -2
  46. package/src/replay/Replay.test.ts +25 -5
  47. package/src/replay/Replay.ts +10 -6
  48. package/src/replay/Trailers.test.ts +10 -10
  49. package/src/replay/Trailers.ts +23 -1
  50. package/src/replay/index.ts +1 -0
  51. package/src/steering/Declared.test.ts +218 -0
  52. package/src/steering/Declared.ts +607 -0
  53. package/src/steering/FORMATS.md +3 -0
  54. package/src/steering/index.ts +12 -0
  55. package/src/steering/internal.ts +1 -0
  56. package/src/step/planStart.test.ts +2 -2
  57. package/src/step/planStep.test.ts +4 -4
  58. package/src/testkit/TestRepo.test.ts +1 -1
  59. package/src/testkit/TestRepo.ts +4 -18
  60. package/src/twins/EmittedScriptRecognizer.ts +5 -3
  61. package/src/twins/EmittedScripts.test.ts +19 -0
  62. package/src/twins/Host.test.ts +151 -0
  63. package/src/twins/Layers.test.ts +2 -2
  64. package/src/twins/Layers.ts +44 -18
  65. package/src/twins/Path.test.ts +52 -0
  66. package/src/twins/SyncIo.test.ts +153 -0
  67. package/src/twins/SyncIo.ts +159 -0
  68. package/src/twins/cliIo.ts +8 -1
  69. package/src/twins/index.ts +5 -0
  70. package/src/workflow/SteeringFormats.ts +4 -0
  71. package/src/workflow/Workflow.ts +2 -0
  72. package/src/workflow/discovery.test.ts +6 -5
  73. package/src/workflow/discovery.ts +43 -32
  74. package/src/workflow/fingerprint.test.ts +23 -3
  75. package/src/workflow/fingerprint.ts +9 -11
  76. package/src/workflow/index.ts +5 -1
  77. package/src/workflow/load.test.ts +16 -6
  78. package/src/workflow/load.ts +107 -69
  79. package/src/workflow/pin.test.ts +3 -2
  80. 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";
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
+ import { A as findingEntries, C as reviewRisks, D as isAnswered, E as FREE_TEXT_PLACEHOLDER, I as formatFinding, M as parseFootnotes, N as parseThreads, O as FIX_BOX, P as parseThreadsWithFindings, S as reviewNotes, T as unansweredNotes, _ as ANSWER_LEAD, a as sections, b as reviewChunks, c as openThreads, f as listFormat, g as ANSWER_FIX, h as applySteeringEdits, i as sectionBodies, j as openThreadFindings, k as NOISE_HEADING, l as steeringFormatFor, m as freeFormFormat, n as stripCodeThreads, o as threads, p as sectionFormat, r as openQuestions, t as parseCodeThreads, v as ANSWER_WONT_FIX, w as reviewSections, x as reviewDecisions, y as REVIEW_SECTIONS } from "./chunks/steering-Cem5i6Xl.mjs";
2
+ export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, listFormat, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sectionFormat, 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-DfWZQCWQ.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-DfWZQCWQ.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 { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-Cem5i6Xl.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.1.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,
@@ -323,6 +325,16 @@ const FLAGS: readonly FlagRow[] = [
323
325
  valueHint: "",
324
326
  help: "Instead of <mode>'s findings, list the open threads in <file>; exits 1 when any are open. Alone, lists the open code-comment threads in the files the current process changed",
325
327
  },
328
+ {
329
+ name: "--spec",
330
+ arity: 1,
331
+ repeatable: false,
332
+ scope: (kind) => kind === "check",
333
+ decode: ([raw]) => Result.succeed(raw ?? ""),
334
+ scopeError: "gtd: --spec is only valid for `gtd check`",
335
+ valueHint: "<json>",
336
+ help: "Check against the declared format this spec describes, named <mode>, instead of a built-in one; validation scripts pass it for a step whose mode is a declared format",
337
+ },
326
338
  {
327
339
  name: "--verbose",
328
340
  arity: 0,
@@ -585,7 +597,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
585
597
  arity: { names: ["mode", "file"] },
586
598
  group: "Driver plumbing",
587
599
  summary: "Check a steering file's format",
588
- details: `Run the steering format <mode> (qa, review or findings) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
600
+ details: `Run the steering format <mode> (qa, review or findings, or the declared format --spec describes) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
589
601
  },
590
602
  {
591
603
  token: "uncheck",
@@ -1122,6 +1134,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1122
1134
  readonly "--threshold"?: Readonly<Record<string, string>>
1123
1135
  readonly "--open-questions"?: boolean
1124
1136
  readonly "--open-threads"?: boolean
1137
+ readonly "--spec"?: string
1125
1138
  readonly "--host"?: string
1126
1139
  readonly "--self-signed"?: boolean
1127
1140
  readonly "--dev"?: boolean
@@ -1197,6 +1210,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1197
1210
  ? { openQuestions: bag["--open-questions"] }
1198
1211
  : {}),
1199
1212
  ...(bag["--open-threads"] !== undefined ? { openThreads: bag["--open-threads"] } : {}),
1213
+ ...(bag["--spec"] !== undefined ? { spec: bag["--spec"] } : {}),
1200
1214
  },
1201
1215
  json,
1202
1216
  verbose,
@@ -1334,10 +1348,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1334
1348
  // runCli
1335
1349
  // ---------------------------------------------------------------------------
1336
1350
 
1337
- export interface CliIo {
1338
- readonly stdout: (chunk: string | Uint8Array) => void
1339
- readonly stderr: (chunk: string) => void
1340
- readonly exit: (code: number) => void
1351
+ export interface CliIo extends Stdio {
1341
1352
  /**
1342
1353
  * Deferred thunk — a layer is built only for a resolved `Command`, never
1343
1354
  * for `--version`/`--help`/a usage error. `verbose` (`--verbose`/`-v`,
@@ -1349,13 +1360,10 @@ export interface CliIo {
1349
1360
  readonly flags?: readonly FlagDeclaration[]
1350
1361
  }
1351
1362
 
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
- }
1363
+ // `nodeCliIo` hands this one sink to both `stderr` (errors) and `layers`'s
1364
+ // `Narrator` (narration), so a real invocation's narration and its
1365
+ // remediation interleave on the same fd exactly as written.
1366
+ const stdio = nodeStdio()
1359
1367
 
1360
1368
  export type LiveToolCommands = Layer.Layer<
1361
1369
  ToolCommands,
@@ -1364,21 +1372,7 @@ export type LiveToolCommands = Layer.Layer<
1364
1372
  >
1365
1373
 
1366
1374
  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
- },
1375
+ ...stdio,
1382
1376
  // A Layer is a lazy description, not a running service — building this
1383
1377
  // object touches no filesystem/git; only `Effect.provide` running the
1384
1378
  // result does. `runCli` calls this thunk exactly when `plan.kind ===
@@ -1394,7 +1388,7 @@ export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
1394
1388
  ConfigDiscovery.Live,
1395
1389
  EngineCore.Live,
1396
1390
  Workspace.Live,
1397
- Narrator.layer(writeStderr, verbose),
1391
+ Narrator.layer(stdio.stderr, verbose),
1398
1392
  CommandRunner.Live,
1399
1393
  ).pipe(
1400
1394
  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)