@pmelab/gtd 18.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "18.1.0",
3
+ "version": "19.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  "watch": "tsdown --watch",
48
48
  "bench:replay": "node scripts/bench-replay.mjs",
49
49
  "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:e2e:inmem test:e2e:live test:web analyze",
50
+ "test:fast": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:web analyze",
50
51
  "test:unit": "vitest run --project unit",
51
52
  "test:watch": "vitest",
52
53
  "test:changed": "vitest run --project unit --project e2e-inmem --changed HEAD",
@@ -63,9 +63,9 @@ current directory is the whole workflow. If one already exists, read it and edit
63
63
  it in place.
64
64
 
65
65
  Prefer the bundled workflow's own parts over re-implementing them — `healthy`,
66
- `escalation`, `gate`, `design`, `architecturePass`, `packages`, `specReview`,
67
- `qualityLap`, `review`, `buildTail`, and single steps like `triage` or `fix`.
68
- Their full step names are versioned API.
66
+ `escalation`, `gate`, `design`, `architecture`, `packages`, `qualityLap`,
67
+ `review`, `buildTail`, and single steps like `triage` or `fix`. Their full step
68
+ names are versioned API.
69
69
 
70
70
  Make one small change, **verify it loads** (see "Verify"), then make the next. A
71
71
  workflow that fails to load breaks every gtd command in the repository.
@@ -212,7 +212,7 @@ sign-off between the two:
212
212
  const planAndBuild = async (): Promise<void> => {
213
213
  for (;;) {
214
214
  await design()
215
- await architecturePass()
215
+ await architecture()
216
216
  await human("approve-plan", {
217
217
  message:
218
218
  "The packages under .gtd/packages/ are ready. Edit them to adjust the plan, or change nothing — then run `gtd land` to start building.",
@@ -229,9 +229,8 @@ const planAndBuild = async (): Promise<void> => {
229
229
  `acceptClean: true` is what makes an untouched landing approve; without it the
230
230
  gate would wait for an edit. `approve-plan` sits in the `root` scope and is a
231
231
  new, unique name. Verify: `gtd next` loads without errors, and in a scratch
232
- repository a landing at `architecture.decompose` (or `architecture-promote`) now
233
- leads to `approve-plan`, and an untouched landing there to
234
- `packages.item.building`.
232
+ repository a landing at `architecture.decompose` now leads to `approve-plan`,
233
+ and an untouched landing there to `packages.item.building`.
235
234
 
236
235
  ## No migration
237
236
 
@@ -36,6 +36,8 @@ export interface CheckOptions {
36
36
  readonly sweep?: readonly string[] | undefined
37
37
  /** Paths removed once it passes. */
38
38
  readonly sweepOnGreen?: readonly string[] | undefined
39
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
40
+ readonly preamble?: readonly string[] | undefined
39
41
  }
40
42
 
41
43
  /**
@@ -48,10 +50,10 @@ export const check = async (
48
50
  command: string,
49
51
  options: CheckOptions,
50
52
  ): Promise<boolean> => {
51
- const { report, label, sweep, sweepOnGreen } = options
53
+ const { report, label, sweep, sweepOnGreen, preamble } = options
52
54
  await run(
53
55
  name,
54
- checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen }),
56
+ checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen, preamble }),
55
57
  { label },
56
58
  )
57
59
  return !wrote(report)
@@ -1,5 +1,9 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, mkdtempSync, 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
- import { restoreScript } from "./scripts.js"
6
+ import { checkScript, restoreScript } from "./scripts.js"
3
7
 
4
8
  describe("restoreScript", () => {
5
9
  const paths = { restore: ["a.ts"], remove: [] }
@@ -14,3 +18,26 @@ describe("restoreScript", () => {
14
18
  expect(script).not.toContain("abc~1")
15
19
  })
16
20
  })
21
+
22
+ describe("checkScript preamble", () => {
23
+ const options = { report: ".gtd/FEEDBACK.md", stamp: "abc" }
24
+ const runIn = (script: string, dir: string): void => {
25
+ writeFileSync(join(dir, "s.sh"), script)
26
+ spawnSync("sh", ["s.sh"], { cwd: dir })
27
+ }
28
+
29
+ it("renders before the command subshell", () => {
30
+ const script = checkScript("npm test", { ...options, preamble: ["echo pre"] })
31
+ expect(script.indexOf("echo pre")).toBeGreaterThan(-1)
32
+ expect(script.indexOf("echo pre")).toBeLessThan(script.indexOf("npm test"))
33
+ })
34
+
35
+ it("an exit 0 in the preamble skips the command and leaves report alone", () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "gtd-pre-"))
37
+ mkdirSync(join(dir, ".gtd"))
38
+ writeFileSync(join(dir, ".gtd/FEEDBACK.md"), "old")
39
+ runIn(checkScript("touch ran", { ...options, preamble: ["exit 0"] }), dir)
40
+ expect(existsSync(join(dir, "ran"))).toBe(false)
41
+ expect(readFileSync(join(dir, ".gtd/FEEDBACK.md"), "utf8")).toBe("old")
42
+ })
43
+ })
@@ -14,6 +14,8 @@ export interface CheckScriptOptions {
14
14
  readonly sweep?: readonly string[] | undefined
15
15
  /** Paths removed once it passes. */
16
16
  readonly sweepOnGreen?: readonly string[] | undefined
17
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
18
+ readonly preamble?: readonly string[] | undefined
17
19
  }
18
20
 
19
21
  const removal = (paths: readonly string[]): string[] =>
@@ -29,6 +31,7 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
29
31
  return [
30
32
  "#!/usr/bin/env sh",
31
33
  "set +e",
34
+ ...(options.preamble ?? []),
32
35
  ...removal(options.sweep ?? []),
33
36
  `mkdir -p "$(dirname ${report})"`,
34
37
  // A subshell, so an `exit` inside the command ends only the command.
@@ -12,7 +12,6 @@ describe("the bundled workflow's access export", () => {
12
12
  design: { write: [] },
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
- "packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
16
15
  "build.review": { write: [".gtd/REVIEW.md"] },
17
16
  "build.review.fix.nits": {},
18
17
  "build.review.fix.risks": {},
@@ -12,7 +12,6 @@ export const access = (
12
12
  design: { write: [] },
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
- "packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
16
15
  "build.review": { write: [".gtd/REVIEW.md"] },
17
16
  // `{}` reopens what `build.review` restricted: the fixes edit code.
18
17
  "build.review.fix.nits": {},
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../judges/index.js"
3
- import { retryQuestion } from "./health.js"
3
+ import { fastSuiteGuard, retryQuestion } from "./health.js"
4
+ import { renderText } from "./text.fixture.js"
4
5
 
5
6
  describe("retryQuestion", () => {
6
7
  it.each([true, false])("is sendable to jev (comparable=%s)", (comparable) => {
@@ -11,3 +12,22 @@ describe("retryQuestion", () => {
11
12
  ).toBeGreaterThanOrEqual(2)
12
13
  })
13
14
  })
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,3 +1,4 @@
1
+ import { dirname } from "node:path"
1
2
  import {
2
3
  answered,
3
4
  check,
@@ -5,6 +6,7 @@ import {
5
6
  human,
6
7
  judge,
7
8
  numeric,
9
+ quote,
8
10
  read,
9
11
  scope,
10
12
  vars,
@@ -13,12 +15,37 @@ import {
13
15
  import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
14
16
  import * as t from "./text.js"
15
17
 
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 under `env:` in `.gtdrc` 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
+
16
35
  /** Fix turns a run of red checks gets before it escalates. */
17
36
  export const FIX_CAP = 3
18
37
 
19
38
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
20
- export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
21
- check(name, env.testCommand ?? "", { report: FEEDBACK, label })
39
+ export const baseline = async (name: string, label = "Checking the baseline"): Promise<boolean> => {
40
+ for (;;) {
41
+ const green = await check(name, env.testCommand ?? "", {
42
+ report: FEEDBACK,
43
+ label,
44
+ preamble: fastSuiteGuard(),
45
+ })
46
+ if (read(SETUP) === undefined) return green
47
+ }
48
+ }
22
49
 
23
50
  /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
24
51
  export interface EscalationCount {
@@ -81,6 +108,21 @@ export interface HealthOptions {
81
108
  readonly fixesSoFar?: number
82
109
  /** Escalation rounds shared with other callers in the same run of red checks. */
83
110
  readonly escalations?: EscalationCount
111
+ /** `fast` runs `fastTestCommand` (default `full`: `testCommand`). */
112
+ readonly suite?: "full" | "fast"
113
+ /** Paths removed on a green run. */
114
+ readonly sweepOnGreen?: readonly string[]
115
+ }
116
+
117
+ const runSuite = (options: HealthOptions): Promise<boolean> => {
118
+ const fast = options.suite === "fast"
119
+ return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
120
+ report: FEEDBACK,
121
+ label: "Running checks",
122
+ // Swept only on green: an unresolved analysis survives every retry.
123
+ sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
124
+ ...(fast ? { preamble: fastSuiteGuard() } : {}),
125
+ })
84
126
  }
85
127
 
86
128
  /**
@@ -96,12 +138,9 @@ export const healthy = async (
96
138
  let fixes = options.fixesSoFar ?? 0
97
139
  let previous: string | undefined
98
140
  for (;;) {
99
- const green = await check("health.check", env.testCommand ?? "", {
100
- report: FEEDBACK,
101
- label: "Running checks",
102
- // Swept only on green: an unresolved analysis survives every retry.
103
- sweepOnGreen: [ESCALATION],
104
- })
141
+ const green = await runSuite(options)
142
+ // Unconfigured: straight back to the check, no fix turn or count.
143
+ if (options.suite === "fast" && read(SETUP) !== undefined) continue
105
144
  if (green) {
106
145
  escalations.rounds = 0
107
146
  return
@@ -0,0 +1,91 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type Change, type StepRequest } from "../flows/index.js"
3
+ import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
4
+ import { fixtureContext } from "./text.fixture.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ describe("declaredTests", () => {
9
+ it("parses unit and e2e lines of the Tests section", () => {
10
+ const text = [
11
+ "# Package",
12
+ "",
13
+ "## Tests",
14
+ "",
15
+ "- unit: `lib/a.test.ts`",
16
+ "- e2e: `spec/a.feature`",
17
+ ].join("\n")
18
+ expect(declaredTests(text)).toEqual([
19
+ { level: "unit", path: "lib/a.test.ts" },
20
+ { level: "e2e", path: "spec/a.feature" },
21
+ ])
22
+ })
23
+
24
+ it("ignores prose, malformed lines and other sections", () => {
25
+ const text = [
26
+ "## Tasks",
27
+ "- unit: `lib/not-here.test.ts`",
28
+ "## Tests",
29
+ "Some prose.",
30
+ "- unit: lib/unquoted.test.ts",
31
+ "- integration: `lib/x.test.ts`",
32
+ "- unit: `lib/ok.test.ts` extra",
33
+ "## Notes",
34
+ "- e2e: `spec/other.feature`",
35
+ ].join("\n")
36
+ expect(declaredTests(text)).toEqual([{ level: "unit", path: "lib/ok.test.ts" }])
37
+ })
38
+
39
+ it("returns nothing without a Tests section", () => {
40
+ expect(declaredTests("# Chore\n\n## Tasks\n- do it\n")).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe("packages scenarios", () => {
45
+ it("holds only package 0's own paths, not what later packages add", async () => {
46
+ const queue = [SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"]
47
+ const touches: { at: number; change: Change }[] = []
48
+ let steps = 0
49
+ const touch = (path: string, status: Change["status"]): void => {
50
+ touches.push({ at: steps, change: { path, status, before: undefined, after: "x" } })
51
+ }
52
+ const effects: Record<string, () => void> = {
53
+ building: () => {
54
+ if (queue[0] === SCENARIO_PACKAGE) {
55
+ touch("tests/e2e/a.feature", "added")
56
+ touch(".gtd/packages/00-e2e-scenarios.md", "modified")
57
+ } else {
58
+ touch("src/impl.ts", "added")
59
+ touch("tests/e2e/b.feature", "added")
60
+ touch("tests/e2e/a.feature", "modified")
61
+ }
62
+ },
63
+ closing: () => void queue.shift(),
64
+ }
65
+ installContext(
66
+ fixtureContext(
67
+ {},
68
+ {
69
+ step: (request: StepRequest) => {
70
+ steps++
71
+ if (request.kind !== "restart") effects[request.name]?.()
72
+ return Promise.resolve()
73
+ },
74
+ pushScope: () => undefined,
75
+ popScope: () => undefined,
76
+ refuse: (message: string): never => {
77
+ throw new Error(message)
78
+ },
79
+ glob: () => [...queue],
80
+ changesSince: (hash: string): readonly Change[] =>
81
+ touches.filter((t) => t.at >= Number(hash.slice(1))).map((t) => t.change),
82
+ head: () => `c${steps}`,
83
+ },
84
+ ),
85
+ )
86
+ const plan = await packages()
87
+ expect(plan.ranges.map((r) => r.pkg)).toEqual([SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"])
88
+ expect(plan.scenarios.added).toEqual(["tests/e2e/a.feature"])
89
+ expect(plan.scenarios.changed).toEqual([])
90
+ })
91
+ })
@@ -1,109 +1,129 @@
1
1
  import {
2
- answered,
3
- changes,
4
2
  changesSince,
3
+ type Change,
5
4
  glob,
6
5
  head,
7
- judge,
8
- numeric,
9
6
  read,
10
7
  refuse,
11
8
  removeScript,
12
9
  run,
13
10
  scope,
14
- sectionBodies,
15
- vars,
16
- wrote,
17
- type JudgeQuestion,
18
11
  } from "../flows/index.js"
19
- import { packageDiff } from "./diff.js"
20
12
  import { healthy } from "./health.js"
21
- import { ARCHITECTURE, build, fixSpec, fixSuite, reviewPackage, SPEC_FEEDBACK } from "./steps.js"
22
- import * as t from "./text.js"
13
+ import { freezeScenarios, guarded, type FrozenScenarios } from "./scenarios.js"
14
+ import { build, fixSuite } from "./steps.js"
23
15
 
24
- const MAX_SECTIONS = 8
25
- const DIFF_KEY = "diff"
16
+ /** Package 0: the e2e scenarios, written red. */
17
+ export const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md"
26
18
 
27
- const sectionQuestion = (id: string, title: string): JudgeQuestion => ({
28
- id,
29
- primitive: "noul",
30
- instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
31
- criteria:
32
- "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
33
- })
19
+ export interface DeclaredTest {
20
+ readonly level: "unit" | "e2e"
21
+ readonly path: string
22
+ }
23
+
24
+ const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/
25
+
26
+ /** The `## Tests` entries of a package file; malformed lines are ignored. */
27
+ export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
28
+ const found: DeclaredTest[] = []
29
+ let inTests = false
30
+ for (const line of packageText.split("\n")) {
31
+ if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line)
32
+ else if (inTests) {
33
+ const match = TEST_LINE.exec(line)
34
+ if (match) found.push({ level: match[1] as DeclaredTest["level"], path: match[2]! })
35
+ }
36
+ }
37
+ return found
38
+ }
34
39
 
35
40
  /**
36
- * Review one freshly built package against its spec: a pre-judge per
37
- * section, then an agent review of the sections it did not confidently
38
- * clear. `since` is the commit the package's own build started from — the
39
- * pre-judge's evidence is a diff over exactly that range, never an earlier
40
- * package's commits. Resolves `true` when approved.
41
+ * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
42
+ * diff since `since`. The package text is read as of `since`, so a builder
43
+ * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
44
+ * written, existing in the tree suffices.
41
45
  */
42
- export const specReview = async (pkg: string, since: string): Promise<boolean> => {
43
- const found = sectionBodies(read(pkg) ?? "")
44
- const titles = found.map((section) => section.title)
45
- const judged = titles.length > 0 && titles.length <= MAX_SECTIONS
46
- const evidence: Record<string, string> = {}
47
- const questions: JudgeQuestion[] = []
48
- if (judged) {
49
- found.forEach(({ title, body }, i) => {
50
- evidence[`section-${i + 1}`] = body
51
- questions.push(sectionQuestion(`section-${i + 1}`, title))
52
- })
53
- // The diff's own even share of the budget: one key per section plus
54
- // `diff` itself, so this cap equals what `budgeted` gives it below.
55
- const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1))
56
- evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes)
57
- }
58
- const { answers, truncated } = await judge("spec.pre", {
59
- questions,
60
- evidence,
61
- message: t.packagesItemSpecPreMessage(),
62
- label: "Judging spec coverage",
63
- })
64
- // A section is cleared only by a confident yes on evidence that was not
65
- // cut — its own body, or the diff every question is judged against.
66
- const clearMinP = numeric(vars.specPreJudge, Infinity)
67
- const failing = titles.filter((_, i) => {
68
- const id = `section-${i + 1}`
69
- return (
70
- !judged ||
71
- truncated.includes(id) ||
72
- truncated.includes(DIFF_KEY) ||
73
- !answered(answers[id], "yes", clearMinP)
46
+ export const requireDeclaredTests = (pkg: string, since: string): void => {
47
+ const range = changesSince(since)
48
+ const text = range.get(pkg)?.before ?? read(pkg) ?? ""
49
+ const satisfied = read(".gtd/SATISFIED.md") !== undefined
50
+ const missing = declaredTests(text)
51
+ .map((test) => test.path)
52
+ .filter((path) =>
53
+ satisfied
54
+ ? read(path) === undefined
55
+ : range.get(path) === undefined ||
56
+ range.get(path)?.status === "deleted" ||
57
+ read(path) === undefined,
74
58
  )
75
- })
76
- if (titles.length > 0 && failing.length === 0) return true
77
- await reviewPackage(pkg, failing)
78
- if (wrote(SPEC_FEEDBACK)) return false
79
- if (changes(SPEC_FEEDBACK).length > 0 || changes().length === 0) return true
80
- return refuse(
81
- "gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve",
59
+ if (missing.length === 0) return
60
+ refuse(
61
+ `gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`,
82
62
  )
83
63
  }
84
64
 
85
- /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
86
- export const packageItem = async (pkg: string): Promise<void> => {
87
- const since = head()
88
- await build(pkg)
89
- for (;;) {
90
- await healthy(fixSuite)
91
- if (await specReview(pkg, since)) break
92
- await fixSpec(pkg)
65
+ export interface PackageRange {
66
+ readonly pkg: string
67
+ readonly from: string
68
+ readonly to: string
69
+ }
70
+
71
+ export interface BuiltPlan {
72
+ readonly ranges: readonly PackageRange[]
73
+ /** Paths package 0 added / modified outside `.gtd/`. */
74
+ readonly scenarios: { readonly added: readonly string[]; readonly changed: readonly string[] }
75
+ /** The wording snapshot after this run, to guard the tail and later laps. */
76
+ readonly frozen: FrozenScenarios | undefined
77
+ }
78
+
79
+ /** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
80
+ export const packageItem = async (pkg: string, frozen?: FrozenScenarios): Promise<PackageRange> => {
81
+ const from = head()
82
+ // Rechecked after every fix too: a fix deleting a red declared test would
83
+ // otherwise green the suite. Before the wording gate, so it refuses the turn.
84
+ const declared = (turn: () => Promise<void>) => async () => {
85
+ await turn()
86
+ requireDeclaredTests(pkg, from)
93
87
  }
94
- // The spent technical plan goes with the first package built from it.
95
- await run("closing", removeScript([pkg, SPEC_FEEDBACK, ".gtd/SATISFIED.md", ARCHITECTURE]), {
88
+ await guarded(
89
+ frozen,
90
+ declared(() => build(pkg)),
91
+ )()
92
+ await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" })
93
+ await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), {
96
94
  label: "Closing out the package",
97
95
  })
96
+ return { pkg, from, to: head() }
98
97
  }
99
98
 
100
99
  /** The first queued package — the one a package step works on. */
101
100
  export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
102
101
 
102
+ const outsideGtd = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
103
+
103
104
  /** Build every package file under `.gtd/packages/`, in name order. */
104
- export const packages = (): Promise<void> =>
105
+ export const packages = (carried?: FrozenScenarios): Promise<BuiltPlan> =>
105
106
  scope("packages", async () => {
107
+ let frozen = carried
108
+ const ranges: PackageRange[] = []
109
+ // Captured right after package 0: a later diff to the tree would also hold what 01…N touch.
110
+ let touched: readonly Change[] = []
106
111
  for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
107
- await scope("item", () => packageItem(pkg))
112
+ const range = await scope("item", () =>
113
+ packageItem(pkg, pkg === SCENARIO_PACKAGE ? undefined : frozen),
114
+ )
115
+ ranges.push(range)
116
+ if (pkg === SCENARIO_PACKAGE) {
117
+ touched = changesSince(range.from).filter(
118
+ (c) => outsideGtd(c.path) && c.status !== "deleted",
119
+ )
120
+ frozen = freezeScenarios(
121
+ touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path),
122
+ frozen,
123
+ )
124
+ }
108
125
  }
126
+ const paths = (status: string): string[] =>
127
+ touched.filter((c) => c.status === status).map((c) => c.path)
128
+ return { ranges, scenarios: { added: paths("added"), changed: paths("modified") }, frozen }
109
129
  })
@@ -1,20 +1,12 @@
1
1
  import {
2
- answered,
3
2
  changes,
4
3
  hasThreadFor,
5
- judge,
6
- moveScript,
7
- numeric,
8
- read,
9
4
  refuse,
10
5
  requireAnswers,
11
6
  requireReplies,
12
7
  requireThreadsClosed,
13
8
  requireProgress,
14
- run,
15
9
  scope,
16
- sections,
17
- vars,
18
10
  } from "../flows/index.js"
19
11
  import {
20
12
  answerProductQuestions,
@@ -25,7 +17,6 @@ import {
25
17
  REQUIREMENTS,
26
18
  triage,
27
19
  } from "./steps.js"
28
- import * as t from "./text.js"
29
20
 
30
21
  /**
31
22
  * Always stop for the human. Resolves `true` when the round changed anything
@@ -62,44 +53,9 @@ export const architecture = (): Promise<void> =>
62
53
  if (changes(".gtd/packages/**").length === 0) {
63
54
  refuse("gtd land: decompose: write at least one package under .gtd/packages/")
64
55
  }
56
+ if (changes(ARCHITECTURE).some((c) => c.status === "deleted")) {
57
+ refuse(
58
+ "gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green",
59
+ )
60
+ }
65
61
  })
66
-
67
- /** A package file name from a plan's first heading. */
68
- const slug = (title: string): string =>
69
- title
70
- .toLowerCase()
71
- .replace(/[^a-z0-9]+/g, "-")
72
- .replace(/^-+|-+$/g, "") || "package"
73
-
74
- /** Whether the settled plan needs its own architecture pass, or goes straight to one package. */
75
- export const architecturePass = async (): Promise<void> => {
76
- const { answers, truncated } = await judge("architecture-pre", {
77
- questions: [
78
- {
79
- id: "architectureWarranted",
80
- primitive: "noul",
81
- instructions:
82
- "Given the settled concerns in `.gtd/REQUIREMENTS.md` (in state), does this plan warrant a dedicated architecture pass — real structural decisions, multiple integration points, or a non-obvious tradeoff — before packages are written?",
83
- criteria:
84
- "Answer yes if uncertain; a trivial, single-concern, mechanical plan with no real design decision answers no.",
85
- },
86
- ],
87
- evidence: { requirements: read(REQUIREMENTS) ?? "" },
88
- message: t.architecturePreMessage(),
89
- label: "Judging whether this plan warrants an architecture pass",
90
- })
91
- // A plan the budget cut can hide its structural concerns from the judge:
92
- // never skip the architecture pass on it, however confident the "no".
93
- const skip =
94
- truncated.length === 0 &&
95
- answered(answers.architectureWarranted, "no", numeric(vars.architectureSkipMinP, Infinity))
96
- const plan = read(REQUIREMENTS)
97
- if (skip && plan !== undefined) {
98
- const target = `.gtd/packages/01-${slug(sections(plan)[0] ?? "")}.md`
99
- await run("architecture-promote", moveScript(REQUIREMENTS, target), {
100
- label: "Promoting the plan straight to a package",
101
- })
102
- return
103
- }
104
- await architecture()
105
- }