@pmelab/gtd 60.1.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.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +991 -627
  6. package/dist/gtd.core.mjs +117 -117
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/Cli.ts +6 -1
  12. package/src/cli/History.test.ts +10 -1
  13. package/src/cli/Launch.test.ts +52 -0
  14. package/src/cli/Launch.ts +143 -0
  15. package/src/cli/Metrics.test.ts +10 -1
  16. package/src/cli/Program.test.ts +30 -20
  17. package/src/cli/Program.ts +4 -2
  18. package/src/cli/Strays.ts +3 -2
  19. package/src/cli/index.ts +1 -0
  20. package/src/edge/Edge.test.ts +79 -26
  21. package/src/edge/Edge.ts +178 -99
  22. package/src/emit/GitScript.ts +21 -3
  23. package/src/emit/index.ts +3 -0
  24. package/src/gherkin/messages.test.ts +1 -0
  25. package/src/gherkin/module-config.feature +1 -0
  26. package/src/gherkin/more-steps.feature +1 -0
  27. package/src/gherkin/testkit.feature +2 -1
  28. package/src/install/Install.ts +9 -2
  29. package/src/platform/PinFile.ts +61 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +35 -8
  32. package/src/replay/Format.ts +7 -4
  33. package/src/replay/Replay.test.ts +54 -0
  34. package/src/replay/Replay.ts +61 -17
  35. package/src/replay/Trailers.test.ts +11 -48
  36. package/src/replay/Trailers.ts +36 -42
  37. package/src/replay/Tree.ts +11 -0
  38. package/src/replay/index.ts +3 -0
  39. package/src/step/LandStep.ts +1 -0
  40. package/src/step/RepoSnapshot.ts +2 -0
  41. package/src/step/planStart.test.ts +2 -3
  42. package/src/step/planStart.ts +1 -5
  43. package/src/step/planStep.test.ts +4 -4
  44. package/src/step/planStep.ts +10 -8
  45. package/src/testkit/TestRepo.test.ts +4 -1
  46. package/src/testkit/TestRepo.ts +3 -1
  47. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  48. package/src/twins/GitTiers.ts +23 -0
  49. package/src/twins/InMemRepo.ts +14 -0
  50. package/src/twins/PinBeats.ts +33 -0
  51. package/src/twins/PinFile.test.ts +107 -0
  52. package/src/twins/PinFile.ts +12 -0
  53. package/src/twins/index.ts +2 -0
  54. package/src/wire/BeatDocument.ts +26 -5
  55. package/src/wire/Demand.ts +16 -6
  56. package/src/wire/constants.ts +3 -0
  57. package/src/wire/types.ts +4 -0
  58. package/src/workflow/fingerprint.ts +2 -2
  59. package/src/workflow/index.ts +2 -2
  60. package/src/workflow/load.ts +14 -1
  61. package/src/workflow/pin.ts +15 -7
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,
@@ -37,18 +35,23 @@ import {
37
35
  isRecordableApproval,
38
36
  parseCommitMessage,
39
37
  replay,
38
+ type CommitMessage,
40
39
  scopeRunStart,
41
40
  treeFromRecord,
42
41
  type EpisodeCommit,
43
42
  type JudgeVerdict,
43
+ type PinChange,
44
+ PIN_STATES,
44
45
  type ReachedStep,
45
46
  type ReplayOutcome,
46
47
  type TreeView,
47
48
  } from "../replay/index.js"
48
49
  import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
49
50
  import { steeringFormatFor } from "../steering/index.js"
51
+ import { pinScript, unpinScript } from "../emit/index.js"
50
52
  import {
51
53
  judgeJson,
54
+ PIN_PATH,
52
55
  UNATTRIBUTED_MODEL,
53
56
  type ApprovalEntry,
54
57
  type ModelCost,
@@ -87,6 +90,16 @@ interface EpisodeLocation {
87
90
  readonly processStart: number
88
91
  }
89
92
 
93
+ const isOpening = ({ parsed, step }: CommitMessage): boolean =>
94
+ parsed !== undefined &&
95
+ step === undefined &&
96
+ parsed.from === undefined &&
97
+ parsed.actor === "human"
98
+
99
+ // A pin commit's subject may name the first step; it bounds nothing.
100
+ const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
101
+ pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
102
+
90
103
  /**
91
104
  * Where the episode HEAD belongs to begins, read off subjects alone. Walking
92
105
  * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
@@ -99,13 +112,7 @@ interface EpisodeLocation {
99
112
  const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
100
113
  for (let i = history.length - 1; i >= 0; i--) {
101
114
  const message = parseCommitMessage(history[i]!.message)
102
- const subject = message.parsed
103
- const opening =
104
- subject !== undefined &&
105
- message.step === undefined &&
106
- subject.from === undefined &&
107
- subject.actor === "human"
108
- if (opening) {
115
+ if (isOpening(message)) {
109
116
  return {
110
117
  workflow: message.workflow,
111
118
  legacyOpening: message.workflow === undefined,
@@ -113,7 +120,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
113
120
  processStart: i,
114
121
  }
115
122
  }
116
- if (subject === undefined || !ACTORS.has(subject.actor) || subject.to === def.initial) {
123
+ if (boundsEpisode(def, message)) {
117
124
  return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
118
125
  }
119
126
  }
@@ -156,14 +163,14 @@ export interface ProcessRun {
156
163
  readonly trace: readonly TraceEntry[]
157
164
  readonly costEntries: readonly CostEntry[]
158
165
  readonly judgeVerdicts: readonly JudgeVerdictEntry[]
166
+ /** Whether a pin commit has landed in this process. */
167
+ readonly pinCommitted: boolean
159
168
  /** Every process commit's `Gtd-Mark`s, oldest first. */
160
169
  readonly marks: readonly Mark[]
161
170
  /** Oldest first. */
162
171
  readonly approvals: readonly ApprovalEntry[]
163
172
  /** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
164
173
  readonly pinnedVars: Record<string, string>
165
- /** Absent for a process started before recording existed. */
166
- readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
167
174
  /** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
168
175
  readonly headTurn:
169
176
  | {
@@ -186,7 +193,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
186
193
  if (head === undefined) return undefined
187
194
  const message = parseCommitMessage(head.message)
188
195
  const subject = message.parsed
189
- if (subject === undefined || !ACTORS.has(subject.actor)) return undefined
196
+ if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
197
+ return undefined
190
198
  return {
191
199
  state: subject.to,
192
200
  actor: subject.actor,
@@ -236,17 +244,6 @@ const runOf = (
236
244
  : Effect.fail(new Error(fault))
237
245
  }
238
246
 
239
- // The newest recording, not the first process commit's: the episode is
240
- // located by the CURRENT config's first step, so a config that renamed it can
241
- // move the boundary onto the start commit itself, or behind it.
242
- const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
243
- for (let i = history.length - 1; i >= 0; i--) {
244
- const files = parseCommitMessage(history[i]!.message).config
245
- if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
246
- }
247
- return undefined
248
- }
249
-
250
247
  const approvalEntryOf = (
251
248
  hash: string,
252
249
  message: ReturnType<typeof parseCommitMessage>,
@@ -267,8 +264,11 @@ const buildRun = (
267
264
  location: EpisodeLocation,
268
265
  closingHash: string | undefined,
269
266
  ): ProcessRun => {
270
- const processCommits = history.slice(location.processStart)
271
- const parsed = processCommits.map((c) => parseCommitMessage(c.message))
267
+ const allProcessCommits = history.slice(location.processStart)
268
+ const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
269
+ // A pin commit is no turn; the unpin commit stays, as the one that closes the process.
270
+ const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
271
+ const parsed = allParsed.filter((m) => m.pin !== "add")
272
272
  const first = parsed[0]
273
273
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
274
274
  const head = history[history.length - 1]
@@ -281,10 +281,10 @@ const buildRun = (
281
281
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
282
282
  costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
283
283
  judgeVerdicts: parsed.flatMap((m) => m.judge),
284
+ pinCommitted: allParsed.some((m) => m.pin === "add"),
284
285
  marks: parsed.flatMap((m) => m.marks),
285
286
  approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
286
287
  pinnedVars: { ...first?.vars },
287
- pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
288
288
  headTurn: headTurnOf(head),
289
289
  closingHash,
290
290
  episode: {
@@ -320,6 +320,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
320
320
  const parsed = parseCommitMessage(subject).parsed
321
321
  if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
322
322
  if (parsed.actor === "human" && parsed.from === undefined) return false
323
+ // A pin commit's subject can name the first step; it bounds nothing.
324
+ if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
323
325
  return parsed.to === def.initial
324
326
  }
325
327
 
@@ -402,37 +404,6 @@ const computeProcessRun = (
402
404
  runOf(history, locateEpisode(def, history)),
403
405
  )
404
406
 
405
- // A file matching its copy in the recording commit is unchanged: the hashes
406
- // are taken before the commit's own hooks run, and a hook may reformat it.
407
- const changedSinceStart = (
408
- pinned: ProcessRun["pinnedConfig"],
409
- current: ConfigFingerprint,
410
- workspace: WorkspaceOps,
411
- ): readonly string[] => {
412
- if (pinned === undefined) return []
413
- return changedConfigFiles(pinned.files, current).filter((path) => {
414
- const committed = workspace.readCommittedSync(path, pinned.commit)
415
- return (
416
- current[path] === undefined ||
417
- committed === undefined ||
418
- contentHash(committed) !== current[path]
419
- )
420
- })
421
- }
422
-
423
- const warnConfigChanges = (
424
- run: ProcessRun,
425
- current: ConfigFingerprint,
426
- workspace: WorkspaceOps,
427
- ): Effect.Effect<void, never, Narrator> =>
428
- Effect.gen(function* () {
429
- const changed = changedSinceStart(run.pinnedConfig, current, workspace)
430
- if (changed.length === 0) return
431
- yield* (yield* Narrator).warn(
432
- `gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
433
- )
434
- })
435
-
436
407
  type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
437
408
 
438
409
  /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
@@ -849,13 +820,15 @@ interface ResolvedRest {
849
820
  * before any mutation.
850
821
  */
851
822
  interface Rest extends ResolvedRest {
823
+ /** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
824
+ readonly pin: PinChange | undefined
825
+ /** Whether HEAD's tree carries the pin. */
826
+ readonly pinned: boolean
852
827
  readonly step: ReachedStep
853
828
  readonly trace: readonly ReachedStep[]
854
829
  readonly run: ProcessRun
855
830
  readonly vars: Record<string, string>
856
831
  readonly env: Record<string, string>
857
- /** What a landing that starts the process records. */
858
- readonly config: ConfigFingerprint
859
832
  readonly changes: readonly PendingChange[]
860
833
  readonly memory: string | undefined
861
834
  readonly memoryResumed: boolean
@@ -985,13 +958,15 @@ export const restAt = (
985
958
  // staying true; passing a pending tree here would make it serve a stale
986
959
  // steering file on every cache hit.
987
960
  const outcome = yield* Effect.promise(() => replayFor(setup))
988
- const error = replayError(outcome)
989
- if (error !== undefined || outcome.kind !== "rest") {
990
- return yield* Effect.fail(error ?? new Error("gtd: replay found no rest"))
961
+ const pinned = headHasPin(workspace, run)
962
+ const pin = pinBeatAt(outcome, run, def, pinned)
963
+ const resting = restingOn(outcome, pin)
964
+ if (resting === undefined) {
965
+ return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
991
966
  }
992
- const step = outcome.rest
993
- yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`)
994
- const stepDef = stepDefOf(step)
967
+ const { reached, step } = resting
968
+ const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
969
+ yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
995
970
  if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
996
971
  return yield* Effect.fail(
997
972
  new Error(
@@ -1006,24 +981,26 @@ export const restAt = (
1006
981
  path: e.path,
1007
982
  }))
1008
983
  : []
1009
- const memory = memoryOf(outcome.trace, run)
1010
- const configNow = config.fingerprint
1011
- // Only the current rest warns, so a command must read it once — a second
1012
- // read warns twice. An earlier ref (restore's tip) would warn about a
1013
- // process the command is not acting on.
1014
- 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
+ )
1015
991
  return {
1016
992
  def,
1017
993
  workflowName: resolved.name,
1018
- state: step.name,
994
+ state,
1019
995
  stepDef,
1020
- actor: step.actor,
996
+ actor: stepDef.actor,
997
+ pin,
998
+ pinned,
1021
999
  step,
1022
- trace: outcome.trace,
1000
+ trace: reached.trace,
1023
1001
  run,
1024
1002
  vars,
1025
1003
  env,
1026
- config: configNow,
1027
1004
  changes,
1028
1005
  memory: memory.key,
1029
1006
  memoryResumed: memory.resumed,
@@ -1035,6 +1012,66 @@ export const restAt = (
1035
1012
 
1036
1013
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1037
1014
 
1015
+ /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
1016
+ const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
1017
+ if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
1018
+ if (outcome.kind !== "ended" || pin !== "remove") return undefined
1019
+ const step = outcome.trace.at(-1)
1020
+ return step === undefined ? undefined : { reached: outcome, step }
1021
+ }
1022
+
1023
+ /** A pin or unpin beat has its own script, no step's, and no agent memory. */
1024
+ const restFacts = (
1025
+ pin: PinChange | undefined,
1026
+ step: ReachedStep,
1027
+ trace: readonly ReachedStep[],
1028
+ run: ProcessRun,
1029
+ ) =>
1030
+ pin === undefined
1031
+ ? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
1032
+ : {
1033
+ stepDef: pinStepDef(pin),
1034
+ state: PIN_STATES[pin],
1035
+ memory: { key: undefined, resumed: false },
1036
+ }
1037
+
1038
+ const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
1039
+ const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
1040
+ return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
1041
+ }
1042
+
1043
+ const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
1044
+ run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
1045
+
1046
+ /**
1047
+ * A started process pins first — again, should a turn have deleted the pin;
1048
+ * a pinned flow that ended unpins before the closing commit.
1049
+ */
1050
+ const pinBeatAt = (
1051
+ outcome: ReplayOutcome,
1052
+ run: ProcessRun,
1053
+ def: WorkflowDefinition,
1054
+ pinned: boolean,
1055
+ ): PinChange | undefined => {
1056
+ if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
1057
+ if (outcome.kind !== "rest" || pinned) return undefined
1058
+ return underwayAt(run, outcome.rest, def) ? "add" : undefined
1059
+ }
1060
+
1061
+ // A default-workflow process back at the first step has ended, as a returned flow has.
1062
+ const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
1063
+ outcome.kind === "ended" ||
1064
+ (outcome.kind === "rest" &&
1065
+ run.workflow === undefined &&
1066
+ outcome.rest.name === def.initial &&
1067
+ underwayAt(run, outcome.rest, def))
1068
+
1069
+ const pinStepDef = (pin: PinChange): StepDef => ({
1070
+ actor: "check",
1071
+ kind: "script",
1072
+ content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
1073
+ })
1074
+
1038
1075
  type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1039
1076
  type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1040
1077
 
@@ -1094,6 +1131,7 @@ export interface RenderedRest extends RestHints {
1094
1131
  readonly content: string
1095
1132
  readonly memory?: string
1096
1133
  readonly memoryResumed: boolean
1134
+ readonly pin?: PinChange
1097
1135
  }
1098
1136
 
1099
1137
  export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
@@ -1105,6 +1143,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1105
1143
  ...rest.hints,
1106
1144
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1107
1145
  memoryResumed: rest.memoryResumed,
1146
+ ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
1108
1147
  })
1109
1148
 
1110
1149
  /**
@@ -1122,9 +1161,7 @@ export const stalledAt = (rest: Rest): boolean =>
1122
1161
 
1123
1162
  /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
1124
1163
  export const noProcessUnderway = (rest: Rest): boolean =>
1125
- rest.run.workflow === undefined &&
1126
- rest.state === rest.def.initial &&
1127
- rest.step.id.occurrence === 1
1164
+ rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
1128
1165
 
1129
1166
  /** `idle` means exactly one thing: no process underway, clean tree. */
1130
1167
  export const restIsIdle = (rest: Rest): boolean =>
@@ -1254,15 +1291,15 @@ const landingTo = (
1254
1291
  if (refused !== undefined) return refused
1255
1292
  // Landing on the first step while a process is underway ends it (flow
1256
1293
  // return and `restart()` alike); the script then points the history ref at
1257
- // that very commit.
1258
- const finishes = underway && to === rest.def.initial
1294
+ // that very commit. A pinned process ends one beat later, at the unpin.
1295
+ const { finishes, landsAt } = endingOf(rest, to, underway)
1259
1296
  return {
1260
1297
  kind: "commit",
1261
- to,
1298
+ to: landsAt,
1262
1299
  spec: {
1263
1300
  actor: rest.actor,
1264
1301
  from: rest.state,
1265
- to,
1302
+ to: landsAt,
1266
1303
  step: rest.step.id,
1267
1304
  ...(marks.length > 0 && { marks }),
1268
1305
  ...approvedTrailer(rest, approver),
@@ -1274,8 +1311,54 @@ const landingTo = (
1274
1311
  }
1275
1312
  }
1276
1313
 
1277
- const startTrailers = (rest: Rest, starts: boolean) =>
1278
- starts ? { vars: rest.vars, config: rest.config } : {}
1314
+ const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
1315
+ const ends = underway && to === rest.def.initial
1316
+ return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
1317
+ }
1318
+
1319
+ /** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
1320
+ const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
1321
+ const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
1322
+ return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
1323
+ }
1324
+
1325
+ const pinCommit = (rest: Rest, change: string | undefined): Landing => {
1326
+ if (change === undefined || change === "D") return pinRefusal("add")
1327
+ const to = rest.step.name
1328
+ return {
1329
+ kind: "commit",
1330
+ to,
1331
+ spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
1332
+ only: PIN_PATH,
1333
+ }
1334
+ }
1335
+
1336
+ const unpinCommit = (
1337
+ rest: Rest,
1338
+ change: string | undefined,
1339
+ branch: Option.Option<string>,
1340
+ ): Landing => {
1341
+ if (change !== "D" && rest.pinned) return pinRefusal("remove")
1342
+ const to = rest.def.initial
1343
+ return {
1344
+ kind: "commit",
1345
+ to,
1346
+ spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
1347
+ keepHistory: processHistoryRef(rest.run.startHash),
1348
+ // A turn already deleted the pin: the closing commit has no path of its own.
1349
+ ...(rest.pinned ? { only: PIN_PATH } : {}),
1350
+ }
1351
+ }
1352
+
1353
+ const pinRefusal = (pin: PinChange): Landing => ({
1354
+ kind: "refusal",
1355
+ message:
1356
+ pin === "add"
1357
+ ? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\``
1358
+ : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
1359
+ })
1360
+
1361
+ const startTrailers = (rest: Rest, starts: boolean) => (starts ? { vars: rest.vars } : {})
1279
1362
 
1280
1363
  const outcomeMarks = (outcome: ReplayOutcome): readonly Mark[] =>
1281
1364
  outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : []
@@ -1292,6 +1375,7 @@ const decideLanding = (
1292
1375
  approver: Option.Option<AuthorIdent>,
1293
1376
  ): Effect.Effect<Landing, Error> =>
1294
1377
  Effect.gen(function* () {
1378
+ if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
1295
1379
  const unapproved = approvalRefusal(rest, approver)
1296
1380
  if (unapproved !== undefined) return unapproved
1297
1381
  const early = cleanLanding(rest)
@@ -1410,13 +1494,13 @@ export interface HistoryTrail {
1410
1494
  readonly versionWarning?: string
1411
1495
  }
1412
1496
 
1413
- /** 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. */
1414
- const inRepoConfigPaths = (
1415
- root: string,
1416
- files: readonly string[],
1417
- recorded: ConfigFingerprint,
1418
- ): readonly string[] => {
1419
- 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>()
1420
1504
  for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1421
1505
  return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1422
1506
  }
@@ -1436,16 +1520,11 @@ const differingConfigPaths = (
1436
1520
  workspace: Workspace["Service"],
1437
1521
  root: string,
1438
1522
  config: { readonly fingerprint: ConfigFingerprint },
1439
- run: ProcessRun,
1440
1523
  hash: string,
1441
1524
  ) =>
1442
1525
  Effect.gen(function* () {
1443
1526
  const differing: string[] = []
1444
- for (const path of inRepoConfigPaths(
1445
- root,
1446
- Object.keys(config.fingerprint),
1447
- run.pinnedConfig?.files ?? {},
1448
- )) {
1527
+ for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
1449
1528
  const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1450
1529
  if (atTip !== workspace.readSync(path)) differing.push(path)
1451
1530
  }
@@ -1531,7 +1610,7 @@ const replayFinishedProcess = (
1531
1610
  // A refusal is often caused by the version change, so it carries the warning.
1532
1611
  const withWarning = (err: Error): Error =>
1533
1612
  versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
1534
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1613
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash)
1535
1614
  if (differing.length > 0)
1536
1615
  return yield* Effect.fail(
1537
1616
  withWarning(configRefusal(how.command, hash, differing, how.configReason)),
@@ -12,12 +12,13 @@ export const pathspec = (paths: ReadonlyArray<string>): string => paths.map(shel
12
12
  * output rather than the exit code, so a genuinely rejected commit (a lint
13
13
  * error, a commit-msg hook) still fails.
14
14
  */
15
- const commitAllowEmpty = (message: string): string => {
15
+ const commitAllowEmpty = (message: string, only?: string): string => {
16
16
  const m = shellQuote(message)
17
+ const paths = only === undefined ? "" : ` -- ${shellQuote(only)}`
17
18
  return [
18
- `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
19
+ `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
19
20
  ` case "$out" in`,
20
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
21
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
21
22
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
22
23
  ` esac`,
23
24
  `fi`,
@@ -31,6 +32,21 @@ export const CHANGED_SINCE_LAND =
31
32
  export const commitAll = (message: string): string =>
32
33
  `git add -A || exit 1\n${commitAllowEmpty(message)}`
33
34
 
35
+ /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
36
+ export const commitPath = (path: string, message: string): string =>
37
+ `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`
38
+
39
+ /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
40
+ export const pinScript = (path: string): string => {
41
+ const p = shellQuote(path)
42
+ const slash = path.lastIndexOf("/")
43
+ const mkdir = slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`
44
+ return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
45
+ }
46
+
47
+ /** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
48
+ export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
49
+
34
50
  /**
35
51
  * An approval records exactly the judged tree: HEAD is pinned before the
36
52
  * clean check and the commit is built over its tree with `commit-tree`, so no
@@ -151,6 +167,8 @@ const renderGitWrite = (write: GitWrite): string => {
151
167
  switch (write.kind) {
152
168
  case "commitAll":
153
169
  return commitAll(write.message)
170
+ case "commitPath":
171
+ return commitPath(write.path, write.message)
154
172
  case "commitClean":
155
173
  return commitClean(write.message)
156
174
  case "updateRef":
package/src/emit/index.ts CHANGED
@@ -16,6 +16,9 @@ export {
16
16
  commitAll,
17
17
  commitAsIs,
18
18
  commitClean,
19
+ commitPath,
20
+ pinScript,
21
+ unpinScript,
19
22
  CHANGED_SINCE_LAND,
20
23
  softResetTo,
21
24
  mixedResetTo,
@@ -45,6 +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
49
  ' actors never used: "repair" (scenario), "ready" (Background)',
49
50
  ].join("\n"),
50
51
  )
@@ -20,5 +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
24
  """
24
25
 
@@ -23,6 +23,7 @@ Feature: steps for checks, stalls, beat fields, commit bodies and the environmen
23
23
 
24
24
  Scenario: the resting step's fields are read by name
25
25
  When gtd starts workflow "tested"
26
+ And the process is driven
26
27
  Then the process rests at "build.plan"
27
28
  And the resting step's model is "smart"
28
29
  And the resting step's idle is "false"
@@ -43,6 +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
47
  gtd(agent): plan → check
47
48
  gtd(check): check → ready
48
49
  gtd(judge): ready → ship
@@ -106,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
106
107
  When gtd starts workflow "tiny"
107
108
  And the process is driven
108
109
  Then the process rests at "plan"
109
- And the last commit subject is "gtd(human): plan"
110
+ And the last commit subject is "gtd(check): pin → plan"
110
111
 
111
112
  Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
112
113
  Given step "check" is prompted with "npm test"
@@ -240,7 +240,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
240
240
  Land it immediately, no display needed.
241
241
  - \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
242
242
  \`--json=log\`, and ignore its exit code — the outcome lives in the tree,
243
- not in the exit code.
243
+ not in the exit code. Two script beats carry a \`pin\` mark: the **pin
244
+ beat** right after a process starts (\`pin: "add"\`, its script writes
245
+ \`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
246
+ once the flow has ended (\`pin: "remove"\`, its script deletes that file).
247
+ Both run unattended, like any script beat. A driver may write or delete the
248
+ file its own way instead of running the script; either way it then lands,
249
+ and the unpin's landing is the commit that closes the process.
244
250
  - \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
245
251
  stdin (it's the bare content at this kind, so no separate \`--json=content\`
246
252
  read is needed), using the accompanying \`session.id\`/\`session.resume\`/
@@ -284,7 +290,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
284
290
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
285
291
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
286
292
  \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
287
- \`{step, name, email, commit}\`, \`[]\` when none).
293
+ \`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
294
+ beat: \`pin\` (\`"add"\` or \`"remove"\`).
288
295
 
289
296
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
290
297
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to