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.
Files changed (60) hide show
  1. package/README.md +52 -28
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +159 -12
  4. package/dist/commands/assignment-resource.js +182 -0
  5. package/dist/commands/bootstrap-loop.js +206 -0
  6. package/dist/commands/init.js +158 -22
  7. package/dist/commands/loop.js +156 -0
  8. package/dist/commands/loops-handlers.js +110 -55
  9. package/dist/commands/mcp-read-handlers.js +45 -4
  10. package/dist/commands/mcp.js +628 -205
  11. package/dist/commands/questions.js +180 -0
  12. package/dist/commands/reply.js +190 -0
  13. package/dist/commands/session-end.js +105 -3
  14. package/dist/commands/session-start.js +32 -53
  15. package/dist/commands/setup.js +87 -48
  16. package/dist/commands/switch.js +21 -1
  17. package/dist/core/agentrun-reconciler.js +65 -0
  18. package/dist/core/agentruns.js +10 -0
  19. package/dist/core/assignments.js +29 -10
  20. package/dist/core/claims.js +29 -0
  21. package/dist/core/context.js +1 -1
  22. package/dist/core/coordination.js +1 -1
  23. package/dist/core/dispatch-status.js +219 -0
  24. package/dist/core/entity-operations.js +166 -10
  25. package/dist/core/entity-registry.js +11 -10
  26. package/dist/core/execution-adapters.js +38 -2
  27. package/dist/core/facade-schema.js +55 -0
  28. package/dist/core/federation-cloud.js +27 -12
  29. package/dist/core/federation-materialize.js +57 -0
  30. package/dist/core/instruction-templates.js +2 -0
  31. package/dist/core/loops/bootstrap-acquire.js +195 -0
  32. package/dist/core/loops/facade-schema.js +68 -1
  33. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  34. package/dist/core/loops/hooks/notify-operator.js +148 -0
  35. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  36. package/dist/core/loops/index.js +8 -2
  37. package/dist/core/loops/next-expected.js +63 -0
  38. package/dist/core/loops/presets/bootstrap.js +75 -0
  39. package/dist/core/loops/presets/index.js +16 -0
  40. package/dist/core/loops/store.js +224 -4
  41. package/dist/core/loops/types.js +346 -1
  42. package/dist/core/loops/verbs.js +739 -6
  43. package/dist/core/schema.js +31 -2
  44. package/dist/core/state.js +62 -0
  45. package/dist/core/store-resolution.js +26 -16
  46. package/dist/facts.js +7 -5
  47. package/dist/facts.json +6 -4
  48. package/docs/cli.md +115 -30
  49. package/docs/concepts/dispatch-lifecycle.md +228 -0
  50. package/docs/concepts/loop-engine.md +55 -0
  51. package/docs/concepts/multi-agent-workflows.md +167 -166
  52. package/docs/concepts/troubleshooting.md +10 -2
  53. package/docs/integrations/agents.md +14 -14
  54. package/docs/integrations/codex.md +15 -12
  55. package/docs/integrations/mcp.md +10 -4
  56. package/docs/integrations/overview.md +11 -0
  57. package/docs/playbooks/productivity/index.md +3 -3
  58. package/docs/quickstart-existing-project.md +48 -28
  59. package/docs/quickstart.md +42 -28
  60. 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