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
@@ -46,6 +46,18 @@ export function getRuntimeSignalPath(root, assignmentId, signal) {
46
46
  export function getRuntimeLogPath(root, assignmentId, stream) {
47
47
  return path.join(runtimeDir(root), 'log', `${assignmentId}.${stream}.log`);
48
48
  }
49
+ /**
50
+ * Worktree-local heartbeat path (sprint 1.5). The project-root signal path is
51
+ * NOT writable from inside many worker sandboxes (Claude Code restricts writes
52
+ * to its working directories; codex workspace-write roots exclude the project) —
53
+ * observed live: the generated brief demanded a heartbeat the worker could not
54
+ * write. The worktree root is the one location every dispatched worker can
55
+ * write, so briefs point step-0 here, and every heartbeat reader checks BOTH
56
+ * locations.
57
+ */
58
+ export function getWorktreeHeartbeatPath(worktreePath, assignmentId) {
59
+ return path.join(worktreePath, `.brainclaw-heartbeat-${assignmentId}`);
60
+ }
49
61
  /** Ensure the ack / signal / log directories exist (best-effort, recursive). */
50
62
  export function ensureRuntimeDirs(root) {
51
63
  const base = runtimeDir(root);
@@ -61,13 +73,7 @@ export function signalExists(root, assignmentId, signal) {
61
73
  return false;
62
74
  }
63
75
  }
64
- /**
65
- * Read the heartbeat sentinel. The body is expected to be
66
- * `work_loop_reached{run_id,nonce}` JSON, but a bare `touch` (empty file) still
67
- * counts as a heartbeat — the mtime alone is a valid life-sign.
68
- */
69
- export function readHeartbeat(root, assignmentId) {
70
- const p = getRuntimeSignalPath(root, assignmentId, 'heartbeat');
76
+ function readHeartbeatFile(p) {
71
77
  try {
72
78
  const stat = fs.statSync(p);
73
79
  const info = { exists: true, mtimeMs: stat.mtimeMs };
@@ -88,12 +94,68 @@ export function readHeartbeat(root, assignmentId) {
88
94
  return { exists: false };
89
95
  }
90
96
  }
97
+ /**
98
+ * Read the heartbeat sentinel. The body is expected to be
99
+ * `work_loop_reached{run_id,nonce}` JSON, but a bare `touch` (empty file) still
100
+ * counts as a heartbeat — the mtime alone is a valid life-sign.
101
+ *
102
+ * Checks the project-root signal path AND (when `worktreePath` is given) the
103
+ * worktree-local heartbeat — sandboxed workers can only write the latter. When
104
+ * both exist, the freshest mtime wins.
105
+ */
106
+ export function readHeartbeat(root, assignmentId, worktreePath) {
107
+ const projectInfo = readHeartbeatFile(getRuntimeSignalPath(root, assignmentId, 'heartbeat'));
108
+ const worktreeInfo = worktreePath
109
+ ? readHeartbeatFile(getWorktreeHeartbeatPath(worktreePath, assignmentId))
110
+ : { exists: false };
111
+ if (!projectInfo.exists)
112
+ return worktreeInfo;
113
+ if (!worktreeInfo.exists)
114
+ return projectInfo;
115
+ return (worktreeInfo.mtimeMs ?? 0) > (projectInfo.mtimeMs ?? 0) ? worktreeInfo : projectInfo;
116
+ }
117
+ /**
118
+ * can_c39f0961 — CP850 high-byte table (0x80–0xFF). Windows-native console
119
+ * tools write redirected stdout/stderr in the OEM codepage (cp850 on western
120
+ * locales), which read as UTF-8 produces U+FFFD mojibake in captured logs.
121
+ * WHATWG TextDecoder does not cover ibm850, so a 128-entry table keeps the
122
+ * fallback decode dependency-free.
123
+ */
124
+ const CP850_HIGH = 'ÇüéâäàåçêëèïîìÄÅ' +
125
+ 'ÉæÆôöòûùÿÖÜø£Ø×ƒ' +
126
+ 'áíóúñѪº¿®¬½¼¡«»' +
127
+ '░▒▓│┤ÁÂÀ©╣║╗╝¢¥┐' +
128
+ '└┴┬├─┼ãÃ╚╔╩╦╠═╬¤' +
129
+ 'ðÐÊËÈıÍÎÏ┘┌█▄¦Ì▀' +
130
+ 'ÓßÔÒõÕµþÞÚÛÙýݯ´' +
131
+ '­±‗¾¶§÷¸°¨·¹³²■ ';
132
+ /**
133
+ * Decode a captured-log buffer: UTF-8 first, falling back to cp850 when the
134
+ * UTF-8 decode shows replacement characters (the OEM-output signature).
135
+ */
136
+ export function decodeOemAwareBuffer(buf) {
137
+ const utf8 = buf.toString('utf-8');
138
+ if (!utf8.includes('�'))
139
+ return utf8;
140
+ let out = '';
141
+ for (const byte of buf) {
142
+ out += byte < 0x80 ? String.fromCharCode(byte) : CP850_HIGH[byte - 0x80];
143
+ }
144
+ return out;
145
+ }
91
146
  /** Read the tail of a captured stream log (for failed_silent diagnostics). */
92
147
  export function readLogTail(root, assignmentId, stream, maxBytes = 2000) {
93
148
  try {
94
149
  const p = getRuntimeLogPath(root, assignmentId, stream);
95
- const content = fs.readFileSync(p, 'utf-8');
96
- return content.length > maxBytes ? content.slice(content.length - maxBytes) : content;
150
+ const buf = fs.readFileSync(p);
151
+ let slice = buf.length > maxBytes ? buf.subarray(buf.length - maxBytes) : buf;
152
+ // A byte-offset tail can start mid-UTF-8-sequence; dropping leading
153
+ // continuation bytes avoids a false U+FFFD that would trigger the cp850
154
+ // fallback on genuine UTF-8 content.
155
+ while (slice.length > 0 && slice[0] >= 0x80 && slice[0] <= 0xbf) {
156
+ slice = slice.subarray(1);
157
+ }
158
+ return decodeOemAwareBuffer(slice);
97
159
  }
98
160
  catch {
99
161
  return '';
@@ -8,6 +8,7 @@ import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
8
8
  import { RuntimeNoteSchema } from './schema.js';
9
9
  import { commitMemoryChange } from './memory-git.js';
10
10
  import { appendEvent } from './event-log.js';
11
+ import { emitRegistryPostImage, emitRegistryTombstone, registryFaultPoint } from './events/registry-post-image.js';
11
12
  function sharedRuntimeDir(cwd, mode = 'read') {
12
13
  return resolveEntityDir('runtime', cwd ?? process.cwd(), mode);
13
14
  }
@@ -46,7 +47,16 @@ export function saveRuntimeNote(note, cwd) {
46
47
  const filepath = visibility === 'shared'
47
48
  ? path.join(sharedAgentDir(note.agent, cwd, 'write'), `${note.id}.json`)
48
49
  : path.join(hostAgentDir(visibility, hostId, note.agent, cwd, 'write'), `${note.id}.json`);
49
- saveVersionedJsonFile('runtime_note', filepath, RuntimeNoteSchema.parse(persistedNote));
50
+ const parsed = RuntimeNoteSchema.parse(persistedNote);
51
+ // pln#568 (I2): journal the post-image BEFORE the projection write — but
52
+ // SHARED notes only. Private/machine-visibility notes must not leak their
53
+ // payload into the shared journal (the observer's board shows shared notes).
54
+ if (visibility === 'shared') {
55
+ const created = !fs.existsSync(filepath);
56
+ emitRegistryPostImage('runtime_note', parsed, { created, agent: note.agent, agent_id: note.agent_id, session_id: note.session_id, cwd });
57
+ registryFaultPoint('after_registry_journal');
58
+ }
59
+ saveVersionedJsonFile('runtime_note', filepath, parsed);
50
60
  appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
51
61
  commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
52
62
  });
@@ -64,10 +74,47 @@ export function deleteRuntimeNote(note, cwd) {
64
74
  if (!fs.existsSync(filepath)) {
65
75
  return false;
66
76
  }
77
+ if ((note.visibility ?? 'shared') === 'shared') {
78
+ emitRegistryTombstone('runtime_note', note.id, {
79
+ agent: note.agent,
80
+ agent_id: note.agent_id,
81
+ session_id: note.session_id,
82
+ cwd,
83
+ });
84
+ registryFaultPoint('after_registry_journal');
85
+ }
67
86
  fs.unlinkSync(filepath);
68
87
  return true;
69
88
  });
70
89
  }
90
+ /**
91
+ * The shared runtime notes that are journaled as post-images (pln#568): notes
92
+ * under `runtime/<agent>/*.json`, EXCLUDING `runtime/agent-runtime/` (which
93
+ * holds runtime EVENT files `evt_*.json`, not saveRuntimeNote post-images —
94
+ * they would otherwise be parsed as notes and report false drift). Single
95
+ * source of truth for the journaled-shared-note set, shared by the registry
96
+ * verifier (verify.ts) and the registry genesis backfill (genesis.ts).
97
+ */
98
+ export function listSharedJournaledRuntimeNotes(cwd) {
99
+ const root = sharedRuntimeDir(cwd, 'read');
100
+ if (!fs.existsSync(root))
101
+ return [];
102
+ const notes = [];
103
+ for (const entry of fs.readdirSync(root).sort()) {
104
+ if (entry === 'agent-runtime')
105
+ continue;
106
+ const agentDir = path.join(root, entry);
107
+ if (!fs.existsSync(agentDir) || !fs.statSync(agentDir).isDirectory())
108
+ continue;
109
+ for (const file of fs.readdirSync(agentDir).filter((name) => name.endsWith('.json')).sort()) {
110
+ try {
111
+ notes.push(loadVersionedJsonFile('runtime_note', path.join(agentDir, file)).document);
112
+ }
113
+ catch { /* mirror listRuntimeNotes' tolerant read */ }
114
+ }
115
+ }
116
+ return notes.sort((a, b) => a.id.localeCompare(b.id));
117
+ }
71
118
  function readAgentNotes(dir, agent) {
72
119
  if (!fs.existsSync(dir))
73
120
  return [];
@@ -136,4 +183,40 @@ export function generateRuntimeNoteId() {
136
183
  const rand = crypto.randomBytes(4).toString('hex');
137
184
  return `rtn_${rand}`;
138
185
  }
186
+ /**
187
+ * can_b8d53d18 — soft migration for runtime notes created with the legacy
188
+ * `run_` prefix (the generateId fallback collided with agent_run ids).
189
+ * Rewrites each note's id to `rtn_<same suffix>` and renames its file.
190
+ * Old ids referenced in historical events stay historical; lookups are
191
+ * list-scan based so nothing else needs to change.
192
+ */
193
+ export function migrateRuntimeNoteIdPrefixes(cwd) {
194
+ const result = { migrated: [], errors: [] };
195
+ const legacy = listRuntimeNotes({ visibility: 'all', includeAllHosts: true }, cwd)
196
+ .filter((note) => note.id.startsWith('run_'));
197
+ if (legacy.length === 0)
198
+ return result;
199
+ const existingIds = new Set(listRuntimeNotes({ visibility: 'all', includeAllHosts: true }, cwd).map((n) => n.id));
200
+ mutate({ cwd }, () => {
201
+ for (const note of legacy) {
202
+ try {
203
+ let newId = `rtn_${note.id.slice('run_'.length)}`;
204
+ while (existingIds.has(newId))
205
+ newId = generateRuntimeNoteId();
206
+ const oldPath = runtimeNotePath(note, cwd);
207
+ const migrated = { ...note, id: newId };
208
+ const newPath = runtimeNotePath(migrated, cwd);
209
+ saveVersionedJsonFile('runtime_note', newPath, RuntimeNoteSchema.parse(migrated));
210
+ if (fs.existsSync(oldPath) && oldPath !== newPath)
211
+ fs.unlinkSync(oldPath);
212
+ existingIds.add(newId);
213
+ result.migrated.push({ from: note.id, to: newId });
214
+ }
215
+ catch (err) {
216
+ result.errors.push(`${note.id}: ${err instanceof Error ? err.message : String(err)}`);
217
+ }
218
+ }
219
+ });
220
+ return result;
221
+ }
139
222
  //# sourceMappingURL=runtime.js.map
@@ -57,6 +57,33 @@ function coerceTags(val) {
57
57
  /** Resilient tags schema that accepts string[] or JSON-serialized string. */
58
58
  export const TagsSchema = z.preprocess(coerceTags, z.array(z.string()));
59
59
  export const TagsWithDefaultSchema = z.preprocess(coerceTags, z.array(z.string()).default([]));
60
+ // --- Memory lifecycle (pln#544) ---
61
+ /**
62
+ * One confirmation/infirmation event on a memory item (decision, constraint,
63
+ * trap). The full history is stored on the item as a bounded list — the
64
+ * `last_confirmed_at` / `last_infirmed_at` / `confirmation_count` /
65
+ * `infirmation_count` denormalisations let read paths avoid scanning the log
66
+ * for the common "is this still trustworthy" question.
67
+ *
68
+ * - `confirm` / `infirm`: passive observation that the rule still holds /
69
+ * no longer holds.
70
+ * - `saved_me`: agent explicitly credits the item with avoiding a bug —
71
+ * reinforces ranking weight via reputation.ts.
72
+ * - `misled_me`: agent explicitly blames the item for a wrong move —
73
+ * sinks ranking weight, mirror of saved_me.
74
+ */
75
+ export const MemoryConfirmationKindSchema = z.enum(['confirm', 'infirm', 'saved_me', 'misled_me']);
76
+ export const MemoryConfirmationEventSchema = z.object({
77
+ at: z.string(),
78
+ by: z.string(),
79
+ by_id: z.string().optional(),
80
+ session_id: z.string().optional(),
81
+ kind: MemoryConfirmationKindSchema,
82
+ /** Pointer at the evidence — file:line, commit sha, message id, command output. */
83
+ evidence: z.string().optional(),
84
+ /** Free-form note (the one-line "why" the agent attests). */
85
+ note: z.string().optional(),
86
+ });
60
87
  // --- Entry schemas ---
61
88
  export const ConstraintStatusSchema = z.enum(['active', 'resolved', 'expired']);
62
89
  export const ConstraintCategorySchema = z.enum(['architecture', 'performance', 'security', 'reliability', 'compatibility', 'process', 'other']);
@@ -135,6 +162,18 @@ export const ConstraintSchema = z.object({
135
162
  related_paths: z.array(z.string()).optional(),
136
163
  plan_id: z.string().optional(),
137
164
  expires_at: z.string().optional(),
165
+ // pln#544 — memory-lifecycle (confirm/decay/reinforce). Symmetric across
166
+ // constraint/decision/trap. `verified_at` (pln#530 perishable-fact
167
+ // re-verification) is kept as a narrower legacy signal alongside.
168
+ last_confirmed_at: z.string().optional(),
169
+ last_infirmed_at: z.string().optional(),
170
+ confirmation_count: z.number().int().nonnegative().optional(),
171
+ infirmation_count: z.number().int().nonnegative().optional(),
172
+ saved_me_count: z.number().int().nonnegative().optional(),
173
+ misled_me_count: z.number().int().nonnegative().optional(),
174
+ /** Bounded event log (most recent N) — older events are dropped, the
175
+ * counts remain accurate. Empty / absent means "never confirmed". */
176
+ confirmations: z.array(MemoryConfirmationEventSchema).optional(),
138
177
  provenance: ProvenancePassthroughSchema,
139
178
  });
140
179
  export const DecisionSchema = z.object({
@@ -154,6 +193,19 @@ export const DecisionSchema = z.object({
154
193
  related_paths: z.array(z.string()).optional(),
155
194
  plan_id: z.string().optional(),
156
195
  tags: TagsSchema,
196
+ // pln#530 — anti-staleness: ISO timestamp this fact was last empirically
197
+ // verified, and an optional command to re-confirm it. For fast-perishable
198
+ // facts (tool behaviour, config values), probe before trusting the memory.
199
+ verified_at: z.string().optional(),
200
+ verify_cmd: z.string().optional(),
201
+ // pln#544 — memory-lifecycle (see ConstraintSchema).
202
+ last_confirmed_at: z.string().optional(),
203
+ last_infirmed_at: z.string().optional(),
204
+ confirmation_count: z.number().int().nonnegative().optional(),
205
+ infirmation_count: z.number().int().nonnegative().optional(),
206
+ saved_me_count: z.number().int().nonnegative().optional(),
207
+ misled_me_count: z.number().int().nonnegative().optional(),
208
+ confirmations: z.array(MemoryConfirmationEventSchema).optional(),
157
209
  provenance: ProvenancePassthroughSchema,
158
210
  });
159
211
  export const TrapSchema = z.object({
@@ -177,6 +229,19 @@ export const TrapSchema = z.object({
177
229
  host_id: z.string().optional(),
178
230
  expires_at: z.string().optional(),
179
231
  platform_scope: z.string().optional(),
232
+ // pln#530 — anti-staleness (see DecisionSchema): when did we last verify this
233
+ // is still true, and how to re-confirm it. Critical for environment/tool-fix
234
+ // traps that go stale (e.g. a service_tier value that the API later rejects).
235
+ verified_at: z.string().optional(),
236
+ verify_cmd: z.string().optional(),
237
+ // pln#544 — memory-lifecycle (see ConstraintSchema).
238
+ last_confirmed_at: z.string().optional(),
239
+ last_infirmed_at: z.string().optional(),
240
+ confirmation_count: z.number().int().nonnegative().optional(),
241
+ infirmation_count: z.number().int().nonnegative().optional(),
242
+ saved_me_count: z.number().int().nonnegative().optional(),
243
+ misled_me_count: z.number().int().nonnegative().optional(),
244
+ confirmations: z.array(MemoryConfirmationEventSchema).optional(),
180
245
  provenance: ProvenancePassthroughSchema,
181
246
  });
182
247
  export const HandoffContractSchema = z.object({
@@ -223,6 +288,17 @@ export const HandoffSchema = z.object({
223
288
  review: HandoffReviewSchema.optional(),
224
289
  snapshot: z.object({
225
290
  diff: z.string().optional(),
291
+ /**
292
+ * pln#569 — digest of the FULL uncommitted diff when `diff` holds only a
293
+ * capped preview (auto-handoffs). Lets a reader know the inline diff is a
294
+ * prefix (and verify a re-fetched full diff) without storing the whole
295
+ * ~450 KB on the handoff. Absent ⇒ `diff` is complete.
296
+ */
297
+ diff_digest: z.object({
298
+ full_bytes: z.number().int().nonnegative(),
299
+ sha256: z.string(),
300
+ truncated: z.boolean(),
301
+ }).optional(),
226
302
  }).optional(),
227
303
  provenance: ProvenancePassthroughSchema,
228
304
  /**
@@ -253,6 +329,15 @@ export const PlanStepSchema = z.object({
253
329
  assignee: z.string().optional(),
254
330
  created_at: z.string(),
255
331
  updated_at: z.string(),
332
+ // Step-level effort (pln#495). estimated_effort accepts legacy duration
333
+ // strings ("2h", "30m") via the same preprocess as plan-level; actual_effort
334
+ // stays a free string parsed by parseEffortMinutes when reported. started_at /
335
+ // completed_at are set on the todo→in_progress→done transitions so the
336
+ // estimation report can sum per-step durations and exclude inter-step idle.
337
+ estimated_effort: z.preprocess(coerceEffortToMinutes, z.number().int().positive().optional()),
338
+ actual_effort: z.string().optional(),
339
+ started_at: z.string().optional(),
340
+ completed_at: z.string().optional(),
256
341
  });
257
342
  export const PlanTypeEnumSchema = z.enum(['feat', 'fix', 'chore', 'spike', 'doc']);
258
343
  export const PlanTypeSchema = PlanTypeEnumSchema.default('feat');
@@ -448,11 +533,25 @@ export const PreinstallConfigSchema = z.object({
448
533
  denylist: z.array(z.string()).default([]),
449
534
  socket_endpoint: z.string().default('https://mcp.socket.dev/'),
450
535
  });
536
+ export const TokenDetectionConfigSchema = z.object({
537
+ enabled: z.boolean().default(true),
538
+ /** Entropy-based detection for high-randomness strings (typically API keys / tokens). */
539
+ entropy: z.object({
540
+ enabled: z.boolean().default(true),
541
+ /** Minimum string length to consider as a potential secret. */
542
+ min_length: z.number().int().min(8).max(1024).default(32),
543
+ /** Minimum Shannon entropy (bits per char) to flag the string. ~4.0 is a reasonable line. */
544
+ min_entropy: z.number().min(0).max(8).default(4.0),
545
+ }).prefault({}),
546
+ /** Per-detector overrides: { aws_access_key: false } disables that detector. */
547
+ detectors: z.record(z.string(), z.boolean()).default({}),
548
+ });
451
549
  export const SecurityConfigSchema = z.object({
452
550
  mode: z.enum(['warn', 'strict']).default('warn'),
453
551
  strict_redaction: z.boolean().default(false),
454
552
  block_sensitive_paths: z.boolean().default(true),
455
553
  preinstall: PreinstallConfigSchema.optional(),
554
+ token_detection: TokenDetectionConfigSchema.prefault({}),
456
555
  });
457
556
  export const MarkdownConfigSchema = z.object({
458
557
  max_items_per_section: z.number().default(20),
@@ -856,6 +955,7 @@ export const RuntimeEventTypeSchema = z.enum([
856
955
  'plan_cascade_to_done',
857
956
  'candidate_harvested',
858
957
  'lane_result_harvested',
958
+ 'lane_integrated',
859
959
  ]);
860
960
  /**
861
961
  * pln#526 — LANE-RESULT convention. A dispatched worker writes a single
@@ -1032,6 +1132,9 @@ export const MemorySeedKindSchema = z.enum([
1032
1132
  'warning',
1033
1133
  'environment',
1034
1134
  'tooling',
1135
+ 'decision',
1136
+ 'constraint',
1137
+ 'trap',
1035
1138
  ]);
1036
1139
  export const MemorySeedSourceKindSchema = z.enum([
1037
1140
  'readme',
@@ -1069,6 +1172,7 @@ export const BootstrapProfileDocumentSchema = z.object({
1069
1172
  schema_version: z.number().int().positive().optional(),
1070
1173
  derived_at: z.string(),
1071
1174
  repo_fingerprint: z.string().optional(),
1175
+ source_fingerprint: z.string().optional(),
1072
1176
  summary: z.string(),
1073
1177
  sources_scanned: z.array(z.string()).default([]),
1074
1178
  git_available: z.boolean().default(false),
@@ -1299,5 +1403,26 @@ export const ConfigSchema = z.object({
1299
1403
  shared_paths: z.array(z.string()).default([]),
1300
1404
  exclude_shared: z.array(z.string()).default([]),
1301
1405
  }).optional(),
1406
+ // Event-log store (pln#543). Absent ⇒ off — fresh and existing stores keep
1407
+ // today's behavior; the journal only activates when explicitly set here (or
1408
+ // via the BRAINCLAW_JOURNAL_MODE env override). The cutover (step 5) flips
1409
+ // the default to dual/primary.
1410
+ store: z.object({
1411
+ journal: z.object({
1412
+ mode: z.enum(['off', 'dual', 'primary', 'registryPrimary']).optional(),
1413
+ fsync: z.enum(['mutation', 'never']).optional(),
1414
+ /**
1415
+ * Phase-3 per-capability sub-flags (pln#566). Each ships + soaks GREEN
1416
+ * independently before its capability is trusted; all default off so a
1417
+ * bare `mode: primary` changes nothing until a capability is enabled.
1418
+ */
1419
+ primary: z.object({
1420
+ checkpointRead: z.boolean().optional(),
1421
+ readReconcile: z.boolean().optional(),
1422
+ tombstoneDelete: z.boolean().optional(),
1423
+ perEntityPatch: z.boolean().optional(),
1424
+ }).optional(),
1425
+ }).optional(),
1426
+ }).optional(),
1302
1427
  });
1303
1428
  //# sourceMappingURL=schema.js.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Structural secret detectors.
3
+ *
4
+ * These run alongside the user-configured regex patterns in
5
+ * `config.redaction.patterns`. They look for well-known token shapes
6
+ * (GitHub PATs, AWS access keys, JWTs, etc.) — high-precision signal
7
+ * that should fire even when the operator hasn't added a custom pattern.
8
+ *
9
+ * Each detector has a stable `id` so operators can disable individual
10
+ * detectors via `security.token_detection.detectors[id] = false` without
11
+ * having to redefine the full list.
12
+ */
13
+ /**
14
+ * Detectors are tuned for low false-positive rates. Anchors and
15
+ * length constraints are intentional; loosening them turns noisy.
16
+ */
17
+ export const BUILTIN_DETECTORS = [
18
+ // GitHub
19
+ { id: 'github_pat', label: 'GitHub personal access token', pattern: /\bghp_[A-Za-z0-9]{36}\b/ },
20
+ { id: 'github_pat_v2', label: 'GitHub fine-grained PAT', pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
21
+ { id: 'github_oauth', label: 'GitHub OAuth token', pattern: /\bgho_[A-Za-z0-9]{36}\b/ },
22
+ { id: 'github_user_to_server', label: 'GitHub user-to-server token', pattern: /\bghu_[A-Za-z0-9]{36}\b/ },
23
+ { id: 'github_server_to_server', label: 'GitHub server-to-server token', pattern: /\bghs_[A-Za-z0-9]{36}\b/ },
24
+ { id: 'github_refresh', label: 'GitHub refresh token', pattern: /\bghr_[A-Za-z0-9]{36}\b/ },
25
+ // AWS
26
+ { id: 'aws_access_key', label: 'AWS access key ID', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
27
+ { id: 'aws_temp_access_key', label: 'AWS temporary access key ID', pattern: /\bASIA[0-9A-Z]{16}\b/ },
28
+ // Google
29
+ { id: 'google_api_key', label: 'Google API key', pattern: /\bAIza[0-9A-Za-z_\-]{35}\b/ },
30
+ // Slack
31
+ { id: 'slack_token', label: 'Slack token', pattern: /\bxox[abprs]-[0-9A-Za-z-]{10,}\b/ },
32
+ { id: 'slack_webhook', label: 'Slack webhook', pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{24,}/ },
33
+ // Stripe
34
+ { id: 'stripe_secret', label: 'Stripe secret key', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{24,}\b/ },
35
+ // Generic structural
36
+ { id: 'jwt', label: 'JSON Web Token', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ },
37
+ { id: 'pem_private_key', label: 'PEM-encoded private key', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/ },
38
+ // Generic high-confidence: connection strings with embedded credentials.
39
+ { id: 'url_basic_auth', label: 'URL with embedded credentials', pattern: /[a-z][a-z+.-]+:\/\/[^\s:@/]+:[^\s@/]{4,}@[A-Za-z0-9.-]+/ },
40
+ ];
41
+ /**
42
+ * Run every enabled structural detector across `text`. Detectors with an
43
+ * explicit `false` in `disabled` are skipped.
44
+ */
45
+ export function runStructuralDetectors(text, disabled) {
46
+ const out = [];
47
+ for (const d of BUILTIN_DETECTORS) {
48
+ if (disabled && disabled[d.id] === false)
49
+ continue;
50
+ const m = text.match(d.pattern);
51
+ if (m) {
52
+ out.push({
53
+ detectorId: d.id,
54
+ label: d.label,
55
+ excerpt: truncate(m[0]),
56
+ });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Shannon entropy in bits per character. Higher = more random.
63
+ * - English prose ~2.5–3.0
64
+ * - hex-encoded data ~3.5–4.0
65
+ * - base64-encoded data ~5.0–6.0
66
+ * - cryptographic keys ~5.5+
67
+ */
68
+ export function shannonEntropy(s) {
69
+ if (!s)
70
+ return 0;
71
+ const counts = new Map();
72
+ for (const ch of s)
73
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
74
+ let h = 0;
75
+ const n = s.length;
76
+ for (const c of counts.values()) {
77
+ const p = c / n;
78
+ h -= p * Math.log2(p);
79
+ }
80
+ return h;
81
+ }
82
+ const SECRET_KEYWORD_CONTEXT = /(?:api[_-]?key|secret|token|password|passwd|auth|bearer|access[_-]?key|private[_-]?key)/i;
83
+ const HIGH_ENTROPY_CANDIDATE = /[A-Za-z0-9_+/=\-]{16,}/g;
84
+ /**
85
+ * Entropy-based detection. Scans `text` for token-shaped substrings near
86
+ * a sensitive keyword. Two-stage gating keeps false positives low:
87
+ * 1. The substring is at least `minLength` chars of token-character class.
88
+ * 2. Shannon entropy ≥ `minEntropy` bits/char.
89
+ * 3. Either the substring or the surrounding text contains a secret
90
+ * keyword (api_key, token, secret, etc.).
91
+ */
92
+ export function runEntropyDetector(text, options = {}) {
93
+ if (options.enabled === false)
94
+ return [];
95
+ const minLength = options.minLength ?? 32;
96
+ const minEntropy = options.minEntropy ?? 4.0;
97
+ const out = [];
98
+ HIGH_ENTROPY_CANDIDATE.lastIndex = 0;
99
+ let m;
100
+ while ((m = HIGH_ENTROPY_CANDIDATE.exec(text)) !== null) {
101
+ const token = m[0];
102
+ if (token.length < minLength)
103
+ continue;
104
+ const entropy = shannonEntropy(token);
105
+ if (entropy < minEntropy)
106
+ continue;
107
+ // Avoid flagging plain decimal numbers (very low entropy but pass length).
108
+ if (/^[0-9]+$/.test(token))
109
+ continue;
110
+ // Context check: keyword either in the candidate itself or in the surrounding 80 chars.
111
+ const start = Math.max(0, m.index - 40);
112
+ const end = Math.min(text.length, m.index + token.length + 40);
113
+ const context = text.slice(start, end);
114
+ if (!SECRET_KEYWORD_CONTEXT.test(context))
115
+ continue;
116
+ out.push({ excerpt: truncate(token), entropy: Math.round(entropy * 100) / 100 });
117
+ }
118
+ return out;
119
+ }
120
+ function truncate(s, maxLen = 48) {
121
+ if (s.length <= maxLen)
122
+ return s;
123
+ return s.slice(0, Math.max(8, maxLen / 2)) + '…' + s.slice(-Math.max(4, maxLen / 4));
124
+ }
125
+ //# sourceMappingURL=security-detectors.js.map