svamp-cli 0.2.97 → 0.2.100
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 +7 -5
- package/bin/skills/loop/IMPLEMENTATION_PROGRESS.md +49 -0
- package/bin/skills/loop/SKILL.md +99 -0
- package/bin/skills/loop/bin/channel-core.mjs +161 -0
- package/bin/skills/loop/bin/channel-server.mjs +151 -0
- package/bin/skills/loop/bin/inject-loop.mjs +41 -0
- package/bin/skills/loop/bin/loop-init.mjs +128 -0
- package/bin/skills/loop/bin/loop-status.mjs +38 -0
- package/bin/skills/loop/bin/precompact.mjs +27 -0
- package/bin/skills/loop/bin/routine-cli.mjs +121 -0
- package/bin/skills/loop/bin/routine-core.mjs +126 -0
- package/bin/skills/loop/bin/routine-runner.mjs +125 -0
- package/bin/skills/loop/bin/routine-store.mjs +49 -0
- package/bin/skills/loop/bin/state-fp.mjs +113 -0
- package/bin/skills/loop/bin/stop-gate.mjs +170 -0
- package/bin/skills/loop/routines.process.yaml +20 -0
- package/bin/skills/loop/test/test-channel-core.mjs +86 -0
- package/bin/skills/loop/test/test-loop-gate.mjs +246 -0
- package/bin/skills/loop/test/test-routine-core.mjs +54 -0
- package/bin/skills/loop/test/test-routine-engine.mjs +122 -0
- package/dist/{agentCommands-PROItll1.mjs → agentCommands-muy26BZI.mjs} +2 -2
- package/dist/{auth-LNLCvIUL.mjs → auth-RVq9wRhV.mjs} +1 -1
- package/dist/{caddy-BMbX-mFX.mjs → caddy-CuTbE3NY.mjs} +1 -14
- package/dist/cli.mjs +76 -77
- package/dist/{commands-ClSwaEXa.mjs → commands-ChzeHFd3.mjs} +1 -1
- package/dist/{commands-CFxWo-VJ.mjs → commands-Cu96nDGv.mjs} +2 -2
- package/dist/{commands-x6AC67Cu.mjs → commands-EwE87XNi.mjs} +1 -1
- package/dist/{commands-DlINkyF8.mjs → commands-lSqc48Ib.mjs} +6 -6
- package/dist/{commands-Bns4qGm-.mjs → commands-rSREfaQg.mjs} +34 -42
- package/dist/{fleet-CFRUR0Zf.mjs → fleet-qN96q6Qb.mjs} +1 -1
- package/dist/{frpc-BLM1a3zD.mjs → frpc-CIkmTNdJ.mjs} +2 -15
- package/dist/{headlessCli-DmyX9JHV.mjs → headlessCli-BVcAcLr1.mjs} +2 -2
- package/dist/index.mjs +1 -1
- package/dist/package-B7S5w1VE.mjs +63 -0
- package/dist/{run-W3GQKGcB.mjs → run-CdtYIBbd.mjs} +202 -709
- package/dist/{run-I7IbKfRn.mjs → run-zXRdkYtk.mjs} +1 -1
- package/dist/{serveCommands-B2BdjSVA.mjs → serveCommands-BZd0reEj.mjs} +5 -5
- package/dist/{serveManager-Dc28oGob.mjs → serveManager-lmPtmRnR.mjs} +3 -3
- package/dist/{sideband-DXtnQ9F-.mjs → sideband-JeID_jF-.mjs} +1 -1
- package/package.json +3 -3
- package/dist/package-DG-a1zOR.mjs +0 -63
package/README.md
CHANGED
|
@@ -107,15 +107,17 @@ svamp session spawn claude -d <path> --deny-read /etc --allow-write /tmp/work
|
|
|
107
107
|
svamp session spawn claude -d <path> --allow-domain api.anthropic.com
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
####
|
|
110
|
+
#### Loop (Self-Verifying Iterative Automation)
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
svamp session
|
|
114
|
-
svamp session
|
|
115
|
-
svamp session
|
|
113
|
+
svamp session loop-start <id> "<task>" [--criteria "..."] [--oracle "cmd"] [--max 20] [--evaluator on|off]
|
|
114
|
+
svamp session loop-cancel <id>
|
|
115
|
+
svamp session loop-status <id>
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
The
|
|
118
|
+
The loop iterates a task until it's *objectively* done: a Claude Code `Stop`-hook gate keeps the agent
|
|
119
|
+
working until an **oracle** (a real pass/fail command) passes AND an **independent evaluator** subagent
|
|
120
|
+
confirms completion. Task/plan/progress live in a git-visible `LOOP.md`. Powered by the `loop` skill.
|
|
119
121
|
|
|
120
122
|
### Machine Management
|
|
121
123
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Loops, Routines & Channels — implementation status
|
|
2
|
+
|
|
3
|
+
Branch: `loop-engineering-phase1`. Design docs: `docs/loops-and-routines-design.md`, `docs/channels-design.md`.
|
|
4
|
+
|
|
5
|
+
This consolidates the work into one accurate picture: what's built, how it was verified, and the single remaining step (deploy + UI QA).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Loop engine — DONE (tested + live-E2E + 2 adversarial reviews)
|
|
10
|
+
Skill-first loop with a harness-enforced completion gate. Files in `skills/loop/`.
|
|
11
|
+
- **`SKILL.md`** — loop-engineering protocol (actor ≠ judge; oracle + isolated evaluator).
|
|
12
|
+
- **`bin/stop-gate.mjs`** — Claude Code `Stop` hook: blocks the turn ending unless the **oracle exits 0 AND a fingerprint-fresh evaluator verdict says "done."** Bounded by clamped `max_iterations` (hard ceiling 200), runtime budget, and token budget (summed from the transcript). Appends a per-iteration audit trail.
|
|
13
|
+
- **`bin/state-fp.mjs`** — working-tree fingerprint (git diff, or filesystem walk incl. symlink targets for non-git dirs) tying a verdict to the exact code it judged.
|
|
14
|
+
- **`bin/inject-loop.mjs`** (SessionStart/UserPromptSubmit) · **`bin/precompact.mjs`** (PreCompact handoff) · **`bin/loop-init.mjs`** (generates LOOP.md + `.claude/settings.json` hooks + optional `loop-evaluator` agent) · **`bin/loop-status.mjs`** (timeline reader).
|
|
15
|
+
- **Tests:** `test/test-loop-gate.mjs` (29) — all pass. **Live-verified** in real svamp sessions: happy path, **early-quit blocked**, isolated evaluator subagent, full regression.
|
|
16
|
+
- Skill↔gate↔inject **contract verified consistent** (verdict path/fields/`state_fp` command all agree).
|
|
17
|
+
|
|
18
|
+
## 2. Routines — DONE (tested + live-E2E)
|
|
19
|
+
Session-scoped triggers → message/loop actions. `skills/loop/bin/routine-*.mjs` (JS engine + supervised server) and `packages/svamp-cli/src/routine/*.ts` (daemon).
|
|
20
|
+
- Triggers: schedule (cron, tz-aware tick **and** catch-up), webhook (capability URL, GET/POST), manual. Actions: message | loop. Overlap (queue/skip/replace concurrent-guard), daily_cap (reserve-before-deliver), missed-run catch-up.
|
|
21
|
+
- `svamp routine add/list/remove/enable/disable/run-now/serve`; daemon RPC (`hyphaSessionService`, 10 runtime tests); frontend **Routines tab**.
|
|
22
|
+
- **Tests:** routine-core, routine-engine (26), cli routine (18), cli routine-rpc (10) — all pass. **Live-verified**: webhook → templated message delivered into a real session.
|
|
23
|
+
|
|
24
|
+
## 3. Channels — DONE (tested + live-E2E both transports + 2 security reviews)
|
|
25
|
+
External / agent-to-agent inbound endpoints. `skills/loop/bin/channel-*.mjs` + `packages/svamp-cli/src/channel/store.ts`.
|
|
26
|
+
- Identity: per-key (verified) / caller-supplied (unverified) / fixed / Hypha `context.user` (opt-in `hypha_allow`, deny-by-default, anonymous rejected). Default **XML provenance template** (all values XML-escaped + control-chars stripped → no forgery/tag-breakout). Auto-generated **skill** per channel.
|
|
27
|
+
- Transports: **Hypha `svamp-channels` service** (verified identity, no key) **and** HTTP `/channel/<id>` (+ `/skill.md`, bearer key). Delivers as a **plain single-tag message**. Actions: **message and loop** (loop seeds `loop-init` + kickoff).
|
|
28
|
+
- `hypha channel list/describe/send` CLI client; daemon RPC (10 runtime tests); frontend **Channels tab**.
|
|
29
|
+
- **Tests:** channel-core (32), cli channel-rpc (10) — all pass. **Live-verified**: HTTP per-key (`from=alice verified`), Hypha (`from=<hypha-user> verified`, no key), loop-action (loop seeded + work completed).
|
|
30
|
+
|
|
31
|
+
## Verification summary
|
|
32
|
+
All feature suites green; **svamp-cli + svamp-app + hypha-cli typecheck 0 errors**. Standalone/skill parts live-E2E-verified. Daemon RPC runtime-verified via `createSessionStore`. Two adversarial review rounds each on the loop gate and channels (caught + fixed 3 CRITICAL block-forever bugs and a CRITICAL provenance-forgery hole); daemon-code review confirmed no destabilization. Review has **converged** (latest pass found nothing).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## The ONE remaining step — deploy + UI QA (must be done from a SEPARATE session)
|
|
37
|
+
The daemon RPC + the three frontend tabs (Routines / Channels / Loop-monitor) are typecheck-clean and production-bundle clean, but their **live rendering + RPC round-trip have not been visually confirmed** — and this repo has no component-render test infra (UI is verified by deploy/manual). Activating them restarts the daemon hosting this loop session, so it is **deliberately not done here**. To finish:
|
|
38
|
+
```
|
|
39
|
+
yarn workspace svamp-cli build && svamp daemon restart # activates routine/channel RPC
|
|
40
|
+
./deploy/quick-deploy.sh # ships the web UI
|
|
41
|
+
```
|
|
42
|
+
Then open a session → input → settings and confirm the **Routines / Channels / Loop-monitor** tabs render and round-trip. The standalone engines need no deploy — `node skills/loop/bin/loop-init.mjs …`, `svamp routine …`, and the supervised channel/routine servers already work today.
|
|
43
|
+
|
|
44
|
+
## Deferred (by design or low-risk-to-leave)
|
|
45
|
+
- **await-reply (channels v1.1)** — request/response; v1 is async (two channels), per the design decision.
|
|
46
|
+
- **Loop gate on an already-running session** — hooks load at Claude startup, so looping a live session is best-effort (self-contained kickoff); full enforcement needs a fresh/restarted session.
|
|
47
|
+
- **#4/#6/#11 concurrency edges** — cross-process lost-update / git↔walk fp flip / store last-writer-wins. Low-likelihood for the single-session loop; a forced fix needs locking that could itself cause block-forever, so documented rather than fixed.
|
|
48
|
+
- **Token/cost budget in USD** — token budget (from transcript) is implemented; USD cost would need daemon usage accounting.
|
|
49
|
+
</content>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loop
|
|
3
|
+
version: 0.2.0
|
|
4
|
+
description: Run a task as a reliable, self-verifying loop — iterate until objective exit conditions are met, with an independent evaluator instead of self-judging. Use when a task needs repeated iterations until "done" (fix until tests pass, refactor until clean, build until a spec is met, autonomous long-running work).
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Loop — loop engineering for long-running, self-verifying agents
|
|
8
|
+
|
|
9
|
+
This skill turns a task into a **loop**: the agent iterates until the work is *objectively* done, not until the agent *feels* done. It is built on one hard-won lesson from loop engineering — **the thing that decides "done" must be independent of the thing doing the work.**
|
|
10
|
+
|
|
11
|
+
## Why loops, and why this design
|
|
12
|
+
|
|
13
|
+
Prompting an agent turn-by-turn doesn't scale to long or autonomous work. Loop engineering is the shift from *running the agent yourself* to *designing the system that runs it*. A good loop has a small number of durable parts, and most of the leverage is in **the loop, not the prompt**.
|
|
14
|
+
|
|
15
|
+
The failure this design exists to prevent is **early quitting**: a model asked to judge its own output will confidently declare success on mediocre or incomplete work, and models also tend to "wrap up" as they approach context limits ("context anxiety"). Trusting the actor to say `DONE` is the single biggest source of unreliable loops.
|
|
16
|
+
|
|
17
|
+
So this loop is built on these principles:
|
|
18
|
+
|
|
19
|
+
1. **Actor ≠ judge.** The agent doing the work never decides completion. An *independent* evaluator — a fresh, skeptical agent that only sees the artifacts, not your reasoning — does. Calibrating a separate evaluator toward skepticism is far more reliable than asking yourself to be self-critical.
|
|
20
|
+
2. **Verification needs an oracle.** The strongest signal is something *outside any model* that returns a hard yes/no: a test suite, a build, a linter, a type-check, a script. The loop runs the oracle itself; you cannot fake it.
|
|
21
|
+
3. **State lives on disk, not in context.** *The model forgets; the repo doesn't.* The task, plan, and progress live in **`LOOP.md`** in the project — readable and editable by both you and the human, surviving compaction, restarts, and fresh sessions. It is your durable memory and your self-improving harness.
|
|
22
|
+
4. **The harness enforces, the prompt only requests.** Completion is gated by a Claude Code **`Stop` hook** that the runtime *obeys* — it physically refuses to let the turn end until the exit conditions hold. This is what makes the loop reliable rather than aspirational.
|
|
23
|
+
5. **Bounded by construction.** Every loop has a `max_iterations` and a token/cost budget so it cannot run away.
|
|
24
|
+
6. **Stay auditable.** Every iteration leaves a trail (oracle result, evaluator verdict, progress notes) so a human can review *why* it stopped, not just *that* it stopped. Build the loop like someone who intends to stay the engineer.
|
|
25
|
+
|
|
26
|
+
These principles are stable even as models get stronger. As the actor improves, you simply lean on lighter scaffolding (e.g. trust longer continuous context, fewer resets) — but *independent verification of "done"* and *durable on-disk state* remain correct regardless of model capability. Don't reimplement what the runtime already gives you (subagents, compaction, worktrees, usage accounting); compose them.
|
|
27
|
+
|
|
28
|
+
## The loop, in one picture
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
read LOOP.md ─► do real work on the task ─► update LOOP.md progress
|
|
32
|
+
▲ │
|
|
33
|
+
│ (think you're done?)
|
|
34
|
+
│ ▼
|
|
35
|
+
│ run ORACLE + spawn independent EVALUATOR
|
|
36
|
+
│ │
|
|
37
|
+
Stop hook blocks ◄── not done ── GATE ── done ──► loop ends
|
|
38
|
+
(oracle/verdict fails)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Each turn is one iteration. When you try to end your turn, the gate re-checks the exit conditions. If they don't hold, you are sent back with specific feedback. You converge by *fixing the blocking issue*, not by re-asserting that you're done.
|
|
42
|
+
|
|
43
|
+
## Exit conditions (what "done" means)
|
|
44
|
+
|
|
45
|
+
The loop ends only when **all** configured conditions hold:
|
|
46
|
+
- **Oracle passes** — the configured command exits 0. The gate runs it; do not stub or fake it.
|
|
47
|
+
- **Independent evaluator says "done"** (when enabled) — a *fresh* verdict tied to the *current* code state. A verdict written before later edits is stale and rejected.
|
|
48
|
+
|
|
49
|
+
## Your protocol each iteration
|
|
50
|
+
|
|
51
|
+
1. **Read `LOOP.md`.** It holds the task, success criteria, plan, and progress. (It is auto-injected each turn.)
|
|
52
|
+
2. **Make real progress** on the task. Prefer the smallest change that advances a success criterion.
|
|
53
|
+
3. **Keep `LOOP.md` current** — refine the plan, append what you did, what passed, what's left. This is the memory the *next* iteration (or a post-restart fresh session) relies on. You may restructure it; you may **not** weaken the success criteria to make quitting easier (that's visible in git and to the human).
|
|
54
|
+
4. **Run the oracle yourself** to check you're actually passing before claiming done.
|
|
55
|
+
5. **When you believe it's complete, get an independent verdict — do not grade yourself:**
|
|
56
|
+
- Spawn a fresh subagent with the `Task` tool (use the `loop-evaluator` agent if present, otherwise an inline *skeptical reviewer* prompt). Give it **only** the goal (from `LOOP.md`), the current diff, and the oracle output — never your chain of thought.
|
|
57
|
+
- It returns `{"verdict":"done"|"continue","reason":...,"guidance":...}`. It should default to `continue` on any doubt.
|
|
58
|
+
- Record it to `.claude/loop/evaluator-verdict.json`, adding the current state fingerprint:
|
|
59
|
+
`{"verdict":"done","reason":"...","guidance":"...","state_fp":"<output of: node .claude/loop/bin/state-fp.mjs>"}`
|
|
60
|
+
6. **End your turn.** The Stop gate verifies oracle + fresh verdict and either ends the loop or sends you back with the exact blocking reason. Address that reason next iteration.
|
|
61
|
+
|
|
62
|
+
If you're blocked by something genuinely outside the task (missing secret, ambiguous requirement, external outage), record it clearly in `LOOP.md` so the human sees it on review.
|
|
63
|
+
|
|
64
|
+
## Setting up a loop
|
|
65
|
+
|
|
66
|
+
Initialise the loop scaffolding in a project (idempotent):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node <skill>/bin/loop-init.mjs <project-dir> \
|
|
70
|
+
--task "Make the failing auth tests pass without weakening them" \
|
|
71
|
+
--criteria "All auth tests pass; no test was weakened or skipped" \
|
|
72
|
+
--oracle "npm test --silent" \
|
|
73
|
+
--evaluator on \
|
|
74
|
+
--max 20
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This generates: `LOOP.md` (task/criteria/progress), `.claude/loop/loop.config.json`, machine state in `.claude/loop/loop-state.json`, the `Stop`/`SessionStart` hooks in `.claude/settings.json`, and (optionally) a materialised `.claude/agents/loop-evaluator.md` skeptical reviewer. Re-running with new flags retunes a live loop.
|
|
78
|
+
|
|
79
|
+
## Anatomy (files & ownership)
|
|
80
|
+
|
|
81
|
+
| File | Owner | Purpose |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `LOOP.md` | you + human | task · success criteria · plan · progress (durable memory, git-visible) |
|
|
84
|
+
| `.claude/loop/loop.config.json` | config | oracle command, evaluator on/off + model, max_iterations |
|
|
85
|
+
| `.claude/loop/loop-state.json` | the gate | iteration, phase, last oracle/eval result (machine state — don't hand-edit) |
|
|
86
|
+
| `.claude/loop/evaluator-verdict.json` | written from the evaluator subagent | latest verdict + state fingerprint |
|
|
87
|
+
| `.claude/settings.json` (hooks) | generated | `Stop` gate + `SessionStart`/`UserPromptSubmit` LOOP.md injection |
|
|
88
|
+
| `.claude/agents/loop-evaluator.md` | generated (optional) | skeptical reviewer persona + model |
|
|
89
|
+
|
|
90
|
+
## Tuning & extending (future-proofing)
|
|
91
|
+
|
|
92
|
+
- **No oracle?** Lean fully on the evaluator (weaker — prefer adding *some* objective check, even a smoke script).
|
|
93
|
+
- **Stronger judgment?** Point the evaluator at a stronger model via `--model`.
|
|
94
|
+
- **Cheaper?** Disable the evaluator (`--evaluator off`) and rely on the oracle alone for purely objective tasks.
|
|
95
|
+
- **Different exit logic** (scores/thresholds, FINISH/REFINE/PIVOT strategies, multi-dimensional rubrics): the verdict file is just JSON and the gate reads it — richer verdicts can be added without changing the protocol.
|
|
96
|
+
- **Triggers & lifecycle** (run on a schedule, on a webhook, via an API; pause/resume): handled by the surrounding routine layer, not this skill — the loop itself stays a pure iterate-until-done engine.
|
|
97
|
+
- **Compaction handoff**: progress in `LOOP.md` is the handoff artifact; a `PreCompact` hook can flush it automatically for very long runs.
|
|
98
|
+
|
|
99
|
+
The contract that must never change: **work is decided done by something other than the worker, and the loop's memory lives on disk.** Everything else is tunable.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// channel-core.mjs — transport-agnostic core for Channels (external / agent-to-agent
|
|
3
|
+
// inbound endpoints). Project-folder config + identity resolution + default XML
|
|
4
|
+
// template + skill generation. Used by both the HTTP and Hypha transports.
|
|
5
|
+
// See docs/channels-design.md.
|
|
6
|
+
import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { renderTemplate } from './routine-core.mjs';
|
|
10
|
+
|
|
11
|
+
const genId = () => 'c_' + randomBytes(5).toString('hex');
|
|
12
|
+
const genKey = () => 'ck_' + randomBytes(18).toString('base64url');
|
|
13
|
+
|
|
14
|
+
// Default message template: XML carrying provenance so the agent always knows
|
|
15
|
+
// who sent it, through which channel, and whether the identity is verified.
|
|
16
|
+
export const DEFAULT_TEMPLATE =
|
|
17
|
+
`<inbound-message from="\${sender.name}" sender-type="\${sender.kind}" verified="\${sender.verified}" channel="\${channel.name}" call-id="\${call.id}" at="\${now}">
|
|
18
|
+
\${body.message}
|
|
19
|
+
</inbound-message>`;
|
|
20
|
+
|
|
21
|
+
// ---- store (project folder: <dir>/.svamp/channels/<id>.json) ------------
|
|
22
|
+
export class ChannelStore {
|
|
23
|
+
constructor(projectDir) { this.dir = join(projectDir, '.svamp', 'channels'); mkdirSync(this.dir, { recursive: true }); }
|
|
24
|
+
_path(id) { return join(this.dir, `${id}.json`); }
|
|
25
|
+
list() {
|
|
26
|
+
return readdirSync(this.dir).filter((f) => f.endsWith('.json')).map((f) => {
|
|
27
|
+
try { return JSON.parse(readFileSync(join(this.dir, f), 'utf8')); } catch { return null; }
|
|
28
|
+
}).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
get(id) { try { return JSON.parse(readFileSync(this._path(id), 'utf8')); } catch { return null; } }
|
|
31
|
+
save(channel) {
|
|
32
|
+
const c = { enabled: true, bind: 'active', template: DEFAULT_TEMPLATE, action: { kind: 'message' },
|
|
33
|
+
identity: { mode: 'per-key', callers: [] }, last_calls: [], ...channel };
|
|
34
|
+
if (!c.id) c.id = genId();
|
|
35
|
+
const errs = validateChannel(c);
|
|
36
|
+
if (errs.length) throw new Error('invalid channel: ' + errs.join('; '));
|
|
37
|
+
const tmp = this._path(c.id) + '.tmp';
|
|
38
|
+
writeFileSync(tmp, JSON.stringify(c, null, 2));
|
|
39
|
+
renameSync(tmp, this._path(c.id));
|
|
40
|
+
return c;
|
|
41
|
+
}
|
|
42
|
+
remove(id) { const p = this._path(id); if (existsSync(p)) { rmSync(p); return true; } return false; }
|
|
43
|
+
addCaller(id, name, kind = 'agent') {
|
|
44
|
+
const c = this.get(id); if (!c) return null;
|
|
45
|
+
c.identity.callers = c.identity.callers || [];
|
|
46
|
+
const caller = { name, kind, key: genKey() };
|
|
47
|
+
c.identity.callers.push(caller);
|
|
48
|
+
this.save(c);
|
|
49
|
+
return caller;
|
|
50
|
+
}
|
|
51
|
+
recordCall(id, entry) {
|
|
52
|
+
const c = this.get(id); if (!c) return null;
|
|
53
|
+
c.last_calls = [{ at: new Date().toISOString(), ...entry }, ...(c.last_calls || [])].slice(0, 30);
|
|
54
|
+
return this.save(c);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateChannel(c) {
|
|
59
|
+
const errs = [];
|
|
60
|
+
if (!c || typeof c !== 'object') return ['channel must be an object'];
|
|
61
|
+
if (!c.name) errs.push('name required');
|
|
62
|
+
const m = c.identity?.mode;
|
|
63
|
+
if (!['per-key', 'caller-supplied', 'fixed'].includes(m)) errs.push('identity.mode must be per-key|caller-supplied|fixed');
|
|
64
|
+
if (m === 'fixed' && !c.identity.fixed?.name) errs.push('identity.fixed.name required for fixed mode');
|
|
65
|
+
if (!['message', 'loop'].includes(c.action?.kind)) errs.push('action.kind must be message|loop');
|
|
66
|
+
if (c.bind !== 'active' && !(c.bind && c.bind.tag) && !(c.bind && c.bind.session)) errs.push('bind must be "active", {tag}, or {session}');
|
|
67
|
+
// Security: a keyless/open channel + loop action = unauthenticated task injection.
|
|
68
|
+
if (c.action?.kind === 'loop' && m === 'caller-supplied' && !c.identity?.shared_key)
|
|
69
|
+
errs.push('a caller-supplied channel without a shared_key may not use a loop action (unauthenticated task injection)');
|
|
70
|
+
// Defense-in-depth: single-line, no XML metachars on operator-set strings that
|
|
71
|
+
// land in the trust tag and/or the served skill markdown (frontmatter breakout).
|
|
72
|
+
const unsafe = /[<>"'&\r\n]/;
|
|
73
|
+
if (unsafe.test(c.name || '')) errs.push('name must be single-line and not contain < > " \' &');
|
|
74
|
+
if (c.description && unsafe.test(c.description)) errs.push('description must be single-line and not contain < > " \' &');
|
|
75
|
+
if (c.skill?.name && unsafe.test(c.skill.name)) errs.push('skill.name must be single-line and not contain < > " \' &');
|
|
76
|
+
if (c.skill?.description && unsafe.test(c.skill.description)) errs.push('skill.description must be single-line and not contain < > " \' &');
|
|
77
|
+
if (m === 'fixed' && c.identity.fixed?.name && unsafe.test(c.identity.fixed.name)) errs.push('identity.fixed.name must not contain < > " \' & or newlines');
|
|
78
|
+
for (const cl of (c.identity?.callers || [])) if (unsafe.test(cl.name || '')) errs.push(`caller name "${cl.name}" must not contain < > " ' & or newlines`);
|
|
79
|
+
return errs;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- identity resolution ------------------------------------------------
|
|
83
|
+
// Returns { sender:{name,kind,verified} } or { error } (401/403-ish).
|
|
84
|
+
export function resolveSender(channel, { key, from, hyphaUser, hyphaWorkspace, hyphaAnonymous } = {}) {
|
|
85
|
+
const id = channel.identity || {};
|
|
86
|
+
// Hypha-authenticated caller: honored ONLY when the channel EXPLICITLY opts in
|
|
87
|
+
// via a non-empty hypha_allow (deny-by-default — never allow-all when unset, and
|
|
88
|
+
// never override a configured per-key/fixed mode). ANONYMOUS Hypha callers are
|
|
89
|
+
// NOT verified identities, so they never take this branch (even with '*').
|
|
90
|
+
if (hyphaUser && !hyphaAnonymous && Array.isArray(id.hypha_allow) && id.hypha_allow.length) {
|
|
91
|
+
if (id.hypha_allow.includes('*') || id.hypha_allow.includes(hyphaUser) || (hyphaWorkspace && id.hypha_allow.includes(hyphaWorkspace)))
|
|
92
|
+
return { sender: { name: hyphaUser, kind: 'agent', verified: true } };
|
|
93
|
+
return { error: 'caller not in hypha_allow' };
|
|
94
|
+
}
|
|
95
|
+
if (id.mode === 'fixed') return { sender: { ...id.fixed, verified: true } };
|
|
96
|
+
if (id.mode === 'per-key') {
|
|
97
|
+
const caller = (id.callers || []).find((c) => c.key && c.key === key);
|
|
98
|
+
if (!caller) return { error: 'invalid or missing key' };
|
|
99
|
+
return { sender: { name: caller.name, kind: caller.kind, verified: true } };
|
|
100
|
+
}
|
|
101
|
+
if (id.mode === 'caller-supplied') {
|
|
102
|
+
if (id.shared_key && key !== id.shared_key) return { error: 'invalid key' };
|
|
103
|
+
return { sender: { name: from || 'anonymous', kind: 'user', verified: false } };
|
|
104
|
+
}
|
|
105
|
+
return { error: 'unsupported identity mode' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- render the injected message ----------------------------------------
|
|
109
|
+
// XML-escape EVERY interpolated value. The <inbound-message from=… verified=…>
|
|
110
|
+
// tag is the agent's entire trust signal, and from/message are caller-controlled
|
|
111
|
+
// — without escaping a caller could forge verified="true" or inject a second tag
|
|
112
|
+
// (provenance forgery / tag breakout). Escaping neutralizes < > " ' &.
|
|
113
|
+
const MAX_BODY = 16 * 1024;
|
|
114
|
+
export function xmlEscape(s) {
|
|
115
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
116
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
117
|
+
}
|
|
118
|
+
// Strip control chars (incl. newlines) — for single-line ATTRIBUTE values, so an
|
|
119
|
+
// attacker-controlled name can't smuggle extra lines into the trust header.
|
|
120
|
+
const stripControl = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f]/g, ' ');
|
|
121
|
+
export function renderMessage(channel, { sender = {}, body = {}, query = {}, callId, now }) {
|
|
122
|
+
const obj = (v) => (typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
|
|
123
|
+
const escVal = (v) => xmlEscape(obj(v)); // element content (multi-line ok)
|
|
124
|
+
const escAttr = (v) => xmlEscape(stripControl(obj(v))); // attribute position (single-line)
|
|
125
|
+
const bodyEsc = {};
|
|
126
|
+
for (const [k, v] of Object.entries(body)) bodyEsc[k] = k === 'message' ? escVal(String(v ?? '').slice(0, MAX_BODY)) : escAttr(v);
|
|
127
|
+
const queryEsc = {};
|
|
128
|
+
for (const [k, v] of Object.entries(query)) if (k !== 'key') queryEsc[k] = escAttr(v); // never echo the secret
|
|
129
|
+
const ctx = {
|
|
130
|
+
sender: { name: escAttr(sender.name), kind: escAttr(sender.kind), verified: sender.verified === true },
|
|
131
|
+
body: bodyEsc, query: queryEsc,
|
|
132
|
+
channel: { name: escAttr(channel.name), id: escAttr(channel.id) },
|
|
133
|
+
call: { id: escAttr(callId) }, now: escAttr(now || new Date().toISOString()),
|
|
134
|
+
};
|
|
135
|
+
return renderTemplate(channel.template || DEFAULT_TEMPLATE, ctx);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- skill (how external agents call this channel) ----------------------
|
|
139
|
+
export function generateSkillBody(channel, urlBase) {
|
|
140
|
+
const url = `${urlBase || 'https://<svamp-tunnel>'}/channel/${channel.id}`;
|
|
141
|
+
return `---
|
|
142
|
+
name: ${channel.skill?.name || channel.name}
|
|
143
|
+
description: ${channel.skill?.description || channel.description || `Send a message to the "${channel.name}" channel.`}
|
|
144
|
+
---
|
|
145
|
+
# ${channel.name}
|
|
146
|
+
${channel.description || ''}
|
|
147
|
+
|
|
148
|
+
This is a self-contained guide for messaging another agent. Share this skill
|
|
149
|
+
(or its URL, \`${url}/skill.md\`) with an agent and it will know how to reach me.
|
|
150
|
+
|
|
151
|
+
**Hypha RPC (preferred, verified identity — no key needed):**
|
|
152
|
+
\`get_service("<ws>/<machine>:channels").send({ channel: "${channel.id}", message: "..." })\`
|
|
153
|
+
|
|
154
|
+
**HTTP:** \`POST ${url}\` with header \`Authorization: Bearer <your-key>\`
|
|
155
|
+
Body: \`{ "message": "<your message>", "from": "<your name>" }\`
|
|
156
|
+
|
|
157
|
+
Your identity is attached automatically; the receiving agent sees an
|
|
158
|
+
\`<inbound-message from="...">\` tag. Keep messages self-contained.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { genId, genKey };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// channel-server.mjs — serve a project's Channels over BOTH transports:
|
|
3
|
+
// • Hypha RPC: registers a `channels` service (type svamp-channels) with
|
|
4
|
+
// list/describe/send; caller identity = verified context.user.
|
|
5
|
+
// • HTTP: POST /channel/<id> (Bearer key), GET /channel/<id>[/skill.md].
|
|
6
|
+
// Delivery: renders the channel template and injects it into the bound session
|
|
7
|
+
// via `svamp session send`. Config from the project folder (.svamp/channels/).
|
|
8
|
+
//
|
|
9
|
+
// Env: CHANNEL_PROJECT_DIR (default cwd), CHANNEL_SESSION (default bind target),
|
|
10
|
+
// CHANNEL_HTTP_PORT (default 8733), CHANNEL_URL_BASE (for skill URLs),
|
|
11
|
+
// HYPHA_SERVER_URL / HYPHA_TOKEN / HYPHA_WORKSPACE (from ~/.hypha/.env).
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { resolve, dirname, join } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { ChannelStore, resolveSender, renderMessage, generateSkillBody, validateChannel } from './channel-core.mjs';
|
|
18
|
+
import { renderTemplate } from './routine-core.mjs';
|
|
19
|
+
|
|
20
|
+
const LOOP_INIT = join(dirname(fileURLToPath(import.meta.url)), 'loop-init.mjs');
|
|
21
|
+
|
|
22
|
+
const PROJECT = resolve(process.env.CHANNEL_PROJECT_DIR || process.cwd());
|
|
23
|
+
const DEFAULT_SESSION = process.env.CHANNEL_SESSION || null;
|
|
24
|
+
const URL_BASE = process.env.CHANNEL_URL_BASE || `http://localhost:${process.env.CHANNEL_HTTP_PORT || 8733}`;
|
|
25
|
+
const store = new ChannelStore(PROJECT);
|
|
26
|
+
const callId = () => 'call_' + randomBytes(5).toString('hex');
|
|
27
|
+
|
|
28
|
+
// svamp binary (override for repo build, e.g. SVAMP_BIN="node …/dist/cli.js").
|
|
29
|
+
const SVAMP = (process.env.SVAMP_BIN || 'svamp').split(' ');
|
|
30
|
+
|
|
31
|
+
function targetSession(channel) {
|
|
32
|
+
if (channel.bind && channel.bind.session) return channel.bind.session; // explicit pin (standalone)
|
|
33
|
+
return DEFAULT_SESSION; // 'active' resolution is the daemon's job; standalone uses the configured session
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Inject the channel message as a PLAIN user message (`session send --plain`) so
|
|
37
|
+
// the agent sees a single `<inbound-message>` provenance tag — not the inbox
|
|
38
|
+
// `<svamp-message>` envelope. (Daemon-native channels do this in-process.)
|
|
39
|
+
async function deliver(channel, sender, body, query) {
|
|
40
|
+
const sid = targetSession(channel);
|
|
41
|
+
if (!sid) return { ok: false, error: 'no bound session (set CHANNEL_SESSION or channel.bind.session)' };
|
|
42
|
+
const id = callId();
|
|
43
|
+
try {
|
|
44
|
+
if (channel.action?.kind === 'loop') {
|
|
45
|
+
// Start a loop in the bound session's project dir, seeded by the caller's
|
|
46
|
+
// request. (Loop-action channels require auth — validateChannel forbids the
|
|
47
|
+
// keyless caller-supplied case — so the caller-influenced task is gated.)
|
|
48
|
+
// CAVEAT: the Stop-GATE only enforces if the session's Claude process started
|
|
49
|
+
// AFTER the .claude/settings.json hooks existed (hooks load at startup). When
|
|
50
|
+
// we loop-init an ALREADY-RUNNING session, the gate isn't active for that
|
|
51
|
+
// process, so the kickoff is self-contained (task + read-LOOP.md + iterate)
|
|
52
|
+
// and the work still gets done; full gate enforcement needs a fresh/restarted
|
|
53
|
+
// session (which daemon-native delivery would do). We mark status accordingly.
|
|
54
|
+
const dir = resolve(channel.action.loop?.dir || PROJECT);
|
|
55
|
+
const ctx = { sender, body, query, channel: { name: channel.name, id: channel.id }, call: { id }, now: new Date().toISOString() };
|
|
56
|
+
const task = (channel.action.task_template ? renderTemplate(channel.action.task_template, ctx) : body.message) || '(task from channel)';
|
|
57
|
+
const labelled = `[via channel "${channel.name}" from ${sender.name}${sender.verified ? '' : ' (unverified)'}] ${task}`;
|
|
58
|
+
const initArgs = [LOOP_INIT, dir, '--task', labelled];
|
|
59
|
+
const oracle = channel.action.loop?.oracle;
|
|
60
|
+
if (oracle) initArgs.push('--oracle', oracle);
|
|
61
|
+
initArgs.push('--evaluator', channel.action.loop?.evaluator || 'off');
|
|
62
|
+
execFileSync(process.execPath, initArgs, { stdio: 'pipe' });
|
|
63
|
+
// Self-contained kickoff (works whether or not the Stop hook is active on the
|
|
64
|
+
// running process): the task + how to verify + iterate.
|
|
65
|
+
const kickoff = `🔁 Loop request via channel "${channel.name}".\nRead LOOP.md and work the task below until it is genuinely complete${oracle ? ` and the oracle passes: \`${oracle}\`` : ''}. Update LOOP.md progress as you go.\n\nTask: ${labelled}`;
|
|
66
|
+
execFileSync(SVAMP[0], [...SVAMP.slice(1), 'session', 'send', sid, kickoff, '--plain'], { stdio: 'pipe' });
|
|
67
|
+
store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'loop-started' });
|
|
68
|
+
return { ok: true, call_id: id, status: 'loop-started' };
|
|
69
|
+
}
|
|
70
|
+
const message = renderMessage(channel, { sender, body, query, callId: id });
|
|
71
|
+
execFileSync(SVAMP[0], [...SVAMP.slice(1), 'session', 'send', sid, message, '--plain'], { stdio: 'pipe' });
|
|
72
|
+
store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'delivered' });
|
|
73
|
+
return { ok: true, call_id: id, status: 'accepted' };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'error' });
|
|
76
|
+
return { ok: false, error: String(e?.message || e) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const channelView = (c) => ({ id: c.id, name: c.name, description: c.description, identity: { mode: c.identity?.mode }, action: c.action?.kind });
|
|
81
|
+
|
|
82
|
+
// ── Hypha RPC service ────────────────────────────────────────────────────
|
|
83
|
+
async function registerHypha() {
|
|
84
|
+
const token = process.env.HYPHA_TOKEN;
|
|
85
|
+
const serverUrl = process.env.HYPHA_SERVER_URL || 'https://hypha.aicell.io';
|
|
86
|
+
if (!token) { console.error('[channels] no HYPHA_TOKEN — skipping Hypha registration (HTTP only)'); return null; }
|
|
87
|
+
const { hyphaWebsocketClient } = await import('hypha-rpc');
|
|
88
|
+
const server = await hyphaWebsocketClient.connectToServer({ server_url: serverUrl, token, workspace: process.env.HYPHA_WORKSPACE || undefined, logger: null });
|
|
89
|
+
const svc = await server.registerService({
|
|
90
|
+
id: 'channels',
|
|
91
|
+
name: 'Svamp Channels',
|
|
92
|
+
type: 'svamp-channels',
|
|
93
|
+
config: { visibility: 'public', require_context: true },
|
|
94
|
+
async list() { return store.list().filter((c) => c.enabled !== false).map(channelView); },
|
|
95
|
+
async describe(kwargs = {}) {
|
|
96
|
+
const c = store.get(kwargs.channel); if (!c) return { error: 'not found' };
|
|
97
|
+
return { ...channelView(c), skill: { body: generateSkillBody(c, URL_BASE) } };
|
|
98
|
+
},
|
|
99
|
+
async send(kwargs = {}, context) {
|
|
100
|
+
const c = store.get(kwargs.channel);
|
|
101
|
+
if (!c || c.enabled === false) return { error: 'channel not found' };
|
|
102
|
+
const user = context?.user?.email || context?.user?.id;
|
|
103
|
+
const r = resolveSender(c, { hyphaUser: user, hyphaAnonymous: context?.user?.is_anonymous === true, hyphaWorkspace: context?.ws || context?.user?.scope?.current_workspace, from: kwargs.from });
|
|
104
|
+
if (r.error) return { error: r.error };
|
|
105
|
+
return deliver(c, r.sender, { message: kwargs.message }, {});
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
console.log(`[channels] registered Hypha service: ${svc.id} (type svamp-channels)`);
|
|
109
|
+
return server;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── HTTP transport ───────────────────────────────────────────────────────
|
|
113
|
+
function startHttp(port) {
|
|
114
|
+
const server = createServer((req, res) => {
|
|
115
|
+
const u = new URL(req.url, URL_BASE);
|
|
116
|
+
if (u.pathname === '/' || u.pathname === '/health') { res.writeHead(200).end('channels ok'); return; }
|
|
117
|
+
if (u.pathname === '/channels') {
|
|
118
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(store.list().filter(c => c.enabled !== false).map(channelView)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let m = u.pathname.match(/^\/channel\/([\w.-]+)\/skill\.md$/);
|
|
122
|
+
if (m) { const c = store.get(m[1]); if (!c) { res.writeHead(404).end('not found'); return; }
|
|
123
|
+
res.writeHead(200, { 'content-type': 'text/markdown' }).end(generateSkillBody(c, URL_BASE)); return; }
|
|
124
|
+
m = u.pathname.match(/^\/channel\/([\w.-]+)$/);
|
|
125
|
+
if (!m) { res.writeHead(404).end('not found'); return; }
|
|
126
|
+
const c = store.get(m[1]);
|
|
127
|
+
if (!c || c.enabled === false) { res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'channel not found' })); return; }
|
|
128
|
+
if (req.method === 'GET' && !u.searchParams.get('message')) {
|
|
129
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...channelView(c), skill_url: `${URL_BASE}/channel/${c.id}/skill.md` })); return;
|
|
130
|
+
}
|
|
131
|
+
let chunks = '';
|
|
132
|
+
req.on('data', (d) => { chunks += d; if (chunks.length > 1e6) req.destroy(); });
|
|
133
|
+
req.on('end', async () => {
|
|
134
|
+
let body = {}; try { body = chunks ? JSON.parse(chunks) : {}; } catch {}
|
|
135
|
+
const key = (req.headers.authorization || '').replace(/^Bearer\s+/i, '') || u.searchParams.get('key');
|
|
136
|
+
const message = body.message ?? u.searchParams.get('message');
|
|
137
|
+
const r = resolveSender(c, { key, from: body.from || u.searchParams.get('from') });
|
|
138
|
+
if (r.error) { res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: r.error })); return; }
|
|
139
|
+
const out = await deliver(c, r.sender, { message }, Object.fromEntries(u.searchParams));
|
|
140
|
+
res.writeHead(out.ok ? 200 : 500, { 'content-type': 'application/json' }).end(JSON.stringify(out));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
server.listen(port, () => console.log(`[channels] HTTP on ${URL_BASE} (POST /channel/<id>)`));
|
|
144
|
+
return server;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const port = Number(process.env.CHANNEL_HTTP_PORT) || 8733;
|
|
148
|
+
const http = startHttp(port);
|
|
149
|
+
const hyphaServer = await registerHypha().catch((e) => { console.error('[channels] Hypha registration failed:', e?.message || e); return null; });
|
|
150
|
+
console.log(`[channels] serving ${store.list().length} channel(s) from ${PROJECT}`);
|
|
151
|
+
process.on('SIGINT', () => { http.close(); hyphaServer?.disconnect?.(); process.exit(0); });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// inject-loop.mjs — Claude Code `SessionStart` / `UserPromptSubmit` hook.
|
|
3
|
+
// Injects the current LOOP.md plus the loop protocol so every iteration starts
|
|
4
|
+
// from the latest task/plan/progress without any daemon re-injection.
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PROJECT = resolve(HERE, '..', '..', '..');
|
|
11
|
+
const LOOP_DIR = join(PROJECT, '.claude', 'loop');
|
|
12
|
+
|
|
13
|
+
function readJSON(p, f) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return f; } }
|
|
14
|
+
const cfg = readJSON(join(LOOP_DIR, 'loop.config.json'), null);
|
|
15
|
+
const state = readJSON(join(LOOP_DIR, 'loop-state.json'), { active: false });
|
|
16
|
+
if (!cfg || state.active === false) process.exit(0); // no active loop -> inject nothing
|
|
17
|
+
|
|
18
|
+
let loopMd = '';
|
|
19
|
+
const loopPath = join(PROJECT, cfg.loop_file || 'LOOP.md');
|
|
20
|
+
if (existsSync(loopPath)) loopMd = readFileSync(loopPath, 'utf8');
|
|
21
|
+
|
|
22
|
+
const evaluatorOn = cfg.evaluator?.enabled !== false;
|
|
23
|
+
const oracleCmd = cfg.oracle?.command || cfg.oracle?.test || cfg.oracle?.build || cfg.oracle || null;
|
|
24
|
+
|
|
25
|
+
const protocol = `# 🔁 LOOP MODE IS ACTIVE
|
|
26
|
+
|
|
27
|
+
You are running inside a loop. Each turn is one iteration. Work toward completing the task described in LOOP.md below. You CANNOT end the loop by simply saying you are done — a Stop gate independently re-checks the exit conditions and will send you back to work if they are not met.
|
|
28
|
+
|
|
29
|
+
**Exit conditions (all must hold before the loop ends):**
|
|
30
|
+
${oracleCmd ? `1. The oracle command must pass: \`${oracleCmd}\` (exit 0). The gate runs this itself — do not fake it.\n` : '1. (No oracle configured.)\n'}${evaluatorOn ? `2. An INDEPENDENT evaluator must judge the work \"done\". Before you finish: spawn a fresh subagent (Task tool) named/acting as \`loop-evaluator\` with a skeptical reviewer prompt; give it ONLY the goal (from LOOP.md), the current diff, and the oracle output. Have IT decide. Then record its verdict to \`.claude/loop/evaluator-verdict.json\`:\n {"verdict":"done"|"continue","reason":"...","guidance":"...","state_fp":"<output of: node .claude/loop/bin/state-fp.mjs>"}\n Do NOT grade your own work — the verdict must come from the subagent, and it is only valid for the exact code state it reviewed.\n` : ''}
|
|
31
|
+
|
|
32
|
+
**Each iteration:** read LOOP.md → make real progress on the task → update the Progress section of LOOP.md → run/verify the oracle → (if you believe it's done) get the evaluator verdict → end your turn to be re-checked. Keep LOOP.md current; it is your durable memory across iterations and restarts.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
## LOOP.md
|
|
36
|
+
${loopMd || '(LOOP.md not found — create it with the task and a Progress section.)'}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
// SessionStart/UserPromptSubmit: stdout is added to the model's context.
|
|
40
|
+
process.stdout.write(protocol);
|
|
41
|
+
process.exit(0);
|