@pmelab/gtd 59.0.1 → 60.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -33
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/{testkit-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +751 -645
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +245 -36
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +183 -182
- package/src/cli/Cli.ts +164 -186
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +31 -1
- package/src/cli/Program.test.ts +65 -79
- package/src/cli/Program.ts +157 -118
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +3 -3
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +188 -54
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +9 -9
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +2 -6
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +3 -3
- package/src/workflow/load.test.ts +256 -116
- package/src/workflow/load.ts +169 -146
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { freeFormFormat } from "./freeform.js"
|
|
3
|
-
import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./
|
|
3
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./internal.js"
|
|
4
4
|
|
|
5
5
|
/** Applies edits back-to-front — a local copy of the file's own splice pattern, mirroring `qa.test.ts`/`review.test.ts`'s identical helper. */
|
|
6
6
|
const applyEdits = (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import
|
|
2
|
+
import * as published from "./index.js"
|
|
3
|
+
import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./internal.js"
|
|
3
4
|
|
|
4
5
|
const qa = steeringFormatFor("qa")!
|
|
5
6
|
const review = steeringFormatFor("review")!
|
|
@@ -60,3 +61,28 @@ describe("viewOf", () => {
|
|
|
60
61
|
expect(view.documentLinks[0]!.line).toBe(0)
|
|
61
62
|
})
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
describe("@pmelab/gtd/steering", () => {
|
|
66
|
+
it("publishes no engine-only name, nor a duplicate of the pure text reads", () => {
|
|
67
|
+
for (const name of [
|
|
68
|
+
"FOOTNOTE_ACTION_TITLE",
|
|
69
|
+
"THREAD_REPLY_ACTION_TITLE",
|
|
70
|
+
"getParseCount",
|
|
71
|
+
"BUILT_IN_MODE_NAMES",
|
|
72
|
+
"steeringFormatOrFreeForm",
|
|
73
|
+
"viewOf",
|
|
74
|
+
"headingSections",
|
|
75
|
+
"headingSectionBodies",
|
|
76
|
+
"unansweredQuestions",
|
|
77
|
+
]) {
|
|
78
|
+
expect(published, name).not.toHaveProperty(name)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("publishes the pure text reads a flow also has", () => {
|
|
83
|
+
expect(published.sections("## A\n\n## B\n")).toEqual(["A", "B"])
|
|
84
|
+
expect(typeof published.threads).toBe("function")
|
|
85
|
+
expect(typeof published.openQuestions).toBe("function")
|
|
86
|
+
expect(typeof published.sectionBodies).toBe("function")
|
|
87
|
+
})
|
|
88
|
+
})
|
package/src/steering/index.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
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"
|
package/src/steering/qa.test.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import {
|
|
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 "./
|
|
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 "./
|
|
2
|
+
import { FOOTNOTE_ACTION_TITLE, steeringFormatFor, viewOf } from "./internal.js"
|
|
3
3
|
import { parseReviewDoc, unansweredNotes } from "./review.js"
|
|
4
|
-
import { getParseCount } from "./
|
|
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 =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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("
|
|
91
|
+
it("fails an undeclared --var name as a usage error", async () => {
|
|
89
92
|
const repo = repoAt()
|
|
90
|
-
const
|
|
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(
|
|
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", () => {
|
package/src/step/planStart.ts
CHANGED
|
@@ -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<
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|