mirai-graph 1.1.1 → 1.3.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.
@@ -63,18 +63,27 @@ function trackedState(repo) {
63
63
  const trackedRun = spawnSync("git", ["ls-files", "-z"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
64
64
  const dirtyRun = spawnSync("git", ["diff", "--name-only", "-z", "HEAD", "--"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
65
65
  return {
66
+ hasGit: Boolean(git(repo, "rev-parse", "--is-inside-work-tree")),
66
67
  tracked: new Set(trackedRun.status === 0 ? trackedRun.stdout.split("\0").filter(Boolean) : []),
67
68
  dirty: new Set(dirtyRun.status === 0 ? dirtyRun.stdout.split("\0").filter(Boolean) : []),
68
69
  };
69
70
  }
70
71
 
71
72
  function graphBlob(graph, relative) {
73
+ if (!graph.hasGit) {
74
+ const absolute = path.join(graph.repo, relative);
75
+ return fs.existsSync(absolute) && !fs.lstatSync(absolute).isSymbolicLink() && fs.statSync(absolute).isFile() ? fs.readFileSync(absolute) : null;
76
+ }
72
77
  if (!graph.tracked?.has(relative) || graph.dirty?.has(relative)) return null;
73
78
  if (!graph.blobCache.has(relative)) graph.blobCache.set(relative, gitBlob(graph.repo, relative));
74
79
  return graph.blobCache.get(relative);
75
80
  }
76
81
 
77
82
  function revisionBound(repo, relative) {
83
+ if (!git(repo, "rev-parse", "--is-inside-work-tree")) {
84
+ const absolute = path.join(repo, relative);
85
+ return fs.existsSync(absolute) && !fs.lstatSync(absolute).isSymbolicLink() && fs.statSync(absolute).isFile();
86
+ }
78
87
  if (!gitBlob(repo, relative)) return false;
79
88
  return spawnSync("git", ["diff", "--quiet", "HEAD", "--", relative], { cwd: repo }).status === 0;
80
89
  }
@@ -197,19 +206,25 @@ function readGraph(repoArg) {
197
206
  if (!objects.has(relation.source) || !objects.has(relation.target)) { output.blockers.push("relation_endpoint_missing"); continue; }
198
207
  relations.push({ ...relation, _record: record });
199
208
  }
200
- const revision = git(repo, "rev-parse", "HEAD") || null;
201
209
  const state = trackedState(repo);
202
- for (const relative of unique([
210
+ const graphSources = unique([
203
211
  "graph.json",
204
212
  ...[...objects.values()].map((record) => record.relative),
205
213
  ...relations.map((relation) => relation._record.relative),
206
- ])) if (!state.tracked.has(relative) || state.dirty.has(relative)) output.blockers.push("graph_source_not_revision_bound");
207
- const graphPayload = {
214
+ ]);
215
+ const newContentBoundGraph = state.hasGit
216
+ && graphSources.length > 0
217
+ && graphSources.every((relative) => !state.tracked.has(relative))
218
+ && graphSources.every((relative) => !state.dirty.has(relative));
219
+ for (const relative of graphSources) if (state.hasGit && !newContentBoundGraph && (!state.tracked.has(relative) || state.dirty.has(relative))) output.blockers.push("graph_source_not_revision_bound");
220
+ const gitRevision = !newContentBoundGraph ? git(repo, "rev-parse", "HEAD") || null : null;
221
+ const graphPayloadBase = {
208
222
  manifest: { id: manifest.id, scope: manifest.scope, profiles: manifest.profiles, graph: manifest.graph },
209
223
  objects: [...objects.values()].map((record) => record.value).sort((a, b) => a.id.localeCompare(b.id)),
210
224
  relations: relations.map(({ _record, ...relation }) => relation).sort((a, b) => a.id.localeCompare(b.id)),
211
- revision,
212
225
  };
226
+ const revision = gitRevision || `content:${digest(graphPayloadBase).slice(7)}`;
227
+ const graphPayload = { ...graphPayloadBase, revision };
213
228
  return {
214
229
  repo,
215
230
  manifest,
@@ -219,6 +234,7 @@ function readGraph(repoArg) {
219
234
  relations,
220
235
  tracked: state.tracked,
221
236
  dirty: state.dirty,
237
+ hasGit: state.hasGit,
222
238
  blobCache: new Map(),
223
239
  blockers: unique(output.blockers),
224
240
  };
@@ -638,7 +654,7 @@ function verifyContext(repository, contextPack, usageEvidence, options = {}) {
638
654
  const absolute = path.join(graph.repo, source.ref);
639
655
  if (!fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink() || !fs.statSync(absolute).isFile()) blockers.push("context_pack_source_unavailable");
640
656
  else {
641
- const blob = gitBlob(graph.repo, source.ref);
657
+ const blob = graphBlob(graph, source.ref);
642
658
  if (!blob || !revisionBound(graph.repo, source.ref) || digest(blob) !== source.sha256 || source.revision !== graph.revision) blockers.push("context_pack_source_revision_or_digest_mismatch");
643
659
  }
644
660
  }
@@ -0,0 +1,414 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+
8
+ // Manifest refs are portable POSIX-style identifiers even when the host is Windows.
9
+ // Convert them to host paths only when joining with the repository root.
10
+ const CONTINUITY_FILE = "graph/specs/project-continuity.json";
11
+ const CONTEXT_PROJECTION_FILE = "graph/docs/project-context.md";
12
+ const BOUNDARIES = new Set(["task_start", "stage_complete", "task_complete"]);
13
+ const SECRET_PATTERN = /(?:ghp_|github_pat_|bearer\s+)[a-z0-9_-]{12,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:^|["'\s/])\.env(?:["'\s/]|$)|(?:password|token|secret|cookie)\s*[:=]/i;
14
+ const PRIVATE_PATH_PATTERN = /(?:^|[^A-Za-z0-9._-])source[\\/](?:private|memory|workflow|handoff)(?:[\\/]|$)/i;
15
+ const HOST_PATH_PATTERN = /(?:^|["'\s])(?:\/[Uu]sers\/[^/\s]+|\/home\/[^/\s]+|[A-Za-z]:\\Users\\[^\\\s]+)/;
16
+
17
+ function sortValue(value) {
18
+ if (Array.isArray(value)) return value.map(sortValue);
19
+ if (!value || typeof value !== "object") return value;
20
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
21
+ }
22
+
23
+ function canonicalBytes(value) {
24
+ return `${JSON.stringify(sortValue(value), null, 2)}\n`;
25
+ }
26
+
27
+ function digest(value) {
28
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(typeof value === "string" ? value : canonicalBytes(value));
29
+ return `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
30
+ }
31
+
32
+ function safeId(value) {
33
+ return String(value || "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
34
+ }
35
+
36
+ function safeText(value, limit = 2000) {
37
+ const text = String(value || "").trim();
38
+ if (!text || text.length > limit || SECRET_PATTERN.test(text) || PRIVATE_PATH_PATTERN.test(text) || HOST_PATH_PATTERN.test(text)) return null;
39
+ return text;
40
+ }
41
+
42
+ function safeRef(value) {
43
+ const text = safeText(value, 512);
44
+ if (!text || path.isAbsolute(text) || text.split(/[\\/]+/).includes("..")) return null;
45
+ return text;
46
+ }
47
+
48
+ function unique(values) {
49
+ return [...new Set(values.filter(Boolean))].sort();
50
+ }
51
+
52
+ function readJson(file, fallback = null) {
53
+ try { return JSON.parse(fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "")); } catch (_) { return fallback; }
54
+ }
55
+
56
+ function atomicWrite(file, bytes) {
57
+ const content = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes));
58
+ if (fs.existsSync(file) && fs.readFileSync(file).equals(content)) return false;
59
+ fs.mkdirSync(path.dirname(file), { recursive: true });
60
+ const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`);
61
+ fs.writeFileSync(temp, content, { mode: 0o600 });
62
+ fs.renameSync(temp, file);
63
+ return true;
64
+ }
65
+
66
+ function graphIdentity(repo, manifest) {
67
+ return safeId(manifest?.id || path.basename(repo)) || digest(repo).slice(7, 23);
68
+ }
69
+
70
+ function stateRoot(repo, manifest, options = {}) {
71
+ const explicit = options.stateRoot || process.env.MIRAI_GRAPH_STATE_ROOT;
72
+ let root = explicit ? path.resolve(explicit) : null;
73
+ if (!root && process.platform === "win32") root = path.join(process.env.LOCALAPPDATA || os.tmpdir(), "mirai-graph", "project-technology");
74
+ if (!root && process.platform === "darwin") root = path.join(os.homedir(), "Library", "Application Support", "mirai-graph", "project-technology");
75
+ if (!root) root = path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "mirai-graph", "project-technology");
76
+ return path.join(root, graphIdentity(repo, manifest));
77
+ }
78
+
79
+ function listFiles(root) {
80
+ if (!fs.existsSync(root)) return [];
81
+ const output = [];
82
+ const visit = (dir) => {
83
+ for (const name of fs.readdirSync(dir).sort()) {
84
+ const file = path.join(dir, name);
85
+ const stat = fs.lstatSync(file);
86
+ if (stat.isSymbolicLink()) continue;
87
+ if (stat.isDirectory()) visit(file);
88
+ else if (stat.isFile()) output.push(file);
89
+ }
90
+ };
91
+ visit(root);
92
+ return output;
93
+ }
94
+
95
+ function graphDigest(repo) {
96
+ const specs = path.join(repo, "graph", "specs");
97
+ const entries = [];
98
+ const manifest = path.join(repo, "graph.json");
99
+ if (fs.existsSync(manifest)) entries.push({ path: "graph.json", sha256: digest(fs.readFileSync(manifest)) });
100
+ for (const file of listFiles(specs)) entries.push({
101
+ path: path.relative(repo, file).split(path.sep).join("/"),
102
+ sha256: digest(fs.readFileSync(file)),
103
+ });
104
+ return digest(entries);
105
+ }
106
+
107
+ function continuityPolicy(manifest) {
108
+ return manifest?.extensions?.["mirai.project_technology"]?.continuity_policy || null;
109
+ }
110
+
111
+ function readObjects(repo) {
112
+ const payload = readJson(path.join(repo, CONTINUITY_FILE), { schema_version: "1.0.0", objects: [] });
113
+ return payload && payload.schema_version === "1.0.0" && Array.isArray(payload.objects) ? payload.objects : [];
114
+ }
115
+
116
+ function latestReceipt(repo, manifest, options = {}) {
117
+ const root = stateRoot(repo, manifest, options);
118
+ const pointer = readJson(path.join(root, "latest-receipt.json"));
119
+ if (!pointer?.receipt_ref) return null;
120
+ const file = path.join(root, pointer.receipt_ref);
121
+ const receipt = readJson(file);
122
+ if (!receipt || receipt.receipt_digest !== digest(Object.fromEntries(Object.entries(receipt).filter(([key]) => key !== "receipt_digest")))) return null;
123
+ return receipt;
124
+ }
125
+
126
+ function status(repo, manifest, options = {}) {
127
+ const policy = continuityPolicy(manifest);
128
+ const currentGraphDigest = graphDigest(repo);
129
+ const receipt = latestReceipt(repo, manifest, options);
130
+ let freshness = "not_configured";
131
+ if (policy === "task_boundary") freshness = !receipt ? "missing" : receipt.current_graph_digest === currentGraphDigest ? "current" : "stale";
132
+ return {
133
+ policy,
134
+ authority: "graph/specs",
135
+ host_state_ref: `host-local://${graphIdentity(repo, manifest)}`,
136
+ graph_digest: currentGraphDigest,
137
+ freshness,
138
+ terminal_receipt: receipt ? {
139
+ receipt_digest: receipt.receipt_digest,
140
+ boundary: receipt.boundary,
141
+ task_digest: receipt.task_digest,
142
+ current_graph_digest: receipt.current_graph_digest,
143
+ } : null,
144
+ };
145
+ }
146
+
147
+ function normalizeEvidence(input) {
148
+ const blockers = [];
149
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { evidence: {}, blockers: ["continuity_evidence_missing"] };
150
+ if (SECRET_PATTERN.test(JSON.stringify(input)) || PRIVATE_PATH_PATTERN.test(JSON.stringify(input)) || HOST_PATH_PATTERN.test(JSON.stringify(input))) blockers.push("continuity_sensitive_or_host_data_forbidden");
151
+ const taskDigest = String(input.task_digest || "").toLowerCase();
152
+ if (!/^sha256:[0-9a-f]{64}$/.test(taskDigest)) blockers.push("continuity_task_digest_invalid");
153
+ const outcome = safeText(input.outcome, 1200);
154
+ if (!outcome) blockers.push("continuity_outcome_missing_or_unsafe");
155
+ const requirementRefs = unique((input.requirement_refs || []).map(safeRef));
156
+ const evidenceRefs = unique((input.evidence_refs || []).map(safeRef));
157
+ const checks = (Array.isArray(input.checks) ? input.checks : []).map((item) => {
158
+ if (!item || typeof item !== "object") return null;
159
+ const id = safeRef(item.id); const verdict = String(item.verdict || "").toLowerCase(); const evidenceRef = safeRef(item.evidence_ref);
160
+ if (!id || !["pass", "fail", "blocked"].includes(verdict) || !evidenceRef) return null;
161
+ return { id, verdict, evidence_ref: evidenceRef };
162
+ }).filter(Boolean).sort((a, b) => a.id.localeCompare(b.id));
163
+ if (!requirementRefs.length) blockers.push("continuity_requirement_refs_missing");
164
+ if (!evidenceRefs.length || !checks.length) blockers.push("continuity_verification_missing");
165
+ if (checks.some((item) => item.verdict !== "pass")) blockers.push("continuity_result_not_verified");
166
+ const changedSurfaces = unique((input.changed_surfaces || []).map(safeRef));
167
+ const caseSignature = safeRef(input.case_signature || "");
168
+ const methodCandidate = safeText(input.method_candidate || "", 1200);
169
+ const decisions = (Array.isArray(input.decisions) ? input.decisions : []).map((item) => {
170
+ if (!item || typeof item !== "object") return null;
171
+ const summary = safeText(item.summary, 1200);
172
+ if (!summary) return null;
173
+ return {
174
+ summary,
175
+ changes_architecture: item.changes_architecture !== false,
176
+ owner_ref: safeRef(item.owner_ref || "") || null,
177
+ approval_ref: safeRef(item.approval_ref || "") || null,
178
+ };
179
+ }).filter(Boolean);
180
+ return {
181
+ evidence: {
182
+ task_digest: taskDigest,
183
+ outcome,
184
+ requirement_refs: requirementRefs,
185
+ evidence_refs: evidenceRefs,
186
+ checks,
187
+ changed_surfaces: changedSurfaces,
188
+ case_signature: caseSignature || digest({ outcome, requirementRefs }).slice(7, 31),
189
+ method_candidate: methodCandidate,
190
+ decisions,
191
+ source_revision: safeRef(input.source_revision || "") || null,
192
+ },
193
+ blockers: unique(blockers),
194
+ };
195
+ }
196
+
197
+ function buildCandidates(existing, evidence, boundary) {
198
+ const signature = digest({ task: evidence.task_digest, outcome: evidence.outcome, requirements: evidence.requirement_refs });
199
+ const suffix = signature.slice(7, 23);
200
+ const promoted = [
201
+ {
202
+ id: `evidence.continuity.${suffix}`,
203
+ kind: "evidence",
204
+ title: `Verified task result ${suffix}`,
205
+ summary: evidence.outcome,
206
+ readiness: "accepted",
207
+ profile: "project_management",
208
+ requirement_refs: evidence.requirement_refs,
209
+ evidence_refs: evidence.evidence_refs,
210
+ checks: evidence.checks,
211
+ changed_surfaces: evidence.changed_surfaces,
212
+ task_digest: evidence.task_digest,
213
+ source_revision: evidence.source_revision,
214
+ provenance_digest: signature,
215
+ },
216
+ {
217
+ id: `regression_case.continuity.${suffix}`,
218
+ kind: "regression_case",
219
+ title: `Verified reusable case ${suffix}`,
220
+ summary: evidence.outcome,
221
+ readiness: "accepted",
222
+ profile: "implementation_control",
223
+ case_signature: evidence.case_signature,
224
+ requirement_refs: evidence.requirement_refs,
225
+ evidence_refs: [`evidence.continuity.${suffix}`],
226
+ task_digest: evidence.task_digest,
227
+ boundary,
228
+ provenance_digest: signature,
229
+ },
230
+ ];
231
+ const proposals = evidence.decisions.map((decision, index) => ({
232
+ id: `decision.proposal.${digest({ signature, decision, index }).slice(7, 23)}`,
233
+ kind: "decision",
234
+ title: `Decision proposal ${index + 1}`,
235
+ summary: decision.summary,
236
+ readiness: decision.approval_ref && !decision.changes_architecture ? "accepted" : "proposal",
237
+ profile: "project_management",
238
+ owner_ref: decision.owner_ref,
239
+ approval_ref: decision.approval_ref,
240
+ source_evidence_ref: `evidence.continuity.${suffix}`,
241
+ }));
242
+ if (evidence.method_candidate) {
243
+ const relatedCaseMap = new Map([...existing, ...promoted]
244
+ .filter((item) => item.kind === "regression_case" && item.case_signature === evidence.case_signature)
245
+ .map((item) => [item.id, item]));
246
+ const relatedCases = [...relatedCaseMap.values()];
247
+ const independentTasks = unique(relatedCases.map((item) => item.task_digest));
248
+ proposals.push({
249
+ id: `lesson.continuity.${digest(evidence.case_signature).slice(7, 23)}`,
250
+ kind: "lesson",
251
+ title: `Reusable method ${evidence.case_signature}`,
252
+ summary: evidence.method_candidate,
253
+ readiness: independentTasks.length >= 2 ? "accepted" : "proposal",
254
+ profile: "project_management",
255
+ case_signature: evidence.case_signature,
256
+ supporting_case_ids: unique(relatedCases.map((item) => item.id)),
257
+ auto_promotion_rule: "two_independent_verified_cases_without_architecture_change",
258
+ });
259
+ }
260
+ return { promoted, proposals };
261
+ }
262
+
263
+ function mergeObjects(existing, additions) {
264
+ const map = new Map(existing.map((item) => [item.id, item]));
265
+ for (const item of additions) map.set(item.id, item);
266
+ return [...map.values()].sort((a, b) => a.id.localeCompare(b.id));
267
+ }
268
+
269
+ function ensureManifestReference(manifest) {
270
+ const next = JSON.parse(JSON.stringify(manifest));
271
+ next.graph = next.graph || {};
272
+ next.graph.source_of_truth = unique([...(next.graph.source_of_truth || []), "graph/specs"]);
273
+ next.graph.objects = unique([...(next.graph.objects || []), CONTINUITY_FILE]);
274
+ next.graph.generated = unique([...(next.graph.generated || []), CONTEXT_PROJECTION_FILE]);
275
+ next.extensions = next.extensions || {};
276
+ next.extensions["mirai.project_technology"] = {
277
+ ...(next.extensions["mirai.project_technology"] || {}),
278
+ continuity_policy: "task_boundary",
279
+ };
280
+ return next;
281
+ }
282
+
283
+ function contextProjection(manifest, objects, currentDigest) {
284
+ const accepted = objects.filter((item) => item.readiness === "accepted");
285
+ const proposals = objects.filter((item) => item.readiness === "proposal");
286
+ const lines = [
287
+ `# ${manifest.title || manifest.id}: project context`,
288
+ "",
289
+ "> Generated from `graph/specs`. This document is a readable projection, not a source of truth.",
290
+ "",
291
+ `Graph digest: \`${currentDigest}\``,
292
+ "",
293
+ "## Accepted context",
294
+ "",
295
+ ...(accepted.length ? accepted.map((item) => `- **${item.title}** — ${item.summary}`) : ["- No accepted continuity records yet."]),
296
+ "",
297
+ "## Proposals requiring a decision",
298
+ "",
299
+ ...(proposals.length ? proposals.map((item) => `- **${item.title}** — ${item.summary}`) : ["- None."]),
300
+ "",
301
+ ];
302
+ return `${lines.join("\n")}\n`;
303
+ }
304
+
305
+ function lock(repo, root) {
306
+ const gitBacked = fs.existsSync(path.join(repo, ".git"));
307
+ const file = gitBacked ? path.join(root, "continuity.lock") : path.join(repo, "graph", ".project-technology-continuity.lock");
308
+ fs.mkdirSync(path.dirname(file), { recursive: true });
309
+ try {
310
+ const fd = fs.openSync(file, "wx", 0o600);
311
+ fs.writeFileSync(fd, canonicalBytes({ pid: process.pid }));
312
+ fs.closeSync(fd);
313
+ return { file, acquired: true };
314
+ } catch (_) { return { file, acquired: false }; }
315
+ }
316
+
317
+ function releaseLock(entry) {
318
+ if (entry?.acquired) try { fs.unlinkSync(entry.file); } catch (_) { /* already removed */ }
319
+ }
320
+
321
+ function sync(repoArg, manifest, boundary, input, options = {}) {
322
+ const repo = path.resolve(repoArg || ".");
323
+ if (!BOUNDARIES.has(boundary)) return { status: "fail", changed: false, blockers: ["continuity_boundary_invalid"] };
324
+ if (boundary === "task_start") return {
325
+ status: "success", changed: false, blockers: [], continuity: { ...status(repo, manifest, options), input_digest: input ? digest(input) : null, omissions: ["task_start_is_read_only"] },
326
+ };
327
+ const normalized = normalizeEvidence(input);
328
+ if (normalized.blockers.length) return { status: "blocked", changed: false, blockers: normalized.blockers };
329
+ const beforeDigest = graphDigest(repo);
330
+ if (options.expectedGraphDigest && options.expectedGraphDigest !== beforeDigest) return { status: "blocked", changed: false, blockers: ["continuity_compare_and_swap_conflict"] };
331
+ const root = stateRoot(repo, manifest, options);
332
+ const lease = lock(repo, root);
333
+ if (!lease.acquired) return { status: "blocked", changed: false, blockers: ["continuity_lease_conflict"] };
334
+ const manifestPath = path.join(repo, "graph.json");
335
+ const continuityPath = path.join(repo, CONTINUITY_FILE);
336
+ const projectionPath = path.join(repo, CONTEXT_PROJECTION_FILE);
337
+ const manifestBefore = fs.readFileSync(manifestPath);
338
+ const continuityBefore = fs.existsSync(continuityPath) ? fs.readFileSync(continuityPath) : null;
339
+ const projectionBefore = fs.existsSync(projectionPath) ? fs.readFileSync(projectionPath) : null;
340
+ try {
341
+ if (graphDigest(repo) !== beforeDigest) return { status: "blocked", changed: false, blockers: ["continuity_compare_and_swap_conflict"] };
342
+ const existing = readObjects(repo);
343
+ const built = buildCandidates(existing, normalized.evidence, boundary);
344
+ const objects = mergeObjects(existing, [...built.promoted, ...built.proposals]);
345
+ const nextManifest = ensureManifestReference(manifest);
346
+ const backupId = digest({ beforeDigest, task: normalized.evidence.task_digest }).slice(7, 23);
347
+ const backupRoot = path.join(root, "rollback", backupId);
348
+ atomicWrite(path.join(backupRoot, "graph.json"), manifestBefore);
349
+ if (continuityBefore) atomicWrite(path.join(backupRoot, "project-continuity.json"), continuityBefore);
350
+ if (projectionBefore) atomicWrite(path.join(backupRoot, "project-context.md"), projectionBefore);
351
+ const manifestChanged = atomicWrite(manifestPath, canonicalBytes(nextManifest));
352
+ const continuityChanged = atomicWrite(continuityPath, canonicalBytes({ schema_version: "1.0.0", objects }));
353
+ const currentDigest = graphDigest(repo);
354
+ const projectionChanged = atomicWrite(projectionPath, contextProjection(nextManifest, objects, currentDigest));
355
+ const receiptBase = {
356
+ contract: "project-continuity-receipt",
357
+ contract_version: "1.0.0",
358
+ repository_id: nextManifest.id,
359
+ boundary,
360
+ task_digest: normalized.evidence.task_digest,
361
+ input_digest: digest(normalized.evidence),
362
+ previous_graph_digest: beforeDigest,
363
+ current_graph_digest: currentDigest,
364
+ saved_refs: built.promoted.map((item) => item.id).sort(),
365
+ proposals: built.proposals.filter((item) => item.readiness === "proposal").map((item) => item.id).sort(),
366
+ omissions: [],
367
+ freshness: "current",
368
+ rollback_ref: `host-local://${graphIdentity(repo, nextManifest)}/rollback/${backupId}`,
369
+ };
370
+ const receipt = { ...receiptBase, receipt_digest: digest(receiptBase) };
371
+ const receiptName = `receipts/${receipt.receipt_digest.slice(7)}.json`;
372
+ atomicWrite(path.join(root, receiptName), canonicalBytes(receipt));
373
+ atomicWrite(path.join(root, "latest-receipt.json"), canonicalBytes({ receipt_ref: receiptName, receipt_digest: receipt.receipt_digest }));
374
+ const readback = readJson(continuityPath);
375
+ if (!readback || graphDigest(repo) !== currentDigest) throw new Error("continuity_readback_failed");
376
+ return { status: "success", changed: manifestChanged || continuityChanged || projectionChanged, blockers: [], continuity: { ...status(repo, nextManifest, options), ...receipt } };
377
+ } catch (_) {
378
+ atomicWrite(manifestPath, manifestBefore);
379
+ if (continuityBefore) atomicWrite(continuityPath, continuityBefore);
380
+ else if (fs.existsSync(continuityPath)) fs.unlinkSync(continuityPath);
381
+ if (projectionBefore) atomicWrite(projectionPath, projectionBefore);
382
+ else if (fs.existsSync(projectionPath)) fs.unlinkSync(projectionPath);
383
+ return { status: "fail", changed: false, blockers: ["continuity_transaction_failed", "continuity_rollback_applied"] };
384
+ } finally { releaseLock(lease); }
385
+ }
386
+
387
+ function verify(repo, manifest, options = {}) {
388
+ const current = status(repo, manifest, options);
389
+ const blockers = [];
390
+ if (current.policy === "task_boundary" && current.freshness !== "current") blockers.push(`continuity_${current.freshness}`);
391
+ if (options.receiptDigest && current.terminal_receipt?.receipt_digest !== options.receiptDigest) blockers.push("continuity_receipt_digest_mismatch");
392
+ for (const object of readObjects(repo)) {
393
+ if (!object.id || !object.kind || !object.title || !object.summary || !object.readiness || !object.profile) blockers.push("continuity_object_incomplete");
394
+ if (SECRET_PATTERN.test(JSON.stringify(object)) || PRIVATE_PATH_PATTERN.test(JSON.stringify(object)) || HOST_PATH_PATTERN.test(JSON.stringify(object))) blockers.push("continuity_object_contains_sensitive_or_host_data");
395
+ if (["decision", "goal"].includes(object.kind) && object.readiness === "accepted" && !object.approval_ref && object.id.includes("proposal")) blockers.push("continuity_proposal_promoted_without_approval");
396
+ }
397
+ return { status: blockers.length ? "blocked" : "success", blockers: unique(blockers), continuity: current };
398
+ }
399
+
400
+ module.exports = {
401
+ BOUNDARIES,
402
+ CONTINUITY_FILE,
403
+ CONTEXT_PROJECTION_FILE,
404
+ canonicalBytes,
405
+ continuityPolicy,
406
+ digest,
407
+ graphDigest,
408
+ latestReceipt,
409
+ normalizeEvidence,
410
+ stateRoot,
411
+ status,
412
+ sync,
413
+ verify,
414
+ };