@inetafrica/open-claudia 2.6.37 β†’ 2.6.39

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/core/runner.js CHANGED
@@ -726,6 +726,16 @@ async function compactActiveSession(cwd, opts = {}) {
726
726
 
727
727
  const { fullBrief, condensed } = splitCompactionBrief(summary);
728
728
  const briefPath = archiveCompactionBrief(fullBrief, state);
729
+ // Append the condensed digest to today's per-day seed file so the nightly
730
+ // dream can review what was worked on across the day. Best-effort.
731
+ try {
732
+ require("./day-seeds").appendDaySeed({
733
+ summary: condensed || fullBrief,
734
+ project: state.currentSession ? `${state.currentSession.name} (${state.currentSession.dir})` : null,
735
+ channel: currentChannelId(),
736
+ briefPath,
737
+ });
738
+ } catch (e) {}
729
739
  // Only seed with the condensed version when the full text actually made it to disk.
730
740
  const seedSummary = (condensed && briefPath) ? condensed : (condensed ? `${fullBrief}\n\n${condensed}` : fullBrief);
731
741
  const repoFacts = collectRepoStateFacts(cwd);
@@ -784,7 +794,21 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
784
794
  resumeSessionId: opts.resumeSessionId || null,
785
795
  }, state, getActiveSessionId);
786
796
 
787
- if (adapter && channelId) adapter.typing(channelId).catch(() => {});
797
+ // Typing heartbeat: keep the indicator alive continuously from message
798
+ // receipt through the recall/discoverer phase and into streaming, not just
799
+ // for the ~5s a single typing action lasts. Stored on state so /stop can
800
+ // clear it instantly. Cleared on close/error/cancel.
801
+ const startTyping = () => {
802
+ if (!adapter || !channelId) return;
803
+ adapter.typing(channelId).catch(() => {});
804
+ if (!state.typingHeartbeat) state.typingHeartbeat = setInterval(() => adapter.typing(channelId).catch(() => {}), 4000);
805
+ };
806
+ const stopTyping = () => { if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; } };
807
+ // Pre-spawn window (recall/compaction/auth): /stop has no process to kill, so
808
+ // it sets state.cancelRequested and we bail at the checkpoint before spawning.
809
+ state.preparingRun = true;
810
+ state.cancelRequested = false;
811
+ startTyping();
788
812
  state.statusMessageId = null;
789
813
  state.streamBuffer = "";
790
814
  let assistantText = "";
@@ -873,7 +897,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
873
897
  } catch (e) { /* announcements are best-effort */ }
874
898
  };
875
899
 
876
- const args = await buildClaudeArgs(prompt, opts);
900
+ let args;
901
+ try {
902
+ args = await buildClaudeArgs(prompt, opts);
903
+ } catch (e) {
904
+ state.preparingRun = false;
905
+ stopTyping();
906
+ throw e;
907
+ }
877
908
  // Recall announcements are now fired at READ time, not injection time:
878
909
  // matched packs/entities enter context only as small headlines (see
879
910
  // system-prompt.js recallHeadline). The "πŸ“– Recalled my notes on …" line is
@@ -892,6 +923,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
892
923
  else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate β€” trivial turn.`).catch(() => {});
893
924
  }
894
925
  } catch (e) { /* best-effort */ }
926
+ // /stop landed during the pre-spawn window (recall/compaction): bail before
927
+ // spawning. The /stop handler already acknowledged in chat, so stay silent.
928
+ if (state.cancelRequested) {
929
+ state.cancelRequested = false;
930
+ state.preparingRun = false;
931
+ stopTyping();
932
+ return;
933
+ }
895
934
  const binaryPath = getActiveBinary();
896
935
  const proc = spawn(binaryPath, args, {
897
936
  cwd,
@@ -901,6 +940,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
901
940
  });
902
941
 
903
942
  state.runningProcess = proc;
943
+ state.preparingRun = false;
904
944
  const startTime = Date.now();
905
945
  let longRunningNotified = false;
906
946
 
@@ -1084,6 +1124,8 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1084
1124
 
1085
1125
  proc.on("close", (code) => chatContext.run(store, async () => {
1086
1126
  state.runningProcess = null;
1127
+ state.preparingRun = false;
1128
+ stopTyping();
1087
1129
  clearTimeout(state.streamInterval); state.streamInterval = null;
1088
1130
  clearTimeout(processTimeout);
1089
1131
 
@@ -1145,10 +1187,16 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1145
1187
  if (settings.budget) settings.budget = null;
1146
1188
  state.statusMessageId = null;
1147
1189
 
1190
+ // Outcome signal: only learn from turns that actually completed. A turn that
1191
+ // errored out is not evidence that a recalled pattern "helped" β€” reinforcing
1192
+ // or recording reuse on it would teach the wrong lesson (gap: reinforcement
1193
+ // must track "helped", not merely "opened").
1194
+ const turnSucceeded = (code === 0 || code === null);
1195
+
1148
1196
  // Hebbian co-use: nodes the agent actually opened together this turn get
1149
1197
  // their `related` edges reinforced, so future spreading activation pulls
1150
1198
  // the cluster together. Reinforce on co-USE (πŸ“–), never co-recall.
1151
- if (openedThisTurn.size > 0) {
1199
+ if (turnSucceeded && openedThisTurn.size > 0) {
1152
1200
  try {
1153
1201
  const recallGraph = require("./recall/graph");
1154
1202
  if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
@@ -1156,6 +1204,22 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1156
1204
  } catch (e) { /* best-effort */ }
1157
1205
  }
1158
1206
 
1207
+ // Close the learning loop: when an ABILITY pack is opened in the same turn
1208
+ // as a project (context) pack, the ability was demonstrably applied while
1209
+ // working on that project. recordCoUse grows the ability's applied_on, which
1210
+ // forms the project→ability governed-by edge on the next structural sync —
1211
+ // so reuse transfers AUTOMATICALLY from actual use, without waiting on the
1212
+ // reviewer to infer it. This runs before the (async) reviewer, so it wins
1213
+ // the race and the reviewer sees applied_on already set (no double-announce).
1214
+ if (turnSucceeded && openedThisTurn.size > 1) {
1215
+ try {
1216
+ for (const t of packsLib.recordCoUse([...openedThisTurn])) {
1217
+ notifySkill(`applied:${t.ability}:${t.project}`,
1218
+ `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} β€” it transfers there now too.`);
1219
+ }
1220
+ } catch (e) { /* best-effort */ }
1221
+ }
1222
+
1159
1223
  // Post-turn pack review: fire-and-forget on a cheap model; never
1160
1224
  // blocks queue drain or the next turn.
1161
1225
  if ((code === 0 || code === null) && assistantText.trim()) {
@@ -1208,6 +1272,8 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1208
1272
 
1209
1273
  proc.on("error", (err) => chatContext.run(store, async () => {
1210
1274
  state.runningProcess = null;
1275
+ state.preparingRun = false;
1276
+ stopTyping();
1211
1277
  clearTimeout(state.streamInterval);
1212
1278
  clearTimeout(processTimeout);
1213
1279
  await send(`Error: ${err.message}`);
package/core/state.js CHANGED
@@ -59,6 +59,9 @@ function createUserState(userId) {
59
59
  channelId: currentChannelId(),
60
60
  currentSession: saved.currentSession || null,
61
61
  runningProcess: null,
62
+ preparingRun: false,
63
+ cancelRequested: false,
64
+ typingHeartbeat: null,
62
65
  statusMessageId: null,
63
66
  streamBuffer: "",
64
67
  streamInterval: null,
package/core/subagent.js CHANGED
@@ -55,14 +55,30 @@ async function spawnSubagent(prompt, opts = {}) {
55
55
  }
56
56
 
57
57
  return new Promise((resolve, reject) => {
58
- const args = [
59
- "-p",
58
+ const args = ["-p"];
59
+ // Tool restriction. --allowedTools/--disallowedTools take variadic
60
+ // <tools...>, which would greedily swallow the trailing prompt arg β€” so we
61
+ // pass a single comma-joined token AND place these flags first, where the
62
+ // next arg (--output-format) is itself a flag, which stops the variadic.
63
+ // With --dangerously-skip-permissions on, a whitelist is the structural way
64
+ // to make a sub-agent genuinely read-only (e.g. the dream's introspection).
65
+ if (opts.allowedTools) args.push("--allowedTools", [].concat(opts.allowedTools).join(","));
66
+ if (opts.disallowedTools) args.push("--disallowedTools", [].concat(opts.disallowedTools).join(","));
67
+ args.push(
60
68
  "--output-format", opts.json ? "json" : "text",
61
69
  "--verbose",
62
70
  "--append-system-prompt", opts.systemPrompt || buildSubagentSystemPrompt(role),
63
- "--dangerously-skip-permissions",
64
- ];
71
+ );
72
+ // Permissions. A genuinely read-only sub-agent (e.g. the dream's
73
+ // introspection) must use plan mode β€” verified to be the ONLY mechanism
74
+ // that blocks writes: --dangerously-skip-permissions OVERRIDES both
75
+ // --allowedTools and --disallowedTools, so a whitelist alone does NOT
76
+ // restrict. Default remains skip-permissions for research sub-agents that
77
+ // legitimately need Bash/Write.
78
+ if (opts.permissionMode) args.push("--permission-mode", opts.permissionMode);
79
+ else args.push("--dangerously-skip-permissions");
65
80
  if (opts.model) args.push("--model", opts.model);
81
+ if (opts.effort) args.push("--effort", opts.effort);
66
82
  args.push(prompt);
67
83
  const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
68
84
  const proc = spawn(CLAUDE_PATH, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
@@ -30,6 +30,42 @@ function buildPersonaBlock() {
30
30
  }
31
31
  }
32
32
 
33
+ // Always-injected lessons: cross-cutting rules learned from past mistakes.
34
+ // Unlike packs/entities these are NOT topic-gated β€” they load every turn
35
+ // because the whole point is they apply when the topic ISN'T matched. They
36
+ // are binding defaults that override generic assumptions; the (src: pack)
37
+ // pointer is where the full context lives.
38
+ function buildLessonsBlock() {
39
+ try {
40
+ const { loadLessonsBlock, LESSONS_FILE } = require("./lessons");
41
+ const block = loadLessonsBlock();
42
+ if (!block) return "";
43
+ return `\n## Lessons learned\nHard-won rules distilled from past mistakes β€” things you got wrong before and were corrected on. These are always loaded (unlike topic-matched packs/notes) precisely because they apply when the topic ISN'T matched. Treat them as binding defaults that override generic assumptions; before acting on one, verify against its "(src: <pack>)" pointer, which holds the full context. Edit via /lessons or ${LESSONS_FILE}.\n\n${block}\n`;
44
+ } catch (e) {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ // Always-on skill index (Hermes Tier-1): names + descriptions of packs you've
50
+ // flagged as reusable how-tos, so the agent knows each skill exists every turn
51
+ // even when the topic isn't matched. The full Procedure is NOT injected here β€”
52
+ // it loads on demand via `open-claudia pack show <dir>` (Tier-3 progressive
53
+ // disclosure). Only skill-flagged packs appear; the 80+ project-tracker packs
54
+ // stay topic-gated so this stays a short, high-signal list.
55
+ function buildSkillIndexBlock() {
56
+ try {
57
+ const packs = require("./packs").listSkillPacks();
58
+ if (!packs.length) return "";
59
+ const lines = packs
60
+ .slice(0, 30)
61
+ .map((p) => `- ${p.name} (\`${p.dir}\`) β€” ${p.description || "(no description)"}`)
62
+ .join("\n");
63
+ return `\n### Skills you've learned (always available)\nVerified how-tos you've been taught and can re-run. This is the index only β€” before doing one, load its full steps with \`open-claudia pack show <dir>\` and follow them rather than improvising (the Procedure encodes prerequisites and pitfalls you hit before):\n\n${lines}\n`;
64
+ } catch (e) {
65
+ return "";
66
+ }
67
+ }
68
+
33
69
  function buildSystemPrompt() {
34
70
  const state = currentState();
35
71
  const soul = loadSoul();
@@ -112,6 +148,7 @@ Keep replies clean and mobile-readable. Use short paragraphs and bullets. Avoid
112
148
  return `
113
149
  ${soul}
114
150
  ${buildPersonaBlock()}
151
+ ${buildLessonsBlock()}
115
152
  ## Runtime Context
116
153
  - Interface: ${channelLabel} chat through Open Claudia.
117
154
  - Active project path: ${state.currentSession ? state.currentSession.dir : "none"}
@@ -122,11 +159,13 @@ ${buildPersonaBlock()}
122
159
  Open Claudia learned skills are stored as context packs under ${path.join(CONFIG_DIR, "packs")}. Older \`~/.claude/skills/<name>/SKILL.md\` skills may have been migrated into packs; their reusable instructions live in the pack's Procedure section.
123
160
 
124
161
  If the user asks for a skill by name, do not rely only on the backend harness's native "Available skills" list. First use any Active context pack injected into the current request as the requested Open Claudia skill. If no matching pack was injected, inspect with \`open-claudia pack list\` / \`open-claudia pack show <dir>\` and legacy \`/skills\` paths before saying the skill does not exist.
162
+ ${buildSkillIndexBlock()}
125
163
 
126
164
  ## Stable Local Paths
127
165
  - Bot code: ${path.join(BOT_DIR, "bot.js")}
128
166
  - Soul file (identity + hard rules): ${SOUL_FILE}
129
167
  - Persona file (voice, evolved by dream): ${require("./persona").PERSONA_FILE}
168
+ - Lessons file (always-loaded learned rules; /lessons): ${require("./lessons").LESSONS_FILE}
130
169
  - Cron config: ${CRONS_FILE}
131
170
  - Vault file: ${VAULT_FILE}
132
171
  - Bot environment: ${path.join(BOT_DIR, ".env")} (sensitive; never expose values)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.37",
3
+ "version": "2.6.39",
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-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"
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-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"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -33,7 +33,15 @@
33
33
  "test-usage-accounting.js",
34
34
  "test-recall-engine.js",
35
35
  "test-recall-graph.js",
36
- "test-recall-discoverer.js"
36
+ "test-recall-discoverer.js",
37
+ "test-project-transcripts-smoke.js",
38
+ "test-abilities.js",
39
+ "test-ability-extraction.js",
40
+ "test-ability-couse.js",
41
+ "test-ability-transfer.js",
42
+ "test-ability-tiers.js",
43
+ "test-ability-merge-guard.js",
44
+ "test-learning-e2e.js"
37
45
  ],
38
46
  "keywords": [
39
47
  "claude",
@@ -0,0 +1,53 @@
1
+ // Abilities are first-class packs (kind:"ability") with reuse provenance
2
+ // (learned_on / applied_on). Verify the frontmatter round-trips, context packs
3
+ // stay churn-free, and the helpers behave.
4
+ const assert = require("assert");
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "abilities-"));
10
+ process.env.PACKS_DIR = path.join(tmp, "packs");
11
+
12
+ const packs = require("./core/packs");
13
+
14
+ // An ability with reuse provenance.
15
+ packs.createPack({
16
+ dir: "mobile-app-deploy", name: "Mobile App Deploy",
17
+ description: "Ship a mobile app: versionCode bump, APK, in-app updater",
18
+ stance: "Reusable how-to.", procedure: "1. bump 2. build 3. update",
19
+ kind: "ability", learned_on: "chat-mobile", applied_on: ["chat-mobile"],
20
+ });
21
+ let p = packs.readPack("mobile-app-deploy");
22
+ assert.strictEqual(p.kind, "ability", "kind persists");
23
+ assert.strictEqual(p.learned_on, "chat-mobile", "learned_on persists");
24
+ assert.deepStrictEqual(p.applied_on, ["chat-mobile"], "applied_on persists");
25
+
26
+ // A default pack is context and emits NO kind line (no churn on 121 existing packs).
27
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices" });
28
+ assert.strictEqual(packs.readPack("billing").kind, "context", "default kind is context");
29
+ const raw = fs.readFileSync(path.join(process.env.PACKS_DIR, "billing", "PACK.md"), "utf-8");
30
+ assert.ok(!/^kind:/m.test(raw), "context packs emit no kind line");
31
+ assert.ok(!/^learned_on:/m.test(raw), "context packs emit no learned_on line");
32
+
33
+ // listAbilities returns only abilities.
34
+ assert.deepStrictEqual(packs.listAbilities().map((a) => a.dir), ["mobile-app-deploy"], "listAbilities filters to abilities");
35
+
36
+ // recordApplied appends + dedupes, sets learned_on if unset.
37
+ packs.recordApplied("mobile-app-deploy", "spaces");
38
+ packs.recordApplied("mobile-app-deploy", "spaces");
39
+ p = packs.readPack("mobile-app-deploy");
40
+ assert.deepStrictEqual(p.applied_on, ["chat-mobile", "spaces"], "applied_on appends + dedupes");
41
+
42
+ // setSkill promotes to always-on AND implies ability (promoted things are abilities).
43
+ packs.createPack({ dir: "ci-verify", name: "CI Verify", description: "verify ci" });
44
+ packs.setSkill("ci-verify", true);
45
+ const sk = packs.readPack("ci-verify");
46
+ assert.strictEqual(sk.skill, true, "setSkill sets skill flag");
47
+ assert.strictEqual(sk.kind, "ability", "setSkill implies kind:ability");
48
+
49
+ // setKind can demote/relabel.
50
+ packs.setKind("ci-verify", "context");
51
+ assert.strictEqual(packs.readPack("ci-verify").kind, "context", "setKind relabels");
52
+
53
+ console.log("abilities OK β€” kind/learned_on/applied_on round-trip; helpers behave; context packs churn-free");
@@ -0,0 +1,68 @@
1
+ // PROOF (closed loop / automatic transfer): when the agent actually OPENS an
2
+ // ability pack alongside a project pack in one turn (the πŸ“– co-use signal),
3
+ // recordCoUse records the application, the governed-by edge forms on the next
4
+ // sync, and the ability becomes graph-discoverable from that project β€” WITHOUT
5
+ // the reviewer inferring anything. This is the automatic feedback loop.
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(), "ability-couse-"));
12
+ process.env.PACKS_DIR = path.join(tmp, "packs");
13
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
14
+
15
+ const packs = require("./core/packs");
16
+ const graph = require("./core/recall/graph");
17
+ const entStub = { listEntities: () => [] };
18
+
19
+ // An ability learned on spaces, plus two other project context packs it has NOT
20
+ // been applied on yet.
21
+ packs.createPack({
22
+ dir: "mobile-app-deploy", name: "Mobile App Deploy",
23
+ description: "Ship a mobile app: bump versionCode, build APK, push in-app updater",
24
+ tags: ["mobile", "deploy", "apk"], kind: "ability",
25
+ learned_on: "spaces", applied_on: ["spaces"], procedure: "1. bump 2. build 3. update",
26
+ });
27
+ packs.createPack({ dir: "spaces", name: "Spaces", description: "Spaces app" });
28
+ packs.createPack({ dir: "chat-mobile", name: "Chat Mobile", description: "Spaces chat client" });
29
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices" });
30
+
31
+ // ── 1. CO-USE: the agent opens the ability and the chat-mobile project pack in
32
+ // the same turn. recordCoUse records the transfer and reports it as new.
33
+ const transferred = packs.recordCoUse(["pack:mobile-app-deploy", "pack:chat-mobile"]);
34
+ assert.strictEqual(transferred.length, 1, "one new transfer recorded");
35
+ assert.strictEqual(transferred[0].ability, "mobile-app-deploy");
36
+ assert.strictEqual(transferred[0].project, "chat-mobile");
37
+ assert.deepStrictEqual(packs.readPack("mobile-app-deploy").applied_on, ["spaces", "chat-mobile"], "applied_on grew from real use");
38
+
39
+ // Idempotent: opening them together again is not a NEW transfer (no re-announce).
40
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy", "pack:chat-mobile"]).length, 0, "repeat co-use is not new");
41
+
42
+ // Guard rails: an ability opened alone, or two context packs together, or an
43
+ // ability + entity, record nothing.
44
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy"]).length, 0, "ability alone records nothing");
45
+ assert.strictEqual(packs.recordCoUse(["pack:spaces", "pack:billing"]).length, 0, "two context packs record nothing");
46
+ assert.strictEqual(packs.recordCoUse(["pack:mobile-app-deploy", "entity:someone"]).length, 0, "ability + entity records nothing");
47
+ assert.deepStrictEqual(packs.readPack("billing").applied_on, [], "billing never gained applied_on");
48
+
49
+ if (!graph.available()) { console.log("ability co-use OK (graph asserts skipped β€” no node:sqlite)"); process.exit(0); }
50
+
51
+ // ── 2. The edge forms from the use-grown provenance, and the ability is now
52
+ // discoverable from chat-mobile via the graph.
53
+ graph.syncFromCorpus(packs, entStub);
54
+ const edges = graph.allEdges();
55
+ assert.ok(
56
+ edges.some((e) => e.src === "pack:chat-mobile" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
57
+ "governed-by edge formed for chat-mobile from co-use"
58
+ );
59
+ const fromChat = graph.expand([{ id: "pack:chat-mobile", score: 4 }], {});
60
+ assert.ok(fromChat.has("pack:mobile-app-deploy"), "ability surfaces from chat-mobile after co-use");
61
+
62
+ // Negative control: billing was never co-used, so it does not surface it.
63
+ const fromBilling = graph.expand([{ id: "pack:billing", score: 4 }], {});
64
+ assert.ok(!fromBilling.has("pack:mobile-app-deploy"), "ability does NOT surface from an unrelated project");
65
+
66
+ console.log("ability co-use OK β€” actual use auto-records reuse, forms the edge, transfers the ability cross-project");
67
+ console.log(" applied_on after co-use:", packs.readPack("mobile-app-deploy").applied_on.join(", "));
68
+ console.log(" working on chat-mobile β†’ graph pulls in:", [...fromChat.keys()].join(", ") || "(none)");
@@ -0,0 +1,109 @@
1
+ // PROOF (capture-time extraction): a reusable how-to demonstrated on ONE turn is
2
+ // extracted by the REVIEWER into an ability pack, the project→ability link forms
3
+ // after that FIRST occurrence (no [[link]] hand-authoring), and once the ability
4
+ // is re-applied to a SECOND project it becomes graph-discoverable from there too.
5
+ // Runs the real reviewer applyAction, real packs.js store, and real recall graph.
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(), "ability-extract-"));
12
+ process.env.PACKS_DIR = path.join(tmp, "packs");
13
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
14
+
15
+ const packs = require("./core/packs");
16
+ const graph = require("./core/recall/graph");
17
+ const review = require("./core/pack-review");
18
+ const entStub = { listEntities: () => [] };
19
+
20
+ // Two real project context packs (so learned_on/applied_on can resolve to nodes),
21
+ // plus a negative-control project.
22
+ packs.createPack({ dir: "spaces", name: "Spaces", description: "Spaces app", stance: "Spaces project." });
23
+ packs.createPack({ dir: "chat-mobile", name: "Chat Mobile", description: "Spaces chat client", stance: "Chat project." });
24
+ packs.createPack({ dir: "billing", name: "Billing", description: "invoices and payments", stance: "Billing project." });
25
+
26
+ // ── 1. CAPTURE: the reviewer extracts an ability from a turn that shipped a
27
+ // mobile build on `spaces`. This is exactly the JSON the model is now told to
28
+ // emit (kind:"ability" + activity name/desc/tags + learned_on).
29
+ const created = review.applyAction({
30
+ action: "create",
31
+ dir: "mobile-app-deploy",
32
+ name: "Mobile App Deploy",
33
+ description: "Ship a mobile app: bump versionCode, build the APK, push the in-app updater",
34
+ tags: ["mobile", "deploy", "apk", "versioncode", "release"],
35
+ kind: "ability",
36
+ learned_on: "spaces",
37
+ applied_on: ["spaces"],
38
+ procedure: "1. bump versionCode 2. build APK 3. push in-app updater",
39
+ journal: "Shipped the spaces mobile build via versionCode bump + APK + in-app updater.",
40
+ });
41
+ assert.strictEqual(created.kind, "create", "ability is created");
42
+ assert.strictEqual(created.ability, true, "create result is flagged as an ability");
43
+ assert.strictEqual(created.learned_on, "spaces", "learned_on surfaces for the chat announcement");
44
+
45
+ const ability = packs.readPack("mobile-app-deploy");
46
+ assert.strictEqual(ability.kind, "ability", "persisted as kind:ability");
47
+ assert.strictEqual(ability.learned_on, "spaces", "learned_on persisted");
48
+ assert.deepStrictEqual(ability.applied_on, ["spaces"], "applied_on persisted");
49
+
50
+ // Activity-oriented metadata so the FIRST cross-project hit can match via FTS
51
+ // before any edge exists (the cold-start path).
52
+ assert.ok(/deploy|apk|versioncode/i.test(ability.description), "description is activity-oriented");
53
+ assert.ok(ability.tags.includes("apk"), "tags carry activity terms");
54
+
55
+ // The project pack was NOT mutated β€” no [[link]] injected, no provenance fight.
56
+ const spacesBody = Object.values(packs.readPack("spaces").sections).join("\n");
57
+ assert.ok(!/\[\[mobile-app-deploy\]\]/.test(spacesBody), "project pack left untouched (link is metadata-derived)");
58
+
59
+ if (!graph.available()) { console.log("ability extraction OK (graph asserts skipped β€” no node:sqlite)"); process.exit(0); }
60
+
61
+ // ── 2. LINK AFTER FIRST OCCURRENCE: syncFromCorpus derives governed-by from the
62
+ // ability's own provenance.
63
+ graph.syncFromCorpus(packs, entStub);
64
+ let edges = graph.allEdges();
65
+ assert.ok(
66
+ edges.some((e) => e.src === "pack:spaces" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
67
+ "governed-by edge formed from metadata after the first occurrence"
68
+ );
69
+
70
+ // ── 3. REUSE ON ORIGIN: working on spaces surfaces the ability.
71
+ const fromSpaces = graph.expand([{ id: "pack:spaces", score: 4 }], {});
72
+ assert.ok(fromSpaces.has("pack:mobile-app-deploy"), "ability surfaces from its origin project");
73
+
74
+ // Negative control BEFORE reuse: an unrelated project does not pull it in.
75
+ const billingBefore = graph.expand([{ id: "pack:billing", score: 4 }], {});
76
+ assert.ok(!billingBefore.has("pack:mobile-app-deploy"), "unrelated project does not surface the ability");
77
+
78
+ // ── 4. CROSS-PROJECT TRANSFER: a later turn re-applies the SAME ability on
79
+ // chat-mobile. The reviewer records it via an update with applied_on (the
80
+ // inferred-from-content path; the co-use signal is the automatic one).
81
+ const reused = review.applyAction({
82
+ action: "update",
83
+ pack: "mobile-app-deploy",
84
+ applied_on: ["chat-mobile"],
85
+ journal: "Reused the mobile deploy flow to ship chat-mobile.",
86
+ });
87
+ assert.strictEqual(reused.appliedTo, "chat-mobile", "update records the cross-project reuse");
88
+ assert.deepStrictEqual(packs.readPack("mobile-app-deploy").applied_on, ["spaces", "chat-mobile"], "applied_on grew");
89
+
90
+ graph.syncFromCorpus(packs, entStub);
91
+ edges = graph.allEdges();
92
+ assert.ok(
93
+ edges.some((e) => e.src === "pack:chat-mobile" && e.dst === "pack:mobile-app-deploy" && e.type === "governed-by"),
94
+ "governed-by edge formed for the second project after reuse"
95
+ );
96
+ const fromChat = graph.expand([{ id: "pack:chat-mobile", score: 4 }], {});
97
+ assert.ok(fromChat.has("pack:mobile-app-deploy"), "ability now surfaces from the second project too");
98
+
99
+ // applied_on must NOT leak onto context packs (churn guard): re-applying to a
100
+ // context pack is ignored.
101
+ const ctxUpdate = review.applyAction({ action: "update", pack: "billing", applied_on: ["spaces"], journal: "noted" });
102
+ assert.strictEqual(ctxUpdate.appliedTo, null, "applied_on ignored on context packs");
103
+ assert.strictEqual(packs.readPack("billing").kind, "context", "billing stays context");
104
+ assert.deepStrictEqual(packs.readPack("billing").applied_on, [], "context pack gains no applied_on");
105
+
106
+ console.log("ability extraction OK β€” reviewer extracts an ability, link forms after first occurrence, transfers cross-project on reuse");
107
+ console.log(" working on spaces β†’ graph pulls in:", [...fromSpaces.keys()].join(", ") || "(none)");
108
+ console.log(" working on chat-mobile β†’ graph pulls in:", [...fromChat.keys()].join(", ") || "(none)");
109
+ console.log(" provenance on ability β†’ learned on", ability.learned_on, "| applied on", packs.readPack("mobile-app-deploy").applied_on.join(", "));
@@ -0,0 +1,42 @@
1
+ // PROOF (dream merge safety): the dream must never merge across the
2
+ // ability/context boundary β€” that would either lose a reusable how-to or
3
+ // pollute it with one project's specifics. Abilities merge only into abilities.
4
+ // Exercises the real applyDream merge path.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ability-merge-"));
11
+ process.env.PACKS_DIR = path.join(tmp, "packs");
12
+ const backup = path.join(tmp, "backup");
13
+
14
+ const packs = require("./core/packs");
15
+ const dream = require("./core/dream");
16
+
17
+ // reach 1 + not-skill so the deterministic tier pass leaves them alone.
18
+ packs.createPack({ dir: "deploy-flow", name: "Deploy Flow", description: "reusable deploy", kind: "ability", applied_on: ["x"] });
19
+ packs.createPack({ dir: "spaces", name: "Spaces", description: "the spaces project" });
20
+
21
+ // ── Cross-boundary merge (ability β†’ context) must be REFUSED.
22
+ let decision = dream.parseDream(JSON.stringify({ merges: [{ into: "spaces", from: ["deploy-flow"], journal: "merge" }] }));
23
+ let lines = dream.applyDream(decision, backup);
24
+ assert.ok(packs.readPack("deploy-flow"), "ability was NOT removed by a cross-boundary merge");
25
+ assert.strictEqual(packs.readPack("deploy-flow").kind, "ability", "ability stays an ability");
26
+ assert.strictEqual(packs.readPack("spaces").kind, "context", "project pack stays context");
27
+ assert.ok(lines.some((l) => /Skipped merge into spaces/.test(l)), "cross-boundary merge announced as skipped");
28
+
29
+ // ── Context β†’ ability is equally refused.
30
+ decision = dream.parseDream(JSON.stringify({ merges: [{ into: "deploy-flow", from: ["spaces"], journal: "merge" }] }));
31
+ lines = dream.applyDream(decision, backup);
32
+ assert.ok(packs.readPack("spaces"), "context pack was NOT absorbed into an ability");
33
+
34
+ // ── Ability β†’ ability IS allowed (the merged result stays an ability).
35
+ packs.createPack({ dir: "deploy-old", name: "Deploy Old", description: "older deploy notes", kind: "ability", applied_on: ["y"] });
36
+ decision = dream.parseDream(JSON.stringify({ merges: [{ into: "deploy-flow", from: ["deploy-old"], journal: "consolidated deploy notes" }] }));
37
+ lines = dream.applyDream(decision, backup);
38
+ assert.ok(!packs.readPack("deploy-old"), "duplicate ability was absorbed");
39
+ assert.strictEqual(packs.readPack("deploy-flow").kind, "ability", "merged result stays an ability");
40
+ assert.ok(lines.some((l) => /Merged deploy-old into deploy-flow/.test(l)), "ability→ability merge applied");
41
+
42
+ console.log("ability merge guard OK — abilities never merge with project packs; ability→ability merges still work");
@@ -0,0 +1,57 @@
1
+ // PROOF (dream tier management): the nightly dream PROMOTES an ability that has
2
+ // transferred across enough projects to the always-on skill index, and eases a
3
+ // cold, never-spread always-on ability back to the match-gated pool β€” both
4
+ // deterministic from reuse breadth, both reversible. Uses the real packs store.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ability-tiers-"));
11
+ process.env.PACKS_DIR = path.join(tmp, "packs");
12
+ // Make "cold" instant so the demote path is exercisable without backdating files.
13
+ process.env.DREAM_ARCHIVE_IDLE_DAYS = "0";
14
+
15
+ const packs = require("./core/packs");
16
+ const dream = require("./core/dream");
17
+ assert.strictEqual(dream.PROMOTE_MIN_PROJECTS, 3, "default promote threshold is 3 projects");
18
+
19
+ // Broadly-reused ability (3 projects) β€” should PROMOTE to always-on.
20
+ packs.createPack({
21
+ dir: "broad-ability", name: "Broad Ability", description: "transfers everywhere",
22
+ kind: "ability", learned_on: "a", applied_on: ["a", "b", "c"],
23
+ });
24
+ // Narrowly-used ability (2 projects) β€” stays match-gated, no promotion yet.
25
+ packs.createPack({
26
+ dir: "narrow-ability", name: "Narrow Ability", description: "two projects so far",
27
+ kind: "ability", learned_on: "a", applied_on: ["a", "b"],
28
+ });
29
+ // A once-promoted always-on ability that never spread (reach 0) and is cold β€”
30
+ // should DEMOTE back to match-gated (still an ability).
31
+ packs.createPack({ dir: "cold-skill", name: "Cold Skill", description: "no longer earning its slot", kind: "ability" });
32
+ packs.setSkill("cold-skill", true);
33
+ assert.strictEqual(packs.readPack("cold-skill").skill, true, "cold-skill starts always-on");
34
+
35
+ const lines = dream.manageAbilityTiers();
36
+
37
+ // Promotion happened.
38
+ assert.strictEqual(packs.readPack("broad-ability").skill, true, "broad ability promoted to always-on");
39
+ assert.strictEqual(packs.readPack("broad-ability").kind, "ability", "promotion keeps it an ability");
40
+ assert.ok(lines.some((l) => /Promoted .*Broad Ability/.test(l)), "promotion announced");
41
+
42
+ // No premature promotion.
43
+ assert.strictEqual(packs.readPack("narrow-ability").skill, false, "narrow ability NOT promoted (only 2 projects)");
44
+
45
+ // Demotion happened, but it stays an ability (match-gated), not archived.
46
+ assert.strictEqual(packs.readPack("cold-skill").skill, false, "cold skill eased out of always-on");
47
+ assert.strictEqual(packs.readPack("cold-skill").kind, "ability", "demotion keeps it an ability (still findable)");
48
+ assert.ok(packs.listAbilities().some((a) => a.dir === "cold-skill"), "demoted ability remains in the ability pool");
49
+ assert.ok(lines.some((l) => /Eased .*Cold Skill/.test(l)), "demotion announced");
50
+
51
+ // Idempotent: a second pass makes no further changes (broad now skill+reach3 so
52
+ // not eligible for demote; cold now not-skill so not eligible for either).
53
+ const second = dream.manageAbilityTiers();
54
+ assert.strictEqual(second.length, 0, "second pass is a no-op");
55
+
56
+ console.log("ability tiers OK β€” broad reuse promotes to always-on; cold/never-spread eases out; both reversible, idempotent");
57
+ console.log(" " + lines.join("\n "));