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.
- package/README.md +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- 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
|
|
96
|
-
|
|
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 '';
|
package/dist/core/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/schema.js
CHANGED
|
@@ -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
|