brainclaw 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +592 -505
  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 +286 -23
  15. package/dist/commands/hooks.js +73 -73
  16. package/dist/commands/init.js +124 -22
  17. package/dist/commands/install-hooks.js +78 -78
  18. package/dist/commands/loops-handlers.js +4 -0
  19. package/dist/commands/mcp-read-handlers.js +253 -41
  20. package/dist/commands/mcp.js +664 -102
  21. package/dist/commands/memory.js +21 -17
  22. package/dist/commands/migrate.js +81 -17
  23. package/dist/commands/prune.js +78 -4
  24. package/dist/commands/reflect.js +26 -20
  25. package/dist/commands/register-agent.js +57 -1
  26. package/dist/commands/repair.js +20 -0
  27. package/dist/commands/session-end.js +15 -6
  28. package/dist/commands/session-start.js +18 -1
  29. package/dist/commands/setup-security.js +39 -18
  30. package/dist/commands/setup.js +26 -27
  31. package/dist/commands/stale.js +16 -2
  32. package/dist/commands/switch.js +26 -5
  33. package/dist/commands/uninstall.js +126 -34
  34. package/dist/commands/update-step.js +6 -0
  35. package/dist/commands/version.js +1 -1
  36. package/dist/commands/worktree.js +60 -0
  37. package/dist/core/actions.js +12 -3
  38. package/dist/core/agent-capability.js +30 -17
  39. package/dist/core/agent-files.js +963 -666
  40. package/dist/core/agent-integrations.js +0 -3
  41. package/dist/core/agent-inventory.js +67 -0
  42. package/dist/core/agent-registry.js +163 -29
  43. package/dist/core/agentrun-reconciler.js +33 -2
  44. package/dist/core/agentruns.js +7 -1
  45. package/dist/core/ai-agent-detection.js +31 -44
  46. package/dist/core/archival.js +15 -9
  47. package/dist/core/assignment-reconciler.js +56 -0
  48. package/dist/core/assignment-sweeper.js +127 -4
  49. package/dist/core/assignments.js +69 -11
  50. package/dist/core/bootstrap.js +233 -67
  51. package/dist/core/brainclaw-version.js +22 -0
  52. package/dist/core/candidates.js +21 -1
  53. package/dist/core/claims.js +313 -150
  54. package/dist/core/codev-prompts.js +38 -38
  55. package/dist/core/config.js +6 -1
  56. package/dist/core/context-diff.js +148 -20
  57. package/dist/core/context.js +129 -8
  58. package/dist/core/coordination.js +22 -3
  59. package/dist/core/default-profiles/doctor.yaml +11 -11
  60. package/dist/core/default-profiles/janitor.yaml +11 -11
  61. package/dist/core/default-profiles/onboarder.yaml +11 -11
  62. package/dist/core/default-profiles/reviewer.yaml +13 -13
  63. package/dist/core/dispatch-status.js +79 -5
  64. package/dist/core/dispatcher.js +65 -12
  65. package/dist/core/entity-operations.js +74 -27
  66. package/dist/core/entity-registry.js +31 -5
  67. package/dist/core/event-log.js +138 -21
  68. package/dist/core/events/checkpoint.js +258 -0
  69. package/dist/core/events/genesis.js +220 -0
  70. package/dist/core/events/journal.js +507 -0
  71. package/dist/core/events/materialize.js +126 -0
  72. package/dist/core/events/registry-post-image.js +110 -0
  73. package/dist/core/events/verify.js +109 -0
  74. package/dist/core/execution-adapters.js +23 -0
  75. package/dist/core/execution.js +1 -1
  76. package/dist/core/facade-schema.js +38 -0
  77. package/dist/core/gc-semantic.js +130 -5
  78. package/dist/core/handoff-snapshot.js +68 -0
  79. package/dist/core/ids.js +19 -8
  80. package/dist/core/instruction-templates.js +34 -115
  81. package/dist/core/io.js +39 -3
  82. package/dist/core/json-store.js +10 -1
  83. package/dist/core/lock.js +153 -28
  84. package/dist/core/loops/bootstrap-acquire.js +25 -1
  85. package/dist/core/loops/facade-schema.js +2 -0
  86. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  87. package/dist/core/loops/index.js +1 -0
  88. package/dist/core/loops/presets/bootstrap.js +7 -0
  89. package/dist/core/loops/store.js +17 -0
  90. package/dist/core/loops/verbs.js +24 -2
  91. package/dist/core/markdown.js +8 -76
  92. package/dist/core/mcp-command-resolution.js +245 -0
  93. package/dist/core/memory-compactor.js +5 -3
  94. package/dist/core/memory-lifecycle.js +282 -0
  95. package/dist/core/merge-risk.js +150 -0
  96. package/dist/core/messaging.js +10 -3
  97. package/dist/core/migration.js +11 -1
  98. package/dist/core/observer-mode.js +26 -0
  99. package/dist/core/operations/memory-mutation.js +90 -65
  100. package/dist/core/operations/plan.js +27 -1
  101. package/dist/core/protocol-skills.js +210 -0
  102. package/dist/core/reflection-safety.js +6 -7
  103. package/dist/core/reputation.js +84 -2
  104. package/dist/core/runtime-signals.js +72 -10
  105. package/dist/core/runtime.js +84 -1
  106. package/dist/core/schema.js +114 -0
  107. package/dist/core/search.js +19 -2
  108. package/dist/core/security-detectors.js +125 -0
  109. package/dist/core/security-extract.js +189 -0
  110. package/dist/core/security-guard.js +217 -139
  111. package/dist/core/security-packages.js +121 -0
  112. package/dist/core/security-scoring.js +76 -9
  113. package/dist/core/security.js +34 -2
  114. package/dist/core/sequence.js +11 -2
  115. package/dist/core/setup-flow.js +141 -13
  116. package/dist/core/spawn-check.js +16 -2
  117. package/dist/core/staleness.js +73 -2
  118. package/dist/core/state.js +250 -54
  119. package/dist/core/store-resolution.js +45 -12
  120. package/dist/core/worktree.js +90 -26
  121. package/dist/facts.js +8 -8
  122. package/dist/facts.json +7 -7
  123. package/docs/PROTOCOL.md +223 -0
  124. package/docs/adapters/openclaw.md +43 -43
  125. package/docs/architecture/project-refs.md +328 -328
  126. package/docs/cli.md +2097 -2096
  127. package/docs/concepts/coordination.md +52 -52
  128. package/docs/concepts/coordinator-runbook.md +129 -0
  129. package/docs/concepts/dispatch-lifecycle.md +245 -245
  130. package/docs/concepts/event-log-store.md +928 -0
  131. package/docs/concepts/ideation-loop.md +317 -317
  132. package/docs/concepts/loop-engine.md +520 -511
  133. package/docs/concepts/mcp-governance.md +268 -268
  134. package/docs/concepts/memory.md +89 -88
  135. package/docs/concepts/multi-agent-workflows.md +167 -167
  136. package/docs/concepts/observer-protocol.md +361 -0
  137. package/docs/concepts/parallel-merge-protocol.md +71 -0
  138. package/docs/concepts/plans-and-claims.md +217 -174
  139. package/docs/concepts/project-md-convention.md +35 -35
  140. package/docs/concepts/runtime-notes.md +38 -38
  141. package/docs/concepts/skills.md +78 -0
  142. package/docs/concepts/troubleshooting.md +254 -254
  143. package/docs/concepts/workspace-bootstrapping.md +142 -81
  144. package/docs/context-format-changelog.md +35 -35
  145. package/docs/context-format.md +48 -48
  146. package/docs/index.md +65 -65
  147. package/docs/integrations/agents.md +162 -162
  148. package/docs/integrations/claude-code.md +23 -23
  149. package/docs/integrations/cline.md +87 -88
  150. package/docs/integrations/codex.md +2 -2
  151. package/docs/integrations/continue.md +60 -60
  152. package/docs/integrations/copilot.md +82 -80
  153. package/docs/integrations/cursor.md +23 -23
  154. package/docs/integrations/kilocode.md +72 -72
  155. package/docs/integrations/mcp.md +377 -377
  156. package/docs/integrations/mistral-vibe.md +122 -122
  157. package/docs/integrations/openclaw.md +99 -98
  158. package/docs/integrations/opencode.md +84 -84
  159. package/docs/integrations/overview.md +122 -122
  160. package/docs/integrations/roo.md +74 -74
  161. package/docs/integrations/windsurf.md +83 -83
  162. package/docs/mcp-schema-changelog.md +360 -329
  163. package/docs/playbooks/integration/index.md +121 -121
  164. package/docs/playbooks/orchestration.md +37 -0
  165. package/docs/playbooks/productivity/index.md +99 -99
  166. package/docs/playbooks/team/index.md +117 -117
  167. package/docs/product/agent-first-model.md +184 -184
  168. package/docs/product/entity-model-audit.md +462 -462
  169. package/docs/product/positioning.md +86 -86
  170. package/docs/quickstart-existing-project.md +107 -107
  171. package/docs/quickstart.md +148 -147
  172. package/docs/release-maintenance.md +79 -79
  173. package/docs/reputation.md +52 -52
  174. package/docs/review.md +45 -45
  175. package/docs/security.md +212 -53
  176. package/docs/server-operations.md +118 -118
  177. package/docs/storage.md +110 -108
  178. package/package.json +86 -69
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Registry / coordination family post-images (pln#568, phase 1.5).
3
+ *
4
+ * The 5 memory families (constraint/decision/trap/handoff/plan) journal full
5
+ * post-images via state.ts `emitPerEntityJournalRecords`. The registry /
6
+ * coordination families persist through their own JsonStore chokepoints and
7
+ * historically reached the journal envelope-only (registry-lifecycle, 0
8
+ * payload) or not at all (trp_2a89ae97) — so the observer could never rebuild
9
+ * them from the journal and had to seed them from an MCP `board_summary`.
10
+ *
11
+ * This is their post-image emit, mirroring the memory path: ONE entity-state
12
+ * `create`/`update` record carrying the byte-faithful prepared document (the
13
+ * same bytes JsonStore.save writes to the projection file) plus a
14
+ * journal-assigned `entity_rev`. Because the materialize reducer already
15
+ * upserts ANY entity-state record (materialize.ts `applyRecordsToLive`), no
16
+ * reducer change is needed — the families light up the moment these records
17
+ * appear.
18
+ *
19
+ * I2 — JOURNAL BEFORE PROJECTION. Callers MUST invoke {@link emitRegistryPostImage}
20
+ * BEFORE writing the projection file (and inside the same mutation lock), so a
21
+ * crash mid-persist can only leave the journal AHEAD of the projection (the one
22
+ * direction lazy-reconcile can recover), never the projection ahead. Use
23
+ * {@link registryFaultPoint} between the emit and the file write to test it.
24
+ *
25
+ * The journal `item_type` deliberately matches the observer's `ARRAY_SLOT`
26
+ * keys (vscode-extension/src/board-projection.ts) so a post-image flows
27
+ * straight into the right board slot with no consumer change. Note the one
28
+ * non-obvious mapping: action_required entities journal under item_type
29
+ * `state` (the slot the observer already reserved for them), not `action`.
30
+ *
31
+ * @module
32
+ */
33
+ import { appendJournalRecords, resolveJournalMode } from './journal.js';
34
+ import { preparePersistedDocument } from '../migration.js';
35
+ /**
36
+ * The registry / coordination families that journal entity-state post-images.
37
+ * `action`'s journal item_type is `state` (not `action`) to match the slot the
38
+ * observer reserved for action_required entities.
39
+ */
40
+ export const REGISTRY_FAMILIES = {
41
+ claim: { journalItemType: 'claim', docType: 'claim' },
42
+ assignment: { journalItemType: 'assignment', docType: 'assignment' },
43
+ agent_run: { journalItemType: 'agent_run', docType: 'agent_run' },
44
+ action: { journalItemType: 'state', docType: 'action_required' },
45
+ candidate: { journalItemType: 'candidate', docType: 'candidate' },
46
+ runtime_note: { journalItemType: 'runtime_note', docType: 'runtime_note' },
47
+ sequence: { journalItemType: 'sequence', docType: 'sequence' },
48
+ };
49
+ /**
50
+ * Journal item_types that carry a registry post-image. The dual-write envelope
51
+ * suppression (event-log.ts) keys off this so these families appear in the v2
52
+ * journal ONLY as post-images, never as the legacy envelope-only lifecycle
53
+ * record. Derived from REGISTRY_FAMILIES so the two can never drift.
54
+ */
55
+ export const REGISTRY_POST_IMAGE_ITEM_TYPES = new Set(Object.values(REGISTRY_FAMILIES)
56
+ .map((spec) => spec.journalItemType)
57
+ // `state` is excluded: the coarse persist store_marker also uses item_type
58
+ // `state` (without an item_id) and must keep dual-writing its journal_note.
59
+ // The action post-image is disambiguated by its item_id, so its OWN emit
60
+ // path (emitRegistryPostImage) is what journals it — not the envelope.
61
+ .filter((itemType) => itemType !== 'state'));
62
+ /**
63
+ * Emit ONE entity-state post-image for a registry/coordination entity. No-op
64
+ * when the journal is off. Failures are swallowed inside appendJournalRecords
65
+ * (dual mode: the v1 projection remains the source of truth).
66
+ */
67
+ export function emitRegistryPostImage(family, item, opts = {}) {
68
+ if (resolveJournalMode(opts.cwd) === 'off')
69
+ return;
70
+ const spec = REGISTRY_FAMILIES[family];
71
+ appendJournalRecords([{
72
+ action: opts.created ? 'create' : 'update',
73
+ item_type: spec.journalItemType,
74
+ item_id: item.id,
75
+ agent: opts.agent ?? 'system',
76
+ agent_id: opts.agent_id,
77
+ session_id: opts.session_id,
78
+ payload: preparePersistedDocument(spec.docType, item),
79
+ }], opts.cwd);
80
+ }
81
+ /**
82
+ * Emit a tombstone for a registry entity whose projection file is removed
83
+ * (e.g. a candidate that left the pending inbox). No-op when the journal is off.
84
+ */
85
+ export function emitRegistryTombstone(family, id, opts = {}) {
86
+ if (resolveJournalMode(opts.cwd) === 'off')
87
+ return;
88
+ const spec = REGISTRY_FAMILIES[family];
89
+ appendJournalRecords([{
90
+ action: 'delete',
91
+ item_type: spec.journalItemType,
92
+ item_id: id,
93
+ agent: opts.agent ?? 'system',
94
+ agent_id: opts.agent_id,
95
+ session_id: opts.session_id,
96
+ }], opts.cwd);
97
+ }
98
+ /**
99
+ * Test-only crash injection (mirrors state.ts `faultPoint`). No-op unless
100
+ * BRAINCLAW_FAULT_POINT matches — then it throws, simulating a process death at
101
+ * that exact point so the I2 journal-before-projection ordering can be tested
102
+ * deterministically. Callers place it BETWEEN the post-image emit and the
103
+ * projection file write.
104
+ */
105
+ export function registryFaultPoint(label) {
106
+ if (process.env.BRAINCLAW_FAULT_POINT === label) {
107
+ throw new Error(`fault-injection: crashed at "${label}" (BRAINCLAW_FAULT_POINT)`);
108
+ }
109
+ }
110
+ //# sourceMappingURL=registry-post-image.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Projection-vs-journal verification (pln#543 cutover gate).
3
+ *
4
+ * Lives in its OWN module (not materialize.ts) on purpose: it needs `loadState`
5
+ * (projection read) AND the journal materializer. If this dependency sat in
6
+ * materialize.ts, the materialize -> state edge would close a cycle
7
+ * state -> events/checkpoint -> events/materialize -> state once the checkpoint
8
+ * read path is wired into loadState — a module-init TDZ class (trp_187e42e9).
9
+ * Keeping verify here means materialize never imports state; verify.ts is
10
+ * imported only by doctor + tests, never by state.
11
+ *
12
+ * @module
13
+ */
14
+ import { loadState } from '../state.js';
15
+ import { MEMORY_FAMILIES, materializeMemoryStateFromJournal, materializeRegistryFromJournal } from './materialize.js';
16
+ import { listClaims } from '../claims.js';
17
+ import { listAssignments } from '../assignments.js';
18
+ import { listAgentRuns } from '../agentruns.js';
19
+ import { listActionRequired } from '../actions.js';
20
+ import { listCandidates } from '../candidates.js';
21
+ import { listSequences } from '../sequence.js';
22
+ import { listSharedJournaledRuntimeNotes } from '../runtime.js';
23
+ import { REGISTRY_FAMILIES } from './registry-post-image.js';
24
+ import { preparePersistedDocument } from '../migration.js';
25
+ function stable(value) {
26
+ return JSON.stringify(value, (_k, v) => {
27
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
28
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)));
29
+ }
30
+ return v;
31
+ });
32
+ }
33
+ /**
34
+ * Compare projection-read state (the truth in dual mode) against the
35
+ * journal-materialized state. Empty drift = the dual-write is faithful and
36
+ * the journal can be trusted as a read substrate — the cutover gate
37
+ * (`brainclaw doctor --verify-journal`).
38
+ */
39
+ export function verifyProjectionsAgainstJournal(cwd) {
40
+ const projection = loadState(cwd);
41
+ const journal = materializeMemoryStateFromJournal(cwd);
42
+ const drift = [];
43
+ for (const { itemType, collection } of MEMORY_FAMILIES) {
44
+ const projItems = new Map(projection[collection].map(i => [i.id, i]));
45
+ const jrnItems = new Map(journal[collection].map(i => [i.id, i]));
46
+ for (const [id, projItem] of projItems) {
47
+ const jrnItem = jrnItems.get(id);
48
+ if (!jrnItem)
49
+ drift.push({ item_type: itemType, item_id: id, kind: 'missing_in_journal' });
50
+ else if (stable(projItem) !== stable(jrnItem))
51
+ drift.push({ item_type: itemType, item_id: id, kind: 'mismatch' });
52
+ }
53
+ for (const id of jrnItems.keys()) {
54
+ if (!projItems.has(id))
55
+ drift.push({ item_type: itemType, item_id: id, kind: 'missing_in_projection' });
56
+ }
57
+ }
58
+ return drift;
59
+ }
60
+ /**
61
+ * The registry / coordination families covered by the registry verification
62
+ * (pln#568). Each maps its `RegistryFamily` (→ journal item_type + doc type) to
63
+ * the projection reader. Scoped to the coordination-class families wired in
64
+ * slice 1 plus the remaining pln#568-wired families. Runtime notes are scoped
65
+ * to shared visibility because private/machine notes deliberately do not enter
66
+ * the shared journal.
67
+ */
68
+ const VERIFIED_REGISTRY_FAMILIES = [
69
+ { family: 'claim', list: listClaims },
70
+ { family: 'assignment', list: listAssignments },
71
+ { family: 'agent_run', list: listAgentRuns },
72
+ { family: 'action', list: listActionRequired },
73
+ // Only PENDING candidates are journaled (archive emits a tombstone, pln#568),
74
+ // so verify compares the pending projection against the journal live set.
75
+ { family: 'candidate', list: (cwd) => listCandidates('pending', cwd) },
76
+ { family: 'runtime_note', list: listSharedJournaledRuntimeNotes },
77
+ { family: 'sequence', list: listSequences },
78
+ ];
79
+ /**
80
+ * Compare the registry projections (the dual-mode source of truth) against the
81
+ * journal-materialized registry post-images (pln#568). Empty drift = the
82
+ * registry dual-write is faithful and the observer can trust the journal for
83
+ * these families — the registry half of the cutover gate, mirroring
84
+ * {@link verifyProjectionsAgainstJournal} for the memory families. The
85
+ * comparison runs both sides through `preparePersistedDocument` so a projection
86
+ * item and its journal payload (which is exactly that) compare like-for-like.
87
+ */
88
+ export function verifyRegistryAgainstJournal(cwd) {
89
+ const journal = materializeRegistryFromJournal(cwd);
90
+ const drift = [];
91
+ for (const { family, list } of VERIFIED_REGISTRY_FAMILIES) {
92
+ const spec = REGISTRY_FAMILIES[family];
93
+ const projItems = new Map(list(cwd).map((i) => [i.id, preparePersistedDocument(spec.docType, i)]));
94
+ const jrnItems = new Map((journal.get(spec.journalItemType) ?? []).map((e) => [e.item_id, e.payload]));
95
+ for (const [id, projItem] of projItems) {
96
+ const jrnItem = jrnItems.get(id);
97
+ if (!jrnItem)
98
+ drift.push({ item_type: spec.journalItemType, item_id: id, kind: 'missing_in_journal' });
99
+ else if (stable(projItem) !== stable(jrnItem))
100
+ drift.push({ item_type: spec.journalItemType, item_id: id, kind: 'mismatch' });
101
+ }
102
+ for (const id of jrnItems.keys()) {
103
+ if (!projItems.has(id))
104
+ drift.push({ item_type: spec.journalItemType, item_id: id, kind: 'missing_in_projection' });
105
+ }
106
+ }
107
+ return drift;
108
+ }
109
+ //# sourceMappingURL=verify.js.map
@@ -45,6 +45,25 @@ export function resolveBinaryOnPath(binary) {
45
45
  return undefined;
46
46
  }
47
47
  }
48
+ /**
49
+ * Git author identity for a dispatched worker (pln#562 step 5). The email is
50
+ * a deterministic non-routable address so `git log --author` can filter by
51
+ * agent. Committer mirrors author so neither field lies about provenance.
52
+ */
53
+ export function buildGitAttributionEnv(agent) {
54
+ const name = agent?.trim();
55
+ if (!name)
56
+ return {};
57
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
58
+ const display = `${name} (via brainclaw)`;
59
+ const email = `${slug}@agents.brainclaw.dev`;
60
+ return {
61
+ GIT_AUTHOR_NAME: display,
62
+ GIT_AUTHOR_EMAIL: email,
63
+ GIT_COMMITTER_NAME: display,
64
+ GIT_COMMITTER_EMAIL: email,
65
+ };
66
+ }
48
67
  function buildManualEnvPrefix(claimId) {
49
68
  // pln#496 step stp_a9afe59d: the cross-platform / cross-shell logic
50
69
  // now lives in execution-profile.ts:buildClaimEnvPrefix. Keep this
@@ -80,6 +99,10 @@ export class CliExecutionAdapter {
80
99
  const isWin32 = process.platform === 'win32';
81
100
  const env = {
82
101
  ...process.env,
102
+ // pln#562 step 5 — truthful attribution: every commit a dispatched
103
+ // worker makes is authored as the AGENT, not as the human whose
104
+ // git config happens to be on the machine. invoke.env may override.
105
+ ...buildGitAttributionEnv(options.agent),
83
106
  ...(invoke.env ?? {}),
84
107
  ...(options.claimId ? { BRAINCLAW_CLAIM_ID: options.claimId } : {}),
85
108
  };
@@ -161,7 +161,7 @@ export async function attemptExecution(invoke, options) {
161
161
  // claim was reused/re-dispatched without one), REFUSE to spawn instead of
162
162
  // falling back to options.cwd — which is the integration repo, where the
163
163
  // worker would edit the main tree directly (dangerous for an autonomous fleet,
164
- // debrief LeaseUp). Return the command for manual, isolated execution.
164
+ // a cross-project field debrief). Return the command for manual, isolated execution.
165
165
  if (options.requireWorktree && !options.worktreePath) {
166
166
  appendAuditEntry({
167
167
  actor: options.dispatcherAgent,
@@ -15,6 +15,13 @@ export const WorkRequestSchema = z.object({
15
15
  contextTarget: z.string().optional(),
16
16
  project: z.string().optional(),
17
17
  compact: z.boolean().optional().default(true),
18
+ /**
19
+ * Approximate token budget for the context payload (agent-ux, pln#542).
20
+ * Relevance-ranked fill: the highest-scoring items are kept until the
21
+ * budget is reached (~4 chars/token heuristic). Applies to both compact
22
+ * and full payloads.
23
+ */
24
+ budget_tokens: z.number().int().positive().optional(),
18
25
  });
19
26
  export const CoordinateRequestSchema = z.object({
20
27
  intent: CoordinateIntentSchema,
@@ -128,6 +135,19 @@ export const VerifyWithSchema = z.object({
128
135
  /** Doc pointer for the diagnostic flow when the check fails. */
129
136
  see_also: z.string(),
130
137
  });
138
+ /**
139
+ * Self-teaching affordance (agent-ux, pln#542): each response carries the
140
+ * recommended next call(s) with exact shapes, generalizing the verify_with
141
+ * pattern. Protocol teaching lives in responses, not the instruction file.
142
+ */
143
+ export const NextActionSchema = z.object({
144
+ /** Tool to call next, e.g. "bclaw_release_claim". */
145
+ tool: z.string(),
146
+ /** Exact argument shape for the call (literal values where known, <placeholders> otherwise). */
147
+ args: z.record(z.string(), z.unknown()).optional(),
148
+ /** When this action applies, e.g. "when implementation is complete". */
149
+ when: z.string().optional(),
150
+ });
131
151
  export const FacadeResponseSchema = z.object({
132
152
  status: z.enum(['ok', 'error', 'partial']),
133
153
  intent: z.string(),
@@ -151,6 +171,18 @@ export const FacadeResponseSchema = z.object({
151
171
  * Additive: existing callers that don't read it are unaffected.
152
172
  */
153
173
  bootstrap_recommended: z.boolean().optional(),
174
+ /**
175
+ * pln#557 step 3 — composite verdict behind `bootstrap_recommended`:
176
+ * 'bootstrap' → no PROJECT.md AND sparse store: from-scratch entry-point
177
+ * (extract route or bootstrap loop per the shared empty-memory rule);
178
+ * 'refresh' → PROJECT.md missing on a RICH store, or fossil relative
179
+ * to recent commit/store activity: regenerate via
180
+ * bclaw_bootstrap(refresh: true), never from-scratch;
181
+ * 'none' → PROJECT.md present and current.
182
+ * `bootstrap_recommended` stays the boolean projection (verdict !== 'none')
183
+ * for backward compatibility.
184
+ */
185
+ bootstrap_verdict: z.enum(['bootstrap', 'refresh', 'none']).optional(),
154
186
  /**
155
187
  * pln#513 step 1 — literal MCP call to surface as the bootstrap entry-point
156
188
  * when `bootstrap_recommended` is true. Carries the canonical-grammar text
@@ -158,5 +190,11 @@ export const FacadeResponseSchema = z.object({
158
190
  * CLI doesn't have to reconstruct it.
159
191
  */
160
192
  next_action: z.string().optional(),
193
+ /**
194
+ * agent-ux (pln#542): recommended follow-up calls with exact shapes —
195
+ * the generalized affordance channel. `next_action` (singular, string)
196
+ * remains for the bootstrap hint; new consumers should read this array.
197
+ */
198
+ next_actions: z.array(NextActionSchema).optional(),
161
199
  });
162
200
  //# sourceMappingURL=facade-schema.js.map
@@ -91,12 +91,10 @@ export function compact(options = {}) {
91
91
  const purgeSessionNotes = options.purgeSessionNotes ?? true;
92
92
  const dedupHandoffs = options.dedupHandoffs ?? true;
93
93
  const cutoff = new Date(Date.now() - minAgeDays * 24 * 60 * 60 * 1000).toISOString();
94
- // Extensions (pln#436): file-direct sweeps, independent of mutateState
95
- // since claims and runtime_notes live in their own stores.
96
- const claimsArchived = purgeReleasedClaims ? archiveReleasedClaims(cwd, minAgeDays, dryRun) : 0;
97
- const sessionNotesArchived = purgeSessionNotes ? archiveSessionNotes(cwd, minAgeDays, dryRun) : 0;
98
- const handoffsDeduped = dedupHandoffs ? dedupAutoHandoffs(cwd, dryRun) : 0;
99
94
  if (dryRun) {
95
+ const claimsArchived = purgeReleasedClaims ? archiveReleasedClaims(cwd, minAgeDays, true) : 0;
96
+ const sessionNotesArchived = purgeSessionNotes ? archiveSessionNotes(cwd, minAgeDays, true) : 0;
97
+ const handoffsDeduped = dedupHandoffs ? dedupAutoHandoffs(cwd, true) : 0;
100
98
  const state = loadState(cwd);
101
99
  const eligible = collectEligible(state, cutoff);
102
100
  const selected = eligible.slice(0, maxItems);
@@ -112,6 +110,11 @@ export function compact(options = {}) {
112
110
  };
113
111
  }
114
112
  return mutateState((state) => {
113
+ // Direct file-store GC participates in the same store-wide mutation lock as
114
+ // state compaction so a stale snapshot cannot race against these deletes.
115
+ const claimsArchived = purgeReleasedClaims ? archiveReleasedClaims(cwd, minAgeDays, false) : 0;
116
+ const sessionNotesArchived = purgeSessionNotes ? archiveSessionNotes(cwd, minAgeDays, false) : 0;
117
+ const handoffsDeduped = dedupHandoffs ? dedupAutoHandoffs(cwd, false) : 0;
115
118
  const eligible = collectEligible(state, cutoff);
116
119
  const selected = eligible.slice(0, maxItems);
117
120
  if (selected.length === 0) {
@@ -418,6 +421,128 @@ function archiveSessionNotes(cwd, minAgeDays, dryRun) {
418
421
  }
419
422
  return eligible.length;
420
423
  }
424
+ /**
425
+ * pln#564 step B — autonomous runtime-note retention.
426
+ *
427
+ * The runtime-note tree (`coordination/runtime/<agent>/*.json`) is fully
428
+ * scanned by buildContext on every read (trp_439fec51). It accumulates
429
+ * unbounded because the only existing GC (archiveSessionNotes) runs solely
430
+ * inside the LLM-driven compaction phase, which is rarely triggered — so
431
+ * 1000s of write-only lifecycle notes bury the real signal and drive a ~11s
432
+ * read. This pass runs cheaply on session-start (full maintenance) with NO
433
+ * LLM gate, capping the redundant classes while preserving genuine captures.
434
+ *
435
+ * Classification (priority order):
436
+ * - `fakehome` — scope points at a Temp/bclaw-fakehome worktree: test
437
+ * leakage referencing dead paths. Archived unconditionally.
438
+ * - `lifecycle` — carries an `event_type` (run_, assignment_, lane_ …):
439
+ * dispatch telemetry already in the event journal.
440
+ * - `session` — note_type session_start/session_end or tagged `session`:
441
+ * session markers already in the audit log + journal.
442
+ * - `observation` — genuine human/agent capture. NEVER archived.
443
+ *
444
+ * `session` and `lifecycle` are capped to the newest `keepPerAgent` per agent;
445
+ * the rest are parked (backed up to gc-backups, then unlinked).
446
+ */
447
+ const RUNTIME_FAKEHOME_RE = /bclaw-fakehome|[\\/]Temp[\\/]/i;
448
+ const DEFAULT_RUNTIME_NOTE_KEEP_PER_AGENT = 20;
449
+ function classifyRuntimeNote(parsed) {
450
+ const scope = typeof parsed.scope === 'string' ? parsed.scope : '';
451
+ if (scope && RUNTIME_FAKEHOME_RE.test(scope))
452
+ return 'fakehome';
453
+ if (typeof parsed.event_type === 'string' && parsed.event_type.length > 0)
454
+ return 'lifecycle';
455
+ const noteType = parsed.note_type;
456
+ const tags = Array.isArray(parsed.tags) ? parsed.tags : [];
457
+ if (noteType === 'session_start' || noteType === 'session_end' || tags.includes('session')) {
458
+ return 'session';
459
+ }
460
+ return 'observation';
461
+ }
462
+ /**
463
+ * Enforce runtime-note retention across all agent dirs. Best-effort and
464
+ * idempotent: archiving older/excess notes is safe against a concurrent
465
+ * reader (it just sees fewer notes) and every removed file is parked under
466
+ * `.brainclaw/gc-backups/` first, so nothing is lost.
467
+ */
468
+ export function enforceRuntimeNoteRetention(options = {}) {
469
+ const cwd = options.cwd ?? process.cwd();
470
+ const keepPerAgent = options.keepPerAgent ?? DEFAULT_RUNTIME_NOTE_KEEP_PER_AGENT;
471
+ const dryRun = options.dryRun ?? false;
472
+ const by_class = { observation: 0, session: 0, lifecycle: 0, fakehome: 0 };
473
+ const result = { scanned: 0, archived: 0, kept: 0, by_class };
474
+ const runtimeDir = path.join(cwd, '.brainclaw', 'coordination', 'runtime');
475
+ if (!fs.existsSync(runtimeDir))
476
+ return result;
477
+ const toArchive = [];
478
+ for (const agent of fs.readdirSync(runtimeDir)) {
479
+ const agentDir = path.join(runtimeDir, agent);
480
+ let isDir = false;
481
+ try {
482
+ isDir = fs.statSync(agentDir).isDirectory();
483
+ }
484
+ catch { /* skip */ }
485
+ if (!isDir)
486
+ continue;
487
+ // Capped classes are grouped per agent so we keep the newest N each.
488
+ const capped = {
489
+ session: [],
490
+ lifecycle: [],
491
+ };
492
+ for (const file of fs.readdirSync(agentDir)) {
493
+ if (!file.endsWith('.json'))
494
+ continue;
495
+ const filePath = path.join(agentDir, file);
496
+ let content;
497
+ let parsed;
498
+ try {
499
+ content = fs.readFileSync(filePath, 'utf-8');
500
+ parsed = JSON.parse(content);
501
+ }
502
+ catch {
503
+ continue; // unparseable — leave it alone
504
+ }
505
+ result.scanned += 1;
506
+ const cls = classifyRuntimeNote(parsed);
507
+ by_class[cls] += 1;
508
+ if (cls === 'observation') {
509
+ result.kept += 1;
510
+ continue;
511
+ }
512
+ if (cls === 'fakehome') {
513
+ toArchive.push({ filePath, content });
514
+ continue;
515
+ }
516
+ const createdAt = typeof parsed.created_at === 'string' ? parsed.created_at : '';
517
+ capped[cls].push({ filePath, content, createdAt });
518
+ }
519
+ for (const cls of ['session', 'lifecycle']) {
520
+ const entries = capped[cls];
521
+ entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // newest first
522
+ result.kept += Math.min(entries.length, keepPerAgent);
523
+ for (const extra of entries.slice(keepPerAgent)) {
524
+ toArchive.push({ filePath: extra.filePath, content: extra.content });
525
+ }
526
+ }
527
+ }
528
+ if (dryRun || toArchive.length === 0) {
529
+ result.archived = toArchive.length;
530
+ return result;
531
+ }
532
+ const timestamp = nowISO().replace(/[:.]/g, '-');
533
+ const backupPath = path.join(cwd, '.brainclaw', 'gc-backups', `runtime-note-retention-${timestamp}.jsonl`);
534
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
535
+ for (const { filePath, content } of toArchive) {
536
+ try {
537
+ fs.appendFileSync(backupPath, content.trim() + '\n', 'utf-8');
538
+ fs.unlinkSync(filePath);
539
+ result.archived += 1;
540
+ }
541
+ catch { /* best-effort: a failed unlink just leaves the note in place */ }
542
+ }
543
+ result.backup_path = backupPath;
544
+ return result;
545
+ }
421
546
  /**
422
547
  * Deduplicate auto-generated session-end handoffs. These carry the same
423
548
  * commits list when several sessions close on the same project state, so the
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Auto-handoff diff snapshot capping (pln#569, décision E).
3
+ *
4
+ * Session-end auto-handoffs carried the FULL uncommitted git diff in
5
+ * `snapshot.diff` (~450 KB each), which made handoffs 53 MB of a 55 MB journal
6
+ * (524 handoffs, trp_2ca4b87b) — yet:
7
+ * - no consumer ever reads more than a bounded prefix (the dispatch review
8
+ * brief slices 5 000 chars; `bclaw_find`/`bclaw_get` bound to
9
+ * DEFAULT_FIND_CHAR_BUDGET = 40 000), and
10
+ * - the canonical full diff lives on the worktree branch — the read path
11
+ * already tells the reader to "read the worktree branch for the full diff".
12
+ *
13
+ * So the inline diff is a CONVENIENCE PREVIEW, not the source of truth. This
14
+ * caps it to a generous preview and records a digest of the full diff when
15
+ * truncated, so nothing is silently lost (a reader knows the inline diff is a
16
+ * prefix and by how much) without paying the ~450 KB per handoff.
17
+ *
18
+ * @module
19
+ */
20
+ import crypto from 'node:crypto';
21
+ /**
22
+ * Max chars of the inline diff preview kept on a handoff. Chosen ABOVE every
23
+ * consumer's read budget except the rare large-budget `find`/`get` (40 000) —
24
+ * the review brief (5 000) is served in full, and the worktree branch carries
25
+ * the canonical diff for anything larger. ~30× smaller than a typical 450 KB
26
+ * uncommitted diff.
27
+ */
28
+ export const HANDOFF_DIFF_PREVIEW_CHARS = 16_384;
29
+ function safePreviewEnd(fullDiff) {
30
+ const end = HANDOFF_DIFF_PREVIEW_CHARS;
31
+ const last = fullDiff.charCodeAt(end - 1);
32
+ const next = fullDiff.charCodeAt(end);
33
+ return last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff ? end - 1 : end;
34
+ }
35
+ /**
36
+ * Build a handoff `snapshot` from a full diff: keep an inline preview, and when
37
+ * the diff exceeds the preview cap, record a digest of the FULL diff
38
+ * (`full_bytes` + `sha256` + `truncated`) so the truncation is explicit and a
39
+ * re-fetched full diff is verifiable. Returns `undefined` for an absent/empty
40
+ * diff (no snapshot at all).
41
+ */
42
+ export function capHandoffDiff(fullDiff) {
43
+ if (!fullDiff)
44
+ return undefined;
45
+ if (fullDiff.length <= HANDOFF_DIFF_PREVIEW_CHARS) {
46
+ return { diff: fullDiff };
47
+ }
48
+ // Buffer round-trip forces a FLAT independent copy: a bare `slice()` returns a
49
+ // V8 SlicedString that pins the whole ~450 KB parent in memory, defeating the
50
+ // cap (the parent never gets freed) — the same trap trimForProjection hit
51
+ // (trp_2ca4b87b).
52
+ const preview = Buffer.from(fullDiff.slice(0, safePreviewEnd(fullDiff)), 'utf8').toString('utf8');
53
+ return {
54
+ diff: preview,
55
+ diff_digest: {
56
+ full_bytes: Buffer.byteLength(fullDiff, 'utf8'),
57
+ sha256: crypto.createHash('sha256').update(fullDiff, 'utf8').digest('hex'),
58
+ truncated: true,
59
+ },
60
+ };
61
+ }
62
+ export function handoffDiffPreviewNote(snapshot) {
63
+ const digest = snapshot?.diff_digest;
64
+ if (!digest?.truncated)
65
+ return undefined;
66
+ return `… [preview — full diff is ${digest.full_bytes} bytes on the worktree branch]`;
67
+ }
68
+ //# sourceMappingURL=handoff-snapshot.js.map
package/dist/core/ids.js CHANGED
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { withLock } from './lock.js';
5
+ import { writeFileAtomic } from './io.js';
5
6
  const PREFIXES = {
6
7
  active_constraints: 'cst',
7
8
  bootstrap_seeds: 'bsd',
@@ -18,10 +19,15 @@ const PREFIXES = {
18
19
  runs: 'run',
19
20
  actions: 'act',
20
21
  runtime_events: 'evt',
22
+ // can_b8d53d18: without an explicit entry, 'runtime_note' fell through to the
23
+ // slice(0,3) fallback = 'run', colliding with agent_run ids and breaking
24
+ // prefix-based routing (dispatch_status). Canonical prefix is 'rtn'.
25
+ runtime_note: 'rtn',
26
+ runtime_notes: 'rtn',
21
27
  };
22
28
  const ID_COUNTER_FILE = '.id-counter.json';
23
- function counterPath(cwd) {
24
- return path.join(cwd ?? process.cwd(), '.brainclaw', ID_COUNTER_FILE);
29
+ function counterPath(cwd, preferredDirName = '.brainclaw') {
30
+ return path.join(cwd ?? process.cwd(), preferredDirName, ID_COUNTER_FILE);
25
31
  }
26
32
  /** Generate a concurrence-safe prefixed ID using 4 random bytes. */
27
33
  export function generateId(section) {
@@ -33,17 +39,22 @@ export function generateId(section) {
33
39
  * Atomically increment the per-prefix counter and return the next short label.
34
40
  * Best-effort: if the counter file is unavailable the call still succeeds.
35
41
  */
36
- export function getNextShortLabel(prefix, cwd) {
37
- const fp = counterPath(cwd);
42
+ export function getNextShortLabel(prefix, cwd, preferredDirName) {
43
+ const fp = counterPath(cwd, preferredDirName);
38
44
  return withLock(fp, () => {
39
45
  let counter = {};
40
46
  try {
41
47
  counter = JSON.parse(fs.readFileSync(fp, 'utf-8'));
42
48
  }
43
- catch { /* first use or missing file */ }
49
+ catch (error) {
50
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
51
+ throw new Error(`Could not read short-label counter ${fp}: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
53
+ }
44
54
  const next = (counter[prefix] ?? 0) + 1;
45
55
  counter[prefix] = next;
46
- fs.writeFileSync(fp, JSON.stringify(counter), 'utf-8');
56
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
57
+ writeFileAtomic(fp, `${JSON.stringify(counter, null, 2)}\n`);
47
58
  return `${prefix}#${next}`;
48
59
  });
49
60
  }
@@ -51,10 +62,10 @@ export function getNextShortLabel(prefix, cwd) {
51
62
  * Generate both a concurrence-safe hash ID and a human-readable short label.
52
63
  * The hash ID is the canonical storage key; the short label is for display and aliased lookups.
53
64
  */
54
- export function generateIdWithLabel(section, cwd) {
65
+ export function generateIdWithLabel(section, cwd, preferredDirName) {
55
66
  const id = generateId(section);
56
67
  const prefix = PREFIXES[section] ?? section.slice(0, 3);
57
- const short_label = getNextShortLabel(prefix, cwd);
68
+ const short_label = getNextShortLabel(prefix, cwd, preferredDirName);
58
69
  return { id, short_label };
59
70
  }
60
71
  export function nowISO() {