@claudexor/control-api 3.1.1 → 3.2.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.
Files changed (120) hide show
  1. package/dist/authorization.d.ts +3 -0
  2. package/dist/authorization.d.ts.map +1 -0
  3. package/dist/authorization.js +15 -0
  4. package/dist/authorization.js.map +1 -0
  5. package/dist/binary-response.d.ts +5 -0
  6. package/dist/binary-response.d.ts.map +1 -0
  7. package/dist/binary-response.js +31 -0
  8. package/dist/binary-response.js.map +1 -0
  9. package/dist/budget-valuation.d.ts +23 -0
  10. package/dist/budget-valuation.d.ts.map +1 -0
  11. package/dist/budget-valuation.js +90 -0
  12. package/dist/budget-valuation.js.map +1 -0
  13. package/dist/catalog-query.d.ts +310 -0
  14. package/dist/catalog-query.d.ts.map +1 -0
  15. package/dist/catalog-query.js +36 -0
  16. package/dist/catalog-query.js.map +1 -0
  17. package/dist/daemon-server.d.ts +33 -79
  18. package/dist/daemon-server.d.ts.map +1 -1
  19. package/dist/daemon-server.js +175 -281
  20. package/dist/daemon-server.js.map +1 -1
  21. package/dist/decision-accept-risk.d.ts +36 -0
  22. package/dist/decision-accept-risk.d.ts.map +1 -0
  23. package/dist/decision-accept-risk.js +79 -0
  24. package/dist/decision-accept-risk.js.map +1 -0
  25. package/dist/decision-rerun.d.ts +2 -1
  26. package/dist/decision-rerun.d.ts.map +1 -1
  27. package/dist/decision-rerun.js +79 -48
  28. package/dist/decision-rerun.js.map +1 -1
  29. package/dist/delegation-control.d.ts +23 -0
  30. package/dist/delegation-control.d.ts.map +1 -0
  31. package/dist/delegation-control.js +81 -0
  32. package/dist/delegation-control.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/maintenance-routes.d.ts +2 -2
  38. package/dist/maintenance-routes.d.ts.map +1 -1
  39. package/dist/maintenance-routes.js +11 -10
  40. package/dist/maintenance-routes.js.map +1 -1
  41. package/dist/operation-catalog.d.ts +9 -9
  42. package/dist/operation-catalog.d.ts.map +1 -1
  43. package/dist/operation-catalog.js +22 -79
  44. package/dist/operation-catalog.js.map +1 -1
  45. package/dist/operation-draft.d.ts +3 -0
  46. package/dist/operation-draft.d.ts.map +1 -0
  47. package/dist/operation-draft.js +2 -0
  48. package/dist/operation-draft.js.map +1 -0
  49. package/dist/operation-parameters.d.ts.map +1 -1
  50. package/dist/operation-summaries.d.ts +3 -0
  51. package/dist/operation-summaries.d.ts.map +1 -0
  52. package/dist/operation-summaries.js +75 -0
  53. package/dist/operation-summaries.js.map +1 -0
  54. package/dist/primary-output.d.ts +3 -3
  55. package/dist/primary-output.d.ts.map +1 -1
  56. package/dist/primary-output.js +70 -19
  57. package/dist/primary-output.js.map +1 -1
  58. package/dist/problem-response.d.ts.map +1 -1
  59. package/dist/problem-response.js +14 -9
  60. package/dist/problem-response.js.map +1 -1
  61. package/dist/project-routes.d.ts +21 -13
  62. package/dist/project-routes.d.ts.map +1 -1
  63. package/dist/project-routes.js +80 -34
  64. package/dist/project-routes.js.map +1 -1
  65. package/dist/remote-operation-descriptors.d.ts +36 -0
  66. package/dist/remote-operation-descriptors.d.ts.map +1 -0
  67. package/dist/remote-operation-descriptors.js +35 -0
  68. package/dist/remote-operation-descriptors.js.map +1 -0
  69. package/dist/resource-routes.d.ts +4 -3
  70. package/dist/resource-routes.d.ts.map +1 -1
  71. package/dist/resource-routes.js +63 -39
  72. package/dist/resource-routes.js.map +1 -1
  73. package/dist/route-stages.d.ts +15 -0
  74. package/dist/route-stages.d.ts.map +1 -0
  75. package/dist/route-stages.js +24 -0
  76. package/dist/route-stages.js.map +1 -0
  77. package/dist/run-facts-projection.d.ts +35 -0
  78. package/dist/run-facts-projection.d.ts.map +1 -0
  79. package/dist/run-facts-projection.js +133 -0
  80. package/dist/run-facts-projection.js.map +1 -0
  81. package/dist/run-list-fingerprint.d.ts +7 -0
  82. package/dist/run-list-fingerprint.d.ts.map +1 -0
  83. package/dist/run-list-fingerprint.js +68 -0
  84. package/dist/run-list-fingerprint.js.map +1 -0
  85. package/dist/run-record.d.ts +53 -0
  86. package/dist/run-record.d.ts.map +1 -0
  87. package/dist/run-record.js +61 -0
  88. package/dist/run-record.js.map +1 -0
  89. package/dist/run-retry-routes.d.ts +3 -2
  90. package/dist/run-retry-routes.d.ts.map +1 -1
  91. package/dist/run-retry-routes.js +129 -48
  92. package/dist/run-retry-routes.js.map +1 -1
  93. package/dist/run-start.d.ts +13 -1
  94. package/dist/run-start.d.ts.map +1 -1
  95. package/dist/run-start.js +90 -18
  96. package/dist/run-start.js.map +1 -1
  97. package/dist/run-timeline.d.ts.map +1 -1
  98. package/dist/run-timeline.js +17 -9
  99. package/dist/run-timeline.js.map +1 -1
  100. package/dist/thread-lifecycle-routes.d.ts +1 -1
  101. package/dist/thread-lifecycle-routes.d.ts.map +1 -1
  102. package/dist/thread-lifecycle-routes.js +3 -4
  103. package/dist/thread-lifecycle-routes.js.map +1 -1
  104. package/dist/thread-mutation.d.ts +6 -0
  105. package/dist/thread-mutation.d.ts.map +1 -1
  106. package/dist/thread-mutation.js +23 -0
  107. package/dist/thread-mutation.js.map +1 -1
  108. package/dist/thread-projection.d.ts +6 -1
  109. package/dist/thread-projection.d.ts.map +1 -1
  110. package/dist/thread-projection.js +28 -1
  111. package/dist/thread-projection.js.map +1 -1
  112. package/dist/thread-recovery.d.ts +54 -0
  113. package/dist/thread-recovery.d.ts.map +1 -0
  114. package/dist/thread-recovery.js +71 -0
  115. package/dist/thread-recovery.js.map +1 -0
  116. package/dist/thread-turn-routes.d.ts +22 -8
  117. package/dist/thread-turn-routes.d.ts.map +1 -1
  118. package/dist/thread-turn-routes.js +73 -49
  119. package/dist/thread-turn-routes.js.map +1 -1
  120. package/package.json +6 -6
@@ -1,5 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { basename, extname, join } from "node:path";
5
5
  import { deriveApplyEligibility, revertInPlaceFromAnchor, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
@@ -8,31 +8,43 @@ import { safeArtifactPath, safeArtifactRoot } from "./artifact-paths.js";
8
8
  import { TERMINAL_STATES } from "./sse-shared.js";
9
9
  import { streamRunEvents } from "./run-events-stream.js";
10
10
  import { boundedArtifactText, outputReadyState, primaryOutput } from "./primary-output.js";
11
+ import { budgetValuationFromEvents, cashEstimatedFromLedgerEvent, normalizeLegacyBudgetComponents, } from "./budget-valuation.js";
11
12
  import { controlWebEvidence, eventPayload, evidenceLevel, latestPlanProgress, readRunEvents, readRunEventsWithIntegrity, timelineEvents, } from "./run-timeline.js";
12
- import { projectSession, projectThread, projectTurn, turnRunCard } from "./thread-projection.js";
13
- import { chainThreadMutation, handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
14
- import { assertThreadIdle, threadIdOfRun } from "./thread-mutation.js";
13
+ import { projectTurnRunCards } from "./thread-projection.js";
14
+ import { projectSession, projectThread, projectTurn } from "./thread-projection.js";
15
+ import { handleThreadTurnCreate, handleThreadTurnRetry, } from "./thread-turn-routes.js";
16
+ import { chainIdleRunMutation, chainThreadMutation } from "./thread-mutation.js";
15
17
  import { handleThreadLifecycleRoutes, } from "./thread-lifecycle-routes.js";
16
18
  import * as runStart from "./run-start.js";
19
+ import { cancelDelegationFamily } from "./delegation-control.js";
20
+ import { directDelegatedChildrenFromRecords, delegatedDescendantsFromRecords, paramsRecord, } from "./run-record.js";
17
21
  import { handleRunRetryRoute } from "./run-retry-routes.js";
18
22
  import { handleRunApplyRoutes, runIdempotentDelivery, } from "./run-apply-routes.js";
19
23
  import { rerunWithFeedback } from "./decision-rerun.js";
24
+ import { acceptRiskDecision } from "./decision-accept-risk.js";
20
25
  export { normalizeRunStartRequest } from "./run-start.js";
21
26
  import { candidatesFor } from "./candidates.js";
22
27
  import { handleProjectRoute } from "./project-routes.js";
28
+ import { writeBinaryResponse } from "./binary-response.js";
29
+ export { inlineContentDisposition } from "./binary-response.js";
23
30
  import { handleRecoveryRoute } from "./recovery-routes.js";
24
31
  import { handleJournalEventRoute } from "./journal-event-routes.js";
25
32
  import { handleMaintenanceRoute } from "./maintenance-routes.js";
26
33
  import { handleResourceRoute } from "./resource-routes.js";
27
34
  import { handleArtifactServeRoute, listArtifacts, resolveProjectRoot, } from "./artifact-serve-routes.js";
28
35
  import { requiredGateSpecsFromTaskArtifact } from "./task-contract-gates.js";
29
- import { assertOnlyQueryParams, optionalBooleanQuery, singleQuery } from "./query.js";
36
+ import { bearerCredential } from "./authorization.js";
37
+ import { assertOnlyQueryParams, singleQuery } from "./query.js";
38
+ import { parseCredentialProfilesSnapshotQuery, parseHarnessListQuery, parseRunApplicabilityQuery, } from "./catalog-query.js";
30
39
  import { lruGet, lruSet, parseRunListQuery, selectRunListPage, } from "./run-list.js";
40
+ import { summaryFingerprint } from "./run-list-fingerprint.js";
41
+ export { resetRunListFingerprintProbeCountForTests, runListFingerprintProbeCountForTests, } from "./run-list-fingerprint.js";
31
42
  import { controlProblemError, normalizeRequestValidationError, revertRefusedProblem, thrownProblemCode, } from "./problem-response.js";
32
43
  import { handleSecurityRoute } from "./security-routes.js";
33
- import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunListResponse, ControlRunSummary, ControlRunResult, ControlBudgetSnapshot, PaidBudget, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlQuotaResponse, ControlCredentialProfilesResponse, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, requiredActionsFor, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
44
+ import { effectiveTerminalFacts, expectedRunFacts, projectRunFactsForDetail, } from "./run-facts-projection.js";
45
+ import { PlanQuestionsArtifact, CouncilProjection, derivePlanReadiness, ControlAuthReadinessRefreshRequest, ControlAuthReadinessRefreshResponse, ControlProblem, AccessProfile, AgentCapabilityCatalog, ControlHarnessListResponse, ControlHarnessModelsResponse, ControlSetupJob, ControlSetupJobCreateRequest, ControlSetupJobEvent, ControlSetupJobSnapshot, isTerminalControlSetupJobState, ControlSetupJobListFilter, ControlSetupJobListResponse, ControlRunStartInfo, ControlRunControlRequest, ControlRunControlResponse, ControlReviewerPanelEntry, ControlRunDetail, ControlRunListResponse, ControlRunSummary, ControlRunResult, ControlBudgetSnapshot, PaidBudget, ControlSettingsSnapshot, ControlSettingsUpdateRequest, ControlQuotaResponse, ControlCredentialProfileCreateRequest, ControlCredentialProfileCreateResponse, ControlCredentialProfileUpdateRequest, ControlCredentialProfileUpdateResponse, ControlCredentialProfileDeleteResponse, ControlInteractionAnswerRequest, ControlInteractionAnswerResponse, ControlRunDecisionRequest, ControlRunDecisionResponse, ControlRunApplicabilityResponse, ControlThreadCreateRequest, ControlThreadTurnRequest, ControlThreadUpdateRequest, ControlThreadDetail, ControlThreadListResponse, DecisionRecord, Id, ModeKind, RoutingGoal, ReviewFinding, RunDeliveryState, RunFailure, RunTelemetry, StructuredOutputConformance, TaskContract, ProtectedPathApproval, isTerminalLifecycle, needsDecision, needsOperatorAttention, outcomeBanner, outcomeFactsFromFailure, TestCommandInvocation, WorkProduct, } from "@claudexor/schema";
34
46
  import { resolveControlProtocol } from "./operation-catalog.js";
35
- import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, sha256, } from "@claudexor/util";
47
+ import { assertNoInlineSecretValues, containsSecretLikeToken, errorCode, noProjectRepoRoot, nowIso, redactSecrets, safeProblemContext, safeProblemRequiredActions, sha256, } from "@claudexor/util";
36
48
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
37
49
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
38
50
  const NO_PROJECT_ROOT = noProjectRepoRoot();
@@ -59,6 +71,8 @@ function stringArrayProperty(error, key) {
59
71
  if (!error || typeof error !== "object" || !(key in error))
60
72
  return [];
61
73
  const value = error[key];
74
+ if (key === "requiredActions")
75
+ return safeProblemRequiredActions(value);
62
76
  return Array.isArray(value)
63
77
  ? value
64
78
  .filter((item) => typeof item === "string" && item.length > 0)
@@ -96,7 +110,7 @@ function problemBody(error, fallbackCode, fallbackRetryable, fallbackMessage) {
96
110
  requiredActions: stringArrayProperty(error, "requiredActions"),
97
111
  evidenceRefs: stringArrayProperty(error, "evidenceRefs"),
98
112
  context: error && typeof error === "object" && "context" in error
99
- ? (error.context ?? {})
113
+ ? safeProblemContext(error.context)
100
114
  : {},
101
115
  });
102
116
  }
@@ -252,15 +266,12 @@ export class DaemonControlApiServer {
252
266
  if (!hostIsLoopback(req.headers.host) ||
253
267
  !originIsLoopback(req.headers.origin))
254
268
  return false;
255
- const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
256
- return this.tokenMatches(m?.[1]?.trim());
269
+ return this.tokenMatches(bearerCredential(req.headers.authorization));
257
270
  }
258
- requestError(res, err) {
259
- // QA-053: at the request boundary, project a raw ZodError into structured
260
- // fieldErrors + a single-line human message (see normalizeRequestValidationError).
261
- // Typed errors and non-Zod errors pass through unchanged.
262
- const normalized = normalizeRequestValidationError(err);
263
- this.problem(res, finiteHttpStatus(normalized, 400), normalized, "invalid_request", false, "bad request");
271
+ requestError(res, err, fallbackStatus = 400) {
272
+ // QA-053: normalize raw Zod issues only at the client-request boundary.
273
+ const normalized = fallbackStatus === 400 ? normalizeRequestValidationError(err) : err;
274
+ this.problem(res, finiteHttpStatus(normalized, fallbackStatus), normalized, fallbackStatus === 400 ? "invalid_request" : "internal_error", false, fallbackStatus === 400 ? "bad request" : "service failed");
264
275
  }
265
276
  json(res, status, body, contentType = "application/json") {
266
277
  if (status >= 400 && contentType === "application/json") {
@@ -375,21 +386,22 @@ export class DaemonControlApiServer {
375
386
  services: this.opts.services,
376
387
  readBody: (request) => this.readBody(request),
377
388
  json: (response, status, body) => this.json(response, status, body),
378
- requestError: (response, error) => this.requestError(response, error),
389
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
379
390
  }, method, path, req, res))
380
391
  return;
381
392
  if (await handleMaintenanceRoute({
382
393
  services: this.opts.services,
383
394
  readBody: (request) => this.readBody(request),
384
395
  json: (response, status, body) => this.json(response, status, body),
385
- requestError: (response, error) => this.requestError(response, error),
396
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
386
397
  }, method, path, req, res))
387
398
  return;
388
399
  if (await handleProjectRoute({
389
400
  services: this.opts.services,
390
401
  readBody: (request) => this.readBody(request),
391
402
  json: (response, status, body) => this.json(response, status, body),
392
- requestError: (response, error) => this.requestError(response, error),
403
+ requestError: (response, error, fallback) => this.requestError(response, error, fallback),
404
+ binary: writeBinaryResponse,
393
405
  }, method, path, req, res))
394
406
  return;
395
407
  if (method === "POST" && path === "/runs") {
@@ -411,8 +423,9 @@ export class DaemonControlApiServer {
411
423
  services: this.opts.services,
412
424
  findRun: (id) => this.findRun(id),
413
425
  waitForRunStart: (id) => this.waitForRunStart(id),
426
+ serializeThreadMutation: (threadId, work) => chainThreadMutation(this.threadTurnChains, threadId, work),
414
427
  json: (response, status, body) => this.json(response, status, body),
415
- requestError: (response, error) => this.requestError(response, error),
428
+ requestError: (response, error, fallbackStatus) => this.requestError(response, error, fallbackStatus),
416
429
  }, method, path, req, res))
417
430
  return;
418
431
  const runDetailMatch = /^\/runs\/([^/]+)$/.exec(path);
@@ -423,7 +436,9 @@ export class DaemonControlApiServer {
423
436
  // Fence order: cursor FIRST, every projection (pending interactions
424
437
  // included) after it — see the detailFor doc comment.
425
438
  const lastSeq = rec.runDir ? lastSeqInFile(join(rec.runDir, "events.jsonl")) : 0;
426
- return this.json(res, 200, detailFor(rec, this.pendingInteractionsFor(rec), lastSeq, this.validOperatorDecisionFor(rec)));
439
+ const parentRunId = rec.runId ?? rec.id;
440
+ const children = directDelegatedChildrenFromRecords(parentRunId, await this.opts.daemon.list()).map((candidate) => this.summarizeRunOrDiagnostic(candidate));
441
+ return this.json(res, 200, detailFor(rec, this.pendingInteractionsFor(rec), lastSeq, this.validOperatorDecisionFor(rec), children));
427
442
  }
428
443
  const interactionAnswerMatch = /^\/runs\/([^/]+)\/interactions\/([^/]+)\/answer$/.exec(path);
429
444
  if (method === "POST" && interactionAnswerMatch) {
@@ -481,13 +496,7 @@ export class DaemonControlApiServer {
481
496
  const idempotencyKey = runStart.requiredIdempotencyKey(req);
482
497
  let repoRoot = null;
483
498
  if (parsed.scope.kind === "project") {
484
- repoRoot = parsed.scope.root.trim();
485
- const absoluteRepoError = runStart.validateAbsoluteRepoRoot(repoRoot);
486
- if (absoluteRepoError)
487
- throw Object.assign(new Error(absoluteRepoError), { status: 400 });
488
- if (!existsSync(repoRoot) || !lstatSync(repoRoot).isDirectory()) {
489
- throw Object.assign(new Error(`project root does not exist or is not a directory: ${repoRoot}`), { status: 400 });
490
- }
499
+ repoRoot = runStart.normalizeExistingProjectRoot(parsed.scope.root);
491
500
  }
492
501
  const thread = await svc({
493
502
  title: parsed.title,
@@ -533,15 +542,7 @@ export class DaemonControlApiServer {
533
542
  const runs = await this.opts.daemon.list();
534
543
  const byRun = new Map(runs.map((r) => [r.runId ?? r.id, r]));
535
544
  const thread = detail.thread;
536
- const cards = new Map();
537
- for (const turn of detail.turns) {
538
- const runId = turn.run_id ?? null;
539
- if (runId && !cards.has(runId)) {
540
- const rec = byRun.get(runId);
541
- if (rec)
542
- cards.set(runId, turnRunCard(this.summarizeRunOrDiagnostic(rec)));
543
- }
544
- }
545
+ const cards = projectTurnRunCards(detail.turns, runs, (record) => this.summarizeRunOrDiagnostic(record));
545
546
  const headRec = byRun.get(thread.head_run_id ?? "");
546
547
  const headNeedsHuman = headRec ? this.runNeedsAttention(headRec) : false;
547
548
  return this.json(res, 200, ControlThreadDetail.parse({
@@ -633,70 +634,17 @@ export class DaemonControlApiServer {
633
634
  return this.requestError(res, err);
634
635
  }
635
636
  if (body.action === "accept_risk" || body.action === "override_needs_human") {
636
- const decisionBody = body;
637
- const decisionAction = body.action;
638
637
  try {
639
- return await this.chainRunMutation(rec, async () => {
640
- // D-16: a work_state veto (the model attested needs_input / incomplete)
641
- // is NON-OVERRIDABLE — a risk acceptance cannot supply missing input,
642
- // and the delivery gate refuses the override on that axis. Reject
643
- // accept_risk with a TYPED problem here instead of recording a false
644
- // "Apply is now available" ACK that the gate would then refuse.
645
- const workVeto = this.runWorkStateVeto(rec);
646
- if (workVeto) {
647
- return this.json(res, 409, {
648
- error: workVeto === "needs_input"
649
- ? "run reported it needs more input; a risk override cannot supply the missing input — re-run with the input (rerun_with_feedback)"
650
- : "run reported the work is incomplete; a risk override cannot finish it — re-run until it completes (rerun_with_feedback)",
651
- code: "work_state_needs_input",
652
- });
653
- }
654
- // The override unblocks a NEEDS-DECISION run (review blocked /
655
- // checks failed, D8); recording one elsewhere would claim an apply
656
- // permission that does not exist.
657
- if (!this.runNeedsDecision(rec)) {
658
- return this.json(res, 409, {
659
- error: rec.state === "succeeded"
660
- ? "run does not need a decision; apply it directly if its review is clean (no risk override needed)"
661
- : `run is ${rec.state}; risk overrides only unblock needs-decision runs (use rerun_with_feedback instead)`,
662
- });
663
- }
664
- const patch = readPatch(rec);
665
- if (patch === null)
666
- return this.json(res, 409, {
667
- error: "no patch artifact; there is nothing to unblock for apply",
668
- });
669
- const decision = this.recordOperatorDecision(rec, {
670
- action: decisionAction,
671
- findingIds: decisionBody.findingIds,
672
- acceptedRisks: decisionBody.acceptedRisks,
673
- patchSha256: sha256(patch),
674
- decidedAt: nowIso(),
675
- }, {
676
- key: decisionKey,
677
- client: "control-api",
678
- request: { runId: rec.runId ?? rec.id, body: decisionBody },
679
- });
680
- try {
681
- writeOperatorDecisionProjection(rec, decision);
682
- appendRunAuditEvent(rec, "control.applied", {
683
- decision: decisionAction,
684
- finding_ids: decisionBody.findingIds,
685
- accepted_risks: decisionBody.acceptedRisks,
686
- });
687
- }
688
- catch {
689
- // Journal authority remains queryable and replayable by Idempotency-Key.
690
- }
691
- return this.json(res, 200, ControlRunDecisionResponse.parse({
692
- accepted: true,
693
- status: "applied",
694
- // Honest ACK (QA-032B): nothing is mutated at decision time. The
695
- // risk is accepted for this exact patch; Apply is now available
696
- // and a fresh final check runs just-in-time before any mutation.
697
- message: `${decisionAction} recorded for this exact patch; Apply is now available and will run a fresh final check before changing the project`,
698
- }));
699
- });
638
+ return await acceptRiskDecision({
639
+ services: this.opts.services,
640
+ chainMutation: (record, work) => this.chainRunMutation(record, work),
641
+ workStateVeto: (record) => this.runWorkStateVeto(record),
642
+ needsDecision: (record) => this.runNeedsDecision(record),
643
+ readPatch,
644
+ writeProjection: writeOperatorDecisionProjection,
645
+ appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
646
+ json: (response, status, responseBody) => this.json(response, status, responseBody),
647
+ }, rec, body, decisionKey, res);
700
648
  }
701
649
  catch (error) {
702
650
  return this.requestError(res, error);
@@ -814,28 +762,23 @@ export class DaemonControlApiServer {
814
762
  return this.requestError(res, error);
815
763
  }
816
764
  }
817
- return rerunWithFeedback({
818
- daemon: this.opts.daemon,
819
- services: this.opts.services,
820
- waitForRunStart: (id) => this.waitForRunStart(id),
821
- appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
822
- json: (response, status, responseBody) => this.json(response, status, responseBody),
823
- }, rec, body, decisionKey, res);
765
+ try {
766
+ return await rerunWithFeedback({
767
+ daemon: this.opts.daemon,
768
+ services: this.opts.services,
769
+ serializeThreadMutation: (threadId, work) => chainThreadMutation(this.threadTurnChains, threadId, work),
770
+ waitForRunStart: (id) => this.waitForRunStart(id),
771
+ appendAudit: (record, payload) => appendRunAuditEvent(record, "control.applied", payload),
772
+ json: (response, status, responseBody) => this.json(response, status, responseBody),
773
+ }, rec, body, decisionKey, res);
774
+ }
775
+ catch (error) {
776
+ return this.requestError(res, error, 500);
777
+ }
824
778
  }
825
779
  if (method === "GET" && path === "/harnesses") {
826
780
  try {
827
- assertOnlyQueryParams(url, ["fresh", "all", "harness"]);
828
- const fresh = optionalBooleanQuery(url, "fresh");
829
- const includeFakes = optionalBooleanQuery(url, "all");
830
- const harnessIds = url.searchParams
831
- .getAll("harness")
832
- .map((id) => id.trim())
833
- .filter(Boolean);
834
- return this.service(res, "harnesses", {
835
- ...(fresh === undefined ? {} : { fresh }),
836
- ...(includeFakes === undefined ? {} : { includeFakes }),
837
- ...(harnessIds.length === 0 ? {} : { harnessIds }),
838
- }, ControlHarnessListResponse);
781
+ return this.service(res, "harnesses", parseHarnessListQuery(url), ControlHarnessListResponse);
839
782
  }
840
783
  catch (err) {
841
784
  return this.requestError(res, err);
@@ -843,6 +786,14 @@ export class DaemonControlApiServer {
843
786
  }
844
787
  if (method === "GET" && path === "/agent-capabilities")
845
788
  return this.service(res, "agentCapabilities", undefined, AgentCapabilityCatalog);
789
+ if (method === "GET" && path === "/run-applicability") {
790
+ try {
791
+ return this.service(res, "runApplicability", parseRunApplicabilityQuery(url), ControlRunApplicabilityResponse);
792
+ }
793
+ catch (error) {
794
+ return this.requestError(res, error);
795
+ }
796
+ }
846
797
  const harnessModelsMatch = /^\/harnesses\/([^/]+)\/models$/.exec(path);
847
798
  if (method === "GET" && harnessModelsMatch) {
848
799
  try {
@@ -976,8 +927,15 @@ export class DaemonControlApiServer {
976
927
  return this.service(res, "quota", undefined, ControlQuotaResponse);
977
928
  if (method === "POST" && path === "/quota")
978
929
  return this.service(res, "refreshQuota", undefined, ControlQuotaResponse);
979
- if (method === "GET" && path === "/credential-profiles")
980
- return this.service(res, "credentialProfiles", undefined, ControlCredentialProfilesResponse);
930
+ if (method === "GET" && path === "/credential-profiles") {
931
+ try {
932
+ const query = parseCredentialProfilesSnapshotQuery(url);
933
+ return this.service(res, "credentialProfiles", query.input, query.schema);
934
+ }
935
+ catch (error) {
936
+ return this.requestError(res, error);
937
+ }
938
+ }
981
939
  if (method === "POST" && path === "/credential-profiles") {
982
940
  let body;
983
941
  try {
@@ -1040,13 +998,30 @@ export class DaemonControlApiServer {
1040
998
  error: `run is ${rec.state}; ${body.control.kind} has nothing to stop`,
1041
999
  });
1042
1000
  }
1043
- await this.opts.daemon.cancel(rec.id);
1001
+ let activeDescendants;
1002
+ try {
1003
+ const cancelled = await cancelDelegationFamily({
1004
+ daemon: this.opts.daemon,
1005
+ parent: rec,
1006
+ descendantsAfterFence: () => this.delegatedDescendants(rec.runId ?? rec.id),
1007
+ pollMs: this.opts.pollMs,
1008
+ });
1009
+ activeDescendants = cancelled.descendants;
1010
+ }
1011
+ catch (error) {
1012
+ appendRunAuditEvent(rec, "control.rejected", {
1013
+ control: body.control,
1014
+ reason: error instanceof Error ? error.message : String(error),
1015
+ });
1016
+ return this.requestError(res, error);
1017
+ }
1044
1018
  appendRunAuditEvent(rec, "control.applied", { control: body.control });
1045
1019
  return this.json(res, 200, ControlRunControlResponse.parse({
1046
1020
  accepted: true,
1047
1021
  status: "applied",
1048
1022
  runId: rec.runId ?? rec.id,
1049
- message: `${body.control.kind} requested`,
1023
+ cascadeRunIds: activeDescendants.map((child) => child.runId ?? child.id),
1024
+ message: `${body.control.kind} requested${activeDescendants.length ? ` for parent and ${activeDescendants.length} delegated descendant(s)` : ""}`,
1050
1025
  }));
1051
1026
  }
1052
1027
  const eventsMatch = /^\/runs\/([^/]+)\/events$/.exec(path);
@@ -1298,6 +1273,11 @@ export class DaemonControlApiServer {
1298
1273
  const runs = await this.opts.daemon.list();
1299
1274
  return runs.find((r) => r.id === id || r.runId === id) ?? null;
1300
1275
  }
1276
+ /** Persisted Delegate-only descendant graph. `parentRunId` alone is broader
1277
+ * thread/retry lineage and deliberately does not participate. */
1278
+ async delegatedDescendants(parentRunId) {
1279
+ return delegatedDescendantsFromRecords(parentRunId, await this.opts.daemon.list());
1280
+ }
1301
1281
  /**
1302
1282
  * GET /v2/runs (QA-052): a bounded, newest-first, keyset-paginated page of run
1303
1283
  * summaries. Ordering, filtering, and slicing all happen on the raw daemon
@@ -1390,23 +1370,8 @@ export class DaemonControlApiServer {
1390
1370
  const state = facts.work_state?.state;
1391
1371
  return state === "needs_input" || state === "incomplete" ? state : null;
1392
1372
  }
1393
- recordOperatorDecision(rec, decision, idempotency) {
1394
- const record = this.opts.services?.recordOperatorDecision;
1395
- if (!record) {
1396
- throw Object.assign(new Error("operator decisions are not supported by this engine build"), {
1397
- status: 501,
1398
- });
1399
- }
1400
- return record(rec.runId ?? rec.id, rec.params, decision, idempotency);
1401
- }
1402
1373
  chainRunMutation(rec, work) {
1403
- const threadId = threadIdOfRun(rec);
1404
- if (!threadId)
1405
- return work();
1406
- return chainThreadMutation(this.threadTurnRouteCtx(), threadId, async () => {
1407
- await assertThreadIdle(rec, () => this.opts.daemon.list());
1408
- return work();
1409
- });
1374
+ return chainIdleRunMutation(this.threadTurnChains, this.opts.daemon, rec, work);
1410
1375
  }
1411
1376
  threadTurnRouteCtx() {
1412
1377
  const services = this.opts.services ?? {};
@@ -1417,10 +1382,12 @@ export class DaemonControlApiServer {
1417
1382
  resolveRunArtifactPath: async (runId, rel) => this.resolveRunArtifactPath(runId, rel),
1418
1383
  normalizeStart: runStart.normalizeRunStart,
1419
1384
  preflightRunRequirements: services.preflightRunRequirements,
1385
+ preflightThreadRunRequirements: services.preflightThreadRunRequirements,
1420
1386
  isTerminalState: (state) => TERMINAL_STATES.has(state),
1421
1387
  daemon: this.opts.daemon,
1422
1388
  threadDetail: services.threadDetail,
1423
1389
  createThreadTurn: services.createThreadTurn,
1390
+ findThreadTurnByIdempotency: services.findThreadTurnByIdempotency,
1424
1391
  setTurnEnqueueError: services.setTurnEnqueueError,
1425
1392
  threadTurnChains: this.threadTurnChains,
1426
1393
  };
@@ -1521,11 +1488,22 @@ export class DaemonControlApiServer {
1521
1488
  return this.summarizeRunLive(rec);
1522
1489
  }
1523
1490
  catch (err) {
1491
+ const delegatedFromRunId = Id.safeParse(paramsRecord(rec)["delegatedFromRunId"]);
1492
+ let waitingOnUser = false;
1493
+ try {
1494
+ waitingOnUser = this.pendingInteractionsFor(rec).length > 0;
1495
+ }
1496
+ catch {
1497
+ // The diagnostic row remains fail-soft even when interaction state is
1498
+ // also corrupt; false is the only safe fallback without evidence.
1499
+ }
1524
1500
  return ControlRunSummary.parse({
1525
1501
  jobId: rec.id,
1526
1502
  runId: rec.runId ?? rec.id,
1503
+ ...(delegatedFromRunId.success ? { delegatedFromRunId: delegatedFromRunId.data } : {}),
1504
+ waitingOnUser,
1527
1505
  state: "failed",
1528
- error: `unprojectable job record: ${err instanceof Error ? err.message : String(err)}`,
1506
+ error: redactSecrets(`unprojectable job record: ${err instanceof Error ? err.message : String(err)}`),
1529
1507
  });
1530
1508
  }
1531
1509
  }
@@ -1572,11 +1550,6 @@ export class DaemonControlApiServer {
1572
1550
  }
1573
1551
  }
1574
1552
  /* ---- Thread projections (engine snake_case -> control camelCase) ---- */
1575
- function paramsRecord(rec) {
1576
- return rec.params && typeof rec.params === "object" && !Array.isArray(rec.params)
1577
- ? rec.params
1578
- : {};
1579
- }
1580
1553
  function projectMetadata(rec) {
1581
1554
  const p = paramsRecord(rec);
1582
1555
  const scope = p["scope"];
@@ -1628,83 +1601,6 @@ function appendRunAuditEvent(rec, type, payload) {
1628
1601
  /* audit append must not change control behavior */
1629
1602
  }
1630
1603
  }
1631
- /**
1632
- * QA-052 observability: how many artifact-path probes the run-list fingerprinter
1633
- * has performed (each probe is a synchronous filesystem stat-family lookup). The
1634
- * single-parse test pattern (eventsParseCount) proves warm-list work is BOUNDED
1635
- * — by page size (only sliced records are fingerprinted) and, for terminal runs,
1636
- * by the one-probe short-circuit below (1 path instead of 12).
1637
- */
1638
- let fingerprintProbeCount = 0;
1639
- export function runListFingerprintProbeCountForTests() {
1640
- return fingerprintProbeCount;
1641
- }
1642
- export function resetRunListFingerprintProbeCountForTests() {
1643
- fingerprintProbeCount = 0;
1644
- }
1645
- function summaryFingerprint(rec) {
1646
- const mtime = (rel) => {
1647
- fingerprintProbeCount++;
1648
- if (!rec.runDir)
1649
- return 0;
1650
- const path = safeArtifactPath(rec.runDir, rel);
1651
- if (!path)
1652
- return 0;
1653
- try {
1654
- return statSync(path).mtimeMs;
1655
- }
1656
- catch {
1657
- return 0;
1658
- }
1659
- };
1660
- const identity = [rec.state, paramsFingerprint(rec), rec.finishedAt ?? "", rec.error ?? ""];
1661
- // QA-052 terminal short-circuit: a TERMINAL run's every projected artifact
1662
- // (events.jsonl, arbitration, telemetry, failure, the final/* outputs,
1663
- // work_product) is written before the daemon marks the run terminal and never
1664
- // changes afterward. The SOLE post-terminal mutation is the delivery/apply
1665
- // overlay (final/delivery_state.yaml), which the apply/revert route is the one
1666
- // writer of [B7]. So a warm re-list of a terminal run fingerprints ONE path
1667
- // instead of twelve — the cheap probe still detects an apply/revert, and every
1668
- // other axis is frozen. Active (queued/running) runs keep the full
1669
- // artifact-mtime fingerprint because their events.jsonl and final/* land while
1670
- // the run is still live.
1671
- if (TERMINAL_STATES.has(rec.state)) {
1672
- return [...identity, mtime("final/delivery_state.yaml")].join("|");
1673
- }
1674
- return [
1675
- ...identity,
1676
- mtime("events.jsonl"),
1677
- mtime("arbitration/decision.yaml"),
1678
- mtime("final/telemetry.yaml"),
1679
- mtime("final/failure.yaml"),
1680
- mtime("final/summary.md"),
1681
- // Primary outputs feed outputReadyState; a summary cached before the
1682
- // answer/plan/report/patch landed must invalidate when it does.
1683
- mtime("final/answer.md"),
1684
- mtime("final/plan.md"),
1685
- mtime("final/explore.md"),
1686
- mtime("final/report.md"),
1687
- mtime("final/patch.diff"),
1688
- // The honest outcome (result kind / diffstat / adopted) is projected from
1689
- // work_product.yaml; a summary cached before it landed must invalidate.
1690
- mtime("final/work_product.yaml"),
1691
- // The MUTABLE delivery state (applied / reverted / not_applied) is written
1692
- // to delivery_state.yaml by markRunApplyState, and controlRunResult prefers
1693
- // that overlay over work_product.yaml's immutable apply_state. It must be in
1694
- // the fingerprint set or a summary cached before an apply/revert keeps
1695
- // reporting the stale applyState (the dedicated apply route writes only this
1696
- // file, so no other fingerprinted mtime would move). [B7]
1697
- mtime("final/delivery_state.yaml"),
1698
- ].join("|");
1699
- }
1700
- function paramsFingerprint(rec) {
1701
- try {
1702
- return sha256(JSON.stringify(rec.params ?? null));
1703
- }
1704
- catch {
1705
- return "unserializable-params";
1706
- }
1707
- }
1708
1604
  /** v0.9 strategy flags projected back so surfaces can tell a race from a repair loop. */
1709
1605
  function strategyFromParams(p) {
1710
1606
  if (p["untilClean"] === true)
@@ -1826,6 +1722,13 @@ function runOutcomeFacts(rec, decision, failure,
1826
1722
  * folded into a DECISION-LESS terminal so a needs_input/incomplete ask
1827
1723
  * carries its veto axis just like an arbitrated agent run. */
1828
1724
  workState = null) {
1725
+ const legacy = legacyRunOutcomeFacts(rec, decision, failure, workState);
1726
+ if (!legacy)
1727
+ return null;
1728
+ return effectiveTerminalFacts(rec.runDir, legacy, decision, expectedRunFacts(rec)).outcomeFacts;
1729
+ }
1730
+ /** Legacy-only terminal projection retained for active/pre-RunFacts records. */
1731
+ function legacyRunOutcomeFacts(rec, decision, failure, workState = null) {
1829
1732
  if (!isTerminalLifecycle(rec.state))
1830
1733
  return null;
1831
1734
  // Arbitrated runs already carry work_state on decision.facts (INV-116).
@@ -1875,6 +1778,15 @@ function summarizeRun(rec, eventsSnapshot) {
1875
1778
  const externalContextPolicy = telemetry?.external_context_policy ?? task?.external_context.policy;
1876
1779
  const webEvidence = controlWebEvidence(telemetry, task);
1877
1780
  const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
1781
+ const failure = readFailure(rec);
1782
+ const workState = telemetry?.final_attempt_id
1783
+ ? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
1784
+ .work_state ?? null)
1785
+ : null;
1786
+ const legacyOutcome = legacyRunOutcomeFacts(rec, decision, failure, workState);
1787
+ const terminalFacts = legacyOutcome
1788
+ ? effectiveTerminalFacts(rec.runDir, legacyOutcome, decision, expectedRunFacts(rec))
1789
+ : { runFacts: null, outcomeFacts: null, decision };
1878
1790
  const budget = budgetSnapshot(rec, decision, eventsSnapshot);
1879
1791
  const parsedReviewerPanel = Array.isArray(p["reviewerPanel"])
1880
1792
  ? ControlReviewerPanelEntry.array().safeParse(p["reviewerPanel"])
@@ -1894,11 +1806,18 @@ function summarizeRun(rec, eventsSnapshot) {
1894
1806
  return ControlRunSummary.parse({
1895
1807
  jobId: rec.id,
1896
1808
  runId: rec.runId ?? rec.id,
1809
+ parentRunId: (typeof p["parentRunId"] === "string" ? p["parentRunId"] : null) ??
1810
+ task?.run_lineage.parent_run_id ??
1811
+ null,
1812
+ delegatedFromRunId: (typeof p["delegatedFromRunId"] === "string" ? p["delegatedFromRunId"] : null) ??
1813
+ task?.run_lineage.delegated_from_run_id ??
1814
+ null,
1815
+ delegation: telemetry?.delegation ?? null,
1897
1816
  taskId: rec.taskId,
1898
1817
  state: rec.state,
1899
1818
  runDir: rec.runDir,
1900
1819
  error: rec.error,
1901
- failure: readFailure(rec),
1820
+ failure,
1902
1821
  project: projectMetadata(rec),
1903
1822
  mode: parsedMode.success ? parsedMode.data : undefined,
1904
1823
  strategy: strategyFromParams(p),
@@ -1925,7 +1844,6 @@ function summarizeRun(rec, eventsSnapshot) {
1925
1844
  // The single engine validator's receipt, projected verbatim — surfaces
1926
1845
  // never re-validate the answer (null = no structured-output contract).
1927
1846
  outputConformance: outputConformance?.status ?? null,
1928
- // Route receipt projected verbatim (INV-061 disclosure); never re-derived.
1929
1847
  authRoute: telemetry?.auth_route
1930
1848
  ? {
1931
1849
  requested: telemetry.auth_route.requested,
@@ -1934,6 +1852,7 @@ function summarizeRun(rec, eventsSnapshot) {
1934
1852
  reason: telemetry.auth_route.reason,
1935
1853
  harnessId: telemetry.auth_route.harness_id,
1936
1854
  attemptId: telemetry.auth_route.attempt_id,
1855
+ profileId: telemetry.auth_route.profile_id,
1937
1856
  modelMismatch: telemetry.auth_route.model_mismatch,
1938
1857
  }
1939
1858
  : null,
@@ -1946,13 +1865,10 @@ function summarizeRun(rec, eventsSnapshot) {
1946
1865
  webEvidence,
1947
1866
  requestRequirements: telemetry?.request_requirements ?? [],
1948
1867
  toolPermissionPolicy: task?.tool_permission_policy,
1949
- outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, readFailure(rec)),
1868
+ outputReadyState: outputReadyState(rec, parsedMode.success ? parsedMode.data : null, failure, terminalFacts.runFacts),
1950
1869
  toolWarningsTotal: telemetry?.tool_warnings_total ?? 0,
1951
1870
  result: controlRunResult(rec),
1952
- outcomeFacts: runOutcomeFacts(rec, decision, readFailure(rec), telemetry?.final_attempt_id
1953
- ? (telemetry.attempts.find((a) => a.attempt_id === telemetry.final_attempt_id)?.outcome
1954
- .work_state ?? null)
1955
- : null),
1871
+ outcomeFacts: terminalFacts.outcomeFacts,
1956
1872
  route: controlRoute(telemetry, p),
1957
1873
  // QA-034: routing rationale projected verbatim from the engine telemetry —
1958
1874
  // surfaces never reconstruct the order from prose.
@@ -1989,7 +1905,7 @@ function controlRoute(telemetry, p) {
1989
1905
  verified: observed !== null,
1990
1906
  };
1991
1907
  }
1992
- function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
1908
+ function detailFor(rec, pendingInteractions = [], cursor, operator = null, children = []) {
1993
1909
  // Snapshot fence: capture the event cursor BEFORE building any projection.
1994
1910
  // The fence promise is "every event with seq <= lastSeq is reflected" — a
1995
1911
  // cursor read AFTER the projections could skip an event that landed in
@@ -2010,22 +1926,29 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
2010
1926
  ? readRunEventsWithIntegrity(rec)
2011
1927
  : { events: [], integrity: undefined };
2012
1928
  const summary = summarizeRun(rec, events);
2013
- // Non-null exactly when the run has an applyable patch — the authoritative
2014
- // "something to apply" banner signal (a convergence patch may carry kind:patch
2015
- // with no meta.result_kind, so result.kind alone would miss it).
2016
- const applyEligibility = applyEligibilityFor(rec, operator);
1929
+ // applyEligibility is non-null exactly when the run has an applyable patch —
1930
+ // the authoritative "something to apply" banner signal (a convergence patch
1931
+ // may carry kind:patch with no meta.result_kind, so result.kind alone would
1932
+ // miss it).
1933
+ const { runFacts, outcomeFacts, applyEligibility, requiredActions } = projectRunFactsForDetail(rec.runDir, summary.outcomeFacts ?? null, operator !== null, () => applyEligibilityFor(rec, operator), expectedRunFacts(rec));
2017
1934
  const planProjection = planProjectionFor(rec, summary.mode);
2018
1935
  return ControlRunDetail.parse({
2019
- summary: { ...summary, waitingOnUser: pendingInteractions.length > 0 },
1936
+ summary: {
1937
+ ...summary,
1938
+ outcomeFacts,
1939
+ waitingOnUser: pendingInteractions.length > 0,
1940
+ },
1941
+ children,
1942
+ runFacts,
2020
1943
  // Server-owned outcome headline (D18), from the single projection owner —
2021
1944
  // surfaces render it verbatim above model prose, never re-derive it.
2022
- outcomeBanner: outcomeBanner(summary.outcomeFacts ?? null, {
1945
+ outcomeBanner: outcomeBanner(outcomeFacts, {
2023
1946
  applyState: summary.result.applyState,
2024
1947
  hasApplyableChange: applyEligibility !== null,
2025
1948
  }),
2026
1949
  lastSeq,
2027
1950
  artifacts: rec.runDir ? listArtifacts(rec.runDir) : [],
2028
- primaryOutput: primaryOutput(rec, summary.mode, failure),
1951
+ primaryOutput: primaryOutput(rec, summary.mode, failure, runFacts),
2029
1952
  timeline: timelineEvents(rec, events, integrity),
2030
1953
  budget: budgetSnapshot(rec, decision, events, integrity),
2031
1954
  finalSummary: boundedArtifactText(rec, "final/summary.md"),
@@ -2049,8 +1972,9 @@ function detailFor(rec, pendingInteractions = [], cursor, operator = null) {
2049
1972
  // Minimal typed required-actions (GH #29) for a succeeded-but-blocked run,
2050
1973
  // from the single status-projection owner: review-blocked / checks-failed /
2051
1974
  // needs-decision / work_state needs_input, keyed to the same validated
2052
- // operator decision the needs-decision + apply gates consult.
2053
- requiredActions: requiredActionsFor(summary.outcomeFacts ?? null, operator !== null),
1975
+ // operator decision the needs-decision + apply gates consult — the
1976
+ // canonical terminal actions plus legitimate post-terminal overlays.
1977
+ requiredActions,
2054
1978
  });
2055
1979
  }
2056
1980
  function readTextArtifact(rec, relPath, redact = true) {
@@ -2074,42 +1998,6 @@ function parseAccessMaybe(value) {
2074
1998
  const parsed = AccessProfile.safeParse(value);
2075
1999
  return parsed.success ? parsed.data : undefined;
2076
2000
  }
2077
- /**
2078
- * Pure subscription-VALUATION fold over a run's events (QA-023c/QA-017b),
2079
- * independent of the cash truth. The owner-locked ledger settles native-
2080
- * subscription work to cash $0 while accumulating its token valuation on
2081
- * `budget.cash.valuation_usd` (last-wins, cumulative). An UNKNOWN valuation (no
2082
- * usage ever reported — e.g. a Cursor draft) stays NULL and is never coerced to
2083
- * a fake $0. The legacy fallback for runs predating budget.cash is the summed
2084
- * `budget.observation` spend. Vendor token valuation is at best an estimate.
2085
- */
2086
- export function budgetValuationFromEvents(events) {
2087
- let valuationUsd = null;
2088
- let observation = 0;
2089
- let sawObservation = false;
2090
- for (const ev of events) {
2091
- const payload = eventPayload(ev);
2092
- if (ev["type"] === "budget.cash") {
2093
- const val = payload["valuation_usd"];
2094
- if (typeof val === "number" && Number.isFinite(val))
2095
- valuationUsd = val;
2096
- continue;
2097
- }
2098
- if (ev["type"] === "budget.observation" && payload["kind"] === "spend") {
2099
- const usd = payload["usd"];
2100
- if (typeof usd === "number" && Number.isFinite(usd)) {
2101
- observation += usd;
2102
- sawObservation = true;
2103
- }
2104
- }
2105
- }
2106
- if (valuationUsd === null && sawObservation)
2107
- valuationUsd = observation;
2108
- return {
2109
- valuationUsd,
2110
- valuationKnowledge: valuationUsd === null ? "unknown" : "estimated",
2111
- };
2112
- }
2113
2001
  /** Typed severity per event type — no string matching over event names. */
2114
2002
  function budgetSnapshot(rec, decision, eventsSnapshot, integrity) {
2115
2003
  const p = paramsRecord(rec);
@@ -2126,7 +2014,9 @@ function budgetSnapshot(rec, decision, eventsSnapshot, integrity) {
2126
2014
  let source = spendUsd === null ? "unknown" : "decision";
2127
2015
  // Subscription VALUATION (QA-023c/QA-017b) beside cash — computed by the pure
2128
2016
  // event fold so an UNKNOWN valuation stays null, never a fabricated $0.
2129
- const { valuationUsd, valuationKnowledge } = budgetValuationFromEvents(evs);
2017
+ const normalized = normalizeLegacyBudgetComponents(decision?.budget_summary ?? null, evs, budgetValuationFromEvents(evs));
2018
+ let { valuationUsd, valuationKnowledge } = normalized;
2019
+ estimated = normalized.cashEstimated;
2130
2020
  if (spendUsd === null) {
2131
2021
  // The CASH truth for a decision-less run (plan/ask/explore — they never
2132
2022
  // write a decision record) is the ledger's own `budget.cash` disclosure:
@@ -2147,8 +2037,10 @@ function budgetSnapshot(rec, decision, eventsSnapshot, integrity) {
2147
2037
  const payload = eventPayload(ev);
2148
2038
  if (ev["type"] === "budget.cash") {
2149
2039
  const cash = payload["cash_spend_usd"];
2150
- if (typeof cash === "number" && Number.isFinite(cash))
2040
+ if (typeof cash === "number" && Number.isFinite(cash)) {
2151
2041
  lastCash = cash;
2042
+ estimated = cashEstimatedFromLedgerEvent(payload);
2043
+ }
2152
2044
  continue;
2153
2045
  }
2154
2046
  if (ev["type"] === "budget.observation" && payload["kind"] === "spend") {
@@ -2270,9 +2162,10 @@ function applyGateError(rec, patch, targetRepoRoot, operatorDecision, finalVerif
2270
2162
  }
2271
2163
  function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
2272
2164
  const decision = safeReadStructuredArtifact(rec, "arbitration/decision.yaml", DecisionRecord);
2165
+ const terminal = effectiveTerminalFacts(rec.runDir, decision?.facts ?? null, decision, expectedRunFacts(rec));
2273
2166
  return {
2274
2167
  state: rec.state,
2275
- decision,
2168
+ decision: terminal.decision,
2276
2169
  workProduct: safeReadStructuredArtifact(rec, "final/work_product.yaml", WorkProduct),
2277
2170
  patch,
2278
2171
  originalRepoRoot: runRepoRoot(rec),
@@ -2285,9 +2178,10 @@ function applyGateInputFor(rec, patch, targetRepoRoot, operatorDecision) {
2285
2178
  // an already-applied / reverted run gets a terminal eligibility disposition
2286
2179
  // instead of a stale "rerun a fresh check" (QA-021).
2287
2180
  applyState: controlRunResult(rec).applyState,
2288
- // D-16 work_state veto (INV-116): thread the model-attested work outcome so
2289
- // the gate refuses a needs_input/incomplete winner even with a clean review.
2290
- workState: decision?.facts?.work_state ?? null,
2181
+ // D-16 work_state veto (INV-116): thread the model-attested work outcome
2182
+ // (the canonical terminal facts) so the gate refuses a needs_input/
2183
+ // incomplete winner even with a clean legacy review.
2184
+ workState: terminal.outcomeFacts?.work_state ?? null,
2291
2185
  };
2292
2186
  }
2293
2187
  /**