pi-crew 0.9.68 → 0.10.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/CHANGELOG.md +124 -0
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +65838 -60331
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +15 -1
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +681 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +35 -1
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +8 -8
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +30 -0
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +214 -0
- package/src/extension/registration/commands/shared.ts +631 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/lifecycle-handlers.ts +126 -15
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +16 -1
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +48 -18
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +7 -1
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +32 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +95 -46
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +129 -1671
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +65 -25
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +66 -229
- package/src/state/types.ts +197 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/widget-renderer.ts +9 -1
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +19 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +10 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -2,7 +2,7 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
|
2
2
|
import type { CrewReliabilityConfig, CrewRuntimeConfig } from "../../config/types.ts";
|
|
3
3
|
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
4
4
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
5
|
-
import { saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
5
|
+
import { loadRunManifestById, saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
6
6
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
7
7
|
import type { WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
8
8
|
import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../heartbeat/worker-heartbeat.ts";
|
|
@@ -115,17 +115,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
115
115
|
heartbeatInFlight = true;
|
|
116
116
|
heartbeatPromise = (async () => {
|
|
117
117
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
...t,
|
|
125
|
-
heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
|
|
126
|
-
};
|
|
127
|
-
});
|
|
128
|
-
await saveRunTasksAsync(manifest, updatedTasks);
|
|
118
|
+
// R15-3: persist heartbeats for ONLY this group's tasks via a fresh
|
|
119
|
+
// disk read. The old closure `updatedTasks` is a dispatch-time
|
|
120
|
+
// snapshot of the FULL task array — a sibling cancelled on disk after
|
|
121
|
+
// dispatch could be un-cancelled by a late heartbeat save (the map
|
|
122
|
+
// only mutated group tasks, but the SAVE wrote the whole stale array).
|
|
123
|
+
await persistGroupHeartbeats(manifest, taskIds);
|
|
129
124
|
} catch {
|
|
130
125
|
// Run may have been pruned mid-dispatch — best-effort only.
|
|
131
126
|
} finally {
|
|
@@ -134,9 +129,12 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
134
129
|
// save was in flight, re-write the (now-terminal) updatedTasks so
|
|
135
130
|
// our late snapshot cannot leave stale pre-terminal state on disk.
|
|
136
131
|
// Runs after our own save resolves, so it always lands last.
|
|
132
|
+
// R15-3: the repair now fresh-reads disk and re-applies ONLY the
|
|
133
|
+
// group's terminal state — the old full-array write could resurrect
|
|
134
|
+
// a sibling cancelled on disk (same flaw as the heartbeat save).
|
|
137
135
|
if (finalWriteStarted) {
|
|
138
136
|
try {
|
|
139
|
-
await
|
|
137
|
+
await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
|
|
140
138
|
} catch {
|
|
141
139
|
// best-effort repair — same swallow policy as the heartbeat.
|
|
142
140
|
}
|
|
@@ -298,6 +296,59 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
298
296
|
return { manifest: updatedManifest, tasks: updatedTasks, taskIds, rawOutput, success };
|
|
299
297
|
}
|
|
300
298
|
|
|
299
|
+
/**
|
|
300
|
+
* R15-3: heartbeat save scoped to this coalesced group only.
|
|
301
|
+
*
|
|
302
|
+
* Previously the heartbeat wrote the dispatch-time closure `updatedTasks` —
|
|
303
|
+
* the FULL task array including sibling tasks outside this group. A sibling
|
|
304
|
+
* cancelled on disk after dispatch (external cancel / reconciler) could be
|
|
305
|
+
* un-cancelled ("resurrected") by a late heartbeat save, because the map only
|
|
306
|
+
* mutated group tasks but the SAVE wrote the whole stale array. Fix: fresh
|
|
307
|
+
* re-read tasks from disk (loadRunManifestById — the established fresh-read
|
|
308
|
+
* pattern used by team-runner) and write back ONLY the group's own task ids
|
|
309
|
+
* with heartbeats touched; sibling tasks are preserved untouched from the
|
|
310
|
+
* fresh disk read. Keeps the FIND-06 'never replace terminal state' guard.
|
|
311
|
+
*/
|
|
312
|
+
async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[]): Promise<void> {
|
|
313
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
314
|
+
if (!fresh) return; // run pruned mid-dispatch — best-effort only.
|
|
315
|
+
const merged = fresh.tasks.map((t) => {
|
|
316
|
+
if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
|
|
317
|
+
// FIND-06 belt-and-suspenders: never replace terminal state or its
|
|
318
|
+
// resultArtifact with a heartbeat-only snapshot.
|
|
319
|
+
if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
|
|
320
|
+
return {
|
|
321
|
+
...t,
|
|
322
|
+
heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
await saveRunTasksAsync(manifest, merged);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* R15-3: FIND-06 P1 repair — re-apply the group's terminal state after a late
|
|
330
|
+
* heartbeat save may have landed last. Fresh-reads disk so sibling tasks are
|
|
331
|
+
* preserved untouched; only the group's own terminal state from `updatedTasks`
|
|
332
|
+
* is re-applied, and a disk-terminal group task is never flipped (same
|
|
333
|
+
* belt-and-suspenders guard as the heartbeat).
|
|
334
|
+
*/
|
|
335
|
+
async function repairGroupTerminalWrite(manifest: TeamRunManifest, taskIds: string[], updatedTasks: TeamTaskState[]): Promise<void> {
|
|
336
|
+
const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
337
|
+
if (!fresh) return; // run pruned mid-dispatch — best-effort only.
|
|
338
|
+
const groupById = new Map(updatedTasks.map((t) => [t.id, t] as const));
|
|
339
|
+
const merged = fresh.tasks.map((t) => {
|
|
340
|
+
if (!taskIds.includes(t.id)) return t; // sibling tasks: preserved untouched.
|
|
341
|
+
if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
|
|
342
|
+
return groupById.get(t.id) ?? t;
|
|
343
|
+
});
|
|
344
|
+
await saveRunTasksAsync(manifest, merged);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @internal 3.5 R15-3 test export — exercise the heartbeat/repair writes directly. */
|
|
348
|
+
export const __test__persistGroupHeartbeats = persistGroupHeartbeats;
|
|
349
|
+
/** @internal 3.5 R15-3 test export — exercise the terminal repair write directly. */
|
|
350
|
+
export const __test__repairGroupTerminalWrite = repairGroupTerminalWrite;
|
|
351
|
+
|
|
301
352
|
async function buildCoalescedPrompt(
|
|
302
353
|
manifest: TeamRunManifest,
|
|
303
354
|
step: WorkflowStep,
|
|
@@ -48,16 +48,6 @@ export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex {
|
|
|
48
48
|
return fresh;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Test/diagnostic helper — invalidate the WeakMap-backed index cache. Not
|
|
52
|
-
* used by production code paths; the WeakMap self-invalidates when a tasks
|
|
53
|
-
* array goes out of scope. Provided for parity with `clearStablePrefixCache`. */
|
|
54
|
-
export function clearTaskGraphIndexCache(): void {
|
|
55
|
-
// WeakMap has no `.clear()`; rely on GC. Exposed as a no-op stub so callers
|
|
56
|
-
// that import `clearStablePrefixCache` (also a no-op stub for symmetry) can
|
|
57
|
-
// adopt a parallel API if needed in the future. Documented as no-op rather
|
|
58
|
-
// than removed so the API stays discoverable.
|
|
59
|
-
}
|
|
60
|
-
|
|
61
51
|
function taskById(tasks: TeamTaskState[]): Map<string, TeamTaskState> {
|
|
62
52
|
return new Map(tasks.map((task) => [task.id, task]));
|
|
63
53
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { projectOverrideWarning, sanitizeProjectConfig } from "../config/sanitize-project-config.ts";
|
|
5
|
+
import type { PiTeamsConfig } from "../config/types.ts";
|
|
4
6
|
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
7
|
import { withFileLockSync } from "../state/coordination/locks.ts";
|
|
6
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
@@ -12,6 +14,11 @@ export interface CrewSettings {
|
|
|
12
14
|
graceTurns?: number;
|
|
13
15
|
defaultJoinMode?: JoinMode;
|
|
14
16
|
schedulingEnabled?: boolean;
|
|
17
|
+
/** Wave B2: user-tier-only opt-in for project-tier scheduledJobs. Semantics
|
|
18
|
+
* are enforced at the tiers layer (loadCrewSettingsTiers), NOT here — the
|
|
19
|
+
* flat sanitizeSettings allowlist must merely preserve the flag so the
|
|
20
|
+
* opt-in survives the read of the user-tier global file. */
|
|
21
|
+
allowProjectScheduledJobs?: boolean;
|
|
15
22
|
notifierIntervalMs?: number;
|
|
16
23
|
/** Scheduled jobs loaded from settings — opaque, passed to crewScheduler */
|
|
17
24
|
scheduledJobs?: unknown[];
|
|
@@ -61,6 +68,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
|
|
|
61
68
|
if (typeof r.schedulingEnabled === "boolean") {
|
|
62
69
|
out.schedulingEnabled = r.schedulingEnabled;
|
|
63
70
|
}
|
|
71
|
+
if (typeof r.allowProjectScheduledJobs === "boolean") {
|
|
72
|
+
out.allowProjectScheduledJobs = r.allowProjectScheduledJobs;
|
|
73
|
+
}
|
|
64
74
|
if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
|
|
65
75
|
out.notifierIntervalMs = r.notifierIntervalMs;
|
|
66
76
|
}
|
|
@@ -89,6 +99,21 @@ function readSettingsFile(filePath: string): CrewSettings {
|
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* LEGACY merged view — USER-TIER-ONLY ingestion path (P1 fix follow-up, bug-026 adjacent).
|
|
104
|
+
*
|
|
105
|
+
* Merges the user-tier global file OVER the project file into one flat object.
|
|
106
|
+
* This view is trusted and MUST NOT be applied to live config anymore: the
|
|
107
|
+
* project fragment bypasses `sanitizeProjectConfig`. The current consumer
|
|
108
|
+
* path is `loadCrewSettingsTiers()` + `applyCrewSettingsTiersToConfig()`
|
|
109
|
+
* (project fragment routed through the schema-driven tiering, Wave 2B).
|
|
110
|
+
*
|
|
111
|
+
* If you need to read crew settings for CONFIG APPLICATION, use the tiers
|
|
112
|
+
* API — never this function. Kept exported for back-compat reads/tests;
|
|
113
|
+
* adding a new consumer that writes into loadedConfig re-introduces the
|
|
114
|
+
* fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
|
|
115
|
+
* and the Wave 2B execution log in docs/refactor-plan.md).
|
|
116
|
+
*/
|
|
92
117
|
export function loadCrewSettings(cwd: string = process.cwd()): CrewSettings {
|
|
93
118
|
return {
|
|
94
119
|
...readSettingsFile(globalPath()),
|
|
@@ -135,6 +160,21 @@ export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings
|
|
|
135
160
|
});
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
/**
|
|
164
|
+
* LEGACY direct-apply — USER-TIER-ONLY (P1 fix follow-up).
|
|
165
|
+
*
|
|
166
|
+
* Writes settings straight into a config object with NO sanitize tiering.
|
|
167
|
+
* Applying a merged (user+project) `CrewSettings` through this function is
|
|
168
|
+
* exactly the P1 bypass fixed in Wave 2B: project-tier values would land in
|
|
169
|
+
* guard fields (`limits.maxConcurrentWorkers`, `runtime.maxTurns`, ...) with
|
|
170
|
+
* no conditional drops. The tiered replacement is
|
|
171
|
+
* `applyCrewSettingsTiersToConfig()` which routes the project fragment
|
|
172
|
+
* through `sanitizeProjectConfig` + tighten-only comparison.
|
|
173
|
+
*
|
|
174
|
+
* Do NOT add new call sites that pass a merged/project-influenced settings
|
|
175
|
+
* object. Acceptable use: tests, and user-tier-only tooling that has already
|
|
176
|
+
* separated tiers.
|
|
177
|
+
*/
|
|
138
178
|
export function applyCrewSettingsToConfig(
|
|
139
179
|
config: {
|
|
140
180
|
limits?: { maxConcurrentWorkers?: number };
|
|
@@ -153,3 +193,182 @@ export function applyCrewSettingsToConfig(
|
|
|
153
193
|
if (settings.defaultJoinMode != null && config.runtime) config.runtime.groupJoin = settings.defaultJoinMode;
|
|
154
194
|
if (settings.notifierIntervalMs != null) config.notifierIntervalMs = settings.notifierIntervalMs;
|
|
155
195
|
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Wave 2B (ITEM 1, P1 security): tiered loading + guarded apply.
|
|
199
|
+
//
|
|
200
|
+
// `<cwd>/.pi/crew-settings.json` is PROJECT-tier state — a cloned repository
|
|
201
|
+
// can ship it. `loadCrewSettings()` historically merged it OVER the user-tier
|
|
202
|
+
// `~/.pi/crew-settings.json` with only the type/range allowlist
|
|
203
|
+
// (`sanitizeSettings`), and `applyCrewSettingsToConfig` then wrote the merged
|
|
204
|
+
// result into guard fields (limits.maxConcurrentWorkers,
|
|
205
|
+
// runtime.maxTurns/graceTurns) AFTER `loadConfig()` had already sanitized the
|
|
206
|
+
// regular project config files — a full bypass of `sanitizeProjectConfig`.
|
|
207
|
+
// The functions below close that hole.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** Tiered view of crew settings (see `loadCrewSettingsTiers`). */
|
|
211
|
+
export interface CrewSettingsTiers {
|
|
212
|
+
/** User-tier `~/.pi/crew-settings.json` — fully trusted. */
|
|
213
|
+
user: CrewSettings;
|
|
214
|
+
/** Project-tier `<cwd>/.pi/crew-settings.json` — UNTRUSTED input. */
|
|
215
|
+
project: CrewSettings;
|
|
216
|
+
/** Historical merge view (project wins over user). Kept for the write-path
|
|
217
|
+
* round-trip (`updateCrewSettings`). NOT the registration source for
|
|
218
|
+
* scheduledJobs anymore — use `effectiveScheduledJobs` (Wave B2 gate). */
|
|
219
|
+
merged: CrewSettings;
|
|
220
|
+
/** Wave B2 GATE — what consumers register: user-tier scheduledJobs plus
|
|
221
|
+
* project-tier scheduledJobs ONLY when the user tier explicitly opted in
|
|
222
|
+
* (user.schedulingEnabled === true && user.allowProjectScheduledJobs ===
|
|
223
|
+
* true). User-first concat order. */
|
|
224
|
+
effectiveScheduledJobs: unknown[];
|
|
225
|
+
/** Absolute path of the project-tier file (used in warnings). */
|
|
226
|
+
projectPath: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Wave B2 gate predicate: the user tier opts in to project-tier scheduledJobs
|
|
230
|
+
* only when BOTH flags are explicitly `true` in the user-tier global file.
|
|
231
|
+
* Any other combination (flags absent, either false) opts OUT. */
|
|
232
|
+
function projectScheduledJobsOptIn(user: CrewSettings): boolean {
|
|
233
|
+
return user.schedulingEnabled === true && user.allowProjectScheduledJobs === true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load crew settings keeping the trust tiers distinguishable. The optional
|
|
238
|
+
* `globalFile` parameter defaults to `~/.pi/crew-settings.json` and exists so
|
|
239
|
+
* callers/tests can pin a hermetic user-tier file.
|
|
240
|
+
*/
|
|
241
|
+
export function loadCrewSettingsTiers(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettingsTiers {
|
|
242
|
+
const user = readSettingsFile(globalFile);
|
|
243
|
+
const projectFilePath = projectPath(cwd);
|
|
244
|
+
const project = readSettingsFile(projectFilePath);
|
|
245
|
+
const effectiveScheduledJobs: unknown[] = [
|
|
246
|
+
...(user.scheduledJobs ?? []),
|
|
247
|
+
...(projectScheduledJobsOptIn(user) ? (project.scheduledJobs ?? []) : []),
|
|
248
|
+
];
|
|
249
|
+
return { user, project, merged: { ...user, ...project }, effectiveScheduledJobs, projectPath: projectFilePath };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Apply crew settings to a config with correct trust tiering. Returns the
|
|
254
|
+
* list of drop warnings (standard project-override format).
|
|
255
|
+
*
|
|
256
|
+
* Pipeline for the PROJECT tier (the user tier is fully trusted and applied
|
|
257
|
+
* exactly like the legacy merged semantics):
|
|
258
|
+
* 1. CHOKE POINT — the fragment is mapped into `PiTeamsConfig` shape and run
|
|
259
|
+
* through `sanitizeProjectConfig`, the same schema-driven sanitize every
|
|
260
|
+
* other project-scoped config ingestion passes through (config.ts applies
|
|
261
|
+
* it to pi-teams.json and .pi/pi-crew.json). Today none of the mapped
|
|
262
|
+
* fields are sensitive-marked, so this is behavior-preserving — but any
|
|
263
|
+
* future `sensitive: true` mark or CONDITIONAL_PROJECT_DROPS entry on
|
|
264
|
+
* these paths automatically applies to crew-settings too.
|
|
265
|
+
* 2. TIGHTEN-ONLY TIERING — `limits.maxConcurrentWorkers`,
|
|
266
|
+
* `runtime.maxTurns` and `runtime.graceTurns` are resource guards: a
|
|
267
|
+
* project-tier value that RAISES the effective value (baseline = config
|
|
268
|
+
* after the trusted user tier was applied) is dropped with the standard
|
|
269
|
+
* warning; lowering/equal survives. A value with NO baseline is also
|
|
270
|
+
* dropped: introducing a limit the user never set cannot be verified as
|
|
271
|
+
* tightening, and the only legitimate writer (updateCrewSettings
|
|
272
|
+
* spreading merged settings into the project file) always mirrors a
|
|
273
|
+
* user-tier value, so the equal case survives. This comparison lives HERE
|
|
274
|
+
* and not in CONDITIONAL_PROJECT_DROPS: that table is a fixed value
|
|
275
|
+
* predicate with no access to the effective-config baseline (Wave 2B
|
|
276
|
+
* decision — predicate signature intentionally unchanged).
|
|
277
|
+
* 3. Survivors are applied over the user tier — safe by construction since
|
|
278
|
+
* survivors can only be equal-or-lower guard values.
|
|
279
|
+
*
|
|
280
|
+
* BOUNDARY (Wave B2 — was ITEM 1.4): scheduling is a USER-TIER decision.
|
|
281
|
+
* `schedulingEnabled` is never honored from the project tier — a project file
|
|
282
|
+
* can neither enable nor disable scheduling (no consumer reads it in src/
|
|
283
|
+
* today; if one is added it MUST read only the user tier). Project-tier
|
|
284
|
+
* `scheduledJobs` are DROPPED (warning emitted below + left out of the gated
|
|
285
|
+
* `effectiveScheduledJobs` view that the scheduler registration consumer
|
|
286
|
+
* loops) unless the user-tier global file explicitly opts in with BOTH
|
|
287
|
+
* `schedulingEnabled: true` AND `allowProjectScheduledJobs: true`. The
|
|
288
|
+
* tighten-only philosophy is intentionally NOT applied here: jobs are not
|
|
289
|
+
* numeric bounds — allowing them is an availability decision the human user
|
|
290
|
+
* makes in their global file. UX consequence (accepted): `crew schedule
|
|
291
|
+
* add/update/remove` persists jobs into the PROJECT file (handle-schedule.ts),
|
|
292
|
+
* so crew-schedule users must set both flags in ~/.pi/crew-settings.json or
|
|
293
|
+
* their own jobs stop registering on session_start.
|
|
294
|
+
*
|
|
295
|
+
* `notifierIntervalMs` (UI poll cadence) and `runtime.groupJoin` (join
|
|
296
|
+
* semantics) are not guard fields and ride the choke point only.
|
|
297
|
+
*/
|
|
298
|
+
export function applyCrewSettingsTiersToConfig(config: PiTeamsConfig, tiers: CrewSettingsTiers): string[] {
|
|
299
|
+
const warnings: string[] = [];
|
|
300
|
+
// User tier: fully trusted, identical to the legacy merged semantics.
|
|
301
|
+
applyCrewSettingsToConfig(config, tiers.user);
|
|
302
|
+
|
|
303
|
+
const project = tiers.project;
|
|
304
|
+
// Wave B2 scheduling gate — MUST run BEFORE the empty-fragment early return
|
|
305
|
+
// below: a project file containing ONLY scheduling fields would otherwise
|
|
306
|
+
// skip the warnings entirely. Both warnings are warning-only: neither field
|
|
307
|
+
// maps into PiTeamsConfig; registration consumers read the gated
|
|
308
|
+
// `effectiveScheduledJobs` view instead.
|
|
309
|
+
if (project.schedulingEnabled !== undefined) {
|
|
310
|
+
// Project can neither enable nor disable scheduling — always dropped.
|
|
311
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, "schedulingEnabled"));
|
|
312
|
+
}
|
|
313
|
+
if (!projectScheduledJobsOptIn(tiers.user) && Array.isArray(project.scheduledJobs) && project.scheduledJobs.length > 0) {
|
|
314
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, "scheduledJobs"));
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
project.maxConcurrent == null &&
|
|
318
|
+
project.defaultMaxTurns == null &&
|
|
319
|
+
project.graceTurns == null &&
|
|
320
|
+
project.defaultJoinMode == null &&
|
|
321
|
+
project.notifierIntervalMs == null
|
|
322
|
+
) {
|
|
323
|
+
return warnings;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const fragment: PiTeamsConfig = {};
|
|
327
|
+
if (project.maxConcurrent != null) fragment.limits = { maxConcurrentWorkers: project.maxConcurrent };
|
|
328
|
+
if (project.defaultMaxTurns != null || project.graceTurns != null || project.defaultJoinMode != null) {
|
|
329
|
+
const runtime: NonNullable<PiTeamsConfig["runtime"]> = {};
|
|
330
|
+
if (project.defaultMaxTurns != null) runtime.maxTurns = project.defaultMaxTurns;
|
|
331
|
+
if (project.graceTurns != null) runtime.graceTurns = project.graceTurns;
|
|
332
|
+
// Legacy quirk (kept for behavior parity): crew-settings JoinMode allows
|
|
333
|
+
// "async", which is not part of runtime.groupJoin's union — the old
|
|
334
|
+
// structural param type let this through implicitly.
|
|
335
|
+
if (project.defaultJoinMode != null)
|
|
336
|
+
runtime.groupJoin = project.defaultJoinMode as NonNullable<PiTeamsConfig["runtime"]>["groupJoin"];
|
|
337
|
+
fragment.runtime = runtime;
|
|
338
|
+
}
|
|
339
|
+
if (project.notifierIntervalMs != null) fragment.notifierIntervalMs = project.notifierIntervalMs;
|
|
340
|
+
|
|
341
|
+
const sanitized = sanitizeProjectConfig(tiers.projectPath, config, fragment);
|
|
342
|
+
warnings.push(...sanitized.warnings);
|
|
343
|
+
|
|
344
|
+
const guards: Array<{ dotted: string; value: number | undefined; baseline: number | undefined }> = [
|
|
345
|
+
{
|
|
346
|
+
dotted: "limits.maxConcurrentWorkers",
|
|
347
|
+
value: sanitized.config.limits?.maxConcurrentWorkers,
|
|
348
|
+
baseline: config.limits?.maxConcurrentWorkers,
|
|
349
|
+
},
|
|
350
|
+
{ dotted: "runtime.maxTurns", value: sanitized.config.runtime?.maxTurns, baseline: config.runtime?.maxTurns },
|
|
351
|
+
{ dotted: "runtime.graceTurns", value: sanitized.config.runtime?.graceTurns, baseline: config.runtime?.graceTurns },
|
|
352
|
+
];
|
|
353
|
+
const dropped = new Set<string>();
|
|
354
|
+
for (const guard of guards) {
|
|
355
|
+
if (guard.value === undefined) continue;
|
|
356
|
+
if (guard.baseline === undefined || guard.value > guard.baseline) {
|
|
357
|
+
dropped.add(guard.dotted);
|
|
358
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, guard.dotted));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!dropped.has("limits.maxConcurrentWorkers") && sanitized.config.limits?.maxConcurrentWorkers != null && config.limits) {
|
|
363
|
+
config.limits.maxConcurrentWorkers = sanitized.config.limits.maxConcurrentWorkers;
|
|
364
|
+
}
|
|
365
|
+
if (sanitized.config.runtime != null && config.runtime) {
|
|
366
|
+
if (!dropped.has("runtime.maxTurns") && sanitized.config.runtime.maxTurns != null)
|
|
367
|
+
config.runtime.maxTurns = sanitized.config.runtime.maxTurns;
|
|
368
|
+
if (!dropped.has("runtime.graceTurns") && sanitized.config.runtime.graceTurns != null)
|
|
369
|
+
config.runtime.graceTurns = sanitized.config.runtime.graceTurns;
|
|
370
|
+
if (sanitized.config.runtime.groupJoin != null) config.runtime.groupJoin = sanitized.config.runtime.groupJoin;
|
|
371
|
+
}
|
|
372
|
+
if (sanitized.config.notifierIntervalMs != null) config.notifierIntervalMs = sanitized.config.notifierIntervalMs;
|
|
373
|
+
return warnings;
|
|
374
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn policy for delegate-driven grandchild admission (ADR-5, WP-5 step 3).
|
|
3
|
+
*
|
|
4
|
+
* Pure evaluation — no I/O, no state mutation. The root-side delegate handler
|
|
5
|
+
* (broker `delegate` method, WP-5 step 5) gathers the inputs and calls
|
|
6
|
+
* `evaluateDelegateAdmission`; on denial it returns the fail-fast message to
|
|
7
|
+
* the worker and logs `delegate.rejected` to events.jsonl (never silent).
|
|
8
|
+
*
|
|
9
|
+
* Gate dimensions (each with a distinct machine-readable reason + fail-fast
|
|
10
|
+
* message; evaluated in this order — cheapest/most-global first):
|
|
11
|
+
* 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
|
|
12
|
+
* 2. role-denied — the PARENT task's role is not executor-class
|
|
13
|
+
* (read-only roles cannot raise spawn privileges),
|
|
14
|
+
* or the requested grandchild role is not one of
|
|
15
|
+
* the delegate-tool's allowed roles
|
|
16
|
+
* 3. trust-denied — untrusted escalation context (manual-only trust
|
|
17
|
+
* gate; the handler resolves trust, policy enforces)
|
|
18
|
+
* 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
|
|
19
|
+
* resolved maxDepth (default 2). Depth comes from
|
|
20
|
+
* the parent task RECORD, never worker env
|
|
21
|
+
* (design §7 rev-2 P0-2)
|
|
22
|
+
* 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
|
|
23
|
+
* fail-fast, never queue
|
|
24
|
+
* 6. budget-insufficient— requested budgetTokens exceed the parent task's
|
|
25
|
+
* remaining allocation (tokensGranted - tokensSpent)
|
|
26
|
+
* 7. model-invalid — requested model is not in the resolved model
|
|
27
|
+
* catalog (the unvalidated provider/model
|
|
28
|
+
* pass-through — model-fallback.ts:282, the
|
|
29
|
+
* 429-cascade root — must not be reachable here)
|
|
30
|
+
* 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { modelRefToString } from "./model/model-fallback.ts";
|
|
34
|
+
import type { NestedSlotSnapshot } from "./scheduling/nested-slots.ts";
|
|
35
|
+
|
|
36
|
+
/** Delegate tool's allowed grandchild roles (design §7 surface). */
|
|
37
|
+
export const DELEGATE_ALLOWED_ROLES = ["explorer", "analyst", "executor"] as const;
|
|
38
|
+
export type DelegateRole = (typeof DELEGATE_ALLOWED_ROLES)[number];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Roles permitted to CALL delegate (executor-class). Read-only/review roles
|
|
42
|
+
* (explorer, analyst, planner, critic, reviewer, verifier, writer,
|
|
43
|
+
* security-reviewer) are denied — delegation is a spawn privilege.
|
|
44
|
+
*/
|
|
45
|
+
export const EXECUTOR_CLASS_ROLES = ["executor", "test-engineer"] as const;
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
|
|
48
|
+
export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
|
|
49
|
+
|
|
50
|
+
export type SpawnPolicyDenyReason =
|
|
51
|
+
| "nesting-disabled"
|
|
52
|
+
| "role-denied"
|
|
53
|
+
| "trust-denied"
|
|
54
|
+
| "depth-exceeded"
|
|
55
|
+
| "slots-exhausted"
|
|
56
|
+
| "budget-insufficient"
|
|
57
|
+
| "model-invalid"
|
|
58
|
+
| "timeout-invalid"
|
|
59
|
+
| "workspace-conflict";
|
|
60
|
+
|
|
61
|
+
export interface DelegateAdmissionInput {
|
|
62
|
+
/** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
|
|
63
|
+
nestingEnabled: boolean;
|
|
64
|
+
/** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
|
|
65
|
+
maxDepth: number;
|
|
66
|
+
/** Parent task record fields the policy is allowed to trust. */
|
|
67
|
+
parentTask: {
|
|
68
|
+
taskId: string;
|
|
69
|
+
role: string;
|
|
70
|
+
/** Absent on pre-v2 records — treated as depth 1 (a worker). */
|
|
71
|
+
depth?: number;
|
|
72
|
+
allocation?: { tokensGranted?: number; tokensSpent?: number };
|
|
73
|
+
};
|
|
74
|
+
/** Untrusted escalation context — the delegate handler resolves trust. */
|
|
75
|
+
untrusted?: boolean;
|
|
76
|
+
/** Nested-slot budget snapshot (used/max). */
|
|
77
|
+
slots: NestedSlotSnapshot;
|
|
78
|
+
/** Requested grandchild parameters (all optional in the tool surface). */
|
|
79
|
+
requested?: {
|
|
80
|
+
role?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
budgetTokens?: number;
|
|
83
|
+
timeoutSec?: number;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Resolved model catalog as canonical `provider/id` strings. When omitted
|
|
87
|
+
* (catalog unavailable at the call-site) model validation is SKIPPED —
|
|
88
|
+
* flag this in review; the broker handler must always supply it.
|
|
89
|
+
*/
|
|
90
|
+
modelCatalog?: readonly string[];
|
|
91
|
+
/** ADR-5 §9 workspace interaction: when the requested grandchild role is
|
|
92
|
+
* write-capable (executor-class), an overlapping in-flight executor can
|
|
93
|
+
* only be tolerated when serialization is established. `serializeEnabled`
|
|
94
|
+
* mirrors config `limits.serializeOnPathOverlap`; `overlappingInFlightExecutors`
|
|
95
|
+
* counts OTHER running executor-class tasks sharing the parent task's cwd. */
|
|
96
|
+
workspace?: {
|
|
97
|
+
serializeEnabled?: boolean;
|
|
98
|
+
overlappingInFlightExecutors?: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SpawnPolicyDecision {
|
|
103
|
+
allowed: boolean;
|
|
104
|
+
/** Machine-readable deny reason (undefined when allowed). */
|
|
105
|
+
reason?: SpawnPolicyDenyReason;
|
|
106
|
+
/** Fail-fast human message (undefined when allowed). */
|
|
107
|
+
message?: string;
|
|
108
|
+
/** The grandchild's depth (parent depth + 1) on success. */
|
|
109
|
+
childDepth?: number;
|
|
110
|
+
/** Effective timeout in seconds (requested or default 900) on success. */
|
|
111
|
+
timeoutSec?: number;
|
|
112
|
+
/** Effective model (normalized) on success. */
|
|
113
|
+
model?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isExecutorClassRole(role: string): boolean {
|
|
117
|
+
return (EXECUTOR_CLASS_ROLES as readonly string[]).includes(role);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecision {
|
|
121
|
+
return { allowed: false, reason, message };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
|
|
125
|
+
const { nestingEnabled, maxDepth, parentTask, requested } = input;
|
|
126
|
+
const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
|
|
127
|
+
const childDepth = parentDepth + 1;
|
|
128
|
+
|
|
129
|
+
// 1. Flag gate — fail-closed default (ADR-5 §10).
|
|
130
|
+
if (!nestingEnabled) {
|
|
131
|
+
return deny(
|
|
132
|
+
"nesting-disabled",
|
|
133
|
+
"delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Role gate — parent must be executor-class; requested role must be in
|
|
138
|
+
// the tool's allowed set.
|
|
139
|
+
if (!isExecutorClassRole(parentTask.role)) {
|
|
140
|
+
return deny(
|
|
141
|
+
"role-denied",
|
|
142
|
+
`delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const requestedRole = requested?.role;
|
|
146
|
+
if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
|
|
147
|
+
return deny(
|
|
148
|
+
"role-denied",
|
|
149
|
+
`delegate rejected: requested grandchild role '${requestedRole}' is not one of ${DELEGATE_ALLOWED_ROLES.join("/")}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Trust gate — untrusted escalation context never spawns.
|
|
154
|
+
if (input.untrusted) {
|
|
155
|
+
return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Depth gate — computed from the parent RECORD (never worker env).
|
|
159
|
+
if (childDepth > maxDepth) {
|
|
160
|
+
return deny(
|
|
161
|
+
"depth-exceeded",
|
|
162
|
+
`delegate rejected: spawn depth ${childDepth} exceeds maxDepth ${maxDepth} (parent task '${parentTask.taskId}' is at depth ${parentDepth})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 5. Nested-slot budget — fail-fast, never queue.
|
|
167
|
+
if (input.slots.used >= input.slots.max) {
|
|
168
|
+
return deny(
|
|
169
|
+
"slots-exhausted",
|
|
170
|
+
`delegate rejected: nested spawn budget exhausted; ${input.slots.used}/${input.slots.max} in flight`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 6. Parent allocation sufficiency — reserve up-front or reject.
|
|
175
|
+
if (requested?.budgetTokens !== undefined) {
|
|
176
|
+
const granted = parentTask.allocation?.tokensGranted ?? 0;
|
|
177
|
+
const spent = parentTask.allocation?.tokensSpent ?? 0;
|
|
178
|
+
const remaining = granted - spent;
|
|
179
|
+
if (requested.budgetTokens > remaining) {
|
|
180
|
+
return deny(
|
|
181
|
+
"budget-insufficient",
|
|
182
|
+
`delegate rejected: requested budget ${requested.budgetTokens} tokens exceeds parent task '${parentTask.taskId}' remaining allocation ${remaining} (${granted} granted - ${spent} spent)`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 7. Model validation against the resolved catalog.
|
|
188
|
+
const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
|
|
189
|
+
if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
|
|
190
|
+
if (!input.modelCatalog.includes(normalizedModel)) {
|
|
191
|
+
return deny("model-invalid", `delegate rejected: model '${requested?.model}' is not in the resolved model catalog`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 8. Timeout validation (mandatory default 900).
|
|
196
|
+
const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
|
|
197
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
|
|
198
|
+
return deny(
|
|
199
|
+
"timeout-invalid",
|
|
200
|
+
`delegate rejected: timeoutSec ${String(requested?.timeoutSec)} is outside 1..${MAX_DELEGATE_TIMEOUT_SEC} (default ${DEFAULT_DELEGATE_TIMEOUT_SEC})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
|
|
205
|
+
// the parent's cwd with ANOTHER in-flight executor is only admitted when
|
|
206
|
+
// serialization is established (limits.serializeOnPathOverlap) — otherwise
|
|
207
|
+
// reject; read-only grandchild roles (explorer/analyst) never conflict.
|
|
208
|
+
const requestedIsWriteCapable = requestedRole !== undefined && (EXECUTOR_CLASS_ROLES as readonly string[]).includes(requestedRole);
|
|
209
|
+
if (requestedIsWriteCapable && !input.workspace?.serializeEnabled && (input.workspace?.overlappingInFlightExecutors ?? 0) > 0) {
|
|
210
|
+
return deny(
|
|
211
|
+
"workspace-conflict",
|
|
212
|
+
`delegate rejected: parent task '${parentTask.taskId}' cwd overlaps ${input.workspace?.overlappingInFlightExecutors} in-flight executor(s) and limits.serializeOnPathOverlap is off — use a read-only grandchild role (explorer/analyst) or enable serialization`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { allowed: true, childDepth, timeoutSec, ...(normalizedModel !== undefined ? { model: normalizedModel } : {}) };
|
|
217
|
+
}
|