openplanr 1.19.0 → 1.20.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 (145) hide show
  1. package/dist/cli/commands/backlog.d.ts.map +1 -1
  2. package/dist/cli/commands/backlog.js +3 -0
  3. package/dist/cli/commands/backlog.js.map +1 -1
  4. package/dist/cli/commands/config.d.ts.map +1 -1
  5. package/dist/cli/commands/config.js +3 -0
  6. package/dist/cli/commands/config.js.map +1 -1
  7. package/dist/cli/commands/epic.d.ts.map +1 -1
  8. package/dist/cli/commands/epic.js +2 -0
  9. package/dist/cli/commands/epic.js.map +1 -1
  10. package/dist/cli/commands/feature.d.ts.map +1 -1
  11. package/dist/cli/commands/feature.js +2 -0
  12. package/dist/cli/commands/feature.js.map +1 -1
  13. package/dist/cli/commands/operate.d.ts +5 -2
  14. package/dist/cli/commands/operate.d.ts.map +1 -1
  15. package/dist/cli/commands/operate.js +10 -101
  16. package/dist/cli/commands/operate.js.map +1 -1
  17. package/dist/cli/commands/plan.d.ts.map +1 -1
  18. package/dist/cli/commands/plan.js +2 -0
  19. package/dist/cli/commands/plan.js.map +1 -1
  20. package/dist/cli/commands/quick.d.ts.map +1 -1
  21. package/dist/cli/commands/quick.js +2 -0
  22. package/dist/cli/commands/quick.js.map +1 -1
  23. package/dist/cli/commands/refine.d.ts.map +1 -1
  24. package/dist/cli/commands/refine.js +2 -0
  25. package/dist/cli/commands/refine.js.map +1 -1
  26. package/dist/cli/commands/revise.d.ts.map +1 -1
  27. package/dist/cli/commands/revise.js +2 -0
  28. package/dist/cli/commands/revise.js.map +1 -1
  29. package/dist/cli/commands/spec.d.ts.map +1 -1
  30. package/dist/cli/commands/spec.js +2 -0
  31. package/dist/cli/commands/spec.js.map +1 -1
  32. package/dist/cli/commands/sprint.d.ts.map +1 -1
  33. package/dist/cli/commands/sprint.js +2 -0
  34. package/dist/cli/commands/sprint.js.map +1 -1
  35. package/dist/cli/commands/story.d.ts.map +1 -1
  36. package/dist/cli/commands/story.js +3 -0
  37. package/dist/cli/commands/story.js.map +1 -1
  38. package/dist/cli/commands/task.d.ts.map +1 -1
  39. package/dist/cli/commands/task.js +3 -0
  40. package/dist/cli/commands/task.js.map +1 -1
  41. package/dist/services/ai-service.d.ts +3 -5
  42. package/dist/services/ai-service.d.ts.map +1 -1
  43. package/dist/services/ai-service.js +7 -1
  44. package/dist/services/ai-service.js.map +1 -1
  45. package/dist/services/credentials-service.d.ts.map +1 -1
  46. package/dist/services/credentials-service.js +2 -0
  47. package/dist/services/credentials-service.js.map +1 -1
  48. package/dist/services/deprecation-notices.d.ts +5 -0
  49. package/dist/services/deprecation-notices.d.ts.map +1 -0
  50. package/dist/services/deprecation-notices.js +6 -0
  51. package/dist/services/deprecation-notices.js.map +1 -0
  52. package/dist/services/operate/advisors.d.ts +144 -166
  53. package/dist/services/operate/advisors.d.ts.map +1 -1
  54. package/dist/services/operate/advisors.js +178 -543
  55. package/dist/services/operate/advisors.js.map +1 -1
  56. package/dist/services/operate/citation-resolution.d.ts.map +1 -1
  57. package/dist/services/operate/citation-resolution.js +50 -1
  58. package/dist/services/operate/citation-resolution.js.map +1 -1
  59. package/dist/services/operate/config.d.ts +18 -52
  60. package/dist/services/operate/config.d.ts.map +1 -1
  61. package/dist/services/operate/config.js +24 -206
  62. package/dist/services/operate/config.js.map +1 -1
  63. package/dist/services/operate/doctor.d.ts +15 -0
  64. package/dist/services/operate/doctor.d.ts.map +1 -1
  65. package/dist/services/operate/doctor.js +142 -32
  66. package/dist/services/operate/doctor.js.map +1 -1
  67. package/dist/services/operate/engine.d.ts +22 -78
  68. package/dist/services/operate/engine.d.ts.map +1 -1
  69. package/dist/services/operate/engine.js +269 -202
  70. package/dist/services/operate/engine.js.map +1 -1
  71. package/dist/services/operate/evidence.d.ts +9 -107
  72. package/dist/services/operate/evidence.d.ts.map +1 -1
  73. package/dist/services/operate/evidence.js +0 -1403
  74. package/dist/services/operate/evidence.js.map +1 -1
  75. package/dist/services/operate/index.d.ts.map +1 -1
  76. package/dist/services/operate/index.js +10 -231
  77. package/dist/services/operate/index.js.map +1 -1
  78. package/dist/services/operate/integrity.d.ts +81 -0
  79. package/dist/services/operate/integrity.d.ts.map +1 -0
  80. package/dist/services/operate/integrity.js +152 -0
  81. package/dist/services/operate/integrity.js.map +1 -0
  82. package/dist/services/operate/interaction/answer-service.d.ts +0 -10
  83. package/dist/services/operate/interaction/answer-service.d.ts.map +1 -1
  84. package/dist/services/operate/interaction/answer-service.js +63 -43
  85. package/dist/services/operate/interaction/answer-service.js.map +1 -1
  86. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  87. package/dist/services/operate/interaction/question-engine.js +105 -21
  88. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  89. package/dist/services/operate/interaction/question-registry.d.ts +16 -1
  90. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  91. package/dist/services/operate/interaction/question-registry.js +45 -39
  92. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  93. package/dist/services/operate/interaction/session-service.d.ts.map +1 -1
  94. package/dist/services/operate/interaction/session-service.js +16 -11
  95. package/dist/services/operate/interaction/session-service.js.map +1 -1
  96. package/dist/services/operate/interaction/terminal-renderer.d.ts.map +1 -1
  97. package/dist/services/operate/interaction/terminal-renderer.js +16 -6
  98. package/dist/services/operate/interaction/terminal-renderer.js.map +1 -1
  99. package/dist/services/operate/maintenance.d.ts +1 -11
  100. package/dist/services/operate/maintenance.d.ts.map +1 -1
  101. package/dist/services/operate/maintenance.js +94 -342
  102. package/dist/services/operate/maintenance.js.map +1 -1
  103. package/dist/services/operate/mission-dispatch.d.ts +73 -55
  104. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  105. package/dist/services/operate/mission-dispatch.js +91 -67
  106. package/dist/services/operate/mission-dispatch.js.map +1 -1
  107. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  108. package/dist/services/operate/projection-persistence.js +22 -2
  109. package/dist/services/operate/projection-persistence.js.map +1 -1
  110. package/dist/services/operate/projection.d.ts.map +1 -1
  111. package/dist/services/operate/projection.js +10 -3
  112. package/dist/services/operate/projection.js.map +1 -1
  113. package/dist/services/operate/protocol.d.ts +1 -41
  114. package/dist/services/operate/protocol.d.ts.map +1 -1
  115. package/dist/services/operate/protocol.js +7 -45
  116. package/dist/services/operate/protocol.js.map +1 -1
  117. package/dist/services/operate/redaction.d.ts +3 -3
  118. package/dist/services/operate/redaction.js +4 -4
  119. package/dist/services/operate/redaction.js.map +1 -1
  120. package/dist/services/operate/reports.d.ts +11 -1
  121. package/dist/services/operate/reports.d.ts.map +1 -1
  122. package/dist/services/operate/reports.js +88 -3
  123. package/dist/services/operate/reports.js.map +1 -1
  124. package/dist/services/operate/types.d.ts +42 -128
  125. package/dist/services/operate/types.d.ts.map +1 -1
  126. package/dist/services/operate/types.js.map +1 -1
  127. package/dist/services/operate/workspace.d.ts +0 -1
  128. package/dist/services/operate/workspace.d.ts.map +1 -1
  129. package/dist/services/operate/workspace.js +0 -2
  130. package/dist/services/operate/workspace.js.map +1 -1
  131. package/dist/services/runtime-manager-service.js +2 -2
  132. package/dist/services/runtime-manager-service.js.map +1 -1
  133. package/package.json +3 -3
  134. package/dist/services/operate/evidence-classifications.d.ts +0 -59
  135. package/dist/services/operate/evidence-classifications.d.ts.map +0 -1
  136. package/dist/services/operate/evidence-classifications.js +0 -132
  137. package/dist/services/operate/evidence-classifications.js.map +0 -1
  138. package/dist/services/operate/evidence-diagnostics.d.ts +0 -23
  139. package/dist/services/operate/evidence-diagnostics.d.ts.map +0 -1
  140. package/dist/services/operate/evidence-diagnostics.js +0 -152
  141. package/dist/services/operate/evidence-diagnostics.js.map +0 -1
  142. package/dist/services/operate/evidence-readiness.d.ts +0 -18
  143. package/dist/services/operate/evidence-readiness.d.ts.map +0 -1
  144. package/dist/services/operate/evidence-readiness.js +0 -94
  145. package/dist/services/operate/evidence-readiness.js.map +0 -1
@@ -3,15 +3,14 @@ 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, buildOperatingMissionPacket, configuredAdvisorProviderPolicy, createConfiguredStructuredAdapter, createOfflineAdvisorAdapter, deriveOperatingMissionEvidenceCaps, dispatchOperatingAdvisors, ensureOperatingProviderConsent, } from './advisors.js';
6
+ import { advisorFailureGaps, buildAdvisorOperatingContext, configuredAdvisorProviderPolicy, createConfiguredStructuredAdapter, createOfflineAdvisorAdapter, dispatchOperatingAdvisors, ensureOperatingProviderConsent, } from './advisors.js';
7
7
  import { recordOperatingCadenceRun } from './cadence.js';
8
8
  import { canonicalDigest, canonicalize } from './canonical.js';
9
- import { operatingProjectKey, readOperatingDispatchModeOverrides, validateOperatingConfiguration, } from './config.js';
9
+ import { operatingProjectKey, validateOperatingConfiguration } from './config.js';
10
10
  import { consolidateOperatingResults } from './consolidation.js';
11
11
  import { OperatingEventStore } from './event-store.js';
12
- import { buildOperatingEvidenceIndex, collectOperatingEvidence, deriveOperatingDeclaredRoots, evidenceProjectionSources, unqualifiedCommercialEvidenceGaps, } from './evidence.js';
12
+ import { evidenceProjectionSources } from './evidence.js';
13
13
  import { OperatingEvidenceCache } from './evidence-cache.js';
14
- import { evaluateEvidenceReadiness } from './evidence-readiness.js';
15
14
  import { nextCycleId } from './ids.js';
16
15
  import { enforceRecordedProposalCitations } from './interaction/action-service.js';
17
16
  import { applyJournalTransaction, prepareJournalTransaction, readJournal, rollbackJournalTransaction, } from './journal.js';
@@ -34,121 +33,6 @@ function normalizeFocus(focus) {
34
33
  function loadJson(target) {
35
34
  return readFile(target, 'utf8').then((raw) => JSON.parse(raw));
36
35
  }
37
- const MISSION_REVISION_PATTERN = /^[a-f0-9]{7,64}$/;
38
- const MISSION_CYCLE_PATTERN = /^CYCLE-[0-9]{3,}$/;
39
- const MISSION_DECISION_PATTERN = /^DEC-[0-9]{3,}$/;
40
- const MISSION_GAP_PATTERN = /^GAP-[A-Za-z0-9._-]+$/;
41
- const MISSION_OUTCOME_PATTERN = /^OUT-[0-9]{3,}$/;
42
- function boundedMissionText(value, maximum, fallback) {
43
- const text = value.replace(/\s+/g, ' ').trim().slice(0, maximum);
44
- return text || fallback;
45
- }
46
- function missionOpenItemIds(items, pattern) {
47
- return [...new Set(items.map((item) => item.id).filter((id) => pattern.test(id)))].sort();
48
- }
49
- /**
50
- * Source a mission packet's non-evidence payload (FR1/E-001) from live cycle
51
- * and workspace state at the point mission packets are constructed: the product
52
- * charter and current goals, a prior-cycle summary with its open
53
- * decisions/gaps/pending outcomes, planning and delivery status, the read roots
54
- * declared for the index, and the revision pinned once at cycle start (recorded
55
- * in the workspace manifest's control repository and threaded identically into
56
- * every mission packet). Nothing here is invented: every value is read from the
57
- * parsed charter context, the reduced state, the operating config, and the
58
- * captured workspace manifest.
59
- */
60
- export function sourceOperatingMissionPacketState(input) {
61
- const { context } = input;
62
- const charter = context.charter;
63
- const charterLine = [
64
- charter.purpose && `Purpose: ${charter.purpose}`,
65
- charter.stage && `Stage: ${charter.stage}`,
66
- charter.businessModel && `Business model: ${charter.businessModel}`,
67
- charter.idealCustomer && `Ideal customer: ${charter.idealCustomer}`,
68
- ]
69
- .filter(Boolean)
70
- .join('. ');
71
- const currentGoals = [...new Set(charter.goals.map((goal) => goal.trim()).filter(Boolean))]
72
- .map((goal) => goal.slice(0, 512))
73
- .sort();
74
- const prior = context.priorCycle;
75
- const priorSummaryText = prior
76
- ? `Prior cycle ${prior.id} (${prior.state}, health ${prior.health ?? 'unknown'}) closed with ` +
77
- `${prior.findings} finding(s), ${prior.decisions} decision(s), ${prior.gaps} gap(s), and ` +
78
- `${prior.pendingOutcomes} pending outcome(s).`
79
- : 'No prior operating cycle.';
80
- const priorCycleSummary = {
81
- ...(prior && MISSION_CYCLE_PATTERN.test(prior.id) ? { cycleId: prior.id } : {}),
82
- summary: boundedMissionText(priorSummaryText, 8192, 'No prior operating cycle.'),
83
- openDecisions: missionOpenItemIds(context.openDecisions, MISSION_DECISION_PATTERN),
84
- openGaps: missionOpenItemIds(context.openGaps, MISSION_GAP_PATTERN),
85
- pendingOutcomes: missionOpenItemIds(context.pendingOutcomes, MISSION_OUTCOME_PATTERN),
86
- };
87
- const planning = boundedMissionText(`${context.openDecisions.length} open decision(s); ${context.openGaps.length} open gap(s); ` +
88
- `${context.pendingOutcomes.length} pending outcome(s).`, 2048, 'No open planning items.');
89
- const delivery = boundedMissionText(prior
90
- ? `Most recent cycle ${prior.id} is ${prior.state}.`
91
- : 'No delivery cycle has completed yet.', 2048, 'No delivery status recorded.');
92
- const pinnedRevision = input.workspace.controlRepository.pinnedRevision;
93
- return {
94
- cycleId: input.cycleId,
95
- ...(pinnedRevision && MISSION_REVISION_PATTERN.test(pinnedRevision) ? { pinnedRevision } : {}),
96
- charter: {
97
- productCharter: boundedMissionText(charterLine, 8192, 'No product charter recorded.'),
98
- currentGoals,
99
- },
100
- priorCycleSummary,
101
- planningStatus: {
102
- planningEngine: input.config.planningEngine,
103
- planning,
104
- delivery,
105
- },
106
- declaredRoots: deriveOperatingDeclaredRoots(input.evidenceIndex),
107
- };
108
- }
109
- /**
110
- * Build the Protocol v1.3 mission packets (FR1/E-001) for a cycle from live
111
- * state: derive the body-free evidence index from the collected snapshot,
112
- * source the non-evidence payload once, and construct one digest-bound packet
113
- * per requested role. Each packet is measured against its role's derived mission
114
- * budget and fails closed with `E_OPERATE_MISSION_PACKET_BUDGET` naming the role
115
- * before any dispatch. Pack mode (`createOperatingAdvisorPack`) is untouched.
116
- *
117
- * `maxEvidenceItems` is the caller's upper bound (`config.budgets.maxItems`); the
118
- * effective per-role cap is the smaller of it and the role's registry-sized
119
- * default (`deriveOperatingMissionEvidenceCap`), so a large-budget lens keeps
120
- * more items than a small-budget one and a healthy repository under the cap is
121
- * never truncated. When a role's index exceeds its cap the pipeline keeps the
122
- * highest-priority items and the drop is returned in `truncations`.
123
- */
124
- export async function buildOperatingMissionPackets(input) {
125
- const evidenceIndex = buildOperatingEvidenceIndex(input.evidence, {
126
- sensitivityCeiling: input.sensitivityCeiling,
127
- });
128
- const state = sourceOperatingMissionPacketState({
129
- cycleId: input.cycleId,
130
- config: input.config,
131
- workspace: input.workspace,
132
- context: input.context,
133
- evidenceIndex,
134
- });
135
- const roleIds = input.roleIds ?? input.config.enabledRoles;
136
- const evidenceCaps = await deriveOperatingMissionEvidenceCaps(input.maxEvidenceItems);
137
- const packets = [];
138
- const truncations = [];
139
- for (const roleId of roleIds) {
140
- const { packet, truncation } = await buildOperatingMissionPacket({
141
- roleId,
142
- ...state,
143
- evidenceIndex,
144
- maxEvidenceItems: evidenceCaps[roleId],
145
- });
146
- packets.push(packet);
147
- if (truncation)
148
- truncations.push(truncation);
149
- }
150
- return { packets, truncations };
151
- }
152
36
  async function snapshotWorkspace(projectRoot, localRoot) {
153
37
  return refreshOperatingWorkspaceManifest(projectRoot, { localRoot });
154
38
  }
@@ -207,6 +91,10 @@ function readinessGaps(readiness, decisionOwner, now) {
207
91
  updatedAt: now,
208
92
  }));
209
93
  }
94
+ // Retained callerless: this fed the collector's `requiredEvidenceRefs` input,
95
+ // which the pinned-revision seed no longer takes. The collector and its dedicated
96
+ // feeders are retired wholesale in T-009; nothing is deleted here.
97
+ // biome-ignore lint/correctness/noUnusedVariables: retained pending collector retirement (T-009)
210
98
  function requiredOpenEvidenceRefs(state) {
211
99
  const records = [
212
100
  ...state.findings.filter((finding) => !['done', 'rejected', 'superseded'].includes(String(finding.status))),
@@ -278,6 +166,121 @@ export function buildChairEvidence(evidence, results, now) {
278
166
  }),
279
167
  };
280
168
  }
169
+ /**
170
+ * FR1/FR2 (isolation rebind): the cycle's pinned-revision evidence seed. Evidence
171
+ * is an OUTPUT now, so a fresh cycle no longer collects a body-bearing snapshot
172
+ * before anyone has investigated; it seeds an empty evidence-of-record whose
173
+ * fingerprint binds the cycle to its control-repository pinned revision. This
174
+ * seed is what the adapter isolation binds to (`persistedAdapterEvidence`'s
175
+ * fingerprint === the prepared session's `evidenceDigest`), so a tampered or
176
+ * revision-mismatched session still fails closed with `E_OPERATE_ADVISOR_ISOLATION`
177
+ * — the guarantee is preserved, only its input is the pinned revision rather than
178
+ * a collected-evidence digest. Nothing is guessed, budgeted, or packed.
179
+ */
180
+ async function buildPinnedRevisionEvidenceSeed(input) {
181
+ return assertOperatingArtifact('operating-evidence', {
182
+ kind: 'operating-evidence',
183
+ schemaVersion: OPERATE_SCHEMA_VERSION,
184
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
185
+ cycleId: input.cycleId,
186
+ fingerprint: canonicalDigest({
187
+ cycleId: input.cycleId,
188
+ pinnedRevision: input.pinnedRevision,
189
+ }),
190
+ collectedAt: input.collectedAt,
191
+ truncated: false,
192
+ items: [],
193
+ sources: [],
194
+ warnings: [],
195
+ });
196
+ }
197
+ /**
198
+ * FR2: dispatch is no longer pre-gated on collected evidence — every enabled role
199
+ * is dispatched with a mandate and post-gated on what its response actually cites.
200
+ * This builds the "all roles runnable" readiness the dispatch consumes, replacing
201
+ * the collector-era evidence-readiness evaluation.
202
+ */
203
+ async function buildDispatchReadiness(input) {
204
+ const hasPinnedRevision = !/^0+$/.test(input.pinnedRevision);
205
+ return assertOperatingArtifact('operating-evidence-readiness', {
206
+ kind: 'operating-evidence-readiness',
207
+ schemaVersion: OPERATE_SCHEMA_VERSION,
208
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
209
+ cycleId: input.cycleId,
210
+ inputDigest: canonicalDigest({ cycleId: input.cycleId, roles: [...input.roles].sort() }),
211
+ evaluatedAt: input.now.toISOString(),
212
+ roles: input.roles.map((roleId, index) => ({
213
+ roleId,
214
+ readiness: 'ready',
215
+ requirements: [
216
+ {
217
+ source: 'mandate',
218
+ claimTypes: ['resolvable-citation-required'],
219
+ minimumItems: 1,
220
+ observedItems: hasPinnedRevision ? 1 : 0,
221
+ maxAgeHours: 8760,
222
+ oldestAgeHours: hasPinnedRevision ? 0 : null,
223
+ observationWindow: 'current-cycle',
224
+ sensitivityCeiling: input.sensitivityCeiling,
225
+ satisfied: hasPinnedRevision,
226
+ },
227
+ ],
228
+ missingEvidence: hasPinnedRevision
229
+ ? []
230
+ : ['mandate:resolvable-citation-required (0/1; no pinned revision)'],
231
+ evidenceRefs: [],
232
+ modelCallAllowed: hasPinnedRevision,
233
+ gapId: hasPinnedRevision ? null : `GAP-${String(index + 1).padStart(3, '0')}`,
234
+ })),
235
+ });
236
+ }
237
+ /**
238
+ * FR2 (evidence-as-output): assemble the cycle's evidence-of-record for
239
+ * consolidation from the resolved-citation snapshots the universal gate minted.
240
+ * Every evidenceRef a gated proposal carries was snapshotted to machine-local
241
+ * evidence (`putCitationSnapshot`); this reads each back (`getCitationSnapshot`)
242
+ * so downstream confidence/sensitivity/freshness scoring resolves against what
243
+ * was actually cited and verified rather than a pre-collected guess. A ref whose
244
+ * snapshot is unreadable still materializes a minimal item so consolidation never
245
+ * rejects a legitimately-minted reference.
246
+ */
247
+ async function assembleCitationEvidence(input) {
248
+ const refs = [
249
+ ...new Set(input.roleResults.flatMap((result) => result.proposals.flatMap((proposal) => proposal.evidenceRefs))),
250
+ ].sort();
251
+ const items = [];
252
+ for (const ref of refs) {
253
+ const snapshot = await input.cache.getCitationSnapshot(ref);
254
+ items.push({
255
+ id: ref,
256
+ source: 'citation',
257
+ location: snapshot?.sourceLocation ?? ref,
258
+ digest: canonicalDigest(snapshot?.content ?? ref),
259
+ collectedAt: input.now,
260
+ observedFrom: null,
261
+ observedTo: null,
262
+ freshness: 'fresh',
263
+ sensitivity: snapshot?.sensitivity ?? 'internal',
264
+ claimTypes: ['verified-citation'],
265
+ ...(snapshot ? { summary: snapshot.content.slice(0, 512) } : {}),
266
+ });
267
+ }
268
+ return assertOperatingArtifact('operating-evidence', {
269
+ kind: 'operating-evidence',
270
+ schemaVersion: OPERATE_SCHEMA_VERSION,
271
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
272
+ cycleId: input.cycleId,
273
+ fingerprint: canonicalDigest({
274
+ source: input.baseFingerprint,
275
+ citations: items.map((item) => ({ id: item.id, digest: item.digest })),
276
+ }),
277
+ collectedAt: input.now,
278
+ truncated: false,
279
+ items,
280
+ sources: [],
281
+ warnings: [],
282
+ });
283
+ }
281
284
  const SENSITIVE_ROUTE_LANGUAGE = /\b(?:payment|billing|security|privacy|legal|tenant|isolation|destructive|delete|purge|credential|secret)\b/i;
282
285
  function containsSensitiveRouteProposal(results) {
283
286
  return results.some((result) => result.proposals.some((proposal) => SENSITIVE_ROUTE_LANGUAGE.test(`${proposal.type} ${proposal.title} ${proposal.problem} ${proposal.proposal}`)));
@@ -441,50 +444,104 @@ async function persistedCycleRoleResults(store, events, cycleId) {
441
444
  * dry-run never enter the mutating branch.
442
445
  */
443
446
  /**
444
- * FR3 / E-003 citation gate, invoked immediately before consolidation.
447
+ * The single governed gap opened for a role whose citation-bearing response
448
+ * grounded zero evidence (FR2). It names the role and its empty grounding so the
449
+ * integrity surface can render the role `not_evaluated` with a real reason rather
450
+ * than a silent absence or a "quiet" that pretends the lens evaluated.
451
+ */
452
+ function roleEmptyGroundingGap(roleId, context, now) {
453
+ const createdAt = now.toISOString();
454
+ return {
455
+ kind: 'operating-data-gap',
456
+ schemaVersion: OPERATE_SCHEMA_VERSION,
457
+ protocolVersion: OPERATE_MISSION_PROTOCOL_VERSION,
458
+ id: `GAP-${canonicalDigest({
459
+ roleId,
460
+ cycleId: context.cycleId,
461
+ reason: 'empty-grounding',
462
+ }).slice('sha256:'.length)}`,
463
+ cycleId: context.cycleId,
464
+ category: 'missing-evidence',
465
+ question: `What evidence can ${roleId} cite? Its response resolved zero citations to evidence.`,
466
+ reason: `Every citation ${roleId} returned failed to resolve at the pinned revision, so the role ` +
467
+ 'grounded no evidence and is recorded not_evaluated instead of dispatching a proposal on an ' +
468
+ 'empty base.',
469
+ unblocks: [],
470
+ affectedRoles: [roleId],
471
+ status: 'open',
472
+ owner: context.owner && context.owner.length > 0 ? context.owner : 'chair',
473
+ evidenceRefs: [],
474
+ createdAt,
475
+ updatedAt: createdAt,
476
+ };
477
+ }
478
+ /**
479
+ * FR2 universal citation gate — the PRIMARY, unconditional mechanism, run on
480
+ * every dispatch path and every evidence source (no `bearing.length === 0`
481
+ * bypass remains, so it is entered even for a citation-free result).
445
482
  *
446
483
  * Every recorded proposal that carries citations is resolved against the cycle's
447
- * pinned revision through T-004's `enforceRecordedProposalCitations`. A proposal
448
- * with ANY unresolvable citation is DROPPED — it never reaches
449
- * `consolidateOperatingResults` — and exactly one `unresolvable-citation` gap is
450
- * opened in its place. A proposal whose citations all resolve keeps its minted
451
- * evidence IDs. Proposals carrying no citations (the v1.2 evidence-ref path) pass
452
- * through untouched, so this is a byte-for-byte no-op for every non-citation
453
- * cycle: the same `roleResults` reference is returned and no gap is emitted.
484
+ * pinned revision through `enforceRecordedProposalCitations`. A proposal with ANY
485
+ * unresolvable citation is DROPPED — it never reaches `consolidateOperatingResults`
486
+ * — and exactly one `unresolvable-citation` gap is opened in its place. A proposal
487
+ * whose citations all resolve keeps its minted evidence IDs.
488
+ *
489
+ * On top of that per-proposal gate, the FR2 role-level rule: a role result whose
490
+ * proposals carried citations but whose accepted citations resolve to ZERO
491
+ * evidence IDs is `not_evaluated` — every one of its proposals is dropped, its id
492
+ * is returned in `notEvaluatedRoleIds`, and one governed `missing-evidence` gap
493
+ * naming the role and its empty grounding is opened. This replaces SPEC-003's
494
+ * mission-only, repository-only starvation gate with one rule that holds whatever
495
+ * the dispatch path or evidence source. A v1.2 pack result (evidenceRefs, no
496
+ * citations) carries no citation-bearing proposal, so it is never demoted — the
497
+ * pack path stays functional until T-003 removes it.
454
498
  */
455
499
  export async function gateRecordedProposalCitations(input) {
456
500
  const bearing = [];
501
+ const citationBearingProposalKeys = new Set();
457
502
  for (const result of input.roleResults) {
458
503
  for (const proposal of result.proposals) {
459
504
  const citations = proposal.citations;
460
505
  if (Array.isArray(citations) && citations.length > 0) {
461
506
  bearing.push({ proposalKey: proposal.proposalKey, citations });
507
+ citationBearingProposalKeys.add(proposal.proposalKey);
462
508
  }
463
509
  }
464
510
  }
465
- if (bearing.length === 0)
466
- return { roleResults: input.roleResults, gaps: [] };
467
511
  const enforcement = await enforceRecordedProposalCitations(bearing, input.context);
468
512
  const rejectedKeys = new Set(enforcement.rejected.map((entry) => entry.proposalKey));
469
513
  const evidenceByKey = new Map(enforcement.accepted.map((entry) => [entry.proposal.proposalKey, entry.evidenceRefs]));
470
- const roleResults = input.roleResults.map((result) => ({
471
- ...result,
472
- proposals: result.proposals
473
- .filter((proposal) => !rejectedKeys.has(proposal.proposalKey))
474
- .map((proposal) => {
475
- const minted = evidenceByKey.get(proposal.proposalKey);
476
- return minted && minted.length > 0
477
- ? {
478
- ...proposal,
479
- evidenceRefs: [...new Set([...proposal.evidenceRefs, ...minted])].sort(),
480
- }
481
- : proposal;
482
- }),
514
+ const gaps = [...enforcement.gaps];
515
+ const notEvaluatedRoleIds = [];
516
+ const now = input.context.now ?? new Date();
517
+ const roleResults = await Promise.all(input.roleResults.map(async (result) => {
518
+ const gated = {
519
+ ...result,
520
+ proposals: result.proposals
521
+ .filter((proposal) => !rejectedKeys.has(proposal.proposalKey))
522
+ .map((proposal) => {
523
+ const minted = evidenceByKey.get(proposal.proposalKey);
524
+ return minted && minted.length > 0
525
+ ? {
526
+ ...proposal,
527
+ evidenceRefs: [...new Set([...proposal.evidenceRefs, ...minted])].sort(),
528
+ }
529
+ : proposal;
530
+ }),
531
+ };
532
+ // FR2 role-level rule: a role whose proposals were citation-bearing but
533
+ // grounded ZERO evidence after resolution is not_evaluated. Drop its
534
+ // surviving (evidence-free) proposals and open one governed gap.
535
+ const wasCitationBearing = result.proposals.some((proposal) => citationBearingProposalKeys.has(proposal.proposalKey));
536
+ const mintedAny = gated.proposals.some((proposal) => proposal.evidenceRefs.length > 0);
537
+ if (wasCitationBearing && !mintedAny) {
538
+ notEvaluatedRoleIds.push(result.roleId);
539
+ gaps.push(await assertOperatingArtifact('operating-data-gap', roleEmptyGroundingGap(result.roleId, input.context, now)));
540
+ return { ...gated, proposals: [] };
541
+ }
542
+ return gated;
483
543
  }));
484
- // The unresolvable-citation gaps are Protocol v1.3 data gaps (they validate
485
- // against the v1.3 operating-data-gap schema, which admits `category`). They
486
- // are persisted alongside the cycle's other gaps by the committed path.
487
- return { roleResults, gaps: enforcement.gaps };
544
+ return { roleResults, gaps, notEvaluatedRoleIds };
488
545
  }
489
546
  export async function runOperatingCycle(input) {
490
547
  const projectRoot = await assertOperatingProject(input.projectRoot);
@@ -493,12 +550,6 @@ export async function runOperatingCycle(input) {
493
550
  const initializedWorkspace = await loadJson(paths.workspace);
494
551
  await assertOperatingArtifact('operating-workspace-manifest', initializedWorkspace);
495
552
  const preferences = await readPreferences(projectRoot, input.localRoot);
496
- // FR4 / E-004 fold-in: the live dispatch call site reads the persisted
497
- // per-project dispatch-mode overrides once and threads them into every advisor
498
- // dispatch below, so a persisted override reaches dispatch provenance.
499
- const dispatchModeOverrides = await readOperatingDispatchModeOverrides(projectRoot, {
500
- localRoot: input.localRoot,
501
- });
502
553
  const store = new OperatingEventStore(projectRoot, { localRoot: input.localRoot });
503
554
  const now = input.now ?? new Date();
504
555
  const deadlineReconciliation = !input.preview && !input.dryRun
@@ -645,36 +696,36 @@ export async function runOperatingCycle(input) {
645
696
  state: initialState,
646
697
  cycleId: cycle.id,
647
698
  });
699
+ const citationCache = new OperatingEvidenceCache(resolveOperatingPaths(projectRoot, { localRoot: input.localRoot }).evidence, preferences.sensitivityCeiling);
700
+ // FR2 universal gate, bound to the cycle's pinned revision. Injected into
701
+ // dispatch so advisors.ts never imports engine.ts, and reused by the
702
+ // consolidation-time gate below so both paths resolve citations identically.
703
+ const resolveCitations = (roleResults) => gateRecordedProposalCitations({
704
+ roleResults,
705
+ context: {
706
+ projectRoot,
707
+ cycleId: cycle.id,
708
+ descriptor: workspace.controlRepository,
709
+ cache: citationCache,
710
+ owner: config.decisionOwner,
711
+ now,
712
+ },
713
+ });
648
714
  const evaluate = async (persist) => {
649
715
  await assertCycleWorkspace();
650
716
  const evidence = existingEvidence ??
651
- (await collectOperatingEvidence({
652
- projectRoot,
717
+ (await buildPinnedRevisionEvidenceSeed({
653
718
  cycleId: cycle.id,
654
- workspace,
655
- providers: config.enabledProviders,
656
- sensitivityCeiling: preferences.sensitivityCeiling,
657
- budgets: {
658
- ...config.budgets,
659
- maxItemBytes: Math.min(config.budgets.maxBytes, 256 * 1024),
660
- },
661
- localRoot: input.localRoot,
662
- now,
663
- incremental: depth === 'standard',
664
- persistIncremental: persist && depth === 'standard',
665
- requiredEvidenceRefs: requiredOpenEvidenceRefs(initialState),
719
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
720
+ collectedAt: timestamp,
666
721
  }));
667
- const evidenceGaps = await unqualifiedCommercialEvidenceGaps({
668
- cycleId: cycle.id,
669
- evidence,
670
- owner: config.decisionOwner,
671
- now: timestamp,
672
- });
722
+ const evidenceGaps = [];
673
723
  const nonChair = config.enabledRoles.filter((role) => role !== 'chair');
674
- let readiness = await evaluateEvidenceReadiness({
724
+ let readiness = await buildDispatchReadiness({
675
725
  cycleId: cycle.id,
676
- evidence,
677
- enabledRoles: nonChair,
726
+ roles: nonChair,
727
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
728
+ sensitivityCeiling: preferences.sensitivityCeiling,
678
729
  now,
679
730
  });
680
731
  readiness = await remapReadinessGaps(readiness, initialState);
@@ -742,13 +793,14 @@ export async function runOperatingCycle(input) {
742
793
  const first = runnableMissingNonChair.length > 0
743
794
  ? await dispatchOperatingAdvisors({
744
795
  cycleId: cycle.id,
745
- evidence,
796
+ projectRoot,
797
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
746
798
  readiness: { ...readiness, roles: missingNonChair },
747
799
  context: advisorContext,
748
800
  adapter: await resolveAdapter(),
749
801
  depth,
750
802
  runtime: resolvedRuntime,
751
- dispatchModeOverrides,
803
+ resolveCitations,
752
804
  })
753
805
  : {
754
806
  results: [],
@@ -757,6 +809,7 @@ export async function runOperatingCycle(input) {
757
809
  blocked: false,
758
810
  skipped: skippedMissingNonChair,
759
811
  failed: [],
812
+ gaps: [],
760
813
  };
761
814
  let roleResults = [
762
815
  ...existingRoleResults.filter((result) => result.roleId !== 'chair'),
@@ -767,14 +820,19 @@ export async function runOperatingCycle(input) {
767
820
  const dispatchProvenance = [...first.provenance];
768
821
  const skipped = [...first.skipped];
769
822
  const failed = [...first.failed];
770
- let consolidationEvidence = evidence;
823
+ // FR2: the governed gaps opened while resolving each dispatched role's
824
+ // citations (unresolvable-citation, empty-grounding) are recorded alongside
825
+ // the cycle's other gaps.
826
+ const dispatchGaps = [...first.gaps];
771
827
  if (config.enabledRoles.includes('chair')) {
772
- const chairEvidence = buildChairEvidence(evidence, roleResults, timestamp);
773
- consolidationEvidence = chairEvidence;
774
- let chairReadiness = await evaluateEvidenceReadiness({
828
+ // FR1: the Chair dispatches its own mandate and investigates independently —
829
+ // it no longer receives a synthesized advisor-results evidence body. Its
830
+ // proposals cite resolvable content the same way every other lens does.
831
+ let chairReadiness = await buildDispatchReadiness({
775
832
  cycleId: cycle.id,
776
- evidence: chairEvidence,
777
- enabledRoles: ['chair'],
833
+ roles: ['chair'],
834
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
835
+ sensitivityCeiling: preferences.sensitivityCeiling,
778
836
  now,
779
837
  });
780
838
  chairReadiness = await remapReadinessGaps(chairReadiness, {
@@ -825,47 +883,52 @@ export async function runOperatingCycle(input) {
825
883
  blocked: false,
826
884
  skipped: [],
827
885
  failed: [],
886
+ gaps: [],
828
887
  }
829
888
  : await dispatchOperatingAdvisors({
830
889
  cycleId: cycle.id,
831
- evidence: chairEvidence,
890
+ projectRoot,
891
+ pinnedRevision: workspace.controlRepository.pinnedRevision,
832
892
  readiness: chairReadiness,
833
893
  context: advisorContext,
834
894
  adapter: await resolveAdapter(),
835
895
  depth,
836
896
  runtime: resolvedRuntime,
837
- dispatchModeOverrides,
897
+ resolveCitations,
838
898
  });
839
899
  roleResults = [...roleResults, ...chair.results];
840
900
  modelCalls += chair.modelCalls;
841
901
  dispatchProvenance.push(...chair.provenance);
842
902
  skipped.push(...chair.skipped);
843
903
  failed.push(...chair.failed);
904
+ dispatchGaps.push(...chair.gaps);
844
905
  blocked ||= chair.blocked || chairReadiness.roles.some((role) => !role.modelCallAllowed);
845
906
  }
846
907
  const technologyRiskReady = readiness.roles.some((role) => role.roleId === 'technology-risk' && role.modelCallAllowed);
847
908
  const technologyRiskFailed = failed.some((entry) => entry.roleId === 'technology-risk');
848
909
  const sensitiveRouteBlocked = containsSensitiveRouteProposal(roleResults) && (!technologyRiskReady || technologyRiskFailed);
849
910
  blocked ||= sensitiveRouteBlocked;
850
- // FR3 / E-003: resolve every recorded proposal's citations against the cycle's
851
- // pinned revision before consolidation. A proposal built on a fabricated,
852
- // drifted, or uncommitted citation is dropped here and can never reach
853
- // consolidation; its place is taken by a single unresolvable-citation gap.
854
- const citationGate = await gateRecordedProposalCitations({
855
- roleResults,
856
- context: {
857
- projectRoot,
858
- cycleId: cycle.id,
859
- descriptor: workspace.controlRepository,
860
- cache: new OperatingEvidenceCache(resolveOperatingPaths(projectRoot, { localRoot: input.localRoot }).evidence, preferences.sensitivityCeiling),
861
- owner: config.decisionOwner,
862
- now,
863
- },
911
+ // FR2/FR3 / E-003: the universal citation gate. Every dispatched role already
912
+ // resolved its citations at dispatch time (the committed result is v1.2-valid
913
+ // with minted evidenceRefs, no raw citations), so re-running the gate over the
914
+ // full role set — including any natively-committed existing results — is a
915
+ // no-op that provides defence in depth and a single point where consolidation
916
+ // input is proven citation-clean.
917
+ const citationGate = await resolveCitations(roleResults);
918
+ // FR2 (evidence-as-output): the cycle's evidence-of-record is assembled from
919
+ // the resolved-citation snapshots, so confidence/sensitivity/freshness scoring
920
+ // resolves against what was actually cited and verified — never a pre-collected
921
+ // guess. This replaces the retired collected-evidence consolidation input.
922
+ const consolidationEvidence = await assembleCitationEvidence({
923
+ cycleId: cycle.id,
924
+ roleResults: citationGate.roleResults,
925
+ cache: citationCache,
926
+ baseFingerprint: evidence.fingerprint,
927
+ now: timestamp,
864
928
  });
865
929
  // Only the gated proposals feed consolidation, so a proposal with an
866
- // unresolvable citation can never become a finding/route. The raw advisor
867
- // result stays intact as the audit record; the rejection is recorded as the
868
- // opened unresolvable-citation gap threaded into `gaps` below.
930
+ // unresolvable citation can never become a finding/route. The rejection is
931
+ // recorded as the opened unresolvable-citation gap threaded into `gaps` below.
869
932
  const consolidated = await consolidateOperatingResults({
870
933
  cycleId: cycle.id,
871
934
  results: citationGate.roleResults,
@@ -936,9 +999,13 @@ export async function runOperatingCycle(input) {
936
999
  decisions: remapped.decisions,
937
1000
  // Citation gaps bypass remapConsolidation (their IDs are digest-derived and
938
1001
  // already canonical) and are persisted alongside the cycle's other gaps.
1002
+ // `dispatchGaps` are the unresolvable-citation/empty-grounding gaps opened
1003
+ // while resolving each role's citations at dispatch time; `citationGate.gaps`
1004
+ // covers the no-op consolidation-time re-gate (empty in practice).
939
1005
  gaps: [
940
1006
  ...readinessGaps(readiness, config.decisionOwner, timestamp),
941
1007
  ...remapped.gaps,
1008
+ ...dispatchGaps,
942
1009
  ...citationGate.gaps,
943
1010
  ],
944
1011
  routes: [],