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.
- package/.github/workflows/ci.yml +3 -0
- package/CHANGELOG.md +48 -0
- package/CITATION.cff +2 -2
- package/README.md +15 -3
- package/package.json +3 -2
- package/packages/cli/graph-manifest.js +2 -1
- package/packages/cli/project-technology.js +10 -1
- package/packages/cli/validate-context-traversal.js +60 -0
- package/packages/cli/validate-project-continuity.js +173 -0
- package/packages/cli/validate-project-technology.js +26 -2
- package/packages/project-technology/context-traversal.js +108 -13
- package/packages/project-technology/continuity.js +414 -0
- package/packages/project-technology/index.js +140 -36
- package/profiles/implementation-control/profile.json +3 -1
- package/profiles/project-management/profile.json +8 -1
- package/releases/1.1.1.md +25 -0
- package/releases/1.2.0.md +19 -0
- package/releases/README.md +2 -0
- package/schemas/project-technology-extension.schema.json +2 -1
- package/standard/context-pack-generation.md +8 -2
- package/standard/object-model.md +6 -0
- package/standard/project-technology.md +35 -4
- package/standard/test-suite.md +4 -1
|
@@ -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
|
|
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
|
|
131
|
-
|
|
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
|
-
])
|
|
136
|
-
const
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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 =
|
|
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
|
+
};
|