@try-works/dsh-recursive-mode 0.3.0 → 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.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +33 -12
- package/lib/index.js +10017 -3969
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/skills.d.ts +70 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +31 -31
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/skills/recursive-mode/SKILL.md +66 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +394 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/skills.ts +151 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/snapshot.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import { readFileSync, existsSync } from 'node:fs'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
import { discoverRuns } from './run.ts'
|
|
15
|
-
import { foldRun } from './status.ts'
|
|
15
|
+
import { foldRun, pendingWork } from './status.ts'
|
|
16
16
|
import { getLockStatus, receiptPath, readReceipt } from './lock.ts'
|
|
17
|
-
import type { RecursiveProjection, RecursiveRunCard, RecursivePhaseRow, RecursiveRunState } from './types.ts'
|
|
17
|
+
import type { PhasePosition, RecursiveProjection, RecursiveRunCard, RecursivePhaseRow, RecursiveRunState, RecursiveTamper } from './types.ts'
|
|
18
18
|
|
|
19
19
|
/** One locked-phase fact from a lock receipt (artifacts/<file>.receipt.json). */
|
|
20
20
|
interface LockFacts {
|
|
@@ -41,8 +41,11 @@ function lockFactsOf(runDir: string, artifact: string): LockFacts {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** One phase row from a run's folded status (locked facts enriched). */
|
|
44
|
-
function phaseRowOf(runDir: string, key: string, file: string, status: string): RecursivePhaseRow {
|
|
44
|
+
function phaseRowOf(runDir: string, key: string, file: string, status: string, position?: PhasePosition): RecursivePhaseRow {
|
|
45
45
|
const row: RecursivePhaseRow = { phase: key, status }
|
|
46
|
+
// T21: carry the server's single derived position onto the wire so the board
|
|
47
|
+
// reads ONE value instead of recombining status/gates/lock fields itself.
|
|
48
|
+
if (position !== undefined) row.position = position
|
|
46
49
|
if (status === 'LOCKED') {
|
|
47
50
|
const facts = lockFactsOf(runDir, file)
|
|
48
51
|
if (facts.lockedAt !== undefined) row.lockedAt = facts.lockedAt
|
|
@@ -63,7 +66,7 @@ export function foldRunCard(runDir: string, runId: string, worktreeRoot: string)
|
|
|
63
66
|
// would never match the filename regex and would break columnForRun/cardFacts.
|
|
64
67
|
const phases: Record<string, RecursivePhaseRow> = {}
|
|
65
68
|
for (const phase of status.phases) {
|
|
66
|
-
if (phase.exists) phases[phase.file] = phaseRowOf(runDir, phase.file, phase.file, phase.status)
|
|
69
|
+
if (phase.exists) phases[phase.file] = phaseRowOf(runDir, phase.file, phase.file, phase.status, phase.position)
|
|
67
70
|
}
|
|
68
71
|
const state = runStateOf(status)
|
|
69
72
|
return {
|
|
@@ -71,17 +74,45 @@ export function foldRunCard(runDir: string, runId: string, worktreeRoot: string)
|
|
|
71
74
|
worktreeRoot,
|
|
72
75
|
phases,
|
|
73
76
|
state,
|
|
74
|
-
tampers:
|
|
77
|
+
tampers: tampersOf(status),
|
|
75
78
|
subagents: {},
|
|
79
|
+
// T18: the card carries the same DERIVED pending set the lock gate enforces,
|
|
80
|
+
// so the board explains a refusal instead of only recording it. Derived on
|
|
81
|
+
// every fold, so a cold resume shows it and no store is involved (§4.0).
|
|
82
|
+
pendingWork: pendingWork(runDir),
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* T15 (F) — tamper facts by DERIVATION, never by persistence.
|
|
88
|
+
*
|
|
89
|
+
* `foldRun` already decides, per phase, whether a LOCKED artifact's stored hash
|
|
90
|
+
* still matches its content: a mismatch lands in `lockProblems` as the literal
|
|
91
|
+
* 'LockHash mismatch' and clears `lockValid`. That IS the tamper fact, so the
|
|
92
|
+
* card reads it off the fold instead of replaying the observed-tamper log —
|
|
93
|
+
* which means it survives a cold resume, honours plan §4.0 ("derived beats
|
|
94
|
+
* stored"), and stays correct even if the log was rotated or never written.
|
|
95
|
+
*
|
|
96
|
+
* Keyed by artifact FILE NAME (e.g. '00-requirements.md'), matching the phase
|
|
97
|
+
* keys above. `foldRun` itself is untouched: its output shape is
|
|
98
|
+
* parity-asserted by tests/status.parity.spec.ts.
|
|
99
|
+
*/
|
|
100
|
+
function tampersOf(status: ReturnType<typeof foldRun>): Record<string, RecursiveTamper> {
|
|
101
|
+
const tampers: Record<string, RecursiveTamper> = {}
|
|
102
|
+
for (const phase of status.phases) {
|
|
103
|
+
if (phase.status !== 'LOCKED' || phase.lockValid) continue
|
|
104
|
+
if (!phase.lockProblems.includes('LockHash mismatch')) continue
|
|
105
|
+
tampers[phase.file] = { path: phase.file, reason: phase.lockProblems.join('; ') }
|
|
106
|
+
}
|
|
107
|
+
return tampers
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
/**
|
|
80
111
|
* Derive the run-level state from the folded status. The fs stores no run-level
|
|
81
112
|
* state file, so the honest mapping is: 'complete' when every non-optional phase
|
|
82
|
-
* exists and is lock-valid, else 'active'.
|
|
83
|
-
*
|
|
84
|
-
* limitation; future enhancement).
|
|
113
|
+
* exists and is lock-valid, else 'active'. Tamper facts ARE fs-recoverable and
|
|
114
|
+
* are derived by tampersOf above; gateBlocked and subagent activity are not
|
|
115
|
+
* (recorded limitation; future enhancement).
|
|
85
116
|
*/
|
|
86
117
|
function runStateOf(status: ReturnType<typeof foldRun>): RecursiveRunState {
|
|
87
118
|
const mandatory = status.phases.filter(p => !p.optional)
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|