pactwright 0.0.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.
Files changed (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +68 -0
  3. package/dist/adapter/claude-code.d.ts +53 -0
  4. package/dist/adapter/claude-code.js +241 -0
  5. package/dist/adapter/commands.d.ts +19 -0
  6. package/dist/adapter/commands.js +162 -0
  7. package/dist/atomic.d.ts +6 -0
  8. package/dist/atomic.js +11 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +561 -0
  11. package/dist/config/config.d.ts +55 -0
  12. package/dist/config/config.js +199 -0
  13. package/dist/config/lifecycle.d.ts +34 -0
  14. package/dist/config/lifecycle.js +81 -0
  15. package/dist/config/lock.d.ts +43 -0
  16. package/dist/config/lock.js +141 -0
  17. package/dist/context.d.ts +59 -0
  18. package/dist/context.js +111 -0
  19. package/dist/errors.d.ts +21 -0
  20. package/dist/errors.js +25 -0
  21. package/dist/eval/case.d.ts +123 -0
  22. package/dist/eval/case.js +17 -0
  23. package/dist/eval/core-suite.d.ts +3 -0
  24. package/dist/eval/core-suite.js +431 -0
  25. package/dist/eval/runner.d.ts +75 -0
  26. package/dist/eval/runner.js +159 -0
  27. package/dist/eval/sandbox.d.ts +39 -0
  28. package/dist/eval/sandbox.js +143 -0
  29. package/dist/extension/manage.d.ts +65 -0
  30. package/dist/extension/manage.js +372 -0
  31. package/dist/extension/manifest.d.ts +36 -0
  32. package/dist/extension/manifest.js +164 -0
  33. package/dist/extension/resolve.d.ts +77 -0
  34. package/dist/extension/resolve.js +271 -0
  35. package/dist/graph/edge-schema.d.ts +55 -0
  36. package/dist/graph/edge-schema.js +0 -0
  37. package/dist/graph/edges.d.ts +22 -0
  38. package/dist/graph/edges.js +63 -0
  39. package/dist/graph/ids.d.ts +14 -0
  40. package/dist/graph/ids.js +38 -0
  41. package/dist/graph/lineage.d.ts +48 -0
  42. package/dist/graph/lineage.js +226 -0
  43. package/dist/graph/mutations.d.ts +108 -0
  44. package/dist/graph/mutations.js +356 -0
  45. package/dist/graph/nodes.d.ts +46 -0
  46. package/dist/graph/nodes.js +137 -0
  47. package/dist/graph/revision.d.ts +50 -0
  48. package/dist/graph/revision.js +75 -0
  49. package/dist/graph/schema.d.ts +54 -0
  50. package/dist/graph/schema.js +90 -0
  51. package/dist/index.d.ts +33 -0
  52. package/dist/index.js +33 -0
  53. package/dist/init.d.ts +47 -0
  54. package/dist/init.js +132 -0
  55. package/dist/lifecycle/engine.d.ts +75 -0
  56. package/dist/lifecycle/engine.js +146 -0
  57. package/dist/lifecycle/record.d.ts +18 -0
  58. package/dist/lifecycle/record.js +157 -0
  59. package/dist/lifecycle/run.d.ts +62 -0
  60. package/dist/lifecycle/run.js +167 -0
  61. package/dist/loader.d.ts +38 -0
  62. package/dist/loader.js +64 -0
  63. package/dist/pack/capabilities.d.ts +22 -0
  64. package/dist/pack/capabilities.js +31 -0
  65. package/dist/pack/locate.d.ts +22 -0
  66. package/dist/pack/locate.js +80 -0
  67. package/dist/pack/manifest.d.ts +34 -0
  68. package/dist/pack/manifest.js +168 -0
  69. package/dist/pack/resolve.d.ts +92 -0
  70. package/dist/pack/resolve.js +238 -0
  71. package/dist/project.d.ts +22 -0
  72. package/dist/project.js +37 -0
  73. package/dist/sync.d.ts +54 -0
  74. package/dist/sync.js +98 -0
  75. package/dist/validate.d.ts +23 -0
  76. package/dist/validate.js +32 -0
  77. package/dist/validation.d.ts +24 -0
  78. package/dist/validation.js +83 -0
  79. package/dist/version.d.ts +2 -0
  80. package/dist/version.js +8 -0
  81. package/dist/yaml.d.ts +12 -0
  82. package/dist/yaml.js +32 -0
  83. package/package.json +65 -0
@@ -0,0 +1,226 @@
1
+ import { decisionFields } from "./schema.js";
2
+ /**
3
+ * Derived Delivery lifecycle states (Delivery Graph §14). These are views of
4
+ * canonical graph structure, never stored node fields.
5
+ */
6
+ export const DELIVERY_STATES = [
7
+ "open",
8
+ "deferred",
9
+ "rejected",
10
+ "contracted",
11
+ "delivering",
12
+ "done",
13
+ ];
14
+ /**
15
+ * A record is current when nothing supersedes it (Delivery Graph §15).
16
+ * `isCurrent` for an id nothing points at is `true`; unknown ids are the
17
+ * caller's concern.
18
+ */
19
+ export function isCurrent(id, edges) {
20
+ return !edges.some((edge) => edge.type === "supersedes" && edge.target === id);
21
+ }
22
+ /** Index of one graph, built once per derivation. */
23
+ class GraphIndex {
24
+ byId;
25
+ superseded;
26
+ bySource = new Map();
27
+ byTarget = new Map();
28
+ constructor(nodes, edges) {
29
+ this.byId = new Map(nodes.map((node) => [node.id, node]));
30
+ this.superseded = new Set(edges.filter((edge) => edge.type === "supersedes").map((edge) => edge.target));
31
+ for (const edge of edges) {
32
+ push(this.bySource, edge.source, edge);
33
+ push(this.byTarget, edge.target, edge);
34
+ }
35
+ }
36
+ isCurrent(id) {
37
+ return !this.superseded.has(id);
38
+ }
39
+ /**
40
+ * Existing nodes of `type` that have an edge of `edgeType` pointing at
41
+ * `target`, sorted by id. Edges with a missing or wrongly typed source are
42
+ * ignored: `validateEdges` reports those.
43
+ */
44
+ sourcesOf(target, edgeType, type) {
45
+ return this.endpoints(this.byTarget.get(target), edgeType, (edge) => edge.source, type);
46
+ }
47
+ /** As `sourcesOf`, following edges the other way. */
48
+ targetsOf(source, edgeType, type) {
49
+ return this.endpoints(this.bySource.get(source), edgeType, (edge) => edge.target, type);
50
+ }
51
+ endpoints(edges, edgeType, pick, type) {
52
+ const found = [];
53
+ for (const edge of edges ?? []) {
54
+ if (edge.type !== edgeType)
55
+ continue;
56
+ const node = this.byId.get(pick(edge));
57
+ if (node !== undefined && node.type === type)
58
+ found.push(node);
59
+ }
60
+ return found.sort(byId);
61
+ }
62
+ }
63
+ function push(map, key, edge) {
64
+ const list = map.get(key);
65
+ if (list === undefined)
66
+ map.set(key, [edge]);
67
+ else
68
+ list.push(edge);
69
+ }
70
+ function byId(a, b) {
71
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
72
+ }
73
+ function ids(nodes) {
74
+ return nodes.map((node) => node.id).join(", ");
75
+ }
76
+ /**
77
+ * Global cardinality constraints (Delivery Graph §21): at most one current
78
+ * Brief decomposes each Contract, and at most one current Evidence record
79
+ * evidences each Brief — checked for every contract and brief in the graph,
80
+ * independently of any intent path and of the record's own currency.
81
+ */
82
+ function checkGlobalCardinality(nodes, graph) {
83
+ const problems = [];
84
+ const ambiguous = new Set();
85
+ const of = (type) => nodes.filter((n) => n.type === type).sort(byId);
86
+ for (const contract of of("contract")) {
87
+ const briefs = graph
88
+ .sourcesOf(contract.id, "decomposes", "brief")
89
+ .filter((brief) => graph.isCurrent(brief.id));
90
+ if (briefs.length > 1) {
91
+ ambiguous.add(contract.id);
92
+ problems.push({
93
+ code: "ambiguous-brief",
94
+ message: `contract "${contract.id}" is decomposed by ${briefs.length} current briefs (${ids(briefs)}); supersede all but one`,
95
+ path: contract.path,
96
+ });
97
+ }
98
+ }
99
+ for (const brief of of("brief")) {
100
+ const evidences = graph
101
+ .sourcesOf(brief.id, "evidences", "evidence")
102
+ .filter((evidence) => graph.isCurrent(evidence.id));
103
+ if (evidences.length > 1) {
104
+ ambiguous.add(brief.id);
105
+ problems.push({
106
+ code: "ambiguous-evidence",
107
+ message: `brief "${brief.id}" is evidenced by ${evidences.length} current evidence records (${ids(evidences)}); supersede all but one`,
108
+ path: brief.path,
109
+ });
110
+ }
111
+ }
112
+ return { problems, ambiguous };
113
+ }
114
+ /**
115
+ * Derives the current lineage of one intent, reporting every ambiguity in it
116
+ * (Delivery Graph §21, Current-lineage ambiguity). Returns no lineage when the
117
+ * lineage is ambiguous.
118
+ */
119
+ function derive(intent, graph, ambiguous) {
120
+ const problems = [];
121
+ const fail = (node, code, message) => {
122
+ problems.push({ code, message, path: node.path });
123
+ };
124
+ const superseded = !graph.isCurrent(intent.id);
125
+ const decisions = graph
126
+ .sourcesOf(intent.id, "resolves", "decision")
127
+ .filter((decision) => graph.isCurrent(decision.id));
128
+ if (decisions.length > 1) {
129
+ fail(intent, "ambiguous-decision", `intent "${intent.id}" is resolved by ${decisions.length} current decisions (${ids(decisions)}); supersede all but one`);
130
+ return { problems };
131
+ }
132
+ const decision = decisions[0];
133
+ if (decision === undefined)
134
+ return { lineage: { intent, state: "open", superseded }, problems };
135
+ // An invalid decision was already reported by validateNode; do not judge its lineage.
136
+ const fields = decisionFields(decision);
137
+ if (fields === undefined)
138
+ return { problems };
139
+ const outcome = fields.outcome;
140
+ const selected = graph.targetsOf(decision.id, "selects", "contract");
141
+ if (outcome !== "proceed") {
142
+ if (selected.length > 0) {
143
+ fail(decision, "unexpected-contract", `decision "${decision.id}" has outcome ${outcome} but selects a contract (${ids(selected)}); only proceed selects a contract`);
144
+ return { problems };
145
+ }
146
+ return {
147
+ lineage: {
148
+ intent,
149
+ decision,
150
+ state: outcome === "defer" ? "deferred" : "rejected",
151
+ superseded,
152
+ },
153
+ problems,
154
+ };
155
+ }
156
+ const contracts = selected.filter((contract) => graph.isCurrent(contract.id));
157
+ if (contracts.length === 0) {
158
+ const superseded = selected.length === 0 ? "" : ` (superseded: ${ids(selected)})`;
159
+ fail(decision, "missing-contract", `decision "${decision.id}" proceeds but selects no current contract${superseded}; proceed selects exactly one current contract`);
160
+ return { problems };
161
+ }
162
+ if (contracts.length > 1) {
163
+ fail(decision, "ambiguous-contract", `decision "${decision.id}" selects ${contracts.length} current contracts (${ids(contracts)}); proceed selects exactly one current contract`);
164
+ return { problems };
165
+ }
166
+ const contract = contracts[0];
167
+ // >1 current brief was already reported by the global cardinality pass.
168
+ if (ambiguous.has(contract.id))
169
+ return { problems };
170
+ const briefs = graph
171
+ .sourcesOf(contract.id, "decomposes", "brief")
172
+ .filter((brief) => graph.isCurrent(brief.id));
173
+ const brief = briefs[0];
174
+ if (brief === undefined) {
175
+ return { lineage: { intent, decision, contract, state: "contracted", superseded }, problems };
176
+ }
177
+ // >1 current evidence was already reported by the global cardinality pass.
178
+ if (ambiguous.has(brief.id))
179
+ return { problems };
180
+ const evidences = graph
181
+ .sourcesOf(brief.id, "evidences", "evidence")
182
+ .filter((evidence) => graph.isCurrent(evidence.id));
183
+ const evidence = evidences[0];
184
+ if (evidence === undefined) {
185
+ return {
186
+ lineage: { intent, decision, contract, brief, state: "delivering", superseded },
187
+ problems,
188
+ };
189
+ }
190
+ return {
191
+ lineage: { intent, decision, contract, brief, evidence, state: "done", superseded },
192
+ problems,
193
+ };
194
+ }
195
+ /**
196
+ * Derives the current Delivery lineage of every intent from graph structure
197
+ * alone (Delivery Graph §§14–15). Every intent is covered, superseded ones
198
+ * included: a superseded intent's lineage is frozen but must still be
199
+ * unambiguous. Edges with missing or wrongly typed endpoints are ignored,
200
+ * so this is safe to run on a graph `validateEdges` has already rejected.
201
+ */
202
+ export function deriveLineages(nodes, edges) {
203
+ const graph = new GraphIndex(nodes, edges);
204
+ const global = checkGlobalCardinality(nodes, graph);
205
+ const lineages = [];
206
+ const problems = [...global.problems];
207
+ for (const intent of [...nodes].filter((node) => node.type === "intent").sort(byId)) {
208
+ const result = derive(intent, graph, global.ambiguous);
209
+ problems.push(...result.problems);
210
+ if (result.lineage !== undefined)
211
+ lineages.push(result.lineage);
212
+ }
213
+ return { lineages, problems };
214
+ }
215
+ /** The lineage of one intent; `undefined` when the id is not an intent or the lineage is ambiguous. */
216
+ export function deriveLineage(intentId, nodes, edges) {
217
+ const intent = nodes.find((node) => node.id === intentId && node.type === "intent");
218
+ if (intent === undefined)
219
+ return undefined;
220
+ const graph = new GraphIndex(nodes, edges);
221
+ return derive(intent, graph, checkGlobalCardinality(nodes, graph).ambiguous).lineage;
222
+ }
223
+ /** Current-lineage ambiguity validation (Delivery Graph §21). */
224
+ export function validateLineages(nodes, edges) {
225
+ return deriveLineages(nodes, edges).problems;
226
+ }
@@ -0,0 +1,108 @@
1
+ import { type Project } from "../loader.js";
2
+ import { type Edge } from "./edges.js";
3
+ import { type GraphNode } from "./nodes.js";
4
+ import { type DecisionOutcome } from "./schema.js";
5
+ /**
6
+ * New canonical records to commit in one atomic mutation.
7
+ *
8
+ * Internal: this raw shape is produced only by the typed mutation functions
9
+ * below (`createIntent`, `recordDecision`, `createBrief`, `createEvidence`).
10
+ * It is deliberately not part of the public package surface — callers own
11
+ * semantic content; the runtime owns node construction, serialisation and
12
+ * destination paths.
13
+ */
14
+ export interface GraphChange {
15
+ readonly addNodes: readonly GraphNode[];
16
+ readonly addEdges: readonly Edge[];
17
+ }
18
+ /** Internal: test-only hooks for the shared commit path. */
19
+ export interface CommitOptions {
20
+ /** Runs after the atomic renames, before resulting-state validation. */
21
+ readonly postWrite?: () => void;
22
+ }
23
+ /** Serialises a node to Markdown + YAML frontmatter that `parseNodeFile` round-trips. */
24
+ export declare function serialiseNode(node: GraphNode): string;
25
+ /** Serialises the shared typed-edge store in the `edges.yml` fixture shape. */
26
+ export declare function serialiseEdges(edges: readonly Edge[]): string;
27
+ /**
28
+ * Internal shared mutation path (Implementation Guide, "Filesystem
29
+ * mutation"): plan → validate the complete proposed state against the
30
+ * current graph state → write atomically → validate the resulting state.
31
+ *
32
+ * Before anything else the selected agent pack is resolved and checked
33
+ * against the required capabilities (Distribution §7): an incomplete or
34
+ * unresolvable pack throws here, before any validation or write.
35
+ *
36
+ * Validation covers the full common node rules (derived path, id shape,
37
+ * frontmatter round-trip, body) plus type schemas, the typed-edge registry,
38
+ * global lineage constraints and id immutability. Every file is written to a
39
+ * temporary sibling and renamed into place, node files first, `edges.yml`
40
+ * last. After the writes the resulting repository state is reloaded through
41
+ * the canonical loader; if it fails, the new node files are removed and
42
+ * `edges.yml` is restored, so no partial graph state remains.
43
+ */
44
+ export declare function commitGraphChange(project: Project, change: GraphChange, options?: CommitOptions): void;
45
+ export interface CreateIntentInput {
46
+ readonly title: string;
47
+ readonly body: string;
48
+ readonly created?: string;
49
+ }
50
+ /**
51
+ * Creates an Intent (Delivery Graph §6). Like every typed mutation, it loads
52
+ * the current graph state itself at commit time, builds the node from that
53
+ * state in one synchronous sequence and validates the complete proposed and
54
+ * resulting states — a caller-held snapshot never reaches the filesystem.
55
+ */
56
+ export declare function createIntent(root: string, input: CreateIntentInput): GraphNode;
57
+ export interface RecordDecisionInput {
58
+ readonly intentId: string;
59
+ readonly outcome: DecisionOutcome;
60
+ /** The actual acting actor, recorded verbatim in `decided_by`, e.g. "human:samir". */
61
+ readonly decidedBy: string;
62
+ readonly title?: string;
63
+ readonly body: string;
64
+ /** The canonical contract; required for proceed, forbidden otherwise (§8, §9). */
65
+ readonly contract?: {
66
+ readonly title: string;
67
+ readonly body: string;
68
+ };
69
+ readonly created?: string;
70
+ }
71
+ export interface RecordedDecision {
72
+ readonly decision: GraphNode;
73
+ readonly contract?: GraphNode;
74
+ }
75
+ /**
76
+ * Records a Decision resolving an Intent (Delivery Graph §8) and, for
77
+ * proceed, creates the one canonical Contract it selects (§9, §19).
78
+ * The acting actor must be authorised for approve-contract by lifecycle.yml;
79
+ * authorisation is checked before anything is built or written. Re-deciding
80
+ * supersedes the previous current decision and its selected contract (§15).
81
+ * Current graph state is loaded at commit time; see `createIntent`.
82
+ */
83
+ export declare function recordDecision(root: string, input: RecordDecisionInput): RecordedDecision;
84
+ export interface CreateBriefInput {
85
+ readonly contractId: string;
86
+ readonly title: string;
87
+ readonly body: string;
88
+ readonly created?: string;
89
+ }
90
+ /**
91
+ * Creates the delivery Brief decomposing a Contract (§10). An existing
92
+ * current brief is superseded explicitly (§15, brief change). Current graph
93
+ * state is loaded at commit time; see `createIntent`.
94
+ */
95
+ export declare function createBrief(root: string, input: CreateBriefInput): GraphNode;
96
+ export interface CreateEvidenceInput {
97
+ readonly briefId: string;
98
+ readonly title: string;
99
+ readonly body: string;
100
+ readonly created?: string;
101
+ }
102
+ /**
103
+ * Creates the Evidence record for a Brief (§12), completing the core
104
+ * Delivery lifecycle. Existing current evidence is superseded explicitly
105
+ * (§15, evidence correction). Current graph state is loaded at commit time;
106
+ * see `createIntent`.
107
+ */
108
+ export declare function createEvidence(root: string, input: CreateEvidenceInput): GraphNode;
@@ -0,0 +1,356 @@
1
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { dump } from "js-yaml";
4
+ import { tempSibling } from "../atomic.js";
5
+ import { PactwrightError } from "../errors.js";
6
+ import { decisionActor } from "../config/lifecycle.js";
7
+ import { loadProject } from "../loader.js";
8
+ import { assertPackComplete } from "../pack/resolve.js";
9
+ import { composedRegistries } from "../extension/resolve.js";
10
+ import { validateEdges } from "./edge-schema.js";
11
+ import { edgeKey } from "./edges.js";
12
+ import { mintNodeId, slugify } from "./ids.js";
13
+ import { validateLineages } from "./lineage.js";
14
+ import { graphRevision } from "./revision.js";
15
+ import { checkNodeIdImmutability, parseNodeFile } from "./nodes.js";
16
+ import { parseDecidedBy, validateNodes, } from "./schema.js";
17
+ function fail(code, message) {
18
+ throw new PactwrightError(code, message);
19
+ }
20
+ /** Serialises a node to Markdown + YAML frontmatter that `parseNodeFile` round-trips. */
21
+ export function serialiseNode(node) {
22
+ const front = dump(node.frontmatter, { lineWidth: -1 });
23
+ return `---\n${front}---\n\n${node.body}\n`;
24
+ }
25
+ /** Serialises the shared typed-edge store in the `edges.yml` fixture shape. */
26
+ export function serialiseEdges(edges) {
27
+ if (edges.length === 0)
28
+ return "edges: []\n";
29
+ const items = edges
30
+ .map((edge) => ` - source: ${edge.source}\n type: ${edge.type}\n target: ${edge.target}\n`)
31
+ .join("");
32
+ return `edges:\n${items}`;
33
+ }
34
+ const COMMON_FIELDS = ["id", "type", "title", "created"];
35
+ /**
36
+ * Complete common validation of one proposed node, exactly as loading it
37
+ * back will judge it: the destination path is derived from the id (never
38
+ * caller-supplied), and the serialised file must round-trip through
39
+ * `parseNodeFile` to the same common fields.
40
+ */
41
+ function checkProposedNode(project, node) {
42
+ const canonical = join(project.paths.nodesDir, `${node.id}.md`);
43
+ if (node.path !== canonical) {
44
+ return [
45
+ {
46
+ code: "invalid-path",
47
+ message: `node "${node.id}" must be committed to ${canonical}; committed paths are derived from the id, never supplied`,
48
+ path: node.path,
49
+ },
50
+ ];
51
+ }
52
+ const parsed = parseNodeFile(serialiseNode(node), canonical);
53
+ if (parsed.value === undefined)
54
+ return parsed.problems;
55
+ const problems = [];
56
+ for (const field of COMMON_FIELDS) {
57
+ if (parsed.value[field] !== node[field]) {
58
+ problems.push({
59
+ code: "field-mismatch",
60
+ message: `node "${node.id}" ${field} does not round-trip: frontmatter carries "${String(parsed.value[field])}" but the node declares "${String(node[field])}"`,
61
+ path: canonical,
62
+ });
63
+ }
64
+ }
65
+ return problems;
66
+ }
67
+ /**
68
+ * Internal shared mutation path (Implementation Guide, "Filesystem
69
+ * mutation"): plan → validate the complete proposed state against the
70
+ * current graph state → write atomically → validate the resulting state.
71
+ *
72
+ * Before anything else the selected agent pack is resolved and checked
73
+ * against the required capabilities (Distribution §7): an incomplete or
74
+ * unresolvable pack throws here, before any validation or write.
75
+ *
76
+ * Validation covers the full common node rules (derived path, id shape,
77
+ * frontmatter round-trip, body) plus type schemas, the typed-edge registry,
78
+ * global lineage constraints and id immutability. Every file is written to a
79
+ * temporary sibling and renamed into place, node files first, `edges.yml`
80
+ * last. After the writes the resulting repository state is reloaded through
81
+ * the canonical loader; if it fails, the new node files are removed and
82
+ * `edges.yml` is restored, so no partial graph state remains.
83
+ */
84
+ export function commitGraphChange(project, change, options = {}) {
85
+ assertPackComplete(project);
86
+ const problems = [];
87
+ const nodes = [...project.graph.nodes];
88
+ const seenIds = new Set(nodes.map((node) => node.id));
89
+ for (const node of change.addNodes) {
90
+ if (seenIds.has(node.id)) {
91
+ problems.push({
92
+ code: "duplicate-id",
93
+ message: `node id "${node.id}" already exists in the graph`,
94
+ path: node.path,
95
+ });
96
+ continue;
97
+ }
98
+ seenIds.add(node.id);
99
+ nodes.push(node);
100
+ problems.push(...checkProposedNode(project, node));
101
+ }
102
+ const edges = [...project.graph.edges];
103
+ const seenEdges = new Set(edges.map(edgeKey));
104
+ for (const edge of change.addEdges) {
105
+ const key = edgeKey(edge);
106
+ if (seenEdges.has(key)) {
107
+ problems.push({ code: "duplicate-edge", message: `edge ${key} already exists` });
108
+ continue;
109
+ }
110
+ seenEdges.add(key);
111
+ edges.push(edge);
112
+ }
113
+ const registries = composedRegistries(project.extensions);
114
+ problems.push(...validateNodes(nodes, registries.nodes), ...validateEdges(edges, nodes, registries.edges, project.paths.edges), ...validateLineages(nodes, edges), ...checkNodeIdImmutability(project.graph.nodes, nodes));
115
+ if (problems.length > 0)
116
+ throw PactwrightError.fromProblems("mutation-invalid", problems);
117
+ // Compare-and-swap: the snapshot this change was planned against must
118
+ // still be the on-disk graph state, or a concurrent writer's records
119
+ // would be silently overwritten by the wholesale edges.yml rewrite.
120
+ const expected = graphRevision({ nodes: project.graph.nodes, edges: project.graph.edges });
121
+ const fresh = loadProject({ root: project.paths.root });
122
+ if (graphRevision({ nodes: fresh.graph.nodes, edges: fresh.graph.edges }) !== expected) {
123
+ throw new PactwrightError("concurrent-modification", "the graph changed since this mutation was planned; reload and retry");
124
+ }
125
+ // path → content, edges.yml last so the links land only after the records.
126
+ const previousEdges = readFileSync(project.paths.edges, "utf8");
127
+ const writes = [
128
+ ...change.addNodes.map((node) => ({ path: node.path, content: serialiseNode(node) })),
129
+ { path: project.paths.edges, content: serialiseEdges(edges) },
130
+ ];
131
+ const temps = [];
132
+ const restore = () => {
133
+ for (const node of change.addNodes) {
134
+ try {
135
+ unlinkSync(node.path);
136
+ }
137
+ catch {
138
+ /* rollback is best effort */
139
+ }
140
+ }
141
+ const temp = tempSibling(project.paths.edges);
142
+ writeFileSync(temp, previousEdges, "utf8");
143
+ renameSync(temp, project.paths.edges);
144
+ };
145
+ try {
146
+ for (const write of writes) {
147
+ const temp = tempSibling(write.path);
148
+ writeFileSync(temp, write.content, "utf8");
149
+ temps.push(temp);
150
+ }
151
+ writes.forEach((write, index) => {
152
+ renameSync(temps[index], write.path);
153
+ temps[index] = "";
154
+ });
155
+ }
156
+ catch (error) {
157
+ for (const temp of temps.filter((t) => t !== "")) {
158
+ try {
159
+ unlinkSync(temp);
160
+ }
161
+ catch {
162
+ /* rollback is best effort */
163
+ }
164
+ }
165
+ restore();
166
+ throw error;
167
+ }
168
+ // Validate the resulting repository state; roll back if it does not load.
169
+ try {
170
+ options.postWrite?.();
171
+ loadProject({ root: project.paths.root });
172
+ }
173
+ catch (error) {
174
+ restore();
175
+ if (error instanceof PactwrightError) {
176
+ throw PactwrightError.fromProblems("resulting-state-invalid", error.problems);
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+ /** Today's date in UTC — deliberately timezone-independent, since `created` feeds the id hash. */
182
+ function today() {
183
+ return new Date().toISOString().slice(0, 10);
184
+ }
185
+ function requireNode(project, id, type) {
186
+ const node = project.graph.nodes.find((candidate) => candidate.id === id);
187
+ if (node === undefined || node.type !== type) {
188
+ fail("unknown-node", `"${id}" is not an existing ${type} node`);
189
+ }
190
+ if (isSuperseded(project, id)) {
191
+ fail("superseded-node", `${type} "${id}" is superseded; only current records take new work`);
192
+ }
193
+ return node;
194
+ }
195
+ function isSuperseded(project, id) {
196
+ return project.graph.edges.some((edge) => edge.type === "supersedes" && edge.target === id);
197
+ }
198
+ /** Current (unsuperseded) sources of `edgeType` edges into `target`. */
199
+ function currentSources(project, target, edgeType) {
200
+ return project.graph.edges
201
+ .filter((edge) => edge.type === edgeType && edge.target === target)
202
+ .map((edge) => edge.source)
203
+ .filter((id) => !isSuperseded(project, id));
204
+ }
205
+ function buildNode(project, input) {
206
+ const title = input.title.trim();
207
+ const body = input.body.trim();
208
+ if (title.length === 0)
209
+ fail("invalid-title", "title must not be empty");
210
+ if (body.length === 0)
211
+ fail("missing-body", "node must have a Markdown body");
212
+ const slug = slugify(title);
213
+ if (slug === undefined)
214
+ fail("invalid-title", `cannot derive a slug from title "${input.title}"`);
215
+ const created = input.created ?? today();
216
+ const taken = new Set([...project.graph.nodes.map((node) => node.id), ...(input.taken ?? [])]);
217
+ const id = mintNodeId(input.type, slug, `${created}\n${title}\n${body}`, taken);
218
+ return {
219
+ id,
220
+ type: input.type,
221
+ title,
222
+ created,
223
+ frontmatter: { id, type: input.type, title, created, ...(input.extraFront ?? {}) },
224
+ body,
225
+ path: join(project.paths.nodesDir, `${id}.md`),
226
+ };
227
+ }
228
+ /**
229
+ * Creates an Intent (Delivery Graph §6). Like every typed mutation, it loads
230
+ * the current graph state itself at commit time, builds the node from that
231
+ * state in one synchronous sequence and validates the complete proposed and
232
+ * resulting states — a caller-held snapshot never reaches the filesystem.
233
+ */
234
+ export function createIntent(root, input) {
235
+ const project = loadProject({ root });
236
+ const intent = buildNode(project, { type: "intent", ...input });
237
+ commitGraphChange(project, { addNodes: [intent], addEdges: [] });
238
+ return intent;
239
+ }
240
+ /** Which `decided_by` kinds the configured lifecycle actor authorises (§8, §17). */
241
+ const AUTHORISED_KINDS = {
242
+ human: ["human"],
243
+ agent: ["agent", "automation"],
244
+ };
245
+ const DECISION_TITLES = {
246
+ proceed: "Proceed with",
247
+ reject: "Reject",
248
+ defer: "Defer",
249
+ };
250
+ /**
251
+ * Records a Decision resolving an Intent (Delivery Graph §8) and, for
252
+ * proceed, creates the one canonical Contract it selects (§9, §19).
253
+ * The acting actor must be authorised for approve-contract by lifecycle.yml;
254
+ * authorisation is checked before anything is built or written. Re-deciding
255
+ * supersedes the previous current decision and its selected contract (§15).
256
+ * Current graph state is loaded at commit time; see `createIntent`.
257
+ */
258
+ export function recordDecision(root, input) {
259
+ const project = loadProject({ root });
260
+ const actor = parseDecidedBy(input.decidedBy);
261
+ if (actor === undefined) {
262
+ fail("invalid-actor", `decided_by "${input.decidedBy}" must be "<kind>:<name>"`);
263
+ }
264
+ const configured = decisionActor(project.lifecycle);
265
+ if (!AUTHORISED_KINDS[configured].includes(actor.kind)) {
266
+ fail("unauthorised-actor", `actor "${input.decidedBy}" is not authorised for approve-contract; lifecycle.yml authorises ${configured} (${AUTHORISED_KINDS[configured].join("/")}) actors`);
267
+ }
268
+ if ((input.outcome === "proceed") !== (input.contract !== undefined)) {
269
+ fail("invalid-outcome-input", input.outcome === "proceed"
270
+ ? "proceed requires a contract; one canonical contract is created with the decision"
271
+ : `${input.outcome} selects no contract`);
272
+ }
273
+ const intent = requireNode(project, input.intentId, "intent");
274
+ const decision = buildNode(project, {
275
+ type: "decision",
276
+ title: input.title ?? `${DECISION_TITLES[input.outcome]} ${intent.title}`,
277
+ body: input.body,
278
+ created: input.created,
279
+ extraFront: { decided_by: input.decidedBy, outcome: input.outcome },
280
+ });
281
+ const addNodes = [decision];
282
+ const addEdges = [{ source: decision.id, type: "resolves", target: intent.id }];
283
+ let contract;
284
+ if (input.contract !== undefined) {
285
+ contract = buildNode(project, {
286
+ type: "contract",
287
+ title: input.contract.title,
288
+ body: input.contract.body,
289
+ created: input.created,
290
+ taken: new Set([decision.id]),
291
+ });
292
+ addNodes.push(contract);
293
+ addEdges.push({ source: decision.id, type: "selects", target: contract.id });
294
+ }
295
+ // Supersede the previous current records explicitly (§15).
296
+ for (const previous of currentSources(project, intent.id, "resolves")) {
297
+ addEdges.push({ source: decision.id, type: "supersedes", target: previous });
298
+ const selected = project.graph.edges
299
+ .filter((edge) => edge.source === previous && edge.type === "selects")
300
+ .map((edge) => edge.target)
301
+ .filter((id) => !isSuperseded(project, id));
302
+ for (const oldContract of selected) {
303
+ if (contract !== undefined) {
304
+ addEdges.push({ source: contract.id, type: "supersedes", target: oldContract });
305
+ }
306
+ else {
307
+ fail("invalid-outcome-input", `intent "${intent.id}" has a current contract "${oldContract}"; a contract change needs a new proceed decision with a new canonical contract (§15)`);
308
+ }
309
+ }
310
+ }
311
+ commitGraphChange(project, { addNodes, addEdges });
312
+ return contract === undefined ? { decision } : { decision, contract };
313
+ }
314
+ /**
315
+ * Creates the delivery Brief decomposing a Contract (§10). An existing
316
+ * current brief is superseded explicitly (§15, brief change). Current graph
317
+ * state is loaded at commit time; see `createIntent`.
318
+ */
319
+ export function createBrief(root, input) {
320
+ const project = loadProject({ root });
321
+ const contract = requireNode(project, input.contractId, "contract");
322
+ const brief = buildNode(project, {
323
+ type: "brief",
324
+ title: input.title,
325
+ body: input.body,
326
+ created: input.created,
327
+ });
328
+ const addEdges = [{ source: brief.id, type: "decomposes", target: contract.id }];
329
+ for (const previous of currentSources(project, contract.id, "decomposes")) {
330
+ addEdges.push({ source: brief.id, type: "supersedes", target: previous });
331
+ }
332
+ commitGraphChange(project, { addNodes: [brief], addEdges });
333
+ return brief;
334
+ }
335
+ /**
336
+ * Creates the Evidence record for a Brief (§12), completing the core
337
+ * Delivery lifecycle. Existing current evidence is superseded explicitly
338
+ * (§15, evidence correction). Current graph state is loaded at commit time;
339
+ * see `createIntent`.
340
+ */
341
+ export function createEvidence(root, input) {
342
+ const project = loadProject({ root });
343
+ const brief = requireNode(project, input.briefId, "brief");
344
+ const evidence = buildNode(project, {
345
+ type: "evidence",
346
+ title: input.title,
347
+ body: input.body,
348
+ created: input.created,
349
+ });
350
+ const addEdges = [{ source: evidence.id, type: "evidences", target: brief.id }];
351
+ for (const previous of currentSources(project, brief.id, "evidences")) {
352
+ addEdges.push({ source: evidence.id, type: "supersedes", target: previous });
353
+ }
354
+ commitGraphChange(project, { addNodes: [evidence], addEdges });
355
+ return evidence;
356
+ }