brainclaw 1.5.5 → 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 (43) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli.js +124 -7
  3. package/dist/commands/bootstrap-loop.js +206 -0
  4. package/dist/commands/loop.js +156 -0
  5. package/dist/commands/loops-handlers.js +110 -55
  6. package/dist/commands/mcp-read-handlers.js +37 -0
  7. package/dist/commands/mcp.js +621 -202
  8. package/dist/commands/questions.js +180 -0
  9. package/dist/commands/reply.js +190 -0
  10. package/dist/commands/session-end.js +105 -3
  11. package/dist/commands/session-start.js +32 -53
  12. package/dist/commands/switch.js +17 -1
  13. package/dist/core/agentrun-reconciler.js +65 -0
  14. package/dist/core/claims.js +29 -0
  15. package/dist/core/dispatch-status.js +219 -0
  16. package/dist/core/entity-operations.js +128 -9
  17. package/dist/core/execution-adapters.js +38 -2
  18. package/dist/core/facade-schema.js +55 -0
  19. package/dist/core/federation-cloud.js +27 -12
  20. package/dist/core/federation-materialize.js +57 -0
  21. package/dist/core/instruction-templates.js +2 -0
  22. package/dist/core/loops/bootstrap-acquire.js +195 -0
  23. package/dist/core/loops/facade-schema.js +68 -1
  24. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  25. package/dist/core/loops/hooks/notify-operator.js +148 -0
  26. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  27. package/dist/core/loops/index.js +8 -2
  28. package/dist/core/loops/next-expected.js +63 -0
  29. package/dist/core/loops/presets/bootstrap.js +75 -0
  30. package/dist/core/loops/presets/index.js +16 -0
  31. package/dist/core/loops/store.js +224 -4
  32. package/dist/core/loops/types.js +346 -1
  33. package/dist/core/loops/verbs.js +739 -6
  34. package/dist/core/schema.js +28 -2
  35. package/dist/core/state.js +62 -0
  36. package/dist/facts.js +7 -5
  37. package/dist/facts.json +6 -4
  38. package/docs/concepts/dispatch-lifecycle.md +228 -0
  39. package/docs/concepts/loop-engine.md +55 -0
  40. package/docs/concepts/multi-agent-workflows.md +167 -166
  41. package/docs/concepts/troubleshooting.md +10 -2
  42. package/docs/integrations/overview.md +14 -12
  43. package/package.json +1 -1
@@ -13,6 +13,7 @@ export const WorkRequestSchema = z.object({
13
13
  task: z.string().optional(),
14
14
  messageId: z.string().optional(),
15
15
  contextTarget: z.string().optional(),
16
+ project: z.string().optional(),
16
17
  compact: z.boolean().optional().default(true),
17
18
  });
18
19
  export const CoordinateRequestSchema = z.object({
@@ -54,6 +55,27 @@ export const CoordinateRequestSchema = z.object({
54
55
  * the target agent picks up the brief async via its own bclaw_work.
55
56
  */
56
57
  project: z.string().optional(),
58
+ /**
59
+ * Bypass the pre-flight uncommitted-changes check (can_30c295b4 fix).
60
+ * By default, bclaw_coordinate refuses dispatches when the source cwd
61
+ * has uncommitted modifications, because the dispatched worker spawns
62
+ * from HEAD and won't see them — leading to silent review on stale code.
63
+ * Set allow_dirty=true to override (e.g. when the caller knows the
64
+ * dispatched work doesn't depend on the dirty files, or when running
65
+ * tests). Has no effect when the source cwd is not a git repo.
66
+ */
67
+ allow_dirty: z.boolean().optional(),
68
+ /**
69
+ * pln#511 step 2 — loop preset selector. When set on intent='ideate',
70
+ * the handler bypasses the kind-default ideation phases and opens the
71
+ * loop with the named preset's phases / stop_condition / protocol.
72
+ * v1 ships a single preset ('bootstrap', see src/core/loops/presets/).
73
+ * The handler validates the name against the preset registry and
74
+ * rejects unknown names with `unknown_preset`. Presets are kind-
75
+ * specific: passing `preset` with any intent other than 'ideate' is
76
+ * rejected as `preset_kind_mismatch`.
77
+ */
78
+ preset: z.string().min(1).optional(),
57
79
  });
58
80
  export const FacadeArtifactSchema = z.object({
59
81
  type: z.string(),
@@ -65,6 +87,21 @@ export const FacadeSideEffectSchema = z.object({
65
87
  entity: z.string(),
66
88
  id: z.string(),
67
89
  });
90
+ /**
91
+ * Self-documenting verification hint attached to dispatch responses (pln#503
92
+ * phase 3.3). Tells the caller exactly which canonical-grammar call to make
93
+ * next to verify the spawn is actually doing work — `delivered_and_started`
94
+ * is a brief-ack signal, not proof of life. See dispatch-lifecycle.md.
95
+ */
96
+ export const VerifyWithSchema = z.object({
97
+ action: z.literal('bclaw_find'),
98
+ entity: z.literal('agent_run'),
99
+ filter: z.object({ assignment_id: z.string() }),
100
+ /** Human-readable description of what to look for in the result. */
101
+ expected_when_alive: z.string(),
102
+ /** Doc pointer for the diagnostic flow when the check fails. */
103
+ see_also: z.string(),
104
+ });
68
105
  export const FacadeResponseSchema = z.object({
69
106
  status: z.enum(['ok', 'error', 'partial']),
70
107
  intent: z.string(),
@@ -77,5 +114,23 @@ export const FacadeResponseSchema = z.object({
77
114
  session_id: z.string().optional(),
78
115
  warnings: z.array(z.string()),
79
116
  execution_status: ExecutionStatusSchema.optional(),
117
+ /** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
118
+ verify_with: VerifyWithSchema.optional(),
119
+ /**
120
+ * pln#513 step 1 — bclaw_work hint surfaced when the project lacks a usable
121
+ * PROJECT.md (absent or zero bytes). True means the agent should consider
122
+ * opening a bootstrap loop before assuming context; the literal call to
123
+ * make is in `next_action`. False or absent means the project already has
124
+ * a PROJECT.md and the bootstrap entry-point should not be offered.
125
+ * Additive: existing callers that don't read it are unaffected.
126
+ */
127
+ bootstrap_recommended: z.boolean().optional(),
128
+ /**
129
+ * pln#513 step 1 — literal MCP call to surface as the bootstrap entry-point
130
+ * when `bootstrap_recommended` is true. Carries the canonical-grammar text
131
+ * (`bclaw_coordinate(intent='ideate', preset='bootstrap')`) verbatim so the
132
+ * CLI doesn't have to reconstruct it.
133
+ */
134
+ next_action: z.string().optional(),
80
135
  });
81
136
  //# sourceMappingURL=facade-schema.js.map
@@ -2,24 +2,29 @@ import { loadConfig } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  const DEFAULT_API_URL = 'https://app.brainclaw.dev';
4
4
  function resolveCloudConfig(cwd) {
5
- // Check env vars first
6
- const apiUrl = process.env.BRAINCLAW_CLOUD_URL ?? DEFAULT_API_URL;
7
- const apiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
8
- if (apiKey) {
9
- return { apiUrl, apiKey };
10
- }
11
- // Check config.yaml
5
+ const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
6
+ const envApiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
7
+ let configEnabled = false;
8
+ let configEndpoint;
9
+ let configApiKey;
12
10
  try {
13
11
  const config = loadConfig(cwd);
14
- const cloud = config.cloud;
15
- if (cloud?.api_key) {
16
- return { apiUrl: cloud.api_url ?? apiUrl, apiKey: cloud.api_key };
12
+ if (config.cloud_sync) {
13
+ configEnabled = config.cloud_sync.enabled === true;
14
+ configEndpoint = config.cloud_sync.endpoint;
15
+ configApiKey = config.cloud_sync.api_key;
17
16
  }
18
17
  }
19
18
  catch {
20
- // No config — cloud not configured
19
+ // No config available fall back to env only
21
20
  }
22
- return undefined;
21
+ const apiKey = envApiKey ?? configApiKey;
22
+ if (!apiKey)
23
+ return undefined;
24
+ // Env-supplied key implies explicit opt-in; config flag is the alternative
25
+ const enabled = Boolean(envApiKey) || configEnabled;
26
+ const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
27
+ return { apiUrl, apiKey, enabled };
23
28
  }
24
29
  export async function pushSignalToCloud(message, cwd) {
25
30
  const cloud = resolveCloudConfig(cwd);
@@ -96,4 +101,14 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
96
101
  export function isCloudConfigured(cwd) {
97
102
  return resolveCloudConfig(cwd) !== undefined;
98
103
  }
104
+ /**
105
+ * Returns true when cloud sync is both configured AND explicitly opted-in.
106
+ * Use this gate for automatic lifecycle hooks (session-start pull, session-end push).
107
+ * `isCloudConfigured` alone does NOT imply opt-in — a stale config api_key without
108
+ * `cloud_sync.enabled=true` and without the BRAINCLAW_CLOUD_API_KEY env var stays inert.
109
+ */
110
+ export function isCloudSyncEnabled(cwd) {
111
+ const cloud = resolveCloudConfig(cwd);
112
+ return cloud !== undefined && cloud.enabled;
113
+ }
99
114
  //# sourceMappingURL=federation-cloud.js.map
@@ -0,0 +1,57 @@
1
+ import { CandidateSchema, HandoffSchema, RuntimeNoteSchema } from './schema.js';
2
+ import { saveCandidate, generateCandidateIdWithLabel } from './candidates.js';
3
+ import { saveRuntimeNote, generateRuntimeNoteId } from './runtime.js';
4
+ import { generateIdWithLabel, nowISO } from './ids.js';
5
+ import { mutateState } from './state.js';
6
+ export function materializeFederationSignal(signal, cwd) {
7
+ const origin = `remote:${signal.from.project_name}:${signal.from.agent_name}`;
8
+ if (signal.type === 'candidate') {
9
+ const parsed = CandidateSchema.safeParse(signal.payload);
10
+ if (!parsed.success)
11
+ return false;
12
+ const { id, short_label } = generateCandidateIdWithLabel(cwd);
13
+ saveCandidate({
14
+ ...parsed.data,
15
+ id,
16
+ short_label,
17
+ created_at: nowISO(),
18
+ source: undefined, // remote signal — treated as 'human' (legacy default)
19
+ star_count: 0,
20
+ starred_by: [],
21
+ usage_count: 0,
22
+ usage_events: [],
23
+ status: 'pending',
24
+ }, cwd);
25
+ return true;
26
+ }
27
+ if (signal.type === 'handoff') {
28
+ const parsed = HandoffSchema.safeParse(signal.payload);
29
+ if (!parsed.success)
30
+ return false;
31
+ const { id, short_label } = generateIdWithLabel('open_handoffs', cwd);
32
+ mutateState((state) => {
33
+ state.open_handoffs.push({
34
+ ...parsed.data,
35
+ id,
36
+ short_label,
37
+ created_at: nowISO(),
38
+ tags: [...(parsed.data.tags ?? []), origin],
39
+ });
40
+ }, cwd);
41
+ return true;
42
+ }
43
+ if (signal.type === 'runtime_note') {
44
+ const parsed = RuntimeNoteSchema.safeParse(signal.payload);
45
+ if (!parsed.success)
46
+ return false;
47
+ saveRuntimeNote({
48
+ ...parsed.data,
49
+ id: generateRuntimeNoteId(),
50
+ created_at: nowISO(),
51
+ tags: [...(parsed.data.tags ?? []), origin],
52
+ }, cwd);
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ //# sourceMappingURL=federation-materialize.js.map
@@ -236,6 +236,8 @@ function renderSessionProtocol() {
236
236
  '- Drive a turn in a loop already assigned to you → `bclaw_loop(intent=turn|complete_turn|advance|close)`',
237
237
  '',
238
238
  'Do NOT call `bclaw_loop(intent=open)` directly — it creates a loop structure without dispatch, so the reviewer/participant never gets the work. Use the goal entries above.',
239
+ '',
240
+ '_How to verify a dispatch actually worked:_ `execution_status="delivered_and_started"` only means the brief-ack sentinel was touched — it does NOT mean the worker is doing useful work. Always (1) `bclaw_find(entity="agent_run", filter={assignment_id})` to read the spawn record; (2) check OS pid liveness yourself (`Get-Process -Id <pid>` on Windows, `kill -0 <pid>` on POSIX); (3) if the worker is silent, read its captured streams at `.brainclaw/coordination/runtime/log/<assignment_id>.{stdout,stderr}.log`. Full FSM tables + diagnostic decision tree in `docs/concepts/dispatch-lifecycle.md`.',
239
241
  ].join('\n');
240
242
  }
241
243
  function renderUserWorkflow() {
@@ -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