@try-works/dsh-recursive-mode 0.2.4 → 0.3.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.
@@ -0,0 +1,141 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
3
+ import type {
4
+ TeamRuntimeLike,
5
+ TeamCallerHandle,
6
+ TeamTaskActionLike,
7
+ TeamTaskViewLike,
8
+ CreateTeamTaskRequestLike,
9
+ UpdateTeamTaskRequestLike,
10
+ } from './teams-loop.ts'
11
+
12
+ /**
13
+ * `recursive_audit_team` — the T3 entry path: a turn-driven adapter over the
14
+ * live `agentTeams` task board. ONE call advances the audit→repair→re-audit
15
+ * state machine by one transition, so the agent drives the loop across turns
16
+ * as continuable-child settlement notices arrive in its inbox.
17
+ *
18
+ * Why one step per call: the live subagents service has no public parent-side
19
+ * "await settlement" promise — a continuable child's verdict arrives as a
20
+ * `subagent-settled` message on a LATER turn. The pure whole-loop driver
21
+ * (`auditToPass` in teams-loop.ts) models the full state machine and is the
22
+ * tested reference; this tool is its honest turn-driven shell.
23
+ */
24
+
25
+ /** Project one live task view to an owned, lossless-JSON-safe record. */
26
+ function taskViewToJson(view: TeamTaskViewLike): JsonValue {
27
+ // Read only leaf fields; never pass the live service object into the model
28
+ // context (the tool boundary is where a live view becomes owned JSON).
29
+ return {
30
+ id: view.id,
31
+ revision: view.revision,
32
+ subject: view.subject,
33
+ description: view.description,
34
+ status: view.status,
35
+ blockedBy: [...view.blockedBy],
36
+ writeScopes: [...view.writeScopes],
37
+ ownerName: view.ownerName ?? null,
38
+ ready: view.ready,
39
+ writeScopeWarnings: [...view.writeScopeWarnings],
40
+ }
41
+ }
42
+
43
+ /** Wrap a non-JSON-pure value in the standard error envelope. */
44
+ function errorJson(message: string): JsonValue {
45
+ return { error: message }
46
+ }
47
+
48
+ export function createRecursiveAuditTeamTool(teams: TeamRuntimeLike | null) {
49
+ return defineTool({
50
+ name: 'recursive_audit_team',
51
+ description: 'Advance one agentTeams Task-board transition for the recursive audit loop (create → claim → edit(REVISE) → complete(APPROVE) → release/interrupt). Drive one step per turn as continuable-child settlement notices arrive; complete the task (and lock the phase) ONLY after an APPROVE verdict.',
52
+ parameters: {
53
+ action: { type: 'string', description: 'create | claim | edit | complete | release | interrupt | get | list. Required.' },
54
+ taskId: { type: 'string', description: 'Task id for claim/edit/complete/release/interrupt/get.' },
55
+ expectedRevision: { type: 'number', description: 'CAS revision for claim/edit/complete/release.' },
56
+ subject: { type: 'string', description: 'Task subject (create).' },
57
+ description: { type: 'string', description: 'Task description (create) or appended repair note (edit).' },
58
+ blockedBy: { type: 'array', items: { type: 'string' }, description: 'Task blockers (create).' },
59
+ writeScopes: { type: 'array', items: { type: 'string' }, description: 'Advisory write scopes (create).' },
60
+ targetName: { type: 'string', description: 'Teammate name to interrupt (interrupt).' },
61
+ },
62
+ output: {
63
+ schema: { type: 'json' },
64
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
65
+ },
66
+ async execute(args: {
67
+ action?: string
68
+ taskId?: string
69
+ expectedRevision?: number
70
+ subject?: string
71
+ description?: string
72
+ blockedBy?: string[]
73
+ writeScopes?: string[]
74
+ targetName?: string
75
+ }, exec) {
76
+ const action = args.action ?? ''
77
+ if (!teams) return errorJson('agentTeams service is not available in this composition')
78
+ // SAFETY: exec.agent is the live Team member Agent (a superset of the
79
+ // named opaque handle); the seam reads only id/session identity and never
80
+ // serializes it. When no agent owns the call, an empty handle degrades to
81
+ // an anonymous caller (the service admits/denies by its own authority).
82
+ const caller: TeamCallerHandle = exec.agent ?? {}
83
+
84
+ try {
85
+ switch (action) {
86
+ case 'create': {
87
+ if (!args.subject || !args.description) return errorJson('create requires subject and description')
88
+ const createRequest: CreateTeamTaskRequestLike = {
89
+ subject: args.subject,
90
+ description: args.description,
91
+ }
92
+ if (args.blockedBy !== undefined) createRequest.blockedBy = args.blockedBy
93
+ if (args.writeScopes !== undefined) createRequest.writeScopes = args.writeScopes
94
+ const view = await teams.createTask(caller, createRequest)
95
+ return taskViewToJson(view)
96
+ }
97
+ case 'claim':
98
+ case 'edit':
99
+ case 'complete':
100
+ case 'release': {
101
+ if (!args.taskId || args.expectedRevision === undefined) {
102
+ return errorJson(action + ' requires taskId and expectedRevision')
103
+ }
104
+ // SAFETY: this case arm is reachable only for action 'claim' | 'edit' |
105
+ // 'complete' | 'release' (the switch discriminates on the same string),
106
+ // each a member of TeamTaskActionLike's subset; the cast re-asserts that
107
+ // narrowing across the four fall-through labels without widening scope.
108
+ const updateAction = action as TeamTaskActionLike
109
+ const updateRequest: UpdateTeamTaskRequestLike = {
110
+ taskId: args.taskId,
111
+ expectedRevision: args.expectedRevision,
112
+ action: updateAction,
113
+ }
114
+ if (action === 'edit' && args.description !== undefined) updateRequest.description = args.description
115
+ const view = await teams.updateTask(caller, updateRequest)
116
+ return taskViewToJson(view)
117
+ }
118
+ case 'interrupt': {
119
+ if (!args.targetName) return errorJson('interrupt requires targetName')
120
+ if (!teams.interrupt) return errorJson('no interrupt seam')
121
+ const outcome = teams.interrupt(caller, args.targetName)
122
+ return { previousStatus: outcome.previousStatus }
123
+ }
124
+ case 'get': {
125
+ if (!args.taskId) return errorJson('get requires taskId')
126
+ if (!teams.getTask) return errorJson('no getTask seam')
127
+ return taskViewToJson(teams.getTask(caller, args.taskId))
128
+ }
129
+ case 'list': {
130
+ if (!teams.listTasks) return errorJson('no listTasks seam')
131
+ return teams.listTasks(caller).map(taskViewToJson)
132
+ }
133
+ default:
134
+ return errorJson('unsupported action: ' + action + ' (create|claim|edit|complete|release|interrupt|get|list)')
135
+ }
136
+ } catch (err) {
137
+ return errorJson(err instanceof Error ? err.message : String(err))
138
+ }
139
+ },
140
+ })
141
+ }
package/src/runtime.ts CHANGED
@@ -21,7 +21,7 @@ import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './
21
21
  import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
22
22
  import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
23
23
  import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
24
- import { delegate, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput } from './delegation.ts'
24
+ import { delegate, delegateContinuable, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
25
25
  import { validateTransition, coupleGateBlockToGoal, type PhaseTransitionIntent, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
26
26
  import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluateToolGuard, detectTamper, type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
27
27
  import type { Session } from '@deepseek-ai/dsh-session'
@@ -29,6 +29,9 @@ import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
29
29
  import { snapshotWorkspace } from './snapshot.ts'
30
30
  import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
31
31
  import { gitFacts } from './git-context.ts'
32
+ import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike } from './goals-projection.ts'
33
+ import { auditToPass, renderTaskHistory, type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts'
34
+ import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts'
32
35
 
33
36
  declare module '@deepseek-ai/cordis' {
34
37
  interface Context {
@@ -70,16 +73,86 @@ const ARTIFACT_STUB = {
70
73
  export class RecursiveRuntime extends Service {
71
74
  /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
72
75
 
73
- constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike } = {}) {
76
+ constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null } = {}) {
74
77
  super(ctx, 'recursive')
75
78
  this.repoRoot = config.repoRoot ?? process.cwd()
76
79
  this.workspaceRegistry = config.workspaceRegistry ?? null
80
+ this.goalsService = config.goals ?? null
77
81
  }
78
82
 
79
83
  private readonly repoRoot: string
80
84
  private readonly workspaceRegistry: WorkspaceRegistryLike | null
85
+ private readonly goalsService: GoalServiceLike | null
81
86
  private _enforcementConfig: EnforcementConfig | null = null
82
87
 
88
+ /**
89
+ * T3 (agentTeams task loop): run the audit→repair→re-audit state machine on
90
+ * ONE durable team task. The `teams` seam (live `ctx.agentTeams`) is injected
91
+ * per-call so the loop stays unit-testable; `runAuditRound` is the caller's
92
+ * round executor (live usage wires T4's continuable delegation). Locking the
93
+ * phase artifact is `lockPhase` — the loop NEVER locks before an APPROVE.
94
+ */
95
+ async auditToPass(input: {
96
+ teams: TeamRuntimeLike
97
+ caller: TeamCallerHandle
98
+ root: string
99
+ runId: string
100
+ phase: string
101
+ artifact: string
102
+ agent?: { session?: { header?: { cwd?: string } } } | null
103
+ runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>
104
+ blockedBy?: readonly string[]
105
+ writeScopes?: readonly string[]
106
+ reviewerName?: string
107
+ maxRounds?: number
108
+ waitTimeoutMs?: number
109
+ }): Promise<AuditToPassResult & { history?: string; lock?: LockArtifactResult }> {
110
+ const { teams, caller, runId, phase, artifact, runAuditRound, agent } = input
111
+ let lockResult: LockArtifactResult | undefined
112
+ const lockPhase = async () => { lockResult = await this.lockArtifact(runId, artifact, false, agent) }
113
+ const result = await auditToPass({
114
+ teams,
115
+ caller,
116
+ runId,
117
+ phase,
118
+ blockedBy: input.blockedBy,
119
+ writeScopes: input.writeScopes,
120
+ reviewerName: input.reviewerName,
121
+ maxRounds: input.maxRounds,
122
+ waitTimeoutMs: input.waitTimeoutMs,
123
+ runAuditRound,
124
+ lockPhase,
125
+ })
126
+ const report: AuditToPassResult & { history?: string; lock?: LockArtifactResult } = {
127
+ ...result,
128
+ history: renderTaskHistory(result.taskView, result.rounds),
129
+ }
130
+ if (lockResult !== undefined) report.lock = lockResult
131
+ return report
132
+ }
133
+
134
+ /**
135
+ * T1 (goals projection): project the run into the native goals service so it is
136
+ * a first-class durable, resumable, blockable object. Best-effort — the run's
137
+ * filesystem state is the source of truth; a goal is the durable projection.
138
+ */
139
+ projectRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, state: Parameters<typeof syncRunGoal>[3] = 'active') {
140
+ if (!agent) return { ok: false, reason: 'no agent' }
141
+ return syncRunGoal(this.goalsService, agent, runId, state)
142
+ }
143
+
144
+ /** T1: block the run's goal on a gate-block (durable + UI-visible). */
145
+ blockRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, reason: { code: string; message: string }) {
146
+ if (!agent) return { ok: false, reason: 'no agent' }
147
+ return blockRunGoal(this.goalsService, agent, runId, reason)
148
+ }
149
+
150
+ /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
151
+ resumeRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string) {
152
+ if (!agent) return { ok: false, reason: 'no agent' }
153
+ return resumeRunGoal(this.goalsService, agent, runId)
154
+ }
155
+
83
156
  /**
84
157
  * Workspace-scoped control-plane root (R1 binding invariant).
85
158
  * Resolves the session agent's canonical cwd -> workspace path via the
@@ -138,8 +211,16 @@ export class RecursiveRuntime extends Service {
138
211
  /**
139
212
  * Phase B (native delegation): build a review bundle (R1) + file-backed
140
213
  * handoff docs (R2), resolve the role via the router policy (R3), and call
141
- * ctx.subagents.start() with the full request (R4). Workspace-scoped: every
142
- * path resolves under the session's control-plane root.
214
+ * ctx.subagents with the full request (R4). Workspace-scoped: every path
215
+ * resolves under the session's control-plane root.
216
+ *
217
+ * `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
218
+ * durable continuable child (startContinuable → followup with the repair
219
+ * instruction → settle) and drains the child on closeout. It requires an
220
+ * `awaitRoundResult` observer (the parent-side settlement seam) AND the exact
221
+ * live `parent` Agent (continuable followup is object-identity authority);
222
+ * when either is absent it falls back to one-shot `delegate()` with a flag —
223
+ * never silently. One-shot `start()` is never called on the continuable path.
143
224
  */
144
225
  async delegateReview(input: {
145
226
  root: string
@@ -160,6 +241,11 @@ export class RecursiveRuntime extends Service {
160
241
  subagents?: SubagentsRuntimeLike
161
242
  maxDepth?: number
162
243
  toolFilter?: unknown
244
+ mode?: 'one-shot' | 'continuable'
245
+ awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
246
+ maxRounds?: number
247
+ /** T4: the exact live direct-parent Agent (object-identity authority). */
248
+ parent?: SubagentParentHandle
163
249
  }) {
164
250
  const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
165
251
  const providers = input.providers ?? {}
@@ -213,7 +299,9 @@ export class RecursiveRuntime extends Service {
213
299
  briefPath,
214
300
  })
215
301
 
216
- // R4: plugin-driven delegation with the full request shape.
302
+ // R4: plugin-driven delegation with the full request shape. `parent` is the
303
+ // exact live direct-parent Agent (object-identity authority in the live
304
+ // subagent service); absent it, the live start() rejects the request.
217
305
  const request: SubagentStartRequestLike = {
218
306
  prompt: [{ type: 'text', text: prompt }],
219
307
  label: input.delegationId + '/' + input.childId,
@@ -221,12 +309,38 @@ export class RecursiveRuntime extends Service {
221
309
  toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
222
310
  maxDepth: input.maxDepth ?? 2,
223
311
  }
312
+ if (input.parent !== undefined) request.parent = input.parent
224
313
 
225
314
  let result: SubagentResultLike | null = null
226
315
  let error: string | null = null
316
+ let continuable: ContinuableDelegationLike | null = null
227
317
  if (decision.tier === 'native' || decision.tier === 'external-cli') {
228
318
  if (!input.subagents) {
229
319
  error = 'no ctx.subagents runtime available (self-audit fallback)'
320
+ } else if (input.mode === 'continuable') {
321
+ // T4: one durable child carries every round; start() is never called.
322
+ continuable = await delegateContinuable({
323
+ subagents: input.subagents,
324
+ provider: decision.provider as string,
325
+ label: input.delegationId + '/' + input.childId,
326
+ prompt,
327
+ childId: input.childId,
328
+ maxDepth: input.maxDepth ?? 2,
329
+ toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
330
+ maxRounds: input.maxRounds ?? 3,
331
+ awaitRoundResult: input.awaitRoundResult,
332
+ parent: input.parent,
333
+ })
334
+ if (continuable.fellBackToOneShot) {
335
+ // The seam has no continuable capability — keep the one-shot result.
336
+ result = continuable.rounds[0]?.result ?? null
337
+ if (!result) error = 'continuable fallback produced no result'
338
+ } else if (continuable.ok && continuable.rounds.length > 0) {
339
+ result = continuable.rounds[continuable.rounds.length - 1].result ?? null
340
+ if (!result) error = 'continuable child produced no final result'
341
+ } else {
342
+ error = continuable.reason ?? 'continuable delegation failed'
343
+ }
230
344
  } else {
231
345
  try {
232
346
  result = await delegate({
@@ -251,7 +365,7 @@ export class RecursiveRuntime extends Service {
251
365
  subagentId: input.childId,
252
366
  phase: input.phase,
253
367
  purpose: input.role + ' for run ' + input.runId,
254
- executionMode: decision.tier,
368
+ executionMode: decision.tier + (input.mode === 'continuable' ? ' (continuable)' : ''),
255
369
  artifactPath: input.artifactPath,
256
370
  upstreamArtifacts: input.upstreamArtifacts,
257
371
  reviewBundle: bundle.repoRelativePath,
@@ -263,6 +377,10 @@ export class RecursiveRuntime extends Service {
263
377
  stopReason: result?.stopReason,
264
378
  })
265
379
 
380
+ // T4: the durable child id is reported for the caller (a tool/closeout that
381
+ // holds the live parent Agent may drain it explicitly); the HOST owns the
382
+ // teardown drain (drainContinuableDescendants) at session close — this loop
383
+ // never forces a drain with a wrong authority credential (childId ≠ parent).
266
384
  return {
267
385
  decision,
268
386
  probe,
@@ -277,6 +395,7 @@ export class RecursiveRuntime extends Service {
277
395
  evaluation,
278
396
  actionRecordPath,
279
397
  error,
398
+ continuable: continuable ? { rounds: continuable.rounds, childId: continuable.childId, fellBackToOneShot: continuable.fellBackToOneShot } : null,
280
399
  }
281
400
  }
282
401
 
@@ -431,6 +550,9 @@ export class RecursiveRuntime extends Service {
431
550
 
432
551
  const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult } = { runDir, runId, created, existing }
433
552
  if (worktree) result.worktree = worktree
553
+ // T1 (goals projection): arm a durable run goal for the driving session.
554
+ // Best-effort — never fails a run init if the goals service is absent/odd.
555
+ try { this.projectRunToGoal(agent, runId, 'active') } catch { /* goal projection is best-effort */ }
434
556
  return result
435
557
  }
436
558
 
@@ -486,6 +608,10 @@ export class RecursiveRuntime extends Service {
486
608
  // durable commit below is what the live fs route folds. No phase-intent event.
487
609
  const blockers = getPrerequisiteBlockers(runDir, artifact)
488
610
  if (blockers.length > 0) {
611
+ // T1 (goals projection): a gate-block becomes a durable, UI-visible goal
612
+ // block rather than a one-line advisory. Best-effort before the throw.
613
+ const message = 'monotonic lock-order: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', ')
614
+ try { this.blockRunToGoal(agent, runId, { code: 'prerequisite-blockers', message }) } catch { /* best-effort */ }
489
615
  throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
490
616
  }
491
617
  let content = readFileSync(artifactPath, 'utf8')
@@ -521,6 +647,8 @@ export class RecursiveRuntime extends Service {
521
647
  const stale = getStaleDownstreamPhases(runDir, artifact)
522
648
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
523
649
  // B2: reopen reverts to DRAFT — the live fs route folds the reverted state.
650
+ // T1 (goals projection): re-arm the durable run goal (reopen un-blocks).
651
+ try { this.resumeRunToGoal(agent, runId) } catch { /* best-effort */ }
524
652
  return {
525
653
  artifact,
526
654
  runId,
@@ -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
+ }