openplanr 1.17.0 → 1.19.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.
- package/dist/cli/commands/operate.d.ts +10 -1
- package/dist/cli/commands/operate.d.ts.map +1 -1
- package/dist/cli/commands/operate.js +55 -7
- package/dist/cli/commands/operate.js.map +1 -1
- package/dist/services/ai-service.d.ts +25 -0
- package/dist/services/ai-service.d.ts.map +1 -1
- package/dist/services/ai-service.js +38 -0
- package/dist/services/ai-service.js.map +1 -1
- package/dist/services/operate/advisors.d.ts +100 -4
- package/dist/services/operate/advisors.d.ts.map +1 -1
- package/dist/services/operate/advisors.js +450 -14
- package/dist/services/operate/advisors.js.map +1 -1
- package/dist/services/operate/config.d.ts +36 -0
- package/dist/services/operate/config.d.ts.map +1 -1
- package/dist/services/operate/config.js +94 -6
- package/dist/services/operate/config.js.map +1 -1
- package/dist/services/operate/doctor.d.ts.map +1 -1
- package/dist/services/operate/doctor.js +121 -1
- package/dist/services/operate/doctor.js.map +1 -1
- package/dist/services/operate/engine.d.ts +22 -6
- package/dist/services/operate/engine.d.ts.map +1 -1
- package/dist/services/operate/engine.js +20 -9
- package/dist/services/operate/engine.js.map +1 -1
- package/dist/services/operate/evidence-readiness.d.ts +11 -1
- package/dist/services/operate/evidence-readiness.d.ts.map +1 -1
- package/dist/services/operate/evidence-readiness.js +12 -2
- package/dist/services/operate/evidence-readiness.js.map +1 -1
- package/dist/services/operate/evidence.d.ts +30 -1
- package/dist/services/operate/evidence.d.ts.map +1 -1
- package/dist/services/operate/evidence.js +248 -10
- package/dist/services/operate/evidence.js.map +1 -1
- package/dist/services/operate/index.d.ts +2 -0
- package/dist/services/operate/index.d.ts.map +1 -1
- package/dist/services/operate/index.js +242 -41
- package/dist/services/operate/index.js.map +1 -1
- package/dist/services/operate/interaction/answer-service.d.ts +17 -0
- package/dist/services/operate/interaction/answer-service.d.ts.map +1 -1
- package/dist/services/operate/interaction/answer-service.js +49 -7
- package/dist/services/operate/interaction/answer-service.js.map +1 -1
- package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
- package/dist/services/operate/interaction/question-engine.js +9 -3
- package/dist/services/operate/interaction/question-engine.js.map +1 -1
- package/dist/services/operate/interaction/question-registry.d.ts +13 -0
- package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
- package/dist/services/operate/interaction/question-registry.js +116 -26
- package/dist/services/operate/interaction/question-registry.js.map +1 -1
- package/dist/services/operate/interaction/terminal-renderer.d.ts +12 -1
- package/dist/services/operate/interaction/terminal-renderer.d.ts.map +1 -1
- package/dist/services/operate/interaction/terminal-renderer.js +26 -15
- package/dist/services/operate/interaction/terminal-renderer.js.map +1 -1
- package/dist/services/operate/lifecycle.d.ts +8 -0
- package/dist/services/operate/lifecycle.d.ts.map +1 -1
- package/dist/services/operate/lifecycle.js +36 -2
- package/dist/services/operate/lifecycle.js.map +1 -1
- package/dist/services/operate/maintenance.d.ts +33 -1
- package/dist/services/operate/maintenance.d.ts.map +1 -1
- package/dist/services/operate/maintenance.js +467 -47
- package/dist/services/operate/maintenance.js.map +1 -1
- package/dist/services/operate/projection-persistence.d.ts +35 -7
- package/dist/services/operate/projection-persistence.d.ts.map +1 -1
- package/dist/services/operate/projection-persistence.js +122 -53
- package/dist/services/operate/projection-persistence.js.map +1 -1
- package/dist/services/operate/projection.d.ts +9 -1
- package/dist/services/operate/projection.d.ts.map +1 -1
- package/dist/services/operate/projection.js +12 -9
- package/dist/services/operate/projection.js.map +1 -1
- package/dist/services/operate/protocol.d.ts +4 -1
- package/dist/services/operate/protocol.d.ts.map +1 -1
- package/dist/services/operate/protocol.js.map +1 -1
- package/dist/services/operate/reports.d.ts +18 -0
- package/dist/services/operate/reports.d.ts.map +1 -1
- package/dist/services/operate/reports.js +29 -1
- package/dist/services/operate/reports.js.map +1 -1
- package/dist/services/operate/routes.d.ts +84 -0
- package/dist/services/operate/routes.d.ts.map +1 -1
- package/dist/services/operate/routes.js +519 -10
- package/dist/services/operate/routes.js.map +1 -1
- package/dist/services/operate/types.d.ts +25 -5
- package/dist/services/operate/types.d.ts.map +1 -1
- package/dist/services/operate/types.js.map +1 -1
- package/dist/services/runtime-manager-service.d.ts.map +1 -1
- package/dist/services/runtime-manager-service.js +25 -5
- package/dist/services/runtime-manager-service.js.map +1 -1
- 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
|
|
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
|
-
|
|
359
|
+
const pack = {
|
|
296
360
|
implementation: 'openplanr-operating-advisor-pack',
|
|
297
361
|
cycleId: input.cycleId,
|
|
298
362
|
roleId: input.role.roleId,
|
|
@@ -301,18 +365,120 @@ 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
|
-
* budget is a
|
|
309
|
-
*
|
|
310
|
-
*
|
|
410
|
+
* budget is a fraction of the role's v1.2 pack budget — `packMaxInputBytes / 32
|
|
411
|
+
* KiB`, rounded — clamped to the registry's real spread `[1, 32]` KiB.
|
|
412
|
+
*
|
|
413
|
+
* The prior `[1, 9]` KiB ceiling was arbitrary: it capped every repository-reading
|
|
414
|
+
* lens (512 KiB → 16 KiB) and `technology-risk` (640 KiB → 20 KiB) down to 9 KiB.
|
|
415
|
+
* At ~300 canonical bytes per index item that admitted only ~25 items, so a real
|
|
416
|
+
* monorepo's packet overflowed the derived budget and mission dispatch failed
|
|
417
|
+
* closed on a healthy repository. The ceiling now tracks the schema's own
|
|
418
|
+
* `maxInputBytes` maximum (1 MiB → 32 KiB), so a large registry budget maps to a
|
|
419
|
+
* proportional mission budget instead of an arbitrary cut. This DERIVES a new
|
|
420
|
+
* value; it never mutates the frozen v1.2 `maxInputBytes`. Enforcing that
|
|
421
|
+
* pack-mode budget is a separate concern handled by
|
|
422
|
+
* `assertOperatingAdvisorPackWithinBudget` at pack construction, not here.
|
|
311
423
|
*/
|
|
312
424
|
export function deriveOperatingMissionBudget(packMaxInputBytes) {
|
|
313
|
-
const kib = Math.min(
|
|
425
|
+
const kib = Math.min(32, Math.max(1, Math.round(packMaxInputBytes / (32 * 1024))));
|
|
314
426
|
return kib * 1024;
|
|
315
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* Conservative upper bound on one evidence index item's canonical JSON size. A
|
|
430
|
+
* v1.3 index item is a fixed-shape pointer (id, path/revision, contentHash,
|
|
431
|
+
* source, classification, freshness, sensitivity, signals[]); measured against
|
|
432
|
+
* real indexes it lands near ~300 bytes even with a long repository path. Using a
|
|
433
|
+
* value at the top of that range as the divisor guarantees the derived item cap
|
|
434
|
+
* keeps a truncated packet within budget rather than merely on average.
|
|
435
|
+
*/
|
|
436
|
+
const MISSION_EVIDENCE_INDEX_ITEM_BYTES = 320;
|
|
437
|
+
/**
|
|
438
|
+
* The non-evidence portion of a mission packet (charter, prior-cycle summary,
|
|
439
|
+
* planning status, role brief, tool grant, envelope) reserved out of the derived
|
|
440
|
+
* byte budget before the remainder is divided into index slots. Sized to roughly
|
|
441
|
+
* twice the measured empty-packet overhead (~1.9 KiB) so a moderately large
|
|
442
|
+
* charter/summary still leaves the derived cap safely inside budget.
|
|
443
|
+
*/
|
|
444
|
+
const MISSION_PACKET_OVERHEAD_RESERVE_BYTES = 4096;
|
|
445
|
+
/**
|
|
446
|
+
* Derive how many evidence index items a role's mission packet can carry before
|
|
447
|
+
* the published pipeline must truncate (the `maxEvidenceItems` the assembler
|
|
448
|
+
* enforces). The cap is the number of `MISSION_EVIDENCE_INDEX_ITEM_BYTES`-sized
|
|
449
|
+
* slots that fit in the role's derived byte budget after reserving
|
|
450
|
+
* `MISSION_PACKET_OVERHEAD_RESERVE_BYTES` for the non-evidence payload,
|
|
451
|
+
* intersected with any caller-supplied upper bound (`config.budgets.maxItems`).
|
|
452
|
+
*
|
|
453
|
+
* This is the "per-role registry-sized default": `technology-risk`'s 640-KiB
|
|
454
|
+
* pack budget yields a larger cap than a 512-KiB lens, and the chair's 192-KiB
|
|
455
|
+
* budget a smaller one — instead of one flat number that starves large-budget
|
|
456
|
+
* roles. Because `slots * itemBytes + reserve <= derivedBudget` holds by
|
|
457
|
+
* construction and the real per-item/overhead costs sit under those bounds, a
|
|
458
|
+
* packet truncated to this cap fits the derived budget with margin.
|
|
459
|
+
*/
|
|
460
|
+
export function deriveOperatingMissionEvidenceCap(packMaxInputBytes, upperBound) {
|
|
461
|
+
const derivedBudget = deriveOperatingMissionBudget(packMaxInputBytes);
|
|
462
|
+
const indexBudget = Math.max(0, derivedBudget - MISSION_PACKET_OVERHEAD_RESERVE_BYTES);
|
|
463
|
+
const registrySizedDefault = Math.max(1, Math.floor(indexBudget / MISSION_EVIDENCE_INDEX_ITEM_BYTES));
|
|
464
|
+
return typeof upperBound === 'number' && upperBound > 0
|
|
465
|
+
? Math.min(upperBound, registrySizedDefault)
|
|
466
|
+
: registrySizedDefault;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The per-role mission evidence-item cap registry, derived once from the
|
|
470
|
+
* pipeline's role registry. Mirrors `deriveOperatingMissionBudgets` for the
|
|
471
|
+
* `maxEvidenceItems` dimension so `buildOperatingMissionPackets` can pass each
|
|
472
|
+
* role its own byte-fitting cap instead of one flat value.
|
|
473
|
+
*/
|
|
474
|
+
export async function deriveOperatingMissionEvidenceCaps(upperBound) {
|
|
475
|
+
const roles = (await loadOperatingProtocol()).listOperatingRoles();
|
|
476
|
+
return Object.fromEntries(roles.map((role) => {
|
|
477
|
+
const budgets = role.budgets;
|
|
478
|
+
const packMax = typeof budgets?.maxInputBytes === 'number' ? budgets.maxInputBytes : 262_144;
|
|
479
|
+
return [role.id, deriveOperatingMissionEvidenceCap(packMax, upperBound)];
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
316
482
|
/**
|
|
317
483
|
* The per-role mission input budget registry, derived once from the pipeline's
|
|
318
484
|
* role registry. Every value is single-digit KiB.
|
|
@@ -331,6 +497,15 @@ function isMissionBudgetError(error) {
|
|
|
331
497
|
return true;
|
|
332
498
|
return error instanceof Error && error.message.includes('E_OPERATE_MISSION_PACKET_BUDGET');
|
|
333
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Human-readable one-line warning for a surfaced index truncation, suitable for
|
|
502
|
+
* a cycle's evidence-warnings channel.
|
|
503
|
+
*/
|
|
504
|
+
export function describeOperatingMissionTruncation(truncation) {
|
|
505
|
+
return (`Mission evidence index for role ${truncation.roleId} was truncated to its ` +
|
|
506
|
+
`${truncation.maxEvidenceItems}-item budget: ${truncation.keptItems} highest-priority ` +
|
|
507
|
+
`item(s) kept, ${truncation.droppedItems} of ${truncation.evidenceItemsBeforeTruncation} dropped.`);
|
|
508
|
+
}
|
|
334
509
|
/**
|
|
335
510
|
* Build a digest-bound Protocol v1.3 mission packet (FR1) by calling the
|
|
336
511
|
* pipeline's `createOperatingMissionPacket` with the live non-evidence payload
|
|
@@ -376,7 +551,30 @@ export async function buildOperatingMissionPacket(input) {
|
|
|
376
551
|
throw new OperateError('E_OPERATE_MISSION_PACKET_BUDGET', `Mission packet for role ${input.roleId} is ${actualBytes} bytes, exceeding its ` +
|
|
377
552
|
`derived ${derivedBudget}-byte mission budget; the evidence index is not truncated to fit.`, { roleId: input.roleId, actualBytes, maxInputBytes: derivedBudget });
|
|
378
553
|
}
|
|
379
|
-
|
|
554
|
+
// Read the published pipeline's (0.35.0+) enforced-truncation record off the
|
|
555
|
+
// signed packet's budgets. When the caller-prioritized index exceeded
|
|
556
|
+
// `maxEvidenceItems`, the assembler kept the highest-priority items and stamped
|
|
557
|
+
// `truncatedEvidenceItems`/`evidenceItemsBeforeTruncation` into the packet.
|
|
558
|
+
// Surface that as a structured record so the caller reports the drop instead of
|
|
559
|
+
// silently returning a smaller-than-requested index. (Narrowed locally: the
|
|
560
|
+
// frozen `OperatingMissionPacket.budgets` type does not carry these optional
|
|
561
|
+
// v1.3 provenance fields.)
|
|
562
|
+
const budgets = packet.budgets;
|
|
563
|
+
if (budgets.truncatedEvidenceItems && typeof budgets.evidenceItemsBeforeTruncation === 'number') {
|
|
564
|
+
const evidenceItemsBeforeTruncation = budgets.evidenceItemsBeforeTruncation;
|
|
565
|
+
const keptItems = packet.evidenceIndex.length;
|
|
566
|
+
return {
|
|
567
|
+
packet,
|
|
568
|
+
truncation: {
|
|
569
|
+
roleId: input.roleId,
|
|
570
|
+
evidenceItemsBeforeTruncation,
|
|
571
|
+
keptItems,
|
|
572
|
+
droppedItems: evidenceItemsBeforeTruncation - keptItems,
|
|
573
|
+
maxEvidenceItems: budgets.maxEvidenceItems ?? input.maxEvidenceItems ?? keptItems,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return { packet };
|
|
380
578
|
}
|
|
381
579
|
export function operatingAdvisorMessages(input) {
|
|
382
580
|
return [
|
|
@@ -500,6 +698,156 @@ export async function createNativeOperatingRoleResult(input) {
|
|
|
500
698
|
protocol.validateOperatingRoleResultDigest(result);
|
|
501
699
|
return result;
|
|
502
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* Sanitize a v1.3 mission advisor response's free text exactly as the v1.2
|
|
703
|
+
* `sanitizeOutput` does, but PRESERVE each proposal's structured `citations`
|
|
704
|
+
* verbatim: they are schema-pattern-bounded locators (repository path, git
|
|
705
|
+
* revision, or planr artifact — never free prose), which the engine resolves and
|
|
706
|
+
* snapshots after the lens returns. Dropping them would silence every proposal.
|
|
707
|
+
*/
|
|
708
|
+
function sanitizeMissionOutput(output) {
|
|
709
|
+
return {
|
|
710
|
+
outcome: output.outcome,
|
|
711
|
+
proposals: output.proposals
|
|
712
|
+
.map((proposal) => ({
|
|
713
|
+
...proposal,
|
|
714
|
+
title: sanitizeGeneratedPlainText(proposal.title).replace(/\s+/g, ' ').trim(),
|
|
715
|
+
problem: sanitizeGeneratedPlainText(proposal.problem).replace(/\s+/g, ' ').trim(),
|
|
716
|
+
proposal: sanitizeGeneratedPlainText(proposal.proposal).replace(/\s+/g, ' ').trim(),
|
|
717
|
+
citations: [...proposal.citations],
|
|
718
|
+
...(proposal.dependsOnProposalKeys
|
|
719
|
+
? { dependsOnProposalKeys: [...new Set(proposal.dependsOnProposalKeys)].sort() }
|
|
720
|
+
: {}),
|
|
721
|
+
...(proposal.conflictsWithProposalKeys
|
|
722
|
+
? { conflictsWithProposalKeys: [...new Set(proposal.conflictsWithProposalKeys)].sort() }
|
|
723
|
+
: {}),
|
|
724
|
+
...(proposal.sequenceProposalKeys
|
|
725
|
+
? { sequenceProposalKeys: [...proposal.sequenceProposalKeys] }
|
|
726
|
+
: {}),
|
|
727
|
+
}))
|
|
728
|
+
.sort((left, right) => left.proposalKey.localeCompare(right.proposalKey) ||
|
|
729
|
+
left.type.localeCompare(right.type) ||
|
|
730
|
+
left.problem.localeCompare(right.problem)),
|
|
731
|
+
gaps: [...new Set(output.gaps.map(sanitizeGeneratedPlainText))].sort(),
|
|
732
|
+
conflicts: [...new Set(output.conflicts.map(sanitizeGeneratedPlainText))].sort(),
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* A mission packet's `role.output` facet mirrors the v1.2 brief's output
|
|
737
|
+
* contract (allowed proposal types, maxima), so a v1.3 response is validated
|
|
738
|
+
* against exactly the same invariants a pack response is — reusing the pipeline's
|
|
739
|
+
* registry-derived brief as the single source of truth.
|
|
740
|
+
*/
|
|
741
|
+
export async function createNativeMissionOperatingRoleResult(input) {
|
|
742
|
+
const protocol = await loadOperatingProtocol();
|
|
743
|
+
// Validate against the INSTALLED v1.3 schema explicitly — the compact response
|
|
744
|
+
// carries no protocol envelope, so the pipeline additively resolves to v1.2
|
|
745
|
+
// unless the version is passed.
|
|
746
|
+
const contractIssues = protocol.validateProtocolArtifact('operating-advisor-response', input.response, { protocolVersion: '1.3.0' });
|
|
747
|
+
if (contractIssues.length > 0) {
|
|
748
|
+
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) });
|
|
749
|
+
}
|
|
750
|
+
const parsed = missionAdvisorOutputSchema.safeParse(input.response);
|
|
751
|
+
if (!parsed.success) {
|
|
752
|
+
throw new OperateError('E_OPERATE_INTERNAL', 'Protocol and OpenPlanr disagree on the v1.3 mission advisor response contract.', {
|
|
753
|
+
issues: parsed.error.issues.slice(0, 8).map((issue) => ({
|
|
754
|
+
path: issue.path.join('.'),
|
|
755
|
+
code: issue.code,
|
|
756
|
+
})),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
const output = sanitizeMissionOutput(parsed.data);
|
|
760
|
+
const brief = protocol.createOperatingAdvisorBrief(input.packet.roleId);
|
|
761
|
+
assertAdvisorOutputMatchesBrief(brief, output);
|
|
762
|
+
const capability = (input.packet.role.capabilityTier ??
|
|
763
|
+
brief.role.capabilityTier);
|
|
764
|
+
// The intermediate result: proposals carry their v1.3 citations and an empty
|
|
765
|
+
// evidenceRefs set. It is deliberately NOT yet a v1.2-valid committed
|
|
766
|
+
// operating-role-result — the citation gate mints the evidenceRefs that make
|
|
767
|
+
// it one. `inputDigest` is the packet's digest, so the record path's
|
|
768
|
+
// input-digest binding (prepare stored the same packet digest) holds.
|
|
769
|
+
const intermediate = {
|
|
770
|
+
kind: 'operating-role-result',
|
|
771
|
+
schemaVersion: OPERATE_SCHEMA_VERSION,
|
|
772
|
+
protocolVersion: OPERATE_PROTOCOL_VERSION,
|
|
773
|
+
cycleId: input.packet.cycleId,
|
|
774
|
+
roleId: input.packet.roleId,
|
|
775
|
+
inputDigest: input.packet.packetDigest,
|
|
776
|
+
resultDigest: input.packet.packetDigest,
|
|
777
|
+
outcome: output.outcome,
|
|
778
|
+
proposals: output.proposals.map((proposal) => ({
|
|
779
|
+
proposalKey: proposal.proposalKey,
|
|
780
|
+
type: proposal.type,
|
|
781
|
+
title: proposal.title,
|
|
782
|
+
problem: proposal.problem,
|
|
783
|
+
proposal: proposal.proposal,
|
|
784
|
+
impact: proposal.impact,
|
|
785
|
+
confidence: proposal.confidence,
|
|
786
|
+
ease: proposal.ease,
|
|
787
|
+
severity: proposal.severity,
|
|
788
|
+
evidenceRefs: [],
|
|
789
|
+
...(proposal.dependsOnProposalKeys
|
|
790
|
+
? { dependsOnProposalKeys: proposal.dependsOnProposalKeys }
|
|
791
|
+
: {}),
|
|
792
|
+
...(proposal.conflictsWithProposalKeys
|
|
793
|
+
? { conflictsWithProposalKeys: proposal.conflictsWithProposalKeys }
|
|
794
|
+
: {}),
|
|
795
|
+
...(proposal.sequenceProposalKeys
|
|
796
|
+
? { sequenceProposalKeys: proposal.sequenceProposalKeys }
|
|
797
|
+
: {}),
|
|
798
|
+
citations: proposal.citations,
|
|
799
|
+
})),
|
|
800
|
+
gaps: output.gaps,
|
|
801
|
+
conflicts: output.conflicts,
|
|
802
|
+
producer: {
|
|
803
|
+
product: 'openplanr',
|
|
804
|
+
version: OPENPLANR_VERSION,
|
|
805
|
+
runtime: input.runtime,
|
|
806
|
+
capability,
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
// A quiet response has no proposals/citations, so it never touches the gate; a
|
|
810
|
+
// proposals response threads its citations through the already-live gate.
|
|
811
|
+
const gated = output.proposals.length > 0
|
|
812
|
+
? await input.resolveCitations([intermediate])
|
|
813
|
+
: { roleResults: [intermediate], gaps: [] };
|
|
814
|
+
const resolved = gated.roleResults[0] ?? intermediate;
|
|
815
|
+
// Finalize into a v1.2-valid committed operating-role-result: strip the
|
|
816
|
+
// now-resolved citations, keep the minted evidenceRefs, and let the surviving
|
|
817
|
+
// proposal count set the honest outcome (an all-unresolvable response commits
|
|
818
|
+
// as quiet, its citations preserved only as the opened gaps).
|
|
819
|
+
const survivingProposals = resolved.proposals
|
|
820
|
+
.map((proposal) => {
|
|
821
|
+
const { citations: _citations, ...rest } = proposal;
|
|
822
|
+
return rest;
|
|
823
|
+
})
|
|
824
|
+
.filter((proposal) => proposal.evidenceRefs.length > 0);
|
|
825
|
+
const unsigned = {
|
|
826
|
+
kind: 'operating-role-result',
|
|
827
|
+
schemaVersion: OPERATE_SCHEMA_VERSION,
|
|
828
|
+
protocolVersion: OPERATE_PROTOCOL_VERSION,
|
|
829
|
+
cycleId: input.packet.cycleId,
|
|
830
|
+
roleId: input.packet.roleId,
|
|
831
|
+
inputDigest: input.packet.packetDigest,
|
|
832
|
+
outcome: survivingProposals.length > 0 ? 'proposals' : 'quiet',
|
|
833
|
+
proposals: survivingProposals,
|
|
834
|
+
gaps: output.gaps,
|
|
835
|
+
conflicts: output.conflicts,
|
|
836
|
+
producer: {
|
|
837
|
+
product: 'openplanr',
|
|
838
|
+
version: OPENPLANR_VERSION,
|
|
839
|
+
runtime: input.runtime,
|
|
840
|
+
capability,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
const result = {
|
|
844
|
+
...unsigned,
|
|
845
|
+
resultDigest: protocol.computeOperatingRoleResultDigest(unsigned),
|
|
846
|
+
};
|
|
847
|
+
await assertOperatingArtifact('operating-role-result', result);
|
|
848
|
+
protocol.validateOperatingRoleResultDigest(result);
|
|
849
|
+
return { result, gaps: gated.gaps };
|
|
850
|
+
}
|
|
503
851
|
function safeFailureMessage(error) {
|
|
504
852
|
try {
|
|
505
853
|
return sanitizeGeneratedPlainText(error instanceof Error ? error.message : String(error));
|
|
@@ -568,6 +916,37 @@ class OpenPlanrStructuredAdapter {
|
|
|
568
916
|
})).result;
|
|
569
917
|
}
|
|
570
918
|
}
|
|
919
|
+
/** Redacted error class label for diagnostics — no message or stack, ever. */
|
|
920
|
+
function redactedProviderErrorClass(error) {
|
|
921
|
+
if (error instanceof AIError)
|
|
922
|
+
return `AIError:${error.code}`;
|
|
923
|
+
if (error instanceof Error && typeof error.name === 'string' && error.name.length > 0) {
|
|
924
|
+
return error.name;
|
|
925
|
+
}
|
|
926
|
+
return 'UnknownError';
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Build the actionable remedy for a failed structured-provider bootstrap,
|
|
930
|
+
* preserving the underlying provider guidance (e.g. the `planr config set-key`
|
|
931
|
+
* block an AIError already carries) and always naming the `--offline` escape.
|
|
932
|
+
*/
|
|
933
|
+
function structuredProviderBootstrapRemedy(error, provider) {
|
|
934
|
+
const detail = error instanceof AIError
|
|
935
|
+
? error.userMessage
|
|
936
|
+
: error instanceof Error
|
|
937
|
+
? error.message
|
|
938
|
+
: String(error);
|
|
939
|
+
const trimmed = detail.trim();
|
|
940
|
+
const suffixParts = [];
|
|
941
|
+
if (!/config set-key/.test(trimmed)) {
|
|
942
|
+
suffixParts.push(`Configure a key with \`planr config set-key ${provider ?? '<provider>'}\``);
|
|
943
|
+
}
|
|
944
|
+
if (!/--offline/.test(trimmed)) {
|
|
945
|
+
suffixParts.push('or run the cycle offline with --offline');
|
|
946
|
+
}
|
|
947
|
+
const suffix = suffixParts.length > 0 ? ` ${suffixParts.join(' ')}.` : '';
|
|
948
|
+
return `Structured AI provider bootstrap failed: ${trimmed}${suffix}`;
|
|
949
|
+
}
|
|
571
950
|
export async function createConfiguredStructuredAdapter(projectRoot, options = {}) {
|
|
572
951
|
// `planr operate init` writes .planr/operate/config.json, not the project-wide
|
|
573
952
|
// .planr/config.json that loadConfig requires. A project that ran only the
|
|
@@ -580,7 +959,20 @@ export async function createConfiguredStructuredAdapter(projectRoot, options = {
|
|
|
580
959
|
if (!config || !isAIConfigured(config)) {
|
|
581
960
|
throw new OperateError('E_OPERATE_ADVISOR_FAILED', 'No structured AI provider is configured; use --offline or configure OpenPlanr AI.');
|
|
582
961
|
}
|
|
583
|
-
|
|
962
|
+
// A named provider whose key cannot be resolved in this (possibly sandboxed)
|
|
963
|
+
// subprocess environment makes getAIProvider throw a raw AIError. Left
|
|
964
|
+
// unguarded it reaches index.ts's failure() as E_OPERATE_INTERNAL — the exact
|
|
965
|
+
// masked crash the audit reproduced. Convert any provider-bootstrap failure
|
|
966
|
+
// into a typed E_OPERATE_ADVISOR_FAILED that preserves the actionable remedy
|
|
967
|
+
// (`planr config set-key …` / `--offline`) and records a redacted error class.
|
|
968
|
+
let provider;
|
|
969
|
+
try {
|
|
970
|
+
provider = await getAIProvider(config);
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
throw new OperateError('E_OPERATE_ADVISOR_FAILED', structuredProviderBootstrapRemedy(error, config.ai?.provider), { errorClass: redactedProviderErrorClass(error) });
|
|
974
|
+
}
|
|
975
|
+
return new OpenPlanrStructuredAdapter(provider, config.ai?.provider ?? 'ai', options.quiet ?? false);
|
|
584
976
|
}
|
|
585
977
|
export async function dispatchOperatingAdvisors(input) {
|
|
586
978
|
assertAdvisorIsolation(input.adapter);
|
|
@@ -620,6 +1012,18 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
620
1012
|
runnable.push(role);
|
|
621
1013
|
}
|
|
622
1014
|
async function dispatchRole(role) {
|
|
1015
|
+
// Resolve THIS role's dispatch mode once and derive provenance from what is
|
|
1016
|
+
// actually dispatched below — never re-derived after the fact. A role that
|
|
1017
|
+
// resolves to a native bounded lens has its read-only tool grant enforced
|
|
1018
|
+
// before the lens runs (below); every other role fails closed to the pack
|
|
1019
|
+
// path, so `isolation` can only read `enforced-read-only-bounded` when the
|
|
1020
|
+
// bounded grant was genuinely enforced, never as a bare label over a pack.
|
|
1021
|
+
const resolution = resolveMode(role.roleId);
|
|
1022
|
+
const dispatch = {
|
|
1023
|
+
dispatchMode: resolution.mode,
|
|
1024
|
+
isolation: resolution.isolation,
|
|
1025
|
+
reconciliation: resolution.reconciliation,
|
|
1026
|
+
};
|
|
623
1027
|
let pack;
|
|
624
1028
|
try {
|
|
625
1029
|
pack = await createOperatingAdvisorPack({
|
|
@@ -635,8 +1039,28 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
635
1039
|
roleId: role.roleId,
|
|
636
1040
|
message: safeFailureMessage(error),
|
|
637
1041
|
modelCalls: 0,
|
|
1042
|
+
dispatch,
|
|
638
1043
|
};
|
|
639
1044
|
}
|
|
1045
|
+
if (resolution.native) {
|
|
1046
|
+
// Route through mission-dispatch.ts's granted-tool-set enforcement: the
|
|
1047
|
+
// native lens is confined to the bounded read-only toolset over its
|
|
1048
|
+
// sensitivity-ceiling-narrowed declared roots. Constructing the toolset is
|
|
1049
|
+
// what makes `enforced-read-only-bounded` true; a callable outside the
|
|
1050
|
+
// read-only grant simply does not exist on the surface it hands the lens.
|
|
1051
|
+
const ceiling = pack.roleBrief.evidence.sensitivityCeiling;
|
|
1052
|
+
const declaredRoots = [
|
|
1053
|
+
...new Set(pack.evidence.items
|
|
1054
|
+
.map((item) => item.location.split('/')[0])
|
|
1055
|
+
.filter((segment) => Boolean(segment))),
|
|
1056
|
+
].sort();
|
|
1057
|
+
const roots = narrowMissionRootsToCeiling({ declaredRoots, evidenceIndex: [], ceiling });
|
|
1058
|
+
const toolset = createMissionToolset({ roots, ceiling });
|
|
1059
|
+
const grantedTools = Object.keys(toolset);
|
|
1060
|
+
if (grantedTools.some((tool) => !MISSION_READ_ONLY_TOOLS.includes(tool))) {
|
|
1061
|
+
throw new OperateError('E_OPERATE_PROVIDER_READ_ONLY', `Mission dispatch for ${role.roleId} assembled a tool outside the bounded read-only grant.`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
640
1064
|
const permittedEvidenceRefs = new Set(role.evidenceRefs);
|
|
641
1065
|
let output;
|
|
642
1066
|
let lastError;
|
|
@@ -667,6 +1091,7 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
667
1091
|
roleId: role.roleId,
|
|
668
1092
|
message: safeFailureMessage(lastError),
|
|
669
1093
|
modelCalls: roleModelCalls,
|
|
1094
|
+
dispatch,
|
|
670
1095
|
};
|
|
671
1096
|
}
|
|
672
1097
|
const unsigned = {
|
|
@@ -693,7 +1118,7 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
693
1118
|
};
|
|
694
1119
|
await assertOperatingArtifact('operating-role-result', result);
|
|
695
1120
|
protocol.validateOperatingRoleResultDigest(result);
|
|
696
|
-
return { ok: true, result, modelCalls: roleModelCalls };
|
|
1121
|
+
return { ok: true, result, modelCalls: roleModelCalls, dispatch };
|
|
697
1122
|
}
|
|
698
1123
|
// Fan the per-role dispatch out in parallel where the adapter reports it,
|
|
699
1124
|
// sequentially otherwise. The orchestrator returns results in `runnable` order
|
|
@@ -705,6 +1130,13 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
705
1130
|
parallel: Boolean(input.adapter.parallelDispatch),
|
|
706
1131
|
run: (role) => dispatchRole(role),
|
|
707
1132
|
});
|
|
1133
|
+
// The per-role dispatch descriptor captured inside `dispatchRole` — provenance
|
|
1134
|
+
// reads it rather than re-resolving, so it can only report the isolation the
|
|
1135
|
+
// role was actually dispatched under.
|
|
1136
|
+
const dispatchByRole = new Map();
|
|
1137
|
+
for (const entry of dispatched) {
|
|
1138
|
+
dispatchByRole.set(entry.ok ? entry.result.roleId : entry.roleId, entry.dispatch);
|
|
1139
|
+
}
|
|
708
1140
|
const results = dispatched
|
|
709
1141
|
.filter((entry) => entry.ok)
|
|
710
1142
|
.map((entry) => entry.result)
|
|
@@ -717,16 +1149,20 @@ export async function dispatchOperatingAdvisors(input) {
|
|
|
717
1149
|
return {
|
|
718
1150
|
results,
|
|
719
1151
|
provenance: results.map((result) => {
|
|
720
|
-
const
|
|
1152
|
+
const dispatchProvenance = dispatchByRole.get(result.roleId) ?? {
|
|
1153
|
+
dispatchMode: resolveMode(result.roleId).mode,
|
|
1154
|
+
isolation: resolveMode(result.roleId).isolation,
|
|
1155
|
+
reconciliation: resolveMode(result.roleId).reconciliation,
|
|
1156
|
+
};
|
|
721
1157
|
return {
|
|
722
1158
|
roleId: result.roleId,
|
|
723
1159
|
runtime: input.runtime ?? input.adapter.id,
|
|
724
1160
|
adapterId: input.adapter.id,
|
|
725
1161
|
capability: input.adapter.capability,
|
|
726
1162
|
dispatch: input.adapter.parallelDispatch ? 'parallel' : 'sequential',
|
|
727
|
-
dispatchMode:
|
|
728
|
-
isolation:
|
|
729
|
-
reconciliation:
|
|
1163
|
+
dispatchMode: dispatchProvenance.dispatchMode,
|
|
1164
|
+
isolation: dispatchProvenance.isolation,
|
|
1165
|
+
reconciliation: dispatchProvenance.reconciliation,
|
|
730
1166
|
};
|
|
731
1167
|
}),
|
|
732
1168
|
skipped,
|