@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.
Files changed (118) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive/agent.cordis.yml +2 -2
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/src/client/derive.ts +18 -2
  61. package/src/closeout-report.ts +274 -0
  62. package/src/closeout-standards.ts +102 -0
  63. package/src/closeout.ts +39 -2
  64. package/src/commands.ts +116 -4
  65. package/src/config.ts +113 -0
  66. package/src/delegation.ts +336 -18
  67. package/src/enforcement.ts +262 -72
  68. package/src/errors.ts +197 -0
  69. package/src/git-context.ts +33 -2
  70. package/src/guard-log.ts +134 -0
  71. package/src/handoff.ts +62 -0
  72. package/src/hooks.ts +316 -0
  73. package/src/identity.ts +230 -0
  74. package/src/index.ts +385 -20
  75. package/src/job-log.ts +112 -0
  76. package/src/jobs-runner.ts +222 -0
  77. package/src/json-safe.ts +75 -0
  78. package/src/lock.ts +153 -16
  79. package/src/memory-feedback.ts +185 -0
  80. package/src/memory-select.ts +187 -0
  81. package/src/memory.ts +309 -0
  82. package/src/model-inventory.ts +196 -0
  83. package/src/phase-graph.ts +191 -0
  84. package/src/phase-rules.ts +236 -0
  85. package/src/plan-gate.ts +111 -0
  86. package/src/policy-globs.ts +636 -0
  87. package/src/policy-write.ts +210 -0
  88. package/src/policy.ts +70 -5
  89. package/src/recursive_ask.tool.ts +276 -0
  90. package/src/recursive_audit_team.tool.ts +7 -3
  91. package/src/recursive_closeout.tool.ts +36 -35
  92. package/src/recursive_delegate.tool.ts +194 -0
  93. package/src/recursive_init.tool.ts +4 -3
  94. package/src/recursive_lint.tool.ts +81 -6
  95. package/src/recursive_lock.tool.ts +21 -4
  96. package/src/recursive_phase.tool.ts +3 -2
  97. package/src/recursive_preview.tool.ts +142 -0
  98. package/src/recursive_review.tool.ts +190 -0
  99. package/src/recursive_scratch.tool.ts +5 -4
  100. package/src/recursive_status.tool.ts +3 -2
  101. package/src/recursive_worktree.tool.ts +6 -5
  102. package/src/result-cap.ts +130 -0
  103. package/src/review-round.ts +335 -0
  104. package/src/review.ts +17 -3
  105. package/src/role-route.ts +230 -0
  106. package/src/router.ts +128 -2
  107. package/src/runtime.ts +968 -39
  108. package/src/settlement.ts +355 -0
  109. package/src/skills-phase.ts +143 -0
  110. package/src/snapshot.ts +39 -8
  111. package/src/status.ts +209 -4
  112. package/src/teams-loop.ts +223 -9
  113. package/src/training.ts +565 -0
  114. package/src/ts-lint.ts +38 -4
  115. package/src/types.ts +51 -0
  116. package/src/workflow-audit.ts +288 -0
  117. package/scripts/install-preset.cmd +0 -7
  118. 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
- const started = await startContinuable(spec)
349
- childId = started.childId
350
- messageIds.push(started.messageId)
351
- rounds.push({ text: prompt })
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 observed = await input.awaitRoundResult!(childId, messageIds[messageIds.length - 1])
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
- return { ok: false, reason: 'continuable child produced no settlement for round ' + (round + 1), childId, messageIds, rounds, accepted: false }
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/ with the canonical sections
567
- * (matches recursive-subagent-action.py). A success:false attempt is written
568
- * with a failed status and is NOT accepted.
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('- ' + String.fromCharCode(96) + norm(v) + String.fromCharCode(96))
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
- '# Subagent action record: ' + input.subagentId,
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
- ...(input.artifactPath ? ['- Current Artifact: ' + String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96)] : []),
598
- ...(input.reviewBundle ? ['- Review Bundle: ' + String.fromCharCode(96) + norm(input.reviewBundle) + String.fromCharCode(96)] : []),
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
- ...list('', input.upstreamArtifacts ?? []).slice(1),
602
- '',
603
- '## Routing',
604
- ...(input.diffBasis ? ['- Diff Basis: ' + input.diffBasis] : ['- Diff Basis: n/a']),
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: ' + (input.artifactPath ? String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96) : 'n/a'),
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
+ }