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.
- package/LICENSE +201 -0
- package/README.md +68 -0
- package/dist/adapter/claude-code.d.ts +53 -0
- package/dist/adapter/claude-code.js +241 -0
- package/dist/adapter/commands.d.ts +19 -0
- package/dist/adapter/commands.js +162 -0
- package/dist/atomic.d.ts +6 -0
- package/dist/atomic.js +11 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +561 -0
- package/dist/config/config.d.ts +55 -0
- package/dist/config/config.js +199 -0
- package/dist/config/lifecycle.d.ts +34 -0
- package/dist/config/lifecycle.js +81 -0
- package/dist/config/lock.d.ts +43 -0
- package/dist/config/lock.js +141 -0
- package/dist/context.d.ts +59 -0
- package/dist/context.js +111 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +25 -0
- package/dist/eval/case.d.ts +123 -0
- package/dist/eval/case.js +17 -0
- package/dist/eval/core-suite.d.ts +3 -0
- package/dist/eval/core-suite.js +431 -0
- package/dist/eval/runner.d.ts +75 -0
- package/dist/eval/runner.js +159 -0
- package/dist/eval/sandbox.d.ts +39 -0
- package/dist/eval/sandbox.js +143 -0
- package/dist/extension/manage.d.ts +65 -0
- package/dist/extension/manage.js +372 -0
- package/dist/extension/manifest.d.ts +36 -0
- package/dist/extension/manifest.js +164 -0
- package/dist/extension/resolve.d.ts +77 -0
- package/dist/extension/resolve.js +271 -0
- package/dist/graph/edge-schema.d.ts +55 -0
- package/dist/graph/edge-schema.js +0 -0
- package/dist/graph/edges.d.ts +22 -0
- package/dist/graph/edges.js +63 -0
- package/dist/graph/ids.d.ts +14 -0
- package/dist/graph/ids.js +38 -0
- package/dist/graph/lineage.d.ts +48 -0
- package/dist/graph/lineage.js +226 -0
- package/dist/graph/mutations.d.ts +108 -0
- package/dist/graph/mutations.js +356 -0
- package/dist/graph/nodes.d.ts +46 -0
- package/dist/graph/nodes.js +137 -0
- package/dist/graph/revision.d.ts +50 -0
- package/dist/graph/revision.js +75 -0
- package/dist/graph/schema.d.ts +54 -0
- package/dist/graph/schema.js +90 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +132 -0
- package/dist/lifecycle/engine.d.ts +75 -0
- package/dist/lifecycle/engine.js +146 -0
- package/dist/lifecycle/record.d.ts +18 -0
- package/dist/lifecycle/record.js +157 -0
- package/dist/lifecycle/run.d.ts +62 -0
- package/dist/lifecycle/run.js +167 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +64 -0
- package/dist/pack/capabilities.d.ts +22 -0
- package/dist/pack/capabilities.js +31 -0
- package/dist/pack/locate.d.ts +22 -0
- package/dist/pack/locate.js +80 -0
- package/dist/pack/manifest.d.ts +34 -0
- package/dist/pack/manifest.js +168 -0
- package/dist/pack/resolve.d.ts +92 -0
- package/dist/pack/resolve.js +238 -0
- package/dist/project.d.ts +22 -0
- package/dist/project.js +37 -0
- package/dist/sync.d.ts +54 -0
- package/dist/sync.js +98 -0
- package/dist/validate.d.ts +23 -0
- package/dist/validate.js +32 -0
- package/dist/validation.d.ts +24 -0
- package/dist/validation.js +83 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/yaml.d.ts +12 -0
- package/dist/yaml.js +32 -0
- 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
|
+
}
|