superdev-cli 0.1.0 → 0.1.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +7 -2
- package/package.json +2 -2
- package/scripts/validate/skill-commands.mjs +71 -1
- package/skills/docs/SKILL.md +1 -1
- package/skills/docs/references/change-tracking.md +9 -1
- package/skills/docs/references/ingestion.md +7 -2
- package/src/cli.mjs +28 -5
- package/skills/docs/scripts/ingest.mjs +0 -984
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Superdev - tell it what you want to build, and it keeps the product map, documentation, decisions and status in step while specialist providers do the specialist work.",
|
|
8
|
-
"version": "0.1.
|
|
8
|
+
"version": "0.1.1",
|
|
9
9
|
"homepage": "https://github.com/superdev-ai/superdev"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "superdev",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence - plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
|
|
16
|
-
"version": "0.1.
|
|
16
|
+
"version": "0.1.1",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Rahul Retnan"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard. Routes specialist work to external providers and reports their readiness truthfully.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rahul Retnan"
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
"homepage": "https://github.com/superdev-ai/superdev",
|
|
17
17
|
"repository": "https://github.com/superdev-ai/superdev",
|
|
18
18
|
"requires": {
|
|
19
|
-
"cli": "0.1.
|
|
19
|
+
"cli": "0.1.1"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Describe a product in ordinary language and get a living map of goals, features, workflows, tasks, integrations, UI surfaces, decisions and evidence, plus a self-contained visual dashboard.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rahul Retnan"
|
|
@@ -22,6 +22,6 @@
|
|
|
22
22
|
"homepage": "https://github.com/superdev-ai/superdev",
|
|
23
23
|
"repository": "https://github.com/superdev-ai/superdev",
|
|
24
24
|
"requires": {
|
|
25
|
-
"cli": "0.1.
|
|
25
|
+
"cli": "0.1.1"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/README.md
CHANGED
|
@@ -141,8 +141,13 @@ claude plugin marketplace add superdev-ai/superdev
|
|
|
141
141
|
claude plugin install superdev@superdev
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
The plugin
|
|
145
|
-
|
|
144
|
+
The plugin calls the `superdev` from step 1. Nothing in it is compiled and it
|
|
145
|
+
installs no dependency of its own: its hooks and skills invoke the command, not
|
|
146
|
+
code inside the plugin.
|
|
147
|
+
|
|
148
|
+
It is distributed as this repository, so the copy your editor caches also
|
|
149
|
+
contains the CLI source it does not use. That is dead weight rather than a second
|
|
150
|
+
installation, and the version that runs is always the one npm installed.
|
|
146
151
|
|
|
147
152
|
To try it without installing permanently:
|
|
148
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdev-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local-first product-building control system for humans and coding agents.",
|
|
6
6
|
"author": "Rahul Retnan",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"release:patch": "npm run release:tooling && npx release-it patch",
|
|
57
57
|
"release:minor": "npm run release:tooling && npx release-it minor",
|
|
58
58
|
"release:major": "npm run release:tooling && npx release-it major",
|
|
59
|
-
"release:dry": "npm run release:tooling && npx release-it --dry-run",
|
|
59
|
+
"release:dry": "npm run release:tooling && npx release-it --dry-run; git checkout -- package.json package-lock.json 2>/dev/null; true",
|
|
60
60
|
"version:sync": "node scripts/package/sync-version.mjs --apply",
|
|
61
61
|
"check": "npm test && node scripts/validate/validate-all.mjs && node src/cli.mjs doctor && node scripts/check/release-criteria.mjs",
|
|
62
62
|
"release:tooling": "npm install --no-save --silent release-it@19 @release-it/conventional-changelog@10",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// about it.
|
|
16
16
|
|
|
17
17
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
18
|
-
import { join } from "node:path";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { ERROR, WARNING, finding, rel } from "./common.mjs";
|
|
21
21
|
|
|
@@ -175,6 +175,16 @@ export async function run(root) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// A skill also tells an agent to run scripts by path, and a path is a claim
|
|
179
|
+
// about the repository exactly like a command name is.
|
|
180
|
+
//
|
|
181
|
+
// Nothing checked those, and five had been dead for as long as the database
|
|
182
|
+
// replaced the file-per-record engine they belonged to. The docs skill still
|
|
183
|
+
// documented a fourteen-command surface at scripts/talks/, and a shipped
|
|
184
|
+
// script imported three modules from it, so its mutation path could never run
|
|
185
|
+
// and failed advising the reader to do the thing they were already doing.
|
|
186
|
+
findings.push(...scriptPaths(root));
|
|
187
|
+
|
|
178
188
|
// One finding per distinct claim: a flag documented in four places is one
|
|
179
189
|
// wrong instruction repeated, but each file still needs fixing, so they are
|
|
180
190
|
// kept separate and only exact duplicates within a file are collapsed.
|
|
@@ -188,3 +198,63 @@ export async function run(root) {
|
|
|
188
198
|
|
|
189
199
|
return { name, findings: unique };
|
|
190
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every script a skill names by path, checked against the repository.
|
|
204
|
+
*
|
|
205
|
+
* Two shapes are read: `${CLAUDE_PLUGIN_ROOT}/...` in the prose an agent
|
|
206
|
+
* follows, and a relative import inside a script the skills ship. A path
|
|
207
|
+
* containing a placeholder such as `<cmd>` is a family rather than a file, and
|
|
208
|
+
* is reported only when its whole directory is missing, because that is the
|
|
209
|
+
* difference between "documented loosely" and "documented for something gone".
|
|
210
|
+
*/
|
|
211
|
+
function scriptPaths(root) {
|
|
212
|
+
const out = [];
|
|
213
|
+
const files = [];
|
|
214
|
+
const walk = (dir) => {
|
|
215
|
+
if (!existsSync(dir)) return;
|
|
216
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
217
|
+
const path = join(dir, entry.name);
|
|
218
|
+
if (entry.isDirectory()) walk(path);
|
|
219
|
+
else if (/\.(md|mjs)$/.test(entry.name)) files.push(path);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(join(root, "skills"));
|
|
223
|
+
walk(join(root, "references"));
|
|
224
|
+
|
|
225
|
+
for (const file of files) {
|
|
226
|
+
const where = rel(root, file);
|
|
227
|
+
const text = readFileSync(file, "utf8");
|
|
228
|
+
|
|
229
|
+
for (const match of text.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([A-Za-z0-9_./<>-]+\.mjs)/g)) {
|
|
230
|
+
const named = match[1];
|
|
231
|
+
if (named.includes("<")) {
|
|
232
|
+
// A family. Only its directory can be checked, and a missing directory
|
|
233
|
+
// means every member of the family is missing too.
|
|
234
|
+
const dir = named.slice(0, named.lastIndexOf("/"));
|
|
235
|
+
if (dir && !existsSync(join(root, dir))) {
|
|
236
|
+
out.push(finding("skill-script-dir-missing", ERROR, where,
|
|
237
|
+
`The skill tells an agent to run scripts under ${dir}/, and that directory does not exist.`));
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!existsSync(join(root, named))) {
|
|
242
|
+
out.push(finding("skill-script-missing", ERROR, where,
|
|
243
|
+
`The skill tells an agent to run ${named}, and there is no such file.`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A shipped script importing something that is not there fails at the
|
|
248
|
+
// moment it is needed, which is later and more confusing than a bad path in
|
|
249
|
+
// prose.
|
|
250
|
+
if (!file.endsWith(".mjs")) continue;
|
|
251
|
+
for (const match of text.matchAll(/new URL\("(\.\.[A-Za-z0-9_./-]+\.mjs)"/g)) {
|
|
252
|
+
const target = resolve(dirname(file), match[1]);
|
|
253
|
+
if (!existsSync(target)) {
|
|
254
|
+
out.push(finding("skill-script-import-missing", ERROR, where,
|
|
255
|
+
`It imports ${match[1]}, which resolves to a file that does not exist.`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
package/skills/docs/SKILL.md
CHANGED
|
@@ -71,7 +71,7 @@ Templates live in `assets/templates/` (universal, provider-neutral). Capability
|
|
|
71
71
|
|
|
72
72
|
## Record-engine commands (durable state, events, sessions, indexes)
|
|
73
73
|
|
|
74
|
-
The record engine is
|
|
74
|
+
The record engine is the database, reached through the `superdev` command, and it is the only thing that writes a record: `superdev change record` for what moved in accepted scope, `superdev decision record` for a decision, `superdev question answer` for an owner question, `superdev assumption record` for a reversible answer, and `superdev docs generate` to rebuild the Markdown projection afterwards. Source material is taken in by `superdev init --brief <file>`, which screens it and records what it states. Never hand-assemble a record file: the Markdown under `talks/` is generated from the database and an edit to it is a proposal, reviewed with `superdev docs diff`.
|
|
75
75
|
|
|
76
76
|
## Pre-delivery verification checklist (every Docs operation, before claiming done)
|
|
77
77
|
|
|
@@ -59,4 +59,12 @@ Generated views (module inventory rollups, decision index, ownership rollups) ar
|
|
|
59
59
|
|
|
60
60
|
## 7. Engine commands
|
|
61
61
|
|
|
62
|
-
Durable writes go through the
|
|
62
|
+
Durable writes go through the database, reached by the `superdev` command.
|
|
63
|
+
Record what moved in accepted scope with
|
|
64
|
+
`superdev change record --summary <what> --reason <why> --apply`, which names the
|
|
65
|
+
records it moved and is refused without a reason. The activity trail behind it is
|
|
66
|
+
append only, enforced by database triggers rather than by convention, and
|
|
67
|
+
screening refuses a secret-shaped value before it can be stored. Regenerate the
|
|
68
|
+
Markdown with `superdev docs generate --apply`. Generated files are never
|
|
69
|
+
hand-edited: an edit becomes a proposal, read with `superdev docs diff` and taken
|
|
70
|
+
in with `superdev docs accept`.
|
|
@@ -40,13 +40,18 @@ Detect and keep visible until resolved: source vs source · source vs code · so
|
|
|
40
40
|
|
|
41
41
|
## 5. Engine commands
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Ingestion is `superdev init --brief <file>`, which reads the source, screens it for
|
|
44
|
+
credential-shaped values before anything is stored, records what it states, and
|
|
45
|
+
records what it does not state as an open question rather than a guess. The
|
|
46
|
+
operations below describe that contract:
|
|
44
47
|
|
|
45
48
|
- `ingest --source <rel> [--apply]` - intake, hashing, screening, revision registration (plan first; unchanged re-ingest is a structural no-op; changed content appends a new revision).
|
|
46
49
|
- `propose --revision <SRC-..:rN> --proposals <file|-> [--apply]` - YOUR semantic claim/contradiction proposals, validated against the deterministic schema (category, six-label epistemic enum, span hash-verified against the revision; Confirmed requires verification evidence). Deterministic identity dedups and merges provenance.
|
|
47
50
|
- `approve|reject --id <CLM-..> --approver <who> [--apply]` - drafts become accepted only here; open contradictions and load-bearing Contradicted/Unknown labels block approval.
|
|
48
51
|
- `resolve --id <CTR-..> --authority <class> --evidence <e> [--apply]` - contradictions stay visible until explicitly resolved; re-ingest never closes them.
|
|
49
52
|
- `verify` - re-verifies every stored provenance span against its recorded revision hash.
|
|
50
|
-
- Owner questions
|
|
53
|
+
- Owner questions: `superdev question list` and `superdev question answer <id>`. Risk
|
|
54
|
+
is carried per feature as its specification depth, set with
|
|
55
|
+
`superdev feature depth <id> <depth>` and enforced at acceptance.
|
|
51
56
|
|
|
52
57
|
Standalone single-skill install: inventory and screening (dry-run) work; record mutation requires the plugin context and is refused with a documented checkpoint (`E_STANDALONE`) - never silently skipped. Inbox retention/commit policy belongs to the project; the engine never edits ignore files.
|
package/src/cli.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// 2 a usage error. A finding is a real answer, so `doctor` and `docs diff`
|
|
12
12
|
// return 1 when they find something rather than pretending nothing is wrong.
|
|
13
13
|
|
|
14
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync, existsSync } from "node:fs";
|
|
15
15
|
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
-
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
17
|
import * as R from "./cli/render.mjs";
|
|
18
18
|
|
|
19
19
|
const nowIso = () => new Date().toISOString();
|
|
@@ -2767,8 +2767,31 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
2767
2767
|
}
|
|
2768
2768
|
}
|
|
2769
2769
|
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2770
|
+
/**
|
|
2771
|
+
* Whether this file is the program, rather than something that imported it.
|
|
2772
|
+
*
|
|
2773
|
+
* Compared through realpath on both sides, because npm installs a `bin` as a
|
|
2774
|
+
* symlink. Run as `superdev`, argv[1] is the link in the bin directory while
|
|
2775
|
+
* import.meta.url is the real file inside the package, so comparing them
|
|
2776
|
+
* directly is always false: every command exited 0 having printed nothing.
|
|
2777
|
+
*
|
|
2778
|
+
* That was invisible until the package had a binary at all. `node src/cli.mjs`
|
|
2779
|
+
* matches on the nose and had been the only way it was ever run.
|
|
2780
|
+
*/
|
|
2781
|
+
function isProgram() {
|
|
2782
|
+
const invoked = process.argv[1];
|
|
2783
|
+
if (!invoked) return false;
|
|
2784
|
+
const real = (path) => {
|
|
2785
|
+
try {
|
|
2786
|
+
return realpathSync(path);
|
|
2787
|
+
} catch {
|
|
2788
|
+
return path;
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
return real(invoked) === real(fileURLToPath(import.meta.url));
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// Imported, by a hook or by a test of the parser, it stays inert.
|
|
2795
|
+
if (isProgram()) {
|
|
2773
2796
|
process.exitCode = await run();
|
|
2774
2797
|
}
|
|
@@ -1,984 +0,0 @@
|
|
|
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();
|