monomind 2.3.3 → 2.3.4

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.
Files changed (41) hide show
  1. package/README.md +37 -4
  2. package/package.json +1 -1
  3. package/packages/@monomind/cli/.claude/commands/mastermind/{approve.md → approvev1.md} +12 -9
  4. package/packages/@monomind/cli/.claude/commands/mastermind/help.md +18 -0
  5. package/packages/@monomind/cli/.claude/commands/mastermind/master.md +3 -3
  6. package/packages/@monomind/cli/.claude/commands/mastermind/runorg.md +19 -107
  7. package/packages/@monomind/cli/.claude/commands/mastermind/runorgv1.md +159 -0
  8. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +45 -2
  9. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +25 -18
  10. package/packages/@monomind/cli/.claude/skills/mastermind-skills/agents.md +5 -1
  11. package/packages/@monomind/cli/.claude/skills/mastermind-skills/{approve.md → approvev1.md} +8 -5
  12. package/packages/@monomind/cli/.claude/skills/mastermind-skills/env.md +7 -2
  13. package/packages/@monomind/cli/.claude/skills/mastermind-skills/{heartbeat.md → heartbeatv1.md} +7 -4
  14. package/packages/@monomind/cli/.claude/skills/mastermind-skills/inbox.md +14 -2
  15. package/packages/@monomind/cli/.claude/skills/mastermind-skills/instance.md +3 -1
  16. package/packages/@monomind/cli/.claude/skills/mastermind-skills/org-settings.md +4 -1
  17. package/packages/@monomind/cli/.claude/skills/mastermind-skills/orgs.md +14 -13
  18. package/packages/@monomind/cli/.claude/skills/mastermind-skills/orgstatus.md +19 -8
  19. package/packages/@monomind/cli/.claude/skills/mastermind-skills/projects.md +3 -0
  20. package/packages/@monomind/cli/.claude/skills/mastermind-skills/runorg.md +37 -725
  21. package/packages/@monomind/cli/.claude/skills/mastermind-skills/runorgv1.md +731 -0
  22. package/packages/@monomind/cli/.claude/skills/mastermind-skills/stoporg.md +10 -3
  23. package/packages/@monomind/cli/.claude/skills/mastermind-skills/tasks.md +4 -1
  24. package/packages/@monomind/cli/README.md +37 -4
  25. package/packages/@monomind/cli/dist/src/commands/cleanup.js +30 -8
  26. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +23 -10
  27. package/packages/@monomind/cli/dist/src/commands/org-observe.js +30 -26
  28. package/packages/@monomind/cli/dist/src/commands/org.js +52 -2
  29. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +0 -0
  30. package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +31 -12
  31. package/packages/@monomind/cli/dist/src/orgrt/daemon.js +45 -9
  32. package/packages/@monomind/cli/dist/src/orgrt/mailbox.d.ts +23 -0
  33. package/packages/@monomind/cli/dist/src/orgrt/mailbox.js +33 -1
  34. package/packages/@monomind/cli/dist/src/orgrt/migrate.d.ts +26 -0
  35. package/packages/@monomind/cli/dist/src/orgrt/migrate.js +111 -0
  36. package/packages/@monomind/cli/dist/src/orgrt/reporting.js +8 -1
  37. package/packages/@monomind/cli/dist/src/orgrt/session.js +8 -2
  38. package/packages/@monomind/cli/dist/src/ui/dashboard.html +44 -0
  39. package/packages/@monomind/cli/dist/src/ui/orgs.html +2 -2
  40. package/packages/@monomind/cli/dist/src/ui/server.mjs +12 -1
  41. package/packages/@monomind/cli/package.json +2 -2
@@ -57,13 +57,17 @@ echo "Stop requested for org '${org_name}' (v2 daemon exits within 2s)."
57
57
  ```
58
58
  - Exit.
59
59
 
60
+ <!-- LEGACY-ORG-V1: remove this branch when v1 orgs are gone -->
61
+ Everything below this point only runs for v1 orgs — v2 orgs already exited above.
62
+
60
63
  If `current_status == "stopped"`:
61
64
  - Print: "Org '<org_name>' is already stopped."
62
65
  - Exit.
63
66
 
64
67
  ---
65
68
 
66
- ## Step 2 Set Status to Stopped and Clear next_run
69
+ <!-- LEGACY-ORG-V1: remove this step when v1 orgs are gone -->
70
+ ## Step 2 — Set Status to Stopped and Clear next_run (v1 only)
67
71
 
68
72
  ```bash
69
73
  orgFile=".monomind/orgs/${org_name}.json"
@@ -74,7 +78,8 @@ jq '.status = "stopped" | if .loop then .loop.next_run = null else . end' "$orgF
74
78
 
75
79
  ---
76
80
 
77
- ## Step 3 Write Stop File (for any running boss agents)
81
+ <!-- LEGACY-ORG-V1: remove this step when v1 orgs are gone -->
82
+ ## Step 3 — Write Stop File (for any running boss agents, v1 only)
78
83
 
79
84
  Any persistent boss agent checks for this file at the start of each loop iteration:
80
85
 
@@ -85,6 +90,7 @@ touch ".monomind/orgs/.stops/${org_name}.stop"
85
90
 
86
91
  ---
87
92
 
93
+ <!-- LEGACY-ORG-V1: remove this step when v1 orgs are gone -->
88
94
  ## Step 4 — Emit Dashboard Events
89
95
 
90
96
  ```bash
@@ -103,7 +109,8 @@ curl -s -X POST "${CTRL_URL}/api/mastermind/event" -H "x-monomind-token: $(cat "
103
109
 
104
110
  ---
105
111
 
106
- ## Step 5 Report to User
112
+ <!-- LEGACY-ORG-V1: remove this step when v1 orgs are gone -->
113
+ ## Step 5 — Report to User (v1 only)
107
114
 
108
115
  ```
109
116
  ✓ Org "<org_name>" stopped.
@@ -35,13 +35,16 @@ If `caller` is not "command", load brain context following _protocol.md Brain Lo
35
35
  ## Step 1 — Load Org Config
36
36
 
37
37
  ```bash
38
+ # LEGACY-ORG-V1: this whole board/column lookup is the pre-v2 board-backed
39
+ # task model — boards belong to the legacy v1 runner, see runorgv1.
38
40
  orgFile=".monomind/orgs/${org_name}.json"
39
41
  board_id=$(jq -r '.board_id // empty' "$orgFile")
40
42
  todo_col=$(jq -r '.todo_col_id // empty' "$orgFile")
41
43
  doing_col=$(jq -r '.doing_col_id // empty' "$orgFile")
42
44
  done_col=$(jq -r '.done_col_id // empty' "$orgFile")
43
45
 
44
- [ -z "$board_id" ] && { echo "ERROR: org config missing board_id — run /mastermind:createorg to rebuild."; exit 1; }
46
+ [ -z "$board_id" ] && { echo "ERROR: org config missing board_id — boards belong to the legacy v1 runner, see runorgv1."; exit 1; }
47
+ # end LEGACY-ORG-V1
45
48
  ```
46
49
 
47
50
  ---
@@ -102,11 +102,25 @@ monomind org run content-team --task "Build and publish 3 blog posts per week"
102
102
  # assigns work to role agents, coordinates until the task completes
103
103
  # or you stop it. Every event streams into the dashboard above.
104
104
 
105
- monomind org status content-team # runtime state
105
+ monomind org status content-team # runtime state (detects crashed daemons)
106
106
  monomind org stop content-team # request a graceful stop
107
- monomind org list # every org + status
107
+ monomind org list # every org + roles, schedule, status
108
108
  ```
109
109
 
110
+ ### Observe, steer, and let it learn
111
+
112
+ ```bash
113
+ monomind org logs content-team --follow # live event stream in the terminal
114
+ monomind org report content-team # outcome, per-role tokens vs budget, assets
115
+ monomind org questions content-team # what agents asked via ask_human
116
+ monomind org answer content-team q-123 "yes" # answer live or queued — no dashboard needed
117
+ monomind org create blog --template content-team --goal "3 posts/week" # scaffold from a template
118
+ monomind org validate blog # schema + structural checks before running
119
+ monomind org run blog --dry-run # preview each role's exact briefing
120
+ ```
121
+
122
+ Orgs are self-improving: the coordinator records every run's outcome (`org_complete`), the next run is briefed on it, and all agents can query accumulated cross-run memory with `org_recall` — a scheduled org gets smarter every cycle instead of starting cold. Crashed agent sessions restart automatically with backoff.
123
+
110
124
  ### What runs under the hood
111
125
 
112
126
  | What | How |
@@ -196,9 +210,28 @@ monomind org run my-team # run your first AI org (see .monomind/orgs/sa
196
210
 
197
211
  ---
198
212
 
213
+ ## 📚 Second Brain — Your Documents, Retrieved by Meaning
214
+
215
+ Drop documents (Markdown, TXT, PDF, DOCX) anywhere in your project and run `monomind init` — the Second Brain activates itself. No flags, no configuration, no accounts. Everything runs on your machine: a local embedding model (MiniLM via transformers.js) and a local SQLite vector store. **Your notes never leave your computer.**
216
+
217
+ From then on, every substantive prompt you type in Claude Code is automatically answered *with your own knowledge in context* — a hook retrieves the most relevant excerpts semantically (the always-on dashboard keeps the model warm, ~60ms per lookup) and injects them before Claude starts thinking. Ask "when do new parents get time off" and the parental-leave section of your handbook is already on the table, even though you never used the word "leave".
218
+
219
+ ```bash
220
+ monomind doc ingest ./notes # index documents (init + session-start do this automatically)
221
+ monomind doc search -q "pricing psychology in checkout" # semantic search, by meaning not keywords
222
+ monomind doc list # what's indexed
223
+ monomind doc export # portable OKF bundle — move your brain between machines
224
+ ```
225
+
226
+ Retrieval quality is a tested invariant, not a hope: a golden-set eval (paraphrase queries against notes written in different vocabulary) runs in CI with an 80% recall bar.
227
+
228
+ > **Privacy note:** the embedding model (~90MB) is fetched once from HuggingFace's CDN when your first document is indexed, then cached locally forever. That download is the only outbound request the Second Brain ever makes — your documents and queries never leave your machine. Offline at first index? Search degrades gracefully to keyword matching and `monomind doctor` tells you how to warm up later.
229
+
230
+ ---
231
+
199
232
  ## 🧠 Memory That Persists
200
233
 
201
- Every session, every agent, every org writes to a persistent memory store — a JSON pattern store with episodic recall that survives across sessions. The next time you run anything, Monomind already knows what was built, what failed, and which patterns work.
234
+ Every session, every agent, every org writes to a persistent memory store that survives across sessions text plus embedding vectors in local SQLite (better-sqlite3, pure-WASM fallback), embedded by a local model. No cloud vector database, no API keys, no data transmission. The next time you run anything, Monomind already knows what was built, what failed, and which patterns work.
202
235
 
203
236
  ```mermaid
204
237
  graph TD
@@ -217,7 +250,7 @@ graph TD
217
250
 
218
251
  ```bash
219
252
  monomind memory store "key insight" --namespace my-project
220
- monomind memory search "auth implementation" # BM25 + semantic hybrid
253
+ monomind memory search "auth implementation" # semantic (local embeddings) with keyword fallback
221
254
  ```
222
255
 
223
256
  ---
@@ -57,16 +57,23 @@ export function findOrphanedProjectData(baseDir, now, aggressive) {
57
57
  if (name.startsWith('.'))
58
58
  continue;
59
59
  const dir = join(baseDir, name);
60
- let mtime = 0;
61
60
  try {
62
- const st = lstatSync(dir);
63
- if (!st.isDirectory())
61
+ if (!lstatSync(dir).isDirectory())
64
62
  continue;
65
- mtime = st.mtimeMs;
66
63
  }
67
64
  catch {
68
65
  continue;
69
66
  }
67
+ // Staleness must consider the files writes actually touch: appends to
68
+ // lancedb/memory.db and origin.json refreshes do NOT bump the slug dir's
69
+ // own mtime, so an actively-used project would otherwise look 30d stale.
70
+ const mtimeOf = (p) => { try {
71
+ return lstatSync(p).mtimeMs;
72
+ }
73
+ catch {
74
+ return 0;
75
+ } };
76
+ const mtime = Math.max(mtimeOf(dir), mtimeOf(join(dir, 'origin.json')), mtimeOf(join(dir, 'lancedb', 'memory.db')), mtimeOf(join(dir, 'memory.db')));
70
77
  const originFile = join(dir, 'origin.json');
71
78
  let originPath = null;
72
79
  let hasOrigin = false;
@@ -76,13 +83,28 @@ export function findOrphanedProjectData(baseDir, now, aggressive) {
76
83
  }
77
84
  catch { /* no/corrupt marker */ }
78
85
  if (hasOrigin && originPath && existsSync(originPath)) {
86
+ // NOTE: the directory is *named* lancedb for historical reasons, but the
87
+ // current SQLite engine keeps its LIVE memory.db inside it. Only genuine
88
+ // LanceDB leftovers (*.lance datasets, no memory.db) are dead weight.
79
89
  const lance = join(dir, 'lancedb');
80
- if (existsSync(lance))
81
- out.push({ path: lance, description: `dead lancedb store (project: ${originPath})`, size: 0 });
90
+ if (existsSync(lance) && !existsSync(join(lance, 'memory.db'))) {
91
+ let hasLanceData = false;
92
+ try {
93
+ hasLanceData = readdirSync(lance).some(f => f.endsWith('.lance') || f === '__manifest');
94
+ }
95
+ catch { /* unreadable — leave it */ }
96
+ if (hasLanceData)
97
+ out.push({ path: lance, description: `dead lancedb store (project: ${originPath})`, size: 0 });
98
+ }
82
99
  continue;
83
100
  }
84
- if (hasOrigin) {
85
- out.push({ path: dir, description: `orphaned project data (origin gone: ${originPath})`, size: 0 });
101
+ if (hasOrigin && originPath) {
102
+ // Only classify as orphaned when the origin's PARENT exists an
103
+ // unmounted volume / disconnected network share makes the whole subtree
104
+ // vanish temporarily, and that must never count as "project deleted".
105
+ if (existsSync(dirname(originPath))) {
106
+ out.push({ path: dir, description: `orphaned project data (origin gone: ${originPath})`, size: 0 });
107
+ }
86
108
  }
87
109
  else if (aggressive || now - mtime > UNKNOWN_DIR_MAX_AGE_MS) {
88
110
  out.push({ path: dir, description: aggressive ? 'unverifiable project data (no origin marker)' : 'unverifiable project data (untouched >30d)', size: 0 });
@@ -52,19 +52,32 @@ export async function checkSecondBrainModel() {
52
52
  if (!existsSync(join(process.cwd(), '.monomind', 'knowledge', 'chunks.jsonl'))) {
53
53
  return { name, status: 'pass', message: 'No knowledge base in this project (nothing to check)' };
54
54
  }
55
- let pkgDir;
55
+ let entryPath = null;
56
56
  try {
57
- // exports map blocks package.json — resolve the entry file and walk up
58
- const entry = await import.meta.resolve?.('@huggingface/transformers');
59
- if (!entry)
60
- throw new Error('resolver unavailable');
61
- pkgDir = fileURLToPath(entry).replace(/\/dist\/.*$/, '');
62
- }
63
- catch {
64
- return { name, status: 'warn', message: '@huggingface/transformers not installed — semantic search degraded to keyword matching', fix: 'reinstall monomind (the model dependency is optional and may have failed to build)' };
57
+ // exports map blocks package.json — resolve the entry file, then walk UP
58
+ // with path ops (no separator-sensitive regex: '/dist/'.replace broke on
59
+ // Windows paths, and any install layout without a dist segment).
60
+ // import.meta.resolve is missing on Node 20.0–20.5; fall back to CJS resolve.
61
+ const viaEsm = import.meta.resolve?.('@huggingface/transformers');
62
+ if (viaEsm)
63
+ entryPath = fileURLToPath(await viaEsm);
64
+ }
65
+ catch { /* try CJS below */ }
66
+ if (!entryPath) {
67
+ try {
68
+ const { createRequire } = await import('node:module');
69
+ entryPath = createRequire(import.meta.url).resolve('@huggingface/transformers');
70
+ }
71
+ catch {
72
+ return { name, status: 'warn', message: '@huggingface/transformers not installed — semantic search degraded to keyword matching', fix: 'reinstall monomind (the model dependency is optional and may have failed to build)' };
73
+ }
65
74
  }
75
+ // Walk up from the entry file to the package root (the dir named 'transformers').
76
+ let pkgDir = dirname(entryPath);
77
+ while (pkgDir !== dirname(pkgDir) && !pkgDir.endsWith('transformers'))
78
+ pkgDir = dirname(pkgDir);
66
79
  const modelCache = join(pkgDir, '.cache', 'Xenova');
67
- if (existsSync(modelCache)) {
80
+ if (pkgDir.endsWith('transformers') && existsSync(modelCache)) {
68
81
  return { name, status: 'pass', message: 'Local embedding model cached — semantic search active' };
69
82
  }
70
83
  return { name, status: 'warn', message: 'Embedding model not downloaded yet — searches use keyword matching until it is', fix: 'run once while online: monomind doc search -q "warmup" (downloads ~90MB locally, one time)' };
@@ -7,7 +7,7 @@ import { output } from '../output.js';
7
7
  import { ORG_DIR, OrgDefSchema } from '../orgrt/types.js';
8
8
  import { formatEvent, listRunDirs, readHistory, readRunEvents, summarizeRun } from '../orgrt/reporting.js';
9
9
  import { ORG_TEMPLATES, buildFromTemplate } from '../orgrt/templates.js';
10
- import { parseSchedule } from '../orgrt/scheduler.js';
10
+ import { checkOrgStructure } from '../orgrt/migrate.js';
11
11
  import { listOrgConfigFiles, validateOrgName } from './org.js';
12
12
  const log = (text) => { console.log(text); };
13
13
  /** Validate org config(s) against OrgDefSchema — the exact parse `org run`/`org serve`
@@ -42,23 +42,7 @@ export const validateAction = async (ctx) => {
42
42
  }
43
43
  try {
44
44
  const def = OrgDefSchema.parse(JSON.parse(readFileSync(path, 'utf8')));
45
- const ids = def.roles.map(r => r.id);
46
- const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
47
- if (dupes.length)
48
- errors.push(`duplicate role id(s): ${[...new Set(dupes)].join(', ')}`);
49
- const roots = def.roles.filter(r => r.reports_to === null);
50
- if (roots.length === 0)
51
- errors.push('no root role — exactly one role must have reports_to: null');
52
- if (roots.length > 1)
53
- errors.push(`multiple root roles (${roots.map(r => r.id).join(', ')}) — exactly one may have reports_to: null`);
54
- for (const r of def.roles) {
55
- if (r.reports_to !== null && !ids.includes(r.reports_to))
56
- errors.push(`role "${r.id}": reports_to "${r.reports_to}" matches no role id`);
57
- if (r.reports_to === r.id)
58
- errors.push(`role "${r.id}" reports to itself`);
59
- }
60
- if (def.schedule != null && parseSchedule(def.schedule) === null)
61
- errors.push(`schedule "${def.schedule}" is not parseable — use "<N>s", "<N>m", or "<N>h"`);
45
+ errors.push(...checkOrgStructure(def));
62
46
  if (def.name !== stem)
63
47
  warnings.push(`def.name "${def.name}" differs from filename — the runtime addresses this org as "${stem}"`);
64
48
  }
@@ -80,9 +64,13 @@ export const validateAction = async (ctx) => {
80
64
  ? { success: false, message: `${failed} of ${files.length} org config(s) failed validation` }
81
65
  : { success: true, message: `${files.length} org config(s) valid` };
82
66
  };
67
+ // Run ids are joined into filesystem paths — enforce the daemon's own id shape
68
+ // so a crafted --run can't traverse out of the org directory (same reason the
69
+ // org-name guard exists).
70
+ const RUN_ID_RE = /^run-[A-Za-z0-9-]+$/;
83
71
  const resolveRun = (cwd, name, runFlag) => {
84
72
  if (typeof runFlag === 'string' && runFlag)
85
- return runFlag;
73
+ return RUN_ID_RE.test(runFlag) ? runFlag : null;
86
74
  return listRunDirs(cwd, name)[0] ?? null;
87
75
  };
88
76
  /** `org logs <name> [--run id] [--role r] [--follow]` — formatted bus.jsonl tail. */
@@ -103,14 +91,19 @@ export const logsAction = async (ctx, name) => {
103
91
  if (!existsSync(file))
104
92
  return;
105
93
  const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean);
106
- for (const l of lines.slice(seenLines)) {
94
+ for (let i = seenLines; i < lines.length; i++) {
107
95
  try {
108
- show(JSON.parse(l));
109
- seenLines++;
96
+ show(JSON.parse(lines[i]));
97
+ seenLines = i + 1;
110
98
  }
111
99
  catch {
112
- break;
113
- } // partial line mid-append retry it next tick
100
+ // Only the FINAL line can be a partial mid-append write worth
101
+ // retrying; a corrupt interior line would otherwise stall the tail
102
+ // forever — skip it and keep going.
103
+ if (i === lines.length - 1)
104
+ break;
105
+ seenLines = i + 1;
106
+ }
114
107
  }
115
108
  };
116
109
  drain();
@@ -249,10 +242,21 @@ export const answerAction = async (ctx, name) => {
249
242
  }
250
243
  }
251
244
  // Offline path: mirror daemon.answerQuestion's org-not-running branch.
252
- const updated = questions.map(x => x.questionId === questionId ? { ...x, answer, answeredAt: Date.now() } : x);
245
+ // RE-READ and merge by questionId just before writing the pre-fetch
246
+ // snapshot can be up to 10s stale (live-delivery timeout), and rewriting
247
+ // from it would delete questions the daemon appended meanwhile and revert
248
+ // answers it recorded (atomic rename prevents torn writes, not lost updates).
249
+ const fresh = readQuestions(ctx.cwd, name);
250
+ const freshQ = fresh.find(x => x.questionId === questionId);
251
+ if (freshQ && freshQ.answer !== null) {
252
+ return { success: false, message: `question "${questionId}" was answered while this command was running` };
253
+ }
254
+ const merged = fresh.some(x => x.questionId === questionId)
255
+ ? fresh.map(x => x.questionId === questionId ? { ...x, answer, answeredAt: Date.now() } : x)
256
+ : [...fresh, { ...q, answer, answeredAt: Date.now() }];
253
257
  const dest = join(ctx.cwd, ORG_DIR, name, 'questions.json');
254
258
  const tmp = `${dest}.${process.pid}.tmp`;
255
- writeFileSync(tmp, JSON.stringify({ questions: updated }, null, 2));
259
+ writeFileSync(tmp, JSON.stringify({ questions: merged }, null, 2));
256
260
  const { renameSync } = await import('node:fs');
257
261
  renameSync(tmp, dest);
258
262
  const { queueMessage } = await import('../orgrt/inbox.js');
@@ -5,6 +5,7 @@ import { output } from '../output.js';
5
5
  import { OrgDaemon } from '../orgrt/daemon.js';
6
6
  import { startOrgServer } from '../orgrt/server.js';
7
7
  import { ORG_DIR, OrgDefSchema } from '../orgrt/types.js';
8
+ import { migrateOrgFile } from '../orgrt/migrate.js';
8
9
  const log = (text) => { console.log(text); };
9
10
  /** Org names are used to build filesystem paths under .monomind/orgs — reject
10
11
  * anything that isn't a plain identifier to prevent path traversal (e.g.
@@ -31,8 +32,13 @@ const ORG_ARTIFACT_SUFFIXES = [
31
32
  '-approval-comments', '-skills',
32
33
  ];
33
34
  export function listOrgConfigFiles(orgsDir) {
35
+ // endsWith, not includes: substring matching hid legitimate orgs whose NAME
36
+ // merely contains an artifact suffix anywhere (e.g. "state-machine.json",
37
+ // "issues-triage.json") — and anything hidden here is also invisible to
38
+ // run/list/serve while `org delete <sibling>` would still remove its files.
34
39
  return readdirSync(orgsDir)
35
- .filter(f => f.endsWith('.json') && !f.startsWith('._') && !ORG_ARTIFACT_SUFFIXES.some(suf => f.includes(suf)));
40
+ .filter(f => f.endsWith('.json') && !f.startsWith('._') && !f.endsWith('.v1.json')
41
+ && !ORG_ARTIFACT_SUFFIXES.some(suf => f.endsWith(`${suf}.json`)));
36
42
  }
37
43
  /** Remove a lingering stopfile so a fresh `org run` doesn't self-terminate. */
38
44
  export const clearStopfile = (cwd, name) => {
@@ -152,6 +158,11 @@ const statusAction = async (ctx) => {
152
158
  if (!validated.ok)
153
159
  return validated.result;
154
160
  name = validated.name;
161
+ // A named org that doesn't exist must error, not report "never run" with exit 0.
162
+ if (!existsSync(join(ctx.cwd, ORG_DIR, `${name}.json`))) {
163
+ log(output.error(`Org not found: ${name}`));
164
+ return { success: false, message: 'org not found' };
165
+ }
155
166
  }
156
167
  const orgDir = join(ctx.cwd, ORG_DIR);
157
168
  const targets = name ? [name] : (existsSync(orgDir)
@@ -385,6 +396,40 @@ const markCompleteAction = async (ctx) => {
385
396
  return { success: false, message: 'server unreachable' };
386
397
  }
387
398
  };
399
+ const migrateAction = async (ctx) => {
400
+ const validated = validateOrgName(ctx.args[0]);
401
+ if (!validated.ok)
402
+ return validated.result;
403
+ const name = validated.name;
404
+ const cwd = ctx.cwd;
405
+ const cfgPath = join(cwd, ORG_DIR, `${name}.json`);
406
+ if (!existsSync(cfgPath)) {
407
+ log(output.error(`Org not found: ${name}`));
408
+ return { success: false, message: 'org not found' };
409
+ }
410
+ if (isOrgRunning(cwd, name)) {
411
+ log(output.error(`Org "${name}" is currently running — stop it first, then migrate.`));
412
+ return { success: false, message: 'org is running' };
413
+ }
414
+ try {
415
+ const outcome = migrateOrgFile(cfgPath, join(cwd, ORG_DIR, `${name}.v1.json`));
416
+ if (outcome.status === 'already-v2') {
417
+ log(output.info(`${name}: already v2 — nothing to migrate.`));
418
+ return { success: true, message: 'already v2' };
419
+ }
420
+ log(output.success(`${name}: migrated to v2 (backup: ${name}.v1.json)`));
421
+ for (const d of outcome.dropped)
422
+ log(output.info(` dropped v1 field: ${d}`));
423
+ for (const n of outcome.notes)
424
+ log(output.info(` ${n}`));
425
+ log(output.info(` run it with: monomind org run ${name}`));
426
+ return { success: true, message: `migrated ${name}` };
427
+ }
428
+ catch (err) {
429
+ log(output.error(`Cannot migrate ${name}: ${err instanceof Error ? err.message : String(err)}`));
430
+ return { success: false, message: 'migration produced an invalid config' };
431
+ }
432
+ };
388
433
  export const orgCommand = {
389
434
  name: 'org',
390
435
  description: 'SDK-based org runtime — run agent organizations as a controlled daemon',
@@ -495,6 +540,11 @@ export const orgCommand = {
495
540
  return validateAction(ctx);
496
541
  },
497
542
  },
543
+ {
544
+ name: 'migrate', description: 'Convert a v1 org config (topology/board/loop) to the v2 daemon shape',
545
+ examples: [{ command: 'monomind org migrate growth', description: 'Migrate one org; original saved as growth.v1.json' }],
546
+ action: migrateAction,
547
+ },
498
548
  { name: 'list', description: 'List all orgs in the current project', action: listAction },
499
549
  {
500
550
  name: 'delete', description: 'Delete an org and all its data',
@@ -511,7 +561,7 @@ export const orgCommand = {
511
561
  // index.ts's dispatcher never prints result.message on a failed action —
512
562
  // it only exits with result.exitCode — so this must log itself or bare
513
563
  // `monomind org` exits silently with code 1 and zero output.
514
- const message = 'usage: monomind org <run|stop|status|serve|test-loop|logs|report|questions|answer|create|validate|list|delete|mark-complete>';
564
+ const message = 'usage: monomind org <run|stop|status|serve|test-loop|logs|report|questions|answer|create|validate|migrate|list|delete|mark-complete>';
515
565
  log(output.error(message));
516
566
  return { success: false, message };
517
567
  },
@@ -263,23 +263,26 @@ export async function bridgeStoreEntry(options) {
263
263
  entry.id = id;
264
264
  if (embedding)
265
265
  entry.embedding = embedding;
266
- // Upsert: delete existing entry with same key+namespace first
266
+ // Upsert: find any existing entry with the same key+namespace — deleted
267
+ // only AFTER the new entry stores successfully, so a failed store() can't
268
+ // destroy the existing data (old order was delete-then-store).
269
+ let upsertVictim = null;
267
270
  if (options.upsert) {
268
271
  try {
269
- const existing = await backend.getByKey(namespace, key);
270
- if (existing)
271
- await backend.delete(existing.id);
272
- }
273
- catch (e) {
274
- if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
275
- console.error('[memory-bridge] upsert failed to delete existing entry — may create a duplicate:', e);
272
+ upsertVictim = await backend.getByKey(namespace, key);
276
273
  }
274
+ catch { /* treat as no existing entry */ }
277
275
  }
278
- // Dedup gate: skip if a near-duplicate already exists
276
+ // Dedup gate: skip if a near-duplicate already exists IN THIS NAMESPACE —
277
+ // an unscoped search let a similar entry in some other namespace swallow
278
+ // the store entirely (returned duplicate:true, nothing written where asked).
279
279
  const automemCfg = getAutomemConfig();
280
280
  if (embedding && !options.upsert) {
281
281
  try {
282
- const similar = await backend.search(embedding, { k: 1, threshold: automemCfg.dedupThreshold });
282
+ const similar = await backend.search(embedding, {
283
+ k: 1, threshold: automemCfg.dedupThreshold,
284
+ filters: { type: 'exact', namespace },
285
+ });
283
286
  if (similar.length > 0 && similar[0].score >= automemCfg.dedupThreshold) {
284
287
  return { success: true, id: similar[0].entry.id, duplicate: true };
285
288
  }
@@ -287,6 +290,15 @@ export async function bridgeStoreEntry(options) {
287
290
  catch { /* non-fatal — store anyway */ }
288
291
  }
289
292
  await backend.store(entry);
293
+ if (upsertVictim && upsertVictim.id !== id) {
294
+ try {
295
+ await backend.delete(upsertVictim.id);
296
+ }
297
+ catch (e) {
298
+ if (process.env.DEBUG || process.env.MONOMIND_DEBUG)
299
+ console.error('[memory-bridge] upsert stored new entry but failed to delete the old one — duplicate may remain:', e);
300
+ }
301
+ }
290
302
  await flushBackend(backend);
291
303
  return { success: true, id, embedding: embeddingInfo };
292
304
  }
@@ -329,9 +341,11 @@ export async function bridgeSearchEntries(options) {
329
341
  // Keyword fallback — scan all entries in namespace (not just first 100)
330
342
  // to avoid missing documents that were ingested later in the batch.
331
343
  if (results.length === 0) {
344
+ // No namespace filter means ALL namespaces — collapsing to 'default'
345
+ // made "search everything" silently miss every non-default entry.
332
346
  const entries = await backend.query({
333
347
  type: 'exact',
334
- namespace: namespace ?? 'default',
348
+ ...(namespace ? { namespace } : {}),
335
349
  limit: 50000,
336
350
  });
337
351
  // Token-based matching, not whole-phrase substring: "semantic test" must
@@ -363,11 +377,16 @@ export async function bridgeSearchEntries(options) {
363
377
  }
364
378
  // Filter stale entries based on automem config — skip for knowledge
365
379
  // namespaces (documents should remain searchable indefinitely)
380
+ // Stale filtering is per-RESULT namespace (documents stay searchable
381
+ // forever) — keying it on the query's namespace filter meant an
382
+ // all-namespace search silently dropped knowledge:* results past the
383
+ // stale cutoff.
366
384
  const isKnowledgeNs = namespace?.startsWith('knowledge:');
367
385
  if (!isKnowledgeNs) {
368
386
  const { staleDays } = getAutomemConfig();
369
387
  const staleCutoff = Date.now() - staleDays * 86400000;
370
- results = results.filter((r) => !r._createdAt || r._createdAt > staleCutoff);
388
+ results = results.filter((r) => String(r.namespace ?? '').startsWith('knowledge:')
389
+ || !r._createdAt || r._createdAt > staleCutoff);
371
390
  }
372
391
  results.forEach((r) => delete r._createdAt);
373
392
  return {
@@ -63,6 +63,12 @@ export class OrgDaemon {
63
63
  const running = { def, run, bus, agents: new Map(), busEvents: () => [...collected] };
64
64
  this.orgs.set(name, running);
65
65
  const perRoleBudget = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / def.roles.length);
66
+ // Single boss-selection rule for kickoff AND org_complete gating — the
67
+ // session layer previously keyed the tool on reports_to===null while the
68
+ // kickoff went to (type==='boss' || reports_to===null || roles[0]), so a
69
+ // fallback-selected boss could be told to call org_complete without having
70
+ // the tool.
71
+ const bossRole = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
66
72
  for (const role of def.roles) {
67
73
  const mailbox = new Mailbox();
68
74
  const policy = new PolicyEngine(role.id, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, cwd);
@@ -72,9 +78,11 @@ export class OrgDaemon {
72
78
  maxTurns: def.run_config.max_turns_per_message,
73
79
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
74
80
  askHuman: (r, question) => this.askHuman(name, r, question),
75
- onComplete: (r, outcome, summary) => {
76
- bus.emit({ type: 'status', from: r, reason: 'org-complete', msg: `run outcome: ${outcome}`, data: { outcome, summary } });
77
- },
81
+ onComplete: role.id === bossRole.id
82
+ ? (r, outcome, summary) => {
83
+ bus.emit({ type: 'status', from: r, reason: 'org-complete', msg: `run outcome: ${outcome}`, data: { outcome, summary } });
84
+ }
85
+ : undefined,
78
86
  recall: async (r, q) => {
79
87
  const answer = await this.recallOrgMemory(name, def, q);
80
88
  bus.emit({ type: 'status', from: r, reason: 'org-recall', msg: `recall: ${q.slice(0, 80)}`, data: { hits: answer.hits } });
@@ -98,10 +106,18 @@ export class OrgDaemon {
98
106
  return;
99
107
  }
100
108
  catch (err) {
109
+ // Drop the crashed session's stale waker immediately: a push()
110
+ // during the backoff window must queue for the NEXT session, not
111
+ // wake the dead generator to swallow it.
112
+ mailbox.detach();
101
113
  const message = err instanceof Error ? err.message : String(err);
102
114
  const crash = () => {
103
115
  runtime.status = 'crashed';
104
116
  runtime.error = message;
117
+ // Close the mailbox so deliver()/receiveRemote() report a real
118
+ // error instead of pushing into a queue no session will read
119
+ // (and returning a false "delivered" receipt to the sender).
120
+ mailbox.close();
105
121
  bus.emit({
106
122
  type: 'audit', from: role.id,
107
123
  msg: `agent "${role.id}" crashed: ${message}`,
@@ -127,7 +143,7 @@ export class OrgDaemon {
127
143
  })();
128
144
  running.agents.set(role.id, runtime);
129
145
  }
130
- const boss = def.roles.find(r => r.type === 'boss' || r.reports_to === null) ?? def.roles[0];
146
+ const boss = bossRole;
131
147
  // Cross-run memory: brief the coordinator on the previous run so scheduled
132
148
  // orgs accumulate instead of starting cold every interval.
133
149
  const prev = readHistory(this.root, name).at(-1);
@@ -195,6 +211,10 @@ export class OrgDaemon {
195
211
  return `ERROR: unknown recipient "${toQualified}" (known: ${[...(targetOrg?.agents.keys() ?? this.orgs.keys())].join(', ')})`;
196
212
  }
197
213
  const targetAgent = targetOrg.agents.get(targetRole);
214
+ if (targetAgent.status === 'crashed') {
215
+ src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'recipient crashed (retry budget exhausted)' });
216
+ return `ERROR: recipient "${toQualified}" crashed and will not recover this run — message not delivered (${targetAgent.error ?? 'unknown error'})`;
217
+ }
198
218
  if (targetAgent.mailbox.isClosed) {
199
219
  // The org is mid-shutdown: mailboxes close before the org is removed
200
220
  // from `this.orgs`, so a message can arrive in that window. push() would
@@ -262,6 +282,8 @@ export class OrgDaemon {
262
282
  const agent = org.agents.get(toRole);
263
283
  if (!agent)
264
284
  return { ok: false, error: `role "${toRole}" not found in org "${toOrg}"` };
285
+ if (agent.status === 'crashed')
286
+ return { ok: false, error: `role "${toRole}" in org "${toOrg}" crashed and will not recover this run` };
265
287
  if (agent.mailbox.isClosed)
266
288
  return { ok: false, error: `role "${toRole}" in org "${toOrg}" is shutting down` };
267
289
  org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
@@ -364,6 +386,11 @@ export class OrgDaemon {
364
386
  // is already gone and no-ops instead of re-running the whole shutdown and
365
387
  // double-emitting 'org stopped' (duplicate org:complete/session:complete).
366
388
  this.orgs.delete(name);
389
+ // Capture THIS run's forwarder now: an autoWake-restart of the same org
390
+ // during the long tail below (agent wait, flush, history write) would
391
+ // register a NEW forwarder under the same name — settling/unsubscribing
392
+ // that one would sever the new run's dashboard stream.
393
+ const forwarder = this.forwarders.get(name);
367
394
  this.leases.get(name)?.stop();
368
395
  this.leases.delete(name);
369
396
  for (const a of org.agents.values())
@@ -402,14 +429,23 @@ export class OrgDaemon {
402
429
  // the "org stopped" event above triggers the forwarder's final org:complete /
403
430
  // session:complete POST — without waiting for it here, the CLI process can exit
404
431
  // (and kill the in-flight fetch) before that last event reaches the dashboard,
405
- // leaving the run stuck showing "running" forever.
406
- const forwarder = this.forwarders.get(name);
432
+ // leaving the run stuck showing "running" forever. Bounded: a stalled
433
+ // dashboard must not hang org shutdown indefinitely.
407
434
  if (forwarder) {
408
- await forwarder.settle();
435
+ await Promise.race([
436
+ forwarder.settle(),
437
+ new Promise(r => { const t = setTimeout(r, 5_000); t.unref?.(); }),
438
+ ]);
409
439
  forwarder.unsubscribe();
410
- this.forwarders.delete(name);
440
+ // Only remove from the map if it's still OURS — an autoWake-restart may
441
+ // have registered the new run's forwarder under this name meanwhile.
442
+ if (this.forwarders.get(name) === forwarder)
443
+ this.forwarders.delete(name);
411
444
  }
412
- this.persistState(name, 'stopped', org.run);
445
+ // Same guard for runtime.json: if a new run started during shutdown, its
446
+ // 'running' record must not be overwritten with this old run's 'stopped'.
447
+ if (!this.orgs.has(name))
448
+ this.persistState(name, 'stopped', org.run);
413
449
  }
414
450
  async stopAll() {
415
451
  await Promise.all([...this.orgs.keys()].map(n => this.stopOrg(n)));