@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
@@ -185,7 +185,7 @@ describe("currentRun", () => {
185
185
  expect(run.episode.base).toBe(boundary)
186
186
  expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
187
187
  "gtd(human): idle → building",
188
- "gtd(check): pin → building",
188
+ "gtd(check): gtd:pin → building",
189
189
  "gtd(agent): building → checking",
190
190
  ])
191
191
  })
@@ -205,7 +205,7 @@ describe("currentRun", () => {
205
205
  await land(repo, { "a.txt": "a\n" })
206
206
  await land(repo, { "b.txt": "b\n" })
207
207
  const closing = await land(repo, { "c.txt": "c\n" })
208
- expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
208
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
209
209
  const next = await land(repo, { "d.txt": "d\n" })
210
210
  const run = await provide(currentRun, repo)
211
211
  expect(run.startParentHash).toBe(closing)
@@ -217,7 +217,7 @@ describe("currentRun", () => {
217
217
  const before = headOf(repo)
218
218
  const opening = await enter(repo, "side")
219
219
  expect(repo.lastCommitMessage(opening)).toBe(
220
- `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 3`,
220
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 4`,
221
221
  )
222
222
  const fixed = await land(repo, { "fix.txt": "x\n" })
223
223
  const run = await provide(currentRun, repo)
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
250
250
  const before = headOf(repo)
251
251
  const opening = await enter(repo, "again")
252
252
  expect(repo.lastCommitMessage(opening)).toBe(
253
- `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3`,
253
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 4`,
254
254
  )
255
255
  const fixed = await land(repo, { "fix.txt": "x\n" })
256
256
  const run = await provide(currentRun, repo)
@@ -264,6 +264,29 @@ export const again = workflow(async () => {
264
264
  ])
265
265
  })
266
266
 
267
+ it("a format 3 pin commit naming the default's initial step bounds nothing either", async () => {
268
+ const repo = repoWith(`import { agent, human, workflow } from "@pmelab/gtd/flows"
269
+
270
+ export default async () => {
271
+ await human("idle", { message: "idle-message" })
272
+ }
273
+
274
+ export const again = workflow(async () => {
275
+ await human("idle", { message: "idle-message" })
276
+ await agent("fixing", "fix-prompt")
277
+ })
278
+ `)
279
+ const before = headOf(repo)
280
+ repo.seedCommit("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3")
281
+ const opening = headOf(repo)
282
+ repo.writeFile(".gtd/process.gtdpin", "gtdpin 1 gtd in-memory\n")
283
+ repo.seedCommit("gtd(check): pin → idle\n\nGtd-Pin: add\nGtd-Format: 3")
284
+ const run = await provide(currentRun, repo)
285
+ expect(run.workflow).toBe("again")
286
+ expect(run.startHash).toBe(opening)
287
+ expect(run.startParentHash).toBe(before)
288
+ })
289
+
267
290
  it("collects every process commit's Gtd-Cost, attributing a model-less one to UNATTRIBUTED_MODEL", async () => {
268
291
  const repo = repoWith(LINEAR)
269
292
  commit(
@@ -395,7 +418,7 @@ export const again = workflow(async () => {
395
418
  it("is undefined at a pin commit, which is no turn", async () => {
396
419
  const repo = repoWith(LINEAR)
397
420
  await land(repo, { "a.txt": "a\n" })
398
- expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
421
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → building")
399
422
  expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
400
423
  })
401
424
 
@@ -403,7 +426,7 @@ export const again = workflow(async () => {
403
426
  const repo = repoWith(LINEAR)
404
427
  await land(repo, { "a.txt": "a\n" })
405
428
  await land(repo)
406
- expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 3")
429
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 4")
407
430
  const run = await provide(currentRun, repo)
408
431
  expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
409
432
  })
@@ -544,7 +567,7 @@ describe("summaryRun", () => {
544
567
  expect(summary.trace).toEqual([
545
568
  { state: "building", hash: building, actor: "human" },
546
569
  { state: "checking", hash: checking, actor: "agent" },
547
- { state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
570
+ { state: "gtd:unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
548
571
  { state: "idle", hash: closing, actor: "check" },
549
572
  ])
550
573
  })
@@ -831,7 +854,7 @@ export const review = workflow(async () => {
831
854
  it("is the base the resting step names", async () => {
832
855
  const repo = repoWith(LOOP)
833
856
  const first = await land(repo, { "a.txt": "a\n" })
834
- expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
857
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → checkpoint")
835
858
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
836
859
  await land(repo, { "b.txt": "b\n" })
837
860
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
@@ -1306,7 +1329,7 @@ describe("finishing landings keep history", () => {
1306
1329
  await land(repo, { "b.txt": "b\n" })
1307
1330
  repo.writeFile("c.txt", "c\n")
1308
1331
  const landing = await landingOf(repo)
1309
- expect(landing.to).toBe("unpin")
1332
+ expect(landing.to).toBe("gtd:unpin")
1310
1333
  expect(landing.keepHistory).toBeUndefined()
1311
1334
  expect(landing.spec.version).toBeUndefined()
1312
1335
  })
@@ -1522,13 +1545,16 @@ describe("historyTrail", () => {
1522
1545
  tip,
1523
1546
  ])
1524
1547
  expect(trail.steps[2]).toMatchObject({
1525
- subject: "gtd(agent): checking → unpin",
1548
+ subject: "gtd(agent): checking → gtd:unpin",
1526
1549
  actor: "agent",
1527
1550
  from: "checking",
1528
- to: "unpin",
1551
+ to: "gtd:unpin",
1529
1552
  verdicts: [],
1530
1553
  })
1531
- expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
1554
+ expect(trail.steps[3]).toMatchObject({
1555
+ subject: "gtd(check): gtd:unpin → idle",
1556
+ actor: "check",
1557
+ })
1532
1558
  expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1533
1559
  })
1534
1560
 
package/src/edge/Edge.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, Option } from "effect"
1
+ import { Effect, Option, Path } from "effect"
2
2
  import {
3
3
  Narrator,
4
4
  GitService,
@@ -42,6 +42,7 @@ import {
42
42
  treeFromRecord,
43
43
  type EpisodeCommit,
44
44
  type JudgeVerdict,
45
+ LEGACY_PIN_STATES,
45
46
  type PinChange,
46
47
  PIN_STATES,
47
48
  type ReachedStep,
@@ -62,7 +63,6 @@ import {
62
63
 
63
64
  import { GTD_VERSION } from "../install/index.js"
64
65
  import { keptRef, processHistoryRef } from "../platform/index.js"
65
- import { isAbsolute, relative, resolve, sep } from "node:path"
66
66
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
67
67
 
68
68
  export { UNATTRIBUTED_MODEL }
@@ -337,8 +337,14 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
337
337
  const parsed = parseCommitMessage(subject).parsed
338
338
  if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
339
339
  if (parsed.actor === "human" && parsed.from === undefined) return false
340
- // A pin commit's subject can name the first step; it bounds nothing.
341
- if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
340
+ // A pin commit's subject can name the first step; it bounds nothing. The
341
+ // subject alone carries no format, so either spelling counts.
342
+ if (
343
+ parsed.actor === "check" &&
344
+ (parsed.from === PIN_STATES.add || parsed.from === LEGACY_PIN_STATES.add)
345
+ ) {
346
+ return false
347
+ }
342
348
  return parsed.to === def.initial
343
349
  }
344
350
 
@@ -421,7 +427,14 @@ const computeProcessRun = (
421
427
  runOf(history, locateEpisode(def, history)),
422
428
  )
423
429
 
424
- type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
430
+ type ConfigRequirements =
431
+ | GitService
432
+ | ConfigService
433
+ | ConfigDiscovery
434
+ | Narrator
435
+ | Workspace
436
+ | Host
437
+ | Path.Path
425
438
 
426
439
  /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
427
440
  export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
@@ -1564,10 +1577,16 @@ export interface HistoryTrail {
1564
1577
  * repository cannot be read at a ref, so it is left out. A file only the old
1565
1578
  * config read needs no check: whichever file imported it differs too.
1566
1579
  */
1567
- const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
1568
- const paths = new Set<string>()
1569
- for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1570
- return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1580
+ const inRepoConfigPaths = (
1581
+ path: Path.Path,
1582
+ root: string,
1583
+ files: readonly string[],
1584
+ ): readonly string[] => {
1585
+ const found = new Set<string>()
1586
+ for (const file of files) {
1587
+ found.add(path.relative(root, path.resolve(root, file)).split(path.sep).join("/"))
1588
+ }
1589
+ return [...found].filter((p) => p !== "" && !p.startsWith("..") && !path.isAbsolute(p)).sort()
1571
1590
  }
1572
1591
 
1573
1592
  const configRefusal = (
@@ -1589,7 +1608,8 @@ const differingConfigPaths = (
1589
1608
  ) =>
1590
1609
  Effect.gen(function* () {
1591
1610
  const differing: string[] = []
1592
- for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
1611
+ const configPaths = inRepoConfigPaths(yield* Path.Path, root, Object.keys(config.fingerprint))
1612
+ for (const path of configPaths) {
1593
1613
  const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1594
1614
  if (atTip !== workspace.readSync(path)) differing.push(path)
1595
1615
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { Effect } from "effect"
2
+ import { Effect, Layer, Path } from "effect"
3
3
  import { resolveMode, validateScriptFor, type ModeResolution } from "./SteeringMode.js"
4
4
  import { Host } from "../platform/index.js"
5
5
  import { steeringFormatFor } from "../steering/index.js"
@@ -16,7 +16,9 @@ const envVarsLayer = Host.layer({ root: "/repo", home: "/repo", env: { PATH: "/u
16
16
 
17
17
  const runScript = (resolved: ModeResolution, file: string) => {
18
18
  if (resolved.kind !== "resolved") throw new Error("expected a resolved mode")
19
- return Effect.runPromise(validateScriptFor(resolved, file).pipe(Effect.provide(envVarsLayer)))
19
+ return Effect.runPromise(
20
+ validateScriptFor(resolved, file).pipe(Effect.provide(Layer.merge(envVarsLayer, Path.layer))),
21
+ )
20
22
  }
21
23
 
22
24
  describe("resolveMode", () => {
@@ -1,6 +1,5 @@
1
- import { join } from "node:path"
2
- import { Effect } from "effect"
3
- import { Host } from "../platform/index.js"
1
+ import { Effect, Path } from "effect"
2
+ import { Host, type HostOps } from "../platform/index.js"
4
3
  import {
5
4
  isSeededValidateCommand,
6
5
  knownModes,
@@ -191,8 +190,11 @@ const modeCommandSteps = (resolved: ResolvedMode, file: string): readonly EmitSt
191
190
  }
192
191
 
193
192
  /** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
194
- const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
195
- join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`)
193
+ const scratchSamplePath = (
194
+ path: Path.Path,
195
+ { scratchDir, pid }: HostOps,
196
+ mode: StateMode,
197
+ ): string => path.join(scratchDir, `gtd-mode-sample-${mode}-${pid}.md`)
196
198
 
197
199
  /**
198
200
  * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
@@ -204,13 +206,12 @@ const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
204
206
  */
205
207
  const modeContradictionSteps = (
206
208
  resolved: ResolvedMode,
207
- ): Effect.Effect<readonly EmitStep[], Error, Host> =>
209
+ ): Effect.Effect<readonly EmitStep[], Error, Host | Path.Path> =>
208
210
  Effect.gen(function* () {
209
211
  if (resolved.formatCommand === undefined) return []
210
212
  const { capabilities } = resolved
211
213
  if (capabilities.format !== undefined && capabilities.liveValidate !== undefined) {
212
- const { scratchDir } = yield* Host
213
- const samplePath = scratchSamplePath(scratchDir, resolved.mode)
214
+ const samplePath = scratchSamplePath(yield* Path.Path, yield* Host, resolved.mode)
214
215
  const formatCommand = withFileVar(resolved.formatCommand, samplePath)
215
216
  return [
216
217
  {
@@ -245,7 +246,7 @@ export interface ValidateScript {
245
246
  export const validateScriptFor = (
246
247
  resolved: ResolvedMode,
247
248
  file: string,
248
- ): Effect.Effect<ValidateScript, Error, Host> =>
249
+ ): Effect.Effect<ValidateScript, Error, Host | Path.Path> =>
249
250
  Effect.gen(function* () {
250
251
  const steps: EmitStep[] = [
251
252
  ...(yield* modeContradictionSteps(resolved)),
@@ -45,7 +45,7 @@ describe("restDescription", () => {
45
45
  " last commits:",
46
46
  " chore: initial commit",
47
47
  " gtd(agent): plan → sign-off",
48
- " gtd(check): pin → sign-off",
48
+ " gtd(check): gtd:pin → sign-off",
49
49
  ' actors never used: "repair" (scenario), "ready" (Background)',
50
50
  ].join("\n"),
51
51
  )
@@ -20,6 +20,6 @@ Feature: a scenario runs a workflow module from disk
20
20
  And the commit subjects end with:
21
21
  """
22
22
  gtd(agent): plan → sign-off
23
- gtd(check): pin → sign-off
23
+ gtd(check): gtd:pin → sign-off
24
24
  """
25
25
 
@@ -43,7 +43,7 @@ Feature: testkit drives any workflow against the real engine
43
43
  And the commit subjects end with:
44
44
  """
45
45
  gtd(human): plan
46
- gtd(check): pin → plan
46
+ gtd(check): gtd:pin → plan
47
47
  gtd(agent): plan → check
48
48
  gtd(check): check → ready
49
49
  gtd(judge): ready → ship
@@ -107,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
107
107
  When gtd starts workflow "tiny"
108
108
  And the process is driven
109
109
  Then the process rests at "plan"
110
- And the last commit subject is "gtd(check): pin → plan"
110
+ And the last commit subject is "gtd(check): gtd:pin → plan"
111
111
 
112
112
  Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
113
113
  Given step "check" is prompted with "npm test"
@@ -1,9 +1,4 @@
1
- import { existsSync } from "node:fs"
2
- import { createRequire } from "node:module"
3
- import { dirname, join } from "node:path"
4
- import { fileURLToPath } from "node:url"
5
-
6
- const _require = createRequire(import.meta.url)
1
+ import { NodeRuntime, nodeSyncIo } from "../platform/index.js"
7
2
 
8
3
  // A source-relative literal (`../../package.json`) breaks the built single-file
9
4
  // bundle: bundling doesn't rewrite `import.meta.url` per original module, so a
@@ -11,13 +6,19 @@ const _require = createRequire(import.meta.url)
11
6
  // dist/ vs src/ — they differ (dist/gtd.bundle.mjs is always one level below
12
7
  // repo root; src/install/Version.ts is two). Walking up from wherever this module
13
8
  // actually runs from resolves correctly in both the unbundled (vitest/tsc) and
14
- // bundled (dist/gtd.bundle.mjs) case.
15
- const findPackageJson = (fromUrl: string): string => {
16
- let dir = dirname(fileURLToPath(fromUrl))
9
+ // bundled (dist/gtd.bundle.mjs) case. URLs, not Path: this runs at import,
10
+ // before any Effect service exists.
11
+ const readPackageJson = (fromUrl: string): string => {
12
+ let dir = new URL(".", fromUrl)
17
13
  for (let i = 0; i < 5; i++) {
18
- const candidate = join(dir, "package.json")
19
- if (existsSync(candidate)) return candidate
20
- dir = dirname(dir)
14
+ const candidate = new URL("package.json", dir)
15
+ try {
16
+ const source = nodeSyncIo.readText(NodeRuntime.fileURLToPath(candidate))
17
+ if (source !== undefined) return source
18
+ } catch {
19
+ // Unreadable counts as absent, as it did for `existsSync`.
20
+ }
21
+ dir = new URL("..", dir)
21
22
  }
22
23
  throw new Error(`package.json not found by walking up from ${fromUrl}`)
23
24
  }
@@ -30,4 +31,4 @@ export const PIN_KEY = Symbol.for("@pmelab/gtd/pin")
30
31
  const pinned = (globalThis as { [PIN_KEY]?: { readonly version: string } })[PIN_KEY]?.version
31
32
 
32
33
  export const GTD_VERSION: string =
33
- pinned ?? (_require(findPackageJson(import.meta.url)) as { version: string }).version
34
+ pinned ?? (JSON.parse(readPackageJson(import.meta.url)) as { version: string }).version
@@ -1,6 +1,6 @@
1
- import { realpathSync } from "node:fs"
2
- import { homedir, tmpdir } from "node:os"
3
1
  import { Context, Effect, Layer } from "effect"
2
+ import { homedir, tmpdir } from "./NodeRuntime.js"
3
+ import { nodeSyncIo } from "./NodeSync.js"
4
4
 
5
5
  const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)))
6
6
 
@@ -8,35 +8,88 @@ export interface HostOps {
8
8
  readonly root: string
9
9
  readonly home: string
10
10
  readonly env: Readonly<Record<string, string | undefined>>
11
- /** `env["TMPDIR"]` when set, `node:os`'s `tmpdir()` otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
11
+ /** `env["TMPDIR"]` when set, the host's temp dir otherwise — never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
12
12
  readonly scratchDir: string
13
13
  /** The real, symlink-resolved form of `path` — used to compare a possibly-symlinked cwd (macOS `/tmp` → `/private/tmp`) against git's own resolution. */
14
14
  readonly realPath: (path: string) => Effect.Effect<string, Error>
15
+ /** Carried by every scratch or temp file name, so concurrent gtd runs never share one. */
16
+ readonly pid: number
17
+ /** `undefined` where the platform has no POSIX user ids. */
18
+ readonly uid: number | undefined
19
+ /** The command that runs this gtd again: its script under the running runtime, or `gtd` off `PATH` when there is none. */
20
+ readonly self: readonly [string, ...string[]]
21
+ /** Standard input read to its end, as UTF-8. */
22
+ readonly stdin: Effect.Effect<string, Error>
15
23
  }
16
24
 
17
- interface HostOpsInput {
18
- readonly root: string
19
- readonly home: string
20
- readonly env: Readonly<Record<string, string | undefined>>
21
- readonly scratchDir?: string
22
- readonly realPath?: (path: string) => Effect.Effect<string, Error>
25
+ /** The output half of the process's stdio, which the CLI writes before any layer is built. */
26
+ export interface Stdio {
27
+ readonly stdout: (chunk: string | Uint8Array) => void
28
+ readonly stderr: (chunk: string) => void
29
+ readonly exit: (code: number) => void
30
+ }
31
+
32
+ /** The slice of Node's `process` the live host reads; a contract test hands in a fake one. */
33
+ export type NodeProcess = Pick<
34
+ NodeJS.Process,
35
+ "argv" | "execPath" | "pid" | "getuid" | "stdin" | "stdout" | "stderr" | "exitCode"
36
+ >
37
+
38
+ type HostOpsInput = Pick<HostOps, "root" | "home" | "env"> & Partial<HostOps>
39
+
40
+ /** `TMPDIR` when set and non-empty, else the host's own `fallback`. */
41
+ export const scratchDirOf = (env: HostOps["env"], fallback: string): string => {
42
+ const dir = env["TMPDIR"]
43
+ return dir !== undefined && dir.length > 0 ? dir : fallback
44
+ }
45
+
46
+ const readAll = (stream: AsyncIterable<Buffer | string>): Effect.Effect<string, Error> =>
47
+ Effect.tryPromise({
48
+ try: async () => {
49
+ const chunks: Buffer[] = []
50
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk))
51
+ return Buffer.concat(chunks).toString("utf8")
52
+ },
53
+ catch: toError,
54
+ })
55
+
56
+ const makeHostOps = (opts: HostOpsInput, proc: NodeProcess): HostOps => {
57
+ const script = proc.argv[1]
58
+ return {
59
+ scratchDir: scratchDirOf(opts.env, tmpdir()),
60
+ realPath: (path: string) =>
61
+ Effect.try({ try: () => nodeSyncIo.realPath(path), catch: toError }),
62
+ pid: proc.pid,
63
+ uid: proc.getuid?.(),
64
+ self: script === undefined ? ["gtd"] : [proc.execPath, script],
65
+ // Lazy: touching `process.stdin` opens it, which the LSP and the pin hand-over must not see.
66
+ stdin: Effect.suspend(() => readAll(proc.stdin)),
67
+ ...opts,
68
+ }
23
69
  }
24
70
 
25
- const makeHostOps = (opts: HostOpsInput): HostOps => ({
26
- root: opts.root,
27
- home: opts.home,
28
- env: opts.env,
29
- scratchDir:
30
- opts.scratchDir ??
31
- (opts.env["TMPDIR"] !== undefined && opts.env["TMPDIR"]!.length > 0
32
- ? opts.env["TMPDIR"]!
33
- : tmpdir()),
34
- realPath:
35
- opts.realPath ??
36
- ((path: string) => Effect.try({ try: () => realpathSync(path), catch: toError })),
71
+ export const nodeStdio = (proc: NodeProcess = process): Stdio => ({
72
+ // `stdout.write` is asynchronous whenever stdout is a pipe or socket —
73
+ // passing a completion callback (and never force-closing, see `exit`
74
+ // below) keeps a queued remainder from being discarded, so a large
75
+ // artifact still reaches a slow reader even after a non-zero exit.
76
+ stdout: (chunk) => {
77
+ proc.stdout.write(chunk, () => {})
78
+ },
79
+ stderr: (chunk) => {
80
+ proc.stderr.write(chunk)
81
+ },
82
+ // Never `process.exit(code)`: that tears the process down out from under
83
+ // an undrained `stdout.write` above and truncates the artifact mid-pipe.
84
+ // Setting `exitCode` lets the event loop drain any queued write first.
85
+ exit: (code) => {
86
+ proc.exitCode = code
87
+ },
37
88
  })
38
89
 
90
+ /** Every member not given in `opts` comes from Node: `proc`, its OS and its file system. */
39
91
  export class Host extends Context.Service<Host, HostOps>()("Host") {
40
- static layer = (opts: HostOpsInput): Layer.Layer<Host> => Layer.succeed(Host, makeHostOps(opts))
92
+ static layer = (opts: HostOpsInput, proc: NodeProcess = process): Layer.Layer<Host> =>
93
+ Layer.succeed(Host, makeHostOps(opts, proc))
41
94
  static Live = Host.layer({ root: process.cwd(), home: homedir(), env: process.env })
42
95
  }
@@ -0,0 +1,44 @@
1
+ import { registerHooks } from "node:module"
2
+ import { join } from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import { nodeSyncIo } from "./NodeSync.js"
5
+ import type { PinFile } from "./PinFile.js"
6
+
7
+ // `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
8
+ // a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
9
+ // read counts as no pin, so the launcher refuses it rather than crashing.
10
+ export const livePinFile =
11
+ (cwd: string, path: string): PinFile =>
12
+ () => {
13
+ const file = join(cwd, path)
14
+ try {
15
+ const bytes = nodeSyncIo.readFile(file)
16
+ if (bytes !== undefined) return { path: file, bytes }
17
+ } catch {
18
+ return { path: file, bytes: new Uint8Array() }
19
+ }
20
+ try {
21
+ return { path: file, bytes: nodeSyncIo.gitBytes(cwd, ["show", `HEAD:${path}`]) }
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Runs a pin's module as this process's main program. Node refuses an unknown
29
+ * extension, so a load hook serves the unpacked source under the pin's own
30
+ * URL — `import.meta.url` stays a file in the repository.
31
+ */
32
+ export const runPinModule = async (path: string, source: string): Promise<void> => {
33
+ const url = pathToFileURL(path).href
34
+ // A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
35
+ registerHooks({
36
+ resolve: (specifier, context, nextResolve) =>
37
+ specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
38
+ load: (requested, context, nextLoad) =>
39
+ requested === url
40
+ ? { format: "module", source, shortCircuit: true }
41
+ : nextLoad(requested, context),
42
+ })
43
+ await import(url)
44
+ }
@@ -0,0 +1,19 @@
1
+ import { createRequire, isBuiltin } from "node:module"
2
+ import { homedir, tmpdir } from "node:os"
3
+ import { fileURLToPath } from "node:url"
4
+ import { stripVTControlCharacters } from "node:util"
5
+ import { runInThisContext } from "node:vm"
6
+ import { gunzipSync, gzipSync } from "node:zlib"
7
+
8
+ // What the core needs of Node beyond the sync port: module loading, the
9
+ // user's directories, file URLs and gzip. Each is a Node fact with no in-memory twin.
10
+
11
+ export { createRequire, fileURLToPath, homedir, isBuiltin, stripVTControlCharacters, tmpdir }
12
+
13
+ export const gzip = (source: string): Uint8Array => gzipSync(source, { level: 9 })
14
+
15
+ export const gunzip = (bytes: Uint8Array): string => gunzipSync(bytes).toString("utf8")
16
+
17
+ /** Compiles `source` as a script in this realm, reporting errors against `filename`. */
18
+ export const compileScript = (source: string, filename: string): unknown =>
19
+ runInThisContext(source, { filename })
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import {
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readlinkSync,
8
+ realpathSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync,
12
+ type Stats,
13
+ } from "node:fs"
14
+ import type { FileInfo, SyncIo } from "./SyncIo.js"
15
+
16
+ // A tree listing, or one committed file, can overflow execFileSync's 1 MB default.
17
+ const MAX_BUFFER = 256 * 1024 * 1024
18
+
19
+ const isAbsent = (e: unknown): boolean => {
20
+ const code = (e as NodeJS.ErrnoException).code
21
+ return code === "ENOENT" || code === "ENOTDIR"
22
+ }
23
+
24
+ const orAbsent = <A>(read: () => A): A | undefined => {
25
+ try {
26
+ return read()
27
+ } catch (e) {
28
+ if (isAbsent(e)) return undefined
29
+ throw e
30
+ }
31
+ }
32
+
33
+ const infoOf = (stats: Stats): FileInfo => ({
34
+ type: stats.isSymbolicLink()
35
+ ? "symlink"
36
+ : stats.isDirectory()
37
+ ? "directory"
38
+ : stats.isFile()
39
+ ? "file"
40
+ : "other",
41
+ uid: process.platform === "win32" ? undefined : stats.uid,
42
+ })
43
+
44
+ export const nodeSyncIo: SyncIo = {
45
+ readFile: (path) => orAbsent(() => readFileSync(path)),
46
+ readText: (path) => orAbsent(() => readFileSync(path, "utf8")),
47
+ writeText: (path, content) => writeFileSync(path, content, "utf8"),
48
+ makeDirectory: (path) => {
49
+ mkdirSync(path, { recursive: true })
50
+ },
51
+ rename: renameSync,
52
+ stat: (path) => orAbsent(() => infoOf(statSync(path))),
53
+ lstat: (path) => orAbsent(() => infoOf(lstatSync(path))),
54
+ readLink: (path) => readlinkSync(path, "utf8"),
55
+ realPath: (path) => realpathSync(path),
56
+ git: (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: MAX_BUFFER }),
57
+ gitBytes: (cwd, args) =>
58
+ execFileSync("git", args, { cwd, maxBuffer: MAX_BUFFER, stdio: ["ignore", "pipe", "ignore"] }),
59
+ hash: (algorithm, ...parts) => {
60
+ const hash = createHash(algorithm)
61
+ for (const part of parts) hash.update(part)
62
+ return hash.digest("hex")
63
+ },
64
+ }