@pmelab/gtd 63.0.0 → 64.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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -11
  3. package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
  4. package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
  5. package/dist/chunks/{steering-DF_BMadH.mjs → steering-Cem5i6Xl.mjs} +422 -2
  6. package/dist/chunks/{testkit-DwypA7cH.mjs → testkit-DfWZQCWQ.mjs} +1073 -328
  7. package/dist/flows.mjs +1 -1
  8. package/dist/gtd.bundle.mjs +4372 -1310
  9. package/dist/gtd.core.mjs +126 -118
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +2 -2
  12. package/dist/testkit.mjs +2 -2
  13. package/dist/workflow-kit.mjs +2 -2
  14. package/package.json +1 -1
  15. package/src/cli/Cli.test.ts +5 -8
  16. package/src/cli/Cli.ts +22 -28
  17. package/src/cli/Output.ts +2 -1
  18. package/src/cli/Program.test.ts +55 -53
  19. package/src/cli/Program.ts +34 -34
  20. package/src/cli/ToolCommands.ts +3 -4
  21. package/src/edge/Edge.test.ts +38 -12
  22. package/src/edge/Edge.ts +53 -19
  23. package/src/emit/SteeringMode.test.ts +4 -2
  24. package/src/emit/SteeringMode.ts +31 -10
  25. package/src/flows/index.ts +1 -0
  26. package/src/flows/runtime.test.ts +2 -1
  27. package/src/flows/runtime.ts +8 -2
  28. package/src/gherkin/messages.test.ts +1 -1
  29. package/src/gherkin/module-config.feature +1 -1
  30. package/src/gherkin/testkit.feature +2 -2
  31. package/src/install/Version.ts +14 -13
  32. package/src/platform/Host.ts +75 -22
  33. package/src/platform/NodePinFile.ts +44 -0
  34. package/src/platform/NodeRuntime.ts +19 -0
  35. package/src/platform/NodeSync.ts +64 -0
  36. package/src/platform/PinFile.ts +0 -50
  37. package/src/platform/Sha.ts +121 -0
  38. package/src/platform/SyncIo.ts +35 -0
  39. package/src/platform/Workspace.test.ts +8 -3
  40. package/src/platform/Workspace.ts +82 -123
  41. package/src/platform/WorktreeState.test.ts +8 -4
  42. package/src/platform/WorktreeState.ts +21 -19
  43. package/src/platform/index.ts +18 -5
  44. package/src/replay/Format.test.ts +16 -13
  45. package/src/replay/Format.ts +4 -2
  46. package/src/replay/Replay.test.ts +25 -5
  47. package/src/replay/Replay.ts +10 -6
  48. package/src/replay/Trailers.test.ts +10 -10
  49. package/src/replay/Trailers.ts +23 -1
  50. package/src/replay/index.ts +1 -0
  51. package/src/steering/Declared.test.ts +218 -0
  52. package/src/steering/Declared.ts +607 -0
  53. package/src/steering/FORMATS.md +3 -0
  54. package/src/steering/index.ts +12 -0
  55. package/src/steering/internal.ts +1 -0
  56. package/src/step/planStart.test.ts +2 -2
  57. package/src/step/planStep.test.ts +4 -4
  58. package/src/testkit/TestRepo.test.ts +1 -1
  59. package/src/testkit/TestRepo.ts +4 -18
  60. package/src/twins/EmittedScriptRecognizer.ts +5 -3
  61. package/src/twins/EmittedScripts.test.ts +19 -0
  62. package/src/twins/Host.test.ts +151 -0
  63. package/src/twins/Layers.test.ts +2 -2
  64. package/src/twins/Layers.ts +44 -18
  65. package/src/twins/Path.test.ts +52 -0
  66. package/src/twins/SyncIo.test.ts +153 -0
  67. package/src/twins/SyncIo.ts +159 -0
  68. package/src/twins/cliIo.ts +8 -1
  69. package/src/twins/index.ts +5 -0
  70. package/src/workflow/SteeringFormats.ts +4 -0
  71. package/src/workflow/Workflow.ts +2 -0
  72. package/src/workflow/discovery.test.ts +6 -5
  73. package/src/workflow/discovery.ts +43 -32
  74. package/src/workflow/fingerprint.test.ts +23 -3
  75. package/src/workflow/fingerprint.ts +9 -11
  76. package/src/workflow/index.ts +5 -1
  77. package/src/workflow/load.test.ts +16 -6
  78. package/src/workflow/load.ts +107 -69
  79. package/src/workflow/pin.test.ts +3 -2
  80. package/src/workflow/pin.ts +17 -11
@@ -1,4 +1,4 @@
1
- import { Effect, Option, Schema } from "effect"
1
+ import { Effect, Option, type Path, Schema } from "effect"
2
2
  import {
3
3
  GtdError,
4
4
  GtdUsageError,
@@ -70,6 +70,7 @@ import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
70
70
  import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
71
71
  import {
72
72
  BUILT_IN_MODE_NAMES,
73
+ formatFromSpec,
73
74
  steeringFormatFor,
74
75
  openThreads,
75
76
  parseCodeThreads,
@@ -99,7 +100,7 @@ import {
99
100
  restoredOutcome,
100
101
  } from "../emit/index.js"
101
102
  import { GTD_VERSION, renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
102
- import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
103
+ import { ToolCommands } from "./ToolCommands.js"
103
104
  import {
104
105
  normalizeScriptNewline,
105
106
  SelectorUsageError,
@@ -169,6 +170,8 @@ export type Command =
169
170
  readonly file?: string
170
171
  readonly openQuestions?: boolean
171
172
  readonly openThreads?: boolean
173
+ /** A declared format's spec: `mode` names it, and no built-in is looked up. */
174
+ readonly spec?: string
172
175
  }
173
176
  | { readonly kind: "uncheck"; readonly file: string }
174
177
  | { readonly kind: "guide" }
@@ -210,14 +213,12 @@ export type Command =
210
213
  */
211
214
  export type Needs = "pure" | "removed" | "none" | "fs" | "state"
212
215
 
213
- const selfGtd = (): SelfGtd => {
214
- const script = process.argv[1]
215
- return script === undefined ? ["gtd"] : [process.execPath, script]
216
- }
217
-
218
216
  /** `needs: "none"` skips the repo-root guard — the server is keyed on file name, not workflow state. */
219
- const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
220
- Effect.flatMap(ToolCommands, (tools) => tools.lsp(selfGtd()))
217
+ const runLspCommand = (): Effect.Effect<void, Error, ToolCommands | Host> =>
218
+ Effect.gen(function* () {
219
+ const { self } = yield* Host
220
+ return yield* (yield* ToolCommands).lsp(self)
221
+ })
221
222
 
222
223
  /**
223
224
  * Prints the briefing, emitted by the binary itself so it's always current.
@@ -246,7 +247,7 @@ const runBundleCommand = (
246
247
  ): Effect.Effect<
247
248
  void,
248
249
  Error,
249
- ConfigService | ConfigDiscovery | EngineCore | Host | Narrator | Workspace
250
+ ConfigService | ConfigDiscovery | EngineCore | Host | Path.Path | Narrator | Workspace
250
251
  > =>
251
252
  Effect.gen(function* () {
252
253
  yield* (yield* ConfigService).load
@@ -430,23 +431,6 @@ const parseJudgeDocument = (rendered: string): Effect.Effect<JudgeDocument, Erro
430
431
  ),
431
432
  })
432
433
 
433
- /**
434
- * The first stdin-consuming command outside `src/lsp/Lsp.ts` (which reads LSP's
435
- * own length-prefixed frames off `process.stdin`, not a one-shot read) —
436
- * reads stdin to completion and decodes it as UTF-8 text, no framing.
437
- */
438
- const readStdin = (): Effect.Effect<string, Error> =>
439
- Effect.tryPromise({
440
- try: async () => {
441
- const chunks: Buffer[] = []
442
- for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
443
- chunks.push(chunk)
444
- }
445
- return Buffer.concat(chunks).toString("utf8")
446
- },
447
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
448
- })
449
-
450
434
  /** A stdin that is no judgment is the caller's mistake, a usage error like `gtd judge answer`'s. */
451
435
  const judgmentShape = (input: string): Effect.Effect<void, GtdUsageError> => {
452
436
  let doc: unknown
@@ -470,7 +454,7 @@ const runJudgeRunCommand = (
470
454
  const host = yield* Host
471
455
  const tools = yield* ToolCommands
472
456
  yield* refuseLegacyRc
473
- const input = yield* readStdin()
457
+ const input = yield* host.stdin
474
458
  yield* judgmentShape(input)
475
459
  const verdict = yield* Effect.tryPromise({
476
460
  try: () =>
@@ -535,7 +519,7 @@ const runJudgeAnswerCommand = (
535
519
  const document = yield* parseJudgeDocument(rendered.judge)
536
520
  const ids = document.questions.map((q) => q.id)
537
521
 
538
- const raw = yield* readStdin()
522
+ const raw = yield* (yield* Host).stdin
539
523
  const parsed = yield* Effect.try({
540
524
  try: () => JSON.parse(raw) as unknown,
541
525
  catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
@@ -922,8 +906,9 @@ const selfValidateCommand = (
922
906
  state: string,
923
907
  mode: StateMode,
924
908
  file: string,
909
+ spec: string | undefined,
925
910
  ): string => {
926
- const resolved = resolveMode(def, state, mode)
911
+ const resolved = resolveMode(def, state, mode, spec)
927
912
  const validate = resolved.kind === "resolved" ? resolved.validate : undefined
928
913
  const command = validate?.kind === "command" ? validate.command : seededValidateCommand(mode)
929
914
  return commandForFile(command, file)
@@ -946,14 +931,14 @@ const resolveValidateScript = (
946
931
  ): Effect.Effect<
947
932
  { readonly file: string; readonly mode: StateMode; readonly script: string } | undefined,
948
933
  Error,
949
- Host
934
+ Host | Path.Path
950
935
  > =>
951
936
  Effect.gen(function* () {
952
937
  const file = rest.hints.file
953
938
  const mode = rest.stepDef.mode
954
939
  if (file === undefined || mode === undefined) return undefined
955
940
 
956
- const resolved = resolveMode(rest.def, rest.state, mode)
941
+ const resolved = resolveMode(rest.def, rest.state, mode, rest.stepDef.modeSpec)
957
942
  if (resolved.kind === "unknown") {
958
943
  return yield* Effect.fail(new Error(resolved.message))
959
944
  }
@@ -995,7 +980,13 @@ const runNextCommand = (
995
980
  yield* writeSelection(out, fields, json.path)
996
981
  } else {
997
982
  const validateCommand = emitsValidatablePrompt(rendered)
998
- ? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
983
+ ? selfValidateCommand(
984
+ rest.def,
985
+ rest.state,
986
+ rendered.mode!,
987
+ rendered.file!,
988
+ rest.stepDef.modeSpec,
989
+ )
999
990
  : undefined
1000
991
  if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
1001
992
  // A prompt's stdout is the agent's input, so its header goes to stderr;
@@ -1038,9 +1029,10 @@ const runCheckCommand = (
1038
1029
  file: string,
1039
1030
  openQuestions: boolean,
1040
1031
  openThreadsFlag: boolean,
1032
+ spec: string | undefined,
1041
1033
  ): Effect.Effect<void, Error, Workspace> =>
1042
1034
  Effect.gen(function* () {
1043
- const format = steeringFormatFor(mode)
1035
+ const format = spec !== undefined ? yield* declaredFormat(spec) : steeringFormatFor(mode)
1044
1036
  if (format === undefined) {
1045
1037
  return yield* Effect.fail(
1046
1038
  new GtdUsageError(
@@ -1090,6 +1082,13 @@ const runCheckCommand = (
1090
1082
  )
1091
1083
  })
1092
1084
 
1085
+ const declaredFormat = (spec: string): Effect.Effect<SteeringFormat, GtdUsageError> =>
1086
+ Effect.try({
1087
+ try: () => formatFromSpec(spec),
1088
+ catch: (error) =>
1089
+ new GtdUsageError(`gtd check: --spec is not a declared format: ${(error as Error).message}`),
1090
+ })
1091
+
1093
1092
  const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
1094
1093
  Effect.flatMap(Workspace, (workspace) => {
1095
1094
  const content = workspace.atPath(file)
@@ -1403,6 +1402,7 @@ const dispatchVoidCommand = (
1403
1402
  command.file,
1404
1403
  command.openQuestions ?? false,
1405
1404
  command.openThreads ?? false,
1405
+ command.spec,
1406
1406
  )
1407
1407
  case "uncheck":
1408
1408
  return runUncheckCommand(command.file)
@@ -1,9 +1,7 @@
1
1
  import { Context } from "effect"
2
2
  import type { Effect } from "effect"
3
3
  import type { UiSettings } from "../flows/index.js"
4
-
5
- /** How to run THIS gtd — a tool (the LSP) never reads a different version's state than the one it ships with. */
6
- export type SelfGtd = readonly [string, ...string[]]
4
+ import type { HostOps } from "../platform/index.js"
7
5
 
8
6
  export interface UiRequest {
9
7
  readonly flags: {
@@ -36,7 +34,8 @@ export class ToolCommands extends Context.Service<
36
34
  request: UiRequest,
37
35
  out: { readonly write: (chunk: string) => void; readonly flush: () => void },
38
36
  ) => Effect.Effect<void, Error>
39
- readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
37
+ /** `gtd` runs THIS gtd, so the LSP never reads a different version's state than the one it ships with. */
38
+ readonly lsp: (gtd: HostOps["self"]) => Effect.Effect<void, Error>
40
39
  readonly judge: (request: JudgeRequest) => Promise<string>
41
40
  }
42
41
  >()("ToolCommands") {}
@@ -185,7 +185,7 @@ describe("currentRun", () => {
185
185
  expect(run.episode.base).toBe(boundary)
186
186
  expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
187
187
  "gtd(human): idle → building",
188
- "gtd(check): pin → building",
188
+ "gtd(check): gtd:pin → building",
189
189
  "gtd(agent): building → checking",
190
190
  ])
191
191
  })
@@ -205,7 +205,7 @@ describe("currentRun", () => {
205
205
  await land(repo, { "a.txt": "a\n" })
206
206
  await land(repo, { "b.txt": "b\n" })
207
207
  const closing = await land(repo, { "c.txt": "c\n" })
208
- expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
208
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:unpin → idle")
209
209
  const next = await land(repo, { "d.txt": "d\n" })
210
210
  const run = await provide(currentRun, repo)
211
211
  expect(run.startParentHash).toBe(closing)
@@ -217,7 +217,7 @@ describe("currentRun", () => {
217
217
  const before = headOf(repo)
218
218
  const opening = await enter(repo, "side")
219
219
  expect(repo.lastCommitMessage(opening)).toBe(
220
- `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 3`,
220
+ `gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 4`,
221
221
  )
222
222
  const fixed = await land(repo, { "fix.txt": "x\n" })
223
223
  const run = await provide(currentRun, repo)
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
250
250
  const before = headOf(repo)
251
251
  const opening = await enter(repo, "again")
252
252
  expect(repo.lastCommitMessage(opening)).toBe(
253
- `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3`,
253
+ `gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 4`,
254
254
  )
255
255
  const fixed = await land(repo, { "fix.txt": "x\n" })
256
256
  const run = await provide(currentRun, repo)
@@ -264,6 +264,29 @@ export const again = workflow(async () => {
264
264
  ])
265
265
  })
266
266
 
267
+ it("a format 3 pin commit naming the default's initial step bounds nothing either", async () => {
268
+ const repo = repoWith(`import { agent, human, workflow } from "@pmelab/gtd/flows"
269
+
270
+ export default async () => {
271
+ await human("idle", { message: "idle-message" })
272
+ }
273
+
274
+ export const again = workflow(async () => {
275
+ await human("idle", { message: "idle-message" })
276
+ await agent("fixing", "fix-prompt")
277
+ })
278
+ `)
279
+ const before = headOf(repo)
280
+ repo.seedCommit("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3")
281
+ const opening = headOf(repo)
282
+ repo.writeFile(".gtd/process.gtdpin", "gtdpin 1 gtd in-memory\n")
283
+ repo.seedCommit("gtd(check): pin → idle\n\nGtd-Pin: add\nGtd-Format: 3")
284
+ const run = await provide(currentRun, repo)
285
+ expect(run.workflow).toBe("again")
286
+ expect(run.startHash).toBe(opening)
287
+ expect(run.startParentHash).toBe(before)
288
+ })
289
+
267
290
  it("collects every process commit's Gtd-Cost, attributing a model-less one to UNATTRIBUTED_MODEL", async () => {
268
291
  const repo = repoWith(LINEAR)
269
292
  commit(
@@ -395,7 +418,7 @@ export const again = workflow(async () => {
395
418
  it("is undefined at a pin commit, which is no turn", async () => {
396
419
  const repo = repoWith(LINEAR)
397
420
  await land(repo, { "a.txt": "a\n" })
398
- expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
421
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → building")
399
422
  expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
400
423
  })
401
424
 
@@ -403,7 +426,7 @@ export const again = workflow(async () => {
403
426
  const repo = repoWith(LINEAR)
404
427
  await land(repo, { "a.txt": "a\n" })
405
428
  await land(repo)
406
- expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 3")
429
+ expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 4")
407
430
  const run = await provide(currentRun, repo)
408
431
  expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
409
432
  })
@@ -544,7 +567,7 @@ describe("summaryRun", () => {
544
567
  expect(summary.trace).toEqual([
545
568
  { state: "building", hash: building, actor: "human" },
546
569
  { state: "checking", hash: checking, actor: "agent" },
547
- { state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
570
+ { state: "gtd:unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
548
571
  { state: "idle", hash: closing, actor: "check" },
549
572
  ])
550
573
  })
@@ -831,7 +854,7 @@ export const review = workflow(async () => {
831
854
  it("is the base the resting step names", async () => {
832
855
  const repo = repoWith(LOOP)
833
856
  const first = await land(repo, { "a.txt": "a\n" })
834
- expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
857
+ expect(repo.lastCommitSubject()).toBe("gtd(check): gtd:pin → checkpoint")
835
858
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
836
859
  await land(repo, { "b.txt": "b\n" })
837
860
  expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
@@ -1306,7 +1329,7 @@ describe("finishing landings keep history", () => {
1306
1329
  await land(repo, { "b.txt": "b\n" })
1307
1330
  repo.writeFile("c.txt", "c\n")
1308
1331
  const landing = await landingOf(repo)
1309
- expect(landing.to).toBe("unpin")
1332
+ expect(landing.to).toBe("gtd:unpin")
1310
1333
  expect(landing.keepHistory).toBeUndefined()
1311
1334
  expect(landing.spec.version).toBeUndefined()
1312
1335
  })
@@ -1522,13 +1545,16 @@ describe("historyTrail", () => {
1522
1545
  tip,
1523
1546
  ])
1524
1547
  expect(trail.steps[2]).toMatchObject({
1525
- subject: "gtd(agent): checking → unpin",
1548
+ subject: "gtd(agent): checking → gtd:unpin",
1526
1549
  actor: "agent",
1527
1550
  from: "checking",
1528
- to: "unpin",
1551
+ to: "gtd:unpin",
1529
1552
  verdicts: [],
1530
1553
  })
1531
- expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
1554
+ expect(trail.steps[3]).toMatchObject({
1555
+ subject: "gtd(check): gtd:unpin → idle",
1556
+ actor: "check",
1557
+ })
1532
1558
  expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
1533
1559
  })
1534
1560
 
package/src/edge/Edge.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect, Option } from "effect"
1
+ import { Effect, Option, Path } from "effect"
2
2
  import {
3
3
  Narrator,
4
4
  GitService,
@@ -42,6 +42,7 @@ import {
42
42
  treeFromRecord,
43
43
  type EpisodeCommit,
44
44
  type JudgeVerdict,
45
+ LEGACY_PIN_STATES,
45
46
  type PinChange,
46
47
  PIN_STATES,
47
48
  type ReachedStep,
@@ -62,7 +63,6 @@ import {
62
63
 
63
64
  import { GTD_VERSION } from "../install/index.js"
64
65
  import { keptRef, processHistoryRef } from "../platform/index.js"
65
- import { isAbsolute, relative, resolve, sep } from "node:path"
66
66
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
67
67
 
68
68
  export { UNATTRIBUTED_MODEL }
@@ -337,8 +337,14 @@ const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean =>
337
337
  const parsed = parseCommitMessage(subject).parsed
338
338
  if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
339
339
  if (parsed.actor === "human" && parsed.from === undefined) return false
340
- // A pin commit's subject can name the first step; it bounds nothing.
341
- if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false
340
+ // A pin commit's subject can name the first step; it bounds nothing. The
341
+ // subject alone carries no format, so either spelling counts.
342
+ if (
343
+ parsed.actor === "check" &&
344
+ (parsed.from === PIN_STATES.add || parsed.from === LEGACY_PIN_STATES.add)
345
+ ) {
346
+ return false
347
+ }
342
348
  return parsed.to === def.initial
343
349
  }
344
350
 
@@ -421,7 +427,14 @@ const computeProcessRun = (
421
427
  runOf(history, locateEpisode(def, history)),
422
428
  )
423
429
 
424
- type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
430
+ type ConfigRequirements =
431
+ | GitService
432
+ | ConfigService
433
+ | ConfigDiscovery
434
+ | Narrator
435
+ | Workspace
436
+ | Host
437
+ | Path.Path
425
438
 
426
439
  /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
427
440
  export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
@@ -692,7 +705,7 @@ const DEFAULT_JUDGE_MESSAGE =
692
705
  const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
693
706
  (value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
694
707
 
695
- type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
708
+ type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode" | "modeSpec">
696
709
 
697
710
  const commonOf = (step: ReachedStep): StepCommon => {
698
711
  const options = step.request.options
@@ -700,7 +713,9 @@ const commonOf = (step: ReachedStep): StepCommon => {
700
713
  actor: step.actor,
701
714
  ...optional("label", options.label),
702
715
  ...optional("file", options.file),
703
- ...optional("mode", options.mode),
716
+ ...(typeof options.mode === "object"
717
+ ? { mode: options.mode.name, modeSpec: options.mode.spec }
718
+ : optional("mode", options.mode)),
704
719
  }
705
720
  }
706
721
 
@@ -820,6 +835,23 @@ const hintsOf = (def: StepDef): RestHints => ({
820
835
  ...optional("access", def.access),
821
836
  })
822
837
 
838
+ /** A step's mode is a name the workflow knows, or a declared format named apart from all of them. */
839
+ const modeFaultOf = (
840
+ def: WorkflowDefinition,
841
+ stepName: string,
842
+ stepDef: StepDef,
843
+ ): string | undefined => {
844
+ if (stepDef.mode === undefined) return undefined
845
+ const known = def.modes[stepDef.mode] !== undefined
846
+ if (stepDef.modeSpec !== undefined)
847
+ return known
848
+ ? `gtd: config: step "${stepName}": declared format "${stepDef.mode}" has the name of a mode this workflow already knows — rename the format`
849
+ : undefined
850
+ return known
851
+ ? undefined
852
+ : `gtd: config: step "${stepName}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`
853
+ }
854
+
823
855
  const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
824
856
 
825
857
  /** The currently rested step and its description — enough for the viewer and the LSP. */
@@ -984,13 +1016,8 @@ export const restAt = (
984
1016
  const { reached, step } = resting
985
1017
  const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
986
1018
  yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
987
- if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
988
- return yield* Effect.fail(
989
- new Error(
990
- `gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
991
- ),
992
- )
993
- }
1019
+ const modeFault = modeFaultOf(def, step.name, stepDef)
1020
+ if (modeFault !== undefined) return yield* Effect.fail(new Error(modeFault))
994
1021
  const changes =
995
1022
  ref === undefined
996
1023
  ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
@@ -1564,10 +1591,16 @@ export interface HistoryTrail {
1564
1591
  * repository cannot be read at a ref, so it is left out. A file only the old
1565
1592
  * config read needs no check: whichever file imported it differs too.
1566
1593
  */
1567
- const inRepoConfigPaths = (root: string, files: readonly string[]): readonly string[] => {
1568
- const paths = new Set<string>()
1569
- for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1570
- return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1594
+ const inRepoConfigPaths = (
1595
+ path: Path.Path,
1596
+ root: string,
1597
+ files: readonly string[],
1598
+ ): readonly string[] => {
1599
+ const found = new Set<string>()
1600
+ for (const file of files) {
1601
+ found.add(path.relative(root, path.resolve(root, file)).split(path.sep).join("/"))
1602
+ }
1603
+ return [...found].filter((p) => p !== "" && !p.startsWith("..") && !path.isAbsolute(p)).sort()
1571
1604
  }
1572
1605
 
1573
1606
  const configRefusal = (
@@ -1589,7 +1622,8 @@ const differingConfigPaths = (
1589
1622
  ) =>
1590
1623
  Effect.gen(function* () {
1591
1624
  const differing: string[] = []
1592
- for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) {
1625
+ const configPaths = inRepoConfigPaths(yield* Path.Path, root, Object.keys(config.fingerprint))
1626
+ for (const path of configPaths) {
1593
1627
  const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1594
1628
  if (atTip !== workspace.readSync(path)) differing.push(path)
1595
1629
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { Effect } from "effect"
2
+ import { Effect, Layer, Path } from "effect"
3
3
  import { resolveMode, validateScriptFor, type ModeResolution } from "./SteeringMode.js"
4
4
  import { Host } from "../platform/index.js"
5
5
  import { steeringFormatFor } from "../steering/index.js"
@@ -16,7 +16,9 @@ const envVarsLayer = Host.layer({ root: "/repo", home: "/repo", env: { PATH: "/u
16
16
 
17
17
  const runScript = (resolved: ModeResolution, file: string) => {
18
18
  if (resolved.kind !== "resolved") throw new Error("expected a resolved mode")
19
- return Effect.runPromise(validateScriptFor(resolved, file).pipe(Effect.provide(envVarsLayer)))
19
+ return Effect.runPromise(
20
+ validateScriptFor(resolved, file).pipe(Effect.provide(Layer.merge(envVarsLayer, Path.layer))),
21
+ )
20
22
  }
21
23
 
22
24
  describe("resolveMode", () => {
@@ -1,13 +1,18 @@
1
- import { join } from "node:path"
2
- import { Effect } from "effect"
3
- import { Host } from "../platform/index.js"
1
+ import { Effect, Path } from "effect"
2
+ import { Host, type HostOps } from "../platform/index.js"
4
3
  import {
4
+ declaredValidateCommand,
5
5
  isSeededValidateCommand,
6
6
  knownModes,
7
7
  type StateMode,
8
8
  type WorkflowDefinition,
9
9
  } from "../workflow/index.js"
10
- import { steeringFormatFor, type SteeringFinding, type SteeringFormat } from "../steering/index.js"
10
+ import {
11
+ formatFromSpec,
12
+ steeringFormatFor,
13
+ type SteeringFinding,
14
+ type SteeringFormat,
15
+ } from "../steering/internal.js"
11
16
  import { buildModeContradictionCheck, modeContradictionSkipNotice } from "./ModeContradiction.js"
12
17
  import {
13
18
  binaryGuard,
@@ -102,12 +107,26 @@ const capabilitiesFor = (
102
107
  * The `"unknown"` arm carries the whole refusal message, naming what IS known
103
108
  * when `def` is given — this is the one place that message is built, so it
104
109
  * can never drift from what actually resolved.
110
+ *
111
+ * `spec` is a declared format's: it alone decides, validating through
112
+ * `gtd check --spec` with no `format:` half.
105
113
  */
106
114
  export const resolveMode = (
107
115
  def: Pick<WorkflowDefinition, "modes"> | undefined,
108
116
  state: string,
109
117
  mode: StateMode,
118
+ spec?: string,
110
119
  ): ModeResolution => {
120
+ if (spec !== undefined) {
121
+ const format = formatFromSpec(spec)
122
+ return {
123
+ kind: "resolved",
124
+ mode,
125
+ format,
126
+ validate: { kind: "command", command: declaredValidateCommand(mode, spec) },
127
+ capabilities: { format, liveValidate: format.validate },
128
+ }
129
+ }
111
130
  const declared = def?.modes?.[mode]
112
131
  const format = steeringFormatFor(mode)
113
132
  if (declared === undefined && format === undefined) {
@@ -191,8 +210,11 @@ const modeCommandSteps = (resolved: ResolvedMode, file: string): readonly EmitSt
191
210
  }
192
211
 
193
212
  /** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
194
- const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
195
- join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`)
213
+ const scratchSamplePath = (
214
+ path: Path.Path,
215
+ { scratchDir, pid }: HostOps,
216
+ mode: StateMode,
217
+ ): string => path.join(scratchDir, `gtd-mode-sample-${mode}-${pid}.md`)
196
218
 
197
219
  /**
198
220
  * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
@@ -204,13 +226,12 @@ const scratchSamplePath = (scratchDir: string, mode: StateMode): string =>
204
226
  */
205
227
  const modeContradictionSteps = (
206
228
  resolved: ResolvedMode,
207
- ): Effect.Effect<readonly EmitStep[], Error, Host> =>
229
+ ): Effect.Effect<readonly EmitStep[], Error, Host | Path.Path> =>
208
230
  Effect.gen(function* () {
209
231
  if (resolved.formatCommand === undefined) return []
210
232
  const { capabilities } = resolved
211
233
  if (capabilities.format !== undefined && capabilities.liveValidate !== undefined) {
212
- const { scratchDir } = yield* Host
213
- const samplePath = scratchSamplePath(scratchDir, resolved.mode)
234
+ const samplePath = scratchSamplePath(yield* Path.Path, yield* Host, resolved.mode)
214
235
  const formatCommand = withFileVar(resolved.formatCommand, samplePath)
215
236
  return [
216
237
  {
@@ -245,7 +266,7 @@ export interface ValidateScript {
245
266
  export const validateScriptFor = (
246
267
  resolved: ResolvedMode,
247
268
  file: string,
248
- ): Effect.Effect<ValidateScript, Error, Host> =>
269
+ ): Effect.Effect<ValidateScript, Error, Host | Path.Path> =>
249
270
  Effect.gen(function* () {
250
271
  const steps: EmitStep[] = [
251
272
  ...(yield* modeContradictionSteps(resolved)),
@@ -50,6 +50,7 @@ export {
50
50
  type Judgment,
51
51
  type KeptTip,
52
52
  type Mark,
53
+ type DeclaredMode,
53
54
  type ModeName,
54
55
  type OpenQuestion,
55
56
  type RunOptions,
@@ -19,6 +19,7 @@ import {
19
19
  type AgentOptions,
20
20
  type HumanOptions,
21
21
  type Judgment,
22
+ type DeclaredMode,
22
23
  type ModeName,
23
24
  type RunOptions,
24
25
  type Settings,
@@ -315,6 +316,6 @@ describe("mode", () => {
315
316
  expectTypeOf<"qa">().toMatchTypeOf<ModeName>()
316
317
  expectTypeOf<string>().toMatchTypeOf<ModeName>()
317
318
  expectTypeOf<ModeName>().toMatchTypeOf<string>()
318
- expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | undefined>()
319
+ expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | DeclaredMode | undefined>()
319
320
  })
320
321
  })
@@ -8,6 +8,12 @@ import type { WorkflowBuild } from "./factory.js"
8
8
  /** A steering-file mode: a built-in format's name or a `config.modes` entry. */
9
9
  export type ModeName = "qa" | "review" | "findings" | (string & {})
10
10
 
11
+ /** A format declared with `listFormat`/`sectionFormat` from `@pmelab/gtd/steering`, used as a step's mode. */
12
+ export interface DeclaredMode {
13
+ readonly name: string
14
+ readonly spec: string
15
+ }
16
+
11
17
  interface SteeringCommon {
12
18
  /** Display name for drivers and viewers. */
13
19
  readonly label?: string | undefined
@@ -24,8 +30,8 @@ type SteeringFile =
24
30
  }
25
31
  | {
26
32
  readonly file: string
27
- /** The steering file's mode — a built-in name or a `modes:` entry. Requires `file`. */
28
- readonly mode: ModeName
33
+ /** The steering file's mode — a built-in name, a `modes:` entry, or a declared format. Requires `file`. */
34
+ readonly mode: ModeName | DeclaredMode
29
35
  }
30
36
 
31
37
  /** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
@@ -45,7 +45,7 @@ describe("restDescription", () => {
45
45
  " last commits:",
46
46
  " chore: initial commit",
47
47
  " gtd(agent): plan → sign-off",
48
- " gtd(check): pin → sign-off",
48
+ " gtd(check): gtd:pin → sign-off",
49
49
  ' actors never used: "repair" (scenario), "ready" (Background)',
50
50
  ].join("\n"),
51
51
  )
@@ -20,6 +20,6 @@ Feature: a scenario runs a workflow module from disk
20
20
  And the commit subjects end with:
21
21
  """
22
22
  gtd(agent): plan → sign-off
23
- gtd(check): pin → sign-off
23
+ gtd(check): gtd:pin → sign-off
24
24
  """
25
25
 
@@ -43,7 +43,7 @@ Feature: testkit drives any workflow against the real engine
43
43
  And the commit subjects end with:
44
44
  """
45
45
  gtd(human): plan
46
- gtd(check): pin → plan
46
+ gtd(check): gtd:pin → plan
47
47
  gtd(agent): plan → check
48
48
  gtd(check): check → ready
49
49
  gtd(judge): ready → ship
@@ -107,7 +107,7 @@ Feature: testkit drives any workflow against the real engine
107
107
  When gtd starts workflow "tiny"
108
108
  And the process is driven
109
109
  Then the process rests at "plan"
110
- And the last commit subject is "gtd(check): pin → plan"
110
+ And the last commit subject is "gtd(check): gtd:pin → plan"
111
111
 
112
112
  Scenario: a prompt asserted beside a script is checked at every visit, before the step acts
113
113
  Given step "check" is prompted with "npm test"