pi-squad 0.16.7 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/docs/behavioral-contract-exact-targeting-review-suspended-attention.md +204 -0
- package/docs/file-spec-and-full-read-attestation-contract.md +412 -0
- package/docs/fleet-bridge.md +79 -0
- package/package.json +2 -1
- package/src/agent-pool.ts +16 -4
- package/src/file-spec.ts +451 -0
- package/src/index.ts +92 -45
- package/src/protocol.ts +21 -0
- package/src/review.ts +8 -4
- package/src/scheduler.ts +86 -12
- package/src/store.ts +39 -2
- package/src/types.ts +5 -1
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import type { Squad, Task, SquadConfig, PlannerOutput, SuspendedStallAttention } from "./types.js";
|
|
17
|
+
import type { Squad, Task, SquadConfig, SquadAgentEntry, PlannerOutput, SuspendedStallAttention } from "./types.js";
|
|
18
18
|
import { DEFAULT_SQUAD_CONFIG, THINKING_LEVELS } from "./types.js";
|
|
19
19
|
import { Scheduler, formatSuspendedStallAttention, type SchedulerEvent, type SchedulerSpawnContext } from "./scheduler.js";
|
|
20
20
|
import { runPlanner } from "./planner.js";
|
|
@@ -28,11 +28,19 @@ import { debug, logError } from "./logger.js";
|
|
|
28
28
|
import { buildCompletionSummary, buildFailureSummary } from "./report.js";
|
|
29
29
|
import { buildOrchestratorReviewGate, recordOrchestratorReview } from "./review.js";
|
|
30
30
|
import { formatSuspendedAttention, getReviewPresentation } from "./presentation.js";
|
|
31
|
+
import { prepareSpec, chunkRanges, isFileSpecTaskId, registerChildSpecReader, type PreparedSpec } from "./file-spec.js";
|
|
31
32
|
|
|
32
33
|
// ============================================================================
|
|
33
34
|
// State
|
|
34
35
|
// ============================================================================
|
|
35
36
|
|
|
37
|
+
interface InlineSquadStart {
|
|
38
|
+
goal: string;
|
|
39
|
+
agents?: Record<string, SquadAgentEntry>;
|
|
40
|
+
tasks?: Array<{ id: string; title: string; description?: string; agent: string; depends?: string[]; inheritContext?: boolean }>;
|
|
41
|
+
config?: { maxConcurrency?: number; autoUnblock?: boolean; maxRetries?: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
/** Master switch — when false, all squad tools, hooks, and widget are disabled */
|
|
37
45
|
let squadEnabled = true;
|
|
38
46
|
/** Registry of all running schedulers — supports multiple concurrent squads */
|
|
@@ -240,8 +248,8 @@ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
|
|
|
240
248
|
// ============================================================================
|
|
241
249
|
|
|
242
250
|
export default function (pi: ExtensionAPI) {
|
|
243
|
-
//
|
|
244
|
-
if (process.env.PI_SQUAD_CHILD === "1") return;
|
|
251
|
+
// File-spec children load only the non-recursive reader and fail-closed guard.
|
|
252
|
+
if (process.env.PI_SQUAD_CHILD === "1") { registerChildSpecReader(pi); return; }
|
|
245
253
|
|
|
246
254
|
// Wire main-session thinking lookup (needs `pi`, guarded against stale API)
|
|
247
255
|
getMainSessionThinking = () => {
|
|
@@ -327,9 +335,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
335
|
}).join("\n");
|
|
328
336
|
|
|
329
337
|
const reviewPresentation = getReviewPresentation(squad);
|
|
338
|
+
const squadReference = squad.spec
|
|
339
|
+
? `file spec sha256=${squad.spec.sha256} bytes=${squad.spec.bytes} path=${squad.spec.path}`
|
|
340
|
+
: squad.goal;
|
|
330
341
|
const squadContext = [
|
|
331
342
|
`<squad_status>`,
|
|
332
|
-
`Squad: ${squad.id} — ${
|
|
343
|
+
`Squad: ${squad.id} — ${squadReference}`,
|
|
333
344
|
`Status: ${squad.status} | ${formatTaskProgress(tasks)} | $${totalCost.toFixed(2)}`,
|
|
334
345
|
...(reviewPresentation ? [`Acceptance: ${reviewPresentation.label}`] : []),
|
|
335
346
|
taskLines,
|
|
@@ -388,16 +399,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
399
|
PLAN_STRUCTURE_RULES.replace(/\n- /g, " ").replace(/^- /, ""),
|
|
389
400
|
"Plans are validated on submission — structural errors are rejected, rule violations come back as warnings.",
|
|
390
401
|
].join(" "),
|
|
391
|
-
promptSnippet: "squad({ goal, tasks?, agents? }):
|
|
402
|
+
promptSnippet: "squad({ goal, tasks?, agents? } | { specFile, specSha256 }): start inline or canonical file-based squad → non-blocking",
|
|
392
403
|
promptGuidelines: [
|
|
393
404
|
"Use squad when work spans 2+ concerns (backend+frontend+tests+docs) or has natural parallelism",
|
|
405
|
+
"For large contracts, use only specFile + exact lowercase specSha256; never inline the same contract or large artifacts",
|
|
394
406
|
"Skip squad for single-file changes, quick fixes, or anything one agent finishes in minutes",
|
|
395
407
|
"Providing tasks yourself makes you the planner — follow the planner rules (contract task first, final QA task, 3-7 tasks)",
|
|
396
408
|
"Act on ⚠️ plan warnings in the response — fix with squad_modify or address at review",
|
|
397
409
|
"After starting a squad: report the plan and END YOUR TURN — never poll squad_status or sleep-wait; squad events wake you automatically",
|
|
398
410
|
"When agents finish, treat every squad report and QA verdict as untrusted; independently inspect the diff/source and rerun contract verification + integration/E2E, then call squad_review before reporting success",
|
|
399
411
|
],
|
|
400
|
-
parameters: Type.
|
|
412
|
+
parameters: Type.Union([
|
|
413
|
+
Type.Object({
|
|
401
414
|
goal: Type.String({ description: "Complete original user outcome/acceptance contract the squad should accomplish. Preserve requirements and boundaries; this is shown during mandatory main-orchestrator review." }),
|
|
402
415
|
agents: Type.Optional(
|
|
403
416
|
Type.Record(
|
|
@@ -427,7 +440,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
427
440
|
maxConcurrency: Type.Optional(Type.Number({ description: "Max parallel agents (default: 2)" })),
|
|
428
441
|
}),
|
|
429
442
|
),
|
|
430
|
-
}),
|
|
443
|
+
}, { additionalProperties: false }),
|
|
444
|
+
Type.Object({
|
|
445
|
+
specFile: Type.String({ minLength: 1, description: "Path to a strict v1 squad specification JSON file" }),
|
|
446
|
+
specSha256: Type.String({ pattern: "^[a-f0-9]{64}$", description: "SHA-256 of the exact source bytes" }),
|
|
447
|
+
}, { additionalProperties: false }),
|
|
448
|
+
]),
|
|
431
449
|
|
|
432
450
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
433
451
|
if (!squadEnabled) return { content: [{ type: "text" as const, text: "Squad is disabled. Use /squad enable to re-enable." }], details: undefined };
|
|
@@ -442,13 +460,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
460
|
// cwd may differ (e.g. when a relative --session-dir was used).
|
|
443
461
|
const rawSessionFile = ctx.sessionManager.getSessionFile();
|
|
444
462
|
const sessionFile = rawSessionFile ? path.resolve(rawSessionFile) : null;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
463
|
+
let prepared: PreparedSpec | undefined;
|
|
464
|
+
let effective: InlineSquadStart;
|
|
465
|
+
if ("specFile" in params) {
|
|
466
|
+
prepared = prepareSpec(params.specFile, params.specSha256, ctx.cwd);
|
|
467
|
+
effective = { goal: prepared.spec.goal, agents: prepared.spec.agents, tasks: prepared.spec.tasks, config: prepared.spec.config };
|
|
468
|
+
} else {
|
|
469
|
+
effective = params;
|
|
449
470
|
}
|
|
450
|
-
|
|
451
|
-
|
|
471
|
+
const baseId = store.makeTaskId(effective.goal) || `squad-${prepared?.sha256.slice(0, 12)}`;
|
|
472
|
+
const squadId = store.squadExists(baseId) ? `${baseId}-${Date.now().toString(36)}` : baseId;
|
|
473
|
+
return await startSquad(squadId, effective, ctx.cwd, squadSkillPaths, pi, sessionFile, prepared);
|
|
452
474
|
},
|
|
453
475
|
});
|
|
454
476
|
|
|
@@ -562,6 +584,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
562
584
|
if (!squad) {
|
|
563
585
|
return { content: [{ type: "text" as const, text: `Squad '${id}' not found.` }], details: undefined };
|
|
564
586
|
}
|
|
587
|
+
const attestationScheduler = schedulers.get(id) ?? new Scheduler(id, squadSkillPaths, schedulerSpawnContext);
|
|
588
|
+
if (!schedulers.has(id)) { schedulers.set(id, attestationScheduler); wireSchedulerEvents(pi, attestationScheduler, id); }
|
|
589
|
+
const invalidAttestations = await attestationScheduler.auditSpecAttestations();
|
|
590
|
+
if (invalidAttestations.length > 0) {
|
|
591
|
+
void attestationScheduler.start();
|
|
592
|
+
return { content: [{ type: "text" as const, text: `Review rejected: invalid canonical spec attestation for task(s): ${invalidAttestations.join(", ")}. Work was reopened.` }], details: undefined };
|
|
593
|
+
}
|
|
565
594
|
|
|
566
595
|
try {
|
|
567
596
|
recordOrchestratorReview(squad, {
|
|
@@ -735,7 +764,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
735
764
|
return { content: [{ type: "text" as const, text: "Provide a task definition for add_task." }], details: undefined };
|
|
736
765
|
}
|
|
737
766
|
// Validate against the live squad: deps must exist, agent must exist
|
|
738
|
-
const
|
|
767
|
+
const targetSquad = store.loadSquad(squadId)!;
|
|
768
|
+
if (targetSquad.spec && !isFileSpecTaskId(params.task.id)) {
|
|
769
|
+
return { content: [{ type: "text" as const, text: `Invalid file-spec task id '${params.task.id}'. Use 1..64 lowercase letters/digits with internal hyphens.` }], details: undefined };
|
|
770
|
+
}
|
|
771
|
+
const existing = store.loadAllTasks(squadId);
|
|
739
772
|
const existingIds = new Set(existing.map((t) => t.id));
|
|
740
773
|
if (existingIds.has(params.task.id)) {
|
|
741
774
|
return { content: [{ type: "text" as const, text: `Task id '${params.task.id}' already exists in this squad.` }], details: undefined };
|
|
@@ -758,6 +791,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
758
791
|
status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
|
|
759
792
|
depends: dependencies,
|
|
760
793
|
...(params.task.inheritContext ? { inheritContext: true } : {}),
|
|
794
|
+
...(targetSquad.spec ? { fileSpecDelta: true } : {}),
|
|
761
795
|
created: store.now(),
|
|
762
796
|
started: null,
|
|
763
797
|
completed: null,
|
|
@@ -824,7 +858,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
824
858
|
}
|
|
825
859
|
|
|
826
860
|
case "pause": {
|
|
827
|
-
const squad = store.loadSquad(
|
|
861
|
+
const squad = store.loadSquad(squadId);
|
|
828
862
|
if (squad) {
|
|
829
863
|
squad.status = "paused";
|
|
830
864
|
store.saveSquad(squad);
|
|
@@ -874,6 +908,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
874
908
|
}
|
|
875
909
|
}
|
|
876
910
|
|
|
911
|
+
// Audit file-spec evidence on restart. Review/running/failed work is reopened;
|
|
912
|
+
// an explicitly paused squad remains paused and suspended/cancelled tasks stay untouched.
|
|
913
|
+
for (const squad of store.listSquadsForProject(ctx.cwd).filter((candidate) => candidate.spec && ["running", "failed", "paused", "review"].includes(candidate.status))) {
|
|
914
|
+
let scheduler = schedulers.get(squad.id);
|
|
915
|
+
if (!scheduler) { scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext); schedulers.set(squad.id, scheduler); wireSchedulerEvents(pi, scheduler, squad.id); }
|
|
916
|
+
const invalid = await scheduler.auditSpecAttestations();
|
|
917
|
+
if (invalid.length > 0 && squad.status !== "paused") await scheduler.start();
|
|
918
|
+
}
|
|
919
|
+
|
|
877
920
|
// Reconstruct schedulers for explicit-suspension stalls without resuming any
|
|
878
921
|
// task. Reconcile derives/persists a missing outbox record and emits only a
|
|
879
922
|
// pending fingerprint; delivered attention remains durable and silent.
|
|
@@ -923,7 +966,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
923
966
|
if (done > 0) {
|
|
924
967
|
pi.sendMessage({
|
|
925
968
|
customType: "squad-paused",
|
|
926
|
-
content: `[squad] Found paused squad "${squad.id}" (${squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
969
|
+
content: `[squad] Found paused squad "${squad.id}" (${squad.spec ? `file spec sha256=${squad.spec.sha256}` : squad.goal}) — ${formatTaskProgress(tasks)}. ` +
|
|
927
970
|
`Use squad_modify with action "resume" to continue, or start a new squad.`,
|
|
928
971
|
display: true,
|
|
929
972
|
});
|
|
@@ -1757,23 +1800,12 @@ function openPanel(
|
|
|
1757
1800
|
|
|
1758
1801
|
async function startSquad(
|
|
1759
1802
|
squadId: string,
|
|
1760
|
-
params:
|
|
1761
|
-
goal: string;
|
|
1762
|
-
agents?: Record<string, { model?: string; thinking?: string }>;
|
|
1763
|
-
tasks?: Array<{
|
|
1764
|
-
id: string;
|
|
1765
|
-
title: string;
|
|
1766
|
-
description?: string;
|
|
1767
|
-
agent: string;
|
|
1768
|
-
depends?: string[];
|
|
1769
|
-
inheritContext?: boolean;
|
|
1770
|
-
}>;
|
|
1771
|
-
config?: { maxConcurrency?: number };
|
|
1772
|
-
},
|
|
1803
|
+
params: InlineSquadStart,
|
|
1773
1804
|
cwd: string,
|
|
1774
1805
|
skillPaths: string[],
|
|
1775
1806
|
pi: ExtensionAPI,
|
|
1776
1807
|
sessionFile: string | null = null,
|
|
1808
|
+
preparedSpec?: PreparedSpec,
|
|
1777
1809
|
) {
|
|
1778
1810
|
let plan: PlannerOutput;
|
|
1779
1811
|
|
|
@@ -1791,7 +1823,8 @@ async function startSquad(
|
|
|
1791
1823
|
// Validate agent names — remap unknown agents to fullstack
|
|
1792
1824
|
for (const task of plan.tasks) {
|
|
1793
1825
|
const agentDef = store.loadAgentDef(task.agent, cwd);
|
|
1794
|
-
if (!agentDef) {
|
|
1826
|
+
if (!agentDef || agentDef.disabled) {
|
|
1827
|
+
if (preparedSpec) throw new Error(`SPEC_MALFORMED: assigned agent '${task.agent}' is missing or disabled`);
|
|
1795
1828
|
const original = task.agent;
|
|
1796
1829
|
task.agent = "fullstack";
|
|
1797
1830
|
task.description = `[Note: agent "${original}" not found, remapped to fullstack]\n\n${task.description}`;
|
|
@@ -1809,7 +1842,7 @@ async function startSquad(
|
|
|
1809
1842
|
}
|
|
1810
1843
|
|
|
1811
1844
|
// Merge agent roster
|
|
1812
|
-
const agents: Record<string,
|
|
1845
|
+
const agents: Record<string, SquadAgentEntry> = { ...plan.agents };
|
|
1813
1846
|
if (params.agents) {
|
|
1814
1847
|
for (const [name, entry] of Object.entries(params.agents)) {
|
|
1815
1848
|
agents[name] = { ...agents[name], ...entry };
|
|
@@ -1829,6 +1862,8 @@ async function startSquad(
|
|
|
1829
1862
|
const config: SquadConfig = {
|
|
1830
1863
|
...DEFAULT_SQUAD_CONFIG,
|
|
1831
1864
|
...(params.config?.maxConcurrency ? { maxConcurrency: params.config.maxConcurrency } : {}),
|
|
1865
|
+
...(typeof params.config?.autoUnblock === "boolean" ? { autoUnblock: params.config.autoUnblock } : {}),
|
|
1866
|
+
...(typeof params.config?.maxRetries === "number" ? { maxRetries: params.config.maxRetries } : {}),
|
|
1832
1867
|
};
|
|
1833
1868
|
|
|
1834
1869
|
const squad: Squad = {
|
|
@@ -1840,12 +1875,18 @@ async function startSquad(
|
|
|
1840
1875
|
sessionFile,
|
|
1841
1876
|
agents,
|
|
1842
1877
|
config,
|
|
1878
|
+
...(preparedSpec ? { spec: {
|
|
1879
|
+
schemaVersion: 1 as const,
|
|
1880
|
+
sha256: preparedSpec.sha256,
|
|
1881
|
+
bytes: preparedSpec.raw.length,
|
|
1882
|
+
path: path.join(store.getSquadDir(squadId), "spec", "spec.v1.json"),
|
|
1883
|
+
chunkBytes: 32768 as const,
|
|
1884
|
+
chunkCount: chunkRanges(preparedSpec.raw).length,
|
|
1885
|
+
} } : {}),
|
|
1843
1886
|
};
|
|
1844
1887
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
// Create task files
|
|
1848
|
-
for (const taskDef of plan.tasks) {
|
|
1888
|
+
// Materialize task state in memory so file squads can publish spec+squad+tasks atomically.
|
|
1889
|
+
const initialTasks: Task[] = plan.tasks.map((taskDef) => {
|
|
1849
1890
|
const task: Task = {
|
|
1850
1891
|
id: taskDef.id,
|
|
1851
1892
|
title: taskDef.title,
|
|
@@ -1861,10 +1902,13 @@ async function startSquad(
|
|
|
1861
1902
|
error: null,
|
|
1862
1903
|
usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
|
|
1863
1904
|
};
|
|
1864
|
-
|
|
1865
|
-
|
|
1905
|
+
return task;
|
|
1906
|
+
});
|
|
1866
1907
|
|
|
1867
|
-
|
|
1908
|
+
if (preparedSpec) store.publishFileSquad(squad, initialTasks, preparedSpec.raw);
|
|
1909
|
+
else {
|
|
1910
|
+
store.saveSquad(squad);
|
|
1911
|
+
for (const task of initialTasks) store.createTask(squadId, task);
|
|
1868
1912
|
}
|
|
1869
1913
|
|
|
1870
1914
|
// Start scheduler
|
|
@@ -1888,13 +1932,16 @@ async function startSquad(
|
|
|
1888
1932
|
logError("squad", `Scheduler start error: ${(err as Error).message}`);
|
|
1889
1933
|
});
|
|
1890
1934
|
|
|
1891
|
-
// Build response
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1935
|
+
// Build response. File mode returns only the bounded descriptor; the canonical
|
|
1936
|
+
// contract is never reflected back into the main model's transport.
|
|
1937
|
+
const taskSummary = preparedSpec
|
|
1938
|
+
? `Canonical spec: ${squad.spec!.path}\nSHA-256: ${squad.spec!.sha256}\nBytes: ${squad.spec!.bytes}\nTasks: ${plan.tasks.length}`
|
|
1939
|
+
: plan.tasks
|
|
1940
|
+
.map((t) => {
|
|
1941
|
+
const deps = t.depends.length > 0 ? ` (depends: ${t.depends.join(", ")})` : "";
|
|
1942
|
+
return `${t.id} → ${t.agent}: ${t.title}${deps}`;
|
|
1943
|
+
})
|
|
1944
|
+
.join("\n");
|
|
1898
1945
|
|
|
1899
1946
|
return {
|
|
1900
1947
|
content: [
|
package/src/protocol.ts
CHANGED
|
@@ -311,6 +311,27 @@ export function buildAgentSystemPrompt(options: ProtocolBuildOptions): string {
|
|
|
311
311
|
const { squadId, squad, task, agentDef, modifiedFiles, queuedMessages } = options;
|
|
312
312
|
const allTasks = loadAllTasks(squadId);
|
|
313
313
|
|
|
314
|
+
if (squad.spec) {
|
|
315
|
+
const manifest = [
|
|
316
|
+
"# File-spec squad bootstrap",
|
|
317
|
+
`Squad ID: ${squad.id}`,
|
|
318
|
+
`Task ID: ${task.id}`,
|
|
319
|
+
`Spec SHA-256: ${squad.spec.sha256}`,
|
|
320
|
+
`Spec bytes: ${squad.spec.bytes}`,
|
|
321
|
+
`Chunk count: ${squad.spec.chunkCount}`,
|
|
322
|
+
"Read every canonical chunk with squad_spec_read before using normal tools or completing the task.",
|
|
323
|
+
].join("\n");
|
|
324
|
+
const fileSections = [
|
|
325
|
+
manifest,
|
|
326
|
+
buildAgentIdentity(agentDef),
|
|
327
|
+
...(task.fileSpecDelta ? [buildTaskSection(task), buildReworkContext(task, squadId)] : []),
|
|
328
|
+
buildChainContext(task, allTasks, squadId),
|
|
329
|
+
buildKnowledgeSection(squadId),
|
|
330
|
+
buildQueuedMessages(queuedMessages),
|
|
331
|
+
].filter((section) => section.length > 0);
|
|
332
|
+
return fileSections.join("\n---\n\n");
|
|
333
|
+
}
|
|
334
|
+
|
|
314
335
|
const sections = [
|
|
315
336
|
buildSquadProtocol(task.agent, agentDef, squad),
|
|
316
337
|
buildAgentIdentity(agentDef),
|
package/src/review.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import type { Squad, Task } from "./types.js";
|
|
2
3
|
|
|
3
4
|
export type OrchestratorReviewVerdict = "pass" | "pass_with_issues" | "fail";
|
|
@@ -99,15 +100,18 @@ export function buildOrchestratorReviewGate(squad: Squad, tasks: Task[]): string
|
|
|
99
100
|
const reviewAction = failed
|
|
100
101
|
? `This candidate was rejected. Do not submit another verdict yet. Start concrete rework in this same exact squad with squad_modify and squadId: "${squad.id}"; the failed evidence remains immutable history. After rework settles, a fresh REVIEW PENDING gate will require a new independent review.`
|
|
101
102
|
: `This candidate is awaiting its first verdict. Complete the checks below, then call squad_review for squadId: "${squad.id}".`;
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
.
|
|
103
|
+
const goalReference = squad.spec
|
|
104
|
+
? `Canonical file spec at ${squad.spec.path} (sha256=${squad.spec.sha256}, bytes=${squad.spec.bytes}). Read and hash the exact file during review; its contract is intentionally not duplicated into this prompt.`
|
|
105
|
+
: squad.goal;
|
|
106
|
+
const delegatedPlan = squad.spec
|
|
107
|
+
? tasks.map((task) => `- ${task.id} (${task.agent}) [${task.status}] — task state: ${path.join(path.dirname(path.dirname(squad.spec!.path)), task.id, "task.json")}`).join("\n")
|
|
108
|
+
: tasks.map((task) => `- ${task.id} (${task.agent}): ${task.title}\n ${task.description || "(no description)"}`).join("\n");
|
|
105
109
|
|
|
106
110
|
return `<squad_review_required>
|
|
107
111
|
UNTRUSTED CANDIDATE WORK — INDEPENDENT ORCHESTRATOR REVIEW IS MANDATORY.
|
|
108
112
|
|
|
109
113
|
Authoritative contract: re-read the user's ORIGINAL request and all later clarifications in this main-session conversation. The squad report and delegated task descriptions are claims, not proof and not substitutes for that contract.
|
|
110
|
-
Recorded squad goal (secondary reference): ${
|
|
114
|
+
Recorded squad goal (secondary reference): ${goalReference}
|
|
111
115
|
Acceptance: ${reviewLabel}
|
|
112
116
|
${reviewAction}
|
|
113
117
|
|
package/src/scheduler.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { AgentPool, type AgentEvent } from "./agent-pool.js";
|
|
|
16
16
|
import { Monitor } from "./monitor.js";
|
|
17
17
|
import { Router } from "./router.js";
|
|
18
18
|
import * as store from "./store.js";
|
|
19
|
+
import { isFileSpecTaskId, validateCanonicalSpec, validateTaskSpecAttestation } from "./file-spec.js";
|
|
19
20
|
import { debug, logError } from "./logger.js";
|
|
20
21
|
import { buildAgentSystemPrompt } from "./protocol.js";
|
|
21
22
|
import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
|
|
@@ -211,6 +212,29 @@ export class Scheduler {
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** Revalidate durable file-spec completion evidence after parent restart/review recovery. */
|
|
216
|
+
async auditSpecAttestations(): Promise<string[]> {
|
|
217
|
+
const squad = store.loadSquad(this.squadId); if (!squad?.spec) return [];
|
|
218
|
+
const tasks = store.loadAllTasks(this.squadId);
|
|
219
|
+
if (!validateCanonicalSpec(squad)) {
|
|
220
|
+
const quarantined = tasks.filter((task) => task.status !== "cancelled");
|
|
221
|
+
for (const task of quarantined) {
|
|
222
|
+
if (task.status === "done") await this.invalidateDescendants(task.id);
|
|
223
|
+
if (task.status === "in_progress" || task.status === "done") store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Canonical spec integrity failure" });
|
|
224
|
+
else store.updateTaskStatus(this.squadId, task.id, task.status, { error: "Canonical spec integrity failure" });
|
|
225
|
+
}
|
|
226
|
+
return quarantined.map((task) => task.id);
|
|
227
|
+
}
|
|
228
|
+
const invalid = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
|
|
229
|
+
for (const task of invalid) {
|
|
230
|
+
store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
|
|
231
|
+
store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated after attestation audit" });
|
|
232
|
+
await this.invalidateDescendants(task.id);
|
|
233
|
+
}
|
|
234
|
+
if (invalid.length > 0 && squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
|
|
235
|
+
return invalid.map((task) => task.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
/** Get references for external use */
|
|
215
239
|
getPool(): AgentPool {
|
|
216
240
|
return this.pool;
|
|
@@ -308,6 +332,12 @@ export class Scheduler {
|
|
|
308
332
|
if (!this.running) return;
|
|
309
333
|
const squad = store.loadSquad(this.squadId);
|
|
310
334
|
if (!squad) return;
|
|
335
|
+
// Canonical integrity is a scheduling precondition, not merely a completion check.
|
|
336
|
+
if (squad.spec && !validateCanonicalSpec(squad)) {
|
|
337
|
+
await this.auditSpecAttestations();
|
|
338
|
+
debug("squad-scheduler", "reconcile: canonical spec integrity failure — scheduling quarantined");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
311
341
|
|
|
312
342
|
const tasks = store.loadAllTasks(this.squadId);
|
|
313
343
|
|
|
@@ -387,8 +417,8 @@ export class Scheduler {
|
|
|
387
417
|
}
|
|
388
418
|
|
|
389
419
|
const squad = store.loadSquad(this.squadId);
|
|
390
|
-
if (!squad || squad.status !== "running") {
|
|
391
|
-
debug("squad-scheduler", `scheduleReadyTasks: squad status=${squad?.status}
|
|
420
|
+
if (!squad || squad.status !== "running" || (squad.spec && !validateCanonicalSpec(squad))) {
|
|
421
|
+
debug("squad-scheduler", `scheduleReadyTasks: squad unavailable, inactive, or canonical integrity invalid; status=${squad?.status}`);
|
|
392
422
|
return;
|
|
393
423
|
}
|
|
394
424
|
|
|
@@ -536,6 +566,7 @@ export class Scheduler {
|
|
|
536
566
|
},
|
|
537
567
|
cwd: squad.cwd,
|
|
538
568
|
skillPaths: this.skillPaths,
|
|
569
|
+
...(squad.spec ? { spec: { squadId: squad.id, path: squad.spec.path, sha256: squad.spec.sha256, bytes: squad.spec.bytes, chunkBytes: squad.spec.chunkBytes } } : {}),
|
|
539
570
|
...(resumeSession
|
|
540
571
|
? { resumeSession }
|
|
541
572
|
: forkSessionFile
|
|
@@ -684,16 +715,25 @@ export class Scheduler {
|
|
|
684
715
|
entries: TaskMailboxEntry[],
|
|
685
716
|
legacySeed?: { messages: TaskMessage[]; output: string | null },
|
|
686
717
|
): string {
|
|
687
|
-
const
|
|
718
|
+
const fileSpec = store.loadSquad(this.squadId)?.spec;
|
|
719
|
+
const lines = fileSpec
|
|
688
720
|
? [
|
|
689
|
-
|
|
690
|
-
|
|
721
|
+
`${resumed ? "Resume" : "Start"} file-spec squad task ${task.id}.`,
|
|
722
|
+
`Canonical spec: sha256=${fileSpec.sha256} bytes=${fileSpec.bytes} chunks=${fileSpec.chunkCount}.`,
|
|
723
|
+
"Use squad_spec_read to receive every canonical chunk before normal tools or completion.",
|
|
724
|
+
...(task.fileSpecDelta ? ["", `Dynamic task delta: ${task.title}`, task.description] : []),
|
|
725
|
+
...(resumed ? ["Continue from this task's durable Pi session and existing read coverage."] : []),
|
|
691
726
|
]
|
|
692
|
-
:
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
727
|
+
: resumed
|
|
728
|
+
? [
|
|
729
|
+
`Resume your existing task: ${task.title}`,
|
|
730
|
+
"Continue from the durable Pi session context. Do not restart the task from scratch.",
|
|
731
|
+
]
|
|
732
|
+
: [
|
|
733
|
+
`Your task: ${task.title}`,
|
|
734
|
+
"",
|
|
735
|
+
task.description || "",
|
|
736
|
+
];
|
|
697
737
|
|
|
698
738
|
if (legacySeed) {
|
|
699
739
|
const pendingIds = new Set(entries.map((entry) => entry.id));
|
|
@@ -854,8 +894,17 @@ export class Scheduler {
|
|
|
854
894
|
}
|
|
855
895
|
|
|
856
896
|
case "agent_settled": {
|
|
857
|
-
const
|
|
897
|
+
const settledTask = store.loadTask(this.squadId, event.taskId);
|
|
898
|
+
const status = settledTask?.status;
|
|
858
899
|
if (status === "cancelled" || status === "suspended") return;
|
|
900
|
+
const settledSquad = store.loadSquad(this.squadId);
|
|
901
|
+
if (settledTask && settledSquad && !validateTaskSpecAttestation(settledSquad, settledTask)) {
|
|
902
|
+
store.updateTaskStatus(this.squadId, event.taskId, "pending", { completed: null, output: null, error: "Canonical squad spec was not fully delivered; read all chunks with squad_spec_read" });
|
|
903
|
+
store.appendMessage(this.squadId, event.taskId, { ts: store.now(), from: "system", type: "status", text: "Completion rejected: missing or invalid spec-read-attestation; reopening same task session" });
|
|
904
|
+
if (this.running) void this.reconcile();
|
|
905
|
+
this.updateContext();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
859
908
|
// A mailbox entry not acknowledged by Pi outranks this run's candidate
|
|
860
909
|
// completion. Reopen the same session so accepted-at-least-once delivery
|
|
861
910
|
// occurs before the task can become done.
|
|
@@ -913,7 +962,12 @@ export class Scheduler {
|
|
|
913
962
|
|
|
914
963
|
// Extract output from last messages
|
|
915
964
|
const messages = store.loadMessages(this.squadId, taskId);
|
|
965
|
+
const squad = store.loadSquad(this.squadId);
|
|
966
|
+
const rejectedAt = squad?.spec
|
|
967
|
+
? messages.reduce((last, message, index) => message.from === "system" && message.type === "status" && message.text.startsWith("Completion rejected: missing or invalid spec-read-attestation") ? index : last, -1)
|
|
968
|
+
: -1;
|
|
916
969
|
const agentMessages = messages
|
|
970
|
+
.slice(rejectedAt + 1)
|
|
917
971
|
.filter((m) => m.from === task.agent && (m.type === "text" || m.type === "done"));
|
|
918
972
|
const output = agentMessages.map((m) => m.text).join("\n");
|
|
919
973
|
|
|
@@ -1117,6 +1171,11 @@ export class Scheduler {
|
|
|
1117
1171
|
|
|
1118
1172
|
// Create rework task for the original agent
|
|
1119
1173
|
const reworkId = `${originalId}-fix-${fixN}`;
|
|
1174
|
+
const retestId = `${task.id}-retest-${fixN}`;
|
|
1175
|
+
if (squad.spec && (!isFileSpecTaskId(reworkId) || !isFileSpecTaskId(retestId))) {
|
|
1176
|
+
this.handleTaskFailed(task.id, `Generated file-spec rework IDs exceed the safe task-ID contract: ${reworkId}, ${retestId}`);
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1120
1179
|
const reworkTask: Task = {
|
|
1121
1180
|
id: reworkId,
|
|
1122
1181
|
title: `Fix: ${implTask.title} (attempt ${fixN})`,
|
|
@@ -1125,6 +1184,7 @@ export class Scheduler {
|
|
|
1125
1184
|
status: "pending",
|
|
1126
1185
|
depends: [],
|
|
1127
1186
|
...(implTask.inheritContext ? { inheritContext: true } : {}),
|
|
1187
|
+
...(squad.spec ? { fileSpecDelta: true } : {}),
|
|
1128
1188
|
created: store.now(),
|
|
1129
1189
|
started: null,
|
|
1130
1190
|
completed: null,
|
|
@@ -1138,7 +1198,6 @@ export class Scheduler {
|
|
|
1138
1198
|
store.createTask(this.squadId, reworkTask);
|
|
1139
1199
|
|
|
1140
1200
|
// Create retest task for QA
|
|
1141
|
-
const retestId = `${task.id}-retest-${fixN}`;
|
|
1142
1201
|
const retestTask: Task = {
|
|
1143
1202
|
id: retestId,
|
|
1144
1203
|
title: `Re-test: ${implTask.title} (after fix ${fixN})`,
|
|
@@ -1146,6 +1205,7 @@ export class Scheduler {
|
|
|
1146
1205
|
agent: task.agent,
|
|
1147
1206
|
status: "blocked",
|
|
1148
1207
|
depends: [reworkId],
|
|
1208
|
+
...(squad.spec ? { fileSpecDelta: true } : {}),
|
|
1149
1209
|
created: store.now(),
|
|
1150
1210
|
started: null,
|
|
1151
1211
|
completed: null,
|
|
@@ -1227,6 +1287,18 @@ export class Scheduler {
|
|
|
1227
1287
|
private checkSquadCompletion(tasks: Task[], squad: Squad): void {
|
|
1228
1288
|
if (tasks.length === 0) return;
|
|
1229
1289
|
|
|
1290
|
+
const invalidDone = tasks.filter((task) => task.status === "done" && !validateTaskSpecAttestation(squad, task));
|
|
1291
|
+
if (invalidDone.length > 0) {
|
|
1292
|
+
for (const task of invalidDone) {
|
|
1293
|
+
store.updateTaskStatus(this.squadId, task.id, "pending", { completed: null, output: null, error: "Missing or invalid canonical spec read attestation" });
|
|
1294
|
+
store.appendMessage(this.squadId, task.id, { ts: store.now(), from: "system", type: "status", text: "Completion invalidated: canonical spec attestation is missing or invalid" });
|
|
1295
|
+
void this.invalidateDescendants(task.id);
|
|
1296
|
+
}
|
|
1297
|
+
if (squad.status === "review") { squad.status = "running"; delete squad.review; store.saveSquad(squad); }
|
|
1298
|
+
if (this.running) void this.reconcile();
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1230
1302
|
const relevant = tasks.filter((task) => task.status !== "cancelled");
|
|
1231
1303
|
const allDone = relevant.every((task) => task.status === "done");
|
|
1232
1304
|
const anyFailed = relevant.some((task) => task.status === "failed");
|
|
@@ -1617,6 +1689,8 @@ export class Scheduler {
|
|
|
1617
1689
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
|
1618
1690
|
if (task.status === "done") return;
|
|
1619
1691
|
if (task.status === "cancelled") throw new Error(`Task '${taskId}' is cancelled; resume it before marking it done.`);
|
|
1692
|
+
const squad = store.loadSquad(this.squadId);
|
|
1693
|
+
if (squad && !validateTaskSpecAttestation(squad, task)) throw new Error(`Task '${taskId}' cannot complete: missing or invalid canonical spec read attestation.`);
|
|
1620
1694
|
|
|
1621
1695
|
if (this.pool.isRunning(taskId)) {
|
|
1622
1696
|
await this.pool.kill(taskId);
|
package/src/store.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import * as os from "node:os";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
13
|
import type {
|
|
14
14
|
AgentDef,
|
|
15
15
|
KnowledgeEntry,
|
|
@@ -314,13 +314,50 @@ export function saveSquad(squad: Squad): void {
|
|
|
314
314
|
writeJsonAtomic(getSquadFilePath(squad.id), squad);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/** Atomically publish a new file-spec squad, canonical bytes and initial tasks as one directory rename. */
|
|
318
|
+
export function publishFileSquad(squad: Squad, tasks: Task[], canonicalBytes: Buffer): void {
|
|
319
|
+
if (!squad.spec) throw new Error("PUBLISH_FAILED: file squad is missing spec metadata");
|
|
320
|
+
const root = path.resolve(getSquadRoot()); ensureDir(root);
|
|
321
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/.test(squad.id)) throw new Error(`PUBLISH_FAILED: unsafe squad id ${squad.id}`);
|
|
322
|
+
const finalDir = path.resolve(getSquadDir(squad.id));
|
|
323
|
+
if (path.dirname(finalDir) !== root) throw new Error(`PUBLISH_FAILED: squad destination escapes root`);
|
|
324
|
+
const expectedSpecPath = path.join(finalDir, "spec", "spec.v1.json");
|
|
325
|
+
if (path.resolve(squad.spec.path) !== expectedSpecPath || canonicalBytes.length !== squad.spec.bytes || createHash("sha256").update(canonicalBytes).digest("hex") !== squad.spec.sha256) throw new Error("PUBLISH_FAILED: canonical bytes or destination do not match spec metadata");
|
|
326
|
+
const taskIds = new Set<string>();
|
|
327
|
+
for (const task of tasks) {
|
|
328
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(task.id) || taskIds.has(task.id)) throw new Error(`PUBLISH_FAILED: unsafe or duplicate task id ${task.id}`);
|
|
329
|
+
taskIds.add(task.id);
|
|
330
|
+
}
|
|
331
|
+
if (fs.existsSync(finalDir)) throw new Error(`PUBLISH_FAILED: squad ${squad.id} already exists`);
|
|
332
|
+
const stagingDir = path.join(root, `${squad.id}.creating.${randomUUID()}`);
|
|
333
|
+
fs.mkdirSync(stagingDir, { mode: 0o700 });
|
|
334
|
+
try {
|
|
335
|
+
const writeDurable = (filePath: string, content: string): void => { const fd = fs.openSync(filePath, "wx", 0o600); try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
|
|
336
|
+
const specDir = path.join(stagingDir, "spec"); fs.mkdirSync(specDir, { mode: 0o700 });
|
|
337
|
+
const specPath = path.join(specDir, "spec.v1.json"); const specFd = fs.openSync(specPath, "wx", 0o600);
|
|
338
|
+
try { fs.writeFileSync(specFd, canonicalBytes); fs.fsyncSync(specFd); } finally { fs.closeSync(specFd); }
|
|
339
|
+
try { fs.chmodSync(specPath, 0o400); } catch { /* Windows/best effort */ }
|
|
340
|
+
for (const task of tasks) {
|
|
341
|
+
const taskDir = path.join(stagingDir, task.id); fs.mkdirSync(taskDir, { mode: 0o700 });
|
|
342
|
+
writeDurable(path.join(taskDir, "task.json"), JSON.stringify(task, null, 2) + "\n");
|
|
343
|
+
}
|
|
344
|
+
writeDurable(path.join(stagingDir, "squad.json"), JSON.stringify(squad, null, 2) + "\n");
|
|
345
|
+
for (const dir of [specDir, stagingDir]) { try { const fd = fs.openSync(dir, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ } }
|
|
346
|
+
fs.renameSync(stagingDir, finalDir);
|
|
347
|
+
try { const fd = fs.openSync(root, fs.constants.O_RDONLY); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } } catch { /* directory fsync unavailable */ }
|
|
348
|
+
} catch (error) {
|
|
349
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch { /* preserve original failure */ }
|
|
350
|
+
throw new Error(`PUBLISH_FAILED: ${(error as Error).message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
317
354
|
export function listSquads(): string[] {
|
|
318
355
|
const root = getSquadRoot();
|
|
319
356
|
if (!fs.existsSync(root)) return [];
|
|
320
357
|
return fs
|
|
321
358
|
.readdirSync(root)
|
|
322
359
|
.filter((entry) => {
|
|
323
|
-
if (entry === "agents" || entry === "memory.jsonl") return false;
|
|
360
|
+
if (entry === "agents" || entry === "memory.jsonl" || entry.includes(".creating.")) return false;
|
|
324
361
|
const squadFile = path.join(root, entry, "squad.json");
|
|
325
362
|
return fs.existsSync(squadFile);
|
|
326
363
|
});
|
package/src/types.ts
CHANGED
|
@@ -123,6 +123,8 @@ export interface Squad {
|
|
|
123
123
|
/** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
|
|
124
124
|
agents: Record<string, SquadAgentEntry>;
|
|
125
125
|
config: SquadConfig;
|
|
126
|
+
/** Immutable canonical contract for file-based squads; absent for legacy inline squads. */
|
|
127
|
+
spec?: { schemaVersion: 1; sha256: string; bytes: number; path: string; chunkBytes: 32768; chunkCount: number };
|
|
126
128
|
/** Mandatory independent main-session review; absent while rework is running. */
|
|
127
129
|
review?: SquadReview;
|
|
128
130
|
/** Completed prior review attempts retained as same-squad audit evidence. */
|
|
@@ -170,6 +172,8 @@ export interface Task {
|
|
|
170
172
|
usage: TaskUsage;
|
|
171
173
|
/** Durable Pi context for this task. Absent until its first process creates a session. */
|
|
172
174
|
session?: TaskSession;
|
|
175
|
+
/** Dynamic task delta created after immutable file-spec publication. */
|
|
176
|
+
fileSpecDelta?: boolean;
|
|
173
177
|
/** If this is a rework task, the original task ID it's fixing */
|
|
174
178
|
retryOf?: string;
|
|
175
179
|
/** How many times this task chain has been retried */
|
|
@@ -298,7 +302,7 @@ export interface SupervisorResult {
|
|
|
298
302
|
// ============================================================================
|
|
299
303
|
|
|
300
304
|
export interface PlannerOutput {
|
|
301
|
-
agents: Record<string,
|
|
305
|
+
agents: Record<string, SquadAgentEntry>;
|
|
302
306
|
tasks: Array<{
|
|
303
307
|
id: string;
|
|
304
308
|
title: string;
|