@pmelab/gtd 59.0.1 → 60.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +48 -32
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/testkit-BhTey1Do.mjs +30517 -0
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +11285 -19864
  10. package/dist/gtd.core.mjs +593 -0
  11. package/dist/steering.mjs +2 -2
  12. package/dist/testkit/gherkin.mjs +246 -37
  13. package/dist/testkit.mjs +3 -2
  14. package/dist/web/main.js +234 -232
  15. package/dist/wire.mjs +2 -2
  16. package/dist/workflow-kit.mjs +3 -3
  17. package/package.json +7 -7
  18. package/skills/authoring/SKILL.md +66 -60
  19. package/src/cli/Cli.test.ts +185 -184
  20. package/src/cli/Cli.ts +219 -224
  21. package/src/cli/History.ts +9 -4
  22. package/src/cli/Judges.ts +1 -1
  23. package/src/cli/Output.test.ts +35 -0
  24. package/src/cli/Output.ts +34 -3
  25. package/src/cli/Program.test.ts +78 -84
  26. package/src/cli/Program.ts +192 -128
  27. package/src/cli/ToolCommands.ts +2 -2
  28. package/src/edge/Edge.test.ts +72 -62
  29. package/src/edge/Edge.ts +6 -6
  30. package/src/flows/factory.test.ts +14 -0
  31. package/src/flows/factory.ts +22 -5
  32. package/src/flows/helpers.test.ts +35 -0
  33. package/src/flows/helpers.ts +19 -10
  34. package/src/flows/index.test.ts +66 -0
  35. package/src/flows/index.ts +109 -5
  36. package/src/flows/internal.ts +7 -0
  37. package/src/flows/runtime.test.ts +192 -1
  38. package/src/flows/runtime.ts +189 -55
  39. package/src/gherkin/git-control.feature +6 -6
  40. package/src/gherkin/index.test.ts +127 -0
  41. package/src/gherkin/index.ts +370 -51
  42. package/src/gherkin/messages.test.ts +38 -4
  43. package/src/gherkin/messages.ts +21 -4
  44. package/src/gherkin/more-steps.feature +170 -0
  45. package/src/gherkin/scenario-end.feature +101 -0
  46. package/src/gherkin/testkit.feature +83 -7
  47. package/src/install/Install.test.ts +7 -7
  48. package/src/install/Install.ts +8 -8
  49. package/src/install/Version.ts +9 -3
  50. package/src/install/index.ts +1 -1
  51. package/src/platform/CommandRunner.ts +13 -11
  52. package/src/platform/Commentary.ts +2 -2
  53. package/src/platform/Git.test.ts +4 -5
  54. package/src/platform/Git.ts +42 -38
  55. package/src/platform/Host.ts +1 -1
  56. package/src/platform/NodeServices.ts +11 -0
  57. package/src/platform/Workspace.test.ts +4 -5
  58. package/src/platform/Workspace.ts +3 -3
  59. package/src/platform/WorktreeState.test.ts +2 -3
  60. package/src/platform/WorktreeState.ts +1 -1
  61. package/src/platform/index.ts +2 -1
  62. package/src/replay/Replay.test.ts +13 -2
  63. package/src/replay/Replay.ts +19 -30
  64. package/src/replay/judgeDiffShape.test.ts +1 -1
  65. package/src/steering/Footnotes.test.ts +1 -1
  66. package/src/steering/SteeringFormats.test.ts +1 -1
  67. package/src/steering/freeform.test.ts +1 -1
  68. package/src/steering/index.test.ts +27 -1
  69. package/src/steering/index.ts +6 -104
  70. package/src/steering/internal.ts +11 -0
  71. package/src/steering/qa.test.ts +7 -2
  72. package/src/steering/reads.test.ts +42 -0
  73. package/src/steering/reads.ts +43 -0
  74. package/src/steering/registry.ts +93 -0
  75. package/src/steering/review.test.ts +2 -2
  76. package/src/step/planStart.test.ts +18 -16
  77. package/src/step/planStart.ts +12 -12
  78. package/src/testkit/TestRepo.test.ts +245 -7
  79. package/src/testkit/TestRepo.ts +271 -40
  80. package/src/testkit/imports.test.ts +58 -0
  81. package/src/testkit/imports.ts +102 -0
  82. package/src/testkit/index.test.ts +28 -0
  83. package/src/testkit/index.ts +14 -2
  84. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  85. package/src/twins/EmittedScripts.test.ts +5 -3
  86. package/src/twins/GitTiers.ts +6 -7
  87. package/src/twins/InMemRepo.ts +1 -0
  88. package/src/twins/Layers.test.ts +1 -1
  89. package/src/twins/Layers.ts +10 -3
  90. package/src/twins/cliIo.ts +9 -4
  91. package/src/wire/Script.ts +11 -0
  92. package/src/wire/WireSchema.test.ts +3 -0
  93. package/src/wire/golden/1/script.json +4 -0
  94. package/src/wire/index.ts +1 -0
  95. package/src/workflow/SteeringFormats.test.ts +1 -1
  96. package/src/workflow/compile.ts +2 -2
  97. package/src/workflow/discovery.test.ts +27 -10
  98. package/src/workflow/discovery.ts +5 -6
  99. package/src/workflow/index.ts +1 -0
  100. package/src/workflow/load.test.ts +316 -119
  101. package/src/workflow/load.ts +478 -305
  102. package/src/workflow/pin.test.ts +159 -0
  103. package/src/workflow/pin.ts +72 -0
  104. package/src/workflow/vars.ts +3 -3
  105. package/src/workflow-kit/index.test.ts +25 -0
  106. package/src/workflow-kit/index.ts +37 -4
  107. package/src/workflow-kit/review.ts +2 -2
  108. package/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
@@ -1,14 +1,6 @@
1
- import type {
2
- SteeringAction,
3
- SteeringFormat,
4
- SteeringLink,
5
- SteeringOutlineNode,
6
- SteeringPointer,
7
- } from "./SteeringFormat.js"
1
+ // `@pmelab/gtd/steering`, listed name by name. gtd's own code imports
2
+ // `./internal.js`, which adds what only the engine, the LSP and `gtd ui` use.
8
3
  export { formatFinding } from "./SteeringFormat.js"
9
- import { qaDescriptor } from "./qa.js"
10
- import { reviewDescriptor } from "./review.js"
11
- import { findingsDescriptor } from "./findings.js"
12
4
  export { findingEntries, FIX_BOX, NOISE_HEADING, type FindingEntry } from "./findings.js"
13
5
  export {
14
6
  ANSWER_FIX,
@@ -30,11 +22,7 @@ export { applySteeringEdits } from "./Edits.js"
30
22
 
31
23
  export type { InlineNode } from "./Inline.js"
32
24
 
33
- // The free-form format: the mode-less/unregistered-mode FALLBACK —
34
- // deliberately not a `REGISTRY` entry of its own, since it is never a mode a
35
- // `mode:` key names.
36
- import { freeFormFormat } from "./freeform.js"
37
- export { freeFormFormat }
25
+ export { freeFormFormat } from "./freeform.js"
38
26
 
39
27
  export type {
40
28
  BlockListItem,
@@ -50,15 +38,7 @@ export type {
50
38
  SteeringViewThread,
51
39
  } from "./SteeringFormat.js"
52
40
 
53
- /** The built-in registry: mode name → its format, in registry order. The single place the built-in formats are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
54
- const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
55
- ["qa", qaDescriptor],
56
- ["review", reviewDescriptor],
57
- ["findings", findingsDescriptor],
58
- ])
59
-
60
- /** Every built-in format name, in registry order. */
61
- export const BUILT_IN_MODE_NAMES: readonly string[] = [...REGISTRY.keys()]
41
+ export { openThreads, steeringFormatFor } from "./registry.js"
62
42
 
63
43
  // The two `qa` primitives the phone client itself needs: `src/web/screens/
64
44
  // Question.tsx` renders the free-text slot and derives answeredness in the
@@ -66,93 +46,15 @@ export const BUILT_IN_MODE_NAMES: readonly string[] = [...REGISTRY.keys()]
66
46
  // with — a second copy there would drift silently.
67
47
  export { FREE_TEXT_PLACEHOLDER, isAnswered } from "./qa.js"
68
48
 
69
- // `ModeContradiction.ts`'s round-trip check re-parses a formatted sample's
70
- // footnotes to prove the formatter moved none of them — the one consumer
71
- // outside this package that needs the footnote parser itself, not a format.
72
49
  export {
73
50
  parseFootnotes,
74
51
  parseThreads,
75
52
  parseThreadsWithFindings,
76
53
  openThreadFindings,
77
- FOOTNOTE_ACTION_TITLE,
78
- THREAD_REPLY_ACTION_TITLE,
79
54
  } from "./Footnotes.js"
80
55
 
81
- // Test-observability only: the memo-hit counter is the one way "one parse per
82
- // document" is assertable from outside `MarkdownTree.ts` — timing is not.
83
- export { getParseCount } from "./MarkdownTree.js"
84
-
85
- // A flow's `sections()` is the one caller outside this package.
86
- export { headingSectionBodies, headingSections } from "./MarkdownTree.js"
87
-
88
- /** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
89
- export const steeringFormatFor = (mode: string): SteeringFormat | undefined => REGISTRY.get(mode)
90
-
91
- /**
92
- * The ui boundary's own resolution: `mode`'s registered format, or
93
- * `freeFormFormat` when `mode` is absent or names nothing in `REGISTRY` —
94
- * never `undefined`. `resolveMode` (`SteeringMode.ts`) stays on
95
- * `steeringFormatFor` alone, so a workflow naming a mode nothing defines
96
- * still fails to compile; this fallback is deliberately reachable only from
97
- * `src/ui/View.ts` and `src/ui/Write.ts`, where a mode-less/unknown-mode
98
- * document must still render and accept a paragraph note, never refuse.
99
- */
100
- export const steeringFormatOrFreeForm = (mode: string | undefined): SteeringFormat =>
101
- (mode !== undefined ? REGISTRY.get(mode) : undefined) ?? freeFormFormat
102
-
103
- /**
104
- * `qa`-only: every OPEN question in `content` not yet answered, as
105
- * `{ question, headingLine }` pairs — enough for a refusal message ("which
106
- * question", "where"). Semantically distinct from `validate`'s findings:
107
- * a document can be perfectly well-formed (zero findings) while still having
108
- * open questions nobody has answered, which is the whole point of the
109
- * answer-completeness gate this feeds. Format-parameterised like everything
110
- * else here: `review` (or any future format with no such concept) yields
111
- * `[]` rather than this being a `qa`-specific function some caller has to
112
- * know not to call on the wrong format.
113
- */
114
- export const unansweredQuestions = (
115
- format: SteeringFormat,
116
- content: string,
117
- ): readonly { readonly question: string; readonly headingLine: number }[] =>
118
- format.unansweredQuestions?.(content) ?? []
119
-
120
- /**
121
- * Every positional thing an editor (the LSP, in a later pass) needs for one
122
- * document, off ONE `viewOf` call rather than four separate ones re-deriving
123
- * ranges each time: `outline` and `documentLinks` are already fully resolved
124
- * (each node/link carries its own range — see `SteeringOutlineNode`/
125
- * `SteeringLink`), while `pointerAt`/`actionsAt` stay closures over `content`
126
- * because they need a cursor position/selection range the caller supplies
127
- * per request. `Lsp.ts` reads its ranges straight off `outline`/`documentLinks`.
128
- */
129
- export interface SteeringEditorView {
130
- readonly outline: readonly SteeringOutlineNode[]
131
- readonly documentLinks: readonly SteeringLink[]
132
- readonly pointerAt: (position: {
133
- readonly line: number
134
- readonly character: number
135
- }) => SteeringPointer | undefined
136
- readonly actionsAt: (range: {
137
- readonly start: { readonly line: number; readonly character: number }
138
- readonly end: { readonly line: number; readonly character: number }
139
- }) => readonly SteeringAction[]
140
- }
141
-
142
- /** Builds `format`'s `SteeringEditorView` of `content` — see `SteeringEditorView`'s own doc for why this is one value, not several. Distinct from `SteeringView` (`SteeringFormat.ts`), which is the format's own node tree the phone client renders. */
143
- export const viewOf = (format: SteeringFormat, content: string): SteeringEditorView => ({
144
- outline: format.outline(content),
145
- documentLinks: format.documentLinks ? format.documentLinks(content) : [],
146
- pointerAt: (position) => format.pointerAt?.(content, position),
147
- actionsAt: (range) => format.actions(content, range),
148
- })
149
-
150
- /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
151
- export const openThreads = (
152
- format: SteeringFormat,
153
- content: string,
154
- ): readonly { readonly name: string; readonly line: number; readonly firstMe: string }[] =>
155
- format.openThreads?.(content) ?? []
56
+ // The pure counterparts of `@pmelab/gtd/flows`' text reads, usable outside a replay.
57
+ export { openQuestions, sectionBodies, sections, threads } from "./reads.js"
156
58
 
157
59
  // Line-comment `H:`/`A:` threads in changed code files: a flow's `codeThreads()`
158
60
  // and `gtd check --open-threads` are the callers outside this package.
@@ -0,0 +1,11 @@
1
+ // gtd's own imports of the steering formats: everything public, plus what only
2
+ // the engine, the LSP and `gtd ui` use.
3
+ export * from "./index.js"
4
+ export { FOOTNOTE_ACTION_TITLE, THREAD_REPLY_ACTION_TITLE } from "./Footnotes.js"
5
+ export { getParseCount, headingSectionBodies } from "./MarkdownTree.js"
6
+ export {
7
+ BUILT_IN_MODE_NAMES,
8
+ steeringFormatOrFreeForm,
9
+ unansweredQuestions,
10
+ viewOf,
11
+ } from "./registry.js"
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
2
+ import {
3
+ FOOTNOTE_ACTION_TITLE,
4
+ steeringFormatFor,
5
+ unansweredQuestions,
6
+ viewOf,
7
+ } from "./internal.js"
3
8
  import { FREE_TEXT_PLACEHOLDER, isAnswered, parseOpenQuestions } from "./qa.js"
4
- import { getParseCount } from "./index.js"
9
+ import { getParseCount } from "./internal.js"
5
10
 
6
11
  const qa = steeringFormatFor("qa")!
7
12
 
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { openQuestions, sectionBodies, sections, threads } from "./reads.js"
3
+
4
+ describe("the pure text reads", () => {
5
+ it("sections and sectionBodies read the top-level ## headings", () => {
6
+ const text = "# Title\n\n## One\n\nfirst\n\n### Sub\n\n## Two\n"
7
+ expect(sections(text)).toEqual(["One", "Two"])
8
+ expect(sectionBodies(text).map((s) => s.title)).toEqual(["One", "Two"])
9
+ expect(sectionBodies(text)[0]!.body).toContain("## One")
10
+ })
11
+
12
+ it("openQuestions lists a qa document's unanswered questions with 1-based lines", () => {
13
+ const text = [
14
+ "## Open Questions",
15
+ "",
16
+ "### Which database?",
17
+ "",
18
+ "- [ ] Postgres",
19
+ "- [ ] SQLite",
20
+ "",
21
+ ].join("\n")
22
+ expect(openQuestions(text)).toEqual([{ question: "Which database?", line: 3 }])
23
+ })
24
+
25
+ it("threads reads a footnote thread with its 1-based line and who it waits on", () => {
26
+ const text = [
27
+ "We cache it.[^cache]",
28
+ "",
29
+ "[^cache]:",
30
+ " - H: why?",
31
+ " - A: because",
32
+ "",
33
+ ].join("\n")
34
+ expect(threads(text)).toEqual([{ name: "cache", line: 3, waitingOn: "human", faults: [] }])
35
+ })
36
+
37
+ it("works outside a replay", () => {
38
+ expect(() => sections("")).not.toThrow()
39
+ expect(threads("no threads here")).toEqual([])
40
+ expect(openQuestions("")).toEqual([])
41
+ })
42
+ })
@@ -0,0 +1,43 @@
1
+ // The text reads `@pmelab/gtd/flows` offers inside a replay, here as plain
2
+ // functions: the replay context answers with these, so both read alike.
3
+ import { parseThreadsWithFindings } from "./Footnotes.js"
4
+ import { headingSectionBodies, headingSections } from "./MarkdownTree.js"
5
+ import { qaDescriptor } from "./qa.js"
6
+ import { unansweredQuestions } from "./registry.js"
7
+
8
+ /** The top-level `## ` heading texts of markdown `text`. */
9
+ export const sections = (text: string): readonly string[] => headingSections(text)
10
+
11
+ /** The top-level `## ` sections of markdown `text`, each with its own markdown, its heading line included. */
12
+ export const sectionBodies = (
13
+ text: string,
14
+ ): readonly { readonly title: string; readonly body: string }[] => headingSectionBodies(text)
15
+
16
+ /** The `qa`-format questions in `text` still unanswered, each with its heading's 1-based line. */
17
+ export const openQuestions = (
18
+ text: string,
19
+ ): readonly { readonly question: string; readonly line: number }[] =>
20
+ unansweredQuestions(qaDescriptor, text).map((q) => ({
21
+ question: q.question,
22
+ line: q.headingLine + 1,
23
+ }))
24
+
25
+ /** Every `H:`/`A:` footnote thread in `text`: its 1-based definition line, who it waits on (`"human"` means open) and its syntax faults. */
26
+ export const threads = (
27
+ text: string,
28
+ ): readonly {
29
+ readonly name: string
30
+ readonly line: number
31
+ readonly waitingOn: "human" | "agent"
32
+ readonly faults: readonly string[]
33
+ }[] => {
34
+ const { threads, findings } = parseThreadsWithFindings(text)
35
+ return threads.map((t) => ({
36
+ name: t.name,
37
+ line: t.line + 1,
38
+ waitingOn: t.waitingOn,
39
+ faults: findings
40
+ .filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
41
+ .map((f) => f.message),
42
+ }))
43
+ }
@@ -0,0 +1,93 @@
1
+ import type {
2
+ SteeringAction,
3
+ SteeringFormat,
4
+ SteeringLink,
5
+ SteeringOutlineNode,
6
+ SteeringPointer,
7
+ } from "./SteeringFormat.js"
8
+ import { qaDescriptor } from "./qa.js"
9
+ import { reviewDescriptor } from "./review.js"
10
+ import { findingsDescriptor } from "./findings.js"
11
+ // The free-form format: the mode-less/unregistered-mode FALLBACK —
12
+ // deliberately not a `REGISTRY` entry of its own, since it is never a mode a
13
+ // `mode:` key names.
14
+ import { freeFormFormat } from "./freeform.js"
15
+
16
+ /** The built-in registry: mode name → its format, in registry order. The single place the built-in formats are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
17
+ const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
18
+ ["qa", qaDescriptor],
19
+ ["review", reviewDescriptor],
20
+ ["findings", findingsDescriptor],
21
+ ])
22
+
23
+ /** Every built-in format name, in registry order. */
24
+ export const BUILT_IN_MODE_NAMES: readonly string[] = [...REGISTRY.keys()]
25
+
26
+ /** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
27
+ export const steeringFormatFor = (mode: string): SteeringFormat | undefined => REGISTRY.get(mode)
28
+
29
+ /**
30
+ * The ui boundary's own resolution: `mode`'s registered format, or
31
+ * `freeFormFormat` when `mode` is absent or names nothing in `REGISTRY` —
32
+ * never `undefined`. `resolveMode` (`SteeringMode.ts`) stays on
33
+ * `steeringFormatFor` alone, so a workflow naming a mode nothing defines
34
+ * still fails to compile; this fallback is deliberately reachable only from
35
+ * `src/ui/View.ts` and `src/ui/Write.ts`, where a mode-less/unknown-mode
36
+ * document must still render and accept a paragraph note, never refuse.
37
+ */
38
+ export const steeringFormatOrFreeForm = (mode: string | undefined): SteeringFormat =>
39
+ (mode !== undefined ? REGISTRY.get(mode) : undefined) ?? freeFormFormat
40
+
41
+ /**
42
+ * `qa`-only: every OPEN question in `content` not yet answered, as
43
+ * `{ question, headingLine }` pairs — enough for a refusal message ("which
44
+ * question", "where"). Semantically distinct from `validate`'s findings:
45
+ * a document can be perfectly well-formed (zero findings) while still having
46
+ * open questions nobody has answered, which is the whole point of the
47
+ * answer-completeness gate this feeds. Format-parameterised like everything
48
+ * else here: `review` (or any future format with no such concept) yields
49
+ * `[]` rather than this being a `qa`-specific function some caller has to
50
+ * know not to call on the wrong format.
51
+ */
52
+ export const unansweredQuestions = (
53
+ format: SteeringFormat,
54
+ content: string,
55
+ ): readonly { readonly question: string; readonly headingLine: number }[] =>
56
+ format.unansweredQuestions?.(content) ?? []
57
+
58
+ /**
59
+ * Every positional thing an editor (the LSP, in a later pass) needs for one
60
+ * document, off ONE `viewOf` call rather than four separate ones re-deriving
61
+ * ranges each time: `outline` and `documentLinks` are already fully resolved
62
+ * (each node/link carries its own range — see `SteeringOutlineNode`/
63
+ * `SteeringLink`), while `pointerAt`/`actionsAt` stay closures over `content`
64
+ * because they need a cursor position/selection range the caller supplies
65
+ * per request. `Lsp.ts` reads its ranges straight off `outline`/`documentLinks`.
66
+ */
67
+ export interface SteeringEditorView {
68
+ readonly outline: readonly SteeringOutlineNode[]
69
+ readonly documentLinks: readonly SteeringLink[]
70
+ readonly pointerAt: (position: {
71
+ readonly line: number
72
+ readonly character: number
73
+ }) => SteeringPointer | undefined
74
+ readonly actionsAt: (range: {
75
+ readonly start: { readonly line: number; readonly character: number }
76
+ readonly end: { readonly line: number; readonly character: number }
77
+ }) => readonly SteeringAction[]
78
+ }
79
+
80
+ /** Builds `format`'s `SteeringEditorView` of `content` — see `SteeringEditorView`'s own doc for why this is one value, not several. Distinct from `SteeringView` (`SteeringFormat.ts`), which is the format's own node tree the phone client renders. */
81
+ export const viewOf = (format: SteeringFormat, content: string): SteeringEditorView => ({
82
+ outline: format.outline(content),
83
+ documentLinks: format.documentLinks ? format.documentLinks(content) : [],
84
+ pointerAt: (position) => format.pointerAt?.(content, position),
85
+ actionsAt: (range) => format.actions(content, range),
86
+ })
87
+
88
+ /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
89
+ export const openThreads = (
90
+ format: SteeringFormat,
91
+ content: string,
92
+ ): readonly { readonly name: string; readonly line: number; readonly firstMe: string }[] =>
93
+ format.openThreads?.(content) ?? []
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./index.js"
2
+ import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./internal.js"
3
3
  import { parseReviewDoc, unansweredNotes } from "./review.js"
4
- import { getParseCount } from "./index.js"
4
+ import { getParseCount } from "./internal.js"
5
5
 
6
6
  describe("parseReviewDoc", () => {
7
7
  it("parses a well-formed review with one chunk, no explanations", () => {
@@ -3,25 +3,28 @@ import { describe, expect, it } from "vitest"
3
3
  import { planStart, type StartOutcome } from "./planStart.js"
4
4
  import { InMemRepo, testLayers } from "../twins/index.js"
5
5
  import { ConfigService } from "../workflow/index.js"
6
+ import { GtdUsageError } from "../platform/index.js"
6
7
 
7
- const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
8
+ const WORKFLOW = `import { agent, human, workflow } from "@pmelab/gtd/flows"
8
9
 
9
10
  export default async () => {
10
11
  await human("idle", { message: "hi" })
11
12
  await agent("work", "work-prompt")
12
13
  }
13
14
 
14
- export const working = async () => {
15
- await agent("work", "work-prompt")
16
- }
17
-
18
- export const reviewcheck = async () => {
19
- await agent("work", "work-prompt")
20
- }
21
-
22
- export const defaults = { base: "" }
15
+ export const working = workflow(
16
+ async () => {
17
+ await agent("work", "work-prompt")
18
+ },
19
+ { defaults: { base: "" } },
20
+ )
23
21
 
24
- export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base ?? "") : undefined)
22
+ export const reviewcheck = workflow(
23
+ async () => {
24
+ await agent("work", "work-prompt")
25
+ },
26
+ { defaults: { base: "" }, base: (_workflow, vars) => vars.base ?? "" },
27
+ )
25
28
  `
26
29
 
27
30
  const repoAt = (): InMemRepo => {
@@ -85,15 +88,14 @@ describe("planStart", () => {
85
88
  })
86
89
  })
87
90
 
88
- it("refuses an undeclared --var name", async () => {
91
+ it("fails an undeclared --var name as a usage error", async () => {
89
92
  const repo = repoAt()
90
- const plan = await start(repo, "idle", "human", {
93
+ const failure = await start(repo, "idle", "human", {
91
94
  workflow: "working",
92
95
  commandLabel: "gtd test",
93
96
  vars: { nope: "x" },
94
- })
95
- expect(plan.kind).toBe("refusal")
96
- expect(plan.kind === "refusal" && plan.message).toContain("not declared by this workflow")
97
+ }).catch((e: unknown) => e)
98
+ expect(String(failure)).toContain(`${GtdUsageError.name}: gtd test: --var name(s) not declared`)
97
99
  })
98
100
 
99
101
  describe("a workflow's diff base", () => {
@@ -40,7 +40,7 @@ const checkOverrides = (
40
40
  varOverrides: Record<string, string>,
41
41
  declaredNames: readonly string[],
42
42
  envNames: readonly string[],
43
- ): Effect.Effect<StartOutcome | undefined, Error> =>
43
+ ): Effect.Effect<void, GtdUsageError> =>
44
44
  Effect.gen(function* () {
45
45
  const environmentOnly = Object.keys(varOverrides).filter(
46
46
  (n) => !declaredNames.includes(n) && envNames.includes(n),
@@ -56,17 +56,18 @@ const checkOverrides = (
56
56
  }
57
57
  const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n))
58
58
  if (undeclared.length > 0) {
59
- return refusal(
60
- `${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(
61
- ", ",
62
- )} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`,
59
+ return yield* Effect.fail(
60
+ new GtdUsageError(
61
+ `${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(
62
+ ", ",
63
+ )} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`,
64
+ ),
63
65
  )
64
66
  }
65
- return undefined
66
67
  })
67
68
 
68
69
  /**
69
- * `gtd --workflow <name>`: start a brand NEW process on a workflow, writing an
70
+ * `gtd start <name>`: start a brand NEW process on a workflow, writing an
70
71
  * opening commit that names the workflow's first step and carries a
71
72
  * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
72
73
  * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
@@ -102,13 +103,12 @@ export const planStart = (
102
103
  if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`)
103
104
  const { firstStep } = first
104
105
 
105
- const overrideRefusal = yield* checkOverrides(
106
+ yield* checkOverrides(
106
107
  commandLabel,
107
108
  varOverrides,
108
109
  Object.keys(workflow.vars),
109
110
  Object.keys(workflow.env),
110
111
  )
111
- if (overrideRefusal !== undefined) return overrideRefusal
112
112
 
113
113
  const baseOf = workflow.def.base
114
114
  const flags = yield* StartFlags
@@ -158,11 +158,11 @@ const resolveBase = (
158
158
  const git = yield* GitService
159
159
  const rendered = value.trim()
160
160
  const target = rendered === "" ? yield* git.defaultBranch() : rendered
161
- const resolvedBase = yield* Effect.either(git.resolveRef(target))
162
- if (resolvedBase._tag === "Left") {
161
+ const resolvedBase = yield* Effect.result(git.resolveRef(target))
162
+ if (resolvedBase._tag === "Failure") {
163
163
  return refusal(`${commandLabel}: "${target}" does not resolve to a commit`)
164
164
  }
165
- const mergeBase = yield* git.mergeBase(resolvedBase.right, "HEAD")
165
+ const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD")
166
166
  if (Option.isNone(mergeBase)) {
167
167
  return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`)
168
168
  }