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
package/dist/core/dispatcher.js
CHANGED
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
* @module
|
|
35
35
|
*/
|
|
36
36
|
import { buildClaimEnvPrefix } from './execution-profile.js';
|
|
37
|
-
import { getActiveSequence } from './sequence.js';
|
|
37
|
+
import { getActiveSequence, listSequences } from './sequence.js';
|
|
38
38
|
import { loadState, persistState } from './state.js';
|
|
39
39
|
import { listClaims, createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment, assessClaimLiveness } from './claims.js';
|
|
40
|
+
import { sanitizeBranchComponent, isBranchMergedByContent, probeLocalBranch, isGitRepo } from './worktree.js';
|
|
40
41
|
import { listAgentIdentities, ensureAgentRegisteredForDispatch } from './agent-registry.js';
|
|
41
42
|
import { sendMessage, hasActiveAssignment } from './messaging.js';
|
|
42
43
|
import { memoryDir } from './io.js';
|
|
@@ -64,14 +65,23 @@ function buildEnvPrefix(claimId) {
|
|
|
64
65
|
}
|
|
65
66
|
// ── Lane Analysis ───────────────────────────────────────────
|
|
66
67
|
/**
|
|
67
|
-
* Analyze
|
|
68
|
+
* Analyze a sequence and categorize each item as ready, active, blocked, or done.
|
|
69
|
+
*
|
|
70
|
+
* `sequenceId` (pln#632 impl-loop bind) targets a SPECIFIC sequence by id instead of
|
|
71
|
+
* the project's active one — so an implementation loop can dispatch its own linked
|
|
72
|
+
* sequence without hijacking the global active-sequence pointer. Omitted → the active
|
|
73
|
+
* sequence (byte-identical to the historical behaviour; the resolver is non-throwing,
|
|
74
|
+
* so an unknown id yields `null` exactly like "no active sequence").
|
|
68
75
|
*/
|
|
69
|
-
export function analyzeSequence(cwd) {
|
|
70
|
-
const sequence =
|
|
76
|
+
export function analyzeSequence(cwd, sequenceId) {
|
|
77
|
+
const sequence = sequenceId
|
|
78
|
+
? listSequences(cwd).find((s) => s.id === sequenceId)
|
|
79
|
+
: getActiveSequence(cwd);
|
|
71
80
|
if (!sequence)
|
|
72
81
|
return null;
|
|
73
82
|
const state = loadState(cwd);
|
|
74
|
-
const
|
|
83
|
+
const allClaimsSnapshot = listClaims(cwd);
|
|
84
|
+
const claims = allClaimsSnapshot.filter(c => c.status === 'active');
|
|
75
85
|
const agents = listAgentIdentities(cwd);
|
|
76
86
|
// Index plans by ID for fast lookup
|
|
77
87
|
const planIndex = new Map();
|
|
@@ -80,12 +90,36 @@ export function analyzeSequence(cwd) {
|
|
|
80
90
|
if (p.short_label)
|
|
81
91
|
planIndex.set(p.short_label, p);
|
|
82
92
|
}
|
|
83
|
-
//
|
|
93
|
+
// pln#529 — index sequence items by planId (scope_hint fallback for branch
|
|
94
|
+
// derivation).
|
|
95
|
+
const itemByPlanId = new Map();
|
|
96
|
+
for (const it of sequence.items)
|
|
97
|
+
itemByPlanId.set(it.planId, it);
|
|
98
|
+
// pln#529 (review Finding 1) — GROUND-TRUTH predecessor branch resolution: a
|
|
99
|
+
// predecessor lane's branch was created by createCoordinatorClaim from its
|
|
100
|
+
// CLAIM scope (which is stable across the coordinate/assign paths + survives a
|
|
101
|
+
// later scope_hint edit + persists on release). Re-deriving from live sequence
|
|
102
|
+
// metadata probes the wrong branch and silently defaults to HEAD. So resolve
|
|
103
|
+
// the predecessor's scope from its persisted claim (any claim for the plan;
|
|
104
|
+
// retries reuse the scope), falling back to the sequence item only when no
|
|
105
|
+
// claim exists.
|
|
106
|
+
const claimByPlanId = new Map();
|
|
107
|
+
for (const c of allClaimsSnapshot) {
|
|
108
|
+
if (c.plan_id)
|
|
109
|
+
claimByPlanId.set(c.plan_id, c);
|
|
110
|
+
}
|
|
111
|
+
const canonicalPlanId = (id) => planIndex.get(id)?.id ?? id;
|
|
112
|
+
const scopeForPred = (predId) => claimByPlanId.get(canonicalPlanId(predId))?.scope ?? itemByPlanId.get(predId)?.scope_hint ?? predId;
|
|
113
|
+
// Collect plan IDs that are done or dropped (terminal → gate-open) and the
|
|
114
|
+
// dropped subset (excluded from socle-fork: never propagate abandoned code —
|
|
115
|
+
// review Finding 6).
|
|
84
116
|
const terminalPlanIds = new Set();
|
|
117
|
+
const droppedPlanIds = new Set();
|
|
85
118
|
for (const p of state.plan_items) {
|
|
86
|
-
if (p.status === 'done' || p.status === 'dropped')
|
|
119
|
+
if (p.status === 'done' || p.status === 'dropped')
|
|
87
120
|
terminalPlanIds.add(p.id);
|
|
88
|
-
|
|
121
|
+
if (p.status === 'dropped')
|
|
122
|
+
droppedPlanIds.add(p.id);
|
|
89
123
|
}
|
|
90
124
|
// Collect plan IDs with active claims
|
|
91
125
|
const claimedPlanIds = new Map();
|
|
@@ -150,16 +184,40 @@ export function analyzeSequence(cwd) {
|
|
|
150
184
|
});
|
|
151
185
|
continue;
|
|
152
186
|
}
|
|
187
|
+
// pln#529 (dec#122 B+A) — for a gated lane, readiness ≠ code-availability:
|
|
188
|
+
// resolve the fork base by CONTENT. A ≥2-unintegrated diamond keeps the gate
|
|
189
|
+
// CLOSED (A); otherwise the lane is ready with its resolved base (HEAD, or a
|
|
190
|
+
// predecessor branch when the socle isn't on HEAD yet — B).
|
|
191
|
+
if (item.hard_after.length > 0) {
|
|
192
|
+
// Socle-fork considers DONE predecessors only — a dropped predecessor still
|
|
193
|
+
// satisfies the gate but its abandoned code must not be propagated (#6).
|
|
194
|
+
const socleDeps = item.hard_after.filter((id) => !droppedPlanIds.has(canonicalPlanId(id)));
|
|
195
|
+
const base = resolveGatedLaneBase(socleDeps, scopeForPred, cwd);
|
|
196
|
+
if (base.gateBlocked) {
|
|
197
|
+
blocked.push({
|
|
198
|
+
item,
|
|
199
|
+
plan,
|
|
200
|
+
lane: item.lane,
|
|
201
|
+
reason: base.gateBlocked.reason,
|
|
202
|
+
blocked_by: base.gateBlocked.unintegrated,
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
ready.push({
|
|
207
|
+
item,
|
|
208
|
+
plan,
|
|
209
|
+
lane: item.lane,
|
|
210
|
+
reason: `All hard dependencies met${softNote}`,
|
|
211
|
+
worktreeBase: base,
|
|
212
|
+
code_propagation_note: base.reason,
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
153
216
|
ready.push({
|
|
154
217
|
item,
|
|
155
218
|
plan,
|
|
156
219
|
lane: item.lane,
|
|
157
220
|
reason: `All hard dependencies met${softNote}`,
|
|
158
|
-
// pln#529 — readiness ≠ code-availability for gated lanes.
|
|
159
|
-
...(item.hard_after.length > 0 ? {
|
|
160
|
-
code_propagation_note: `Unblocked by hard_after [${item.hard_after.join(', ')}]. Ensure that work is committed AND on the dispatch base (HEAD), ` +
|
|
161
|
-
`or dispatch this lane with ref=<predecessor branch> — otherwise the worker spawns from HEAD without it.`,
|
|
162
|
-
} : {}),
|
|
163
221
|
});
|
|
164
222
|
}
|
|
165
223
|
// Build capacity summary per agent (multi-instance aware)
|
|
@@ -279,13 +337,39 @@ export function buildProtocolSection(options) {
|
|
|
279
337
|
}
|
|
280
338
|
if (options?.worktreePath) {
|
|
281
339
|
parts.push(`Worktree: ${options.worktreePath}`);
|
|
282
|
-
// pln#523: tell the worker how dependencies are provisioned so
|
|
283
|
-
// stall trying to install them.
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
340
|
+
// pln#523 / trp_37b05a15: tell the worker how dependencies are provisioned so
|
|
341
|
+
// it does not stall trying to (re)install them. The authoritative record is
|
|
342
|
+
// the worktree's `.brainclaw-worktree.json` → `deps_mode` (absent ⇒ `link`).
|
|
343
|
+
// - link (default): node_modules (incl. monorepo per-package) is
|
|
344
|
+
// junction-linked from the main repo — build/typecheck directly; do NOT
|
|
345
|
+
// `npm install`. An out-of-root symlink, so `next dev`/Turbopack rejects
|
|
346
|
+
// it (build/tsc/vitest are fine).
|
|
347
|
+
// - install/copy: node_modules is a REAL in-root directory — everything,
|
|
348
|
+
// including a dev server, works directly; no reinstall needed.
|
|
349
|
+
// - none: no deps provisioned — run the project's install first.
|
|
350
|
+
let depsMode = 'link';
|
|
351
|
+
let depsProvisioned;
|
|
352
|
+
try {
|
|
353
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(options.worktreePath, '.brainclaw-worktree.json'), 'utf-8'));
|
|
354
|
+
if (sidecar.deps_mode)
|
|
355
|
+
depsMode = sidecar.deps_mode;
|
|
356
|
+
depsProvisioned = sidecar.deps_provisioned;
|
|
357
|
+
}
|
|
358
|
+
catch { /* sidecar absent/unreadable — assume the default `link` */ }
|
|
359
|
+
if ((depsMode === 'install' || depsMode === 'copy') && depsProvisioned === false) {
|
|
360
|
+
// Codex review P1: provisioning was ATTEMPTED but FAILED (best-effort, non-fatal).
|
|
361
|
+
// Do not claim node_modules is usable — tell the worker to install it.
|
|
362
|
+
parts.push(`Dependencies: in-root provisioning was attempted (deps_mode=${depsMode}) but FAILED — node_modules may be missing or incomplete. Run the project's install (npm/pnpm/yarn/bun) in the worktree before building; see .brainclaw-worktree.json symlink_warnings for the failure.`);
|
|
363
|
+
}
|
|
364
|
+
else if (depsMode === 'install' || depsMode === 'copy') {
|
|
365
|
+
parts.push(`Dependencies: node_modules is a real in-root directory (deps_mode=${depsMode}) — build, typecheck, and dev server all work directly; do NOT reinstall. If anything is missing, see .brainclaw-worktree.json symlink_warnings.`);
|
|
366
|
+
}
|
|
367
|
+
else if (depsMode === 'none') {
|
|
368
|
+
parts.push('Dependencies: none were provisioned (deps_mode=none) — run the project\'s install (npm/pnpm/yarn/bun) in the worktree before building.');
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally. (Out-of-root symlink: next dev/Turbopack needs deps_mode=install.)');
|
|
372
|
+
}
|
|
289
373
|
}
|
|
290
374
|
parts.push('');
|
|
291
375
|
// Assignment lifecycle protocol (Agent SDK)
|
|
@@ -605,20 +689,113 @@ function countCycleByResource(cycleAssignments, resourceKey) {
|
|
|
605
689
|
}
|
|
606
690
|
return total;
|
|
607
691
|
}
|
|
608
|
-
|
|
609
|
-
|
|
692
|
+
/**
|
|
693
|
+
* pln#529 (dec#122 B+A) — resolve the fork base for a gated lane whose hard_after
|
|
694
|
+
* predecessors are all DONE (dropped predecessors are excluded by the caller —
|
|
695
|
+
* their abandoned code must not be propagated). "Readiness ≠ code-availability":
|
|
696
|
+
* a done predecessor's code may be committed on its own branch but NOT integrated
|
|
697
|
+
* on HEAD (the standard squash-merge breaks ancestry — trp#926 — so integration
|
|
698
|
+
* is detected by CONTENT via `isBranchMergedByContent`, patch-id + file-content,
|
|
699
|
+
* not ancestry).
|
|
700
|
+
*
|
|
701
|
+
* `scopeFor(predId)` MUST return the GROUND-TRUTH scope the predecessor's branch
|
|
702
|
+
* was created from — its persisted claim scope (review Finding 1). Re-deriving
|
|
703
|
+
* the branch from live/mutable sequence metadata probes the wrong branch under
|
|
704
|
+
* the coordinate(assign) path or an edited scope_hint, and the miss silently
|
|
705
|
+
* defaults to HEAD — the very socle-drop this feature closes.
|
|
706
|
+
*
|
|
707
|
+
* `cwd` MUST be the project's MAIN git worktree (HEAD = the integration target);
|
|
708
|
+
* `analyzeSequence` is the sole production caller and passes the coordinator root.
|
|
709
|
+
*
|
|
710
|
+
* Per predecessor (branch = `feat/<sanitized scope>`), by tri-state probe:
|
|
711
|
+
* - present + content-merged → verified on HEAD;
|
|
712
|
+
* - present + NOT merged → committed-but-unintegrated (fork candidate);
|
|
713
|
+
* - absent (clean not-found) → ASSUMED on HEAD (merged + branch cleaned up) —
|
|
714
|
+
* honestly labelled "assumed", never claimed "verified";
|
|
715
|
+
* - unknown (git probe FAILED) → unverifiable → fail SAFE (gateBlocked), never
|
|
716
|
+
* silently "on HEAD" (review Finding 3).
|
|
717
|
+
* Then: any unverifiable, or ≥2 fork-candidates → gateBlocked (A); exactly 1
|
|
718
|
+
* fork-candidate → fork from it (B); else baseRef HEAD (A satisfied).
|
|
719
|
+
*/
|
|
720
|
+
export function resolveGatedLaneBase(hardAfter, scopeFor, cwd) {
|
|
610
721
|
if (hardAfter.length === 0)
|
|
611
722
|
return {};
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
723
|
+
// Non-git project → branch/worktree socle propagation is inapplicable; keep the
|
|
724
|
+
// legacy HEAD base (the tri-state "unknown" fail-safe is ONLY for a git repo
|
|
725
|
+
// whose branch probe transiently failed, not for a project that has no git at
|
|
726
|
+
// all — otherwise every non-git gated lane would wrongly gate-block).
|
|
727
|
+
if (!isGitRepo(cwd)) {
|
|
728
|
+
return { baseRef: 'HEAD', resetExistingBranch: true, reason: 'non-git project — socle propagation not applicable; base = HEAD' };
|
|
729
|
+
}
|
|
730
|
+
const unintegrated = [];
|
|
731
|
+
const unverifiable = [];
|
|
732
|
+
const verifiedOnHead = [];
|
|
733
|
+
const assumedOnHead = [];
|
|
734
|
+
for (const predId of hardAfter) {
|
|
735
|
+
const branch = `feat/${sanitizeBranchComponent(scopeFor(predId))}`;
|
|
736
|
+
const probe = probeLocalBranch(cwd, branch);
|
|
737
|
+
if (probe === 'unknown') {
|
|
738
|
+
unverifiable.push({ planId: predId, branch });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (probe === 'absent') {
|
|
742
|
+
assumedOnHead.push(predId);
|
|
743
|
+
continue;
|
|
744
|
+
} // merged + branch GC'd
|
|
745
|
+
if (isBranchMergedByContent(cwd, branch, 'HEAD')) {
|
|
746
|
+
verifiedOnHead.push(predId);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
unintegrated.push({ planId: predId, branch });
|
|
750
|
+
}
|
|
751
|
+
// Fail SAFE: a git probe we could not complete must NOT open the gate on a
|
|
752
|
+
// "HEAD is fine" assumption. Combine with the ≥2-fork-candidate diamond.
|
|
753
|
+
if (unverifiable.length > 0 || unintegrated.length >= 2) {
|
|
754
|
+
const parts = [
|
|
755
|
+
...unintegrated.map((u) => `${u.planId}→${u.branch} (committed, not on HEAD)`),
|
|
756
|
+
...unverifiable.map((u) => `${u.planId}→${u.branch} (integration UNVERIFIABLE — git probe failed)`),
|
|
757
|
+
];
|
|
758
|
+
return {
|
|
759
|
+
gateBlocked: {
|
|
760
|
+
reason: `pln#529(A): cannot safely resolve a single fork base for this gated lane — ${parts.join('; ')}. Integrate the un-integrated predecessors onto HEAD (merge/squash), or retry once git is reachable; a single worktree cannot fork from multiple bases without silently dropping a predecessor's code.`,
|
|
761
|
+
unintegrated: [...unintegrated, ...unverifiable].map((u) => u.planId),
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const headNote = (verb) => `${verb}${verifiedOnHead.length ? ` content-verified on HEAD: ${verifiedOnHead.join(', ')}` : ''}` +
|
|
766
|
+
`${assumedOnHead.length ? `${verifiedOnHead.length ? '; ' : ' '}assumed on HEAD (branch absent — merged + cleaned, unverifiable): ${assumedOnHead.join(', ')}` : ''}`;
|
|
767
|
+
if (unintegrated.length === 1) {
|
|
768
|
+
const u = unintegrated[0];
|
|
769
|
+
return {
|
|
770
|
+
baseRef: u.branch,
|
|
771
|
+
resetExistingBranch: true,
|
|
772
|
+
reason: `pln#529(B): predecessor ${u.planId} is committed on ${u.branch} but not yet integrated on HEAD — the dependent lane forks from that branch so it carries the socle code. (${headNote('Other predecessors:')})`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
616
775
|
return {
|
|
617
776
|
baseRef: 'HEAD',
|
|
618
777
|
resetExistingBranch: true,
|
|
619
|
-
reason: `
|
|
778
|
+
reason: `pln#529: ${headNote('hard_after predecessors —')}`,
|
|
620
779
|
};
|
|
621
780
|
}
|
|
781
|
+
/**
|
|
782
|
+
* @deprecated pln#529 — superseded by `resolveGatedLaneBase` (content + claim
|
|
783
|
+
* aware). Retained for callers that only have `(item, analysis)`; forwards using
|
|
784
|
+
* the analysis's done set for scope fallback (no claim access). Prefer the
|
|
785
|
+
* pre-computed `ReadyLane.worktreeBase`.
|
|
786
|
+
*/
|
|
787
|
+
export function selectWorktreeBaseForReadyLane(item, analysis, cwd = process.cwd()) {
|
|
788
|
+
const hardAfter = item.hard_after ?? [];
|
|
789
|
+
if (hardAfter.length === 0)
|
|
790
|
+
return {};
|
|
791
|
+
const donePlanIds = new Set(analysis.done.map((entry) => entry.planId));
|
|
792
|
+
if (!hardAfter.every((planId) => donePlanIds.has(planId)))
|
|
793
|
+
return {};
|
|
794
|
+
const itemByPlanId = new Map();
|
|
795
|
+
for (const entry of analysis.done)
|
|
796
|
+
itemByPlanId.set(entry.planId, entry);
|
|
797
|
+
return resolveGatedLaneBase(hardAfter, (predId) => itemByPlanId.get(predId)?.scope_hint ?? predId, cwd);
|
|
798
|
+
}
|
|
622
799
|
/**
|
|
623
800
|
* Run a dispatch cycle: analyze the sequence, generate briefs, send assignments.
|
|
624
801
|
*/
|
|
@@ -628,7 +805,7 @@ export async function dispatch(options, cwd) {
|
|
|
628
805
|
sweepAssignments(cwd, { actor: options.dispatcherAgent });
|
|
629
806
|
}
|
|
630
807
|
catch { /* best-effort */ }
|
|
631
|
-
const analysis = analyzeSequence(cwd);
|
|
808
|
+
const analysis = analyzeSequence(cwd, options.sequenceId);
|
|
632
809
|
if (!analysis)
|
|
633
810
|
return null;
|
|
634
811
|
const result = { delivery_plan: [], messages_sent: [], commands: [], skipped: [], warnings: [] };
|
|
@@ -702,7 +879,10 @@ export async function dispatch(options, cwd) {
|
|
|
702
879
|
let claimId = '(dry-run)';
|
|
703
880
|
let worktreePath;
|
|
704
881
|
if (!options.dryRun) {
|
|
705
|
-
|
|
882
|
+
// pln#529 — use the content-aware base resolved during analyzeSequence
|
|
883
|
+
// (HEAD when the socle is integrated, else the predecessor branch). Fall
|
|
884
|
+
// back to a fresh resolution for direct callers that bypassed analyze.
|
|
885
|
+
const worktreeBase = readyItem.worktreeBase ?? selectWorktreeBaseForReadyLane(readyItem.item, analysis, cwd);
|
|
706
886
|
const claimResult = createCoordinatorClaim({
|
|
707
887
|
agent: targetAgent,
|
|
708
888
|
scope: claimScope,
|
|
@@ -5,13 +5,39 @@ import { buildClaimEnvPrefix, buildWorkerIdentityEnv } from './execution-profile
|
|
|
5
5
|
import { getCapabilityProfile } from './agent-capability.js';
|
|
6
6
|
import { nowISO } from './ids.js';
|
|
7
7
|
import { ensureRuntimeDirs, getRuntimeLogPath, getRuntimeSignalPath, } from './runtime-signals.js';
|
|
8
|
-
|
|
8
|
+
// The turn-echo values are raw-embedded into a shell one-liner (see marker()),
|
|
9
|
+
// so the `[A-Za-z0-9_-]` safety invariant documented on TurnEcho is LOAD-BEARING,
|
|
10
|
+
// not cosmetic. A stray `"` desyncs cmd.exe quote-parity (no sentinel file is
|
|
11
|
+
// written → the turn-owned run never converges under read-strict acceptance —
|
|
12
|
+
// exactly the §13 D2 non-convergence this feature prevents); a `'` breaks out of
|
|
13
|
+
// the POSIX `printf '…'` wrapper. All real sources (deriveTurnId/deriveChildIds
|
|
14
|
+
// hex, crypto.randomUUID nonce) satisfy it, so this guard never fires in
|
|
15
|
+
// production — it exists to turn a future out-of-class caller's SILENT corruption
|
|
16
|
+
// into a loud, fast failure at the embed site.
|
|
17
|
+
const TURN_ECHO_SAFE = /^[A-Za-z0-9_-]+$/;
|
|
18
|
+
export function buildAckWrapCommand(bashCommand, paths, isWin32, turnEcho) {
|
|
19
|
+
if (turnEcho) {
|
|
20
|
+
for (const [field, value] of Object.entries(turnEcho)) {
|
|
21
|
+
if (!TURN_ECHO_SAFE.test(value)) {
|
|
22
|
+
throw new Error(`buildAckWrapCommand: turnEcho.${field} must match ${TURN_ECHO_SAFE} to be shell-safe for the completion sentinel (got ${JSON.stringify(value)})`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
9
26
|
const touch = isWin32
|
|
10
27
|
? (p) => `type nul > "${p}"`
|
|
11
28
|
: (p) => `touch "${p}"`;
|
|
29
|
+
// completed/failed marker: a turn-keyed JSON body when turnEcho is present,
|
|
30
|
+
// else the legacy empty touch (byte-for-byte unchanged for non-turn-owned
|
|
31
|
+
// spawns — full back-compat).
|
|
32
|
+
const marker = (p, status) => {
|
|
33
|
+
if (!turnEcho)
|
|
34
|
+
return touch(p);
|
|
35
|
+
const body = JSON.stringify({ turn_id: turnEcho.turn_id, run_id: turnEcho.run_id, nonce: turnEcho.nonce, status });
|
|
36
|
+
return isWin32 ? `echo ${body}>"${p}"` : `printf '%s' '${body}' > "${p}"`;
|
|
37
|
+
};
|
|
12
38
|
const redirected = `${bashCommand} > "${paths.stdoutLog}" 2> "${paths.stderrLog}"`;
|
|
13
39
|
return (`${touch(paths.ackPath)} && ` +
|
|
14
|
-
`( ${redirected} && ${
|
|
40
|
+
`( ${redirected} && ${marker(paths.completedPath, 'completed')} || ${marker(paths.failedPath, 'failed')} )`);
|
|
15
41
|
}
|
|
16
42
|
/**
|
|
17
43
|
* Check if a binary is resolvable on the system PATH.
|
|
@@ -148,7 +174,7 @@ export class CliExecutionAdapter {
|
|
|
148
174
|
failedPath: getRuntimeSignalPath(signalRoot, options.assignmentId, 'failed'),
|
|
149
175
|
stdoutLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stdout'),
|
|
150
176
|
stderrLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stderr'),
|
|
151
|
-
}, isWin32);
|
|
177
|
+
}, isWin32, options.turnEcho);
|
|
152
178
|
child = spawn(wrappedCmd, [], {
|
|
153
179
|
detached: !isWin32,
|
|
154
180
|
shell: true,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { getLoop } from './loops/store.js';
|
|
2
|
+
import { complete_turn, advance, evaluatePhaseAdvanceGate } from './loops/verbs.js';
|
|
3
|
+
import { withLoopLock } from './loops/lock.js';
|
|
4
|
+
import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
|
|
5
|
+
/** ideate-loop:lop_xxx[:slot] → the loop id (dispatch sets `ideate-loop:${loopId}:${slotId}`). */
|
|
6
|
+
const IDEATE_LOOP_SCOPE_RE = /^ideate-loop:(lop_[0-9a-z]+)/;
|
|
7
|
+
const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
|
|
8
|
+
/** Byte-cap a critique body (keep the head) so complete_turn's 4 KiB artifact-body limit
|
|
9
|
+
* can't reject a long critique. Leaves envelope headroom for the artifact JSON. */
|
|
10
|
+
function capCritique(body) {
|
|
11
|
+
const MAX = LOOP_ARTIFACT_BODY_MAX_BYTES - 512;
|
|
12
|
+
if (Buffer.byteLength(body, 'utf8') <= MAX)
|
|
13
|
+
return body;
|
|
14
|
+
let end = body.length;
|
|
15
|
+
while (end > 0 && Buffer.byteLength(body.slice(0, end), 'utf8') > MAX)
|
|
16
|
+
end -= 64;
|
|
17
|
+
return `${body.slice(0, Math.max(0, end))}…[truncated]`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the critic slot to complete. STRICT by assignment_id (bound since pln#629), so
|
|
21
|
+
* multi-critic loops complete the right slot; never steal a slot bound to a DIFFERENT
|
|
22
|
+
* assignment. Legacy unbound slots fall back to agent / single-active.
|
|
23
|
+
*/
|
|
24
|
+
function resolveCriticSlot(loop, assignment) {
|
|
25
|
+
// role === 'critic' is LOAD-BEARING (review F-A): a coordinate-opened ideation loop
|
|
26
|
+
// also has an unbound `champion` slot that lane-harvest never completes; without this
|
|
27
|
+
// filter the single-active fallback below would select the CHAMPION after the critics
|
|
28
|
+
// finish, corrupting the loop. Mirrors resolveReviewerSlot's role filter.
|
|
29
|
+
const active = loop.slots.filter((s) => s.role === 'critic' && s.status !== 'done' && s.status !== 'cancelled' && s.status !== 'failed');
|
|
30
|
+
if (active.length === 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (assignment.id) {
|
|
33
|
+
const bound = active.find((s) => s.assignment_id === assignment.id);
|
|
34
|
+
if (bound)
|
|
35
|
+
return bound;
|
|
36
|
+
if (active.some((s) => s.assignment_id !== undefined))
|
|
37
|
+
return undefined; // bound elsewhere → don't steal
|
|
38
|
+
}
|
|
39
|
+
if (active.length === 1)
|
|
40
|
+
return active[0];
|
|
41
|
+
const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
|
|
42
|
+
return byAgent ?? active[0];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Map a harvested critic lane onto its ideation loop and converge it. Fires ONLY on an
|
|
46
|
+
* `ideate-loop:<lop>` scope + a completed lane; otherwise returns undefined and harvest
|
|
47
|
+
* proceeds unchanged. Idempotent (a terminal/absent slot → noop), defensive (any
|
|
48
|
+
* loop-verb / lock error is swallowed into a noop so a convergence failure never breaks
|
|
49
|
+
* harvest — mirrors closeReviewLoopFromLaneResult).
|
|
50
|
+
*/
|
|
51
|
+
export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
|
|
52
|
+
const m = assignment.scope?.match(IDEATE_LOOP_SCOPE_RE);
|
|
53
|
+
if (!m)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (lane.status !== 'completed')
|
|
56
|
+
return undefined; // only a completed critic converges
|
|
57
|
+
const loopId = m[1];
|
|
58
|
+
const noop = (reason, loop_status) => ({
|
|
59
|
+
loop_id: loopId, action: 'noop', reason, loop_status,
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return withLoopLock({
|
|
63
|
+
cwd, intent: 'ideate-harvest-close', agentId: actor, scope: { kind: 'loop', loopId },
|
|
64
|
+
work: () => {
|
|
65
|
+
const loop = getLoop(loopId, cwd);
|
|
66
|
+
if (!loop)
|
|
67
|
+
return noop('loop not found');
|
|
68
|
+
if (LOOP_TERMINAL.has(loop.status))
|
|
69
|
+
return noop(`loop already ${loop.status}`, loop.status);
|
|
70
|
+
// Advance, treating ONLY phase_advance_blocked as the expected gate-not-met case;
|
|
71
|
+
// re-throw any OTHER advance error to the outer catch so a real failure becomes a
|
|
72
|
+
// noop carrying the actual message, NEVER a misreported success (review F-C).
|
|
73
|
+
const tryAdvance = (recorded) => {
|
|
74
|
+
try {
|
|
75
|
+
const advanced = advance({ id: loopId, actor }, cwd);
|
|
76
|
+
return {
|
|
77
|
+
loop_id: loopId,
|
|
78
|
+
action: advanced.auto_closed ? 'closed' : 'advanced',
|
|
79
|
+
reason: `critique gate met → phase "${advanced.loop.current_phase}"`,
|
|
80
|
+
loop_status: advanced.loop.status,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof Error && /phase_advance_blocked/.test(err.message)) {
|
|
85
|
+
return recorded
|
|
86
|
+
? { loop_id: loopId, action: 'critique_recorded', reason: 'critique recorded; gate not yet met (more critics needed)', loop_status: getLoop(loopId, cwd)?.status }
|
|
87
|
+
: noop('no active critic slot; critique gate not yet met (idempotent)', getLoop(loopId, cwd)?.status);
|
|
88
|
+
}
|
|
89
|
+
throw err; // a REAL advance error → outer catch → noop with the message
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const slot = resolveCriticSlot(loop, assignment);
|
|
93
|
+
if (!slot) {
|
|
94
|
+
// No active critic slot: either already processed, OR a prior pass recorded the
|
|
95
|
+
// critique(s) and crashed BEFORE advancing → the loop is stuck at a satisfied
|
|
96
|
+
// critique gate. RESUME only in that precise case (review F-B) — the current
|
|
97
|
+
// phase's gate must be a critique gate that now evaluates MET — so we never
|
|
98
|
+
// over-advance a loop that already moved on to revision/synthesis.
|
|
99
|
+
const gate = loop.phases.find((p) => p.name === loop.current_phase)?.advance_gate;
|
|
100
|
+
const stuckAtCritiqueGate = gate?.kind === 'min_artifacts_by_type' && gate.type === 'critique' && evaluatePhaseAdvanceGate(loop, gate).advance;
|
|
101
|
+
return stuckAtCritiqueGate ? tryAdvance(false) : noop('no active critic slot; nothing to resume', loop.status);
|
|
102
|
+
}
|
|
103
|
+
// A critic's LANE-RESULT carries free-form summary/notes (no structured
|
|
104
|
+
// critiques[] field) → ONE critique artifact. A bare lane with no critique
|
|
105
|
+
// content FAILS the slot (mirror ideationReducer: no fake gate progress).
|
|
106
|
+
const critique = [lane.summary, lane.notes]
|
|
107
|
+
.map((s) => (s ?? '').trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join('\n\n')
|
|
110
|
+
.trim();
|
|
111
|
+
if (!critique) {
|
|
112
|
+
complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'failed', failure_reason: 'critic lane produced no critique content (bare summary)' }, cwd);
|
|
113
|
+
return { loop_id: loopId, action: 'failed', reason: 'bare critic lane → slot failed; critique gate unchanged', loop_status: getLoop(loopId, cwd)?.status };
|
|
114
|
+
}
|
|
115
|
+
complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: loop.current_phase, type: 'critique', body: capCritique(critique) } }, cwd);
|
|
116
|
+
return tryAdvance(true);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return noop(`ideation close error (swallowed): ${err instanceof Error ? err.message : String(err)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=ideation-loop-close.js.map
|