@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/delegation.ts
CHANGED
|
@@ -69,6 +69,15 @@ export interface SubagentStartRequestLike {
|
|
|
69
69
|
persona?: string
|
|
70
70
|
parent?: unknown
|
|
71
71
|
signal?: unknown
|
|
72
|
+
/**
|
|
73
|
+
* T9 — the child's provider/model overrides.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ `SubagentStartRequest.agentOptions` is only valid for a provider that DECLARES
|
|
76
|
+
* `capabilities.agentOptions`; the harness REJECTS a start that sends it otherwise. Passing
|
|
77
|
+
* it unconditionally would therefore BREAK delegations on providers that do not support it,
|
|
78
|
+
* which is why the caller gates on the capability rather than on the model being non-null.
|
|
79
|
+
*/
|
|
80
|
+
agentOptions?: { model?: string; provider?: string }
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
export interface SubagentResultLike {
|
|
@@ -235,11 +244,42 @@ export interface ContinuableDelegationLike {
|
|
|
235
244
|
accepted: boolean
|
|
236
245
|
/** True when the fallback one-shot `delegate()` was used (no continuable seam). */
|
|
237
246
|
fellBackToOneShot?: boolean
|
|
247
|
+
/**
|
|
248
|
+
* True when the round ended because NO settlement has landed yet — the caller's
|
|
249
|
+
* signal to resume on a later turn with the SAME `childId`, not a failure. The
|
|
250
|
+
* harness offers no parent-side await-settlement promise, so this is the honest
|
|
251
|
+
* report of "the child is still working".
|
|
252
|
+
*/
|
|
253
|
+
parked?: boolean
|
|
238
254
|
}
|
|
239
255
|
|
|
240
256
|
/** Verdict vocabulary shared by T3/T4 (matches the delegated review schema). */
|
|
241
257
|
export type DelegationVerdict = 'APPROVE' | 'REVISE' | 'REJECT'
|
|
242
258
|
|
|
259
|
+
/**
|
|
260
|
+
* T28 — how much delegation depth is left for a child of a parent at `parentDepth`.
|
|
261
|
+
*
|
|
262
|
+
* The configured maximum is a ceiling for the WHOLE recursion, not a fresh allowance
|
|
263
|
+
* at every level. Passing the configured maximum down unchanged at each level is how
|
|
264
|
+
* a "depth 3" budget silently permits 3^depth children, which bounds nothing.
|
|
265
|
+
*
|
|
266
|
+
* A caller may ask for LESS than what remains (`requested`) and never for more:
|
|
267
|
+
* `min(remaining, requested)`. A parent already at or past the cap yields **0** —
|
|
268
|
+
* "delegate no further" — never a negative that some downstream comparison could read
|
|
269
|
+
* as permission.
|
|
270
|
+
*/
|
|
271
|
+
export function remainingDepthFor(
|
|
272
|
+
budgets: { maxDelegationDepth: number },
|
|
273
|
+
parentDepth: number,
|
|
274
|
+
requested?: number,
|
|
275
|
+
): number {
|
|
276
|
+
const depth = Number.isSafeInteger(parentDepth) && parentDepth > 0 ? parentDepth : 0
|
|
277
|
+
const remaining = Math.max(0, budgets.maxDelegationDepth - depth)
|
|
278
|
+
if (requested === undefined) return remaining
|
|
279
|
+
const want = Number.isSafeInteger(requested) && requested > 0 ? requested : 0
|
|
280
|
+
return Math.max(0, Math.min(remaining, want))
|
|
281
|
+
}
|
|
282
|
+
|
|
243
283
|
/** Read the verdict from a review-schema structured result (pure). */
|
|
244
284
|
export function readVerdictFromStructured(result: SubagentResultLike): DelegationVerdict {
|
|
245
285
|
// SAFETY: reviewOutputSchema() defines verdict as a string enum; the cast reads
|
|
@@ -263,6 +303,123 @@ export function readRepairFromStructured(result: SubagentResultLike): string {
|
|
|
263
303
|
return 'REVISE: address the review findings and re-submit.'
|
|
264
304
|
}
|
|
265
305
|
|
|
306
|
+
/** What a delegated child's `reply.md` said, as far as the plugin can tell. */
|
|
307
|
+
export interface ReplyVerdict {
|
|
308
|
+
/** The verdict the reply STATES, or null when it states none. */
|
|
309
|
+
verdict: DelegationVerdict | null
|
|
310
|
+
/** Finding titles, when the reply carried review-schema JSON. */
|
|
311
|
+
findings: string[]
|
|
312
|
+
/** Why no verdict was read, for the repair instruction. Null when one was. */
|
|
313
|
+
problem: string | null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read a verdict out of a child's `reply.md`, FAILING CLOSED.
|
|
318
|
+
*
|
|
319
|
+
* WHY THIS EXISTS RATHER THAN `readVerdictFromStructured`. The settlement's closing
|
|
320
|
+
* text is free-form — a child may report prose, a fenced JSON block, or a bare
|
|
321
|
+
* field line — and the structured reader's fallback for "no verdict" is
|
|
322
|
+
* `APPROVE`. That default is defensible where the caller re-evaluates the result,
|
|
323
|
+
* but it is the wrong default for a REVIEW ROUND: a child that answered with prose,
|
|
324
|
+
* or answered the wrong question, or wrote nothing parseable, must never be read as
|
|
325
|
+
* having approved the work. Verification that fails open is not verification.
|
|
326
|
+
*
|
|
327
|
+
* So this reader accepts exactly three things — review-schema JSON (fenced or
|
|
328
|
+
* bare), or an explicit `Verdict:` field — and reports `verdict: null` plus a
|
|
329
|
+
* `problem` for anything else. The caller turns that into a REVISE with a repair
|
|
330
|
+
* instruction that says what was wrong, so an unreadable reply costs a round rather
|
|
331
|
+
* than a false approval.
|
|
332
|
+
*/
|
|
333
|
+
export function parseReplyVerdict(replyText: string): ReplyVerdict {
|
|
334
|
+
const text = replyText ?? ''
|
|
335
|
+
if (text.trim() === '') {
|
|
336
|
+
return { verdict: null, findings: [], problem: 'the reply is empty' }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const candidates: string[] = []
|
|
340
|
+
// A fenced block first: a child that wraps its JSON is being explicit about it.
|
|
341
|
+
for (const match of text.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)) {
|
|
342
|
+
if (match[1] !== undefined) candidates.push(match[1])
|
|
343
|
+
}
|
|
344
|
+
// Then a bare object, so an unfenced submission still reads.
|
|
345
|
+
const bare = text.match(/\{[\s\S]*\}/)
|
|
346
|
+
if (bare?.[0] !== undefined) candidates.push(bare[0])
|
|
347
|
+
|
|
348
|
+
for (const candidate of candidates) {
|
|
349
|
+
let parsed: unknown
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(candidate)
|
|
352
|
+
} catch {
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
const record = typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null
|
|
356
|
+
const raw = record?.verdict
|
|
357
|
+
const verdict = normaliseVerdict(raw)
|
|
358
|
+
if (verdict !== null) {
|
|
359
|
+
const findings = Array.isArray(record?.findings)
|
|
360
|
+
? record.findings
|
|
361
|
+
.map((f) => (typeof f === 'object' && f !== null ? (f as { title?: unknown }).title : undefined))
|
|
362
|
+
.filter((t): t is string => typeof t === 'string' && t !== '')
|
|
363
|
+
: []
|
|
364
|
+
return { verdict, findings, problem: null }
|
|
365
|
+
}
|
|
366
|
+
return { verdict: null, findings: [], problem: 'the reply carries JSON but no usable "verdict" field' }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A plain field line, which is how a child that ignores the schema tends to answer.
|
|
370
|
+
// The decoration class around the value allows the asterisks, backticks and
|
|
371
|
+
// underscores a child may wrap it in — including a backtick INSIDE bold, as in
|
|
372
|
+
// `**Verdict:** \`REVISE\``, which is the shape that first broke this reader.
|
|
373
|
+
const field = text.match(/^[ \t>*_`-]*verdict[ \t]*[:=][ \t>*_`-]*([A-Za-z]+)/im)
|
|
374
|
+
const fieldVerdict = normaliseVerdict(field?.[1])
|
|
375
|
+
if (fieldVerdict !== null) return { verdict: fieldVerdict, findings: [], problem: null }
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
verdict: null,
|
|
379
|
+
findings: [],
|
|
380
|
+
problem: field?.[1] !== undefined
|
|
381
|
+
? 'the reply states a verdict of "' + field[1] + '", which is not one of APPROVE, REVISE or REJECT'
|
|
382
|
+
: 'the reply states no verdict',
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Accept the verdict vocabulary case-insensitively; reject everything else. */
|
|
387
|
+
function normaliseVerdict(raw: unknown): DelegationVerdict | null {
|
|
388
|
+
if (typeof raw !== 'string') return null
|
|
389
|
+
const upper = raw.trim().toUpperCase()
|
|
390
|
+
if (upper === 'APPROVE' || upper === 'REVISE' || upper === 'REJECT') return upper
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The verdict for one round, from the child's reply text, FAILING CLOSED: an
|
|
396
|
+
* unreadable reply becomes `REVISE`, never `APPROVE`.
|
|
397
|
+
*/
|
|
398
|
+
export function readVerdictFromReply(replyText: string): DelegationVerdict {
|
|
399
|
+
const parsed = parseReplyVerdict(replyText)
|
|
400
|
+
if (parsed.verdict !== null) return parsed.verdict
|
|
401
|
+
return 'REVISE'
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The repair instruction for a round whose reply did not approve.
|
|
406
|
+
*
|
|
407
|
+
* Findings drive it when the reply carried them (and are the ONLY source of
|
|
408
|
+
* instruction text — a child cannot inject instructions, since only the titles
|
|
409
|
+
* travel). When there is nothing to quote, the instruction states the contract
|
|
410
|
+
* violation instead of asking vaguely for "improvement", because a repair request
|
|
411
|
+
* that does not say what was wrong cannot be acted on.
|
|
412
|
+
*/
|
|
413
|
+
export function readRepairFromReply(replyText: string): string {
|
|
414
|
+
const parsed = parseReplyVerdict(replyText)
|
|
415
|
+
if (parsed.findings.length > 0) return 'Address the findings: ' + parsed.findings.join('; ')
|
|
416
|
+
if (parsed.problem !== null) {
|
|
417
|
+
return 'Your reply.md could not be read as a review: ' + parsed.problem + '. Re-submit with the required '
|
|
418
|
+
+ 'JSON contract (verdict APPROVE | REVISE | REJECT, plus findings) written to reply.md.'
|
|
419
|
+
}
|
|
420
|
+
return 'REVISE: address the review findings and re-submit.'
|
|
421
|
+
}
|
|
422
|
+
|
|
266
423
|
/**
|
|
267
424
|
* Run a multi-round delegated task on ONE durable continuable child:
|
|
268
425
|
* 1. `startContinuable` (initial prompt) — `start()` is never called.
|
|
@@ -284,10 +441,19 @@ export async function delegateContinuable(input: {
|
|
|
284
441
|
toolFilter?: unknown
|
|
285
442
|
maxDepth?: number
|
|
286
443
|
childId?: ContinuableChildId
|
|
444
|
+
/**
|
|
445
|
+
* T36: RESUME an existing durable child instead of starting one. The turn-shaped
|
|
446
|
+
* caller passes the childId from a previous `parked` round, which is what makes
|
|
447
|
+
* the loop resumable across turns — `startContinuable` is not called, so a parked
|
|
448
|
+
* round does not create a second child.
|
|
449
|
+
*/
|
|
450
|
+
resumeChild?: ContinuableChildId
|
|
287
451
|
maxRounds?: number
|
|
288
452
|
readVerdict?: (result: SubagentResultLike) => DelegationVerdict
|
|
289
453
|
readRepair?: (result: SubagentResultLike) => string | undefined
|
|
290
454
|
awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
|
|
455
|
+
/** T9: the child's provider/model overrides, forwarded onto the start request verbatim. */
|
|
456
|
+
agentOptions?: { model?: string; provider?: string }
|
|
291
457
|
}): Promise<ContinuableDelegationLike> {
|
|
292
458
|
const { subagents, provider, label, prompt, parent, toolFilter, maxDepth } = input
|
|
293
459
|
const maxRounds = input.maxRounds ?? 3
|
|
@@ -302,6 +468,15 @@ export async function delegateContinuable(input: {
|
|
|
302
468
|
// actual result) rather than fabricating authority and silently APPROVE-ing.
|
|
303
469
|
const hasContinuableSeam = startContinuable !== undefined && followup !== undefined && input.awaitRoundResult !== undefined
|
|
304
470
|
if (!hasContinuableSeam || subagents === undefined || parent === undefined) {
|
|
471
|
+
// ⚠ FU-9 — NAME WHICH CONDITION FAILED, because this branch covers THREE of them and the report did not.
|
|
472
|
+
// A live run returned `status: unavailable` with "no continuable seam or no live parent" for three rounds,
|
|
473
|
+
// making its cause UNDECIDABLE while this code knew it exactly. That is the defect class this session keeps
|
|
474
|
+
// finding - a surface describing less than the code knows - and it cost more than the bug it was hiding.
|
|
475
|
+
const fallbackReason = 'missing ' + [
|
|
476
|
+
hasContinuableSeam ? null : 'the continuable seam (startContinuable, followup, awaitRoundResult)',
|
|
477
|
+
subagents === undefined ? 'the subagents runtime' : null,
|
|
478
|
+
parent === undefined ? 'the exact live parent Agent' : null,
|
|
479
|
+
].filter((part): part is string => part !== null).join(', ')
|
|
305
480
|
// Fall back to one-shot delegation (self-audit-safe): never silently drop.
|
|
306
481
|
try {
|
|
307
482
|
const oneShot = await delegate({
|
|
@@ -321,6 +496,7 @@ export async function delegateContinuable(input: {
|
|
|
321
496
|
rounds: [{ text: prompt, result: oneShot }],
|
|
322
497
|
accepted: verdict === 'APPROVE' && evaluateDelegationResult(oneShot).accepted,
|
|
323
498
|
fellBackToOneShot: true,
|
|
499
|
+
reason: fallbackReason,
|
|
324
500
|
}
|
|
325
501
|
} catch (err) {
|
|
326
502
|
return { ok: false, reason: err instanceof Error ? err.message : String(err), rounds: [], accepted: false, fellBackToOneShot: true }
|
|
@@ -337,24 +513,61 @@ export async function delegateContinuable(input: {
|
|
|
337
513
|
}
|
|
338
514
|
if (toolFilter !== undefined) request.toolFilter = toolFilter
|
|
339
515
|
if (maxDepth !== undefined) request.maxDepth = maxDepth
|
|
516
|
+
// T9: the child's provider/model overrides, forwarded only when the caller supplied them —
|
|
517
|
+
// and the caller supplies them only for a provider that DECLARES the capability, because the
|
|
518
|
+
// harness rejects a start that carries them otherwise.
|
|
519
|
+
if (input.agentOptions !== undefined) request.agentOptions = input.agentOptions
|
|
340
520
|
|
|
341
521
|
const spec: ContinuableStartSpecLike = {
|
|
342
522
|
provider,
|
|
343
523
|
label,
|
|
344
524
|
request,
|
|
525
|
+
// ⚠ FU-9 — A SIGNAL IS MANDATORY IN PRACTICE, NOT DECORATION. The host reads `signal.aborted` WITHOUT
|
|
526
|
+
// optional chaining in `packages/subagent/subagent/src/control.ts:106`, while its own sibling
|
|
527
|
+
// `list-children.ts:130` guards it with `signal?.aborted`. So a continuable start carrying no signal throws
|
|
528
|
+
// `Cannot read properties of undefined (reading 'aborted')` — the delegation's catch records that as its
|
|
529
|
+
// reason, the driver reports "no continuable repair path", and THE CHILD NEVER STARTS. That one chain
|
|
530
|
+
// produced every artifact this investigation chased: no child session, no reply, no settlement, and four
|
|
531
|
+
// brief-only child directories. The host should guard its own read (reported as a DSH limitation); supplying
|
|
532
|
+
// a signal on the SPEC — the request type excludes it deliberately — is this plugin's side of the contract.
|
|
533
|
+
signal: new AbortController().signal,
|
|
345
534
|
}
|
|
346
535
|
if (input.childId !== undefined) spec.childId = input.childId
|
|
347
536
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
537
|
+
if (input.resumeChild !== undefined) {
|
|
538
|
+
// T36: RESUME. The initial prompt went out in an earlier turn, so this round
|
|
539
|
+
// has no message id of its own — and the observer keys on the CHILD anyway,
|
|
540
|
+
// because the settlement log is child-keyed. Starting a second child here
|
|
541
|
+
// would orphan the one already doing the work.
|
|
542
|
+
childId = input.resumeChild
|
|
543
|
+
rounds.push({ text: prompt })
|
|
544
|
+
} else {
|
|
545
|
+
const started = await startContinuable(spec)
|
|
546
|
+
childId = started.childId
|
|
547
|
+
messageIds.push(started.messageId)
|
|
548
|
+
rounds.push({ text: prompt })
|
|
549
|
+
}
|
|
352
550
|
|
|
353
551
|
for (let round = 0; round < maxRounds; round += 1) {
|
|
354
552
|
const current = rounds[round]
|
|
355
|
-
const
|
|
553
|
+
const lastMessageId = messageIds.length > 0
|
|
554
|
+
? messageIds[messageIds.length - 1]!
|
|
555
|
+
: ('' as ContinuableMessageId)
|
|
556
|
+
const observed = await input.awaitRoundResult!(childId, lastMessageId)
|
|
356
557
|
if (observed === null) {
|
|
357
|
-
|
|
558
|
+
// NOT a failure: nothing has settled yet. Reported as `parked` so a
|
|
559
|
+
// turn-shaped caller resumes on a later turn instead of treating the round
|
|
560
|
+
// as lost, and so `accepted` stays false — an unobserved round is never an
|
|
561
|
+
// approval.
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
reason: 'no settlement has landed for round ' + (round + 1) + ' yet (the child is still working)',
|
|
565
|
+
childId,
|
|
566
|
+
messageIds,
|
|
567
|
+
rounds,
|
|
568
|
+
accepted: false,
|
|
569
|
+
parked: true,
|
|
570
|
+
}
|
|
358
571
|
}
|
|
359
572
|
current.result = observed
|
|
360
573
|
const verdict = readVerdict(observed)
|
|
@@ -556,6 +769,13 @@ export interface ActionRecordInput {
|
|
|
556
769
|
findings?: string[]
|
|
557
770
|
success: boolean
|
|
558
771
|
stopReason?: string
|
|
772
|
+
/**
|
|
773
|
+
* ⚠ FU-9 — WHY IT FAILED, when it did. `success` is a boolean, so a record could say `Status: failed` and
|
|
774
|
+
* nothing else: a delegation that FAILED and a delegation that NEVER HAPPENED read identically, which is what
|
|
775
|
+
* let me conclude for three rounds that the host was not scheduling children. The caller ALREADY passed
|
|
776
|
+
* `stopReason`, and the live record said `n/a` — because there was no result to take a stop reason from.
|
|
777
|
+
*/
|
|
778
|
+
failure?: string
|
|
559
779
|
}
|
|
560
780
|
|
|
561
781
|
function slugify(value: string): string {
|
|
@@ -563,9 +783,18 @@ function slugify(value: string): string {
|
|
|
563
783
|
}
|
|
564
784
|
|
|
565
785
|
/**
|
|
566
|
-
* Write a durable action record under subagents/
|
|
567
|
-
* (
|
|
568
|
-
*
|
|
786
|
+
* Write a durable action record under subagents/ in the shape this repo's own
|
|
787
|
+
* linter accepts (ts-lint.ts lintSubagentActionRecordFile — every top-level .md
|
|
788
|
+
* under a run's subagents/ is linted as one):
|
|
789
|
+
* - the literal title `# Subagent Action Record`;
|
|
790
|
+
* - `Run ID` and `Timestamp` in ## Metadata (Run ID must equal the run dir name);
|
|
791
|
+
* - `Current Artifact`, `Artifact Content Hash` (the artifact's LF-normalized
|
|
792
|
+
* sha256, derived from the artifact itself — no extra caller input), `Diff
|
|
793
|
+
* Basis`, `Review Bundle`, and the NAMED fields `Upstream Artifacts` /
|
|
794
|
+
* `Code Refs` strictly inside ## Inputs Provided. The linter resolves each of
|
|
795
|
+
* those through the heading body, so a field under another heading is not
|
|
796
|
+
* found at all.
|
|
797
|
+
* A success:false attempt is written with a failed status and is NOT accepted.
|
|
569
798
|
*/
|
|
570
799
|
export function writeActionRecord(input: ActionRecordInput): string {
|
|
571
800
|
const { root, runId } = input
|
|
@@ -574,34 +803,68 @@ export function writeActionRecord(input: ActionRecordInput): string {
|
|
|
574
803
|
const fileName = Date.now() + '-' + slugify(input.subagentId) + '-action.md'
|
|
575
804
|
const path = join(dir, fileName)
|
|
576
805
|
|
|
806
|
+
const tick = String.fromCharCode(96)
|
|
807
|
+
const code = (value: string) => tick + norm(value) + tick
|
|
808
|
+
|
|
577
809
|
const list = (title: string, values: string[]) => {
|
|
578
810
|
const out: string[] = [title]
|
|
579
811
|
if (!values?.length) {
|
|
580
812
|
out.push('- none')
|
|
581
813
|
return out
|
|
582
814
|
}
|
|
583
|
-
for (const v of values) out.push('- ' +
|
|
815
|
+
for (const v of values) out.push('- ' + code(v))
|
|
584
816
|
return out
|
|
585
817
|
}
|
|
586
818
|
|
|
819
|
+
/** A NAMED field whose value is a block of backticked paths (`Field: none` when empty). */
|
|
820
|
+
const namedList = (fieldName: string, values: string[]): string[] =>
|
|
821
|
+
values.length
|
|
822
|
+
? ['- ' + fieldName + ':', ...values.map((v) => ' - ' + code(v))]
|
|
823
|
+
: ['- ' + fieldName + ': none']
|
|
824
|
+
|
|
825
|
+
const artifactRel = input.artifactPath ? norm(input.artifactPath) : ''
|
|
826
|
+
let artifactHash = ''
|
|
827
|
+
if (artifactRel) {
|
|
828
|
+
try {
|
|
829
|
+
const artifactAbs = resolveUnderRoot(root, artifactRel)
|
|
830
|
+
if (existsSync(artifactAbs)) artifactHash = contentSha256(readFileSync(artifactAbs, 'utf8'))
|
|
831
|
+
} catch {
|
|
832
|
+
artifactHash = ''
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
587
836
|
const lines: string[] = [
|
|
588
|
-
|
|
837
|
+
// The canonical template (references/artifact-template.md) H1 and the string the
|
|
838
|
+
// linter looks for — the subagent identity belongs in Metadata, not the title.
|
|
839
|
+
'# Subagent Action Record',
|
|
589
840
|
'',
|
|
590
841
|
'## Metadata',
|
|
591
842
|
'- Subagent ID: ' + input.subagentId,
|
|
843
|
+
'- Run ID: ' + runId,
|
|
592
844
|
'- Phase: ' + input.phase,
|
|
593
845
|
'- Purpose: ' + input.purpose,
|
|
594
846
|
'- Execution Mode: ' + input.executionMode,
|
|
595
847
|
'- Status: ' + (input.success ? 'accepted' : 'failed'),
|
|
848
|
+
// ⚠ EMITTED ONLY WHEN A REASON IS GIVEN, so a caller that says nothing produces the record it always did.
|
|
849
|
+
// Not politeness: this record's shape is asserted by specs, and a first attempt that always emitted the line
|
|
850
|
+
// failed 13 tests across 5 files. A change to a shared surface should be additive where it can be.
|
|
851
|
+
...(input.success === false && input.failure !== undefined ? ['- Failure: ' + input.failure] : []),
|
|
596
852
|
'- Stop Reason: ' + (input.stopReason ?? 'n/a'),
|
|
597
|
-
|
|
598
|
-
|
|
853
|
+
// Timestamp LAST in Metadata. This USED to be load-bearing: `getHeadingBody` ended
|
|
854
|
+
// its capture with a `\Z` that JavaScript reads as a literal `Z`, so a body was
|
|
855
|
+
// truncated at its first `Z` and an ISO timestamp could cut the section short. T33
|
|
856
|
+
// replaced that anchor with `(?![\s\S])`, so the hazard is gone and this ordering is
|
|
857
|
+
// now only conventional — kept because Timestamp is naturally the last metadata field.
|
|
858
|
+
'- Timestamp: ' + new Date().toISOString(),
|
|
599
859
|
'',
|
|
600
860
|
'## Inputs Provided',
|
|
601
|
-
...
|
|
602
|
-
'',
|
|
603
|
-
'
|
|
604
|
-
...(
|
|
861
|
+
...(artifactRel ? ['- Current Artifact: ' + code(artifactRel)] : []),
|
|
862
|
+
...(artifactHash ? ['- Artifact Content Hash: ' + code(artifactHash)] : []),
|
|
863
|
+
'- Diff Basis: ' + (input.diffBasis ?? 'n/a'),
|
|
864
|
+
...namedList('Upstream Artifacts', input.upstreamArtifacts ?? []),
|
|
865
|
+
...(input.reviewBundle ? ['- Review Bundle: ' + code(norm(input.reviewBundle))] : []),
|
|
866
|
+
...namedList('Code Refs', input.codeRefs ?? []),
|
|
867
|
+
'- Audit / Task Questions: ' + (input.auditQuestions?.length ? input.auditQuestions.join(' ') : 'n/a'),
|
|
605
868
|
'',
|
|
606
869
|
'## Claimed Actions Taken',
|
|
607
870
|
...list('', input.actionsTaken ?? []).slice(1),
|
|
@@ -618,9 +881,19 @@ export function writeActionRecord(input: ActionRecordInput): string {
|
|
|
618
881
|
...(input.findings?.length ? input.findings.map((f) => '- ' + f) : ['- none']),
|
|
619
882
|
'',
|
|
620
883
|
'## Verification Handoff',
|
|
621
|
-
'- Inspect first: ' + (
|
|
884
|
+
'- Inspect first: ' + (artifactRel ? code(artifactRel) : 'n/a'),
|
|
622
885
|
'- Notes: main agent must verify every claimed reference against actual files, actual recursive artifacts, and the actual diff before acceptance.',
|
|
623
886
|
'',
|
|
887
|
+
// Provenance tail. This USED to be load-bearing: with the old `\Z` anchor a
|
|
888
|
+
// document's FINAL section always read as empty, so `## Verification Handoff` would
|
|
889
|
+
// have been reported as "Missing or empty section" unless something followed it. T33
|
|
890
|
+
// gave the section parsers a real end-of-input assertion, so the trailing heading is
|
|
891
|
+
// no longer needed to satisfy the linter — it is kept because the record genuinely
|
|
892
|
+
// wants to state who wrote it and how its hashes are computed.
|
|
893
|
+
'## Record Provenance',
|
|
894
|
+
'- Writer: dsh-recursive-mode ' + code('writeActionRecord') + ' (plugin-generated action record; hashes are LF-normalized sha256).',
|
|
895
|
+
'- Contract: canonical Subagent Action Record sections, as read by recursive_lint.',
|
|
896
|
+
'',
|
|
624
897
|
]
|
|
625
898
|
|
|
626
899
|
writeFileSync(path, lines.join('\n'), 'utf8')
|
|
@@ -640,3 +913,48 @@ export function evaluateDelegationResult(result: SubagentResultLike): { accepted
|
|
|
640
913
|
}
|
|
641
914
|
return { accepted: true, reason: 'delegation completed' }
|
|
642
915
|
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* T8 — the child's CLAIMED references, read from wherever the delegation put them.
|
|
919
|
+
*
|
|
920
|
+
* The review output schema requires `references`, so a reviewer states which files back its
|
|
921
|
+
* verdict — and NOTHING read that field: `evaluateDelegationResult` looks only at
|
|
922
|
+
* `success`/`stopReason`, so a review citing files that do not exist was indistinguishable
|
|
923
|
+
* from one citing real evidence. This reads the claims so they can be checked.
|
|
924
|
+
*
|
|
925
|
+
* Both carriers are tried, because a delegation may return structured output or the raw
|
|
926
|
+
* JSON text: `structured` first (the native path), then `output` parsed as JSON. A result
|
|
927
|
+
* that carries neither yields `[]` — "no claims" — which the caller treats as NOTHING TO
|
|
928
|
+
* CHECK rather than as a pass, so an unparseable result can never be mistaken for a
|
|
929
|
+
* verified one. Malformed entries are dropped rather than thrown on: this reads a model's
|
|
930
|
+
* output, which is untrusted.
|
|
931
|
+
*/
|
|
932
|
+
export function referencesFromResult(result: SubagentResultLike): Reference[] {
|
|
933
|
+
const candidates: unknown[] = []
|
|
934
|
+
if (result.structured !== undefined && result.structured !== null && typeof result.structured === 'object') {
|
|
935
|
+
candidates.push((result.structured as { references?: unknown }).references)
|
|
936
|
+
}
|
|
937
|
+
if (typeof result.output === 'string' && result.output.trim() !== '') {
|
|
938
|
+
try {
|
|
939
|
+
const parsed = JSON.parse(result.output) as { references?: unknown } | null
|
|
940
|
+
if (parsed !== null && typeof parsed === 'object') candidates.push(parsed.references)
|
|
941
|
+
} catch {
|
|
942
|
+
// Not JSON: no structured claims to read. Never a failure of the delegation itself.
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const candidate of candidates) {
|
|
946
|
+
if (!Array.isArray(candidate)) continue
|
|
947
|
+
const refs: Reference[] = []
|
|
948
|
+
for (const entry of candidate) {
|
|
949
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
950
|
+
const path = (entry as { path?: unknown }).path
|
|
951
|
+
if (typeof path !== 'string' || path.trim() === '') continue
|
|
952
|
+
const reference: Reference = { path }
|
|
953
|
+
const lineRange = (entry as { lineRange?: unknown }).lineRange
|
|
954
|
+
if (typeof lineRange === 'string') reference.lineRange = lineRange
|
|
955
|
+
refs.push(reference)
|
|
956
|
+
}
|
|
957
|
+
if (refs.length > 0) return refs
|
|
958
|
+
}
|
|
959
|
+
return []
|
|
960
|
+
}
|