superdev-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,984 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Source ingestion engine (ADR-0006). Package-closed inside the Docs skill:
4
+ * inventory and screening run anywhere; record mutation (--apply) requires the
5
+ * Superdev plugin installation (the record engine is loaded dynamically) and is
6
+ * refused truthfully on a standalone single-skill install.
7
+ *
8
+ * Structural authority rules: raw sources are never modified and NEVER
9
+ * executed or treated as instructions; claims are drafts until approved;
10
+ * ingestion is the ONLY writer of talks/project/accepted/ and writes there ONLY
11
+ * on an owner-approved, fresh, uncontradicted, integrity-verified claim (approval
12
+ * fails closed if any contradiction record is even unreadable); re-ingestion of
13
+ * unchanged content is a no-op; contradictions never auto-close.
14
+ *
15
+ * Operations:
16
+ * inventory --root <p> inbox + source-record status
17
+ * ingest --root <p> --source <rel> [--apply] register + screen a source revision
18
+ * propose --root <p> --revision <SRC..:rN> --proposals <file|-> [--apply]
19
+ * validate agent-proposed claims/contradictions against the
20
+ * deterministic schema; dedup by identity; merge provenance
21
+ * approve --root <p> --id <CLM-..> --approver <who> [--apply]
22
+ * reject --root <p> --id <CLM-..> --approver <who> --reason <r> [--apply]
23
+ * resolve --root <p> --id <CTR-..> --authority <class> --evidence <e> [--apply]
24
+ * verify --root <p> re-verify provenance spans against revision hashes
25
+ * list --root <p> [--kind claims|contradictions] read-only listing
26
+ *
27
+ * Exit codes: 0 ok, 1 refused/findings, 2 usage error.
28
+ */
29
+ import { parseArgs } from "node:util";
30
+ import crypto from "node:crypto";
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import { screenText, screenIntake, hasSensitive, MAX_SOURCE_BYTES, SENSITIVE_CODES } from "./screen.mjs";
35
+
36
+ const USAGE = `Usage: node ingest.mjs <inventory|ingest|propose|approve|reject|resolve|verify|list> --root <path> [options]
37
+ See the operation table in the header. --apply mutates; everything else plans.
38
+ --json / --out <file> machine-readable output / report path`;
39
+
40
+ // Test-only deterministic fault injection: throw right after a named write
41
+ // boundary so batch transactionality can be exercised without real I/O failures.
42
+ // Never triggers unless the env var is explicitly set by a test harness.
43
+ function testFailPoint(boundary) {
44
+ if (process.env.SUPERDEV_TEST_FAIL_AFTER === boundary)
45
+ throw new IngestError("E_TEST_FAULT", `injected fault after ${boundary}`);
46
+ }
47
+
48
+ export const CLAIM_CATEGORIES = [
49
+ "goal", "user-role", "requirement", "exclusion", "constraint", "claimed-decision",
50
+ "unresolved-question", "feature", "surface-action", "api", "data-entity",
51
+ "workflow", "nfr", "risk", "external-dependency",
52
+ ];
53
+ export const EPISTEMIC_LABELS = ["Confirmed", "Strongly supported", "Inferred", "Assumed", "Unknown", "Contradicted"];
54
+
55
+ class IngestError extends Error {
56
+ constructor(code, message) {
57
+ super(message);
58
+ this.code = code;
59
+ }
60
+ }
61
+
62
+ /** Record engine, plugin-mode only. Standalone: null → mutation refused truthfully. */
63
+ async function loadRecordEngine() {
64
+ try {
65
+ const lib = await import(new URL("../../../scripts/talks/lib.mjs", import.meta.url).href);
66
+ const events = await import(new URL("../../../scripts/talks/events.mjs", import.meta.url).href);
67
+ return { lib, events };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function sha256(data) {
74
+ return crypto.createHash("sha256").update(data).digest("hex");
75
+ }
76
+
77
+ /** Content hash of a file without buffering it (used for oversize sources). */
78
+ function streamHash(abs) {
79
+ const h = crypto.createHash("sha256");
80
+ const fd = fs.openSync(abs, "r");
81
+ try {
82
+ const chunk = Buffer.alloc(1 << 16);
83
+ let n;
84
+ while ((n = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) h.update(chunk.subarray(0, n));
85
+ } finally {
86
+ fs.closeSync(fd);
87
+ }
88
+ return h.digest("hex");
89
+ }
90
+
91
+ export function normalizeKey(key) {
92
+ return String(key).normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
93
+ }
94
+
95
+ /** Every persisted user/agent-supplied field is screened before it lands in a
96
+ * record, report, index, or CLI output. Sensitive (secret/PII) shapes are
97
+ * refused with a coded error; the offending value is never echoed. */
98
+ export function assertFieldsSafe(obj, where) {
99
+ const walk = (v) => {
100
+ if (typeof v === "string") {
101
+ if (hasSensitive(v)) throw new IngestError("E_FIELD_SENSITIVE", `${where} contains a secret- or PII-shaped value; refused (never stored or echoed)`);
102
+ } else if (Array.isArray(v)) v.forEach(walk);
103
+ else if (v && typeof v === "object") {
104
+ for (const [k, val] of Object.entries(v)) {
105
+ if (REASONING_FIELD_NAMES.has(k)) throw new IngestError("E_FIELD_REASONING", `${where} contains a reasoning-shaped field "${k}"; refused`);
106
+ walk(val);
107
+ }
108
+ }
109
+ };
110
+ walk(obj);
111
+ }
112
+ const REASONING_FIELD_NAMES = new Set(["reasoning", "chainOfThought", "chain_of_thought", "thinking", "internalMonologue", "internal_monologue", "thoughts", "scratchpad", "hiddenReasoning", "privateNotes"]);
113
+
114
+ // Domain separation via JSON-encoded arrays: unambiguous, collision-free, and
115
+ // pure ASCII in source (no NUL/control separators - the shipped file stays text).
116
+ export function sourceId(relPath) {
117
+ return `SRC-${sha256(JSON.stringify(["source", relPath.split(path.sep).join("/")])).slice(0, 10)}`;
118
+ }
119
+
120
+ export function claimIdentity(category, canonicalKey) {
121
+ return { category, canonicalKey: normalizeKey(canonicalKey) };
122
+ }
123
+
124
+ export function claimId(category, canonicalKey) {
125
+ const id = claimIdentity(category, canonicalKey);
126
+ return `CLM-${sha256(JSON.stringify(["claim", id.category, id.canonicalKey])).slice(0, 10)}`;
127
+ }
128
+
129
+ export function contradictionIdentity(a, b) {
130
+ return [a, b].sort();
131
+ }
132
+
133
+ export function contradictionId(a, b) {
134
+ return `CTR-${sha256(JSON.stringify(["contradiction", ...contradictionIdentity(a, b)])).slice(0, 10)}`;
135
+ }
136
+
137
+ function confine(rootReal, rel) {
138
+ const v = String(rel);
139
+ if (path.isAbsolute(v) || /^[A-Za-z]:[\\/]/.test(v)) throw new IngestError("E_PATH_ABSOLUTE", "absolute source path rejected");
140
+ if (v.split(/[\\/]/).includes("..")) throw new IngestError("E_PATH_TRAVERSAL", "path traversal rejected");
141
+ const resolved = path.resolve(rootReal, v);
142
+ if (resolved !== rootReal && !resolved.startsWith(rootReal + path.sep)) throw new IngestError("E_PATH_OUTSIDE", "path resolves outside root");
143
+ let probe = resolved;
144
+ while (!fs.existsSync(probe)) probe = path.dirname(probe);
145
+ const real = fs.realpathSync(probe);
146
+ if (real !== rootReal && !real.startsWith(rootReal + path.sep)) throw new IngestError("E_PATH_SYMLINK", "symlink escape rejected");
147
+ return resolved;
148
+ }
149
+
150
+ // Central ID validators: exact prefix + fixed shape, no separators/traversal/
151
+ // control chars. A record id is ONLY ever a basename - never a path fragment.
152
+ const ID_SHAPES = {
153
+ SRC: /^SRC-[0-9a-f]{10}$/,
154
+ CLM: /^CLM-[0-9a-f]{10}$/,
155
+ CTR: /^CTR-[0-9a-f]{10}$/,
156
+ };
157
+ export function validateId(kind, id) {
158
+ if (typeof id !== "string" || !ID_SHAPES[kind]) throw new IngestError("E_ID_SHAPE", `invalid ${kind} id`);
159
+ if (/[\\/]|\.\.|[\u0000-\u001f\u007f]/.test(id) || !ID_SHAPES[kind].test(id))
160
+ throw new IngestError("E_ID_SHAPE", `invalid ${kind} id shape`);
161
+ return id;
162
+ }
163
+
164
+ // Project-relative directory for each record kind - the single source of truth
165
+ // for record locations. Everything is resolved from these via confine(), the
166
+ // symlink-aware primitive, so a swapped/symlinked canonical directory can never
167
+ // redirect a read or write outside the project root.
168
+ const REL_DIR = { SRC: "talks/inbox/sources", CLM: "talks/claims", CTR: "talks/contradictions" };
169
+
170
+ /** Resolve a canonical record file from a validated id, confined to its dir.
171
+ * confine() walks the real ancestor chain and rejects any symlink escape, so a
172
+ * replaced canonical directory cannot redirect the write outside the root. */
173
+ export function recordFile(rootReal, kind, id) {
174
+ validateId(kind, id);
175
+ if (!REL_DIR[kind]) throw new IngestError("E_ID_SHAPE", `unknown record kind ${kind}`);
176
+ return confine(rootReal, `${REL_DIR[kind]}/${id}.json`);
177
+ }
178
+
179
+ /** Read a canonical record; verify filename↔embedded-id↔type agreement. */
180
+ export function readRecord(rootReal, kind, id, idField) {
181
+ const file = recordFile(rootReal, kind, id);
182
+ if (!fs.existsSync(file)) return null;
183
+ let rec;
184
+ try {
185
+ rec = JSON.parse(fs.readFileSync(file, "utf8"));
186
+ } catch {
187
+ throw new IngestError("E_RECORD_TORN", `${kind} ${id} is unreadable (integrity)`);
188
+ }
189
+ if (rec?.[idField] !== id) throw new IngestError("E_RECORD_MISMATCH", `${kind} ${id} embedded id disagrees with filename (integrity)`);
190
+ return rec;
191
+ }
192
+
193
+ function readJsonDir(rootReal, rel) {
194
+ const dir = confine(rootReal, rel); // symlink-aware: never read outside the root
195
+ if (!fs.existsSync(dir)) return [];
196
+ const out = [];
197
+ for (const f of fs.readdirSync(dir).sort()) {
198
+ if (!f.endsWith(".json") || f.startsWith(".tmp-")) continue;
199
+ try {
200
+ out.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")));
201
+ } catch {
202
+ out.push({ _unreadable: f });
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ // ---- ingest: intake, screening, revision registration ----
209
+
210
+ /** Per-source revision store: one immutable exclusive-create file per revision,
211
+ * keyed by content hash - NOT a mutable array in the identity file. Concurrent
212
+ * different-content ingests write distinct files and never lose a revision. */
213
+ function revsDir(rootReal, id) {
214
+ validateId("SRC", id);
215
+ return confine(rootReal, `${REL_DIR.SRC}/${id}.revs`);
216
+ }
217
+ function processedReportFile(rootReal, id, contentHash) {
218
+ validateId("SRC", id);
219
+ if (!/^[0-9a-f]{64}$/.test(String(contentHash))) throw new IngestError("E_HASH_SHAPE", "content hash must be 64 hex chars");
220
+ return confine(rootReal, `talks/inbox/processed/${id}-${contentHash}.json`);
221
+ }
222
+
223
+ /** Derived, ordered revision list for a source. Order is deterministic across
224
+ * processes (observedAt, then contentHash). `revision` index and `status` are
225
+ * derived: "processed" iff the immutable processing report exists. */
226
+ export function sourceRevisions(rootReal, id) {
227
+ const dir = revsDir(rootReal, id);
228
+ if (!fs.existsSync(dir)) return [];
229
+ const revs = fs
230
+ .readdirSync(dir)
231
+ .filter((f) => f.endsWith(".json") && !f.startsWith(".tmp-"))
232
+ .map((f) => {
233
+ let rec;
234
+ try {
235
+ rec = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
236
+ } catch {
237
+ throw new IngestError("E_RECORD_TORN", `revision ${id}/${f} is unreadable (integrity)`);
238
+ }
239
+ // Bind the filename to the recorded content hash and validate the shape:
240
+ // a tampered/renamed revision file is an integrity failure, not silent data.
241
+ const nameHash = f.replace(/\.json$/, "");
242
+ if (!/^[0-9a-f]{64}$/.test(String(rec.contentHash)) || rec.contentHash !== nameHash)
243
+ throw new IngestError("E_RECORD_MISMATCH", `revision ${id}/${f} content hash disagrees with its filename (integrity)`);
244
+ if (typeof rec.observedAt !== "string" || typeof rec.mediaType !== "string" || typeof rec.extractable !== "boolean")
245
+ throw new IngestError("E_RECORD_MISMATCH", `revision ${id}/${f} is missing required fields (integrity)`);
246
+ return rec;
247
+ })
248
+ .sort((a, b) => (a.observedAt < b.observedAt ? -1 : a.observedAt > b.observedAt ? 1 : a.contentHash < b.contentHash ? -1 : 1));
249
+ return revs.map((r, i) => ({
250
+ ...r,
251
+ revision: `r${i}`,
252
+ status: fs.existsSync(processedReportFile(rootReal, id, r.contentHash)) ? "processed" : "registered",
253
+ }));
254
+ }
255
+
256
+ /** Revision index/status for a source id + content hash, from the revision store. */
257
+ export function revisionStatusFor(rootReal, id, contentHash) {
258
+ const srcFile = recordFile(rootReal, "SRC", id);
259
+ if (!fs.existsSync(srcFile)) return { revision: "r0", status: "new-source" };
260
+ const revisions = sourceRevisions(rootReal, id);
261
+ const existing = revisions.findIndex((r) => r.contentHash === contentHash);
262
+ if (existing !== -1)
263
+ return { revision: `r${existing}`, status: revisions[existing].status === "processed" ? "unchanged-processed" : "unchanged-unprocessed" };
264
+ return { revision: `r${revisions.length}`, status: revisions.length ? "new-revision" : "new-source" };
265
+ }
266
+
267
+ export function planIngest(rootReal, sourceRel) {
268
+ const abs = confine(rootReal, sourceRel);
269
+ const stat = fs.existsSync(abs) ? fs.statSync(abs) : null;
270
+ if (!stat || !stat.isFile()) throw new IngestError("E_SOURCE_MISSING", "source file not found");
271
+
272
+ // Enforce the size ceiling from metadata BEFORE reading the whole file:
273
+ // oversized sources are inventoried and hashed by a streamed digest, never
274
+ // fully buffered, decoded, screened, excerpted, or made extractable.
275
+ if (stat.size > MAX_SOURCE_BYTES) {
276
+ const id = sourceId(path.relative(rootReal, abs));
277
+ const contentHash = streamHash(abs);
278
+ const { revision, status } = revisionStatusFor(rootReal, id, contentHash);
279
+ return {
280
+ sourceId: id,
281
+ path: path.relative(rootReal, abs).split(path.sep).join("/"),
282
+ bytes: stat.size,
283
+ mediaType: "application/octet-stream",
284
+ contentHash,
285
+ revision,
286
+ status,
287
+ extractable: false,
288
+ intakeFindings: [{ code: "SCR-OVERSIZE", severity: "P1", detail: `exceeds ${MAX_SOURCE_BYTES} byte boundary; inventoried, never decoded or extracted` }],
289
+ screeningFindings: [],
290
+ };
291
+ }
292
+
293
+ const buf = fs.readFileSync(abs);
294
+ const contentHash = sha256(buf);
295
+ const intake = screenIntake(path.basename(abs), buf);
296
+ const extractable = !intake.some((f) => ["SCR-EXECUTABLE", "SCR-BINARY", "SCR-ENCODING", "SCR-OVERSIZE"].includes(f.code));
297
+ const screening = extractable ? screenText(buf.toString("utf8")) : [];
298
+ const id = sourceId(path.relative(rootReal, abs));
299
+ const { revision, status } = revisionStatusFor(rootReal, id, contentHash);
300
+ return {
301
+ sourceId: id,
302
+ path: path.relative(rootReal, abs).split(path.sep).join("/"),
303
+ bytes: buf.length,
304
+ mediaType: abs.endsWith(".md") ? "text/markdown" : abs.endsWith(".json") ? "application/json" : "text/plain",
305
+ contentHash,
306
+ revision,
307
+ status,
308
+ extractable,
309
+ intakeFindings: intake,
310
+ screeningFindings: screening,
311
+ };
312
+ }
313
+
314
+ export async function applyIngest(rootReal, sourceRel, suppliedBy) {
315
+ const engine = await loadRecordEngine();
316
+ if (!engine)
317
+ throw new IngestError("E_STANDALONE", "record mutation requires the Superdev plugin installation; standalone Docs can inventory and screen (dry-run) - apply from the plugin context (documented checkpoint)");
318
+ engine.lib.assertNotInterrupted(rootReal);
319
+ if (suppliedBy != null) assertFieldsSafe({ suppliedBy }, "supplied-by"); // persisted in the revision record
320
+ const plan = planIngest(rootReal, sourceRel);
321
+ if (plan.status === "unchanged-processed") return { ...plan, applied: false, noOp: true };
322
+ const { lib } = engine;
323
+ const srcFile = recordFile(rootReal, "SRC", plan.sourceId);
324
+ // Bind the SRC id to its normalized project-relative path. If the identity file
325
+ // already exists, its recorded path MUST match - a mismatch is a short-hash
326
+ // collision (two paths → one id) and is refused rather than silently adding
327
+ // revisions under the wrong source identity.
328
+ const assertSrcIdentity = () => {
329
+ const rec = readRecord(rootReal, "SRC", plan.sourceId, "sourceId");
330
+ if (rec && rec.path !== plan.path)
331
+ throw new IngestError("E_ID_COLLISION", "SRC id resolves to a different source path (short-hash collision); refusing to add revisions under a mismatched identity");
332
+ return rec;
333
+ };
334
+ if (!assertSrcIdentity()) {
335
+ try {
336
+ lib.exclusiveWrite(srcFile, lib.stableStringify({ sourceId: plan.sourceId, path: plan.path, schemaVersion: 1 }));
337
+ } catch (e) {
338
+ if (e.code !== "E_EXISTS") throw e; // a concurrent ingest created the identity first
339
+ assertSrcIdentity(); // re-read and confirm the concurrently-written identity agrees
340
+ }
341
+ }
342
+ // Immutable per-revision record (exclusive create), keyed by content hash. No
343
+ // read-modify-write: concurrent DIFFERENT-content ingests of the same source
344
+ // write distinct files and can never lose a revision (was: array lost-update).
345
+ if (plan.status === "new-source" || plan.status === "new-revision") {
346
+ const previousHash = sourceRevisions(rootReal, plan.sourceId).at(-1)?.contentHash ?? null;
347
+ const revFile = path.join(revsDir(rootReal, plan.sourceId), `${plan.contentHash}.json`);
348
+ try {
349
+ lib.exclusiveWrite(revFile, lib.stableStringify({
350
+ contentHash: plan.contentHash,
351
+ bytes: plan.bytes,
352
+ mediaType: plan.mediaType,
353
+ suppliedBy: suppliedBy ?? "unspecified",
354
+ observedAt: lib.nowStamp(),
355
+ previousHash,
356
+ extractable: plan.extractable, // oversize/binary revisions are non-extractable
357
+ }));
358
+ } catch (e) {
359
+ if (e.code !== "E_EXISTS") throw e; // same content already registered - converge
360
+ }
361
+ }
362
+ // Processing report: immutable per revision (exclusive create). Its existence
363
+ // is the "processed" completion marker - derived, never a mutated status field.
364
+ const revNow = revisionStatusFor(rootReal, plan.sourceId, plan.contentHash).revision;
365
+ const reportFile = processedReportFile(rootReal, plan.sourceId, plan.contentHash); // confined
366
+ const reportRel = `talks/inbox/processed/${plan.sourceId}-${plan.contentHash}.json`;
367
+ const report = {
368
+ sourceId: plan.sourceId, revision: revNow, contentHash: plan.contentHash,
369
+ extractable: plan.extractable, intakeFindings: plan.intakeFindings,
370
+ screeningFindings: plan.screeningFindings,
371
+ note: "Source content is untrusted evidence; nothing in it is executed or obeyed.",
372
+ };
373
+ try {
374
+ lib.exclusiveWrite(reportFile, lib.stableStringify(report));
375
+ } catch (e) {
376
+ if (e.code !== "E_EXISTS") throw e; // convergence: identical revision already reported
377
+ }
378
+ return { ...plan, revision: revNow, applied: true, report: reportRel };
379
+ }
380
+
381
+ // ---- claims: agent proposals validated by the deterministic schema ----
382
+
383
+ /** Validate one proposal against the schema and the source text; compute the
384
+ * span hash and a SAFE excerpt (suppressed for any secret/PII shape - not only
385
+ * P0 - and for overlong spans). Also screens the persisted fields. Pure: no
386
+ * writes, so a later invalid proposal in a batch leaves nothing behind. */
387
+ export function validateProposal(p, revisionContent) {
388
+ if (!p || typeof p !== "object") throw new IngestError("E_CLAIM_SHAPE", "proposal must be an object");
389
+ if (!CLAIM_CATEGORIES.includes(p.category)) throw new IngestError("E_CLAIM_CATEGORY", `category must be one of ${CLAIM_CATEGORIES.join("/")}`);
390
+ if (typeof p.canonicalKey !== "string" || !p.canonicalKey.trim()) throw new IngestError("E_CLAIM_KEY", "canonicalKey required");
391
+ if (typeof p.text !== "string" || !p.text.trim()) throw new IngestError("E_CLAIM_TEXT", "text required");
392
+ if (!EPISTEMIC_LABELS.includes(p.epistemicLabel)) throw new IngestError("E_CLAIM_LABEL", `epistemicLabel must be one of the six labels`);
393
+ if (p.epistemicLabel === "Confirmed" && (!p.verification || typeof p.verification.authority !== "string" || typeof p.verification.evidence !== "string"))
394
+ throw new IngestError("E_CLAIM_LABEL", "Confirmed requires verification { authority, evidence } - a source assertion alone is not Confirmed");
395
+ if (!p.span || !Number.isInteger(p.span.startLine) || !Number.isInteger(p.span.endLine) || p.span.startLine < 1 || p.span.endLine < p.span.startLine)
396
+ throw new IngestError("E_CLAIM_SPAN", "span { startLine, endLine } required (1-based, ordered)");
397
+ const lines = revisionContent.split("\n");
398
+ if (p.span.endLine > lines.length) throw new IngestError("E_CLAIM_SPAN", "span exceeds source length");
399
+ // Every agent-authored field that will persist is screened up front.
400
+ assertFieldsSafe({ canonicalKey: p.canonicalKey, text: p.text, verification: p.verification ?? null, extractionMethod: p.extractionMethod ?? null }, "claim proposal");
401
+ const spanText = lines.slice(p.span.startLine - 1, p.span.endLine).join("\n");
402
+ const sensitive = screenText(spanText).some((f) => SENSITIVE_CODES.has(f.code));
403
+ return {
404
+ spanSha256: sha256(spanText),
405
+ excerpt: sensitive || spanText.length > 300 ? null : spanText, // no excerpt for any secret/PII shape
406
+ };
407
+ }
408
+
409
+ // Provenance lives in per-claim exclusive-create files so concurrent additions
410
+ // from different sources never race a read-modify-write: each provenance is one
411
+ // file keyed by its span hash. Distinct spans coexist; identical spans converge.
412
+ function provDir(rootReal, claimId) {
413
+ validateId("CLM", claimId); // never trust a caller-supplied id in a path
414
+ return confine(rootReal, `${REL_DIR.CLM}/${claimId}.prov`);
415
+ }
416
+ /** The provenance file key: the full-identity hash (source + revision + content
417
+ * + span). The filename IS this key, so it can be re-derived and verified. */
418
+ function provKey(pr) {
419
+ return sha256(JSON.stringify([pr.sourceId, pr.revision, pr.contentHash, pr.spanSha256])).slice(0, 16);
420
+ }
421
+ export function readClaimProvenance(rootReal, claimId) {
422
+ const dir = provDir(rootReal, claimId);
423
+ if (!fs.existsSync(dir)) return [];
424
+ return fs
425
+ .readdirSync(dir)
426
+ .filter((f) => f.endsWith(".json") && !f.startsWith(".tmp-"))
427
+ .sort()
428
+ .map((f) => {
429
+ let rec;
430
+ try {
431
+ rec = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
432
+ } catch {
433
+ throw new IngestError("E_RECORD_TORN", `provenance ${claimId}/${f} is unreadable (integrity)`);
434
+ }
435
+ // Bind the filename to the record's full identity: a provenance file whose
436
+ // name does not match its (source,revision,content,span) key - or whose
437
+ // fields are malformed - is an integrity failure, never silently trusted.
438
+ if (typeof rec.sourceId !== "string" || typeof rec.revision !== "string" || !/^[0-9a-f]{64}$/.test(String(rec.contentHash)) || !/^[0-9a-f]{64}$/.test(String(rec.spanSha256)) || !rec.span)
439
+ throw new IngestError("E_RECORD_MISMATCH", `provenance ${claimId}/${f} is missing required identity fields (integrity)`);
440
+ if (`${provKey(rec)}.json` !== f)
441
+ throw new IngestError("E_RECORD_MISMATCH", `provenance ${claimId}/${f} disagrees with its full-identity key (integrity)`);
442
+ return rec;
443
+ });
444
+ }
445
+
446
+ function loadRevision(rootReal, revisionKey) {
447
+ const m = /^(SRC-[0-9a-f]{10}):r(\d+)$/.exec(revisionKey);
448
+ if (!m) throw new IngestError("E_REVISION", "revision must look like SRC-xxxxxxxxxx:rN");
449
+ const record = readRecord(rootReal, "SRC", m[1], "sourceId");
450
+ if (!record) throw new IngestError("E_REVISION", "unknown source");
451
+ const rev = sourceRevisions(rootReal, m[1])[Number(m[2])];
452
+ if (!rev) throw new IngestError("E_REVISION", "unknown revision");
453
+ if (rev.extractable === false)
454
+ throw new IngestError("E_REVISION_NONEXTRACTABLE", "this revision is non-extractable (oversize/binary); no claims can be extracted from it");
455
+ const raw = confine(rootReal, record.path); // confined even though it came from a record
456
+ if (!fs.existsSync(raw)) throw new IngestError("E_SOURCE_MISSING", "raw source no longer present; claims persist but no new spans can be verified");
457
+ const stat = fs.statSync(raw);
458
+ if (stat.size > MAX_SOURCE_BYTES) throw new IngestError("E_REVISION_NONEXTRACTABLE", "raw source is now oversize; not decoded for extraction");
459
+ const content = fs.readFileSync(raw);
460
+ if (sha256(content) !== rev.contentHash) throw new IngestError("E_REVISION_STALE", "raw file no longer matches this revision; ingest the current content first");
461
+ return { sourceId: m[1], revIndex: m[2], rev, text: content.toString("utf8") };
462
+ }
463
+
464
+ export async function applyProposals(rootReal, revisionKey, proposals, { apply }) {
465
+ const engine = await loadRecordEngine();
466
+ if (!engine && apply) throw new IngestError("E_STANDALONE", "record mutation requires the Superdev plugin installation");
467
+ if (apply) engine.lib.assertNotInterrupted(rootReal);
468
+ const { sourceId: srcId, revIndex, rev, text } = loadRevision(rootReal, revisionKey);
469
+
470
+ // PREFLIGHT: validate the COMPLETE batch before any mutation. Any failure
471
+ // here leaves every canonical file, event, and mtime unchanged.
472
+ const claimPlans = [];
473
+ for (const p of Array.isArray(proposals?.claims) ? proposals.claims : []) {
474
+ const { spanSha256, excerpt } = validateProposal(p, text);
475
+ const id = claimId(p.category, p.canonicalKey);
476
+ // Collision binding: a matching short hash must agree on full identity.
477
+ const existing = readRecord(rootReal, "CLM", id, "claimId");
478
+ if (existing && (existing.category !== p.category || existing.canonicalKey !== normalizeKey(p.canonicalKey)))
479
+ throw new IngestError("E_ID_COLLISION", "deterministic claim id collided with a different category/key");
480
+ claimPlans.push({ p, id, existing, provenance: { sourceId: srcId, revision: `r${revIndex}`, contentHash: rev.contentHash, span: p.span, spanSha256, excerpt } });
481
+ }
482
+ const ctrPlans = [];
483
+ for (const c of Array.isArray(proposals?.contradictions) ? proposals.contradictions : []) {
484
+ if (typeof c.claimA !== "string" || typeof c.claimB !== "string") throw new IngestError("E_CTR_SHAPE", "contradiction proposal needs claimA/claimB ids");
485
+ for (const cid of [c.claimA, c.claimB]) {
486
+ validateId("CLM", cid);
487
+ if (!fs.existsSync(recordFile(rootReal, "CLM", cid)) && !claimPlans.some((cp) => cp.id === cid))
488
+ throw new IngestError("E_CTR_UNKNOWN", "contradiction references an unknown claim");
489
+ }
490
+ assertFieldsSafe({ kind: c.kind ?? null, downstreamImpact: c.downstreamImpact ?? null }, "contradiction proposal");
491
+ ctrPlans.push({ id: contradictionId(c.claimA, c.claimB), kind: c.kind ?? "source-vs-source", sides: [{ claimId: c.claimA }, { claimId: c.claimB }], severity: c.severity ?? "P1", downstreamImpact: c.downstreamImpact ?? "" });
492
+ }
493
+
494
+ // DRY-RUN: report the plan without mutation.
495
+ if (!apply) {
496
+ const results = claimPlans.map(({ id, existing }) => ({ claimId: id, action: existing ? "unchanged-or-merge" : "create" }));
497
+ return { applied: false, revision: revisionKey, claims: results, contradictions: ctrPlans.map((c) => c.id) };
498
+ }
499
+
500
+ // ---- Batch transaction: reserve → commit every write (each exclusive-create,
501
+ // so replays and concurrent same-batch runs converge) → completion marker.
502
+ // A re-run whose completion marker exists returns the recorded result (idempotent);
503
+ // an interrupted batch has NO completion marker, so a partial can never look
504
+ // complete, and re-running converges. If THIS call owns the reservation and a
505
+ // write faults, it rolls back only the files it created (pristine on failure). ----
506
+ // The apply loop is itself idempotent (every write is an exclusive-create that
507
+ // converges on E_EXISTS), so we always re-evaluate; the completion marker is a
508
+ // completeness SIGNAL (partial batches lack it), not a result cache.
509
+ const marks = batchMarkers(rootReal, engine, revisionKey, proposals);
510
+ const created = []; // files THIS call exclusive-created (rollback set)
511
+ const exCreate = (file, content) => {
512
+ try { engine.lib.exclusiveWrite(file, content); created.push(file); return true; }
513
+ catch (e) { if (e.code !== "E_EXISTS") throw e; return false; } // converge on a concurrent/prior identical write
514
+ };
515
+ let ownsReservation = false;
516
+ try {
517
+ ownsReservation = exCreate(marks.reservation, engine.lib.stableStringify({ batchId: marks.batchId, revisionKey, at: engine.lib.nowStamp(), claims: claimPlans.map((c) => c.id) }));
518
+
519
+ const results = [];
520
+ const persistedCtrs = [];
521
+ const autoContradictions = [];
522
+ for (const { p, id, existing, provenance } of claimPlans) {
523
+ const key = `${provKey(provenance)}.json`;
524
+ if (existing) {
525
+ if (normalizeKey(existing.text) !== normalizeKey(p.text))
526
+ autoContradictions.push({ id: contradictionId(id, `${id}@${provenance.spanSha256.slice(0, 6)}`), kind: "source-vs-source", claimId: id, sides: [{ text: existing.text }, { text: p.text }] });
527
+ const wrote = exCreate(path.join(provDir(rootReal, id), key), engine.lib.stableStringify(provenance));
528
+ results.push({ claimId: id, action: wrote ? "merge-provenance" : "unchanged" });
529
+ } else {
530
+ results.push({ claimId: id, action: "create" });
531
+ const claim = {
532
+ claimId: id, category: p.category, canonicalKey: normalizeKey(p.canonicalKey), text: p.text,
533
+ epistemicLabel: p.epistemicLabel, verification: p.verification ?? null, status: "draft",
534
+ extractionMethod: p.extractionMethod ?? "agent-proposed", loadBearing: Boolean(p.loadBearing),
535
+ publicId: null, related: [], history: [{ at: engine.lib.nowStamp(), action: "created", from: `${srcId}:r${revIndex}` }],
536
+ };
537
+ const won = exCreate(recordFile(rootReal, "CLM", id), engine.lib.stableStringify(claim));
538
+ testFailPoint("claim");
539
+ // Lost the create race (concurrent or in-batch peer): the winner's text may differ → contradiction.
540
+ if (!won) {
541
+ const winner = readRecord(rootReal, "CLM", id, "claimId");
542
+ if (winner && normalizeKey(winner.text) !== normalizeKey(p.text))
543
+ autoContradictions.push({ id: contradictionId(id, `${id}@${provenance.spanSha256.slice(0, 6)}`), kind: "source-vs-source", claimId: id, sides: [{ text: winner.text }, { text: p.text }] });
544
+ }
545
+ exCreate(path.join(provDir(rootReal, id), key), engine.lib.stableStringify(provenance));
546
+ testFailPoint("provenance");
547
+ if (writeDraftArtifact(rootReal, engine, { claimId: id, category: p.category, text: p.text, epistemicLabel: p.epistemicLabel, provenance })) created.push(confine(rootReal, `talks/inbox/drafts/${id}.md`));
548
+ testFailPoint("draft");
549
+ }
550
+ }
551
+ for (const c of [...autoContradictions, ...ctrPlans]) {
552
+ persistedCtrs.push(c.id);
553
+ const ctrRecord = {
554
+ contradictionId: c.id, kind: c.kind, claimId: c.claimId ?? null, sides: c.sides,
555
+ severity: c.severity ?? "P1", authorityClass: null, downstreamImpact: c.downstreamImpact ?? "",
556
+ status: "open", ownerDecisionNeeded: true, resolution: null,
557
+ history: [{ at: engine.lib.nowStamp(), action: "opened" }],
558
+ };
559
+ const wrote = exCreate(recordFile(rootReal, "CTR", c.id), engine.lib.stableStringify(ctrRecord));
560
+ if (!wrote) {
561
+ // Converge ONLY if the existing record has the same canonical identity
562
+ // (kind + sides + claimId). A different identity under the same id is a
563
+ // short-hash collision and is refused - never silently reused.
564
+ const existing = readRecord(rootReal, "CTR", c.id, "contradictionId");
565
+ if (existing && (existing.kind !== ctrRecord.kind || engine.lib.stableStringify(existing.sides) !== engine.lib.stableStringify(ctrRecord.sides) || (existing.claimId ?? null) !== (ctrRecord.claimId ?? null)))
566
+ throw new IngestError("E_ID_COLLISION", "contradiction id resolves to a different contradiction (kind/sides mismatch); refusing to reuse the existing record");
567
+ }
568
+ testFailPoint("contradiction");
569
+ }
570
+
571
+ const result = { applied: true, revision: revisionKey, claims: results, contradictions: persistedCtrs };
572
+ // Completion marker last: its presence is the ONLY signal the batch is complete.
573
+ try { engine.lib.exclusiveWrite(marks.done, engine.lib.stableStringify({ batchId: marks.batchId, at: engine.lib.nowStamp(), result })); }
574
+ catch (e) { if (e.code !== "E_EXISTS") throw e; } // a concurrent same-batch runner completed first - converge
575
+ return result;
576
+ } catch (err) {
577
+ // Owned-failure rollback: remove only the files THIS call created, leaving the
578
+ // canonical state pristine. A crash (no catch) instead leaves valid, idempotent
579
+ // partial records with no completion marker - re-running converges.
580
+ if (ownsReservation) for (const f of created.reverse()) { try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } }
581
+ throw err;
582
+ }
583
+ }
584
+
585
+ // Category → accepted public-id prefix.
586
+ const CATEGORY_PREFIX = {
587
+ goal: "REQ", "user-role": "REQ", requirement: "REQ", exclusion: "REQ", constraint: "REQ",
588
+ "claimed-decision": "REQ", "unresolved-question": "Q", feature: "FEAT", "surface-action": "SURF",
589
+ api: "API", "data-entity": "DATA", workflow: "WF", nfr: "REQ", risk: "REQ", "external-dependency": "REQ",
590
+ };
591
+
592
+ /** Provenance-linked DRAFT artifact - a distinct file from raw source, evidence
593
+ * claim, and (later) accepted artifact. Marked draft; non-authoritative. */
594
+ function writeDraftArtifact(rootReal, engine, { claimId, category, text, epistemicLabel, provenance }) {
595
+ validateId("CLM", claimId);
596
+ const rel = `talks/inbox/drafts/${claimId}.md`;
597
+ const body = [
598
+ "<!-- DRAFT - non-authoritative until approved. Generated from evidence claim; do not treat as accepted. -->",
599
+ `# Draft: ${category}`,
600
+ "",
601
+ `- **Claim:** ${claimId}`,
602
+ `- **Epistemic label:** ${epistemicLabel}`,
603
+ `- **Source:** ${provenance.sourceId} ${provenance.revision} lines ${provenance.span.startLine}-${provenance.span.endLine}`,
604
+ "",
605
+ "## Statement",
606
+ "",
607
+ text,
608
+ "",
609
+ "> Complete the appropriate first-party Docs template on approval; this stub cites the evidence only.",
610
+ "",
611
+ ].join("\n");
612
+ try {
613
+ engine.lib.exclusiveWrite(confine(rootReal, rel), body);
614
+ return true; // created
615
+ } catch (e) {
616
+ if (e.code !== "E_EXISTS") throw e; // draft already generated - idempotent
617
+ return false;
618
+ }
619
+ }
620
+
621
+ // ---- approval, rejection, resolution ----
622
+
623
+ /** Provenance freshness for ONE claim: every span still hash-verifies against
624
+ * a present, unchanged source revision. Returns findings (empty = fresh). */
625
+ export function verifyClaim(rootReal, claimId) {
626
+ const findings = [];
627
+ const prov = readClaimProvenance(rootReal, claimId);
628
+ if (!prov.length) findings.push({ code: "ING-NO-PROVENANCE", claimId });
629
+ for (const pr of prov) {
630
+ const src = readRecord(rootReal, "SRC", pr.sourceId, "sourceId");
631
+ if (!src) {
632
+ findings.push({ code: "ING-NO-SOURCE", claimId, detail: pr.sourceId });
633
+ continue;
634
+ }
635
+ const raw = confine(rootReal, src.path);
636
+ if (!fs.existsSync(raw)) {
637
+ findings.push({ code: "ING-SOURCE-GONE", claimId, detail: "raw file removed; claim persists, span unverifiable" });
638
+ continue;
639
+ }
640
+ const content = fs.readFileSync(raw);
641
+ if (sha256(content) !== pr.contentHash) {
642
+ findings.push({ code: "ING-STALE", claimId, detail: "source changed since this revision" });
643
+ continue;
644
+ }
645
+ const spanText = content.toString("utf8").split("\n").slice(pr.span.startLine - 1, pr.span.endLine).join("\n");
646
+ if (sha256(spanText) !== pr.spanSha256) findings.push({ code: "ING-SPAN-MISMATCH", claimId, detail: "span hash mismatch" });
647
+ }
648
+ return findings;
649
+ }
650
+
651
+ export async function decideClaim(rootReal, id, decision, approver, reason, { apply }) {
652
+ const engine = await loadRecordEngine();
653
+ if (!engine && apply) throw new IngestError("E_STANDALONE", "record mutation requires the Superdev plugin installation");
654
+ if (apply) engine.lib.assertNotInterrupted(rootReal);
655
+ validateId("CLM", id);
656
+ const claim = readRecord(rootReal, "CLM", id, "claimId");
657
+ if (!claim) throw new IngestError("E_CLAIM_UNKNOWN", "no such claim");
658
+ if (!["approved", "rejected"].includes(decision)) throw new IngestError("E_CLAIM_TRANSITION", "decision must be approved or rejected");
659
+ if (!approver) throw new IngestError("E_APPROVER", "an approver is required");
660
+ assertFieldsSafe({ approver, reason: reason ?? null }, "decision"); // approver + reason are persisted
661
+ // Idempotent + no silent overwrite of a prior owner decision. Recovery: a prior
662
+ // decision may have written the claim state but crashed before appending the
663
+ // audit event (state write precedes event append). Re-ensure the event here -
664
+ // appendIngestEvent is deterministic + existence-checked, so this is exactly-once.
665
+ if (claim.status === decision) {
666
+ // Recovery: re-ensure the audit event from the CANONICAL claim record, so the
667
+ // payload is byte-identical to the original write (keyed on recorded history,
668
+ // not this call's approver) and converges exactly-once.
669
+ if (apply) await appendIngestEvent(rootReal, engine, decisionEvent(id, claim, decision));
670
+ return { claimId: id, status: decision, applied: false, idempotent: true };
671
+ }
672
+ if (["approved", "rejected"].includes(claim.status))
673
+ throw new IngestError("E_CLAIM_TRANSITION", `claim already ${claim.status}; a new owner decision must be an explicit re-open, not a silent overwrite`);
674
+
675
+ if (decision === "approved") {
676
+ if (claim.loadBearing && ["Contradicted", "Unknown"].includes(claim.epistemicLabel))
677
+ throw new IngestError("E_CLAIM_BLOCKED", "a load-bearing Contradicted/Unknown claim cannot be approved into accepted fact");
678
+ const contradictions = readJsonDir(rootReal, REL_DIR.CTR);
679
+ // Fail closed: a torn/unparseable contradiction file could be an open
680
+ // contradiction for this claim. We cannot prove it isn't → refuse approval.
681
+ const unreadable = contradictions.filter((c) => c._unreadable);
682
+ if (unreadable.length)
683
+ throw new IngestError("E_CLAIM_BLOCKED", `cannot verify contradiction status: unreadable record(s) ${unreadable.map((c) => c._unreadable).join(", ")}; refusing approval until repaired`);
684
+ const open = contradictions.filter(
685
+ (c) => c.status === "open" && (c.claimId === id || (c.sides ?? []).some((s) => s.claimId === id))
686
+ );
687
+ if (open.length) throw new IngestError("E_CLAIM_BLOCKED", "claim has open contradictions; resolve them first");
688
+ // Integrity + provenance freshness: no promotion on stale/unverifiable evidence.
689
+ const stale = verifyClaim(rootReal, id);
690
+ if (stale.length) throw new IngestError("E_CLAIM_STALE", `claim evidence is stale or unverifiable (${stale.map((f) => f.code).join(",")}); re-ingest before approval`);
691
+ }
692
+ if (!apply) return { plan: decision, claimId: id, applied: false };
693
+
694
+ // ---- Reserve the terminal decision: an atomic compare-and-swap via exclusive
695
+ // create. The FIRST writer to create the marker commits the claim's terminal
696
+ // decision. A concurrent CONFLICTING decision (approve vs reject) sees the
697
+ // marker and is refused - no last-writer-wins, no orphan accepted artifact, no
698
+ // conflicting events. The SAME decision (or a crash-recovery re-run) converges
699
+ // and completes the remaining writes idempotently. ----
700
+ const marker = decisionMarkerFile(rootReal, id);
701
+ const reservation = { claimId: id, decision, approver, reason: reason ?? null, at: engine.lib.nowStamp() };
702
+ let reserved = true, committed = reservation;
703
+ try {
704
+ engine.lib.exclusiveWrite(marker, engine.lib.stableStringify(reservation));
705
+ } catch (e) {
706
+ if (e.code !== "E_EXISTS") throw e;
707
+ reserved = false;
708
+ try { committed = JSON.parse(fs.readFileSync(marker, "utf8")); }
709
+ catch { throw new IngestError("E_RECORD_TORN", "decision reservation is unreadable (integrity)"); }
710
+ if (committed.decision !== decision)
711
+ throw new IngestError("E_CLAIM_TRANSITION", `claim already committed to "${committed.decision}"; a conflicting "${decision}" is refused`);
712
+ // same decision already committed by a concurrent/prior writer → complete idempotently.
713
+ }
714
+
715
+ // ---- Complete the committed decision (idempotent; safe to re-run after a crash).
716
+ const fresh = readRecord(rootReal, "CLM", id, "claimId");
717
+ if (decision === "rejected") {
718
+ if (fresh.status !== "rejected") {
719
+ fresh.status = "rejected";
720
+ fresh.history.push({ at: engine.lib.nowStamp(), action: "rejected", approver: committed.approver, reason: committed.reason ?? null });
721
+ engine.lib.atomicWrite(recordFile(rootReal, "CLM", id), engine.lib.stableStringify(fresh));
722
+ }
723
+ await appendIngestEvent(rootReal, engine, decisionEvent(id, readRecord(rootReal, "CLM", id, "claimId"), "rejected"));
724
+ return { claimId: id, status: "rejected", applied: reserved, ...(reserved ? {} : { idempotent: true }) };
725
+ }
726
+
727
+ // Approve → mint/bind the accepted public id (deterministic → recovery re-mints
728
+ // the SAME id) and promote. Fold the category into the mint key so distinct
729
+ // claims sharing a canonical key across categories never alias one public id.
730
+ const prefix = CATEGORY_PREFIX[fresh.category] ?? "REQ";
731
+ const publicId = await mintPublicId(rootReal, engine, prefix, JSON.stringify(["public", fresh.category, fresh.canonicalKey]));
732
+ const acceptedRel = `talks/project/accepted/${publicId}.md`;
733
+ const accepted = [
734
+ `<!-- Accepted artifact. Promoted from evidence claim ${id} on owner approval. -->`,
735
+ `# ${publicId}: ${fresh.category}`,
736
+ "",
737
+ `- **Status:** accepted`,
738
+ `- **Evidence claim:** ${id} (${fresh.epistemicLabel})`,
739
+ `- **Approver:** ${committed.approver}`,
740
+ "",
741
+ "## Statement",
742
+ "",
743
+ fresh.text,
744
+ "",
745
+ ].join("\n");
746
+ // Promote (idempotent overwrite of the derived accepted file is fine).
747
+ engine.lib.atomicWrite(confine(rootReal, acceptedRel), accepted);
748
+ if (fresh.status !== "approved") {
749
+ fresh.status = "approved";
750
+ fresh.publicId = publicId;
751
+ fresh.history.push({ at: engine.lib.nowStamp(), action: "approved", approver: committed.approver, publicId });
752
+ engine.lib.atomicWrite(recordFile(rootReal, "CLM", id), engine.lib.stableStringify(fresh));
753
+ }
754
+ await appendIngestEvent(rootReal, engine, decisionEvent(id, readRecord(rootReal, "CLM", id, "claimId"), "approved"));
755
+ return { claimId: id, status: "approved", publicId, acceptedArtifact: acceptedRel, applied: reserved, ...(reserved ? {} : { idempotent: true }) };
756
+ }
757
+
758
+ /** The claim's terminal-decision reservation marker (CAS record). Named without a
759
+ * .json extension so it is not itself listed as a claim; confined like all paths. */
760
+ function decisionMarkerFile(rootReal, claimId) {
761
+ validateId("CLM", claimId);
762
+ return confine(rootReal, `${REL_DIR.CLM}/${claimId}.decision`);
763
+ }
764
+
765
+ // Proposal-batch reservation (.batch) and completion (.done) markers, keyed by a
766
+ // deterministic hash of the revision + the exact proposals - so a re-run of the
767
+ // SAME batch converges to the SAME markers.
768
+ function batchMarkers(rootReal, engine, revisionKey, proposals) {
769
+ const batchId = sha256(JSON.stringify(["ingest-batch", revisionKey, engine.lib.stableStringify(proposals)])).slice(0, 16);
770
+ return {
771
+ batchId,
772
+ reservation: confine(rootReal, `talks/inbox/batches/${batchId}.batch`),
773
+ done: confine(rootReal, `talks/inbox/batches/${batchId}.done`),
774
+ };
775
+ }
776
+
777
+ async function mintPublicId(rootReal, engine, prefix, canonicalKey) {
778
+ const idmod = await import(new URL("../../../scripts/talks/id.mjs", import.meta.url).href);
779
+ return idmod.mintId(rootReal, prefix, canonicalKey, { apply: true }).id;
780
+ }
781
+
782
+ /** Append a lifecycle event with a DETERMINISTIC id keyed by the decision, and
783
+ * skip if it already exists. This makes the append idempotent so a crash between
784
+ * the claim-state write and the event append is recoverable: re-running the
785
+ * decision re-appends the (still-missing) event exactly once, never a duplicate. */
786
+ /** Build a decision's lifecycle-event payload from the CANONICAL claim record so
787
+ * the normal write and any later recovery re-run produce a byte-identical event
788
+ * (actor and rationale come from recorded history, not the live call). */
789
+ function decisionEvent(id, claim, decision) {
790
+ const entry = [...claim.history].reverse().find((h) => h.action === decision);
791
+ const actor = `superdev/ingest:${entry?.approver ?? "owner"}`;
792
+ if (decision === "approved")
793
+ return { eventKey: ["approve", id, claim.publicId], files: [`talks/claims/${id}.json`, `talks/project/accepted/${claim.publicId}.md`], summary: `Approve claim ${id} → ${claim.publicId}`, rationale: "owner approved; evidence fresh and uncontradicted", actor };
794
+ return { eventKey: ["reject", id], files: [`talks/claims/${id}.json`], summary: `Reject claim ${id}`, rationale: entry?.reason ?? "owner rejected", actor };
795
+ }
796
+
797
+ async function appendIngestEvent(rootReal, engine, { eventKey, summary, files, rationale, actor }) {
798
+ const eventId = `CHG-ing-${sha256(JSON.stringify(["ingest-event", ...eventKey])).slice(0, 12)}`;
799
+ // Exactly-once: exclusive-create of the exact deterministic id; a concurrent or
800
+ // recovery re-run converges (alreadyRecorded) instead of minting a suffixed
801
+ // duplicate. The event payload is derived only from the decision identity (the
802
+ // eventKey) and stable recorded fields - never a transient/live approver - so
803
+ // re-runs produce an identical payload and never trip an E_EVENT_COLLISION.
804
+ return engine.events.appendDeterministicEvent(rootReal, {
805
+ eventId,
806
+ actor,
807
+ requestSummary: summary,
808
+ scope: "ingestion lifecycle",
809
+ filesChanged: files,
810
+ observableRationale: rationale,
811
+ validationResults: "provenance freshness + contradiction gates enforced at approval",
812
+ sessionId: "S-ingest",
813
+ });
814
+ }
815
+
816
+ export async function resolveContradiction(rootReal, id, authorityClass, evidence, { apply }) {
817
+ const engine = await loadRecordEngine();
818
+ if (!engine && apply) throw new IngestError("E_STANDALONE", "record mutation requires the Superdev plugin installation");
819
+ if (apply) engine.lib.assertNotInterrupted(rootReal);
820
+ validateId("CTR", id);
821
+ const ctr = readRecord(rootReal, "CTR", id, "contradictionId");
822
+ if (!ctr) throw new IngestError("E_CTR_UNKNOWN", "no such contradiction");
823
+ if (!authorityClass || !evidence) throw new IngestError("E_CTR_RESOLUTION", "resolution requires an authority class and observable evidence");
824
+ assertFieldsSafe({ authorityClass, evidence }, "contradiction resolution");
825
+ const resolveEvent = (ctrRec) => ({ eventKey: ["resolve", id], summary: `Resolve contradiction ${id}`, files: [`talks/contradictions/${id}.json`], rationale: `resolved via ${ctrRec.resolution.authorityClass}`, actor: "superdev/ingest:owner" });
826
+ if (ctr.status === "resolved") {
827
+ if (apply) // recovery: re-ensure the (possibly-missing) audit event, exactly once, from the recorded resolution
828
+ await appendIngestEvent(rootReal, engine, resolveEvent(ctr));
829
+ return { contradictionId: id, status: "resolved", applied: false, idempotent: true };
830
+ }
831
+ if (!apply) return { plan: "resolve", contradictionId: id, applied: false };
832
+ ctr.status = "resolved";
833
+ ctr.resolution = { authorityClass, evidence, at: engine.lib.nowStamp() };
834
+ ctr.history.push({ at: engine.lib.nowStamp(), action: "resolved", authorityClass });
835
+ engine.lib.atomicWrite(recordFile(rootReal, "CTR", id), engine.lib.stableStringify(ctr)); // history preserved; never deleted
836
+ await appendIngestEvent(rootReal, engine, resolveEvent(ctr));
837
+ return { contradictionId: id, status: "resolved", applied: true };
838
+ }
839
+
840
+ /** Re-verify every stored provenance span across all claims. */
841
+ export function verifyProvenance(rootReal) {
842
+ const findings = [];
843
+ const dir = confine(rootReal, REL_DIR.CLM);
844
+ const ids = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".tmp-")).map((f) => f.replace(/\.json$/, "")) : [];
845
+ for (const id of ids) {
846
+ try {
847
+ findings.push(...verifyClaim(rootReal, id));
848
+ } catch (e) {
849
+ findings.push({ code: e.code ?? "ING-ERR", claimId: id, detail: e.message });
850
+ }
851
+ }
852
+ return { findings };
853
+ }
854
+
855
+ // ---- CLI ----
856
+
857
+ function writeReportLocal(outPath, report) {
858
+ if (!outPath) return;
859
+ const dir = path.dirname(path.resolve(outPath));
860
+ const tmp = path.join(dir, `.tmp-report-${process.pid}`);
861
+ fs.mkdirSync(dir, { recursive: true });
862
+ fs.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n");
863
+ fs.renameSync(tmp, path.resolve(outPath));
864
+ }
865
+
866
+ async function main() {
867
+ let args, positionals;
868
+ try {
869
+ const parsed = parseArgs({
870
+ options: {
871
+ root: { type: "string", default: "." },
872
+ source: { type: "string" },
873
+ revision: { type: "string" },
874
+ proposals: { type: "string" },
875
+ id: { type: "string" },
876
+ approver: { type: "string" },
877
+ reason: { type: "string" },
878
+ authority: { type: "string" },
879
+ evidence: { type: "string" },
880
+ "supplied-by": { type: "string" },
881
+ kind: { type: "string" },
882
+ apply: { type: "boolean", default: false },
883
+ json: { type: "boolean", default: false },
884
+ out: { type: "string" },
885
+ help: { type: "boolean", default: false },
886
+ },
887
+ allowPositionals: true,
888
+ });
889
+ args = parsed.values;
890
+ positionals = parsed.positionals;
891
+ } catch (err) {
892
+ console.error(String(err.message ?? err));
893
+ console.error(USAGE);
894
+ process.exit(2);
895
+ }
896
+ if (args.help) {
897
+ console.log(USAGE);
898
+ process.exit(0);
899
+ }
900
+ const op = positionals[0];
901
+ const OPS = ["inventory", "ingest", "propose", "approve", "reject", "resolve", "verify", "list"];
902
+ if (!OPS.includes(op)) {
903
+ console.error(`unknown operation: ${op ?? "(none)"}`);
904
+ console.error(USAGE);
905
+ process.exit(2);
906
+ }
907
+ if (!fs.existsSync(args.root) || !fs.statSync(args.root).isDirectory()) {
908
+ console.error(`root is not a directory: ${args.root}`);
909
+ console.error(USAGE);
910
+ process.exit(2);
911
+ }
912
+ const rootReal = fs.realpathSync(args.root);
913
+ try {
914
+ let report;
915
+ let failed = false;
916
+ if (op === "inventory") {
917
+ const rawDir = confine(rootReal, "talks/inbox/raw");
918
+ const raw = fs.existsSync(rawDir) ? fs.readdirSync(rawDir).filter((f) => !f.startsWith(".")).sort() : [];
919
+ report = { raw, sources: readJsonDir(rootReal, REL_DIR.SRC).filter((s) => s.sourceId).map((s) => {
920
+ const revs = sourceRevisions(rootReal, s.sourceId);
921
+ return { sourceId: s.sourceId, path: s.path, revisions: revs.length, latestStatus: revs.at(-1)?.status ?? null };
922
+ }) };
923
+ } else if (op === "ingest") {
924
+ if (!args.source) throw new IngestError("E_ARGS", "ingest requires --source <project-relative path>");
925
+ report = args.apply ? await applyIngest(rootReal, args.source, args["supplied-by"]) : { mode: "plan", ...planIngest(rootReal, args.source) };
926
+ failed = report.intakeFindings?.some((f) => f.severity === "P0") ?? false;
927
+ } else if (op === "propose") {
928
+ if (!args.revision || !args.proposals) throw new IngestError("E_ARGS", "propose requires --revision and --proposals");
929
+ let rawIn;
930
+ try {
931
+ rawIn = args.proposals === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(args.proposals, "utf8");
932
+ } catch (e) {
933
+ // File-not-found / unreadable is a usage error (exit 2), distinct from
934
+ // a malformed-JSON body (exit 1). Never conflate the two.
935
+ throw new IngestError("E_ARGS", `cannot read proposals file (${e.code ?? "read error"})`);
936
+ }
937
+ let payload;
938
+ try {
939
+ payload = JSON.parse(rawIn);
940
+ } catch {
941
+ throw new IngestError("E_PROPOSAL_PARSE", "proposals payload is not valid JSON"); // content never echoed
942
+ }
943
+ report = await applyProposals(rootReal, args.revision, payload, { apply: args.apply });
944
+ } else if (op === "approve" || op === "reject") {
945
+ if (!args.id) throw new IngestError("E_ARGS", `${op} requires --id`);
946
+ report = await decideClaim(rootReal, args.id, op === "approve" ? "approved" : "rejected", args.approver, args.reason, { apply: args.apply });
947
+ } else if (op === "resolve") {
948
+ if (!args.id) throw new IngestError("E_ARGS", "resolve requires --id");
949
+ report = await resolveContradiction(rootReal, args.id, args.authority, args.evidence, { apply: args.apply });
950
+ } else if (op === "verify") {
951
+ report = verifyProvenance(rootReal);
952
+ failed = report.findings.length > 0;
953
+ } else {
954
+ const kind = args.kind ?? "claims";
955
+ if (!["claims", "contradictions"].includes(kind)) throw new IngestError("E_ARGS", "--kind must be claims or contradictions");
956
+ const items = readJsonDir(rootReal, kind === "claims" ? REL_DIR.CLM : REL_DIR.CTR);
957
+ report = { count: items.length, [kind]: items.map((i) => i._unreadable ? { unreadable: i._unreadable } : kind === "claims" ? { claimId: i.claimId, category: i.category, epistemicLabel: i.epistemicLabel, status: i.status, sources: readClaimProvenance(rootReal, i.claimId).length } : { contradictionId: i.contradictionId, status: i.status, severity: i.severity }) };
958
+ }
959
+ console.log(args.json ? JSON.stringify(report, null, 2) : humanizeReport(op, report));
960
+ writeReportLocal(args.out, report);
961
+ process.exit(failed ? 1 : 0);
962
+ } catch (e) {
963
+ if (e instanceof IngestError || e?.code?.startsWith?.("E_")) {
964
+ console.error(`[${e.code}] ${e.message}`);
965
+ // Usage errors (missing/unreadable args) exit 2; refusals/failures exit 1.
966
+ process.exit(e.code === "E_ARGS" ? 2 : 1);
967
+ }
968
+ throw e;
969
+ }
970
+ }
971
+
972
+ /** Terse human-readable summary (default output); --json emits the full record. */
973
+ function humanizeReport(op, report) {
974
+ const scalar = (v) => v === null || ["string", "number", "boolean"].includes(typeof v);
975
+ const parts = [];
976
+ for (const [k, v] of Object.entries(report)) {
977
+ if (scalar(v)) parts.push(`${k}=${v}`);
978
+ else if (Array.isArray(v)) parts.push(`${k}[${v.length}]`);
979
+ else if (v && typeof v === "object") parts.push(`${k}{${Object.keys(v).length}}`);
980
+ }
981
+ return `${op}: ${parts.join(" ")}`;
982
+ }
983
+
984
+ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) main();