brainclaw 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +223 -43
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/install-hooks.js +184 -27
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-claims.js +57 -0
  15. package/dist/commands/mcp-write-coordination.js +216 -57
  16. package/dist/commands/mcp-write-entities.js +11 -0
  17. package/dist/commands/mcp.js +29 -2
  18. package/dist/commands/session-end.js +15 -0
  19. package/dist/commands/session-start.js +19 -0
  20. package/dist/core/agentrun-reconciler.js +171 -7
  21. package/dist/core/agentruns.js +6 -1
  22. package/dist/core/claim-conformity.js +193 -0
  23. package/dist/core/claim-scope.js +155 -0
  24. package/dist/core/claims.js +127 -2
  25. package/dist/core/code-map/aggregate.js +473 -0
  26. package/dist/core/code-map/backend.js +36 -10
  27. package/dist/core/code-map/freshness.js +36 -1
  28. package/dist/core/code-map/lang/c/imports.scm +12 -0
  29. package/dist/core/code-map/lang/c/index.js +150 -0
  30. package/dist/core/code-map/lang/c/tags.scm +68 -0
  31. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  32. package/dist/core/code-map/lang/cpp/index.js +149 -0
  33. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  34. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  35. package/dist/core/code-map/lang/csharp/index.js +224 -0
  36. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  37. package/dist/core/code-map/lang/go/imports.scm +13 -0
  38. package/dist/core/code-map/lang/go/index.js +139 -0
  39. package/dist/core/code-map/lang/go/tags.scm +36 -0
  40. package/dist/core/code-map/lang/providers.js +12 -1
  41. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  42. package/dist/core/code-map/lang/ruby/index.js +198 -0
  43. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  44. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  45. package/dist/core/code-map/lang/rust/index.js +136 -0
  46. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  47. package/dist/core/code-map/query.js +229 -80
  48. package/dist/core/code-map/types.js +18 -0
  49. package/dist/core/code-map/work-section.js +8 -7
  50. package/dist/core/codev-responses.js +16 -0
  51. package/dist/core/dispatcher.js +176 -22
  52. package/dist/core/execution-adapters.js +29 -3
  53. package/dist/core/facade-schema.js +32 -0
  54. package/dist/core/guidance-telemetry.js +197 -0
  55. package/dist/core/ideation-loop-close.js +152 -0
  56. package/dist/core/instruction-templates.js +11 -3
  57. package/dist/core/loops/artifact-resolver.js +197 -0
  58. package/dist/core/loops/attempt-reservation.js +576 -0
  59. package/dist/core/loops/commit-intent.js +494 -0
  60. package/dist/core/loops/facade-schema.js +48 -0
  61. package/dist/core/loops/impl-bind.js +144 -0
  62. package/dist/core/loops/index.js +1 -1
  63. package/dist/core/loops/iteration-engine.js +29 -0
  64. package/dist/core/loops/lock.js +14 -0
  65. package/dist/core/loops/project-resolution.js +157 -0
  66. package/dist/core/loops/reconcile-turn.js +369 -0
  67. package/dist/core/loops/result-reducers.js +88 -0
  68. package/dist/core/loops/store.js +46 -7
  69. package/dist/core/loops/types.js +139 -11
  70. package/dist/core/loops/verbs.js +49 -4
  71. package/dist/core/loops/verify-command.js +209 -0
  72. package/dist/core/messaging.js +58 -5
  73. package/dist/core/next-actions.js +157 -0
  74. package/dist/core/review-loop-close.js +27 -6
  75. package/dist/core/review-loop-turn-dispatch.js +290 -28
  76. package/dist/core/runtime-signals.js +68 -0
  77. package/dist/core/schema.js +64 -0
  78. package/dist/core/surface-freshness.js +150 -0
  79. package/dist/core/warnings.js +98 -0
  80. package/dist/core/worktree.js +24 -0
  81. package/dist/facts.js +9 -9
  82. package/dist/facts.json +8 -8
  83. package/dist/wasm/tree-sitter-c.wasm +0 -0
  84. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  85. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  86. package/dist/wasm/tree-sitter-go.wasm +0 -0
  87. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  88. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  89. package/docs/cli.md +1 -1
  90. package/docs/code-map.md +22 -6
  91. package/docs/concepts/loop-engine.md +24 -0
  92. package/docs/concepts/observer-protocol.md +22 -0
  93. package/docs/concepts/plans-and-claims.md +57 -0
  94. package/docs/integrations/claude-code.md +53 -0
  95. package/docs/integrations/mcp.md +45 -0
  96. package/docs/mcp-schema-changelog.md +118 -2
  97. package/package.json +1 -1
@@ -0,0 +1,152 @@
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 expectedArtifactType = 'critique';
107
+ // Prefer the typed envelope, but honor the legacy artifacts labels too:
108
+ // coverage_gap used to be silently invisible to a critique gate.
109
+ const reportedArtifactType = lane.artifact_type?.trim()
110
+ ?? lane.artifacts?.find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
111
+ const body = lane.body?.trim();
112
+ const critique = body || [lane.summary, lane.notes]
113
+ .map((s) => (s ?? '').trim())
114
+ .filter(Boolean)
115
+ .join('\n\n')
116
+ .trim();
117
+ if (!critique) {
118
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'failed', failure_reason: 'critic lane produced no critique content (bare summary)' }, cwd);
119
+ return { loop_id: loopId, action: 'failed', reason: reportedArtifactType && reportedArtifactType !== expectedArtifactType
120
+ ? `reported artifact type "${reportedArtifactType}" has no usable body; expected "${expectedArtifactType}"`
121
+ : 'bare critic lane → slot failed; critique gate unchanged',
122
+ loop_status: getLoop(loopId, cwd)?.status };
123
+ }
124
+ // pln#639 BUG-2 — attribute the artifact to the phase the slot was
125
+ // DISPATCHED in, not the loop's phase at close time.
126
+ //
127
+ // `turn()` stamps `slot.phase = current_phase` when the slot is handed
128
+ // out (loops/verbs.ts). Using `loop.current_phase` here instead means a
129
+ // lane that returns AFTER a phase advance has its work filed under the
130
+ // new phase: a critique landing 90 seconds late is recorded in
131
+ // `revision`, where the critique gate cannot see it and where it
132
+ // misrepresents what the agent was asked to do. Reproduced in the
133
+ // pln#638 1a/1b ideation, which advanced ~90s after its last critic.
134
+ //
135
+ // Truthful attribution is also the fix for "don't count it": the gate
136
+ // filters on `artifact.phase === current_phase`, so an out-of-phase
137
+ // artifact stops satisfying the current gate by construction — no
138
+ // separate refusal path, and the content is preserved rather than lost.
139
+ const dispatchPhase = slot.phase ?? loop.current_phase;
140
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: dispatchPhase, type: 'critique', body: capCritique(critique) } }, cwd);
141
+ const advanced = tryAdvance(true);
142
+ return reportedArtifactType && reportedArtifactType !== expectedArtifactType
143
+ ? { ...advanced, reason: `reconciled reported artifact type "${reportedArtifactType}" to expected "${expectedArtifactType}"; ${advanced.reason}` }
144
+ : advanced;
145
+ },
146
+ });
147
+ }
148
+ catch (err) {
149
+ return noop(`ideation close error (swallowed): ${err instanceof Error ? err.message : String(err)}`);
150
+ }
151
+ }
152
+ //# sourceMappingURL=ideation-loop-close.js.map
@@ -217,10 +217,18 @@ function renderHeader(input) {
217
217
  `> Regenerate: brainclaw export --format ${formatForAgent(input.profile.name)} --write`,
218
218
  ].join('\n');
219
219
  }
220
- function renderLiveHeader(_input) {
220
+ function renderLiveHeader(input) {
221
+ // pln#638 volet 2a — HONESTY FIX. This header used to say "auto-refreshed",
222
+ // but regeneration is EXPLICIT: it happens on session-end, handoff, and
223
+ // `export --write`. An agent tier that never fires those events (no hooks, no
224
+ // MCP) read a file claiming to be fresh while being arbitrarily stale. A claim
225
+ // that is false for half the tiers is worse than no claim, so the header now
226
+ // names the actual triggers and tells the reader how to force a refresh.
227
+ // Guarded by tests/unit/guidance-engine-consistency.test.ts.
221
228
  return [
222
- `> Brainclaw live state — auto-refreshed, do not edit.`,
223
- `> Last updated: ${new Date().toISOString().slice(0, 19)}`,
229
+ `> Brainclaw live state — do not edit. Regenerated on: session-end, handoff, \`brainclaw export --write\`.`,
230
+ `> Written by brainclaw v${input.brainclawVersion} at ${new Date().toISOString().slice(0, 19)}`,
231
+ `> Older than your last session? It is stale — run \`brainclaw export --write\` to refresh.`,
224
232
  ].join('\n');
225
233
  }
226
234
  // Kept deliberately small (pln#542): entry point + grammar + escalation
@@ -0,0 +1,197 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { memoryDir } from '../io.js';
5
+ /**
6
+ * Safe canonical artifact resolver (pln#630 §7).
7
+ *
8
+ * The single central resolver every loop-artifact reader/writer must use. It
9
+ * replaces the ad-hoc `path.join(dir, body.ref)` (hooks/bootstrap-write.ts) that
10
+ * joined a WORKER-CONTROLLED `ref` straight onto a store dir — a path-traversal
11
+ * hole (`ref: "../../../etc/passwd"` escaped the artifacts dir).
12
+ *
13
+ * The safety protocol, mandatory before any state mutation (§7):
14
+ * 1. Brainclaw-generated target basenames — `<artifact_id>.<ext>`, never a
15
+ * worker-supplied name.
16
+ * 2. Worker source paths validated by `realpath` CONTAINMENT (reject `../`
17
+ * escapes and symlink-out) before any read.
18
+ * 3. Atomic temp-copy + fsync + rename into the canonical store.
19
+ * 4. size + sha256 validation against the attempt's expected_artifacts.
20
+ * 5. Deterministic (artifact_id-keyed) target + hash check = per-turn
21
+ * idempotency: a crash between copy and the artifact/event write retries
22
+ * without duplicating (re-copy of an identical payload is a no-op).
23
+ *
24
+ * Canonical home (unifies the two conflicting doc paths §7):
25
+ * .brainclaw/loops/artifacts/<lop_id>/<artifact_id>.<ext>
26
+ * Migration is new-then-legacy on READ, reject-on-hash-mismatch; writes go to the
27
+ * new path only.
28
+ */
29
+ export class ArtifactResolverError extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ this.name = 'ArtifactResolverError';
35
+ }
36
+ }
37
+ /** Legacy on-disk home for ref-based payloads (pre-§7). Read fallback only. */
38
+ function legacyArtifactsDir(loopId, cwd) {
39
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'threads', loopId, 'artifacts');
40
+ }
41
+ /** Canonical home for a loop's artifact payloads (§7). */
42
+ export function canonicalArtifactsDir(loopId, cwd) {
43
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops', 'artifacts', loopId);
44
+ }
45
+ /**
46
+ * The canonical absolute path for a brainclaw-owned artifact payload. The
47
+ * basename is derived ENTIRELY from brainclaw-generated ids (never a worker
48
+ * string), so it cannot traverse. `ext` is sanitized to a bare alnum extension.
49
+ */
50
+ export function canonicalArtifactPath(loopId, artifactId, ext, cwd) {
51
+ const safeExt = ext.replace(/^\.+/, '').replace(/[^A-Za-z0-9]/g, '') || 'txt';
52
+ return path.join(canonicalArtifactsDir(loopId, cwd), `${artifactId}.${safeExt}`);
53
+ }
54
+ /**
55
+ * Validate that a worker-relative path resolves to a real file CONTAINED within
56
+ * `workerRoot` (no `../` escape, no symlink pointing outside). Returns the
57
+ * validated absolute path; throws `containment_violation` / `source_missing`
58
+ * otherwise. This is the mandatory gate before ANY read of a worker-produced
59
+ * artifact (§7 / invariant #7).
60
+ */
61
+ export function resolveContainedWorkerPath(workerRoot, workerRelPath, _cwd) {
62
+ // realpath the containment ROOT first (it must exist and be a directory).
63
+ let rootReal;
64
+ try {
65
+ rootReal = fs.realpathSync(workerRoot);
66
+ }
67
+ catch {
68
+ throw new ArtifactResolverError('source_missing', `resolveContainedWorkerPath: worker root ${workerRoot} does not resolve`);
69
+ }
70
+ // Reject an absolute worker path outright — an expected artifact is always
71
+ // worker-RELATIVE; an absolute path is a red flag we never join.
72
+ if (path.isAbsolute(workerRelPath)) {
73
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: absolute worker path "${workerRelPath}" rejected`);
74
+ }
75
+ const joined = path.resolve(rootReal, workerRelPath);
76
+ // Lexical containment check on the joined path BEFORE touching the FS (guards
77
+ // the case where the target itself does not exist yet).
78
+ const rootWithSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
79
+ if (joined !== rootReal && !joined.startsWith(rootWithSep)) {
80
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: "${workerRelPath}" escapes worker root`);
81
+ }
82
+ // realpath the target and re-check containment — defeats a symlink inside the
83
+ // root that points outside it (lexical check alone would pass).
84
+ let targetReal;
85
+ try {
86
+ targetReal = fs.realpathSync(joined);
87
+ }
88
+ catch {
89
+ throw new ArtifactResolverError('source_missing', `resolveContainedWorkerPath: "${workerRelPath}" does not resolve to a file under the worker root`);
90
+ }
91
+ if (targetReal !== rootReal && !targetReal.startsWith(rootWithSep)) {
92
+ throw new ArtifactResolverError('containment_violation', `resolveContainedWorkerPath: "${workerRelPath}" resolves (via symlink) outside the worker root`);
93
+ }
94
+ return targetReal;
95
+ }
96
+ function sha256OfFile(absPath) {
97
+ const buf = fs.readFileSync(absPath);
98
+ return { sha256: crypto.createHash('sha256').update(buf).digest('hex'), byte_count: buf.length };
99
+ }
100
+ /**
101
+ * Copy a containment-validated worker source into the canonical store — atomically
102
+ * (temp + fsync + rename), with size/sha256 validation and per-turn idempotency
103
+ * (§7). Idempotent: if the deterministic target already holds the same bytes, this
104
+ * is a no-op; if it holds DIFFERENT bytes, that is a hard `canonical_hash_conflict`
105
+ * (a deterministic-id collision or corruption — never silently overwrite).
106
+ */
107
+ export function copyArtifactToCanonicalStore(input) {
108
+ const { loopId, artifactId, ext, sourceAbsPath, expectedSha256, expectedByteCount, cwd } = input;
109
+ if (!fs.existsSync(sourceAbsPath)) {
110
+ throw new ArtifactResolverError('source_missing', `copyArtifactToCanonicalStore: source ${sourceAbsPath} missing`);
111
+ }
112
+ // Read the source EXACTLY ONCE (review Finding 4): hash + validate + write the
113
+ // SAME buffer, so a source mutation between a validate-read and a copy-read can
114
+ // never let bytes whose hash differs from the reported/validated sha256 become
115
+ // canonical state.
116
+ const buf = fs.readFileSync(sourceAbsPath);
117
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
118
+ const byte_count = buf.length;
119
+ // Validate against the attempt's declared expectations BEFORE any write.
120
+ if (expectedSha256 !== undefined && expectedSha256 !== sha256) {
121
+ throw new ArtifactResolverError('sha256_mismatch', `copyArtifactToCanonicalStore: sha256 ${sha256} != expected ${expectedSha256}`);
122
+ }
123
+ if (expectedByteCount !== undefined && expectedByteCount !== byte_count) {
124
+ throw new ArtifactResolverError('byte_count_mismatch', `copyArtifactToCanonicalStore: byte_count ${byte_count} != expected ${expectedByteCount}`);
125
+ }
126
+ const canonicalPath = canonicalArtifactPath(loopId, artifactId, ext, cwd);
127
+ // Idempotency: a matching target is a no-op; a mismatching target is a conflict.
128
+ if (fs.existsSync(canonicalPath)) {
129
+ const existing = sha256OfFile(canonicalPath);
130
+ if (existing.sha256 === sha256) {
131
+ return { canonicalPath, sha256, byte_count, idempotent: true };
132
+ }
133
+ throw new ArtifactResolverError('canonical_hash_conflict', `copyArtifactToCanonicalStore: ${canonicalPath} already exists with a DIFFERENT hash (${existing.sha256} vs ${sha256}) — refusing to overwrite`);
134
+ }
135
+ const dir = path.dirname(canonicalPath);
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ // Atomic temp-copy + fsync + rename of the ALREADY-HASHED buffer. The temp name
138
+ // is process/id-scoped so concurrent copies of distinct artifacts never collide.
139
+ const tmpPath = path.join(dir, `.${artifactId}.${process.pid}.tmp`);
140
+ const fd = fs.openSync(tmpPath, 'w');
141
+ try {
142
+ let off = 0;
143
+ while (off < buf.length)
144
+ off += fs.writeSync(fd, buf, off, buf.length - off);
145
+ fs.fsyncSync(fd);
146
+ }
147
+ finally {
148
+ fs.closeSync(fd);
149
+ }
150
+ try {
151
+ fs.renameSync(tmpPath, canonicalPath);
152
+ }
153
+ catch (err) {
154
+ // A racing writer may have created the target between our existence check and
155
+ // the rename. Re-check idempotency rather than clobbering.
156
+ fs.rmSync(tmpPath, { force: true });
157
+ if (fs.existsSync(canonicalPath) && sha256OfFile(canonicalPath).sha256 === sha256) {
158
+ return { canonicalPath, sha256, byte_count, idempotent: true };
159
+ }
160
+ throw err;
161
+ }
162
+ return { canonicalPath, sha256, byte_count, idempotent: false };
163
+ }
164
+ /**
165
+ * Read an artifact payload from the canonical store, falling back to the legacy
166
+ * `loops/threads/<loop_id>/artifacts/<ref>` path for pre-§7 artifacts. When BOTH
167
+ * exist, their hashes MUST match (reject-on-mismatch migration safety §7). An
168
+ * `expectedSha256` is validated against whichever copy is returned.
169
+ */
170
+ export function readCanonicalArtifact(loopId, artifactId, ext, opts = {}) {
171
+ const { legacyRef, expectedSha256, cwd } = opts;
172
+ const canonicalPath = canonicalArtifactPath(loopId, artifactId, ext, cwd);
173
+ const legacyPath = legacyRef ? path.join(legacyArtifactsDir(loopId, cwd), legacyRef) : undefined;
174
+ const canonicalExists = fs.existsSync(canonicalPath);
175
+ const legacyExists = legacyPath !== undefined && fs.existsSync(legacyPath);
176
+ if (!canonicalExists && !legacyExists) {
177
+ throw new ArtifactResolverError('artifact_missing', `readCanonicalArtifact: ${artifactId} not found (canonical nor legacy)`);
178
+ }
179
+ if (canonicalExists && legacyExists) {
180
+ // Migration overlap — both must agree, else refuse (never trust a divergent legacy copy).
181
+ const c = sha256OfFile(canonicalPath);
182
+ const l = sha256OfFile(legacyPath);
183
+ if (c.sha256 !== l.sha256) {
184
+ throw new ArtifactResolverError('canonical_hash_conflict', `readCanonicalArtifact: ${artifactId} canonical/legacy hash mismatch (${c.sha256} vs ${l.sha256})`);
185
+ }
186
+ }
187
+ const readPath = canonicalExists ? canonicalPath : legacyPath;
188
+ const buf = fs.readFileSync(readPath);
189
+ if (expectedSha256 !== undefined) {
190
+ const actual = crypto.createHash('sha256').update(buf).digest('hex');
191
+ if (actual !== expectedSha256) {
192
+ throw new ArtifactResolverError('sha256_mismatch', `readCanonicalArtifact: ${artifactId} sha256 ${actual} != expected ${expectedSha256}`);
193
+ }
194
+ }
195
+ return buf;
196
+ }
197
+ //# sourceMappingURL=artifact-resolver.js.map