amicus 4.4.0 → 4.5.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +13 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/pack/pack-forward.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module pack/pack-forward
|
|
6
|
+
* Wave-1 review fix wave (I1/I2/I3): validates a pack-forwarded `maxCost`/
|
|
7
|
+
* `template` (pack-resolve.js's `applyPackToMcpInput(...).forward` — see that
|
|
8
|
+
* module's own docblock; only ever `maxCost`/`template`, both optional)
|
|
9
|
+
* BEFORE any spawn or state write, on every MCP path that forwards them
|
|
10
|
+
* (amicus_fanout; amicus_start's spawn-fallback AND in-process paths).
|
|
11
|
+
* Extracted out of mcp-server.js (grandfathered/size-exempt) because this is
|
|
12
|
+
* genuine new logic — pack-content validation and template rendering, not
|
|
13
|
+
* wiring — and needs direct unit tests a source-text grep can't provide.
|
|
14
|
+
*
|
|
15
|
+
* Two independent defects, one call:
|
|
16
|
+
* - I2 (maxCost): `checkBudget` (sidecar/budget.js) only enforces
|
|
17
|
+
* `typeof === 'number' && > 0`; a hand-edited pack with a string/zero/
|
|
18
|
+
* negative maxCost silently runs UNCAPPED on the in-process path (the
|
|
19
|
+
* type check just falls through, so no ceiling is ever applied), while a
|
|
20
|
+
* spawned CLI child re-validates its own --max-cost and refuses it
|
|
21
|
+
* (BAD_ARGS) — but only AFTER the tool already returned success and (on
|
|
22
|
+
* amicus_fanout) already wrote a 'running' wave record. Caught here,
|
|
23
|
+
* pre-spend, on every path, instead.
|
|
24
|
+
* - I1 (template): the built-in `review` template needs {{artifact}}/
|
|
25
|
+
* {{artifact_path}}, which fanout/start can never supply (neither passes
|
|
26
|
+
* an --artifact-equivalent through pack forwarding). A naive blind forward
|
|
27
|
+
* lets the CHILD's render fail via process.exit AFTER the tool already
|
|
28
|
+
* returned success — on amicus_fanout the pre-seeded wave record has no
|
|
29
|
+
* `pid`, so crash-detection (which only probes pid-bearing records) never
|
|
30
|
+
* notices, and the wave is stuck 'running' forever. Fixed by dry-running
|
|
31
|
+
* applyTemplate here, in-process, with EXACTLY the inputs the child will
|
|
32
|
+
* use (no artifact, no --var — a pack's forwarded template carries only
|
|
33
|
+
* its name). applyTemplate is a pure function of those inputs ({{date}}
|
|
34
|
+
* is day-granular), so a success here guarantees the child's own later
|
|
35
|
+
* (real) re-render succeeds identically — the child still does its own
|
|
36
|
+
* render, so template provenance (`promptMeta.template`) is still
|
|
37
|
+
* recorded exactly as Wave 1 built it.
|
|
38
|
+
*
|
|
39
|
+
* Callers that only need the pre-spend validation (the two spawn paths)
|
|
40
|
+
* ignore the returned `renderedPrompt`; amicus_start's in-process path
|
|
41
|
+
* (which never spawns a child, so nothing else would render it) reuses it
|
|
42
|
+
* as the actual prompt instead of rendering a second time.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const { ERROR_CODES } = require('../utils/error-doc');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {{forward: {maxCost?: number, template?: string}, packRef: string,
|
|
49
|
+
* prompt: string, project: string}} opts `forward` is pack-resolve.js's
|
|
50
|
+
* `applyPackToMcpInput(...).forward`; `packRef` names the pack in error
|
|
51
|
+
* text — pass the RESOLVED pack's name (`packRecord.name`), not necessarily
|
|
52
|
+
* the caller's raw `--pack`/`pack` ref (which may be a path).
|
|
53
|
+
* @returns {{maxCost?: number, templateName?: string, renderedPrompt?: string,
|
|
54
|
+
* notices: string[]} | {error: {code, message, hint}}}
|
|
55
|
+
*/
|
|
56
|
+
function prepareForward({ forward, packRef, prompt, project }) {
|
|
57
|
+
const notices = [];
|
|
58
|
+
const result = { notices };
|
|
59
|
+
|
|
60
|
+
if (forward.maxCost !== undefined) {
|
|
61
|
+
const mc = forward.maxCost;
|
|
62
|
+
// Same validity rule cli-handlers-run.js applies to a typed --max-cost
|
|
63
|
+
// (handleStart/handleFanout): only a positive finite JS number passes. A
|
|
64
|
+
// pack is JSON, so a hand-edited "maxCost": "2.00" (string) is exactly
|
|
65
|
+
// the kind of mistake this must catch — it would otherwise reach
|
|
66
|
+
// checkBudget's `typeof === 'number'` check, fail it silently, and run
|
|
67
|
+
// with no ceiling at all.
|
|
68
|
+
if (typeof mc !== 'number' || !Number.isFinite(mc) || mc <= 0) {
|
|
69
|
+
return {
|
|
70
|
+
error: {
|
|
71
|
+
code: ERROR_CODES.PACK_INVALID,
|
|
72
|
+
message: `Error: pack '${packRef}' sets an invalid max-cost (${JSON.stringify(mc)}) — must be a positive number`,
|
|
73
|
+
hint: null,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
result.maxCost = mc;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (forward.template !== undefined) {
|
|
81
|
+
// Same call shape the child process makes (cli-handlers-run.js's
|
|
82
|
+
// handleStart :40-43 / handleFanout :164-167): no artifactFile, no
|
|
83
|
+
// varList — a pack's forwarded template carries only its name, never
|
|
84
|
+
// --artifact/--var (pack-resolve.js only ever forwards `template`).
|
|
85
|
+
const { applyTemplate } = require('../template/apply');
|
|
86
|
+
const t = applyTemplate({ templateRef: forward.template, prompt, project });
|
|
87
|
+
if (t.error) { return { error: t.error }; }
|
|
88
|
+
result.templateName = forward.template;
|
|
89
|
+
result.renderedPrompt = t.prompt;
|
|
90
|
+
notices.push(...t.notices);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { prepareForward };
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// src/pack/pack-resolve.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module pack/pack-resolve
|
|
6
|
+
* B7/F5 (v4.5), Task 11. The pack→args merge engine: the single place that
|
|
7
|
+
* turns a resolved, validated pack into filled-in CLI arg values. Precedence
|
|
8
|
+
* is explicit flag > pack > config default > built-in, achieved by filling
|
|
9
|
+
* `args` ONLY for keys the caller did not type (`explicit`, from parseArgs'
|
|
10
|
+
* `__explicit` — Task 10) — everything else (config defaults, built-in
|
|
11
|
+
* fallbacks, cross-field validation) is left to the handler that calls this,
|
|
12
|
+
* unchanged, so v4.0 pre-flight validation runs on the merged, effective
|
|
13
|
+
* values automatically (spec §5.4).
|
|
14
|
+
*
|
|
15
|
+
* Deliberately does NOT call applyTemplate: a pack's `briefing.template` is
|
|
16
|
+
* just another knob that fills `args.template` (a ref, not rendered text).
|
|
17
|
+
* Rendering stays a single-application-point concern of the CLI handlers
|
|
18
|
+
* (Task 12/13), so a pack-filled --template and a typed --template take
|
|
19
|
+
* exactly the same downstream code path.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { readPack } = require('./pack-store');
|
|
23
|
+
const { validatePack } = require('./pack-validate');
|
|
24
|
+
const { ERROR_CODES } = require('../utils/error-doc');
|
|
25
|
+
|
|
26
|
+
/** `options.*` knobs shared by every kind that defines them (council/fanout/solo).
|
|
27
|
+
* `agent` is deliberately NOT in this table — see the dedicated fill below. */
|
|
28
|
+
const COMMON_OPTION_KNOBS = [
|
|
29
|
+
['timeout', 'timeout'], ['maxCost', 'max-cost'], ['gateway', 'gateway'],
|
|
30
|
+
['thinking', 'thinking'], ['summaryLength', 'summary-length'],
|
|
31
|
+
];
|
|
32
|
+
/** `options.*` knobs shared by fanout + solo only (v4.5 F4 context controls). */
|
|
33
|
+
const CONTEXT_OPTION_KNOBS = [
|
|
34
|
+
['noContext', 'no-context'], ['contextTurns', 'context-turns'], ['contextMaxTokens', 'context-max-tokens'],
|
|
35
|
+
];
|
|
36
|
+
/** Concrete command name per pack kind, for the KIND_MISMATCH message (never "this command"). */
|
|
37
|
+
const COMMAND_NAME_BY_KIND = { council: 'council run', fanout: 'fanout', solo: 'start' };
|
|
38
|
+
/** MCP tool name per pack kind, for the Finding-2 (Task 15 review) orphan-knob notice below. */
|
|
39
|
+
const MCP_TOOL_NAME_BY_KIND = { council: 'amicus_council_run', fanout: 'amicus_fanout', solo: 'amicus_start' };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* council/fanout only: `bench` fills `args.council` (by-name) or `args.models`
|
|
43
|
+
* (csv), unless the caller already typed one of the two — then it is never
|
|
44
|
+
* silently dropped, it becomes a notice instead.
|
|
45
|
+
* @returns {string|null} a notice string, or null when the bench was applied cleanly.
|
|
46
|
+
*/
|
|
47
|
+
function resolveBenchKnob(pack, args, explicit) {
|
|
48
|
+
const bench = pack.bench;
|
|
49
|
+
if (bench === undefined || bench === null) { return null; }
|
|
50
|
+
const modelsExplicit = explicit.has('models');
|
|
51
|
+
const councilExplicit = explicit.has('council');
|
|
52
|
+
if (!modelsExplicit && !councilExplicit) {
|
|
53
|
+
if (typeof bench === 'string') { args.council = bench; }
|
|
54
|
+
else if (Array.isArray(bench)) { args.models = bench.join(','); }
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const flag = modelsExplicit ? '--models' : '--council';
|
|
58
|
+
return `Notice: ${flag} overrides the bench from pack '${pack.name}'`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{packRef: string, expectedKind: 'council'|'fanout'|'solo', args: object,
|
|
63
|
+
* explicit: Set<string>, useJson?: boolean}} opts `useJson` is accepted for
|
|
64
|
+
* call-site symmetry with sibling resolvers (e.g. applyTemplate) but unused
|
|
65
|
+
* here: this module never writes to stdout/stderr itself — notices are
|
|
66
|
+
* returned as plain strings and the caller alone decides how to print them.
|
|
67
|
+
* @returns {{packRecord: {name, version, hash, source}, notices: string[]}
|
|
68
|
+
* | {error: {code, message, hint}}}
|
|
69
|
+
*/
|
|
70
|
+
function applyPackToArgs({ packRef, expectedKind, args, explicit }) {
|
|
71
|
+
const rp = readPack(packRef);
|
|
72
|
+
if (rp.error) {
|
|
73
|
+
return { error: { code: ERROR_CODES.PACK_NOT_FOUND, message: rp.error, hint: 'amicus pack list' } };
|
|
74
|
+
}
|
|
75
|
+
const { pack, source, hash } = rp;
|
|
76
|
+
|
|
77
|
+
if (pack.kind !== expectedKind) {
|
|
78
|
+
return {
|
|
79
|
+
error: {
|
|
80
|
+
code: ERROR_CODES.PACK_KIND_MISMATCH,
|
|
81
|
+
message: `Error: pack '${pack.name}' is kind '${pack.kind}' — ${COMMAND_NAME_BY_KIND[expectedKind] || 'this command'} accepts kind '${expectedKind}'; make two packs if you want both shapes`,
|
|
82
|
+
hint: null,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const validation = validatePack(pack, { mode: 'run' });
|
|
88
|
+
if (!validation.ok) {
|
|
89
|
+
return {
|
|
90
|
+
error: {
|
|
91
|
+
code: ERROR_CODES.PACK_INVALID,
|
|
92
|
+
message: `Error: pack '${pack.name}' failed validation: ${validation.errors.join('; ')}`,
|
|
93
|
+
hint: null,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const opts = pack.options || {};
|
|
99
|
+
const notices = [];
|
|
100
|
+
const fill = (argKey, value) => {
|
|
101
|
+
if (!explicit.has(argKey) && value !== undefined && value !== null) {
|
|
102
|
+
args[argKey] = value;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (pack.kind === 'council') {
|
|
107
|
+
fill('chair', pack.chair);
|
|
108
|
+
}
|
|
109
|
+
if (pack.kind === 'solo') {
|
|
110
|
+
fill('model', pack.model);
|
|
111
|
+
fill('no-ui', opts.noUi);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [optKey, argKey] of COMMON_OPTION_KNOBS) { fill(argKey, opts[optKey]); }
|
|
115
|
+
|
|
116
|
+
// v4.5 final-review F4: cli-handlers-run.js normalizes a legacy --mode flag
|
|
117
|
+
// into args.agent AFTER this merge runs (`args.agent = args.agent ||
|
|
118
|
+
// args.mode`, handleStart/handleFanout only — council has no --mode flag).
|
|
119
|
+
// A typed --mode with no --agent lands 'mode' in `explicit` but not 'agent',
|
|
120
|
+
// so the generic fill() above would silently overwrite a pack's
|
|
121
|
+
// options.agent onto args.agent before that fallback ever sees args.mode —
|
|
122
|
+
// the user's typed value loses to the pack, invisibly. Count --mode as
|
|
123
|
+
// agent-explicit too (same shape as the debate/no-debate negation check
|
|
124
|
+
// below, which also treats an alternate typed form as full explicitness).
|
|
125
|
+
const agentExplicit = explicit.has('agent') || explicit.has('mode');
|
|
126
|
+
if (!agentExplicit && opts.agent !== undefined && opts.agent !== null) { args.agent = opts.agent; }
|
|
127
|
+
|
|
128
|
+
if (pack.kind === 'council') {
|
|
129
|
+
// Task 10 ruling: negations record only the literal typed key, so a
|
|
130
|
+
// negatable boolean's "was this explicit" check must test both forms.
|
|
131
|
+
const debateExplicit = explicit.has('debate') || explicit.has('no-debate');
|
|
132
|
+
if (!debateExplicit && opts.debate !== undefined && opts.debate !== null) {
|
|
133
|
+
args.debate = opts.debate;
|
|
134
|
+
}
|
|
135
|
+
// 2026-07-28 ruling: skip filling pack critic when --lenses is explicit —
|
|
136
|
+
// it would trip the handler's critic/lenses mutual-exclusion pre-flight.
|
|
137
|
+
if (!explicit.has('lenses')) { fill('critic', pack.critic); }
|
|
138
|
+
// 2026-07-28 ruling: skip filling pack lenses when --critic is explicit —
|
|
139
|
+
// same mutual-exclusion pre-flight, mirrored direction.
|
|
140
|
+
if (!explicit.has('critic')) {
|
|
141
|
+
fill('lenses', Array.isArray(pack.lenses) ? pack.lenses.join(',') : pack.lenses);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (pack.kind === 'fanout' || pack.kind === 'solo') {
|
|
146
|
+
for (const [optKey, argKey] of CONTEXT_OPTION_KNOBS) { fill(argKey, opts[optKey]); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (pack.kind === 'council' || pack.kind === 'fanout') {
|
|
150
|
+
const notice = resolveBenchKnob(pack, args, explicit);
|
|
151
|
+
if (notice) { notices.push(notice); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fill('template', pack.briefing && pack.briefing.template);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
packRecord: { name: pack.name, version: pack.version, hash, source },
|
|
158
|
+
notices,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** argKeys whose CLI-side value is a comma-joined string but whose MCP-side
|
|
163
|
+
* value is an array (`models`, `lenses`) — the two shapes multi-value knobs
|
|
164
|
+
* have always used on their respective sides, round-tripped by the bridge
|
|
165
|
+
* below so `applyPackToArgs`'s tables never need to know about MCP shapes. */
|
|
166
|
+
const CSV_ARG_KEYS = new Set(['models', 'lenses']);
|
|
167
|
+
|
|
168
|
+
/** Reverse of the knob tables above (argKey -> pack's own camelCase option
|
|
169
|
+
* key), used ONLY in notice text (v4.5 decision 1b, T15-m10) — naming a
|
|
170
|
+
* pack's own key is less confusing than the CLI's kebab-case arg-key. Most
|
|
171
|
+
* bespoke (non-table) knobs — chair/critic/lenses/agent/debate — share their
|
|
172
|
+
* argKey/option-key spelling, so the plain-argKey fallback is correct for
|
|
173
|
+
* them; `no-ui` does NOT (pack-side `noUi`) and is listed explicitly. `bench`
|
|
174
|
+
* fills EITHER args.council or args.models (never an argKey of its own) —
|
|
175
|
+
* accepted because both always have a real MCP destination, so a
|
|
176
|
+
* bench-derived value never actually reaches the notice path. */
|
|
177
|
+
const ARG_KEY_TO_OPT_KEY = Object.assign(
|
|
178
|
+
Object.fromEntries([...COMMON_OPTION_KNOBS, ...CONTEXT_OPTION_KNOBS].map(([optKey, argKey]) => [argKey, optKey])),
|
|
179
|
+
{ 'no-ui': 'noUi' },
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
/** argKeys forwarded instead of notice'd when a tool's paramMap has no
|
|
183
|
+
* destination for them (v4.5 decision 1): a pack's maxCost/template must
|
|
184
|
+
* still apply on `amicus_fanout`/`amicus_start` for CLI parity, even with no
|
|
185
|
+
* schema param for either. `amicus_council_run` already has real destinations
|
|
186
|
+
* for both (COUNCIL_PACK_PARAM_MAP), so it never reaches this path. Context
|
|
187
|
+
* knobs are deliberately excluded — ruled notice-only, out of scope. */
|
|
188
|
+
const FORWARDABLE_ARG_KEYS = new Set(['max-cost', 'template']);
|
|
189
|
+
|
|
190
|
+
function toArgValue(argKey, value) {
|
|
191
|
+
return (CSV_ARG_KEYS.has(argKey) && Array.isArray(value)) ? value.join(',') : value;
|
|
192
|
+
}
|
|
193
|
+
function fromArgValue(argKey, value) {
|
|
194
|
+
return (CSV_ARG_KEYS.has(argKey) && typeof value === 'string')
|
|
195
|
+
? value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
196
|
+
: value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* MCP entry point (Task 15, B7/F5): the same merge as `applyPackToArgs`, for a
|
|
201
|
+
* caller whose knobs live on an `input` object with MCP-shaped key names
|
|
202
|
+
* (camelCase, some renamed/inverted vs. the CLI's kebab-case arg keys) rather
|
|
203
|
+
* than parsed CLI `args`. Builds an args-shaped bridge object via `paramMap`,
|
|
204
|
+
* reuses `applyPackToArgs` UNCHANGED (same knob tables, same precedence, same
|
|
205
|
+
* notices), then copies pack-filled values back onto `input` under their
|
|
206
|
+
* MCP-facing names. An mcpKey already present on `input` is never touched
|
|
207
|
+
* (explicit wins) — `explicit` is built from the caller's OWN `Object.keys(input)`,
|
|
208
|
+
* mapped through `paramMap`, mirroring `args.__explicit` on the CLI side.
|
|
209
|
+
*
|
|
210
|
+
* Deliberately contains NO pack-domain knowledge (no knob names, no bench/chair/
|
|
211
|
+
* timeout logic) beyond the key-renaming in `paramMap` — that stays entirely in
|
|
212
|
+
* `applyPackToArgs`'s tables above. Re-deriving "is this pack-filled" outside
|
|
213
|
+
* pack-resolve is exactly the mistake a prior review caught (progress.md,
|
|
214
|
+
* Task-12 entry: "packSuffix is council-local; if fanout/solo need attribution,
|
|
215
|
+
* centralize 'pack-filled' in pack-resolve rather than re-deriving").
|
|
216
|
+
*
|
|
217
|
+
* Fix wave 2 (Task 15 review, Finding 2): a pack knob `applyPackToArgs` fills
|
|
218
|
+
* but `paramMap` has no MCP destination for (e.g. fanout's `contextTurns`/
|
|
219
|
+
* `contextMaxTokens`) would otherwise be a silent dead-fill — written into the
|
|
220
|
+
* local `args` bridge, then dropped on the floor because the write-back loop
|
|
221
|
+
* below only ever visits `paramMap` entries. Made loud instead: every `args`
|
|
222
|
+
* key with no `paramMap` destination becomes one notice, naming the pack, the
|
|
223
|
+
* knob (by the pack's own camelCase option key — v4.5 HOLD-gate decision 1b,
|
|
224
|
+
* T15-m10), and the MCP tool.
|
|
225
|
+
*
|
|
226
|
+
* v4.5 decision 1: two otherwise-orphaned knobs — maxCost/template — are NOT
|
|
227
|
+
* turned into a notice on `amicus_fanout`/`amicus_start` (no schema param for
|
|
228
|
+
* either); CLI parity requires them to still apply, so their pack-filled
|
|
229
|
+
* values come back on `forward` instead, for the caller (mcp-server.js) to
|
|
230
|
+
* apply itself: forwarded to a spawned child's argv as plain flags, or (the
|
|
231
|
+
* in-process shared-server path) via the same budget-gate/template-render
|
|
232
|
+
* code the CLI uses. `amicus_council_run` already has real destinations for
|
|
233
|
+
* both, so `forward` is always `{}` there.
|
|
234
|
+
* @param {{packRef: string, expectedKind: 'council'|'fanout'|'solo',
|
|
235
|
+
* input: object, paramMap: Object<string, string|{argKey: string, invert: true}>}} opts
|
|
236
|
+
* `paramMap` maps each MCP input key a tool exposes to the CLI arg-key name
|
|
237
|
+
* the same knob uses in the tables above (e.g. council's `timeoutMinutes` →
|
|
238
|
+
* `'timeout'`, plain string = same-polarity rename/identity). An
|
|
239
|
+
* `{argKey, invert: true}` entry flips a boolean both ways (fanout/solo's
|
|
240
|
+
* `includeContext` vs. the CLI/pack-side `no-context` polarity). An MCP key
|
|
241
|
+
* with no pack-fillable knob (e.g. `briefingFile`, `project`) is simply
|
|
242
|
+
* omitted from `paramMap` and left untouched by this function.
|
|
243
|
+
* @returns {{packRecord: {name, version, hash, source}, notices: string[],
|
|
244
|
+
* forward: {maxCost?: number, template?: string}} | {error: {code, message, hint}}}
|
|
245
|
+
*/
|
|
246
|
+
function applyPackToMcpInput({ packRef, expectedKind, input, paramMap }) {
|
|
247
|
+
const args = {};
|
|
248
|
+
const explicit = new Set();
|
|
249
|
+
for (const mcpKey of Object.keys(input)) {
|
|
250
|
+
const entry = paramMap[mcpKey];
|
|
251
|
+
if (!entry) { continue; }
|
|
252
|
+
const argKey = typeof entry === 'string' ? entry : entry.argKey;
|
|
253
|
+
const invert = typeof entry === 'object' && !!entry.invert;
|
|
254
|
+
args[argKey] = invert ? !input[mcpKey] : toArgValue(argKey, input[mcpKey]);
|
|
255
|
+
explicit.add(argKey);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const pr = applyPackToArgs({ packRef, expectedKind, args, explicit });
|
|
259
|
+
if (pr.error) { return { error: pr.error }; }
|
|
260
|
+
|
|
261
|
+
// Finding 2: diff every key applyPackToArgs touched (explicit bridge seeds +
|
|
262
|
+
// pack fills) against paramMap's own destination argKeys. The bridging loop
|
|
263
|
+
// above only ever seeds `args` from a paramMap destination, so anything left
|
|
264
|
+
// over here was added by a pack fill, never a caller value — always a
|
|
265
|
+
// genuine dead-fill, never a false positive on an explicit param.
|
|
266
|
+
const destArgKeys = new Set(
|
|
267
|
+
Object.values(paramMap).map((entry) => (typeof entry === 'string' ? entry : entry.argKey))
|
|
268
|
+
);
|
|
269
|
+
const notices = [...pr.notices];
|
|
270
|
+
const toolName = MCP_TOOL_NAME_BY_KIND[expectedKind] || `amicus (${expectedKind})`;
|
|
271
|
+
// v4.5 HOLD-gate decision 1: maxCost/template have no MCP schema destination
|
|
272
|
+
// on fanout/start, but must still apply (CLI parity) — collected here for the
|
|
273
|
+
// caller to apply itself, instead of turned into an ignore-notice. Council
|
|
274
|
+
// already has real destinations for both (destArgKeys.has(argKey) is true
|
|
275
|
+
// there), so this loop never adds to `forward` for council calls.
|
|
276
|
+
const forward = {};
|
|
277
|
+
for (const argKey of Object.keys(args)) {
|
|
278
|
+
if (destArgKeys.has(argKey)) { continue; }
|
|
279
|
+
if (FORWARDABLE_ARG_KEYS.has(argKey)) {
|
|
280
|
+
forward[ARG_KEY_TO_OPT_KEY[argKey] || argKey] = args[argKey];
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const optKey = ARG_KEY_TO_OPT_KEY[argKey] || argKey;
|
|
284
|
+
notices.push(`Notice: pack '${pr.packRecord.name}' sets ${optKey}, which ${toolName} does not support over MCP — ignored.`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [mcpKey, entry] of Object.entries(paramMap)) {
|
|
288
|
+
const argKey = typeof entry === 'string' ? entry : entry.argKey;
|
|
289
|
+
if (explicit.has(argKey) || !(argKey in args)) { continue; }
|
|
290
|
+
const invert = typeof entry === 'object' && !!entry.invert;
|
|
291
|
+
input[mcpKey] = invert ? !args[argKey] : fromArgValue(argKey, args[argKey]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { packRecord: pr.packRecord, notices, forward };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { applyPackToArgs, applyPackToMcpInput };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/pack/pack-store.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module pack/pack-store
|
|
6
|
+
* B7/F5 (v4.5): packs are ONE JSON file per pack in <configDir>/packs/ (spec
|
|
7
|
+
* carried decision 2 — shareability wins: send or commit the file). The
|
|
8
|
+
* CONTENT HASH is the truth anchor: sha256 over the canonical form (recursively
|
|
9
|
+
* sorted keys), first 12 hex chars, recorded on every run — a hand-edited pack
|
|
10
|
+
* whose version wasn't bumped still gets a distinct recorded hash.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
17
|
+
|
|
18
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
19
|
+
|
|
20
|
+
function _getConfigDir() { return require('../utils/config').getConfigDir(); }
|
|
21
|
+
|
|
22
|
+
/** @returns {string} the packs directory (peer of templates/) */
|
|
23
|
+
function packsDir() { return path.join(_getConfigDir(), 'packs'); }
|
|
24
|
+
|
|
25
|
+
function sortKeysDeep(v) {
|
|
26
|
+
if (Array.isArray(v)) { return v.map(sortKeysDeep); }
|
|
27
|
+
if (v && typeof v === 'object') {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const k of Object.keys(v).sort()) { out[k] = sortKeysDeep(v[k]); }
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** sha256 of the canonical (sorted-keys) JSON form, first 12 hex chars. */
|
|
36
|
+
function canonicalHash(pack) {
|
|
37
|
+
return crypto.createHash('sha256')
|
|
38
|
+
.update(JSON.stringify(sortKeysDeep(pack)), 'utf-8').digest('hex').slice(0, 12);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {{kind:'path',path}|{kind:'name',name}|{error}} */
|
|
42
|
+
function resolvePackRef(ref) {
|
|
43
|
+
const v = String(ref);
|
|
44
|
+
if (v.endsWith('.json') || v.includes('/') || v.includes(path.sep)) {
|
|
45
|
+
return { kind: 'path', path: path.resolve(v) };
|
|
46
|
+
}
|
|
47
|
+
if (!NAME_RE.test(v)) {
|
|
48
|
+
return { error: `Error: invalid pack name '${v}' — pack names are 1-64 chars of [a-z0-9._-] starting alphanumeric` };
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'name', name: v };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripBom(text) { return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; }
|
|
54
|
+
|
|
55
|
+
/** @returns {{pack, path, source:'dir'|'path', hash}|{error}} */
|
|
56
|
+
function readPack(ref) {
|
|
57
|
+
const r = resolvePackRef(ref);
|
|
58
|
+
if (r.error) { return r; }
|
|
59
|
+
const file = r.kind === 'path' ? r.path : path.join(packsDir(), `${r.name}.json`);
|
|
60
|
+
let raw;
|
|
61
|
+
try { raw = stripBom(fs.readFileSync(file, 'utf-8')); }
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (r.kind === 'name') { return { error: `Error: Pack '${r.name}' not found in ${packsDir()}` }; }
|
|
64
|
+
return { error: `Error: cannot read pack ${ref}: ${err.message}` };
|
|
65
|
+
}
|
|
66
|
+
let pack;
|
|
67
|
+
try { pack = JSON.parse(raw); }
|
|
68
|
+
catch (err) { return { error: `Error: pack ${file} is not valid JSON: ${err.message}` }; }
|
|
69
|
+
if (r.kind === 'name' && pack && pack.name !== r.name) {
|
|
70
|
+
return { error: `Error: pack file ${file} declares name '${pack && pack.name}' which does not match its filename — rename one of them` };
|
|
71
|
+
}
|
|
72
|
+
return { pack, path: file, source: r.kind === 'name' ? 'dir' : 'path', hash: canonicalHash(pack) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bumpPatch(version) {
|
|
76
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(String(version));
|
|
77
|
+
if (!m) { return '0.0.1'; }
|
|
78
|
+
return `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write <packsDir>/<pack.name>.json. Existing name: unchanged canonical hash →
|
|
83
|
+
* {noop:true}; changed with the same version string → auto-bump patch (spec
|
|
84
|
+
* carried decision 6). Caller validates the pack first (pack-validate).
|
|
85
|
+
* @returns {{path, hash, overwritten, bumped}|{noop:true, path}}
|
|
86
|
+
*/
|
|
87
|
+
function writePack(pack) {
|
|
88
|
+
fs.mkdirSync(packsDir(), { recursive: true, mode: 0o700 });
|
|
89
|
+
const file = path.join(packsDir(), `${pack.name}.json`);
|
|
90
|
+
let existing = null;
|
|
91
|
+
try { existing = JSON.parse(stripBom(fs.readFileSync(file, 'utf-8'))); } catch { /* new pack */ }
|
|
92
|
+
const toWrite = { ...pack };
|
|
93
|
+
let bumped = false;
|
|
94
|
+
if (existing) {
|
|
95
|
+
if (canonicalHash(existing) === canonicalHash(toWrite)) { return { noop: true, path: file }; }
|
|
96
|
+
if (existing.version === toWrite.version) {
|
|
97
|
+
toWrite.version = bumpPatch(toWrite.version);
|
|
98
|
+
bumped = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
writeFileAtomic(file, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
|
|
102
|
+
return { path: file, hash: canonicalHash(toWrite), overwritten: !!existing, bumped, version: toWrite.version };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns {{packs: Array<{name,kind,version,description}>, warnings: string[]}} name-sorted */
|
|
106
|
+
function listPacks() {
|
|
107
|
+
let entries = [];
|
|
108
|
+
try { entries = fs.readdirSync(packsDir()); } catch { /* no dir yet */ }
|
|
109
|
+
const packs = [];
|
|
110
|
+
const warnings = [];
|
|
111
|
+
for (const f of entries) {
|
|
112
|
+
if (!f.endsWith('.json')) { continue; }
|
|
113
|
+
try {
|
|
114
|
+
const p = JSON.parse(stripBom(fs.readFileSync(path.join(packsDir(), f), 'utf-8')));
|
|
115
|
+
packs.push({ name: p.name, kind: p.kind, version: p.version, description: p.description || '' });
|
|
116
|
+
} catch (err) { warnings.push(`${f}: ${err.message}`); }
|
|
117
|
+
}
|
|
118
|
+
packs.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
119
|
+
return { packs, warnings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @returns {{removed: boolean}} */
|
|
123
|
+
function rmPack(name) {
|
|
124
|
+
const r = resolvePackRef(name);
|
|
125
|
+
if (r.error || r.kind !== 'name') { return { removed: false }; }
|
|
126
|
+
try { fs.unlinkSync(path.join(packsDir(), `${r.name}.json`)); return { removed: true }; }
|
|
127
|
+
catch { return { removed: false }; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { packsDir, canonicalHash, resolvePackRef, readPack, writePack, listPacks, rmPack };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// src/pack/pack-validate.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module pack/pack-validate
|
|
6
|
+
* Spec §5.6. Runs at `pack save` (hard-fail), `pack show` (reports, never
|
|
7
|
+
* fails), and run-time resolve (hard-fail through the envelope, pre-spend).
|
|
8
|
+
* Kind matching itself (PACK_KIND_MISMATCH) is the caller's check — this
|
|
9
|
+
* module validates a pack's internal consistency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
13
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+/;
|
|
14
|
+
const KINDS = ['council', 'fanout', 'solo'];
|
|
15
|
+
|
|
16
|
+
/** Per-kind allowed `options` keys (spec §5.1; solo UI-suppression key per Task 0's verified flag set).
|
|
17
|
+
* v4.5 HOLD-gate decision 2 (final-review F1): `agent`/`thinking`/`summaryLength`
|
|
18
|
+
* are inert on EVERY council surface — handleCouncilRun never reads a pack-filled
|
|
19
|
+
* one, and the engine hardcodes agent 'Plan'/summaryLength 'verbose' regardless.
|
|
20
|
+
* Dropped from `council` pre-release rather than shipped as dead weight a pack
|
|
21
|
+
* author would reasonably expect to work; a council pack that still sets one now
|
|
22
|
+
* fails save/run validation (PACK_INVALID) like any other unknown option for the
|
|
23
|
+
* kind. They remain valid (and functional) on `fanout`/`solo`. */
|
|
24
|
+
const KIND_OPTIONS = Object.freeze({
|
|
25
|
+
council: ['timeout', 'maxCost', 'gateway', 'debate'],
|
|
26
|
+
fanout: ['timeout', 'maxCost', 'gateway', 'agent', 'thinking', 'summaryLength',
|
|
27
|
+
'noContext', 'contextTurns', 'contextMaxTokens'],
|
|
28
|
+
solo: ['timeout', 'maxCost', 'gateway', 'agent', 'thinking', 'summaryLength',
|
|
29
|
+
'noUi', 'noContext', 'contextTurns', 'contextMaxTokens'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Fields allowed per kind beyond the common set. */
|
|
33
|
+
const COMMON_FIELDS = ['schemaVersion', 'type', 'name', 'version', 'kind', 'description', 'options', 'briefing'];
|
|
34
|
+
const KIND_FIELDS = Object.freeze({
|
|
35
|
+
council: [...COMMON_FIELDS, 'bench', 'chair', 'critic', 'lenses'],
|
|
36
|
+
fanout: [...COMMON_FIELDS, 'bench'],
|
|
37
|
+
solo: [...COMMON_FIELDS, 'model'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} pack
|
|
42
|
+
* @param {{mode: 'save'|'run'}} opts
|
|
43
|
+
* @returns {{ok:true, warnings:string[]} | {ok:false, errors:string[]}}
|
|
44
|
+
*/
|
|
45
|
+
function validatePack(pack, { mode } = { mode: 'run' }) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const warnings = [];
|
|
48
|
+
if (!pack || typeof pack !== 'object') { return { ok: false, errors: ['pack is not an object'] }; }
|
|
49
|
+
if (pack.schemaVersion !== 1) { errors.push(`schemaVersion must be 1 (got ${pack.schemaVersion})`); }
|
|
50
|
+
if (pack.type !== 'pack') { errors.push(`type must be 'pack' (got '${pack.type}')`); }
|
|
51
|
+
if (typeof pack.name !== 'string' || !NAME_RE.test(pack.name)) { errors.push(`invalid pack name '${pack.name}'`); }
|
|
52
|
+
if (typeof pack.version !== 'string' || !SEMVER_RE.test(pack.version)) { errors.push(`version must be semver-shaped (got '${pack.version}')`); }
|
|
53
|
+
if (!KINDS.includes(pack.kind)) {
|
|
54
|
+
errors.push(`kind must be one of ${KINDS.join('|')} (got '${pack.kind}')`);
|
|
55
|
+
return { ok: false, errors };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const key of Object.keys(pack)) {
|
|
59
|
+
if (!KIND_FIELDS[pack.kind].includes(key)) { errors.push(`field '${key}' is not valid for kind '${pack.kind}'`); }
|
|
60
|
+
}
|
|
61
|
+
const opts = pack.options || {};
|
|
62
|
+
if (typeof opts !== 'object' || Array.isArray(opts)) { errors.push('options must be an object'); }
|
|
63
|
+
else {
|
|
64
|
+
for (const key of Object.keys(opts)) {
|
|
65
|
+
if (!KIND_OPTIONS[pack.kind].includes(key)) { errors.push(`unknown option '${key}' for kind '${pack.kind}'`); }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { getEffectiveAliases, getCouncilWithSource } = require('../utils/config');
|
|
70
|
+
const aliases = getEffectiveAliases();
|
|
71
|
+
const seatOk = (m) => typeof m === 'string' && (m.includes('/') || !!aliases[m]);
|
|
72
|
+
|
|
73
|
+
if (pack.kind === 'solo') {
|
|
74
|
+
if (typeof pack.model !== 'string' || !pack.model.trim()) { errors.push('solo pack requires model'); }
|
|
75
|
+
else if (!seatOk(pack.model)) { errors.push(`unresolvable model '${pack.model}'`); }
|
|
76
|
+
} else {
|
|
77
|
+
const bench = pack.bench;
|
|
78
|
+
if (typeof bench === 'string') {
|
|
79
|
+
const { members } = getCouncilWithSource(bench, []);
|
|
80
|
+
if (!members) { errors.push(`bench names unknown council '${bench}'`); }
|
|
81
|
+
else if (pack.kind === 'council' && (pack.lenses || pack.chair || pack.critic)) {
|
|
82
|
+
warnings.push('bench is by-name: member-level checks (chair/critic/lenses vs seats) deferred to run time');
|
|
83
|
+
}
|
|
84
|
+
} else if (Array.isArray(bench) && bench.length >= 2) {
|
|
85
|
+
const bad = bench.filter((m) => !seatOk(m));
|
|
86
|
+
if (bad.length) { errors.push(`unresolvable bench member(s): ${bad.join(', ')}`); }
|
|
87
|
+
if (pack.kind === 'council') {
|
|
88
|
+
if (pack.chair && bench.includes(pack.chair)) { errors.push(`chair '${pack.chair}' is a bench seat — the chair must not review`); }
|
|
89
|
+
if (pack.critic && !bench.includes(pack.critic)) { errors.push(`critic '${pack.critic}' must be one of the bench seats`); }
|
|
90
|
+
if (pack.critic && pack.lenses) { errors.push('critic and lenses are mutually exclusive'); }
|
|
91
|
+
if (Array.isArray(pack.lenses) && pack.lenses.length !== bench.length) {
|
|
92
|
+
errors.push(`lenses needs exactly one lens per seat (${bench.length} seats, got ${pack.lenses.length})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
errors.push('bench must be a council name or an array of 2+ members');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tplRef = pack.briefing && pack.briefing.template;
|
|
101
|
+
if (tplRef !== undefined && tplRef !== null) {
|
|
102
|
+
const { resolveTemplate } = require('../template/store');
|
|
103
|
+
const t = resolveTemplate(tplRef);
|
|
104
|
+
if (t.error) {
|
|
105
|
+
if (mode === 'save') { warnings.push(`briefing.template '${tplRef}' does not resolve on this machine (packs travel; it may exist where the pack is used)`); }
|
|
106
|
+
else { errors.push(`briefing.template '${tplRef}' does not resolve`); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return errors.length ? { ok: false, errors } : { ok: true, warnings };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { validatePack, KIND_OPTIONS, KINDS };
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
const { getChildren, getMessages } = require('../opencode-client');
|
|
44
44
|
const { createMirrorState, mirrorUsageOnly } = require('./conversation-mirror');
|
|
45
|
-
const { sumPerMessageUsage
|
|
45
|
+
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Bounds. Hard constants rather than env knobs: they exist to stop a pathological
|
|
@@ -88,7 +88,6 @@ function usageOfSession(messages) {
|
|
|
88
88
|
// what they are. A session with neither tokens nor cost is not free either:
|
|
89
89
|
// we saw nothing, and nothing is not zero.
|
|
90
90
|
priced: totals.costReported > 0,
|
|
91
|
-
observed: hasObservedTokens(totals.tokens),
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
93
|
|