@wopr-network/defcon 1.13.0 → 1.15.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 +44 -0
- package/dist/src/engine/engine.js +36 -1
- package/dist/src/engine/invocation-builder.js +1 -1
- package/dist/src/execution/cli.js +3 -1
- package/dist/src/execution/provision-worktree.js +5 -0
- package/dist/src/repositories/drizzle/entity.repo.d.ts +1 -0
- package/dist/src/repositories/drizzle/entity.repo.js +13 -0
- package/dist/src/repositories/event-sourced/entity.repo.d.ts +1 -0
- package/dist/src/repositories/event-sourced/entity.repo.js +4 -0
- package/dist/src/repositories/event-sourced/replay.js +15 -0
- package/dist/src/repositories/interfaces.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,6 +204,50 @@ npx defcon run --flow my-pipeline
|
|
|
204
204
|
|
|
205
205
|
Same flow. Same gates. Same escalation. The only difference is who's turning the crank — your agent or DEFCON's runner. Either way, the work doesn't advance until the evidence says it should.
|
|
206
206
|
|
|
207
|
+
## The Deeper Truth: Defcon Is a Prompt Engineering State Machine
|
|
208
|
+
|
|
209
|
+
The manifesto above tells you why gates matter. Here's the insight that changes how you think about everything else.
|
|
210
|
+
|
|
211
|
+
Defcon is not an orchestration engine that happens to give prompts to agents. **Defcon is a prompt engineering state machine.** Every state is a prompt. Every transition is a context transformation. Every gate is a deterministic filter that decides what prompt the agent gets next — or whether it gets one at all.
|
|
212
|
+
|
|
213
|
+
The flow definition is the engineering artifact. Not the agent code. Not the model selection. The flow.
|
|
214
|
+
|
|
215
|
+
### Context Assembly Is the Contract
|
|
216
|
+
|
|
217
|
+
An agent invocation is expensive. An agent invocation where the agent spends tool calls reading its own issue, checking CI status, or finding the PR it's supposed to review — that is a flow engineering defect. The onEnter hook should have assembled that context before the agent fired.
|
|
218
|
+
|
|
219
|
+
**Every tool call an agent makes to gather context is a failure of the flow definition to provide it.**
|
|
220
|
+
|
|
221
|
+
When the architect calls Linear to read the issue description — that's already in `entity.refs.linear.description`. Put it in the prompt template. When the coder calls `gh pr list` — that's a missing onEnter hook. When the reviewer runs `gh pr checks` — the gate already verified this.
|
|
222
|
+
|
|
223
|
+
The measure of a well-engineered state is: **can the agent complete its job with zero context-gathering tool calls?** Every tool call should be *work* — writing code, posting comments, running tests — never reconnaissance.
|
|
224
|
+
|
|
225
|
+
### Gates Are Prompt Qualification
|
|
226
|
+
|
|
227
|
+
A gate doesn't just verify that work is done. A gate verifies that **the next state's context can be assembled completely**.
|
|
228
|
+
|
|
229
|
+
`review-bots-ready` waiting for CI and bot comments isn't patience. It ensures the reviewer's prompt will contain: green CI, all bot findings, full diff. Without the gate, the reviewer either polls (burning tokens) or reviews without full information (wrong answer, another loop).
|
|
230
|
+
|
|
231
|
+
The cost of a gate is milliseconds of shell execution. The cost of a skipped gate is a full review/fix cycle — potentially minutes and dollars.
|
|
232
|
+
|
|
233
|
+
### The 1:2.8 Ratio Is Physics
|
|
234
|
+
|
|
235
|
+
For every 1 coder invocation, there are approximately 2.8 reviewer/fixer invocations. This is not a pipeline inefficiency. It is the actual shape of software.
|
|
236
|
+
|
|
237
|
+
The coder produces a first approximation. Reality pushes back: CI failures, static analysis findings, edge cases the spec didn't anticipate, style violations the linter catches. 70% of the engineering work happens after the code is written. You cannot prompt-engineer your way out of this. You cannot pre-load enough context to get one-shot correctness. The iteration is load-bearing.
|
|
238
|
+
|
|
239
|
+
The design question is not "how do we reduce the review/fix loop." It is: **given that ~2.8 cycles is the physics, how do we make each cycle as cheap and fast as possible?**
|
|
240
|
+
|
|
241
|
+
Every gate that catches a problem before an agent runs saves a full cycle. Every onEnter hook that assembles complete context means the agent spends its tokens on reasoning instead of discovery. Every failure prompt that tells the agent exactly what went wrong reduces the chance of another loop.
|
|
242
|
+
|
|
243
|
+
### Flow Engineering Is 90% of the Work
|
|
244
|
+
|
|
245
|
+
The promise is big: software that ships with 100% overhead reduction. But 90% of the engineering work to get there is flow engineering — designing states, writing hooks, placing gates, and crafting prompts. The agent is the easy part. The agent is a commodity. The flow is the competitive advantage.
|
|
246
|
+
|
|
247
|
+
A poorly written failure prompt extends the loop. A gate that fires too early sends an under-qualified prompt to the reviewer. A missing onEnter hook makes the agent reconstruct context with tool calls instead of reasoning. The flow definition IS the quality of the system.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
207
251
|
## The Engine
|
|
208
252
|
|
|
209
253
|
A **flow** is a state machine. Entities enter it and move through states. At each state an agent does work. At each boundary a deterministic gate verifies the output. Transitions fire on signals — not parsed natural language, not regex, but typed strings agents emit via tool call. The entire definition lives in a database and can be mutated at runtime.
|
|
@@ -8,6 +8,8 @@ import { buildInvocation } from "./invocation-builder.js";
|
|
|
8
8
|
import { executeOnEnter } from "./on-enter.js";
|
|
9
9
|
import { executeOnExit } from "./on-exit.js";
|
|
10
10
|
import { findTransition, isTerminal } from "./state-machine.js";
|
|
11
|
+
const MERGE_BLOCKED_THRESHOLD = 3;
|
|
12
|
+
const MERGE_BLOCKED_STUCK_MESSAGE = "PR blocked in merge queue 3+ times — likely branch protection or queue contention issue";
|
|
11
13
|
export class Engine {
|
|
12
14
|
entityRepo;
|
|
13
15
|
flowRepo;
|
|
@@ -93,10 +95,31 @@ export class Engine {
|
|
|
93
95
|
return { gated: true, ...routing, terminal: false };
|
|
94
96
|
}
|
|
95
97
|
// Gate passed or redirected — determine the actual destination.
|
|
96
|
-
|
|
98
|
+
let toState = routing.kind === "redirect" ? routing.toState : transition.toState;
|
|
97
99
|
const trigger = routing.kind === "redirect" ? routing.trigger : signal;
|
|
98
100
|
const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
|
|
99
101
|
const { gatesPassed } = routing;
|
|
102
|
+
// 4a. Merge-blocked loop detection: if the watcher sends "blocked" from
|
|
103
|
+
// "merging", increment a counter. After MERGE_BLOCKED_THRESHOLD cycles,
|
|
104
|
+
// override the destination to "stuck" to break the loop.
|
|
105
|
+
if (signal === "blocked" && entity.state === "merging") {
|
|
106
|
+
const prevCount = (typeof entity.artifacts?.merge_blocked_count === "number" ? entity.artifacts.merge_blocked_count : 0);
|
|
107
|
+
const newCount = prevCount + 1;
|
|
108
|
+
await this.entityRepo.updateArtifacts(entityId, { merge_blocked_count: newCount });
|
|
109
|
+
if (newCount >= MERGE_BLOCKED_THRESHOLD) {
|
|
110
|
+
const stuckState = flow.states.find((s) => s.name === "stuck");
|
|
111
|
+
if (stuckState) {
|
|
112
|
+
toState = "stuck";
|
|
113
|
+
await this.entityRepo.updateArtifacts(entityId, {
|
|
114
|
+
merge_blocked_message: MERGE_BLOCKED_STUCK_MESSAGE,
|
|
115
|
+
});
|
|
116
|
+
this.logger.warn(`[engine] Entity ${entityId} merge-blocked ${newCount} times, transitioning to stuck`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
this.logger.warn(`merge_blocked_count >= threshold but no stuck state in flow ${flow.name} — entity ${entityId} will continue looping`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
100
123
|
// 4b. Execute onExit hook on the DEPARTING state (before transition)
|
|
101
124
|
const departingStateDef = flow.states.find((s) => s.name === entity.state);
|
|
102
125
|
if (departingStateDef?.onExit) {
|
|
@@ -145,6 +168,18 @@ export class Engine {
|
|
|
145
168
|
// 6b. Execute onEnter hook if defined on the new state
|
|
146
169
|
const newStateDef = flow.states.find((s) => s.name === toState);
|
|
147
170
|
if (newStateDef?.onEnter) {
|
|
171
|
+
// Clear stale onEnter artifact keys so the hook re-runs on state re-entry.
|
|
172
|
+
// Only the keys belonging to THIS state's onEnter are removed; other artifacts are preserved.
|
|
173
|
+
const keysToRemove = [...newStateDef.onEnter.artifacts, "onEnter_error"];
|
|
174
|
+
const currentArtifacts = updated.artifacts ?? {};
|
|
175
|
+
const hasStaleKeys = keysToRemove.some((k) => currentArtifacts[k] !== undefined);
|
|
176
|
+
if (hasStaleKeys) {
|
|
177
|
+
await this.entityRepo.removeArtifactKeys(entityId, keysToRemove);
|
|
178
|
+
// Refresh in-memory entity so executeOnEnter sees cleared artifacts
|
|
179
|
+
const refreshed = await this.entityRepo.get(entityId);
|
|
180
|
+
if (refreshed)
|
|
181
|
+
updated = refreshed;
|
|
182
|
+
}
|
|
148
183
|
const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
|
|
149
184
|
if (onEnterResult.skipped) {
|
|
150
185
|
await emitter.emit({
|
|
@@ -46,7 +46,7 @@ export async function buildInvocation(state, entity, adapters, flow, logger = co
|
|
|
46
46
|
let systemPrompt = "";
|
|
47
47
|
let userContent = "";
|
|
48
48
|
if (state.promptTemplate) {
|
|
49
|
-
const template = getHandlebars().compile(state.promptTemplate);
|
|
49
|
+
const template = getHandlebars().compile(state.promptTemplate, { noEscape: true });
|
|
50
50
|
prompt = template(context);
|
|
51
51
|
systemPrompt = `${INJECTION_WARNING}\n\n${prompt}`;
|
|
52
52
|
userContent = `<external-data>\n${JSON.stringify(entityDataForContext(entity), null, 2)}\n</external-data>`;
|
|
@@ -474,9 +474,11 @@ program
|
|
|
474
474
|
sqlite.close();
|
|
475
475
|
});
|
|
476
476
|
// ─── provision-worktree ───
|
|
477
|
+
// TODO(WOP-2014): Remove this subcommand once nuke containers are deployed and stable.
|
|
478
|
+
// Nuke workers provision their own workspace inside the container, making this unnecessary.
|
|
477
479
|
program
|
|
478
480
|
.command("provision-worktree")
|
|
479
|
-
.description("Provision a git worktree and branch for an issue")
|
|
481
|
+
.description("[DEPRECATED] Provision a git worktree and branch for an issue (superseded by nuke containers)")
|
|
480
482
|
.argument("<repo>", "GitHub repo (e.g. wopr-network/defcon)")
|
|
481
483
|
.argument("<issue-key>", "Issue key (e.g. WOP-392)")
|
|
482
484
|
.option("--base-path <path>", "Worktree base directory", join(homedir(), "worktrees"))
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated provision-worktree is superseded by nuke containerized workers (WOP-2014).
|
|
3
|
+
* Nuke containers provision their own workspace inside the container.
|
|
4
|
+
* TODO: remove this file once nuke is deployed and stable.
|
|
5
|
+
*/
|
|
1
6
|
import { execFileSync } from "node:child_process";
|
|
2
7
|
import { existsSync } from "node:fs";
|
|
3
8
|
import { homedir } from "node:os";
|
|
@@ -12,6 +12,7 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
|
|
|
12
12
|
hasAnyInFlowAndState(flowId: string, stateNames: string[]): Promise<boolean>;
|
|
13
13
|
transition(id: string, toState: string, _trigger: string, artifacts?: Partial<Artifacts>, _invocationId?: string | null): Promise<Entity>;
|
|
14
14
|
updateArtifacts(id: string, artifacts: Partial<Artifacts>): Promise<void>;
|
|
15
|
+
removeArtifactKeys(id: string, keys: string[]): Promise<void>;
|
|
15
16
|
claim(flowId: string, state: string, agentId: string): Promise<Entity | null>;
|
|
16
17
|
claimById(entityId: string, agentId: string): Promise<Entity | null>;
|
|
17
18
|
release(entityId: string, agentId: string): Promise<void>;
|
|
@@ -101,6 +101,19 @@ export class DrizzleEntityRepository {
|
|
|
101
101
|
.set({ artifacts: { ...existing, ...artifacts }, updatedAt: Date.now() })
|
|
102
102
|
.where(eq(entities.id, id));
|
|
103
103
|
}
|
|
104
|
+
async removeArtifactKeys(id, keys) {
|
|
105
|
+
if (keys.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
const rows = await this.db.select().from(entities).where(eq(entities.id, id)).limit(1);
|
|
108
|
+
if (rows.length === 0)
|
|
109
|
+
throw new NotFoundError(`Entity not found: ${id}`);
|
|
110
|
+
const existing = rows[0].artifacts ?? {};
|
|
111
|
+
const cleaned = { ...existing };
|
|
112
|
+
for (const key of keys) {
|
|
113
|
+
delete cleaned[key];
|
|
114
|
+
}
|
|
115
|
+
await this.db.update(entities).set({ artifacts: cleaned, updatedAt: Date.now() }).where(eq(entities.id, id));
|
|
116
|
+
}
|
|
104
117
|
async claim(flowId, state, agentId) {
|
|
105
118
|
return this.db.transaction((tx) => {
|
|
106
119
|
const rows = tx
|
|
@@ -11,6 +11,7 @@ export declare class EventSourcedEntityRepository implements IEntityRepository {
|
|
|
11
11
|
hasAnyInFlowAndState(flowId: string, stateNames: string[]): Promise<boolean>;
|
|
12
12
|
transition(id: string, toState: string, trigger: string, artifacts?: Partial<Artifacts>): Promise<Entity>;
|
|
13
13
|
updateArtifacts(id: string, artifacts: Partial<Artifacts>): Promise<void>;
|
|
14
|
+
removeArtifactKeys(id: string, keys: string[]): Promise<void>;
|
|
14
15
|
claim(flowId: string, state: string, agentId: string): Promise<Entity | null>;
|
|
15
16
|
claimById(entityId: string, agentId: string): Promise<Entity | null>;
|
|
16
17
|
release(entityId: string, agentId: string): Promise<void>;
|
|
@@ -48,6 +48,10 @@ export class EventSourcedEntityRepository {
|
|
|
48
48
|
async updateArtifacts(id, artifacts) {
|
|
49
49
|
return this.mutable.updateArtifacts(id, artifacts);
|
|
50
50
|
}
|
|
51
|
+
async removeArtifactKeys(id, keys) {
|
|
52
|
+
await this.mutable.removeArtifactKeys(id, keys);
|
|
53
|
+
await this.domainEvents.append("entity.artifacts_removed", id, { keys });
|
|
54
|
+
}
|
|
51
55
|
async claim(flowId, state, agentId) {
|
|
52
56
|
return this.mutable.claim(flowId, state, agentId);
|
|
53
57
|
}
|
|
@@ -59,6 +59,21 @@ export function replayEntity(snapshot, events, entityId) {
|
|
|
59
59
|
state.updatedAt = new Date(event.emittedAt);
|
|
60
60
|
break;
|
|
61
61
|
}
|
|
62
|
+
case "entity.artifacts_removed": {
|
|
63
|
+
if (!state)
|
|
64
|
+
break;
|
|
65
|
+
const p = event.payload;
|
|
66
|
+
const keys = p.keys;
|
|
67
|
+
if (state.artifacts && Array.isArray(keys)) {
|
|
68
|
+
const updated = { ...state.artifacts };
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
delete updated[key];
|
|
71
|
+
}
|
|
72
|
+
state.artifacts = updated;
|
|
73
|
+
}
|
|
74
|
+
state.updatedAt = new Date(event.emittedAt);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
62
77
|
// invocation.*, gate.*, onEnter.*, onExit.*, flow.spawned — no entity state mutation
|
|
63
78
|
}
|
|
64
79
|
}
|
|
@@ -237,6 +237,8 @@ export interface IEntityRepository {
|
|
|
237
237
|
/** Merge partial artifacts into an entity's existing artifact bag. Performs a shallow merge
|
|
238
238
|
* ({ ...existing, ...artifacts }) — only the specified keys are updated; unspecified keys are preserved. */
|
|
239
239
|
updateArtifacts(id: string, artifacts: Partial<Artifacts>): Promise<void>;
|
|
240
|
+
/** Remove specific keys from an entity's artifact bag. Keys that don't exist are ignored. */
|
|
241
|
+
removeArtifactKeys(id: string, keys: string[]): Promise<void>;
|
|
240
242
|
/** Atomically claim one unclaimed entity in the given flow+state for the specified agent. Returns null if none available. Uses compare-and-swap (UPDATE WHERE claimedBy IS NULL). */
|
|
241
243
|
claim(flowId: string, state: string, agentId: string): Promise<Entity | null>;
|
|
242
244
|
/** Atomically claim a specific entity by ID for the specified agent. Returns null if already claimed. Uses compare-and-swap (UPDATE WHERE claimedBy IS NULL). */
|