@felixdotgo/aidlc-kit 0.0.1 → 0.1.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/DEVELOPMENT.md +1 -1
- package/README.md +5 -4
- package/dist/assets/.agents/aidlc/conventions.md +8 -8
- package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
- package/dist/assets/.agents/aidlc/phase-build.md +7 -7
- package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
- package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
- package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
- package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
- package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
- package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
- package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
- package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
- package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
- package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
- package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
- package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
- package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
- package/dist/services/aidlc-coordination/compose.yaml +23 -2
- package/dist/services/aidlc-coordination/package-lock.json +1 -0
- package/dist/services/aidlc-coordination/package.json +2 -1
- package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
- package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
- package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
- package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
- package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
- package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
- package/dist/services/aidlc-coordination/src/server.mjs +53 -9
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
- package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
- package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
- package/dist/src/adapters.js +25 -5
- package/dist/src/cli.js +66 -6
- package/dist/src/gate.js +3 -3
- package/dist/src/installer.d.ts +5 -1
- package/dist/src/installer.js +114 -30
- package/dist/src/managed-content.js +1 -1
- package/dist/src/model.d.ts +15 -0
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +3 -1
- package/dist/src/profiles.js +26 -3
- package/dist/src/state.d.ts +7 -0
- package/dist/src/state.js +54 -3
- package/dist/src/upgrade.d.ts +2 -2
- package/dist/src/upgrade.js +39 -4
- package/dist/src/workflow.d.ts +1 -0
- package/dist/src/workflow.js +2 -1
- package/docs/README.md +2 -2
- package/docs/architecture.md +6 -1
- package/docs/command-reference.md +63 -5
- package/docs/configuration.md +20 -1
- package/docs/coordination-service.md +54 -1
- package/docs/development.md +1 -1
- package/docs/operating-workflow.md +50 -14
- package/docs/testing-and-release.md +47 -4
- package/package.json +2 -2
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { FleetConflict, PostgresFleetRepository, SqliteFleetRepository, applyFleetCommand, featureReady, validateCanonicalFeature, validateFeature } from "../src/fleet.mjs";
|
|
4
|
+
import { PostgresStateRepository, SqliteStateRepository } from "../src/repository.mjs";
|
|
5
|
+
import { callTool } from "../src/fleet-client.mjs";
|
|
6
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import { PostgresCoordinationRepository } from "../src/coordination.mjs";
|
|
12
|
+
|
|
13
|
+
const sha = "a".repeat(40);
|
|
14
|
+
const task = { id: "T1", title: "First", type: "infra", phase: "clarify", gate: "G0_confirm", status: "blocked_on_user", language: "en", risk: "normal", areas: ["root"], branch: "—", artifacts: { intent: ".agents/data/tasks/T1/intent.md", design: ".agents/data/tasks/T1/design.md", workplan: ".agents/data/tasks/T1/workplan.md" }, decisions: [], tasks: [], evidence: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" };
|
|
15
|
+
|
|
16
|
+
test("feature graph rejects missing dependencies and cycles and derives ready work", () => {
|
|
17
|
+
const manifest = validateFeature({ id: "F1", integrationBranch: "feature/one", tasks: [{ id: "T1", dependsOn: [] }, { id: "T2", dependsOn: ["T1"] }] });
|
|
18
|
+
assert.deepEqual(featureReady(manifest, []).map((item) => item.ready), [true, false]);
|
|
19
|
+
assert.deepEqual(featureReady(manifest, ["T1"]).map((item) => item.ready), [false, true]);
|
|
20
|
+
assert.throws(() => validateFeature({ ...manifest, tasks: [{ id: "T1", dependsOn: ["T2"] }, { id: "T2", dependsOn: ["T1"] }] }), /cycle/);
|
|
21
|
+
assert.throws(() => validateFeature({ ...manifest, tasks: [{ id: "T1", dependsOn: ["missing"] }] }), /unknown feature dependency/);
|
|
22
|
+
assert.deepEqual(validateCanonicalFeature(manifest,{tasks:{T1:{featureId:"F1"},T2:{featureId:"F1"}}}),manifest);
|
|
23
|
+
assert.throws(() => validateCanonicalFeature(manifest,{tasks:{T1:{featureId:"F1"}}}),/does not exist in canonical state/);
|
|
24
|
+
assert.throws(() => validateCanonicalFeature(manifest,{tasks:{T1:{featureId:"F1"},T2:{featureId:"other"}}}),/another feature/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("fleet claims fence stale leads and workers, persist checkpoints, and replay idempotently", async () => {
|
|
28
|
+
const repo = new SqliteFleetRepository(":memory:");
|
|
29
|
+
try {
|
|
30
|
+
const feature = await repo.apply("w", 0, "feature", "lead", "lead", { type: "feature_apply", feature: { id: "F1", integrationBranch: "feature/one", tasks: [{ id: "T1", dependsOn: [] }] } });
|
|
31
|
+
assert.equal(feature.fleet.features.F1.revision, 1);
|
|
32
|
+
const claimed = await repo.apply("w", 1, "lead-1", "lead", "lead", { type: "run_claim", taskId: "T1", leaseMs: 60000 });
|
|
33
|
+
assert.equal(claimed.fleet.leads.T1.epoch, 1);
|
|
34
|
+
const retry = await repo.apply("w", 1, "lead-1", "lead", "lead", { type: "run_claim", taskId: "T1", leaseMs: 60000 });
|
|
35
|
+
assert.deepEqual(retry, claimed);
|
|
36
|
+
await assert.rejects(() => repo.apply("w", 2, "wrong-lead", "other", "lead", { type: "run_claim", taskId: "T1", leaseMs: 60000 }), FleetConflict);
|
|
37
|
+
const scheduled = await repo.apply("w", 2, "schedule", "lead", "lead", { type: "job_schedule", jobId: "J1", taskId: "T1", itemId: "I1", workerId: "worker1", agent: "codex", attempt: 1, leadEpoch: 1, ownedPaths: ["src/a.ts"], baseCommit: sha, branch: "worker/J1" });
|
|
38
|
+
await assert.rejects(() => repo.apply("w", 3, "wrong-worker", "worker2", "worker", { type: "job_claim", jobId: "J1", leaseMs: 60000 }), /not assigned/);
|
|
39
|
+
const worker = await repo.apply("w", 3, "worker-claim", "worker1", "worker", { type: "job_claim", jobId: "J1", leaseMs: 60000 });
|
|
40
|
+
assert.equal(worker.fleet.jobs.J1.epoch, 1);
|
|
41
|
+
const checkpoint = await repo.apply("w", 4, "checkpoint", "worker1", "worker", { type: "job_checkpoint", jobId: "J1", epoch: 1, sessionId: "session-1", checkpoint: "turn 2" });
|
|
42
|
+
assert.equal(checkpoint.fleet.jobs.J1.sessionId, "session-1");
|
|
43
|
+
await assert.rejects(() => repo.apply("w", 5, "changed-session", "worker1", "worker", { type: "job_checkpoint", jobId: "J1", epoch: 1, sessionId: "another-session", checkpoint: "turn 3" }), /session cannot change/);
|
|
44
|
+
await assert.rejects(() => repo.apply("w", 5, "stale-worker", "worker1", "worker", { type: "job_report", jobId: "J1", epoch: 0, commitSha: sha, changedPaths: [] }), FleetConflict);
|
|
45
|
+
const report = await repo.apply("w", 5, "report", "worker1", "worker", { type: "job_report", jobId: "J1", epoch: 1, commitSha: sha, changedPaths: ["src/a.ts"] });
|
|
46
|
+
assert.equal(report.fleet.jobs.J1.state, "reported");
|
|
47
|
+
await assert.rejects(() => repo.apply("w", 6, "duplicate-report", "worker1", "worker", { type: "job_report", jobId: "J1", epoch: 1, commitSha: sha, changedPaths: [] }), FleetConflict);
|
|
48
|
+
assert.equal(scheduled.revision, 3);
|
|
49
|
+
} finally { repo.close(); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("lead takeover makes every unfinished prior-epoch job uncertain and recovers a verified commit", () => {
|
|
53
|
+
const time=Date.parse("2026-01-01T01:00:00.000Z");
|
|
54
|
+
const base={schemaVersion:1,features:{},leads:{T1:{taskId:"T1",actor:"old",epoch:1,expiresAt:new Date(time-1).toISOString()}},jobs:{}};
|
|
55
|
+
for(const [name,state] of [["ready","ready"],["leased","leased"],["reported","reported"],["integrated","integrated"]]) base.jobs[name]={schemaVersion:1,id:name,taskId:"T1",itemId:name,workerId:"worker1",agent:"codex",attempt:1,leadEpoch:1,epoch:1,ownedPaths:[`src/${name}.ts`],baseCommit:sha,branch:`worker/${name}`,state,expiresAt:new Date(time+1000).toISOString(),sessionId:"old-session",checkpoint:"old",report:state==="reported"||state==="integrated"?{commitSha:sha,changedPaths:[`src/${name}.ts`]}:null};
|
|
56
|
+
const expiredWorker=structuredClone(base); expiredWorker.leads.T1.expiresAt=new Date(time+60000).toISOString(); expiredWorker.jobs.leased.expiresAt=new Date(time-1).toISOString();
|
|
57
|
+
assert.throws(()=>applyFleetCommand(expiredWorker,{type:"job_claim",jobId:"leased",leaseMs:60000},"worker1","worker",time),/requires lead reconciliation/);
|
|
58
|
+
const reconciled=applyFleetCommand(expiredWorker,{type:"job_reconcile",jobId:"leased",leadEpoch:1,state:"uncertain"},"old","lead",time);
|
|
59
|
+
assert.equal(reconciled.jobs.leased.uncertainAt,new Date(time).toISOString());
|
|
60
|
+
const taken=applyFleetCommand(base,{type:"run_claim",taskId:"T1",leaseMs:60000},"new","lead",time);
|
|
61
|
+
assert.equal(taken.leads.T1.epoch,2);
|
|
62
|
+
for(const name of ["ready","leased","reported"]) { assert.equal(taken.jobs[name].state,"uncertain"); assert.equal(taken.jobs[name].uncertainAt,new Date(time).toISOString()); }
|
|
63
|
+
assert.equal(taken.jobs.integrated.state,"integrated");
|
|
64
|
+
assert.throws(()=>applyFleetCommand(taken,{type:"job_report",jobId:"leased",epoch:1,commitSha:sha,changedPaths:["src/leased.ts"]},"worker1","worker",time+1),/stale or expired job claim/);
|
|
65
|
+
const recovered=applyFleetCommand(taken,{type:"job_recover",jobId:"reported",leadEpoch:2,commitSha:sha,changedPaths:["src/reported.ts"]},"new","lead",time+1);
|
|
66
|
+
assert.equal(recovered.jobs.reported.state,"reported"); assert.equal(recovered.jobs.reported.leadEpoch,2); assert.equal(recovered.jobs.reported.uncertainAt,null);
|
|
67
|
+
const requeued=applyFleetCommand(taken,{type:"job_reconcile",jobId:"ready",leadEpoch:2,state:"ready"},"new","lead",time+1);
|
|
68
|
+
assert.equal(requeued.jobs.ready.uncertainAt,null); assert.equal(requeued.jobs.ready.sessionId,null);
|
|
69
|
+
const integrated=applyFleetCommand(recovered,{type:"job_reconcile",jobId:"reported",leadEpoch:2,state:"integrated"},"new","lead",time+2);
|
|
70
|
+
assert.equal(integrated.jobs.reported.state,"integrated");
|
|
71
|
+
assert.throws(()=>applyFleetCommand(taken,{type:"job_recover",jobId:"ready",leadEpoch:2,commitSha:sha,changedPaths:["other.ts"]},"new","lead",time+1),/out-of-scope/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("review snapshots require a ready wait and approval is atomic against its revision", async () => {
|
|
75
|
+
const repo = new SqliteStateRepository(":memory:");
|
|
76
|
+
try {
|
|
77
|
+
const first = await repo.apply("w", 0, "create", { type: "upsertTask", taskId: "T1", task }, "/workspace/project");
|
|
78
|
+
assert.equal(first.nextActions.T1.classification, "await_user");
|
|
79
|
+
const snapshot = await repo.putGateSnapshot("w", 1, "snapshot", "T1", "G0_confirm", "Review this intent");
|
|
80
|
+
assert.equal((await repo.gateInbox("w")).length, 1);
|
|
81
|
+
const sibling={...task,id:"T2",title:"Sibling",status:"active",artifacts:{intent:".agents/data/tasks/T2/intent.md",design:".agents/data/tasks/T2/design.md",workplan:".agents/data/tasks/T2/workplan.md"}};
|
|
82
|
+
await repo.apply("w",1,"sibling",{type:"upsertTask",taskId:"T2",task:sibling},"/workspace/project");
|
|
83
|
+
assert.equal((await repo.gateInbox("w"))[0].currentRevision,2);
|
|
84
|
+
await assert.rejects(() => repo.approveGate("w", 0, "stale", "T1", snapshot.id, "human approval", "/workspace/project"), /revision conflict/);
|
|
85
|
+
const approval = await repo.approveGate("w", 2, "approve", "T1", snapshot.id, "human approval", "/workspace/project");
|
|
86
|
+
assert.equal(approval.state.tasks.T1.phase, "plan");
|
|
87
|
+
assert.equal(approval.state.tasks.T1.evidence.at(-1).kind, "approval");
|
|
88
|
+
assert.deepEqual(await repo.gateInbox("w"), []);
|
|
89
|
+
assert.deepEqual(await repo.approveGate("w", 2, "approve", "T1", snapshot.id, "human approval", "/workspace/project"), approval);
|
|
90
|
+
await assert.rejects(() => repo.approveGate("w", 3, "again", "T1", snapshot.id, "human approval", "/workspace/project"), /stale gate snapshot/);
|
|
91
|
+
} finally { repo.close(); }
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("G2 snapshot binds the reviewed integration commit through approval", async () => {
|
|
95
|
+
const repo=new SqliteStateRepository(":memory:");
|
|
96
|
+
try {
|
|
97
|
+
const g2={...task,phase:"build",gate:"G2_codereview",evidence:[
|
|
98
|
+
{kind:"approval",gate:"G1_review",result:"pass",source:"human",recordedAt:"2026-01-01T00:00:01.000Z"},
|
|
99
|
+
{kind:"test",area:"root",result:"pass",source:"test",recordedAt:"2026-01-01T00:00:02.000Z"},
|
|
100
|
+
{kind:"review",result:"pass",source:"review",recordedAt:"2026-01-01T00:00:03.000Z"}
|
|
101
|
+
]};
|
|
102
|
+
const created=await repo.apply("g2",0,"create",{type:"upsertTask",taskId:"T1",task:g2},"/workspace/project");
|
|
103
|
+
assert.equal(created.nextActions.T1.classification,"await_user");
|
|
104
|
+
await assert.rejects(() => repo.putGateSnapshot("g2",1,"missing","T1","G2_codereview","Review build"),/integration commit SHA/);
|
|
105
|
+
const snapshot=await repo.putGateSnapshot("g2",1,"snapshot","T1","G2_codereview","Review build",undefined,sha);
|
|
106
|
+
assert.deepEqual(await repo.putGateSnapshot("g2",0,"snapshot","T1","G2_codereview","Changed retry",undefined,sha),snapshot);
|
|
107
|
+
assert.equal((await repo.gateInbox("g2"))[0].integrationCommit,sha);
|
|
108
|
+
assert.match(snapshot.reviewText,new RegExp(`Integration commit: ${sha}`));
|
|
109
|
+
await assert.rejects(() => repo.approveGate("g2",1,"wrong","T1",snapshot.id,"human","/workspace/project","b".repeat(40)),/does not match/);
|
|
110
|
+
const approved=await repo.approveGate("g2",1,"approved","T1",snapshot.id,"human","/workspace/project",sha);
|
|
111
|
+
assert.equal(approved.state.tasks.T1.phase,"wrap");
|
|
112
|
+
} finally {repo.close();}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a fleet lifecycle mutation is fenced by the current lead epoch", async () => {
|
|
116
|
+
const dir=mkdtempSync(join(tmpdir(),"aidlc-fleet-fence-")); const file=join(dir,"state.db");
|
|
117
|
+
const fleet=new SqliteFleetRepository(file); const state=new SqliteStateRepository(file);
|
|
118
|
+
try {
|
|
119
|
+
const lead=await fleet.apply("w",0,"claim","lead","lead",{type:"run_claim",taskId:"T1",leaseMs:60000});
|
|
120
|
+
assert.equal(lead.fleet.leads.T1.epoch,1);
|
|
121
|
+
await assert.rejects(() => state.apply("w",0,"wrong",{type:"upsertTask",taskId:"T1",task},"/workspace/project",0),/stale or expired lead epoch/);
|
|
122
|
+
const activeT1={...task,status:"active"};
|
|
123
|
+
const created=await state.apply("w",0,"create",{type:"upsertTask",taskId:"T1",task:activeT1},"/workspace/project",1);
|
|
124
|
+
assert.equal(created.revision,1);
|
|
125
|
+
assert.equal(created.nextActions.T1.classification,"run_phase");
|
|
126
|
+
const forged={...activeT1,evidence:[{kind:"approval",gate:"G0_confirm",result:"pass",source:"forged by lead",recordedAt:"2026-01-01T00:00:01.000Z"}]};
|
|
127
|
+
await assert.rejects(() => state.apply("w",1,"forged",{type:"upsertTask",taskId:"T1",task:forged},"/workspace/project",1),/lead credential cannot record human gate approval/);
|
|
128
|
+
await fleet.apply("w",1,"claim-T2","lead","lead",{type:"run_claim",taskId:"T2",leaseMs:60000});
|
|
129
|
+
const active={...task,id:"T2",title:"Active sibling",status:"active",artifacts:{intent:".agents/data/tasks/T2/intent.md",design:".agents/data/tasks/T2/design.md",workplan:".agents/data/tasks/T2/workplan.md"}};
|
|
130
|
+
await state.apply("w",1,"create-T2",{type:"upsertTask",taskId:"T2",task:active},"/workspace/project",1);
|
|
131
|
+
for(const [phase,gate,status] of [["plan","G1_review","active"],["build","G2_codereview","active"],["wrap","none","active"],["done","none","done"]]) {
|
|
132
|
+
const skipped={...active,phase,gate,status,...(phase==="done"?{lessonDisposition:{status:"none",reason:"none",source:"lead",recordedAt:"2026-01-01T00:00:02.000Z"}}:{})};
|
|
133
|
+
await assert.rejects(() => state.apply("w",2,`skip-${phase}`,{type:"upsertTask",taskId:"T2",task:skipped},"/workspace/project",1),/human gate approval is required/);
|
|
134
|
+
}
|
|
135
|
+
await assert.rejects(() => state.apply("w",2,"remove-active",{type:"removeTask",taskId:"T2"},"/workspace/project",1),/active fleet task cannot be removed/);
|
|
136
|
+
await fleet.apply("w",2,"release","lead","lead",{type:"run_release",taskId:"T1",epoch:1});
|
|
137
|
+
await assert.rejects(() => state.apply("w",2,"stale",{type:"upsertTask",taskId:"T1",task},"/workspace/project",1),/stale or expired lead epoch/);
|
|
138
|
+
} finally {state.close();fleet.close();rmSync(dir,{recursive:true,force:true});}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("fenced lead may complete the gateless wrap phase after lesson disposition", async () => {
|
|
142
|
+
const dir=mkdtempSync(join(tmpdir(),"aidlc-fleet-wrap-")); const file=join(dir,"state.db");
|
|
143
|
+
const fleet=new SqliteFleetRepository(file); const state=new SqliteStateRepository(file);
|
|
144
|
+
try {
|
|
145
|
+
const wrapped={...task,phase:"wrap",gate:"none",status:"active",evidence:[
|
|
146
|
+
{kind:"approval",gate:"G1_review",result:"pass",source:"human",recordedAt:"2026-01-01T00:00:00.000Z"},
|
|
147
|
+
{kind:"approval",gate:"G2_codereview",result:"pass",source:"human",recordedAt:"2026-01-01T00:00:01.000Z"}
|
|
148
|
+
],lessonDisposition:{status:"none",reason:"none",source:"human",recordedAt:"2026-01-01T00:00:02.000Z"}};
|
|
149
|
+
await state.apply("w",0,"seed",{type:"upsertTask",taskId:"T1",task:wrapped},"/workspace/project");
|
|
150
|
+
await fleet.apply("w",0,"claim","lead","lead",{type:"run_claim",taskId:"T1",leaseMs:60000});
|
|
151
|
+
const done=await state.apply("w",1,"complete",{type:"transition",taskId:"T1",target:"done"},"/workspace/project",1);
|
|
152
|
+
assert.equal(done.state.tasks.T1.phase,"done");
|
|
153
|
+
assert.equal(done.nextActions.T1.classification,"complete");
|
|
154
|
+
} finally {state.close();fleet.close();rmSync(dir,{recursive:true,force:true});}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("MCP client parses a stateless SSE tool response and rejects tool errors", async () => {
|
|
158
|
+
const ok=await callTool({url:"http://localhost/mcp",token:"test",name:"fleet_get",args:{workspace:"w"},fetchImpl:async (_,options)=>{const body=JSON.parse(options.body);return new Response(`data: ${JSON.stringify({jsonrpc:"2.0",id:body.id,result:{content:[{type:"text",text:JSON.stringify({revision:1})}]}})}\n\n`,{status:200,headers:{"content-type":"text/event-stream"}});}});
|
|
159
|
+
assert.equal(ok.revision,1);
|
|
160
|
+
await assert.rejects(() => callTool({url:"http://localhost/mcp",token:"test",name:"x",fetchImpl:async (_,options)=>{const body=JSON.parse(options.body);return new Response(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{isError:true,content:[{type:"text",text:JSON.stringify({code:"DENIED",message:"role denied"})}]}}),{status:200,headers:{"content-type":"application/json"}});}}),/role denied/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("installed project fleet wrapper resolves its runner assets", () => {
|
|
164
|
+
const dir=mkdtempSync(join(tmpdir(),"aidlc-fleet-installed-"));
|
|
165
|
+
try {
|
|
166
|
+
const serviceSrc=join(dir,".agents","aidlc-coordination","src");
|
|
167
|
+
mkdirSync(serviceSrc,{recursive:true});
|
|
168
|
+
for(const file of ["fleet-run.mjs","fleet-client.mjs"]) cpSync(new URL(`../src/${file}`,import.meta.url),join(serviceSrc,file));
|
|
169
|
+
cpSync(new URL("../../../assets/aidlc/scripts",import.meta.url),join(dir,".agents","aidlc","scripts"),{recursive:true});
|
|
170
|
+
const run=spawnSync(process.execPath,[join(serviceSrc,"fleet-run.mjs")],{encoding:"utf8"});
|
|
171
|
+
assert.equal(run.status,1);
|
|
172
|
+
assert.match(`${run.stdout}\n${run.stderr}`,/Usage: aidlc-fleet coordinator/,JSON.stringify({stdout:run.stdout,stderr:run.stderr,error:run.error?.message}));
|
|
173
|
+
assert.doesNotMatch(run.stderr,/Fleet runner assets are missing|ERR_MODULE_NOT_FOUND/);
|
|
174
|
+
} finally { rmSync(dir,{recursive:true,force:true}); }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("PostgreSQL board preserves the full task/comment projection across writes", { skip: !process.env.TEST_POSTGRES_URL }, async () => {
|
|
178
|
+
const repo=new PostgresCoordinationRepository(process.env.TEST_POSTGRES_URL); const workspace=`test-${randomUUID()}`;
|
|
179
|
+
try {
|
|
180
|
+
await repo.apply(workspace,0,"create","human","writer",{type:"upsert_task",task:{id:"K1",title:"Work",status:"ready"}});
|
|
181
|
+
await repo.apply(workspace,1,"comment","human","commenter",{type:"add_comment",taskId:"K1",authorType:"human",intent:"status",body:"Progress"});
|
|
182
|
+
const board=await repo.get(workspace); const thread=await repo.thread(workspace,"K1");
|
|
183
|
+
assert.deepEqual(board.board.tasks.map((item)=>item.id),["K1"]);
|
|
184
|
+
assert.deepEqual(thread.comments.map((item)=>item.body),["Progress"]);
|
|
185
|
+
const row=(await repo.pool.query("SELECT board FROM coordination_workspaces WHERE workspace=$1",[workspace])).rows[0];
|
|
186
|
+
assert.equal(Array.isArray(row.board.tasks),false);
|
|
187
|
+
assert.equal(row.board.comments.K1.length,1);
|
|
188
|
+
} finally { await repo.pool.query("DELETE FROM coordination_events WHERE workspace=$1",[workspace]); await repo.pool.query("DELETE FROM coordination_idempotency WHERE workspace=$1",[workspace]); await repo.pool.query("DELETE FROM coordination_workspaces WHERE workspace=$1",[workspace]); await repo.close(); }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("PostgreSQL fleet and canonical state enforce fencing and atomic reviewer approval", { skip: !process.env.TEST_POSTGRES_URL }, async () => {
|
|
192
|
+
const fleet=new PostgresFleetRepository(process.env.TEST_POSTGRES_URL); const state=new PostgresStateRepository(process.env.TEST_POSTGRES_URL); const workspace=`test-${randomUUID()}`;
|
|
193
|
+
try {
|
|
194
|
+
const claim=await fleet.apply(workspace,0,"claim","lead","lead",{type:"run_claim",taskId:"T1",leaseMs:60000});
|
|
195
|
+
assert.equal(claim.fleet.leads.T1.epoch,1);
|
|
196
|
+
await assert.rejects(() => state.apply(workspace,0,"stale",{type:"upsertTask",taskId:"T1",task},"/workspace/project",0),/stale or expired lead epoch/);
|
|
197
|
+
const activeTask={...task,status:"active"};
|
|
198
|
+
const created=await state.apply(workspace,0,"create",{type:"upsertTask",taskId:"T1",task:activeTask},"/workspace/project",1);
|
|
199
|
+
assert.equal(created.nextActions.T1.classification,"run_phase");
|
|
200
|
+
for(const [phase,gate,status] of [["plan","G1_review","active"],["build","G2_codereview","active"],["wrap","none","active"],["done","none","done"]]) {
|
|
201
|
+
const skipped={...activeTask,phase,gate,status,...(phase==="done"?{lessonDisposition:{status:"none",reason:"none",source:"lead",recordedAt:"2026-01-01T00:00:02.000Z"}}:{})};
|
|
202
|
+
await assert.rejects(() => state.apply(workspace,1,`skip-${phase}`,{type:"upsertTask",taskId:"T1",task:skipped},"/workspace/project",1),/human gate approval is required/);
|
|
203
|
+
}
|
|
204
|
+
await state.apply(workspace,1,"wait",{type:"upsertTask",taskId:"T1",task},"/workspace/project",1);
|
|
205
|
+
const snapshot=await state.putGateSnapshot(workspace,2,"snapshot","T1","G0_confirm","Review this intent",1);
|
|
206
|
+
assert.deepEqual(await state.putGateSnapshot(workspace,0,"snapshot","T1","G0_confirm","Changed retry",1),snapshot);
|
|
207
|
+
const forged={...task,status:"active",evidence:[{kind:"approval",gate:"G0_confirm",result:"pass",source:"forged by lead",recordedAt:"2026-01-01T00:00:01.000Z"}]};
|
|
208
|
+
await assert.rejects(() => state.apply(workspace,2,"forged",{type:"upsertTask",taskId:"T1",task:forged},"/workspace/project",1),/lead credential cannot record human gate approval/);
|
|
209
|
+
const sibling={...task,id:"T2",title:"Sibling",status:"active",artifacts:{intent:".agents/data/tasks/T2/intent.md",design:".agents/data/tasks/T2/design.md",workplan:".agents/data/tasks/T2/workplan.md"}};
|
|
210
|
+
await state.apply(workspace,2,"sibling",{type:"upsertTask",taskId:"T2",task:sibling},"/workspace/project");
|
|
211
|
+
assert.equal((await state.gateInbox(workspace))[0].currentRevision,3);
|
|
212
|
+
const approval=await state.approveGate(workspace,3,"approve","T1",snapshot.id,"human approval","/workspace/project");
|
|
213
|
+
assert.equal(approval.state.tasks.T1.phase,"plan");
|
|
214
|
+
await assert.rejects(() => state.approveGate(workspace,4,"stale-snapshot","T1",snapshot.id,"human approval","/workspace/project"),/stale gate snapshot/);
|
|
215
|
+
const g2={...task,id:"T3",title:"G2 task",phase:"build",gate:"G2_codereview",artifacts:{intent:".agents/data/tasks/T3/intent.md",design:".agents/data/tasks/T3/design.md",workplan:".agents/data/tasks/T3/workplan.md"},evidence:[
|
|
216
|
+
{kind:"approval",gate:"G1_review",result:"pass",source:"human",recordedAt:"2026-01-01T00:00:01.000Z"},
|
|
217
|
+
{kind:"test",area:"root",result:"pass",source:"test",recordedAt:"2026-01-01T00:00:02.000Z"},
|
|
218
|
+
{kind:"review",result:"pass",source:"review",recordedAt:"2026-01-01T00:00:03.000Z"}
|
|
219
|
+
]};
|
|
220
|
+
await state.apply(workspace,4,"seed-g2",{type:"upsertTask",taskId:"T3",task:g2},"/workspace/project");
|
|
221
|
+
const g2Snapshot=await state.putGateSnapshot(workspace,5,"g2-snapshot","T3","G2_codereview","Review integrated build",undefined,sha);
|
|
222
|
+
assert.equal((await state.gateInbox(workspace))[0].integrationCommit,sha);
|
|
223
|
+
assert.match(g2Snapshot.reviewText,new RegExp(`Integration commit: ${sha}`));
|
|
224
|
+
await assert.rejects(() => state.approveGate(workspace,5,"g2-wrong","T3",g2Snapshot.id,"human approval","/workspace/project","b".repeat(40)),/does not match/);
|
|
225
|
+
const g2Approval=await state.approveGate(workspace,5,"g2-approved","T3",g2Snapshot.id,"human approval","/workspace/project",sha);
|
|
226
|
+
assert.equal(g2Approval.state.tasks.T3.phase,"wrap");
|
|
227
|
+
await fleet.apply(workspace,1,"schedule","lead","lead",{type:"job_schedule",jobId:"J1",taskId:"T1",itemId:"I1",workerId:"worker1",agent:"codex",attempt:1,leadEpoch:1,ownedPaths:["src/a.ts"],baseCommit:sha,branch:"worker/J1"});
|
|
228
|
+
await fleet.apply(workspace,2,"worker-claim","worker1","worker",{type:"job_claim",jobId:"J1",leaseMs:60000});
|
|
229
|
+
await fleet.apply(workspace,3,"worker-report","worker1","worker",{type:"job_report",jobId:"J1",epoch:1,commitSha:sha,changedPaths:["src/a.ts"]});
|
|
230
|
+
await fleet.apply(workspace,4,"release","lead","lead",{type:"run_release",taskId:"T1",epoch:1});
|
|
231
|
+
const takeover=await fleet.apply(workspace,5,"takeover","lead","lead",{type:"run_claim",taskId:"T1",leaseMs:60000});
|
|
232
|
+
assert.equal(takeover.fleet.jobs.J1.state,"uncertain");
|
|
233
|
+
assert.match(takeover.fleet.jobs.J1.uncertainAt,/^\d{4}-\d{2}-\d{2}T/);
|
|
234
|
+
await assert.rejects(() => fleet.apply(workspace,6,"late-worker","worker1","worker",{type:"job_report",jobId:"J1",epoch:1,commitSha:sha,changedPaths:["src/a.ts"]}),/stale or expired job claim/);
|
|
235
|
+
const recovered=await fleet.apply(workspace,6,"recover","lead","lead",{type:"job_recover",jobId:"J1",leadEpoch:2,commitSha:sha,changedPaths:["src/a.ts"]});
|
|
236
|
+
assert.equal(recovered.fleet.jobs.J1.state,"reported"); assert.equal(recovered.fleet.jobs.J1.leadEpoch,2);
|
|
237
|
+
} finally {
|
|
238
|
+
for(const table of ["state_gate_snapshots","state_events","state_idempotency","state_workspaces","fleet_idempotency","fleet_workspaces"]) await state.pool.query(`DELETE FROM ${table} WHERE workspace=$1`,[workspace]);
|
|
239
|
+
await state.close();await fleet.close();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("tokenized supervisor claim fences same-identity contenders and atomically checkpoints approval", async () => {
|
|
244
|
+
const dir=mkdtempSync(join(tmpdir(),"aidlc-checkpoint-")); const file=join(dir,"state.db");
|
|
245
|
+
const fleet=new SqliteFleetRepository(file); let state=new SqliteStateRepository(file);
|
|
246
|
+
const ownerToken="a".repeat(64);
|
|
247
|
+
try {
|
|
248
|
+
await state.apply("w",0,"seed",{type:"upsertTask",taskId:"T1",task},"/workspace/project");
|
|
249
|
+
const claim=await fleet.apply("w",0,"claim-token","lead","lead",{type:"run_claim",taskId:"T1",leaseMs:60000,ownerToken});
|
|
250
|
+
assert.equal(JSON.stringify(claim).includes(ownerToken),false);
|
|
251
|
+
await assert.rejects(() => fleet.apply("w",1,"bad-heartbeat","lead","lead",{type:"run_heartbeat",taskId:"T1",epoch:1,leaseMs:60000,ownerToken:"b".repeat(64)}),/stale or expired/);
|
|
252
|
+
await assert.rejects(() => state.putGateSnapshot("w",1,"bad-snapshot","T1","G0_confirm","Review",1,undefined,"b".repeat(64),true),/stale or expired/);
|
|
253
|
+
const snap=await state.putGateSnapshot("w",1,"snapshot-token","T1","G0_confirm","Review",1,undefined,ownerToken,true);
|
|
254
|
+
assert.equal((await state.gateCheckpoint("w","T1")).status,"pending");
|
|
255
|
+
await assert.rejects(() => state.approveGate("w",0,"bad-approval","T1",snap.id,"human","/workspace/project"),/revision conflict/);
|
|
256
|
+
assert.equal((await state.gateCheckpoint("w","T1")).status,"pending");
|
|
257
|
+
const approved=await state.approveGate("w",1,"approve-token","T1",snap.id,"human","/workspace/project");
|
|
258
|
+
state.close(); state=new SqliteStateRepository(file);
|
|
259
|
+
const checkpoint=await state.gateCheckpoint("w","T1");
|
|
260
|
+
assert.equal(checkpoint.status,"approved"); assert.equal(checkpoint.approvalRevision,approved.revision); assert.equal(checkpoint.snapshotId,snap.id);
|
|
261
|
+
assert.deepEqual(await state.approveGate("w",1,"approve-token","T1",snap.id,"human","/workspace/project"),approved);
|
|
262
|
+
await fleet.apply("w",1,"release-token","lead","lead",{type:"run_release",taskId:"T1",epoch:1,ownerToken});
|
|
263
|
+
await assert.rejects(() => state.apply("w",approved.revision,"stale-token",{type:"upsertTask",taskId:"T1",task:approved.state.tasks.T1},"/workspace/project",1,ownerToken),/stale or expired/);
|
|
264
|
+
} finally {state.close();fleet.close();rmSync(dir,{recursive:true,force:true});}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("PostgreSQL tokenized checkpoint survives approval and rejects stale owners", { skip: !process.env.TEST_POSTGRES_URL }, async () => {
|
|
268
|
+
const workspace = `test-${randomUUID()}`;
|
|
269
|
+
const fleet = new PostgresFleetRepository(process.env.TEST_POSTGRES_URL);
|
|
270
|
+
const state = new PostgresStateRepository(process.env.TEST_POSTGRES_URL);
|
|
271
|
+
const ownerToken = "c".repeat(64);
|
|
272
|
+
try {
|
|
273
|
+
await state.apply(workspace, 0, "seed", { type: "upsertTask", taskId: "T1", task }, "/workspace/project");
|
|
274
|
+
await fleet.apply(workspace, 0, "claim", "lead", "lead", { type: "run_claim", taskId: "T1", leaseMs: 60000, ownerToken });
|
|
275
|
+
await assert.rejects(() => state.putGateSnapshot(workspace, 1, "bad", "T1", "G0_confirm", "Review", 1, undefined, "d".repeat(64), true), /stale or expired/);
|
|
276
|
+
const snapshot = await state.putGateSnapshot(workspace, 1, "snapshot", "T1", "G0_confirm", "Review", 1, undefined, ownerToken, true);
|
|
277
|
+
assert.equal((await state.gateCheckpoint(workspace, "T1")).status, "pending");
|
|
278
|
+
const approved = await state.approveGate(workspace, 1, "approve", "T1", snapshot.id, "human", "/workspace/project");
|
|
279
|
+
const checkpoint = await state.gateCheckpoint(workspace, "T1");
|
|
280
|
+
assert.equal(checkpoint.status, "approved");
|
|
281
|
+
assert.equal(Number(checkpoint.approvalRevision), approved.revision);
|
|
282
|
+
assert.equal(checkpoint.snapshotId, snapshot.id);
|
|
283
|
+
await fleet.apply(workspace, 1, "release", "lead", "lead", { type: "run_release", taskId: "T1", epoch: 1, ownerToken });
|
|
284
|
+
await assert.rejects(() => state.apply(workspace, approved.revision, "stale", { type: "upsertTask", taskId: "T1", task: approved.state.tasks.T1 }, "/workspace/project", 1, ownerToken), /stale or expired/);
|
|
285
|
+
} finally {
|
|
286
|
+
for (const table of ["state_gate_checkpoints", "state_gate_snapshots", "state_events", "state_idempotency", "state_workspaces", "fleet_idempotency", "fleet_workspaces"]) await state.pool.query(`DELETE FROM ${table} WHERE workspace=$1`, [workspace]);
|
|
287
|
+
await state.close(); await fleet.close();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('supervised restart atomically validates approval checkpoint before claiming and consumes on fenced write', async () => {
|
|
292
|
+
const dir=mkdtempSync(join(tmpdir(),'fleet-restart-')), file=join(dir,'state.db');
|
|
293
|
+
const fleet=new SqliteFleetRepository(file), state=new SqliteStateRepository(file), supervisor='s'.repeat(64), run='r'.repeat(64);
|
|
294
|
+
try {
|
|
295
|
+
const active={...task,status:'active'};
|
|
296
|
+
await state.apply('w',0,'seed',{type:'upsertTask',taskId:'T1',task:active},'/workspace/project');
|
|
297
|
+
await fleet.apply('w',0,'super','lead','lead',{type:'supervisor_claim',taskId:'T1',leaseMs:60000,ownerToken:supervisor});
|
|
298
|
+
const digest=(value)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
299
|
+
const initial={type:'run_claim',taskId:'T1',leaseMs:60000,ownerToken:run,supervisorEpoch:1,supervisorOwnerToken:supervisor,expectedStateRevision:1,expectedTaskDigest:digest((await state.get('w')).state.tasks.T1)};
|
|
300
|
+
await assert.rejects(fleet.apply('w',1,'bad-digest','lead','lead',{...initial,expectedTaskDigest:'0'.repeat(64)}),/changed or is not runnable/);
|
|
301
|
+
await fleet.apply('w',1,'run','lead','lead',initial);
|
|
302
|
+
await state.apply('w',1,'wait',{type:'upsertTask',taskId:'T1',task},'/workspace/project',1,run,'lead');
|
|
303
|
+
const snapshot=await state.putGateSnapshot('w',2,'snap','T1','G0_confirm','Review',1,undefined,run,true,'lead');
|
|
304
|
+
await assert.rejects(state.putGateSnapshot('w',2,'snap','T1','G0_confirm','Changed review',1,undefined,run,true,'lead'),/idempotency payload/);
|
|
305
|
+
await fleet.apply('w',2,'release-run','lead','lead',{type:'run_release',taskId:'T1',epoch:1,ownerToken:run});
|
|
306
|
+
const approved=await state.approveGate('w',2,'approval','T1',snapshot.id,'human','/workspace/project',undefined,'reviewer');
|
|
307
|
+
await assert.rejects(state.approveGate('w',2,'approval','T1',snapshot.id,'changed','/workspace/project',undefined,'reviewer'),/idempotency payload/);
|
|
308
|
+
const restart={...initial,expectedStateRevision:approved.revision,expectedTaskDigest:digest(approved.state.tasks.T1),checkpointSnapshotId:snapshot.id,checkpointApprovalRevision:approved.revision};
|
|
309
|
+
await assert.rejects(fleet.apply('w',3,'bad-restart','lead','lead',{...restart,checkpointSnapshotId:'wrong'}),/checkpoint/);
|
|
310
|
+
await fleet.apply('w',3,'restart','lead','lead',restart);
|
|
311
|
+
assert.equal((await state.gateCheckpoint('w','T1')).status,'approved');
|
|
312
|
+
await state.apply('w',approved.revision,'progress',{type:'upsertTask',taskId:'T1',task:approved.state.tasks.T1},'/workspace/project',2,run,'lead');
|
|
313
|
+
assert.equal((await state.gateCheckpoint('w','T1')).status,'consumed');
|
|
314
|
+
await fleet.apply('w',4,'release-super','lead','lead',{type:'supervisor_release',taskId:'T1',epoch:1,ownerToken:supervisor});
|
|
315
|
+
await assert.rejects(state.apply('w',approved.revision,'progress',{type:'upsertTask',taskId:'T1',task:approved.state.tasks.T1},'/workspace/project',2,run,'lead'),/supervisor/);
|
|
316
|
+
} finally {state.close();fleet.close();rmSync(dir,{recursive:true,force:true});}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('PostgreSQL supervised restart validates and consumes checkpoint', { skip: !process.env.TEST_POSTGRES_URL }, async () => {
|
|
320
|
+
const fleet=new PostgresFleetRepository(process.env.TEST_POSTGRES_URL), state=new PostgresStateRepository(process.env.TEST_POSTGRES_URL), supervisor='s'.repeat(64), run='r'.repeat(64);
|
|
321
|
+
const workspace=`restart-${randomUUID()}`;
|
|
322
|
+
try {
|
|
323
|
+
const active={...task,status:'active'};
|
|
324
|
+
await state.apply(workspace,0,'seed',{type:'upsertTask',taskId:'T1',task:active},'/workspace/project');
|
|
325
|
+
await fleet.apply(workspace,0,'super','lead','lead',{type:'supervisor_claim',taskId:'T1',leaseMs:60000,ownerToken:supervisor});
|
|
326
|
+
const digest=(value)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
327
|
+
const initial={type:'run_claim',taskId:'T1',leaseMs:60000,ownerToken:run,supervisorEpoch:1,supervisorOwnerToken:supervisor,expectedStateRevision:1,expectedTaskDigest:digest((await state.get(workspace)).state.tasks.T1)};
|
|
328
|
+
await assert.rejects(fleet.apply(workspace,1,'bad-digest','lead','lead',{...initial,expectedTaskDigest:'0'.repeat(64)}),/changed or is not runnable/);
|
|
329
|
+
await fleet.apply(workspace,1,'run','lead','lead',initial);
|
|
330
|
+
await state.apply(workspace,1,'wait',{type:'upsertTask',taskId:'T1',task},'/workspace/project',1,run,'lead');
|
|
331
|
+
const snapshot=await state.putGateSnapshot(workspace,2,'snap','T1','G0_confirm','Review',1,undefined,run,true,'lead');
|
|
332
|
+
await assert.rejects(state.putGateSnapshot(workspace,2,'snap','T1','G0_confirm','Changed review',1,undefined,run,true,'lead'),/idempotency payload/);
|
|
333
|
+
await fleet.apply(workspace,2,'release-run','lead','lead',{type:'run_release',taskId:'T1',epoch:1,ownerToken:run});
|
|
334
|
+
const approved=await state.approveGate(workspace,2,'approval','T1',snapshot.id,'human','/workspace/project',undefined,'reviewer');
|
|
335
|
+
await assert.rejects(state.approveGate(workspace,2,'approval','T1',snapshot.id,'changed','/workspace/project',undefined,'reviewer'),/idempotency payload/);
|
|
336
|
+
const restart={...initial,expectedStateRevision:approved.revision,expectedTaskDigest:digest((await state.get(workspace)).state.tasks.T1),checkpointSnapshotId:snapshot.id,checkpointApprovalRevision:approved.revision};
|
|
337
|
+
await assert.rejects(fleet.apply(workspace,3,'bad-restart','lead','lead',{...restart,checkpointSnapshotId:'wrong'}),/checkpoint/);
|
|
338
|
+
await fleet.apply(workspace,3,'restart','lead','lead',restart);
|
|
339
|
+
assert.equal((await state.gateCheckpoint(workspace,'T1')).status,'approved');
|
|
340
|
+
await state.apply(workspace,approved.revision,'progress',{type:'upsertTask',taskId:'T1',task:approved.state.tasks.T1},'/workspace/project',2,run,'lead');
|
|
341
|
+
assert.equal((await state.gateCheckpoint(workspace,'T1')).status,'consumed');
|
|
342
|
+
const intent={workspace,taskId:'T1',epoch:2,ownerToken:run,operationId:'git-op',ref:'refs/heads/main',oldOid:'a'.repeat(40),newOid:'b'.repeat(40)};
|
|
343
|
+
assert.equal((await fleet.prepareGitOperation(intent)).status,'pending');
|
|
344
|
+
await assert.rejects(fleet.withGitFence(intent,async()=>{throw new Error('uncertain');}),/uncertain/);
|
|
345
|
+
assert.equal((await fleet.getGitOperation(intent)).status,'pending');
|
|
346
|
+
await fleet.withGitFence(intent,async ({assertValid,complete})=>{await assertValid();await complete();});
|
|
347
|
+
assert.equal((await fleet.getGitOperation(intent)).status,'complete');
|
|
348
|
+
await fleet.apply(workspace,4,'release-super','lead','lead',{type:'supervisor_release',taskId:'T1',epoch:1,ownerToken:supervisor});
|
|
349
|
+
await assert.rejects(state.apply(workspace,approved.revision,'progress',{type:'upsertTask',taskId:'T1',task:approved.state.tasks.T1},'/workspace/project',2,run,'lead'),/supervisor/);
|
|
350
|
+
} finally {for(const table of ['state_request_fingerprints','state_gate_checkpoints','state_gate_snapshots','state_events','state_idempotency','state_workspaces','fleet_git_operations','fleet_idempotency','fleet_workspaces']) await state.pool.query(`DELETE FROM ${table} WHERE workspace=$1`,[workspace]); await state.close();await fleet.close();}
|
|
351
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { createGitGateway } from "../src/git-gateway.mjs";
|
|
8
|
+
const git=(cwd,...args)=>execFileSync("git",args,{cwd,encoding:"utf8",stdio:["ignore","pipe","pipe"]}).trim();
|
|
9
|
+
const token="x".repeat(64);
|
|
10
|
+
function fixture(t) {
|
|
11
|
+
const dir=mkdtempSync(join(tmpdir(),"aidlc-gateway-")); t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
|
12
|
+
const repo=join(dir,"bare"), work=join(dir,"work"); mkdirSync(repo); git(repo,"init","--bare"); git(dir,"clone",repo,work); git(work,"checkout","-b","main");
|
|
13
|
+
writeFileSync(join(work,"a"),"one"); git(work,"add","a"); git(work,"-c","user.name=Test","-c","user.email=test@test","commit","-m","one"); const oldCommit=git(work,"rev-parse","HEAD"); git(work,"push","origin","HEAD:refs/heads/main");
|
|
14
|
+
writeFileSync(join(work,"a"),"two"); git(work,"add","a"); git(work,"-c","user.name=Test","-c","user.email=test@test","commit","-m","two"); const newCommit=git(work,"rev-parse","HEAD"); git(work,"push","origin","HEAD:refs/heads/objects-staging");
|
|
15
|
+
return {repo,oldCommit,newCommit};
|
|
16
|
+
}
|
|
17
|
+
function fakeAuthority() {
|
|
18
|
+
const records=new Map(); let valid=true, active=false, failComplete=false;
|
|
19
|
+
return {
|
|
20
|
+
records, setValid:(value)=>{valid=value;}, failComplete:()=>{failComplete=true;},
|
|
21
|
+
async prepareGitOperation(input) { if(!valid || input.ownerToken!==token) throw new Error("stale fence"); const {ownerToken,...operation}=input; const old=records.get(operation.operationId); if(old && JSON.stringify(old.record)!==JSON.stringify(operation)) throw new Error("operation conflict"); if(!old) records.set(operation.operationId,{record:operation,status:"pending"}); return records.get(operation.operationId); },
|
|
22
|
+
async withGitFence(input, callback) { if(!valid || input.ownerToken!==token || active) throw new Error("stale fence"); active=true; try { const stored=records.get(input.operationId); return await callback({record:stored.record,assertValid:async ()=>{if(!valid) throw new Error("stale fence");},complete:async ()=>{if(failComplete) throw new Error("crash"); stored.status="complete";}}); } finally {active=false;} }
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
test("gateway fences, CAS updates, and idempotently returns a completed operation", async (t) => {
|
|
26
|
+
const {repo,oldCommit,newCommit}=fixture(t), authority=fakeAuthority();
|
|
27
|
+
const gateway=createGitGateway({repo,protectedRefs:["refs/heads/main"],authority});
|
|
28
|
+
const req={workspace:"w",taskId:"t",epoch:1,ownerToken:token,operationId:"one",ref:"refs/heads/main",oldCommit,newCommit};
|
|
29
|
+
await assert.rejects(()=>gateway.update({...req,ownerToken:"y".repeat(64)}),/stale fence/);
|
|
30
|
+
await assert.rejects(()=>gateway.update({...req,ref:"refs/heads/objects-staging"}),/Invalid protected/);
|
|
31
|
+
assert.equal((await gateway.update(req)).status,"complete"); assert.equal(git(repo,"rev-parse","refs/heads/main"),newCommit);
|
|
32
|
+
assert.equal((await gateway.update(req)).status,"complete");
|
|
33
|
+
await assert.rejects(()=>gateway.update({...req,epoch:2}),/operation conflict/);
|
|
34
|
+
});
|
|
35
|
+
test("pending intent after ref update and failed authority commit fails closed", async (t) => {
|
|
36
|
+
const {repo,oldCommit,newCommit}=fixture(t), authority=fakeAuthority(); const gateway=createGitGateway({repo,protectedRefs:["refs/heads/main"],authority});
|
|
37
|
+
const req={workspace:"w",taskId:"t",epoch:1,ownerToken:token,operationId:"one",ref:"refs/heads/main",oldCommit,newCommit};
|
|
38
|
+
authority.failComplete(); await assert.rejects(()=>gateway.update(req),/authority commit uncertain/);
|
|
39
|
+
assert.equal(git(repo,"rev-parse","refs/heads/main"),newCommit); assert.equal(authority.records.get("one").status,"pending");
|
|
40
|
+
await assert.rejects(()=>gateway.update(req),/outcome uncertain/);
|
|
41
|
+
});
|
|
42
|
+
test("ref moved before the lease-locked CAS is rejected", async (t) => {
|
|
43
|
+
const {repo,oldCommit,newCommit}=fixture(t), authority=fakeAuthority(); const gateway=createGitGateway({repo,protectedRefs:["refs/heads/main"],authority});
|
|
44
|
+
git(repo,"update-ref","refs/heads/main",newCommit,oldCommit);
|
|
45
|
+
await assert.rejects(()=>gateway.update({workspace:"w",taskId:"t",epoch:1,ownerToken:token,operationId:"race",ref:"refs/heads/main",oldCommit,newCommit}),/outcome uncertain/);
|
|
46
|
+
assert.equal(authority.records.get("race").status,"pending");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("lease loss after durable intent prevents Git ref update", async (t) => {
|
|
50
|
+
const {repo,oldCommit,newCommit}=fixture(t), authority=fakeAuthority();
|
|
51
|
+
const prepare=authority.prepareGitOperation;
|
|
52
|
+
authority.prepareGitOperation=async (input)=>{const result=await prepare(input);authority.setValid(false);return result;};
|
|
53
|
+
const gateway=createGitGateway({repo,protectedRefs:["refs/heads/main"],authority});
|
|
54
|
+
await assert.rejects(()=>gateway.update({workspace:"w",taskId:"t",epoch:1,ownerToken:token,operationId:"expiry",ref:"refs/heads/main",oldCommit,newCommit}),/stale fence/);
|
|
55
|
+
assert.equal(git(repo,"rev-parse","refs/heads/main"),oldCommit);
|
|
56
|
+
assert.equal(authority.records.get("expiry").status,"pending");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("SQLite authority holds its real lease while gateway updates the Git ref", async (t) => {
|
|
60
|
+
const {repo,oldCommit,newCommit}=fixture(t);
|
|
61
|
+
const {SqliteFleetRepository,applyFleetCommand}=await import("../src/fleet.mjs");
|
|
62
|
+
const authority=new SqliteFleetRepository(":memory:");
|
|
63
|
+
t.after(()=>authority.close());
|
|
64
|
+
await authority.get("w");
|
|
65
|
+
const supervisorToken="s".repeat(64);
|
|
66
|
+
let fleet={schemaVersion:1,features:{},leads:{},jobs:{}};
|
|
67
|
+
fleet=applyFleetCommand(fleet,{type:"supervisor_claim",taskId:"t",leaseMs:60000,ownerToken:supervisorToken},"lead","lead");
|
|
68
|
+
fleet=applyFleetCommand(fleet,{type:"run_claim",taskId:"t",leaseMs:60000,ownerToken:token,supervisorEpoch:1,supervisorOwnerToken:supervisorToken},"lead","lead");
|
|
69
|
+
authority.db.prepare("UPDATE fleet_workspaces SET fleet=? WHERE workspace=?").run(JSON.stringify(fleet),"w");
|
|
70
|
+
const gateway=createGitGateway({repo,protectedRefs:["refs/heads/main"],authority});
|
|
71
|
+
const request={workspace:"w",taskId:"t",epoch:1,ownerToken:token,operationId:"actual",ref:"refs/heads/main",oldCommit,newCommit};
|
|
72
|
+
assert.equal((await gateway.update(request)).status,"complete");
|
|
73
|
+
assert.equal((await authority.getGitOperation({workspace:"w",operationId:"actual"})).status,"complete");
|
|
74
|
+
assert.equal(git(repo,"rev-parse","refs/heads/main"),newCommit);
|
|
75
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { applyFleetCommand, SqliteFleetRepository } from '../src/fleet.mjs';
|
|
5
|
+
const owner = 's'.repeat(48), runOwner = 'r'.repeat(48), bad = 'x'.repeat(48), leaseMs = 60000, actor = 'lead', time = 1800000000000;
|
|
6
|
+
const run = (state, command, timestamp = time) => applyFleetCommand(state, command, actor, 'lead', timestamp);
|
|
7
|
+
test('supervisor binds run and fences stale and tokenless leads', () => {
|
|
8
|
+
let state = { schemaVersion: 1, features: {}, leads: {}, jobs: {} };
|
|
9
|
+
state = run(state, { type: 'supervisor_claim', taskId: 'T', leaseMs, ownerToken: owner });
|
|
10
|
+
assert.throws(() => run(state, { type: 'supervisor_claim', taskId: 'T', leaseMs, ownerToken: bad }), /held/);
|
|
11
|
+
assert.throws(() => run(state, { type: 'run_claim', taskId: 'T', leaseMs }), /supervisor claim/);
|
|
12
|
+
assert.throws(() => run(state, { type: 'run_claim', taskId: 'T', leaseMs, supervisorEpoch: 1, supervisorOwnerToken: bad, ownerToken: runOwner }), /supervisor claim/);
|
|
13
|
+
state = run(state, { type: 'run_claim', taskId: 'T', leaseMs, supervisorEpoch: 1, supervisorOwnerToken: owner, ownerToken: runOwner });
|
|
14
|
+
assert.throws(() => run(state, { type: 'run_heartbeat', taskId: 'T', epoch: 1, leaseMs, ownerToken: bad }), /lead claim/);
|
|
15
|
+
state = run(state, { type: 'supervisor_release', taskId: 'T', epoch: 1, ownerToken: owner });
|
|
16
|
+
assert.throws(() => run(state, { type: 'run_heartbeat', taskId: 'T', epoch: 1, leaseMs, ownerToken: runOwner }), /supervisor claim/);
|
|
17
|
+
state = run(state, { type: 'supervisor_claim', taskId: 'T', leaseMs, ownerToken: bad }, time + leaseMs + 1);
|
|
18
|
+
assert.equal(state.supervisors.T.epoch, 2);
|
|
19
|
+
assert.throws(() => run(state, { type: 'run_claim', taskId: 'T', leaseMs, supervisorEpoch: 1, supervisorOwnerToken: owner, ownerToken: runOwner }, time + leaseMs + 2), /supervisor claim/);
|
|
20
|
+
});
|
|
21
|
+
test('supervisor and lead secrets never appear in fleet output or idempotency cache', async () => {
|
|
22
|
+
const repo = new SqliteFleetRepository(':memory:');
|
|
23
|
+
try {
|
|
24
|
+
const command = { type: 'supervisor_claim', taskId: 'T', leaseMs, ownerToken: owner };
|
|
25
|
+
const first = await repo.apply('W', 0, 's1', actor, 'lead', command);
|
|
26
|
+
assert.equal(first.fleet.supervisors.T.epoch, 1);
|
|
27
|
+
const task = { id: 'T', phase: 'plan', gate: 'G1_review', status: 'active', decisions: [], tasks: [], evidence: [] };
|
|
28
|
+
repo.db.exec('CREATE TABLE state_workspaces (workspace TEXT, revision INTEGER, state TEXT); CREATE TABLE state_gate_checkpoints (workspace TEXT, task_id TEXT); CREATE TABLE state_gate_snapshots (workspace TEXT, id TEXT)');
|
|
29
|
+
repo.db.prepare('INSERT INTO state_workspaces VALUES (?, ?, ?)').run('W', 1, JSON.stringify({ tasks: { T: task } }));
|
|
30
|
+
const digest = createHash('sha256').update(JSON.stringify(task)).digest('hex');
|
|
31
|
+
const runClaim = { type: 'run_claim', taskId: 'T', leaseMs, ownerToken: runOwner, supervisorEpoch: 1, supervisorOwnerToken: owner, expectedStateRevision: 1, expectedTaskDigest: digest };
|
|
32
|
+
const next = await repo.apply('W', 1, 'r1', actor, 'lead', runClaim);
|
|
33
|
+
const serialized = JSON.stringify([first, next, await repo.get('W'), repo.db.prepare('SELECT response FROM fleet_idempotency').all()]);
|
|
34
|
+
assert.ok(!serialized.includes(owner) && !serialized.includes(runOwner) && !serialized.includes('ownerTokenHash') && !serialized.includes('supervisorOwnerTokenHash'));
|
|
35
|
+
assert.deepEqual(await repo.apply('W', 0, 's1', actor, 'lead', command), first);
|
|
36
|
+
await assert.rejects(repo.apply('W', 0, 's1', actor, 'lead', { ...command, leaseMs: 120000 }), /payload mismatch/);
|
|
37
|
+
await repo.apply('W', 2, 'release', actor, 'lead', { type: 'supervisor_release', taskId: 'T', epoch: 1, ownerToken: owner });
|
|
38
|
+
await assert.rejects(repo.apply('W', 1, 'r1', actor, 'lead', runClaim), /supervisor claim/);
|
|
39
|
+
} finally { repo.close(); }
|
|
40
|
+
});
|
|
41
|
+
test('Git gateway persists intent, fences lease and retains pending on uncertain callback', async () => {
|
|
42
|
+
const repo = new SqliteFleetRepository(':memory:');
|
|
43
|
+
try {
|
|
44
|
+
await repo.apply('W', 0, 's1', actor, 'lead', { type:'supervisor_claim',taskId:'T',leaseMs,ownerToken:owner });
|
|
45
|
+
const task={id:'T',phase:'plan',gate:'G1_review',status:'active',decisions:[],tasks:[],evidence:[]};
|
|
46
|
+
repo.db.exec('CREATE TABLE state_workspaces (workspace TEXT, revision INTEGER, state TEXT); CREATE TABLE state_gate_checkpoints (workspace TEXT, task_id TEXT); CREATE TABLE state_gate_snapshots (workspace TEXT, id TEXT)');
|
|
47
|
+
repo.db.prepare('INSERT INTO state_workspaces VALUES (?,?,?)').run('W',1,JSON.stringify({tasks:{T:task}}));
|
|
48
|
+
await repo.apply('W',1,'run',actor,'lead',{type:'run_claim',taskId:'T',leaseMs,ownerToken:runOwner,supervisorEpoch:1,supervisorOwnerToken:owner,expectedStateRevision:1,expectedTaskDigest:createHash('sha256').update(JSON.stringify(task)).digest('hex')});
|
|
49
|
+
const intent={workspace:'W',taskId:'T',epoch:1,ownerToken:runOwner,operationId:'op',ref:'refs/heads/main',oldOid:'a'.repeat(40),newOid:'b'.repeat(40)};
|
|
50
|
+
assert.deepEqual(await repo.prepareGitOperation(intent),{status:'pending'});
|
|
51
|
+
await assert.rejects(repo.prepareGitOperation({...intent,newOid:'c'.repeat(40)}),/mismatch/);
|
|
52
|
+
await assert.rejects(repo.withGitFence(intent,async ({assertValid})=>{assertValid();throw Error('ambiguous Git result');}),/ambiguous/);
|
|
53
|
+
assert.equal((await repo.getGitOperation(intent)).status,'pending');
|
|
54
|
+
await repo.withGitFence(intent,async ({record,assertValid,complete})=>{assert.equal(record.ref,intent.ref);assertValid();await complete();});
|
|
55
|
+
assert.equal((await repo.getGitOperation(intent)).status,'complete');
|
|
56
|
+
await repo.apply('W',2,'super-release',actor,'lead',{type:'supervisor_release',taskId:'T',epoch:1,ownerToken:owner});
|
|
57
|
+
await assert.rejects(repo.prepareGitOperation(intent),/supervisor/);
|
|
58
|
+
} finally { repo.close(); }
|
|
59
|
+
});
|
package/dist/src/adapters.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
const hasManagedBlock = (root, path, owner) => {
|
|
4
|
+
const target = join(root, path);
|
|
5
|
+
if (!existsSync(target))
|
|
6
|
+
return false;
|
|
7
|
+
try {
|
|
8
|
+
const content = readFileSync(target, "utf8");
|
|
9
|
+
return content.includes(`<!-- aidlc-installer:${owner}:start -->`) || content.includes(`<!-- aidlc-installer:${owner} -->`);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
3
15
|
const adapterFile = (owner, path, body) => ({
|
|
4
16
|
path,
|
|
5
17
|
owner,
|
|
@@ -12,11 +24,12 @@ const instruction = (agent, adapter) => [
|
|
|
12
24
|
"",
|
|
13
25
|
"Read `.agents/aidlc/orchestrator.md`. Use bounded `state.mjs task list` or `task find --query <text>`; never read or dump lifecycle files directly.",
|
|
14
26
|
"",
|
|
15
|
-
"At each phase entry and dependency boundary, apply the orchestrator's delegation contract. When parallel work is independent and valuable, use your host's native collaboration/delegation tools to fan out at most the available collaboration slots minus the lead. The lead alone owns lifecycle mutations, gates, and task review artifacts; assign non-overlapping paths, require each worker to return its summary, changed paths, verification evidence, and concerns, then integrate and verify its result before advancing.",
|
|
27
|
+
"The current client conversation is the interactive lead. At each phase entry and dependency boundary, apply the orchestrator's delegation contract. When parallel work is independent and valuable, use your host's native collaboration/delegation tools to fan out at most the available collaboration slots minus the lead. The lead alone owns lifecycle mutations, gates, and task review artifacts; assign non-overlapping paths, require each worker to return its summary, changed paths, verification evidence, and concerns, then integrate and verify its result before advancing.",
|
|
16
28
|
"",
|
|
17
29
|
"For a NEW task, preserve an exact user-supplied canonical ID when present. Run `node .agents/aidlc/scripts/state.mjs task create <id> --title <title>`, then execute the clarify phase; do not call `task next` before the task exists.",
|
|
18
30
|
"If `task create` reports another actionable task, stop and surface it. Pass the returned `--switch-from <task-id>` acknowledgement only when the user explicitly chooses to switch; the prior task keeps its state and remains the resume target — never pause, close, or forget it silently.",
|
|
19
|
-
"For an existing task, run `state.mjs task next <task-id>` and use the matching phase packet.",
|
|
31
|
+
"For an existing task or an explicit `resume <task-id>` request, run `state.mjs task next <task-id>` and use the matching phase packet. Keep advancing within this conversation until a human gate, structured blocker, terminal action, or completion.",
|
|
32
|
+
"Before native worker dispatch and after reopening a conversation, run `assignment.mjs reconcile <task-id>` and inspect uncertain/reported claims against canonical task state and worker output. Claim each independent item with `assignment.mjs claim <task-id> <item-id> --worker <id> --paths <owned-paths>`; pass the assignment ID and exclusive paths to the worker. Release or reconcile claims after integrating. Never redispatch an uncertain claim without inspection. These records are operational only; task status and `nextAction` remain canonical.",
|
|
20
33
|
...(adapter === "codex" ? ["Codex execpolicy prefix rules match one command invocation. Run every `node .agents/aidlc/scripts/...` lifecycle command in a separate exec call; never join lifecycle commands with shell operators such as `&`, `&&`, `;`, or `|`."] : []),
|
|
21
34
|
"",
|
|
22
35
|
"Before a human gate: run `gate-check.mjs`; set `task status <task-id> --status blocked_on_user`; render the task artifact when the phase requires it; then run `gate-view.mjs <task-id>` and relay its output verbatim.",
|
|
@@ -47,6 +60,7 @@ const claudeLocalSettings = JSON.stringify({
|
|
|
47
60
|
const codexExecPolicy = [
|
|
48
61
|
"# Permit only AI-DLC lifecycle scripts without an approval prompt.",
|
|
49
62
|
"prefix_rule(pattern = [\"node\", \".agents/aidlc/scripts/context.mjs\"], decision = \"allow\")",
|
|
63
|
+
"prefix_rule(pattern = [\"node\", \".agents/aidlc/scripts/assignment.mjs\"], decision = \"allow\")",
|
|
50
64
|
"prefix_rule(pattern = [\"node\", \".agents/aidlc/scripts/gate-check.mjs\"], decision = \"allow\")",
|
|
51
65
|
"prefix_rule(pattern = [\"node\", \".agents/aidlc/scripts/gate-view.mjs\"], decision = \"allow\")",
|
|
52
66
|
"prefix_rule(pattern = [\"node\", \".agents/aidlc/scripts/render.mjs\"], decision = \"allow\")",
|
|
@@ -62,7 +76,7 @@ export const adapters = [
|
|
|
62
76
|
files: () => [
|
|
63
77
|
{ ...adapterFile("claude", "CLAUDE.md", instruction("Claude Code", "claude")), strategy: "managed-block" },
|
|
64
78
|
{ path: ".claude/settings.local.json", owner: "claude", content: claudeLocalSettings },
|
|
65
|
-
...["clarify:
|
|
79
|
+
...["clarify:Confirm requirements", "plan:Approve plan", "build:Review implementation", "wrap:none", "index:none"].map((entry) => {
|
|
66
80
|
const [phase, gate] = entry.split(":");
|
|
67
81
|
return adapterFile("claude", `.claude/skills/aidlc-${phase}/SKILL.md`, claudeSkill(phase, gate));
|
|
68
82
|
})
|
|
@@ -71,12 +85,18 @@ export const adapters = [
|
|
|
71
85
|
{
|
|
72
86
|
id: "codex",
|
|
73
87
|
displayName: "Codex",
|
|
74
|
-
detect: (root) => existsSync(join(root, "
|
|
88
|
+
detect: (root) => existsSync(join(root, ".codex")) || hasManagedBlock(root, "AGENTS.md", "codex"),
|
|
75
89
|
files: () => [
|
|
76
90
|
{ ...adapterFile("codex", "AGENTS.md", instruction("Codex", "codex")), strategy: "managed-block" },
|
|
77
91
|
{ path: ".codex/config.toml", owner: "codex", content: "approval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n" },
|
|
78
92
|
{ path: ".codex/rules/aidlc.rules", owner: "codex", content: codexExecPolicy }
|
|
79
93
|
]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "opencode",
|
|
97
|
+
displayName: "OpenCode",
|
|
98
|
+
detect: (root) => existsSync(join(root, ".opencode")) || hasManagedBlock(root, "AGENTS.md", "opencode"),
|
|
99
|
+
files: () => [{ ...adapterFile("opencode", "AGENTS.md", instruction("OpenCode", "opencode")), strategy: "managed-block" }]
|
|
80
100
|
}
|
|
81
101
|
];
|
|
82
102
|
export const resolvedAdapters = () => adapters;
|