@pmelab/gtd 24.0.0 → 24.2.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 (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,642 @@
1
+ // Each block is recognized by RE-RUNNING the same `GitScript.ts`/`Emit.ts`
2
+ // builder against values extracted from it and comparing strings — never by
3
+ // duplicating a builder's template as a regex — so an unrecognized block
4
+ // fails loudly instead of silently passing through.
5
+
6
+ import {
7
+ commitAll,
8
+ commitAsIs,
9
+ deleteRef,
10
+ discardPending,
11
+ hardResetTo,
12
+ mixedResetTo,
13
+ shellQuote,
14
+ softResetTo,
15
+ updateRef,
16
+ binaryGuard,
17
+ DID_NOT_RUN_COMMENT,
18
+ failurePromptWrapper,
19
+ fileExistsGuard,
20
+ PRESENTATION_FAILURE_WARNING,
21
+ PRESENTATION_ONLY_COMMENT,
22
+ buildModeContradictionCheck,
23
+ contradictionMessage,
24
+ modeContradictionSkipNotice,
25
+ OUTCOME_MARKER,
26
+ } from "../emit/index.js"
27
+ import { steeringFormatFor, type SteeringFormat } from "../steering/index.js"
28
+ import type { InMemRepo } from "./InMemRepo.js"
29
+ import type { ScriptedCommand } from "./Layers.js"
30
+
31
+ export interface AppliedScriptResult {
32
+ readonly ok: boolean
33
+ /** Present exactly when `ok` is `false` — names the block/line that stopped the script. */
34
+ readonly error?: string
35
+ }
36
+
37
+ /**
38
+ * Reverses `GitScript.ts`'s `shellQuote`: extracts every `'...'` token from
39
+ * `text`, in order, unescaping the `'\''` (close-literal-open) sequence back
40
+ * to a plain `'`. Position-agnostic on purpose — a builder's block may carry
41
+ * the same value quoted more than once (`commitAllowEmpty`'s retry line
42
+ * repeats the message), so callers take whichever index they need rather
43
+ * than relying on a fixed token count.
44
+ */
45
+ const extractQuotedTokens = (text: string): string[] => {
46
+ const tokens: string[] = []
47
+ let i = 0
48
+ while (i < text.length) {
49
+ if (text[i] !== "'") {
50
+ i += 1
51
+ continue
52
+ }
53
+ i += 1
54
+ let value = ""
55
+ while (i < text.length) {
56
+ if (text[i] === "'") {
57
+ if (text.slice(i, i + 4) === "'\\''") {
58
+ value += "'"
59
+ i += 4
60
+ continue
61
+ }
62
+ i += 1
63
+ break
64
+ }
65
+ value += text[i]
66
+ i += 1
67
+ }
68
+ tokens.push(value)
69
+ }
70
+ return tokens
71
+ }
72
+
73
+ type BlockOutcome =
74
+ | { readonly kind: "noop" }
75
+ | { readonly kind: "applied" }
76
+ | { readonly kind: "failed"; readonly error: string }
77
+ /**
78
+ * `fileExistsGuard` tripping on an absent file. The one shape that succeeds
79
+ * EARLY, skipping every remaining block — every other guard here either
80
+ * no-ops or fails the script.
81
+ */
82
+ | { readonly kind: "stopped" }
83
+
84
+ const SET_FLAGS_RE = /^set\s+-\S+(\s+\S+)*$/
85
+
86
+ const recognizeSetFlags = (block: string): BlockOutcome | undefined =>
87
+ SET_FLAGS_RE.test(block) ? { kind: "noop" } : undefined
88
+
89
+ /**
90
+ * This module's own invented precondition-assertion shape, not a real gtd
91
+ * convention: a `[ ... ] || { ...; exit 1; }` guard on the current `HEAD`,
92
+ * matching `set -euo pipefail` semantics — a tripped precondition must stop
93
+ * the script, exactly like a failed builder line would.
94
+ */
95
+ export const preconditionHeadEquals = (hash: string): string =>
96
+ `[ "$(git rev-parse HEAD)" = '${hash}' ] || { echo "precondition failed: HEAD moved" >&2; exit 1; }`
97
+
98
+ const PRECONDITION_RE =
99
+ /^\[ "\$\(git rev-parse HEAD\)" = '([0-9a-f]{40})' \] \|\| \{ echo "precondition failed: HEAD moved" >&2; exit 1; \}$/
100
+
101
+ const recognizePrecondition = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
102
+ const match = PRECONDITION_RE.exec(block)
103
+ if (!match) return undefined
104
+ const expected = match[1]!
105
+ const actual = repo.resolveRef("HEAD")
106
+ if (actual === expected) return { kind: "noop" }
107
+ return {
108
+ kind: "failed",
109
+ error: `precondition failed: HEAD is ${actual ?? "(no commits)"}, expected ${expected}`,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The 8 `GitScript.ts` builders. Each branch checks a cheap literal PREFIX
115
+ * first (to pick which builder to try), then re-derives the builder's
116
+ * arguments from the block's quoted tokens and confirms the match by calling
117
+ * the SAME builder and comparing strings — so a block is only ever "applied"
118
+ * when it is byte-for-byte what that builder would have produced.
119
+ */
120
+ /** The `if ! out=$(<git command> 2>&1); then` head of an if/case/fi builder — everything from its opening up to the terminator, however many lines its quoted argument spans. */
121
+ const CONDITION_TERMINATOR = " 2>&1); then"
122
+
123
+ const conditionStatement = (block: string): string => {
124
+ const start = block.indexOf("if ! out=$(")
125
+ if (start === -1) return block
126
+ const end = block.indexOf(CONDITION_TERMINATOR, start)
127
+ return end === -1 ? block.slice(start) : block.slice(start, end)
128
+ }
129
+
130
+ // fallow-ignore-next-line complexity
131
+ const recognizeGitBuilders = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
132
+ if (block === discardPending()) {
133
+ repo.discardPending()
134
+ return { kind: "applied" }
135
+ }
136
+
137
+ // The two multi-line if/case/fi builders below re-quote their message past
138
+ // their opening `if ! out=$(...` statement — tokens are extracted from THAT
139
+ // statement only, or a quoted fragment further down would pollute the
140
+ // extracted args. Sliced by its `2>&1); then` terminator rather than taken
141
+ // as one LINE, because a commit message is routinely multi-line (every
142
+ // trailer-carrying subject is) and its later lines are part of the same
143
+ // quoted argument.
144
+ const conditionLine = conditionStatement(block)
145
+
146
+ if (block.startsWith("git add -A &&\n")) {
147
+ const [message] = extractQuotedTokens(conditionLine)
148
+ if (message !== undefined && commitAll(message) === block) {
149
+ repo.commitAllWithPrefix(message)
150
+ return { kind: "applied" }
151
+ }
152
+ return undefined
153
+ }
154
+
155
+ if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
156
+ const [message] = extractQuotedTokens(conditionLine)
157
+ if (message !== undefined && commitAsIs(message) === block) {
158
+ repo.commitAsIs(message)
159
+ return { kind: "applied" }
160
+ }
161
+ return undefined
162
+ }
163
+
164
+ if (block.startsWith("git reset --soft ")) {
165
+ const [ref] = extractQuotedTokens(block)
166
+ if (ref !== undefined && softResetTo(ref) === block) {
167
+ repo.softResetTo(ref)
168
+ return { kind: "applied" }
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ if (block.startsWith("git reset --mixed ")) {
174
+ const [ref] = extractQuotedTokens(block)
175
+ if (ref !== undefined && mixedResetTo(ref) === block) {
176
+ repo.mixedResetTo(ref)
177
+ return { kind: "applied" }
178
+ }
179
+ return undefined
180
+ }
181
+
182
+ if (block.startsWith("git reset --hard ")) {
183
+ const [ref] = extractQuotedTokens(block)
184
+ if (ref !== undefined && hardResetTo(ref) === block) {
185
+ repo.hardResetTo(ref)
186
+ return { kind: "applied" }
187
+ }
188
+ return undefined
189
+ }
190
+
191
+ if (block.startsWith("git update-ref -d ")) {
192
+ const [ref] = extractQuotedTokens(block)
193
+ if (ref !== undefined && deleteRef(ref) === block) {
194
+ repo.deleteRef(ref)
195
+ return { kind: "applied" }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ if (block.startsWith("git update-ref ")) {
201
+ const [ref, hash] = extractQuotedTokens(block)
202
+ if (ref !== undefined && hash !== undefined && updateRef(ref, hash) === block) {
203
+ repo.updateRef(ref, hash)
204
+ return { kind: "applied" }
205
+ }
206
+ return undefined
207
+ }
208
+
209
+ return undefined
210
+ }
211
+
212
+ /** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
213
+ const recognizeFileExistsGuard = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
214
+ const [file] = extractQuotedTokens(block)
215
+ if (file === undefined || fileExistsGuard(file) !== block) return undefined
216
+ return repo.readFile(file) !== undefined ? { kind: "noop" } : { kind: "stopped" }
217
+ }
218
+
219
+ /** `binaryGuard`. The in-memory tier has no real `$PATH` to probe — a scripted command IS the stand-in for "this binary exists" — so this always no-ops; the guard actually tripping is `@live`-only coverage. */
220
+ const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/
221
+
222
+ const recognizeBinaryGuard = (block: string): BlockOutcome | undefined => {
223
+ const [binary, , message] = extractQuotedTokens(block)
224
+ if (binary === undefined || message === undefined) return undefined
225
+ const match = BINARY_GUARD_MESSAGE_RE.exec(message)
226
+ if (!match) return undefined
227
+ const [, mode, key, messageBinary] = match
228
+ if (messageBinary !== binary) return undefined
229
+ if (binaryGuard(binary, mode!, key as "format" | "validate") !== block) return undefined
230
+ return { kind: "noop" }
231
+ }
232
+
233
+ /** Recognized by `OUTCOME_MARKER` rather than re-derived and string-compared: an outcome only prints, so no git effect a loose match could miss — unlike `recognizeGitBuilders`, where a near-miss would silently skip a real mutation. */
234
+ const recognizeOutcome = (block: string): BlockOutcome | undefined =>
235
+ block.startsWith(OUTCOME_MARKER) ? { kind: "noop" } : undefined
236
+
237
+ /**
238
+ * `failurePromptWrapper`. The constants below bracket the template's one
239
+ * variable part; the extracted pair is confirmed by RE-RUNNING the real
240
+ * builder and string-comparing. On a failing inner outcome it prefixes the
241
+ * prompt onto the error — the fake's stand-in for what the real wrapper
242
+ * prints before exiting non-zero.
243
+ */
244
+ const FILE_VAR_PREFIX_RE = /^export GTD_FILE=('(?:[^']|'\\'')*')\n/
245
+
246
+ /** The command after `withFileVar`'s `export GTD_FILE=…` line, `$GTD_FILE` expanded the way the shell would. */
247
+ const expandFileVar = (text: string): string | undefined => {
248
+ const match = FILE_VAR_PREFIX_RE.exec(text)
249
+ if (match === null) return undefined
250
+ const [file] = extractQuotedTokens(match[1]!)
251
+ if (file === undefined) return undefined
252
+ return text
253
+ .slice(match[0].length)
254
+ .replace(/"\$GTD_FILE"|"\$\{GTD_FILE\}"/g, shellQuote(file))
255
+ .replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file)
256
+ }
257
+
258
+ /** A mode's `format:`/`validate:` command: `gtd check`, or a scripted command. */
259
+ const recognizeModeCommand = (
260
+ repo: InMemRepo,
261
+ commands: ReadonlyMap<string, ScriptedCommand>,
262
+ command: string,
263
+ ): BlockOutcome | undefined =>
264
+ recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command)
265
+
266
+ const FAILURE_PROMPT_HEADER = 'gtd_validate_status=0\ngtd_validate_out="$( {\n'
267
+ const FAILURE_PROMPT_MIDDLE =
268
+ '\n} 2>&1 )" || gtd_validate_status=$?\n' +
269
+ 'if [ "$gtd_validate_status" -ne 0 ]; then\n' +
270
+ " printf '%s\\n\\n%s\\n' "
271
+ const FAILURE_PROMPT_FOOTER = ' "$gtd_validate_out"\n exit "$gtd_validate_status"\nfi'
272
+
273
+ const recognizeFailurePromptWrapper = (
274
+ repo: InMemRepo,
275
+ commands: ReadonlyMap<string, ScriptedCommand>,
276
+ block: string,
277
+ ): BlockOutcome | undefined => {
278
+ if (!block.startsWith(FAILURE_PROMPT_HEADER) || !block.endsWith(FAILURE_PROMPT_FOOTER)) {
279
+ return undefined
280
+ }
281
+ const middleIndex = block.indexOf(FAILURE_PROMPT_MIDDLE, FAILURE_PROMPT_HEADER.length)
282
+ if (middleIndex === -1) return undefined
283
+
284
+ const inner = block.slice(FAILURE_PROMPT_HEADER.length, middleIndex)
285
+ const promptQuoted = block.slice(
286
+ middleIndex + FAILURE_PROMPT_MIDDLE.length,
287
+ block.length - FAILURE_PROMPT_FOOTER.length,
288
+ )
289
+ const [prompt] = extractQuotedTokens(promptQuoted)
290
+ if (prompt === undefined || failurePromptWrapper(inner, prompt) !== block) return undefined
291
+
292
+ const innerOutcome = recognizeModeCommand(repo, commands, expandFileVar(inner) ?? inner)
293
+ if (innerOutcome === undefined) return undefined
294
+ if (innerOutcome.kind === "failed") {
295
+ return { kind: "failed", error: `${prompt}\n\n${innerOutcome.error}` }
296
+ }
297
+ return innerOutcome
298
+ }
299
+
300
+ /** `modeContradictionSkipNotice` — a mode whose validator is EXTERNAL. Re-derived from the mode name embedded in its own decoded message, keeping the "call the real builder, never hand-copy its template" discipline. */
301
+ const SKIP_NOTICE_MODE_RE = /mode "([^"]+)" has an external validate:/
302
+
303
+ const recognizeModeContradictionSkipNotice = (block: string): BlockOutcome | undefined => {
304
+ // Two quoted tokens: the literal `printf` format string `'%s\n'` first,
305
+ // then the message itself.
306
+ const [, message] = extractQuotedTokens(block)
307
+ if (message === undefined) return undefined
308
+ const match = SKIP_NOTICE_MODE_RE.exec(message)
309
+ if (!match) return undefined
310
+ const mode = match[1]!
311
+ if (modeContradictionSkipNotice(mode) !== block) return undefined
312
+ return { kind: "noop" }
313
+ }
314
+
315
+ /** Everything `parseModeContradictionCheck` recovers from a matched block — enough for `simulateModeContradictionCheck` to run it, with no further parsing. */
316
+ interface ParsedModeContradictionCheck {
317
+ readonly mode: string
318
+ readonly samplePath: string
319
+ readonly sample: string
320
+ readonly formatCommand: string
321
+ readonly format: SteeringFormat
322
+ }
323
+
324
+ /** The printf line's own pieces — the sample bytes, the scratch path, and the literal PREFIX text (up to and including that path) — or `undefined` when `block` doesn't open with `printf '%s' ...`. */
325
+ const parsePrintfPrefix = (
326
+ block: string,
327
+ ):
328
+ | { readonly sample: string; readonly samplePath: string; readonly prefix: string }
329
+ | undefined => {
330
+ if (!block.startsWith("printf '%s' ")) return undefined
331
+ // Three leading quoted tokens: the literal printf format string `'%s'`
332
+ // first, then the sample, then the scratch path.
333
+ const [, sample, samplePath] = extractQuotedTokens(block)
334
+ if (sample === undefined || samplePath === undefined) return undefined
335
+ const prefix = `printf '%s' ${shellQuote(sample)} > ${shellQuote(samplePath)}`
336
+ if (!block.startsWith(`${prefix}\n`)) return undefined
337
+ return { sample, samplePath, prefix }
338
+ }
339
+
340
+ /** The mode name and `format:` command sandwiched between the printf `prefix` and the fixed `gtd check <mode> <pathQ> >/dev/null ...` line, or `undefined` when that line isn't there right after it. */
341
+ const parseModeAndFormatCommand = (
342
+ block: string,
343
+ prefix: string,
344
+ pathQ: string,
345
+ ): { readonly mode: string; readonly formatCommand: string } | undefined => {
346
+ const suffix = ` ${pathQ} >/dev/null 2>&1 || {\n`
347
+ const suffixIndex = block.indexOf(suffix, prefix.length + 1)
348
+ if (suffixIndex === -1) return undefined
349
+ const lineStart = block.lastIndexOf("\ngtd check ", suffixIndex)
350
+ if (lineStart === -1) return undefined
351
+ const mode = block.slice(lineStart + "\ngtd check ".length, suffixIndex)
352
+ if (mode.length === 0 || /\s/.test(mode)) return undefined
353
+ return { mode, formatCommand: block.slice(prefix.length + 1, lineStart) }
354
+ }
355
+
356
+ /** Parses `block` back into the real builder's inputs. Split from the simulation so extraction (many guards, no effects) and simulation (few guards, all effects) stay separate. A wrong guess fails to parse rather than mis-simulating. */
357
+ const parseModeContradictionCheck = (block: string): ParsedModeContradictionCheck | undefined => {
358
+ const prefixParts = parsePrintfPrefix(block)
359
+ if (prefixParts === undefined) return undefined
360
+ const { sample, samplePath, prefix } = prefixParts
361
+
362
+ const modeParts = parseModeAndFormatCommand(block, prefix, shellQuote(samplePath))
363
+ if (modeParts === undefined) return undefined
364
+ const { mode, formatCommand } = modeParts
365
+
366
+ const format = steeringFormatFor(mode)
367
+ if (format === undefined) return undefined
368
+ if (buildModeContradictionCheck({ mode, samplePath, sample, formatCommand }) !== block) {
369
+ return undefined
370
+ }
371
+ return { mode, samplePath, sample, formatCommand, format }
372
+ }
373
+
374
+ /**
375
+ * One parsed round-trip: write the sample, run its `format:` through the
376
+ * scripted-command table (real bash is unreachable in this tier), re-validate,
377
+ * and clean up on every path out, mirroring the real script's `rm -f` on both
378
+ * branches. An unscripted `format:` fails loudly rather than succeeding.
379
+ */
380
+ const simulateModeContradictionCheck = (
381
+ repo: InMemRepo,
382
+ commands: ReadonlyMap<string, ScriptedCommand>,
383
+ parsed: ParsedModeContradictionCheck,
384
+ ): BlockOutcome => {
385
+ const { mode, samplePath, sample, formatCommand, format } = parsed
386
+ repo.writeFile(samplePath, sample)
387
+ const command = expandFileVar(formatCommand) ?? formatCommand
388
+ const formatOutcome = recognizeScriptedCommand(repo, commands, command)
389
+ if (formatOutcome === undefined) {
390
+ repo.deleteFile(samplePath)
391
+ return {
392
+ kind: "failed",
393
+ error: `unscripted command "${command}" — declare it with a Given step`,
394
+ }
395
+ }
396
+ if (formatOutcome.kind === "failed") {
397
+ repo.deleteFile(samplePath)
398
+ return formatOutcome
399
+ }
400
+ const formatted = repo.readFile(samplePath) ?? sample
401
+ const findings = format.validate(formatted)
402
+ repo.deleteFile(samplePath)
403
+ if (findings.length === 0) return { kind: "noop" }
404
+ return {
405
+ kind: "failed",
406
+ error: `${contradictionMessage(mode, formatCommand)}\n${formatted}`,
407
+ }
408
+ }
409
+
410
+ /**
411
+ * `src/emit/ModeContradiction.ts`'s `buildModeContradictionCheck` — the
412
+ * contradiction round-trip block `resolveValidateScript` (`src/cli/Program.ts`)
413
+ * emits ahead of `fileExistsGuard` for a mode with a live built-in validator
414
+ * and a declared `format:`. Carries no blank line of its own, so it always
415
+ * arrives here as ONE block.
416
+ */
417
+ const recognizeModeContradictionCheck = (
418
+ repo: InMemRepo,
419
+ commands: ReadonlyMap<string, ScriptedCommand>,
420
+ block: string,
421
+ ): BlockOutcome | undefined => {
422
+ const parsed = parseModeContradictionCheck(block)
423
+ return parsed === undefined ? undefined : simulateModeContradictionCheck(repo, commands, parsed)
424
+ }
425
+
426
+ const GTD_CHECK_RE = /^gtd check (\S+) (.+)$/
427
+
428
+ /** `gtd check <mode> <file>` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
429
+ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
430
+ const match = GTD_CHECK_RE.exec(block)
431
+ if (!match) return undefined
432
+ const mode = match[1]!
433
+ const [file] = extractQuotedTokens(match[2]!)
434
+ const format = steeringFormatFor(mode)
435
+ if (format === undefined || file === undefined) {
436
+ return { kind: "failed", error: `gtd check: no built-in steering format named "${mode}"` }
437
+ }
438
+ // An ABSENT file has nothing to check and exits 0 — `runCheckCommand`'s own
439
+ // documented behavior. Validating `""` instead would report every "missing
440
+ // header" finding the format has, turning "the reviewer deleted the file"
441
+ // (a case the review-doc guard owns) into a script failure.
442
+ const content = repo.readFile(file)
443
+ if (content === undefined) return { kind: "noop" }
444
+ const findings = format.validate(content)
445
+ if (findings.length > 0) {
446
+ const formatted = findings.map((f) =>
447
+ f.line !== undefined ? `${file}:${f.line + 1}: ${f.message}` : f.message,
448
+ )
449
+ return { kind: "failed", error: `gtd check ${mode} ${file}: ${formatted.join("; ")}` }
450
+ }
451
+ return { kind: "noop" }
452
+ }
453
+
454
+ const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
455
+
456
+ /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
457
+ const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
458
+ const match = GTD_UNCHECK_RE.exec(block)
459
+ if (!match) return undefined
460
+ const [file] = extractQuotedTokens(match[1]!)
461
+ if (file === undefined) return undefined
462
+ const content = repo.readFile(file)
463
+ if (content === undefined) return { kind: "noop" }
464
+ const format = steeringFormatFor("review")
465
+ const cleared = format === undefined ? content : format.clearTicks(content)
466
+ if (cleared !== content) repo.writeFile(file, cleared)
467
+ return { kind: "noop" }
468
+ }
469
+
470
+ /**
471
+ * `Emit.ts`'s `combinedScript` leading comment — `gtd land`/`gtd --workflow`'s
472
+ * whole plain-text artifact opens with this line ahead of the required
473
+ * script, so recognizing it as a no-op (like the retry-helper function
474
+ * definition) keeps the `@inmem` tier green.
475
+ */
476
+ const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
477
+ block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
478
+
479
+ /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
480
+ const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
481
+ const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
482
+
483
+ const recognizePresentationSubshell = (
484
+ repo: InMemRepo,
485
+ commands: ReadonlyMap<string, ScriptedCommand>,
486
+ block: string,
487
+ ): BlockOutcome | undefined => {
488
+ if (
489
+ !block.startsWith(PRESENTATION_SUBSHELL_PREFIX) ||
490
+ !block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)
491
+ ) {
492
+ return undefined
493
+ }
494
+ const inner = block.slice(
495
+ PRESENTATION_SUBSHELL_PREFIX.length,
496
+ block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
497
+ )
498
+ applyEmittedScript(repo, commands, inner)
499
+ return { kind: "noop" }
500
+ }
501
+
502
+ /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
503
+ const recognizeScriptedCommand = (
504
+ repo: InMemRepo,
505
+ commands: ReadonlyMap<string, ScriptedCommand>,
506
+ block: string,
507
+ ): BlockOutcome | undefined => {
508
+ const scripted = commands.get(block)
509
+ if (scripted === undefined) return undefined
510
+ if (scripted.kind === "rewrite") {
511
+ repo.writeFile(scripted.file, scripted.content)
512
+ return { kind: "applied" }
513
+ }
514
+ if (scripted.status !== 0) {
515
+ return {
516
+ kind: "failed",
517
+ error: `scripted command "${block}" exited ${scripted.status}: ${scripted.output}`,
518
+ }
519
+ }
520
+ return { kind: "noop" }
521
+ }
522
+
523
+ /**
524
+ * Splits on blank lines, IGNORING those inside a single-quoted string: every
525
+ * trailer-carrying commit subject spans one, so a naive `split(/\n{2,}/)`
526
+ * tears a `commitAll` block into fragments that recognize as nothing.
527
+ *
528
+ * Quote depth toggles on every `'`, with one load-bearing correction: the `\`
529
+ * in `'\''` escapes its following `'` OUTSIDE any quote, so that middle quote
530
+ * must not toggle. Deliberately not a bash lexer — gtd's emission vocabulary
531
+ * is closed, and no double-quoted string in it ever contains a `'`.
532
+ */
533
+ const trackQuoteState = (line: string, quoted: boolean): boolean => {
534
+ let inQuote = quoted
535
+ let index = 0
536
+ while (index < line.length) {
537
+ const char = line[index]
538
+ if (!inQuote && char === "\\") {
539
+ index += 2
540
+ continue
541
+ }
542
+ if (char === "'") inQuote = !inQuote
543
+ index += 1
544
+ }
545
+ return inQuote
546
+ }
547
+
548
+ /**
549
+ * The optional half is a bare `(\n...\n) || <warning>` whose inner script has
550
+ * its OWN blank-line-separated sections, so `splitBlocks` needs this depth to
551
+ * keep them from tearing it apart. Safe to key on a bare `(`/`)` line because
552
+ * no other shape in this closed vocabulary produces one.
553
+ */
554
+ const nextSubshellDepth = (trimmedLine: string, depth: number): number => {
555
+ if (trimmedLine === "(") return depth + 1
556
+ if (depth > 0 && trimmedLine.startsWith(")")) return depth - 1
557
+ return depth
558
+ }
559
+
560
+ const splitBlocks = (script: string): readonly string[] => {
561
+ const blocks: string[] = []
562
+ let current = ""
563
+ let quoted = false
564
+ let subshellDepth = 0
565
+ for (const line of script.trim().split("\n")) {
566
+ const trimmedLine = line.trim()
567
+ if (!quoted && subshellDepth === 0 && trimmedLine.length === 0) {
568
+ if (current.trim().length > 0) blocks.push(current.trim())
569
+ current = ""
570
+ continue
571
+ }
572
+ current += (current.length > 0 ? "\n" : "") + line
573
+ quoted = trackQuoteState(line, quoted)
574
+ if (!quoted) subshellDepth = nextSubshellDepth(trimmedLine, subshellDepth)
575
+ }
576
+ if (current.trim().length > 0) blocks.push(current.trim())
577
+ return blocks
578
+ }
579
+
580
+ /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
581
+ const recognizersFor = (
582
+ repo: InMemRepo,
583
+ commands: ReadonlyMap<string, ScriptedCommand>,
584
+ ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
585
+ (block) => recognizeSetFlags(block),
586
+ (block) => recognizeDidNotRunComment(block),
587
+ (block) => recognizePrecondition(repo, block),
588
+ (block) => recognizeFileExistsGuard(repo, block),
589
+ (block) => recognizeBinaryGuard(block),
590
+ (block) => recognizeGitBuilders(repo, block),
591
+ (block) => recognizeFailurePromptWrapper(repo, commands, block),
592
+ (block) => recognizePresentationSubshell(repo, commands, block),
593
+ (block) => recognizeModeContradictionSkipNotice(block),
594
+ (block) => recognizeModeContradictionCheck(repo, commands, block),
595
+ (block) => recognizeGtdCheck(repo, block),
596
+ (block) => recognizeGtdUncheck(repo, block),
597
+ (block) => {
598
+ const command = expandFileVar(block)
599
+ return command === undefined ? undefined : recognizeModeCommand(repo, commands, command)
600
+ },
601
+ (block) => recognizeOutcome(block),
602
+ (block) => recognizeScriptedCommand(repo, commands, block),
603
+ ]
604
+
605
+ const recognizeBlock = (
606
+ recognizers: ReadonlyArray<(block: string) => BlockOutcome | undefined>,
607
+ block: string,
608
+ ): BlockOutcome | undefined => {
609
+ for (const recognize of recognizers) {
610
+ const outcome = recognize(block)
611
+ if (outcome !== undefined) return outcome
612
+ }
613
+ return undefined
614
+ }
615
+
616
+ /**
617
+ * Applies `script` to `repo` block by block, stopping at the first failure —
618
+ * an unrecognized block, a tripped precondition, a failing `gtd check`, or a
619
+ * non-zero scripted-command exit — exactly like `set -euo pipefail` stops a
620
+ * real script. No block after the failing one is applied.
621
+ */
622
+ export const applyEmittedScript = (
623
+ repo: InMemRepo,
624
+ commands: ReadonlyMap<string, ScriptedCommand>,
625
+ script: string,
626
+ ): AppliedScriptResult => {
627
+ const recognizers = recognizersFor(repo, commands)
628
+
629
+ for (const block of splitBlocks(script)) {
630
+ const outcome = recognizeBlock(recognizers, block)
631
+
632
+ if (outcome === undefined) {
633
+ return { ok: false, error: `unrecognized script block: ${block.split("\n")[0]}` }
634
+ }
635
+ if (outcome.kind === "stopped") return { ok: true }
636
+ if (outcome.kind === "failed") {
637
+ return { ok: false, error: outcome.error }
638
+ }
639
+ }
640
+
641
+ return { ok: true }
642
+ }