@pmelab/gtd 61.0.0 → 62.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.
@@ -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-CPzWnl3P.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-CPzWnl3P.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.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": {
@@ -114,6 +114,7 @@ describe("parseArgv — scope", () => {
114
114
  ["validate", "--json"],
115
115
  ["abandon", "--json"],
116
116
  ["restore", "--json"],
117
+ ["repin", "--json"],
117
118
  ["start", "--json"],
118
119
  ["start", "fix", "--json"],
119
120
  ["history", "fetch", "--json"],
@@ -1116,6 +1117,7 @@ describe("standaloneKinds / needsOf", () => {
1116
1117
  "start",
1117
1118
  "abandon",
1118
1119
  "restore",
1120
+ "repin",
1119
1121
  "next",
1120
1122
  "validate",
1121
1123
  "ui",
package/src/cli/Cli.ts CHANGED
@@ -131,6 +131,7 @@ const SCRIPT_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
131
131
  "start",
132
132
  "abandon",
133
133
  "restore",
134
+ "repin",
134
135
  "validate",
135
136
  "historyFetch",
136
137
  ])
@@ -448,6 +449,16 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
448
449
  summary: "Bring back the last abandoned process",
449
450
  details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
450
451
  },
452
+ {
453
+ token: "repin",
454
+ kind: "repin",
455
+ arity: "none",
456
+ group: "Start and stop",
457
+ summary: "Pin the process to the live gtd and workflow",
458
+ details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. Refuses when no process is underway, when the next beat is a pin or unpin beat already, or when the live workflow does not replay the process.
459
+
460
+ s=$(gtd repin --json=script) && sh -c "$s"`,
461
+ },
451
462
  {
452
463
  token: "summary",
453
464
  kind: "summary",
@@ -632,6 +643,7 @@ const flagHeader = (row: FlagRow): string => {
632
643
 
633
644
  const GLOBAL_OPTIONS: readonly (readonly [string, string])[] = [
634
645
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
646
+ ["--live", "Run this gtd, not the process's pin; it may misread the process"],
635
647
  ["-h, --help", "Print help; gtd <command> --help for one command"],
636
648
  ["-V, --version", "Print the version"],
637
649
  ["--", "End the options: every word after it is an argument"],
@@ -643,7 +655,11 @@ const EXTRA_TOPICS: readonly (readonly [string, string, string])[] = [
643
655
  "Print help for gtd or one command",
644
656
  "Print gtd's overview, or with <command> that command's own help.",
645
657
  ],
646
- ["version", "Print the version", "Print gtd's version."],
658
+ [
659
+ "version",
660
+ "Print the version",
661
+ "Print gtd's version, and the version of a pinned process's gtd.",
662
+ ],
647
663
  ]
648
664
 
649
665
  const EXAMPLES = ` $ gtd start list what can start
@@ -1301,6 +1317,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1301
1317
  | "lsp"
1302
1318
  | "abandon"
1303
1319
  | "restore"
1320
+ | "repin"
1304
1321
  | "next"
1305
1322
  | "validate"
1306
1323
  | "guide"
@@ -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,144 @@
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
+ "repin",
15
+ "historyList",
16
+ "historyShow",
17
+ "historyMetrics",
18
+ "historyJudges",
19
+ "historyFetch",
20
+ ])
21
+
22
+ export type Launch =
23
+ | { readonly kind: "live"; readonly argv: readonly string[] }
24
+ | {
25
+ readonly kind: "pin"
26
+ readonly argv: readonly string[]
27
+ readonly path: string
28
+ readonly version: string
29
+ /** Unpacked only when run: its failure is the hand-over's. */
30
+ readonly source: () => string
31
+ }
32
+ | { readonly kind: "output"; readonly stdout: string }
33
+ | { readonly kind: "refuse"; readonly message: string }
34
+
35
+ const LIVE_FLAG = "--live"
36
+
37
+ // Only before `--`: after it, `--live` is an argument.
38
+ const optionWords = (argv: readonly string[]): readonly string[] => {
39
+ const end = argv.indexOf("--", 2)
40
+ return argv.slice(2, end < 0 ? undefined : end)
41
+ }
42
+
43
+ const withoutLiveFlag = (argv: readonly string[]): readonly string[] => {
44
+ const end = argv.indexOf("--", 2)
45
+ return argv.filter((word, i) => word !== LIVE_FLAG || i < 2 || (end >= 0 && i > end))
46
+ }
47
+
48
+ /**
49
+ * Whether this gtd runs the command or hands it to the process's pin. A pin
50
+ * this gtd cannot run is refused, never guessed at; a usage error the live
51
+ * parser finds is still the pin's to judge, since its engine may read
52
+ * another CLI.
53
+ */
54
+ export const launchFor = (argv: readonly string[], pinFile: PinFile): Launch => {
55
+ // The engine a pin carries is this same code: once inside one, never hand over again.
56
+ if ((globalThis as { [PIN_KEY]?: unknown })[PIN_KEY] !== undefined) return { kind: "live", argv }
57
+ const live = optionWords(argv).includes(LIVE_FLAG)
58
+ const rest = withoutLiveFlag(argv)
59
+ if (live) return { kind: "live", argv: rest }
60
+ const plan = parseArgv(rest)
61
+ const version = plan.kind === "output" && plan.stdout === `${GTD_VERSION}\n`
62
+ if (plan.kind === "output" && !version) return { kind: "live", argv: rest }
63
+ if (plan.kind === "command" && LIVE_KINDS.has(plan.command.kind))
64
+ return { kind: "live", argv: rest }
65
+ const found = pinFile()
66
+ if (found === undefined) return { kind: "live", argv: rest }
67
+ const header = pinHeader(found.bytes)
68
+ if (version)
69
+ return {
70
+ kind: "output",
71
+ stdout:
72
+ header === undefined
73
+ ? plan.stdout
74
+ : `${GTD_VERSION} (this process is pinned to gtd ${header.version})\n`,
75
+ }
76
+ if (header === undefined || header.format !== PIN_FORMAT) {
77
+ const what =
78
+ header === undefined
79
+ ? "is no pin this gtd can read"
80
+ : `is pin format ${header.format}, made by gtd ${header.version}`
81
+ return {
82
+ kind: "refuse",
83
+ 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)`,
84
+ }
85
+ }
86
+ return {
87
+ kind: "pin",
88
+ argv: rest,
89
+ path: found.path,
90
+ version: header.version,
91
+ source: () => readPin(found.bytes).source,
92
+ }
93
+ }
94
+
95
+ export interface Launcher {
96
+ readonly stdout: (chunk: string) => void
97
+ readonly stderr: (chunk: string) => void
98
+ readonly exit: (code: number) => void
99
+ readonly live: (argv: readonly string[]) => void | Promise<void>
100
+ readonly pin: (launch: Extract<Launch, { kind: "pin" }>) => Promise<void>
101
+ }
102
+
103
+ const isJsonFlag = (word: string): boolean => word === "--json" || word.startsWith("--json=")
104
+
105
+ // The CLI's own error envelope too, which a `--json` driver reads.
106
+ const refusalText = (argv: readonly string[], message: string): string =>
107
+ (optionWords(argv).some(isJsonFlag)
108
+ ? `${JSON.stringify({ state: "error", prompt: message })}\n`
109
+ : "") + `${message}\n`
110
+
111
+ const runPin = async (
112
+ launch: Extract<Launch, { kind: "pin" }>,
113
+ launcher: Launcher,
114
+ ): Promise<void> => {
115
+ const words = optionWords(launch.argv)
116
+ if (words.includes("--verbose") || words.includes("-v"))
117
+ launcher.stderr(`launcher: running the pin ${launch.path} (gtd ${launch.version})\n`)
118
+ try {
119
+ await launcher.pin(launch)
120
+ } catch (error) {
121
+ const reason = error instanceof Error ? error.message : String(error)
122
+ launcher.stderr(`gtd: could not run the pin ${launch.path}: ${reason}\n`)
123
+ launcher.exit(EXIT_RUNTIME_ERROR)
124
+ }
125
+ }
126
+
127
+ export const runLauncher = async (
128
+ argv: readonly string[],
129
+ pinFile: PinFile,
130
+ launcher: Launcher,
131
+ ): Promise<void> => {
132
+ const launch = launchFor(argv, pinFile)
133
+ switch (launch.kind) {
134
+ case "live":
135
+ return launcher.live(launch.argv)
136
+ case "output":
137
+ return launcher.stdout(launch.stdout)
138
+ case "refuse":
139
+ launcher.stderr(refusalText(argv, launch.message))
140
+ return launcher.exit(EXIT_RUNTIME_ERROR)
141
+ case "pin":
142
+ return runPin(launch, launcher)
143
+ }
144
+ }
@@ -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)
@@ -1718,6 +1717,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1718
1717
  start: { kind: "start", name: "fix", args: [], vars: {} },
1719
1718
  abandon: { kind: "abandon" },
1720
1719
  restore: { kind: "restore" },
1720
+ repin: { kind: "repin" },
1721
1721
  next: { kind: "next" },
1722
1722
  validate: { kind: "validate" },
1723
1723
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
@@ -1744,7 +1744,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1744
1744
  const NO_COMMITS_MESSAGE =
1745
1745
  "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1746
1746
 
1747
- it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1747
+ it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1748
1748
  expect(stateKinds.sort()).toEqual(
1749
1749
  [
1750
1750
  "abandon",
@@ -1754,6 +1754,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1754
1754
  "land",
1755
1755
  "next",
1756
1756
  "restore",
1757
+ "repin",
1757
1758
  "summary",
1758
1759
  "validate",
1759
1760
  "ui",
@@ -65,6 +65,7 @@ import {
65
65
  summaryRun,
66
66
  type RenderedRest,
67
67
  } from "../edge/index.js"
68
+ import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
68
69
  import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
69
70
  import {
70
71
  BUILT_IN_MODE_NAMES,
@@ -82,6 +83,9 @@ import {
82
83
  hardResetTo,
83
84
  mixedResetTo,
84
85
  unpinScript,
86
+ repinScript,
87
+ commitPath,
88
+ transitionOutcome,
85
89
  ScriptSurface,
86
90
  updateRef,
87
91
  type RunnableScript,
@@ -154,6 +158,7 @@ export type Command =
154
158
  }
155
159
  | { readonly kind: "abandon" }
156
160
  | { readonly kind: "restore" }
161
+ | { readonly kind: "repin" }
157
162
  | { readonly kind: "next" }
158
163
  | { readonly kind: "validate" }
159
164
  | {
@@ -619,7 +624,6 @@ const startWorkflow = (
619
624
  first: started,
620
625
  commandLabel,
621
626
  vars: varOverrides,
622
- config: rest.config,
623
627
  },
624
628
  )
625
629
  if (plan.kind === "refusal") {
@@ -863,6 +867,40 @@ const runRestoreCommand = (
863
867
  yield* writeScript(json, out, combinedScript(required, ""))
864
868
  })
865
869
 
870
+ /**
871
+ * `gtd repin`: the pin beat's script and landing, on demand. It runs live,
872
+ * so reading the rest first refuses a live workflow that cannot replay the
873
+ * process before any pin is written.
874
+ */
875
+ const runRepinCommand = (
876
+ json: JsonMode,
877
+ out: ArtifactOut,
878
+ ): Effect.Effect<void, Error, CommandRequirements> =>
879
+ Effect.gen(function* () {
880
+ const rest = yield* currentRest
881
+ const refusal =
882
+ rest.pin === "add"
883
+ ? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
884
+ : rest.pin === "remove"
885
+ ? "the flow has ended — its unpin beat is next, so there is nothing to repin"
886
+ : noProcessUnderway(rest)
887
+ ? "no gtd process is underway — nothing to repin"
888
+ : undefined
889
+ if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
890
+ const message = formatCommitMessage({
891
+ actor: "check",
892
+ from: PIN_STATES.add,
893
+ to: rest.step.name,
894
+ pin: "add",
895
+ })
896
+ const steps: EmitStep[] = [
897
+ { kind: "gitWrite", command: repinScript(PIN_PATH) },
898
+ { kind: "gitWrite", command: commitPath(PIN_PATH, message) },
899
+ { kind: "outcome", command: transitionOutcome(PIN_STATES.add, rest.step.name) },
900
+ ]
901
+ yield* writeScript(json, out, combinedScript(emitScripts(steps).required, ""))
902
+ })
903
+
866
904
  /**
867
905
  * The mode's own validate command for `file`: a declared `validate:` command
868
906
  * verbatim; a `"builtin"` validator or no mode at all names the leaf
@@ -1340,6 +1378,8 @@ const dispatchVoidCommand = (
1340
1378
  return runAbandonCommand(json, out)
1341
1379
  case "restore":
1342
1380
  return runRestoreCommand(json, out)
1381
+ case "repin":
1382
+ return runRepinCommand(json, out)
1343
1383
  case "next":
1344
1384
  return runNextCommand(json, out)
1345
1385
  case "validate":
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)),
@@ -44,6 +44,13 @@ export const pinScript = (path: string): string => {
44
44
  return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
45
45
  }
46
46
 
47
+ /** `gtd repin`'s pin write: a failed `gtd bundle` leaves the committed pin in place. */
48
+ export const repinScript = (path: string): string => {
49
+ const p = shellQuote(path)
50
+ const next = shellQuote(`${path}.new`)
51
+ return `gtd bundle >${next} || { rm -f -- ${next}; exit 1; }\nmv -f -- ${next} ${p}`
52
+ }
53
+
47
54
  /** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
48
55
  export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
49
56
 
package/src/emit/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  commitClean,
19
19
  commitPath,
20
20
  pinScript,
21
+ repinScript,
21
22
  unpinScript,
22
23
  CHANGED_SINCE_LAND,
23
24
  softResetTo,