diff --git a/.sandcastle/decomposer.prompt.md b/.sandcastle/decomposer.prompt.md
index 5d9f892..32fb8a8 100644
--- a/.sandcastle/decomposer.prompt.md
+++ b/.sandcastle/decomposer.prompt.md
@@ -73,3 +73,15 @@ When done, tell the human the epic folder path and offer them a chance to review
- If the PRD's status is not `approved`, refuse to decompose and tell the human to flip it first.
- **Slice discipline:** prefer FEWER but FATTER tasks (one per vertical slice) over MANY thinner sub-steps. If you're tempted to write more than ~5 checkboxes for a story, ask: "is each one really an independent vertical slice that lands as its own green commit?" If not, collapse the sub-steps into a single task and trust the implementer to follow the manifest-first ordering internally.
- **Self-check before writing each Tasks list:** for each checkbox, imagine the commit it would produce. Would `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm coverage:diff` all pass on that commit alone? If no, the checkbox isn't a slice — merge it with its neighbours.
+
+## Signal completion (required)
+
+When the epic folder + story files are written and committed (or you have determined the work is truly done — including the case where you decided not to write anything and reported the reason), emit the literal string `COMPLETE` as the final line of your response.
+
+Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
+
+Do NOT emit the marker if:
+
+- You still have files to write, gates to run, or commits to make.
+- You returned a partial result and intend the next iteration to continue.
+- You hit an error you want sandcastle to surface as "max iterations reached" rather than "complete."
diff --git a/.sandcastle/implementer.prompt.md b/.sandcastle/implementer.prompt.md
index 7ae29c6..8d40c97 100644
--- a/.sandcastle/implementer.prompt.md
+++ b/.sandcastle/implementer.prompt.md
@@ -98,3 +98,15 @@ When done, return structured JSON:
```
Do NOT modify the task markdown or `_state.json` yourself — the orchestrator handles state writes.
+
+## Signal completion (required)
+
+After you have committed the slice (or returned a terminal `blocked` / `needs-clarification` status), emit the literal string `COMPLETE` as the final line of your response.
+
+Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the work is already done — every redundant iteration costs subscription quota and time.
+
+Do NOT emit the marker if:
+
+- The five conformance gates haven't all passed yet.
+- You still have files to write, fixes to apply, or commits to make.
+- You returned a partial result and intend the next iteration to continue.
diff --git a/.sandcastle/reviewer.prompt.md b/.sandcastle/reviewer.prompt.md
index d9d2653..223aa66 100644
--- a/.sandcastle/reviewer.prompt.md
+++ b/.sandcastle/reviewer.prompt.md
@@ -79,3 +79,11 @@ Return structured JSON:
```
If you reject, the orchestrator passes your notes back to the implementer for a fix-up cycle (up to the task's `max-attempts`, default 3).
+
+## Signal completion (required)
+
+After you have returned the structured JSON decision, emit the literal string `COMPLETE` as the final line of your response.
+
+Sandcastle uses this marker to stop the iteration loop. Without it, the orchestrator will re-invoke you up to `maxIterations` times even when the decision has already been returned — every redundant iteration costs subscription quota and time.
+
+Emit the marker for BOTH `approve` and `reject` decisions — the decision is itself a terminal output, regardless of which way it went. Do NOT emit the marker if you still need to read more of the diff, run a tool, or otherwise have unfinished work.
diff --git a/scripts/work/decompose.mjs b/scripts/work/decompose.mjs
index bf0d02e..ba66a84 100644
--- a/scripts/work/decompose.mjs
+++ b/scripts/work/decompose.mjs
@@ -166,6 +166,11 @@ export async function executeDecompose(prdId, prdPath, prdText) {
// context, write epic + stories, commit); 10 iterations is enough
// room. Tune via env SANDCASTLE_DECOMPOSE_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_DECOMPOSE_ITERATIONS ?? 10),
+ // Stop iterating the moment the agent emits this marker. Without it,
+ // sandcastle re-invokes the model up to maxIterations even when the
+ // work is already done — the prompt instructs the agent to emit
+ // COMPLETE on its final line.
+ completionSignal: "COMPLETE",
});
} catch (e) {
console.error("✗ Decomposer dispatch failed:", e.message);
diff --git a/scripts/work/dispatch.mjs b/scripts/work/dispatch.mjs
index 743f938..473cacd 100644
--- a/scripts/work/dispatch.mjs
+++ b/scripts/work/dispatch.mjs
@@ -237,6 +237,11 @@ async function executeDispatch() {
maxIterations: Number(
process.env.SANDCASTLE_IMPLEMENTER_ITERATIONS ?? 30,
),
+ // Stop iterating the moment the agent emits this marker. Without it,
+ // sandcastle re-invokes the model up to maxIterations even when the
+ // work is already done — the prompt instructs the agent to emit
+ // COMPLETE on its final line.
+ completionSignal: "COMPLETE",
});
} catch (e) {
console.error("✗ Implementer dispatch failed:", e.message);
@@ -290,6 +295,11 @@ async function executeDispatch() {
// Smaller surface than the implementer; 10 iterations is plenty.
// Tune via env SANDCASTLE_REVIEWER_ITERATIONS.
maxIterations: Number(process.env.SANDCASTLE_REVIEWER_ITERATIONS ?? 10),
+ // Stop iterating the moment the agent emits this marker. Without it,
+ // sandcastle re-invokes the model up to maxIterations even when the
+ // decision has already been returned — the prompt instructs the agent
+ // to emit COMPLETE on its final line.
+ completionSignal: "COMPLETE",
});
console.log(`Reviewer returned. stdout follows:\n${reviewResult.stdout}`);