@try-works/dsh-recursive-mode 0.3.1 → 0.4.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 (117) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
package/src/status.ts CHANGED
@@ -1,7 +1,32 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { readFileSync, statSync } from 'node:fs'
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import type { ArtifactState, PhaseDef, PhaseState, RecursiveStatusResult } from './types.ts'
4
+ import type { ArtifactState, PendingWorkItem, PhaseDef, PhasePosition, PhaseState, RecursiveStatusResult } from './types.ts'
5
+ import { PHASE_POSITIONS } from './types.ts'
6
+
7
+ export { PHASE_POSITIONS }
8
+ export type { ArtifactState, PhasePosition }
9
+
10
+ /**
11
+ * T21 — derive the ONE named position of a phase from the fields consumers
12
+ * currently recombine for themselves. Total and disjoint: every reachable shape
13
+ * returns exactly one member of {@link PHASE_POSITIONS}, so two consumers cannot
14
+ * read the same phase and disagree.
15
+ *
16
+ * Order is the whole design. `tampered` outranks every other lock problem
17
+ * because a content/hash divergence is the more serious fact — a locked artifact
18
+ * whose bytes changed invalidates everything that cited it, whereas a failed gate
19
+ * on an otherwise-intact artifact is a fixable omission.
20
+ */
21
+ export function phasePosition(state: ArtifactState): PhasePosition {
22
+ if (state.status === 'SKIPPED') return 'skipped'
23
+ if (!state.exists) return 'absent'
24
+ if (state.status === 'LOCKED') {
25
+ if (state.lockValid) return 'locked'
26
+ return state.lockProblems.includes('LockHash mismatch') ? 'tampered' : 'invalid-lock'
27
+ }
28
+ return state.blockers.length > 0 ? 'blocked' : 'draft'
29
+ }
5
30
 
6
31
  export const RUN_ARTIFACT_SEQUENCE = [
7
32
  '00-requirements.md',
@@ -118,6 +143,87 @@ export function getWorkflowProfile(runDir: string): string {
118
143
  export { getLatestRunDirectory, discoverRuns, resolveRunDir } from './run.ts'
119
144
  export type { RunDiscoveryResult } from './run.ts'
120
145
 
146
+ /**
147
+ * T18 — unresolved in-flight work, DERIVED from the run directory.
148
+ *
149
+ * There is no ledger, no queue and no stored flag: this reads what is already on
150
+ * disk, which is what makes the quiescence rule in `lockArtifact` cheap enough to
151
+ * run on every lock. Plan §4.0: derived beats stored wherever derivation is
152
+ * cheap, and losing a derived fact costs nothing because it cannot be lost.
153
+ *
154
+ * WHY THIS IS THE ONLY CASE IMPLEMENTED. The pairing is created early in the
155
+ * happy path — `runtime.ts` writes `subagents/<id>/handoff.md` BEFORE the bundle
156
+ * and before the child starts — so the exposure is exactly the window between
157
+ * the handoff and the reply. The plan also names "a reopen plan without a
158
+ * completion marker" and "a closeout phase scaffolded without a receipt"; neither
159
+ * exists on disk (`reopenArtifact` reverts the artifact in place and invalidates
160
+ * receipts; a scaffolded-but-unlocked closeout artifact is the normal pre-lock
161
+ * state of every phase), and enforcing either would make locking impossible.
162
+ * That omission is deliberate and recorded on the item.
163
+ *
164
+ * A reply that exists but is empty is NOT a submission, so it stays pending —
165
+ * a child that created the file and wrote nothing has not answered.
166
+ *
167
+ * Total: a missing or unreadable run directory yields an empty set, never a throw.
168
+ */
169
+ export function pendingWork(runDir: string): PendingWorkItem[] {
170
+ const subagents = join(runDir, 'subagents')
171
+ let delegations: string[] = []
172
+ try {
173
+ delegations = readdirSync(subagents, { withFileTypes: true })
174
+ .filter((d) => {
175
+ if (d.isDirectory()) return true
176
+ if (!d.isSymbolicLink()) return false
177
+ try { return statSync(join(subagents, d.name)).isDirectory() } catch { return false }
178
+ })
179
+ .map((d) => d.name)
180
+ .sort()
181
+ } catch {
182
+ return []
183
+ }
184
+
185
+ const out: PendingWorkItem[] = []
186
+ for (const delegationId of delegations) {
187
+ const dir = join(subagents, delegationId)
188
+ // Only a delegation directory counts: a stray directory with no handoff is
189
+ // not evidence that work was started.
190
+ if (!existsSync(join(dir, 'handoff.md'))) continue
191
+ const handoffRel = 'subagents/' + delegationId + '/handoff.md'
192
+
193
+ let children: string[] = []
194
+ try {
195
+ children = readdirSync(dir, { withFileTypes: true })
196
+ .filter((d) => d.isDirectory() && d.name.startsWith('child-'))
197
+ .map((d) => d.name)
198
+ .sort()
199
+ } catch { /* an unreadable delegation dir is treated as unanswered below */ }
200
+
201
+ const replies = children.map((child) => 'subagents/' + delegationId + '/' + child + '/reply.md')
202
+ const present = replies.filter((rel) => existsSync(join(runDir, rel)))
203
+ if (present.length === 0) {
204
+ out.push({
205
+ kind: 'unanswered-delegation',
206
+ delegationId,
207
+ path: handoffRel,
208
+ detail: 'delegation ' + delegationId + ' wrote ' + handoffRel + ' but no child has written a reply.md yet',
209
+ })
210
+ continue
211
+ }
212
+ const nonEmpty = present.filter((rel) => {
213
+ try { return readFileSync(join(runDir, rel), 'utf8').trim() !== '' } catch { return false }
214
+ })
215
+ if (nonEmpty.length === 0) {
216
+ out.push({
217
+ kind: 'empty-reply',
218
+ delegationId,
219
+ path: present[0]!,
220
+ detail: 'delegation ' + delegationId + ' has only empty reply file(s) - an empty file is not a submission',
221
+ })
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
121
227
  export function getArtifactState(artifactPath: string, workflowProfile: string): ArtifactState {
122
228
  let content: string | null = null
123
229
  try { content = readFileSync(artifactPath, 'utf8') } catch { /* missing */ }
@@ -168,27 +274,126 @@ export function getArtifactState(artifactPath: string, workflowProfile: string):
168
274
  return { exists: true, status, lockValid, lockProblems, blockers: dedupedBlockers, lockedAt, storedHash, actualHash, coverage, approval, audit, todoHasSection: todo.hasTodo, todoUnchecked: todo.unchecked }
169
275
  }
170
276
 
277
+ /**
278
+ * T21 — the fold frame: a PURE CACHE of one run's folded state, always
279
+ * rebuildable from the artifacts, so losing it costs time and never correctness
280
+ * (plan §4.0: a cache, not a store).
281
+ *
282
+ * `stamps` records a cheap per-artifact fingerprint (`size:mtimeMs`, or `null`
283
+ * for absent) at the moment that artifact was folded. Opening a `size:mtimeMs`
284
+ * file is orders of magnitude cheaper than reading and parsing 126 KB of lint
285
+ * input, which is what `snapshotWorkspace` currently does per run, per request.
286
+ */
287
+ interface FoldFrame {
288
+ profile: string
289
+ stamps: Record<string, string | null>
290
+ states: Map<string, ArtifactState>
291
+ result: RecursiveStatusResult
292
+ }
293
+
294
+ const foldFrames = new Map<string, FoldFrame>()
295
+
296
+ /** Counters so a caller can see whether the cache is doing anything. */
297
+ const foldStats = { folds: 0, reuses: 0, reparsed: 0 }
298
+
299
+ /**
300
+ * Fold-cache diagnostics. Read-only observation, kept in the shipped API because
301
+ * "is the frame hitting?" is the first question when the board feels slow, and a
302
+ * counter is cheaper than guessing. `reparsed` counts EXISTING artifacts re-read
303
+ * (an absent one costs only an existsSync).
304
+ */
305
+ export function foldDiagnostics(): { folds: number; reuses: number; reparsed: number } {
306
+ return { ...foldStats }
307
+ }
308
+
309
+ /** Drop every frame and counter. For tests, and for a caller that knows the tree changed underneath. */
310
+ export function resetFoldCache(): void {
311
+ foldFrames.clear()
312
+ foldStats.folds = 0
313
+ foldStats.reuses = 0
314
+ foldStats.reparsed = 0
315
+ }
316
+
317
+ /** `size:mtimeMs`, or null when the artifact does not exist. Never throws. */
318
+ function stampOf(path: string): string | null {
319
+ try {
320
+ const s = statSync(path)
321
+ return s.size + ':' + s.mtimeMs
322
+ } catch {
323
+ return null
324
+ }
325
+ }
326
+
171
327
  export function foldRun(runDir: string, runId: string): RecursiveStatusResult {
328
+ foldStats.folds += 1
172
329
  const workflowProfile = getWorkflowProfile(runDir)
330
+
331
+ const stamps: Record<string, string | null> = {}
332
+ for (const phase of PHASES) stamps[phase.key] = stampOf(join(runDir, phase.file))
333
+
334
+ const frame = foldFrames.get(runDir)
335
+ if (frame) {
336
+ // APPEND-ONLY ASSERTION. Tardigrade's atom throws when its source shrinks;
337
+ // here the artifacts are the run's evidence — a hash chain and receipts cite
338
+ // them — so an artifact that a previous fold saw and the tree no longer has
339
+ // is an anomaly, not an edit. Reporting it beats quietly returning a shorter
340
+ // state. Scoped to the CACHE on purpose: a cold fold (below) still reports
341
+ // the honest current tree, so recovery is not blocked by this check.
342
+ for (const key of Object.keys(frame.stamps)) {
343
+ if (frame.stamps[key] !== null && stamps[key] === null) {
344
+ const file = PHASES.find((p) => p.key === key)?.file ?? key
345
+ throw new Error(
346
+ 'fold frame is append-only: ' + file + ' was present at the last fold and is now missing under ' + runDir +
347
+ ' - a run artifact is evidence and the chain should only grow',
348
+ )
349
+ }
350
+ }
351
+ const sameProfile = frame.profile === workflowProfile
352
+ const unchanged = sameProfile && PHASES.every((p) => frame.stamps[p.key] === stamps[p.key])
353
+ if (unchanged) {
354
+ foldStats.reuses += 1
355
+ return frame.result
356
+ }
357
+ }
358
+
173
359
  const states = new Map<string, ArtifactState>()
174
360
  for (const phase of PHASES) {
361
+ const stamp = stamps[phase.key] ?? null
362
+ const reusable = frame && frame.profile === workflowProfile && frame.stamps[phase.key] === stamp
363
+ ? frame.states.get(phase.key)
364
+ : undefined
365
+ if (reusable) {
366
+ states.set(phase.key, reusable)
367
+ continue
368
+ }
175
369
  const state = getArtifactState(join(runDir, phase.file), workflowProfile)
370
+ if (stamp !== null) foldStats.reparsed += 1
176
371
  if (phase.optional && !state.exists) state.status = 'SKIPPED'
177
372
  else if (workflowProfile === 'legacy' && LATE_PHASE_KEYS.has(phase.key) && !state.exists) state.status = 'SKIPPED'
178
373
  states.set(phase.key, state)
179
374
  }
375
+
180
376
  let currentPhase: RecursiveStatusResult['currentPhase'] = null
181
377
  for (const phase of PHASES) {
182
378
  const state = states.get(phase.key)!
183
379
  if (state.status === 'SKIPPED') continue
184
380
  if (!state.exists || !state.lockValid) {
381
+ // `currentPhase` keeps its pinned four-field shape: status.parity deep-equals
382
+ // it, and T21 must not move a parity-asserted contract.
185
383
  currentPhase = { key: phase.key, label: phase.label, phaseName: phase.phaseName, status: state.status }
186
384
  break
187
385
  }
188
386
  }
189
387
  const phases: PhaseState[] = PHASES.map(phase => {
190
388
  const state = states.get(phase.key)!
191
- return { key: phase.key, label: phase.label, file: phase.file, optional: phase.optional, exists: state.exists, status: state.status, lockValid: state.lockValid, lockProblems: state.lockProblems, blockers: state.blockers }
389
+ return {
390
+ key: phase.key, label: phase.label, file: phase.file, optional: phase.optional,
391
+ exists: state.exists, status: state.status, lockValid: state.lockValid,
392
+ lockProblems: state.lockProblems, blockers: state.blockers,
393
+ position: phasePosition(state),
394
+ }
192
395
  })
193
- return { runId, currentPhase, phases, workflowProfile }
396
+ const result: RecursiveStatusResult = { runId, currentPhase, phases, workflowProfile }
397
+ foldFrames.set(runDir, { profile: workflowProfile, stamps, states, result })
398
+ return result
194
399
  }
package/src/teams-loop.ts CHANGED
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * The recursion concept's core state machine, expressed over the native
5
5
  * `agentTeams` service: ONE durable task per phase carries the loop —
6
- * createTask (pending) → claim (in_progress) → audit round → on REVISE
7
- * updateTask(edit, repair instruction) → re-audit the SAME task → on APPROVE
6
+ * createTask (pending) → claim (in_progress) → audit round → on REVISE* updateTask(edit, repair instruction) → re-audit the SAME task → on APPROVE
8
7
  * updateTask(complete) → lock. A stuck reviewer is interrupted through the
9
8
  * team's own kill switch; a REJECT or round-cap releases the task and fails
10
9
  * loud — a lock NEVER happens before an APPROVE verdict.
@@ -28,6 +27,15 @@ export interface TeamCallerHandle {
28
27
  readonly session?: { readonly header?: { readonly cwd?: string } }
29
28
  }
30
29
 
30
+ /**
31
+ * T19: the ONLY import this module takes. Everything else it needs arrives through
32
+ * its injected seams, which is what keeps the loop drivable by fakes — but operation
33
+ * identity must be computed with the SAME canonical rule as every other operation in
34
+ * the plugin, so it comes from the shared module rather than a local copy. That would
35
+ * be the one place two implementations could silently disagree.
36
+ */
37
+ import { canonicalInput, operationId } from './identity.ts'
38
+
31
39
  /** Minimal cancellation shape (a live AbortSignal satisfies it). */
32
40
  export interface TeamAbortSignalLike {
33
41
  readonly throwIfAborted: () => void
@@ -111,6 +119,11 @@ export interface AuditLoopRound {
111
119
  readonly verdict: AuditVerdict
112
120
  readonly repair?: string
113
121
  readonly taskRevision: number
122
+ /**
123
+ * T20: true when this round produced the SAME progress cursor as its predecessor —
124
+ * the same finding, restated. Rendered so the board shows a loop as a loop.
125
+ */
126
+ readonly noProgress?: boolean
114
127
  }
115
128
 
116
129
  /** The auditToPass result. */
@@ -124,6 +137,45 @@ export interface AuditToPassResult {
124
137
  readonly locked: boolean
125
138
  /** Latest task view (board-facing per-phase history). */
126
139
  readonly taskView?: TeamTaskViewLike
140
+ /**
141
+ * T19: true when this call was recognised as a repeat of an audit-to-pass that had
142
+ * already completed for this run and phase, so no second task was created. The
143
+ * caller must read the PHASE's lock state itself — this result cannot claim the
144
+ * artifact is locked, because a recognised repeat performs no locking.
145
+ */
146
+ readonly recognisedRepeat?: boolean
147
+ /**
148
+ * T20: how many rounds ran. The absolute backstop, reported so a caller can see how
149
+ * close the loop came to its cap.
150
+ */
151
+ readonly attempts?: number
152
+ /** T20: consecutive rounds that produced the SAME progress cursor. */
153
+ readonly consecutiveNoProgress?: number
154
+ /** T20: the last round's progress cursor — what the round said was wrong. */
155
+ readonly progressCursor?: string
156
+ /** T28: how many repairs were requested before the loop stopped. */
157
+ readonly repairAttempts?: number
158
+ /**
159
+ * T20: true when the loop stopped in a state that must NOT be retried
160
+ * automatically — no progress, or the attempt cap. The caller has to decide to
161
+ * resume, deliberately, rather than a loop quietly grinding on or quietly giving up.
162
+ */
163
+ readonly resumeRequired?: boolean
164
+ }
165
+
166
+ /**
167
+ * T19: the operation-index seam.
168
+ *
169
+ * INJECTED, not imported. `auditToPass` is pure over its seams — it has no `runDir`
170
+ * and no filesystem access, which is exactly what lets the whole loop be driven by
171
+ * fakes in tests. Reaching for `recordOperation` directly would have traded that away
172
+ * for one line of convenience, so the caller supplies persistence instead.
173
+ */
174
+ export interface AuditOperationsSeamLike {
175
+ /** The previously recorded attempt for an id, or null when it is new. */
176
+ find?: (id: string) => { id: string; outcome?: string } | null
177
+ /** Record one attempt. Best-effort: a failed write must not change the loop's outcome. */
178
+ record?: (record: { id: string; act: string; at: string; outcome?: string }) => void
127
179
  }
128
180
 
129
181
  /** Inputs for one audit-to-pass loop. */
@@ -139,6 +191,11 @@ export interface AuditToPassInput {
139
191
  readonly blockedBy?: readonly TeamTaskIdLike[]
140
192
  /** Write scopes for the phase artifact (advisory, overlap-warned). */
141
193
  readonly writeScopes?: readonly string[]
194
+ /**
195
+ * T19: the operation index, injected. Absent it, the loop runs exactly as before —
196
+ * no pre-check, no records, no behaviour change.
197
+ */
198
+ readonly operations?: AuditOperationsSeamLike
142
199
  /** Run ONE audit round for the current task; live usage delegates (T4). */
143
200
  readonly runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>
144
201
  /** Lock the phase artifact — called ONLY after an APPROVE verdict. */
@@ -147,6 +204,22 @@ export interface AuditToPassInput {
147
204
  readonly reviewerName?: string
148
205
  /** Round cap (fail loud past it; no lock). */
149
206
  readonly maxRounds?: number
207
+ /**
208
+ * T20: how many CONSECUTIVE rounds may repeat the same progress cursor before the
209
+ * loop stops and demands a resume. Default 2, so three identical rounds terminate:
210
+ * the first sets the cursor and the next two repeat it. Absent the bound, a phase
211
+ * could restate one unfixed finding until the attempt cap, which is the wrong
212
+ * budget — a round count indulges a loop while cutting off genuine progress.
213
+ */
214
+ readonly maxNoProgress?: number
215
+ /**
216
+ * T28: the configurable caps. `maxAuditRounds` is the absolute round ceiling and
217
+ * `maxRepairAttempts` bounds how many times a phase may be sent back for repair
218
+ * even when every round finds something NEW — the case T20's no-progress bound
219
+ * deliberately lets run. Both are optional so an existing caller keeps its
220
+ * behaviour, with the round cap falling back to `maxRounds ?? 3`.
221
+ */
222
+ readonly budgets?: { readonly maxAuditRounds?: number; readonly maxRepairAttempts?: number }
150
223
  /** Per-round wait timeout before the audit round runs (skipped without the seam). */
151
224
  readonly waitTimeoutMs?: number
152
225
  }
@@ -167,7 +240,13 @@ export function renderTaskHistory(task: TeamTaskViewLike | undefined, rounds: re
167
240
  }
168
241
  for (const round of rounds) {
169
242
  const repair = round.repair ? ' — ' + round.repair : ''
170
- lines.push('round ' + round.round + ': ' + round.verdict + ' (task rev ' + round.taskRevision + ')' + repair)
243
+ const marker = round.noProgress ? ' [no progress]' : ''
244
+ lines.push('round ' + round.round + ': ' + round.verdict + ' (task rev ' + round.taskRevision + ')' + repair + marker)
245
+ }
246
+ // T20: a loop should LOOK like a loop on the board, not just end like one.
247
+ const noProgressRounds = rounds.filter((round) => round.noProgress === true).length
248
+ if (noProgressRounds > 0) {
249
+ lines.push('no progress: ' + noProgressRounds + ' round(s) restated the same finding — an explicit resume is required')
171
250
  }
172
251
  return lines.join('\n')
173
252
  }
@@ -181,9 +260,67 @@ export function renderTaskHistory(task: TeamTaskViewLike | undefined, rounds: re
181
260
  * → APPROVE: updateTask(complete) → lockPhase()
182
261
  * → REJECT / cap / stuck: updateTask(release) + interrupt, NO lock.
183
262
  */
263
+ /**
264
+ * T19 wrapper: recognise a repeat, then record what happened.
265
+ *
266
+ * The identity covers this run and phase. A call whose operation was already recorded
267
+ * as APPLIED returns immediately without creating a second task — the acceptance's
268
+ * "recognised no-op rather than a second execution". Every other outcome (rejected,
269
+ * capped, errored) records as `unaccepted`, so a retry still runs: a failed audit must
270
+ * stay retryable, and only a COMPLETED one is a repeat.
271
+ *
272
+ * Per-round entries are recorded after the loop from the result's own round trail.
273
+ * That is one write per call instead of one per round, and it keeps the loop body
274
+ * free of persistence — but it does mean a crash MID-loop leaves the whole operation
275
+ * unrecorded, which is the honest trade for not threading a writer through every
276
+ * branch.
277
+ */
184
278
  export async function auditToPass(input: AuditToPassInput): Promise<AuditToPassResult> {
279
+ const operation = operationId({ act: 'audit-to-pass', input: { runId: input.runId, phase: input.phase } })
280
+ const prior = input.operations?.find?.(operation) ?? null
281
+ if (prior?.outcome === 'applied') {
282
+ return {
283
+ ok: true,
284
+ reason: 'recognised repeat: an audit-to-pass for ' + input.runId + ' ' + input.phase +
285
+ ' already completed, so no second task was created',
286
+ rounds: [],
287
+ locked: false,
288
+ recognisedRepeat: true,
289
+ }
290
+ }
291
+
292
+ const result = await runAuditToPass(input)
293
+ const at = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
294
+ input.operations?.record?.({
295
+ id: operation,
296
+ act: 'audit-to-pass',
297
+ at,
298
+ outcome: result.ok && result.locked ? 'applied' : 'unaccepted',
299
+ })
300
+ for (const round of result.rounds) {
301
+ input.operations?.record?.({
302
+ id: operationId({ act: 'audit-round', input: { runId: input.runId, phase: input.phase, round: round.round } }),
303
+ act: 'audit-round',
304
+ at,
305
+ outcome: round.verdict,
306
+ })
307
+ }
308
+ return result
309
+ }
310
+
311
+ async function runAuditToPass(input: AuditToPassInput): Promise<AuditToPassResult> {
185
312
  const { teams, caller, runId, phase, runAuditRound, lockPhase } = input
186
- const maxRounds = input.maxRounds ?? 3
313
+ const maxRounds = input.maxRounds ?? input.budgets?.maxAuditRounds ?? 3
314
+ // T28: the repair budget. Absent a configured cap, repairs are unbounded here on
315
+ // purpose — the round cap and the no-progress bound still apply, and inventing a
316
+ // silent default would change the behaviour of every existing caller.
317
+ const maxRepairAttempts = input.budgets?.maxRepairAttempts ?? Number.POSITIVE_INFINITY
318
+ let repairAttempts = 0
319
+ // T20: the no-progress bound, and the counters that make it visible.
320
+ const maxNoProgress = input.maxNoProgress ?? 2
321
+ let consecutiveNoProgress = 0
322
+ let progressCursor = ''
323
+ let attempts = 0
187
324
  const waitTimeoutMs = input.waitTimeoutMs ?? 30_000
188
325
  const waitForChange = teams.waitForChange
189
326
  const interrupt = teams.interrupt
@@ -212,11 +349,23 @@ export async function auditToPass(input: AuditToPassInput): Promise<AuditToPassR
212
349
  const outcome = await runAuditRound(round, current)
213
350
  if (!isSameTask(current, task.id)) return { ok: false, reason: 'round observed a foreign task', taskId: task.id, rounds, locked: false, taskView: current }
214
351
 
352
+ // T20 — THE PROGRESS CURSOR. A round's statement of what is wrong is its verdict
353
+ // plus the repair it produced (that repair is synthesized from the findings, so
354
+ // it is the round's own summary of the problem). The SAME cursor twice running
355
+ // means the previous repair did not change anything, which is a loop; a CHANGED
356
+ // cursor is progress and resets the counter. Canonical JSON, so two structurally
357
+ // identical rounds cannot differ by key order.
358
+ attempts = round
359
+ const cursor = canonicalInput({ verdict: outcome.verdict, repair: outcome.repair ?? '', reason: outcome.reason ?? '' })
360
+ const repeated = cursor === progressCursor
361
+ consecutiveNoProgress = repeated ? consecutiveNoProgress + 1 : 0
362
+ progressCursor = cursor
363
+
215
364
  if (outcome.verdict === 'APPROVE') {
216
365
  const completed = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'complete' })
217
366
  await lockPhase()
218
367
  rounds.push({ round, verdict: 'APPROVE', taskRevision: completed.revision })
219
- return { ok: outcome.accepted, reason: outcome.accepted ? 'audit passed and phase locked' : 'verdict APPROVE but delegation not accepted', taskId: task.id, rounds, locked: true, taskView: completed }
368
+ return { ok: outcome.accepted, reason: outcome.accepted ? 'audit passed and phase locked' : 'verdict APPROVE but delegation not accepted', taskId: task.id, rounds, locked: true, taskView: completed, attempts, consecutiveNoProgress, progressCursor }
220
369
  }
221
370
 
222
371
  if (outcome.verdict === 'REJECT') {
@@ -235,11 +384,65 @@ export async function auditToPass(input: AuditToPassInput): Promise<AuditToPassR
235
384
  description: current.description + '\nround ' + round + ' repair: ' + repair,
236
385
  })
237
386
  current = edited
238
- rounds.push({ round, verdict: 'REVISE', repair, taskRevision: edited.revision })
387
+ repairAttempts += 1
388
+ rounds.push({ round, verdict: 'REVISE', repair, taskRevision: edited.revision, ...(repeated ? { noProgress: true } : {}) })
389
+
390
+ // T28 — THE REPAIR BUDGET. Checked before the no-progress bound because it is
391
+ // the wider stopping condition: a repair budget catches a loop that keeps
392
+ // finding NEW things, which is precisely the loop the no-progress bound is
393
+ // designed not to stop.
394
+ if (repairAttempts >= maxRepairAttempts) {
395
+ const released = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'release' })
396
+ return {
397
+ ok: false,
398
+ reason: 'repair budget reached: ' + repairAttempts + ' repair attempt(s) without an APPROVE',
399
+ taskId: task.id,
400
+ rounds,
401
+ locked: false,
402
+ taskView: released,
403
+ attempts,
404
+ repairAttempts,
405
+ consecutiveNoProgress,
406
+ progressCursor,
407
+ resumeRequired: true,
408
+ }
409
+ }
410
+
411
+ // T20 — STOP THE LOOP, DO NOT SPEND THE CAP ON IT. A phase that restates the
412
+ // same finding has not been repaired, and more rounds will not repair it. The
413
+ // terminal state demands an EXPLICIT resume: silently grinding on wastes the
414
+ // budget, and silently giving up hides an unfixed phase.
415
+ if (consecutiveNoProgress >= maxNoProgress) {
416
+ const released = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'release' })
417
+ return {
418
+ ok: false,
419
+ reason: 'no progress: ' + consecutiveNoProgress + ' consecutive rounds repeated the same finding (' + repair + ')',
420
+ taskId: task.id,
421
+ rounds,
422
+ locked: false,
423
+ taskView: released,
424
+ attempts,
425
+ consecutiveNoProgress,
426
+ progressCursor,
427
+ resumeRequired: true,
428
+ }
429
+ }
239
430
  }
240
- // Round cap: release and fail loud — never lock without an APPROVE.
431
+ // Round cap: release and fail loud — never lock without an APPROVE. T20 keeps the
432
+ // cap as the ABSOLUTE backstop for a loop that keeps making progress.
241
433
  const released = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'release' })
242
- return { ok: false, reason: 'max rounds reached without an APPROVE', taskId: task.id, rounds, locked: false, taskView: released }
434
+ return {
435
+ ok: false,
436
+ reason: 'max rounds reached without an APPROVE',
437
+ taskId: task.id,
438
+ rounds,
439
+ locked: false,
440
+ taskView: released,
441
+ attempts,
442
+ consecutiveNoProgress,
443
+ progressCursor,
444
+ resumeRequired: true,
445
+ }
243
446
  } catch (err) {
244
447
  const message = err instanceof Error ? err.message : String(err)
245
448
  // A stuck reviewer is interrupted through the team kill switch (the task
@@ -249,7 +452,18 @@ export async function auditToPass(input: AuditToPassInput): Promise<AuditToPassR
249
452
  interrupt(caller, input.reviewerName ?? 'auditor')
250
453
  } catch { /* interrupt is best-effort */ }
251
454
  }
252
- return { ok: false, reason: message, taskId: task.id, rounds, locked: false, taskView: current }
455
+ return {
456
+ ok: false,
457
+ reason: message,
458
+ taskId: task.id,
459
+ rounds,
460
+ locked: false,
461
+ taskView: current,
462
+ attempts,
463
+ consecutiveNoProgress,
464
+ progressCursor,
465
+ resumeRequired: true,
466
+ }
253
467
  }
254
468
  }
255
469