@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.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  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-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
@@ -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 door "$@")"
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 --workflow\`/\`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 door <name> [args...]\`, captured by command substitution — never a
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 door ... | sh\` under \`set -e\` would sail past a refusal and \`exec\` a
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 doors\` lists the doors a repository offers (those of the workflows its
579
- config exports — gtd-workflows' \`default\` brings \`fix\` and
580
- \`review [base]\` — plus any the config declares). Refuses with
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 --workflow <name>\`, \`gtd abandon\`,
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")
@@ -11,14 +11,13 @@ import {
11
11
  type StepRequest,
12
12
  type Flow,
13
13
  type Mark,
14
- } from "../flows/index.js"
14
+ } from "../flows/internal.js"
15
15
  import {
16
- headingSectionBodies,
17
- headingSections,
18
- steeringFormatFor,
16
+ openQuestions,
19
17
  parseCodeThreads,
20
- parseThreadsWithFindings,
21
- unansweredQuestions,
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 = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step)
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: (text) => headingSections(text),
671
- sectionBodies: (text) => headingSectionBodies(text),
672
- openQuestions: (text) => {
673
- const qa = steeringFormatFor("qa")
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,
@@ -6,7 +6,7 @@ import {
6
6
  type FlowContext,
7
7
  type JudgeAnswer,
8
8
  type StepRequest,
9
- } from "../flows/index.js"
9
+ } from "../flows/internal.js"
10
10
  import { toRequest } from "../judges/index.js"
11
11
  import { globMatches } from "../wire/index.js"
12
12
 
@@ -697,7 +697,7 @@ describe("case-insensitive definition matching", () => {
697
697
  })
698
698
  })
699
699
 
700
- import { freeFormFormat, getParseCount, steeringFormatFor } from "./index.js"
700
+ import { freeFormFormat, getParseCount, steeringFormatFor } from "./internal.js"
701
701
 
702
702
  const qaDescriptor = steeringFormatFor("qa")!
703
703
  const reviewDescriptor = steeringFormatFor("review")!
@@ -5,7 +5,7 @@ import {
5
5
  parseFootnotes,
6
6
  parseThreads,
7
7
  steeringFormatFor,
8
- } from "./index.js"
8
+ } from "./internal.js"
9
9
 
10
10
  const QA_FORMAT = steeringFormatFor("qa")!
11
11
  const REVIEW_FORMAT = 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 "./index.js"
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 { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
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
+ })
@@ -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", () => {