@try-works/dsh-recursive-mode 0.2.3 → 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.
- package/lib/client/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client.js +813 -58
- package/lib/delegation.d.ts +168 -0
- package/lib/enforcement.d.ts +8 -0
- package/lib/goals-projection.d.ts +92 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +915 -68
- package/lib/live-route.d.ts +1 -1
- package/lib/recursive_audit_team.tool.d.ts +2 -0
- package/lib/runtime.d.ts +112 -3
- package/lib/teams-loop.d.ts +160 -0
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +45 -19
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +1 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/styles.ts +310 -0
- package/src/delegation.ts +337 -1
- package/src/enforcement.ts +16 -1
- package/src/goals-projection.ts +149 -0
- package/src/index.ts +41 -11
- package/src/live-route.ts +69 -2
- package/src/recursive_audit_team.tool.ts +141 -0
- package/src/runtime.ts +134 -6
- package/src/teams-loop.ts +259 -0
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
|
|
142
|
-
*
|
|
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
|
+
}
|