@superjackfruitlabs/salaam 0.0.3 → 0.0.4
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/CHANGELOG.md +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi.js +22 -1
- package/dist/src/protocol.js +5 -2
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +141 -6
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +222 -6
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/tool-identity.ts +110 -0
- package/src/usage.ts +1 -1
package/dist/src/supervisor.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { BoardReads } from './board-reads.js';
|
|
2
|
+
import { HostBreaker, callThroughBreaker } from './host-breaker.js';
|
|
1
3
|
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
4
|
import { execFileSync } from 'node:child_process';
|
|
3
5
|
import { EventEmitter } from 'node:events';
|
|
4
|
-
import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync } from 'node:fs';
|
|
6
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync, rmSync } from 'node:fs';
|
|
5
7
|
import { join, resolve } from 'node:path';
|
|
6
8
|
import { PROTOCOL_VERSION, canonical, object, ServiceError, textDigest, workerTools, workerCapabilities, string, } from './protocol.js';
|
|
7
9
|
import { PI_CLI, HOUSE_EXTENSION, POLICY_DIGEST, bundleDigest, VERSION, PI_VERSION, } from './config.js';
|
|
8
10
|
import { readinessFingerprint } from './readiness-fingerprint.js';
|
|
9
11
|
import { PiProcess, PiCommandError } from './pi.js';
|
|
12
|
+
import { mergeMcpProvenance } from './operator-mcp.js';
|
|
10
13
|
import { OFFLINE_ROUTE, providerRoute, providerHasOutputCap, } from './provider-config.js';
|
|
11
14
|
import { validateApiKey } from './credentials.js';
|
|
12
15
|
import { resolveProviderAuth } from './provider-auth.js';
|
|
@@ -37,7 +40,18 @@ export function processIdentity(pid) {
|
|
|
37
40
|
throw Error('Invalid process start identity');
|
|
38
41
|
return { start: fields[19], group: fields[2], session: fields[3] };
|
|
39
42
|
};
|
|
40
|
-
const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
|
|
43
|
+
const before = stat(), boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
|
|
44
|
+
// A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
|
|
45
|
+
executable = (() => {
|
|
46
|
+
try {
|
|
47
|
+
return readlinkSync(path + '/exe');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'EACCES')
|
|
51
|
+
return 'unreadable';
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
})(), argv = readFileSync(path + '/cmdline');
|
|
41
55
|
if (!/^[a-f0-9-]{36}$/.test(boot) ||
|
|
42
56
|
!argv.length ||
|
|
43
57
|
argv.length > 16384 ||
|
|
@@ -75,8 +89,12 @@ export class Supervisor extends EventEmitter {
|
|
|
75
89
|
localCoding;
|
|
76
90
|
tasks;
|
|
77
91
|
compactionJobs = new Set();
|
|
92
|
+
fetchBreaker = new HostBreaker();
|
|
78
93
|
managed;
|
|
79
94
|
mcp;
|
|
95
|
+
operatorMcp;
|
|
96
|
+
/** Item 9: the host's pinned persona, given to every worker on this host. */
|
|
97
|
+
persona;
|
|
80
98
|
toolkit;
|
|
81
99
|
station;
|
|
82
100
|
children;
|
|
@@ -118,7 +136,26 @@ export class Supervisor extends EventEmitter {
|
|
|
118
136
|
const child = this.children?.owned(id);
|
|
119
137
|
if (child)
|
|
120
138
|
return JSON.parse(child.authority);
|
|
121
|
-
return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id));
|
|
139
|
+
return workerTools(!!this.options.effectsEnabled, !!this.children?.owned(id), this.codingBinding(id), undefined, !!this.mcp, !!this.toolkit, !!this.station, this.store.localWorkspace(id), this.store.localCoding(id), this.declaredMcp(id));
|
|
140
|
+
}
|
|
141
|
+
/** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
|
|
142
|
+
declaredMcp(id) {
|
|
143
|
+
return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
|
|
144
|
+
}
|
|
145
|
+
/** Every MCP server the session got, from both sources; persisted beside the worker. */
|
|
146
|
+
mcpServers(id) {
|
|
147
|
+
const live = this.workers.get(id)?.mcpServers;
|
|
148
|
+
if (live)
|
|
149
|
+
return structuredClone(live);
|
|
150
|
+
try {
|
|
151
|
+
const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
|
|
152
|
+
return existsSync(path)
|
|
153
|
+
? JSON.parse(readFileSync(path, 'utf8'))
|
|
154
|
+
: undefined;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
122
159
|
}
|
|
123
160
|
childBoundary(id, epoch) {
|
|
124
161
|
const child = this.children?.owned(id);
|
|
@@ -149,6 +186,10 @@ export class Supervisor extends EventEmitter {
|
|
|
149
186
|
}
|
|
150
187
|
bootId = randomUUID();
|
|
151
188
|
workers = new Map();
|
|
189
|
+
boardReadsStore;
|
|
190
|
+
get boardReads() {
|
|
191
|
+
return (this.boardReadsStore ??= new BoardReads(this.store));
|
|
192
|
+
}
|
|
152
193
|
monitor;
|
|
153
194
|
bundle;
|
|
154
195
|
constructor(store, options) {
|
|
@@ -276,6 +317,18 @@ export class Supervisor extends EventEmitter {
|
|
|
276
317
|
worker.mcp = await this.mcp.ready(id);
|
|
277
318
|
if (this.toolkit && !child)
|
|
278
319
|
worker.toolkit = await this.toolkit.ready(id);
|
|
320
|
+
if (this.declaredMcp(id))
|
|
321
|
+
worker.operatorMcp = this.operatorMcp.provenance();
|
|
322
|
+
if (worker.operatorMcp || worker.mcp) {
|
|
323
|
+
// Collisions are refused at admission; nothing is silently shadowed.
|
|
324
|
+
worker.mcpServers = mergeMcpProvenance(worker.operatorMcp ?? [], (worker.mcp?.servers ?? []).map((server) => ({
|
|
325
|
+
name: server.name,
|
|
326
|
+
source: 'agentpod',
|
|
327
|
+
transport: 'http',
|
|
328
|
+
endpoint_digest: server.endpoint_digest,
|
|
329
|
+
declaration_digest: digest(server),
|
|
330
|
+
})));
|
|
331
|
+
}
|
|
279
332
|
if (this.station && !child)
|
|
280
333
|
worker.station = this.station.ready(id);
|
|
281
334
|
this.store.execution.assert(id);
|
|
@@ -290,6 +343,11 @@ export class Supervisor extends EventEmitter {
|
|
|
290
343
|
}), { mode: 0o600 });
|
|
291
344
|
const sessionDir = join(workerDir, 'sessions');
|
|
292
345
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
346
|
+
const provenancePath = join(workerDir, 'mcp-servers.json');
|
|
347
|
+
if (worker.mcpServers)
|
|
348
|
+
writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
|
|
349
|
+
else
|
|
350
|
+
rmSync(provenancePath, { force: true });
|
|
293
351
|
const config = {
|
|
294
352
|
socket: this.options.bridgeSocket,
|
|
295
353
|
token: worker.token,
|
|
@@ -306,8 +364,11 @@ export class Supervisor extends EventEmitter {
|
|
|
306
364
|
local: this.store.localWorkspace(id),
|
|
307
365
|
localCoding: this.store.localCoding(id),
|
|
308
366
|
...(worker.mcp ? { mcp: worker.mcp } : {}),
|
|
367
|
+
...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
|
|
368
|
+
...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
|
|
309
369
|
...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
|
|
310
370
|
...(worker.station ? { station: worker.station } : {}),
|
|
371
|
+
...(this.persona ? { persona: this.persona } : {}),
|
|
311
372
|
};
|
|
312
373
|
const args = [
|
|
313
374
|
PI_CLI,
|
|
@@ -543,6 +604,36 @@ export class Supervisor extends EventEmitter {
|
|
|
543
604
|
}
|
|
544
605
|
return w;
|
|
545
606
|
}
|
|
607
|
+
/** Is this attempt a task step that has acceptance checks to decide it? */
|
|
608
|
+
checkedStepAttempt(attempt) {
|
|
609
|
+
const row = this.store.db
|
|
610
|
+
.prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
|
|
611
|
+
.get(attempt);
|
|
612
|
+
try {
|
|
613
|
+
const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
|
|
614
|
+
return Array.isArray(checks) && checks.length > 0;
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** A station call refused before it runs leaves no station_calls row, so record its
|
|
621
|
+
* method, requested action and refusal code: never its path, arguments or content. */
|
|
622
|
+
recordStationRefusal(id, method, body, error) {
|
|
623
|
+
const args = body.args && typeof body.args === 'object' ? body.args : {};
|
|
624
|
+
const ident = (v) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
|
|
625
|
+
try {
|
|
626
|
+
this.store.transaction(() => this.store.event(id, 'station.call_refused', {
|
|
627
|
+
attempt_id: this.workers.get(id)?.attempt ?? null,
|
|
628
|
+
method: ident(method),
|
|
629
|
+
action: ident(args.action),
|
|
630
|
+
code: error instanceof ServiceError ? ident(error.code) : 'error',
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// Recording a refusal never changes the refusal itself.
|
|
635
|
+
}
|
|
636
|
+
}
|
|
546
637
|
async bridge(id, method, token, body) {
|
|
547
638
|
const w = this.authenticate(id, token, body);
|
|
548
639
|
const route = w.route;
|
|
@@ -587,8 +678,14 @@ export class Supervisor extends EventEmitter {
|
|
|
587
678
|
context_digest: textDigest(w.frame),
|
|
588
679
|
...(boundary ? { worker_binding: boundary } : {}),
|
|
589
680
|
...(w.mcp ? { mcp: w.mcp } : {}),
|
|
681
|
+
...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
|
|
682
|
+
...(w.mcpServers
|
|
683
|
+
? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
|
|
684
|
+
: {}),
|
|
590
685
|
...(w.toolkit ? { toolkit: w.toolkit } : {}),
|
|
591
686
|
...(w.station ? { station: w.station } : {}),
|
|
687
|
+
// Item 9: the worker proves it runs with the host's pinned persona.
|
|
688
|
+
...(this.persona ? { persona_digest: this.persona.digest } : {}),
|
|
592
689
|
};
|
|
593
690
|
for (const [key, value] of Object.entries(expected))
|
|
594
691
|
if (canonical(body[key]) !== canonical(value))
|
|
@@ -680,6 +777,45 @@ export class Supervisor extends EventEmitter {
|
|
|
680
777
|
w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
|
|
681
778
|
return auth;
|
|
682
779
|
}
|
|
780
|
+
if (method.startsWith('operator_mcp.')) {
|
|
781
|
+
if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
|
|
782
|
+
throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
|
|
783
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
784
|
+
if (method === 'operator_mcp.catalog')
|
|
785
|
+
return await this.operatorMcp.catalog(id, body.server, body.tool);
|
|
786
|
+
if (method !== 'operator_mcp.call')
|
|
787
|
+
throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
|
|
788
|
+
const attempt = w.attempt;
|
|
789
|
+
const args = body.arguments ?? {};
|
|
790
|
+
const server = String(body.server ?? ''), tool = String(body.tool ?? '');
|
|
791
|
+
// A host failing again and again is paused briefly (never called, so no operator MCP call
|
|
792
|
+
// is recorded); the agent gets a tool error and the run carries on.
|
|
793
|
+
return await callThroughBreaker(this.fetchBreaker, id, args, (paused) => this.store.transaction(() => this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused })), async () => {
|
|
794
|
+
try {
|
|
795
|
+
const result = await this.operatorMcp.call(id, body.server, body.tool, args);
|
|
796
|
+
this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
|
|
797
|
+
attempt_id: attempt,
|
|
798
|
+
server,
|
|
799
|
+
tool,
|
|
800
|
+
argument_digest: digest(args),
|
|
801
|
+
outcome: result.is_error ? 'tool_error' : 'returned',
|
|
802
|
+
result_digest: digest(result),
|
|
803
|
+
}));
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
this.store.transaction(() => this.store.event(id, 'operator_mcp.call', {
|
|
808
|
+
attempt_id: attempt,
|
|
809
|
+
server,
|
|
810
|
+
tool,
|
|
811
|
+
argument_digest: digest(args),
|
|
812
|
+
outcome: 'failed',
|
|
813
|
+
error: error.code ?? 'mcp_server_failed',
|
|
814
|
+
}));
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
}
|
|
683
819
|
if (method.startsWith('toolkit.')) {
|
|
684
820
|
if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
|
|
685
821
|
throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
|
|
@@ -732,6 +868,15 @@ export class Supervisor extends EventEmitter {
|
|
|
732
868
|
: {}),
|
|
733
869
|
};
|
|
734
870
|
}
|
|
871
|
+
if (method === 'board.read' || method === 'board.status') {
|
|
872
|
+
// Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
|
|
873
|
+
if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
|
|
874
|
+
throw new ServiceError('station_disabled', 'Board reads need protected station work');
|
|
875
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
876
|
+
return method === 'board.read'
|
|
877
|
+
? this.boardReads.request(id, string(body.tool_call_id, 'tool_call_id', 512), object(body.args))
|
|
878
|
+
: this.boardReads.get(id, string(body.read_id, 'read_id', 64));
|
|
879
|
+
}
|
|
735
880
|
if (method.startsWith('station.')) {
|
|
736
881
|
if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
|
|
737
882
|
throw new ServiceError('station_disabled', 'Station execution is unavailable');
|
|
@@ -948,6 +1093,8 @@ export class Supervisor extends EventEmitter {
|
|
|
948
1093
|
}
|
|
949
1094
|
catch (error) {
|
|
950
1095
|
this.store.taskBudgetDenied?.(id, error);
|
|
1096
|
+
if (method.startsWith('station.'))
|
|
1097
|
+
this.recordStationRefusal(id, method, body, error);
|
|
951
1098
|
if (method.startsWith('station.') &&
|
|
952
1099
|
error instanceof ServiceError &&
|
|
953
1100
|
[
|
|
@@ -960,6 +1107,16 @@ export class Supervisor extends EventEmitter {
|
|
|
960
1107
|
'not_found',
|
|
961
1108
|
].includes(error.code))
|
|
962
1109
|
throw error;
|
|
1110
|
+
if (method.startsWith('operator_mcp.') &&
|
|
1111
|
+
error instanceof ServiceError &&
|
|
1112
|
+
[
|
|
1113
|
+
'invalid_field',
|
|
1114
|
+
'invalid_mcp_server',
|
|
1115
|
+
'invalid_mcp_tool',
|
|
1116
|
+
'invalid_mcp_arguments',
|
|
1117
|
+
'mcp_server_failed',
|
|
1118
|
+
].includes(error.code))
|
|
1119
|
+
throw error;
|
|
963
1120
|
if ((method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
|
|
964
1121
|
error instanceof ServiceError &&
|
|
965
1122
|
[
|
|
@@ -1064,6 +1221,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1064
1221
|
? null
|
|
1065
1222
|
: 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
|
|
1066
1223
|
},
|
|
1224
|
+
...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
|
|
1067
1225
|
};
|
|
1068
1226
|
const w = this.workers.get(id);
|
|
1069
1227
|
if (w?.invalid)
|
|
@@ -1317,11 +1475,17 @@ export class Supervisor extends EventEmitter {
|
|
|
1317
1475
|
outcome = candidate;
|
|
1318
1476
|
}
|
|
1319
1477
|
}
|
|
1320
|
-
|
|
1478
|
+
// A checked task step is decided by its checks, not by a tool error the model went on
|
|
1479
|
+
// to recover from (operator decision, 2026-10-23). The error stays in the journal
|
|
1480
|
+
// (tool.finished failed=true). A delegated child turn, whose denied probes are its
|
|
1481
|
+
// boundary proof, and any attempt outside a checked step still fail on it.
|
|
1482
|
+
if (value.type === 'tool_execution_end' &&
|
|
1483
|
+
value.isError === true &&
|
|
1484
|
+
(this.children?.owned(id) || !this.checkedStepAttempt(work.attempt.id)))
|
|
1321
1485
|
outcome ??= {
|
|
1322
1486
|
version: 1,
|
|
1323
1487
|
stopReason: 'error',
|
|
1324
|
-
failure: acpFailure(new ServiceError('tool_failed',
|
|
1488
|
+
failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
|
|
1325
1489
|
};
|
|
1326
1490
|
};
|
|
1327
1491
|
w.pi.on('record', record);
|
|
@@ -1386,6 +1550,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1386
1550
|
try {
|
|
1387
1551
|
this.managed?.invalidateWorker(w.id, w.epoch);
|
|
1388
1552
|
this.cancelMcpWorker(w);
|
|
1553
|
+
void this.operatorMcp?.stopSession(w.id).catch(() => { });
|
|
1389
1554
|
this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
|
|
1390
1555
|
}
|
|
1391
1556
|
catch {
|
|
@@ -1414,3 +1579,10 @@ export class Supervisor extends EventEmitter {
|
|
|
1414
1579
|
throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
|
|
1415
1580
|
}
|
|
1416
1581
|
}
|
|
1582
|
+
/** Names the failing tool (never its arguments or output), so a failed step says which call
|
|
1583
|
+
* failed it. Only a plain tool identifier is echoed. */
|
|
1584
|
+
export function toolFailureMessage(toolName) {
|
|
1585
|
+
return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
|
|
1586
|
+
? `Tool execution failed (tool ${toolName})`
|
|
1587
|
+
: 'Tool execution failed';
|
|
1588
|
+
}
|
package/dist/src/task-budget.js
CHANGED
|
@@ -6,6 +6,12 @@ export class TaskBudgetError extends ServiceError {
|
|
|
6
6
|
this.session_id = session_id;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
/** Tools a critic review child may hold and still be left out of the verification busy check. */
|
|
10
|
+
export const READ_ONLY_REVIEW_TOOLS = [
|
|
11
|
+
'salaam_findings',
|
|
12
|
+
'salaam_probe',
|
|
13
|
+
'salaam_repository',
|
|
14
|
+
];
|
|
9
15
|
export class TaskBudget {
|
|
10
16
|
store;
|
|
11
17
|
tasks;
|
|
@@ -23,6 +29,8 @@ export class TaskBudget {
|
|
|
23
29
|
store.taskChildFinished = (attempt) => this.releaseChild(attempt);
|
|
24
30
|
store.taskCommandAdmission = (session) => this.command(session);
|
|
25
31
|
store.taskRemainingRuntime = (session) => this.remaining(session);
|
|
32
|
+
// Only the task's own session: a child's attempt keeps the budget its reservation set.
|
|
33
|
+
store.taskAttemptRequestLimit = (session) => this.tasks.forSession(session)?.limits.requests;
|
|
26
34
|
}
|
|
27
35
|
task(session) {
|
|
28
36
|
const direct = this.tasks.forSession(session);
|
|
@@ -39,14 +47,42 @@ export class TaskBudget {
|
|
|
39
47
|
.n);
|
|
40
48
|
return { ...runtime, invocations_spent: n, external_model_usage: n ? 'unknown' : 'none' };
|
|
41
49
|
}
|
|
42
|
-
|
|
50
|
+
/** The child attempt of each running critic review of this task revision whose recorded
|
|
51
|
+
* tools are all read-only (operator ruling 2026-10-30, option A). Only verification reads may
|
|
52
|
+
* leave these out of the busy count: the reviewer reads a digest-bound snapshot and cannot
|
|
53
|
+
* write the workspace, run a command or reach the station (staging card_019a647a169744ff). */
|
|
54
|
+
readOnlyReviews(task) {
|
|
55
|
+
if (!this.store.db
|
|
56
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_critics'")
|
|
57
|
+
.get())
|
|
58
|
+
return [];
|
|
59
|
+
const rows = this.store.db
|
|
60
|
+
.prepare("SELECT c.child_attempt, c.authority FROM task_critics r JOIN child_tasks c ON c.id=r.child_id WHERE r.task_id=? AND r.plan_revision=? AND r.state='running' AND c.parent_session=?")
|
|
61
|
+
.all(task.id, task.plan_revision, task.session_id);
|
|
62
|
+
return rows
|
|
63
|
+
.filter((r) => {
|
|
64
|
+
try {
|
|
65
|
+
const tools = JSON.parse(r.authority);
|
|
66
|
+
return (Array.isArray(tools) &&
|
|
67
|
+
tools.every((t) => READ_ONLY_REVIEW_TOOLS.includes(t)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.map((r) => r.child_attempt);
|
|
74
|
+
}
|
|
75
|
+
/** Live work in the task's session tree. `verification` leaves out a running read-only review's
|
|
76
|
+
* child attempt, for verification reads only; admission and concurrency count everything. */
|
|
77
|
+
active(task, opts = {}) {
|
|
78
|
+
const excluded = JSON.stringify(opts.verification ? this.readOnlyReviews(task) : []);
|
|
43
79
|
const row = this.store.db
|
|
44
80
|
.prepare(`WITH tree(id) AS (SELECT ? UNION ALL SELECT child_session FROM child_tasks WHERE parent_session=?) SELECT
|
|
45
|
-
(SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running')) +
|
|
81
|
+
(SELECT count(*) FROM attempts WHERE session_id IN (SELECT id FROM tree) AND state IN ('accepted','dispatching','running') AND id NOT IN (SELECT value FROM json_each(?))) +
|
|
46
82
|
(SELECT count(*) FROM station_calls WHERE session_id IN (SELECT id FROM tree) AND kind='check' AND attempt_id IS NULL AND state IN ('prepared','dispatching')) +
|
|
47
83
|
(SELECT count(*) FROM command_runs WHERE session_id IN (SELECT id FROM tree) AND state NOT IN ('succeeded','failed','cancelled','timed_out','output_limited','interrupted')) +
|
|
48
84
|
(SELECT count(*) FROM external_runs e JOIN child_tasks c ON c.id=e.child_id JOIN attempts a ON a.id=c.child_attempt WHERE c.parent_session=? AND e.stopped=0 AND a.state NOT IN ('accepted','dispatching','running')) AS n`)
|
|
49
|
-
.get(task.session_id, task.session_id, task.session_id);
|
|
85
|
+
.get(task.session_id, task.session_id, excluded, task.session_id);
|
|
50
86
|
return Number(row.n);
|
|
51
87
|
}
|
|
52
88
|
deny(session, message) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* The completion critic (item 8). When the admitted plan declares a critic, the built-in reviewer
|
|
4
|
+
* reads an immutable snapshot of the verified deliverables against the plan's rubric before the
|
|
5
|
+
* task may complete. Its verdict is bound to the exact verified evidence (checks and tree): any
|
|
6
|
+
* later change makes it stale. A critical or high finding means changes are needed; a reviewer
|
|
7
|
+
* that reports nothing at all is a failure, never a pass.
|
|
8
|
+
*/
|
|
9
|
+
export const CRITIC_BUDGET = 4;
|
|
10
|
+
export const CRITIC_REOPENS = 2;
|
|
11
|
+
export class TaskCritic {
|
|
12
|
+
store;
|
|
13
|
+
artifacts;
|
|
14
|
+
deps;
|
|
15
|
+
constructor(store, artifacts, deps) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.artifacts = artifacts;
|
|
18
|
+
this.deps = deps;
|
|
19
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS task_critics(
|
|
20
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
21
|
+
task_id TEXT NOT NULL,
|
|
22
|
+
plan_revision INTEGER NOT NULL,
|
|
23
|
+
evidence_digest TEXT NOT NULL,
|
|
24
|
+
round INTEGER NOT NULL,
|
|
25
|
+
child_id TEXT NOT NULL,
|
|
26
|
+
state TEXT NOT NULL,
|
|
27
|
+
findings TEXT,
|
|
28
|
+
reason TEXT,
|
|
29
|
+
UNIQUE(task_id, plan_revision, evidence_digest))`);
|
|
30
|
+
}
|
|
31
|
+
/** Whether this exact verified evidence has a passing critic verdict. */
|
|
32
|
+
passed(task, evidenceDigest) {
|
|
33
|
+
return (this.store.db
|
|
34
|
+
.prepare("SELECT 1 FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=? AND state='pass'")
|
|
35
|
+
.get(task.id, task.plan_revision, evidenceDigest) !== undefined);
|
|
36
|
+
}
|
|
37
|
+
/** How many times the critic has sent this plan revision back for changes. */
|
|
38
|
+
reopens(task) {
|
|
39
|
+
return Number(this.store.db
|
|
40
|
+
.prepare("SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=? AND state='changes_needed'")
|
|
41
|
+
.get(task.id, task.plan_revision).n);
|
|
42
|
+
}
|
|
43
|
+
/** The verdict for this verified evidence, starting the reviewer the first time it is asked. */
|
|
44
|
+
async gate(task, evidenceDigest, deliverables) {
|
|
45
|
+
const critic = task.completion_gate?.critic;
|
|
46
|
+
if (!critic)
|
|
47
|
+
return { state: 'pass' };
|
|
48
|
+
const row = this.store.db
|
|
49
|
+
.prepare('SELECT * FROM task_critics WHERE task_id=? AND plan_revision=? AND evidence_digest=?')
|
|
50
|
+
.get(task.id, task.plan_revision, evidenceDigest);
|
|
51
|
+
if (row)
|
|
52
|
+
return this.settle(row);
|
|
53
|
+
const last = this.store.db
|
|
54
|
+
.prepare('SELECT b.attempt_id FROM task_step_attempts b JOIN task_steps s ON s.id=b.step_id WHERE s.task_id=? ORDER BY b.rowid DESC LIMIT 1')
|
|
55
|
+
.get(task.id);
|
|
56
|
+
if (!last)
|
|
57
|
+
return { state: 'failed', reason: 'No step attempt for the critic to follow' };
|
|
58
|
+
if (!deliverables.length)
|
|
59
|
+
return { state: 'failed', reason: 'No verified deliverables to review' };
|
|
60
|
+
const round = Number(this.store.db
|
|
61
|
+
.prepare('SELECT count(*) AS n FROM task_critics WHERE task_id=? AND plan_revision=?')
|
|
62
|
+
.get(task.id, task.plan_revision).n) + 1;
|
|
63
|
+
const snapshot = Object.fromEntries(deliverables.map((d) => [
|
|
64
|
+
d.path,
|
|
65
|
+
{ content: Buffer.from(d.content, 'utf8').toString('base64'), executable: false },
|
|
66
|
+
]));
|
|
67
|
+
const grant = await this.deps.admit(task, `critic:${task.id}:${task.plan_revision}`);
|
|
68
|
+
const child = this.deps.spawn(task, String(last.attempt_id), `critic-${task.plan_revision}-${round}`, [
|
|
69
|
+
'Judge the delivered work against this rubric before it is accepted:',
|
|
70
|
+
critic.rubric,
|
|
71
|
+
'',
|
|
72
|
+
'Objective: ' + task.objective,
|
|
73
|
+
'Deliverables (the snapshot): ' + deliverables.map((d) => d.path).join(', '),
|
|
74
|
+
'',
|
|
75
|
+
'Report findings with salaam_findings, citing the file and line. Use critical or high only',
|
|
76
|
+
'for what must change before the work is accepted; medium or low for suggestions. If the',
|
|
77
|
+
'work meets the rubric, report an empty list.',
|
|
78
|
+
].join('\n'), CRITIC_BUDGET, grant, snapshot);
|
|
79
|
+
this.store.db
|
|
80
|
+
.prepare("INSERT INTO task_critics VALUES (?,?,?,?,?,?,'running',NULL,NULL)")
|
|
81
|
+
.run(randomUUID(), task.id, task.plan_revision, evidenceDigest, round, child.id);
|
|
82
|
+
return { state: 'pending' };
|
|
83
|
+
}
|
|
84
|
+
settle(row) {
|
|
85
|
+
if (row.state === 'pass')
|
|
86
|
+
return { state: 'pass' };
|
|
87
|
+
if (row.state === 'changes_needed')
|
|
88
|
+
return { state: 'changes_needed', findings: JSON.parse(String(row.findings)) };
|
|
89
|
+
if (row.state === 'failed')
|
|
90
|
+
return { state: 'failed', reason: String(row.reason) };
|
|
91
|
+
const child = this.store.db
|
|
92
|
+
.prepare('SELECT state FROM child_tasks WHERE id=?')
|
|
93
|
+
.get(String(row.child_id));
|
|
94
|
+
if (!child || child.state === 'accepted')
|
|
95
|
+
return { state: 'pending' };
|
|
96
|
+
const report = this.store.db
|
|
97
|
+
.prepare('SELECT artifact_id FROM specialist_reports WHERE worker_id=?')
|
|
98
|
+
.get(this.deps.workerId(String(row.child_id)));
|
|
99
|
+
const set = (state, findings, reason) => this.store.db
|
|
100
|
+
.prepare('UPDATE task_critics SET state=?,findings=?,reason=? WHERE id=?')
|
|
101
|
+
.run(state, findings ? JSON.stringify(findings) : null, reason, String(row.id));
|
|
102
|
+
if (!report) {
|
|
103
|
+
const reason = 'The critic returned no findings report';
|
|
104
|
+
set('failed', null, reason);
|
|
105
|
+
return { state: 'failed', reason };
|
|
106
|
+
}
|
|
107
|
+
const findings = JSON.parse(this.artifacts.read(String(report.artifact_id))).findings.map(({ path, line, severity, message }) => ({ path, line, severity, message }));
|
|
108
|
+
if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) {
|
|
109
|
+
set('changes_needed', findings, null);
|
|
110
|
+
return { state: 'changes_needed', findings };
|
|
111
|
+
}
|
|
112
|
+
set('pass', findings, null);
|
|
113
|
+
return { state: 'pass' };
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/src/task-driver.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { CRITIC_REOPENS } from './task-critic.js';
|
|
2
|
+
import { requiredChecks } from './task-policy.js';
|
|
3
|
+
import { RATE_LIMIT_STOP, RateLimitRetries } from './rate-limit-retries.js';
|
|
1
4
|
import { randomUUID } from 'node:crypto';
|
|
2
5
|
import { commandTerminal } from './coding.js';
|
|
3
6
|
import { canonical, ServiceError } from './protocol.js';
|
|
@@ -14,6 +17,7 @@ export class TaskDriver {
|
|
|
14
17
|
awaitingAuthorityRenewal;
|
|
15
18
|
active;
|
|
16
19
|
stopped = true;
|
|
20
|
+
rateLimits;
|
|
17
21
|
constructor(store, tasks, budget, verification, supervisor, children, coding) {
|
|
18
22
|
this.store = store;
|
|
19
23
|
this.tasks = tasks;
|
|
@@ -22,6 +26,7 @@ export class TaskDriver {
|
|
|
22
26
|
this.supervisor = supervisor;
|
|
23
27
|
this.children = children;
|
|
24
28
|
this.coding = coding;
|
|
29
|
+
this.rateLimits = new RateLimitRetries(store);
|
|
25
30
|
tasks.assertProgressionBinding = (task, binding) => {
|
|
26
31
|
if (canonical(this.verification.binding(task)) !== binding)
|
|
27
32
|
throw new ServiceError('task_progression_stale', 'Workspace or execution environment changed since the bounded grant');
|
|
@@ -295,6 +300,22 @@ export class TaskDriver {
|
|
|
295
300
|
return;
|
|
296
301
|
}
|
|
297
302
|
if (task.steps.some((s) => ['interrupted', 'failed', 'cancelled'].includes(s.state))) {
|
|
303
|
+
// A provider 429 is retried as a fresh attempt of the step after a backoff; past the limit,
|
|
304
|
+
// or with no progression step left, the task stops with a block recovery can settle.
|
|
305
|
+
const retry = this.rateLimits.decide(task);
|
|
306
|
+
if (retry.action === 'wait')
|
|
307
|
+
return;
|
|
308
|
+
if (retry.action === 'retry' && Number(r.remaining_steps) >= 1) {
|
|
309
|
+
if (!(await this.worker(task)) || this.stopped)
|
|
310
|
+
return;
|
|
311
|
+
this.tasks.retryStep(task.id, retry.step, retry.n, Number(r.auto_epoch));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (retry.action !== 'none') {
|
|
315
|
+
this.budget.block(task.session_id, RATE_LIMIT_STOP);
|
|
316
|
+
this.stop(task, RATE_LIMIT_STOP);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
298
319
|
this.stop(task, 'An interrupted or failed step requires explicit operator review');
|
|
299
320
|
return;
|
|
300
321
|
}
|
|
@@ -334,14 +355,37 @@ export class TaskDriver {
|
|
|
334
355
|
// A passed receipt cannot be re-observed while another final check owns
|
|
335
356
|
// the native workspace. Wait for the whole set before testing freshness;
|
|
336
357
|
// finishAsync still performs the final transactional evidence validation.
|
|
337
|
-
if (task.
|
|
358
|
+
if (requiredChecks(task).some((check) => {
|
|
338
359
|
const receipt = this.verification.latest(task, check.key, null);
|
|
339
360
|
return receipt?.state === 'accepted' || receipt?.state === 'running';
|
|
340
361
|
}))
|
|
341
362
|
return;
|
|
342
|
-
for (const check of task
|
|
363
|
+
for (const check of requiredChecks(task))
|
|
343
364
|
if (!(await this.check(task, check.key, null)))
|
|
344
365
|
return;
|
|
366
|
+
// Item 8: a declared critic reviews the verified deliverables before completion.
|
|
367
|
+
if (task.completion_gate?.critic) {
|
|
368
|
+
const verdict = await this.verification.criticGateAsync(task);
|
|
369
|
+
if (verdict.state === 'pending')
|
|
370
|
+
return;
|
|
371
|
+
if (verdict.state === 'failed') {
|
|
372
|
+
this.stop(task, 'Critic review failed: ' + verdict.reason);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (verdict.state === 'changes_needed') {
|
|
376
|
+
const round = this.verification.critic.reopens(task);
|
|
377
|
+
if (round > CRITIC_REOPENS || Number(r.remaining_steps) < 1) {
|
|
378
|
+
this.stop(task, 'Critic requested changes');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!(await this.worker(task)) || this.stopped)
|
|
382
|
+
return;
|
|
383
|
+
this.tasks.reopenForCritic(task.id, task.steps.at(-1).id, round, Number(r.auto_epoch), verdict.findings
|
|
384
|
+
.map((f) => `- ${f.path}:${f.line} (${f.severity}) ${f.message}`)
|
|
385
|
+
.join('\n'));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
345
389
|
await this.verification.finishAsync(task.id, 'auto-finish:' + task.id + ':' + task.plan_revision, task.revision, null, () => {
|
|
346
390
|
if (!this.budget.state(task.id).auto_enabled)
|
|
347
391
|
throw new ServiceError('task_authority', 'Progression paused before completion');
|
package/dist/src/task-policy.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { integer, object, ServiceError, string } from './protocol.js';
|
|
2
2
|
import { filePath } from './workspaces.js';
|
|
3
|
+
/** Every check completion requires: the acceptance checks, then each hook as hook-<key>. */
|
|
4
|
+
export function requiredChecks(policy) {
|
|
5
|
+
return [
|
|
6
|
+
...policy.acceptance,
|
|
7
|
+
...(policy.completion_gate?.hooks ?? []).map((h) => ({ ...h, key: 'hook-' + h.key })),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
const check = (v, keys, what) => {
|
|
11
|
+
const c = object(v), key = string(c.key, what + ' key', 64);
|
|
12
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
13
|
+
throw new ServiceError('invalid_plan', what + ' keys must be unique identifiers', 400);
|
|
14
|
+
keys.add(key);
|
|
15
|
+
return {
|
|
16
|
+
key,
|
|
17
|
+
command: string(c.command, what + ' command', 4096),
|
|
18
|
+
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
3
21
|
export const DEFAULT_TASK_LIMITS = {
|
|
4
22
|
requests: 64,
|
|
5
23
|
runtime_ms: 900000,
|
|
@@ -14,17 +32,7 @@ export function taskPolicy(value) {
|
|
|
14
32
|
if (!Array.isArray(raw) || raw.length > 8)
|
|
15
33
|
throw new ServiceError('invalid_plan', 'Supply at most eight acceptance checks', 400);
|
|
16
34
|
const keys = new Set();
|
|
17
|
-
const acceptance = raw.map((v) =>
|
|
18
|
-
const c = object(v), key = string(c.key, 'check key', 64);
|
|
19
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(key) || keys.has(key))
|
|
20
|
-
throw new ServiceError('invalid_plan', 'Acceptance keys must be unique identifiers', 400);
|
|
21
|
-
keys.add(key);
|
|
22
|
-
return {
|
|
23
|
-
key,
|
|
24
|
-
command: string(c.command, 'check command', 4096),
|
|
25
|
-
cwd: c.cwd === undefined || c.cwd === '.' ? '.' : filePath(c.cwd),
|
|
26
|
-
};
|
|
27
|
-
});
|
|
35
|
+
const acceptance = raw.map((v) => check(v, keys, 'Acceptance'));
|
|
28
36
|
const paths = value.deliverables ?? [];
|
|
29
37
|
if (!Array.isArray(paths) || paths.length > 32)
|
|
30
38
|
throw new ServiceError('invalid_plan', 'Supply at most 32 deliverable paths', 400);
|
|
@@ -66,5 +74,29 @@ export function taskPolicy(value) {
|
|
|
66
74
|
throw new ServiceError('invalid_budget', 'Invocation limit must be 1–64', 400);
|
|
67
75
|
limits.invocations = n;
|
|
68
76
|
}
|
|
69
|
-
|
|
77
|
+
let completion_gate;
|
|
78
|
+
if (value.completion_gate !== undefined) {
|
|
79
|
+
const g = object(value.completion_gate);
|
|
80
|
+
const hooks = g.hooks;
|
|
81
|
+
const c = g.critic;
|
|
82
|
+
const critic = c === null
|
|
83
|
+
? null
|
|
84
|
+
: (() => {
|
|
85
|
+
const v = object(c);
|
|
86
|
+
if (Object.keys(v).join(',') !== 'rubric')
|
|
87
|
+
throw new ServiceError('invalid_plan', 'A critic names only its rubric', 400);
|
|
88
|
+
return { rubric: string(v.rubric, 'critic rubric', 4096) };
|
|
89
|
+
})();
|
|
90
|
+
if (Object.keys(g).sort().join(',') !== 'critic,hooks' ||
|
|
91
|
+
!Array.isArray(hooks) ||
|
|
92
|
+
hooks.length > 8 ||
|
|
93
|
+
(!critic && hooks.length < 1))
|
|
94
|
+
throw new ServiceError('invalid_plan', 'A completion gate has a critic or 1–8 hooks, and at most 8 hooks', 400);
|
|
95
|
+
const hookKeys = new Set();
|
|
96
|
+
completion_gate = { critic, hooks: hooks.map((v) => check(v, hookKeys, 'Hook')) };
|
|
97
|
+
if (requiredChecks({ acceptance, completion_gate }).length !==
|
|
98
|
+
new Set(requiredChecks({ acceptance, completion_gate }).map((c) => c.key)).size)
|
|
99
|
+
throw new ServiceError('invalid_plan', 'A hook collides with an acceptance check', 400);
|
|
100
|
+
}
|
|
101
|
+
return { acceptance, deliverables, limits, ...(completion_gate ? { completion_gate } : {}) };
|
|
70
102
|
}
|