brainclaw 1.7.5 → 1.9.0

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 (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -1,6 +1,14 @@
1
+ import { runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
1
2
  /**
2
- * Scan a text string for sensitive patterns defined in the config.
3
- * Returns a list of warnings.
3
+ * Scan a text string for sensitive content. Three signal layers run:
4
+ * 1. User-configured regex patterns from `config.redaction.patterns`
5
+ * (the legacy MVP behavior).
6
+ * 2. Structural detectors — exact token shapes for GitHub PATs, AWS
7
+ * access keys, JWTs, etc. High precision; on by default.
8
+ * 3. Entropy detector — flags high-entropy token-like substrings near
9
+ * a sensitive keyword. Tunable, on by default.
10
+ *
11
+ * In strict mode all signals escalate to `block`; otherwise `warn`.
4
12
  */
5
13
  export function scanText(text, config) {
6
14
  const warnings = [];
@@ -24,6 +32,30 @@ export function scanText(text, config) {
24
32
  // skip invalid regex patterns
25
33
  }
26
34
  }
35
+ // Structural detectors — only run when token_detection is enabled.
36
+ const td = config.security?.token_detection;
37
+ if (td?.enabled !== false) {
38
+ const disabled = td?.detectors;
39
+ for (const m of runStructuralDetectors(text, disabled)) {
40
+ warnings.push({
41
+ level,
42
+ message: `${m.label} (id=${m.detectorId}) detected: ${m.excerpt}`,
43
+ });
44
+ }
45
+ if (td?.entropy?.enabled !== false) {
46
+ const entropyMatches = runEntropyDetector(text, {
47
+ enabled: td?.entropy?.enabled ?? true,
48
+ minLength: td?.entropy?.min_length,
49
+ minEntropy: td?.entropy?.min_entropy,
50
+ });
51
+ for (const m of entropyMatches) {
52
+ warnings.push({
53
+ level,
54
+ message: `High-entropy token-shaped substring near a secret keyword (entropy=${m.entropy}): ${m.excerpt}`,
55
+ });
56
+ }
57
+ }
58
+ }
27
59
  const blockPaths = config.security?.block_sensitive_paths ?? true;
28
60
  if (blockPaths) {
29
61
  for (const sp of config.sensitive_paths) {
@@ -5,6 +5,7 @@ import { generateIdWithLabel, nowISO } from './ids.js';
5
5
  import { resolveEntityDir } from './io.js';
6
6
  import { SequenceItemSchema, SequenceSchema } from './schema.js';
7
7
  import { refreshLiveCompanions } from '../commands/export.js';
8
+ import { emitRegistryPostImage, emitRegistryTombstone, registryFaultPoint } from './events/registry-post-image.js';
8
9
  function sequencesDir(cwd, mode = 'read') {
9
10
  return resolveEntityDir('sequences', cwd ?? process.cwd(), mode);
10
11
  }
@@ -39,7 +40,13 @@ function validateRanks(items) {
39
40
  export function saveSequence(sequence, cwd) {
40
41
  mutate({ cwd }, () => {
41
42
  ensureSequencesDir(cwd);
42
- sequenceStore(cwd, 'write').save(SequenceSchema.parse(sequence));
43
+ const store = sequenceStore(cwd, 'write');
44
+ const parsed = SequenceSchema.parse(sequence);
45
+ // pln#568 (I2): journal the post-image BEFORE the projection write.
46
+ const created = !store.exists(parsed.id);
47
+ emitRegistryPostImage('sequence', parsed, { created, agent: parsed.author, agent_id: parsed.author_id, session_id: parsed.session_id, cwd });
48
+ registryFaultPoint('after_registry_journal');
49
+ store.save(parsed);
43
50
  // Auto-refresh live companions after sequence changes (non-fatal)
44
51
  try {
45
52
  refreshLiveCompanions(cwd);
@@ -99,6 +106,8 @@ export function deleteSequence(id, cwd) {
99
106
  if (!current) {
100
107
  throw new Error(`Sequence not found: ${id}`);
101
108
  }
109
+ emitRegistryTombstone('sequence', current.id, { agent: current.author, agent_id: current.author_id, session_id: current.session_id, cwd });
110
+ registryFaultPoint('after_registry_journal');
102
111
  store.delete(current.id);
103
112
  return { id: current.id, name: current.name };
104
113
  });
@@ -123,7 +132,7 @@ export function updateSequence(input, cwd) {
123
132
  tags: input.tags ?? current.tags,
124
133
  updated_at: nowISO(),
125
134
  };
126
- store.save(SequenceSchema.parse(next));
135
+ saveSequence(SequenceSchema.parse(next), cwd);
127
136
  return next;
128
137
  });
129
138
  }
@@ -11,13 +11,150 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
- import { memoryExists } from './io.js';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { memoryDir, memoryExists } from './io.js';
15
16
  import { detectAiAgent } from './ai-agent-detection.js';
16
17
  import { resolveStoreChain } from './store-resolution.js';
17
18
  import { analyzeRepository } from './repo-analysis.js';
18
19
  import { getAgentCapabilityProfile, getAllAgentCapabilityProfiles } from './agent-capability.js';
19
20
  import { describeAgentSurfaces } from './agent-capability.js';
20
21
  import { loadState } from './state.js';
22
+ /** Entries that don't count as "repo content" when deciding the bootstrap route. */
23
+ const CONTENT_IGNORED = new Set(['.git', '.brainclaw', '.gitignore', '.gitattributes', '.DS_Store', 'Thumbs.db']);
24
+ /** True when cwd contains anything beyond git/brainclaw plumbing. */
25
+ export function repoHasContent(cwd) {
26
+ try {
27
+ return fs.readdirSync(cwd).some((e) => !CONTENT_IGNORED.has(e));
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * THE decision rule for "the memory store is empty — what now?", emitted
35
+ * identically by the three onboarding surfaces (bclaw_work hint, quick_init
36
+ * preview, init preflight):
37
+ *
38
+ * - repo with existing content → bclaw_bootstrap (extract context from docs/manifests/history)
39
+ * - greenfield (nothing to extract) → bootstrap loop (ideate the vision first)
40
+ *
41
+ * The two routes are chainable: extraction first, then a bootstrap loop for
42
+ * whatever vision the docs could not provide — or ideation first, then
43
+ * extraction once content exists.
44
+ * Documented in docs/concepts/workspace-bootstrapping.md ("Empty memory: one rule").
45
+ */
46
+ export function resolveEmptyMemoryRecommendation(cwd = process.cwd()) {
47
+ if (repoHasContent(cwd)) {
48
+ return {
49
+ route: 'extract',
50
+ reason: 'repo has existing content to extract from',
51
+ mcp_next_action: 'bclaw_bootstrap()',
52
+ cli_next_action: 'brainclaw bootstrap',
53
+ chained_mcp_action: "bclaw_coordinate(intent='ideate', preset='bootstrap')",
54
+ text: "Memory is empty and the repo has existing content → run bclaw_bootstrap (CLI: brainclaw bootstrap) to extract initial context. If the project vision is still missing afterwards, chain a bootstrap loop: bclaw_coordinate(intent='ideate', preset='bootstrap').",
55
+ };
56
+ }
57
+ return {
58
+ route: 'ideate',
59
+ reason: 'greenfield repo — nothing to extract yet',
60
+ mcp_next_action: "bclaw_coordinate(intent='ideate', preset='bootstrap')",
61
+ cli_next_action: 'brainclaw bootstrap-loop',
62
+ chained_mcp_action: 'bclaw_bootstrap()',
63
+ text: "Memory is empty and the repo is greenfield → open a bootstrap loop to ideate the project vision: bclaw_coordinate(intent='ideate', preset='bootstrap') (CLI: brainclaw bootstrap-loop). Once content exists, chain bclaw_bootstrap to extract it.",
64
+ };
65
+ }
66
+ /** Event log this large reads as a mature store, not a from-scratch case. */
67
+ const RICH_EVENT_LOG_BYTES = 64 * 1024;
68
+ /** This many memory items reads as a mature store. */
69
+ const RICH_STATE_ITEMS = 25;
70
+ /** PROJECT.md this much older than the latest repo/store activity is fossil. */
71
+ const FOSSIL_GAP_MS = 30 * 86_400_000;
72
+ function lastCommitEpochMs(cwd) {
73
+ try {
74
+ const probe = spawnSync('git', ['log', '-1', '--format=%ct'], {
75
+ cwd,
76
+ encoding: 'utf-8',
77
+ timeout: 2000,
78
+ windowsHide: true,
79
+ });
80
+ if (probe.status !== 0)
81
+ return undefined;
82
+ const epoch = Number.parseInt(probe.stdout.trim(), 10);
83
+ return Number.isFinite(epoch) ? epoch * 1000 : undefined;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * Composite replacement for the one-bit PROJECT.md stat() that backed
91
+ * `bootstrap_recommended` (pln#513). The single bit had two failure modes on
92
+ * a mature ("amorcé") project:
93
+ * - false positive: rich store, missing PROJECT.md → recommended a
94
+ * from-scratch bootstrap over 17k events of accumulated memory;
95
+ * - eternal false negative: PROJECT.md exists but fossilized — never
96
+ * flagged again no matter how far the repo drifted.
97
+ *
98
+ * Signals combined: PROJECT.md presence × its mtime vs recent activity
99
+ * (last commit, last memory write) × store density (event-log size,
100
+ * memory item count). 'refresh' maps to `bclaw_bootstrap(refresh: true)` —
101
+ * coordinate with the pln#514 step 1 force-flag on the bootstrap-loop side.
102
+ */
103
+ export function assessBootstrapNeed(cwd = process.cwd()) {
104
+ const reasons = [];
105
+ // Signal 1 — PROJECT.md presence.
106
+ const projectMdPath = path.join(cwd, 'PROJECT.md');
107
+ let projectMdPresent = false;
108
+ let projectMdMtimeMs;
109
+ try {
110
+ const stat = fs.statSync(projectMdPath);
111
+ if (stat.isFile() && stat.size > 0) {
112
+ projectMdPresent = true;
113
+ projectMdMtimeMs = stat.mtimeMs;
114
+ }
115
+ }
116
+ catch { /* absent */ }
117
+ // Signal 2 — store density.
118
+ let eventLogBytes = 0;
119
+ let eventLogMtimeMs;
120
+ try {
121
+ const stat = fs.statSync(path.join(memoryDir(cwd), 'events.jsonl'));
122
+ eventLogBytes = stat.size;
123
+ eventLogMtimeMs = stat.mtimeMs;
124
+ }
125
+ catch { /* no event log */ }
126
+ let stateItems = 0;
127
+ try {
128
+ const state = loadState(cwd);
129
+ stateItems = state.active_constraints.length + state.recent_decisions.length
130
+ + state.known_traps.length + state.open_handoffs.length + state.plan_items.length;
131
+ }
132
+ catch { /* unreadable state → 0 */ }
133
+ const storeDensity = eventLogBytes >= RICH_EVENT_LOG_BYTES || stateItems >= RICH_STATE_ITEMS
134
+ ? 'rich'
135
+ : eventLogBytes === 0 && stateItems === 0
136
+ ? 'empty'
137
+ : 'low';
138
+ if (!projectMdPresent) {
139
+ if (storeDensity === 'rich') {
140
+ // The store already knows this project — regenerate PROJECT.md from it
141
+ // (scanner + memory), do NOT restart discovery from scratch.
142
+ reasons.push(`PROJECT.md missing but the store is rich (${stateItems} items, ${Math.round(eventLogBytes / 1024)} KB events) — regenerate from existing memory instead of bootstrapping from scratch`);
143
+ return { verdict: 'refresh', reasons, project_md_present: false, store_density: storeDensity };
144
+ }
145
+ reasons.push('PROJECT.md missing and the store is sparse — initial bootstrap applies');
146
+ return { verdict: 'bootstrap', reasons, project_md_present: false, store_density: storeDensity };
147
+ }
148
+ // Signal 3 — fossil check: PROJECT.md much older than the latest activity.
149
+ const lastActivityMs = Math.max(lastCommitEpochMs(cwd) ?? 0, eventLogMtimeMs ?? 0);
150
+ if (projectMdMtimeMs !== undefined && lastActivityMs > 0 && lastActivityMs - projectMdMtimeMs > FOSSIL_GAP_MS) {
151
+ const gapDays = Math.round((lastActivityMs - projectMdMtimeMs) / 86_400_000);
152
+ reasons.push(`PROJECT.md is ${gapDays} days older than the latest repo/store activity — likely fossil, refresh it`);
153
+ return { verdict: 'refresh', reasons, project_md_present: true, store_density: storeDensity };
154
+ }
155
+ reasons.push('PROJECT.md present and current relative to recent activity');
156
+ return { verdict: 'none', reasons, project_md_present: true, store_density: storeDensity };
157
+ }
21
158
  /**
22
159
  * Probe the current working directory to understand what we're working with.
23
160
  * This is the first step of the quick setup flow — no questions yet, just detection.
@@ -37,16 +174,7 @@ export function probeForQuickSetup(cwd = process.cwd()) {
37
174
  : getAllAgentCapabilityProfiles();
38
175
  // Scan nearby stores
39
176
  const nearbyStores = resolveStoreChain(cwd);
40
- // Has content?
41
- const IGNORED = new Set(['.git', '.brainclaw', '.gitignore', '.gitattributes', '.DS_Store', 'Thumbs.db']);
42
- let hasContent = false;
43
- try {
44
- const entries = fs.readdirSync(cwd);
45
- hasContent = entries.some((e) => !IGNORED.has(e));
46
- }
47
- catch {
48
- // empty or unreadable
49
- }
177
+ const hasContent = repoHasContent(cwd);
50
178
  // Analyze repo for project type suggestion
51
179
  let suggestedProjectType = 'standalone';
52
180
  let analysisReasons = [];
@@ -164,7 +292,7 @@ export function buildOnboardingPreview(cwd) {
164
292
  const traps = state.known_traps.filter((t) => t.visibility === 'shared' && (!t.status || t.status === 'active'));
165
293
  const plans = state.plan_items.filter((p) => p.status === 'in_progress' || p.status === 'todo');
166
294
  if (constraints.length === 0 && traps.length === 0 && plans.length === 0) {
167
- return 'Memory is empty. Run bclaw_bootstrap to extract initial context from this repo.';
295
+ return resolveEmptyMemoryRecommendation(cwd).text;
168
296
  }
169
297
  const lines = ['Here is what your agent will see:'];
170
298
  if (constraints.length > 0) {
@@ -185,7 +313,7 @@ export function buildOnboardingPreview(cwd) {
185
313
  return lines.join('\n');
186
314
  }
187
315
  catch {
188
- return 'Memory is empty. Run bclaw_bootstrap to extract initial context from this repo.';
316
+ return resolveEmptyMemoryRecommendation(cwd).text;
189
317
  }
190
318
  }
191
319
  //# sourceMappingURL=setup-flow.js.map
@@ -17,14 +17,33 @@
17
17
  import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
20
21
  import { buildInvokeCommand, getSpawnableAgents, getCapabilityProfile, } from './agent-capability.js';
21
22
  import { defaultExecutionAdapter, resolveBinaryOnPath } from './execution-adapters.js';
22
23
  import { signalExists, readLogTail } from './runtime-signals.js';
24
+ import { recognizeStderrSignature } from './dispatch-status.js';
23
25
  const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
24
26
  const DEFAULT_PROBE_PROMPT = 'Reply with exactly: OK';
25
27
  async function sleep(ms) {
26
28
  return new Promise((r) => setTimeout(r, ms));
27
29
  }
30
+ /**
31
+ * Make the probe's temp dir a real (empty) git repo so the round-trip is
32
+ * representative of a real dispatch (workers always run inside a git worktree)
33
+ * and so CLIs with a boot-time git-repo / trusted-directory check don't refuse
34
+ * it (pln#533 fix). Best-effort: if git is unavailable the probe still runs.
35
+ */
36
+ function initProbeGitRepo(root) {
37
+ try {
38
+ const run = (...args) => spawnSync('git', args, { cwd: root, encoding: 'utf-8', timeout: 5000 });
39
+ run('init', '-q');
40
+ run('config', 'user.email', 'spawn-check@brainclaw.local');
41
+ run('config', 'user.name', 'brainclaw spawn-check');
42
+ run('config', 'commit.gpgsign', 'false');
43
+ run('commit', '--allow-empty', '-q', '-m', 'spawn-check probe');
44
+ }
45
+ catch { /* git absent or failed — probe proceeds without it */ }
46
+ }
28
47
  /** Check one agent's spawn round-trip. Exposed for focused testing. */
29
48
  export async function checkAgentSpawn(agent, options = {}) {
30
49
  const start = Date.now();
@@ -43,6 +62,11 @@ export async function checkAgentSpawn(agent, options = {}) {
43
62
  }
44
63
  // Isolated signals root so the probe never pollutes the project's runtime dir.
45
64
  const root = fs.mkdtempSync(path.join(os.tmpdir(), `bclaw-spawncheck-${agent}-`));
65
+ // pln#533 fix: make the probe dir a real git repo. Real workers run inside a
66
+ // git worktree, and some CLIs refuse a non-git / untrusted dir at boot (codex:
67
+ // "Not inside a trusted directory and --skip-git-repo-check was not specified")
68
+ // — a non-git temp dir would otherwise produce a false-negative spawn failure.
69
+ initProbeGitRepo(root);
46
70
  const assignmentId = 'spawn_check';
47
71
  try {
48
72
  defaultExecutionAdapter.start(invoke, { agent, assignmentId, ackRoot: root, worktreePath: root });
@@ -59,17 +83,21 @@ export async function checkAgentSpawn(agent, options = {}) {
59
83
  const completed = signalExists(root, assignmentId, 'completed');
60
84
  const failed = signalExists(root, assignmentId, 'failed');
61
85
  const duration_ms = Date.now() - start;
86
+ // Capture the stderr tail once (used both for the detail string and for
87
+ // pln#533 boot-signature recognition on the preflight path).
88
+ const stderrRaw = readLogTail(root, assignmentId, 'stderr', 800).trim();
89
+ const stderrTail = stderrRaw ? stderrRaw.split(/\r?\n/).filter(Boolean) : undefined;
62
90
  if (completed) {
63
91
  return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
64
92
  }
65
93
  if (failed) {
66
- const tail = readLogTail(root, assignmentId, 'stderr', 400).trim() || readLogTail(root, assignmentId, 'stdout', 400).trim();
67
- return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}` };
94
+ const tail = stderrRaw || readLogTail(root, assignmentId, 'stdout', 400).trim();
95
+ return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`, stderr_tail: stderrTail };
68
96
  }
69
97
  if (delivered) {
70
- return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)` };
98
+ return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)`, stderr_tail: stderrTail };
71
99
  }
72
- return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed` };
100
+ return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed`, stderr_tail: stderrTail };
73
101
  }
74
102
  finally {
75
103
  try {
@@ -122,4 +150,82 @@ export function renderSpawnCheckReport(report) {
122
150
  }
123
151
  return lines.join('\n');
124
152
  }
153
+ /**
154
+ * Pre-flight a single target agent. Pass criteria:
155
+ * - `ok` (ack + completed) → pass.
156
+ * - `delivered_no_completion` (ack but the trivial probe didn't finish in the
157
+ * short window) → PASS: the ack proves spawn + wrapper + delivery work; a
158
+ * boot death never acks. We don't want a slow-but-healthy agent to block.
159
+ * - `failed` / no-ack → BLOCK with a reason (enriched by a recognized boot
160
+ * signature when the stderr matches one).
161
+ * - `not_installed` / `no_template` → BLOCK: the agent cannot be spawned here,
162
+ * so opening a loop on it would only time out.
163
+ * When BRAINCLAW_NO_SPAWN is set (tests/CI), pre-flight is skipped (ok:true).
164
+ */
165
+ /**
166
+ * Pure mapper: SpawnCheckEntry → PreflightResult. No spawning — exposed so the
167
+ * pass/block policy (and the boot-signature enrichment) can be unit-tested with
168
+ * synthetic entries.
169
+ */
170
+ export function preflightResultFromEntry(entry) {
171
+ const agent = entry.agent;
172
+ if (entry.status === 'ok' || entry.status === 'delivered_no_completion') {
173
+ return { agent, ok: true, status: entry.status, reason: entry.detail };
174
+ }
175
+ if (entry.status === 'not_installed') {
176
+ return {
177
+ agent, ok: false, status: entry.status,
178
+ reason: `${agent} binary not on PATH — cannot spawn it here`,
179
+ recommended_next_action: `Install the ${agent} CLI (or target a different agent), then retry.`,
180
+ };
181
+ }
182
+ if (entry.status === 'no_template') {
183
+ return {
184
+ agent, ok: false, status: entry.status,
185
+ reason: `${agent} has no CLI spawn template — it cannot be auto-dispatched (IDE-only?)`,
186
+ recommended_next_action: `Target a CLI-spawnable agent, or hand this work to ${agent} interactively.`,
187
+ };
188
+ }
189
+ // failed (or no-ack) — try to attach a recognized boot signature.
190
+ const sig = recognizeStderrSignature(entry.stderr_tail);
191
+ return {
192
+ agent, ok: false, status: entry.status,
193
+ reason: sig?.summary ?? `${agent} failed its pre-flight spawn — ${entry.detail}`,
194
+ recommended_next_action: sig?.recommended_next_action
195
+ ?? `Inspect the ${agent} CLI config/auth (run \`brainclaw doctor --spawn-check\` for detail), fix it, then retry.`,
196
+ };
197
+ }
198
+ export async function preflightAgentSpawn(agent, options = {}) {
199
+ if (process.env.BRAINCLAW_NO_SPAWN === '1') {
200
+ return { agent, ok: true, status: 'skipped', reason: 'pre-flight skipped (BRAINCLAW_NO_SPAWN)' };
201
+ }
202
+ // Pre-flight uses a tighter window than the full doctor round-trip: a boot
203
+ // death fails fast, and an ack is enough to pass, so we don't need to wait
204
+ // out a healthy agent's full probe completion.
205
+ const entry = await checkAgentSpawn(agent, { timeoutMs: 8_000, ...options });
206
+ return preflightResultFromEntry(entry);
207
+ }
208
+ /**
209
+ * Pre-flight a set of target agents (deduped), one trivial probe each. Returns
210
+ * the per-agent results plus `blocked` (the agents that failed). Callers use
211
+ * `blocked` to skip those agents and surface their reasons instead of opening a
212
+ * loop / dispatching work that would only time out.
213
+ */
214
+ export async function preflightAgents(agents, options = {}) {
215
+ const unique = [...new Set(agents)];
216
+ const results = [];
217
+ for (const agent of unique) {
218
+ try {
219
+ results.push(await preflightAgentSpawn(agent, options));
220
+ }
221
+ catch (err) {
222
+ results.push({
223
+ agent, ok: false, status: 'failed',
224
+ reason: `pre-flight threw: ${err instanceof Error ? err.message : String(err)}`,
225
+ });
226
+ }
227
+ }
228
+ const blocked = results.filter((r) => !r.ok);
229
+ return { results, blocked, all_ok: blocked.length === 0 };
230
+ }
125
231
  //# sourceMappingURL=spawn-check.js.map
@@ -4,6 +4,8 @@
4
4
  * Staleness is a soft signal — items are warned, not auto-archived.
5
5
  * Users choose to dismiss, resolve, or archive via explicit commands.
6
6
  */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
7
9
  import { resolvedSource } from './candidates.js';
8
10
  /** Thresholds in days. Adjust via config in the future. */
9
11
  export const STALENESS_THRESHOLDS = {
@@ -100,6 +102,101 @@ export function detectExpiredTraps(traps, nowIso = new Date().toISOString(), now
100
102
  }
101
103
  return warnings;
102
104
  }
105
+ /** pln#530 — a perishable fact unverified for longer than this reads as stale. */
106
+ const VERIFIED_STALE_DAYS = 30;
107
+ /**
108
+ * pln#530 — flag perishable memories (traps that opted in by carrying a
109
+ * `verify_cmd` and/or `verified_at`) whose last empirical verification is stale
110
+ * or never happened, so an agent re-probes the live system instead of trusting a
111
+ * value that may have drifted (the LeaseUp `service_tier` trap that the API later
112
+ * rejected is the motivating case). Only traps with these fields are considered —
113
+ * durable facts are untouched.
114
+ */
115
+ export function detectUnverifiedMemory(traps, nowMs = Date.now()) {
116
+ const warnings = [];
117
+ for (const trap of traps) {
118
+ if (trap.status !== 'active')
119
+ continue;
120
+ if (!trap.verify_cmd && !trap.verified_at)
121
+ continue; // opt-in: only perishable facts
122
+ const age = trap.verified_at ? ageDays(trap.verified_at, nowMs) : Infinity;
123
+ if (trap.verified_at && age < VERIFIED_STALE_DAYS)
124
+ continue; // freshly verified
125
+ warnings.push({
126
+ id: trap.id,
127
+ entity: 'trap',
128
+ text: truncate(trap.text),
129
+ age_days: Number.isFinite(age) ? age : 9999,
130
+ reason: trap.verified_at
131
+ ? `Perishable fact last verified ${age} day${age === 1 ? '' : 's'} ago — re-confirm against the live system before trusting`
132
+ : `Perishable fact never empirically verified (verify_cmd set) — confirm before trusting`,
133
+ suggested_action: trap.verify_cmd
134
+ ? `Run \`${trap.verify_cmd}\`, then bclaw_update(trap, ${trap.short_label ?? trap.id}, { verified_at: <now> })`
135
+ : `Re-verify against the live system, then set verified_at via bclaw_update`,
136
+ });
137
+ }
138
+ return warnings;
139
+ }
140
+ /** Glob chars / URL schemes — entries we cannot meaningfully existsSync. */
141
+ const NON_CHECKABLE_PATH = /[*?[\]]|:\/\//;
142
+ /**
143
+ * Detect memory entities whose `related_paths` point at files that no longer
144
+ * exist — memory that stayed "confident" through a refactor and is now wrong.
145
+ * Staleness today is purely temporal; this is the structural complement
146
+ * (pln#557 step 2, bridge to pln_79a995b6 memory-lifecycle confirm/decay).
147
+ *
148
+ * Only plain paths are probed: glob patterns and URLs are skipped, and a
149
+ * single missing path among several is enough to flag (the warning lists
150
+ * exactly which ones are gone).
151
+ */
152
+ export function detectDeadReferences(items, projectRoot, nowMs = Date.now()) {
153
+ const warnings = [];
154
+ for (const item of items) {
155
+ if (!item.related_paths || item.related_paths.length === 0)
156
+ continue;
157
+ const missing = [];
158
+ for (const ref of item.related_paths) {
159
+ const trimmed = ref.trim();
160
+ if (!trimmed || NON_CHECKABLE_PATH.test(trimmed))
161
+ continue;
162
+ const resolved = path.isAbsolute(trimmed) ? trimmed : path.join(projectRoot, trimmed);
163
+ try {
164
+ if (!fs.existsSync(resolved))
165
+ missing.push(trimmed);
166
+ }
167
+ catch { /* unreadable path — treat as non-checkable, not dead */ }
168
+ }
169
+ if (missing.length === 0)
170
+ continue;
171
+ warnings.push({
172
+ id: item.id,
173
+ entity: item.entity,
174
+ text: truncate(item.text),
175
+ age_days: ageDays(item.created_at, nowMs),
176
+ reason: `References missing path${missing.length > 1 ? 's' : ''}: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` (+${missing.length - 3} more)` : ''} — likely stale after a refactor`,
177
+ suggested_action: `bclaw_update(entity: "${item.entity}", id: "${item.short_label ?? item.id}", { related_paths: [<current paths>] }) # or archive if obsolete`,
178
+ });
179
+ }
180
+ return warnings;
181
+ }
182
+ /** Map active entities into the dead-reference detector's input shape. */
183
+ export function collectDeadReferenceCandidates(scan) {
184
+ const items = [];
185
+ for (const d of scan.decisions ?? []) {
186
+ items.push({ id: d.id, entity: 'decision', text: d.text, created_at: d.created_at, related_paths: d.related_paths, short_label: d.short_label });
187
+ }
188
+ for (const c of scan.constraints ?? []) {
189
+ if ((c.status ?? 'active') !== 'active')
190
+ continue;
191
+ items.push({ id: c.id, entity: 'constraint', text: c.text, created_at: c.created_at, related_paths: c.related_paths, short_label: c.short_label });
192
+ }
193
+ for (const t of scan.traps ?? []) {
194
+ if (t.status !== 'active')
195
+ continue;
196
+ items.push({ id: t.id, entity: 'trap', text: t.text, created_at: t.created_at, related_paths: t.related_paths, short_label: t.short_label });
197
+ }
198
+ return items;
199
+ }
103
200
  /**
104
201
  * Detect open handoffs that have not been acted on for a long time.
105
202
  */
@@ -203,16 +300,24 @@ export function detectStaleRuntimeNotes(notes, nowMs = Date.now(), thresholds =
203
300
  * @param candidates Pending candidates
204
301
  * @param nowMs Optional timestamp override (for testing)
205
302
  */
206
- export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date.now(), runtimeNotes = []) {
303
+ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date.now(), runtimeNotes = [], deadRefScan) {
207
304
  const nowIso = new Date(nowMs).toISOString();
208
305
  const planWarnings = detectStalePlans(plans, nowMs);
209
306
  const trapWarnings = detectExpiredTraps(traps, nowIso, nowMs);
307
+ const unverifiedWarnings = detectUnverifiedMemory(traps, nowMs); // pln#530
210
308
  const handoffWarnings = detectStaleHandoffs(handoffs, nowMs);
211
309
  const candidateWarnings = detectStaleCandidates(candidates, nowMs);
212
310
  const noteWarnings = detectStaleRuntimeNotes(runtimeNotes, nowMs);
311
+ // pln#557 step 2 — structural staleness: dead related_paths. The trap list
312
+ // for the scan defaults to the traps already passed in.
313
+ const deadRefWarnings = deadRefScan
314
+ ? detectDeadReferences(collectDeadReferenceCandidates({ traps, ...deadRefScan }), deadRefScan.projectRoot, nowMs)
315
+ : [];
213
316
  const warnings = [
317
+ ...deadRefWarnings,
214
318
  ...planWarnings,
215
319
  ...trapWarnings,
320
+ ...unverifiedWarnings,
216
321
  ...handoffWarnings,
217
322
  ...candidateWarnings,
218
323
  ...noteWarnings,
@@ -224,6 +329,7 @@ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date
224
329
  handoff_count: handoffWarnings.length,
225
330
  candidate_count: candidateWarnings.length,
226
331
  runtime_note_count: noteWarnings.length,
332
+ ...(deadRefScan ? { dead_reference_count: deadRefWarnings.length } : {}),
227
333
  };
228
334
  }
229
335
  /** Total warning count across all entity types. */
@@ -241,6 +347,8 @@ export function staleSummary(report) {
241
347
  parts.push(`${report.candidate_count} pending candidate${report.candidate_count > 1 ? 's' : ''}`);
242
348
  if (report.runtime_note_count > 0)
243
349
  parts.push(`${report.runtime_note_count} stale runtime note${report.runtime_note_count > 1 ? 's' : ''}`);
350
+ if ((report.dead_reference_count ?? 0) > 0)
351
+ parts.push(`${report.dead_reference_count} item${report.dead_reference_count > 1 ? 's' : ''} with dead file references`);
244
352
  return parts.join(', ');
245
353
  }
246
354
  //# sourceMappingURL=staleness.js.map