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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +124 -7
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +37 -0
- package/dist/commands/mcp.js +621 -202
- 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/switch.js +17 -1
- package/dist/core/agentrun-reconciler.js +65 -0
- package/dist/core/claims.js +29 -0
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +128 -9
- 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 +28 -2
- package/dist/core/state.js +62 -0
- package/dist/facts.js +7 -5
- package/dist/facts.json +6 -4
- 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/overview.md +14 -12
- 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
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 —
|
|
19
|
+
// No config available — fall back to env only
|
|
21
20
|
}
|
|
22
|
-
|
|
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
|