amicus 3.2.3 → 4.0.1
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 +106 -0
- package/README.md +15 -3
- package/electron/main.js +4 -1
- package/package.json +3 -1
- package/schemas/abort.schema.json +17 -0
- package/schemas/alias-audit.schema.json +17 -0
- package/schemas/council-run.schema.json +38 -0
- package/schemas/council-stats.schema.json +28 -0
- package/schemas/council-tally.schema.json +70 -0
- package/schemas/council-validate.schema.json +22 -0
- package/schemas/council-verdict.schema.json +47 -0
- package/schemas/doctor.schema.json +29 -0
- package/schemas/error.schema.json +23 -0
- package/schemas/model-catalog.schema.json +19 -0
- package/schemas/run.schema.json +26 -0
- package/schemas/spend.schema.json +16 -0
- package/schemas/wave.schema.json +33 -0
- package/skills/second-opinion/SEAT-BRIEFS.md +5 -3
- package/skills/second-opinion/SKILL.md +8 -0
- package/src/cli-handlers-abort.js +29 -0
- package/src/cli-handlers-council-run.js +168 -0
- package/src/cli-handlers-council.js +8 -5
- package/src/cli-handlers-status.js +35 -4
- package/src/cli.js +9 -0
- package/src/council/anonymize.js +76 -0
- package/src/council/briefings-stage2.js +150 -0
- package/src/council/briefings.js +141 -0
- package/src/council/findings.js +13 -1
- package/src/council/ledger.js +13 -1
- package/src/council/parse-stage2.js +103 -0
- package/src/council/run-assemble.js +100 -0
- package/src/council/run-launch.js +99 -0
- package/src/council/run-stages.js +203 -0
- package/src/council/run-state.js +161 -0
- package/src/council/run.js +277 -0
- package/src/council/tally.js +3 -1
- package/src/council/verdict.js +9 -2
- package/src/headless.js +24 -25
- package/src/mcp-council-awareness.js +187 -0
- package/src/mcp-council-run.js +161 -0
- package/src/mcp-server.js +87 -28
- package/src/mcp-tools.js +50 -0
- package/src/prompt-builder.js +36 -19
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/resume.js +7 -2
- package/src/utils/abort-result.js +1 -1
- package/src/utils/error-doc.js +2 -0
- package/src/utils/fold-marker.js +21 -0
- package/src/utils/route-error.js +26 -0
- package/src/utils/start-helpers.js +19 -10
- package/src/utils/untrusted-fence.js +8 -7
package/src/mcp-server.js
CHANGED
|
@@ -198,6 +198,18 @@ function appendVersionWarning(content) {
|
|
|
198
198
|
return content;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* v4.0 §7: stamp the additive {schemaVersion, type} envelope keys onto an MCP
|
|
203
|
+
* JSON success body. `type` reflects the doc's subject family — 'run' for
|
|
204
|
+
* session status/acks, 'wave' for wave status + fanout acks, 'abort' for abort
|
|
205
|
+
* acks. Published result-doc schemas (schemas/) describe the durable --json
|
|
206
|
+
* docs; these snapshots/acks share the family type names (docs/schemas.md).
|
|
207
|
+
*/
|
|
208
|
+
function stampEnvelope(type, body) {
|
|
209
|
+
const { SCHEMA_VERSION } = require('./utils/result-schema-version');
|
|
210
|
+
return { schemaVersion: SCHEMA_VERSION, type, ...body };
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
/**
|
|
202
214
|
* Compute next poll hint for headless sessions.
|
|
203
215
|
* @returns {{ hint: string }}
|
|
@@ -244,9 +256,17 @@ const handlers = {
|
|
|
244
256
|
const { validateStartInputs } = require('./utils/input-validators');
|
|
245
257
|
const validation = validateStartInputs(input);
|
|
246
258
|
if (!validation.valid) {
|
|
259
|
+
// v4.0 §7: error-doc-shaped tool text (was the raw validation_error object).
|
|
260
|
+
const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
|
|
261
|
+
const verr = validation.error;
|
|
247
262
|
return {
|
|
248
263
|
isError: true,
|
|
249
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
264
|
+
content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({
|
|
265
|
+
code: verr.field === 'prompt' ? ERROR_CODES.MISSING_PROMPT : ERROR_CODES.BAD_ARGS,
|
|
266
|
+
message: `${verr.field}: ${verr.message}`,
|
|
267
|
+
hint: Array.isArray(verr.suggestions) && verr.suggestions.length
|
|
268
|
+
? `Valid values: ${verr.suggestions.join(', ')}` : null,
|
|
269
|
+
})) }],
|
|
250
270
|
};
|
|
251
271
|
}
|
|
252
272
|
|
|
@@ -263,7 +283,7 @@ const handlers = {
|
|
|
263
283
|
// resolveLaunchModel (start-helpers.js) via model-input-default.js.
|
|
264
284
|
const { resolveGatewayMode } = require('./utils/config');
|
|
265
285
|
const { resolveRouteForLaunch } = require('./utils/route-launch');
|
|
266
|
-
const {
|
|
286
|
+
const { toErrorDocFields } = require('./utils/route-error');
|
|
267
287
|
const { resolveModelInputOrDefault } = require('./utils/model-input-default');
|
|
268
288
|
|
|
269
289
|
const modelInput = resolveModelInputOrDefault(input.model);
|
|
@@ -277,9 +297,10 @@ const handlers = {
|
|
|
277
297
|
validateModel: true,
|
|
278
298
|
});
|
|
279
299
|
if (routeResult.kind !== 'resolved') {
|
|
300
|
+
const { buildErrorDoc } = require('./utils/error-doc');
|
|
280
301
|
return {
|
|
281
302
|
isError: true,
|
|
282
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
303
|
+
content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(toErrorDocFields(routeResult))) }],
|
|
283
304
|
};
|
|
284
305
|
}
|
|
285
306
|
const resolvedModel = routeResult.executableId;
|
|
@@ -472,10 +493,10 @@ const handlers = {
|
|
|
472
493
|
});
|
|
473
494
|
|
|
474
495
|
// Return immediately
|
|
475
|
-
const body = JSON.stringify({
|
|
496
|
+
const body = JSON.stringify(stampEnvelope('run', {
|
|
476
497
|
taskId, status: 'running', mode: 'headless',
|
|
477
498
|
message: 'Amicus started in headless mode. Use amicus_status to check progress.',
|
|
478
|
-
});
|
|
499
|
+
}));
|
|
479
500
|
// FIX 2 (#61 whole-branch review): surface the router's one-shot
|
|
480
501
|
// migration notice here too — resolveRouteForLaunch already burned
|
|
481
502
|
// the migration_notified flag for this vendor when it built
|
|
@@ -532,7 +553,7 @@ const handlers = {
|
|
|
532
553
|
"Tell the user: 'Let me know when you're done with the session and have clicked Fold.' " +
|
|
533
554
|
'Then wait for the user to tell you. Use amicus_read to get results once they confirm.';
|
|
534
555
|
|
|
535
|
-
const body = JSON.stringify({ taskId, status: 'running', mode, message });
|
|
556
|
+
const body = JSON.stringify(stampEnvelope('run', { taskId, status: 'running', mode, message }));
|
|
536
557
|
// FIX 2 (#61 whole-branch review): the spawn path never touches stderr of
|
|
537
558
|
// the CLI child that will do the routing print — this handler already
|
|
538
559
|
// resolved the route in-process above, so surface its notice here.
|
|
@@ -550,6 +571,17 @@ const handlers = {
|
|
|
550
571
|
const sessionDir = safeSessionDir(cwd, input.taskId);
|
|
551
572
|
const metadata = readMetadata(input.taskId, cwd);
|
|
552
573
|
if (!metadata) {
|
|
574
|
+
// Council runs live behind sessions-dir pointer files, not session dirs
|
|
575
|
+
// (v4.0 spec §8). Resolve them before failing.
|
|
576
|
+
const council = require('./mcp-council-run').buildCouncilStatusPayload(cwd, input.taskId);
|
|
577
|
+
if (council) {
|
|
578
|
+
const content = [{ type: 'text', text: JSON.stringify(council) }];
|
|
579
|
+
appendVersionWarning(content);
|
|
580
|
+
if (council.status === 'running') {
|
|
581
|
+
content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
|
|
582
|
+
}
|
|
583
|
+
return { content };
|
|
584
|
+
}
|
|
553
585
|
return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
|
|
554
586
|
'If you ran it in a different project, pass the original "project".', true);
|
|
555
587
|
}
|
|
@@ -608,12 +640,12 @@ const handlers = {
|
|
|
608
640
|
}
|
|
609
641
|
|
|
610
642
|
const ms = elapsedMs(metadata);
|
|
611
|
-
const response = {
|
|
612
|
-
taskId: metadata.taskId,
|
|
643
|
+
const response = stampEnvelope('wave', {
|
|
644
|
+
taskId: metadata.taskId, status: metadata.status,
|
|
613
645
|
legsComplete: done, legsTotal: legs.length, legs,
|
|
614
646
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
615
647
|
version: RUNNING_VERSION,
|
|
616
|
-
};
|
|
648
|
+
});
|
|
617
649
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
618
650
|
response.reason = metadata.reason || 'Unknown error';
|
|
619
651
|
}
|
|
@@ -641,11 +673,11 @@ const handlers = {
|
|
|
641
673
|
}
|
|
642
674
|
|
|
643
675
|
const ms = elapsedMs(metadata);
|
|
644
|
-
const response = {
|
|
676
|
+
const response = stampEnvelope('run', {
|
|
645
677
|
taskId: metadata.taskId, status: metadata.status,
|
|
646
678
|
elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
|
|
647
679
|
version: RUNNING_VERSION,
|
|
648
|
-
};
|
|
680
|
+
});
|
|
649
681
|
if (metadata.model) { response.model = metadata.model; }
|
|
650
682
|
|
|
651
683
|
// F6: agent-visible mode (headless|interactive). metadata.mode is written at
|
|
@@ -817,7 +849,10 @@ const handlers = {
|
|
|
817
849
|
}
|
|
818
850
|
}
|
|
819
851
|
|
|
820
|
-
|
|
852
|
+
// v4.0 §8: council runs are pointer files in the same sessions root — merge
|
|
853
|
+
// them as first-class rows (type 'council-run') before sorting/filtering.
|
|
854
|
+
const councilRows = require('./mcp-council-run').listCouncilRuns(cwd);
|
|
855
|
+
let sessions = Array.from(byId.values()).concat(councilRows)
|
|
821
856
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
822
857
|
|
|
823
858
|
if (input.status && input.status !== 'all') {
|
|
@@ -837,10 +872,10 @@ const handlers = {
|
|
|
837
872
|
try { spawnSidecarProcess(args, sessionDir); } catch (err) {
|
|
838
873
|
return textResult(`Failed to resume: ${err.message}`, true);
|
|
839
874
|
}
|
|
840
|
-
return textResult(JSON.stringify({
|
|
875
|
+
return textResult(JSON.stringify(stampEnvelope('run', {
|
|
841
876
|
taskId: input.taskId, status: 'running',
|
|
842
877
|
message: 'Session resumed. Use amicus_status to check progress.',
|
|
843
|
-
}));
|
|
878
|
+
})));
|
|
844
879
|
},
|
|
845
880
|
|
|
846
881
|
async amicus_continue(input, project, mcpServer) {
|
|
@@ -857,7 +892,7 @@ const handlers = {
|
|
|
857
892
|
if (input.model) {
|
|
858
893
|
const { resolveGatewayMode } = require('./utils/config');
|
|
859
894
|
const { resolveRouteForLaunch } = require('./utils/route-launch');
|
|
860
|
-
const {
|
|
895
|
+
const { toErrorDocFields } = require('./utils/route-error');
|
|
861
896
|
const { resolveModelInputOrDefault } = require('./utils/model-input-default');
|
|
862
897
|
|
|
863
898
|
const modelInput = resolveModelInputOrDefault(input.model);
|
|
@@ -869,9 +904,10 @@ const handlers = {
|
|
|
869
904
|
validateModel: true,
|
|
870
905
|
});
|
|
871
906
|
if (routeResult.kind !== 'resolved') {
|
|
907
|
+
const { buildErrorDoc } = require('./utils/error-doc');
|
|
872
908
|
return {
|
|
873
909
|
isError: true,
|
|
874
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
910
|
+
content: [{ type: 'text', text: JSON.stringify(buildErrorDoc(toErrorDocFields(routeResult))) }],
|
|
875
911
|
};
|
|
876
912
|
}
|
|
877
913
|
resolvedModel = routeResult.executableId;
|
|
@@ -910,16 +946,29 @@ const handlers = {
|
|
|
910
946
|
return textResult(`Failed to continue: ${err.message}`, true);
|
|
911
947
|
}
|
|
912
948
|
recordSession(newTaskId, cwd); // #40: global index for cross-project lookup
|
|
913
|
-
return textResult(JSON.stringify({
|
|
949
|
+
return textResult(JSON.stringify(stampEnvelope('run', {
|
|
914
950
|
taskId: newTaskId, status: 'running',
|
|
915
951
|
message: 'Continuation started. Use amicus_status to check progress.',
|
|
916
|
-
}));
|
|
952
|
+
})));
|
|
917
953
|
},
|
|
918
954
|
|
|
919
955
|
async amicus_abort(input, project) {
|
|
920
956
|
const cwd = project || getProjectDir(input.project);
|
|
921
957
|
const metadata = readMetadata(input.taskId, cwd);
|
|
922
958
|
if (!metadata) {
|
|
959
|
+
// Council runs resolve via the sessions-dir pointer file (v4.0 §8):
|
|
960
|
+
// mark run.json aborted + cascade to the active wave/leg records.
|
|
961
|
+
const council = require('./mcp-council-run').abortCouncilRun(cwd, input.taskId);
|
|
962
|
+
if (council) {
|
|
963
|
+
if (council.alreadyTerminal) {
|
|
964
|
+
return textResult(`Council run ${input.taskId} is not running (status: ${council.status}).`);
|
|
965
|
+
}
|
|
966
|
+
return textResult(JSON.stringify({
|
|
967
|
+
taskId: input.taskId, status: 'aborted', legsAborted: council.cascaded,
|
|
968
|
+
message: `Council run abort requested. ${council.cascaded} running leg(s) marked aborted; ` +
|
|
969
|
+
'the engine process will finalize run.json as aborted shortly.',
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
923
972
|
return textResult(`Session ${input.taskId} not found in project ${cwd}. ` +
|
|
924
973
|
'If you ran it in a different project, pass the original "project".', true);
|
|
925
974
|
}
|
|
@@ -953,11 +1002,11 @@ const handlers = {
|
|
|
953
1002
|
// if they outlive the grace window. Fire-and-forget — the tool result
|
|
954
1003
|
// must not block on the grace period.
|
|
955
1004
|
waitThenKill([metadata.pid, metadata.goPid]).catch(() => { /* best-effort */ });
|
|
956
|
-
return textResult(JSON.stringify({
|
|
1005
|
+
return textResult(JSON.stringify(stampEnvelope('abort', {
|
|
957
1006
|
taskId: input.taskId, status: 'aborted', legsAborted,
|
|
958
1007
|
message: `Wave abort requested. ${legsAborted} running leg(s) marked aborted; ` +
|
|
959
1008
|
'the fan-out process will terminate shortly.',
|
|
960
|
-
}));
|
|
1009
|
+
})));
|
|
961
1010
|
}
|
|
962
1011
|
|
|
963
1012
|
// Single session: marker FIRST — the headless loop and the interactive
|
|
@@ -969,10 +1018,10 @@ const handlers = {
|
|
|
969
1018
|
markAborted(sessionDir, 'manual abort (MCP)');
|
|
970
1019
|
waitThenKill(metadata.pid).catch(() => { /* best-effort */ });
|
|
971
1020
|
|
|
972
|
-
return textResult(JSON.stringify({
|
|
1021
|
+
return textResult(JSON.stringify(stampEnvelope('abort', {
|
|
973
1022
|
taskId: input.taskId, status: 'aborted',
|
|
974
1023
|
message: 'Session abort requested. The Amicus process will terminate shortly.',
|
|
975
|
-
}));
|
|
1024
|
+
})));
|
|
976
1025
|
},
|
|
977
1026
|
|
|
978
1027
|
async amicus_fanout(input, project, mcpServer) {
|
|
@@ -1062,12 +1111,12 @@ const handlers = {
|
|
|
1062
1111
|
return textResult(`Failed to start fan-out: ${err.message}`, true);
|
|
1063
1112
|
}
|
|
1064
1113
|
|
|
1065
|
-
const body = JSON.stringify({
|
|
1114
|
+
const body = JSON.stringify(stampEnvelope('wave', {
|
|
1066
1115
|
waveId, taskIds: legIds, status: 'running', mode: 'headless',
|
|
1067
1116
|
message: 'Fan-out started. Preferred: call amicus_wait with the waveId — one blocking call ' +
|
|
1068
1117
|
'replaces polling; re-call it while it returns timedOut: true. Fallback: poll amicus_status ' +
|
|
1069
1118
|
'with the waveId. Either way, amicus_read the waveId when complete.',
|
|
1070
|
-
});
|
|
1119
|
+
}));
|
|
1071
1120
|
return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
|
|
1072
1121
|
},
|
|
1073
1122
|
|
|
@@ -1078,24 +1127,34 @@ const handlers = {
|
|
|
1078
1127
|
// Auto-append to the reliability ledger (parity with `amicus council
|
|
1079
1128
|
// tally`). Best-effort: a ledger write failure must not fail the tally.
|
|
1080
1129
|
try { require('./council/ledger').appendRun(record); } catch { /* best-effort */ }
|
|
1081
|
-
|
|
1130
|
+
// v4.0 §8 (H9): fence the JSON — council output summarizes untrusted
|
|
1131
|
+
// model prose entering the orchestrating agent's context. JSON intact
|
|
1132
|
+
// inside the fence; CLI --json stays unfenced (the programmatic channel).
|
|
1133
|
+
return textResult(fenceSidecarOutput(JSON.stringify(record)));
|
|
1082
1134
|
} catch (err) { return textResult(`council tally failed: ${err.message}`, true); }
|
|
1083
1135
|
},
|
|
1084
1136
|
|
|
1085
1137
|
async amicus_council_stats() {
|
|
1086
1138
|
try {
|
|
1087
|
-
const { deriveReliability } = require('./council/ledger');
|
|
1088
|
-
return textResult(JSON.stringify(deriveReliability()));
|
|
1139
|
+
const { deriveReliability, buildStatsDoc } = require('./council/ledger');
|
|
1140
|
+
return textResult(fenceSidecarOutput(JSON.stringify(buildStatsDoc(deriveReliability()))));
|
|
1089
1141
|
} catch (err) { return textResult(`council stats failed: ${err.message}`, true); }
|
|
1090
1142
|
},
|
|
1091
1143
|
|
|
1092
1144
|
async amicus_verdict(input) {
|
|
1093
1145
|
try {
|
|
1094
1146
|
const { buildVerdict } = require('./council/verdict');
|
|
1095
|
-
return textResult(JSON.stringify(buildVerdict(input.record, input.decisions || [])));
|
|
1147
|
+
return textResult(fenceSidecarOutput(JSON.stringify(buildVerdict(input.record, input.decisions || []))));
|
|
1096
1148
|
} catch (err) { return textResult(`verdict build failed: ${err.message}`, true); }
|
|
1097
1149
|
},
|
|
1098
1150
|
|
|
1151
|
+
async amicus_council_run(input, project, mcpServer) {
|
|
1152
|
+
const cwd = project || getProjectDir(input.project);
|
|
1153
|
+
return require('./mcp-council-run').handleCouncilRunTool(input, cwd, {
|
|
1154
|
+
spawnFn: spawnSidecarProcess, clientName: detectClient(mcpServer),
|
|
1155
|
+
});
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1099
1158
|
async amicus_setup() {
|
|
1100
1159
|
const { checkElectronAvailable } = require('./sidecar/interactive-process');
|
|
1101
1160
|
if (!checkElectronAvailable()) {
|
package/src/mcp-tools.js
CHANGED
|
@@ -421,6 +421,56 @@ function getTools() {
|
|
|
421
421
|
project: z.string().optional().describe('Optional project directory path.'),
|
|
422
422
|
},
|
|
423
423
|
},
|
|
424
|
+
{
|
|
425
|
+
name: 'amicus_council_run',
|
|
426
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
427
|
+
description:
|
|
428
|
+
'Run the FULL headless council engine: Stage-1 independent reviews → ' +
|
|
429
|
+
'anonymized peer cross-review → deterministic tally → non-bench chair ' +
|
|
430
|
+
'synthesis → verdict.json + report.html, without any orchestrating agent. ' +
|
|
431
|
+
'Returns {runId, runDir} immediately (async). Preferred: call amicus_wait ' +
|
|
432
|
+
'with the runId; re-call while it returns timedOut: true. Artifacts land ' +
|
|
433
|
+
'in runDir. Exit semantics: run.json status complete (full run), partial ' +
|
|
434
|
+
'(degraded: dead leg / thin judging / no chair verdict), error, aborted.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
briefingFile: z.string().describe(
|
|
437
|
+
'Path to the briefing file (self-contained material + criteria). The file is ' +
|
|
438
|
+
'copied into the run dir; councils always brief via file (no inline prompt).'
|
|
439
|
+
),
|
|
440
|
+
models: z.array(safeModel).min(2).max(10).optional().describe(
|
|
441
|
+
`2-10 bench seats. Short aliases (${aliasNames}) or full model IDs. Omit when using 'council'.`
|
|
442
|
+
),
|
|
443
|
+
council: z.string().optional().describe(
|
|
444
|
+
"Run a saved council or built-in bench ('free', 'budget', 'frontier') instead of 'models'."
|
|
445
|
+
),
|
|
446
|
+
chair: z.string().optional().describe(
|
|
447
|
+
"Chair model (default 'deepseek'). Must NOT be a bench seat; synthesizes the verdict."
|
|
448
|
+
),
|
|
449
|
+
critic: z.string().optional().describe(
|
|
450
|
+
'Optional critic seat: one bench member swaps to an adversarial brief. Must BE a bench seat. ' +
|
|
451
|
+
'Mutually exclusive with lenses.'
|
|
452
|
+
),
|
|
453
|
+
lenses: z.array(z.string()).optional().describe(
|
|
454
|
+
'Optional expert lenses, one per seat (count must equal seat count). Forces no-ledger. ' +
|
|
455
|
+
'Mutually exclusive with critic.'
|
|
456
|
+
),
|
|
457
|
+
outDir: z.string().optional().describe(
|
|
458
|
+
'Run directory (default <project>/council-<runId>/).'
|
|
459
|
+
),
|
|
460
|
+
maxCost: z.number().optional().describe(
|
|
461
|
+
'Whole-run USD ceiling, checked before each paid stage launch.'
|
|
462
|
+
),
|
|
463
|
+
timeoutMinutes: z.number().optional().describe(
|
|
464
|
+
'Per-leg timeout in minutes (existing fanout semantics). Default: 15.'
|
|
465
|
+
),
|
|
466
|
+
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
467
|
+
'Routing preference: auto (default), direct, or openrouter.'
|
|
468
|
+
),
|
|
469
|
+
project: z.string().optional().describe(
|
|
470
|
+
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
471
|
+
),
|
|
472
|
+
},
|
|
473
|
+
},
|
|
424
474
|
{
|
|
425
475
|
name: 'amicus_guide',
|
|
426
476
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
package/src/prompt-builder.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Spec Reference: §6 Fold Mechanism, §9 Implementation
|
|
5
5
|
* Constructs system prompts for sidecar sessions in both interactive and headless modes.
|
|
6
6
|
*/
|
|
7
|
-
const { buildFoldMarker } = require('./utils/fold-marker');
|
|
7
|
+
const { buildFoldMarker, stripFoldMarkers } = require('./utils/fold-marker');
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Summary template for fold output per spec §6.1
|
|
@@ -78,17 +78,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
|
|
|
78
78
|
* @param {string} [mode='code'] - Agent mode ('code', 'ask', or 'plan')
|
|
79
79
|
* @param {string} [summaryLength='normal'] - Desired summary length for headless mode
|
|
80
80
|
* @param {string} [client='code-local'] - Client type for branding
|
|
81
|
-
* @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual).
|
|
82
|
-
* headless mode
|
|
83
|
-
* the legacy bare `[SIDECAR_FOLD]`, so
|
|
84
|
-
* this SAME nonce) can't be forced into
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
|
|
88
|
-
* options.nonce.
|
|
89
|
-
* marker
|
|
90
|
-
*
|
|
81
|
+
* @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). REQUIRED in
|
|
82
|
+
* headless mode; ignored in interactive mode. In headless mode the model is instructed
|
|
83
|
+
* to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare `[SIDECAR_FOLD]`, so
|
|
84
|
+
* runHeadless's detector (which must be given this SAME nonce) can't be forced into
|
|
85
|
+
* completing by a model that merely echoes the public, guessable bare marker. Callers
|
|
86
|
+
* that build a headless prompt generate one nonce (utils/fold-marker.generateFoldNonce())
|
|
87
|
+
* BEFORE calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
|
|
88
|
+
* options.nonce. Omitting it in headless mode THROWS (see @throws) — there is no
|
|
89
|
+
* bare-marker fallback on this live path: a real prompt must never advertise the
|
|
90
|
+
* guessable marker. Interactive mode ignores the nonce entirely (GUI fold is
|
|
91
|
+
* exit-code driven, not marker-detected).
|
|
91
92
|
* @returns {{system: string, userMessage: string}} Separated prompts
|
|
93
|
+
* @throws {TypeError} If `headless` is true and no `nonce` is supplied.
|
|
92
94
|
*
|
|
93
95
|
* @example
|
|
94
96
|
* const { system, userMessage } = buildPrompts(
|
|
@@ -101,6 +103,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
|
|
|
101
103
|
* // Use: POST /session/:id/message { system, parts: [{ type: 'text', text: userMessage }] }
|
|
102
104
|
*/
|
|
103
105
|
function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client, nonce) {
|
|
106
|
+
// 15b.3 (#BL-7 residual): a headless run MUST carry a per-run nonce so the
|
|
107
|
+
// model is instructed to emit the unguessable `[SIDECAR_FOLD:<nonce>]` — never
|
|
108
|
+
// the public, guessable bare `[SIDECAR_FOLD]`. buildPrompts is the live
|
|
109
|
+
// orchestration boundary (start / continue / fanout / mcp-server all route
|
|
110
|
+
// through it, and all four already generate and pass a nonce), so a forgotten
|
|
111
|
+
// nonce fails loud HERE rather than silently baking the bare marker into an
|
|
112
|
+
// executed prompt. Mirrors headless.js's producer precedent — extractSummary /
|
|
113
|
+
// formatFoldOutput throw a TypeError when the nonce is missing. Interactive
|
|
114
|
+
// mode is exempt: its fold is exit-code driven, not marker-detected.
|
|
115
|
+
if (headless && !nonce) {
|
|
116
|
+
throw new TypeError('buildPrompts requires a per-run nonce for headless mode (15b.3/v4.0 §9)');
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
const systemSections = [
|
|
105
120
|
buildHeader(client),
|
|
106
121
|
buildEnvironmentSection(project, mode),
|
|
@@ -108,11 +123,11 @@ function buildPrompts(briefing, context, project, headless, mode, summaryLength
|
|
|
108
123
|
];
|
|
109
124
|
|
|
110
125
|
// Strip fold markers from context so the model doesn't mimic them from
|
|
111
|
-
// previous sidecar outputs in the conversation history.
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
const cleanContext = context
|
|
126
|
+
// previous sidecar outputs in the conversation history. stripFoldMarkers
|
|
127
|
+
// (src/utils/fold-marker.js) removes BOTH the legacy bare `[SIDECAR_FOLD]`
|
|
128
|
+
// and any nonced `[SIDECAR_FOLD:<nonce>]` — marker-only lines vanish
|
|
129
|
+
// entirely; inline occurrences are removed in place (v4.0 §9).
|
|
130
|
+
const cleanContext = stripFoldMarkers(context);
|
|
116
131
|
|
|
117
132
|
let userMessage;
|
|
118
133
|
if (headless) {
|
|
@@ -254,9 +269,11 @@ Keep track of key findings as you work.`;
|
|
|
254
269
|
* @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). When provided,
|
|
255
270
|
* the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare
|
|
256
271
|
* `[SIDECAR_FOLD]` — see buildPrompts' @param doc for the full rationale. Falls back to
|
|
257
|
-
* the legacy bare marker when omitted
|
|
258
|
-
*
|
|
259
|
-
*
|
|
272
|
+
* the legacy bare marker when omitted. NOTE: buildPrompts — the live orchestration path —
|
|
273
|
+
* now THROWS rather than reach this helper without a nonce in headless mode, so the
|
|
274
|
+
* fallback branch is reachable ONLY via the deprecated buildSystemPrompt(), which has no
|
|
275
|
+
* orchestration-layer caller to source a nonce from and launches no real runs. Its bare
|
|
276
|
+
* marker is therefore inert: nothing on a live run path ever advertises the guessable form.
|
|
260
277
|
* @returns {string}
|
|
261
278
|
*/
|
|
262
279
|
function buildHeadlessModeSection(summaryLength, nonce) {
|
|
@@ -64,7 +64,7 @@ function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
|
|
|
64
64
|
* Run one leg end-to-end: session record → runHeadless (shared server) →
|
|
65
65
|
* leg finalize. Never throws — always resolves to a run document.
|
|
66
66
|
*/
|
|
67
|
-
async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
|
|
67
|
+
async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce, directory }) {
|
|
68
68
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
69
69
|
const { markAborted } = require('../utils/session-abort');
|
|
70
70
|
const { runHeadless } = require('../headless');
|
|
@@ -107,7 +107,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
|
|
|
107
107
|
result = await runHeadless(
|
|
108
108
|
leg.model, systemPrompt, userMessage, legId, project,
|
|
109
109
|
timeoutMs, agent || 'build',
|
|
110
|
-
{ client, server, watchdog, summaryLength, reasoning, nonce: foldNonce }
|
|
110
|
+
{ client, server, watchdog, summaryLength, reasoning, nonce: foldNonce, directory }
|
|
111
111
|
);
|
|
112
112
|
} catch (err) {
|
|
113
113
|
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -245,7 +245,7 @@ async function runFanout(options) {
|
|
|
245
245
|
leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
|
|
246
246
|
timeoutMs, agent: options.agent, client, server,
|
|
247
247
|
summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
|
|
248
|
-
foldNonce,
|
|
248
|
+
foldNonce, directory: options.directory,
|
|
249
249
|
})
|
|
250
250
|
: Promise.resolve(buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }))
|
|
251
251
|
)));
|
package/src/sidecar/resume.js
CHANGED
|
@@ -17,7 +17,7 @@ const {
|
|
|
17
17
|
} = require('./session-utils');
|
|
18
18
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
19
19
|
const { runHeadless } = require('../headless');
|
|
20
|
-
const { extractNonceFromText, generateFoldNonce } = require('../utils/fold-marker');
|
|
20
|
+
const { extractNonceFromText, generateFoldNonce, stripFoldMarkers } = require('../utils/fold-marker');
|
|
21
21
|
const { logger } = require('../utils/logger');
|
|
22
22
|
|
|
23
23
|
/** Load session metadata from session directory */
|
|
@@ -83,7 +83,12 @@ function buildResumeUserMessage(briefing, conversation) {
|
|
|
83
83
|
|
|
84
84
|
if (conversation) {
|
|
85
85
|
parts.push('## PREVIOUS CONVERSATION\n');
|
|
86
|
-
|
|
86
|
+
// v4.0 §9: the replay used to carry the previous turn's valid nonced
|
|
87
|
+
// [SIDECAR_FOLD:<nonce>] marker verbatim (BACKLOG "Resume nonce-echo
|
|
88
|
+
// hazard"). Each run mints a fresh nonce so the stale marker could never
|
|
89
|
+
// fold the new run, but a wire-format token has no business riding into
|
|
90
|
+
// the new prompt — strip every marker line before embedding.
|
|
91
|
+
parts.push(stripFoldMarkers(conversation));
|
|
87
92
|
parts.push('\n---\n');
|
|
88
93
|
parts.push('## RESUME\n');
|
|
89
94
|
parts.push('You are resuming a previous session. Continue from where you left off.');
|
|
@@ -16,7 +16,7 @@ const { SCHEMA_VERSION } = require('./result-schema-version');
|
|
|
16
16
|
* a specific taskId that exists yet was not running (already terminal) reports
|
|
17
17
|
* ok:false so a scripted caller can tell "nothing happened" from "you aborted N".
|
|
18
18
|
* @param {object} opts
|
|
19
|
-
* @param {'session'|'wave'|'all'} opts.scope
|
|
19
|
+
* @param {'session'|'wave'|'all'|'council-run'} opts.scope
|
|
20
20
|
* @param {string|null} opts.taskId - null for scope:'all'
|
|
21
21
|
* @param {string[]} opts.aborted - ids actually marked aborted (session/wave id + any legs)
|
|
22
22
|
* @returns {object} abort document
|
package/src/utils/error-doc.js
CHANGED
|
@@ -20,6 +20,8 @@ const ERROR_CODES = Object.freeze({
|
|
|
20
20
|
BAD_SESSION: 'BAD_SESSION', // task id missing / invalid / not found
|
|
21
21
|
BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', // the WS-2 #10 spend gate
|
|
22
22
|
INTERNAL: 'INTERNAL', // unexpected pre-flight throw
|
|
23
|
+
COUNCIL_QUORUM: 'COUNCIL_QUORUM', // council run: <2 surviving Stage-1 reviews (v4.0 §4)
|
|
24
|
+
COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
/**
|
package/src/utils/fold-marker.js
CHANGED
|
@@ -70,10 +70,31 @@ function extractNonceFromText(text) {
|
|
|
70
70
|
return m ? m[1] : null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* v4.0 §9 (BL-7 done-done): remove every fold-marker occurrence — bare
|
|
75
|
+
* `[SIDECAR_FOLD]` and nonced `[SIDECAR_FOLD:<nonce>]` — from a text.
|
|
76
|
+
* A line consisting of ONLY a marker (plus horizontal whitespace) is removed
|
|
77
|
+
* together with its line terminator; a marker embedded mid-line is removed in
|
|
78
|
+
* place, keeping the rest of the line. Superset of prompt-builder.js's old
|
|
79
|
+
* inline regex (which left empty lines behind) — used by the resume replay
|
|
80
|
+
* (src/sidecar/resume.js) and buildPrompts (src/prompt-builder.js) so a
|
|
81
|
+
* replayed conversation never carries a stale wire-format token.
|
|
82
|
+
* @param {string|null|undefined} text
|
|
83
|
+
* @returns {string|null|undefined} falsy input is returned unchanged
|
|
84
|
+
*/
|
|
85
|
+
function stripFoldMarkers(text) {
|
|
86
|
+
if (!text) { return text; }
|
|
87
|
+
const marker = `\\[${FOLD_MARKER_PREFIX}(:[^\\]]*)?\\]`;
|
|
88
|
+
const markerOnlyLine = new RegExp(`^[^\\S\\r\\n]*${marker}[^\\S\\r\\n]*(?:\\r?\\n|$)`, 'gm');
|
|
89
|
+
const inlineMarker = new RegExp(marker, 'g');
|
|
90
|
+
return text.replace(markerOnlyLine, '').replace(inlineMarker, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
module.exports = {
|
|
74
94
|
FOLD_MARKER_PREFIX,
|
|
75
95
|
generateFoldNonce,
|
|
76
96
|
buildFoldMarker,
|
|
77
97
|
trailingFoldMarkerRegex,
|
|
78
98
|
extractNonceFromText,
|
|
99
|
+
stripFoldMarkers,
|
|
79
100
|
};
|
package/src/utils/route-error.js
CHANGED
|
@@ -145,9 +145,35 @@ function toCliMessage(result) {
|
|
|
145
145
|
return lines.join('\n');
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* v4.0 §7: map a RouteResult onto error-doc fields ({code, message, hint})
|
|
150
|
+
* for buildErrorDoc/failJson — the CLI --json and MCP failure surfaces.
|
|
151
|
+
* Key-shaped reasons map to MISSING_KEY; everything else is BAD_MODEL.
|
|
152
|
+
* Suggestions are inlined into the message text (the error doc has no
|
|
153
|
+
* structured suggestions slot; the hint carries the fix line).
|
|
154
|
+
* @param {object} result a RouteResult with kind 'error' or 'selection_required'
|
|
155
|
+
* @returns {{code: string, message: string, hint: (string|null)}}
|
|
156
|
+
*/
|
|
157
|
+
function toErrorDocFields(result) {
|
|
158
|
+
const { ERROR_CODES } = require('./error-doc');
|
|
159
|
+
const KEY_REASONS = ['no_openrouter_key', 'no_direct_key', 'no_key_for_vendor'];
|
|
160
|
+
const err = toStructuredError(result);
|
|
161
|
+
const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
|
|
162
|
+
let message = err.requested ? `${sentence} (requested "${err.requested}")` : sentence;
|
|
163
|
+
if (err.suggestions.length > 0) {
|
|
164
|
+
message += ` Did you mean: ${err.suggestions.map((s) => s && s.model).filter(Boolean).join(', ')}?`;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
code: KEY_REASONS.includes(err.reason) ? ERROR_CODES.MISSING_KEY : ERROR_CODES.BAD_MODEL,
|
|
168
|
+
message,
|
|
169
|
+
hint: FIX_HINTS[err.reason] || null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
module.exports = {
|
|
149
174
|
toStructuredError,
|
|
150
175
|
toCliMessage,
|
|
176
|
+
toErrorDocFields,
|
|
151
177
|
REASON_TEXT,
|
|
152
178
|
ROUTE_ERROR_REASONS,
|
|
153
179
|
SELECTION_REQUIRED_REASON,
|
|
@@ -46,7 +46,8 @@ function deriveAlias(args) {
|
|
|
46
46
|
async function resolveLaunchModel(args) {
|
|
47
47
|
const { resolveGatewayMode } = require('./config');
|
|
48
48
|
const { resolveRouteForLaunch } = require('./route-launch');
|
|
49
|
-
const { toCliMessage,
|
|
49
|
+
const { toCliMessage, toErrorDocFields } = require('./route-error');
|
|
50
|
+
const { failJson, ERROR_CODES } = require('./error-doc');
|
|
50
51
|
const { resolveModelInputOrDefault } = require('./model-input-default');
|
|
51
52
|
|
|
52
53
|
const gatewayMode = resolveGatewayMode(args.gateway);
|
|
@@ -66,9 +67,13 @@ async function resolveLaunchModel(args) {
|
|
|
66
67
|
// via model-input-default.js, so this lookup lives in exactly one place.
|
|
67
68
|
const modelInput = resolveModelInputOrDefault(args.model);
|
|
68
69
|
if (modelInput === undefined) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const message = 'No model specified and no default configured. Run \'amicus setup\' to set a default model.';
|
|
71
|
+
if (args.json) {
|
|
72
|
+
// v4.0 §7: --json pre-flight failures land on STDOUT as the error doc.
|
|
73
|
+
failJson(true, { code: ERROR_CODES.BAD_MODEL, message, hint: 'amicus setup' });
|
|
74
|
+
} else {
|
|
75
|
+
process.stderr.write(`${message}\n`);
|
|
76
|
+
}
|
|
72
77
|
process.exit(1);
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -106,17 +111,21 @@ async function resolveLaunchModel(args) {
|
|
|
106
111
|
provenance: result.provenance || {},
|
|
107
112
|
};
|
|
108
113
|
} catch (err) {
|
|
109
|
-
|
|
114
|
+
const message = err.message || 'Model selection cancelled.';
|
|
115
|
+
if (args.json) {
|
|
116
|
+
failJson(true, { code: ERROR_CODES.BAD_MODEL, message,
|
|
117
|
+
hint: 'Pass --model <vendor/model> explicitly to skip the picker.' });
|
|
118
|
+
} else {
|
|
119
|
+
process.stderr.write(`${message}\n`);
|
|
120
|
+
}
|
|
110
121
|
process.exit(1);
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
|
|
114
|
-
// 'error': render and exit.
|
|
115
|
-
//
|
|
116
|
-
// resolves a catalog miss straight to kind:'error' with reason
|
|
117
|
-
// 'model_not_found' instead; see gateway-router.js's catalogGate.)
|
|
125
|
+
// 'error': render and exit. Under --json the error doc goes to STDOUT
|
|
126
|
+
// (v4.0 §7 — was toStructuredError on stderr); human stderr is unchanged.
|
|
118
127
|
if (args.json) {
|
|
119
|
-
|
|
128
|
+
failJson(true, toErrorDocFields(result));
|
|
120
129
|
} else {
|
|
121
130
|
process.stderr.write(`${toCliMessage(result)}\n`);
|
|
122
131
|
}
|