@pmelab/gtd 27.4.0 → 27.6.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -0
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-Df8GeSHw.mjs} +97 -8
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-DmIfEAI4.mjs} +1 -1
  6. package/dist/flows.mjs +20 -2
  7. package/dist/gtd.bundle.mjs +97 -17
  8. package/dist/testkit.mjs +80 -18
  9. package/dist/workflows/cleanup.mjs +2 -2
  10. package/dist/workflows/feature.mjs +193 -66
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +2 -2
  13. package/dist/workflows/rollout.mjs +1 -1
  14. package/dist/workflows.mjs +447 -114
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +49 -1
  17. package/src/cli/Cli.ts +20 -3
  18. package/src/edge/Edge.test.ts +156 -1
  19. package/src/edge/Edge.ts +30 -7
  20. package/src/flags/index.test.ts +27 -1
  21. package/src/flags/index.ts +15 -3
  22. package/src/flows/helpers.ts +7 -3
  23. package/src/flows/runtime.test.ts +42 -0
  24. package/src/flows/runtime.ts +36 -0
  25. package/src/replay/Replay.test.ts +43 -0
  26. package/src/replay/Replay.ts +10 -2
  27. package/src/replay/Trailers.test.ts +54 -0
  28. package/src/replay/Trailers.ts +15 -1
  29. package/src/replay/judgeDiffShape.test.ts +43 -0
  30. package/src/step/planStart.ts +3 -2
  31. package/src/testkit/TestRepo.ts +3 -0
  32. package/src/testkit/steps.ts +18 -0
  33. package/src/workflow/index.ts +8 -1
  34. package/src/workflow/vars.test.ts +23 -1
  35. package/src/workflow/vars.ts +24 -0
  36. package/src/workflows/feature/feature.ts +11 -2
  37. package/src/workflows/feature/index.ts +1 -0
  38. package/src/workflows/feature/risk-tier.feature +301 -0
  39. package/src/workflows/feature/seam-pin.feature +52 -0
  40. package/src/workflows/feature/seam.feature +387 -0
  41. package/src/workflows/feature/seam.test.ts +88 -0
  42. package/src/workflows/feature/seam.ts +111 -0
  43. package/src/workflows/feature/steps.ts +3 -2
  44. package/src/workflows/feature/tier.test.ts +164 -0
  45. package/src/workflows/feature/tier.ts +74 -0
  46. package/src/workflows/index.ts +1 -0
  47. package/src/workflows/shared/options.test.ts +17 -1
  48. package/src/workflows/shared/options.ts +1 -0
  49. package/src/workflows/shared/skills.test.ts +20 -2
  50. package/src/workflows/shared/skills.ts +6 -0
  51. package/src/workflows/shared/text.test.ts +188 -0
  52. package/src/workflows/shared/text.ts +139 -5
  53. package/src/workflows/shared/vars.ts +6 -0
  54. package/src/workflows/shared/workflows.steps.ts +47 -1
@@ -0,0 +1,164 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type FlowContext } from "../../flows/index.js"
3
+ import { fixtureContext } from "../shared/text.fixture.js"
4
+ import { productionSignals, readRiskTier, recordRisk, riskTierOn } from "./tier.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ const withVars = (vars: Record<string, string>) => installContext(fixtureContext({ vars }))
9
+
10
+ describe("riskTierOn", () => {
11
+ it.each([
12
+ [{ flag_naive_pass: "1", flag_risk_tier: "1" }, true],
13
+ [{ flag_naive_pass: "1", flag_risk_tier: "0" }, false],
14
+ [{ flag_naive_pass: "0", flag_risk_tier: "1" }, false],
15
+ [{ flag_naive_pass: "0", flag_risk_tier: "0" }, false],
16
+ [{ flag_naive_pass: "1" }, false],
17
+ ])("%j is %s", (vars, expected) => {
18
+ withVars(vars)
19
+ expect(riskTierOn()).toBe(expected)
20
+ })
21
+ })
22
+
23
+ describe("readRiskTier", () => {
24
+ const doc = (body: string) =>
25
+ `# Requirements\n\n## Risk Tier\n\n${body}\n\n## Answered Questions\n`
26
+
27
+ it.each(["low", "`low`", "**Low** — trivial copy", "LOW - copy", "_low_ — x"])(
28
+ "reads %j as low",
29
+ (body) => expect(readRiskTier(doc(body))).toBe("low"),
30
+ )
31
+
32
+ it.each(["high", "medium", "lower", "", " \n\n"])("reads %j as high", (body) =>
33
+ expect(readRiskTier(doc(body))).toBe("high"),
34
+ )
35
+
36
+ it("reads a missing section and undefined as high", () => {
37
+ expect(readRiskTier("# Requirements\n\n## Other\n\nlow\n")).toBe("high")
38
+ expect(readRiskTier(undefined)).toBe("high")
39
+ })
40
+
41
+ it("takes the first non-blank line only and stops at the next section", () => {
42
+ expect(readRiskTier("## Risk Tier\n\nhigh — why\nlow\n")).toBe("high")
43
+ expect(readRiskTier("## Risk Tier\n\n## Other\n\nlow\n")).toBe("high")
44
+ })
45
+ })
46
+
47
+ describe("readRiskTier fences", () => {
48
+ it("ignores a backtick-fenced example above the real section", () => {
49
+ const text = "```\n## Risk Tier\n\nlow\n```\n\n## Risk Tier\n\nhigh\n"
50
+ expect(readRiskTier(text)).toBe("high")
51
+ })
52
+
53
+ it("reads a fenced example with no real section as high", () => {
54
+ expect(readRiskTier("```md\n## Risk Tier\n\nlow\n```\n")).toBe("high")
55
+ })
56
+
57
+ it("keeps the signals section open across a fence and skips fenced items", () => {
58
+ const signals = "## Production Signals\n- before\n```\n# x\n- fake\n```\n- after\n"
59
+ expect(productionSignals(signals)).toEqual(["before", "after"])
60
+ })
61
+
62
+ it("ignores a tilde-fenced example above the real section", () => {
63
+ const text = "~~~\n## Risk Tier\n\nlow\n~~~\n\n## Risk Tier\n\nhigh\n"
64
+ expect(readRiskTier(text)).toBe("high")
65
+ })
66
+
67
+ it("does not read a fence opener's info string as the tier", () => {
68
+ expect(readRiskTier("## Risk Tier\n\n```low\nhigh\n```\n")).toBe("high")
69
+ expect(readRiskTier("## Risk Tier\n\n~~~low\nlow\n~~~\n")).toBe("high")
70
+ })
71
+
72
+ it("fails closed on an unclosed fence before the real section", () => {
73
+ expect(readRiskTier("```\nstray\n\n## Risk Tier\n\nlow\n")).toBe("high")
74
+ })
75
+
76
+ it("does not close a 4-backtick fence with 3 backticks", () => {
77
+ const text = "````\n```\n## Risk Tier\n\nlow\n````\n"
78
+ expect(readRiskTier(text)).toBe("high")
79
+ })
80
+ })
81
+
82
+ describe("readRiskTier fence closers", () => {
83
+ it.each([
84
+ ["a tilde line does not close a backtick fence", "```\n~~~\n## Risk Tier\n\nlow\n```\n"],
85
+ ["a closer with an info string does not close", "```\n```js\n## Risk Tier\n\nlow\n```\n"],
86
+ ])("%s", (_, text) => {
87
+ expect(readRiskTier(text)).toBe("high")
88
+ })
89
+ })
90
+
91
+ describe("productionSignals", () => {
92
+ it("lists top-level items only, skipping blanks and indented lines", () => {
93
+ const text = `## Production Signals
94
+
95
+ - error rate on /pay
96
+ not a signal
97
+ - nested detail
98
+ -
99
+ - spaced out\t signal
100
+ * star item
101
+
102
+ ## Answered Questions
103
+
104
+ - not a signal
105
+ `
106
+ expect(productionSignals(text)).toEqual([
107
+ "error rate on /pay",
108
+ "spaced out signal",
109
+ "star item",
110
+ ])
111
+ })
112
+
113
+ it("skips a fenced Production Signals example", () => {
114
+ const text = "```\n## Production Signals\n- fake\n```\n\n## Production Signals\n- real\n"
115
+ expect(productionSignals(text)).toEqual(["real"])
116
+ })
117
+
118
+ it("drops control characters", () => {
119
+ expect(productionSignals("## Production Signals\n- a\u001bb\u0085c\n")).toEqual(["abc"])
120
+ })
121
+
122
+ it("is empty for a missing section or undefined", () => {
123
+ expect(productionSignals("## Risk Tier\nlow\n")).toEqual([])
124
+ expect(productionSignals(undefined)).toEqual([])
125
+ })
126
+ })
127
+
128
+ describe("recordRisk", () => {
129
+ const record = (requirements: string | undefined) => {
130
+ const marks: [string, string][] = []
131
+ installContext({
132
+ ...fixtureContext({ read: () => requirements }),
133
+ mark: (name: string, value: string) => marks.push([name, value]),
134
+ } as unknown as FlowContext)
135
+ return { tier: recordRisk(), marks }
136
+ }
137
+
138
+ it("marks the tier first, then one production-signal each", () => {
139
+ const { tier, marks } = record(
140
+ "## Risk Tier\nhigh — payments\n\n## Production Signals\n- a\n- b\n",
141
+ )
142
+ expect(tier).toBe("high")
143
+ expect(marks).toEqual([
144
+ ["risk-tier", "high"],
145
+ ["production-signal", "a"],
146
+ ["production-signal", "b"],
147
+ ])
148
+ })
149
+
150
+ it("marks risk-tier=low too", () => {
151
+ const { tier, marks } = record("## Risk Tier\nlow — copy\n\n## Production Signals\n- a\n")
152
+ expect(tier).toBe("low")
153
+ expect(marks).toEqual([
154
+ ["risk-tier", "low"],
155
+ ["production-signal", "a"],
156
+ ])
157
+ })
158
+
159
+ it("treats a missing file as high", () => {
160
+ const { tier, marks } = record(undefined)
161
+ expect(tier).toBe("high")
162
+ expect(marks).toEqual([["risk-tier", "high"]])
163
+ })
164
+ })
@@ -0,0 +1,74 @@
1
+ import { mark, read, vars } from "../../flows/index.js"
2
+ import { REQUIREMENTS } from "../shared/steps.js"
3
+
4
+ export type RiskTier = "low" | "high"
5
+
6
+ /** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
7
+ export const riskTierOn = (): boolean => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1"
8
+
9
+ // An unclosed fence runs to end of file, so the tier reads `high` (fail-closed). A backtick
10
+ // opener with a backtick in its info string still counts as a fence: deviates from CommonMark,
11
+ // worst case is `high`.
12
+ const markFenced = (text: string): readonly { line: string; fenced: boolean }[] => {
13
+ let fence: string | undefined // the opener run, e.g. "````"
14
+ return text.split(/\r?\n/).map((line) => {
15
+ const run = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
16
+ const fenced = fence !== undefined || run !== undefined
17
+ if (fence === undefined) fence = run
18
+ else if (
19
+ run !== undefined &&
20
+ run[0] === fence[0] &&
21
+ run.length >= fence.length &&
22
+ /^ {0,3}(`+|~+)[ \t]*$/.test(line)
23
+ )
24
+ fence = undefined
25
+ return { line, fenced }
26
+ })
27
+ }
28
+
29
+ const sectionLines = (
30
+ text: string | undefined,
31
+ title: string,
32
+ ): readonly { line: string; fenced: boolean }[] => {
33
+ if (text === undefined) return []
34
+ const heading = new RegExp(`^##[ \\t]+${title}[ \\t]*$`, "i")
35
+ const lines = markFenced(text)
36
+ const start = lines.findIndex((l) => !l.fenced && heading.test(l.line))
37
+ if (start < 0) return []
38
+ const rest = lines.slice(start + 1)
39
+ const end = rest.findIndex((l) => !l.fenced && /^#{1,2}[ \t]/.test(l.line))
40
+ return end < 0 ? rest : rest.slice(0, end)
41
+ }
42
+
43
+ /** Anything but a legible `low` is `high`: under-tiering is the risky direction. */
44
+ export const readRiskTier = (requirements: string | undefined): RiskTier => {
45
+ const first = sectionLines(requirements, "Risk Tier").find(
46
+ (l) => !l.fenced && l.line.trim() !== "",
47
+ )?.line
48
+ const word = first?.trim().split(/\s+/)[0]?.replace(/[`*_]/g, "").toLowerCase()
49
+ return word === "low" ? "low" : "high"
50
+ }
51
+
52
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
53
+ const CONTROL = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g
54
+
55
+ const tidy = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROL, "").trim()
56
+
57
+ export const productionSignals = (requirements: string | undefined): readonly string[] => {
58
+ const items: string[] = []
59
+ for (const { line, fenced } of sectionLines(requirements, "Production Signals")) {
60
+ if (fenced) continue
61
+ const top = /^[-*+][ \t]+(.*)$/.exec(line)
62
+ if (top !== null) items.push(top[1]!)
63
+ }
64
+ return items.map(tidy).filter((item) => item !== "")
65
+ }
66
+
67
+ /** The tier is always marked, first, so the last `risk-tier` mark opens the current lap's marks. */
68
+ export const recordRisk = (): RiskTier => {
69
+ const requirements = read(REQUIREMENTS)
70
+ const tier = readRiskTier(requirements)
71
+ mark("risk-tier", tier)
72
+ for (const signal of productionSignals(requirements)) mark("production-signal", signal)
73
+ return tier
74
+ }
@@ -23,6 +23,7 @@ export * from "./feature/steps.js"
23
23
  export * from "./feature/planning.js"
24
24
  export * from "./feature/packages.js"
25
25
  export * from "./feature/refactor.js"
26
+ export * from "./feature/seam.js"
26
27
  export { buildTail, type ReviewOutcome } from "./feature/review.js"
27
28
  export * from "./feature/feature.js"
28
29
  export { base, review, reviewFlow } from "./review/review.js"
@@ -9,10 +9,26 @@ const { flagShipped } = options
9
9
  describe("the shipped workflow factories", () => {
10
10
  it("take exactly the settings the shipped workflows declare as options", () => {
11
11
  expect(Object.keys(ShippedOptions.fields).sort()).toEqual(
12
- [...Object.keys(defaults), ...Object.keys(envDefaults)].sort(),
12
+ [...Object.keys(defaults), ...Object.keys(envDefaults)]
13
+ .filter((name) => name !== "flag_naive_pass" && name !== "flag_risk_tier")
14
+ .sort(),
13
15
  )
14
16
  })
15
17
 
18
+ it("declare seamAllow and flag_naive_pass defaults; flag_naive_pass is no option", () => {
19
+ expect(defaults.flag_naive_pass).toBe("0")
20
+ expect(Object.keys(ShippedOptions.fields)).not.toContain("flag_naive_pass")
21
+ expect(configure(feature({ seamAllow: "docs/**" }))).toMatchObject({
22
+ ok: true,
23
+ build: { defaults: { seamAllow: "docs/**", flag_naive_pass: "0" } },
24
+ })
25
+ })
26
+
27
+ it("declare flag_risk_tier off by default; it is no factory option", () => {
28
+ expect(defaults.flag_risk_tier).toBe("0")
29
+ expect(Object.keys(ShippedOptions.fields)).not.toContain("flag_risk_tier")
30
+ })
31
+
16
32
  it("lay each option over the shipped setting of its own kind", () => {
17
33
  const result = configure(feature({ qualityReviews: "correctness", testCommand: "npm run ci" }))
18
34
  expect(result).toMatchObject({
@@ -17,6 +17,7 @@ export const ShippedOptions = Schema.Struct({
17
17
  reviewNoteActionable: setting,
18
18
  reviewSeverityMinP: setting,
19
19
  refactorFlagPattern: setting,
20
+ seamAllow: setting,
20
21
  qualityReviews: setting,
21
22
  testCommand: setting,
22
23
  plannerModel: setting,
@@ -29,6 +29,10 @@ describe("the bundled workflow's skills export", () => {
29
29
  "refactor",
30
30
  "refactor.fix.suite",
31
31
  "refactor.health",
32
+ "seam",
33
+ "implement",
34
+ "implement.fix.suite",
35
+ "implement.health",
32
36
  "build.quality.correctness",
33
37
  "build.quality.owasp-security",
34
38
  "build.quality.ponytail-review",
@@ -39,8 +43,18 @@ describe("the bundled workflow's skills export", () => {
39
43
  )
40
44
  })
41
45
 
42
- it("keys no group scope", () => {
43
- for (const group of ["packages.item.fix", "build.fix", "build.review.fix", "build.quality"]) {
46
+ it("keys no group scope or non-agent step", () => {
47
+ for (const group of [
48
+ "packages.item.fix",
49
+ "build.fix",
50
+ "build.review.fix",
51
+ "build.quality",
52
+ "implement.fix",
53
+ "seam.judge",
54
+ "seam.approve",
55
+ "seam.steer",
56
+ "seam.by-hand",
57
+ ]) {
44
58
  expect(bundled).not.toHaveProperty(group)
45
59
  }
46
60
  })
@@ -63,6 +77,10 @@ describe("the bundled workflow's skills export", () => {
63
77
  refactor: ["ponytail"],
64
78
  "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
65
79
  "refactor.health": ["ponytail", "debugging-and-error-recovery"],
80
+ seam: ["incremental-implementation"],
81
+ implement: ["test-driven-development", "incremental-implementation"],
82
+ "implement.fix.suite": ["debugging-and-error-recovery"],
83
+ "implement.health": ["debugging-and-error-recovery"],
66
84
  })
67
85
  })
68
86
 
@@ -29,6 +29,12 @@ export const skills = (
29
29
  refactor: ["ponytail"],
30
30
  "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
31
31
  "refactor.health": ["ponytail", "debugging-and-error-recovery"],
32
+ // Declared whether or not `naive_pass` is on, so a config keyed on them keeps
33
+ // loading when the flag flips off.
34
+ seam: ["incremental-implementation"],
35
+ implement: ["test-driven-development", "incremental-implementation"],
36
+ "implement.fix.suite": ["debugging-and-error-recovery"],
37
+ "implement.health": ["debugging-and-error-recovery"],
32
38
  ...lensScopes(
33
39
  "build.quality",
34
40
  lensesOf(vars.qualityReviews ?? ""),
@@ -17,6 +17,11 @@ import {
17
17
  buildReviewCollectingPrompt,
18
18
  designGateAnswerMessage,
19
19
  designTriagePrompt,
20
+ implementBuildingPrompt,
21
+ implementFixPrompt,
22
+ naivePassPrompt,
23
+ seamByHandMessage,
24
+ seamSteerMessage,
20
25
  summaryPrompt,
21
26
  withSkills,
22
27
  } from "./text.js"
@@ -105,6 +110,33 @@ describe("agentWithSkills access preamble", () => {
105
110
  })
106
111
  })
107
112
 
113
+ describe("the risk tier prompt and gate line", () => {
114
+ const triage = (riskTier: boolean) => renderText(() => designTriagePrompt("base", riskTier))
115
+ const gate = (riskTier: boolean) => renderText(() => designGateAnswerMessage(riskTier))
116
+
117
+ it("asks for the tier and the signals, before the answered questions, unsure meaning high", () => {
118
+ const prompt = triage(true)
119
+ expect(prompt).toContain("`## Risk Tier`")
120
+ expect(prompt).toContain("`## Production Signals`")
121
+ expect(prompt).toContain("BEFORE `## Answered Questions`")
122
+ expect(prompt).toContain("unsure means `high`")
123
+ expect(prompt).toContain("Whichever lap this is")
124
+ })
125
+
126
+ it("only appends the tier section when on", () => {
127
+ expect(triage(false)).not.toContain("Risk Tier")
128
+ expect(triage(true).startsWith(triage(false))).toBe(true)
129
+ })
130
+
131
+ it("says how to override the tier at the gate when on", () => {
132
+ expect(gate(true)).toContain("edit the first word under `## Risk Tier`")
133
+ expect(gate(true)).toContain("approve the next lap with no further change")
134
+ expect(gate(true)).toContain("means `high`")
135
+ expect(gate(false)).not.toContain("Risk Tier")
136
+ expect(gate(true).startsWith(gate(false))).toBe(true)
137
+ })
138
+ })
139
+
108
140
  describe("the shared open-question instruction", () => {
109
141
  it("leaves the option count to the agent, floored at two real options", () => {
110
142
  const prompt = renderText(() => designTriagePrompt("base"))
@@ -241,11 +273,103 @@ describe("summaryPrompt", () => {
241
273
  { model: "smart", cost: 5 },
242
274
  { model: "base", cost: 7 },
243
275
  ],
276
+ marks: [],
244
277
  vars: {},
245
278
  env: {},
246
279
  })
247
280
  expect(prompt).toContain("Token cost: 12\n- smart: 5\n- base: 7\n\nPrint the closing message")
248
281
  })
282
+
283
+ const base = {
284
+ entryCommit: "e",
285
+ processBase: "b",
286
+ processTip: "t",
287
+ humanCommits: [],
288
+ processCost: 0,
289
+ processCostByModel: [],
290
+ marks: [],
291
+ vars: {},
292
+ env: {},
293
+ }
294
+
295
+ it("with no marks leaves today's text", () => {
296
+ expect(summaryPrompt(base)).toContain("Token cost: 0\n\nPrint the closing message")
297
+ expect(summaryPrompt(base)).not.toContain("Risk tier")
298
+ expect(summaryPrompt(base)).not.toContain("Production signals")
299
+ })
300
+
301
+ it("adds the advisory risk line once for the last lap's high tier", () => {
302
+ const prompt = summaryPrompt({
303
+ ...base,
304
+ marks: [
305
+ { name: "risk-tier", value: "low" },
306
+ { name: "risk-tier", value: "high" },
307
+ ],
308
+ })
309
+ expect(prompt).toContain("Risk tier: high — this merge needs a human reviewer (advisory")
310
+ expect(prompt.split("Risk tier: high").length).toBe(2)
311
+ })
312
+
313
+ it("reads only the last lap: a lowered tier and a dropped signal leave no trace", () => {
314
+ const prompt = summaryPrompt({
315
+ ...base,
316
+ marks: [
317
+ { name: "risk-tier", value: "high" },
318
+ { name: "production-signal", value: "error rate" },
319
+ { name: "risk-tier", value: "low" },
320
+ { name: "production-signal", value: "p95" },
321
+ ],
322
+ })
323
+ expect(prompt).not.toContain("Risk tier")
324
+ expect(prompt).not.toContain("error rate")
325
+ expect(prompt).toContain("- `p95`\n")
326
+ })
327
+
328
+ it("treats a flag-on process with no tier mark as high", () => {
329
+ const prompt = summaryPrompt({
330
+ ...base,
331
+ vars: { flag_risk_tier: "1", flag_naive_pass: "1" },
332
+ })
333
+ expect(prompt).toContain("Risk tier: high")
334
+ })
335
+
336
+ it("marks no tier when the risk-tier flag is on without naive-pass", () => {
337
+ const prompt = summaryPrompt({ ...base, vars: { flag_risk_tier: "1", flag_naive_pass: "0" } })
338
+ expect(prompt).not.toContain("Risk tier")
339
+ })
340
+
341
+ const signal = (value: string) => ({ name: "production-signal", value })
342
+
343
+ it("caps the listed signals at 10 and fences them as not instructions", () => {
344
+ const prompt = summaryPrompt({
345
+ ...base,
346
+ marks: [
347
+ { name: "risk-tier", value: "low" },
348
+ ...Array.from({ length: 12 }, (_, i) => signal(`s${i}`)),
349
+ ],
350
+ })
351
+ expect(prompt).toContain("not instructions")
352
+ for (let i = 0; i < 10; i++) expect(prompt).toContain(`- \`s${i}\`\n`)
353
+ expect(prompt).not.toContain("`s10`")
354
+ })
355
+
356
+ it("cuts a long signal to exactly 200 chars", () => {
357
+ const prompt = summaryPrompt({
358
+ ...base,
359
+ marks: [{ name: "risk-tier", value: "low" }, signal("s0"), signal("x".repeat(300))],
360
+ })
361
+ expect(prompt).toContain(`- \`${"x".repeat(200)}\`\n`)
362
+ expect(prompt).not.toContain("x".repeat(201))
363
+ })
364
+
365
+ it("lists a repeated signal once, in first-occurrence order", () => {
366
+ const prompt = summaryPrompt({
367
+ ...base,
368
+ marks: [{ name: "risk-tier", value: "low" }, signal("s0"), signal("s1"), signal("s0")],
369
+ })
370
+ expect(prompt).toContain("- `s0`\n- `s1`\n")
371
+ expect(prompt.split("- `s0`").length).toBe(2)
372
+ })
249
373
  })
250
374
 
251
375
  describe("the thread rules", () => {
@@ -441,3 +565,67 @@ describe("buildReviewReviewingPrompt scenarios", () => {
441
565
  expect(prompt).not.toContain("Weakened:")
442
566
  })
443
567
  })
568
+
569
+ describe("the seam phase's texts", () => {
570
+ const rejection = { strays: ["src/flows/helpers.ts", "src/flows/runtime.ts"], p: 0.85 }
571
+
572
+ it("asks the first naive pass for the quickest working change, with no rejection", () => {
573
+ const prompt = renderText(() => naivePassPrompt())
574
+ expect(prompt).toContain(".gtd/REQUIREMENTS.md")
575
+ expect(prompt).not.toContain("rejected")
576
+ })
577
+
578
+ it("names the reject verdict, its p, every stray and the criterion on a retry", () => {
579
+ const prompt = renderText(() => naivePassPrompt(rejection))
580
+ expect(prompt).toContain("reject")
581
+ expect(prompt).toContain("0.85")
582
+ expect(prompt).toContain("`src/flows/helpers.ts`")
583
+ expect(prompt).toContain("`src/flows/runtime.ts`")
584
+ expect(prompt).toContain("solved the task the wrong way")
585
+ })
586
+
587
+ it("cites the reference as reading material only and asks for a new release flag", () => {
588
+ const prompt = renderText(() => implementBuildingPrompt("abc123"))
589
+ expect(prompt).toContain("git show abc123")
590
+ expect(prompt).toContain("never apply")
591
+ expect(prompt).toContain("release flag")
592
+ })
593
+
594
+ it("high and undefined tiers carry the flag bullet; low says to add none", () => {
595
+ const base = renderText(() => implementBuildingPrompt("abc123"))
596
+ expect(renderText(() => implementBuildingPrompt("abc123", "high"))).toBe(base)
597
+ expect(base).toContain("new release flag")
598
+ const low = renderText(() => implementBuildingPrompt("abc123", "low"))
599
+ expect(low).toContain("Add no release flag — this change is low risk tier")
600
+ expect(low).not.toContain("new release flag")
601
+ expect(low).toContain("git show abc123")
602
+ })
603
+
604
+ it("keeps a fix turn to the feedback file's rules", () => {
605
+ expect(renderText(() => implementFixPrompt())).toContain(".gtd/FEEDBACK.md")
606
+ })
607
+
608
+ it("names both steer answers and the overrule risk, and lists every stray", () => {
609
+ const message = renderText(() => seamSteerMessage(rejection))
610
+ expect(message).toContain("edit `.gtd/REQUIREMENTS.md`")
611
+ expect(message).toContain("clean")
612
+ expect(message).toContain("Overrule")
613
+ expect(message).toContain("unaddressed")
614
+ expect(message).toContain("src/flows/helpers.ts")
615
+ })
616
+
617
+ it("names every unreadable path for a by-hand landing", () => {
618
+ const message = renderText(() =>
619
+ seamByHandMessage(["vendor/a.bin", "src/flows/helpers.ts"], ["vendor/a.bin"], "abc123"),
620
+ )
621
+ expect(message).toContain("`vendor/a.bin` (unreadable)")
622
+ expect(message).toContain("`src/flows/helpers.ts`\n")
623
+ expect(message).toContain("git show abc123 --")
624
+ })
625
+
626
+ it("keeps a stray path with a newline on one line", () => {
627
+ const message = renderText(() => seamSteerMessage({ strays: ["a\nb.ts"], p: 0.9 }))
628
+ expect(message).toContain('`"a\\nb.ts"`')
629
+ expect(message).not.toContain("a\nb.ts")
630
+ })
631
+ })