@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.
package/src/delegation.ts CHANGED
@@ -11,11 +11,53 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
11
  import { join, resolve, sep } from 'node:path'
12
12
  import { contentSha256 } from './review.ts'
13
13
 
14
+ /**
15
+ * Opaque handle to the live direct-parent Agent. The live continuable service
16
+ * authorizes by EXACT live object identity — `ctx.agents.get(parent.id) ===
17
+ * parent` (authorizeLineage), `ancestry.has(parent)` (interrupt/drain), and a
18
+ * `WeakSet` of live ancestry — so this must be the real live `Agent`, never a
19
+ * structural `{ id }` copy. The seam only ever reads `id`/`session.header.cwd`
20
+ * for attribution, and never serializes or inspects the live object.
21
+ */
22
+ export interface SubagentParentHandle {
23
+ readonly id?: string
24
+ readonly session?: { readonly header?: { readonly cwd?: string } }
25
+ }
26
+
27
+ /** Durable identity of one continuable child session (string-branded in the host). */
28
+ export type ContinuableChildId = string
29
+
30
+ /** Durable identity of one accepted inbox message (string-branded in the host). */
31
+ export type ContinuableMessageId = string
32
+
33
+ /**
34
+ * Attribution for a model coordinator's follow-up to one of its children (the
35
+ * live `CoordinatorMessageSource` subset — see subagent/src/continuation.ts).
36
+ */
37
+ export interface CoordinatorSourceLike {
38
+ readonly kind: 'coordinator'
39
+ readonly form: 'relay'
40
+ readonly senderSessionId: string
41
+ }
42
+
43
+ /** Uniform outcome for the interrupt/drain kill-switch helpers. */
44
+ export interface ContinuableOpResult {
45
+ ok: boolean
46
+ reason?: string
47
+ }
48
+
14
49
  /** Minimal host-realm contract for ctx.subagents (the seam we call). */
15
50
  export interface SubagentsRuntimeLike {
16
51
  start(name: string, request: SubagentStartRequestLike): Promise<SubagentResultLike>
17
52
  getProvider?(name: string): unknown
18
53
  list?(): unknown
54
+ /** T4: continuable child lifecycle (startContinuable / followup / interrupt / drain). */
55
+ startContinuable?(spec: ContinuableStartSpecLike): Promise<ContinuableStartLike>
56
+ /** The parent MUST be the exact live Agent (object-identity authority), never a `{ id }` copy. */
57
+ followup?(parent: SubagentParentHandle, childId: ContinuableChildId, content: readonly { type: 'text'; text: string }[], options: SubagentFollowupOptionsLike): Promise<ContinuableMessageId>
58
+ interrupt?(targetSessionId: ContinuableChildId, authority: SubagentInterruptAuthorityLike): void
59
+ drainContinuableChildren?(parent: SubagentParentHandle, childIds: readonly ContinuableChildId[]): Promise<void>
60
+ drainContinuableDescendants?(parents: readonly SubagentParentHandle[]): Promise<void>
19
61
  }
20
62
 
21
63
  export interface SubagentStartRequestLike {
@@ -116,11 +158,305 @@ export async function delegate(input: {
116
158
  }
117
159
  }
118
160
 
161
+ /**
162
+ * T4: continuable-child delegation — ONE durable child receives the initial
163
+ * prompt (startContinuable), each REVISE is delivered as a followup to the SAME
164
+ * child (FIFO, working set retained), and the parent observes each round's
165
+ * settlement through the injected `awaitRoundResult` seam (in live usage the
166
+ * child's settlement lands in the parent's inbox — `reportFrom` is the
167
+ * CHILD-side API, so the parent-side loop collects via settlement, not by
168
+ * calling it). A hung reviewer is cancelled with `interruptContinuable`
169
+ * (keepInbox: the child's pending inbox survives). Falls back to one-shot
170
+ * `delegate` when the seam has no continuable methods.
171
+ */
172
+
173
+ /** What the caller asks for when starting a continuable background child (structural subset). */
174
+ export interface ContinuableStartSpecLike {
175
+ /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
176
+ readonly provider: string
177
+ /** The initial delegation's short `description`, persisted as the child's creation label. */
178
+ readonly label: string
179
+ /** Optional caller-reserved child identity. */
180
+ childId?: ContinuableChildId
181
+ /** The delegation request (prompt + parent + toolFilter + maxDepth; no label/signal/outputSchema). */
182
+ readonly request: Omit<SubagentStartRequestLike, 'label' | 'signal' | 'outputSchema'>
183
+ /** Caller cancellation, owning the operation only until inbox acceptance. */
184
+ readonly signal?: AbortSignalLike
185
+ }
186
+
187
+ /** Minimal cancellation shape (a live AbortSignal satisfies it). */
188
+ export interface AbortSignalLike {
189
+ readonly throwIfAborted: () => void
190
+ }
191
+
192
+ /** Identities returned once a continuable child accepted its initial prompt. */
193
+ export interface ContinuableStartLike {
194
+ /** The durable child session id, stable across activations. */
195
+ readonly childId: ContinuableChildId
196
+ /** The accepted initial prompt's inbox message id. */
197
+ readonly messageId: ContinuableMessageId
198
+ }
199
+
200
+ /** Options for following up with one continuable child (structural subset). */
201
+ export interface SubagentFollowupOptionsLike {
202
+ /** Durable attribution retained on the delivered message. */
203
+ readonly source: CoordinatorSourceLike
204
+ /** Caller cancellation, owning the operation only until inbox acceptance. */
205
+ readonly signal?: AbortSignalLike
206
+ }
207
+
208
+ /** Authority under which one interrupt request is admitted. */
209
+ export type SubagentInterruptAuthorityLike =
210
+ | { readonly kind: 'user'; readonly parentSessionId: string }
211
+ | { readonly kind: 'ancestor'; readonly agent: SubagentParentHandle }
212
+
213
+ /** One round of a continuable child: the delivered text plus the observed outcome. */
214
+ export interface ContinuableRoundLike {
215
+ /** The message text delivered as this round's user prompt. */
216
+ text: string
217
+ /** The child's observed outcome for this round. */
218
+ result?: SubagentResultLike
219
+ /** True when this round's verdict was REVISE (a repair followup followed). */
220
+ revise?: boolean
221
+ /** The repair instruction delivered in the followup (only when revise). */
222
+ repair?: string
223
+ }
224
+
225
+ /** The full T4 delegation outcome. */
226
+ export interface ContinuableDelegationLike {
227
+ ok: boolean
228
+ reason?: string
229
+ /** The durable child session id (stable across rounds). */
230
+ childId?: ContinuableChildId
231
+ /** Inbox message ids: [initial acceptance, ...followups]. */
232
+ messageIds?: ContinuableMessageId[]
233
+ rounds: ContinuableRoundLike[]
234
+ /** Final outcome accepted (last verdict APPROVE + result accepted). */
235
+ accepted: boolean
236
+ /** True when the fallback one-shot `delegate()` was used (no continuable seam). */
237
+ fellBackToOneShot?: boolean
238
+ }
239
+
240
+ /** Verdict vocabulary shared by T3/T4 (matches the delegated review schema). */
241
+ export type DelegationVerdict = 'APPROVE' | 'REVISE' | 'REJECT'
242
+
243
+ /** Read the verdict from a review-schema structured result (pure). */
244
+ export function readVerdictFromStructured(result: SubagentResultLike): DelegationVerdict {
245
+ // SAFETY: reviewOutputSchema() defines verdict as a string enum; the cast reads
246
+ // one leaf field only, never mutates, and falls back on a non-matching value.
247
+ const verdict = (result.structured as { verdict?: unknown } | undefined)?.verdict
248
+ if (verdict === 'APPROVE' || verdict === 'REVISE' || verdict === 'REJECT') return verdict
249
+ // No structured verdict: a completed run with text output is a provisional APPROVE
250
+ // candidate, but delegation acceptance stays strict (caller evaluates).
251
+ return 'APPROVE'
252
+ }
253
+
254
+ /** Read the repair instruction from a review-schema structured result (pure). */
255
+ export function readRepairFromStructured(result: SubagentResultLike): string {
256
+ // SAFETY: reviewOutputSchema() defines findings as an array of {severity,title,
257
+ // detail}; the cast reads leaf fields only (no live data, no mutation). The
258
+ // repair instruction is ALWAYS synthesized from the findings — a child cannot
259
+ // inject arbitrary instruction text (prompt-injection hygiene).
260
+ const findings = (result.structured as { findings?: Array<{ title?: string }> } | undefined)?.findings
261
+ const titles = Array.isArray(findings) ? findings.map(f => f.title ?? '').filter(Boolean) : []
262
+ if (titles.length > 0) return 'Address the findings: ' + titles.join('; ')
263
+ return 'REVISE: address the review findings and re-submit.'
264
+ }
265
+
266
+ /**
267
+ * Run a multi-round delegated task on ONE durable continuable child:
268
+ * 1. `startContinuable` (initial prompt) — `start()` is never called.
269
+ * 2. `awaitRoundResult` observes the child's settlement for that round.
270
+ * 3. On REVISE: `followup` delivers the repair instruction to the SAME child.
271
+ * 4. On APPROVE/REJECT: finish (accepted only when the verdict is APPROVE and
272
+ * the result evaluates as accepted).
273
+ *
274
+ * `awaitRoundResult(childId, messageId)` is the ONLY parent-side observation
275
+ * seam: in live usage it waits for the child's settlement notice (the child's
276
+ * `reportFrom` lands in the parent's inbox); in tests it is a fake queue.
277
+ */
278
+ export async function delegateContinuable(input: {
279
+ subagents: SubagentsRuntimeLike
280
+ provider: string
281
+ label: string
282
+ prompt: string
283
+ parent?: SubagentParentHandle
284
+ toolFilter?: unknown
285
+ maxDepth?: number
286
+ childId?: ContinuableChildId
287
+ maxRounds?: number
288
+ readVerdict?: (result: SubagentResultLike) => DelegationVerdict
289
+ readRepair?: (result: SubagentResultLike) => string | undefined
290
+ awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
291
+ }): Promise<ContinuableDelegationLike> {
292
+ const { subagents, provider, label, prompt, parent, toolFilter, maxDepth } = input
293
+ const maxRounds = input.maxRounds ?? 3
294
+ const readVerdict = input.readVerdict ?? readVerdictFromStructured
295
+ const readRepair = input.readRepair ?? readRepairFromStructured
296
+
297
+ const startContinuable = subagents?.startContinuable
298
+ const followup = subagents?.followup
299
+ // A continuable loop MUST observe the child's real settlement AND hold the
300
+ // exact live parent Agent (the live service authorizes followup by object
301
+ // identity). When either is missing, fall back to one-shot (which returns the
302
+ // actual result) rather than fabricating authority and silently APPROVE-ing.
303
+ const hasContinuableSeam = startContinuable !== undefined && followup !== undefined && input.awaitRoundResult !== undefined
304
+ if (!hasContinuableSeam || subagents === undefined || parent === undefined) {
305
+ // Fall back to one-shot delegation (self-audit-safe): never silently drop.
306
+ try {
307
+ const oneShot = await delegate({
308
+ subagents,
309
+ provider,
310
+ request: {
311
+ prompt: [{ type: 'text', text: prompt }],
312
+ label,
313
+ toolFilter,
314
+ maxDepth,
315
+ parent,
316
+ },
317
+ })
318
+ const verdict = readVerdict(oneShot)
319
+ return {
320
+ ok: true,
321
+ rounds: [{ text: prompt, result: oneShot }],
322
+ accepted: verdict === 'APPROVE' && evaluateDelegationResult(oneShot).accepted,
323
+ fellBackToOneShot: true,
324
+ }
325
+ } catch (err) {
326
+ return { ok: false, reason: err instanceof Error ? err.message : String(err), rounds: [], accepted: false, fellBackToOneShot: true }
327
+ }
328
+ }
329
+
330
+ const messageIds: ContinuableMessageId[] = []
331
+ const rounds: ContinuableRoundLike[] = []
332
+ let childId: ContinuableChildId | undefined
333
+
334
+ const request: ContinuableStartSpecLike['request'] = {
335
+ prompt: [{ type: 'text', text: prompt }],
336
+ parent,
337
+ }
338
+ if (toolFilter !== undefined) request.toolFilter = toolFilter
339
+ if (maxDepth !== undefined) request.maxDepth = maxDepth
340
+
341
+ const spec: ContinuableStartSpecLike = {
342
+ provider,
343
+ label,
344
+ request,
345
+ }
346
+ if (input.childId !== undefined) spec.childId = input.childId
347
+ try {
348
+ const started = await startContinuable(spec)
349
+ childId = started.childId
350
+ messageIds.push(started.messageId)
351
+ rounds.push({ text: prompt })
352
+
353
+ for (let round = 0; round < maxRounds; round += 1) {
354
+ const current = rounds[round]
355
+ const observed = await input.awaitRoundResult!(childId, messageIds[messageIds.length - 1])
356
+ if (observed === null) {
357
+ return { ok: false, reason: 'continuable child produced no settlement for round ' + (round + 1), childId, messageIds, rounds, accepted: false }
358
+ }
359
+ current.result = observed
360
+ const verdict = readVerdict(observed)
361
+ if (verdict !== 'REVISE') {
362
+ const accepted = verdict === 'APPROVE' && evaluateDelegationResult(observed).accepted
363
+ return {
364
+ ok: accepted,
365
+ reason: accepted ? 'delegation completed' : 'delegation stopped with verdict ' + verdict,
366
+ childId,
367
+ messageIds,
368
+ rounds,
369
+ accepted,
370
+ }
371
+ }
372
+ // REVISE: send the repair instruction to the SAME child (FIFO, context retained).
373
+ const repair = readRepair(observed)
374
+ if (!repair) {
375
+ return { ok: false, reason: 'REVISE verdict without a repair instruction', childId, messageIds, rounds, accepted: false }
376
+ }
377
+ const followupId = await followup(
378
+ parent,
379
+ childId,
380
+ [{ type: 'text', text: repair }],
381
+ { source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id ?? '' } },
382
+ )
383
+ messageIds.push(followupId)
384
+ current.revise = true
385
+ current.repair = repair
386
+ rounds.push({ text: repair })
387
+ }
388
+ return { ok: false, reason: 'max rounds reached without an APPROVE', childId, messageIds, rounds, accepted: false }
389
+ } catch (err) {
390
+ const message = err instanceof Error ? err.message : String(err)
391
+ // Failure preserves the child (a later followup may still resume it); the
392
+ // kill switch is explicit (interruptContinuable), never implicit.
393
+ return { ok: false, reason: message, childId, messageIds, rounds, accepted: false }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * T4 kill switch: interrupt one live continuable child's current turn. Admission
399
+ * is synchronous, the effect asynchronous, and the child's pending inbox is
400
+ * preserved (keepInbox semantics) — a followup later resumes the parked queue.
401
+ */
402
+ export function interruptContinuable(
403
+ subagents: SubagentsRuntimeLike,
404
+ childId: ContinuableChildId,
405
+ parentSessionId: string,
406
+ ): ContinuableOpResult {
407
+ const interrupt = subagents?.interrupt
408
+ if (interrupt === undefined) {
409
+ return { ok: false, reason: 'no continuable interrupt seam' }
410
+ }
411
+ try {
412
+ interrupt(childId, { kind: 'user', parentSessionId })
413
+ return { ok: true }
414
+ } catch (err) {
415
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * T4 closeout: release one continuable child (host drains its Activation and
421
+ * disposes its handle). No-op when the seam lacks the method (one-shot hosts).
422
+ */
423
+ export async function drainContinuableChildren(
424
+ subagents: SubagentsRuntimeLike,
425
+ parent: SubagentParentHandle,
426
+ childIds: readonly ContinuableChildId[],
427
+ ): Promise<ContinuableOpResult> {
428
+ if (subagents?.drainContinuableChildren === undefined || childIds.length === 0) return { ok: true }
429
+ try {
430
+ await subagents.drainContinuableChildren(parent, childIds)
431
+ return { ok: true }
432
+ } catch (err) {
433
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
434
+ }
435
+ }
436
+
437
+ /**
438
+ * T4 closeout (host teardown path): release every continuable descendant below
439
+ * the given live parents (mirrors the live `drainContinuableDescendants`).
440
+ * No-op when the seam lacks the method; the host owns this at session teardown.
441
+ */
442
+ export async function drainContinuableDescendants(
443
+ subagents: SubagentsRuntimeLike | null,
444
+ parents: readonly SubagentParentHandle[],
445
+ ): Promise<ContinuableOpResult> {
446
+ const drain = subagents?.drainContinuableDescendants
447
+ if (drain === undefined || parents.length === 0) return { ok: true }
448
+ try {
449
+ await drain(parents)
450
+ return { ok: true }
451
+ } catch (err) {
452
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
453
+ }
454
+ }
455
+
119
456
  export interface Reference {
120
457
  path: string
121
458
  lineRange?: string
122
459
  }
123
-
124
460
  export interface ReferenceCheck {
125
461
  ok: boolean
126
462
  failures: string[]
@@ -46,7 +46,7 @@ const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
46
46
  * Pure predicate: inspects the pending tool execution (name + args) against
47
47
  * the run tree under the given worktree root.
48
48
  */
49
- export type ToolGuardDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
49
+ export type ToolGuardDecision = { kind: 'allow'; warn?: string } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
50
50
 
51
51
  export interface ToolExecLike {
52
52
  name: string
@@ -108,6 +108,21 @@ export function evaluateToolGuard(
108
108
  return { kind: 'allow' }
109
109
  }
110
110
 
111
+ /**
112
+ * T6 (approval ask→policy bridge): an `ask` decision must never be a silent
113
+ * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
114
+ * but flags a `warn` so the caller never lets it through unlogged. Non-ask
115
+ * decisions pass through unchanged.
116
+ */
117
+ export function coerceAskToDecision(decision: ToolGuardDecision, mode: EnforcementMode = 'advisory'): ToolGuardDecision {
118
+ if (decision.kind !== 'ask') return decision
119
+ if (mode === 'strict') {
120
+ return { kind: 'deny', reason: decision.reason ?? 'ask under strict enforcement denies' }
121
+ }
122
+ // advisory: allow, but carry the warning so the caller logs (never silent).
123
+ return { kind: 'allow', warn: decision.reason ?? 'ask under advisory enforcement allows' }
124
+ }
125
+
111
126
  /** Resolve a tool-target path to an absolute path under the worktree root. */
112
127
  function resolveTargetPath(target: string, worktreeRoot: string): string | null {
113
128
  const normalized = target.replace(/\\/g, '/')
@@ -0,0 +1,149 @@
1
+ /**
2
+ * goals-projection.ts (T1, STRENGTHENING-PLAN): project a recursive run into the
3
+ * native `goals` service so the run is a first-class durable, resumable,
4
+ * blockable object — and a gate block is durable + UI-visible rather than a
5
+ * one-line advisory.
6
+ *
7
+ * Pure/structural: this module takes a `GoalServiceLike` seam (the real
8
+ * `ctx.goals` satisfies it structurally) and an opaque `AgentHandle`, and never
9
+ * imports the host `@deepseek-ai/dsh-goal` package. The real service's methods
10
+ * take a live `Agent` and throw if the agent is not the registry's live
11
+ * instance, so callers pass the live agent from a tool/pre-step `exec`.
12
+ *
13
+ * Safety rule: the projection NEVER clobbers a foreign goal. A goal whose
14
+ * objective is not a `recursive-run:<id>` marker is left untouched (only a
15
+ * completed goal may be replaced, per the service contract).
16
+ */
17
+ import type { RunState } from './lifecycle.ts'
18
+
19
+ /** Native goal phase (mirrors @deepseek-ai/dsh-goal GoalPhase). */
20
+ export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'
21
+
22
+ /** CSA identity for one exact goal revision. */
23
+ export interface GoalRefLike {
24
+ id: string
25
+ revision: number
26
+ }
27
+
28
+ /** Input resolved by the service when the round cap is omitted. */
29
+ export interface CreateGoalRequestLike {
30
+ objective: string
31
+ maxGoalRounds?: number
32
+ }
33
+
34
+ /** The subset of the goal view the projection reads. */
35
+ export interface GoalViewLike extends GoalRefLike {
36
+ objective?: string
37
+ phase?: GoalPhase
38
+ }
39
+
40
+ /** Opaque handle to the live DSH Agent; the projection never inspects it further. */
41
+ export interface AgentHandle {
42
+ session?: { header?: { cwd?: string } }
43
+ }
44
+
45
+ /** Structural seam for the live `goals` service (real methods return GoalView). */
46
+ export interface GoalServiceLike {
47
+ get(agent: AgentHandle): GoalViewLike | undefined
48
+ create(agent: AgentHandle, req: CreateGoalRequestLike): GoalViewLike
49
+ block(agent: AgentHandle, ref: GoalRefLike, reason: { code: string; message: string }): GoalViewLike
50
+ pause(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
51
+ resume(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
52
+ complete(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
53
+ clear(agent: AgentHandle, ref: GoalRefLike): GoalRefLike
54
+ }
55
+
56
+ /** Outcome of a run→goal sync. */
57
+ export type SyncResult =
58
+ | { ok: true; phase: GoalPhase; ref?: GoalRefLike; created?: boolean }
59
+ | { ok: false; reason: string }
60
+
61
+ /** Marker embedded in the goal objective so a goal can be matched to its run. */
62
+ export function runGoalTag(runId: string): string {
63
+ return 'recursive-run:' + runId
64
+ }
65
+
66
+ /** The durable objective string for a run goal. */
67
+ export function goalObjective(runId: string, runState: RunState = 'active'): string {
68
+ return runGoalTag(runId) + ' · ' + runState
69
+ }
70
+
71
+ /** Map a run state onto the native goal phase it should project to. */
72
+ export const RUN_TO_GOAL_PHASE = {
73
+ new: 'active', active: 'active', paused: 'paused', blocked: 'blocked', complete: 'complete',
74
+ } as const satisfies Record<RunState, GoalPhase>
75
+
76
+ /** Is this goal's objective the marker for `runId`? */
77
+ export function isRunGoal(goal: GoalViewLike | undefined, runId: string): boolean {
78
+ return goal?.objective?.startsWith(runGoalTag(runId)) === true
79
+ }
80
+
81
+ /** Read a live ref (id + revision) for a goal. */
82
+ function refOf(goal: GoalViewLike): GoalRefLike {
83
+ return { id: goal.id, revision: goal.revision }
84
+ }
85
+
86
+ /** Commit a phase mutation; the real service returns a truthy GoalView on success. */
87
+ function mutatePhase(service: GoalServiceLike, agent: AgentHandle, ref: GoalRefLike, target: GoalPhase): boolean {
88
+ switch (target) {
89
+ case 'blocked': return !!service.block(agent, ref, { code: 'run-gate-block', message: 'recursive run gate block' })
90
+ case 'paused': return !!service.pause(agent, ref)
91
+ case 'complete': return !!service.complete(agent, ref)
92
+ case 'active': return !!service.resume(agent, ref)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Sync a run's durable goal to the requested phase. Safe: never touches a goal
98
+ * whose objective is not this run's marker, and never re-creates over a
99
+ * non-complete foreign goal.
100
+ */
101
+ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, runState: RunState): SyncResult {
102
+ if (!service) return { ok: false, reason: 'no goals service' }
103
+ const target = RUN_TO_GOAL_PHASE[runState]
104
+ const current = service.get(agent)
105
+
106
+ // 1. Existing goal for this run -> mutate to the requested phase (no-op at target).
107
+ if (current && isRunGoal(current, runId)) {
108
+ const phase = current.phase ?? 'active'
109
+ const ref = refOf(current)
110
+ if (phase === target) return { ok: true, phase: target, ref }
111
+ // A completed goal is final: the contract allows it to be REPLACED, not resumed.
112
+ if (phase === 'complete') {
113
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
114
+ return { ok: true, phase: target, ref: refOf(created), created: true }
115
+ }
116
+ const ok = mutatePhase(service, agent, ref, target)
117
+ return ok ? { ok: true, phase: target, ref } : { ok: false, reason: 'goal mutation failed' }
118
+ }
119
+
120
+ // 2. A completed goal may be replaced; every other current phase must be
121
+ // cleared or resumed instead. Never clobber a foreign goal.
122
+ if (current) {
123
+ if (current.phase === 'complete') {
124
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
125
+ return { ok: true, phase: target, ref: refOf(created), created: true }
126
+ }
127
+ return { ok: false, reason: 'a non-matching active goal exists (foreign goal not touched)' }
128
+ }
129
+
130
+ // 3. No current goal -> create and arm.
131
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
132
+ return { ok: true, phase: target, ref: refOf(created), created: true }
133
+ }
134
+
135
+ /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
136
+ export function blockRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, reason: { code: string; message: string }): SyncResult {
137
+ if (!service) return { ok: false, reason: 'no goals service' }
138
+ const current = service.get(agent)
139
+ if (!current) return { ok: false, reason: 'no current goal to block' }
140
+ if (!isRunGoal(current, runId)) return { ok: false, reason: 'current goal is not for this run (foreign goal not touched)' }
141
+ const ref = refOf(current)
142
+ const ok = !!service.block(agent, ref, reason)
143
+ return ok ? { ok: true, phase: 'blocked', ref } : { ok: false, reason: 'goal block failed' }
144
+ }
145
+
146
+ /** Bridge a run's blocked goal back to active (used on a reopen). */
147
+ export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string): SyncResult {
148
+ return syncRunGoal(service, agent, runId, 'active')
149
+ }
package/src/index.ts CHANGED
@@ -9,10 +9,13 @@ import { createRecursiveLockTool } from './recursive_lock.tool.ts'
9
9
  import { createRecursiveLintTool } from './recursive_lint.tool.ts'
10
10
  import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
11
11
  import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
12
- import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
12
+ import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
13
13
  import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
14
+ import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
14
15
  import { registerRecursiveCommand } from './commands.ts'
15
- import { evaluateToolGuard } from './enforcement.ts'
16
+ import { evaluateToolGuard, coerceAskToDecision } from './enforcement.ts'
17
+ import type { GoalServiceLike } from './goals-projection.ts'
18
+ import type { TeamRuntimeLike } from './teams-loop.ts'
16
19
  import { renderRecursivePolicy } from './policy.ts'
17
20
  import { fsPolicyIntent } from './fs-intent.ts'
18
21
  import { snapshotWorkspace } from './snapshot.ts'
@@ -30,7 +33,7 @@ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
30
33
  export { createRecursiveLintTool } from './recursive_lint.tool.ts'
31
34
  export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
32
35
  export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
33
- export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
36
+ export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
34
37
  export { createRecursivePhaseTool } from './recursive_phase.tool.ts'
35
38
  export * from './status.ts'
36
39
  export {
@@ -70,6 +73,7 @@ export * from './enforcement.ts'
70
73
  export * from './policy.ts'
71
74
  export * from './snapshot.ts'
72
75
  export * from './live-route.ts'
76
+ export * from './teams-loop.ts'
73
77
 
74
78
  /**
75
79
  * Bundle plugin entry. The Loader activates this row once `tools` is available
@@ -101,10 +105,26 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
101
105
  // property access requires inject and would fail boot when undeclared.
102
106
  // Resolve the control-plane root strictly from the session agent's cwd.
103
107
  const workspaceRegistry = ctx.get('workspaceRegistry') as never
104
- const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry })
108
+ // T1 (goals projection): the goals service is on the host plane; it resolves
109
+ // from inside the recursive-realm via inheritance (same as workspaceRegistry).
110
+ // SAFETY: the goals service is an optional host service (could be absent); the
111
+ // run projection treats null as "no goal backing" and never throws.
112
+ const goals = ctx.get('goals') as GoalServiceLike | null
113
+ const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals })
105
114
 
106
- const repairedRoots = new Set<string>()
115
+ const repairedRoots = new Set<string>()
107
116
  const reminderGate = new ReminderOnceGate()
117
+ // T3 (agentTeams task loop): wire the live ctx.agentTeams service (optional —
118
+ // absent in compositions without the experimental agent-team row) into the
119
+ // turn-driven task-board tool. The whole-loop driver (auditToPass) is also
120
+ // exported for callers with a settlement observer.
121
+ // SAFETY: ctx.get returns the live service as an opaque value; the single
122
+ // boundary cast asserts it satisfies the TeamRuntimeLike structural seam
123
+ // (createTask/updateTask plus optional wait/interrupt/board reads). The
124
+ // live service's real Agent parameter is a superset of TeamCallerHandle, so
125
+ // the seam passes the exact live Agent the tool extracts from exec.agent.
126
+ const agentTeams = ctx.get('agentTeams') as TeamRuntimeLike | undefined
127
+
108
128
  const disposers = [
109
129
  ctx.tools.register(createRecursiveStatusTool(recursive)),
110
130
  ctx.tools.register(createRecursiveInitTool(recursive)),
@@ -112,8 +132,9 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
112
132
  ctx.tools.register(createRecursiveLintTool(recursive)),
113
133
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
114
134
  ctx.tools.register(createRecursiveScratchTool(recursive)),
115
- ctx.tools.register(createRecursiveWorktreeTool(recursive)),
135
+ ctx.tools.register(createRecursiveWorktreeTool(recursive)),
116
136
  ctx.tools.register(createRecursivePhaseTool(recursive)),
137
+ ...(agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []),
117
138
  ]
118
139
 
119
140
  // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
@@ -167,7 +188,16 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
167
188
  const decision = evaluateToolGuard(exec as never, root, '', recursive.enforcementConfig.toolGuards)
168
189
  if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
169
190
  if (decision.kind === 'deny') return decision
170
- // ask -> in advisory we let it through with a warn (the approval seam is Phase D)
191
+ // T6 (approval ask→policy bridge): an `ask` must never be a silent
192
+ // allow. Strict coerces to deny; advisory allows but carries a warn that
193
+ // the caller logs below. The approval seam is the follow-on (Phase D).
194
+ const coerced = coerceAskToDecision(decision, recursive.enforcementConfig.toolGuards)
195
+ if (coerced.kind === 'deny') return coerced
196
+ if (coerced.kind === 'allow' && coerced.warn) {
197
+ // Package-tagged host logging; never a silent pass under approval=never.
198
+ console.warn('[recursive] tool guard (advisory): ' + coerced.warn + ' — allowing')
199
+ }
200
+ return typeof next === 'function' ? next() : { kind: 'allow' }
171
201
  }))
172
202
  }
173
203
 
@@ -212,10 +242,10 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
212
242
  const phasePath = join(runDir, phase)
213
243
  const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
214
244
  if (status !== 'DRAFT') return { kind: 'enter', messages } as const
215
- if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
216
- // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
217
- // The scaffold repair above is deduped via repairedRoots; the reminder itself was
218
- // not, so every pre-step while DRAFT re-injected it.
245
+ if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
246
+ // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
247
+ // The scaffold repair above is deduped via repairedRoots; the reminder itself was
248
+ // not, so every pre-step while DRAFT re-injected it.
219
249
  const reminder = phaseLintRulesMessage(phase)
220
250
  return {
221
251
  kind: 'enter',