@pmelab/gtd 61.0.0 → 62.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { n as runsItsScript, r as testRepo } from "../chunks/testkit-C9sq0x07.mjs";
1
+ import { n as runsItsScript, r as testRepo } from "../chunks/testkit-XQeYCNMn.mjs";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
package/dist/testkit.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
2
- import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-C9sq0x07.mjs";
2
+ import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-XQeYCNMn.mjs";
3
3
  export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "61.0.0",
3
+ "version": "62.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -632,6 +632,7 @@ const flagHeader = (row: FlagRow): string => {
632
632
 
633
633
  const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
634
634
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
635
+ ["--live", "Run this gtd, not the process's pin; it may misread the process"],
635
636
  ["-h, --help", "Print help; gtd <command> --help for one command"],
636
637
  ["-V, --version", "Print the version"],
637
638
  ["--", "End the options: every word after it is an argument"],
@@ -643,7 +644,11 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
643
644
  "Print help for gtd or one command",
644
645
  "Print gtd's overview, or with <command> that command's own help.",
645
646
  ],
646
- ["version", "Print the version", "Print gtd's version."],
647
+ [
648
+ "version",
649
+ "Print the version",
650
+ "Print gtd's version, and the version of a pinned process's gtd.",
651
+ ],
647
652
  ]
648
653
 
649
654
  const EXAMPLES = ` $ gtd start list what can start
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { GTD_VERSION, PIN_KEY } from "../install/index.js"
3
+ import { launchFor } from "./Launch.js"
4
+
5
+ const pin =
6
+ (header = "gtdpin 1 gtd 1.2.3") =>
7
+ () => ({
8
+ path: "/repo/.gtd/process.gtdpin",
9
+ bytes: Buffer.from(`${header}\n`),
10
+ })
11
+ const argv = (...words: string[]) => ["node", "gtd", ...words]
12
+
13
+ describe("launchFor", () => {
14
+ afterEach(() => {
15
+ delete (globalThis as { [PIN_KEY]?: unknown })[PIN_KEY]
16
+ })
17
+
18
+ it("hands a state command over to the pin", () => {
19
+ expect(launchFor(argv("next"), pin())).toMatchObject({ kind: "pin", version: "1.2.3" })
20
+ })
21
+
22
+ it("never hands over from inside a pin", () => {
23
+ ;(globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] = { version: "1.2.3" }
24
+ expect(launchFor(argv("next"), pin())).toEqual({ kind: "live", argv: argv("next") })
25
+ })
26
+
27
+ it("strips --live, but keeps one after --", () => {
28
+ expect(launchFor(argv("--live", "start", "--", "--live"), pin())).toEqual({
29
+ kind: "live",
30
+ argv: argv("start", "--", "--live"),
31
+ })
32
+ expect(launchFor(argv("start", "--", "--live"), pin())).toMatchObject({
33
+ kind: "pin",
34
+ argv: argv("start", "--", "--live"),
35
+ })
36
+ })
37
+
38
+ it("leaves the version to this gtd without a pin, and for a file that is no pin", () => {
39
+ expect(launchFor(argv("-V"), () => undefined)).toEqual({ kind: "live", argv: argv("-V") })
40
+ expect(launchFor(argv("version"), pin("garbage"))).toEqual({
41
+ kind: "output",
42
+ stdout: `${GTD_VERSION}\n`,
43
+ })
44
+ })
45
+
46
+ it("names a newer pin's version even when it cannot run it", () => {
47
+ expect(launchFor(argv("--version"), pin("gtdpin 9 gtd 99.0.0"))).toEqual({
48
+ kind: "output",
49
+ stdout: `${GTD_VERSION} (this process is pinned to gtd 99.0.0)\n`,
50
+ })
51
+ })
52
+ })
@@ -0,0 +1,143 @@
1
+ import type { PinFile } from "../platform/index.js"
2
+ import { GTD_VERSION, PIN_KEY } from "../install/index.js"
3
+ import { PIN_FORMAT, pinHeader, readPin } from "../workflow/index.js"
4
+ import { EXIT_RUNTIME_ERROR } from "../wire/index.js"
5
+ import { parseArgv } from "./Cli.js"
6
+ import type { Command } from "./Program.js"
7
+
8
+ /** What the core a pin carries lacks, or what must read the live state: these never hand over. */
9
+ const LIVE_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
10
+ "ui",
11
+ "lsp",
12
+ "guide",
13
+ "bundle",
14
+ "historyList",
15
+ "historyShow",
16
+ "historyMetrics",
17
+ "historyJudges",
18
+ "historyFetch",
19
+ ])
20
+
21
+ export type Launch =
22
+ | { readonly kind: "live"; readonly argv: readonly string[] }
23
+ | {
24
+ readonly kind: "pin"
25
+ readonly argv: readonly string[]
26
+ readonly path: string
27
+ readonly version: string
28
+ /** Unpacked only when run: its failure is the hand-over's. */
29
+ readonly source: () => string
30
+ }
31
+ | { readonly kind: "output"; readonly stdout: string }
32
+ | { readonly kind: "refuse"; readonly message: string }
33
+
34
+ const LIVE_FLAG = "--live"
35
+
36
+ // Only before `--`: after it, `--live` is an argument.
37
+ const optionWords = (argv: readonly string[]): readonly string[] => {
38
+ const end = argv.indexOf("--", 2)
39
+ return argv.slice(2, end < 0 ? undefined : end)
40
+ }
41
+
42
+ const withoutLiveFlag = (argv: readonly string[]): readonly string[] => {
43
+ const end = argv.indexOf("--", 2)
44
+ return argv.filter((word, i) => word !== LIVE_FLAG || i < 2 || (end >= 0 && i > end))
45
+ }
46
+
47
+ /**
48
+ * Whether this gtd runs the command or hands it to the process's pin. A pin
49
+ * this gtd cannot run is refused, never guessed at; a usage error the live
50
+ * parser finds is still the pin's to judge, since its engine may read
51
+ * another CLI.
52
+ */
53
+ export const launchFor = (argv: readonly string[], pinFile: PinFile): Launch => {
54
+ // The engine a pin carries is this same code: once inside one, never hand over again.
55
+ if ((globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] !== undefined) return { kind: "live", argv }
56
+ const live = optionWords(argv).includes(LIVE_FLAG)
57
+ const rest = withoutLiveFlag(argv)
58
+ if (live) return { kind: "live", argv: rest }
59
+ const plan = parseArgv(rest)
60
+ const version = plan.kind === "output" && plan.stdout === `${GTD_VERSION}\n`
61
+ if (plan.kind === "output" && !version) return { kind: "live", argv: rest }
62
+ if (plan.kind === "command" && LIVE_KINDS.has(plan.command.kind))
63
+ return { kind: "live", argv: rest }
64
+ const found = pinFile()
65
+ if (found === undefined) return { kind: "live", argv: rest }
66
+ const header = pinHeader(found.bytes)
67
+ if (version)
68
+ return {
69
+ kind: "output",
70
+ stdout:
71
+ header === undefined
72
+ ? plan.stdout
73
+ : `${GTD_VERSION} (this process is pinned to gtd ${header.version})\n`,
74
+ }
75
+ if (header === undefined || header.format !== PIN_FORMAT) {
76
+ const what =
77
+ header === undefined
78
+ ? "is no pin this gtd can read"
79
+ : `is pin format ${header.format}, made by gtd ${header.version}`
80
+ return {
81
+ kind: "refuse",
82
+ message: `gtd: ${found.path} ${what}; gtd ${GTD_VERSION} runs pin format ${PIN_FORMAT} — upgrade gtd (\`gtd --live\` runs this gtd instead, and may misread the process)`,
83
+ }
84
+ }
85
+ return {
86
+ kind: "pin",
87
+ argv: rest,
88
+ path: found.path,
89
+ version: header.version,
90
+ source: () => readPin(found.bytes).source,
91
+ }
92
+ }
93
+
94
+ export interface Launcher {
95
+ readonly stdout: (chunk: string) => void
96
+ readonly stderr: (chunk: string) => void
97
+ readonly exit: (code: number) => void
98
+ readonly live: (argv: readonly string[]) => void | Promise<void>
99
+ readonly pin: (launch: Extract<Launch, { kind: "pin" }>) => Promise<void>
100
+ }
101
+
102
+ const isJsonFlag = (word: string): boolean => word === "--json" || word.startsWith("--json=")
103
+
104
+ // The CLI's own error envelope too, which a `--json` driver reads.
105
+ const refusalText = (argv: readonly string[], message: string): string =>
106
+ (optionWords(argv).some(isJsonFlag)
107
+ ? `${JSON.stringify({ state: "error", prompt: message })}\n`
108
+ : "") + `${message}\n`
109
+
110
+ const runPin = async (
111
+ launch: Extract<Launch, { kind: "pin" }>,
112
+ launcher: Launcher,
113
+ ): Promise<void> => {
114
+ const words = optionWords(launch.argv)
115
+ if (words.includes("--verbose") || words.includes("-v"))
116
+ launcher.stderr(`launcher: running the pin ${launch.path} (gtd ${launch.version})\n`)
117
+ try {
118
+ await launcher.pin(launch)
119
+ } catch (error) {
120
+ const reason = error instanceof Error ? error.message : String(error)
121
+ launcher.stderr(`gtd: could not run the pin ${launch.path}: ${reason}\n`)
122
+ launcher.exit(EXIT_RUNTIME_ERROR)
123
+ }
124
+ }
125
+
126
+ export const runLauncher = async (
127
+ argv: readonly string[],
128
+ pinFile: PinFile,
129
+ launcher: Launcher,
130
+ ): Promise<void> => {
131
+ const launch = launchFor(argv, pinFile)
132
+ switch (launch.kind) {
133
+ case "live":
134
+ return launcher.live(launch.argv)
135
+ case "output":
136
+ return launcher.stdout(launch.stdout)
137
+ case "refuse":
138
+ launcher.stderr(refusalText(argv, launch.message))
139
+ return launcher.exit(EXIT_RUNTIME_ERROR)
140
+ case "pin":
141
+ return runPin(launch, launcher)
142
+ }
143
+ }
@@ -33,7 +33,6 @@ import {
33
33
  restoredOutcome,
34
34
  } from "../emit/index.js"
35
35
  import { GitService, HISTORY_REF, Host } from "../platform/index.js"
36
- import { contentHash } from "../workflow/index.js"
37
36
  import { WORKFLOWS_REPO } from "../install/index.js"
38
37
 
39
38
  const run = async (
@@ -111,7 +110,7 @@ export const hotfix = workflow(
111
110
  expect(exitCode).toBe(0)
112
111
  expect(stdout).toContain(
113
112
  commitAll(
114
- `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 2`,
113
+ `gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 2`,
115
114
  ),
116
115
  )
117
116
  expect(repo.commitHistory()).toHaveLength(before)
@@ -619,7 +619,6 @@ const startWorkflow = (
619
619
  first: started,
620
620
  commandLabel,
621
621
  vars: varOverrides,
622
- config: rest.config,
623
622
  },
624
623
  )
625
624
  if (plan.kind === "refusal") {
package/src/cli/index.ts CHANGED
@@ -12,3 +12,4 @@ export {
12
12
  type LiveToolCommands,
13
13
  } from "./Cli.js"
14
14
  export { ToolCommands, type UiRequest } from "./ToolCommands.js"
15
+ export { runLauncher } from "./Launch.js"
@@ -23,7 +23,7 @@ import { keptRef, processHistoryRef } from "../platform/index.js"
23
23
  import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
24
24
  import type { Mark } from "../flows/index.js"
25
25
  import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
26
- import { contentHash, StartFlags } from "../workflow/index.js"
26
+ import { StartFlags } from "../workflow/index.js"
27
27
  import {
28
28
  fakeGitOperations,
29
29
  InMemRepo,
@@ -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-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 2`,
220
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 2`,
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-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 2`,
253
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 2`,
254
254
  )
255
255
  const fixed = await land(repo, { "fix.txt": "x\n" })
256
256
  const run = await provide(currentRun, repo)
package/src/edge/Edge.ts CHANGED
@@ -12,8 +12,6 @@ import {
12
12
  import {
13
13
  ConfigDiscovery,
14
14
  ConfigService,
15
- changedConfigFiles,
16
- contentHash,
17
15
  multilineSetting,
18
16
  resolveVars,
19
17
  resolveStartVars,
@@ -173,8 +171,6 @@ export interface ProcessRun {
173
171
  readonly approvals: readonly ApprovalEntry[]
174
172
  /** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
175
173
  readonly pinnedVars: Record<string, string>
176
- /** Absent for a process started before recording existed. */
177
- readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
178
174
  /** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
179
175
  readonly headTurn:
180
176
  | {
@@ -248,17 +244,6 @@ const runOf = (
248
244
  : Effect.fail(new Error(fault))
249
245
  }
250
246
 
251
- // The newest recording, not the first process commit's: the episode is
252
- // located by the CURRENT config's first step, so a config that renamed it can
253
- // move the boundary onto the start commit itself, or behind it.
254
- const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
255
- for (let i = history.length - 1; i >= 0; i--) {
256
- const files = parseCommitMessage(history[i]!.message).config
257
- if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
258
- }
259
- return undefined
260
- }
261
-
262
247
  const approvalEntryOf = (
263
248
  hash: string,
264
249
  message: ReturnType<typeof parseCommitMessage>,
@@ -300,7 +285,6 @@ const buildRun = (
300
285
  marks: parsed.flatMap((m) => m.marks),
301
286
  approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
302
287
  pinnedVars: { ...first?.vars },
303
- pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
304
288
  headTurn: headTurnOf(head),
305
289
  closingHash,
306
290
  episode: {
@@ -420,37 +404,6 @@ const computeProcessRun = (
420
404
  runOf(history, locateEpisode(def, history)),
421
405
  )
422
406
 
423
- // A file matching its copy in the recording commit is unchanged: the hashes
424
- // are taken before the commit's own hooks run, and a hook may reformat it.
425
- const changedSinceStart = (
426
- pinned: ProcessRun["pinnedConfig"],
427
- current: ConfigFingerprint,
428
- workspace: WorkspaceOps,
429
- ): readonly string[] => {
430
- if (pinned === undefined) return []
431
- return changedConfigFiles(pinned.files, current).filter((path) => {
432
- const committed = workspace.readCommittedSync(path, pinned.commit)
433
- return (
434
- current[path] === undefined ||
435
- committed === undefined ||
436
- contentHash(committed) !== current[path]
437
- )
438
- })
439
- }
440
-
441
- const warnConfigChanges = (
442
- run: ProcessRun,
443
- current: ConfigFingerprint,
444
- workspace: WorkspaceOps,
445
- ): Effect.Effect<void, never, Narrator> =>
446
- Effect.gen(function* () {
447
- const changed = changedSinceStart(run.pinnedConfig, current, workspace)
448
- if (changed.length === 0) return
449
- yield* (yield* Narrator).warn(
450
- `gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
451
- )
452
- })
453
-
454
407
  type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
455
408
 
456
409
  /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
@@ -876,8 +829,6 @@ interface Rest extends ResolvedRest {
876
829
  readonly run: ProcessRun
877
830
  readonly vars: Record<string, string>
878
831
  readonly env: Record<string, string>
879
- /** What a landing that starts the process records. */
880
- readonly config: ConfigFingerprint
881
832
  readonly changes: readonly PendingChange[]
882
833
  readonly memory: string | undefined
883
834
  readonly memoryResumed: boolean
@@ -1030,11 +981,13 @@ export const restAt = (
1030
981
  path: e.path,
1031
982
  }))
1032
983
  : []
1033
- const configNow = config.fingerprint
1034
- // Only the current rest warns, so a command must read it once — a second
1035
- // read warns twice. An earlier ref (restore's tip) would warn about a
1036
- // process the command is not acting on.
1037
- if (ref === undefined) yield* warnConfigChanges(run, configNow, workspace)
984
+ // Only the current rest notices, so a command must read it once — a
985
+ // second read notices twice. An earlier ref (restore's tip) is not the
986
+ // process the command acts on.
987
+ if (ref === undefined && config.drift.length > 0)
988
+ yield* (yield* Narrator).warn(
989
+ `gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`,
990
+ )
1038
991
  return {
1039
992
  def,
1040
993
  workflowName: resolved.name,
@@ -1048,7 +1001,6 @@ export const restAt = (
1048
1001
  run,
1049
1002
  vars,
1050
1003
  env,
1051
- config: configNow,
1052
1004
  changes,
1053
1005
  memory: memory.key,
1054
1006
  memoryResumed: memory.resumed,
@@ -1406,8 +1358,7 @@ const pinRefusal = (pin: PinChange): Landing => ({
1406
1358
  : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
1407
1359
  })
1408
1360
 
1409
- const startTrailers = (rest: Rest, starts: boolean) =>
1410
- starts ? { vars: rest.vars, config: rest.config } : {}
1361
+ const startTrailers = (rest: Rest, starts: boolean) => (starts ? { vars: rest.vars } : {})
1411
1362
 
1412
1363
  const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
1413
1364
  outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
@@ -1543,13 +1494,13 @@ export interface HistoryTrail {
1543
1494
  readonly versionWarning?: string
1544
1495
  }
1545
1496
 
1546
- /** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
1547
- const inRepoConfigPaths = (
1548
- root: string,
1549
- files: readonly string[],
1550
- recorded: ConfigFingerprint,
1551
- ): readonly string[] => {
1552
- const paths = new Set<string>(Object.keys(recorded))
1497
+ /**
1498
+ * Repository-relative paths of today's config files; one outside the
1499
+ * repository cannot be read at a ref, so it is left out. A file only the old
1500
+ * config read needs no check: whichever file imported it differs too.
1501
+ */
1502
+ const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
1503
+ const paths = new Set<string>()
1553
1504
  for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1554
1505
  return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1555
1506
  }
@@ -1569,16 +1520,11 @@ const differingConfigPaths = (
1569
1520
  workspace: Workspace["Service"],
1570
1521
  root: string,
1571
1522
  config: { readonly fingerprint: ConfigFingerprint },
1572
- run: ProcessRun,
1573
1523
  hash: string,
1574
1524
  ) =>
1575
1525
  Effect.gen(function* () {
1576
1526
  const differing: string[] = []
1577
- for (const path of inRepoConfigPaths(
1578
- root,
1579
- Object.keys(config.fingerprint),
1580
- run.pinnedConfig?.files ?? {},
1581
- )) {
1527
+ for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
1582
1528
  const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1583
1529
  if (atTip !== workspace.readSync(path)) differing.push(path)
1584
1530
  }
@@ -1664,7 +1610,7 @@ const replayFinishedProcess = (
1664
1610
  // A refusal is often caused by the version change, so it carries the warning.
1665
1611
  const withWarning = (err: Error): Error =>
1666
1612
  versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
1667
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1613
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash)
1668
1614
  if (differing.length > 0)
1669
1615
  return yield* Effect.fail(
1670
1616
  withWarning(configRefusal(how.command, hash, differing, how.configReason)),
@@ -0,0 +1,61 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { existsSync, readFileSync } from "node:fs"
3
+ import { registerHooks } from "node:module"
4
+ import { join } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+
7
+ /** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
8
+ export interface FoundPin {
9
+ readonly path: string
10
+ readonly bytes: Uint8Array
11
+ }
12
+
13
+ /**
14
+ * Where the launcher looks for a pin: the worktree's copy, else HEAD's — so
15
+ * the landing after an unpin beat deleted the file still runs on the pin.
16
+ */
17
+ export type PinFile = () => FoundPin | undefined
18
+
19
+ // `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
20
+ // a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
21
+ // read counts as no pin, so the launcher refuses it rather than crashing.
22
+ export const livePinFile =
23
+ (cwd: string, path: string): PinFile =>
24
+ () => {
25
+ const file = join(cwd, path)
26
+ if (existsSync(file)) {
27
+ try {
28
+ return { path: file, bytes: readFileSync(file) }
29
+ } catch {
30
+ return { path: file, bytes: new Uint8Array() }
31
+ }
32
+ }
33
+ try {
34
+ const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
35
+ cwd,
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ })
38
+ return { path: file, bytes }
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Runs a pin's module as this process's main program. Node refuses an unknown
46
+ * extension, so a load hook serves the unpacked source under the pin's own
47
+ * URL — `import.meta.url` stays a file in the repository.
48
+ */
49
+ export const runPinModule = async (path: string, source: string): Promise<void> => {
50
+ const url = pathToFileURL(path).href
51
+ // A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
52
+ registerHooks({
53
+ resolve: (specifier, context, nextResolve) =>
54
+ specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
55
+ load: (requested, context, nextLoad) =>
56
+ requested === url
57
+ ? { format: "module", source, shortCircuit: true }
58
+ : nextLoad(requested, context),
59
+ })
60
+ await import(url)
61
+ }
@@ -29,3 +29,4 @@ export {
29
29
  readRetainedHistory,
30
30
  restorability,
31
31
  } from "./RetainedHistory.js"
32
+ export { livePinFile, runPinModule, type PinFile } from "./PinFile.js"
@@ -60,43 +60,6 @@ describe("the commit-message codec", () => {
60
60
  expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
61
61
  })
62
62
 
63
- it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
64
- const message = formatCommitMessage({
65
- actor: "human",
66
- to: "x",
67
- vars: { a: "1" },
68
- config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
69
- })
70
- expect(message).toBe(
71
- "gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 2",
72
- )
73
- expect(parseCommitMessage(message).config).toEqual({
74
- "gtd.config.ts": "deadbeefdeadbeef",
75
- "lib/my flow.ts": "0123456789abcdef",
76
- })
77
- })
78
-
79
- it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
80
- const config = {
81
- " lead.ts": "0123456789abcdef",
82
- "trail.ts ": "0123456789abcdef",
83
- '"q.ts': "0123456789abcdef",
84
- }
85
- const message = formatCommitMessage({ actor: "human", to: "x", config })
86
- expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
87
- expect(parseCommitMessage(message).config).toEqual(config)
88
- })
89
-
90
- it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
91
- const read = (value: string) =>
92
- parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
93
- expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
94
- expect(read("deadbeef gtd.config.ts")).toEqual({})
95
- expect(read("deadbeefdeadbeef")).toEqual({})
96
- expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
97
- expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
98
- })
99
-
100
63
  it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
101
64
  const message = formatCommitMessage({
102
65
  actor: "agent",
@@ -83,8 +83,6 @@ export interface CommitMessage {
83
83
  /** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
84
84
  readonly marks: readonly Mark[]
85
85
  readonly vars: Readonly<Record<string, string>>
86
- /** Written only on a process's first commit. */
87
- readonly config: Readonly<Record<string, string>>
88
86
  readonly reviewBase: string | undefined
89
87
  readonly cost: readonly CostEntry[]
90
88
  /** Written only on a process's finishing landing. */
@@ -120,20 +118,10 @@ const parseJson = (raw: string): unknown => {
120
118
  const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
121
119
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
122
120
  const VAR_RE = /^([^=\s]+)=(.*)$/
123
- const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/
124
- // A path git's message cleanup or the trailer syntax would alter travels as a
125
- // JSON string. A control character is refused on read: history written by
126
- // someone else must never put escape sequences on a terminal.
121
+ // A control character is refused on read: history written by someone else
122
+ // must never put escape sequences on a terminal.
127
123
  // eslint-disable-next-line no-control-regex -- the control range IS the point
128
124
  const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
129
- const encodePath = (path: string): string =>
130
- path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
131
- ? JSON.stringify(path)
132
- : path
133
- const decodePath = (raw: string): string | undefined => {
134
- const path = raw.startsWith('"') ? parseJson(raw) : raw
135
- return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
136
- }
137
125
  const decodeApproval = (raw: string): Approval | undefined => {
138
126
  const parsed = parseJson(raw)
139
127
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
@@ -168,7 +156,6 @@ interface Trailers {
168
156
  readonly judge: JudgeVerdict[]
169
157
  readonly marks: Mark[]
170
158
  readonly vars: Record<string, string>
171
- readonly config: Record<string, string>
172
159
  readonly cost: CostEntry[]
173
160
  format: number | undefined
174
161
  }
@@ -193,11 +180,6 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
193
180
  const kv = VAR_RE.exec(value)
194
181
  if (kv !== null) into.vars[kv[1]!] = kv[2]!
195
182
  },
196
- "Gtd-Config": (value, into) => {
197
- const entry = CONFIG_RE.exec(value)
198
- const path = entry === null ? undefined : decodePath(entry[2]!)
199
- if (path !== undefined) into.config[path] = entry![1]!
200
- },
201
183
  "Gtd-Review-Base": (value, into) => {
202
184
  into.reviewBase ??= value.split(/\s/)[0]
203
185
  },
@@ -242,7 +224,6 @@ export const parseCommitMessage = (message: string): CommitMessage => {
242
224
  judge: [],
243
225
  marks: [],
244
226
  vars: {},
245
- config: {},
246
227
  cost: [],
247
228
  format: undefined,
248
229
  }
@@ -262,7 +243,6 @@ export interface CommitSpec {
262
243
  readonly workflow?: string
263
244
  readonly reviewBase?: string
264
245
  readonly vars?: Readonly<Record<string, string>>
265
- readonly config?: Readonly<Record<string, string>>
266
246
  readonly cost?: { readonly cost: number; readonly model?: string }
267
247
  readonly judge?: readonly JudgeVerdict[]
268
248
  /** Written after `Gtd-Judge`, before `Gtd-Approved`. */
@@ -290,7 +270,6 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
290
270
  ...trailer("Workflow", spec.workflow),
291
271
  ...trailer("Review-Base", spec.reviewBase),
292
272
  ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
293
- ...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
294
273
  ...trailer("Cost", costValue(spec.cost)),
295
274
  ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
296
275
  ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
@@ -56,7 +56,6 @@ const start = (
56
56
  first: startRefusal === undefined ? { firstStep: "work" } : { refusal: startRefusal },
57
57
  commandLabel: req.commandLabel,
58
58
  vars: req.vars,
59
- config: {},
60
59
  })
61
60
  }).pipe(Effect.provide(testLayers(repo))),
62
61
  )