brainclaw 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -8
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-review.js +2 -2
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +241 -25
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agent-capability.js +7 -2
- package/dist/core/agent-files.js +53 -2
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +209 -29
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +106 -34
- package/dist/core/review-loop-turn-dispatch.js +445 -0
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +34 -0
- package/dist/core/worktree.js +240 -22
- package/dist/facts.js +10 -10
- package/dist/facts.json +9 -9
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +28 -2
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/integrations/codex.md +19 -3
- package/docs/mcp-schema-changelog.md +43 -1
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#632 impl-loop bind — the ENGINE action for an implementation loop's `bind` phase.
|
|
3
|
+
*
|
|
4
|
+
* The implementation protocol declares `bind` as "bind plan+sequence and dispatch" (see
|
|
5
|
+
* LOOP_PROTOCOLS.implementation in types.ts). This is that action: read the loop's linked
|
|
6
|
+
* sequence, dispatch its ready lanes via the EXISTING sequence spawner, then advance
|
|
7
|
+
* `bind → execute` so the loop enters its execute↔verify cycle.
|
|
8
|
+
*
|
|
9
|
+
* Reuses `dispatch()` ADDITIVELY via its `sequenceId` option (pln#632) so the loop drives
|
|
10
|
+
* its OWN linked sequence WITHOUT touching the project's global active-sequence pointer —
|
|
11
|
+
* no hijack of whatever sequence other bclaw_dispatch work is using. Mirrors the
|
|
12
|
+
* async-handler-spawns pattern already used by bclaw_coordinate(open_loop=true) for review
|
|
13
|
+
* loops; touches NO review/ideation dispatch. The live spawn happens only when this runs
|
|
14
|
+
* on a real loop (exactly like bclaw_dispatch) — `dryRun` previews with no spawn and no
|
|
15
|
+
* phase mutation, so the flow is unit-testable.
|
|
16
|
+
*/
|
|
17
|
+
import { dispatch } from '../dispatcher.js';
|
|
18
|
+
import { listSequences } from '../sequence.js';
|
|
19
|
+
import { getLoop } from './store.js';
|
|
20
|
+
import { advance } from './verbs.js';
|
|
21
|
+
import { withLoopLock } from './lock.js';
|
|
22
|
+
/**
|
|
23
|
+
* Bind an implementation loop to its linked sequence and dispatch it. Async because the
|
|
24
|
+
* underlying spawn is async (the only awaited work; `dryRun` resolves synchronously).
|
|
25
|
+
*
|
|
26
|
+
* Idempotent: a loop already past `bind` returns a `noop` (never re-dispatches on a second
|
|
27
|
+
* bind after it advanced). A crash BETWEEN dispatch and advance leaves the loop in `bind`,
|
|
28
|
+
* so a retry re-dispatches only the still-unassigned lanes (dispatch skips lanes with an
|
|
29
|
+
* active assignment) and re-attempts the advance — safe crash-recovery.
|
|
30
|
+
*/
|
|
31
|
+
export async function runImplBind(input, cwd) {
|
|
32
|
+
const { loop_id, dispatcherAgent } = input;
|
|
33
|
+
const loop = getLoop(loop_id, cwd);
|
|
34
|
+
if (!loop)
|
|
35
|
+
throw new Error(`unknown loop_id ${loop_id}`);
|
|
36
|
+
if (loop.kind !== 'implementation') {
|
|
37
|
+
throw new Error(`bind is only valid for implementation loops (loop ${loop_id} is kind='${loop.kind}'); review/ideation loops dispatch via bclaw_coordinate`);
|
|
38
|
+
}
|
|
39
|
+
// bind SPAWNS real workers via dispatch() — never do that on a loop that is not open.
|
|
40
|
+
// A paused loop keeps current_phase='bind' (pause only flips status), and open→close
|
|
41
|
+
// leaves the phase at 'bind' too, so the phase-idempotency check below is NOT sufficient
|
|
42
|
+
// to stop a spawn on a held/terminal loop (review Finding 1). Gate on status FIRST, so
|
|
43
|
+
// no dispatch fires and we don't spawn then throw at the advance.
|
|
44
|
+
if (loop.status !== 'open') {
|
|
45
|
+
throw new Error(`bind requires an open loop; loop ${loop_id} is ${loop.status} (resume a paused loop, or reopen a terminal one, before binding)`);
|
|
46
|
+
}
|
|
47
|
+
const sequenceId = loop.linked?.sequence_ids?.[0];
|
|
48
|
+
// Idempotency: `bind` is the loop's FIRST phase. A loop already past it was bound before.
|
|
49
|
+
if (loop.current_phase !== 'bind') {
|
|
50
|
+
return {
|
|
51
|
+
loop_id,
|
|
52
|
+
sequence_id: sequenceId ?? '',
|
|
53
|
+
action: 'noop',
|
|
54
|
+
dispatch: null,
|
|
55
|
+
messages_sent: 0,
|
|
56
|
+
reason: `loop is in phase '${loop.current_phase}', not 'bind' — already bound (idempotent)`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (!sequenceId) {
|
|
60
|
+
throw new Error(`impl-bind requires a linked sequence: open the implementation loop with linked.sequence_ids=[…] (the sequence whose lanes it executes). None found on ${loop_id}.`);
|
|
61
|
+
}
|
|
62
|
+
// Validate up-front (non-throwing lookup) so a missing sequence fails with a clear
|
|
63
|
+
// message rather than a silent null dispatch.
|
|
64
|
+
const seq = listSequences(cwd).find((s) => s.id === sequenceId);
|
|
65
|
+
if (!seq)
|
|
66
|
+
throw new Error(`linked sequence ${sequenceId} not found for loop ${loop_id}`);
|
|
67
|
+
const dispatched = await dispatch({
|
|
68
|
+
sequenceId,
|
|
69
|
+
dispatcherAgent,
|
|
70
|
+
dispatcherAgentId: input.dispatcherAgentId,
|
|
71
|
+
sessionId: input.sessionId,
|
|
72
|
+
dryRun: input.dryRun,
|
|
73
|
+
lanes: input.lanes,
|
|
74
|
+
autoExecute: input.autoExecute,
|
|
75
|
+
model: input.model,
|
|
76
|
+
maxAssignments: input.maxAssignments,
|
|
77
|
+
}, cwd ?? process.cwd());
|
|
78
|
+
const result = dispatched?.result ?? null;
|
|
79
|
+
const messages_sent = result?.messages_sent.length ?? 0;
|
|
80
|
+
// Guard a race: the sequence disappeared between validation and dispatch → don't advance.
|
|
81
|
+
if (!dispatched) {
|
|
82
|
+
return {
|
|
83
|
+
loop_id,
|
|
84
|
+
sequence_id: sequenceId,
|
|
85
|
+
action: 'noop',
|
|
86
|
+
dispatch: null,
|
|
87
|
+
messages_sent: 0,
|
|
88
|
+
reason: `sequence ${sequenceId} yielded no dispatch analysis (unavailable); loop stays in 'bind'`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (input.dryRun) {
|
|
92
|
+
return {
|
|
93
|
+
loop_id,
|
|
94
|
+
sequence_id: sequenceId,
|
|
95
|
+
action: 'preview',
|
|
96
|
+
dispatch: result,
|
|
97
|
+
messages_sent,
|
|
98
|
+
reason: `dry run: ${result?.delivery_plan.length ?? 0} lane(s) would dispatch; loop stays in 'bind' (no spawn, no advance)`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Real bind: advance bind → execute so the loop enters the execute↔verify cycle. The
|
|
102
|
+
// implementation protocol's `bind` phase carries no advance_gate, so the advance is
|
|
103
|
+
// unconditional FROM bind — but the phase-check and the advance must be ATOMIC or two
|
|
104
|
+
// racing binds each advance once and push the loop bind→execute→verify with no execute
|
|
105
|
+
// work done (review Finding 2). advance() does not self-lock (its callers — the facade's
|
|
106
|
+
// withLockedLoopMutation and the ideation closer — provide the lock), so we take the loop
|
|
107
|
+
// lock here and re-check the phase under it: only the bind that still sees 'bind' advances.
|
|
108
|
+
const advanced = withLoopLock({
|
|
109
|
+
cwd,
|
|
110
|
+
intent: 'impl-bind-advance',
|
|
111
|
+
agentId: dispatcherAgent,
|
|
112
|
+
scope: { kind: 'loop', loopId: loop_id },
|
|
113
|
+
work: () => {
|
|
114
|
+
const fresh = getLoop(loop_id, cwd);
|
|
115
|
+
// Raced: a concurrent bind (or a pause/close) moved the loop out of an open 'bind'
|
|
116
|
+
// state → do NOT advance again. Idempotent by construction.
|
|
117
|
+
if (!fresh || fresh.status !== 'open' || fresh.current_phase !== 'bind')
|
|
118
|
+
return null;
|
|
119
|
+
const a = advance({ id: loop_id, actor: dispatcherAgent }, cwd);
|
|
120
|
+
return { phase: a.loop.current_phase, auto_closed: a.auto_closed };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (!advanced) {
|
|
124
|
+
return {
|
|
125
|
+
loop_id,
|
|
126
|
+
sequence_id: sequenceId,
|
|
127
|
+
action: 'noop',
|
|
128
|
+
dispatch: result,
|
|
129
|
+
messages_sent,
|
|
130
|
+
reason: `dispatched ${messages_sent} assignment(s); phase already advanced out of 'bind' under a concurrent bind (idempotent — not re-advanced)`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
loop_id,
|
|
135
|
+
sequence_id: sequenceId,
|
|
136
|
+
action: 'bound',
|
|
137
|
+
advanced_to: advanced.phase,
|
|
138
|
+
auto_closed: advanced.auto_closed,
|
|
139
|
+
dispatch: result,
|
|
140
|
+
messages_sent,
|
|
141
|
+
reason: `dispatched ${messages_sent} assignment(s) on sequence ${sequenceId}; advanced bind → ${advanced.phase}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=impl-bind.js.map
|
package/dist/core/loops/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
2
|
export { AwaitingFileApplyApprovalError, closeLoop, ensureLoopsDir, generateLoopId, generateMutationId, generateSlotId, getLoop, listLoopEvents, listLoops, openLoop, writeThreadFile, } from './store.js';
|
|
3
3
|
export { add_artifact, advance, complete_turn, evaluatePhaseAdvanceGate, evaluateStopCondition, pause, provideInput, reconcileOpenQuestions, requestInput, resume, sweepPauseTimeouts, turn, } from './verbs.js';
|
|
4
|
-
export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, } from './iteration-engine.js';
|
|
4
|
+
export { decideNextPhase, artifactsInIteration, noNewCritiqueInIteration, hasCriticSignalInIteration, hasPassingVerifyReportInIteration, } from './iteration-engine.js';
|
|
5
5
|
export { computeNextExpected, } from './next-expected.js';
|
|
6
6
|
export { buildIdeationBrief, } from './brief-assembly.js';
|
|
7
7
|
export { BOOTSTRAP_PRESET } from './presets/bootstrap.js';
|
|
@@ -92,6 +92,15 @@ export function decideNextPhase(thread, protocol) {
|
|
|
92
92
|
reason: 'no_new_critique_artifacts',
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
+
if (iterationBlock.exit_when === 'command_green' &&
|
|
96
|
+
hasPassingVerifyReportInIteration(thread, thread.iteration_count)) {
|
|
97
|
+
return {
|
|
98
|
+
kind: 'exit_cycle',
|
|
99
|
+
target: postCycleTarget,
|
|
100
|
+
iteration: thread.iteration_count,
|
|
101
|
+
reason: 'command_green',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
95
104
|
// Cap check. If incrementing iteration_count would exceed the cap,
|
|
96
105
|
// exit via max_iterations. (iteration_count is 0-indexed; cap=3 means
|
|
97
106
|
// iterations 0, 1, 2 are allowed; refusing iteration 3 → exit.)
|
|
@@ -136,4 +145,24 @@ export function noNewCritiqueInIteration(thread, iteration) {
|
|
|
136
145
|
export function hasCriticSignalInIteration(thread, iteration) {
|
|
137
146
|
return thread.artifacts.some((a) => (a.iteration ?? 0) === iteration && a.type === 'critic_signal');
|
|
138
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* `exit_when='command_green'` predicate (pln#609): true when the just-finished
|
|
150
|
+
* iteration produced a PASSING `verify_report` artifact (body.passed === true).
|
|
151
|
+
* The verify command ran during the verify turn and recorded the report — this
|
|
152
|
+
* is a PURE read; no command is executed here (execution seam = verify-command.ts,
|
|
153
|
+
* Increment 2). Iteration-window scoped so a green from a prior iteration can
|
|
154
|
+
* never satisfy the current one; absence reads as false.
|
|
155
|
+
*/
|
|
156
|
+
export function hasPassingVerifyReportInIteration(thread, iteration) {
|
|
157
|
+
return artifactsInIteration(thread, iteration).some((a) => {
|
|
158
|
+
if (a.type !== 'verify_report')
|
|
159
|
+
return false;
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(a.body ?? '{}').passed === true;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
139
168
|
//# sourceMappingURL=iteration-engine.js.map
|
package/dist/core/loops/lock.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { memoryDir } from '../io.js';
|
|
6
6
|
import { nowISO } from '../ids.js';
|
|
7
|
+
import { recoverPendingIntents } from './commit-intent.js';
|
|
7
8
|
/**
|
|
8
9
|
* Per-loop exclusive lock + idempotency + fencing helpers.
|
|
9
10
|
*
|
|
@@ -298,6 +299,19 @@ export function withLoopLock(options) {
|
|
|
298
299
|
intent: options.intent,
|
|
299
300
|
});
|
|
300
301
|
try {
|
|
302
|
+
// pln#630 PR1b (dec#137) — recovery-before-mutate at the loop-lock entry
|
|
303
|
+
// boundary. Any durable-but-unapplied commit intent is replayed to the
|
|
304
|
+
// journal+thread BEFORE we read the version / run the work, so a mutation
|
|
305
|
+
// never starts from a half-applied state and currentVersion() sees the
|
|
306
|
+
// caught-up thread. Not in getLoop: the loop lock is not re-entrant and
|
|
307
|
+
// verbs call getLoop while already holding it. FAILS CLOSED (PR #102 review
|
|
308
|
+
// HIGH): a genuine recovery error (torn/unreadable journal, I/O) propagates
|
|
309
|
+
// and aborts the mutation rather than proceeding against corrupt state;
|
|
310
|
+
// quarantined conflicts/corruption are handled inside recoverPendingIntents
|
|
311
|
+
// and do not throw.
|
|
312
|
+
if (options.scope.kind === 'loop') {
|
|
313
|
+
recoverPendingIntents(options.scope.loopId, options.cwd);
|
|
314
|
+
}
|
|
301
315
|
// Idempotency short-circuit (inside lock).
|
|
302
316
|
if (options.clientRequestId && options.requestPayload !== undefined) {
|
|
303
317
|
const loopIdForKey = options.scope.kind === 'loop'
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#521 P1 — loop project resolution gate.
|
|
3
|
+
*
|
|
4
|
+
* A review loop that lands in the wrong store is worse than a review loop that
|
|
5
|
+
* never opened: the candidate, claim, assignment and loop all persist in a
|
|
6
|
+
* project nobody is watching, and the reviewer spawns against the wrong repo
|
|
7
|
+
* (DGX misroute, 2026-05). This module answers ONE question before any
|
|
8
|
+
* persistence happens: *which project does this loop belong to, and did anyone
|
|
9
|
+
* actually choose it?*
|
|
10
|
+
*
|
|
11
|
+
* Resolution ladder (B2 of art_18e7c6101880):
|
|
12
|
+
* 1. explicit `project` argument → routed, source='explicit'
|
|
13
|
+
* 2. a real selector already won upstream → routed, source=<that selector>
|
|
14
|
+
* (env_project / session / cwd_child / global — `resolveEffectiveCwdInfo`
|
|
15
|
+
* has already applied them by the time a handler receives its cwd)
|
|
16
|
+
* 3. bare cwd fallback in a store that can host several projects
|
|
17
|
+
* → `needs_project_selection`
|
|
18
|
+
*
|
|
19
|
+
* There is deliberately NO inference from ref / scope / path: B3 was rejected
|
|
20
|
+
* (art_e29e88878209) because a wrong guess costs more than an explicit choice.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { loadConfig } from '../config.js';
|
|
26
|
+
import { resolveProjectCwd } from '../cross-project.js';
|
|
27
|
+
import { resolvePrimaryStore } from '../store-resolution.js';
|
|
28
|
+
import { scanNestedBrainclawProjects, summarizeWorkspaceProjects } from '../workspace-projects.js';
|
|
29
|
+
/**
|
|
30
|
+
* Decide which project a loop belongs to, or refuse when nobody chose.
|
|
31
|
+
*
|
|
32
|
+
* Throws only when an explicit `project` cannot be resolved — that error is
|
|
33
|
+
* `resolveProjectCwd`'s (unknown project, unavailable link) and callers already
|
|
34
|
+
* surface it as a tool error rather than silently falling back.
|
|
35
|
+
*/
|
|
36
|
+
export function validateLoopProjectResolution(input) {
|
|
37
|
+
const sourceCwd = path.resolve(input.cwd);
|
|
38
|
+
const projectArg = input.projectArg?.trim() || undefined;
|
|
39
|
+
// 1. Explicit project wins outright and needs no ambiguity check: the caller
|
|
40
|
+
// named it, so there is nothing left to disambiguate.
|
|
41
|
+
if (projectArg) {
|
|
42
|
+
const projectCwd = path.resolve(resolveProjectCwd(projectArg, sourceCwd));
|
|
43
|
+
return resolved(projectCwd, 'explicit', {
|
|
44
|
+
source_cwd: sourceCwd,
|
|
45
|
+
effective_cwd: projectCwd,
|
|
46
|
+
active_source: 'explicit',
|
|
47
|
+
project_arg: projectArg,
|
|
48
|
+
candidates: [],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// 2. A selector other than the bare cwd fallback means somebody DID choose —
|
|
52
|
+
// `--cwd`, BRAINCLAW_PROJECT, a session switch, the physical child store,
|
|
53
|
+
// or the workspace's active-project pointer. Honour it as-is.
|
|
54
|
+
const activeSource = input.activeSource ?? 'cwd';
|
|
55
|
+
if (activeSource !== 'cwd') {
|
|
56
|
+
return resolved(sourceCwd, activeSource, {
|
|
57
|
+
source_cwd: sourceCwd,
|
|
58
|
+
effective_cwd: sourceCwd,
|
|
59
|
+
active_source: activeSource,
|
|
60
|
+
candidates: [],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// 3. Bare fallback. Harmless in a single-project store (there is exactly one
|
|
64
|
+
// answer), a misroute waiting to happen in a store that hosts children.
|
|
65
|
+
const candidates = collectCandidateProjects(sourceCwd);
|
|
66
|
+
const trace = {
|
|
67
|
+
source_cwd: sourceCwd,
|
|
68
|
+
effective_cwd: sourceCwd,
|
|
69
|
+
active_source: 'cwd',
|
|
70
|
+
candidates,
|
|
71
|
+
};
|
|
72
|
+
if (candidates.length === 0) {
|
|
73
|
+
return resolved(sourceCwd, 'cwd', trace);
|
|
74
|
+
}
|
|
75
|
+
const rendered = candidates
|
|
76
|
+
.map((c) => {
|
|
77
|
+
const label = c.name ?? (c.path ? path.basename(c.path) : '(unnamed)');
|
|
78
|
+
return ` - ${label}${c.path ? ` (${c.path})` : ''}`;
|
|
79
|
+
})
|
|
80
|
+
.join('\n');
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
code: 'needs_project_selection',
|
|
84
|
+
message: `This store hosts ${candidates.length} project(s) and no project was selected, so the loop would land in `
|
|
85
|
+
+ `${sourceCwd} by default rather than where the work is. Nothing was created.\n`
|
|
86
|
+
+ `Candidates:\n${rendered}\n`
|
|
87
|
+
+ `Choose one explicitly — pass project='<name>' on this call, or make it sticky with `
|
|
88
|
+
+ `bclaw_switch(project='<name>'). Ref/scope/path are never used to guess (B3, art_e29e88878209).`,
|
|
89
|
+
candidates,
|
|
90
|
+
trace,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function resolved(projectCwd, source, trace) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
project_cwd: projectCwd,
|
|
97
|
+
project_name: projectNameFor(projectCwd),
|
|
98
|
+
source,
|
|
99
|
+
trace,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function projectNameFor(cwd) {
|
|
103
|
+
try {
|
|
104
|
+
return loadConfig(cwd).project_name;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Sibling projects the store at `cwd` can host. Empty for an ordinary
|
|
112
|
+
* single-project repo — which is what keeps this gate a strict no-op there.
|
|
113
|
+
*
|
|
114
|
+
* Two signals, both already load-bearing elsewhere in the codebase:
|
|
115
|
+
* `project_mode === 'multi-project'` (the convention used by doctor / status /
|
|
116
|
+
* code-map) and a `workspace`-role store with nested project stores under it.
|
|
117
|
+
*/
|
|
118
|
+
function collectCandidateProjects(cwd) {
|
|
119
|
+
let config;
|
|
120
|
+
try {
|
|
121
|
+
config = loadConfig(cwd);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Uninitialised store: loop creation will fail downstream on its own terms.
|
|
125
|
+
// Refusing here would only swap a clear error for a vaguer one.
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const seen = new Map();
|
|
129
|
+
const add = (candidate) => {
|
|
130
|
+
// A config-declared namespace has no store of its own yet, so it is keyed
|
|
131
|
+
// by name; anything on disk is keyed by its absolute path.
|
|
132
|
+
const abs = candidate.path ? path.resolve(cwd, candidate.path) : undefined;
|
|
133
|
+
if (abs === cwd)
|
|
134
|
+
return;
|
|
135
|
+
const key = abs ?? `name:${candidate.name ?? ''}`;
|
|
136
|
+
if (seen.has(key))
|
|
137
|
+
return;
|
|
138
|
+
seen.set(key, { ...candidate, ...(abs ? { path: abs } : {}) });
|
|
139
|
+
};
|
|
140
|
+
if (config.project_mode === 'multi-project') {
|
|
141
|
+
for (const project of summarizeWorkspaceProjects(cwd, config).discovered_projects) {
|
|
142
|
+
add({
|
|
143
|
+
name: project.project_name,
|
|
144
|
+
// `config` entries carry a namespace name in `path`, not a real path.
|
|
145
|
+
...(project.source === 'config' ? {} : { path: project.path }),
|
|
146
|
+
source: project.source,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (resolvePrimaryStore(cwd)?.role === 'workspace') {
|
|
151
|
+
for (const project of scanNestedBrainclawProjects(cwd)) {
|
|
152
|
+
add({ name: project.project_name, path: project.path, source: 'store_chain' });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...seen.values()].sort((a, b) => (a.path ?? a.name ?? '').localeCompare(b.path ?? b.name ?? ''));
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=project-resolution.js.map
|