@pmelab/gtd 64.3.0 → 66.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 (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -4,6 +4,9 @@ import type { GitOperations } from "./Git.js"
4
4
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
5
5
  export const HISTORY_REF = "refs/worktree/gtd/history"
6
6
 
7
+ /** Set by an abandon that left `gtd/<b>` for `<b>`: only then does restore recreate `gtd/<b>`. */
8
+ export const RESUME_BRANCH_REF = "refs/worktree/gtd/history-branch"
9
+
7
10
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
8
11
  export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
9
12
 
@@ -4,6 +4,8 @@ export interface FileInfo {
4
4
  readonly type: "file" | "directory" | "symlink" | "other"
5
5
  /** `undefined` where the platform has no POSIX user ids. */
6
6
  readonly uid: number | undefined
7
+ /** Any execute bit set; `undefined` where the platform has none. */
8
+ readonly executable?: boolean
7
9
  }
8
10
 
9
11
  /**
@@ -1,4 +1,5 @@
1
1
  import {
2
+ chmodSync,
2
3
  mkdirSync,
3
4
  mkdtempSync,
4
5
  readFileSync,
@@ -38,6 +39,7 @@ interface WorkspaceTier {
38
39
  readonly root: string
39
40
  readonly writeWorking: (path: string, content: string) => void
40
41
  readonly commit: (path: string, content: string) => void
42
+ readonly chmod: (path: string, executable: boolean) => void
41
43
  readonly provide: <A>(eff: Effect.Effect<A, Error, Workspace>) => Promise<A>
42
44
  readonly dispose: () => void
43
45
  }
@@ -70,6 +72,7 @@ const makeLiveTier = (): WorkspaceTier => {
70
72
  gitExecIn(root, "add", "-A")
71
73
  gitExecIn(root, "commit", "-m", `commit ${path}`)
72
74
  },
75
+ chmod: (path, executable) => chmodSync(join(root, path), executable ? 0o755 : 0o644),
73
76
  provide: (eff) => Effect.runPromise(eff.pipe(Effect.provide(workspaceLayer))),
74
77
  dispose: () => rmSync(root, { recursive: true, force: true }),
75
78
  }
@@ -86,6 +89,7 @@ const makeInMemTier = (): WorkspaceTier => {
86
89
  repo.writeFile(path, content)
87
90
  repo.seedCommit(`commit ${path}`)
88
91
  },
92
+ chmod: (path, executable) => repo.setExecutable(path, executable),
89
93
  provide: (eff) =>
90
94
  Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
91
95
  dispose: () => {},
@@ -232,6 +236,39 @@ for (const { name, make } of tiers) {
232
236
  await expect(attempt).rejects.toThrow(/repo-relative paths only/)
233
237
  })
234
238
 
239
+ it("modeSync reports committed and working-tree modes, kept across an overwrite", async () => {
240
+ t = make()
241
+ t.commit("a.sh", "echo\n")
242
+ t.chmod("a.sh", true)
243
+ const modes = await t.provide(
244
+ Effect.gen(function* () {
245
+ const workspace = yield* Workspace
246
+ const committed = workspace.modeSync("HEAD").get("a.sh")
247
+ const chmodded = workspace.modeSync(undefined).get("a.sh")
248
+ t.writeWorking("a.sh", "echo 2\n")
249
+ return [committed, chmodded, workspace.modeSync(undefined).get("a.sh")]
250
+ }),
251
+ )
252
+ expect(modes).toEqual(["100644", "100755", "100755"])
253
+ })
254
+
255
+ it.runIf(name === "Live")(
256
+ "modeSync reads only the owner execute bit, as git does",
257
+ async () => {
258
+ t = make()
259
+ t.commit("a.sh", "echo\n")
260
+ t.writeWorking("a.sh", "echo 2\n")
261
+ chmodSync(join(t.root, "a.sh"), 0o654)
262
+ const mode = await t.provide(
263
+ Effect.gen(function* () {
264
+ return (yield* Workspace).modeSync(undefined).get("a.sh")
265
+ }),
266
+ )
267
+ gitExecIn(t.root, "add", "a.sh")
268
+ expect(mode).toBe(gitExecIn(t.root, "ls-files", "-s", "a.sh").split(" ")[0])
269
+ },
270
+ )
271
+
235
272
  it("atPath reads an absolute path INSIDE root, the same content readSync's relative form sees", async () => {
236
273
  t = make()
237
274
  t.writeWorking("nested/a.txt", "inside\n")
@@ -150,6 +150,16 @@ const applyRawDiff = (tree: Map<string, string>, entries: readonly RawDiffEntry[
150
150
  }
151
151
  }
152
152
 
153
+ /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → mode`. */
154
+ const parseLsTreeModes = (out: string): Map<string, string> => {
155
+ const modes = new Map<string, string>()
156
+ for (const record of out.split("\0")) {
157
+ const tab = record.indexOf("\t")
158
+ if (tab !== -1) modes.set(record.slice(tab + 1), record.slice(0, tab).split(" ")[0]!)
159
+ }
160
+ return modes
161
+ }
162
+
153
163
  /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
154
164
  const parseLsTree = (out: string): Map<string, string> => {
155
165
  const entries = new Map<string, string>()
@@ -227,6 +237,8 @@ export interface WorkspaceOps {
227
237
  * reader compares contents instead.
228
238
  */
229
239
  readonly worktreeSync: () => ReadonlyMap<string, string | undefined>
240
+ /** Every path's git file mode (`100644`, `100755`, …) at `ref`, or in the working tree as `git add -A` would store it when `ref` is `undefined`. */
241
+ readonly modeSync: (ref: string | undefined) => ReadonlyMap<string, string>
230
242
  /**
231
243
  * `hash → (path → blob id)` for `base` (when given) and every one of
232
244
  * `commits`, in ONE call regardless of how many commits that is: a single
@@ -528,6 +540,29 @@ const makeWorkspaceOps = (
528
540
  return new Map([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
529
541
  }
530
542
 
543
+ const modeSync = (ref: string | undefined): ReadonlyMap<string, string> => {
544
+ const modes = parseLsTreeModes(gitSync(["ls-tree", "-r", "-z", "--full-tree", ref ?? "HEAD"]))
545
+ if (ref !== undefined) return modes
546
+ const status = gitSync([
547
+ "status",
548
+ "--porcelain=v1",
549
+ "-z",
550
+ "--untracked-files=all",
551
+ "--no-renames",
552
+ "--ignore-submodules=none",
553
+ ])
554
+ for (const record of status.split("\0")) {
555
+ if (record.length < 4) continue
556
+ const path = record.slice(3).replace(/\/$/, "")
557
+ const stat = io.lstat(paths.join(root, path))
558
+ if (stat === undefined) modes.delete(path)
559
+ else if (stat.type === "symlink") modes.set(path, "120000")
560
+ else if (stat.type === "directory") modes.set(path, "160000")
561
+ else modes.set(path, stat.executable === true ? "100755" : "100644")
562
+ }
563
+ return modes
564
+ }
565
+
531
566
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
532
567
  const gitlinkOf = (path: string): string | undefined => {
533
568
  try {
@@ -547,6 +582,7 @@ const makeWorkspaceOps = (
547
582
  return {
548
583
  treeSync,
549
584
  worktreeSync,
585
+ modeSync,
550
586
  episodeTrees,
551
587
  readSync,
552
588
  read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
@@ -31,6 +31,7 @@ export * as NodeServices from "./NodeServices.js"
31
31
  export { loopLogPath } from "./WorktreeState.js"
32
32
  export {
33
33
  HISTORY_REF,
34
+ RESUME_BRANCH_REF,
34
35
  keptRef,
35
36
  processHistoryRef,
36
37
  PROCESS_HISTORY_PREFIX,
@@ -15,6 +15,7 @@ import {
15
15
  refuse,
16
16
  restart,
17
17
  run,
18
+ ship,
18
19
  scope,
19
20
  threads,
20
21
  type Flow,
@@ -647,6 +648,14 @@ describe("replay", () => {
647
648
  expect(outcome.kind === "failed" && outcome.message).toContain("shell script string")
648
649
  })
649
650
 
651
+ it("fails a ship step with an empty message", async () => {
652
+ const wf = async () => {
653
+ await ship("shipping", " ")
654
+ }
655
+ const outcome = await replayOf(wf, new History())
656
+ expect(outcome.kind === "failed" && outcome.message).toContain("non-empty commit message")
657
+ })
658
+
650
659
  it("fails a flow that awaits something other than a step", async () => {
651
660
  const waiting = async () => {
652
661
  await new Promise((resolve) => setTimeout(resolve, 50))
@@ -20,7 +20,7 @@ import {
20
20
  threads,
21
21
  } from "../steering/index.js"
22
22
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
23
- import { globMatches, PIN_PATH } from "../wire/index.js"
23
+ import { globMatches, PIN_PATH, RUN_FAILED_PATH } from "../wire/index.js"
24
24
  import { refNameFault } from "./RefName.js"
25
25
  import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
26
26
  import {
@@ -60,6 +60,8 @@ export interface Episode {
60
60
  export interface PendingTurn {
61
61
  readonly tree: TreeView
62
62
  readonly verdicts?: readonly JudgeVerdict[]
63
+ /** What a pending ship step resolves to: a stand-in hash when it will ship, the real one exists only once it landed. */
64
+ readonly shipped?: string
63
65
  }
64
66
 
65
67
  export interface ReplayInput {
@@ -90,7 +92,13 @@ export type StepKind = Exclude<StepRequest["kind"], "restart">
90
92
  type Actor = "agent" | "human" | "check" | "judge"
91
93
 
92
94
  const actorOfKind = (kind: StepKind): Actor =>
93
- kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge"
95
+ kind === "run" || kind === "ship"
96
+ ? "check"
97
+ : kind === "agent"
98
+ ? "agent"
99
+ : kind === "human"
100
+ ? "human"
101
+ : "judge"
94
102
 
95
103
  /** One step replay reached, completed or not. */
96
104
  export interface ReachedStep {
@@ -314,12 +322,53 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
314
322
  // The option keys each step accepts. A gtd.config.ts is evaluated without a
315
323
  // type check, so a misspelt key would otherwise be silently ignored.
316
324
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
317
- agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
325
+ agent: new Set([
326
+ ...STEERING_OPTIONS,
327
+ "model",
328
+ "system",
329
+ "allowEmpty",
330
+ "keep",
331
+ "validate",
332
+ "cap",
333
+ "exhausted",
334
+ "correction",
335
+ ]),
318
336
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
319
- run: new Set(STEERING_OPTIONS),
337
+ run: new Set([...STEERING_OPTIONS, "onFail"]),
338
+ ship: new Set(["label", "base"]),
320
339
  judge: new Set([...STEERING_OPTIONS, "message"]),
321
340
  }
322
341
 
342
+ const validatorOptionFault = (
343
+ name: string,
344
+ options: Extract<StepRequest, { kind: "agent" }>["options"],
345
+ ): string | undefined => {
346
+ const { validate, cap, exhausted, correction } = options
347
+ const prefix = `gtd: step "${name}": `
348
+ const notFunction = Object.entries({ validate, exhausted, correction }).find(
349
+ ([, value]) => value !== undefined && typeof value !== "function",
350
+ )
351
+ if (notFunction !== undefined)
352
+ return `${prefix}${notFunction[0]} must be a function in agent() options`
353
+ if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) {
354
+ return `${prefix}cap must be a positive integer in agent() options`
355
+ }
356
+ if (validate !== undefined && options.keep === true) {
357
+ return `${prefix}validate cannot be combined with keep: true — a kept turn is set aside, so there is no landed result to validate`
358
+ }
359
+ return undefined
360
+ }
361
+
362
+ const restsOnFail = (step: ReachedStep): boolean =>
363
+ step.request.kind === "run" && step.request.options.onFail === "rest"
364
+
365
+ const onFailFault = (step: ReachedStep): string | undefined =>
366
+ step.request.kind === "run" &&
367
+ step.request.options.onFail !== undefined &&
368
+ step.request.options.onFail !== "rest"
369
+ ? `gtd: step "${step.name}": onFail must be "rest" in run() options`
370
+ : undefined
371
+
323
372
  const unknownOptions = (step: ReachedStep): string | undefined => {
324
373
  const unknown = Object.keys(step.request.options).filter(
325
374
  (key) => !KNOWN_OPTIONS[step.kind].has(key),
@@ -454,12 +503,13 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
454
503
  const scoped = (name: string): string =>
455
504
  [...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
456
505
 
457
- const isAttemptAt = (commit: ParsedCommit, name: string): boolean =>
506
+ const isAttemptAt = (commit: ParsedCommit, step: ReachedStep): boolean =>
458
507
  commit.parsed.step === undefined &&
459
508
  commit.parsed.parsed !== undefined &&
460
509
  commit.parsed.parsed.from === undefined &&
461
- commit.parsed.parsed.to === name &&
462
- isEmptyDiff(diffTrees(position.tree, commit.tree))
510
+ commit.parsed.parsed.to === step.name &&
511
+ (isEmptyDiff(diffTrees(position.tree, commit.tree)) ||
512
+ (restsOnFail(step) && commit.tree.read(RUN_FAILED_PATH) !== undefined))
463
513
 
464
514
  const advance = (next: Position): void => {
465
515
  previousPosition = position
@@ -469,13 +519,16 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
469
519
  const replyFor = (
470
520
  request: Exclude<StepRequest, { kind: "restart" }>,
471
521
  verdicts: readonly JudgeVerdict[],
522
+ shipped: string | undefined,
472
523
  ): unknown =>
473
524
  request.kind === "judge"
474
525
  ? {
475
526
  answers: answersFrom(verdicts, request.questions),
476
527
  truncated: judgeCuts.get(request) ?? [],
477
528
  }
478
- : undefined
529
+ : request.kind === "ship"
530
+ ? shipped
531
+ : undefined
479
532
 
480
533
  const judgeCuts = new WeakMap<object, readonly string[]>()
481
534
 
@@ -636,7 +689,11 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
636
689
  )
637
690
  }
638
691
  const error =
639
- unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
692
+ unknownOptions(step) ??
693
+ onFailFault(step) ??
694
+ approveConflict(step) ??
695
+ modeWithoutFile(step) ??
696
+ checkIdentity(step)
640
697
  return error === undefined ? undefined : { kind: "failed", message: error }
641
698
  }
642
699
 
@@ -670,7 +727,7 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
670
727
  expectNextOf(commit)
671
728
  skipPins()
672
729
  noteKept(step, commit)
673
- return replyFor(step.request, commit.parsed.judge)
730
+ return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped)
674
731
  }
675
732
 
676
733
  // A turn that ends a pinned process names the unpin, which is no step.
@@ -699,11 +756,15 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
699
756
  keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
700
757
  }
701
758
  advance({ hash: "", tree: keeping ? position.tree : pending.tree })
702
- return replyFor(step.request, pending.verdicts ?? [])
759
+ return replyFor(step.request, pending.verdicts ?? [], pending.shipped)
703
760
  }
704
761
 
705
762
  const answer = (step: ReachedStep): unknown => {
706
- while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) {
763
+ while (cursor < commits.length && isAttemptAt(commits[cursor]!, step)) {
764
+ // An attempt that recorded a failure carries its tree; an empty one changes nothing.
765
+ if (!isEmptyDiff(diffTrees(position.tree, commits[cursor]!.tree))) {
766
+ advance({ hash: commits[cursor]!.hash, tree: commits[cursor]!.tree })
767
+ }
707
768
  cursor++
708
769
  skipPins()
709
770
  }
@@ -718,19 +779,34 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
718
779
  marks,
719
780
  })
720
781
  }
782
+ if (restsOnFail(step) && input.pending.tree.read(RUN_FAILED_PATH) !== undefined) {
783
+ return finish({ kind: "rest", rest: step, trace, landed, marks })
784
+ }
721
785
  return landPending(step, input.pending)
722
786
  }
723
787
 
788
+ const bodyFault = (request: Exclude<StepRequest, { kind: "restart" | "agent" }>) => {
789
+ if (
790
+ request.kind === "ship" &&
791
+ (typeof request.message !== "string" || request.message.trim() === "")
792
+ ) {
793
+ return `gtd: step "${scoped(request.name)}": a ship() message is a non-empty commit message`
794
+ }
795
+ if (request.kind === "run" && typeof request.body !== "string") {
796
+ return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
797
+ }
798
+ return undefined
799
+ }
800
+
724
801
  // gtd.config.ts is loaded without a type check.
725
802
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
726
803
  if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
727
804
  return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
728
805
  }
729
- if (request.kind === "run" && typeof request.body !== "string") {
730
- return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
731
- }
732
- if (request.kind !== "agent") return undefined
806
+ if (request.kind !== "agent") return bodyFault(request)
733
807
  const name = scoped(request.name)
808
+ const optionFault = validatorOptionFault(name, request.options)
809
+ if (optionFault !== undefined) return optionFault
734
810
  if (request.options.keep === true) {
735
811
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
736
812
  if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
@@ -118,6 +118,10 @@ export interface CommitMessage {
118
118
  readonly pin: PinChange | undefined
119
119
  /** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
120
120
  readonly anchor: string | undefined
121
+ /** `Gtd-Shipped`, on a ship step's landing: the commit it put on the user's branch. */
122
+ readonly shipped: string | undefined
123
+ /** `Gtd-Tip`, on a shipped commit: the process commit whose tree it carries. */
124
+ readonly tip: string | undefined
121
125
  readonly format: number
122
126
  }
123
127
 
@@ -178,6 +182,8 @@ interface Trailers {
178
182
  approved: Approval | undefined
179
183
  pin: PinChange | undefined
180
184
  anchor: string | undefined
185
+ shipped: string | undefined
186
+ tip: string | undefined
181
187
  readonly judge: JudgeVerdict[]
182
188
  readonly marks: Mark[]
183
189
  readonly vars: Record<string, string>
@@ -228,6 +234,12 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
228
234
  "Gtd-Anchor": (value, into) => {
229
235
  if (HASH_RE.test(value)) into.anchor ??= value
230
236
  },
237
+ "Gtd-Shipped": (value, into) => {
238
+ if (HASH_RE.test(value)) into.shipped ??= value
239
+ },
240
+ "Gtd-Tip": (value, into) => {
241
+ if (HASH_RE.test(value)) into.tip ??= value
242
+ },
231
243
  "Gtd-Format": (value, into) => {
232
244
  if (FORMAT_RE.test(value)) into.format ??= Number(value)
233
245
  },
@@ -250,6 +262,8 @@ export const parseCommitMessage = (message: string): CommitMessage => {
250
262
  approved: undefined,
251
263
  pin: undefined,
252
264
  anchor: undefined,
265
+ shipped: undefined,
266
+ tip: undefined,
253
267
  judge: [],
254
268
  marks: [],
255
269
  vars: {},
@@ -282,6 +296,8 @@ export interface CommitSpec {
282
296
  readonly anchor?: string
283
297
  readonly branch?: string
284
298
  readonly version?: string
299
+ /** Written after `Gtd-Format`: a landing recording a ship an earlier, hook-rejected attempt already made. */
300
+ readonly shipped?: string
285
301
  }
286
302
 
287
303
  const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
@@ -309,6 +325,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
309
325
  ...trailer("Branch", spec.branch),
310
326
  ...trailer("Version", spec.version),
311
327
  `Gtd-Format: ${HISTORY_FORMAT}`,
328
+ ...trailer("Shipped", spec.shipped),
312
329
  ]
313
330
  const subject = formatSubject(spec.actor, spec.to, spec.from)
314
331
  return `${subject}\n\n${lines.join("\n")}`
@@ -8,6 +8,8 @@ export interface TreeView {
8
8
  readonly paths: () => readonly string[]
9
9
  readonly read: (path: string) => string | undefined
10
10
  readonly id?: (path: string) => string | undefined
11
+ /** Git file mode; when both sides give one, a differing mode is a modification even with equal content. */
12
+ readonly mode?: (path: string) => string | undefined
11
13
  }
12
14
 
13
15
  export const treeFromRecord = (files: Readonly<Record<string, string>>): TreeView => ({
@@ -24,6 +26,9 @@ export interface TreeDiff {
24
26
  export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
25
27
  // Blob ids only compare against blob ids; otherwise fall back to contents.
26
28
  const same = (path: string): boolean => {
29
+ if (before.mode !== undefined && after.mode !== undefined) {
30
+ if (before.mode(path) !== after.mode(path)) return false
31
+ }
27
32
  const a = before.id?.(path)
28
33
  const b = after.id?.(path)
29
34
  return a !== undefined && b !== undefined ? a === b : before.read(path) === after.read(path)
@@ -6,7 +6,7 @@ export {
6
6
  type ReachedStep,
7
7
  type ReplayOutcome,
8
8
  } from "./Replay.js"
9
- export { treeFromRecord, type TreeView } from "./Tree.js"
9
+ export { diffTrees, treeFromRecord, type TreeView } from "./Tree.js"
10
10
  export {
11
11
  formatCommitMessage,
12
12
  formatStepId,
@@ -17,7 +17,9 @@ read them.
17
17
  pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
18
18
  its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
19
19
  chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
20
- chunk of its own.
20
+ chunk of its own. Under `## Findings` a location is a plain list item
21
+ (`- ./path`, `- ./path#start-end`) with no checkbox; a `- [ ]`/`- [x]` pointer
22
+ there is an error.
21
23
  - **`findings`**: the finding gate's file. `## ` entries, those under a
22
24
  `# Dropped as noise` heading being the noise; each holds exactly one
23
25
  `- [ ] fix` box. A ticked box fixes the finding; text below the box is the
@@ -17,6 +17,11 @@ export interface SteeringEdit {
17
17
  export interface SteeringAction {
18
18
  readonly title: string
19
19
  readonly edits: readonly SteeringEdit[]
20
+ /** A range the editor selects once the edits apply; absent means no reveal. */
21
+ readonly select?: {
22
+ readonly start: { readonly line: number; readonly character: number }
23
+ readonly end: { readonly line: number; readonly character: number }
24
+ }
20
25
  }
21
26
 
22
27
  /** A go-to-definition target. `path` absent means "this same document" (a footnote jump); `character` defaults to 0. */
@@ -105,6 +110,12 @@ export interface SteeringViewThread {
105
110
  readonly waitingOn: "human" | "agent"
106
111
  }
107
112
 
113
+ /** One labelled part of a node's drafted comment. */
114
+ export interface SteeringViewSection {
115
+ readonly label: string
116
+ readonly body: readonly SteeringViewNode[]
117
+ }
118
+
108
119
  /**
109
120
  * One node of a format's `view`: a generic container/item tree, the SAME
110
121
  * shape for every format. Deliberately never a closed per-format union — a
@@ -140,6 +151,8 @@ export interface SteeringViewNode {
140
151
  * (question); absent wherever `detail` itself is absent.
141
152
  */
142
153
  readonly detailInline?: readonly InlineNode[]
154
+ /** A review finding's drafted PR comment as labelled sections (What / Impact / Fix), when it has one. */
155
+ readonly comment?: readonly SteeringViewSection[]
143
156
  /** A file path this node points at, when it has one (a `review` hunk). */
144
157
  readonly path?: string
145
158
  /** A 1-based line in `path` this node points at, when it has one. */
@@ -12,7 +12,7 @@ The parser keeps a lone `\r` as content.
12
12
 
13
13
  In the code:
14
14
 
15
- - [x] ./src/parse.ts#30-31 — where it is kept
15
+ - ./src/parse.ts#30-31 — where it is kept
16
16
 
17
17
  Tick exactly one:
18
18
 
@@ -62,7 +62,6 @@
62
62
  "path": "./src/parse.ts",
63
63
  "line": 30,
64
64
  "endLine": 31,
65
- "checked": true,
66
65
  "detail": "where it is kept",
67
66
  "anchor": {
68
67
  "kind": "hunk",
@@ -47,6 +47,7 @@ export type {
47
47
  SteeringPointer,
48
48
  SteeringView,
49
49
  SteeringViewNode,
50
+ SteeringViewSection,
50
51
  SteeringViewThread,
51
52
  } from "./SteeringFormat.js"
52
53