@pmelab/gtd 60.1.0 → 61.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +1666 -1449
  6. package/dist/gtd.core.mjs +86 -86
  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/History.test.ts +10 -1
  12. package/src/cli/Metrics.test.ts +10 -1
  13. package/src/cli/Program.test.ts +30 -19
  14. package/src/cli/Program.ts +4 -1
  15. package/src/cli/Strays.ts +3 -2
  16. package/src/edge/Edge.test.ts +78 -25
  17. package/src/edge/Edge.ts +161 -28
  18. package/src/emit/GitScript.ts +21 -3
  19. package/src/emit/index.ts +3 -0
  20. package/src/gherkin/messages.test.ts +1 -0
  21. package/src/gherkin/module-config.feature +1 -0
  22. package/src/gherkin/more-steps.feature +1 -0
  23. package/src/gherkin/testkit.feature +2 -1
  24. package/src/install/Install.ts +9 -2
  25. package/src/replay/Format.test.ts +35 -8
  26. package/src/replay/Format.ts +7 -4
  27. package/src/replay/Replay.test.ts +54 -0
  28. package/src/replay/Replay.ts +61 -17
  29. package/src/replay/Trailers.test.ts +12 -12
  30. package/src/replay/Trailers.ts +35 -20
  31. package/src/replay/Tree.ts +11 -0
  32. package/src/replay/index.ts +3 -0
  33. package/src/step/LandStep.ts +1 -0
  34. package/src/step/RepoSnapshot.ts +2 -0
  35. package/src/step/planStart.test.ts +2 -2
  36. package/src/step/planStep.test.ts +4 -4
  37. package/src/step/planStep.ts +10 -8
  38. package/src/testkit/TestRepo.test.ts +4 -1
  39. package/src/testkit/TestRepo.ts +3 -1
  40. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  41. package/src/twins/GitTiers.ts +23 -0
  42. package/src/twins/InMemRepo.ts +14 -0
  43. package/src/twins/PinBeats.ts +33 -0
  44. package/src/twins/index.ts +1 -0
  45. package/src/wire/BeatDocument.ts +26 -5
  46. package/src/wire/Demand.ts +16 -6
  47. package/src/wire/constants.ts +3 -0
  48. package/src/wire/types.ts +4 -0
package/src/edge/Edge.ts CHANGED
@@ -37,18 +37,23 @@ import {
37
37
  isRecordableApproval,
38
38
  parseCommitMessage,
39
39
  replay,
40
+ type CommitMessage,
40
41
  scopeRunStart,
41
42
  treeFromRecord,
42
43
  type EpisodeCommit,
43
44
  type JudgeVerdict,
45
+ type PinChange,
46
+ PIN_STATES,
44
47
  type ReachedStep,
45
48
  type ReplayOutcome,
46
49
  type TreeView,
47
50
  } from "../replay/index.js"
48
51
  import type { Mark, ScopeAccess, SummaryContext } from "../flows/index.js"
49
52
  import { steeringFormatFor } from "../steering/index.js"
53
+ import { pinScript, unpinScript } from "../emit/index.js"
50
54
  import {
51
55
  judgeJson,
56
+ PIN_PATH,
52
57
  UNATTRIBUTED_MODEL,
53
58
  type ApprovalEntry,
54
59
  type ModelCost,
@@ -87,6 +92,16 @@ interface EpisodeLocation {
87
92
  readonly processStart: number
88
93
  }
89
94
 
95
+ const isOpening = ({ parsed, step }: CommitMessage): boolean =>
96
+ parsed !== undefined &&
97
+ step === undefined &&
98
+ parsed.from === undefined &&
99
+ parsed.actor === "human"
100
+
101
+ // A pin commit's subject may name the first step; it bounds nothing.
102
+ const boundsEpisode = (def: WorkflowDefinition, { parsed, pin }: CommitMessage): boolean =>
103
+ pin !== "add" && (parsed === undefined || !ACTORS.has(parsed.actor) || parsed.to === def.initial)
104
+
90
105
  /**
91
106
  * Where the episode HEAD belongs to begins, read off subjects alone. Walking
92
107
  * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
@@ -99,13 +114,7 @@ interface EpisodeLocation {
99
114
  const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
100
115
  for (let i = history.length - 1; i >= 0; i--) {
101
116
  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) {
117
+ if (isOpening(message)) {
109
118
  return {
110
119
  workflow: message.workflow,
111
120
  legacyOpening: message.workflow === undefined,
@@ -113,7 +122,7 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
113
122
  processStart: i,
114
123
  }
115
124
  }
116
- if (subject === undefined || !ACTORS.has(subject.actor) || subject.to === def.initial) {
125
+ if (boundsEpisode(def, message)) {
117
126
  return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
118
127
  }
119
128
  }
@@ -156,6 +165,8 @@ export interface ProcessRun {
156
165
  readonly trace: readonly TraceEntry[]
157
166
  readonly costEntries: readonly CostEntry[]
158
167
  readonly judgeVerdicts: readonly JudgeVerdictEntry[]
168
+ /** Whether a pin commit has landed in this process. */
169
+ readonly pinCommitted: boolean
159
170
  /** Every process commit's `Gtd-Mark`s, oldest first. */
160
171
  readonly marks: readonly Mark[]
161
172
  /** Oldest first. */
@@ -186,7 +197,8 @@ const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] =
186
197
  if (head === undefined) return undefined
187
198
  const message = parseCommitMessage(head.message)
188
199
  const subject = message.parsed
189
- if (subject === undefined || !ACTORS.has(subject.actor)) return undefined
200
+ if (subject === undefined || !ACTORS.has(subject.actor) || message.pin !== undefined)
201
+ return undefined
190
202
  return {
191
203
  state: subject.to,
192
204
  actor: subject.actor,
@@ -267,8 +279,11 @@ const buildRun = (
267
279
  location: EpisodeLocation,
268
280
  closingHash: string | undefined,
269
281
  ): ProcessRun => {
270
- const processCommits = history.slice(location.processStart)
271
- const parsed = processCommits.map((c) => parseCommitMessage(c.message))
282
+ const allProcessCommits = history.slice(location.processStart)
283
+ const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message))
284
+ // A pin commit is no turn; the unpin commit stays, as the one that closes the process.
285
+ const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
286
+ const parsed = allParsed.filter((m) => m.pin !== "add")
272
287
  const first = parsed[0]
273
288
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
274
289
  const head = history[history.length - 1]
@@ -281,6 +296,7 @@ const buildRun = (
281
296
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
282
297
  costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
283
298
  judgeVerdicts: parsed.flatMap((m) => m.judge),
299
+ pinCommitted: allParsed.some((m) => m.pin === "add"),
284
300
  marks: parsed.flatMap((m) => m.marks),
285
301
  approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
286
302
  pinnedVars: { ...first?.vars },
@@ -320,6 +336,8 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
320
336
  const parsed = parseCommitMessage(subject).parsed
321
337
  if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
322
338
  if (parsed.actor === "human" && parsed.from === undefined) return false
339
+ // A pin commit's subject can name the first step; it bounds nothing.
340
+ if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
323
341
  return parsed.to === def.initial
324
342
  }
325
343
 
@@ -849,6 +867,10 @@ interface ResolvedRest {
849
867
  * before any mutation.
850
868
  */
851
869
  interface Rest extends ResolvedRest {
870
+ /** Set at a pin or unpin beat; `step` is then the step the flow reached around it. */
871
+ readonly pin: PinChange | undefined
872
+ /** Whether HEAD's tree carries the pin. */
873
+ readonly pinned: boolean
852
874
  readonly step: ReachedStep
853
875
  readonly trace: readonly ReachedStep[]
854
876
  readonly run: ProcessRun
@@ -985,13 +1007,15 @@ export const restAt = (
985
1007
  // staying true; passing a pending tree here would make it serve a stale
986
1008
  // steering file on every cache hit.
987
1009
  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"))
1010
+ const pinned = headHasPin(workspace, run)
1011
+ const pin = pinBeatAt(outcome, run, def, pinned)
1012
+ const resting = restingOn(outcome, pin)
1013
+ if (resting === undefined) {
1014
+ return yield* Effect.fail(replayError(outcome) ?? new Error("gtd: replay found no rest"))
991
1015
  }
992
- const step = outcome.rest
993
- yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`)
994
- const stepDef = stepDefOf(step)
1016
+ const { reached, step } = resting
1017
+ const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
1018
+ yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
995
1019
  if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
996
1020
  return yield* Effect.fail(
997
1021
  new Error(
@@ -1006,7 +1030,6 @@ export const restAt = (
1006
1030
  path: e.path,
1007
1031
  }))
1008
1032
  : []
1009
- const memory = memoryOf(outcome.trace, run)
1010
1033
  const configNow = config.fingerprint
1011
1034
  // Only the current rest warns, so a command must read it once — a second
1012
1035
  // read warns twice. An earlier ref (restore's tip) would warn about a
@@ -1015,11 +1038,13 @@ export const restAt = (
1015
1038
  return {
1016
1039
  def,
1017
1040
  workflowName: resolved.name,
1018
- state: step.name,
1041
+ state,
1019
1042
  stepDef,
1020
- actor: step.actor,
1043
+ actor: stepDef.actor,
1044
+ pin,
1045
+ pinned,
1021
1046
  step,
1022
- trace: outcome.trace,
1047
+ trace: reached.trace,
1023
1048
  run,
1024
1049
  vars,
1025
1050
  env,
@@ -1035,6 +1060,66 @@ export const restAt = (
1035
1060
 
1036
1061
  export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
1037
1062
 
1063
+ /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
1064
+ const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
1065
+ if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
1066
+ if (outcome.kind !== "ended" || pin !== "remove") return undefined
1067
+ const step = outcome.trace.at(-1)
1068
+ return step === undefined ? undefined : { reached: outcome, step }
1069
+ }
1070
+
1071
+ /** A pin or unpin beat has its own script, no step's, and no agent memory. */
1072
+ const restFacts = (
1073
+ pin: PinChange | undefined,
1074
+ step: ReachedStep,
1075
+ trace: readonly ReachedStep[],
1076
+ run: ProcessRun,
1077
+ ) =>
1078
+ pin === undefined
1079
+ ? { stepDef: stepDefOf(step), state: step.name, memory: memoryOf(trace, run) }
1080
+ : {
1081
+ stepDef: pinStepDef(pin),
1082
+ state: PIN_STATES[pin],
1083
+ memory: { key: undefined, resumed: false },
1084
+ }
1085
+
1086
+ const headHasPin = (workspace: WorkspaceOps, run: ProcessRun): boolean => {
1087
+ const head = run.episode.commits.at(-1)?.hash ?? run.episode.base
1088
+ return head !== undefined && workspace.treeSync(head).has(PIN_PATH)
1089
+ }
1090
+
1091
+ const underwayAt = (run: ProcessRun, step: ReachedStep, def: WorkflowDefinition): boolean =>
1092
+ run.workflow !== undefined || step.name !== def.initial || step.id.occurrence !== 1
1093
+
1094
+ /**
1095
+ * A started process pins first — again, should a turn have deleted the pin;
1096
+ * a pinned flow that ended unpins before the closing commit.
1097
+ */
1098
+ const pinBeatAt = (
1099
+ outcome: ReplayOutcome,
1100
+ run: ProcessRun,
1101
+ def: WorkflowDefinition,
1102
+ pinned: boolean,
1103
+ ): PinChange | undefined => {
1104
+ if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : undefined
1105
+ if (outcome.kind !== "rest" || pinned) return undefined
1106
+ return underwayAt(run, outcome.rest, def) ? "add" : undefined
1107
+ }
1108
+
1109
+ // A default-workflow process back at the first step has ended, as a returned flow has.
1110
+ const flowEnded = (outcome: ReplayOutcome, run: ProcessRun, def: WorkflowDefinition): boolean =>
1111
+ outcome.kind === "ended" ||
1112
+ (outcome.kind === "rest" &&
1113
+ run.workflow === undefined &&
1114
+ outcome.rest.name === def.initial &&
1115
+ underwayAt(run, outcome.rest, def))
1116
+
1117
+ const pinStepDef = (pin: PinChange): StepDef => ({
1118
+ actor: "check",
1119
+ kind: "script",
1120
+ content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH),
1121
+ })
1122
+
1038
1123
  type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
1039
1124
  type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
1040
1125
 
@@ -1094,6 +1179,7 @@ export interface RenderedRest extends RestHints {
1094
1179
  readonly content: string
1095
1180
  readonly memory?: string
1096
1181
  readonly memoryResumed: boolean
1182
+ readonly pin?: PinChange
1097
1183
  }
1098
1184
 
1099
1185
  export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
@@ -1105,6 +1191,7 @@ export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
1105
1191
  ...rest.hints,
1106
1192
  ...(rest.memory !== undefined ? { memory: rest.memory } : {}),
1107
1193
  memoryResumed: rest.memoryResumed,
1194
+ ...(rest.pin !== undefined ? { pin: rest.pin } : {}),
1108
1195
  })
1109
1196
 
1110
1197
  /**
@@ -1122,9 +1209,7 @@ export const stalledAt = (rest: Rest): boolean =>
1122
1209
 
1123
1210
  /** 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
1211
  export const noProcessUnderway = (rest: Rest): boolean =>
1125
- rest.run.workflow === undefined &&
1126
- rest.state === rest.def.initial &&
1127
- rest.step.id.occurrence === 1
1212
+ rest.pin === undefined && !underwayAt(rest.run, rest.step, rest.def)
1128
1213
 
1129
1214
  /** `idle` means exactly one thing: no process underway, clean tree. */
1130
1215
  export const restIsIdle = (rest: Rest): boolean =>
@@ -1254,15 +1339,15 @@ const landingTo = (
1254
1339
  if (refused !== undefined) return refused
1255
1340
  // Landing on the first step while a process is underway ends it (flow
1256
1341
  // return and `restart()` alike); the script then points the history ref at
1257
- // that very commit.
1258
- const finishes = underway && to === rest.def.initial
1342
+ // that very commit. A pinned process ends one beat later, at the unpin.
1343
+ const { finishes, landsAt } = endingOf(rest, to, underway)
1259
1344
  return {
1260
1345
  kind: "commit",
1261
- to,
1346
+ to: landsAt,
1262
1347
  spec: {
1263
1348
  actor: rest.actor,
1264
1349
  from: rest.state,
1265
- to,
1350
+ to: landsAt,
1266
1351
  step: rest.step.id,
1267
1352
  ...(marks.length > 0 && { marks }),
1268
1353
  ...approvedTrailer(rest, approver),
@@ -1274,6 +1359,53 @@ const landingTo = (
1274
1359
  }
1275
1360
  }
1276
1361
 
1362
+ const endingOf = (rest: Rest, to: StateName, underway: boolean) => {
1363
+ const ends = underway && to === rest.def.initial
1364
+ return { finishes: ends && !rest.pinned, landsAt: ends && rest.pinned ? PIN_STATES.remove : to }
1365
+ }
1366
+
1367
+ /** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
1368
+ const pinLanding = (rest: Rest, pin: PinChange, branch: Option.Option<string>): Landing => {
1369
+ const change = rest.changes.find((c) => c.path === PIN_PATH)?.status
1370
+ return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch)
1371
+ }
1372
+
1373
+ const pinCommit = (rest: Rest, change: string | undefined): Landing => {
1374
+ if (change === undefined || change === "D") return pinRefusal("add")
1375
+ const to = rest.step.name
1376
+ return {
1377
+ kind: "commit",
1378
+ to,
1379
+ spec: { actor: rest.actor, from: rest.state, to, pin: "add" },
1380
+ only: PIN_PATH,
1381
+ }
1382
+ }
1383
+
1384
+ const unpinCommit = (
1385
+ rest: Rest,
1386
+ change: string | undefined,
1387
+ branch: Option.Option<string>,
1388
+ ): Landing => {
1389
+ if (change !== "D" && rest.pinned) return pinRefusal("remove")
1390
+ const to = rest.def.initial
1391
+ return {
1392
+ kind: "commit",
1393
+ to,
1394
+ spec: { actor: rest.actor, from: rest.state, to, pin: "remove", ...closingTrailers(branch) },
1395
+ keepHistory: processHistoryRef(rest.run.startHash),
1396
+ // A turn already deleted the pin: the closing commit has no path of its own.
1397
+ ...(rest.pinned ? { only: PIN_PATH } : {}),
1398
+ }
1399
+ }
1400
+
1401
+ const pinRefusal = (pin: PinChange): Landing => ({
1402
+ kind: "refusal",
1403
+ message:
1404
+ pin === "add"
1405
+ ? `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\``
1406
+ : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`,
1407
+ })
1408
+
1277
1409
  const startTrailers = (rest: Rest, starts: boolean) =>
1278
1410
  starts ? { vars: rest.vars, config: rest.config } : {}
1279
1411
 
@@ -1292,6 +1424,7 @@ const decideLanding = (
1292
1424
  approver: Option.Option<AuthorIdent>,
1293
1425
  ): Effect.Effect<Landing, Error> =>
1294
1426
  Effect.gen(function* () {
1427
+ if (rest.pin !== undefined) return pinLanding(rest, rest.pin, branch)
1295
1428
  const unapproved = approvalRefusal(rest, approver)
1296
1429
  if (unapproved !== undefined) return unapproved
1297
1430
  const early = cleanLanding(rest)
@@ -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
@@ -2,25 +2,31 @@ import { describe, expect, it } from "vitest"
2
2
  import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
3
3
 
4
4
  describe("the history format", () => {
5
- it("accepts a history entirely in the current format", () => {
6
- expect(formatFault([{ hash: "a".repeat(40), format: 1 }])).toBeUndefined()
5
+ it("accepts a history in the current format and in the one before it", () => {
6
+ expect(formatFault([{ hash: "a".repeat(40), format: 2 }])).toBeUndefined()
7
+ expect(
8
+ formatFault([
9
+ { hash: "a".repeat(40), format: 1 },
10
+ { hash: "b".repeat(40), format: 2 },
11
+ ]),
12
+ ).toBeUndefined()
7
13
  expect(formatFault([])).toBeUndefined()
8
14
  })
9
15
 
10
16
  it("names the first foreign commit, the found and readable formats, and what to do", () => {
11
17
  const fault = formatFault([
12
18
  { hash: "a".repeat(40), format: 1 },
13
- { hash: "b".repeat(40), format: 2 },
14
- { hash: "c".repeat(40), format: 3 },
19
+ { hash: "b".repeat(40), format: 3 },
20
+ { hash: "c".repeat(40), format: 4 },
15
21
  ])
16
22
  expect(fault).toBe(
17
- "gtd: commit bbbbbbb is written in history format 2, but this gtd reads format 1 — run a gtd release that reads format 2 to continue this process",
23
+ "gtd: commit bbbbbbb is written in history format 3, but this gtd reads formats 1 to 2 — run a gtd release that reads format 3 to continue this process",
18
24
  )
19
25
  })
20
26
  })
21
27
 
22
28
  describe("Gtd-Approved and the history format", () => {
23
- it("leaves Gtd-Format at 1 on a message carrying it", () => {
29
+ it("writes the current format on a message carrying it", () => {
24
30
  const message = formatCommitMessage({
25
31
  actor: "human",
26
32
  to: "b",
@@ -29,7 +35,28 @@ describe("Gtd-Approved and the history format", () => {
29
35
  expect(message).toContain(
30
36
  'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
31
37
  )
32
- expect(message).toContain("Gtd-Format: 1")
33
- expect(parseCommitMessage(message).format).toBe(1)
38
+ expect(message).toContain("Gtd-Format: 2")
39
+ expect(parseCommitMessage(message).format).toBe(2)
40
+ })
41
+ })
42
+
43
+ describe("Gtd-Pin", () => {
44
+ it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
45
+ const message = formatCommitMessage({
46
+ actor: "check",
47
+ from: "unpin",
48
+ to: "idle",
49
+ pin: "remove",
50
+ branch: "main",
51
+ })
52
+ expect(message).toBe(
53
+ "gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 2",
54
+ )
55
+ expect(parseCommitMessage(message).pin).toBe("remove")
56
+ expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
57
+ })
58
+
59
+ it("reads an unknown value as no pin", () => {
60
+ expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
34
61
  })
35
62
  })
@@ -1,14 +1,17 @@
1
1
  // Bump only when what replay reads back out of git changes incompatibly — the
2
2
  // package version must not stand in for it, since most majors leave history
3
- // untouched. A bump that keeps reading the previous format widens the check below.
4
- export const HISTORY_FORMAT = 1
3
+ // untouched. Format 2 adds pin commits, which format 1 would replay as turns.
4
+ export const HISTORY_FORMAT = 2
5
+ const OLDEST_READABLE_FORMAT = 1
5
6
 
6
7
  const short = (hash: string): string => hash.slice(0, 7)
7
8
 
8
9
  export const formatFault = (
9
10
  commits: readonly { readonly hash: string; readonly format: number }[],
10
11
  ): string | undefined => {
11
- const foreign = commits.find((c) => c.format !== HISTORY_FORMAT)
12
+ const foreign = commits.find(
13
+ (c) => c.format < OLDEST_READABLE_FORMAT || c.format > HISTORY_FORMAT,
14
+ )
12
15
  if (foreign === undefined) return undefined
13
- return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads format ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
16
+ return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
14
17
  }
@@ -6,6 +6,7 @@ import {
6
6
  changes,
7
7
  changesSince,
8
8
  codeThreads,
9
+ head,
9
10
  human,
10
11
  judge,
11
12
  kept,
@@ -1217,3 +1218,56 @@ describe("human({ approve })", () => {
1217
1218
  expect(outcome.kind).toBe("rest")
1218
1219
  })
1219
1220
  })
1221
+
1222
+ describe("pin commits", () => {
1223
+ const PIN = ".gtd/process.gtdpin"
1224
+ const pinned = () =>
1225
+ new History()
1226
+ .land("human", "idle", 1, "build", { "NOTE.md": "n" })
1227
+ .raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
1228
+
1229
+ it("are never a turn: the next step answers from the commit after the pin", async () => {
1230
+ const flow = async () => {
1231
+ await human("idle")
1232
+ await agent("build", "b")
1233
+ await agent("check", "c")
1234
+ }
1235
+ const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
1236
+ expect(restName(await replayOf(flow, h))).toBe("check#1")
1237
+ })
1238
+
1239
+ it("hide the pin from every tree flow code reads, so a turn's changes leave it out", async () => {
1240
+ let seen: { readonly changed: readonly string[]; readonly pin: string | undefined } | undefined
1241
+ const flow = async () => {
1242
+ await human("idle")
1243
+ await agent("build", "b")
1244
+ seen = { changed: changes().map((c) => c.path), pin: read(PIN) }
1245
+ await agent("check", "c")
1246
+ }
1247
+ const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
1248
+ await replayOf(flow, h)
1249
+ expect(seen).toEqual({ changed: ["a.ts"], pin: undefined })
1250
+ })
1251
+
1252
+ it("are never what head() names: a flow reverting the start reverts the turn", async () => {
1253
+ let named: string | undefined
1254
+ const flow = async () => {
1255
+ await human("idle")
1256
+ named = head()
1257
+ await agent("build", "b")
1258
+ }
1259
+ await replayOf(flow, pinned())
1260
+ expect(named).toBe(hashOf(1))
1261
+ })
1262
+
1263
+ it("trailing an ended flow are no divergence", async () => {
1264
+ const flow = async () => {
1265
+ await human("idle")
1266
+ await agent("build", "b")
1267
+ }
1268
+ const h = pinned()
1269
+ .land("agent", "build", 1, "unpin", { "a.ts": "a" })
1270
+ .raw("gtd(check): unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
1271
+ expect((await replayOf(flow, h)).kind).toBe("ended")
1272
+ })
1273
+ })