@pmelab/gtd 18.0.0 → 19.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.
@@ -0,0 +1,256 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type StepRequest } from "../flows/index.js"
3
+ import {
4
+ driftedScenarios,
5
+ freezeScenarios,
6
+ guarded,
7
+ holdWording,
8
+ scenarioText,
9
+ } from "./scenarios.js"
10
+ import { fixtureContext, renderText } from "./text.fixture.js"
11
+
12
+ afterEach(() => installContext(undefined))
13
+
14
+ const tree = (files: Record<string, string>) => ({
15
+ read: (path: string) => files[path],
16
+ })
17
+
18
+ describe("scenarioText", () => {
19
+ it("ignores comments, tags, blank lines and indentation", () => {
20
+ const loose = [
21
+ "@wip @slow",
22
+ "Feature: x",
23
+ "",
24
+ " # a comment",
25
+ " @tag",
26
+ " Scenario: y",
27
+ " Given a thing",
28
+ ].join("\n")
29
+ const tight = "Feature: x\nScenario: y\nGiven a thing"
30
+ expect(scenarioText(loose)).toBe(scenarioText(tight))
31
+ })
32
+
33
+ it("keeps changed wording", () => {
34
+ expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
35
+ })
36
+
37
+ it.each([
38
+ ["a # line", "# Review: abc"],
39
+ ["an @ line", "@mention"],
40
+ ["a blank line", ""],
41
+ ])("keeps %s inside a doc string", (_, line) => {
42
+ const doc = (body: string[]) =>
43
+ ["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
44
+ "\n",
45
+ )
46
+ expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
47
+ })
48
+
49
+ it("keeps indentation inside a doc string", () => {
50
+ const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
51
+ expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
52
+ })
53
+
54
+ it("ignores a doc string re-indented together with its delimiters", () => {
55
+ const at = (indent: string) =>
56
+ [
57
+ "Given a file with:",
58
+ `${indent}\`\`\``,
59
+ `${indent}# Review`,
60
+ `${indent} - x`,
61
+ `${indent}\`\`\``,
62
+ ].join("\n")
63
+ expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
64
+ })
65
+ })
66
+
67
+ describe("freezeScenarios", () => {
68
+ it("snapshots wording at head, nothing frozen is undefined", () => {
69
+ const files = { "a.feature": "@t\nGiven a" }
70
+ renderText(() => {
71
+ expect(freezeScenarios([])).toBeUndefined()
72
+ })
73
+ installContext(fixtureContext({ head: "h1" }, tree(files)))
74
+ expect(freezeScenarios(["a.feature"])).toEqual({
75
+ at: "h1",
76
+ texts: { "a.feature": scenarioText("Given a") },
77
+ })
78
+ })
79
+
80
+ it("merges over previous, a new path overriding an old one", () => {
81
+ installContext(
82
+ fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
83
+ )
84
+ const merged = freezeScenarios(["a.feature", "b.feature"], {
85
+ at: "h1",
86
+ texts: { "a.feature": "Given a", "c.feature": "Given c" },
87
+ })
88
+ expect(merged).toEqual({
89
+ at: "h2",
90
+ texts: {
91
+ "a.feature": "Given a2",
92
+ "b.feature": "Given b",
93
+ "c.feature": "Given c",
94
+ },
95
+ })
96
+ })
97
+
98
+ it("keeps previous untouched when no paths are given", () => {
99
+ const previous = { at: "h1", texts: { "a.feature": "Given a" } }
100
+ installContext(fixtureContext({ head: "h2" }, tree({})))
101
+ expect(freezeScenarios([], previous)).toBe(previous)
102
+ })
103
+ })
104
+
105
+ describe("driftedScenarios", () => {
106
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
107
+
108
+ it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
109
+ installContext(
110
+ fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
111
+ )
112
+ expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
113
+ })
114
+
115
+ it("is empty while wording is unchanged", () => {
116
+ installContext(
117
+ fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
118
+ )
119
+ expect(driftedScenarios(frozen)).toEqual([])
120
+ })
121
+ })
122
+
123
+ describe("holdWording", () => {
124
+ const run = async (
125
+ files: () => Record<string, string>,
126
+ frozen: Parameters<typeof holdWording>[0],
127
+ head = "h2",
128
+ ): Promise<StepRequest[]> => {
129
+ const steps: StepRequest[] = []
130
+ installContext(
131
+ fixtureContext(
132
+ { head },
133
+ {
134
+ read: (path) => files()[path],
135
+ step: (request) => {
136
+ steps.push(request)
137
+ return Promise.resolve()
138
+ },
139
+ },
140
+ ),
141
+ )
142
+ await holdWording(frozen)
143
+ return steps
144
+ }
145
+
146
+ it("does nothing without a freeze or without drift", async () => {
147
+ expect(await run(() => ({}), undefined)).toEqual([])
148
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
149
+ expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
150
+ })
151
+
152
+ it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
153
+ const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
154
+ const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
155
+ const [step] = steps
156
+ if (step?.kind !== "human") throw new Error("expected a human step")
157
+ expect(step.name).toBe("scenario-wording")
158
+ expect(step.options.acceptClean).toBe(true)
159
+ expect(step.options.base).toBe("h1")
160
+ const message = step.options.message ?? ""
161
+ expect(message).toContain("- Then ok")
162
+ expect(message).toContain("+ Then better")
163
+ expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
164
+ expect(message).toContain("partial accept")
165
+ })
166
+
167
+ const shownDiff = async (before: string, after: string): Promise<string[]> => {
168
+ const frozen = { at: "h1", texts: { "a.feature": before } }
169
+ const [step] = await run(() => ({ "a.feature": after }), frozen)
170
+ if (step?.kind !== "human") throw new Error("expected a human step")
171
+ return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
172
+ }
173
+
174
+ it("shows a moved step as one removal and one addition of it, in place", async () => {
175
+ expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
176
+ "- Given a",
177
+ "+ Given a",
178
+ ])
179
+ })
180
+
181
+ it("shows a blank line added inside a doc string", async () => {
182
+ expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
183
+ })
184
+
185
+ it("shows a repeated step as its added copy", async () => {
186
+ expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
187
+ })
188
+
189
+ it("accepts still-differing files by moving the snapshot to head", async () => {
190
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
191
+ await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
192
+ expect(frozen).toEqual({
193
+ at: "h2",
194
+ texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
195
+ })
196
+ })
197
+
198
+ it("rejects restored files, keeping the original wording frozen", async () => {
199
+ const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
200
+ let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
201
+ installContext(
202
+ fixtureContext(
203
+ { head: "h2" },
204
+ {
205
+ read: (path) => files[path],
206
+ step: () => {
207
+ files = { "a.feature": "Given a", "b.feature": "Given b2" }
208
+ return Promise.resolve()
209
+ },
210
+ },
211
+ ),
212
+ )
213
+ await holdWording(frozen)
214
+ expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
215
+ })
216
+
217
+ it("drops an accepted deletion from the snapshot", async () => {
218
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
219
+ await run(() => ({}), frozen)
220
+ expect(frozen.texts).toEqual({})
221
+ })
222
+ })
223
+
224
+ describe("guarded", () => {
225
+ it("runs the turn, then holds wording", async () => {
226
+ const order: string[] = []
227
+ const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
228
+ installContext(
229
+ fixtureContext(
230
+ { head: "h2" },
231
+ {
232
+ read: () => "Given changed",
233
+ step: (request) => {
234
+ order.push(request.kind)
235
+ return Promise.resolve()
236
+ },
237
+ },
238
+ ),
239
+ )
240
+ await guarded(frozen, () => {
241
+ order.push("turn")
242
+ return Promise.resolve()
243
+ })()
244
+ expect(order).toEqual(["turn", "human"])
245
+ })
246
+
247
+ it("is the bare turn without a freeze", async () => {
248
+ const order: string[] = []
249
+ installContext(fixtureContext())
250
+ await guarded(undefined, () => {
251
+ order.push("turn")
252
+ return Promise.resolve()
253
+ })()
254
+ expect(order).toEqual(["turn"])
255
+ })
256
+ })
@@ -0,0 +1,137 @@
1
+ import { head, human, read, scope } from "../flows/index.js"
2
+ import * as t from "./text.js"
3
+
4
+ /** Mutable: an accepted change moves the snapshot forward. */
5
+ export interface FrozenScenarios {
6
+ at: string
7
+ texts: Record<string, string>
8
+ }
9
+
10
+ const DOC_STRING = /^(\s*)("""|```)/
11
+
12
+ /**
13
+ * Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
14
+ * dropped — except inside a doc string, whose content is test input: kept
15
+ * whole, indented relative to its opening delimiter (as Gherkin reads it).
16
+ */
17
+ export const scenarioText = (source: string): string => {
18
+ const out: string[] = []
19
+ let doc: { readonly indent: number; readonly fence: string } | undefined
20
+ for (const line of source.split("\n")) {
21
+ const delimiter = DOC_STRING.exec(line)
22
+ if (doc === undefined && delimiter !== null) {
23
+ doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
24
+ out.push(line.trim())
25
+ } else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
26
+ doc = undefined
27
+ out.push(line.trim())
28
+ } else if (doc !== undefined) {
29
+ const lead = line.length - line.trimStart().length
30
+ out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
31
+ } else {
32
+ const trimmed = line.trim()
33
+ if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
34
+ }
35
+ }
36
+ return out.join("\n")
37
+ }
38
+
39
+ const textOf = (path: string): string | undefined => {
40
+ const source = read(path)
41
+ return source === undefined ? undefined : scenarioText(source)
42
+ }
43
+
44
+ /** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
45
+ export const freezeScenarios = (
46
+ paths: readonly string[],
47
+ previous?: FrozenScenarios,
48
+ ): FrozenScenarios | undefined => {
49
+ if (paths.length === 0) return previous
50
+ const texts: Record<string, string> = { ...previous?.texts }
51
+ for (const path of paths) {
52
+ const text = textOf(path)
53
+ if (text !== undefined) texts[path] = text
54
+ }
55
+ return Object.keys(texts).length === 0 ? previous : { at: head(), texts }
56
+ }
57
+
58
+ /** Paths whose wording differs from the snapshot (a deleted file counts). */
59
+ export const driftedScenarios = (frozen: FrozenScenarios): readonly string[] =>
60
+ Object.entries(frozen.texts)
61
+ .filter(([path, text]) => textOf(path) !== text)
62
+ .map(([path]) => path)
63
+
64
+ // Blank lines survive only inside doc strings, where they are wording.
65
+ const lines = (text: string | undefined): string[] =>
66
+ text === undefined || text === "" ? [] : text.split("\n")
67
+
68
+ /** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
69
+ const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
70
+ const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
71
+ for (let i = a.length - 1; i >= 0; i--) {
72
+ for (let j = b.length - 1; j >= 0; j--) {
73
+ table[i]![j] =
74
+ a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
75
+ }
76
+ }
77
+ return table
78
+ }
79
+
80
+ /** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
81
+ const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
82
+ const table = lcsTable(a, b)
83
+ const out: string[] = []
84
+ let i = 0
85
+ let j = 0
86
+ while (i < a.length && j < b.length) {
87
+ if (a[i] === b[j]) {
88
+ i++
89
+ j++
90
+ } else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
91
+ else out.push(`+ ${b[j++]}`)
92
+ }
93
+ return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
94
+ }
95
+
96
+ export const wordingDrift = (frozen: FrozenScenarios, path: string): t.WordingDrift => ({
97
+ path,
98
+ lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
99
+ })
100
+
101
+ /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
102
+ export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<void> => {
103
+ if (frozen === undefined) return
104
+ const drifted = driftedScenarios(frozen)
105
+ if (drifted.length === 0) return
106
+ await human("scenario-wording", {
107
+ message: t.scenarioWordingMessage(
108
+ frozen.at,
109
+ drifted.map((path) => wordingDrift(frozen, path)),
110
+ ),
111
+ label: "Scenario wording changed",
112
+ acceptClean: true,
113
+ base: frozen.at,
114
+ })
115
+ // Still differing is accepted; restored is rejected and stays frozen as it was.
116
+ const accepted = driftedScenarios(frozen)
117
+ if (accepted.length === 0) return
118
+ for (const path of accepted) {
119
+ const text = textOf(path)
120
+ if (text === undefined) delete frozen.texts[path]
121
+ else frozen.texts[path] = text
122
+ }
123
+ frozen.at = head()
124
+ }
125
+
126
+ /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
127
+ export const guarded =
128
+ (
129
+ frozen: FrozenScenarios | undefined,
130
+ turn: () => Promise<void>,
131
+ within?: string,
132
+ ): (() => Promise<void>) =>
133
+ async () => {
134
+ await turn()
135
+ if (within === undefined) await holdWording(frozen)
136
+ else await scope(within, () => holdWording(frozen))
137
+ }
@@ -18,8 +18,6 @@ describe("the bundled workflow's skills export", () => {
18
18
  "architecture.decompose",
19
19
  "packages.item",
20
20
  "packages.item.fix.suite",
21
- "packages.item.fix.spec",
22
- "packages.item.spec",
23
21
  "packages.item.health",
24
22
  "build",
25
23
  "build.health",
@@ -50,8 +48,6 @@ describe("the bundled workflow's skills export", () => {
50
48
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
51
49
  "packages.item": ["test-driven-development", "incremental-implementation"],
52
50
  "packages.item.fix.suite": ["debugging-and-error-recovery"],
53
- "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
54
- "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
55
51
  "packages.item.health": ["debugging-and-error-recovery"],
56
52
  build: ["debugging-and-error-recovery"],
57
53
  "build.health": ["debugging-and-error-recovery"],
@@ -15,8 +15,6 @@ export const skills = (
15
15
  "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
16
16
  "packages.item": ["test-driven-development", "incremental-implementation"],
17
17
  "packages.item.fix.suite": ["debugging-and-error-recovery"],
18
- "packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
19
- "packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
20
18
  // `health` is shared code reached from both `packages.item` and `build`.
21
19
  "packages.item.health": ["debugging-and-error-recovery"],
22
20
  build: ["debugging-and-error-recovery"],
@@ -40,16 +40,6 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
40
40
  expect(prompt).toContain("debugging-and-error-recovery")
41
41
  })
42
42
 
43
- it("fixSpec carries packages.item.fix.spec's bundled skills", async () => {
44
- const prompt = agentPrompt(await capture(() => steps.fixSpec("pkg"), "packages.item"))
45
- expect(prompt).toContain("incremental-implementation, code-simplification")
46
- })
47
-
48
- it("reviewPackage carries packages.item.spec.review's bundled skills", async () => {
49
- const prompt = agentPrompt(await capture(() => steps.reviewPackage("pkg"), "packages.item"))
50
- expect(prompt).toContain("code-review-and-quality, spec-driven-development")
51
- })
52
-
53
43
  it("fix carries build.fix's bundled skills", async () => {
54
44
  const prompt = agentPrompt(await capture(() => steps.fix(), "build"))
55
45
  expect(prompt).toContain("debugging-and-error-recovery")
@@ -12,7 +12,6 @@ export const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
12
12
  export const ARCHITECTURE = ".gtd/ARCHITECTURE.md"
13
13
  export const REVIEW = ".gtd/REVIEW.md"
14
14
  export const QUALITY = ".gtd/QUALITY.md"
15
- export const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md"
16
15
 
17
16
  const planner = (): string => env.plannerModel ?? ""
18
17
  const coder = (): string => env.coderModel ?? ""
@@ -80,27 +79,10 @@ export const fixSuite = (): Promise<void> =>
80
79
  system: t.builderSystem(),
81
80
  })
82
81
 
83
- export const fixSpec = (pkg: string): Promise<void> =>
84
- t.agentWithSkills("fix.spec.fixing", t.packagesItemFixSpecPrompt(pkg), {
85
- label: "Fixing review feedback",
86
- file: SPEC_FEEDBACK,
87
- model: coder(),
88
- system: t.builderSystem(),
89
- })
90
-
91
- /** Review `pkg` against its spec, focused on the `failing` sections the pre-judge could not clear. */
92
- export const reviewPackage = (pkg: string, failing: readonly string[] = []): Promise<void> =>
93
- t.agentWithSkills("spec.review", t.packagesItemSpecReviewPrompt(pkg, failing), {
94
- label: "Reviewing the package",
95
- model: planner(),
96
- system: t.specReviewerSystem(),
97
- allowEmpty: true,
98
- })
99
-
100
82
  // ── Keeping the suite green ─────────────────────────────────────────────────
101
83
 
102
- export const fix = (): Promise<void> =>
103
- t.agentWithSkills("fix", t.buildFixPrompt(), {
84
+ export const fix = (built?: t.BuildContext): Promise<void> =>
85
+ t.agentWithSkills("fix", t.buildFixPrompt(built), {
104
86
  label: "Fixing the check",
105
87
  file: FEEDBACK,
106
88
  model: coder(),
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  installContext,
3
3
  type CodeThreadInfo,
4
+ type AccessDef,
4
5
  type FlowContext,
6
+ type ScopeAccess,
5
7
  type StepRequest,
6
8
  } from "../flows/index.js"
9
+ import { foldAccess } from "../replay/index.js"
10
+ import { access as bundledAccess } from "./access.js"
7
11
  import { skills as bundledSkills } from "./skills.js"
8
12
  import { defaults, envDefaults } from "./vars.js"
9
13
 
@@ -18,6 +22,8 @@ export interface TextContext {
18
22
  readonly scope?: string
19
23
  /** Stands in for `.gtdrc` `skills:`, keyed by scope full name. */
20
24
  readonly skills?: Readonly<Record<string, readonly string[]>>
25
+ /** Stands in for `.gtdrc` `access:`, keyed by scope full name. */
26
+ readonly access?: Readonly<Record<string, ScopeAccess>>
21
27
  }
22
28
 
23
29
  const unavailable = (): never => {
@@ -39,6 +45,25 @@ const skillsForOf =
39
45
  return []
40
46
  }
41
47
 
48
+ /** The same walk for access, folded the way `Replay.ts` folds it. */
49
+ const accessForOf =
50
+ (context: TextContext) =>
51
+ (localName: string, file?: string): AccessDef => {
52
+ const full = [context.scope, localName].filter((part) => part !== undefined && part !== "")
53
+ const parts = full.join(".").split(".").slice(0, -1)
54
+ const bundled = bundledAccess({ ...defaults, ...context.vars })
55
+ let hit: ScopeAccess | undefined
56
+ for (let n = parts.length; n >= 0 && hit === undefined; n--) {
57
+ const prefix = parts.slice(0, n).join(".")
58
+ hit = context.access?.[prefix] ?? bundled[prefix]
59
+ }
60
+ return foldAccess(
61
+ hit,
62
+ file,
63
+ (context.codeThreads ?? []).filter((t) => t.waitingOn === "agent").map((t) => t.path),
64
+ )
65
+ }
66
+
42
67
  /** The fixed replay context texts render against; steps, refusals and scopes are unavailable unless overridden. */
43
68
  export const fixtureContext = (
44
69
  context: TextContext = {},
@@ -63,6 +88,7 @@ export const fixtureContext = (
63
88
  head: () => context.head ?? "",
64
89
  start: () => context.start ?? "",
65
90
  skillsFor: skillsForOf(context),
91
+ accessFor: accessForOf(context),
66
92
  ...overrides,
67
93
  })
68
94
 
@@ -4,6 +4,7 @@ import {
4
4
  agentWithSkills,
5
5
  architectureAuthorPrompt,
6
6
  architectureGateAnswerMessage,
7
+ buildFixPrompt,
7
8
  buildFixQualityPrompt,
8
9
  buildQualityReviewingPrompt,
9
10
  buildReviewAwaitReviewMessage,
@@ -56,6 +57,47 @@ describe("agentWithSkills", () => {
56
57
  })
57
58
  })
58
59
 
60
+ describe("agentWithSkills access preamble", () => {
61
+ const capture = (
62
+ context: Parameters<typeof captureStep>[1],
63
+ options: Parameters<typeof agentWithSkills>[2] = {},
64
+ ) => captureStep(() => agentWithSkills("name", "do-the-work", options), context)
65
+
66
+ it("names each restricted side, after the skills preamble and before the prompt", async () => {
67
+ const request = await capture({
68
+ scope: "a",
69
+ skills: { a: ["testing"] },
70
+ access: { a: { read: ["docs/**"], write: ["out/**"] } },
71
+ })
72
+ if (request.kind !== "agent") throw new Error("unreachable")
73
+ const prompt = request.prompt
74
+ expect(prompt).toContain("- This turn may read only: docs/**")
75
+ expect(prompt).toContain(
76
+ "- This turn may write only: out/** — anything else is refused when the turn lands",
77
+ )
78
+ expect(prompt.indexOf("missing one: testing")).toBeLessThan(
79
+ prompt.indexOf("This turn may read only"),
80
+ )
81
+ expect(prompt.endsWith("\n\ndo-the-work")).toBe(true)
82
+ })
83
+
84
+ it("folds the steering file into the named globs", async () => {
85
+ const request = await capture(
86
+ { scope: "a", access: { a: { write: [] } } },
87
+ { file: ".gtd/NOTES.md" },
88
+ )
89
+ if (request.kind !== "agent") throw new Error("unreachable")
90
+ expect(request.prompt).toContain("may write only: .gtd/NOTES.md —")
91
+ expect(request.prompt).not.toContain("may read only")
92
+ })
93
+
94
+ it("leaves the prompt byte-identical when both sides are unrestricted", async () => {
95
+ const request = await capture({ scope: "a", skills: { a: [] }, access: { a: {} } })
96
+ if (request.kind !== "agent") throw new Error("unreachable")
97
+ expect(request.prompt).toBe("do-the-work")
98
+ })
99
+ })
100
+
59
101
  describe("the shared open-question instruction", () => {
60
102
  it("leaves the option count to the agent, floored at two real options", () => {
61
103
  const prompt = renderText(() => designTriagePrompt("base"))
@@ -119,6 +161,65 @@ describe("architectureAuthorPrompt", () => {
119
161
  const prompt = renderText(() => architectureAuthorPrompt())
120
162
  expect(prompt).toContain("is an open question, not a")
121
163
  })
164
+ it("names the four content sections in order and the entry forms", () => {
165
+ const prompt = renderText(() => architectureAuthorPrompt())
166
+ const at = ["## Interfaces", "## Call Stacks", "## E2E Scenarios", "## Unit Tests"].map((h) =>
167
+ prompt.indexOf(h),
168
+ )
169
+ expect(at.every((i) => i >= 0)).toBe(true)
170
+ expect([...at].sort((a, b) => a - b)).toEqual(at)
171
+ expect(prompt).toContain("- unit: <path>")
172
+ expect(prompt).toContain("- e2e: <path>")
173
+ expect(prompt).toContain("- chore: <path>")
174
+ expect(prompt).toContain("No e2e change.")
175
+ })
176
+ })
177
+
178
+ describe("buildFixPrompt with build context", () => {
179
+ const built = {
180
+ ranges: [
181
+ { pkg: "00-scenarios", from: "aaa1111", to: "bbb2222" },
182
+ { pkg: "01-engine", from: "bbb2222", to: "ccc3333" },
183
+ ],
184
+ scenarios: { added: ["tests/new.feature"], changed: ["tests/old.feature"] },
185
+ }
186
+
187
+ it("points at the architecture document", () => {
188
+ expect(renderText(() => buildFixPrompt(built))).toContain(".gtd/ARCHITECTURE.md")
189
+ })
190
+
191
+ it("lists new scenario paths as likely missing wiring", () => {
192
+ const prompt = renderText(() => buildFixPrompt(built))
193
+ expect(prompt).toContain("tests/new.feature")
194
+ expect(prompt).toContain("missing wiring")
195
+ })
196
+
197
+ it("lists changed or existing scenarios as regressions", () => {
198
+ const prompt = renderText(() => buildFixPrompt(built))
199
+ expect(prompt).toContain("regression")
200
+ expect(prompt).toContain("tests/old.feature")
201
+ })
202
+
203
+ it("lists one range line per package", () => {
204
+ const prompt = renderText(() => buildFixPrompt(built))
205
+ expect(prompt).toContain("git log --oneline aaa1111..bbb2222 # 00-scenarios")
206
+ expect(prompt).toContain("git log --oneline bbb2222..ccc3333 # 01-engine")
207
+ })
208
+
209
+ it("is today's prompt without a build context", () => {
210
+ const prompt = renderText(() => buildFixPrompt())
211
+ expect(renderText(() => buildFixPrompt(undefined))).toBe(prompt)
212
+ expect(prompt).toMatch(/\n- Leave everything uncommitted — do not commit\n$/)
213
+ expect(prompt).toContain("writes its own\n- Leave")
214
+ expect(prompt).not.toContain("ARCHITECTURE")
215
+ expect(prompt).not.toContain("git log --oneline")
216
+ })
217
+
218
+ it("starts each bullet on its own line", () => {
219
+ const prompt = renderText(() => buildFixPrompt(built))
220
+ expect(prompt).toMatch(/^- Read `\.gtd\/ARCHITECTURE\.md`/m)
221
+ expect(prompt).toMatch(/^- Leave everything uncommitted/m)
222
+ })
122
223
  })
123
224
 
124
225
  describe("buildFixQualityPrompt", () => {