openplanr 1.17.0 → 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 (63) 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 +55 -7
  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 +37 -1
  10. package/dist/services/operate/advisors.d.ts.map +1 -1
  11. package/dist/services/operate/advisors.js +351 -10
  12. package/dist/services/operate/advisors.js.map +1 -1
  13. package/dist/services/operate/config.d.ts +26 -0
  14. package/dist/services/operate/config.d.ts.map +1 -1
  15. package/dist/services/operate/config.js +41 -0
  16. package/dist/services/operate/config.js.map +1 -1
  17. package/dist/services/operate/doctor.d.ts.map +1 -1
  18. package/dist/services/operate/doctor.js +121 -1
  19. package/dist/services/operate/doctor.js.map +1 -1
  20. package/dist/services/operate/evidence.d.ts.map +1 -1
  21. package/dist/services/operate/evidence.js +29 -0
  22. package/dist/services/operate/evidence.js.map +1 -1
  23. package/dist/services/operate/index.d.ts +2 -0
  24. package/dist/services/operate/index.d.ts.map +1 -1
  25. package/dist/services/operate/index.js +237 -41
  26. package/dist/services/operate/index.js.map +1 -1
  27. package/dist/services/operate/interaction/answer-service.d.ts +17 -0
  28. package/dist/services/operate/interaction/answer-service.d.ts.map +1 -1
  29. package/dist/services/operate/interaction/answer-service.js +49 -7
  30. package/dist/services/operate/interaction/answer-service.js.map +1 -1
  31. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  32. package/dist/services/operate/interaction/question-engine.js +9 -3
  33. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  34. package/dist/services/operate/interaction/question-registry.d.ts +13 -0
  35. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  36. package/dist/services/operate/interaction/question-registry.js +116 -26
  37. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  38. package/dist/services/operate/interaction/terminal-renderer.d.ts +12 -1
  39. package/dist/services/operate/interaction/terminal-renderer.d.ts.map +1 -1
  40. package/dist/services/operate/interaction/terminal-renderer.js +26 -15
  41. package/dist/services/operate/interaction/terminal-renderer.js.map +1 -1
  42. package/dist/services/operate/lifecycle.d.ts +8 -0
  43. package/dist/services/operate/lifecycle.d.ts.map +1 -1
  44. package/dist/services/operate/lifecycle.js +19 -1
  45. package/dist/services/operate/lifecycle.js.map +1 -1
  46. package/dist/services/operate/maintenance.d.ts +22 -0
  47. package/dist/services/operate/maintenance.d.ts.map +1 -1
  48. package/dist/services/operate/maintenance.js +397 -44
  49. package/dist/services/operate/maintenance.js.map +1 -1
  50. package/dist/services/operate/protocol.d.ts +4 -1
  51. package/dist/services/operate/protocol.d.ts.map +1 -1
  52. package/dist/services/operate/protocol.js.map +1 -1
  53. package/dist/services/operate/reports.d.ts +9 -0
  54. package/dist/services/operate/reports.d.ts.map +1 -1
  55. package/dist/services/operate/reports.js +9 -0
  56. package/dist/services/operate/reports.js.map +1 -1
  57. package/dist/services/operate/types.d.ts +19 -0
  58. package/dist/services/operate/types.d.ts.map +1 -1
  59. package/dist/services/operate/types.js.map +1 -1
  60. package/dist/services/runtime-manager-service.d.ts.map +1 -1
  61. package/dist/services/runtime-manager-service.js +25 -5
  62. package/dist/services/runtime-manager-service.js.map +1 -1
  63. 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,11 +220,24 @@ 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) {
@@ -559,7 +644,7 @@ export async function readPersistedOperatingRoleResults(store, cycleId) {
559
644
  }
560
645
  return [...byRole.values()].sort((left, right) => left.roleId.localeCompare(right.roleId));
561
646
  }
562
- async function readAdapterSession(projectRoot, cycleId, localRoot) {
647
+ async function readAdapterSession(projectRoot, cycleId, localRoot, nowMs = Date.now()) {
563
648
  const parsed = JSON.parse(await readFile(adapterSessionPath(projectRoot, cycleId, localRoot), 'utf8'));
564
649
  const session = {
565
650
  ...parsed,
@@ -569,7 +654,10 @@ async function readAdapterSession(projectRoot, cycleId, localRoot) {
569
654
  if (session.implementation !== 'openplanr-operate-adapter') {
570
655
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Adapter session is invalid.');
571
656
  }
572
- 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)) {
573
661
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Adapter session expired.', {
574
662
  cycleId,
575
663
  recoveryCommand: retryRunCommand(session),
@@ -600,6 +688,81 @@ async function assertAdapterCycleBinding(input, session) {
600
688
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Adapter evidence no longer matches the committed cycle snapshot.');
601
689
  }
602
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
+ }
603
766
  export async function createOperatingAdapterStartHandoff(input) {
604
767
  const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
605
768
  const existing = await readFile(target, 'utf8')
@@ -625,7 +788,19 @@ export async function createOperatingAdapterStartHandoff(input) {
625
788
  }
626
789
  }
627
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);
628
802
  const handoff = protocol.createOperatingAdapterHandoff({
803
+ ...(anyMission ? { protocolVersion: '1.3.0' } : {}),
629
804
  phase: input.phase,
630
805
  state: 'prepare-required',
631
806
  cycleId: input.cycleId,
@@ -646,6 +821,13 @@ export async function operateAdapterLifecycle(input) {
646
821
  if (!input.cycleId || !input.idempotencyKey) {
647
822
  throw new OperateError('E_OPERATE_CONFIG_INVALID', 'Adapter calls require --cycle-id and --idempotency-key.');
648
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
+ });
649
831
  const target = adapterSessionPath(input.projectRoot, input.cycleId, input.localRoot);
650
832
  if (input.action === 'prepare') {
651
833
  if (!input.evidenceDigest?.startsWith('sha256:')) {
@@ -655,6 +837,9 @@ export async function operateAdapterLifecycle(input) {
655
837
  localRoot: input.localRoot,
656
838
  });
657
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);
658
843
  const cycle = state.cycles.find((record) => record.id === input.cycleId);
659
844
  if (!cycle || !['advising', 'blocked'].includes(cycle.state)) {
660
845
  throw new OperateError('E_OPERATE_STATE_INVALID', 'Adapter prepare requires an advising or blocked cycle.');
@@ -693,36 +878,63 @@ export async function operateAdapterLifecycle(input) {
693
878
  const existing = await readFile(target, 'utf8')
694
879
  .then((raw) => JSON.parse(raw))
695
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`.
696
891
  let recoverableSession = null;
697
892
  if (existing) {
698
893
  const normalized = {
699
894
  ...existing,
700
895
  phase: existing.phase ?? adapterPhase(existing.roles ?? []),
701
896
  runtime: existing.runtime ?? 'auto',
897
+ boardIdentity: existing.boardIdentity ?? '',
702
898
  };
703
- const exact = normalized.cycleId === input.cycleId &&
704
- normalized.evidenceDigest === input.evidenceDigest &&
705
- normalized.runtime === runtime &&
706
- normalized.phase === phase &&
707
- sameRoleSet(normalized.roles, roles);
708
- if (normalized.idempotencyKey === input.idempotencyKey) {
709
- if (!exact) {
710
- 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
+ };
711
925
  }
712
- if (sessionExpired(normalized) || normalized.state === 'cancelled') {
713
- 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;
714
937
  }
715
- return { ...normalized, handoff: await adapterHandoff(normalized) };
716
- }
717
- if (!sessionExpired(normalized) && ['prepared', 'recording'].includes(normalized.state)) {
718
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already has an active adapter session with another binding.`, { recoveryCommand: retryRunCommand(normalized) });
719
- }
720
- if (normalized.state === 'finalized' &&
721
- !(normalized.phase === 'advisors' && phase === 'chair')) {
722
- throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Cycle ${input.cycleId} already finalized its ${normalized.phase} adapter phase.`, { recoveryCommand: retryRunCommand(normalized) });
723
- }
724
- if (exact && (sessionExpired(normalized) || normalized.state === 'cancelled')) {
725
- recoverableSession = normalized;
726
938
  }
727
939
  }
728
940
  const roleEvidence = roles[0] === 'chair'
@@ -748,8 +960,22 @@ export async function operateAdapterLifecycle(input) {
748
960
  state,
749
961
  cycleId: input.cycleId,
750
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);
751
977
  const rolePacks = recoverableSession?.rolePacks ??
752
- Object.fromEntries(await Promise.all(readiness.roles.map(async (role) => [
978
+ Object.fromEntries(await Promise.all(packReadinessRoles.map(async (role) => [
753
979
  role.roleId,
754
980
  await createOperatingAdvisorPack({
755
981
  cycleId: input.cycleId,
@@ -758,7 +984,66 @@ export async function operateAdapterLifecycle(input) {
758
984
  context,
759
985
  }),
760
986
  ])));
761
- 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
+ }));
762
1047
  const validRecordedRoles = [];
763
1048
  for (const role of roles) {
764
1049
  const prior = await readFile(path.join(path.dirname(target), `${input.cycleId}.${role}.json`), 'utf8')
@@ -767,7 +1052,7 @@ export async function operateAdapterLifecycle(input) {
767
1052
  if (prior &&
768
1053
  prior.cycleId === input.cycleId &&
769
1054
  prior.roleId === role &&
770
- prior.inputDigest === rolePacks[role].inputDigest) {
1055
+ prior.inputDigest === roleInputDigests[role]) {
771
1056
  try {
772
1057
  await assertOperatingArtifact('operating-role-result', prior);
773
1058
  protocol.validateOperatingRoleResultDigest(prior);
@@ -781,6 +1066,7 @@ export async function operateAdapterLifecycle(input) {
781
1066
  }
782
1067
  const session = {
783
1068
  implementation: 'openplanr-operate-adapter',
1069
+ boardIdentity,
784
1070
  cycleId: input.cycleId,
785
1071
  evidenceDigest: input.evidenceDigest,
786
1072
  phase,
@@ -788,20 +1074,31 @@ export async function operateAdapterLifecycle(input) {
788
1074
  lease: randomBytes(32).toString('base64url'),
789
1075
  idempotencyKey: input.idempotencyKey,
790
1076
  state: validRecordedRoles.length > 0 ? 'recording' : 'prepared',
791
- expiresAt: new Date(Date.now() + 15 * 60 * 1_000).toISOString(),
1077
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
792
1078
  roles,
793
1079
  recordedRoles: validRecordedRoles.sort(),
794
1080
  roleBriefs,
795
1081
  rolePacks,
796
- 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,
797
1086
  };
798
1087
  await atomicPrivateWrite(target, session);
799
- 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
+ };
800
1097
  }
801
1098
  if (!input.lease) {
802
1099
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', 'Adapter lease is required.');
803
1100
  }
804
- const session = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot);
1101
+ const session = await readAdapterSession(input.projectRoot, input.cycleId, input.localRoot, nowMs);
805
1102
  assertAdapterBinding(session, input.lease, input.idempotencyKey, input.evidenceDigest);
806
1103
  await assertAdapterCycleBinding({
807
1104
  projectRoot: input.projectRoot,
@@ -812,7 +1109,11 @@ export async function operateAdapterLifecycle(input) {
812
1109
  if (!['prepared', 'recording'].includes(session.state)) {
813
1110
  throw new OperateError('E_OPERATE_STATE_INVALID', `Adapter resume is not valid after the session is ${session.state}.`, { recoveryCommand: retryRunCommand(session) });
814
1111
  }
815
- return { ...session, handoff: await adapterHandoff(session) };
1112
+ return {
1113
+ ...session,
1114
+ leaseStatus: adapterLeaseStatus(session, nowMs),
1115
+ handoff: await adapterHandoff(session),
1116
+ };
816
1117
  }
817
1118
  if (input.action === 'cancel') {
818
1119
  if (session.state === 'finalized') {
@@ -821,6 +1122,7 @@ export async function operateAdapterLifecycle(input) {
821
1122
  if (session.state === 'cancelled') {
822
1123
  return {
823
1124
  session: adapterSessionSummary(session),
1125
+ leaseStatus: adapterLeaseStatus(session, nowMs),
824
1126
  handoff: await adapterHandoff(session),
825
1127
  };
826
1128
  }
@@ -828,6 +1130,7 @@ export async function operateAdapterLifecycle(input) {
828
1130
  await atomicPrivateWrite(target, cancelled);
829
1131
  return {
830
1132
  session: adapterSessionSummary(cancelled),
1133
+ leaseStatus: adapterLeaseStatus(cancelled, nowMs),
831
1134
  handoff: await adapterHandoff(cancelled),
832
1135
  };
833
1136
  }
@@ -870,6 +1173,9 @@ export async function operateAdapterLifecycle(input) {
870
1173
  }
871
1174
  const submittedRecord = submitted && typeof submitted === 'object' ? submitted : {};
872
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 = [];
873
1179
  if (submittedRecord.kind === 'operating-role-result') {
874
1180
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'Native advisors must submit only the compact response contract; OpenPlanr owns canonical result metadata.', {
875
1181
  ...advisorResponseContractDetails(session.roleBriefs[input.role]),
@@ -898,12 +1204,50 @@ export async function operateAdapterLifecycle(input) {
898
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 });
899
1205
  }
900
1206
  }
1207
+ const missionPacket = session.roleMissionPackets?.[input.role];
901
1208
  try {
902
- result = await createNativeOperatingRoleResult({
903
- pack: session.rolePacks[input.role],
904
- response: submitted,
905
- runtime: session.runtime,
906
- });
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
+ }
907
1251
  }
908
1252
  catch (error) {
909
1253
  if (error instanceof OperateError && error.code === 'E_OPERATE_ADVISOR_FAILED') {
@@ -965,16 +1309,23 @@ export async function operateAdapterLifecycle(input) {
965
1309
  throw new OperateError('E_OPERATE_ADVISOR_ISOLATION', `Role ${input.role} already recorded a different result.`);
966
1310
  }
967
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.
968
1316
  const updated = {
969
1317
  ...session,
970
1318
  state: 'recording',
971
1319
  recordedRoles: [...new Set([...session.recordedRoles, input.role])].sort(),
1320
+ expiresAt: new Date(nowMs + leaseDurationMs).toISOString(),
972
1321
  };
973
1322
  await atomicPrivateWrite(target, updated);
974
1323
  return {
975
1324
  recorded: input.role,
976
1325
  result,
1326
+ ...(recordGaps.length > 0 ? { citationGaps: recordGaps } : {}),
977
1327
  session: adapterSessionSummary(updated),
1328
+ leaseStatus: adapterLeaseStatus(updated, nowMs),
978
1329
  handoff: await adapterHandoff(updated),
979
1330
  };
980
1331
  }
@@ -994,6 +1345,7 @@ export async function operateAdapterLifecycle(input) {
994
1345
  return {
995
1346
  session: adapterSessionSummary(session),
996
1347
  results: summaries,
1348
+ leaseStatus: adapterLeaseStatus(session, nowMs),
997
1349
  handoff: await adapterHandoff(session),
998
1350
  };
999
1351
  }
@@ -1065,6 +1417,7 @@ export async function operateAdapterLifecycle(input) {
1065
1417
  inputDigest: result.inputDigest,
1066
1418
  resultDigest: result.resultDigest,
1067
1419
  })),
1420
+ leaseStatus: adapterLeaseStatus(finalized, nowMs),
1068
1421
  handoff: await adapterHandoff(finalized),
1069
1422
  };
1070
1423
  }