brainclaw 1.8.0 → 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 (140) hide show
  1. package/README.md +12 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -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 +285 -22
  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 +588 -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 +79 -5
  55. package/dist/core/dispatcher.js +64 -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/facade-schema.js +38 -0
  67. package/dist/core/gc-semantic.js +130 -5
  68. package/dist/core/handoff-snapshot.js +68 -0
  69. package/dist/core/ids.js +19 -8
  70. package/dist/core/instruction-templates.js +34 -115
  71. package/dist/core/io.js +39 -3
  72. package/dist/core/json-store.js +10 -1
  73. package/dist/core/lock.js +153 -28
  74. package/dist/core/loops/bootstrap-acquire.js +25 -1
  75. package/dist/core/loops/facade-schema.js +2 -0
  76. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  77. package/dist/core/loops/index.js +1 -0
  78. package/dist/core/loops/presets/bootstrap.js +7 -0
  79. package/dist/core/loops/store.js +17 -0
  80. package/dist/core/loops/verbs.js +24 -1
  81. package/dist/core/markdown.js +8 -76
  82. package/dist/core/mcp-command-resolution.js +245 -0
  83. package/dist/core/memory-compactor.js +5 -3
  84. package/dist/core/memory-lifecycle.js +282 -0
  85. package/dist/core/merge-risk.js +150 -0
  86. package/dist/core/messaging.js +8 -1
  87. package/dist/core/migration.js +11 -1
  88. package/dist/core/observer-mode.js +26 -0
  89. package/dist/core/operations/memory-mutation.js +90 -65
  90. package/dist/core/operations/plan.js +27 -1
  91. package/dist/core/protocol-skills.js +210 -0
  92. package/dist/core/reflection-safety.js +6 -7
  93. package/dist/core/reputation.js +84 -2
  94. package/dist/core/runtime-signals.js +71 -9
  95. package/dist/core/runtime.js +84 -1
  96. package/dist/core/schema.js +114 -0
  97. package/dist/core/security-detectors.js +125 -0
  98. package/dist/core/security-extract.js +189 -0
  99. package/dist/core/security-guard.js +107 -29
  100. package/dist/core/security-packages.js +121 -0
  101. package/dist/core/security-scoring.js +76 -9
  102. package/dist/core/security.js +34 -2
  103. package/dist/core/sequence.js +11 -2
  104. package/dist/core/setup-flow.js +141 -13
  105. package/dist/core/staleness.js +72 -1
  106. package/dist/core/state.js +250 -54
  107. package/dist/core/store-resolution.js +19 -5
  108. package/dist/core/worktree.js +72 -8
  109. package/dist/facts.js +8 -8
  110. package/dist/facts.json +7 -7
  111. package/docs/PROTOCOL.md +223 -0
  112. package/docs/cli.md +11 -10
  113. package/docs/concepts/coordinator-runbook.md +129 -0
  114. package/docs/concepts/event-log-store-critique-A.md +333 -0
  115. package/docs/concepts/event-log-store-critique-B.md +353 -0
  116. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  117. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  118. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  119. package/docs/concepts/event-log-store.md +928 -0
  120. package/docs/concepts/identity-model-proposal.md +371 -0
  121. package/docs/concepts/memory.md +5 -4
  122. package/docs/concepts/observer-protocol.md +361 -0
  123. package/docs/concepts/parallel-merge-protocol.md +71 -0
  124. package/docs/concepts/plans-and-claims.md +43 -0
  125. package/docs/concepts/skills.md +78 -0
  126. package/docs/concepts/workspace-bootstrapping.md +61 -0
  127. package/docs/integrations/agents.md +4 -4
  128. package/docs/integrations/cline.md +10 -11
  129. package/docs/integrations/codex.md +2 -2
  130. package/docs/integrations/continue.md +5 -5
  131. package/docs/integrations/copilot.md +14 -12
  132. package/docs/integrations/openclaw.md +7 -6
  133. package/docs/integrations/overview.md +7 -7
  134. package/docs/integrations/roo.md +3 -3
  135. package/docs/integrations/windsurf.md +6 -6
  136. package/docs/mcp-schema-changelog.md +29 -2
  137. package/docs/quickstart.md +48 -47
  138. package/docs/security.md +174 -15
  139. package/docs/storage.md +4 -2
  140. 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
@@ -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 = {
@@ -135,6 +137,66 @@ export function detectUnverifiedMemory(traps, nowMs = Date.now()) {
135
137
  }
136
138
  return warnings;
137
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
+ }
138
200
  /**
139
201
  * Detect open handoffs that have not been acted on for a long time.
140
202
  */
@@ -238,7 +300,7 @@ export function detectStaleRuntimeNotes(notes, nowMs = Date.now(), thresholds =
238
300
  * @param candidates Pending candidates
239
301
  * @param nowMs Optional timestamp override (for testing)
240
302
  */
241
- export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date.now(), runtimeNotes = []) {
303
+ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date.now(), runtimeNotes = [], deadRefScan) {
242
304
  const nowIso = new Date(nowMs).toISOString();
243
305
  const planWarnings = detectStalePlans(plans, nowMs);
244
306
  const trapWarnings = detectExpiredTraps(traps, nowIso, nowMs);
@@ -246,7 +308,13 @@ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date
246
308
  const handoffWarnings = detectStaleHandoffs(handoffs, nowMs);
247
309
  const candidateWarnings = detectStaleCandidates(candidates, nowMs);
248
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
+ : [];
249
316
  const warnings = [
317
+ ...deadRefWarnings,
250
318
  ...planWarnings,
251
319
  ...trapWarnings,
252
320
  ...unverifiedWarnings,
@@ -261,6 +329,7 @@ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date
261
329
  handoff_count: handoffWarnings.length,
262
330
  candidate_count: candidateWarnings.length,
263
331
  runtime_note_count: noteWarnings.length,
332
+ ...(deadRefScan ? { dead_reference_count: deadRefWarnings.length } : {}),
264
333
  };
265
334
  }
266
335
  /** Total warning count across all entity types. */
@@ -278,6 +347,8 @@ export function staleSummary(report) {
278
347
  parts.push(`${report.candidate_count} pending candidate${report.candidate_count > 1 ? 's' : ''}`);
279
348
  if (report.runtime_note_count > 0)
280
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`);
281
352
  return parts.join(', ');
282
353
  }
283
354
  //# sourceMappingURL=staleness.js.map