Skip to content

Commit 813fc20

Browse files
sw-architectclaude
andcommitted
v0.30.0 — terminal-agent mutator/spotter loop + Anthropic standard pipeline
Two architectural fixes verified via live multi-agent E2E test (terminal Claude CLI agents on Pro-plan auth, $0 cost, no API key): 1. β LLM filer (spotter) refactored from `spawn claude` subprocess to the queue-based terminal-agent pattern that CliClaudeMutator already uses: sc-api enqueues task_queue_pg row (role='mutator-general') → terminal Claude CLI agent claims via zc_claim_task → reasons over signals with Sonnet 4.6 → persists to mutation_results_pg side-channel (hash-verified) → broadcasts STATUS state='spotter-filer-result' with pointer → β filer polls PG broadcasts, fetches + decodes decisions Bug found + fixed during E2E: agent emitted `signal_id` as string, validator required number. Now coerces numeric strings. 2. v0.29.0 Anthropic skill-design standard (four invariants, script rules, scope matrix, composition principles) consolidated into a single source of truth at src/skills/anthropic_standard.ts and injected into buildProposerPrompt so candidates respect the standard at generation time, not just at admission time. The standard is exposed via GET /api/v1/skills/standard so the dispatcher (A2A_dispatcher) can sync it into mutator-* deepPrompts without a repo checkout. With the dispatcher-side change, the standard reaches the agent at spawn time — the per-task `payload.proposer_prompt` bridge in cli_claude.ts is no longer needed and has been removed. Live verification: - spotter run 50126b81: 12 signals → 12 decoded → all 12 rejected (6 dup, 3 variable-instance, 2 low-signal, 1 fits-in-prompt) — agent applied conservative judgment per standard - mutation task mut-v030-test-mpe2ruag: 5 candidates generated in ~3.5min, best self-rated 0.85. Candidate 5 explicitly cites the Anthropic composition principle by name and proposes a decomposition into a composable `ci-gate` sub-skill — proves the standard reached the agent's reasoning context. Files: - src/skills/anthropic_standard.ts (new) — single source of truth - src/skills/mutator.ts — buildProposerPrompt now embeds the standard - src/skills/mutators/cli_claude.ts — removed payload.proposer_prompt bridge - src/skills/spotter/llm_filer.ts — queue+poll refactor, type coercion - src/api-server.ts — /api/v1/skills/standard endpoint + project_path on llm-file - examples/skills/writing-skills/scripts/preview-admission.py — UTF-8 stdout fix for Windows cp1252 terminals - scripts/_reprocess_spotter_filer.mjs — replay tool for re-decoding existing side-channel rows after validator bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e940310 commit 813fc20

10 files changed

Lines changed: 807 additions & 190 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zc-ctx",
3-
"version": "0.29.0",
3+
"version": "0.30.0",
44
"description": "Secure memory + persistent context + skill-admission gate for Claude Code. HMAC-chained tool_calls + outcomes, per-agent HKDF subkey isolation, Postgres RLS, work-stealing task queue, MemGPT-style persistent memory, Agent Notebook Model (Read→Summary redirect), and v0.26.0+ Anthropic-style filesystem skills with AST-based admission scanning, HMAC tamper detection, chained audit log, and marketplace bundled-script support. 30+ MCP tools. MIT license.",
55
"type": "mcp",
66
"mcp": {

examples/skills/writing-skills/scripts/preview-admission.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
checks plus a local AST scan via the bundled py_ast_walker (if findable).
2121
"""
2222
import argparse
23+
import io
2324
import json
2425
import os
2526
import subprocess
@@ -28,6 +29,13 @@
2829
import urllib.request
2930
from pathlib import Path
3031

32+
# Force UTF-8 stdout on Windows so unicode marker chars don't crash cp1252.
33+
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
34+
try:
35+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
36+
except AttributeError:
37+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
38+
3139

3240
def find_py_ast_walker() -> Path | None:
3341
"""Locate scripts/py_ast_walker.py from the SecureContext repo if available."""

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zc-ctx",
3-
"version": "0.29.0",
3+
"version": "0.30.0",
44
"description": "Secure memory & context optimization MCP plugin for Claude Code — drop-in replacement for context-mode with credential isolation, SSRF protection, MemGPT-style persistent memory, and A2A multi-agent broadcast channel",
55
"keywords": [
66
"claude-code",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// One-shot reprocess: re-run the β filer's persistence step against an
2+
// already-existing mutation_results_pg row. Used to validate the type-coercion
3+
// bug fix without burning another Sonnet call.
4+
//
5+
// Usage: node scripts/_reprocess_spotter_filer.mjs <run_id>
6+
7+
import { withClient } from "/app/dist/pg_pool.js";
8+
import { randomUUID } from "node:crypto";
9+
10+
const runId = process.argv[2];
11+
if (!runId) {
12+
console.error("usage: node _reprocess_spotter_filer.mjs <run_id>");
13+
process.exit(1);
14+
}
15+
16+
// 1. Fetch the existing mutation_results row.
17+
const row = await withClient(async (c) => {
18+
const r = await c.query(
19+
`SELECT result_id, bodies, bodies_hash, candidate_count
20+
FROM mutation_results_pg
21+
WHERE mutation_id = $1
22+
ORDER BY created_at DESC LIMIT 1`,
23+
[runId],
24+
);
25+
return r.rows[0] ?? null;
26+
});
27+
if (!row) {
28+
console.error(`No mutation_results row for run_id=${runId}`);
29+
process.exit(1);
30+
}
31+
32+
console.log(`Found result_id=${row.result_id} with ${row.candidate_count} bodies`);
33+
34+
// 2. Decode bodies as decisions, applying the new lenient validation.
35+
const bodies = JSON.parse(row.bodies);
36+
const VALID_OUTCOMES = new Set([
37+
"filed_candidate", "rejected_low_signal", "rejected_not_procedural",
38+
"rejected_fits_in_prompt", "rejected_duplicate", "rejected_variable_instances",
39+
]);
40+
41+
const decisions = [];
42+
for (const body of bodies) {
43+
let parsed;
44+
try { parsed = JSON.parse(body.candidate_body); }
45+
catch (e) { console.warn("malformed body, skip:", e.message); continue; }
46+
const coercedId = typeof parsed.signal_id === "number"
47+
? parsed.signal_id
48+
: (typeof parsed.signal_id === "string" && /^-?\d+$/.test(parsed.signal_id)
49+
? parseInt(parsed.signal_id, 10)
50+
: NaN);
51+
if (!Number.isFinite(coercedId)) {
52+
console.warn(`skip: bad signal_id=${JSON.stringify(parsed.signal_id)}`);
53+
continue;
54+
}
55+
if (!VALID_OUTCOMES.has(parsed.outcome)) {
56+
console.warn(`skip: bad outcome=${parsed.outcome}`);
57+
continue;
58+
}
59+
decisions.push({ ...parsed, signal_id: coercedId });
60+
}
61+
62+
console.log(`Decoded ${decisions.length} valid decisions`);
63+
64+
// 3. Apply decisions to skill_spotter_signals_pg + skill_candidates_pg.
65+
const byOutcome = {
66+
filed_candidate: 0, rejected_low_signal: 0, rejected_not_procedural: 0,
67+
rejected_fits_in_prompt: 0, rejected_duplicate: 0, rejected_variable_instances: 0,
68+
};
69+
let candidatesFiled = 0;
70+
71+
for (const d of decisions) {
72+
byOutcome[d.outcome]++;
73+
if (d.outcome === "filed_candidate" && d.candidate) {
74+
const candidateId = randomUUID();
75+
const cand = d.candidate;
76+
await withClient(async (c) => {
77+
await c.query(
78+
`INSERT INTO skill_candidates_pg (
79+
candidate_id, project_hash, target_role, rejection_count,
80+
first_rejection_at, last_rejection_at, rejection_outcomes,
81+
headline, proposed_skill_body, proposed_at, status
82+
) VALUES ($1, $2, $3, $4, now(), now(), $5::jsonb,
83+
$6, $7, now(), 'ready')`,
84+
[
85+
candidateId, "spotter-global", "developer", 0,
86+
JSON.stringify({ source: "skill-spotter", signal_id: d.signal_id, scope: cand.scope, result_id: row.result_id, bodies_hash: row.bodies_hash }),
87+
`[spotter] ${cand.skill_name}: ${(cand.description ?? "").slice(0, 140)}`,
88+
`---\nname: ${cand.skill_name}\ndescription: |\n ${cand.description}\nscope: ${cand.scope}\n---\n\n${cand.proposed_skill_body}`,
89+
],
90+
);
91+
await c.query(
92+
`UPDATE skill_spotter_signals_pg
93+
SET outcome = 'filed_candidate', outcome_reason = $2, candidate_id = $3::uuid
94+
WHERE signal_id = $1`,
95+
[d.signal_id, d.outcome_reason ?? "", candidateId],
96+
);
97+
});
98+
candidatesFiled++;
99+
console.log(` filed: signal_id=${d.signal_id} as ${cand.skill_name}`);
100+
} else {
101+
await withClient(async (c) => {
102+
await c.query(
103+
`UPDATE skill_spotter_signals_pg
104+
SET outcome = $2, outcome_reason = $3
105+
WHERE signal_id = $1`,
106+
[d.signal_id, d.outcome, d.outcome_reason ?? ""],
107+
);
108+
});
109+
console.log(` signal_id=${d.signal_id}: ${d.outcome}`);
110+
}
111+
}
112+
113+
// 4. Update the spotter run row.
114+
await withClient(async (c) => {
115+
await c.query(
116+
`UPDATE skill_spotter_runs_pg
117+
SET mode = 'llm-proposed', candidates_filed = $2
118+
WHERE run_id = $1`,
119+
[runId, candidatesFiled],
120+
);
121+
});
122+
123+
console.log("");
124+
console.log("=== REPROCESS COMPLETE ===");
125+
console.log(`signals_processed: ${decisions.length}`);
126+
console.log(`candidates_filed: ${candidatesFiled}`);
127+
console.log("by_outcome: ", JSON.stringify(byOutcome));
128+
process.exit(0);

src/api-server.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,22 @@ export async function createApiServer(storeOverride?: Store) {
12991299
}
13001300
});
13011301

1302+
// v0.30.0 — expose the v0.29.0 Anthropic skill-design standard so external
1303+
// consumers (e.g. A2A_dispatcher's sync-standard-from-securecontext.mjs)
1304+
// can pull it without needing a checkout of this repo.
1305+
app.get("/api/v1/skills/standard", async (_request, reply) => {
1306+
try {
1307+
const { ANTHROPIC_SKILL_STANDARD } = await import("./skills/anthropic_standard.js");
1308+
reply.type("application/json").send({
1309+
version: "v0.29.0",
1310+
length: ANTHROPIC_SKILL_STANDARD.length,
1311+
standard: ANTHROPIC_SKILL_STANDARD,
1312+
});
1313+
} catch (e) {
1314+
reply.status(500).type("application/json").send({ error: (e as Error).message });
1315+
}
1316+
});
1317+
13021318
// v0.28.0-α — Signals from one run.
13031319
app.get("/api/v1/skills/spotter/runs/:run_id", async (request, reply) => {
13041320
try {
@@ -1311,25 +1327,38 @@ export async function createApiServer(storeOverride?: Store) {
13111327
}
13121328
});
13131329

1314-
// v0.28.0-β — Run the LLM filer on a specific spotter run. Spawns the
1315-
// claude CLI subprocess with sonnet-4-6, applies the four Anthropic
1316-
// skill-quality gates to each observed signal, files matching ones as
1317-
// skill_candidates_pg rows with status='ready' (operator approves to
1318-
// trigger the existing mutator-generated body → admission flow).
1330+
// v0.28.0-β — Run the LLM filer on a specific spotter run.
1331+
// v0.30.0 — REFACTORED: enqueues a skill-spotter-filer task into the
1332+
// mutator pool. A terminal Claude CLI agent (Pro-plan auth) claims it,
1333+
// applies the four Anthropic skill-quality gates to each observed signal,
1334+
// stores decisions in the mutation_results side-channel, and broadcasts
1335+
// a STATUS pointer. The β filer then writes filed_candidate decisions to
1336+
// skill_candidates_pg with status='ready'.
13191337
//
1320-
// Cost: ~$0.15 per run with the operator's Pro plan. Time: ~30-90s.
1338+
// Cost: $0 (Pro plan). Time: ≤ timeout_ms (default 10 min).
13211339
//
13221340
// POST /api/v1/skills/spotter/runs/:run_id/llm-file
1323-
// [?model=claude-sonnet-4-6] [?timeout_ms=300000]
1341+
// [?project_path=...] [?agent_role=mutator] [?timeout_ms=600000]
1342+
// project_path falls back to ZC_SPOTTER_DEFAULT_PROJECT_PATH if unset.
13241343
app.post("/api/v1/skills/spotter/runs/:run_id/llm-file", async (request, reply) => {
13251344
try {
13261345
const { run_id } = request.params as { run_id: string };
1327-
const { model, timeout_ms } = request.query as Record<string, unknown>;
1346+
const q = request.query as Record<string, unknown>;
1347+
const projectPath = (typeof q.project_path === "string" && q.project_path.trim())
1348+
? q.project_path
1349+
: process.env.ZC_SPOTTER_DEFAULT_PROJECT_PATH;
1350+
if (!projectPath) {
1351+
reply.status(400).type("application/json").send({
1352+
error: "project_path is required (query param or ZC_SPOTTER_DEFAULT_PROJECT_PATH env var) — used to route the queue task to a terminal mutator agent",
1353+
});
1354+
return;
1355+
}
13281356
const { runSpotterLlmFiler } = await import("./skills/spotter/llm_filer.js");
13291357
const result = await runSpotterLlmFiler({
13301358
run_id,
1331-
model: typeof model === "string" && model.trim() ? model : undefined,
1332-
timeout_ms: timeout_ms ? Math.max(60_000, Math.min(900_000, parseInt(String(timeout_ms), 10) || 300_000)) : undefined,
1359+
project_path: projectPath,
1360+
agent_role: typeof q.agent_role === "string" && q.agent_role.trim() ? q.agent_role : undefined,
1361+
timeout_ms: q.timeout_ms ? Math.max(60_000, Math.min(1_800_000, parseInt(String(q.timeout_ms), 10) || 600_000)) : undefined,
13331362
});
13341363
reply.type("application/json").send(result);
13351364
} catch (e) {
@@ -1437,11 +1466,21 @@ export async function createApiServer(storeOverride?: Store) {
14371466
});
14381467

14391468
// v0.28.0-β — POST handler: triggers LLM filer + returns inline summary fragment.
1469+
// v0.30.0 — uses terminal-agent queue pattern. project_path comes from
1470+
// ZC_SPOTTER_DEFAULT_PROJECT_PATH or the ?project_path query parameter.
14401471
app.post("/dashboard/spotter/runs/:run_id/llm-file", async (request, reply) => {
14411472
try {
14421473
const { run_id } = request.params as { run_id: string };
1474+
const q = request.query as Record<string, unknown>;
1475+
const projectPath = (typeof q.project_path === "string" && q.project_path.trim())
1476+
? q.project_path
1477+
: process.env.ZC_SPOTTER_DEFAULT_PROJECT_PATH;
1478+
if (!projectPath) {
1479+
reply.type("text/html").send(`<div class="error">LLM filer failed: project_path is required. Set ZC_SPOTTER_DEFAULT_PROJECT_PATH on sc-api or pass <code>?project_path=...</code>.</div>`);
1480+
return;
1481+
}
14431482
const { runSpotterLlmFiler } = await import("./skills/spotter/llm_filer.js");
1444-
const result = await runSpotterLlmFiler({ run_id });
1483+
const result = await runSpotterLlmFiler({ run_id, project_path: projectPath });
14451484
const errorsHtml = result.errors.length > 0
14461485
? `<div style="color:#fca5a5; margin-top:6px">Errors: ${result.errors.map(e => escapeHtml(e)).join("; ")}</div>`
14471486
: "";

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const env = process.env;
3737

3838
export const Config = {
3939
// ── Version ──────────────────────────────────────────────────────────────
40-
VERSION: "0.29.0",
40+
VERSION: "0.30.0",
4141

4242
// ── Storage paths ────────────────────────────────────────────────────────
4343
DB_DIR: join(homedir(), ".claude", "zc-ctx", "sessions"),

0 commit comments

Comments
 (0)