mirai-graph 1.1.1 → 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.
@@ -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
+ };