@pmelab/gtd 59.0.0 → 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 +42 -38
- 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-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
- package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +1101 -1025
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +371 -82
- 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 +68 -61
- package/src/cli/Cli.test.ts +311 -229
- package/src/cli/Cli.ts +486 -585
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- 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 +131 -82
- package/src/cli/Program.ts +197 -111
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +25 -6
- 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 +481 -98
- package/src/gherkin/messages.test.ts +124 -0
- package/src/gherkin/messages.ts +101 -0
- 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 +289 -6
- package/src/testkit/TestRepo.ts +317 -41
- 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 +7 -14
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Land.ts +1 -1
- 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 +175 -152
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- 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
package/src/install/Install.ts
CHANGED
|
@@ -175,7 +175,7 @@ if [ -z "\${1:-}" ]; then
|
|
|
175
175
|
exit 2
|
|
176
176
|
fi
|
|
177
177
|
cd "$(git rev-parse --show-toplevel)"
|
|
178
|
-
script="$(gtd
|
|
178
|
+
script="$(gtd start "$@")"
|
|
179
179
|
sh -c "$script"
|
|
180
180
|
exec "$GTD_BUILD"`
|
|
181
181
|
|
|
@@ -365,7 +365,7 @@ const DRIVER_OBLIGATIONS = `
|
|
|
365
365
|
\`--json=judge\`, pipes that rendered \`{ schema, state, questions }\` document to a
|
|
366
366
|
judge model, and pipes ITS verdict (one \`{ id, answer, p }\` per pending
|
|
367
367
|
question, as JSON on stdin) to \`gtd judge answer\` — the FIFTH command
|
|
368
|
-
that changes anything, alongside \`gtd land\`/\`gtd
|
|
368
|
+
that changes anything, alongside \`gtd land\`/\`gtd start\`/\`gtd
|
|
369
369
|
abandon\`/\`gtd restore\`. It follows the exact same required-half /
|
|
370
370
|
optional-half script contract as \`gtd land\` in obligation 8 above:
|
|
371
371
|
read \`--json=script\` (and, if you want them, \`--json=settled\`/
|
|
@@ -565,9 +565,9 @@ ${EDIT_COMMAND}
|
|
|
565
565
|
|
|
566
566
|
### \`gtd-door <name> [args...]\` — start a process through a door and drive it
|
|
567
567
|
|
|
568
|
-
Runs \`gtd
|
|
568
|
+
Runs \`gtd start <name> [args...]\`, captured by command substitution — never a
|
|
569
569
|
pipe: a pipeline reports only its LAST command's exit status, so
|
|
570
|
-
\`gtd
|
|
570
|
+
\`gtd start ... | sh\` under \`set -e\` would sail past a refusal and \`exec\` a
|
|
571
571
|
loop over a process that was never started. It runs the captured script, then
|
|
572
572
|
\`exec\`s the suite's RESOLVED \`gtd-build\` path — never the literal string
|
|
573
573
|
\`gtd-build\`. This is one invocation that carries the process all the way to
|
|
@@ -575,9 +575,9 @@ its next human gate, not just a starter: \`gtd-door review\` on a RED baseline
|
|
|
575
575
|
hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
|
|
576
576
|
rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
|
|
577
577
|
straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
|
|
578
|
-
\`gtd
|
|
579
|
-
|
|
580
|
-
\`review [base]\`
|
|
578
|
+
A bare \`gtd start\` lists what a repository can start: its workflows, and
|
|
579
|
+
the doors those workflows bring (gtd-workflows' \`default\` brings \`fix\`
|
|
580
|
+
and \`review [base]\`) plus any the config declares. Refuses with
|
|
581
581
|
\`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
|
|
582
582
|
missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
|
|
583
583
|
|
|
@@ -682,7 +682,7 @@ const PREREQUISITES = `
|
|
|
682
682
|
## Prerequisites and portability
|
|
683
683
|
|
|
684
684
|
- A POSIX \`sh\` (dash, ash, bash's own POSIX mode, etc.) — gtd's own emitted
|
|
685
|
-
scripts (\`gtd land --json=script\`, \`gtd
|
|
685
|
+
scripts (\`gtd land --json=script\`, \`gtd start <name>\`, \`gtd abandon\`,
|
|
686
686
|
\`gtd restore\`, \`gtd judge answer --json=script\`) are POSIX sh; captured,
|
|
687
687
|
then piped into it (see obligation 8 above). Reading
|
|
688
688
|
\`gtd next --json=<path>\`/\`gtd land --json=<path>\`'s own output needs
|
|
@@ -702,7 +702,7 @@ describe("replay: keep", () => {
|
|
|
702
702
|
it("rejects keep together with allowEmpty", async () => {
|
|
703
703
|
const flow: Flow = async () => {
|
|
704
704
|
await human("idle")
|
|
705
|
-
await agent("naive", "p", { keep: true, allowEmpty: true })
|
|
705
|
+
await agent("naive", "p", { keep: true, allowEmpty: true } as never)
|
|
706
706
|
}
|
|
707
707
|
const h = new History().land("human", "idle", 1, "naive", {})
|
|
708
708
|
const outcome = await replayOf(flow, h)
|
|
@@ -1166,6 +1166,17 @@ describe("human({ approve })", () => {
|
|
|
1166
1166
|
}
|
|
1167
1167
|
})
|
|
1168
1168
|
|
|
1169
|
+
it.each([
|
|
1170
|
+
["human", async () => human("gate", { mode: "qa" } as never)],
|
|
1171
|
+
["agent", async () => agent("gate", "p", { mode: "qa" } as never)],
|
|
1172
|
+
["run", async () => run("gate", "true", { mode: "qa" } as never)],
|
|
1173
|
+
] as const)("refuses a %s step's mode without a file", async (kind, flow) => {
|
|
1174
|
+
expect(await replayOf(flow, new History())).toEqual({
|
|
1175
|
+
kind: "failed",
|
|
1176
|
+
message: `gtd: step "gate": mode requires file in ${kind}() options — name the steering file the mode checks`,
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1169
1180
|
describe("refuses mode and file", () => {
|
|
1170
1181
|
const tail =
|
|
1171
1182
|
"in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing"
|
|
@@ -1189,7 +1200,7 @@ describe("human({ approve })", () => {
|
|
|
1189
1200
|
for (const options of [
|
|
1190
1201
|
{ approve: true, label: "x" },
|
|
1191
1202
|
{ approve: true, base: "start" },
|
|
1192
|
-
{ approve: false, mode: "review" },
|
|
1203
|
+
{ approve: false, mode: "review", file: ".gtd/REVIEW.md" },
|
|
1193
1204
|
{ approve: true, mode: undefined },
|
|
1194
1205
|
]) {
|
|
1195
1206
|
expect((await outcomeOf(options)).kind).toBe("rest")
|
package/src/replay/Replay.ts
CHANGED
|
@@ -11,14 +11,13 @@ import {
|
|
|
11
11
|
type StepRequest,
|
|
12
12
|
type Flow,
|
|
13
13
|
type Mark,
|
|
14
|
-
} from "../flows/
|
|
14
|
+
} from "../flows/internal.js"
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
headingSections,
|
|
18
|
-
steeringFormatFor,
|
|
16
|
+
openQuestions,
|
|
19
17
|
parseCodeThreads,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
sectionBodies,
|
|
19
|
+
sections,
|
|
20
|
+
threads,
|
|
22
21
|
} from "../steering/index.js"
|
|
23
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
24
23
|
import { globMatches } from "../wire/index.js"
|
|
@@ -291,6 +290,13 @@ const approveConflict = (step: ReachedStep): string | undefined => {
|
|
|
291
290
|
return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`
|
|
292
291
|
}
|
|
293
292
|
|
|
293
|
+
// A mode names how a steering file is checked; with no file there is nothing to check.
|
|
294
|
+
const modeWithoutFile = (step: ReachedStep): string | undefined => {
|
|
295
|
+
const options = step.request.options
|
|
296
|
+
if (options.mode === undefined || options.file !== undefined) return undefined
|
|
297
|
+
return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
|
|
298
|
+
}
|
|
299
|
+
|
|
294
300
|
export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
295
301
|
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
296
302
|
...c,
|
|
@@ -524,7 +530,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
524
530
|
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
|
|
525
531
|
}
|
|
526
532
|
}
|
|
527
|
-
const error =
|
|
533
|
+
const error =
|
|
534
|
+
unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
|
|
528
535
|
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
529
536
|
}
|
|
530
537
|
|
|
@@ -604,7 +611,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
604
611
|
const name = scoped(request.name)
|
|
605
612
|
if (request.options.keep === true) {
|
|
606
613
|
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
|
607
|
-
if (request.options.allowEmpty === true) {
|
|
614
|
+
if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
|
|
608
615
|
return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`
|
|
609
616
|
}
|
|
610
617
|
// "/" would let one step's ref directory collide with another step's ref (git refuses `a` and `a/b` together).
|
|
@@ -667,28 +674,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
667
674
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
668
675
|
changesSince,
|
|
669
676
|
matches: globMatches,
|
|
670
|
-
sections
|
|
671
|
-
sectionBodies
|
|
672
|
-
openQuestions
|
|
673
|
-
|
|
674
|
-
return qa === undefined
|
|
675
|
-
? []
|
|
676
|
-
: unansweredQuestions(qa, text).map((q) => ({
|
|
677
|
-
question: q.question,
|
|
678
|
-
line: q.headingLine + 1,
|
|
679
|
-
}))
|
|
680
|
-
},
|
|
681
|
-
threads: (text) => {
|
|
682
|
-
const { threads, findings } = parseThreadsWithFindings(text)
|
|
683
|
-
return threads.map((t) => ({
|
|
684
|
-
name: t.name,
|
|
685
|
-
line: t.line + 1,
|
|
686
|
-
waitingOn: t.waitingOn,
|
|
687
|
-
faults: findings
|
|
688
|
-
.filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
|
|
689
|
-
.map((f) => f.message),
|
|
690
|
-
}))
|
|
691
|
-
},
|
|
677
|
+
sections,
|
|
678
|
+
sectionBodies,
|
|
679
|
+
openQuestions,
|
|
680
|
+
threads,
|
|
692
681
|
codeThreads: () =>
|
|
693
682
|
changesBetween(
|
|
694
683
|
treeAt(input.start) ?? input.startTree ?? input.episode.base.tree,
|
|
@@ -697,7 +697,7 @@ describe("case-insensitive definition matching", () => {
|
|
|
697
697
|
})
|
|
698
698
|
})
|
|
699
699
|
|
|
700
|
-
import { freeFormFormat, getParseCount, steeringFormatFor } from "./
|
|
700
|
+
import { freeFormFormat, getParseCount, steeringFormatFor } from "./internal.js"
|
|
701
701
|
|
|
702
702
|
const qaDescriptor = steeringFormatFor("qa")!
|
|
703
703
|
const reviewDescriptor = steeringFormatFor("review")!
|
|
@@ -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", () => {
|