openplanr 1.16.2 → 1.18.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 (128) hide show
  1. package/dist/cli/commands/operate.d.ts +10 -1
  2. package/dist/cli/commands/operate.d.ts.map +1 -1
  3. package/dist/cli/commands/operate.js +240 -13
  4. package/dist/cli/commands/operate.js.map +1 -1
  5. package/dist/services/ai-service.d.ts +25 -0
  6. package/dist/services/ai-service.d.ts.map +1 -1
  7. package/dist/services/ai-service.js +38 -0
  8. package/dist/services/ai-service.js.map +1 -1
  9. package/dist/services/operate/advisors.d.ts +82 -1
  10. package/dist/services/operate/advisors.d.ts.map +1 -1
  11. package/dist/services/operate/advisors.js +469 -17
  12. package/dist/services/operate/advisors.js.map +1 -1
  13. package/dist/services/operate/artifacts.d.ts +27 -1
  14. package/dist/services/operate/artifacts.d.ts.map +1 -1
  15. package/dist/services/operate/artifacts.js +103 -1
  16. package/dist/services/operate/artifacts.js.map +1 -1
  17. package/dist/services/operate/cadence.d.ts +50 -0
  18. package/dist/services/operate/cadence.d.ts.map +1 -0
  19. package/dist/services/operate/cadence.js +56 -0
  20. package/dist/services/operate/cadence.js.map +1 -0
  21. package/dist/services/operate/citation-resolution.d.ts +101 -0
  22. package/dist/services/operate/citation-resolution.d.ts.map +1 -0
  23. package/dist/services/operate/citation-resolution.js +308 -0
  24. package/dist/services/operate/citation-resolution.js.map +1 -0
  25. package/dist/services/operate/config.d.ts +93 -1
  26. package/dist/services/operate/config.d.ts.map +1 -1
  27. package/dist/services/operate/config.js +199 -9
  28. package/dist/services/operate/config.js.map +1 -1
  29. package/dist/services/operate/decision-brief.d.ts +148 -0
  30. package/dist/services/operate/decision-brief.d.ts.map +1 -0
  31. package/dist/services/operate/decision-brief.js +194 -0
  32. package/dist/services/operate/decision-brief.js.map +1 -0
  33. package/dist/services/operate/doctor.d.ts.map +1 -1
  34. package/dist/services/operate/doctor.js +271 -3
  35. package/dist/services/operate/doctor.js.map +1 -1
  36. package/dist/services/operate/engine.d.ts +66 -2
  37. package/dist/services/operate/engine.d.ts.map +1 -1
  38. package/dist/services/operate/engine.js +219 -7
  39. package/dist/services/operate/engine.js.map +1 -1
  40. package/dist/services/operate/event-store.d.ts +35 -1
  41. package/dist/services/operate/event-store.d.ts.map +1 -1
  42. package/dist/services/operate/event-store.js +142 -29
  43. package/dist/services/operate/event-store.js.map +1 -1
  44. package/dist/services/operate/evidence-cache.d.ts +25 -1
  45. package/dist/services/operate/evidence-cache.d.ts.map +1 -1
  46. package/dist/services/operate/evidence-cache.js +62 -1
  47. package/dist/services/operate/evidence-cache.js.map +1 -1
  48. package/dist/services/operate/evidence.d.ts +20 -1
  49. package/dist/services/operate/evidence.d.ts.map +1 -1
  50. package/dist/services/operate/evidence.js +148 -0
  51. package/dist/services/operate/evidence.js.map +1 -1
  52. package/dist/services/operate/index.d.ts +2 -0
  53. package/dist/services/operate/index.d.ts.map +1 -1
  54. package/dist/services/operate/index.js +309 -46
  55. package/dist/services/operate/index.js.map +1 -1
  56. package/dist/services/operate/interaction/action-service.d.ts +17 -0
  57. package/dist/services/operate/interaction/action-service.d.ts.map +1 -1
  58. package/dist/services/operate/interaction/action-service.js +19 -0
  59. package/dist/services/operate/interaction/action-service.js.map +1 -1
  60. package/dist/services/operate/interaction/answer-service.d.ts +17 -0
  61. package/dist/services/operate/interaction/answer-service.d.ts.map +1 -1
  62. package/dist/services/operate/interaction/answer-service.js +49 -7
  63. package/dist/services/operate/interaction/answer-service.js.map +1 -1
  64. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  65. package/dist/services/operate/interaction/question-engine.js +9 -3
  66. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  67. package/dist/services/operate/interaction/question-registry.d.ts +13 -0
  68. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  69. package/dist/services/operate/interaction/question-registry.js +116 -26
  70. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  71. package/dist/services/operate/interaction/terminal-renderer.d.ts +12 -1
  72. package/dist/services/operate/interaction/terminal-renderer.d.ts.map +1 -1
  73. package/dist/services/operate/interaction/terminal-renderer.js +26 -15
  74. package/dist/services/operate/interaction/terminal-renderer.js.map +1 -1
  75. package/dist/services/operate/journal.d.ts.map +1 -1
  76. package/dist/services/operate/journal.js +11 -5
  77. package/dist/services/operate/journal.js.map +1 -1
  78. package/dist/services/operate/lifecycle.d.ts +8 -0
  79. package/dist/services/operate/lifecycle.d.ts.map +1 -1
  80. package/dist/services/operate/lifecycle.js +19 -1
  81. package/dist/services/operate/lifecycle.js.map +1 -1
  82. package/dist/services/operate/maintenance.d.ts +22 -0
  83. package/dist/services/operate/maintenance.d.ts.map +1 -1
  84. package/dist/services/operate/maintenance.js +406 -46
  85. package/dist/services/operate/maintenance.js.map +1 -1
  86. package/dist/services/operate/migration.d.ts +68 -0
  87. package/dist/services/operate/migration.d.ts.map +1 -0
  88. package/dist/services/operate/migration.js +235 -0
  89. package/dist/services/operate/migration.js.map +1 -0
  90. package/dist/services/operate/mission-dispatch.d.ts +193 -0
  91. package/dist/services/operate/mission-dispatch.d.ts.map +1 -0
  92. package/dist/services/operate/mission-dispatch.js +444 -0
  93. package/dist/services/operate/mission-dispatch.js.map +1 -0
  94. package/dist/services/operate/projection-persistence.d.ts +6 -0
  95. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  96. package/dist/services/operate/projection-persistence.js +87 -11
  97. package/dist/services/operate/projection-persistence.js.map +1 -1
  98. package/dist/services/operate/projection.d.ts +26 -1
  99. package/dist/services/operate/projection.d.ts.map +1 -1
  100. package/dist/services/operate/projection.js +62 -0
  101. package/dist/services/operate/projection.js.map +1 -1
  102. package/dist/services/operate/protocol.d.ts +50 -3
  103. package/dist/services/operate/protocol.d.ts.map +1 -1
  104. package/dist/services/operate/protocol.js +57 -5
  105. package/dist/services/operate/protocol.js.map +1 -1
  106. package/dist/services/operate/read-only-providers.d.ts +37 -0
  107. package/dist/services/operate/read-only-providers.d.ts.map +1 -1
  108. package/dist/services/operate/read-only-providers.js +130 -0
  109. package/dist/services/operate/read-only-providers.js.map +1 -1
  110. package/dist/services/operate/reports.d.ts +22 -0
  111. package/dist/services/operate/reports.d.ts.map +1 -1
  112. package/dist/services/operate/reports.js +226 -1
  113. package/dist/services/operate/reports.js.map +1 -1
  114. package/dist/services/operate/routes.d.ts.map +1 -1
  115. package/dist/services/operate/routes.js +79 -5
  116. package/dist/services/operate/routes.js.map +1 -1
  117. package/dist/services/operate/types.d.ts +139 -4
  118. package/dist/services/operate/types.d.ts.map +1 -1
  119. package/dist/services/operate/types.js +13 -0
  120. package/dist/services/operate/types.js.map +1 -1
  121. package/dist/services/operate/workspace.d.ts +7 -0
  122. package/dist/services/operate/workspace.d.ts.map +1 -1
  123. package/dist/services/operate/workspace.js +14 -5
  124. package/dist/services/operate/workspace.js.map +1 -1
  125. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  126. package/dist/services/runtime-manager-service.js +25 -5
  127. package/dist/services/runtime-manager-service.js.map +1 -1
  128. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
1
  import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, buildAdvisorOperatingContext, createNativeOperatingRoleResult, createOperatingAdvisorPack, } from './advisors.js';
4
+ import { advisorResponseContractDetails, assertAdvisorOutputMatchesBrief, assertOperatingAdvisorPackWithinBudget, buildAdvisorOperatingContext, createNativeMissionOperatingRoleResult, createNativeOperatingRoleResult, createOperatingAdvisorPack, } from './advisors.js';
5
5
  import { canonicalDigest, canonicalize, sha256Digest } from './canonical.js';
6
- import { operatingProjectKey } from './config.js';
7
- import { buildChairEvidence } from './engine.js';
6
+ import { operatingProjectKey, readOperatingAdapterLeaseDurationMs, readOperatingDispatchModeOverrides, } from './config.js';
7
+ import { buildChairEvidence, buildOperatingMissionPackets, gateRecordedProposalCitations, } from './engine.js';
8
8
  import { OperatingEventStore } from './event-store.js';
9
9
  import { OperatingEvidenceCache } from './evidence-cache.js';
10
10
  import { purgeStaleEvidenceClassifications } from './evidence-classifications.js';
@@ -13,12 +13,22 @@ import { evaluateEvidenceReadiness } from './evidence-readiness.js';
13
13
  import { guidedSessionStatus, purgeGuidedSessions } from './interaction/session-service.js';
14
14
  import { assertCommittedOperatingView, recoverOperatingTransactions } from './journal.js';
15
15
  import { withOperatingLock } from './lock-service.js';
16
+ import { narrowMissionRootsToCeiling, operatingRegistryDispatchMode, operatingRuntimeEnforcesBoundedReadOnly, resolveOperatingDispatchMode, } from './mission-dispatch.js';
16
17
  import { assertOperatingArtifact, loadOperatingProtocol } from './protocol.js';
17
18
  import { containsSecret, redactSensitiveText } from './redaction.js';
18
19
  import { OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
19
- import { resolveContainedPath, resolveOperatingPaths } from './workspace.js';
20
+ import { readOperatingConfig, refreshOperatingWorkspaceManifest, resolveContainedPath, resolveOperatingPaths, } from './workspace.js';
21
+ /**
22
+ * Whether any bound role of this session resolved to native mission dispatch, so
23
+ * both adapter-handoff call sites can pass `protocolVersion: '1.3.0'` and the
24
+ * pipeline emits the v1.3 mission record action (`dispatch.agent` +
25
+ * `dispatch.missionPacketPointer`) instead of the v1.2 `rolePackPointer`.
26
+ */
27
+ function sessionHasMissionRole(session) {
28
+ return Boolean(session.roleMissionPackets && Object.keys(session.roleMissionPackets).length > 0);
29
+ }
20
30
  function adapterSessionSummary(session) {
21
- const { roleBriefs: _roleBriefs, rolePacks: _rolePacks, ...summary } = session;
31
+ const { roleBriefs: _roleBriefs, rolePacks: _rolePacks, roleMissionPackets: _roleMissionPackets, ...summary } = session;
22
32
  return summary;
23
33
  }
24
34
  async function adapterHandoff(session) {
@@ -32,6 +42,10 @@ async function adapterHandoff(session) {
32
42
  : 'finalize-required';
33
43
  const protocol = await loadOperatingProtocol();
34
44
  const handoff = protocol.createOperatingAdapterHandoff({
45
+ // v1.3 when any bound role resolved mission, so the record action names the
46
+ // generated lens agent and points at its mission packet; v1.2 (pack) default
47
+ // otherwise, byte-compatible with the pack-mode handoff.
48
+ ...(sessionHasMissionRole(session) ? { protocolVersion: '1.3.0' } : {}),
35
49
  phase: session.phase,
36
50
  state,
37
51
  cycleId: session.cycleId,
@@ -55,8 +69,66 @@ function sameRoleSet(left, right) {
55
69
  return (left.length === right.length &&
56
70
  [...left].sort().every((role, index) => role === [...right].sort()[index]));
57
71
  }
58
- function sessionExpired(session) {
59
- return Date.parse(session.expiresAt) <= Date.now();
72
+ function sessionExpired(session, nowMs = Date.now()) {
73
+ return Date.parse(session.expiresAt) <= nowMs;
74
+ }
75
+ /**
76
+ * FR4: the board's identity is the hash of its event-chain genesis event (the
77
+ * event whose `previousEventHash` is null). It is immutable for a board
78
+ * generation — appending events never rewrites the genesis — and a board
79
+ * re-inited at the same path re-genesises the chain, so the identity changes.
80
+ * Machine-local adapter sessions are bound to it so a session from a superseded
81
+ * generation is never matched or reused by the current board. Returns '' when
82
+ * no committed chain exists yet, or if the chain cannot be read/verified (that
83
+ * failure is surfaced by the dedicated event-replay diagnostics, not here).
84
+ */
85
+ async function committedBoardIdentity(store) {
86
+ try {
87
+ const { events } = await store.replay();
88
+ const genesis = events.find((event) => event.previousEventHash === null) ?? events[0];
89
+ return genesis?.eventHash ?? '';
90
+ }
91
+ catch {
92
+ return '';
93
+ }
94
+ }
95
+ async function removeMachineLocalCacheDir(target) {
96
+ const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
97
+ const removed = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).length;
98
+ await rm(target, { recursive: true, force: true });
99
+ return removed;
100
+ }
101
+ /**
102
+ * FR4: purge the board's machine-local advisor sessions (`<localRoot>/advisors/`)
103
+ * and incremental evidence baselines (`<localRoot>/evidence/incremental/`). A
104
+ * committed `operate init` apply calls this so a board re-inited at the same path
105
+ * never inherits a prior generation's sessions or cached baselines, and
106
+ * `operate cache purge` calls it so the doctor's staleness diagnostics have a
107
+ * scoped fix command. Both surfaces are rebuildable machine-local caches, never
108
+ * committed protocol artifacts.
109
+ */
110
+ export async function purgeBoardMachineLocalCaches(input) {
111
+ const paths = resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot });
112
+ const removedAdvisorSessions = await removeMachineLocalCacheDir(paths.advisors);
113
+ const removedIncrementalBaselines = await removeMachineLocalCacheDir(path.join(paths.evidence, 'incremental'));
114
+ return { removedAdvisorSessions, removedIncrementalBaselines };
115
+ }
116
+ /**
117
+ * Surface the adapter session lease ergonomically (FR10 / T-008): the absolute
118
+ * `expiresAt` plus the remaining time relative to the resolved clock. Included in
119
+ * every adapter lifecycle response so a native runtime can see, at a glance, how
120
+ * long its prepared session is still valid and when it must resume or re-run —
121
+ * rather than parsing `expiresAt` against wall-clock itself. `remainingMs` is
122
+ * floored at zero so an already-lapsed lease reads as `expired`, never negative.
123
+ */
124
+ function adapterLeaseStatus(session, nowMs) {
125
+ const remainingMs = Math.max(0, Date.parse(session.expiresAt) - nowMs);
126
+ return {
127
+ expiresAt: session.expiresAt,
128
+ remainingMs,
129
+ remainingSeconds: Math.floor(remainingMs / 1_000),
130
+ expired: remainingMs <= 0,
131
+ };
60
132
  }
61
133
  function retryRunCommand(session) {
62
134
  return `planr operate run --cycle-id ${session.cycleId} --runtime ${session.runtime} --json`;
@@ -148,16 +220,36 @@ export async function operatingCacheAction(input) {
148
220
  localRoot: input.localRoot,
149
221
  purge: true,
150
222
  });
223
+ // FR4/FR11: clearing machine-local caches also drops board-bound adapter
224
+ // sessions and incremental evidence baselines, so this is the scoped fix the
225
+ // doctor names for stale adapter sessions and stale incremental baselines.
226
+ const board = await purgeBoardMachineLocalCaches({
227
+ projectRoot: input.projectRoot,
228
+ localRoot: input.localRoot,
229
+ });
151
230
  return {
152
- removed: removed.length + sessions.removed + classifications.purged,
231
+ removed: removed.length +
232
+ sessions.removed +
233
+ classifications.purged +
234
+ board.removedAdvisorSessions +
235
+ board.removedIncrementalBaselines,
153
236
  evidence: { removed: removed.length, entries: removed },
154
237
  sessions,
155
238
  classifications,
239
+ adapterSessions: { removed: board.removedAdvisorSessions },
240
+ incrementalBaselines: { removed: board.removedIncrementalBaselines },
156
241
  };
157
242
  }
158
243
  function integrityKeyPath(projectRoot, localRoot) {
159
244
  return path.join(resolveOperatingPaths(projectRoot, { localRoot }).localRoot, 'integrity.key');
160
245
  }
246
+ // The committed checkpoint moved under `.state/` in Protocol v1.3; derive its
247
+ // project-relative display path from the resolver so these results never drift
248
+ // from the on-disk layout again.
249
+ function operatingCheckpointRelativePath(projectRoot, localRoot) {
250
+ const paths = resolveOperatingPaths(projectRoot, { localRoot });
251
+ return path.relative(projectRoot, paths.checkpoint).split(path.sep).join('/');
252
+ }
161
253
  async function loadIntegrityKey(projectRoot, localRoot) {
162
254
  return readFile(integrityKeyPath(projectRoot, localRoot)).catch(() => null);
163
255
  }
@@ -201,7 +293,7 @@ export async function operatingIntegrityAction(input) {
201
293
  return {
202
294
  status: checkpoint.integrity.status,
203
295
  keyId: checkpoint.integrity.status === 'signed' ? checkpoint.integrity.signature.keyId : null,
204
- checkpoint: '.planr/operate/checkpoints/current.json',
296
+ checkpoint: operatingCheckpointRelativePath(input.projectRoot, input.localRoot),
205
297
  };
206
298
  });
207
299
  }
@@ -467,7 +559,7 @@ export async function repairOperatingSecurity(input) {
467
559
  },
468
560
  checkpoint: {
469
561
  integrity: checkpoint.integrity,
470
- path: '.planr/operate/checkpoints/current.json',
562
+ path: operatingCheckpointRelativePath(input.projectRoot, input.localRoot),
471
563
  },
472
564
  guidance,
473
565
  };
@@ -552,7 +644,7 @@ export async function readPersistedOperatingRoleResults(store, cycleId) {
552
644
  }
553
645
  return [...byRole.values()].sort((left, right) => left.roleId.localeCompare(right.roleId));
554
646
  }
555
- async function readAdapterSession(projectRoot, cycleId, localRoot) {
647
+ async function readAdapterSession(projectRoot, cycleId, localRoot, nowMs = Date.now()) {
556
648
  const parsed = JSON.parse(await readFile(adapterSessionPath(projectRoot, cycleId, localRoot), 'utf8'));
557
649
  const session = {
558
650
  ...parsed,
@@ -562,7 +654,10 @@ async function readAdapterSession(projectRoot, cycleId, localRoot) {
562
654
  if (session.implementation !== 'openplanr-operate-adapter') {
563
655
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Adapter session is invalid.');
564
656
  }
565
- if (sessionExpired(session)) {
657
+ // Expiry is evaluated against the resolved clock (an injected clock in tests,
658
+ // wall-clock in production) so a lease that lapsed after its refresh window is
659
+ // still rejected here even when the caller supplies a deterministic clock.
660
+ if (sessionExpired(session, nowMs)) {
566
661
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Adapter session expired.', {
567
662
  cycleId,
568
663
  recoveryCommand: retryRunCommand(session),
@@ -593,6 +688,81 @@ async function assertAdapterCycleBinding(input, session) {
593
688
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Adapter evidence no longer matches the committed cycle snapshot.');
594
689
  }
595
690
  }
691
+ /**
692
+ * The machine-local sensitivity ceiling (default `internal`), read the same way
693
+ * the evidence cache and cycle engine read it. Mission packets and their tool
694
+ * grants are narrowed to this ceiling.
695
+ */
696
+ async function readAdapterSensitivityCeiling(projectRoot, localRoot) {
697
+ const paths = resolveOperatingPaths(projectRoot, { localRoot });
698
+ return readFile(path.join(paths.localRoot, 'preferences.json'), 'utf8')
699
+ .then((raw) => JSON.parse(raw).sensitivityCeiling ??
700
+ 'internal')
701
+ .catch(() => 'internal');
702
+ }
703
+ /**
704
+ * FR2: deny-list every declared read root that contains an above-ceiling evidence
705
+ * item BEFORE the mission packet is built, so no above-ceiling file is reachable
706
+ * even inside a granted root. Uses `mission-dispatch.ts`'s
707
+ * `narrowMissionRootsToCeiling` over the FULL (pre-ceiling-filter) evidence, then
708
+ * drops the items in the denied roots. `buildOperatingMissionPackets` then derives
709
+ * the packet's `declaredRoots` (and thus its tool grant) from only the narrowed
710
+ * roots. This is the belt to the bounded reader's read-time-ceiling suspenders.
711
+ */
712
+ function narrowEvidenceToMissionCeiling(evidence, ceiling) {
713
+ const topSegment = (location) => {
714
+ const [top] = location.split('/');
715
+ return top && top.length > 0 ? top : null;
716
+ };
717
+ const declaredRoots = [
718
+ ...new Set(evidence.items
719
+ .map((item) => topSegment(item.location))
720
+ .filter((segment) => Boolean(segment))),
721
+ ];
722
+ const evidenceIndex = evidence.items.map((item) => ({
723
+ path: item.location,
724
+ sensitivity: item.sensitivity,
725
+ }));
726
+ const allowedRoots = new Set(narrowMissionRootsToCeiling({ declaredRoots, evidenceIndex, ceiling }));
727
+ return {
728
+ ...evidence,
729
+ items: evidence.items.filter((item) => {
730
+ const top = topSegment(item.location);
731
+ return top === null || allowedRoots.has(top);
732
+ }),
733
+ };
734
+ }
735
+ /**
736
+ * Resolve every requested role's effective dispatch mode for THIS runtime (FR1):
737
+ * the v1.3 registry default, overridden by the machine-local
738
+ * `dispatchModeOverrides`, reconciled against whether the runtime natively
739
+ * enforces the bounded read-only boundary. The adapter lifecycle IS the native
740
+ * runtime path, so the hosting adapter is native-capable by construction; the
741
+ * runtime's own enforceability then decides whether a role receives a native
742
+ * bounded mission lens (`resolution.native`) or falls closed to the v1.2 pack.
743
+ */
744
+ async function resolveBoundRoleDispatchModes(input) {
745
+ const protocol = await loadOperatingProtocol();
746
+ const registryDefaults = new Map(protocol.listOperatingRoles().map((role) => [
747
+ role.id,
748
+ operatingRegistryDispatchMode(role),
749
+ ]));
750
+ const overrides = await readOperatingDispatchModeOverrides(input.projectRoot, {
751
+ localRoot: input.localRoot,
752
+ });
753
+ const runtimeEnforcesBoundedReadOnly = await operatingRuntimeEnforcesBoundedReadOnly(input.runtime);
754
+ const resolved = new Map();
755
+ for (const roleId of input.roles) {
756
+ resolved.set(roleId, resolveOperatingDispatchMode({
757
+ roleId: roleId,
758
+ registryDefault: registryDefaults.get(roleId) ?? 'mission',
759
+ override: overrides[roleId],
760
+ runtimeEnforcesBoundedReadOnly,
761
+ adapterNativeCapable: true,
762
+ }));
763
+ }
764
+ return resolved;
765
+ }
596
766
  export async function createOperatingAdapterStartHandoff(input) {
597
767
  const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
598
768
  const existing = await readFile(target, 'utf8')
@@ -618,7 +788,19 @@ export async function createOperatingAdapterStartHandoff(input) {
618
788
  }
619
789
  }
620
790
  const protocol = await loadOperatingProtocol();
791
+ // FR1 call site 2 (the start / prepare-required handoff): stamp v1.3 whenever
792
+ // any bound role would resolve to native mission dispatch on this runtime, so
793
+ // the whole lifecycle — including the record actions built after prepare —
794
+ // carries the mission protocol version rather than defaulting to pack.
795
+ const dispatchModes = await resolveBoundRoleDispatchModes({
796
+ projectRoot: input.projectRoot,
797
+ localRoot: input.localRoot,
798
+ runtime: input.runtime,
799
+ roles: input.roles,
800
+ });
801
+ const anyMission = [...dispatchModes.values()].some((resolution) => resolution.native);
621
802
  const handoff = protocol.createOperatingAdapterHandoff({
803
+ ...(anyMission ? { protocolVersion: '1.3.0' } : {}),
622
804
  phase: input.phase,
623
805
  state: 'prepare-required',
624
806
  cycleId: input.cycleId,
@@ -639,6 +821,13 @@ export async function operateAdapterLifecycle(input) {
639
821
  if (!input.cycleId || !input.idempotencyKey) {
640
822
  throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter calls require --cycle-id and --idempotency-key.');
641
823
  }
824
+ const nowMs = (input.now?.() ?? new Date()).getTime();
825
+ // The lease window is a machine-local preference (default 15 minutes). Resolved
826
+ // once so both the fresh `prepare` expiry and the per-`record` refresh use the
827
+ // same configured duration.
828
+ const leaseDurationMs = await readOperatingAdapterLeaseDurationMs(input.projectRoot, {
829
+ localRoot: input.localRoot,
830
+ });
642
831
  const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
643
832
  if (input.action === 'prepare') {
644
833
  if (!input.evidenceDigest?.startsWith('sha256:')) {
@@ -648,6 +837,9 @@ export async function operateAdapterLifecycle(input) {
648
837
  localRoot: input.localRoot,
649
838
  });
650
839
  const state = await store.state();
840
+ // FR4: the identity of the board that owns this prepare. A machine-local
841
+ // session bound to any other genesis belongs to a superseded generation.
842
+ const boardIdentity = await committedBoardIdentity(store);
651
843
  const cycle = state.cycles.find((record) => record.id === input.cycleId);
652
844
  if (!cycle || !['advising', 'blocked'].includes(cycle.state)) {
653
845
  throw new OperateError('E_OPERATE_STATE_INVALID', 'Adapter prepare requires an advising or blocked cycle.');
@@ -686,36 +878,63 @@ export async function operateAdapterLifecycle(input) {
686
878
  const existing = await readFile(target, 'utf8')
687
879
  .then((raw) => JSON.parse(raw))
688
880
  .catch(() => null);
881
+ // Recovery semantics (FR10 / T-008, behavior unchanged — documented here so
882
+ // the lease ergonomics read coherently): when a prior session for this cycle
883
+ // exists and its binding is an *exact* match (same cycle, evidence digest,
884
+ // runtime, phase, and role set) but it has lapsed or was cancelled, its
885
+ // deterministic role packs are reused rather than rebuilt. The reused packs
886
+ // carry the same per-role `inputDigest`, so any machine-local role result that
887
+ // still matches that digest is re-adopted below as already-recorded work — the
888
+ // lease is reissued fresh (new token + refreshed `expiresAt`) while the proven,
889
+ // digest-bound advisory output is preserved. A non-exact prior binding is never
890
+ // recovered; it fails closed above with `E_OPERATE_ADVISOR_ISOLATION`.
689
891
  let recoverableSession = null;
690
892
  if (existing) {
691
893
  const normalized = {
692
894
  ...existing,
693
895
  phase: existing.phase ?? adapterPhase(existing.roles ?? []),
694
896
  runtime: existing.runtime ?? 'auto',
897
+ boardIdentity: existing.boardIdentity ?? '',
695
898
  };
696
- const exact = normalized.cycleId === input.cycleId &&
697
- normalized.evidenceDigest === input.evidenceDigest &&
698
- normalized.runtime === runtime &&
699
- normalized.phase === phase &&
700
- sameRoleSet(normalized.roles, roles);
701
- if (normalized.idempotencyKey === input.idempotencyKey) {
702
- if (!exact) {
703
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Idempotent adapter prepare does not match its original cycle, evidence, runtime, phase, or role binding.');
899
+ // FR4: a session bound to a superseded board generation (its genesis no
900
+ // longer matches the committed event chain) never blocks, matches, or is
901
+ // reused by the current board — it is silently superseded by the fresh
902
+ // session written below. This is what lets a board re-inited at the same
903
+ // path — whose CYCLE-NNN ordinal collides with a prior generation's
904
+ // finalized session — prepare cleanly instead of dead-ending on
905
+ // `E_OPERATE_ADVISOR_ISOLATION` / a whole-cycle cancel.
906
+ const sessionMatchesBoard = normalized.boardIdentity === boardIdentity;
907
+ if (sessionMatchesBoard) {
908
+ const exact = normalized.cycleId === input.cycleId &&
909
+ normalized.evidenceDigest === input.evidenceDigest &&
910
+ normalized.runtime === runtime &&
911
+ normalized.phase === phase &&
912
+ sameRoleSet(normalized.roles, roles);
913
+ if (normalized.idempotencyKey === input.idempotencyKey) {
914
+ if (!exact) {
915
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Idempotent adapter prepare does not match its original cycle, evidence, runtime, phase, or role binding.');
916
+ }
917
+ if (sessionExpired(normalized) || normalized.state === 'cancelled') {
918
+ throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'The prepared adapter session is expired or cancelled; request a fresh CLI-owned handoff.', { recoveryCommand: retryRunCommand(normalized) });
919
+ }
920
+ return {
921
+ ...normalized,
922
+ leaseStatus: adapterLeaseStatus(normalized, nowMs),
923
+ handoff: await adapterHandoff(normalized),
924
+ };
704
925
  }
705
- if (sessionExpired(normalized) || normalized.state === 'cancelled') {
706
- throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'The prepared adapter session is expired or cancelled; request a fresh CLI-owned handoff.', { recoveryCommand: retryRunCommand(normalized) });
926
+ if (!sessionExpired(normalized) && ['prepared', 'recording'].includes(normalized.state)) {
927
+ throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already has an active adapter session with another binding.`, { recoveryCommand: retryRunCommand(normalized) });
928
+ }
929
+ // FR4: a finalized/expired/cancelled same-board session no longer forces
930
+ // a whole-cycle-cancelling error on a new compatible prepare. The
931
+ // advisors→chair continuation and a same-phase re-prepare both fall
932
+ // through and supersede the dead session; an exact dead session still
933
+ // recovers its already-built packs so recorded advisory work is reused
934
+ // rather than recomputed.
935
+ if (exact && (sessionExpired(normalized) || normalized.state === 'cancelled')) {
936
+ recoverableSession = normalized;
707
937
  }
708
- return { ...normalized, handoff: await adapterHandoff(normalized) };
709
- }
710
- if (!sessionExpired(normalized) && ['prepared', 'recording'].includes(normalized.state)) {
711
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already has an active adapter session with another binding.`, { recoveryCommand: retryRunCommand(normalized) });
712
- }
713
- if (normalized.state === 'finalized' &&
714
- !(normalized.phase === 'advisors' && phase === 'chair')) {
715
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already finalized its ${normalized.phase} adapter phase.`, { recoveryCommand: retryRunCommand(normalized) });
716
- }
717
- if (exact && (sessionExpired(normalized) || normalized.state === 'cancelled')) {
718
- recoverableSession = normalized;
719
938
  }
720
939
  }
721
940
  const roleEvidence = roles[0] === 'chair'
@@ -741,8 +960,22 @@ export async function operateAdapterLifecycle(input) {
741
960
  state,
742
961
  cycleId: input.cycleId,
743
962
  });
963
+ // FR1: resolve each requested role's dispatch mode for THIS runtime. A role
964
+ // that resolves to a native bounded lens (`resolution.native`) gets a
965
+ // body-free v1.3 mission packet; every other role keeps the v1.2 role pack.
966
+ // A recovered dead-but-exact session reuses its stored packs/packets verbatim.
967
+ const dispatchModes = await resolveBoundRoleDispatchModes({
968
+ projectRoot: input.projectRoot,
969
+ localRoot: input.localRoot,
970
+ runtime,
971
+ roles,
972
+ });
973
+ const missionRoleIds = readiness.roles
974
+ .map((role) => role.roleId)
975
+ .filter((roleId) => dispatchModes.get(roleId)?.native);
976
+ const packReadinessRoles = readiness.roles.filter((role) => !dispatchModes.get(role.roleId)?.native);
744
977
  const rolePacks = recoverableSession?.rolePacks ??
745
- Object.fromEntries(await Promise.all(readiness.roles.map(async (role) => [
978
+ Object.fromEntries(await Promise.all(packReadinessRoles.map(async (role) => [
746
979
  role.roleId,
747
980
  await createOperatingAdvisorPack({
748
981
  cycleId: input.cycleId,
@@ -751,7 +984,66 @@ export async function operateAdapterLifecycle(input) {
751
984
  context,
752
985
  }),
753
986
  ])));
754
- const roleBriefs = Object.fromEntries(Object.entries(rolePacks).map(([role, pack]) => [role, pack.roleBrief]));
987
+ // FR1: build the natively-dispatched roles' mission packets from live
988
+ // cycle/workspace state. Only reached when a role actually resolved mission,
989
+ // so a pure pack-mode prepare never loads config/workspace and stays
990
+ // byte-compatible with the v1.2 path. The evidence is ceiling-narrowed by
991
+ // `narrowMissionRootsToCeiling` first, so a declared read root can never reach
992
+ // an above-ceiling file even before the bounded reader's read-time check.
993
+ const roleMissionPackets = recoverableSession?.roleMissionPackets ??
994
+ (missionRoleIds.length > 0
995
+ ? await (async () => {
996
+ const [config, workspace, sensitivityCeiling] = await Promise.all([
997
+ readOperatingConfig(input.projectRoot, { localRoot: input.localRoot }),
998
+ refreshOperatingWorkspaceManifest(input.projectRoot, { localRoot: input.localRoot }),
999
+ readAdapterSensitivityCeiling(input.projectRoot, input.localRoot),
1000
+ ]);
1001
+ const packets = await buildOperatingMissionPackets({
1002
+ cycleId: input.cycleId,
1003
+ config,
1004
+ workspace,
1005
+ context,
1006
+ evidence: narrowEvidenceToMissionCeiling(roleEvidence, sensitivityCeiling),
1007
+ roleIds: missionRoleIds,
1008
+ sensitivityCeiling,
1009
+ });
1010
+ return Object.fromEntries(packets.map((packet) => [packet.roleId, packet]));
1011
+ })()
1012
+ : {});
1013
+ // FR2: fail closed before a native adapter session is assembled. A freshly
1014
+ // built pack is already measured inside `createOperatingAdvisorPack`, but the
1015
+ // session may instead be RECOVERED from disk (`recoverableSession`) — possibly
1016
+ // written before pack-budget enforcement existed. Re-measure every pack that
1017
+ // will back this session against its role's published v1.2 `maxInputBytes`, so
1018
+ // an oversized pack can never reach the adapter from either source instead of
1019
+ // silently constructing an oversized session. (Mission packets are measured
1020
+ // against their derived mission budget inside `buildOperatingMissionPackets`.)
1021
+ const packInputBudgets = new Map(protocol.listOperatingRoles().map((role) => {
1022
+ const maxInputBytes = role.budgets
1023
+ ?.maxInputBytes;
1024
+ return [role.id, typeof maxInputBytes === 'number' ? maxInputBytes : 262_144];
1025
+ }));
1026
+ for (const [roleId, pack] of Object.entries(rolePacks)) {
1027
+ assertOperatingAdvisorPackWithinBudget(pack, packInputBudgets.get(roleId) ?? 262_144);
1028
+ }
1029
+ // A mission role's brief facet is the same registry-derived brief pack mode
1030
+ // uses, so the record path's brief-bound machinery (output limits, contract
1031
+ // details, output-vs-brief checks) applies uniformly across both modes.
1032
+ const roleBriefs = {
1033
+ ...Object.fromEntries(Object.entries(rolePacks).map(([role, pack]) => [role, pack.roleBrief])),
1034
+ ...Object.fromEntries(Object.keys(roleMissionPackets).map((role) => [
1035
+ role,
1036
+ protocol.createOperatingAdvisorBrief(role),
1037
+ ])),
1038
+ };
1039
+ // A role's committed input digest is its pack's `inputDigest` (pack mode) or
1040
+ // its mission packet's `packetDigest` (mission mode). The record path binds a
1041
+ // recorded result to exactly this value, and a recovered machine-local result
1042
+ // is re-adopted only when it still matches it.
1043
+ const roleInputDigests = Object.fromEntries(roles.map((role) => {
1044
+ const digest = roleMissionPackets[role]?.packetDigest ?? rolePacks[role]?.inputDigest;
1045
+ return [role, digest];
1046
+ }));
755
1047
  const validRecordedRoles = [];
756
1048
  for (const role of roles) {
757
1049
  const prior = await readFile(path.join(path.dirname(target), `${input.cycleId}.${role}.json`), 'utf8')
@@ -760,7 +1052,7 @@ export async function operateAdapterLifecycle(input) {
760
1052
  if (prior &&
761
1053
  prior.cycleId === input.cycleId &&
762
1054
  prior.roleId === role &&
763
- prior.inputDigest === rolePacks[role].inputDigest) {
1055
+ prior.inputDigest === roleInputDigests[role]) {
764
1056
  try {
765
1057
  await assertOperatingArtifact('operating-role-result', prior);
766
1058
  protocol.validateOperatingRoleResultDigest(prior);
@@ -774,6 +1066,7 @@ export async function operateAdapterLifecycle(input) {
774
1066
  }
775
1067
  const session = {
776
1068
  implementation: 'openplanr-operate-adapter',
1069
+ boardIdentity,
777
1070
  cycleId: input.cycleId,
778
1071
  evidenceDigest: input.evidenceDigest,
779
1072
  phase,
@@ -781,20 +1074,31 @@ export async function operateAdapterLifecycle(input) {
781
1074
  lease: randomBytes(32).toString('base64url'),
782
1075
  idempotencyKey: input.idempotencyKey,
783
1076
  state: validRecordedRoles.length > 0 ? 'recording' : 'prepared',
784
- expiresAt: new Date(Date.now() + 15 * 60 * 1_000).toISOString(),
1077
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
785
1078
  roles,
786
1079
  recordedRoles: validRecordedRoles.sort(),
787
1080
  roleBriefs,
788
1081
  rolePacks,
789
- roleInputDigests: Object.fromEntries(roles.map((role) => [role, rolePacks[role].inputDigest])),
1082
+ // Omitted entirely for a pure pack-mode session so its on-disk shape stays
1083
+ // byte-compatible with the v1.2 path.
1084
+ ...(Object.keys(roleMissionPackets).length > 0 ? { roleMissionPackets } : {}),
1085
+ roleInputDigests,
790
1086
  };
791
1087
  await atomicPrivateWrite(target, session);
792
- return { ...session, handoff: await adapterHandoff(session) };
1088
+ return {
1089
+ ...session,
1090
+ // Expose the mission packets at `/data/missionPackets/<role>` so the v1.3
1091
+ // record action's `dispatch.missionPacketPointer` resolves against the
1092
+ // prepare result; pack roles keep resolving at `/data/rolePacks/<role>`.
1093
+ ...(Object.keys(roleMissionPackets).length > 0 ? { missionPackets: roleMissionPackets } : {}),
1094
+ leaseStatus: adapterLeaseStatus(session, nowMs),
1095
+ handoff: await adapterHandoff(session),
1096
+ };
793
1097
  }
794
1098
  if (!input.lease) {
795
1099
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Adapter lease is required.');
796
1100
  }
797
- const session = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot);
1101
+ const session = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot, nowMs);
798
1102
  assertAdapterBinding(session, input.lease, input.idempotencyKey, input.evidenceDigest);
799
1103
  await assertAdapterCycleBinding({
800
1104
  projectRoot: input.projectRoot,
@@ -805,7 +1109,11 @@ export async function operateAdapterLifecycle(input) {
805
1109
  if (!['prepared', 'recording'].includes(session.state)) {
806
1110
  throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter resume is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
807
1111
  }
808
- return { ...session, handoff: await adapterHandoff(session) };
1112
+ return {
1113
+ ...session,
1114
+ leaseStatus: adapterLeaseStatus(session, nowMs),
1115
+ handoff: await adapterHandoff(session),
1116
+ };
809
1117
  }
810
1118
  if (input.action === 'cancel') {
811
1119
  if (session.state === 'finalized') {
@@ -814,6 +1122,7 @@ export async function operateAdapterLifecycle(input) {
814
1122
  if (session.state === 'cancelled') {
815
1123
  return {
816
1124
  session: adapterSessionSummary(session),
1125
+ leaseStatus: adapterLeaseStatus(session, nowMs),
817
1126
  handoff: await adapterHandoff(session),
818
1127
  };
819
1128
  }
@@ -821,6 +1130,7 @@ export async function operateAdapterLifecycle(input) {
821
1130
  await atomicPrivateWrite(target, cancelled);
822
1131
  return {
823
1132
  session: adapterSessionSummary(cancelled),
1133
+ leaseStatus: adapterLeaseStatus(cancelled, nowMs),
824
1134
  handoff: await adapterHandoff(cancelled),
825
1135
  };
826
1136
  }
@@ -863,6 +1173,9 @@ export async function operateAdapterLifecycle(input) {
863
1173
  }
864
1174
  const submittedRecord = submitted && typeof submitted === 'object' ? submitted : {};
865
1175
  let result;
1176
+ // FR1: unresolvable-citation gaps opened while resolving a mission response's
1177
+ // citations, surfaced in the record response for observability.
1178
+ let recordGaps = [];
866
1179
  if (submittedRecord.kind === 'operating-role-result') {
867
1180
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Native advisors must submit only the compact response contract; OpenPlanr owns canonical result metadata.', {
868
1181
  ...advisorResponseContractDetails(session.roleBriefs[input.role]),
@@ -891,12 +1204,50 @@ export async function operateAdapterLifecycle(input) {
891
1204
  throw new OperateError('E_OPERATE_SECRET_DETECTED', `Native advisor response contains a secret at ${field.location}; nothing was persisted.`, { roleId: input.role, field: field.location });
892
1205
  }
893
1206
  }
1207
+ const missionPacket = session.roleMissionPackets?.[input.role];
894
1208
  try {
895
- result = await createNativeOperatingRoleResult({
896
- pack: session.rolePacks[input.role],
897
- response: submitted,
898
- runtime: session.runtime,
899
- });
1209
+ if (missionPacket) {
1210
+ // FR1: a mission-mode role submits the v1.3 citation-bearing response.
1211
+ // Its citations flow into the engine's already-live citation gate,
1212
+ // which resolves them against the cycle's pinned revision and mints the
1213
+ // evidenceRefs that make the committed result v1.2-valid — the pack
1214
+ // path is untouched.
1215
+ const gated = await createNativeMissionOperatingRoleResult({
1216
+ packet: missionPacket,
1217
+ response: submitted,
1218
+ runtime: session.runtime,
1219
+ resolveCitations: async (roleResults) => {
1220
+ const [workspace, sensitivityCeiling, config] = await Promise.all([
1221
+ refreshOperatingWorkspaceManifest(input.projectRoot, {
1222
+ localRoot: input.localRoot,
1223
+ }),
1224
+ readAdapterSensitivityCeiling(input.projectRoot, input.localRoot),
1225
+ readOperatingConfig(input.projectRoot, { localRoot: input.localRoot }).catch(() => null),
1226
+ ]);
1227
+ return gateRecordedProposalCitations({
1228
+ roleResults,
1229
+ context: {
1230
+ projectRoot: input.projectRoot,
1231
+ cycleId: input.cycleId,
1232
+ descriptor: workspace.controlRepository,
1233
+ cache: new OperatingEvidenceCache(resolveOperatingPaths(input.projectRoot, {
1234
+ localRoot: input.localRoot,
1235
+ }).evidence, sensitivityCeiling),
1236
+ owner: config?.decisionOwner,
1237
+ },
1238
+ });
1239
+ },
1240
+ });
1241
+ result = gated.result;
1242
+ recordGaps = gated.gaps;
1243
+ }
1244
+ else {
1245
+ result = await createNativeOperatingRoleResult({
1246
+ pack: session.rolePacks[input.role],
1247
+ response: submitted,
1248
+ runtime: session.runtime,
1249
+ });
1250
+ }
900
1251
  }
901
1252
  catch (error) {
902
1253
  if (error instanceof OperateError && error.code === 'E_OPERATE_ADVISOR_FAILED') {
@@ -958,16 +1309,23 @@ export async function operateAdapterLifecycle(input) {
958
1309
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} already recorded a different result.`);
959
1310
  }
960
1311
  await atomicPrivateWrite(path.join(path.dirname(target), `${input.cycleId}.${input.role}.json`), result);
1312
+ // A successful record refreshes the lease forward from now (FR10 / T-008):
1313
+ // an advisor making steady progress across a multi-role dispatch keeps its
1314
+ // session alive without a separate keep-alive call, while a session that goes
1315
+ // idle past the refreshed window still lapses and is rejected on the next call.
961
1316
  const updated = {
962
1317
  ...session,
963
1318
  state: 'recording',
964
1319
  recordedRoles: [...new Set([...session.recordedRoles, input.role])].sort(),
1320
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
965
1321
  };
966
1322
  await atomicPrivateWrite(target, updated);
967
1323
  return {
968
1324
  recorded: input.role,
969
1325
  result,
1326
+ ...(recordGaps.length > 0 ? { citationGaps: recordGaps } : {}),
970
1327
  session: adapterSessionSummary(updated),
1328
+ leaseStatus: adapterLeaseStatus(updated, nowMs),
971
1329
  handoff: await adapterHandoff(updated),
972
1330
  };
973
1331
  }
@@ -987,6 +1345,7 @@ export async function operateAdapterLifecycle(input) {
987
1345
  return {
988
1346
  session: adapterSessionSummary(session),
989
1347
  results: summaries,
1348
+ leaseStatus: adapterLeaseStatus(session, nowMs),
990
1349
  handoff: await adapterHandoff(session),
991
1350
  };
992
1351
  }
@@ -1058,6 +1417,7 @@ export async function operateAdapterLifecycle(input) {
1058
1417
  inputDigest: result.inputDigest,
1059
1418
  resultDigest: result.resultDigest,
1060
1419
  })),
1420
+ leaseStatus: adapterLeaseStatus(finalized, nowMs),
1061
1421
  handoff: await adapterHandoff(finalized),
1062
1422
  };
1063
1423
  }