@inetafrica/open-claudia 2.8.0 → 2.9.1
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/CHANGELOG.md +10 -0
- package/bin/tool.js +40 -4
- package/core/actions.js +33 -2
- package/core/approvals.js +42 -9
- package/core/loopback.js +1 -1
- package/core/pack-review.js +66 -12
- package/core/packs.js +22 -2
- package/core/runner.js +13 -0
- package/core/system-prompt.js +8 -4
- package/package.json +4 -3
- package/test-approval-async.js +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.9.1
|
|
4
|
+
- **Post-turn reviewer no longer rewrites pack sections blind — context starvation fixed.** The reviewer prompt showed only the pack *index* (name + description), so a State rewrite was regenerated from the turn text alone and silently destroyed durable facts the turn didn't restate; worse, with no project name in the turn text it could attribute the work to the wrong pack entirely (observed live: an AQUELLE product-shot turn filed under Alpha Protein, wiping a promoted root-cause fact). The runner now passes the turn's **active packs** (recall-surfaced ∪ explicitly opened) into `reviewTurn`; the prompt includes their **full current Stance/Procedure/State + recent journal**, with two new rules: attribute updates to an active pack unless the turn clearly names another, and treat section rewrites as **merges** — carry forward every still-true fact, drop only what the turn contradicted or completed.
|
|
5
|
+
- **Structural enforcement, not just prompt guidance.** `applyAction` drops section rewrites (State/Stance/Procedure) targeting any pack whose current content the reviewer did not see — journal lines still land; the announce line notes the drop. Applies to the update path, the exists-on-create path, and the versioned-duplicate fold.
|
|
6
|
+
- **Provenance guard extended.** A user-authored Procedure now gets the same intent-wins protection as Stance (an automated writer never silently replaces it). State stays rewritable by design, but replacing a user-authored State is now flagged in the reviewer's announcement (⚠️) so the overwrite is visible instead of silent.
|
|
7
|
+
|
|
8
|
+
## v2.9.0
|
|
9
|
+
- **Hybrid async approvals — a destructive run no longer burns the turn or dead-ends on a late press.** v2.8.0's chat approval blocked the CLI for 5 minutes and treated timeout as denial, which had two UX failures: an undecided window wasted minutes of live turn, and a button pressed after the window hit a dead-end (terse auto-ack, no agent reaction — the record was already denied). The flow is now two-phase. **Fast path**: the CLI waits ~90s; an inline Approve runs immediately, Deny refuses — unchanged semantics, just a tighter window. **Async path**: if undecided at 90s, the CLI marks the record `detached` and exits code 5 with instructions to end the turn ("pending your Approve/Deny — do NOT retry"); the record stays open for 24h. When the button is eventually pressed, the `apr:` handler sees the detached record and **schedules an immediate wakeup** in the originating channel carrying the decision and the exact command, so the agent reacts either way (runs it, or acknowledges the denial).
|
|
10
|
+
- **One-shot approval tokens, fail-closed on every branch.** The woken agent redeems the decision with `tool run <name> <args> --approval <apr_id>`. `approvals.consume()` requires ALL of: status `approved`, never consumed before (single use — a replay is refused), within 24h of request, and a command line **byte-identical** to what the user saw on the buttons (a mismatch refuses without burning the token). `--approval` is honored on top-level runs only — a nested/composed tool can't smuggle a token — and redemption stamps `approvedVia: "chat-approval"` + exports `OPEN_CLAUDIA_APPROVED_TIER=destructive` exactly like an inline approve, so composition inheritance is unchanged. Undecided, denied, expired, unknown, or mismatched never runs.
|
|
11
|
+
- Approval prompt and system-prompt docs updated for the two-phase flow; new `test-approval-async.js` covers the full lifecycle (detach semantics, pending/denied/unknown refusals, exact-match redemption, replay refusal, token not burned on mismatch, 24h expiry, 1000-char command cap, 90s default window).
|
|
12
|
+
|
|
3
13
|
## v2.8.0
|
|
4
14
|
- **Per-verb manifests — the gate now knows each verb's blast radius (Phase 5).** A tool may declare its verbs in the header (`verb: <name> [args] | <tier> | <doc>` — one line per verb); `tool run` then resolves the risk tier **per invoked verb** instead of whole-tool: `spaces unread` runs freely while `spaces upload` demands `--yes`, and `prod-k8s cert-status` is read-only while `cert-renew` stays write-gated. An **undeclared verb on a manifest tool is refused outright** regardless of flags (declare it before using it), `help`/no-verb is always read-only, and manifest-less tools keep the old whole-tool gate unchanged (fully backward compatible — older runners ignore `verb:` lines). `tool scaffold --verbs "list:read-only:doc,create:write:doc"` generates the manifest lines, a dispatcher entry per verb, and a TOOL.md verbs block; `tool show` lists the declared manifest. Migrated all 8 live verb-CLIs (clickhouse, inet-central-cli, inet-ops, kazee-chat, kticket, metabase, prod-k8s, spaces — 41 declared verbs) in the tools repo.
|
|
5
15
|
- **Chat approval with the EXACT payload — destructive runs escalate to inline Approve/Deny.** `--yes-destructive` acknowledged the agent's *paraphrase* of a destructive action; now an unflagged destructive top-level `tool run` inside a bot task POSTs the literal resolved command line to the requesting channel via a new loopback `approval-request` kind — the user sees `open-claudia tool run <name> <args>` verbatim with ✅ Approve / ⛔ Deny buttons (owner-only, idempotent: a second press can't overturn the first). The CLI polls the file-backed record (`core/approvals.js`) and **fails closed**: timeout (5 min) or deny = refusal at exit 3; only an explicit approve proceeds, stamped `approvedVia: "chat-approval"` with `OPEN_CLAUDIA_APPROVED_TIER=destructive` exported for composition. Every run's history entry now records the acknowledged tier — auditable forever.
|
package/bin/tool.js
CHANGED
|
@@ -353,7 +353,17 @@ async function run(args) {
|
|
|
353
353
|
if (!name) { console.error("Usage: tool run <name> [args...]"); process.exitCode = 1; return; }
|
|
354
354
|
const t = tools.findTool(name);
|
|
355
355
|
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
356
|
-
|
|
356
|
+
let toolArgs = rest.slice(1);
|
|
357
|
+
|
|
358
|
+
// Async-approval token (--approval apr_x): redeemed one-shot against the
|
|
359
|
+
// exact command line WITHOUT the token flag, so what's checked is what
|
|
360
|
+
// was shown to the user. Stripped before spawn — tools never see it.
|
|
361
|
+
let approvalId = "";
|
|
362
|
+
const aprIdx = toolArgs.indexOf("--approval");
|
|
363
|
+
if (aprIdx >= 0) {
|
|
364
|
+
approvalId = toolArgs[aprIdx + 1] || "";
|
|
365
|
+
toolArgs = [...toolArgs.slice(0, aprIdx), ...toolArgs.slice(aprIdx + 2)];
|
|
366
|
+
}
|
|
357
367
|
|
|
358
368
|
// Risk-tier gate, resolved per verb when the tool declares a manifest:
|
|
359
369
|
// write needs --yes, destructive needs --yes-destructive. Undeclared
|
|
@@ -361,24 +371,50 @@ async function run(args) {
|
|
|
361
371
|
// calling this tool) gate on the tier the OUTERMOST invocation approved
|
|
362
372
|
// instead — argv flags can't fabricate approval.
|
|
363
373
|
const gate = tools.runGate(t, toolArgs, process.env);
|
|
374
|
+
const commandLine = ["open-claudia tool run", t.name, ...toolArgs].join(" ");
|
|
364
375
|
let approvedVia = "";
|
|
365
|
-
|
|
376
|
+
|
|
377
|
+
if (approvalId) {
|
|
378
|
+
const approvals = require("../core/approvals");
|
|
379
|
+
if (String(process.env.OPEN_CLAUDIA_TOOL_STACK || "").trim()) {
|
|
380
|
+
console.error("⛔ --approval tokens are only honored on top-level runs, not nested tool calls.");
|
|
381
|
+
process.exitCode = 3; return;
|
|
382
|
+
}
|
|
383
|
+
const redeemed = approvals.consume(approvalId, commandLine);
|
|
384
|
+
if (!redeemed.ok) {
|
|
385
|
+
console.error(`⛔ Approval ${approvalId} rejected: ${redeemed.reason}. The run stays blocked.`);
|
|
386
|
+
process.exitCode = 3; return;
|
|
387
|
+
}
|
|
388
|
+
approvedVia = "chat-approval";
|
|
389
|
+
console.error(`✅ Approval ${approvalId} redeemed (single use) — running.`);
|
|
390
|
+
} else if (!gate.ok) {
|
|
366
391
|
// Chat-approval fallback: an unflagged destructive top-level run inside
|
|
367
392
|
// a bot task escalates to the requesting channel with the EXACT command
|
|
368
|
-
// and inline Approve/Deny.
|
|
393
|
+
// and inline Approve/Deny. Fast path: block ~90s for an inline decision.
|
|
394
|
+
// No decision in time → detach and exit; the button press later fires a
|
|
395
|
+
// wakeup carrying a one-shot --approval token. Anything but an explicit
|
|
396
|
+
// approve = the command never runs.
|
|
369
397
|
const canAsk = gate.tier === "destructive" && !gate.nested && !gate.undeclared
|
|
370
398
|
&& process.env.OC_SEND_URL && process.env.OC_SEND_TOKEN && process.env.OC_CHANNEL_ID && process.env.OC_CHANNEL_ADAPTER;
|
|
371
399
|
if (!canAsk) { console.error(gate.message); process.exitCode = 3; return; }
|
|
372
|
-
const commandLine = ["open-claudia tool run", t.name, ...toolArgs].join(" ");
|
|
373
400
|
console.error(`Destructive run without --yes-destructive — asking for chat approval of the exact command...`);
|
|
374
401
|
let decision = "denied";
|
|
402
|
+
let reqId = "";
|
|
375
403
|
try {
|
|
376
404
|
const { postJson } = require("./loopback-client");
|
|
377
405
|
const res = await postJson("approval-request", {
|
|
378
406
|
tool: t.name, verb: tools.invokedVerb(toolArgs), tier: gate.tier, command: commandLine,
|
|
379
407
|
});
|
|
408
|
+
reqId = res.id;
|
|
380
409
|
const approvals = require("../core/approvals");
|
|
381
410
|
decision = await approvals.waitForDecision(res.id);
|
|
411
|
+
if (decision === "timeout") {
|
|
412
|
+
approvals.detach(res.id);
|
|
413
|
+
console.error(`⏳ No decision yet — approval ${res.id} stays open for 24h (detached).`);
|
|
414
|
+
console.error(`End the turn and tell the user the run is pending their Approve/Deny. When they press a button, ` +
|
|
415
|
+
`a wakeup will fire with the outcome — do NOT retry or re-request this command in the meantime.`);
|
|
416
|
+
process.exitCode = 5; return;
|
|
417
|
+
}
|
|
382
418
|
} catch (e) {
|
|
383
419
|
console.error(`Chat approval unavailable (${e.message}).`);
|
|
384
420
|
}
|
package/core/actions.js
CHANGED
|
@@ -305,12 +305,43 @@ async function handleAction(envelope) {
|
|
|
305
305
|
const id = parts[1];
|
|
306
306
|
const choice = parts[2] === "ok" ? "approved" : "denied";
|
|
307
307
|
const approvals = require("./approvals");
|
|
308
|
+
const wasPending = approvals.read(id)?.status === "pending";
|
|
308
309
|
const rec = approvals.decide(id, choice, String(envelope.channelId));
|
|
309
310
|
if (!rec) return send("That approval request has expired or was cleaned up — the run stays blocked.");
|
|
310
311
|
// If a second press disagreed, rec.status still holds the FIRST decision.
|
|
312
|
+
const label = `${rec.tool}${rec.verb ? " " + rec.verb : ""}`;
|
|
313
|
+
if (rec.detached && wasPending) {
|
|
314
|
+
// The CLI gave up its fast-path wait and exited — no live turn is
|
|
315
|
+
// listening. Wake the agent so it can redeem (or acknowledge) the
|
|
316
|
+
// decision. The one-shot token in consume() keeps this fail-closed.
|
|
317
|
+
const prompt = rec.status === "approved"
|
|
318
|
+
? `[Tool approval ${rec.id}] The user APPROVED the pending destructive run:\n${rec.command}\n` +
|
|
319
|
+
`Run it now EXACTLY as:\n${rec.command} --approval ${rec.id}\n` +
|
|
320
|
+
`The token is single-use and bound to that exact command — do not alter it. Report the result to the user.`
|
|
321
|
+
: `[Tool approval ${rec.id}] The user DENIED the pending destructive run:\n${rec.command}\n` +
|
|
322
|
+
`Do NOT run it. Acknowledge the denial to the user and adjust course.`;
|
|
323
|
+
try {
|
|
324
|
+
scheduler.addJob({
|
|
325
|
+
kind: "wakeup",
|
|
326
|
+
adapter: rec.adapter || (adapter && adapter.id) || "",
|
|
327
|
+
adapterType: adapter && adapter.type,
|
|
328
|
+
channelId: String(rec.channelId || envelope.channelId),
|
|
329
|
+
prompt,
|
|
330
|
+
label: `Tool approval ${rec.status}: ${label}`.slice(0, 60),
|
|
331
|
+
source: "approval",
|
|
332
|
+
fireAt: Date.now() + 2000,
|
|
333
|
+
});
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return send(`Decision recorded (${rec.status}) but I couldn't wake the agent: ${e.message}`);
|
|
336
|
+
}
|
|
337
|
+
await send(rec.status === "approved"
|
|
338
|
+
? `✅ Approved — waking the agent to run ${label} now.`
|
|
339
|
+
: `⛔ Denied — ${label} stays blocked; letting the agent know.`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
311
342
|
await send(rec.status === "approved"
|
|
312
|
-
? `✅ Approved — ${
|
|
313
|
-
: `⛔ Denied — ${
|
|
343
|
+
? `✅ Approved — ${label} will run now.`
|
|
344
|
+
: `⛔ Denied — ${label} will not run.`);
|
|
314
345
|
return;
|
|
315
346
|
}
|
|
316
347
|
if (d.startsWith("tt:")) {
|
package/core/approvals.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
// --yes-destructive flag acknowledges a summary — here the user approves the
|
|
5
5
|
// literal command that will execute, not the agent's paraphrase of it.
|
|
6
6
|
//
|
|
7
|
-
// Mechanics: file-backed records under CONFIG_DIR/approvals.
|
|
8
|
-
// subprocess) POSTs approval-request to the bot's loopback
|
|
9
|
-
// the button message and creates the pending record
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
7
|
+
// Mechanics (hybrid async): file-backed records under CONFIG_DIR/approvals.
|
|
8
|
+
// The CLI (a bot subprocess) POSTs approval-request to the bot's loopback
|
|
9
|
+
// server, which sends the button message and creates the pending record. The
|
|
10
|
+
// CLI then polls the record for a short fast-path window; if the user decides
|
|
11
|
+
// in time the run proceeds (or refuses) inline. Otherwise the CLI marks the
|
|
12
|
+
// record "detached" and exits — the agent's turn ends cleanly, and when the
|
|
13
|
+
// button is eventually pressed, actions.js schedules a wakeup so the agent can
|
|
14
|
+
// consume the approval as a ONE-SHOT token bound to the exact command string
|
|
15
|
+
// (single use, 24h TTL, fail-closed: undecided or mismatched never runs).
|
|
13
16
|
|
|
14
17
|
const fs = require("fs");
|
|
15
18
|
const path = require("path");
|
|
@@ -71,8 +74,9 @@ function decide(id, status, by = "") {
|
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
// Poll until decided or timeout. Timeout resolves as "timeout" — the caller
|
|
74
|
-
// must treat anything other than "approved" as a refusal
|
|
75
|
-
async
|
|
77
|
+
// must treat anything other than "approved" as a refusal (or detach and go
|
|
78
|
+
// async).
|
|
79
|
+
async function waitForDecision(id, { timeoutMs = 90000, pollMs = 1500 } = {}) {
|
|
76
80
|
const deadline = Date.now() + timeoutMs;
|
|
77
81
|
for (;;) {
|
|
78
82
|
const rec = read(id);
|
|
@@ -82,4 +86,33 @@ async function waitForDecision(id, { timeoutMs = 300000, pollMs = 1500 } = {}) {
|
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
|
|
89
|
+
// The CLI gave up its fast-path wait and exited; the record stays pending.
|
|
90
|
+
// actions.js uses this flag to know a later button press must wake the agent
|
|
91
|
+
// (an attached waiter reacts by itself, a detached one cannot).
|
|
92
|
+
function detach(id) {
|
|
93
|
+
const rec = read(id);
|
|
94
|
+
if (!rec || rec.status !== "pending") return rec;
|
|
95
|
+
rec.detached = true;
|
|
96
|
+
rec.detachedAt = new Date().toISOString();
|
|
97
|
+
fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
98
|
+
return rec;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// One-shot token redemption for the async path. Fail-closed on every branch:
|
|
102
|
+
// only an approved, un-consumed, un-expired record whose stored command is
|
|
103
|
+
// byte-identical to what is about to run can be consumed — and only once.
|
|
104
|
+
function consume(id, commandLine) {
|
|
105
|
+
const rec = read(id);
|
|
106
|
+
if (!rec) return { ok: false, reason: "unknown or expired approval id" };
|
|
107
|
+
if (rec.status !== "approved") return { ok: false, reason: `approval is ${rec.status}, not approved` };
|
|
108
|
+
if (rec.consumedAt) return { ok: false, reason: "approval already consumed (single use)" };
|
|
109
|
+
if (Date.now() - Date.parse(rec.requestedAt) > MAX_AGE_MS) return { ok: false, reason: "approval expired (24h)" };
|
|
110
|
+
if (String(commandLine || "").slice(0, 1000) !== rec.command) {
|
|
111
|
+
return { ok: false, reason: "command does not match the approved payload" };
|
|
112
|
+
}
|
|
113
|
+
rec.consumedAt = new Date().toISOString();
|
|
114
|
+
fs.writeFileSync(fileOf(id), JSON.stringify(rec, null, 2), { mode: 0o600 });
|
|
115
|
+
return { ok: true, record: rec };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { APPROVALS_DIR, create, read, decide, waitForDecision, detach, consume };
|
package/core/loopback.js
CHANGED
|
@@ -477,7 +477,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
477
477
|
`<pre>${String(payload.command).slice(0, 800).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</pre>`,
|
|
478
478
|
``,
|
|
479
479
|
`Tool: ${payload.tool}${payload.verb ? ` · verb: ${payload.verb}` : ""} · tier: ${rec.tier}`,
|
|
480
|
-
`This is the literal command that will execute.
|
|
480
|
+
`This is the literal command that will execute. Decide within ~90s to run it inline; after that the agent moves on, but your Approve/Deny stays valid for 24h and wakes it back up.`,
|
|
481
481
|
].join("\n");
|
|
482
482
|
const keyboard = { inline_keyboard: [[
|
|
483
483
|
{ text: "✅ Approve", callback_data: `apr:${rec.id}:ok` },
|
package/core/pack-review.js
CHANGED
|
@@ -58,7 +58,34 @@ function formatAnnouncement(lines) {
|
|
|
58
58
|
return [header, ...lines].join("\n\n");
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// Full current content of the packs a turn actually worked with, rendered
|
|
62
|
+
// into the review prompt so section rewrites are merges (old content visible)
|
|
63
|
+
// instead of blind regenerations from the turn text — a blind State rewrite
|
|
64
|
+
// silently destroys durable facts the turn didn't restate.
|
|
65
|
+
const ACTIVE_PACK_MAX = 4;
|
|
66
|
+
const ACTIVE_SECTION_CHARS = 1500;
|
|
67
|
+
|
|
68
|
+
function renderActivePacks(activePacks) {
|
|
69
|
+
const dirs = (Array.isArray(activePacks) ? activePacks : []).slice(0, ACTIVE_PACK_MAX);
|
|
70
|
+
const blocks = [];
|
|
71
|
+
for (const dir of dirs) {
|
|
72
|
+
let p = null;
|
|
73
|
+
try { p = packs.readPack(dir); } catch (e) {}
|
|
74
|
+
if (!p) continue;
|
|
75
|
+
const sec = p.sections || {};
|
|
76
|
+
const journal = String(sec.Journal || "").split("\n").filter((l) => l.trim()).slice(-3).join("\n");
|
|
77
|
+
const parts = [`### ${p.dir}: ${p.name} — ${p.description || ""}`];
|
|
78
|
+
if (String(sec.Stance || "").trim()) parts.push(`Stance:\n${clip(sec.Stance.trim(), ACTIVE_SECTION_CHARS)}`);
|
|
79
|
+
if (String(sec.Procedure || "").trim()) parts.push(`Procedure:\n${clip(sec.Procedure.trim(), ACTIVE_SECTION_CHARS)}`);
|
|
80
|
+
if (String(sec.State || "").trim()) parts.push(`State (current):\n${clip(sec.State.trim(), ACTIVE_SECTION_CHARS)}`);
|
|
81
|
+
if (journal) parts.push(`Journal (recent):\n${journal}`);
|
|
82
|
+
blocks.push(parts.join("\n"));
|
|
83
|
+
}
|
|
84
|
+
return blocks.join("\n\n");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildReviewPrompt(userText, assistantText, activePacks) {
|
|
88
|
+
const activeBlock = renderActivePacks(activePacks);
|
|
62
89
|
const index = packs.listPacks().map((p) => {
|
|
63
90
|
const ab = p.kind === "ability" ? " ◆ability" : "";
|
|
64
91
|
const prov = p.kind === "ability" && p.learned_on
|
|
@@ -93,7 +120,11 @@ A LESSON is a single always-loaded rule (NOT topic-gated like packs/entities —
|
|
|
93
120
|
|
|
94
121
|
Existing packs:
|
|
95
122
|
${index}
|
|
123
|
+
${activeBlock ? `
|
|
124
|
+
Packs ACTIVE during this turn — their FULL CURRENT content. The turn's work almost certainly belongs to one of these:
|
|
96
125
|
|
|
126
|
+
${activeBlock}
|
|
127
|
+
` : ""}
|
|
97
128
|
Known entities:
|
|
98
129
|
${entityIndex}
|
|
99
130
|
|
|
@@ -112,6 +143,8 @@ ${clip(assistantText)}
|
|
|
112
143
|
|
|
113
144
|
Decide. Rules:
|
|
114
145
|
- Bias toward action: if the turn did real work on an identifiable topic (a named system, project, server, app, person, or domain), record it — at minimum a journal line. Most working turns deserve one. Reserve empty actions for small talk, pure status checks, and turns that contain nothing new.
|
|
146
|
+
- ATTRIBUTION: prefer updating one of the ACTIVE packs above. The turn text often does not name its project (pronouns, follow-ups, pasted URLs) — never attribute the work to a different pack from the index on thematic similarity alone when an active pack fits the work.
|
|
147
|
+
- State/Stance/Procedure rewrites are MERGES, never regenerations: start from the pack's CURRENT content shown in the ACTIVE block above, carry forward every fact that is still true, drop only what this turn contradicted or completed, then fold in what changed. Never drop a durable fact merely because this turn did not mention it. If a pack's current content is NOT shown above, do not rewrite its sections — record a journal line only (section rewrites there are discarded).
|
|
115
148
|
- UPDATE an existing pack when the turn touched that topic: append a journal line, and rewrite State if where-things-stand changed. Update Stance when the user expressed a lasting preference or rule; Procedure when a verified working method emerged.
|
|
116
149
|
- PROMOTE durable conclusions out of the Journal. A confirmed root-cause diagnosis, an established fact, or a settled design decision must go into Stance/Procedure/State (the parts always injected in full), NOT only a Journal line — Journal is truncated to the last few lines on injection, so a conclusion left only there will silently fall out of recall over time.
|
|
117
150
|
- CREATE a pack ONLY when the turn worked on a durable topic that NO existing pack covers. Before creating, scan the existing packs above for one already about this project/system/domain — if one exists, UPDATE it instead. A new pack must be a genuinely DISTINCT topic, never a narrower facet, version, release, sub-feature, phase, or component of a topic an existing pack already owns. In particular: a release or version of a project (e.g. a pack named after "<project> v2.6.39") is a Journal line + State update on that project's EXISTING pack — NEVER its own pack. Likewise sub-features of one system belong in that system's pack, not in siblings. When unsure whether something is a new topic or a facet of an existing one, treat it as a facet and UPDATE. Do not create packs for one-off trivia.
|
|
@@ -189,7 +222,7 @@ function findDuplicatePack({ dir, name, description, tags }) {
|
|
|
189
222
|
return null;
|
|
190
223
|
}
|
|
191
224
|
|
|
192
|
-
function applyAction(a) {
|
|
225
|
+
function applyAction(a, activeSet = null) {
|
|
193
226
|
if (!a || typeof a !== "object") return null;
|
|
194
227
|
const guard = guardCheck(a);
|
|
195
228
|
if (guard.flagged) {
|
|
@@ -198,11 +231,17 @@ function applyAction(a) {
|
|
|
198
231
|
if (a.action === "update" && a.pack) {
|
|
199
232
|
const existing = packs.readPack(a.pack);
|
|
200
233
|
if (!existing) return null;
|
|
234
|
+
// Structural enforcement of merge-not-regenerate: the reviewer only saw
|
|
235
|
+
// the full current sections of the turn's ACTIVE packs, so a section
|
|
236
|
+
// rewrite on any other pack is blind (generated from the turn text alone)
|
|
237
|
+
// and would destroy durable facts. Journal lines always land; section
|
|
238
|
+
// rewrites are dropped for packs outside the active set.
|
|
239
|
+
const blind = activeSet instanceof Set && !activeSet.has(a.pack);
|
|
201
240
|
const r = packs.updatePack(a.pack, {
|
|
202
241
|
journal: a.journal || "",
|
|
203
|
-
state: typeof a.state === "string" ? a.state : "",
|
|
204
|
-
stance: typeof a.stance === "string" ? a.stance : "",
|
|
205
|
-
procedure: typeof a.procedure === "string" ? a.procedure : "",
|
|
242
|
+
state: !blind && typeof a.state === "string" ? a.state : "",
|
|
243
|
+
stance: !blind && typeof a.stance === "string" ? a.stance : "",
|
|
244
|
+
procedure: !blind && typeof a.procedure === "string" ? a.procedure : "",
|
|
206
245
|
}, "reviewer");
|
|
207
246
|
// Cross-project reuse: only abilities carry applied_on, so ignore the field
|
|
208
247
|
// on context packs (keeps project trackers churn-free).
|
|
@@ -212,7 +251,15 @@ function applyAction(a) {
|
|
|
212
251
|
if (packs.recordApplied(a.pack, proj)) appliedTo = proj;
|
|
213
252
|
}
|
|
214
253
|
}
|
|
215
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
kind: "update", dir: a.pack, name: existing.name,
|
|
256
|
+
note: a.journal || (appliedTo ? `reused on ${appliedTo}` : "state updated"),
|
|
257
|
+
protectedStance: !!r.protectedStance,
|
|
258
|
+
protectedProcedure: !!r.protectedProcedure,
|
|
259
|
+
overwroteUserState: !!r.overwroteUserState,
|
|
260
|
+
blindSkipped: blind && [a.state, a.stance, a.procedure].some((s) => typeof s === "string" && s.trim()),
|
|
261
|
+
appliedTo,
|
|
262
|
+
};
|
|
216
263
|
}
|
|
217
264
|
if (a.action === "create" && (a.dir || a.name)) {
|
|
218
265
|
const dir = packs.slugify(a.dir || a.name);
|
|
@@ -223,7 +270,8 @@ function applyAction(a) {
|
|
|
223
270
|
: [];
|
|
224
271
|
const projects = applied_on.length ? applied_on : (learned_on ? [learned_on] : []);
|
|
225
272
|
if (packs.readPack(dir)) {
|
|
226
|
-
|
|
273
|
+
const blind = activeSet instanceof Set && !activeSet.has(dir);
|
|
274
|
+
packs.updatePack(dir, { journal: a.journal || "", state: blind ? "" : (a.state || "") }, "reviewer");
|
|
227
275
|
if (kind === "ability") for (const proj of projects) packs.recordApplied(dir, proj);
|
|
228
276
|
return { kind: "update", dir, name: a.name || dir, note: a.journal || "state updated" };
|
|
229
277
|
}
|
|
@@ -232,7 +280,8 @@ function applyAction(a) {
|
|
|
232
280
|
// they are activity-named and cross-project by design).
|
|
233
281
|
const dupeDir = kind === "context" ? findDuplicatePack({ dir, name: a.name, description: a.description, tags: a.tags }) : null;
|
|
234
282
|
if (dupeDir && packs.readPack(dupeDir)) {
|
|
235
|
-
|
|
283
|
+
const blind = activeSet instanceof Set && !activeSet.has(dupeDir);
|
|
284
|
+
packs.updatePack(dupeDir, { journal: a.journal || "", state: blind ? "" : (a.state || "") }, "reviewer");
|
|
236
285
|
const ex = packs.readPack(dupeDir);
|
|
237
286
|
return { kind: "update", dir: dupeDir, name: (ex && ex.name) || dupeDir, note: a.journal || "folded a new-pack proposal into the existing pack" };
|
|
238
287
|
}
|
|
@@ -319,7 +368,7 @@ function shouldSkipReview({ userText, assistantText, signals = {} }) {
|
|
|
319
368
|
|
|
320
369
|
// Fire-and-forget. `announce` is an async (text) => void bound to the
|
|
321
370
|
// originating channel; failures are logged, never thrown into the turn.
|
|
322
|
-
function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
|
|
371
|
+
function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks }) {
|
|
323
372
|
if (!enabled()) return;
|
|
324
373
|
const skip = shouldSkipReview({ userText, assistantText, signals });
|
|
325
374
|
if (skip) {
|
|
@@ -329,7 +378,8 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
|
|
|
329
378
|
return;
|
|
330
379
|
}
|
|
331
380
|
|
|
332
|
-
const
|
|
381
|
+
const activeSet = Array.isArray(activePacks) && activePacks.length ? new Set(activePacks) : null;
|
|
382
|
+
const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")), activePacks);
|
|
333
383
|
|
|
334
384
|
spawnSubagent(prompt, {
|
|
335
385
|
model: REVIEW_MODEL,
|
|
@@ -343,7 +393,7 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
|
|
|
343
393
|
const usedIds = []; // nodes this turn's work demonstrably touched
|
|
344
394
|
for (const a of decision.actions) {
|
|
345
395
|
try {
|
|
346
|
-
const r = applyAction(a);
|
|
396
|
+
const r = applyAction(a, activeSet);
|
|
347
397
|
if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
|
|
348
398
|
if (r && r.kind === "skipped") {
|
|
349
399
|
lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
|
|
@@ -352,7 +402,11 @@ function reviewTurn({ userText, assistantText, channelId, announce, signals }) {
|
|
|
352
402
|
} else if (r && r.kind === "create") {
|
|
353
403
|
lines.push(`📦 New pack: ${r.name}${r.parent ? ` (filed under ${r.parent})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
|
|
354
404
|
} else if (r) {
|
|
355
|
-
|
|
405
|
+
const marks = [];
|
|
406
|
+
if (r.appliedTo) marks.push(` 🧩 (now also applies to ${r.appliedTo})`);
|
|
407
|
+
if (r.overwroteUserState) marks.push(" ⚠️ replaced a user-written State — check it kept your facts");
|
|
408
|
+
if (r.blindSkipped) marks.push(" (section rewrite dropped: pack wasn't active this turn — journal only)");
|
|
409
|
+
lines.push(`✏️ ${r.name} — ${clipWords(r.note, 180)}${marks.join("")}\n↳ open-claudia pack show ${r.dir}`);
|
|
356
410
|
}
|
|
357
411
|
} catch (e) {
|
|
358
412
|
console.warn(`[pack-review] apply failed: ${e.message}`);
|
package/core/packs.js
CHANGED
|
@@ -393,8 +393,26 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
|
|
|
393
393
|
applied.push("Stance");
|
|
394
394
|
}
|
|
395
395
|
}
|
|
396
|
-
|
|
397
|
-
if (typeof
|
|
396
|
+
let protectedProcedure = false;
|
|
397
|
+
if (typeof procedure === "string" && procedure.trim()) {
|
|
398
|
+
// Same intent-wins rule as Stance: a user-authored Procedure is a verified
|
|
399
|
+
// how-to — an automated writer never silently replaces it.
|
|
400
|
+
if (origin !== "user" && provenanceOf(dir, "Procedure") === "user") {
|
|
401
|
+
protectedProcedure = true;
|
|
402
|
+
} else {
|
|
403
|
+
pack.sections.Procedure = procedure.trim();
|
|
404
|
+
applied.push("Procedure");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// State stays rewritable by design (it tracks "now"), but replacing a
|
|
408
|
+
// user-authored State is flagged so the caller can announce the overwrite
|
|
409
|
+
// instead of it happening silently.
|
|
410
|
+
let overwroteUserState = false;
|
|
411
|
+
if (typeof state === "string" && state.trim()) {
|
|
412
|
+
if (origin !== "user" && provenanceOf(dir, "State") === "user") overwroteUserState = true;
|
|
413
|
+
pack.sections.State = state.trim();
|
|
414
|
+
applied.push("State");
|
|
415
|
+
}
|
|
398
416
|
if (typeof journal === "string" && journal.trim()) {
|
|
399
417
|
const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
|
|
400
418
|
const line = journal.trim().replace(/\n+/g, " ");
|
|
@@ -409,6 +427,8 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
|
|
|
409
427
|
writePack(pack);
|
|
410
428
|
if (applied.length) setProvenance(dir, applied, origin);
|
|
411
429
|
pack.protectedStance = protectedStance;
|
|
430
|
+
pack.protectedProcedure = protectedProcedure;
|
|
431
|
+
pack.overwroteUserState = overwroteUserState;
|
|
412
432
|
return pack;
|
|
413
433
|
}
|
|
414
434
|
|
package/core/runner.js
CHANGED
|
@@ -1041,8 +1041,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1041
1041
|
// reflects what was read, not what was pushed. (consumeLastInjected is
|
|
1042
1042
|
// drained here to keep the per-turn buffer from leaking into the next turn.)
|
|
1043
1043
|
let turnRecallTier = "";
|
|
1044
|
+
// Pack dirs surfaced (recall-matched) for this turn — joined with
|
|
1045
|
+
// openedThisTurn at end-of-turn to tell the post-turn reviewer which packs
|
|
1046
|
+
// this turn's work belongs to (attribution + safe State rewrites).
|
|
1047
|
+
let surfacedPackDirs = [];
|
|
1044
1048
|
try {
|
|
1045
1049
|
const injected = require("./system-prompt").consumeLastInjected();
|
|
1050
|
+
if (injected && Array.isArray(injected.packDirs)) surfacedPackDirs = injected.packDirs;
|
|
1046
1051
|
if (injected && injected.recall) {
|
|
1047
1052
|
const r = injected.recall;
|
|
1048
1053
|
turnRecallTier = String(r.tier || "");
|
|
@@ -1537,6 +1542,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1537
1542
|
userText: prompt,
|
|
1538
1543
|
assistantText,
|
|
1539
1544
|
channelId,
|
|
1545
|
+
// Packs this turn demonstrably worked with: surfaced by recall for the
|
|
1546
|
+
// incoming message, or explicitly opened by the agent. The reviewer
|
|
1547
|
+
// sees their full current sections and must attribute updates here —
|
|
1548
|
+
// prevents cross-project misattribution and blind State rewrites.
|
|
1549
|
+
activePacks: [...new Set([
|
|
1550
|
+
...surfacedPackDirs,
|
|
1551
|
+
...[...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5)),
|
|
1552
|
+
])],
|
|
1540
1553
|
signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
|
|
1541
1554
|
announce: (text) => chatContext.run(store, () => send(text)),
|
|
1542
1555
|
});
|
package/core/system-prompt.js
CHANGED
|
@@ -110,7 +110,7 @@ Operational work — hitting an API, mutating a live system, driving an interfac
|
|
|
110
110
|
Raw shell one-liners and heredocs are for probing and diagnosis only — never the operational action itself. If a tool is broken, fix it or journal the defect (\`tool note <name> --issue "..."\`); do not route around it with a script.
|
|
111
111
|
|
|
112
112
|
- Risk gate: read-only tools run freely; write-tier needs \`--yes\`; destructive needs \`--yes-destructive\`. The flag is your mechanical acknowledgment AFTER the user approves the action — it never replaces asking first.
|
|
113
|
-
- Per-verb manifest: a tool that declares \`verb:\` header lines is gated verb-by-verb — each verb runs at its own declared tier, and an undeclared verb is refused outright (declare it in the header before using it). An unflagged destructive run inside a bot task escalates to chat with the EXACT command and Approve/Deny buttons
|
|
113
|
+
- Per-verb manifest: a tool that declares \`verb:\` header lines is gated verb-by-verb — each verb runs at its own declared tier, and an undeclared verb is refused outright (declare it in the header before using it). An unflagged destructive run inside a bot task escalates to chat with the EXACT command and Approve/Deny buttons and waits ~90s: an inline Approve runs it, Deny refuses. If the window lapses (exit code 5, "pending"), the approval detaches but stays valid 24h — END the turn telling the user the run awaits their button press; do NOT retry or re-request it. When they later press a button you're woken with the outcome: if approved, run the EXACT command with \`--approval <id>\` appended (one-shot token, top-level runs only, bound byte-for-byte to the approved command — never alter or reuse it); if denied, acknowledge and move on.
|
|
114
114
|
- Tools carry their own memory: TOOL.md (Interface · Known issues · State · Journal) is the manual, state.json records every run (per-verb health), and every change is a git commit. Keep TOOL.md truthful as you work; update the tool in place when behaviour changes.
|
|
115
115
|
- Preauth, least-privilege: keyring creds are injected ONLY into \`tool run\` subprocesses, and only the keys a tool declares via \`--requires\` (\`open-claudia keyring list\` shows names) — your own shell and sub-agents hold NO creds. Reference creds as \`$inet_central_user\` etc. inside tool sources and declare every one; never write a secret into a tool file (saves are refused).
|
|
116
116
|
- Deny-gate: raw shell commands that act on known production surfaces (platform APIs, prod hosts, in-cluster mongo, GitLab host shell) are blocked mid-flight with a redirect to the covering tool — that block is the system working, not an obstacle to route around. Genuine one-off no tool covers yet? \`open-claudia keyring exec --reason "<why>" -- <cmd>\` runs with full creds and logs the bypass for audit; scaffold the tool if you'd ever run it twice.
|
|
@@ -442,10 +442,10 @@ function tryUseRecallBudget(budget, text) {
|
|
|
442
442
|
// What the last promptWithDynamicContext call freshly injected (not the
|
|
443
443
|
// deduped repeats) — consumed by the runner to announce recalls in chat,
|
|
444
444
|
// mirroring the write-side announcements.
|
|
445
|
-
let lastInjected = { packs: [], entities: [], recall: null };
|
|
445
|
+
let lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
|
|
446
446
|
function consumeLastInjected() {
|
|
447
447
|
const out = lastInjected;
|
|
448
|
-
lastInjected = { packs: [], entities: [], recall: null };
|
|
448
|
+
lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
|
|
449
449
|
return out;
|
|
450
450
|
}
|
|
451
451
|
|
|
@@ -743,7 +743,7 @@ function voiceReplyGuidance() {
|
|
|
743
743
|
}
|
|
744
744
|
|
|
745
745
|
async function promptWithDynamicContext(prompt, opts = {}) {
|
|
746
|
-
lastInjected = { packs: [], entities: [], recall: null };
|
|
746
|
+
lastInjected = { packs: [], packDirs: [], entities: [], recall: null };
|
|
747
747
|
try {
|
|
748
748
|
const { userText, contextText } = recallMatchParts(prompt);
|
|
749
749
|
let historyText = "";
|
|
@@ -770,6 +770,10 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
770
770
|
const toolBlock = result.toolBlock || "";
|
|
771
771
|
const episodeBlock = result.episodeBlock || "";
|
|
772
772
|
const why = result.why || {};
|
|
773
|
+
// Pack DIRS surfaced for this turn (matched, whether or not the body was
|
|
774
|
+
// freshly injected or deduped) — consumed by the post-turn reviewer so it
|
|
775
|
+
// knows which packs the turn's work belongs to and can see their content.
|
|
776
|
+
lastInjected.packDirs = (result.packMatches || []).map((m) => m.dir).filter(Boolean);
|
|
773
777
|
lastInjected.recall = {
|
|
774
778
|
engine: engine.name || recall.activeEngineName(settings),
|
|
775
779
|
gated: !!result.gated,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"test-tool-graph.js",
|
|
50
50
|
"test-tool-guard.js",
|
|
51
51
|
"test-tooling-mode.js",
|
|
52
|
-
"test-tool-manifest.js"
|
|
52
|
+
"test-tool-manifest.js",
|
|
53
|
+
"test-approval-async.js"
|
|
53
54
|
],
|
|
54
55
|
"keywords": [
|
|
55
56
|
"claude",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Hybrid async approvals: when the CLI's fast-path wait lapses it detaches
|
|
2
|
+
// and exits, and a later button press is redeemed as a ONE-SHOT token bound
|
|
3
|
+
// to the exact command string. Every branch here is fail-closed — only an
|
|
4
|
+
// approved, un-consumed, un-expired, byte-identical command runs, and only
|
|
5
|
+
// once.
|
|
6
|
+
const assert = require("assert");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "approval-async-test-"));
|
|
12
|
+
process.env.APPROVALS_DIR = path.join(tmp, "approvals");
|
|
13
|
+
|
|
14
|
+
const approvals = require("./core/approvals");
|
|
15
|
+
|
|
16
|
+
(async () => {
|
|
17
|
+
const CMD = "open-claudia tool run fleet purge n1";
|
|
18
|
+
|
|
19
|
+
// ── detach: pending record gains the flag; decided records are untouched ──
|
|
20
|
+
const rec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: CMD, channelId: "c1", adapter: "tg" });
|
|
21
|
+
const det = approvals.detach(rec.id);
|
|
22
|
+
assert.strictEqual(det.detached, true, "pending record detaches");
|
|
23
|
+
assert.ok(det.detachedAt, "detach is timestamped");
|
|
24
|
+
assert.strictEqual(approvals.read(rec.id).status, "pending", "detach does not decide");
|
|
25
|
+
|
|
26
|
+
const decided = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: CMD, channelId: "c1", adapter: "tg" });
|
|
27
|
+
approvals.decide(decided.id, "denied", "c1");
|
|
28
|
+
const det2 = approvals.detach(decided.id);
|
|
29
|
+
assert.notStrictEqual(det2.detached, true, "decided record cannot be detached");
|
|
30
|
+
|
|
31
|
+
// ── consume: only approved records redeem ──
|
|
32
|
+
assert.strictEqual(approvals.consume(rec.id, CMD).ok, false, "pending record cannot be consumed");
|
|
33
|
+
assert.ok(/pending/.test(approvals.consume(rec.id, CMD).reason));
|
|
34
|
+
assert.strictEqual(approvals.consume(decided.id, CMD).ok, false, "denied record cannot be consumed");
|
|
35
|
+
|
|
36
|
+
// ── late decide on a detached record still works, then redeems exactly once ──
|
|
37
|
+
approvals.decide(rec.id, "approved", "c1");
|
|
38
|
+
const bad = approvals.consume(rec.id, CMD + " --extra");
|
|
39
|
+
assert.strictEqual(bad.ok, false, "command mismatch refused");
|
|
40
|
+
assert.ok(/does not match/.test(bad.reason));
|
|
41
|
+
assert.strictEqual(approvals.read(rec.id).consumedAt, undefined, "failed consume does not burn the token");
|
|
42
|
+
|
|
43
|
+
const good = approvals.consume(rec.id, CMD);
|
|
44
|
+
assert.strictEqual(good.ok, true, "exact command redeems");
|
|
45
|
+
assert.ok(good.record.consumedAt, "redemption is stamped");
|
|
46
|
+
|
|
47
|
+
const replay = approvals.consume(rec.id, CMD);
|
|
48
|
+
assert.strictEqual(replay.ok, false, "second consume refused (single use)");
|
|
49
|
+
assert.ok(/already consumed/.test(replay.reason));
|
|
50
|
+
|
|
51
|
+
// ── unknown id fails closed ──
|
|
52
|
+
assert.strictEqual(approvals.consume("apr_nope", CMD).ok, false, "unknown id refused");
|
|
53
|
+
|
|
54
|
+
// ── expiry: an approved token older than 24h cannot redeem ──
|
|
55
|
+
const old = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: CMD, channelId: "c1", adapter: "tg" });
|
|
56
|
+
approvals.decide(old.id, "approved", "c1");
|
|
57
|
+
const f = path.join(approvals.APPROVALS_DIR, `${old.id}.json`);
|
|
58
|
+
const j = JSON.parse(fs.readFileSync(f, "utf-8"));
|
|
59
|
+
j.requestedAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
|
|
60
|
+
fs.writeFileSync(f, JSON.stringify(j));
|
|
61
|
+
const expired = approvals.consume(old.id, CMD);
|
|
62
|
+
assert.strictEqual(expired.ok, false, "expired token refused");
|
|
63
|
+
assert.ok(/expired/.test(expired.reason));
|
|
64
|
+
|
|
65
|
+
// ── long commands: stored payload is capped at 1000 chars and matching respects the cap ──
|
|
66
|
+
const longCmd = "open-claudia tool run fleet purge " + "x".repeat(1200);
|
|
67
|
+
const lrec = approvals.create({ tool: "fleet", verb: "purge", tier: "destructive", command: longCmd, channelId: "c1", adapter: "tg" });
|
|
68
|
+
approvals.decide(lrec.id, "approved", "c1");
|
|
69
|
+
assert.strictEqual(approvals.consume(lrec.id, longCmd).ok, true, "capped command still matches its own approval");
|
|
70
|
+
|
|
71
|
+
// ── waitForDecision default fast-path window is 90s (not the old 5 min) ──
|
|
72
|
+
const src = fs.readFileSync(path.join(__dirname, "core", "approvals.js"), "utf-8");
|
|
73
|
+
assert.ok(/timeoutMs = 90000/.test(src), "fast-path default is 90s");
|
|
74
|
+
|
|
75
|
+
console.log("approval async OK");
|
|
76
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|