@try-works/dsh-recursive-mode 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * T3: model the audit→repair→re-audit loop as agentTeams Tasks.
3
+ *
4
+ * The recursion concept's core state machine, expressed over the native
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
8
+ * updateTask(complete) → lock. A stuck reviewer is interrupted through the
9
+ * team's own kill switch; a REJECT or round-cap releases the task and fails
10
+ * loud — a lock NEVER happens before an APPROVE verdict.
11
+ *
12
+ * Everything here is a structural seam (TeamRuntimeLike / TeamTaskViewLike)
13
+ * so the whole loop is unit-testable with a fake team; the live `agentTeams`
14
+ * service satisfies the seam without an adapter. The caller Agent passes
15
+ * through as an opaque `TeamCallerHandle` (never serialized; workspace-scoped
16
+ * invariant).
17
+ */
18
+
19
+ /** Verdict vocabulary shared with T4 (matches the delegated review schema). */
20
+ export type AuditVerdict = 'APPROVE' | 'REVISE' | 'REJECT'
21
+
22
+ /**
23
+ * Opaque handle to the live Team member/lead Agent authorizing task mutations.
24
+ * The seam uses it only for identity/authority and never inspects or serializes it.
25
+ */
26
+ export interface TeamCallerHandle {
27
+ readonly id?: string
28
+ readonly session?: { readonly header?: { readonly cwd?: string } }
29
+ }
30
+
31
+ /** Minimal cancellation shape (a live AbortSignal satisfies it). */
32
+ export interface TeamAbortSignalLike {
33
+ readonly throwIfAborted: () => void
34
+ }
35
+
36
+ /** Task identity: a branded string in the live service. */
37
+ export type TeamTaskIdLike = string
38
+
39
+ /** Task status vocabulary (mirrors the live TeamTaskStatus). */
40
+ export type TeamTaskStatusLike = 'pending' | 'in_progress' | 'completed' | 'deleted'
41
+
42
+ /** Task action vocabulary (mirrors the live TeamTaskAction; the loop uses a subset). */
43
+ export type TeamTaskActionLike = 'claim' | 'release' | 'edit' | 'complete'
44
+
45
+ /** One runtime-enriched task view (the fields the loop reads/writes). */
46
+ export interface TeamTaskViewLike {
47
+ readonly id: TeamTaskIdLike
48
+ readonly revision: number
49
+ readonly subject: string
50
+ readonly description: string
51
+ readonly status: TeamTaskStatusLike
52
+ readonly blockedBy: TeamTaskIdLike[]
53
+ readonly writeScopes: string[]
54
+ readonly ownerName?: string
55
+ readonly ready: boolean
56
+ readonly writeScopeWarnings: string[]
57
+ }
58
+
59
+ /** A wait observation (mirrors TeamWaitResult). */
60
+ export interface TeamWaitResultLike {
61
+ readonly timedOut: boolean
62
+ }
63
+
64
+ /** Create-task request (mirrors CreateTeamTaskRequest). */
65
+ export interface CreateTeamTaskRequestLike {
66
+ subject: string
67
+ description: string
68
+ blockedBy?: readonly TeamTaskIdLike[]
69
+ writeScopes?: readonly string[]
70
+ }
71
+
72
+ /** Compare-and-set task transition (mirrors UpdateTeamTaskRequest). */
73
+ export interface UpdateTeamTaskRequestLike {
74
+ taskId: TeamTaskIdLike
75
+ expectedRevision: number
76
+ action: TeamTaskActionLike
77
+ subject?: string
78
+ description?: string
79
+ blockedBy?: readonly TeamTaskIdLike[]
80
+ writeScopes?: readonly string[]
81
+ owner?: string
82
+ }
83
+
84
+ /**
85
+ * The agentTeams seam the loop calls. `waitForChange`/`interrupt`/`getTask`/
86
+ * `listTasks` are optional (loops degrade: no wait, no kill switch, no board
87
+ * re-read) — `createTask`/`updateTask` are hard requirements.
88
+ */
89
+ export interface TeamRuntimeLike {
90
+ createTask(caller: TeamCallerHandle, request: CreateTeamTaskRequestLike): Promise<TeamTaskViewLike>
91
+ updateTask(caller: TeamCallerHandle, request: UpdateTeamTaskRequestLike): Promise<TeamTaskViewLike>
92
+ getTask?(caller: TeamCallerHandle, id: TeamTaskIdLike): TeamTaskViewLike
93
+ listTasks?(caller: TeamCallerHandle): TeamTaskViewLike[]
94
+ waitForChange?(caller: TeamCallerHandle, timeoutMs: number, signal: TeamAbortSignalLike | undefined): Promise<TeamWaitResultLike>
95
+ interrupt?(caller: TeamCallerHandle, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' }
96
+ }
97
+
98
+ /** One audit round's outcome (the verdict + synthesized repair + acceptance). */
99
+ export interface AuditRoundOutcome {
100
+ readonly verdict: AuditVerdict
101
+ /** Repair instruction synthesized from findings (REVISE only). */
102
+ readonly repair?: string
103
+ /** Whether the underlying delegation result itself was accepted. */
104
+ readonly accepted: boolean
105
+ readonly reason?: string
106
+ }
107
+
108
+ /** One completed loop round (the task revision trail for the board history). */
109
+ export interface AuditLoopRound {
110
+ readonly round: number
111
+ readonly verdict: AuditVerdict
112
+ readonly repair?: string
113
+ readonly taskRevision: number
114
+ }
115
+
116
+ /** The auditToPass result. */
117
+ export interface AuditToPassResult {
118
+ readonly ok: boolean
119
+ readonly reason?: string
120
+ /** The durable task the loop ran on (revision trail lives on the team log). */
121
+ readonly taskId?: TeamTaskIdLike
122
+ readonly rounds: AuditLoopRound[]
123
+ /** True only when an APPROVE verdict completed the task and locked the phase. */
124
+ readonly locked: boolean
125
+ /** Latest task view (board-facing per-phase history). */
126
+ readonly taskView?: TeamTaskViewLike
127
+ }
128
+
129
+ /** Inputs for one audit-to-pass loop. */
130
+ export interface AuditToPassInput {
131
+ /** The agentTeams seam. */
132
+ readonly teams: TeamRuntimeLike
133
+ /** Exact live Team member/lead authorizing the task mutations. */
134
+ readonly caller: TeamCallerHandle
135
+ /** Phase + run identity (task subject/description vocabulary). */
136
+ readonly runId: string
137
+ readonly phase: string
138
+ /** Optional task blockers (previous-phase task ids). */
139
+ readonly blockedBy?: readonly TeamTaskIdLike[]
140
+ /** Write scopes for the phase artifact (advisory, overlap-warned). */
141
+ readonly writeScopes?: readonly string[]
142
+ /** Run ONE audit round for the current task; live usage delegates (T4). */
143
+ readonly runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>
144
+ /** Lock the phase artifact — called ONLY after an APPROVE verdict. */
145
+ readonly lockPhase: () => Promise<void>
146
+ /** Team member name to interrupt on a stuck reviewer (defaults to the role). */
147
+ readonly reviewerName?: string
148
+ /** Round cap (fail loud past it; no lock). */
149
+ readonly maxRounds?: number
150
+ /** Per-round wait timeout before the audit round runs (skipped without the seam). */
151
+ readonly waitTimeoutMs?: number
152
+ }
153
+
154
+ /** Whether a view is the loop's expected task (guards CAS against foreign ids). */
155
+ function isSameTask(task: TeamTaskViewLike, id: TeamTaskIdLike): boolean {
156
+ return task.id === id
157
+ }
158
+
159
+ /**
160
+ * Render a per-phase task history (board-facing; pure). One line per round plus
161
+ * the final task status — no live data, no mutation.
162
+ */
163
+ export function renderTaskHistory(task: TeamTaskViewLike | undefined, rounds: readonly AuditLoopRound[]): string {
164
+ const lines: string[] = []
165
+ if (task !== undefined) {
166
+ lines.push('task ' + task.id + ' (' + task.status + ', rev ' + task.revision + '): ' + task.subject)
167
+ }
168
+ for (const round of rounds) {
169
+ const repair = round.repair ? ' — ' + round.repair : ''
170
+ lines.push('round ' + round.round + ': ' + round.verdict + ' (task rev ' + round.taskRevision + ')' + repair)
171
+ }
172
+ return lines.join('\n')
173
+ }
174
+
175
+ /**
176
+ * T3 driver: audit the phase until it passes, on ONE durable team task.
177
+ *
178
+ * Transition trail (the fake records exactly this order):
179
+ * createTask(pending) → claim(in_progress) → waitForChange → audit round
180
+ * → REVISE: updateTask(edit, repair) → waitForChange → re-audit SAME task
181
+ * → APPROVE: updateTask(complete) → lockPhase()
182
+ * → REJECT / cap / stuck: updateTask(release) + interrupt, NO lock.
183
+ */
184
+ export async function auditToPass(input: AuditToPassInput): Promise<AuditToPassResult> {
185
+ const { teams, caller, runId, phase, runAuditRound, lockPhase } = input
186
+ const maxRounds = input.maxRounds ?? 3
187
+ const waitTimeoutMs = input.waitTimeoutMs ?? 30_000
188
+ const waitForChange = teams.waitForChange
189
+ const interrupt = teams.interrupt
190
+ const rounds: AuditLoopRound[] = []
191
+
192
+ const createRequest: CreateTeamTaskRequestLike = {
193
+ subject: 'Audit to pass: ' + runId + ' ' + phase,
194
+ description: 'drive ' + phase + ' through draft → audit → repair → re-audit → pass → lock for run ' + runId,
195
+ }
196
+ if (input.blockedBy !== undefined) createRequest.blockedBy = input.blockedBy
197
+ if (input.writeScopes !== undefined) createRequest.writeScopes = input.writeScopes
198
+ const task = await teams.createTask(caller, createRequest)
199
+
200
+ const claim = await teams.updateTask(caller, { taskId: task.id, expectedRevision: task.revision, action: 'claim' })
201
+ if (!isSameTask(claim, task.id)) return { ok: false, reason: 'claim returned a foreign task', taskId: task.id, rounds, locked: false, taskView: claim }
202
+
203
+ let current = claim
204
+ rounds.push({ round: 0, verdict: 'REVISE', taskRevision: current.revision })
205
+
206
+ try {
207
+ for (let round = 1; round <= maxRounds; round += 1) {
208
+ // Wait for team activity before the round (bounded; skipped without the seam).
209
+ if (waitForChange !== undefined) {
210
+ await waitForChange(caller, waitTimeoutMs, undefined)
211
+ }
212
+ const outcome = await runAuditRound(round, current)
213
+ if (!isSameTask(current, task.id)) return { ok: false, reason: 'round observed a foreign task', taskId: task.id, rounds, locked: false, taskView: current }
214
+
215
+ if (outcome.verdict === 'APPROVE') {
216
+ const completed = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'complete' })
217
+ await lockPhase()
218
+ 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 }
220
+ }
221
+
222
+ if (outcome.verdict === 'REJECT') {
223
+ // A rejected audit is not a pass: release the task and fail loud.
224
+ const released = await teams.updateTask(caller, { taskId: task.id, expectedRevision: current.revision, action: 'release' })
225
+ rounds.push({ round, verdict: 'REJECT', taskRevision: released.revision })
226
+ return { ok: false, reason: 'audit rejected at round ' + round, taskId: task.id, rounds, locked: false, taskView: released }
227
+ }
228
+
229
+ // REVISE: record the repair instruction on the SAME task and re-audit.
230
+ const repair = outcome.repair ?? 'REVISE: address the review findings and re-submit.'
231
+ const edited = await teams.updateTask(caller, {
232
+ taskId: task.id,
233
+ expectedRevision: current.revision,
234
+ action: 'edit',
235
+ description: current.description + '\nround ' + round + ' repair: ' + repair,
236
+ })
237
+ current = edited
238
+ rounds.push({ round, verdict: 'REVISE', repair, taskRevision: edited.revision })
239
+ }
240
+ // Round cap: release and fail loud — never lock without an APPROVE.
241
+ 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 }
243
+ } catch (err) {
244
+ const message = err instanceof Error ? err.message : String(err)
245
+ // A stuck reviewer is interrupted through the team kill switch (the task
246
+ // itself stays for a later resume); best-effort, never replaces the error.
247
+ if (interrupt !== undefined) {
248
+ try {
249
+ interrupt(caller, input.reviewerName ?? 'auditor')
250
+ } catch { /* interrupt is best-effort */ }
251
+ }
252
+ return { ok: false, reason: message, taskId: task.id, rounds, locked: false, taskView: current }
253
+ }
254
+ }
255
+
256
+ /** Whether a task view is currently claimed by the named owner (board-facing). */
257
+ export function isTaskClaimedBy(task: TeamTaskViewLike, ownerName: string): boolean {
258
+ return task.ownerName === ownerName && task.status === 'in_progress'
259
+ }