mirai-graph 1.1.0 → 1.2.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.
@@ -19,7 +19,13 @@ const SUPPORTED_RELATIONS = new Set([
19
19
  "conflicts_with",
20
20
  ]);
21
21
  const TERMINAL_KINDS = new Set(["resource", "source", "check", "gate", "constraint"]);
22
- const ACTIVE_READINESS = new Set(["ready", "accepted", "implemented", "validated", "operating", "evolving"]);
22
+ const ACTIVE_READINESS = new Set(["ready", "accepted", "implemented", "validated", "operating", "evolving", "pilot"]);
23
+ const LEGACY_ACTIVE_READINESS = new Set([
24
+ "r3_structured", "r3_specified", "r4_integrated", "r4_validated",
25
+ "r4_executable", "r4_evidence_ready", "a4_validated_projection",
26
+ "a6_accepted_local_candidate", "t5_implementation_ready",
27
+ "active_controlled_paid_test",
28
+ ]);
23
29
  const BLOCKING_READINESS = new Set(["blocked", "deprecated", "stale", "retired", "superseded"]);
24
30
  const SECRET_PARTS = [".env", "credential", "secret", "token", "password", "cookie", "private-key", "id_rsa", ".pem", ".p12"];
25
31
 
@@ -53,7 +59,31 @@ function gitBlob(repo, relative) {
53
59
  return completed.status === 0 ? completed.stdout : null;
54
60
  }
55
61
 
62
+ function trackedState(repo) {
63
+ const trackedRun = spawnSync("git", ["ls-files", "-z"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
64
+ const dirtyRun = spawnSync("git", ["diff", "--name-only", "-z", "HEAD", "--"], { cwd: repo, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
65
+ return {
66
+ hasGit: Boolean(git(repo, "rev-parse", "--is-inside-work-tree")),
67
+ tracked: new Set(trackedRun.status === 0 ? trackedRun.stdout.split("\0").filter(Boolean) : []),
68
+ dirty: new Set(dirtyRun.status === 0 ? dirtyRun.stdout.split("\0").filter(Boolean) : []),
69
+ };
70
+ }
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
+ }
77
+ if (!graph.tracked?.has(relative) || graph.dirty?.has(relative)) return null;
78
+ if (!graph.blobCache.has(relative)) graph.blobCache.set(relative, gitBlob(graph.repo, relative));
79
+ return graph.blobCache.get(relative);
80
+ }
81
+
56
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
+ }
57
87
  if (!gitBlob(repo, relative)) return false;
58
88
  return spawnSync("git", ["diff", "--quiet", "HEAD", "--", relative], { cwd: repo }).status === 0;
59
89
  }
@@ -72,6 +102,54 @@ function safeRef(value) {
72
102
  return safeRelative(value);
73
103
  }
74
104
 
105
+ const LEGACY_RELATION_TYPES = new Map([
106
+ ["implements", { type: "contains" }],
107
+ ["has_capability", { type: "contains" }],
108
+ ["uses_process", { type: "contains" }],
109
+ ["includes", { type: "contains" }],
110
+ ["member_of", { type: "contains", reverse: true }],
111
+ ["refines", { type: "specializes" }],
112
+ ["depends_on", { type: "requires" }],
113
+ ["requires_current_source_check", { type: "requires" }],
114
+ ["requires_gate", { type: "validated_by" }],
115
+ ["requires_quality_gate", { type: "validated_by" }],
116
+ ["tested_by_scenario", { type: "validated_by" }],
117
+ ["validates", { type: "validated_by", reverse: true }],
118
+ ["assessed_by", { type: "validated_by" }],
119
+ ["blocked_without_gate", { type: "validated_by" }],
120
+ ["conforms_to", { type: "governed_by" }],
121
+ ["constrained_by", { type: "governed_by" }],
122
+ ["evidenced_by", { type: "documented_by" }],
123
+ ]);
124
+
125
+ function normalizeObject(value) {
126
+ const normalized = { ...value };
127
+ if (!normalized.kind && normalized.type) normalized.kind = normalized.type;
128
+ if (!normalized.readiness) normalized.readiness = normalized.lifecycle || normalized.status || "accepted";
129
+ if (!value.kind) delete normalized.type;
130
+ return normalized;
131
+ }
132
+
133
+ function normalizeRelation(value) {
134
+ const legacyType = value.type || value.relation_type;
135
+ const mapping = LEGACY_RELATION_TYPES.get(legacyType) || { type: legacyType };
136
+ let source = value.source || value.from;
137
+ let target = value.target || value.to;
138
+ if (mapping.reverse) [source, target] = [target, source];
139
+ const normalized = {
140
+ ...value,
141
+ type: mapping.type,
142
+ source,
143
+ target,
144
+ readiness: value.readiness || value.status || value.lifecycle || "accepted",
145
+ };
146
+ delete normalized.from;
147
+ delete normalized.to;
148
+ delete normalized.relation_type;
149
+ delete normalized.status;
150
+ return normalized;
151
+ }
152
+
75
153
  function loadEntry(repo, relative, kind, output, visited = new Set()) {
76
154
  if (!safeRelative(relative) || visited.has(relative)) return;
77
155
  visited.add(relative);
@@ -112,14 +190,15 @@ function readGraph(repoArg) {
112
190
  for (const relative of manifest.graph?.relations || []) loadEntry(repo, relative, "relations", output);
113
191
  const objects = new Map();
114
192
  for (const record of output.objects) {
115
- const id = String(record.value.id || "").trim();
193
+ const normalized = normalizeObject(record.value);
194
+ const id = String(normalized.id || "").trim();
116
195
  if (!id || objects.has(id)) { output.blockers.push(id ? "duplicate_object_id" : "object_id_missing"); continue; }
117
- objects.set(id, record);
196
+ objects.set(id, { ...record, value: normalized });
118
197
  }
119
198
  const relations = [];
120
199
  const relationIds = new Set();
121
200
  for (const record of output.relations) {
122
- const relation = record.value;
201
+ const relation = normalizeRelation(record.value);
123
202
  const id = String(relation.id || "").trim();
124
203
  if (!id || relationIds.has(id)) { output.blockers.push(id ? "duplicate_relation_id" : "relation_id_missing"); continue; }
125
204
  relationIds.add(id);
@@ -127,18 +206,25 @@ function readGraph(repoArg) {
127
206
  if (!objects.has(relation.source) || !objects.has(relation.target)) { output.blockers.push("relation_endpoint_missing"); continue; }
128
207
  relations.push({ ...relation, _record: record });
129
208
  }
130
- const revision = git(repo, "rev-parse", "HEAD") || null;
131
- for (const relative of unique([
209
+ const state = trackedState(repo);
210
+ const graphSources = unique([
132
211
  "graph.json",
133
212
  ...[...objects.values()].map((record) => record.relative),
134
213
  ...relations.map((relation) => relation._record.relative),
135
- ])) if (!revisionBound(repo, relative)) output.blockers.push("graph_source_not_revision_bound");
136
- 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 = {
137
222
  manifest: { id: manifest.id, scope: manifest.scope, profiles: manifest.profiles, graph: manifest.graph },
138
223
  objects: [...objects.values()].map((record) => record.value).sort((a, b) => a.id.localeCompare(b.id)),
139
224
  relations: relations.map(({ _record, ...relation }) => relation).sort((a, b) => a.id.localeCompare(b.id)),
140
- revision,
141
225
  };
226
+ const revision = gitRevision || `content:${digest(graphPayloadBase).slice(7)}`;
227
+ const graphPayload = { ...graphPayloadBase, revision };
142
228
  return {
143
229
  repo,
144
230
  manifest,
@@ -146,6 +232,10 @@ function readGraph(repoArg) {
146
232
  graphDigest: digest(graphPayload),
147
233
  objects,
148
234
  relations,
235
+ tracked: state.tracked,
236
+ dirty: state.dirty,
237
+ hasGit: state.hasGit,
238
+ blobCache: new Map(),
149
239
  blockers: unique(output.blockers),
150
240
  };
151
241
  }
@@ -158,6 +248,11 @@ function readinessOf(object) {
158
248
  return String(object.readiness || object.lifecycle || object.status || "unknown").toLowerCase();
159
249
  }
160
250
 
251
+ function activeReadiness(value) {
252
+ const normalized = String(value || "").toLowerCase();
253
+ return ACTIVE_READINESS.has(normalized) || LEGACY_ACTIVE_READINESS.has(normalized);
254
+ }
255
+
161
256
  function outgoing(graph, id) {
162
257
  return graph.relations.filter((relation) => relation.source === id);
163
258
  }
@@ -181,7 +276,7 @@ function sourcePassport(graph, record, value) {
181
276
  if (!fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink() || !fs.statSync(absolute).isFile()) {
182
277
  return { ref, availability: "unavailable", revision: graph.revision, sha256: null };
183
278
  }
184
- const blob = gitBlob(graph.repo, ref);
279
+ const blob = graphBlob(graph, ref);
185
280
  return { ref, availability: blob ? "available" : "unbound", revision: graph.revision, sha256: digest(blob || fs.readFileSync(absolute)) };
186
281
  }).concat([{ ref: record.relative, availability: "available", revision: graph.revision, sha256: record.sha256 }]);
187
282
  }
@@ -446,7 +541,7 @@ function compileContext(repository, traversalReceipt, selectionInput, options =
446
541
  for (const id of closure.ids) {
447
542
  const passport = nodePassport(graph, id);
448
543
  if (!passport) continue;
449
- if (!ACTIVE_READINESS.has(passport.readiness)) blockers.push(`context_node_${passport.readiness}`);
544
+ if (!activeReadiness(passport.readiness)) blockers.push(`context_node_${passport.readiness}`);
450
545
  if (passport.omitted_source_ref_count > 0) blockers.push("unsafe_source_reference_omitted");
451
546
  if (passport.sensitive_metadata_detected) blockers.push("context_node_sensitive_metadata");
452
547
  if (passport.expansion_policy === "terminal" && passport.expandable) blockers.push("terminal_node_has_children");
@@ -468,7 +563,7 @@ function compileContext(repository, traversalReceipt, selectionInput, options =
468
563
  const includedIds = [...included].sort();
469
564
  const includedNodes = includedIds.map((id) => nodePassport(graph, id)).filter(Boolean);
470
565
  const includedRelations = graph.relations.filter((relation) => included.has(relation.source) && included.has(relation.target)).map(relationPassport).sort((a, b) => a.id.localeCompare(b.id));
471
- for (const relation of includedRelations) if (!ACTIVE_READINESS.has(String(relation.readiness).toLowerCase())) blockers.push(`context_relation_${String(relation.readiness).toLowerCase()}`);
566
+ for (const relation of includedRelations) if (!activeReadiness(relation.readiness)) blockers.push(`context_relation_${String(relation.readiness).toLowerCase()}`);
472
567
  const terminalSourceMap = new Map();
473
568
  for (const node of includedNodes.filter((item) => !item.expandable || ["resource", "source"].includes(item.kind))) {
474
569
  for (const source of node.source_refs) {
@@ -559,7 +654,7 @@ function verifyContext(repository, contextPack, usageEvidence, options = {}) {
559
654
  const absolute = path.join(graph.repo, source.ref);
560
655
  if (!fs.existsSync(absolute) || fs.lstatSync(absolute).isSymbolicLink() || !fs.statSync(absolute).isFile()) blockers.push("context_pack_source_unavailable");
561
656
  else {
562
- const blob = gitBlob(graph.repo, source.ref);
657
+ const blob = graphBlob(graph, source.ref);
563
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");
564
659
  }
565
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
+ };