@pmelab/gtd 38.0.0 → 38.1.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.
@@ -18,13 +18,15 @@ import {
18
18
  run,
19
19
  scope,
20
20
  slice as sliced,
21
+ start,
21
22
  vars,
22
23
  wrote,
23
24
  type Change,
24
25
  type DiffShapeResult,
25
26
  type KeptTip,
26
27
  } from "../../flows/index.js"
27
- import { coder, FEEDBACK, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
28
+ import { continuing, phase, type Handoff, type Phase } from "../shared/continuation.js"
29
+ import { coder, FEEDBACK, HANDOFF, REFACTOR_NOTE, REQUIREMENTS, SLICES } from "../shared/steps.js"
28
30
  import * as t from "../shared/text.js"
29
31
  import { defaults } from "../shared/vars.js"
30
32
  import { green, requireCoverage } from "./coverage.js"
@@ -32,6 +34,7 @@ import {
32
34
  criterionIds,
33
35
  requireCriteria,
34
36
  requireCriteriaFrozen,
37
+ requireCriteriaIfEdited,
35
38
  sliceIds,
36
39
  type CriteriaSlice,
37
40
  } from "./criteria.js"
@@ -60,14 +63,47 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
60
63
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
61
64
  })
62
65
 
63
- export const naivePass = (retry?: t.NaiveRetry, slice?: CriteriaSlice): Promise<void> =>
64
- t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice)), {
65
- label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
66
+ export const naivePass = (
67
+ retry?: t.NaiveRetry,
68
+ slice?: CriteriaSlice,
69
+ from?: Handoff,
70
+ ): Promise<void> =>
71
+ t.agentWithSkills("naive", t.naivePassPrompt(retry, sliceIds(slice), from), {
72
+ label:
73
+ from !== undefined
74
+ ? "Continuing the naive pass"
75
+ : retry === undefined
76
+ ? "Writing the naive pass"
77
+ : "Rewriting the naive pass",
66
78
  keep: true,
67
79
  model: coder(),
68
80
  system: t.builderSystem(),
69
81
  })
70
82
 
83
+ const handoffOf = (tip: KeptTip, slice: CriteriaSlice | undefined): Handoff | undefined => {
84
+ const note = tip.changes.find((c) => c.path === HANDOFF && c.after !== undefined)
85
+ if (note === undefined) return undefined
86
+ return {
87
+ snapshot: tip.hash,
88
+ note: note.after!.trim(),
89
+ open: unnamed(idsOf(REQUIREMENTS, slice), acceptanceTests(tip.changes)),
90
+ }
91
+ }
92
+
93
+ /** A naive pass, continued until a pass ends without a handoff note: the snapshot is its kept tip. */
94
+ const naiveContinued = (
95
+ retry: t.NaiveRetry | undefined,
96
+ slice: CriteriaSlice | undefined,
97
+ naive: Phase,
98
+ ) =>
99
+ continuing(naive, async (from, fresh) => {
100
+ const tip = await fresh(async () => {
101
+ await naivePass(retry, slice, from)
102
+ return kept("naive")!
103
+ })
104
+ return { result: tip, handoff: handoffOf(tip, slice) }
105
+ })
106
+
71
107
  // Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
72
108
  const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
73
109
  const EXTENSION = /\.[\w-]+$/
@@ -258,6 +294,56 @@ const applied = async (tip: string): Promise<boolean> => {
258
294
  return !wrote(FEEDBACK) && codeChanges().length > 0
259
295
  }
260
296
 
297
+ /** `carried` limited to the criteria `.gtd/REQUIREMENTS.md` lists now; a file listing none narrows nothing. */
298
+ const settled = (
299
+ carried: readonly string[] | undefined,
300
+ slice: CriteriaSlice | undefined,
301
+ ): readonly string[] => {
302
+ const listed = idsOf(REQUIREMENTS, slice)
303
+ if (carried === undefined) return listed
304
+ return listed.length === 0 ? carried : carried.filter((id) => listed.includes(id))
305
+ }
306
+
307
+ // A snapshot lands on the branch as it is; the health loop greens the whole build after.
308
+ // Open IDs count every test line since the process start, so an amend lap's earlier tests close theirs.
309
+ const build = (options: ImplementOptions): Promise<void> =>
310
+ continuing(phase("build", requireCriteriaIfEdited), async (from, fresh) => {
311
+ await fresh(() =>
312
+ t.agentWithSkills(
313
+ "building",
314
+ t.implementBuildingPrompt(
315
+ options.reference,
316
+ options.tier,
317
+ options.amend,
318
+ options.afterRefactor,
319
+ flagModeOn(),
320
+ options.rebuild,
321
+ sliceIds(options.slice),
322
+ from,
323
+ ),
324
+ {
325
+ label: from === undefined ? "Implementing" : "Continuing the implementation",
326
+ model: coder(),
327
+ system: t.builderSystem(),
328
+ ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
329
+ },
330
+ ),
331
+ )
332
+ requireCriteriaFrozen(REQUIREMENTS)
333
+ // Committed by the snapshot, so a turn stopped again may leave it as it was: present means stopped.
334
+ const note = read(HANDOFF)
335
+ if (note === undefined) return { result: undefined }
336
+ const ids = settled(options.rebuild?.acceptance?.ids, options.slice)
337
+ return {
338
+ result: undefined,
339
+ handoff: {
340
+ snapshot: head(),
341
+ note: note.trim(),
342
+ open: unnamed(ids, acceptanceTests(changesSince(start()))),
343
+ },
344
+ }
345
+ })
346
+
261
347
  export function implement(options?: ImplementOptions): Promise<void>
262
348
  /** @deprecated Pass `implement({ reference, tier, amend })`. */
263
349
  export function implement(
@@ -276,27 +362,11 @@ export function implement(
276
362
  const since = head()
277
363
  const acceptance = options.rebuild?.acceptance
278
364
  const built = options.naive === undefined || !(await applied(options.naive))
279
- if (built)
280
- await t.agentWithSkills(
281
- "building",
282
- t.implementBuildingPrompt(
283
- options.reference,
284
- options.tier,
285
- options.amend,
286
- options.afterRefactor,
287
- flagModeOn(),
288
- options.rebuild,
289
- sliceIds(options.slice),
290
- ),
291
- {
292
- label: "Implementing",
293
- model: coder(),
294
- system: t.builderSystem(),
295
- ...(options.afterRefactor === true ? { allowEmpty: true } : {}),
296
- },
297
- )
298
- requireCriteriaFrozen(REQUIREMENTS)
299
- if (built && acceptance !== undefined) requireAcceptance(acceptance, since)
365
+ if (built) await build(options)
366
+ else requireCriteriaFrozen(REQUIREMENTS)
367
+ // Only the criteria still listed: a person may narrow them at `implement.oversized`.
368
+ if (built && acceptance !== undefined)
369
+ requireAcceptance({ ...acceptance, ids: settled(acceptance.ids, options.slice) }, since)
300
370
  // Fix turns are held to the carried tests too: deleting a red one must not buy a green suite.
301
371
  await green(
302
372
  built && acceptance !== undefined ? () => requireAcceptanceKept(acceptance) : undefined,
@@ -311,9 +381,11 @@ export const closeOut = (): Promise<void> => scope("implement", () => closing(fa
311
381
 
312
382
  /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
313
383
  const closing = (more: boolean): Promise<void> =>
314
- run("closing", removeScript(more ? [REFACTOR_NOTE] : [REQUIREMENTS, REFACTOR_NOTE, SLICES]), {
315
- label: "Closing out the implementation",
316
- })
384
+ run(
385
+ "closing",
386
+ removeScript(more ? [REFACTOR_NOTE, HANDOFF] : [REQUIREMENTS, REFACTOR_NOTE, SLICES, HANDOFF]),
387
+ { label: "Closing out the implementation" },
388
+ )
317
389
 
318
390
  interface Decision {
319
391
  readonly reference: string
@@ -392,10 +464,10 @@ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision |
392
464
  const decide = (slice: CriteriaSlice | undefined): Promise<Decision> =>
393
465
  scope("seam", async () => {
394
466
  const vetting: Vetting = { again: budget(), waived: false }
467
+ const naive = phase("naive pass", requireCriteriaIfEdited)
395
468
  let retry: t.NaiveRetry | undefined
396
469
  for (;;) {
397
- await naivePass(retry, slice)
398
- const tip = kept("naive")!
470
+ const tip = await naiveContinued(retry, slice, naive)
399
471
  const outcome =
400
472
  (await vetted(tip, vetting, slice)) ??
401
473
  (flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash, tip })
@@ -0,0 +1,92 @@
1
+ import { human, refuse, scope, vars } from "../../flows/index.js"
2
+ import * as t from "./text.js"
3
+ import type { Handoff } from "./text.js"
4
+ import { defaults } from "./vars.js"
5
+
6
+ export type { Handoff }
7
+
8
+ export interface Turned<T> {
9
+ readonly result: T
10
+ /** Set when the turn wrote a handoff note, i.e. stopped at its context limit. */
11
+ readonly handoff?: Handoff | undefined
12
+ }
13
+
14
+ /** Runs a turn's agent step in a conversation of its own, or in the caller's. */
15
+ export type Fresh = <T>(fn: () => Promise<T>) => Promise<T>
16
+
17
+ /**
18
+ * One phase's continuations, shared by every `continuing()` call in it (a naive
19
+ * retry, a refactor lap) so the caps hold per phase. `spent`: once a turn hit
20
+ * its context limit, resuming that conversation would only hit it again.
21
+ */
22
+ export interface Phase {
23
+ readonly name: string
24
+ spent: boolean
25
+ count: number
26
+ stalled: number
27
+ /** Run after a landing at `oversized`, where a person may narrow the requirements; absent, the gate does not suggest it. */
28
+ readonly steered?: (() => void) | undefined
29
+ }
30
+
31
+ export const phase = (name: string, steered?: () => void): Phase => ({
32
+ name,
33
+ spent: false,
34
+ count: 0,
35
+ stalled: 0,
36
+ steered,
37
+ })
38
+
39
+ const own: Fresh = (fn) => fn()
40
+ // Every `scope()` call is a new conversation; the names keep continuations
41
+ // apart in history from later turns of a phase that merely start afresh.
42
+ const continued: Fresh = (fn) => scope("continue", fn)
43
+ const afresh: Fresh = (fn) => scope("fresh", fn)
44
+
45
+ const setting = (name: "continuations" | "continuationStall", least: number): number => {
46
+ const raw = vars[name] ?? defaults[name]!
47
+ const n = Number(raw)
48
+ if (raw.trim() === "" || !Number.isInteger(n) || n < least)
49
+ refuse(`gtd: ${name} must be a whole number of at least ${least}, got ${JSON.stringify(raw)}`)
50
+ return n
51
+ }
52
+
53
+ /**
54
+ * Run `turn` until one ends without a handoff note: each stopped turn is picked
55
+ * up by a continuation in a fresh conversation. Past `continuations` of them in
56
+ * the phase, or after `continuationStall` in a row that close nothing still
57
+ * open, the phase rests at `oversized` for a person; landing there grants a new round.
58
+ */
59
+ export const continuing = async <T>(
60
+ phase: Phase,
61
+ turn: (from: Handoff | undefined, fresh: Fresh) => Promise<Turned<T>>,
62
+ ): Promise<T> => {
63
+ const cap = setting("continuations", 0)
64
+ const stall = setting("continuationStall", 1)
65
+ let last = await turn(undefined, phase.spent ? afresh : own)
66
+ while (last.handoff !== undefined) {
67
+ phase.spent = true
68
+ const from = last.handoff
69
+ if (phase.count >= cap || phase.stalled >= stall) {
70
+ await human("oversized", {
71
+ message: t.oversizedMessage(
72
+ phase.name,
73
+ from,
74
+ phase.count >= cap ? { cap } : { stall },
75
+ phase.steered !== undefined,
76
+ ),
77
+ label: "Too big for its context",
78
+ acceptClean: true,
79
+ })
80
+ phase.steered?.()
81
+ phase.count = 0
82
+ phase.stalled = 0
83
+ }
84
+ last = await turn(from, continued)
85
+ phase.count++
86
+ // Nothing countable left open is no stall: only the cap stops such a phase.
87
+ const closed =
88
+ from.open.length === 0 || from.open.some((item) => !(last.handoff?.open ?? []).includes(item))
89
+ phase.stalled = closed ? 0 : phase.stalled + 1
90
+ }
91
+ return last.result
92
+ }
@@ -24,6 +24,8 @@ export const ShippedOptions = Schema.Struct({
24
24
  sliceMaxCriteria: setting,
25
25
  qualityReviews: setting,
26
26
  refactorQualityReviews: setting,
27
+ continuations: setting,
28
+ continuationStall: setting,
27
29
  testCommand: setting,
28
30
  plannerModel: setting,
29
31
  coderModel: setting,
@@ -15,6 +15,7 @@ export const REFACTOR = ".gtd/REFACTOR.md"
15
15
  export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
16
16
  export const QUALITY = ".gtd/QUALITY.md"
17
17
  export const SLICES = ".gtd/SLICES.md"
18
+ export const HANDOFF = ".gtd/HANDOFF.md"
18
19
 
19
20
  export const planner = (): string => env.plannerModel ?? ""
20
21
  export const coder = (): string => env.coderModel ?? ""
@@ -976,7 +976,9 @@ describe("the seam phase's texts", () => {
976
976
  it("has a red retry pick up the kept attempt instead of starting over", () => {
977
977
  const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
978
978
  expect(prompt).toContain("left the suite red")
979
- expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
979
+ expect(prompt).toContain(
980
+ "git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
981
+ )
980
982
  expect(prompt).toContain(".gtd/FEEDBACK.md")
981
983
  expect(prompt).not.toContain("rejected")
982
984
  })
@@ -1042,7 +1044,9 @@ describe("the seam phase's texts", () => {
1042
1044
  }
1043
1045
  const prompt = renderText(() => naivePassPrompt(fault))
1044
1046
  expect(prompt).toContain("names AC-2")
1045
- expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
1047
+ expect(prompt).toContain(
1048
+ "git diff --binary --no-color --no-ext-diff --no-textconv abc123^ abc123 | git apply",
1049
+ )
1046
1050
  const message = renderText(() => seamSteerMessage(fault))
1047
1051
  expect(message).toContain("names AC-2")
1048
1052
  expect(message).toContain("`seam.check` runs its suite")
@@ -375,6 +375,7 @@ export const refactorBuildingPrompt = ({
375
375
  reference,
376
376
  feedback = false,
377
377
  note = false,
378
+ from,
378
379
  }: {
379
380
  pkg: string
380
381
  strays: readonly string[]
@@ -382,6 +383,8 @@ export const refactorBuildingPrompt = ({
382
383
  reference?: string | undefined
383
384
  feedback?: boolean | undefined
384
385
  note?: boolean | undefined
386
+ /** A continuation of a refactor turn that stopped at its context limit; `open` are its untouched paths. */
387
+ from?: Handoff | undefined
385
388
  }): string =>
386
389
  `${stateFileRules}
387
390
  - The package to implement is \`${pkg}\`; never delete it
@@ -410,8 +413,9 @@ ${
410
413
  refactor that touches none of it is wrong, however clean — never
411
414
  swap in an unrelated tidy-up
412
415
  `
413
- }- Leave everything uncommitted and finish your turn — do not commit
414
- `
416
+ }${handoffRule()}
417
+ - Leave everything uncommitted and finish your turn — do not commit
418
+ ${from === undefined ? "" : continuationLines(from)}`
415
419
 
416
420
  export const refactorFixPrompt = (strays: readonly string[], pattern: string): string =>
417
421
  `${stateFileRules}
@@ -1198,8 +1202,8 @@ export type NaiveRetry = SeamRejection | NaiveRed | NaiveFault
1198
1202
  const isRed = (retry: NaiveRetry): retry is NaiveRed => "red" in retry
1199
1203
  const isFault = (retry: NaiveRetry): retry is NaiveFault => "fault" in retry
1200
1204
 
1201
- const pickUp = (hash: string): string =>
1202
- `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``
1205
+ const pickUp = (hash: string, exclude?: string): string =>
1206
+ `Pick it up — \`git diff --binary --no-color --no-ext-diff --no-textconv ${hash}^ ${hash}${exclude === undefined ? "" : ` -- ${quote(":(top)")} ${quote(`:(top,exclude)${exclude}`)}`} | git apply\``
1203
1207
 
1204
1208
  const criteriaBinding = (refused: boolean): string =>
1205
1209
  `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
@@ -1227,7 +1231,72 @@ ${listed(retry.strays)}
1227
1231
  Start over and choose a different approach; do not rebuild the same diff.
1228
1232
  `
1229
1233
 
1230
- export const naivePassPrompt = (retry?: NaiveRetry, slice?: readonly string[]): string =>
1234
+ /** A turn that stopped at its context limit: the snapshot it left, its handoff note, what it left open. */
1235
+ export interface Handoff {
1236
+ /** The commit holding the stopped turn's work: its kept tip, or the turn's own commit. */
1237
+ readonly snapshot: string
1238
+ readonly note: string
1239
+ readonly open: readonly string[]
1240
+ }
1241
+
1242
+ const quoted = (text: string): string =>
1243
+ text
1244
+ .trimEnd()
1245
+ .split("\n")
1246
+ .map((line) => (line === "" ? "" : ` > ${line}`))
1247
+ .join("\n")
1248
+
1249
+ /** Every turn a continuation may pick up tells its agent where the note goes. */
1250
+ const handoffRule = (): string =>
1251
+ `- Should you be told to stop at a context limit, write the handoff note to
1252
+ \`.gtd/HANDOFF.md\` — what you touched, what was next — and finish your turn:
1253
+ a fresh turn picks up from what you leave, broken or not. That file is the one
1254
+ exception to any path rule here; write it at no other time`
1255
+
1256
+ /** `pickUp`: how the snapshot gets into the tree — absent when it already is. */
1257
+ const continuationLines = (from: Handoff, pickUp?: string): string =>
1258
+ `
1259
+ Your previous turn stopped at its context limit; this fresh turn picks up from
1260
+ its snapshot (\`${from.snapshot}\`). ${pickUp === undefined ? "Its work is already in the tree." : `${pickUp}.`} Its handoff note:
1261
+
1262
+ ${quoted(from.note === "" ? "(empty)" : from.note)}
1263
+
1264
+ Still open: ${from.open.length === 0 ? "nothing the workflow can count — finish what the note says is next" : from.open.join(", ")}.
1265
+ ${pickUp === undefined ? "Delete `.gtd/HANDOFF.md` once you are done; rewrite it should you be stopped again.\n" : ""}`
1266
+
1267
+ export const oversizedMessage = (
1268
+ phase: string,
1269
+ from: Handoff,
1270
+ why: { readonly cap: number } | { readonly stall: number },
1271
+ narrowable = true,
1272
+ ): string =>
1273
+ `The ${phase} keeps outgrowing its context: ${
1274
+ "cap" in why
1275
+ ? why.cap === 0
1276
+ ? "continuations are off (`continuations` is 0) and the turn stopped at its context limit"
1277
+ : `${why.cap} continuation${why.cap === 1 ? "" : "s"} ran and the last still stopped at its context limit`
1278
+ : `the last ${why.stall} continuation${why.stall === 1 ? "" : "s"} closed nothing still open`
1279
+ }. The last snapshot is \`${from.snapshot}\`; still open: ${from.open.length === 0 ? "nothing countable" : from.open.join(", ")}.
1280
+
1281
+ Its handoff note:
1282
+
1283
+ ${quoted(from.note === "" ? "(empty)" : from.note)}
1284
+
1285
+ Land to go on: a fresh round of continuations picks up from that snapshot${
1286
+ narrowable
1287
+ ? ` — narrow
1288
+ \`.gtd/REQUIREMENTS.md\` first if the change is too big, or raise the context limit`
1289
+ : ` — raise
1290
+ the context limit first if the refactor is too big`
1291
+ }. \`gtd abandon\` gives the change up.
1292
+ `
1293
+
1294
+ /** `from`: a continuation of a pass that stopped at its context limit, in place of `retry`'s pick-up. */
1295
+ export const naivePassPrompt = (
1296
+ retry?: NaiveRetry,
1297
+ slice?: readonly string[],
1298
+ from?: Handoff,
1299
+ ): string =>
1231
1300
  `${stateFileRules}
1232
1301
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1233
1302
  ${sliceLine(slice)}${criteriaBinding(false)}
@@ -1245,9 +1314,34 @@ ${sliceLine(slice)}${criteriaBinding(false)}
1245
1314
  - Run \`${env.testCommand}\` and finish only once it is green with every
1246
1315
  criterion's test passing: the next step runs it on your diff and sends a red
1247
1316
  pass back
1248
- - Leave \`.gtd/\` untouched
1317
+ - Leave \`.gtd/\` untouched, a handoff note excepted
1318
+ ${handoffRule()}
1249
1319
  - Leave everything uncommitted and finish your turn — do not commit
1250
- ${retry === undefined ? "" : naiveRetry(retry)}`
1320
+ ${
1321
+ from !== undefined
1322
+ ? `${continuationLines(from, pickUp(from.snapshot, ".gtd/HANDOFF.md"))}${
1323
+ retry === undefined
1324
+ ? ""
1325
+ : isFault(retry)
1326
+ ? `
1327
+ The pass this continues was a retry: its tests fell short, and still must not:
1328
+ ${retry.fault}
1329
+ `
1330
+ : isRed(retry)
1331
+ ? `
1332
+ The pass this continues was a retry after a red suite (\`.gtd/FEEDBACK.md\`
1333
+ holds the output): finish only once the suite is green.
1334
+ `
1335
+ : `
1336
+ The pass this continues was a retry after a \`reject\`: keep away from the
1337
+ approach that changed these files outside the expected set:
1338
+ ${listed(retry.strays)}
1339
+ `
1340
+ }`
1341
+ : retry === undefined
1342
+ ? ""
1343
+ : naiveRetry(retry)
1344
+ }`
1251
1345
 
1252
1346
  /** The naive pass's kept tip, its acceptance test files and the criterion IDs they name. */
1253
1347
  export interface Acceptance {
@@ -1340,6 +1434,7 @@ export const implementBuildingPrompt = (
1340
1434
  flagMode = true,
1341
1435
  rebuild: Rebuild = {},
1342
1436
  slice?: readonly string[],
1437
+ from?: Handoff,
1343
1438
  ): string =>
1344
1439
  `${stateFileRules}
1345
1440
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
@@ -1366,9 +1461,10 @@ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${
1366
1461
  - Write the tests first, then the code that makes them pass. Name each
1367
1462
  criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
1368
1463
  green a judge checks every ID has a passing test, and sends uncovered ones back
1464
+ ${handoffRule()}
1369
1465
  - Leave \`.gtd/REQUIREMENTS.md\` in place and everything uncommitted, then
1370
1466
  finish your turn
1371
- `
1467
+ ${from === undefined ? "" : continuationLines(from)}`
1372
1468
 
1373
1469
  export const implementFixPrompt = (): string =>
1374
1470
  `${stateFileRules}
@@ -34,6 +34,11 @@ export const defaults: Readonly<Record<string, string>> = {
34
34
  qualityReviews:
35
35
  "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge",
36
36
  refactorQualityReviews: "correctness, ponytail-review, conventions",
37
+ // Per phase (naive pass, refactor, build): continuations of a turn that
38
+ // stopped at its context limit before a person is asked, and how many in a
39
+ // row may close nothing still open.
40
+ continuations: "3",
41
+ continuationStall: "2",
37
42
  }
38
43
 
39
44
  /** Environment settings. */