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
@@ -2,12 +2,13 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { z } from 'zod';
5
+ import { AIError } from '../../ai/errors.js';
5
6
  import { DEFAULT_MODELS } from '../../ai/types.js';
6
7
  import { OPENPLANR_VERSION } from '../../utils/package-version.js';
7
8
  import { generateJSON, getAIProvider, isAIConfigured } from '../ai-service.js';
8
9
  import { loadConfig } from '../config-service.js';
9
10
  import { canonicalDigest, canonicalize } from './canonical.js';
10
- import { operatingRegistryDispatchMode, operatingRuntimeEnforcesBoundedReadOnly, resolveOperatingDispatchMode, runMissionDispatchFanOut, } from './mission-dispatch.js';
11
+ import { createMissionToolset, MISSION_READ_ONLY_TOOLS, narrowMissionRootsToCeiling, operatingRegistryDispatchMode, operatingRuntimeEnforcesBoundedReadOnly, resolveOperatingDispatchMode, runMissionDispatchFanOut, } from './mission-dispatch.js';
11
12
  import { assertOperatingArtifact, loadOperatingMissionApi, loadOperatingProtocol, } from './protocol.js';
12
13
  import { prepareAdvisorEvidenceText, sanitizeGeneratedPlainText } from './redaction.js';
13
14
  import { OPERATE_MISSION_PROTOCOL_VERSION, OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
@@ -39,6 +40,68 @@ const advisorOutputSchema = z
39
40
  conflicts: z.array(z.string()),
40
41
  })
41
42
  .strict();
43
+ // The Protocol v1.3 mission (`operating-advisor-response@1.3.0`) proposal shape:
44
+ // each proposal carries `citations` (repository path / git revision / planr
45
+ // artifact, each bound to the cycle's frozen `pinnedRevision`) INSTEAD of the
46
+ // v1.2 `evidenceRefs`. The pipeline snapshots each citation after the lens
47
+ // returns; OpenPlanr never widens the set. Kept in lockstep with the installed
48
+ // `schemas/v1.3.0/operating-citation.schema.json` so a locally parsed response
49
+ // and the pipeline-validated one cannot drift.
50
+ const missionCitationSchema = z
51
+ .object({
52
+ citationKey: z
53
+ .string()
54
+ .regex(/^[A-Za-z0-9._-]+$/)
55
+ .max(128)
56
+ .optional(),
57
+ repositoryPath: z
58
+ .string()
59
+ .max(1024)
60
+ .regex(/^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$/)
61
+ .optional(),
62
+ lineRange: z
63
+ .object({ start: z.number().int().min(1), end: z.number().int().min(1) })
64
+ .strict()
65
+ .optional(),
66
+ gitRevision: z
67
+ .string()
68
+ .regex(/^[a-f0-9]{7,64}$/)
69
+ .optional(),
70
+ planrArtifactId: z
71
+ .string()
72
+ .regex(/^(?:EPIC|FEAT|US|SPEC|TASK|ADR|DEC|FND|GAP|OUT)-[A-Za-z0-9._-]+$/)
73
+ .optional(),
74
+ pinnedRevision: z.string().regex(/^[a-f0-9]{7,64}$/),
75
+ })
76
+ .strict();
77
+ const missionProposalSchema = z
78
+ .object({
79
+ proposalKey: z.string().regex(/^[A-Za-z0-9._-]+$/),
80
+ type: z.enum(['finding', 'decision', 'data-gap', 'merge', 'sequence']),
81
+ title: z.string().min(1),
82
+ problem: z.string().min(1),
83
+ proposal: z.string().min(1),
84
+ impact: z.number().int().min(1).max(5),
85
+ confidence: z.number().int().min(1).max(5),
86
+ ease: z.number().int().min(1).max(5),
87
+ severity: z.enum(['low', 'medium', 'high', 'critical']),
88
+ citations: z.array(missionCitationSchema).min(1).max(50),
89
+ dependsOnProposalKeys: z.array(z.string().regex(/^[A-Za-z0-9._-]+$/)).optional(),
90
+ conflictsWithProposalKeys: z.array(z.string().regex(/^[A-Za-z0-9._-]+$/)).optional(),
91
+ sequenceProposalKeys: z
92
+ .array(z.string().regex(/^[A-Za-z0-9._-]+$/))
93
+ .min(2)
94
+ .optional(),
95
+ })
96
+ .strict();
97
+ const missionAdvisorOutputSchema = z
98
+ .object({
99
+ outcome: z.enum(['proposals', 'quiet']),
100
+ proposals: z.array(missionProposalSchema).max(20),
101
+ gaps: z.array(z.string()),
102
+ conflicts: z.array(z.string()),
103
+ })
104
+ .strict();
42
105
  export function advisorResponseContractDetails(brief) {
43
106
  const examples = brief.output.jsonSchema?.examples;
44
107
  return {
@@ -283,7 +346,8 @@ export async function createOperatingAdvisorPack(input) {
283
346
  })),
284
347
  }),
285
348
  };
286
- const roleBrief = (await loadOperatingProtocol()).createOperatingAdvisorBrief(input.role.roleId);
349
+ const protocol = await loadOperatingProtocol();
350
+ const roleBrief = protocol.createOperatingAdvisorBrief(input.role.roleId);
287
351
  const inputDigest = canonicalDigest({
288
352
  cycleId: input.cycleId,
289
353
  roleId: input.role.roleId,
@@ -292,7 +356,7 @@ export async function createOperatingAdvisorPack(input) {
292
356
  evidenceRefs: roleItems.map((item) => item.id).sort(),
293
357
  context,
294
358
  });
295
- return {
359
+ const pack = {
296
360
  implementation: 'openplanr-operating-advisor-pack',
297
361
  cycleId: input.cycleId,
298
362
  roleId: input.role.roleId,
@@ -301,13 +365,52 @@ export async function createOperatingAdvisorPack(input) {
301
365
  context,
302
366
  inputDigest,
303
367
  };
368
+ // FR2: measure the canonicalized v1.2 pack against the role's published
369
+ // `maxInputBytes` and fail closed BEFORE returning it. Redaction quarantines
370
+ // a single oversized excerpt (its 16 KiB per-item gate) but never bounds the
371
+ // AGGREGATE pack, so a role carrying many in-gate excerpts can still exceed
372
+ // its input budget — the field incident shipped a 2,736,185-byte pack against
373
+ // a 393,216-byte role budget with nothing catching it. The pack is never
374
+ // truncated to fit; the role fails closed instead, mirroring the mission
375
+ // budget's `E_OPERATE_MISSION_PACKET_BUDGET` semantics with the existing
376
+ // `E_OPERATE_EVIDENCE_BUDGET` code (no new OperateErrorCode is minted).
377
+ assertOperatingAdvisorPackWithinBudget(pack, resolveOperatingPackBudget(protocol, input.role.roleId));
378
+ return pack;
379
+ }
380
+ /**
381
+ * A role's v1.2 pack input budget, read from the pipeline's published role
382
+ * registry (the same authoritative source `deriveOperatingMissionBudgets` reads).
383
+ * A registry entry that omits `budgets.maxInputBytes` falls back to the same
384
+ * 256 KiB default the mission-budget derivation uses, so an unpublished budget
385
+ * still fails closed rather than admitting an unbounded pack.
386
+ */
387
+ function resolveOperatingPackBudget(protocol, roleId) {
388
+ const role = protocol.listOperatingRoles().find((candidate) => candidate.id === roleId);
389
+ const maxInputBytes = role?.budgets?.maxInputBytes;
390
+ return typeof maxInputBytes === 'number' ? maxInputBytes : 262_144;
391
+ }
392
+ /**
393
+ * Measure a canonicalized advisor pack and fail closed when it exceeds the
394
+ * role's v1.2 `maxInputBytes`. Shared by `createOperatingAdvisorPack` (fresh
395
+ * construction) and `operateAdapterLifecycle`'s prepare branch (which also
396
+ * guards packs restored from an on-disk session that may predate this check),
397
+ * so an oversized pack can never reach a provider or native adapter from either
398
+ * call site. Reuses the existing `E_OPERATE_EVIDENCE_BUDGET` code.
399
+ */
400
+ export function assertOperatingAdvisorPackWithinBudget(pack, maxInputBytes) {
401
+ const actualBytes = Buffer.byteLength(canonicalize(pack), 'utf8');
402
+ if (actualBytes > maxInputBytes) {
403
+ throw new OperateError('E_OPERATE_EVIDENCE_BUDGET', `Advisor pack for role ${pack.roleId} is ${actualBytes} bytes, exceeding its ` +
404
+ `${maxInputBytes}-byte v1.2 pack input budget; the pack is not truncated to fit.`, { roleId: pack.roleId, actualBytes, maxInputBytes });
405
+ }
304
406
  }
305
407
  /**
306
408
  * Derive a role's mission-mode input budget from the pipeline's published pack
307
409
  * budget. Mission packets carry only an evidence INDEX (no bodies), so their
308
410
  * budget is a single-digit-KiB fraction of the role's v1.2 pack budget, clamped
309
411
  * to `[1, 9]` KiB. This DERIVES a new value; it never mutates the frozen v1.2
310
- * `maxInputBytes` that pack mode still enforces.
412
+ * `maxInputBytes`. Enforcing that pack-mode budget is a separate concern handled
413
+ * by `assertOperatingAdvisorPackWithinBudget` at pack construction, not here.
311
414
  */
312
415
  export function deriveOperatingMissionBudget(packMaxInputBytes) {
313
416
  const kib = Math.min(9, Math.max(1, Math.round(packMaxInputBytes / (32 * 1024))));
@@ -500,6 +603,156 @@ export async function createNativeOperatingRoleResult(input) {
500
603
  protocol.validateOperatingRoleResultDigest(result);
501
604
  return result;
502
605
  }
606
+ /**
607
+ * Sanitize a v1.3 mission advisor response's free text exactly as the v1.2
608
+ * `sanitizeOutput` does, but PRESERVE each proposal's structured `citations`
609
+ * verbatim: they are schema-pattern-bounded locators (repository path, git
610
+ * revision, or planr artifact — never free prose), which the engine resolves and
611
+ * snapshots after the lens returns. Dropping them would silence every proposal.
612
+ */
613
+ function sanitizeMissionOutput(output) {
614
+ return {
615
+ outcome: output.outcome,
616
+ proposals: output.proposals
617
+ .map((proposal) => ({
618
+ ...proposal,
619
+ title: sanitizeGeneratedPlainText(proposal.title).replace(/\s+/g, ' ').trim(),
620
+ problem: sanitizeGeneratedPlainText(proposal.problem).replace(/\s+/g, ' ').trim(),
621
+ proposal: sanitizeGeneratedPlainText(proposal.proposal).replace(/\s+/g, ' ').trim(),
622
+ citations: [...proposal.citations],
623
+ ...(proposal.dependsOnProposalKeys
624
+ ? { dependsOnProposalKeys: [...new Set(proposal.dependsOnProposalKeys)].sort() }
625
+ : {}),
626
+ ...(proposal.conflictsWithProposalKeys
627
+ ? { conflictsWithProposalKeys: [...new Set(proposal.conflictsWithProposalKeys)].sort() }
628
+ : {}),
629
+ ...(proposal.sequenceProposalKeys
630
+ ? { sequenceProposalKeys: [...proposal.sequenceProposalKeys] }
631
+ : {}),
632
+ }))
633
+ .sort((left, right) => left.proposalKey.localeCompare(right.proposalKey) ||
634
+ left.type.localeCompare(right.type) ||
635
+ left.problem.localeCompare(right.problem)),
636
+ gaps: [...new Set(output.gaps.map(sanitizeGeneratedPlainText))].sort(),
637
+ conflicts: [...new Set(output.conflicts.map(sanitizeGeneratedPlainText))].sort(),
638
+ };
639
+ }
640
+ /**
641
+ * A mission packet's `role.output` facet mirrors the v1.2 brief's output
642
+ * contract (allowed proposal types, maxima), so a v1.3 response is validated
643
+ * against exactly the same invariants a pack response is — reusing the pipeline's
644
+ * registry-derived brief as the single source of truth.
645
+ */
646
+ export async function createNativeMissionOperatingRoleResult(input) {
647
+ const protocol = await loadOperatingProtocol();
648
+ // Validate against the INSTALLED v1.3 schema explicitly — the compact response
649
+ // carries no protocol envelope, so the pipeline additively resolves to v1.2
650
+ // unless the version is passed.
651
+ const contractIssues = protocol.validateProtocolArtifact('operating-advisor-response', input.response, { protocolVersion: '1.3.0' });
652
+ if (contractIssues.length > 0) {
653
+ throw new OperateError('E_OPERATE_ADVISOR_FAILED', `Native ${input.packet.roleId} response does not match operating-advisor-response@1.3.0.`, { issues: contractIssues.slice(0, 8) });
654
+ }
655
+ const parsed = missionAdvisorOutputSchema.safeParse(input.response);
656
+ if (!parsed.success) {
657
+ throw new OperateError('E_OPERATE_INTERNAL', 'Protocol and OpenPlanr disagree on the v1.3 mission advisor response contract.', {
658
+ issues: parsed.error.issues.slice(0, 8).map((issue) => ({
659
+ path: issue.path.join('.'),
660
+ code: issue.code,
661
+ })),
662
+ });
663
+ }
664
+ const output = sanitizeMissionOutput(parsed.data);
665
+ const brief = protocol.createOperatingAdvisorBrief(input.packet.roleId);
666
+ assertAdvisorOutputMatchesBrief(brief, output);
667
+ const capability = (input.packet.role.capabilityTier ??
668
+ brief.role.capabilityTier);
669
+ // The intermediate result: proposals carry their v1.3 citations and an empty
670
+ // evidenceRefs set. It is deliberately NOT yet a v1.2-valid committed
671
+ // operating-role-result — the citation gate mints the evidenceRefs that make
672
+ // it one. `inputDigest` is the packet's digest, so the record path's
673
+ // input-digest binding (prepare stored the same packet digest) holds.
674
+ const intermediate = {
675
+ kind: 'operating-role-result',
676
+ schemaVersion: OPERATE_SCHEMA_VERSION,
677
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
678
+ cycleId: input.packet.cycleId,
679
+ roleId: input.packet.roleId,
680
+ inputDigest: input.packet.packetDigest,
681
+ resultDigest: input.packet.packetDigest,
682
+ outcome: output.outcome,
683
+ proposals: output.proposals.map((proposal) => ({
684
+ proposalKey: proposal.proposalKey,
685
+ type: proposal.type,
686
+ title: proposal.title,
687
+ problem: proposal.problem,
688
+ proposal: proposal.proposal,
689
+ impact: proposal.impact,
690
+ confidence: proposal.confidence,
691
+ ease: proposal.ease,
692
+ severity: proposal.severity,
693
+ evidenceRefs: [],
694
+ ...(proposal.dependsOnProposalKeys
695
+ ? { dependsOnProposalKeys: proposal.dependsOnProposalKeys }
696
+ : {}),
697
+ ...(proposal.conflictsWithProposalKeys
698
+ ? { conflictsWithProposalKeys: proposal.conflictsWithProposalKeys }
699
+ : {}),
700
+ ...(proposal.sequenceProposalKeys
701
+ ? { sequenceProposalKeys: proposal.sequenceProposalKeys }
702
+ : {}),
703
+ citations: proposal.citations,
704
+ })),
705
+ gaps: output.gaps,
706
+ conflicts: output.conflicts,
707
+ producer: {
708
+ product: 'openplanr',
709
+ version: OPENPLANR_VERSION,
710
+ runtime: input.runtime,
711
+ capability,
712
+ },
713
+ };
714
+ // A quiet response has no proposals/citations, so it never touches the gate; a
715
+ // proposals response threads its citations through the already-live gate.
716
+ const gated = output.proposals.length > 0
717
+ ? await input.resolveCitations([intermediate])
718
+ : { roleResults: [intermediate], gaps: [] };
719
+ const resolved = gated.roleResults[0] ?? intermediate;
720
+ // Finalize into a v1.2-valid committed operating-role-result: strip the
721
+ // now-resolved citations, keep the minted evidenceRefs, and let the surviving
722
+ // proposal count set the honest outcome (an all-unresolvable response commits
723
+ // as quiet, its citations preserved only as the opened gaps).
724
+ const survivingProposals = resolved.proposals
725
+ .map((proposal) => {
726
+ const { citations: _citations, ...rest } = proposal;
727
+ return rest;
728
+ })
729
+ .filter((proposal) => proposal.evidenceRefs.length > 0);
730
+ const unsigned = {
731
+ kind: 'operating-role-result',
732
+ schemaVersion: OPERATE_SCHEMA_VERSION,
733
+ protocolVersion: OPERATE_PROTOCOL_VERSION,
734
+ cycleId: input.packet.cycleId,
735
+ roleId: input.packet.roleId,
736
+ inputDigest: input.packet.packetDigest,
737
+ outcome: survivingProposals.length > 0 ? 'proposals' : 'quiet',
738
+ proposals: survivingProposals,
739
+ gaps: output.gaps,
740
+ conflicts: output.conflicts,
741
+ producer: {
742
+ product: 'openplanr',
743
+ version: OPENPLANR_VERSION,
744
+ runtime: input.runtime,
745
+ capability,
746
+ },
747
+ };
748
+ const result = {
749
+ ...unsigned,
750
+ resultDigest: protocol.computeOperatingRoleResultDigest(unsigned),
751
+ };
752
+ await assertOperatingArtifact('operating-role-result', result);
753
+ protocol.validateOperatingRoleResultDigest(result);
754
+ return { result, gaps: gated.gaps };
755
+ }
503
756
  function safeFailureMessage(error) {
504
757
  try {
505
758
  return sanitizeGeneratedPlainText(error instanceof Error ? error.message : String(error));
@@ -568,6 +821,37 @@ class OpenPlanrStructuredAdapter {
568
821
  })).result;
569
822
  }
570
823
  }
824
+ /** Redacted error class label for diagnostics — no message or stack, ever. */
825
+ function redactedProviderErrorClass(error) {
826
+ if (error instanceof AIError)
827
+ return `AIError:${error.code}`;
828
+ if (error instanceof Error && typeof error.name === 'string' && error.name.length > 0) {
829
+ return error.name;
830
+ }
831
+ return 'UnknownError';
832
+ }
833
+ /**
834
+ * Build the actionable remedy for a failed structured-provider bootstrap,
835
+ * preserving the underlying provider guidance (e.g. the `planr config set-key`
836
+ * block an AIError already carries) and always naming the `--offline` escape.
837
+ */
838
+ function structuredProviderBootstrapRemedy(error, provider) {
839
+ const detail = error instanceof AIError
840
+ ? error.userMessage
841
+ : error instanceof Error
842
+ ? error.message
843
+ : String(error);
844
+ const trimmed = detail.trim();
845
+ const suffixParts = [];
846
+ if (!/config set-key/.test(trimmed)) {
847
+ suffixParts.push(`Configure a key with \`planr config set-key ${provider ?? '<provider>'}\``);
848
+ }
849
+ if (!/--offline/.test(trimmed)) {
850
+ suffixParts.push('or run the cycle offline with --offline');
851
+ }
852
+ const suffix = suffixParts.length > 0 ? ` ${suffixParts.join(' ')}.` : '';
853
+ return `Structured AI provider bootstrap failed: ${trimmed}${suffix}`;
854
+ }
571
855
  export async function createConfiguredStructuredAdapter(projectRoot, options = {}) {
572
856
  // `planr operate init` writes .planr/operate/config.json, not the project-wide
573
857
  // .planr/config.json that loadConfig requires. A project that ran only the
@@ -580,7 +864,20 @@ export async function createConfiguredStructuredAdapter(projectRoot, options = {
580
864
  if (!config || !isAIConfigured(config)) {
581
865
  throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'No structured AI provider is configured; use --offline or configure OpenPlanr AI.');
582
866
  }
583
- return new OpenPlanrStructuredAdapter(await getAIProvider(config), config.ai?.provider ?? 'ai', options.quiet ?? false);
867
+ // A named provider whose key cannot be resolved in this (possibly sandboxed)
868
+ // subprocess environment makes getAIProvider throw a raw AIError. Left
869
+ // unguarded it reaches index.ts's failure() as E_OPERATE_INTERNAL — the exact
870
+ // masked crash the audit reproduced. Convert any provider-bootstrap failure
871
+ // into a typed E_OPERATE_ADVISOR_FAILED that preserves the actionable remedy
872
+ // (`planr config set-key …` / `--offline`) and records a redacted error class.
873
+ let provider;
874
+ try {
875
+ provider = await getAIProvider(config);
876
+ }
877
+ catch (error) {
878
+ throw new OperateError('E_OPERATE_ADVISOR_FAILED', structuredProviderBootstrapRemedy(error, config.ai?.provider), { errorClass: redactedProviderErrorClass(error) });
879
+ }
880
+ return new OpenPlanrStructuredAdapter(provider, config.ai?.provider ?? 'ai', options.quiet ?? false);
584
881
  }
585
882
  export async function dispatchOperatingAdvisors(input) {
586
883
  assertAdvisorIsolation(input.adapter);
@@ -620,6 +917,18 @@ export async function dispatchOperatingAdvisors(input) {
620
917
  runnable.push(role);
621
918
  }
622
919
  async function dispatchRole(role) {
920
+ // Resolve THIS role's dispatch mode once and derive provenance from what is
921
+ // actually dispatched below — never re-derived after the fact. A role that
922
+ // resolves to a native bounded lens has its read-only tool grant enforced
923
+ // before the lens runs (below); every other role fails closed to the pack
924
+ // path, so `isolation` can only read `enforced-read-only-bounded` when the
925
+ // bounded grant was genuinely enforced, never as a bare label over a pack.
926
+ const resolution = resolveMode(role.roleId);
927
+ const dispatch = {
928
+ dispatchMode: resolution.mode,
929
+ isolation: resolution.isolation,
930
+ reconciliation: resolution.reconciliation,
931
+ };
623
932
  let pack;
624
933
  try {
625
934
  pack = await createOperatingAdvisorPack({
@@ -635,8 +944,28 @@ export async function dispatchOperatingAdvisors(input) {
635
944
  roleId: role.roleId,
636
945
  message: safeFailureMessage(error),
637
946
  modelCalls: 0,
947
+ dispatch,
638
948
  };
639
949
  }
950
+ if (resolution.native) {
951
+ // Route through mission-dispatch.ts's granted-tool-set enforcement: the
952
+ // native lens is confined to the bounded read-only toolset over its
953
+ // sensitivity-ceiling-narrowed declared roots. Constructing the toolset is
954
+ // what makes `enforced-read-only-bounded` true; a callable outside the
955
+ // read-only grant simply does not exist on the surface it hands the lens.
956
+ const ceiling = pack.roleBrief.evidence.sensitivityCeiling;
957
+ const declaredRoots = [
958
+ ...new Set(pack.evidence.items
959
+ .map((item) => item.location.split('/')[0])
960
+ .filter((segment) => Boolean(segment))),
961
+ ].sort();
962
+ const roots = narrowMissionRootsToCeiling({ declaredRoots, evidenceIndex: [], ceiling });
963
+ const toolset = createMissionToolset({ roots, ceiling });
964
+ const grantedTools = Object.keys(toolset);
965
+ if (grantedTools.some((tool) => !MISSION_READ_ONLY_TOOLS.includes(tool))) {
966
+ throw new OperateError('E_OPERATE_PROVIDER_READ_ONLY', `Mission dispatch for ${role.roleId} assembled a tool outside the bounded read-only grant.`);
967
+ }
968
+ }
640
969
  const permittedEvidenceRefs = new Set(role.evidenceRefs);
641
970
  let output;
642
971
  let lastError;
@@ -667,6 +996,7 @@ export async function dispatchOperatingAdvisors(input) {
667
996
  roleId: role.roleId,
668
997
  message: safeFailureMessage(lastError),
669
998
  modelCalls: roleModelCalls,
999
+ dispatch,
670
1000
  };
671
1001
  }
672
1002
  const unsigned = {
@@ -693,7 +1023,7 @@ export async function dispatchOperatingAdvisors(input) {
693
1023
  };
694
1024
  await assertOperatingArtifact('operating-role-result', result);
695
1025
  protocol.validateOperatingRoleResultDigest(result);
696
- return { ok: true, result, modelCalls: roleModelCalls };
1026
+ return { ok: true, result, modelCalls: roleModelCalls, dispatch };
697
1027
  }
698
1028
  // Fan the per-role dispatch out in parallel where the adapter reports it,
699
1029
  // sequentially otherwise. The orchestrator returns results in `runnable` order
@@ -705,6 +1035,13 @@ export async function dispatchOperatingAdvisors(input) {
705
1035
  parallel: Boolean(input.adapter.parallelDispatch),
706
1036
  run: (role) => dispatchRole(role),
707
1037
  });
1038
+ // The per-role dispatch descriptor captured inside `dispatchRole` — provenance
1039
+ // reads it rather than re-resolving, so it can only report the isolation the
1040
+ // role was actually dispatched under.
1041
+ const dispatchByRole = new Map();
1042
+ for (const entry of dispatched) {
1043
+ dispatchByRole.set(entry.ok ? entry.result.roleId : entry.roleId, entry.dispatch);
1044
+ }
708
1045
  const results = dispatched
709
1046
  .filter((entry) => entry.ok)
710
1047
  .map((entry) => entry.result)
@@ -717,16 +1054,20 @@ export async function dispatchOperatingAdvisors(input) {
717
1054
  return {
718
1055
  results,
719
1056
  provenance: results.map((result) => {
720
- const resolution = resolveMode(result.roleId);
1057
+ const dispatchProvenance = dispatchByRole.get(result.roleId) ?? {
1058
+ dispatchMode: resolveMode(result.roleId).mode,
1059
+ isolation: resolveMode(result.roleId).isolation,
1060
+ reconciliation: resolveMode(result.roleId).reconciliation,
1061
+ };
721
1062
  return {
722
1063
  roleId: result.roleId,
723
1064
  runtime: input.runtime ?? input.adapter.id,
724
1065
  adapterId: input.adapter.id,
725
1066
  capability: input.adapter.capability,
726
1067
  dispatch: input.adapter.parallelDispatch ? 'parallel' : 'sequential',
727
- dispatchMode: resolution.mode,
728
- isolation: resolution.isolation,
729
- reconciliation: resolution.reconciliation,
1068
+ dispatchMode: dispatchProvenance.dispatchMode,
1069
+ isolation: dispatchProvenance.isolation,
1070
+ reconciliation: dispatchProvenance.reconciliation,
730
1071
  };
731
1072
  }),
732
1073
  skipped,