@pmelab/gtd 23.2.0 → 24.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,127 @@
1
+ import type { BeatKind } from "./Demand.js"
2
+ import type { BeatDocument } from "./BeatDocument.js"
3
+ import type { StatusChange } from "./BeatStatus.js"
4
+ import { UNATTRIBUTED_MODEL } from "./constants.js"
5
+ import type { ModelCost } from "./types.js"
6
+
7
+ /**
8
+ * True when the per-model breakdown adds information beyond the `Cost:`
9
+ * total: more than one model, or a single model that carries an actual
10
+ * `--model` tag (a lone `unspecified` bucket just restates the total, so
11
+ * it's suppressed).
12
+ */
13
+ const breakdownIsInformative = (byModel: readonly ModelCost[]): boolean =>
14
+ byModel.length > 1 || (byModel.length === 1 && byModel[0]!.model !== UNATTRIBUTED_MODEL)
15
+
16
+ const costStatusLines = (cost: number, byModel: readonly ModelCost[]): string[] => {
17
+ if (cost <= 0) return []
18
+ const lines = [`Cost: ${cost}`]
19
+ if (breakdownIsInformative(byModel)) {
20
+ for (const m of byModel) lines.push(` ${m.model}: ${m.cost}`)
21
+ }
22
+ return lines
23
+ }
24
+
25
+ const pendingStatusLines = (statusChanges: readonly StatusChange[]): string[] =>
26
+ statusChanges.length === 0
27
+ ? ["Pending: (clean)"]
28
+ : ["Pending:", ...statusChanges.map((c) => ` ${c.status} ${c.path}`)]
29
+
30
+ const nextStatusLine = (next: BeatDocument["next"]): string =>
31
+ next === null ? "Next: (nothing would land)" : `Next: → ${next.target}`
32
+
33
+ /** Builds `{[key]: value}` for each entry whose value isn't `undefined` — the shared "omit absent optional fields" shape the header uses. */
34
+ const definedFields = (
35
+ entries: readonly (readonly [string, unknown])[],
36
+ ): Record<string, unknown> => {
37
+ const result: Record<string, unknown> = {}
38
+ for (const [key, value] of entries) if (value !== undefined) result[key] = value
39
+ return result
40
+ }
41
+
42
+ /**
43
+ * The header block's own lines — `State:`/`Awaits:`/`Workflow:`/optional `Label:`/
44
+ * `Model:`/`Memory:`/`File:`/`Mode:`/`Cost:`(+breakdown)/`Pending:`/`Next:`,
45
+ * in that order. Used by `renderBeatPlain`'s header-showing kinds below.
46
+ */
47
+ const beatHeaderLines = (document: BeatDocument): string[] => {
48
+ const optional = definedFields([
49
+ ["Label", document.label],
50
+ ["Model", document.model],
51
+ ["Memory", document.memory],
52
+ ["File", document.file],
53
+ ["Mode", document.mode],
54
+ ])
55
+ return [
56
+ `State: ${document.state}`,
57
+ `Awaits: ${document.actor}`,
58
+ `Workflow: ${document.workflow}`,
59
+ ...Object.entries(optional).map(([key, value]) => `${key}: ${value}`),
60
+ ...costStatusLines(document.cost ?? 0, document.costByModel ?? []),
61
+ ...pendingStatusLines(document.changes),
62
+ nextStatusLine(document.next),
63
+ ]
64
+ }
65
+
66
+ /** `script`'s plain-output instruction line — a literal constant, never a template. */
67
+ const SCRIPT_INSTRUCTION = "Run this script:"
68
+
69
+ /** `capture`'s plain-output instruction line — a literal constant, never a template. */
70
+ const CAPTURE_INSTRUCTION = "The edit is already made — run `gtd land` to land it."
71
+
72
+ /** One `BeatKind`'s plain-render briefing: the instruction line prepended above the header (`undefined` for none), and whether the status header is suppressed entirely (`prompt` alone — those bytes ARE the agent's input). */
73
+ export interface DemandBriefing {
74
+ readonly instructionLine: string | undefined
75
+ readonly suppressHeader: boolean
76
+ }
77
+
78
+ /**
79
+ * One entry per `BeatKind`, typed as `Record<BeatKind, DemandBriefing>` so
80
+ * the object literal itself is exhaustive: adding or removing a `BeatKind`
81
+ * member without updating this map fails `tsc`, the same guarantee
82
+ * `dispatchFieldsOf`'s switch (`BeatDocument.ts`) gives the JSON side. Exported
83
+ * so the one cross-cutting exhaustiveness test (`BeatDocument.test.ts`) can
84
+ * assert against it directly, rather than re-deriving it from
85
+ * `renderBeatPlain`'s output. Replaces the old `renderBeatPlain`'s hand-written
86
+ * `if (kind === "script") … if (kind === "capture") …` pair.
87
+ */
88
+ export const DEMAND_BRIEFING: Record<BeatKind, DemandBriefing> = {
89
+ capture: { instructionLine: CAPTURE_INSTRUCTION, suppressHeader: false },
90
+ message: { instructionLine: undefined, suppressHeader: false },
91
+ script: { instructionLine: SCRIPT_INSTRUCTION, suppressHeader: false },
92
+ stalled: { instructionLine: undefined, suppressHeader: false },
93
+ prompt: { instructionLine: undefined, suppressHeader: true },
94
+ }
95
+
96
+ /**
97
+ * The self-validation instruction appended to a `prompt` beat's plain output
98
+ * only — a structured (`--json`) driver runs the `validate` field
99
+ * itself instead. Advisory either way: `gtd land` refuses a turn whose
100
+ * steering file is invalid regardless of whether this was followed.
101
+ */
102
+ const selfValidateInstruction = (command: string, file: string): string =>
103
+ `\nBefore finishing your turn, run \`${command}\` — it checks ${file} — and fix ` +
104
+ `every violation it reports until it exits cleanly. Do not finish while it ` +
105
+ `still reports violations.\n`
106
+
107
+ /**
108
+ * `gtd next`'s plain-text encoding, driven by `DEMAND_BRIEFING`. At
109
+ * `kind === "prompt"` the status header is suppressed and only `content`
110
+ * plus the self-validation instruction is emitted — those bytes ARE the
111
+ * agent's input, so gtd's own bookkeeping (state name, edges, pending
112
+ * changes) must not be prefixed onto it. Every other kind gets the header
113
+ * plus its instruction line, since its plain output is read by a human or
114
+ * driver, never an agent. `selfValidateCommand` is already resolved; this
115
+ * function never resolves it itself.
116
+ */
117
+ export const renderBeatPlain = (document: BeatDocument, selfValidateCommand?: string): string => {
118
+ const content = document.content.endsWith("\n") ? document.content : document.content + "\n"
119
+ const briefing = DEMAND_BRIEFING[document.kind]
120
+ if (briefing.suppressHeader) {
121
+ return selfValidateCommand !== undefined && document.file !== undefined
122
+ ? content + selfValidateInstruction(selfValidateCommand, document.file)
123
+ : content
124
+ }
125
+ const body = beatHeaderLines(document).join("\n") + "\n\n" + content
126
+ return briefing.instructionLine !== undefined ? `${briefing.instructionLine}\n` + body : body
127
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { statusOf } from "./BeatStatus.js"
3
+ import type { RenderedDemandSource } from "./index.js"
4
+
5
+ const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
6
+ state: "build.fixing",
7
+ actor: "agent",
8
+ content: "fix it",
9
+ ...overrides,
10
+ })
11
+
12
+ const baseInput = (overrides: Partial<RenderedDemandSource> = {}) => ({
13
+ rendered: rendered(overrides),
14
+ idle: false,
15
+ initial: false,
16
+ workflow: "feature",
17
+ log: "log",
18
+ changes: [],
19
+ next: null,
20
+ cost: 0,
21
+ costByModel: [],
22
+ })
23
+
24
+ describe("statusOf", () => {
25
+ it("carries model through verbatim, including the empty string", () => {
26
+ expect(statusOf(baseInput({ model: "" })).model).toBe("")
27
+ })
28
+
29
+ it("omits system when its rendered value is the empty string, unlike model", () => {
30
+ const status = statusOf(baseInput({ model: "", system: "" }))
31
+ expect(status.system).toBeUndefined()
32
+ expect(status.model).toBe("")
33
+ })
34
+
35
+ it("carries system through when non-empty", () => {
36
+ expect(statusOf(baseInput({ system: "a persona" })).system).toBe("a persona")
37
+ })
38
+
39
+ it("carries idle/log/changes/next/cost/costByModel through unchanged (ungated — beatDocument applies the wire's omission rules)", () => {
40
+ const status = statusOf({
41
+ rendered: rendered(),
42
+ idle: true,
43
+ initial: true,
44
+ workflow: "feature",
45
+ log: ".git/gtd-loop.log",
46
+ changes: [{ status: "M", path: "TODO.md" }],
47
+ next: { target: "idle" },
48
+ cost: 12,
49
+ costByModel: [{ model: "opus", cost: 12 }],
50
+ })
51
+ expect(status.workflow).toBe("feature")
52
+ expect(status.idle).toBe(true)
53
+ expect(status.log).toBe(".git/gtd-loop.log")
54
+ expect(status.changes).toEqual([{ status: "M", path: "TODO.md" }])
55
+ expect(status.next).toEqual({ target: "idle" })
56
+ expect(status.cost).toBe(12)
57
+ expect(status.costByModel).toEqual([{ model: "opus", cost: 12 }])
58
+ })
59
+ })
@@ -0,0 +1,81 @@
1
+ import type { Actor, ModelCost, RenderedDemandSource, StateMode, StateName } from "./types.js"
2
+
3
+ /** One pending change — `gtd next --json`'s `changes` entries. */
4
+ export interface StatusChange {
5
+ readonly status: string
6
+ readonly path: string
7
+ }
8
+
9
+ /** The step landing the pending change would leave the process at. */
10
+ export interface NextMatch {
11
+ readonly target: string
12
+ }
13
+
14
+ /**
15
+ * Everything a `gtd next` beat carries that no driver `case`-arm branches on
16
+ * — the doc-tested minimal driver never reads any of these except
17
+ * `model`/`system` (read unconditionally, not gated to a `kind`) and never
18
+ * switches behavior on them. Ungated: `cost`/`costByModel`/`next` here carry
19
+ * their raw values; `beatDocument` applies the wire's own
20
+ * present-vs-omitted rules when flattening.
21
+ */
22
+ export interface BeatStatus {
23
+ readonly idle: boolean
24
+ /** No process is underway, whatever the tree holds — `idle` additionally needs a clean tree. */
25
+ readonly initial: boolean
26
+ readonly model: string | undefined
27
+ readonly system: string | undefined
28
+ readonly log: string
29
+ readonly state: StateName
30
+ readonly actor: Actor
31
+ /** The workflow the process runs: its pinned name, or the default's display name. */
32
+ readonly workflow: string
33
+ readonly label: string | undefined
34
+ readonly memory: string | undefined
35
+ readonly file: string | undefined
36
+ readonly mode: StateMode | undefined
37
+ readonly changes: readonly StatusChange[]
38
+ readonly next: NextMatch | null
39
+ readonly cost: number
40
+ readonly costByModel: readonly ModelCost[]
41
+ readonly judge: string | undefined
42
+ }
43
+
44
+ /**
45
+ * Assemble one `BeatStatus`. `system` is omitted (not just falsy) when its
46
+ * rendered value is the empty string, unlike `model`: an empty
47
+ * `--system-prompt ""` would silently delete the harness's own default
48
+ * instead of failing loudly.
49
+ */
50
+ export const statusOf = (input: {
51
+ readonly rendered: RenderedDemandSource
52
+ readonly idle: boolean
53
+ readonly initial: boolean
54
+ readonly workflow: string
55
+ readonly log: string
56
+ readonly changes: readonly StatusChange[]
57
+ readonly next: NextMatch | null
58
+ readonly cost: number
59
+ readonly costByModel: readonly ModelCost[]
60
+ }): BeatStatus => {
61
+ const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input
62
+ return {
63
+ idle,
64
+ initial,
65
+ model: rendered.model,
66
+ system: rendered.system !== undefined && rendered.system !== "" ? rendered.system : undefined,
67
+ log,
68
+ state: rendered.state,
69
+ actor: rendered.actor,
70
+ workflow,
71
+ label: rendered.label,
72
+ memory: rendered.memory,
73
+ file: rendered.file,
74
+ mode: rendered.mode,
75
+ changes,
76
+ next,
77
+ cost,
78
+ costByModel,
79
+ judge: rendered.judge,
80
+ }
81
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { BEAT_KINDS, beatKindOf, demandOf, stallDiagnosis } from "./Demand.js"
3
+ import type { RenderedDemandSource } from "./index.js"
4
+
5
+ const rendered = (overrides: Partial<RenderedDemandSource> = {}): RenderedDemandSource => ({
6
+ state: "build.fixing",
7
+ actor: "agent",
8
+ content: "fix it",
9
+ ...overrides,
10
+ })
11
+
12
+ describe("beatKindOf", () => {
13
+ it("is stalled whenever stalled is true, regardless of content kind or dirtiness", () => {
14
+ for (const contentKind of ["script", "prompt", "message"] as const) {
15
+ for (const dirty of [true, false]) {
16
+ expect(beatKindOf({ contentKind, dirty, stalled: true })).toBe("stalled")
17
+ }
18
+ }
19
+ })
20
+
21
+ it("is capture at a dirty message rest", () => {
22
+ expect(beatKindOf({ contentKind: "message", dirty: true, stalled: false })).toBe("capture")
23
+ })
24
+
25
+ it("is message at a clean message rest", () => {
26
+ expect(beatKindOf({ contentKind: "message", dirty: false, stalled: false })).toBe("message")
27
+ })
28
+
29
+ it("is script at a script rest, dirty or clean", () => {
30
+ expect(beatKindOf({ contentKind: "script", dirty: true, stalled: false })).toBe("script")
31
+ expect(beatKindOf({ contentKind: "script", dirty: false, stalled: false })).toBe("script")
32
+ })
33
+
34
+ it("is prompt at a prompt rest, dirty or clean", () => {
35
+ expect(beatKindOf({ contentKind: "prompt", dirty: true, stalled: false })).toBe("prompt")
36
+ expect(beatKindOf({ contentKind: "prompt", dirty: false, stalled: false })).toBe("prompt")
37
+ })
38
+ })
39
+
40
+ describe("stallDiagnosis", () => {
41
+ it("names the step and mentions both escapes", () => {
42
+ const text = stallDiagnosis("build.working", "agent")
43
+ expect(text).toContain('stalled at "build.working"')
44
+ expect(text).toContain("gtd(agent): build.working")
45
+ expect(text).toMatch(/escalation/)
46
+ expect(text).toMatch(/allowEmpty: true/)
47
+ expect(text).toMatch(/prompt so the turn has something concrete/)
48
+ })
49
+ })
50
+
51
+ describe("demandOf", () => {
52
+ it("uses the rendered content verbatim at every non-stalled kind", () => {
53
+ for (const kind of BEAT_KINDS.filter((k) => k !== "stalled")) {
54
+ const demand = demandOf({ rendered: rendered(), kind })
55
+ expect(demand.content).toBe("fix it")
56
+ }
57
+ })
58
+
59
+ it("uses the stall diagnosis as content, never the rendered content, at kind stalled", () => {
60
+ const demand = demandOf({ rendered: rendered(), kind: "stalled" })
61
+ expect(demand.content).toBe(stallDiagnosis("build.fixing", "agent"))
62
+ expect(demand.content).not.toBe("fix it")
63
+ })
64
+
65
+ it("carries session/validate only on the prompt variant — no other kind's Demand shape can hold them", () => {
66
+ const session = { id: "8f2c", resume: true }
67
+ for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
68
+ const demand = demandOf({
69
+ rendered: rendered(),
70
+ kind,
71
+ session,
72
+ validate: "gtd check qa 'TODO.md'",
73
+ })
74
+ expect("session" in demand).toBe(false)
75
+ expect("validate" in demand).toBe(false)
76
+ }
77
+ })
78
+
79
+ it("carries session/validate at kind prompt when supplied", () => {
80
+ const session = { id: "8f2c", resume: true }
81
+ const demand = demandOf({
82
+ rendered: rendered(),
83
+ kind: "prompt",
84
+ session,
85
+ validate: "gtd check qa 'TODO.md'",
86
+ })
87
+ expect(demand.kind).toBe("prompt")
88
+ if (demand.kind !== "prompt") throw new Error("unreachable")
89
+ expect(demand.session).toEqual(session)
90
+ expect(demand.validate).toBe("gtd check qa 'TODO.md'")
91
+ })
92
+
93
+ it("carries session/validate present-with-undefined (not omitted) at kind prompt when the caller supplied none", () => {
94
+ const demand = demandOf({ rendered: rendered(), kind: "prompt" })
95
+ expect(demand.kind).toBe("prompt")
96
+ if (demand.kind !== "prompt") throw new Error("unreachable")
97
+ expect("session" in demand).toBe(true)
98
+ expect(demand.session).toBeUndefined()
99
+ expect("validate" in demand).toBe(true)
100
+ expect(demand.validate).toBeUndefined()
101
+ })
102
+
103
+ it("carries skills at kind prompt when the rendered source has a non-empty list", () => {
104
+ const demand = demandOf({ rendered: rendered({ skills: ["a", "b"] }), kind: "prompt" })
105
+ expect(demand.kind).toBe("prompt")
106
+ if (demand.kind !== "prompt") throw new Error("unreachable")
107
+ expect(demand.skills).toEqual(["a", "b"])
108
+ })
109
+
110
+ it("normalizes an empty or absent skills list to undefined at kind prompt, never []", () => {
111
+ const empty = demandOf({ rendered: rendered({ skills: [] }), kind: "prompt" })
112
+ const absent = demandOf({ rendered: rendered(), kind: "prompt" })
113
+ if (empty.kind !== "prompt" || absent.kind !== "prompt") throw new Error("unreachable")
114
+ expect(empty.skills).toBeUndefined()
115
+ expect(absent.skills).toBeUndefined()
116
+ })
117
+
118
+ it("carries access at kind prompt, unrestricted on both sides when the source has none", () => {
119
+ const bare = demandOf({ rendered: rendered(), kind: "prompt" })
120
+ const restricted = demandOf({
121
+ rendered: rendered({ access: { read: ["docs/**"], write: [] } }),
122
+ kind: "prompt",
123
+ })
124
+ if (bare.kind !== "prompt" || restricted.kind !== "prompt") throw new Error("unreachable")
125
+ expect(bare.access).toEqual({ read: null, write: null })
126
+ expect(restricted.access).toEqual({ read: ["docs/**"], write: [] })
127
+ })
128
+
129
+ it("carries no access field at any non-prompt kind", () => {
130
+ for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
131
+ const demand = demandOf({ rendered: rendered({ access: { read: null, write: [] } }), kind })
132
+ expect("access" in demand).toBe(false)
133
+ }
134
+ })
135
+
136
+ it("carries no skills field at any non-prompt kind, even when the rendered source has one", () => {
137
+ for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
138
+ const demand = demandOf({ rendered: rendered({ skills: ["a"] }), kind })
139
+ expect("skills" in demand).toBe(false)
140
+ }
141
+ })
142
+ })
@@ -0,0 +1,108 @@
1
+ import type { Actor, ContentKind, RenderedDemandSource, StateName, StepAccess } from "./types.js"
2
+
3
+ /** The whole beat vocabulary a driver acts on — what to DO with a rest, not merely whether dispatching it is safe. */
4
+ export type BeatKind = "capture" | "message" | "script" | "prompt" | "stalled"
5
+
6
+ /**
7
+ * `BeatKind`'s own member list, as a value — the ONE place the five kinds are
8
+ * enumerated. Every test that needs to loop "every kind" (the JSON document,
9
+ * `DEMAND_BRIEFING`, the plain header) imports this instead of re-typing its
10
+ * own `as const satisfies readonly BeatKind[]` literal, so the type and the
11
+ * loop can never drift apart.
12
+ */
13
+ export const BEAT_KINDS: readonly BeatKind[] = ["capture", "message", "script", "prompt", "stalled"]
14
+
15
+ /**
16
+ * A resting content kind: `renderRest` never resolves a `Rest` at a commit
17
+ * state, so `beatKindOf` is never handed `"commit"` — narrowing the
18
+ * parameter here (rather than switching on all four `ContentKind`s) makes
19
+ * that case unwritable instead of merely untested.
20
+ */
21
+ export type RestingContentKind = Exclude<ContentKind, "commit">
22
+
23
+ /**
24
+ * The beat kind for a rest, in precedence order:
25
+ *
26
+ * 1. `stalled` — `Edge.ts`'s `stalledAt` verdict, passed in as a plain
27
+ * boolean (this module never resolves it itself). `stalledAt` already
28
+ * requires a clean tree, so this can never collide with `capture` below.
29
+ * 2. `capture` — a `message` rest with a DIRTY tree: the human already
30
+ * acted; a driver lands it immediately.
31
+ * 3. Otherwise, the content kind verbatim (`script`/`prompt`, or `message`
32
+ * on a clean tree).
33
+ */
34
+ export const beatKindOf = (input: {
35
+ readonly contentKind: RestingContentKind
36
+ readonly dirty: boolean
37
+ readonly stalled: boolean
38
+ }): BeatKind => {
39
+ if (input.stalled) return "stalled"
40
+ if (input.contentKind === "message" && input.dirty) return "capture"
41
+ return input.contentKind
42
+ }
43
+
44
+ /**
45
+ * The `stalled` beat's own content: a diagnosis naming the stuck step and
46
+ * the ways out. The first line is shaped so a stderr grep for
47
+ * `stalled at "<state>"` stays a stable substring (see
48
+ * `tests/integration/features/driver-doc.feature`).
49
+ */
50
+ export const stallDiagnosis = (state: StateName, actor: Actor): string =>
51
+ `stalled at "${state}": the last gtd(${actor}): ${state} turn landed an empty ` +
52
+ `attempt, the tree is clean, and another dispatch would repeat it.\n\n` +
53
+ `Two ways out:\n` +
54
+ ` - sharpen the step's prompt so the turn has something concrete to author\n` +
55
+ ` - pass allowEmpty: true to the step, so an empty turn completes it and the\n` +
56
+ ` flow decides what follows — finishing, or an escalation step after N\n` +
57
+ ` empty turns\n`
58
+
59
+ /** One dispatch session — `resume`'s own meaning lives with `src/wire/Sessions.ts`'s `resolveSession`, which produces it. */
60
+ export interface DemandSession {
61
+ readonly id: string
62
+ readonly resume: boolean
63
+ }
64
+
65
+ /**
66
+ * One driver `case` arm's whole demand: what to DO, and (only at `prompt`)
67
+ * the dispatch bookkeeping (`session`/`validate`) no other kind carries.
68
+ * Replaces `BeatFields`' flat `session`/`validate` fields (gated at
69
+ * `kind === "prompt"` by a runtime check) with a shape where a non-`prompt`
70
+ * variant cannot HOLD a session at all.
71
+ */
72
+ export type Demand =
73
+ | { readonly kind: "capture"; readonly content: string }
74
+ | { readonly kind: "message"; readonly content: string }
75
+ | { readonly kind: "script"; readonly content: string }
76
+ | { readonly kind: "stalled"; readonly content: string }
77
+ | {
78
+ readonly kind: "prompt"
79
+ readonly content: string
80
+ readonly session: DemandSession | undefined
81
+ readonly validate: string | undefined
82
+ readonly skills: readonly string[] | undefined
83
+ readonly access: StepAccess
84
+ }
85
+
86
+ /**
87
+ * Assemble one `Demand` — the ONLY place a rest's resolved content becomes
88
+ * `Demand.content` (`stallDiagnosis` at `kind === "stalled"`, the rendered
89
+ * content otherwise), and the only place `session`/`validate`/`skills`/`access` are
90
+ * attached, which the `prompt` variant's own type makes impossible to do at
91
+ * any other kind.
92
+ */
93
+ export const demandOf = (input: {
94
+ readonly rendered: Pick<RenderedDemandSource, "actor" | "content" | "state" | "skills" | "access">
95
+ readonly kind: BeatKind
96
+ readonly session?: DemandSession
97
+ readonly validate?: string
98
+ }): Demand => {
99
+ const { rendered, kind, session, validate } = input
100
+ const content =
101
+ kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor) : rendered.content
102
+ const skills =
103
+ rendered.skills !== undefined && rendered.skills.length > 0 ? rendered.skills : undefined
104
+ const access = rendered.access ?? { read: null, write: null }
105
+ return kind === "prompt"
106
+ ? { kind, content, session, validate, skills, access }
107
+ : { kind, content }
108
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ EXIT_CODES,
4
+ EXIT_OK,
5
+ EXIT_RUNTIME_ERROR,
6
+ EXIT_SIGINT,
7
+ EXIT_SIGTERM,
8
+ EXIT_USAGE_ERROR,
9
+ } from "./index.js"
10
+
11
+ describe("EXIT_CODES", () => {
12
+ it("is frozen at exactly {0, 1, 2, 130, 143} — a new code breaks every driver", () => {
13
+ expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 130, 143]))
14
+ })
15
+
16
+ it("each named constant is a member of the set", () => {
17
+ for (const code of [EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, EXIT_SIGINT, EXIT_SIGTERM]) {
18
+ expect(EXIT_CODES.has(code)).toBe(true)
19
+ }
20
+ })
21
+ })
@@ -0,0 +1,16 @@
1
+ export const EXIT_OK = 0
2
+ /** Something failed that was never a matter of how gtd was invoked — a refusal or an unexpected error. */
3
+ export const EXIT_RUNTIME_ERROR = 1
4
+ /** gtd was invoked wrong — an unknown option/command, bad arity, a scope violation, a decode failure. */
5
+ export const EXIT_USAGE_ERROR = 2
6
+ export const EXIT_SIGINT = 130
7
+ export const EXIT_SIGTERM = 143
8
+
9
+ /** Frozen: adding a code is a driver-protocol break, the same as removing a wire field. */
10
+ export const EXIT_CODES: ReadonlySet<number> = new Set([
11
+ EXIT_OK,
12
+ EXIT_RUNTIME_ERROR,
13
+ EXIT_USAGE_ERROR,
14
+ EXIT_SIGINT,
15
+ EXIT_SIGTERM,
16
+ ])
@@ -0,0 +1,29 @@
1
+ // `*` stays within one path segment, `**` crosses any number of them (zero
2
+ // included), and dotfiles are not special: this matches repository paths, not
3
+ // a shell's filename expansion.
4
+
5
+ const ESCAPE_RE = /[.+^${}()|[\]\\?]/g
6
+
7
+ const compile = (glob: string): RegExp => {
8
+ let pattern = "^"
9
+ let i = 0
10
+ while (i < glob.length) {
11
+ const char = glob[i]!
12
+ if (char !== "*") {
13
+ pattern += char.replace(ESCAPE_RE, "\\$&")
14
+ i += 1
15
+ } else if (glob[i + 1] !== "*") {
16
+ pattern += "[^/]*"
17
+ i += 1
18
+ } else if (glob[i + 2] === "/") {
19
+ pattern += "(?:.*/)?"
20
+ i += 3
21
+ } else {
22
+ pattern += ".*"
23
+ i += 2
24
+ }
25
+ }
26
+ return new RegExp(`${pattern}$`)
27
+ }
28
+
29
+ export const globMatches = (path: string, glob: string): boolean => compile(glob).test(path)
@@ -0,0 +1,22 @@
1
+ import { WIRE_SCHEMA } from "./constants.js"
2
+
3
+ export type JudgePrimitive = "noul" | "choice" | "score"
4
+
5
+ export interface JudgeQuestion {
6
+ readonly id: string
7
+ readonly primitive: JudgePrimitive
8
+ readonly instructions: string
9
+ readonly criteria: string
10
+ }
11
+
12
+ /** Everything a judge needs: a judge gate's beat carries it, stringified, in `judge`; `gtd judge run` reads it on stdin. */
13
+ export interface JudgeDocument {
14
+ readonly schema: number
15
+ readonly state: Readonly<Record<string, string>>
16
+ readonly questions: readonly JudgeQuestion[]
17
+ }
18
+
19
+ export const judgeJson = (
20
+ state: JudgeDocument["state"],
21
+ questions: JudgeDocument["questions"],
22
+ ): string => JSON.stringify({ schema: WIRE_SCHEMA, state, questions } satisfies JudgeDocument)