@pmelab/gtd 29.0.0 → 29.1.1

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.
@@ -15,46 +15,129 @@ import { build, fixSuite } from "./steps.js"
15
15
  export interface DeclaredTest {
16
16
  readonly level: "unit" | "e2e"
17
17
  readonly path: string
18
+ readonly unchanged: boolean
18
19
  }
19
20
 
20
- const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/
21
+ const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/
22
+ const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i
23
+ const ITEM = /^\s*-\s/
21
24
 
22
- /** The `## Tests` entries of a package file; malformed lines are ignored. */
25
+ /** The `## Tests` entries of a package file; malformed entries are ignored. */
23
26
  export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
24
27
  const found: DeclaredTest[] = []
25
28
  let inTests = false
26
- for (const line of packageText.split("\n")) {
27
- if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line)
28
- else if (inTests) {
29
- const match = TEST_LINE.exec(line)
30
- if (match) found.push({ level: match[1] as DeclaredTest["level"], path: match[2]! })
31
- }
29
+ let entry: string | undefined
30
+ const flush = () => {
31
+ const match = entry === undefined ? null : TEST_LINE.exec(entry)
32
+ if (match)
33
+ found.push({
34
+ level: match[1] as DeclaredTest["level"],
35
+ path: match[2]!,
36
+ unchanged: MARKER.test(match[3]!),
37
+ })
38
+ entry = undefined
39
+ }
40
+ // An entry is a list line plus its indented continuation lines (oxfmt wraps
41
+ // long ones); a blank line, a new item or a heading ends it.
42
+ for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) {
43
+ if (/^##\s/.test(line)) {
44
+ flush()
45
+ inTests = /^##\s+Tests\s*$/.test(line)
46
+ } else if (!inTests) continue
47
+ else if (ITEM.test(line)) {
48
+ flush()
49
+ entry = line.trim()
50
+ } else if (entry !== undefined && /^\s/.test(line) && line.trim() !== "")
51
+ entry = `${entry} ${line.trim()}`
52
+ else flush()
32
53
  }
54
+ flush()
33
55
  return found
34
56
  }
35
57
 
58
+ export type DeclaredTestsTurn = "build" | "fix"
59
+
60
+ type Unmet = "notInDiff" | "modifiedMarked" | "addedMarked" | "deletedMarked" | "notInTree"
61
+
62
+ type Declared = { path: string; unchanged: boolean }
63
+
64
+ const diffBucket = (
65
+ test: Declared,
66
+ status: string | undefined,
67
+ turn: DeclaredTestsTurn,
68
+ satisfied: boolean,
69
+ ): Unmet | undefined => {
70
+ if (test.unchanged) {
71
+ if (turn !== "build") return undefined
72
+ if (status === "modified") return "modifiedMarked"
73
+ return status === "added" ? "addedMarked" : undefined
74
+ }
75
+ return !satisfied && (status === undefined || status === "deleted") ? "notInDiff" : undefined
76
+ }
77
+
78
+ const treeBucket = (test: Declared, status: string | undefined): Unmet | undefined => {
79
+ if (read(test.path) !== undefined) return undefined
80
+ return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree"
81
+ }
82
+
83
+ const unmetBucket = (
84
+ test: Declared,
85
+ change: { status: string } | undefined,
86
+ turn: DeclaredTestsTurn,
87
+ satisfied: boolean,
88
+ ): Unmet | undefined =>
89
+ diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status)
90
+
36
91
  /**
37
92
  * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
38
93
  * diff since `since`. The package text is read as of `since`, so a builder
39
- * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
40
- * written, existing in the tree suffices.
94
+ * editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
95
+ * with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
96
+ * build turn must neither modify nor add a marked test; a fix turn may edit it.
41
97
  */
42
- export const requireDeclaredTests = (pkg: string, since: string): void => {
98
+ export const requireDeclaredTests = (
99
+ pkg: string,
100
+ since: string,
101
+ turn: DeclaredTestsTurn = "fix",
102
+ ): void => {
43
103
  const range = changesSince(since)
44
104
  const text = range.get(pkg)?.before ?? read(pkg) ?? ""
45
105
  const satisfied = read(".gtd/SATISFIED.md") !== undefined
46
- const missing = declaredTests(text)
47
- .map((test) => test.path)
48
- .filter((path) =>
49
- satisfied
50
- ? read(path) === undefined
51
- : range.get(path) === undefined ||
52
- range.get(path)?.status === "deleted" ||
53
- read(path) === undefined,
54
- )
55
- if (missing.length === 0) return
106
+ const buckets = {
107
+ notInDiff: [] as string[],
108
+ modifiedMarked: [] as string[],
109
+ addedMarked: [] as string[],
110
+ deletedMarked: [] as string[],
111
+ notInTree: [] as string[],
112
+ }
113
+ const entries = declaredTests(text)
114
+ const seen = new Set<string>()
115
+ for (const test of entries) {
116
+ // A file listed both ways is written by the package: the unmarked entry wins.
117
+ if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue
118
+ const bucket = unmetBucket(test, range.get(test.path), turn, satisfied)
119
+ if (bucket && !seen.has(test.path)) {
120
+ seen.add(test.path)
121
+ buckets[bucket].push(test.path)
122
+ }
123
+ }
124
+ const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets
125
+ const groups = [
126
+ ["not in the package's diff — write them", notInDiff],
127
+ ["marked (unchanged) but modified — revert them", modifiedMarked],
128
+ [
129
+ "marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person",
130
+ addedMarked,
131
+ ],
132
+ ["marked (unchanged) but deleted — restore them", deletedMarked],
133
+ ["missing from the tree", notInTree],
134
+ ] as const
135
+ const reasons = groups.filter(([, paths]) => paths.length > 0)
136
+ if (reasons.length === 0) return
56
137
  refuse(
57
- `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")}`,
138
+ `gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons
139
+ .map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`)
140
+ .join("\n")}`,
58
141
  )
59
142
  }
60
143
 
@@ -66,12 +149,12 @@ export const packageItem = async (pkg: string): Promise<void> => {
66
149
  const from = head()
67
150
  // Rechecked after every fix too: a fix deleting a red declared test would
68
151
  // otherwise green the suite.
69
- const declared = (turn: () => Promise<void>) => async () => {
152
+ const declared = (turn: () => Promise<void>, kind: DeclaredTestsTurn) => async () => {
70
153
  await turn()
71
- requireDeclaredTests(pkg, from)
154
+ requireDeclaredTests(pkg, from, kind)
72
155
  }
73
- await declared(() => build(pkg))()
74
- await healthy(declared(fixSuite))
156
+ await declared(() => build(pkg), "build")()
157
+ await healthy(declared(fixSuite, "fix"))
75
158
  const last = glob(".gtd/packages/*.md").length === 1
76
159
  await run("closing", removeScript([pkg, ".gtd/SATISFIED.md", ...(last ? [ARCHITECTURE] : [])]), {
77
160
  label: "Closing out the package",
@@ -216,7 +216,13 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
216
216
  )
217
217
  await t.agentWithSkills(
218
218
  "building",
219
- t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== undefined),
219
+ t.refactorBuildingPrompt({
220
+ pkg: REFACTOR_PACKAGE,
221
+ strays,
222
+ pattern,
223
+ reference,
224
+ feedback: feedback !== undefined,
225
+ }),
220
226
  { label: "Refactoring", model: coder(), system: t.builderSystem() },
221
227
  )
222
228
  guard()
@@ -275,7 +281,8 @@ export const reviewedRefactor = async (
275
281
  for (;;) {
276
282
  await refactorLap(p, guard, collected)
277
283
  // A person's edit at an escalation rest is never refused, as in the lap.
278
- const outcome = await buildTail(false, entry, true, {
284
+ const outcome = await buildTail(entry, {
285
+ green: true,
279
286
  guard,
280
287
  guardRests: false,
281
288
  lenses: lensesOf(vars.refactorQualityReviews ?? ""),
@@ -256,6 +256,10 @@ export interface FeedbackTarget {
256
256
  }
257
257
 
258
258
  export interface BuildTailOptions {
259
+ /** Fix first when entered red; default `false`. */
260
+ readonly fixFirst?: boolean
261
+ /** Skip keeping green; default `false`. */
262
+ readonly green?: boolean
259
263
  /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
260
264
  readonly guard?: Guard
261
265
  /** `false`: `guard` skips the escalation's human rests. */
@@ -274,22 +278,36 @@ export interface BuildTailOptions {
274
278
  }
275
279
 
276
280
  /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
277
- export const buildTail = (
281
+ export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
282
+ /** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
283
+ export function buildTail(
278
284
  fixFirst: boolean,
279
285
  base: string,
280
- green = false,
281
- options: BuildTailOptions = {},
282
- ): Promise<kit.ReviewOutcome> =>
283
- tail(
284
- fixFirst,
286
+ green?: boolean,
287
+ options?: Omit<BuildTailOptions, "fixFirst" | "green">,
288
+ ): Promise<kit.ReviewOutcome>
289
+ export function buildTail(
290
+ first: boolean | string,
291
+ second?: string | BuildTailOptions,
292
+ third = false,
293
+ fourth: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
294
+ ): Promise<kit.ReviewOutcome> {
295
+ const base = typeof first === "string" ? first : (second as string)
296
+ const options: BuildTailOptions =
297
+ typeof first === "string"
298
+ ? ((second as BuildTailOptions | undefined) ?? {})
299
+ : { ...fourth, fixFirst: first, green: third }
300
+ return tail(
301
+ options.fixFirst ?? false,
285
302
  (escalations) => review(base, escalations, options),
286
- green,
303
+ options.green ?? false,
287
304
  options.guard,
288
305
  options.lenses,
289
306
  options.guardRests,
290
307
  options.lensBase,
291
308
  options.rules,
292
309
  )
310
+ }
293
311
 
294
312
  /**
295
313
  * Undo the human's review-round code edits. A path changed since the human
@@ -41,17 +41,36 @@ export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
41
41
  system: t.builderSystem(),
42
42
  })
43
43
 
44
- export const implement = (
44
+ export interface ImplementOptions {
45
+ readonly reference?: string | undefined
46
+ readonly tier?: RiskTier | undefined
47
+ readonly amend?: boolean | undefined
48
+ }
49
+
50
+ export function implement(options?: ImplementOptions): Promise<void>
51
+ /** @deprecated Pass `implement({ reference, tier, amend })`. */
52
+ export function implement(
45
53
  reference: string | undefined,
46
54
  tier?: RiskTier,
47
55
  amend?: boolean,
48
- ): Promise<void> =>
49
- scope("implement", async () => {
50
- await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier, amend), {
51
- label: "Implementing",
52
- model: coder(),
53
- system: t.builderSystem(),
54
- })
56
+ ): Promise<void>
57
+ export function implement(
58
+ first?: string | ImplementOptions,
59
+ tier?: RiskTier,
60
+ amend?: boolean,
61
+ ): Promise<void> {
62
+ const options: ImplementOptions =
63
+ typeof first === "object" && first !== null ? first : { reference: first, tier, amend }
64
+ return scope("implement", async () => {
65
+ await t.agentWithSkills(
66
+ "building",
67
+ t.implementBuildingPrompt(options.reference, options.tier, options.amend),
68
+ {
69
+ label: "Implementing",
70
+ model: coder(),
71
+ system: t.builderSystem(),
72
+ },
73
+ )
55
74
  await healthy(() =>
56
75
  t.agentWithSkills("fix.suite.fixing", t.implementFixPrompt(), {
57
76
  label: "Fixing the check",
@@ -62,6 +81,7 @@ export const implement = (
62
81
  )
63
82
  await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" })
64
83
  })
84
+ }
65
85
 
66
86
  interface Decision {
67
87
  readonly reference: string
@@ -9,6 +9,8 @@ import {
9
9
  buildReviewReviewingPrompt,
10
10
  agentWithSkills,
11
11
  architectureAuthorPrompt,
12
+ architectureDecomposePrompt,
13
+ packagesItemBuildingPrompt,
12
14
  architectureGateAnswerMessage,
13
15
  buildFixPrompt,
14
16
  buildFixQualityPrompt,
@@ -1266,3 +1268,45 @@ describe("buildReviewReviewingPrompt lap wording", () => {
1266
1268
  },
1267
1269
  )
1268
1270
  })
1271
+
1272
+ describe("architectureDecomposePrompt tests rule", () => {
1273
+ it("states one rule: write-or-change, relied-on marked (unchanged), no other untouched", () => {
1274
+ const prompt = renderText(() => architectureDecomposePrompt()).replace(/\s+/g, " ")
1275
+ expect(prompt).toContain("List every test the package writes or changes")
1276
+ expect(prompt).toContain("also list an existing test a criterion relies on")
1277
+ expect(prompt).toContain("list no other untouched test")
1278
+ expect(prompt).toContain("- unit: \\`<path>\\` (unchanged)")
1279
+ expect(prompt).toContain("- e2e: \\`<path>\\` (unchanged)")
1280
+ expect(prompt).toContain("only decompose places the marker")
1281
+ })
1282
+ })
1283
+
1284
+ describe("packagesItemBuildingPrompt", () => {
1285
+ const prompt = renderText(() => packagesItemBuildingPrompt(".gtd/packages/01-a.md")).replace(
1286
+ /\s+/g,
1287
+ " ",
1288
+ )
1289
+ it("carries the evidence kinds and the already-done check", () => {
1290
+ for (const w of [
1291
+ "evidence per criterion",
1292
+ "commit, file, symbol, issue URL, or command output — and change nothing else",
1293
+ "it was not already done",
1294
+ "via `gh`",
1295
+ ])
1296
+ expect(prompt).toContain(w)
1297
+ })
1298
+ it("states the already-met, no-tests out-of-tree and implement-normally rules flat", () => {
1299
+ expect(prompt).toContain("Every criterion already met in the tree")
1300
+ expect(prompt).toContain("No `## Tests` and work outside the tree")
1301
+ expect(prompt).toContain(
1302
+ "Any other package**: implement normally, write no `.gtd/SATISFIED.md`",
1303
+ )
1304
+ })
1305
+ it("says an (unchanged) line must exist and stay untouched", () => {
1306
+ expect(prompt).toContain("(unchanged)")
1307
+ expect(prompt).toContain("stay untouched")
1308
+ expect(prompt).toContain("creating, editing or deleting it refuses the turn")
1309
+ expect(prompt).toContain("must exist before the turn")
1310
+ expect(prompt).toContain("stop without creating it")
1311
+ })
1312
+ })
@@ -376,7 +376,12 @@ ${stateFileRules}
376
376
  - Every package with tests has a \`## Tests\` section, one line per
377
377
  declared test, copied from the architecture entries:
378
378
  \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
379
- none
379
+ none. List every test the package writes or changes; also list an
380
+ existing test a criterion relies on, marked \`(unchanged)\` —
381
+ \`- unit: \\\`<path>\\\` (unchanged)\` or
382
+ \`- e2e: \\\`<path>\\\` (unchanged)\`; list no other untouched test;
383
+ never mark a file the package also writes or changes — list it once,
384
+ unmarked; only decompose places the marker
380
385
  - Leave everything uncommitted and finish
381
386
  `
382
387
 
@@ -413,15 +418,26 @@ export const packagesItemBuildingPrompt = (pkg: string): string =>
413
418
  never delete the package file (the declared-tests guard reads it
414
419
  after you)
415
420
  - The package to implement is \`${pkg}\`
416
- - First check its acceptance criteria against the current tree —
417
- an earlier fix turn may already satisfy them. If **every**
418
- criterion is met, implement nothing: write \`.gtd/SATISFIED.md\`
419
- with each criterion's concrete evidence (commit, file, or
420
- symbol), change nothing else, and finish. Otherwise implement
421
- normally and skip that file
421
+ - First check the acceptance criteria against the current tree —
422
+ an earlier fix turn may already meet them
423
+ - **Every criterion already met in the tree**: write
424
+ \`.gtd/SATISFIED.md\` with evidence per criterion — commit, file,
425
+ symbol, issue URL, or command output — and change nothing else
426
+ - **No \`## Tests\` and work outside the tree**: first check it was
427
+ not already done by an earlier attempt (e.g. an existing issue
428
+ with the same title via \`gh\`) and reuse that as evidence, or do
429
+ the work; then write \`.gtd/SATISFIED.md\` with that evidence and
430
+ change nothing else in the tree
431
+ - **Any other package**: implement normally, write no
432
+ \`.gtd/SATISFIED.md\`
422
433
  - Write the tests the package declares under \`## Tests\` first,
423
434
  then the code that makes them pass; the turn is refused unless
424
- every declared test is in your diff
435
+ every declared test is in your diff — except a line marked
436
+ \`(unchanged)\`, which must exist before the turn and stay
437
+ untouched — creating, editing or deleting it refuses the turn. If
438
+ it does not exist yet, or the marker is wrong because the package
439
+ must change that test, stop without creating it and leave it to
440
+ a person
425
441
  - A declared e2e test is a scenario in the repository's own e2e
426
442
  framework for the behaviour its \`## E2E Scenarios\` entry names.
427
443
  Word it to fit the existing steps — reuse and generalise them
@@ -457,13 +473,44 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
457
473
 
458
474
  const withRules = (rules?: string): string => (rules === undefined ? "" : `${rules}\n`)
459
475
 
460
- export const refactorBuildingPrompt = (
476
+ interface RefactorBuildingArgs {
477
+ pkg: string
478
+ strays: readonly string[]
479
+ pattern: string
480
+ reference?: string | undefined
481
+ feedback?: boolean | undefined
482
+ }
483
+
484
+ export function refactorBuildingPrompt(args: RefactorBuildingArgs): string
485
+ /** @deprecated Pass `refactorBuildingPrompt({ pkg, strays, pattern, reference, feedback })`. */
486
+ export function refactorBuildingPrompt(
461
487
  pkg: string,
462
488
  strays: readonly string[],
463
489
  pattern: string,
464
490
  reference?: string,
491
+ feedback?: boolean,
492
+ ): string
493
+ export function refactorBuildingPrompt(
494
+ first: string | RefactorBuildingArgs,
495
+ strays?: readonly string[],
496
+ pattern?: string,
497
+ reference?: string,
498
+ feedback?: boolean,
499
+ ): string {
500
+ return refactorBuildingPromptOf(
501
+ typeof first === "string"
502
+ ? { pkg: first, strays: strays ?? [], pattern: pattern ?? "", reference, feedback }
503
+ : first,
504
+ )
505
+ }
506
+
507
+ const refactorBuildingPromptOf = ({
508
+ pkg,
509
+ strays,
510
+ pattern,
511
+ reference,
465
512
  feedback = false,
466
- ): string =>
513
+ }: RefactorBuildingArgs): string =>
467
514
  `${stateFileRules}
468
515
  - The package to implement is \`${pkg}\`; never delete it
469
516
  ${