@pmelab/gtd 24.3.0 → 25.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +780 -813
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +666 -161
  9. package/dist/workflow-kit.mjs +55 -96
  10. package/dist/workflows/feature.mjs +171 -76
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +353 -310
  14. package/package.json +1 -2
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/replay/Replay.ts +2 -1
  23. package/src/steering/FORMATS.md +1 -1
  24. package/src/steering/SteeringFormat.test.ts +9 -1
  25. package/src/steering/SteeringFormat.ts +7 -0
  26. package/src/steering/contract.test.ts +3 -0
  27. package/src/steering/index.ts +1 -0
  28. package/src/steering/review.test.ts +30 -0
  29. package/src/steering/review.ts +8 -2
  30. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  31. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  32. package/src/testkit/EmittedScripts.test.ts +166 -0
  33. package/src/testkit/FlowScripts.test.ts +242 -0
  34. package/src/testkit/FlowScripts.ts +326 -0
  35. package/src/testkit/GitTiers.ts +140 -49
  36. package/src/testkit/InMemRepo.ts +35 -2
  37. package/src/testkit/Layers.ts +10 -2
  38. package/src/testkit/TestRepo.ts +4 -1
  39. package/src/testkit/index.ts +1 -1
  40. package/src/workflow/discovery.test.ts +147 -0
  41. package/src/workflow/discovery.ts +5 -0
  42. package/src/workflow/fingerprint.test.ts +20 -4
  43. package/src/workflow/fingerprint.ts +8 -9
  44. package/src/workflow/index.ts +1 -6
  45. package/src/workflow/load.test.ts +5 -19
  46. package/src/workflow/load.ts +94 -10
  47. package/src/workflow-kit/review.test.ts +84 -0
  48. package/src/workflow-kit/review.ts +9 -6
  49. package/src/workflow-kit/scenarios.test.ts +116 -0
  50. package/src/workflow-kit/scenarios.ts +53 -113
  51. package/src/workflows/feature/feature.ts +2 -5
  52. package/src/workflows/feature/packages.test.ts +17 -34
  53. package/src/workflows/feature/packages.ts +13 -52
  54. package/src/workflows/feature/planning.ts +1 -1
  55. package/src/workflows/feature/review.test.ts +233 -54
  56. package/src/workflows/feature/review.ts +178 -41
  57. package/src/workflows/feature/steps.test.ts +12 -0
  58. package/src/workflows/feature/steps.ts +8 -1
  59. package/src/workflows/index.ts +0 -2
  60. package/src/workflows/shared/access.test.ts +4 -2
  61. package/src/workflows/shared/access.ts +1 -0
  62. package/src/workflows/shared/health.test.ts +1 -21
  63. package/src/workflows/shared/health.ts +4 -39
  64. package/src/workflows/shared/options.test.ts +8 -0
  65. package/src/workflows/shared/options.ts +1 -1
  66. package/src/workflows/shared/steps.ts +18 -11
  67. package/src/workflows/shared/tail.test.ts +1 -41
  68. package/src/workflows/shared/tail.ts +17 -41
  69. package/src/workflows/shared/text.fixture.ts +1 -0
  70. package/src/workflows/shared/text.test.ts +129 -41
  71. package/src/workflows/shared/text.ts +111 -75
  72. package/src/workflows/shared/vars.ts +1 -1
  73. package/src/workflows/shared/scenarios.test.ts +0 -256
  74. package/src/workflows/shared/scenarios.ts +0 -35
@@ -9,7 +9,6 @@ export { ShippedOptions } from "./shared/options.js"
9
9
  export { agentWithSkills } from "./shared/text.js"
10
10
  export * from "./shared/steps.js"
11
11
  export * from "./shared/health.js"
12
- export * from "./shared/scenarios.js"
13
12
  export {
14
13
  closeReview,
15
14
  fixQualityFindings,
@@ -18,7 +17,6 @@ export {
18
17
  qualityLap,
19
18
  REVIEW_ORIG,
20
19
  tail,
21
- type TailPlan,
22
20
  } from "./shared/tail.js"
23
21
  export { lensesOf } from "../workflow-kit/index.js"
24
22
  export * from "./feature/steps.js"
@@ -13,6 +13,7 @@ describe("the bundled workflow's access export", () => {
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
15
  "build.review": { write: [".gtd/REVIEW.md"] },
16
+ "build.review.defer": { write: [".gtd/TODO.md"] },
16
17
  "build.review.fix.nits": {},
17
18
  "build.review.fix.risks": {},
18
19
  "build.quality.correctness": { write: [] },
@@ -33,8 +34,9 @@ describe("the bundled workflow's access export", () => {
33
34
  ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
34
35
  })
35
36
 
36
- it("keys only scopes that run a turn (every key is a skills key)", () => {
37
- const known = Object.keys(workflow.skills(defaults))
37
+ it("keys only turn or script scopes", () => {
38
+ // `build.review.defer` is a script scope: no turn, so no skills entry.
39
+ const known = [...Object.keys(workflow.skills(defaults)), "build.review.defer"]
38
40
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
39
41
  })
40
42
  })
@@ -13,6 +13,7 @@ export const access = (
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
15
  "build.review": { write: [".gtd/REVIEW.md"] },
16
+ "build.review.defer": { write: [".gtd/TODO.md"] },
16
17
  // `{}` reopens what `build.review` restricted: the fixes edit code.
17
18
  "build.review.fix.nits": {},
18
19
  "build.review.fix.risks": {},
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../../judges/index.js"
3
- import { fastSuiteGuard, retryQuestion } from "./health.js"
4
- import { renderText } from "./text.fixture.js"
3
+ import { retryQuestion } from "./health.js"
5
4
 
6
5
  describe("retryQuestion", () => {
7
6
  it.each([true, false])("is sendable to jev (comparable=%s)", (comparable) => {
@@ -12,22 +11,3 @@ describe("retryQuestion", () => {
12
11
  ).toBeGreaterThanOrEqual(2)
13
12
  })
14
13
  })
15
-
16
- describe("fastSuiteGuard", () => {
17
- it("unset: names the setting and exits 0", () => {
18
- const lines = renderText(fastSuiteGuard).join("\n")
19
- expect(lines).toContain("fastTestCommand")
20
- expect(lines).toContain("GTD_FASTTESTCOMMAND")
21
- expect(lines).toContain("exit 0")
22
- })
23
-
24
- it("blank after trim counts as unset", () => {
25
- expect(renderText(fastSuiteGuard, { env: { fastTestCommand: " \t " } })).toContain("exit 0")
26
- })
27
-
28
- it("set: only removes SETUP", () => {
29
- const lines = renderText(fastSuiteGuard, { env: { fastTestCommand: "true" } })
30
- expect(lines.join("\n")).not.toContain("exit 0")
31
- expect(lines.join("\n")).toContain("rm -f")
32
- })
33
- })
@@ -1,4 +1,3 @@
1
- import { dirname } from "node:path"
2
1
  import {
3
2
  answered,
4
3
  check,
@@ -6,8 +5,6 @@ import {
6
5
  human,
7
6
  judge,
8
7
  numeric,
9
- quote,
10
- read,
11
8
  vars,
12
9
  type JudgeQuestion,
13
10
  } from "../../flows/index.js"
@@ -15,35 +12,12 @@ import * as kit from "../../workflow-kit/index.js"
15
12
  import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
16
13
  import * as t from "./text.js"
17
14
 
18
- /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
19
- export const SETUP = ".gtd/SETUP.md"
20
-
21
- const SETUP_TEXT =
22
- "The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it as a workflow option in `gtd.config.ts` or as GTD_FASTTESTCOMMAND."
23
-
24
- /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
25
- export const fastSuiteGuard = (): string[] =>
26
- (env.fastTestCommand ?? "").trim() === ""
27
- ? [
28
- `mkdir -p ${quote(dirname(SETUP))}`,
29
- `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
30
- `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
31
- "exit 0",
32
- ]
33
- : [`rm -f ${quote(SETUP)}`]
34
-
35
15
  /** Fix turns a run of red checks gets before it escalates. */
36
16
  export const FIX_CAP = 3
37
17
 
38
- const unconfigured = (): boolean => read(SETUP) !== undefined
39
-
40
18
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
41
19
  export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
42
- kit.untilSettled(
43
- () =>
44
- check(name, env.testCommand ?? "", { report: FEEDBACK, label, preamble: fastSuiteGuard() }),
45
- unconfigured,
46
- )
20
+ check(name, env.testCommand ?? "", { report: FEEDBACK, label })
47
21
 
48
22
  export type { EscalationCount } from "../../workflow-kit/index.js"
49
23
 
@@ -103,22 +77,17 @@ export interface HealthOptions {
103
77
  readonly fixesSoFar?: number
104
78
  /** Escalation rounds shared with other callers in the same run of red checks. */
105
79
  readonly escalations?: kit.EscalationCount
106
- /** `fast` runs `fastTestCommand` (default `full`: `testCommand`). */
107
- readonly suite?: "full" | "fast"
108
80
  /** Paths removed on a green run. */
109
81
  readonly sweepOnGreen?: readonly string[]
110
82
  }
111
83
 
112
- const runSuite = (options: HealthOptions): Promise<boolean> => {
113
- const fast = options.suite === "fast"
114
- return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
84
+ const runSuite = (options: HealthOptions): Promise<boolean> =>
85
+ check("health.check", env.testCommand ?? "", {
115
86
  report: FEEDBACK,
116
87
  label: "Running checks",
117
88
  // Swept only on green: an unresolved analysis survives every retry.
118
89
  sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
119
- ...(fast ? { preamble: fastSuiteGuard() } : {}),
120
90
  })
121
- }
122
91
 
123
92
  /**
124
93
  * Loop until the suite is green: check, and on red run `fix` — escalating
@@ -127,11 +96,7 @@ const runSuite = (options: HealthOptions): Promise<boolean> => {
127
96
  */
128
97
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
129
98
  kit.healthy(fix, {
130
- // Unconfigured: straight back to the check, no fix turn or count.
131
- check:
132
- options.suite === "fast"
133
- ? () => kit.untilSettled(() => runSuite(options), unconfigured)
134
- : () => runSuite(options),
99
+ check: () => runSuite(options),
135
100
  report: FEEDBACK,
136
101
  fixCap: FIX_CAP,
137
102
  fixesSoFar: options.fixesSoFar ?? 0,
@@ -22,6 +22,14 @@ describe("the shipped workflow factories", () => {
22
22
  })
23
23
  })
24
24
 
25
+ it("default reviewSeverityMinP to 0.7 and let a factory override win", () => {
26
+ expect(defaults.reviewSeverityMinP).toBe("0.7")
27
+ expect(configure(feature({ reviewSeverityMinP: "0.9" }))).toMatchObject({
28
+ ok: true,
29
+ build: { defaults: { reviewSeverityMinP: "0.9" } },
30
+ })
31
+ })
32
+
25
33
  it("build the shipped settings unchanged with no options", () => {
26
34
  for (const factory of [feature, fix, review]) {
27
35
  expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
@@ -15,9 +15,9 @@ export const ShippedOptions = Schema.Struct({
15
15
  judgeBudgetBytes: setting,
16
16
  judgeIdenticalMinP: setting,
17
17
  reviewNoteActionable: setting,
18
+ reviewSeverityMinP: setting,
18
19
  qualityReviews: setting,
19
20
  testCommand: setting,
20
- fastTestCommand: setting,
21
21
  plannerModel: setting,
22
22
  coderModel: setting,
23
23
  })
@@ -1,4 +1,5 @@
1
- import { env, human } from "../../flows/index.js"
1
+ import { changesSince, env, human } from "../../flows/index.js"
2
+ import { removedAssertions } from "../../workflow-kit/index.js"
2
3
  import * as t from "./text.js"
3
4
 
4
5
  // The bundled workflow's single steps. A step's name is relative to the
@@ -18,8 +19,8 @@ export const coder = (): string => env.coderModel ?? ""
18
19
 
19
20
  // ── Keeping the suite green ─────────────────────────────────────────────────
20
21
 
21
- export const fixCheck = (built?: t.BuildContext): Promise<void> =>
22
- t.agentWithSkills("fix", t.buildFixPrompt(built), {
22
+ export const fixCheck = (): Promise<void> =>
23
+ t.agentWithSkills("fix", t.buildFixPrompt(), {
23
24
  label: "Fixing the check",
24
25
  file: FEEDBACK,
25
26
  model: coder(),
@@ -90,14 +91,18 @@ export const fixQuality = (): Promise<void> =>
90
91
 
91
92
  /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
92
93
  export const reviewing = (base: string, carry?: string): Promise<void> =>
93
- t.agentWithSkills("review.reviewing", t.buildReviewReviewingPrompt(base, carry), {
94
- label: "Reviewing",
95
- file: REVIEW,
96
- mode: "review",
97
- model: planner(),
98
- system: t.reviewerSystem(),
99
- base,
100
- })
94
+ t.agentWithSkills(
95
+ "review.reviewing",
96
+ t.buildReviewReviewingPrompt(base, carry, removedAssertions(changesSince(base))),
97
+ {
98
+ label: "Reviewing",
99
+ file: REVIEW,
100
+ mode: "review",
101
+ model: planner(),
102
+ system: t.reviewerSystem(),
103
+ base,
104
+ },
105
+ )
101
106
 
102
107
  /** Fix the `Risk:`-marked notes the reviewer named; an empty turn means the risk was judged false. */
103
108
  export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
@@ -112,6 +117,7 @@ export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
112
117
  export const awaitReview = (
113
118
  base: string,
114
119
  message = t.buildReviewAwaitReviewMessage(base),
120
+ acceptClean = false,
115
121
  ): Promise<void> =>
116
122
  human("review.await-review", {
117
123
  message,
@@ -119,6 +125,7 @@ export const awaitReview = (
119
125
  file: REVIEW,
120
126
  mode: "review",
121
127
  base,
128
+ ...(acceptClean ? { acceptClean } : {}),
122
129
  })
123
130
 
124
131
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
@@ -5,51 +5,11 @@ import {
5
5
  type CodeThreadInfo,
6
6
  type StepRequest,
7
7
  } from "../../flows/index.js"
8
- import { fixQualityFindings, handOffReview, REVIEW_ORIG } from "./tail.js"
8
+ import { handOffReview, REVIEW_ORIG } from "./tail.js"
9
9
  import { fixtureContext } from "./text.fixture.js"
10
10
 
11
11
  afterEach(() => installContext(undefined))
12
12
 
13
- describe("the quality fix loop", () => {
14
- it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
15
- const QUALITY = ".gtd/QUALITY.md"
16
- const log: string[] = []
17
- const files = new Map([
18
- [QUALITY, "- finding"],
19
- ["e2e/a.feature", "Given a"],
20
- ])
21
- let last: readonly Change[] = []
22
- installContext(
23
- fixtureContext(
24
- {},
25
- {
26
- step: (request: StepRequest) => {
27
- if (request.kind === "restart") return Promise.resolve()
28
- log.push(request.name)
29
- last = []
30
- if (request.name === "fix.quality.fixing") {
31
- files.delete(QUALITY)
32
- files.set("e2e/a.feature", "Given b")
33
- last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
34
- }
35
- return Promise.resolve()
36
- },
37
- pushScope: () => undefined,
38
- popScope: () => undefined,
39
- read: (path) => files.get(path),
40
- changes: () => last,
41
- matches: (path, pattern) => path === pattern,
42
- head: () => "h2",
43
- start: () => "h0",
44
- },
45
- ),
46
- )
47
- const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
48
- expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
49
- expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
50
- })
51
- })
52
-
53
13
  const REVIEW = ".gtd/REVIEW.md"
54
14
  const reviewDoc = (note: string): string =>
55
15
  [
@@ -1,9 +1,7 @@
1
- import { quote, read, refuse, removeScript, run, scope, vars } from "../../flows/index.js"
1
+ import { read, refuse, removeScript, run, scope, showScript, vars } from "../../flows/index.js"
2
2
  import * as kit from "../../workflow-kit/index.js"
3
3
  import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
4
- import { guarded, type FrozenScenarios } from "./scenarios.js"
5
4
  import {
6
- ARCHITECTURE,
7
5
  awaitReview,
8
6
  fixCheck,
9
7
  fixQuality,
@@ -15,7 +13,6 @@ import {
15
13
  reviewQuality,
16
14
  } from "./steps.js"
17
15
  import * as t from "./text.js"
18
- import type { BuildContext } from "./text.js"
19
16
 
20
17
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
21
18
  export const REVIEW_ORIG = ".gtd/REVIEW.orig.md"
@@ -29,22 +26,14 @@ export const qualityLap = (): Promise<"clean" | "findings"> =>
29
26
  kit.qualityLap(kit.lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY)
30
27
 
31
28
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
32
- export const fixQualityFindings = (
33
- escalations: EscalationCount,
34
- frozen?: FrozenScenarios,
35
- ): Promise<boolean> =>
29
+ export const fixQualityFindings = (escalations: EscalationCount): Promise<boolean> =>
36
30
  kit.fixFindings({
37
- fix: guarded(frozen, fixQuality),
31
+ fix: fixQuality,
38
32
  findings: QUALITY,
39
33
  cap: FIX_CAP,
40
34
  escalate: () => escalation(escalations),
41
35
  })
42
36
 
43
- /** What the tail's full run knows of a plan the process built. */
44
- export interface TailPlan extends BuildContext {
45
- readonly frozen: FrozenScenarios | undefined
46
- }
47
-
48
37
  /** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
49
38
  export const closeReview = (round: string): Promise<void> =>
50
39
  run("review.closing", removeScript([REVIEW, REVIEW_ORIG]), {
@@ -63,47 +52,33 @@ export const refusePendingHandOff = (): void => {
63
52
 
64
53
  /**
65
54
  * The build tail: fix (when entered red), keep green, the quality lap, then
66
- * `finish` — the human review, as the calling workflow runs it.
55
+ * `finish` — the human review, as the calling workflow runs it. `green`: the
56
+ * caller just left the full suite green (the package loop), so no run opens it.
67
57
  */
68
58
  export const tail = <T>(
69
59
  fixFirst: boolean,
70
- finish: (escalations: EscalationCount, frozen: FrozenScenarios | undefined) => Promise<T>,
71
- built?: TailPlan,
60
+ finish: (escalations: EscalationCount) => Promise<T>,
61
+ green = false,
72
62
  ): Promise<T> =>
73
63
  scope("build", async () => {
74
- const frozen = built?.frozen
75
64
  const escalations: EscalationCount = { rounds: 0 }
76
- const guardedFix = guarded(frozen, () => fixCheck())
65
+ const fix = fixCheck
77
66
  const repair = async () => {
78
- await guardedFix()
79
- await healthy(guardedFix, { fixesSoFar: 1, escalations })
67
+ await fix()
68
+ await healthy(fix, { fixesSoFar: 1, escalations })
80
69
  }
81
70
  // fixFirst's own fix + healthy below already is the full run.
82
71
  if (fixFirst) await repair()
83
- else {
84
- await healthy(
85
- guarded(frozen, () => fixCheck(built)),
86
- { escalations, sweepOnGreen: [ARCHITECTURE] },
87
- )
88
- }
72
+ else if (!green) await healthy(fix, { escalations })
89
73
  // The lap runs once a tail: after its findings are fixed, review follows.
90
74
  if ((await qualityLap()) === "findings") {
91
- if (await fixQualityFindings(escalations, frozen)) {
92
- await healthy(guardedFix, { escalations })
75
+ if (await fixQualityFindings(escalations)) {
76
+ await healthy(fix, { escalations })
93
77
  } else await repair()
94
78
  }
95
- return finish(escalations, frozen)
79
+ return finish(escalations)
96
80
  })
97
81
 
98
- // The committed blob, not the working copy: the human's notes already landed over it.
99
- const handOffScript = (reviewed: string): string =>
100
- [
101
- "#!/usr/bin/env sh",
102
- "set -e",
103
- `git show ${quote(`${reviewed}:${REVIEW}`)} > ${quote(REVIEW_ORIG)}`,
104
- "",
105
- ].join("\n")
106
-
107
82
  /**
108
83
  * The review and fix workflows' human review since `base`: a sign-off closes
109
84
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
@@ -114,12 +89,13 @@ export const handOffReview = (base: string, escalations: EscalationCount): Promi
114
89
  file: REVIEW,
115
90
  reviewing,
116
91
  fixRisks,
117
- keepGreen: () => healthy(() => fixCheck(), { escalations }),
92
+ keepGreen: () => healthy(fixCheck, { escalations }),
118
93
  awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
119
94
  reviewMissing,
120
95
  close: closeReview,
121
96
  handOff: (reviewed) =>
122
- run("review.handing-off", handOffScript(reviewed), {
97
+ // The committed blob, not the working copy: the human's notes already landed over it.
98
+ run("review.handing-off", showScript(reviewed, REVIEW, REVIEW_ORIG), {
123
99
  label: "Handing your notes to fix",
124
100
  }),
125
101
  threadsPending:
@@ -114,6 +114,7 @@ export const captureStep = async (
114
114
  captured = request
115
115
  return Promise.resolve()
116
116
  },
117
+ changesSince: () => [],
117
118
  }),
118
119
  )
119
120
  try {
@@ -1,5 +1,11 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
1
5
  import { describe, expect, it } from "vitest"
2
6
  import {
7
+ buildReviewTriageMessage,
8
+ deferNotesScript,
3
9
  buildReviewReviewingPrompt,
4
10
  agentWithSkills,
5
11
  architectureAuthorPrompt,
@@ -14,6 +20,7 @@ import {
14
20
  summaryPrompt,
15
21
  withSkills,
16
22
  } from "./text.js"
23
+ import { applyEmittedScript, InMemRepo } from "../../testkit/index.js"
17
24
  import { captureStep, renderText } from "./text.fixture.js"
18
25
 
19
26
  describe("withSkills", () => {
@@ -175,50 +182,11 @@ describe("architectureAuthorPrompt", () => {
175
182
  })
176
183
  })
177
184
 
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", () => {
185
+ describe("buildFixPrompt", () => {
186
+ it("starts each bullet on its own line", () => {
210
187
  const prompt = renderText(() => buildFixPrompt())
211
- expect(renderText(() => buildFixPrompt(undefined))).toBe(prompt)
212
188
  expect(prompt).toMatch(/\n- Leave everything uncommitted — do not commit\n$/)
213
189
  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
190
  })
223
191
  })
224
192
 
@@ -353,3 +321,123 @@ describe("buildReviewReviewingPrompt risk marking", () => {
353
321
  expect(prompt).toContain("re-mark only a risk the fix did not resolve")
354
322
  })
355
323
  })
324
+
325
+ describe("deferNotesScript", () => {
326
+ const run = (notes: Parameters<typeof deferNotesScript>[0], existing?: string): string => {
327
+ const dir = mkdtempSync(join(tmpdir(), "defer-"))
328
+ mkdirSync(join(dir, ".gtd"))
329
+ if (existing !== undefined) writeFileSync(join(dir, ".gtd/TODO.md"), existing)
330
+ execFileSync("sh", ["-c", deferNotesScript(notes, "abcdef1234567890")], { cwd: dir })
331
+ return readFileSync(join(dir, ".gtd/TODO.md"), "utf8")
332
+ }
333
+ const notes = [{ id: "note-1", anchor: "a.ts:3", text: "plain", label: "could: tiny" }]
334
+
335
+ it("creates the file with one section headed by the round's short sha", () => {
336
+ expect(run(notes)).toBe(
337
+ "## Deferred review notes (abcdef1)\n\n- note-1 — a.ts:3 (could: tiny): plain\n",
338
+ )
339
+ })
340
+
341
+ it("appends after a blank line, never truncating", () => {
342
+ const out = run(notes, "# Sketch\n\nkeep me\n")
343
+ expect(out.startsWith("# Sketch\n\nkeep me\n\n## Deferred review notes (abcdef1)\n")).toBe(true)
344
+ })
345
+
346
+ it("separates from a file lacking a trailing newline", () => {
347
+ expect(run(notes, "keep")).toContain("keep\n\n## Deferred")
348
+ })
349
+
350
+ it("lands quotes, dollars and backticks verbatim", () => {
351
+ const text = 'it\'s $HOME `x` "q" \\n'
352
+ const out = run([{ id: "note-1", anchor: "a", text }])
353
+ expect(out).toContain(text)
354
+ })
355
+
356
+ it("is an oxfmt fixed point, for a note longer than one line", () => {
357
+ const long = [
358
+ {
359
+ id: "note-3",
360
+ anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
361
+ text: "rename the helper to something clearer please",
362
+ label: "could",
363
+ },
364
+ ]
365
+ const dir = mkdtempSync(join(tmpdir(), "fmt-"))
366
+ const file = join(dir, "TODO.md")
367
+ writeFileSync(file, run(long, "# Sketch\n"))
368
+ execFileSync("npx", ["oxfmt", "--check", file], { cwd: process.cwd() })
369
+ })
370
+ })
371
+
372
+ describe("severity wording", () => {
373
+ it("triage message names the four severity ids", () => {
374
+ const m = renderText(() => buildReviewTriageMessage())
375
+ for (const id of ["note-<n>.security", ".reversibility", ".blastRadius", ".newConcept"])
376
+ expect(m).toContain(id)
377
+ })
378
+
379
+ it("collecting prompt keeps capture order and carries labels", () => {
380
+ const p = renderText(() => buildReviewCollectingPrompt("capture"))
381
+ expect(p).toContain("capture order")
382
+ expect(p).toContain("(tier: reason)")
383
+ })
384
+
385
+ it("design triage parks deferred review notes", () => {
386
+ expect(renderText(() => designTriagePrompt("base"))).toContain("## Deferred review notes")
387
+ })
388
+ })
389
+
390
+ describe("deferNotesScript — the in-memory recognizer agrees with the real sh script", () => {
391
+ const notes = [
392
+ { id: "note-1", anchor: "a.ts:3", text: 'it\'s $HOME `x` "q" 100%', label: "could" },
393
+ {
394
+ id: "note-2",
395
+ anchor: "Parser accepts CRLF ./src/parse.ts#30-31",
396
+ text: "rename the helper to something clearer please",
397
+ label: "could",
398
+ },
399
+ ]
400
+ const cases: [string, string | undefined][] = [
401
+ ["no file", undefined],
402
+ ["an empty file", ""],
403
+ ["a file without a trailing newline", "keep"],
404
+ ["a file with a trailing newline", "# Sketch\n\nkeep\n"],
405
+ ]
406
+
407
+ it.each(cases)("%s", (_name, existing) => {
408
+ const script = deferNotesScript(notes, "abcdef1234567890")
409
+ const dir = mkdtempSync(join(tmpdir(), "defer-contract-"))
410
+ if (existing !== undefined) {
411
+ mkdirSync(join(dir, ".gtd"))
412
+ writeFileSync(join(dir, ".gtd/TODO.md"), existing)
413
+ }
414
+ execFileSync("sh", ["-c", script], { cwd: dir })
415
+ const real = readFileSync(join(dir, ".gtd/TODO.md"), "utf8")
416
+
417
+ const repo = new InMemRepo()
418
+ if (existing !== undefined) repo.writeFile(".gtd/TODO.md", existing)
419
+ expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
420
+ expect(repo.readFile(".gtd/TODO.md")).toBe(real)
421
+ })
422
+ })
423
+
424
+ describe("buildReviewReviewingPrompt scenarios", () => {
425
+ it("names each removed assertion and asks for a Weakened: note", () => {
426
+ const prompt = renderText(() =>
427
+ buildReviewReviewingPrompt("base", undefined, [
428
+ {
429
+ path: "e2e/a.feature",
430
+ lines: [{ scenario: "Scenario: shown", line: "Then it is shown" }],
431
+ },
432
+ ]),
433
+ )
434
+ expect(prompt).toContain("e2e/a.feature — Scenario: shown — Then it is shown")
435
+ expect(prompt).toContain("`Weakened:`")
436
+ })
437
+
438
+ it("asks for no Weakened: note when nothing was removed", () => {
439
+ const prompt = renderText(() => buildReviewReviewingPrompt("base"))
440
+ expect(prompt).toContain("## Scenarios")
441
+ expect(prompt).not.toContain("Weakened:")
442
+ })
443
+ })