brainclaw 1.5.4 → 1.6.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 +52 -28
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +159 -12
- package/dist/commands/assignment-resource.js +182 -0
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/init.js +158 -22
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +45 -4
- package/dist/commands/mcp.js +628 -205
- package/dist/commands/questions.js +180 -0
- package/dist/commands/reply.js +190 -0
- package/dist/commands/session-end.js +105 -3
- package/dist/commands/session-start.js +32 -53
- package/dist/commands/setup.js +87 -48
- package/dist/commands/switch.js +21 -1
- package/dist/core/agentrun-reconciler.js +65 -0
- package/dist/core/agentruns.js +10 -0
- package/dist/core/assignments.js +29 -10
- package/dist/core/claims.js +29 -0
- package/dist/core/context.js +1 -1
- package/dist/core/coordination.js +1 -1
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +166 -10
- package/dist/core/entity-registry.js +11 -10
- package/dist/core/execution-adapters.js +38 -2
- package/dist/core/facade-schema.js +55 -0
- package/dist/core/federation-cloud.js +27 -12
- package/dist/core/federation-materialize.js +57 -0
- package/dist/core/instruction-templates.js +2 -0
- package/dist/core/loops/bootstrap-acquire.js +195 -0
- package/dist/core/loops/facade-schema.js +68 -1
- package/dist/core/loops/hooks/bootstrap-write.js +144 -0
- package/dist/core/loops/hooks/notify-operator.js +148 -0
- package/dist/core/loops/hooks/survey-source-reader.js +256 -0
- package/dist/core/loops/index.js +8 -2
- package/dist/core/loops/next-expected.js +63 -0
- package/dist/core/loops/presets/bootstrap.js +75 -0
- package/dist/core/loops/presets/index.js +16 -0
- package/dist/core/loops/store.js +224 -4
- package/dist/core/loops/types.js +346 -1
- package/dist/core/loops/verbs.js +739 -6
- package/dist/core/schema.js +31 -2
- package/dist/core/state.js +62 -0
- package/dist/core/store-resolution.js +26 -16
- package/dist/facts.js +7 -5
- package/dist/facts.json +6 -4
- package/docs/cli.md +115 -30
- package/docs/concepts/dispatch-lifecycle.md +228 -0
- package/docs/concepts/loop-engine.md +55 -0
- package/docs/concepts/multi-agent-workflows.md +167 -166
- package/docs/concepts/troubleshooting.md +10 -2
- package/docs/integrations/agents.md +14 -14
- package/docs/integrations/codex.md +15 -12
- package/docs/integrations/mcp.md +10 -4
- package/docs/integrations/overview.md +11 -0
- package/docs/playbooks/productivity/index.md +3 -3
- package/docs/quickstart-existing-project.md +48 -28
- package/docs/quickstart.md +42 -28
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#518 step 1 — bootstrap-loop singleton acquire path.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the find-existing + coordination-lock + openLoop sequence from the
|
|
5
|
+
* bclaw_coordinate ideate handler so that both the CLI (`brainclaw
|
|
6
|
+
* bootstrap-loop`) and the MCP facade share the same code path. Previously
|
|
7
|
+
* the CLI used a bare `listLoops` scan without acquiring any lock, allowing
|
|
8
|
+
* two concurrent CLI invocations to both pass the scan and call `openLoop`
|
|
9
|
+
* directly — bypassing the singleton acquire and producing duplicate loops.
|
|
10
|
+
*
|
|
11
|
+
* Algorithm (lock is opportunistic, not blocking):
|
|
12
|
+
* 1. Find an existing bootstrap loop in {open, paused} → join it.
|
|
13
|
+
* 2. Atomically acquire the coordination-lock claim. If another caller won,
|
|
14
|
+
* re-find once; join if now visible, else throw
|
|
15
|
+
* `BootstrapCoordinationInProgressError`.
|
|
16
|
+
* 3. Call `openLoop`, release the lock (success or fail).
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { acquireClaimScope, listClaims, releaseClaim } from '../claims.js';
|
|
21
|
+
import { BOOTSTRAP_PRESET } from './presets/bootstrap.js';
|
|
22
|
+
import { listLoops, openLoop } from './store.js';
|
|
23
|
+
export class BootstrapCoordinationInProgressError extends Error {
|
|
24
|
+
blockingClaimId;
|
|
25
|
+
constructor(blockingClaimId) {
|
|
26
|
+
super(`another coordinator is currently opening a bootstrap loop (claim ${blockingClaimId}); retry shortly.`);
|
|
27
|
+
this.name = 'BootstrapCoordinationInProgressError';
|
|
28
|
+
this.blockingClaimId = blockingClaimId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// ---- internal helpers -------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Resolve symlinks + relative segments and normalize casing on
|
|
34
|
+
* case-insensitive filesystems (Windows) so that two callers
|
|
35
|
+
* reaching the same directory via different path representations
|
|
36
|
+
* always produce the same lock scope key.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeLockKey(cwd) {
|
|
39
|
+
let normalized;
|
|
40
|
+
try {
|
|
41
|
+
normalized = fs.realpathSync(cwd);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Path may not exist (yet) — fall back to path.resolve which still
|
|
45
|
+
// strips relative segments + normalizes separators.
|
|
46
|
+
normalized = path.resolve(cwd);
|
|
47
|
+
}
|
|
48
|
+
// Windows: case-insensitive filesystem, normalize to lower case.
|
|
49
|
+
if (process.platform === 'win32') {
|
|
50
|
+
normalized = normalized.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
/** Returns the first active/paused bootstrap loop, or undefined. */
|
|
55
|
+
export function findExistingBootstrapLoop(cwd) {
|
|
56
|
+
const all = listLoops({ kind: 'ideation' }, cwd);
|
|
57
|
+
return all.find((l) => l.protocol?.preset === 'bootstrap' && (l.status === 'open' || l.status === 'paused'));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* pln#518 step 4 — orphan-lock TTL sweep.
|
|
61
|
+
*
|
|
62
|
+
* A process crash between `saveClaim(lock)` and the release branches in
|
|
63
|
+
* `acquireBootstrapLoop` leaves the opportunistic coordination lock active
|
|
64
|
+
* forever. Subsequent bootstrap callers would surface
|
|
65
|
+
* `BootstrapCoordinationInProgressError` to the operator until manual
|
|
66
|
+
* cleanup. This sweep releases any active lock that:
|
|
67
|
+
* - matches the bootstrap-coordination-lock scope key for `cwd`, AND
|
|
68
|
+
* - is older than `ttlMs` (default 5 minutes), AND
|
|
69
|
+
* - has NO backing bootstrap loop materialized (releasing while a loop
|
|
70
|
+
* IS being opened would mask a real-time race; we only sweep true
|
|
71
|
+
* orphans).
|
|
72
|
+
*
|
|
73
|
+
* Best-effort: never throws. Returns the count of locks released so callers
|
|
74
|
+
* can surface the action in warnings if useful.
|
|
75
|
+
*/
|
|
76
|
+
const DEFAULT_LOCK_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
77
|
+
export function sweepOrphanBootstrapLocks(cwd, opts = {}) {
|
|
78
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_LOCK_TTL_MS;
|
|
79
|
+
const now = opts.now ?? new Date();
|
|
80
|
+
const lockScope = `bootstrap-coordination-lock:${normalizeLockKey(cwd ?? process.cwd())}`;
|
|
81
|
+
// If a backing loop already exists, do NOT sweep — a concurrent acquire
|
|
82
|
+
// might be mid-flight and the lock is legitimate. The findExisting check
|
|
83
|
+
// in acquireBootstrapLoop will catch the loop before we even reach the
|
|
84
|
+
// lock check, so sweeping here would only catch stuck-mid-open cases.
|
|
85
|
+
const backingLoop = findExistingBootstrapLoop(cwd);
|
|
86
|
+
if (backingLoop)
|
|
87
|
+
return { released: 0 };
|
|
88
|
+
let released = 0;
|
|
89
|
+
try {
|
|
90
|
+
const claims = listClaims(cwd);
|
|
91
|
+
for (const claim of claims) {
|
|
92
|
+
if (claim.status !== 'active' || claim.scope !== lockScope)
|
|
93
|
+
continue;
|
|
94
|
+
const createdAt = new Date(claim.created_at).getTime();
|
|
95
|
+
if (Number.isNaN(createdAt))
|
|
96
|
+
continue;
|
|
97
|
+
const ageMs = now.getTime() - createdAt;
|
|
98
|
+
if (ageMs <= ttlMs)
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
releaseClaim(claim.id, cwd);
|
|
102
|
+
released += 1;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* best-effort */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* best-effort: never throw on sweep failure */
|
|
111
|
+
}
|
|
112
|
+
return { released };
|
|
113
|
+
}
|
|
114
|
+
// ---- main export ------------------------------------------------------------
|
|
115
|
+
/**
|
|
116
|
+
* Singleton acquire path for the bootstrap loop.
|
|
117
|
+
*
|
|
118
|
+
* Callers must NOT call `openLoop` themselves for bootstrap — this function
|
|
119
|
+
* is the sole entry point. Both the CLI and the MCP bclaw_coordinate ideate
|
|
120
|
+
* handler delegate here.
|
|
121
|
+
*
|
|
122
|
+
* Throws `BootstrapCoordinationInProgressError` when a coordination lock is
|
|
123
|
+
* held by a concurrent caller and no loop has materialised yet; callers
|
|
124
|
+
* should surface this to the operator with a "retry shortly" message.
|
|
125
|
+
*
|
|
126
|
+
* All other errors (e.g. from `openLoop`) propagate as-is.
|
|
127
|
+
*/
|
|
128
|
+
export function acquireBootstrapLoop(opts, cwd) {
|
|
129
|
+
const warnings = [];
|
|
130
|
+
// Step 1 — find an already-open loop.
|
|
131
|
+
const existing = findExistingBootstrapLoop(cwd);
|
|
132
|
+
if (existing) {
|
|
133
|
+
warnings.push(`bootstrap loop already open on this project (${existing.id}, phase=${existing.current_phase}, status=${existing.status}); joined existing instead of opening a duplicate.`);
|
|
134
|
+
return { action: 'joined', loop: existing, warnings };
|
|
135
|
+
}
|
|
136
|
+
// Step 1b — sweep any stale orphan coordination locks before the acquire
|
|
137
|
+
// (pln#518 step 4). Without this, a crash between saveClaim(lock) and the
|
|
138
|
+
// release branches would block all future bootstrap callers indefinitely.
|
|
139
|
+
const sweep = sweepOrphanBootstrapLocks(cwd);
|
|
140
|
+
if (sweep.released > 0) {
|
|
141
|
+
warnings.push(`released ${sweep.released} orphan bootstrap coordination lock(s) older than the TTL with no backing loop.`);
|
|
142
|
+
}
|
|
143
|
+
// Step 2 — atomically acquire the coordination-lock claim with a
|
|
144
|
+
// normalized scope key (pln#518 step 2 — symlinks / Windows casing
|
|
145
|
+
// / relative-segment representations all map to the same key).
|
|
146
|
+
const lockScope = `bootstrap-coordination-lock:${normalizeLockKey(cwd ?? process.cwd())}`;
|
|
147
|
+
const acquireResult = acquireClaimScope({
|
|
148
|
+
scope: lockScope,
|
|
149
|
+
agent: opts.actor,
|
|
150
|
+
agent_id: opts.agent_id,
|
|
151
|
+
description: `bootstrap coordination lock (open by ${opts.actor})`,
|
|
152
|
+
user: process.env.USER || process.env.USERNAME || undefined,
|
|
153
|
+
session_id: opts.session_id,
|
|
154
|
+
model: opts.model,
|
|
155
|
+
}, cwd);
|
|
156
|
+
if (!acquireResult.acquired) {
|
|
157
|
+
// Lost race — re-check once: the holder may have just finished opening the loop.
|
|
158
|
+
const reFound = findExistingBootstrapLoop(cwd);
|
|
159
|
+
if (reFound) {
|
|
160
|
+
warnings.push(`bootstrap loop opened by a parallel coordinator (${reFound.id}); joined existing.`);
|
|
161
|
+
return { action: 'joined', loop: reFound, warnings };
|
|
162
|
+
}
|
|
163
|
+
throw new BootstrapCoordinationInProgressError(acquireResult.conflicting_claim.id);
|
|
164
|
+
}
|
|
165
|
+
// Step 3 — open the loop, release the lock.
|
|
166
|
+
const lockClaimId = acquireResult.claim.id;
|
|
167
|
+
try {
|
|
168
|
+
const loop = openLoop({
|
|
169
|
+
kind: 'ideation',
|
|
170
|
+
title: opts.title ?? 'Bootstrap PROJECT.md',
|
|
171
|
+
goal: opts.goal,
|
|
172
|
+
created_by: opts.created_by ?? opts.agent_id ?? opts.actor,
|
|
173
|
+
slots: [
|
|
174
|
+
{
|
|
175
|
+
role: 'champion',
|
|
176
|
+
agent: opts.actor,
|
|
177
|
+
...(opts.agent_id ? { agent_id: opts.agent_id } : {}),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
phases: BOOTSTRAP_PRESET.phases,
|
|
181
|
+
stop_condition: BOOTSTRAP_PRESET.stop_condition,
|
|
182
|
+
protocol: BOOTSTRAP_PRESET.protocol,
|
|
183
|
+
}, cwd);
|
|
184
|
+
return { action: 'opened', loop, warnings };
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
try {
|
|
188
|
+
releaseClaim(lockClaimId, cwd);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* best-effort */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=bootstrap-acquire.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { LOOP_KINDS, LOOP_STATUSES, LoopLinksSchema, LoopPhaseSchema, LoopRefSchema, LoopSlotSchema, REVIEW_MODES, StopConditionSchema, } from './types.js';
|
|
2
|
+
import { LOOP_KINDS, LOOP_STATUSES, LoopLinksSchema, LoopPhaseSchema, LoopRefSchema, LoopSlotSchema, ON_TIMEOUT_POLICIES, OperatorQuestionOptionSchema, PAUSE_SCOPES, RESOLVED_VIA, REVIEW_MODES, StopConditionSchema, } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* `bclaw_loop(intent)` request schemas — one per intent, unioned into a
|
|
5
5
|
* discriminated schema. Mirrors the BclawLoopInput type from the v8 RFC.
|
|
@@ -7,6 +7,7 @@ import { LOOP_KINDS, LOOP_STATUSES, LoopLinksSchema, LoopPhaseSchema, LoopRefSch
|
|
|
7
7
|
const CallerEnvelopeFields = {
|
|
8
8
|
agent: z.string().optional(),
|
|
9
9
|
agentId: z.string().optional(),
|
|
10
|
+
project: z.string().optional(),
|
|
10
11
|
client_request_id: z.string().min(1).optional(),
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
@@ -73,6 +74,8 @@ export const BclawLoopCompleteTurnSchema = z.object({
|
|
|
73
74
|
type: z.string().min(1),
|
|
74
75
|
body: z.string().optional(),
|
|
75
76
|
ref: LoopRefSchema.optional(),
|
|
77
|
+
/** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
|
|
78
|
+
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
76
79
|
})
|
|
77
80
|
.optional(),
|
|
78
81
|
expected_version: z.number().int().nonnegative().optional(),
|
|
@@ -96,6 +99,8 @@ export const BclawLoopAddArtifactSchema = z.object({
|
|
|
96
99
|
body: z.string().optional(),
|
|
97
100
|
produced_by: z.string().optional(),
|
|
98
101
|
ref: LoopRefSchema.optional(),
|
|
102
|
+
/** pln#492 synthesis audit trail. Required when type === 'plan_draft'. */
|
|
103
|
+
addresses_critique: z.array(z.string().min(1)).optional(),
|
|
99
104
|
}),
|
|
100
105
|
expected_version: z.number().int().nonnegative().optional(),
|
|
101
106
|
...CallerEnvelopeFields,
|
|
@@ -121,6 +126,64 @@ export const BclawLoopCloseSchema = z.object({
|
|
|
121
126
|
expected_version: z.number().int().nonnegative().optional(),
|
|
122
127
|
...CallerEnvelopeFields,
|
|
123
128
|
});
|
|
129
|
+
/**
|
|
130
|
+
* pln#508 step 2 — `bclaw_loop(intent='request_input')`.
|
|
131
|
+
*
|
|
132
|
+
* A slot pauses on an operator question. The handler generates a fresh
|
|
133
|
+
* `question_id`, JSON-encodes the OperatorQuestionBody, attaches it as an
|
|
134
|
+
* `operator_question` artifact, appends the id to `LoopThread.open_questions`,
|
|
135
|
+
* and transitions either the slot (`pause_scope='slot'` → status=waiting_input)
|
|
136
|
+
* or the whole loop (`pause_scope='loop'` → status=paused, pause_reason='awaiting_operator').
|
|
137
|
+
*
|
|
138
|
+
* Refused when the loop is not in status='open' (no compounding pauses) and
|
|
139
|
+
* when `loop.protocol.max_operator_questions` is already reached (anti
|
|
140
|
+
* autonomy-gap cap, e.g. the bootstrap preset sets max=3).
|
|
141
|
+
*/
|
|
142
|
+
export const BclawLoopRequestInputSchema = z.object({
|
|
143
|
+
intent: z.literal('request_input'),
|
|
144
|
+
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
145
|
+
slot_id: z.string().min(1),
|
|
146
|
+
phase: z.string().min(1),
|
|
147
|
+
question_text: z.string().min(1).max(500),
|
|
148
|
+
evidence: z.array(z.string().min(1)).min(1),
|
|
149
|
+
suggested_default: z.string().optional(),
|
|
150
|
+
options: z.array(OperatorQuestionOptionSchema).min(2).max(4).optional(),
|
|
151
|
+
pause_scope: z.enum(PAUSE_SCOPES),
|
|
152
|
+
on_timeout: z.enum(ON_TIMEOUT_POLICIES),
|
|
153
|
+
timeout_at: z.string().datetime().optional(),
|
|
154
|
+
expected_version: z.number().int().nonnegative().optional(),
|
|
155
|
+
...CallerEnvelopeFields,
|
|
156
|
+
});
|
|
157
|
+
/**
|
|
158
|
+
* pln#508 step 2 — `bclaw_loop(intent='provide_input')`.
|
|
159
|
+
*
|
|
160
|
+
* Resolves an open operator_question. Idempotency: if `replies_to` is no
|
|
161
|
+
* longer in `loop.open_questions` but an existing operator_answer artifact
|
|
162
|
+
* references it, the existing answer is returned (no new artifact created).
|
|
163
|
+
* Unknown `replies_to` → `unknown_question` error.
|
|
164
|
+
*
|
|
165
|
+
* Resume logic:
|
|
166
|
+
* - If the source question had `pause_scope='slot'`, the asking slot
|
|
167
|
+
* (`by_slot_id`) transitions from `waiting_input` back to `working`.
|
|
168
|
+
* - If `pause_scope='loop'` AND `open_questions` becomes empty AND the
|
|
169
|
+
* loop is paused on `awaiting_operator`, the loop resumes to status='open'.
|
|
170
|
+
*/
|
|
171
|
+
export const BclawLoopProvideInputSchema = z.object({
|
|
172
|
+
intent: z.literal('provide_input'),
|
|
173
|
+
loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
|
|
174
|
+
replies_to: z.string().regex(/^qst_[0-9a-z]+$/),
|
|
175
|
+
resolved_via: z.enum(RESOLVED_VIA),
|
|
176
|
+
answer_text: z.string().optional(),
|
|
177
|
+
chosen_option_id: z.string().optional(),
|
|
178
|
+
/**
|
|
179
|
+
* Defaults to 'operator'. The timeout machinery (pln#508 step 3) calls
|
|
180
|
+
* the underlying verb with `by='system'` to create synthetic answers,
|
|
181
|
+
* but external callers should leave this absent.
|
|
182
|
+
*/
|
|
183
|
+
by: z.enum(['operator', 'system']).optional(),
|
|
184
|
+
expected_version: z.number().int().nonnegative().optional(),
|
|
185
|
+
...CallerEnvelopeFields,
|
|
186
|
+
});
|
|
124
187
|
export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
|
|
125
188
|
BclawLoopOpenSchema,
|
|
126
189
|
BclawLoopGetSchema,
|
|
@@ -132,6 +195,8 @@ export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
|
|
|
132
195
|
BclawLoopPauseSchema,
|
|
133
196
|
BclawLoopResumeSchema,
|
|
134
197
|
BclawLoopCloseSchema,
|
|
198
|
+
BclawLoopRequestInputSchema,
|
|
199
|
+
BclawLoopProvideInputSchema,
|
|
135
200
|
]);
|
|
136
201
|
export const BCLAW_LOOP_INTENTS = [
|
|
137
202
|
'open',
|
|
@@ -144,5 +209,7 @@ export const BCLAW_LOOP_INTENTS = [
|
|
|
144
209
|
'pause',
|
|
145
210
|
'resume',
|
|
146
211
|
'close',
|
|
212
|
+
'request_input',
|
|
213
|
+
'provide_input',
|
|
147
214
|
];
|
|
148
215
|
//# sourceMappingURL=facade-schema.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { memoryDir, writeFileAtomic } from '../../io.js';
|
|
5
|
+
import { nowISO } from '../../ids.js';
|
|
6
|
+
import { LoopArtifactSchema, RefBasedArtifactBodySchema, } from '../types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Directory where ref-based artifact payloads for a given loop live on disk.
|
|
9
|
+
* No central helper exists yet, so this is the canonical place to compute it.
|
|
10
|
+
* Layout mirrors the thread/event storage convention in store.ts:
|
|
11
|
+
*
|
|
12
|
+
* <memoryDir>/loops/threads/<loop_id>/artifacts/<ref>
|
|
13
|
+
*/
|
|
14
|
+
function loopArtifactsDir(loopId, cwd) {
|
|
15
|
+
return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'threads', loopId, 'artifacts');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns the most recently produced `project_md_final` artifact on the loop,
|
|
19
|
+
* or `undefined` if none has been added yet. Artifacts on `loop.artifacts` are
|
|
20
|
+
* appended in production order, so the last match is "latest".
|
|
21
|
+
*/
|
|
22
|
+
function findLatestFinal(loop) {
|
|
23
|
+
for (let i = loop.artifacts.length - 1; i >= 0; i--) {
|
|
24
|
+
const a = loop.artifacts[i];
|
|
25
|
+
if (a.type === 'project_md_final')
|
|
26
|
+
return a;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse the ref-based body off a known-typed loop artifact. The
|
|
32
|
+
* LoopArtifactSchema validator already enforces the JSON shape, so any failure
|
|
33
|
+
* here points at a corrupt thread file rather than schema drift.
|
|
34
|
+
*/
|
|
35
|
+
function parseRefBody(artifact) {
|
|
36
|
+
if (!artifact.body) {
|
|
37
|
+
throw new Error(`writeProjectMdSafe: artifact ${artifact.artifact_id} (type=${artifact.type}) has no body — expected ref-based payload`);
|
|
38
|
+
}
|
|
39
|
+
return RefBasedArtifactBodySchema.parse(JSON.parse(artifact.body));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hand-rolled unified-diff renderer.
|
|
43
|
+
*
|
|
44
|
+
* Phase 0 spec §file_overwrite_approval explicitly accepts a coarse diff for
|
|
45
|
+
* v1 — the operator reads it; nothing automated patches with it. So we render
|
|
46
|
+
* every old line as `-` and every new line as `+` under a single `@@` hunk
|
|
47
|
+
* header. Output is stable and deterministic.
|
|
48
|
+
*/
|
|
49
|
+
function generateUnifiedDiff(oldContent, newContent, oldLabel, newLabel) {
|
|
50
|
+
const oldLines = oldContent.length === 0 ? [] : oldContent.split(/\r?\n/);
|
|
51
|
+
const newLines = newContent.length === 0 ? [] : newContent.split(/\r?\n/);
|
|
52
|
+
const oldCount = oldLines.length;
|
|
53
|
+
const newCount = newLines.length;
|
|
54
|
+
const oldStart = oldCount === 0 ? 0 : 1;
|
|
55
|
+
const newStart = newCount === 0 ? 0 : 1;
|
|
56
|
+
const lines = [];
|
|
57
|
+
lines.push(`--- ${oldLabel}`);
|
|
58
|
+
lines.push(`+++ ${newLabel}`);
|
|
59
|
+
lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
60
|
+
for (const l of oldLines)
|
|
61
|
+
lines.push(`-${l}`);
|
|
62
|
+
for (const l of newLines)
|
|
63
|
+
lines.push(`+${l}`);
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
function sha256Hex(content) {
|
|
67
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* pln#512 step 1 — IMPL.
|
|
71
|
+
*
|
|
72
|
+
* @see WriteProjectMdResult
|
|
73
|
+
* @see WriteProjectMdOptions
|
|
74
|
+
*/
|
|
75
|
+
export function writeProjectMdSafe(loop, cwd, opts) {
|
|
76
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
77
|
+
const target_path = path.join(resolvedCwd, 'PROJECT.md');
|
|
78
|
+
const finalArtifact = findLatestFinal(loop);
|
|
79
|
+
if (!finalArtifact) {
|
|
80
|
+
return {
|
|
81
|
+
needs_approval: false,
|
|
82
|
+
target_path,
|
|
83
|
+
written: false,
|
|
84
|
+
reason: 'no_final_artifact',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const body = parseRefBody(finalArtifact);
|
|
88
|
+
const sourcePath = path.join(loopArtifactsDir(loop.id, resolvedCwd), body.ref);
|
|
89
|
+
const sourceContent = fs.readFileSync(sourcePath, 'utf8');
|
|
90
|
+
const exists = fs.existsSync(target_path);
|
|
91
|
+
const isEmpty = exists && fs.statSync(target_path).size === 0;
|
|
92
|
+
if (!exists || isEmpty) {
|
|
93
|
+
writeFileAtomic(target_path, sourceContent);
|
|
94
|
+
return {
|
|
95
|
+
needs_approval: false,
|
|
96
|
+
target_path,
|
|
97
|
+
written: true,
|
|
98
|
+
reason: exists ? 'empty' : 'absent',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// pln#512 step 2 — approval short-circuit. When the operator has already
|
|
102
|
+
// signed off on the overwrite (via a resolved file_overwrite_approval
|
|
103
|
+
// question), the caller passes opts.approved=true and we write atomically
|
|
104
|
+
// without re-generating a diff artifact. Mirrors the absent/empty branch
|
|
105
|
+
// semantics so callers see a unified shape.
|
|
106
|
+
if (opts?.approved === true) {
|
|
107
|
+
writeFileAtomic(target_path, sourceContent);
|
|
108
|
+
return {
|
|
109
|
+
needs_approval: false,
|
|
110
|
+
target_path,
|
|
111
|
+
written: true,
|
|
112
|
+
reason: 'present_non_empty',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Present + non-empty → build a file_diff artifact for operator approval.
|
|
116
|
+
// We do NOT touch the loop thread or the target file; the caller (step 2)
|
|
117
|
+
// splices the artifact onto the thread under request_input.
|
|
118
|
+
const existingContent = fs.readFileSync(target_path, 'utf8');
|
|
119
|
+
const diff = generateUnifiedDiff(existingContent, sourceContent, 'PROJECT.md', 'PROJECT.md (proposed)');
|
|
120
|
+
const artifactId = `art_${crypto.randomBytes(6).toString('hex')}`;
|
|
121
|
+
const patchRef = `${artifactId}.patch`;
|
|
122
|
+
const patchPath = path.join(loopArtifactsDir(loop.id, resolvedCwd), patchRef);
|
|
123
|
+
fs.mkdirSync(path.dirname(patchPath), { recursive: true });
|
|
124
|
+
writeFileAtomic(patchPath, diff);
|
|
125
|
+
const refBody = {
|
|
126
|
+
ref: patchRef,
|
|
127
|
+
byte_count: Buffer.byteLength(diff, 'utf8'),
|
|
128
|
+
sha256: sha256Hex(diff),
|
|
129
|
+
};
|
|
130
|
+
const diff_artifact = LoopArtifactSchema.parse({
|
|
131
|
+
artifact_id: artifactId,
|
|
132
|
+
phase: finalArtifact.phase,
|
|
133
|
+
type: 'file_diff',
|
|
134
|
+
body: JSON.stringify(refBody),
|
|
135
|
+
produced_at: nowISO(),
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
needs_approval: true,
|
|
139
|
+
target_path,
|
|
140
|
+
diff_artifact,
|
|
141
|
+
reason: 'present_non_empty',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=bootstrap-write.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import child_process from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* pln#513 step 4 — OS notifications hook on input_requested events.
|
|
4
|
+
*
|
|
5
|
+
* Best-effort, fire-and-forget OS-native heads-up so the operator notices
|
|
6
|
+
* when a bootstrap loop pauses on an operator_question. Gated by
|
|
7
|
+
* `BRAINCLAW_OPERATOR_NOTIFICATIONS=1` (opt-in) and scoped to bootstrap-preset
|
|
8
|
+
* loops in v1. Every code path is wrapped so a missing notifier binary,
|
|
9
|
+
* unparseable artifact body, or spawn error never propagates to the caller —
|
|
10
|
+
* the journal write must remain the source of truth.
|
|
11
|
+
*/
|
|
12
|
+
const TITLE = 'brainclaw';
|
|
13
|
+
const QUESTION_TEXT_CAP = 80;
|
|
14
|
+
function isEnabled() {
|
|
15
|
+
return process.env.BRAINCLAW_OPERATOR_NOTIFICATIONS === '1';
|
|
16
|
+
}
|
|
17
|
+
function isBootstrapLoop(loop) {
|
|
18
|
+
return loop.protocol?.preset === 'bootstrap';
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the matching operator_question artifact body for the event's
|
|
22
|
+
* question_id and return its question_text, truncated. Returns undefined
|
|
23
|
+
* whenever the artifact can't be located or its body fails to parse —
|
|
24
|
+
* the notification body still works without it.
|
|
25
|
+
*/
|
|
26
|
+
function resolveQuestionText(event, loop) {
|
|
27
|
+
if (event.kind !== 'input_requested')
|
|
28
|
+
return undefined;
|
|
29
|
+
for (const artifact of loop.artifacts) {
|
|
30
|
+
if (artifact.type !== 'operator_question' || artifact.body === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
const body = JSON.parse(artifact.body);
|
|
34
|
+
if (body.question_id === event.question_id) {
|
|
35
|
+
const text = body.question_text;
|
|
36
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
return text.length > QUESTION_TEXT_CAP
|
|
39
|
+
? `${text.slice(0, QUESTION_TEXT_CAP)}…`
|
|
40
|
+
: text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// ignore unparseable bodies; fall through to the next artifact
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Sanitize the message before passing it to a shell-bridge command
|
|
51
|
+
* (osascript, powershell). We allow only printable ASCII apart from
|
|
52
|
+
* double-quotes / backticks / backslashes / control chars to avoid quoting
|
|
53
|
+
* pitfalls on every platform. The fallback `notify-send` on Linux runs
|
|
54
|
+
* via an arg vector so its sanitization is just length-capping.
|
|
55
|
+
*/
|
|
56
|
+
function sanitizeForShell(message) {
|
|
57
|
+
return message
|
|
58
|
+
.replace(/["`\\$]/g, '')
|
|
59
|
+
.replace(/[\r\n\t]/g, ' ')
|
|
60
|
+
.replace(/[\x00-\x1f\x7f]/g, '');
|
|
61
|
+
}
|
|
62
|
+
function composeMessage(event, loop) {
|
|
63
|
+
const base = `brainclaw bootstrap: question awaiting input on loop ${loop.id}`;
|
|
64
|
+
const text = resolveQuestionText(event, loop);
|
|
65
|
+
return text ? `${base} — ${text}` : base;
|
|
66
|
+
}
|
|
67
|
+
function spawnDetached(command, args) {
|
|
68
|
+
const child = child_process.spawn(command, args, {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: 'ignore',
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
child.on('error', () => {
|
|
74
|
+
// missing binary or exec failure — best-effort, swallow.
|
|
75
|
+
});
|
|
76
|
+
child.unref();
|
|
77
|
+
}
|
|
78
|
+
function notifyLinux(message) {
|
|
79
|
+
spawnDetached('notify-send', [TITLE, message]);
|
|
80
|
+
}
|
|
81
|
+
function notifyMac(message) {
|
|
82
|
+
const safe = sanitizeForShell(message);
|
|
83
|
+
spawnDetached('osascript', [
|
|
84
|
+
'-e',
|
|
85
|
+
`display notification "${safe}" with title "${TITLE}"`,
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
function notifyWindows(message) {
|
|
89
|
+
const safe = sanitizeForShell(message);
|
|
90
|
+
// Try BurntToast if available; fall back to a terminal bell on stderr if
|
|
91
|
+
// PowerShell itself cannot be invoked. Both paths are best-effort — we
|
|
92
|
+
// never observe the exit code.
|
|
93
|
+
const psCommand = `if (Get-Module -ListAvailable -Name BurntToast) { ` +
|
|
94
|
+
`Import-Module BurntToast; New-BurntToastNotification -Text "${TITLE}", "${safe}" ` +
|
|
95
|
+
`} else { [console]::Beep(800, 200) }`;
|
|
96
|
+
const child = child_process.spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', psCommand], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
97
|
+
child.on('error', () => {
|
|
98
|
+
try {
|
|
99
|
+
process.stderr.write('\x07');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// give up silently
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
child.unref();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fire an OS-native notification on `input_requested` events for bootstrap
|
|
109
|
+
* loops. Returns immediately when:
|
|
110
|
+
* - the event is not `input_requested`,
|
|
111
|
+
* - the env-var opt-in is missing,
|
|
112
|
+
* - the loop's protocol preset is not `bootstrap`,
|
|
113
|
+
* - the host platform has no supported notifier.
|
|
114
|
+
*
|
|
115
|
+
* Never throws. The cwd parameter is accepted for parity with other hooks
|
|
116
|
+
* but currently unused — the hook decides everything from the event + loop
|
|
117
|
+
* snapshot the caller already loaded.
|
|
118
|
+
*/
|
|
119
|
+
export function notifyOperatorOnInputRequested(event, loop, cwd) {
|
|
120
|
+
void cwd;
|
|
121
|
+
try {
|
|
122
|
+
if (event.kind !== 'input_requested')
|
|
123
|
+
return;
|
|
124
|
+
if (!isEnabled())
|
|
125
|
+
return;
|
|
126
|
+
if (!isBootstrapLoop(loop))
|
|
127
|
+
return;
|
|
128
|
+
const message = composeMessage(event, loop);
|
|
129
|
+
switch (process.platform) {
|
|
130
|
+
case 'linux':
|
|
131
|
+
notifyLinux(message);
|
|
132
|
+
return;
|
|
133
|
+
case 'darwin':
|
|
134
|
+
notifyMac(message);
|
|
135
|
+
return;
|
|
136
|
+
case 'win32':
|
|
137
|
+
notifyWindows(message);
|
|
138
|
+
return;
|
|
139
|
+
default:
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Hook is best-effort — swallow any unexpected error so the journal
|
|
145
|
+
// write that triggered us stays the source of truth.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=notify-operator.js.map
|