@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/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 +773 -9
- 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/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/recursive_audit_team.tool.ts +141 -0
- package/src/runtime.ts +134 -6
- package/src/teams-loop.ts +259 -0
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[]
|
package/src/enforcement.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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',
|