openplanr 1.21.2 → 1.22.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 (100) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/doctor.js +11 -1
  3. package/dist/cli/commands/doctor.js.map +1 -1
  4. package/dist/cli/commands/operate.d.ts.map +1 -1
  5. package/dist/cli/commands/operate.js +91 -2
  6. package/dist/cli/commands/operate.js.map +1 -1
  7. package/dist/services/operate/advisors.d.ts +63 -0
  8. package/dist/services/operate/advisors.d.ts.map +1 -1
  9. package/dist/services/operate/advisors.js +68 -4
  10. package/dist/services/operate/advisors.js.map +1 -1
  11. package/dist/services/operate/artifacts.d.ts +9 -3
  12. package/dist/services/operate/artifacts.d.ts.map +1 -1
  13. package/dist/services/operate/artifacts.js +126 -5
  14. package/dist/services/operate/artifacts.js.map +1 -1
  15. package/dist/services/operate/citation-resolution.d.ts +14 -0
  16. package/dist/services/operate/citation-resolution.d.ts.map +1 -1
  17. package/dist/services/operate/citation-resolution.js +9 -0
  18. package/dist/services/operate/citation-resolution.js.map +1 -1
  19. package/dist/services/operate/completion.d.ts +80 -0
  20. package/dist/services/operate/completion.d.ts.map +1 -0
  21. package/dist/services/operate/completion.js +279 -0
  22. package/dist/services/operate/completion.js.map +1 -0
  23. package/dist/services/operate/config.d.ts +7 -0
  24. package/dist/services/operate/config.d.ts.map +1 -1
  25. package/dist/services/operate/config.js +7 -0
  26. package/dist/services/operate/config.js.map +1 -1
  27. package/dist/services/operate/context-research.d.ts +43 -0
  28. package/dist/services/operate/context-research.d.ts.map +1 -1
  29. package/dist/services/operate/context-research.js +219 -1
  30. package/dist/services/operate/context-research.js.map +1 -1
  31. package/dist/services/operate/decision-brief.d.ts +90 -1
  32. package/dist/services/operate/decision-brief.d.ts.map +1 -1
  33. package/dist/services/operate/decision-brief.js +175 -1
  34. package/dist/services/operate/decision-brief.js.map +1 -1
  35. package/dist/services/operate/doctor.d.ts +58 -0
  36. package/dist/services/operate/doctor.d.ts.map +1 -1
  37. package/dist/services/operate/doctor.js +395 -21
  38. package/dist/services/operate/doctor.js.map +1 -1
  39. package/dist/services/operate/engine.d.ts +75 -2
  40. package/dist/services/operate/engine.d.ts.map +1 -1
  41. package/dist/services/operate/engine.js +504 -33
  42. package/dist/services/operate/engine.js.map +1 -1
  43. package/dist/services/operate/event-store.d.ts +22 -0
  44. package/dist/services/operate/event-store.d.ts.map +1 -1
  45. package/dist/services/operate/event-store.js +35 -0
  46. package/dist/services/operate/event-store.js.map +1 -1
  47. package/dist/services/operate/evidence-cache.d.ts.map +1 -1
  48. package/dist/services/operate/evidence-cache.js +41 -5
  49. package/dist/services/operate/evidence-cache.js.map +1 -1
  50. package/dist/services/operate/index.d.ts.map +1 -1
  51. package/dist/services/operate/index.js +98 -1
  52. package/dist/services/operate/index.js.map +1 -1
  53. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  54. package/dist/services/operate/interaction/question-engine.js +36 -8
  55. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  56. package/dist/services/operate/interaction/question-registry.d.ts +8 -0
  57. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  58. package/dist/services/operate/interaction/question-registry.js +21 -2
  59. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  60. package/dist/services/operate/lifecycle-driver.d.ts +309 -0
  61. package/dist/services/operate/lifecycle-driver.d.ts.map +1 -0
  62. package/dist/services/operate/lifecycle-driver.js +434 -0
  63. package/dist/services/operate/lifecycle-driver.js.map +1 -0
  64. package/dist/services/operate/maintenance.d.ts +65 -1
  65. package/dist/services/operate/maintenance.d.ts.map +1 -1
  66. package/dist/services/operate/maintenance.js +718 -24
  67. package/dist/services/operate/maintenance.js.map +1 -1
  68. package/dist/services/operate/mission-dispatch.d.ts +61 -1
  69. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  70. package/dist/services/operate/mission-dispatch.js +78 -5
  71. package/dist/services/operate/mission-dispatch.js.map +1 -1
  72. package/dist/services/operate/profile-migration.d.ts +69 -0
  73. package/dist/services/operate/profile-migration.d.ts.map +1 -0
  74. package/dist/services/operate/profile-migration.js +462 -0
  75. package/dist/services/operate/profile-migration.js.map +1 -0
  76. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  77. package/dist/services/operate/projection-persistence.js +24 -5
  78. package/dist/services/operate/projection-persistence.js.map +1 -1
  79. package/dist/services/operate/projection.d.ts.map +1 -1
  80. package/dist/services/operate/projection.js +9 -0
  81. package/dist/services/operate/projection.js.map +1 -1
  82. package/dist/services/operate/protocol.d.ts +1 -0
  83. package/dist/services/operate/protocol.d.ts.map +1 -1
  84. package/dist/services/operate/protocol.js.map +1 -1
  85. package/dist/services/operate/scratch.d.ts +81 -0
  86. package/dist/services/operate/scratch.d.ts.map +1 -0
  87. package/dist/services/operate/scratch.js +190 -0
  88. package/dist/services/operate/scratch.js.map +1 -0
  89. package/dist/services/operate/types.d.ts +3 -2
  90. package/dist/services/operate/types.d.ts.map +1 -1
  91. package/dist/services/operate/types.js.map +1 -1
  92. package/dist/services/operate/workspace.d.ts +1 -0
  93. package/dist/services/operate/workspace.d.ts.map +1 -1
  94. package/dist/services/operate/workspace.js +2 -0
  95. package/dist/services/operate/workspace.js.map +1 -1
  96. package/dist/services/runtime-manager-service.d.ts +23 -0
  97. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  98. package/dist/services/runtime-manager-service.js +30 -0
  99. package/dist/services/runtime-manager-service.js.map +1 -1
  100. package/package.json +2 -2
@@ -3,18 +3,21 @@ import { mkdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { OPENPLANR_VERSION } from '../../utils/package-version.js';
5
5
  import { loadConfig } from '../config-service.js';
6
- import { advisorFailureGaps, buildAdvisorOperatingContext, configuredAdvisorProviderPolicy, createConfiguredStructuredAdapter, createOfflineAdvisorAdapter, dispatchOperatingAdvisors, ensureOperatingProviderConsent, } from './advisors.js';
6
+ import { advisorFailureGaps, buildAdvisorOperatingContext, configuredAdvisorProviderPolicy, createConfiguredStructuredAdapter, createOfflineAdvisorAdapter, DEFAULT_OPERATING_DISPATCH_CONCURRENCY, dispatchOperatingAdvisors, ensureOperatingProviderConsent, } from './advisors.js';
7
7
  import { recordOperatingCadenceRun } from './cadence.js';
8
8
  import { canonicalDigest, canonicalize } from './canonical.js';
9
9
  import { operatingProjectKey, validateOperatingConfiguration } from './config.js';
10
10
  import { consolidateOperatingResults } from './consolidation.js';
11
- import { OperatingEventStore } from './event-store.js';
11
+ import { chairBoardGapLines, classifyChairRoleContributions, } from './decision-brief.js';
12
+ import { OperatingEventStore, toPersistedDataGap, } from './event-store.js';
12
13
  import { evidenceProjectionSources } from './evidence.js';
13
14
  import { OperatingEvidenceCache } from './evidence-cache.js';
14
15
  import { nextCycleId } from './ids.js';
16
+ import { buildOperatingIntegritySummary, } from './integrity.js';
15
17
  import { enforceRecordedProposalCitations } from './interaction/action-service.js';
16
18
  import { applyJournalTransaction, prepareJournalTransaction, readJournal, rollbackJournalTransaction, } from './journal.js';
17
19
  import { reconcileOperatingDecisionDeadlines } from './lifecycle.js';
20
+ import { AdvisorLifecycleDriver, } from './lifecycle-driver.js';
18
21
  import { withOperatingLock } from './lock-service.js';
19
22
  import { reconcileOperatingOutcomeFiles } from './outcomes.js';
20
23
  import { persistOperatingProjections } from './projection-persistence.js';
@@ -107,11 +110,28 @@ function requiredOpenEvidenceRefs(state) {
107
110
  : [])),
108
111
  ].sort();
109
112
  }
110
- export function buildChairEvidence(evidence, results, now) {
113
+ /**
114
+ * The Chair's grounded input assembled from the board (FR13). Each recorded role
115
+ * contributes its real analysis as synthetic advisor-result evidence, labelled
116
+ * with its six-way outcome. Citation-rejected proposals are EXCLUDED from the
117
+ * grounded input (never silently dropped — they are named in the gap list), so a
118
+ * proposal Chair could not have grounded can never reach its synthesis. Roles
119
+ * with no valid analysis (not-evaluated, failed, still-running, all-rejected)
120
+ * contribute no proposal item: their absence is surfaced as a gap, never as a
121
+ * fabricated conclusion.
122
+ */
123
+ export function buildChairEvidence(evidence, results, now, context = {}) {
124
+ const contributions = classifyChairRoleContributions(results, context);
125
+ const outcomeByRole = new Map(contributions.map((entry) => [entry.roleId, entry.outcome]));
126
+ const excludedByRole = new Map(contributions.map((entry) => [entry.roleId, new Set(entry.excludedProposalKeys)]));
111
127
  const evidenceById = new Map(evidence.items.map((item) => [item.id, item]));
112
128
  const synthetic = results.flatMap((result) => {
129
+ const excluded = excludedByRole.get(result.roleId) ?? new Set();
130
+ // Grounded proposals only: a citation-rejected proposal is excluded here so it
131
+ // never becomes Chair-citable evidence, while `chairBoardGapLines` names it.
132
+ const groundedProposals = result.proposals.filter((proposal) => !excluded.has(proposal.proposalKey));
113
133
  const evidenceRefs = [
114
- ...new Set(result.proposals.flatMap((proposal) => proposal.evidenceRefs)),
134
+ ...new Set(groundedProposals.flatMap((proposal) => proposal.evidenceRefs)),
115
135
  ].sort();
116
136
  const missingRefs = evidenceRefs.filter((reference) => !evidenceById.has(reference));
117
137
  if (missingRefs.length > 0) {
@@ -127,12 +147,13 @@ export function buildChairEvidence(evidence, results, now) {
127
147
  location: `${result.roleId}/context`,
128
148
  body: {
129
149
  roleId: result.roleId,
130
- outcome: result.outcome,
150
+ outcome: outcomeByRole.get(result.roleId) ?? result.outcome,
131
151
  gaps: [...new Set(result.gaps)].sort(),
132
152
  conflicts: [...new Set(result.conflicts)].sort(),
153
+ ...(excluded.size > 0 ? { citationRejectedProposals: [...excluded].sort() } : {}),
133
154
  },
134
155
  },
135
- ...result.proposals.map((proposal) => ({
156
+ ...groundedProposals.map((proposal) => ({
136
157
  id: `EVD-advisor-results-${result.roleId}-${proposal.proposalKey}`,
137
158
  location: `${result.roleId}/proposals/${proposal.proposalKey}`,
138
159
  body: {
@@ -166,6 +187,21 @@ export function buildChairEvidence(evidence, results, now) {
166
187
  }),
167
188
  };
168
189
  }
190
+ /**
191
+ * Assemble the Chair's input from a possibly-partial board (FR13). This is the
192
+ * single entry point that ties the grounded evidence to the honest per-role
193
+ * ledger and the named-gap list: one missing perspective can never destroy an
194
+ * otherwise-useful board, and the Chair is handed a structural record of what did
195
+ * NOT evaluate rather than a silent absence it might synthesize around.
196
+ */
197
+ export function assembleChairBoardInput(evidence, results, now, context = {}) {
198
+ const contributions = classifyChairRoleContributions(results, context);
199
+ return {
200
+ evidence: buildChairEvidence(evidence, results, now, context),
201
+ contributions,
202
+ gaps: chairBoardGapLines(contributions),
203
+ };
204
+ }
169
205
  /**
170
206
  * FR1/FR2 (isolation rebind): the cycle's pinned-revision evidence seed. Evidence
171
207
  * is an OUTPUT now, so a fresh cycle no longer collects a body-bearing snapshot
@@ -511,7 +547,39 @@ export async function gateRecordedProposalCitations(input) {
511
547
  const enforcement = await enforceRecordedProposalCitations(bearing, input.context);
512
548
  const rejectedKeys = new Set(enforcement.rejected.map((entry) => entry.proposalKey));
513
549
  const evidenceByKey = new Map(enforcement.accepted.map((entry) => [entry.proposal.proposalKey, entry.evidenceRefs]));
514
- const gaps = [...enforcement.gaps];
550
+ // FR13 (T-017): name the role whose proposal each unresolvable-citation gap
551
+ // rejected, so the integrity summary links the citation rejection to that role.
552
+ // Without it the gap carries no `affectedRoles`, the Chair board cannot tell a
553
+ // fully-rejected role's `citation-rejected` outcome apart from a plain
554
+ // not-evaluated absence, and the integrity report's "Affected:" line is blank.
555
+ const roleByProposalKey = new Map();
556
+ for (const result of input.roleResults) {
557
+ for (const proposal of result.proposals) {
558
+ roleByProposalKey.set(proposal.proposalKey, result.roleId);
559
+ }
560
+ }
561
+ const rolesByGapId = new Map();
562
+ for (const entry of enforcement.rejected) {
563
+ const roleId = roleByProposalKey.get(entry.proposalKey);
564
+ if (!roleId)
565
+ continue;
566
+ const roles = rolesByGapId.get(entry.gapId) ?? new Set();
567
+ roles.add(roleId);
568
+ rolesByGapId.set(entry.gapId, roles);
569
+ }
570
+ const gaps = enforcement.gaps.map((gap) => {
571
+ const roles = rolesByGapId.get(String(gap.id));
572
+ if (!roles || roles.size === 0)
573
+ return gap;
574
+ const record = gap;
575
+ const existing = Array.isArray(record.affectedRoles)
576
+ ? record.affectedRoles
577
+ : [];
578
+ return {
579
+ ...record,
580
+ affectedRoles: [...new Set([...existing, ...roles])].sort(),
581
+ };
582
+ });
515
583
  const notEvaluatedRoleIds = [];
516
584
  const now = input.context.now ?? new Date();
517
585
  const roleResults = await Promise.all(input.roleResults.map(async (result) => {
@@ -543,6 +611,39 @@ export async function gateRecordedProposalCitations(input) {
543
611
  }));
544
612
  return { roleResults, gaps, notEvaluatedRoleIds };
545
613
  }
614
+ /**
615
+ * FR3/FR13 (SPEC-005 T-015): mint the governed data gap persisted for a lens the
616
+ * lifecycle driver terminated `not_evaluated` — a stalled lens that exhausted its
617
+ * bounded retry budget. It carries the driver's REAL governed reason and links
618
+ * the role so `status`/`report` render a governed gap rather than a silent
619
+ * absence. It is shaped as a v1.2.0 `operating-data-gap` (no `category`) so it
620
+ * validates as a `data-gap` record, exactly as `readinessGaps` gaps do; the
621
+ * Chair board's `not-evaluated` CLASSIFICATION is sourced separately, directly
622
+ * from the driver's in-memory governed gaps, so it needs no v1.3 category here.
623
+ * The digest-derived id keeps it canonical and idempotent across resume.
624
+ */
625
+ function driverNotEvaluatedGap(gap, cycleId, owner, now) {
626
+ return {
627
+ kind: 'operating-data-gap',
628
+ schemaVersion: OPERATE_SCHEMA_VERSION,
629
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
630
+ id: `GAP-${canonicalDigest({
631
+ roleId: gap.roleId,
632
+ cycleId,
633
+ reason: 'lifecycle-not-evaluated',
634
+ }).slice('sha256:'.length)}`,
635
+ cycleId,
636
+ question: `Why did ${gap.roleId} not evaluate this cycle?`,
637
+ reason: gap.reason,
638
+ unblocks: [],
639
+ affectedRoles: [gap.roleId],
640
+ status: 'open',
641
+ owner: owner && owner.length > 0 ? owner : 'chair',
642
+ evidenceRefs: [],
643
+ createdAt: now,
644
+ updatedAt: now,
645
+ };
646
+ }
546
647
  export async function runOperatingCycle(input) {
547
648
  const projectRoot = await assertOperatingProject(input.projectRoot);
548
649
  const config = await validateOperatingConfiguration(projectRoot);
@@ -763,7 +864,23 @@ export async function runOperatingCycle(input) {
763
864
  };
764
865
  const existingByRole = new Map(existingRoleResults.map((result) => [result.roleId, result]));
765
866
  const missingNonChair = readiness.roles.filter((role) => role.roleId !== 'chair' && !existingByRole.has(role.roleId));
766
- const runnableMissingNonChair = missingNonChair.filter((role) => role.modelCallAllowed);
867
+ // SPEC-005 T-020 (FR13): a non-chair lens with NO committed result that is
868
+ // nonetheless named by an open governed cycle gap was terminated
869
+ // `not_evaluated` — a runtime `harness abandon`, or the operator escape after a
870
+ // stall. It is terminal, not runnable: exclude it so a genuinely stalled lens
871
+ // no longer strands the cycle at `phase: advisors` forever, exactly as a
872
+ // recorded lens does not block it. On the adapter-lifecycle path the only
873
+ // committed cycle gaps that name a lens with no result are these terminal ones
874
+ // (the citation gate's gaps always co-occur with a recorded result, handled by
875
+ // `existingByRole`; readiness gaps are never persisted on this path), so this
876
+ // never suppresses a still-pending lens — a pending lens has no naming gap yet.
877
+ const terminatedNonChair = new Set(missingNonChair
878
+ .filter((role) => initialState.dataGaps.some((gap) => (gap.cycleId === undefined || gap.cycleId === cycle.id) &&
879
+ !['closed', 'superseded'].includes(String(gap.status)) &&
880
+ Array.isArray(gap.affectedRoles) &&
881
+ gap.affectedRoles.includes(role.roleId)))
882
+ .map((role) => role.roleId));
883
+ const runnableMissingNonChair = missingNonChair.filter((role) => role.modelCallAllowed && !terminatedNonChair.has(role.roleId));
767
884
  const skippedMissingNonChair = missingNonChair
768
885
  .filter((role) => !role.modelCallAllowed || role.readiness === 'not_evaluated')
769
886
  .map((role) => ({
@@ -795,27 +912,182 @@ export async function runOperatingCycle(input) {
795
912
  };
796
913
  }
797
914
  await assertCycleWorkspace();
798
- const first = runnableMissingNonChair.length > 0
799
- ? await dispatchOperatingAdvisors({
800
- cycleId: cycle.id,
801
- projectRoot,
802
- pinnedRevision: workspace.controlRepository.pinnedRevision,
803
- readiness: { ...readiness, roles: missingNonChair },
804
- context: advisorContext,
805
- adapter: await resolveAdapter(),
806
- depth,
807
- runtime: resolvedRuntime,
808
- resolveCitations,
809
- })
810
- : {
811
- results: [],
812
- provenance: [],
813
- modelCalls: 0,
814
- blocked: false,
815
- skipped: skippedMissingNonChair,
816
- failed: [],
817
- gaps: [],
915
+ // FR3 (SPEC-005 T-015): drive the non-chair advisor fan-out through the
916
+ // AdvisorLifecycleDriver (T-003) rather than a single blocking dispatch. The
917
+ // driver owns the per-role lifecycle around the runtime dispatch — bounded
918
+ // retry, per-attempt timeout, and automatic lease heartbeat — and never
919
+ // dispatches an agent itself: its `dispatch` callback IS the runtime dispatch
920
+ // (a single-role `dispatchOperatingAdvisors`) and `recordRole` commits the
921
+ // role's built result. A stalled lens is retried within its budget then
922
+ // terminated `not_evaluated` with a governed gap while its siblings keep
923
+ // recording and the lease keeps renewing — none of which the pre-driver
924
+ // single-call fan-out could survive, because one hung role deadlocked the
925
+ // whole `Promise.all`. A healthy board records identically to before.
926
+ const lifecycleTuning = input.advisorLifecycle ?? {};
927
+ const driveNonChairFanOut = async () => {
928
+ if (runnableMissingNonChair.length === 0) {
929
+ return {
930
+ dispatch: {
931
+ results: [],
932
+ provenance: [],
933
+ modelCalls: 0,
934
+ blocked: false,
935
+ skipped: skippedMissingNonChair,
936
+ failed: [],
937
+ gaps: [],
938
+ },
939
+ };
940
+ }
941
+ const adapter = await resolveAdapter();
942
+ const protocol = await loadOperatingProtocol();
943
+ // Registry order so the aggregated per-role results are byte-identical to
944
+ // the single-call dispatch's canonically-ordered result set (FR4).
945
+ const registryOrder = new Map(protocol.listOperatingRoles().map((role, index) => [
946
+ role.id,
947
+ index,
948
+ ]));
949
+ const readinessByRole = new Map(readiness.roles.map((role) => [role.roleId, role]));
950
+ const recordedResults = new Map();
951
+ const provenanceByRole = new Map();
952
+ const aggregatedGaps = [];
953
+ // Roles whose single-role dispatch produced a genuine FAILURE (an adapter
954
+ // error), as distinct from a STALL (a per-attempt timeout with no dispatch
955
+ // error). A genuine failure is preserved as `failed` — the pre-driver
956
+ // semantics: an `X failed` warning plus an `advisorFailureGaps` gap — while
957
+ // only a true stall becomes a governed not_evaluated gap (FR3).
958
+ const failedEntries = [];
959
+ let aggregatedModelCalls = 0;
960
+ let aggregatedBlocked = false;
961
+ const clock = lifecycleTuning.now ?? (() => Date.now());
962
+ // The engine-managed cycle lease window (FR2). Defaults to the documented
963
+ // 15-minute session lease; the driver's heartbeat renews it independent of
964
+ // any role completing, so a slow lens cannot expire recorded work.
965
+ const leaseWindowMs = lifecycleTuning.leaseWindowMs ?? 900_000;
966
+ let leaseExpiresAtMs = clock() + leaseWindowMs;
967
+ const plans = runnableMissingNonChair.map((role) => ({
968
+ roleId: role.roleId,
969
+ // Every advisory lens is structurally OPTIONAL here: a stalled lens
970
+ // terminates not_evaluated with a governed gap and the cycle continues to
971
+ // Chair (FR3/FR13); nothing in the board is structurally required.
972
+ retryBudget: lifecycleTuning.retryBudget ?? 0,
973
+ }));
974
+ const driver = new AdvisorLifecycleDriver({
975
+ roles: plans,
976
+ now: clock,
977
+ ...(lifecycleTuning.setTimer ? { setTimer: lifecycleTuning.setTimer } : {}),
978
+ ...(lifecycleTuning.clearTimer ? { clearTimer: lifecycleTuning.clearTimer } : {}),
979
+ ...(lifecycleTuning.roleTimeoutMs !== undefined
980
+ ? { roleTimeoutMs: lifecycleTuning.roleTimeoutMs }
981
+ : {}),
982
+ ...(lifecycleTuning.heartbeatLeadMs !== undefined
983
+ ? { heartbeatLeadMs: lifecycleTuning.heartbeatLeadMs }
984
+ : {}),
985
+ leaseExpiresAtMs,
986
+ concurrency: DEFAULT_OPERATING_DISPATCH_CONCURRENCY,
987
+ heartbeat: async () => {
988
+ leaseExpiresAtMs = clock() + leaseWindowMs;
989
+ return { leaseExpiresAtMs };
990
+ },
991
+ recordRole: async ({ roleId, output }) => {
992
+ recordedResults.set(roleId, output);
993
+ leaseExpiresAtMs = clock() + leaseWindowMs;
994
+ return { recorded: true, leaseExpiresAtMs };
995
+ },
996
+ });
997
+ const dispatchOne = async (roleId) => {
998
+ const roleReadiness = readinessByRole.get(roleId);
999
+ if (!roleReadiness) {
1000
+ throw new OperateError('E_OPERATE_STATE_INVALID', `No dispatch readiness for advisor role ${roleId}.`);
1001
+ }
1002
+ const single = await dispatchOperatingAdvisors({
1003
+ cycleId: cycle.id,
1004
+ projectRoot,
1005
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
1006
+ readiness: { ...readiness, roles: [roleReadiness] },
1007
+ context: advisorContext,
1008
+ adapter,
1009
+ depth,
1010
+ runtime: resolvedRuntime,
1011
+ resolveCitations,
1012
+ });
1013
+ aggregatedModelCalls += single.modelCalls;
1014
+ for (const entry of single.provenance)
1015
+ provenanceByRole.set(entry.roleId, entry);
1016
+ aggregatedGaps.push(...single.gaps);
1017
+ if (single.blocked)
1018
+ aggregatedBlocked = true;
1019
+ const built = single.results.find((result) => result.roleId === roleId);
1020
+ if (built)
1021
+ return built;
1022
+ // The lens dispatch errored. Record it as a genuine failure (preserved as
1023
+ // `failed` below) and surface it to the driver, which terminates the role.
1024
+ // A HANG instead never reaches here: the driver's per-attempt timeout
1025
+ // rejects `dispatchOne` from outside, so a timeout is classified a stall
1026
+ // (not a failure) and becomes a governed not_evaluated gap. The driver
1027
+ // dispatches nothing itself — this callback IS the dispatch.
1028
+ const failure = single.failed.find((entry) => entry.roleId === roleId);
1029
+ failedEntries.push({
1030
+ roleId,
1031
+ message: failure?.message ?? `Advisor role ${roleId} produced no committed result.`,
1032
+ });
1033
+ throw new OperateError('E_OPERATE_ADVISOR_FAILED', failure?.message ?? `Advisor role ${roleId} produced no committed result.`);
1034
+ };
1035
+ const heartbeatIntervalMs = lifecycleTuning.heartbeatIntervalMs ?? 60_000;
1036
+ const stopHeartbeat = driver.scheduleHeartbeat(heartbeatIntervalMs);
1037
+ try {
1038
+ await driver.run({
1039
+ parallel: Boolean(adapter.parallelDispatch),
1040
+ dispatch: (roleId) => dispatchOne(roleId),
1041
+ });
1042
+ }
1043
+ finally {
1044
+ stopHeartbeat();
1045
+ }
1046
+ const governedGaps = driver.governedGaps();
1047
+ const failedRoleIds = new Set(failedEntries.map((entry) => entry.roleId));
1048
+ // Only a TRUE stall (a timeout, never a dispatch failure) becomes a governed
1049
+ // not_evaluated gap; a role the driver terminated after a genuine dispatch
1050
+ // error is preserved as `failed` and drops out of the stall set.
1051
+ const stallGaps = governedGaps.filter((gap) => gap.outcome === 'not_evaluated' && !failedRoleIds.has(gap.roleId));
1052
+ const results = [...recordedResults.values()].sort((left, right) => (registryOrder.get(left.roleId) ?? Number.MAX_SAFE_INTEGER) -
1053
+ (registryOrder.get(right.roleId) ?? Number.MAX_SAFE_INTEGER));
1054
+ const provenance = results
1055
+ .map((result) => provenanceByRole.get(result.roleId))
1056
+ .filter((entry) => entry !== undefined);
1057
+ // Genuine dispatch failures preserved with the pre-driver semantics, plus any
1058
+ // structurally-required lens the driver terminated `failed` (none on this
1059
+ // path, where every lens is optional).
1060
+ const failed = [
1061
+ ...failedEntries,
1062
+ ...governedGaps
1063
+ .filter((gap) => gap.outcome === 'failed')
1064
+ .map((gap) => ({ roleId: gap.roleId, message: gap.reason })),
1065
+ ];
1066
+ return {
1067
+ dispatch: {
1068
+ results,
1069
+ provenance,
1070
+ modelCalls: aggregatedModelCalls,
1071
+ blocked: aggregatedBlocked,
1072
+ skipped: skippedMissingNonChair,
1073
+ failed,
1074
+ gaps: [...aggregatedGaps],
1075
+ },
1076
+ lifecycle: {
1077
+ snapshot: driver.snapshot(),
1078
+ // Only the genuine stalls — a failed lens is surfaced through `failed`,
1079
+ // not as a not_evaluated governed gap.
1080
+ gaps: stallGaps,
1081
+ chairReady: driver.chairReady(),
1082
+ heartbeats: driver.heartbeatsIssued(),
1083
+ recorded: results.map((result) => result.roleId),
1084
+ notEvaluated: stallGaps.map((gap) => gap.roleId),
1085
+ },
818
1086
  };
1087
+ };
1088
+ const fanOut = await driveNonChairFanOut();
1089
+ const first = fanOut.dispatch;
1090
+ const advisorLifecycleSummary = fanOut.lifecycle;
819
1091
  let roleResults = [
820
1092
  ...existingRoleResults.filter((result) => result.roleId !== 'chair'),
821
1093
  ...first.results,
@@ -829,6 +1101,7 @@ export async function runOperatingCycle(input) {
829
1101
  // citations (unresolvable-citation, empty-grounding) are recorded alongside
830
1102
  // the cycle's other gaps.
831
1103
  const dispatchGaps = [...first.gaps];
1104
+ let chairBoard;
832
1105
  if (config.enabledRoles.includes('chair')) {
833
1106
  // FR1: the Chair dispatches its own mandate and investigates independently —
834
1107
  // it no longer receives a synthesized advisor-results evidence body. Its
@@ -880,6 +1153,174 @@ export async function runOperatingCycle(input) {
880
1153
  },
881
1154
  };
882
1155
  }
1156
+ // FR13 (SPEC-005 T-015): assemble the Chair's grounded input from the
1157
+ // partial, honestly-labelled non-chair board with T-004's assembler, then
1158
+ // feed its contributions and named-gap lines into the Chair mandate. The
1159
+ // integrity summary (built from the cycle's committed + dispatch gaps), the
1160
+ // dispatch `failed` list, and the citation gate's rejected proposal keys are
1161
+ // threaded in so every not-evaluated / failed / still-running /
1162
+ // citation-rejected lens is surfaced to the Chair as a gap it must NOT
1163
+ // synthesize around. Absent lenses contribute ZERO proposal items to the
1164
+ // grounded evidence by construction — the assembler only synthesizes from
1165
+ // roles that recorded a result.
1166
+ const nonChairResults = roleResults.filter((result) => result.roleId !== 'chair');
1167
+ // Citation rejections / boundary refusals come from the cycle's committed +
1168
+ // dispatch gaps (category-bearing v1.3 gaps read in-memory). The stalled
1169
+ // lenses' not_evaluated classification is sourced directly from the driver's
1170
+ // in-memory governed gaps — its authoritative record of what did NOT
1171
+ // evaluate and WHY — rather than from a persisted category, so the Chair
1172
+ // sees every absent perspective with its real reason.
1173
+ const baseIntegrity = buildOperatingIntegritySummary({
1174
+ ...initialState,
1175
+ dataGaps: [...initialState.dataGaps, ...dispatchGaps],
1176
+ }, cycle.id);
1177
+ const stallNotEvaluatedRoles = (advisorLifecycleSummary?.gaps ?? []).map((gap) => ({
1178
+ roleId: gap.roleId,
1179
+ gapId: `GAP-lifecycle-${gap.roleId}`,
1180
+ reason: gap.reason,
1181
+ detail: gap.reason,
1182
+ }));
1183
+ // SPEC-005 T-019 (FR5/FR13): reconstruct the citation-rejection signal for the
1184
+ // agent-native adapter-lifecycle path. On that path a fully citation-rejected
1185
+ // lens commits a quiet result in a PRIOR `run` (via `operateAdapterLifecycle`'s
1186
+ // record step), which persists the citation gate's governed gaps
1187
+ // (`maintenance.ts`). On THIS consolidation continuation the role is already
1188
+ // recorded, so it is never re-dispatched and its category-bearing v1.3 gaps are
1189
+ // never rebuilt in memory — `dispatchGaps` is empty. The persisted gaps carry
1190
+ // no `category` (the v1.2 append-log projection T-017 uses drops it, and the
1191
+ // reducer projects it away regardless), so `buildOperatingIntegritySummary`
1192
+ // cannot re-derive the signal from committed state, and the lens would fall
1193
+ // through to a false-clean `recorded-quiet`.
1194
+ //
1195
+ // Carry T-017's signal across that boundary using the SAME co-occurring
1196
+ // structural fact its classifier keys on — never the gap's prose: a lens that
1197
+ // recorded a result grounding ZERO proposals AND is named by a governed cycle
1198
+ // gap was citation-rejected (the citation gate is the only producer of a
1199
+ // role-affecting gap at record time on this path). Layer those as
1200
+ // `citationRejections` onto the Chair integrity exactly as the stall
1201
+ // not_evaluated roles are layered above, skipping any role `baseIntegrity`
1202
+ // already classified (so the inline path — whose in-memory `dispatchGaps` still
1203
+ // carry `category` — is untouched and T-017's inline proof stays green).
1204
+ const recordedZeroGroundedRoles = new Set(nonChairResults
1205
+ .filter((result) => result.proposals.length === 0)
1206
+ .map((result) => result.roleId));
1207
+ const alreadyClassifiedRoles = new Set([
1208
+ ...baseIntegrity.citationRejections.flatMap((entry) => entry.affectedRoles),
1209
+ ...baseIntegrity.boundaryRefusals.flatMap((entry) => entry.affectedRoles),
1210
+ ...baseIntegrity.notEvaluatedRoles.map((role) => role.roleId),
1211
+ ...stallNotEvaluatedRoles.map((role) => role.roleId),
1212
+ ]);
1213
+ const nativeCitationRejections = [];
1214
+ for (const roleId of [...recordedZeroGroundedRoles].sort()) {
1215
+ if (alreadyClassifiedRoles.has(roleId))
1216
+ continue;
1217
+ const namingGap = initialState.dataGaps
1218
+ .filter((gap) => (gap.cycleId === undefined || gap.cycleId === cycle.id) &&
1219
+ Array.isArray(gap.affectedRoles) &&
1220
+ gap.affectedRoles.includes(roleId))
1221
+ .sort((left, right) => String(left.id).localeCompare(String(right.id)))[0];
1222
+ if (!namingGap)
1223
+ continue;
1224
+ const reason = String(namingGap.reason ?? '').trim();
1225
+ const detail = String(namingGap.question ?? '').trim();
1226
+ nativeCitationRejections.push({
1227
+ gapId: String(namingGap.id),
1228
+ reason: reason || 'A cited location could not be resolved to evidence.',
1229
+ detail: detail || 'Every citation this role returned failed to resolve to evidence.',
1230
+ affectedRoles: [roleId],
1231
+ });
1232
+ }
1233
+ // SPEC-005 T-020 (FR13): a lens dispatched but that never returned — a true
1234
+ // stall, terminated via `harness abandon` (runtime) or the operator escape —
1235
+ // commits NO result at all, only a governed terminal gap (`maintenance.ts`
1236
+ // `governTerminalNotEvaluated`). It is the inverse of T-019's citation-rejected
1237
+ // case (which DID record a zero-grounded result), so it is reconstructed the
1238
+ // same structural way, keyed on the same committed-state fact and never gap
1239
+ // prose: a non-chair enabled lens with NO committed result that is named by an
1240
+ // open governed cycle gap is `not_evaluated`, carrying that gap's reason and
1241
+ // id. This keeps it a distinct `not-evaluated` outcome — never citation-rejected
1242
+ // (which requires a recorded result), never a fabricated conclusion — so the
1243
+ // Chair names it as an explicit gap it must not synthesize around, and it
1244
+ // contributes zero grounded proposal items by construction (it recorded none).
1245
+ const recordedNonChairRoles = new Set(nonChairResults.map((result) => result.roleId));
1246
+ const nativeNotEvaluatedRoles = [];
1247
+ for (const roleId of [...nonChair].sort()) {
1248
+ if (recordedNonChairRoles.has(roleId))
1249
+ continue;
1250
+ if (alreadyClassifiedRoles.has(roleId))
1251
+ continue;
1252
+ const namingGap = initialState.dataGaps
1253
+ .filter((gap) => (gap.cycleId === undefined || gap.cycleId === cycle.id) &&
1254
+ !['closed', 'superseded'].includes(String(gap.status)) &&
1255
+ Array.isArray(gap.affectedRoles) &&
1256
+ gap.affectedRoles.includes(roleId))
1257
+ .sort((left, right) => String(left.id).localeCompare(String(right.id)))[0];
1258
+ if (!namingGap)
1259
+ continue;
1260
+ const reason = String(namingGap.reason ?? '').trim();
1261
+ const detail = String(namingGap.question ?? '').trim();
1262
+ nativeNotEvaluatedRoles.push({
1263
+ roleId,
1264
+ gapId: String(namingGap.id),
1265
+ reason: reason || 'The lens was dispatched but never recorded a result.',
1266
+ detail: detail ||
1267
+ 'The lens was dispatched but never recorded a result; it is recorded not_evaluated.',
1268
+ });
1269
+ }
1270
+ const chairIntegrity = {
1271
+ ...baseIntegrity,
1272
+ citationRejections: [...baseIntegrity.citationRejections, ...nativeCitationRejections],
1273
+ notEvaluatedRoles: [
1274
+ ...baseIntegrity.notEvaluatedRoles,
1275
+ ...stallNotEvaluatedRoles,
1276
+ ...nativeNotEvaluatedRoles,
1277
+ ],
1278
+ hasConcerns: baseIntegrity.hasConcerns ||
1279
+ stallNotEvaluatedRoles.length > 0 ||
1280
+ nativeCitationRejections.length > 0 ||
1281
+ nativeNotEvaluatedRoles.length > 0,
1282
+ };
1283
+ // The grounded evidence-of-record the recorded proposals cite: read back
1284
+ // from the citation snapshots the dispatch-time gate minted, so
1285
+ // `buildChairEvidence` resolves every recorded proposal's evidenceRefs
1286
+ // instead of the body-free pinned-revision seed (whose `items` are empty).
1287
+ const chairGroundedEvidence = await assembleCitationEvidence({
1288
+ cycleId: cycle.id,
1289
+ roleResults: nonChairResults,
1290
+ cache: citationCache,
1291
+ baseFingerprint: evidence.fingerprint,
1292
+ now: timestamp,
1293
+ });
1294
+ chairBoard = assembleChairBoardInput(chairGroundedEvidence, nonChairResults, timestamp, {
1295
+ expectedRoles: nonChair,
1296
+ integrity: chairIntegrity,
1297
+ failedReasons: Object.fromEntries(failed.map((entry) => [entry.roleId, entry.message])),
1298
+ // Recorded results are already citation-gated at dispatch time, so no
1299
+ // recorded proposal is outstanding-rejected here; a role whose proposals
1300
+ // were all rejected committed not_evaluated and is classified via the
1301
+ // integrity summary's missing-evidence gaps above.
1302
+ rejectedProposalKeys: {},
1303
+ });
1304
+ // Layer the named-gap lines onto the Chair's context so a real runtime agent
1305
+ // sees each absent perspective explicitly. The offline/test adapter ignores
1306
+ // context, so the machine-checked proof of the wiring is the exposed
1307
+ // `chairBoard`; the Chair still investigates independently and receives no
1308
+ // synthesized advisor conclusions — only the honest record of what did NOT
1309
+ // evaluate, which it must surface as a gap rather than fabricate around.
1310
+ const chairAdvisorContext = {
1311
+ ...advisorContext,
1312
+ openGaps: [
1313
+ ...advisorContext.openGaps,
1314
+ ...chairBoard.gaps.map((line, index) => ({
1315
+ id: `BOARD-GAP-${String(index + 1).padStart(3, '0')}`,
1316
+ status: 'open',
1317
+ summary: line,
1318
+ owner: null,
1319
+ evidenceRefs: [],
1320
+ affectedRoles: ['chair'],
1321
+ })),
1322
+ ],
1323
+ };
883
1324
  const chair = persistedChair
884
1325
  ? {
885
1326
  results: [persistedChair],
@@ -895,7 +1336,7 @@ export async function runOperatingCycle(input) {
895
1336
  projectRoot,
896
1337
  pinnedRevision: workspace.controlRepository.pinnedRevision,
897
1338
  readiness: chairReadiness,
898
- context: advisorContext,
1339
+ context: chairAdvisorContext,
899
1340
  adapter: await resolveAdapter(),
900
1341
  depth,
901
1342
  runtime: resolvedRuntime,
@@ -949,6 +1390,12 @@ export async function runOperatingCycle(input) {
949
1390
  owner: config.decisionOwner,
950
1391
  now: timestamp,
951
1392
  });
1393
+ // FR3 (SPEC-005 T-015): the governed data gaps for lenses the lifecycle driver
1394
+ // terminated not_evaluated (a stall past the retry budget). They join the
1395
+ // consolidation gap set so `remapConsolidation` mints their canonical `GAP-NNN`
1396
+ // ids alongside every other cycle gap, then persist and render like any other
1397
+ // governed gap — so `status`/`report` show a stalled lens honestly.
1398
+ const lifecycleNotEvaluatedGaps = (advisorLifecycleSummary?.gaps ?? []).map((gap) => driverNotEvaluatedGap(gap, cycle.id, config.decisionOwner, timestamp));
952
1399
  const surfacedDedup = suppressRepeatedFindings(initialState, consolidated.findings, cycle.id);
953
1400
  const overflowDedup = suppressRepeatedFindings(initialState, consolidated.criticalOverflow, cycle.id);
954
1401
  const parkedDedup = suppressRepeatedFindings(initialState, consolidated.parked, cycle.id);
@@ -963,7 +1410,7 @@ export async function runOperatingCycle(input) {
963
1410
  .filter((role) => role.gapId)
964
1411
  .map((role) => ({ id: role.gapId, status: 'open' })),
965
1412
  ],
966
- }, [...surfacedDedup.findings, ...overflowDedup.findings], parkedDedup.findings, consolidated.decisions, [...evidenceGaps, ...consolidated.gaps, ...failureGaps]);
1413
+ }, [...surfacedDedup.findings, ...overflowDedup.findings], parkedDedup.findings, consolidated.decisions, [...evidenceGaps, ...consolidated.gaps, ...failureGaps, ...lifecycleNotEvaluatedGaps]);
967
1414
  const warnings = [
968
1415
  ...evidence.warnings,
969
1416
  ...(evidenceGaps.length > 0
@@ -1018,6 +1465,8 @@ export async function runOperatingCycle(input) {
1018
1465
  modelCalls,
1019
1466
  warnings,
1020
1467
  dispatchProvenance,
1468
+ ...(advisorLifecycleSummary ? { advisorLifecycle: advisorLifecycleSummary } : {}),
1469
+ ...(chairBoard ? { chairBoard } : {}),
1021
1470
  };
1022
1471
  };
1023
1472
  if (input.dryRun)
@@ -1102,7 +1551,19 @@ export async function runOperatingCycle(input) {
1102
1551
  state,
1103
1552
  revalidateEventHead: async () => (await store.replay()).eventHead,
1104
1553
  });
1105
- return { ...evaluated, state };
1554
+ // FR5 (SPEC-005 T-002): the status-facing summary this native handoff
1555
+ // returns reflects LIVE persisted role results — every lens the harness
1556
+ // has already committed to the canonical event log, re-read here from the
1557
+ // freshest replay rather than the call-entry snapshot. Combined with the
1558
+ // harness recording each role's event the moment it validates (not only
1559
+ // at finalize), a `run` re-entered mid fan-out reports exactly the roles
1560
+ // that are durably recorded, never a stale "nothing recorded yet" view.
1561
+ const livePersistedRoleResults = await persistedCycleRoleResults(store, (await store.replay()).events, cycle.id);
1562
+ return {
1563
+ ...evaluated,
1564
+ roleResults: livePersistedRoleResults.filter((result) => result.roleId !== 'chair'),
1565
+ state,
1566
+ };
1106
1567
  }
1107
1568
  if (phase === 'advising') {
1108
1569
  const recordedRoles = new Set(existingRoleResults.map((result) => result.roleId));
@@ -1124,8 +1585,18 @@ export async function runOperatingCycle(input) {
1124
1585
  head = appended.head;
1125
1586
  }
1126
1587
  const existingGapIds = new Set(initialState.dataGaps.filter((gap) => gap.cycleId === cycle.id).map((gap) => gap.id));
1127
- for (const gap of evaluated.gaps ?? []) {
1128
- if (existingGapIds.has(gap.id))
1588
+ // FR2/FR13 (T-017): a citation-rejection / empty-grounding gap is a
1589
+ // Protocol v1.3 gap in memory (category + digest id), where the Chair
1590
+ // board reads its category to classify the role. The append log and the
1591
+ // gap.open/operating-record schemas only admit a v1.2 data gap, so
1592
+ // persist each gap in its v1.2 projection (canonical `GAP-NNN` id, no
1593
+ // v1.3-only fields) — lossless for replayed state, which drops category
1594
+ // regardless. Seed the ordinal past every canonical id already present in
1595
+ // committed state and this batch so a minted id never collides.
1596
+ let gapOrdinal = maximumOrdinal([...initialState.dataGaps, ...(evaluated.gaps ?? [])], 'GAP');
1597
+ for (const rawGap of evaluated.gaps ?? []) {
1598
+ const gap = toPersistedDataGap(rawGap, () => `GAP-${String(++gapOrdinal).padStart(3, '0')}`);
1599
+ if (existingGapIds.has(rawGap.id) || existingGapIds.has(gap.id))
1129
1600
  continue;
1130
1601
  await assertOperatingArtifact('operating-data-gap', gap);
1131
1602
  await store.putRecord('data-gap', recordContent(gap), {