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,199 @@
|
|
|
1
|
+
import { Checker, expectBoolean, expectEnum, expectRecord, expectString, expectVersion, rejectUnknownKeys, requireKeys, } from "../validation.js";
|
|
2
|
+
import { parseYaml, readYamlFile } from "../yaml.js";
|
|
3
|
+
import { EXTENSION_ID_PATTERN } from "./lock.js";
|
|
4
|
+
export const CONFIG_VERSION = 1;
|
|
5
|
+
export const ADAPTER_TYPES = ["claude-code"];
|
|
6
|
+
export function parseConfig(raw, path) {
|
|
7
|
+
const c = new Checker(path);
|
|
8
|
+
const root = expectRecord(c, raw, "config");
|
|
9
|
+
if (root === undefined)
|
|
10
|
+
return { value: undefined, problems: c.problems };
|
|
11
|
+
requireKeys(c, root, "config", ["version", "agent_pack", "adapter", "github"]);
|
|
12
|
+
rejectUnknownKeys(c, root, "config", [
|
|
13
|
+
"version",
|
|
14
|
+
"agent_pack",
|
|
15
|
+
"adapter",
|
|
16
|
+
"extensions",
|
|
17
|
+
"github",
|
|
18
|
+
]);
|
|
19
|
+
expectVersion(c, root["version"], "config.version", CONFIG_VERSION);
|
|
20
|
+
let source;
|
|
21
|
+
let packVersion;
|
|
22
|
+
const agentPack = expectRecord(c, root["agent_pack"], "config.agent_pack");
|
|
23
|
+
if (agentPack !== undefined) {
|
|
24
|
+
requireKeys(c, agentPack, "config.agent_pack", ["source"]);
|
|
25
|
+
rejectUnknownKeys(c, agentPack, "config.agent_pack", ["source", "version"]);
|
|
26
|
+
source = expectString(c, agentPack["source"], "config.agent_pack.source");
|
|
27
|
+
packVersion = expectString(c, agentPack["version"], "config.agent_pack.version");
|
|
28
|
+
}
|
|
29
|
+
let adapterType;
|
|
30
|
+
const adapter = expectRecord(c, root["adapter"], "config.adapter");
|
|
31
|
+
if (adapter !== undefined) {
|
|
32
|
+
requireKeys(c, adapter, "config.adapter", ["type"]);
|
|
33
|
+
rejectUnknownKeys(c, adapter, "config.adapter", ["type"]);
|
|
34
|
+
adapterType = expectEnum(c, adapter["type"], "config.adapter.type", ADAPTER_TYPES);
|
|
35
|
+
}
|
|
36
|
+
const extensions = Object.create(null);
|
|
37
|
+
if (root["extensions"] !== undefined) {
|
|
38
|
+
const raw = expectRecord(c, root["extensions"], "config.extensions");
|
|
39
|
+
if (raw !== undefined) {
|
|
40
|
+
for (const id of Object.keys(raw).sort()) {
|
|
41
|
+
if (!EXTENSION_ID_PATTERN.test(id)) {
|
|
42
|
+
c.fail("invalid-extension-id", `config.extensions key "${id}" is not a valid extension id`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const label = `config.extensions.${id}`;
|
|
46
|
+
const entry = expectRecord(c, raw[id], label);
|
|
47
|
+
if (entry === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
requireKeys(c, entry, label, ["enabled", "source"]);
|
|
50
|
+
rejectUnknownKeys(c, entry, label, ["enabled", "source"]);
|
|
51
|
+
const extensionEnabled = expectBoolean(c, entry["enabled"], `${label}.enabled`);
|
|
52
|
+
const extensionSource = expectString(c, entry["source"], `${label}.source`);
|
|
53
|
+
if (extensionEnabled !== undefined && extensionSource !== undefined) {
|
|
54
|
+
extensions[id] = { enabled: extensionEnabled, source: extensionSource };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let enabled;
|
|
60
|
+
const github = expectRecord(c, root["github"], "config.github");
|
|
61
|
+
if (github !== undefined) {
|
|
62
|
+
requireKeys(c, github, "config.github", ["enabled"]);
|
|
63
|
+
rejectUnknownKeys(c, github, "config.github", ["enabled"]);
|
|
64
|
+
enabled = expectBoolean(c, github["enabled"], "config.github.enabled");
|
|
65
|
+
}
|
|
66
|
+
if (!c.ok || source === undefined || adapterType === undefined || enabled === undefined) {
|
|
67
|
+
return { value: undefined, problems: c.problems };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
value: {
|
|
71
|
+
version: 1,
|
|
72
|
+
agentPack: packVersion === undefined ? { source } : { source, version: packVersion },
|
|
73
|
+
adapter: { type: adapterType },
|
|
74
|
+
// Copied to a plain object; callers guard dynamic id lookups with
|
|
75
|
+
// `Object.hasOwn` so an id like "constructor" cannot resolve to an
|
|
76
|
+
// Object.prototype member.
|
|
77
|
+
extensions: { ...extensions },
|
|
78
|
+
github: { enabled },
|
|
79
|
+
},
|
|
80
|
+
problems: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function loadConfig(path) {
|
|
84
|
+
const read = readYamlFile(path);
|
|
85
|
+
if (read.problems.length > 0)
|
|
86
|
+
return { value: undefined, problems: read.problems };
|
|
87
|
+
return parseConfig(read.value, path);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A YAML double-quoted scalar holding `text`. JSON is a subset of YAML 1.2
|
|
91
|
+
* and the two share their double-quoted escape set, so `JSON.stringify` is a
|
|
92
|
+
* correct encoder here — and for an ordinary package name or relative path it
|
|
93
|
+
* emits exactly the bytes a hand-written config already has. Interpolating
|
|
94
|
+
* such values raw would let a `"`, `\` or newline in a source break out of
|
|
95
|
+
* the field and leave an unparseable config behind.
|
|
96
|
+
*/
|
|
97
|
+
function scalar(text) {
|
|
98
|
+
return JSON.stringify(text);
|
|
99
|
+
}
|
|
100
|
+
/** The canonical `extensions:` block, the only region the commands rewrite. */
|
|
101
|
+
function extensionsBlock(config) {
|
|
102
|
+
const ids = Object.keys(config.extensions).sort();
|
|
103
|
+
if (ids.length === 0)
|
|
104
|
+
return ["extensions: {}"];
|
|
105
|
+
const lines = ["extensions:"];
|
|
106
|
+
ids.forEach((id, index) => {
|
|
107
|
+
const extension = config.extensions[id];
|
|
108
|
+
if (index > 0)
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(` ${id}:`, ` enabled: ${extension.enabled}`, ` source: ${scalar(extension.source)}`);
|
|
111
|
+
});
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Serialises a configuration in the canonical `.pactwright/config.yml`
|
|
116
|
+
* shape — the exact bytes `pactwright init` writes for the default state.
|
|
117
|
+
* This is the fallback whenever the previous file cannot be edited in place;
|
|
118
|
+
* it re-emits from parsed values, so comments and incidental layout are not
|
|
119
|
+
* carried over. `rewriteConfig` is what the commands normally use.
|
|
120
|
+
*/
|
|
121
|
+
export function serialiseConfig(config) {
|
|
122
|
+
const lines = [
|
|
123
|
+
"version: 1",
|
|
124
|
+
"",
|
|
125
|
+
"agent_pack:",
|
|
126
|
+
` source: ${scalar(config.agentPack.source)}`,
|
|
127
|
+
];
|
|
128
|
+
if (config.agentPack.version !== undefined) {
|
|
129
|
+
lines.push(` version: ${scalar(config.agentPack.version)}`);
|
|
130
|
+
}
|
|
131
|
+
// `adapter.type` is a validated enum and extension ids are validated
|
|
132
|
+
// kebab-case, so both are safe bare; quoting them would also change the
|
|
133
|
+
// bytes `init` writes.
|
|
134
|
+
lines.push("", "adapter:", ` type: ${config.adapter.type}`, "");
|
|
135
|
+
lines.push(...extensionsBlock(config));
|
|
136
|
+
lines.push("", "github:", ` enabled: ${config.github.enabled}`, "");
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
/** The line range of the top-level `extensions:` key, or `undefined`. */
|
|
140
|
+
function extensionsRegion(lines) {
|
|
141
|
+
const start = lines.findIndex((line) => /^extensions:/.test(line));
|
|
142
|
+
if (start === -1)
|
|
143
|
+
return undefined;
|
|
144
|
+
if (lines.some((line) => line.includes("\t")))
|
|
145
|
+
return undefined;
|
|
146
|
+
// A flow mapping (`extensions: {}`) is the whole region. Anything else on
|
|
147
|
+
// the key line is a shape this editor does not claim to understand.
|
|
148
|
+
const inline = lines[start].slice("extensions:".length).trim();
|
|
149
|
+
if (inline !== "")
|
|
150
|
+
return inline === "{}" ? { start, end: start } : undefined;
|
|
151
|
+
let end = start;
|
|
152
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
if (line.trim() === "")
|
|
155
|
+
continue;
|
|
156
|
+
if (!/^\s/.test(line))
|
|
157
|
+
break;
|
|
158
|
+
end = i;
|
|
159
|
+
}
|
|
160
|
+
return { start, end };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The previous configuration text with only its `extensions:` block replaced
|
|
164
|
+
* (Distribution §3). `extension add` and `remove` change nothing else, so
|
|
165
|
+
* every other byte — comments, key order, spacing a team chose — survives.
|
|
166
|
+
*
|
|
167
|
+
* Falls back to a full `serialiseConfig` rewrite whenever the edit cannot be
|
|
168
|
+
* made confidently, including when the spliced result does not parse back to
|
|
169
|
+
* the intended configuration. The fallback is today's behaviour, so the worst
|
|
170
|
+
* case is losing comments rather than corrupting the file — which matters
|
|
171
|
+
* because `removeExtension` never rolls its write back.
|
|
172
|
+
*
|
|
173
|
+
* Comments *inside* the extensions block are part of the replaced region and
|
|
174
|
+
* are not preserved.
|
|
175
|
+
*/
|
|
176
|
+
export function rewriteConfig(previous, config) {
|
|
177
|
+
const canonical = serialiseConfig(config);
|
|
178
|
+
const newline = previous.includes("\r\n") ? "\r\n" : "\n";
|
|
179
|
+
const lines = previous.split(/\r?\n/);
|
|
180
|
+
const block = [...extensionsBlock(config)];
|
|
181
|
+
const region = extensionsRegion(lines);
|
|
182
|
+
let spliced;
|
|
183
|
+
if (region !== undefined) {
|
|
184
|
+
spliced = [...lines.slice(0, region.start), ...block, ...lines.slice(region.end + 1)].join(newline);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
// No `extensions:` key at all: place it before `github:`, else append.
|
|
188
|
+
const github = lines.findIndex((line) => /^github:/.test(line));
|
|
189
|
+
const at = github === -1 ? lines.length : github;
|
|
190
|
+
spliced = [...lines.slice(0, at), ...block, "", ...lines.slice(at)].join(newline);
|
|
191
|
+
}
|
|
192
|
+
const read = parseYaml(spliced, "config.yml");
|
|
193
|
+
if (read.problems.length > 0)
|
|
194
|
+
return canonical;
|
|
195
|
+
const reparsed = parseConfig(read.value, "config.yml");
|
|
196
|
+
if (reparsed.value === undefined)
|
|
197
|
+
return canonical;
|
|
198
|
+
return serialiseConfig(reparsed.value) === canonical ? spliced : canonical;
|
|
199
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ParseResult } from "./config.js";
|
|
2
|
+
/** The seven core Delivery lifecycle stages, in lifecycle order (Delivery Graph §17). */
|
|
3
|
+
export declare const CORE_STAGES: readonly ["capture-intent", "propose-contracts", "approve-contract", "write-brief", "deliver-brief", "review", "prepare-evidence"];
|
|
4
|
+
export type StageName = (typeof CORE_STAGES)[number];
|
|
5
|
+
export declare const EXECUTION_MODES: readonly ["manual", "automatic"];
|
|
6
|
+
export type ExecutionMode = (typeof EXECUTION_MODES)[number];
|
|
7
|
+
export declare const ACTORS: readonly ["human", "agent"];
|
|
8
|
+
export type Actor = (typeof ACTORS)[number];
|
|
9
|
+
export interface StageConfig {
|
|
10
|
+
readonly execution: ExecutionMode;
|
|
11
|
+
readonly actor?: Actor;
|
|
12
|
+
}
|
|
13
|
+
/** `.pactwright/lifecycle.yml` — how the repository operates the lifecycle. */
|
|
14
|
+
export interface LifecycleConfig {
|
|
15
|
+
readonly version: 1;
|
|
16
|
+
readonly stages: Readonly<Record<StageName, StageConfig>>;
|
|
17
|
+
}
|
|
18
|
+
export declare const LIFECYCLE_VERSION = 1;
|
|
19
|
+
/** The stage whose configured actor authorises Decisions (Delivery Graph §8). */
|
|
20
|
+
export declare const DECISION_STAGE: "approve-contract";
|
|
21
|
+
export declare function parseLifecycle(raw: unknown, path: string): ParseResult<LifecycleConfig>;
|
|
22
|
+
export declare function loadLifecycle(path: string): ParseResult<LifecycleConfig>;
|
|
23
|
+
/** The actor authorised to make Decisions: `approve-contract`'s configured actor. */
|
|
24
|
+
export declare function decisionActor(lifecycle: LifecycleConfig): Actor;
|
|
25
|
+
/**
|
|
26
|
+
* A human gate is a stage that cannot proceed without a human: manual
|
|
27
|
+
* execution or a human actor. `lifecycle run` stops here and never skips one
|
|
28
|
+
* (Delivery Graph §20). In the §17 default example the gates are
|
|
29
|
+
* capture-intent and approve-contract; in the automated example only
|
|
30
|
+
* capture-intent remains.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isHumanGate(stage: StageConfig): boolean;
|
|
33
|
+
/** The human gates of a lifecycle, in stage order. */
|
|
34
|
+
export declare function humanGates(lifecycle: LifecycleConfig): readonly StageName[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Checker, expectEnum, expectRecord, expectVersion, rejectUnknownKeys, requireKeys, } from "../validation.js";
|
|
2
|
+
import { readYamlFile } from "../yaml.js";
|
|
3
|
+
/** The seven core Delivery lifecycle stages, in lifecycle order (Delivery Graph §17). */
|
|
4
|
+
export const CORE_STAGES = [
|
|
5
|
+
"capture-intent",
|
|
6
|
+
"propose-contracts",
|
|
7
|
+
"approve-contract",
|
|
8
|
+
"write-brief",
|
|
9
|
+
"deliver-brief",
|
|
10
|
+
"review",
|
|
11
|
+
"prepare-evidence",
|
|
12
|
+
];
|
|
13
|
+
export const EXECUTION_MODES = ["manual", "automatic"];
|
|
14
|
+
export const ACTORS = ["human", "agent"];
|
|
15
|
+
export const LIFECYCLE_VERSION = 1;
|
|
16
|
+
/** The stage whose configured actor authorises Decisions (Delivery Graph §8). */
|
|
17
|
+
export const DECISION_STAGE = "approve-contract";
|
|
18
|
+
export function parseLifecycle(raw, path) {
|
|
19
|
+
const c = new Checker(path);
|
|
20
|
+
const root = expectRecord(c, raw, "lifecycle");
|
|
21
|
+
if (root === undefined)
|
|
22
|
+
return { value: undefined, problems: c.problems };
|
|
23
|
+
requireKeys(c, root, "lifecycle", ["version", "stages"]);
|
|
24
|
+
rejectUnknownKeys(c, root, "lifecycle", ["version", "stages"]);
|
|
25
|
+
expectVersion(c, root["version"], "lifecycle.version", LIFECYCLE_VERSION);
|
|
26
|
+
const stages = {};
|
|
27
|
+
const rawStages = expectRecord(c, root["stages"], "lifecycle.stages");
|
|
28
|
+
if (rawStages !== undefined) {
|
|
29
|
+
requireKeys(c, rawStages, "lifecycle.stages", CORE_STAGES);
|
|
30
|
+
for (const key of Object.keys(rawStages)) {
|
|
31
|
+
if (!CORE_STAGES.includes(key)) {
|
|
32
|
+
c.fail("unknown-stage", `lifecycle.stages has unknown stage "${key}"; only core Delivery stages are configurable here`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const name of CORE_STAGES) {
|
|
36
|
+
const label = `lifecycle.stages.${name}`;
|
|
37
|
+
const stage = expectRecord(c, rawStages[name], label);
|
|
38
|
+
if (stage === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
requireKeys(c, stage, label, ["execution"]);
|
|
41
|
+
rejectUnknownKeys(c, stage, label, ["execution", "actor"]);
|
|
42
|
+
const execution = expectEnum(c, stage["execution"], `${label}.execution`, EXECUTION_MODES);
|
|
43
|
+
const actor = stage["actor"] === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: expectEnum(c, stage["actor"], `${label}.actor`, ACTORS);
|
|
46
|
+
if (name === DECISION_STAGE && stage["actor"] === undefined) {
|
|
47
|
+
c.fail("missing-actor", `${label} must declare "actor"; Decisions must be authorised by lifecycle.yml (Delivery Graph §8)`);
|
|
48
|
+
}
|
|
49
|
+
if (execution !== undefined) {
|
|
50
|
+
stages[name] = actor === undefined ? { execution } : { execution, actor };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!c.ok)
|
|
55
|
+
return { value: undefined, problems: c.problems };
|
|
56
|
+
return { value: { version: 1, stages: stages }, problems: [] };
|
|
57
|
+
}
|
|
58
|
+
export function loadLifecycle(path) {
|
|
59
|
+
const read = readYamlFile(path);
|
|
60
|
+
if (read.problems.length > 0)
|
|
61
|
+
return { value: undefined, problems: read.problems };
|
|
62
|
+
return parseLifecycle(read.value, path);
|
|
63
|
+
}
|
|
64
|
+
/** The actor authorised to make Decisions: `approve-contract`'s configured actor. */
|
|
65
|
+
export function decisionActor(lifecycle) {
|
|
66
|
+
return lifecycle.stages[DECISION_STAGE].actor;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A human gate is a stage that cannot proceed without a human: manual
|
|
70
|
+
* execution or a human actor. `lifecycle run` stops here and never skips one
|
|
71
|
+
* (Delivery Graph §20). In the §17 default example the gates are
|
|
72
|
+
* capture-intent and approve-contract; in the automated example only
|
|
73
|
+
* capture-intent remains.
|
|
74
|
+
*/
|
|
75
|
+
export function isHumanGate(stage) {
|
|
76
|
+
return stage.execution === "manual" || stage.actor === "human";
|
|
77
|
+
}
|
|
78
|
+
/** The human gates of a lifecycle, in stage order. */
|
|
79
|
+
export function humanGates(lifecycle) {
|
|
80
|
+
return CORE_STAGES.filter((name) => isHumanGate(lifecycle.stages[name]));
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ParseResult } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* One locked extension: the exact package and version it resolved to, and a
|
|
4
|
+
* hash of its manifest (Distribution §6).
|
|
5
|
+
*/
|
|
6
|
+
export interface LockExtension {
|
|
7
|
+
readonly package: string;
|
|
8
|
+
readonly version: string;
|
|
9
|
+
/**
|
|
10
|
+
* Hash of the extension's declared manifest — its id, package, version,
|
|
11
|
+
* runtime range, dependencies, graph types, namespaces, capabilities and
|
|
12
|
+
* GitHub profile. It pins what the extension declares, not the bytes of
|
|
13
|
+
* the code it ships.
|
|
14
|
+
*/
|
|
15
|
+
readonly hash: string;
|
|
16
|
+
/** Extension id → exact version of a required peer extension. Omitted when empty. */
|
|
17
|
+
readonly dependencies?: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
/** `.pactwright/lock.yml` — the exact resolved setup (Distribution §6). */
|
|
20
|
+
export interface LockFile {
|
|
21
|
+
readonly runtime: {
|
|
22
|
+
readonly version: string;
|
|
23
|
+
};
|
|
24
|
+
readonly agentPack: {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly version: string;
|
|
27
|
+
readonly hash: string;
|
|
28
|
+
};
|
|
29
|
+
/** Agent name → content hash. */
|
|
30
|
+
readonly agents: Readonly<Record<string, string>>;
|
|
31
|
+
/** Skill name → content hash. A runtime addition relative to the §6 shape. */
|
|
32
|
+
readonly skills: Readonly<Record<string, string>>;
|
|
33
|
+
/**
|
|
34
|
+
* Extension id → locked extension (Distribution §6). Empty only when no
|
|
35
|
+
* extension is configured.
|
|
36
|
+
*/
|
|
37
|
+
readonly extensions: Readonly<Record<string, LockExtension>>;
|
|
38
|
+
}
|
|
39
|
+
export declare const HASH_PATTERN: RegExp;
|
|
40
|
+
/** Extension ids are kebab-case identifiers, like `project-intelligence`. */
|
|
41
|
+
export declare const EXTENSION_ID_PATTERN: RegExp;
|
|
42
|
+
export declare function parseLock(raw: unknown, path: string): ParseResult<LockFile>;
|
|
43
|
+
export declare function loadLock(path: string): ParseResult<LockFile>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Checker, expectRecord, expectString, rejectUnknownKeys, requireKeys, } from "../validation.js";
|
|
2
|
+
import { readYamlFile } from "../yaml.js";
|
|
3
|
+
export const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
4
|
+
/** Extension ids are kebab-case identifiers, like `project-intelligence`. */
|
|
5
|
+
export const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
6
|
+
/** The lock records exact resolved state, so versions are never ranges. */
|
|
7
|
+
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
8
|
+
function expectHash(c, value, label) {
|
|
9
|
+
const text = expectString(c, value, label);
|
|
10
|
+
if (text === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (!HASH_PATTERN.test(text))
|
|
13
|
+
return c.fail("invalid-hash", `${label} must match sha256:<64 hex>`);
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
function expectExactVersion(c, value, label) {
|
|
17
|
+
const text = expectString(c, value, label);
|
|
18
|
+
if (text === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!EXACT_VERSION_PATTERN.test(text)) {
|
|
21
|
+
return c.fail("invalid-version", `${label} must be an exact x.y.z version, found "${text}"`);
|
|
22
|
+
}
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
function parseExtension(c, raw, label) {
|
|
26
|
+
const record = expectRecord(c, raw, label);
|
|
27
|
+
if (record === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
requireKeys(c, record, label, ["package", "version", "hash"]);
|
|
30
|
+
rejectUnknownKeys(c, record, label, ["package", "version", "hash", "dependencies"]);
|
|
31
|
+
const pkg = expectString(c, record["package"], `${label}.package`);
|
|
32
|
+
const version = expectExactVersion(c, record["version"], `${label}.version`);
|
|
33
|
+
const hash = expectHash(c, record["hash"], `${label}.hash`);
|
|
34
|
+
let dependencies;
|
|
35
|
+
if (record["dependencies"] !== undefined) {
|
|
36
|
+
const deps = expectRecord(c, record["dependencies"], `${label}.dependencies`);
|
|
37
|
+
if (deps !== undefined) {
|
|
38
|
+
dependencies = {};
|
|
39
|
+
for (const id of Object.keys(deps).sort()) {
|
|
40
|
+
if (!EXTENSION_ID_PATTERN.test(id)) {
|
|
41
|
+
c.fail("invalid-extension-id", `${label}.dependencies key "${id}" is not a valid extension id`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const wanted = expectExactVersion(c, deps[id], `${label}.dependencies.${id}`);
|
|
45
|
+
if (wanted !== undefined)
|
|
46
|
+
dependencies[id] = wanted;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (pkg === undefined || version === undefined || hash === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
return {
|
|
53
|
+
package: pkg,
|
|
54
|
+
version,
|
|
55
|
+
hash,
|
|
56
|
+
...(dependencies === undefined || Object.keys(dependencies).length === 0
|
|
57
|
+
? {}
|
|
58
|
+
: { dependencies }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseExtensions(c, raw) {
|
|
62
|
+
const out = {};
|
|
63
|
+
if (raw === undefined)
|
|
64
|
+
return out;
|
|
65
|
+
const record = expectRecord(c, raw, "lock.extensions");
|
|
66
|
+
if (record === undefined)
|
|
67
|
+
return out;
|
|
68
|
+
for (const id of Object.keys(record).sort()) {
|
|
69
|
+
if (!EXTENSION_ID_PATTERN.test(id)) {
|
|
70
|
+
c.fail("invalid-extension-id", `lock.extensions key "${id}" is not a valid extension id`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const extension = parseExtension(c, record[id], `lock.extensions.${id}`);
|
|
74
|
+
if (extension !== undefined)
|
|
75
|
+
out[id] = extension;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function parseHashMap(c, raw, label) {
|
|
80
|
+
const out = {};
|
|
81
|
+
if (raw === undefined)
|
|
82
|
+
return out;
|
|
83
|
+
const record = expectRecord(c, raw, label);
|
|
84
|
+
if (record === undefined)
|
|
85
|
+
return out;
|
|
86
|
+
for (const key of Object.keys(record).sort()) {
|
|
87
|
+
const hash = expectHash(c, record[key], `${label}.${key}`);
|
|
88
|
+
if (hash !== undefined)
|
|
89
|
+
out[key] = hash;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function parseLock(raw, path) {
|
|
94
|
+
const c = new Checker(path);
|
|
95
|
+
const root = expectRecord(c, raw, "lock");
|
|
96
|
+
if (root === undefined)
|
|
97
|
+
return { value: undefined, problems: c.problems };
|
|
98
|
+
requireKeys(c, root, "lock", ["runtime", "agent_pack"]);
|
|
99
|
+
rejectUnknownKeys(c, root, "lock", ["runtime", "agent_pack", "agents", "skills", "extensions"]);
|
|
100
|
+
let runtimeVersion;
|
|
101
|
+
const runtime = expectRecord(c, root["runtime"], "lock.runtime");
|
|
102
|
+
if (runtime !== undefined) {
|
|
103
|
+
requireKeys(c, runtime, "lock.runtime", ["version"]);
|
|
104
|
+
rejectUnknownKeys(c, runtime, "lock.runtime", ["version"]);
|
|
105
|
+
runtimeVersion = expectString(c, runtime["version"], "lock.runtime.version");
|
|
106
|
+
}
|
|
107
|
+
let pack;
|
|
108
|
+
const agentPack = expectRecord(c, root["agent_pack"], "lock.agent_pack");
|
|
109
|
+
if (agentPack !== undefined) {
|
|
110
|
+
requireKeys(c, agentPack, "lock.agent_pack", ["name", "version", "hash"]);
|
|
111
|
+
rejectUnknownKeys(c, agentPack, "lock.agent_pack", ["name", "version", "hash"]);
|
|
112
|
+
const name = expectString(c, agentPack["name"], "lock.agent_pack.name");
|
|
113
|
+
const version = expectString(c, agentPack["version"], "lock.agent_pack.version");
|
|
114
|
+
const hash = expectHash(c, agentPack["hash"], "lock.agent_pack.hash");
|
|
115
|
+
if (name !== undefined && version !== undefined && hash !== undefined) {
|
|
116
|
+
pack = { name, version, hash };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const agents = parseHashMap(c, root["agents"], "lock.agents");
|
|
120
|
+
const skills = parseHashMap(c, root["skills"], "lock.skills");
|
|
121
|
+
const extensions = parseExtensions(c, root["extensions"]);
|
|
122
|
+
if (!c.ok || runtimeVersion === undefined || pack === undefined) {
|
|
123
|
+
return { value: undefined, problems: c.problems };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
value: {
|
|
127
|
+
runtime: { version: runtimeVersion },
|
|
128
|
+
agentPack: pack,
|
|
129
|
+
agents,
|
|
130
|
+
skills,
|
|
131
|
+
extensions,
|
|
132
|
+
},
|
|
133
|
+
problems: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function loadLock(path) {
|
|
137
|
+
const read = readYamlFile(path);
|
|
138
|
+
if (read.problems.length > 0)
|
|
139
|
+
return { value: undefined, problems: read.problems };
|
|
140
|
+
return parseLock(read.value, path);
|
|
141
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Edge } from "./graph/edges.js";
|
|
2
|
+
import { type DeliveryState, type Lineage } from "./graph/lineage.js";
|
|
3
|
+
import type { GraphNode } from "./graph/nodes.js";
|
|
4
|
+
import type { Project } from "./loader.js";
|
|
5
|
+
/** A superseded record of the lineage's history (`--history` only). */
|
|
6
|
+
export interface HistoryRecord {
|
|
7
|
+
readonly node: GraphNode;
|
|
8
|
+
/** Ids of the records that supersede this one. */
|
|
9
|
+
readonly supersededBy: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/** One namespaced extension contribution (Delivery Graph §22). */
|
|
12
|
+
export interface ExtensionContext {
|
|
13
|
+
readonly namespace: string;
|
|
14
|
+
readonly context: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The extension-context seam. Enabled extensions contribute after the core
|
|
18
|
+
* lineage is resolved; they receive it read-only and can only add one
|
|
19
|
+
* namespaced entry, so extension context never alters the Delivery lineage.
|
|
20
|
+
* No core contributor exists in this checkpoint.
|
|
21
|
+
*/
|
|
22
|
+
export type ContextContributor = (input: {
|
|
23
|
+
readonly project: Project;
|
|
24
|
+
readonly lineage: Lineage;
|
|
25
|
+
readonly history: boolean;
|
|
26
|
+
}) => ExtensionContext | undefined;
|
|
27
|
+
/** `pactwright context <node-id>` result (Delivery Graph §22). */
|
|
28
|
+
export interface DeliveryContext {
|
|
29
|
+
/** The node id that was asked for. */
|
|
30
|
+
readonly requested: string;
|
|
31
|
+
readonly intent: string;
|
|
32
|
+
readonly state: DeliveryState;
|
|
33
|
+
/** Current core lineage in stage order; only existing stages. */
|
|
34
|
+
readonly lineage: readonly GraphNode[];
|
|
35
|
+
/** False when the requested node is superseded (it is then not in `lineage`). */
|
|
36
|
+
readonly requestedIsCurrent: boolean;
|
|
37
|
+
/** Superseded records of this intent's tree, sorted by id; only with `history`. */
|
|
38
|
+
readonly history?: readonly HistoryRecord[];
|
|
39
|
+
/** Namespaced extension context; empty in this checkpoint. */
|
|
40
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
41
|
+
}
|
|
42
|
+
export interface ContextOptions {
|
|
43
|
+
readonly history?: boolean;
|
|
44
|
+
readonly contributors?: readonly ContextContributor[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The intent a core Delivery node belongs to, found by walking structural
|
|
48
|
+
* edges (superseded records included). `undefined` when the node is not
|
|
49
|
+
* linked to any intent.
|
|
50
|
+
*/
|
|
51
|
+
export declare function findIntentOf(nodeId: string, nodes: readonly GraphNode[], edges: readonly Edge[]): GraphNode | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Resolves the current core Delivery lineage the node belongs to (§22).
|
|
54
|
+
* Only the five core node types exist in the graph, so rejected
|
|
55
|
+
* alternatives, review transcripts, obsolete reasoning and execution
|
|
56
|
+
* provenance can never appear; superseded records appear only under
|
|
57
|
+
* `history` when asked for.
|
|
58
|
+
*/
|
|
59
|
+
export declare function loadContext(project: Project, nodeId: string, options?: ContextOptions): DeliveryContext;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { PactwrightError } from "./errors.js";
|
|
2
|
+
import { deriveLineage } from "./graph/lineage.js";
|
|
3
|
+
/** Structural edge from a core node towards its intent, regardless of currency. */
|
|
4
|
+
const TOWARDS_INTENT = {
|
|
5
|
+
evidence: { type: "evidences", direction: "out" }, // evidence --evidences--> brief
|
|
6
|
+
brief: { type: "decomposes", direction: "out" }, // brief --decomposes--> contract
|
|
7
|
+
contract: { type: "selects", direction: "in" }, // decision --selects--> contract
|
|
8
|
+
decision: { type: "resolves", direction: "out" }, // decision --resolves--> intent
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The intent a core Delivery node belongs to, found by walking structural
|
|
12
|
+
* edges (superseded records included). `undefined` when the node is not
|
|
13
|
+
* linked to any intent.
|
|
14
|
+
*/
|
|
15
|
+
export function findIntentOf(nodeId, nodes, edges) {
|
|
16
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
let current = byId.get(nodeId);
|
|
19
|
+
while (current !== undefined && !seen.has(current.id)) {
|
|
20
|
+
if (current.type === "intent")
|
|
21
|
+
return current;
|
|
22
|
+
seen.add(current.id);
|
|
23
|
+
const step = TOWARDS_INTENT[current.type];
|
|
24
|
+
if (step === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
const id = current.id;
|
|
27
|
+
const link = edges.find((edge) => step.direction === "out"
|
|
28
|
+
? edge.type === step.type && edge.source === id
|
|
29
|
+
: edge.type === step.type && edge.target === id);
|
|
30
|
+
current =
|
|
31
|
+
link === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: byId.get(step.direction === "out" ? link.target : link.source);
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Every core node in the intent's tree, current or not, sorted by id. */
|
|
38
|
+
function lineageTree(intent, nodes, edges) {
|
|
39
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
40
|
+
const collect = (targets, type, direction, nodeType) => edges
|
|
41
|
+
.filter((edge) => edge.type === type &&
|
|
42
|
+
targets.includes(direction === "sources" ? edge.target : edge.source))
|
|
43
|
+
.map((edge) => byId.get(direction === "sources" ? edge.source : edge.target))
|
|
44
|
+
.filter((node) => node !== undefined && node.type === nodeType);
|
|
45
|
+
const decisions = collect([intent.id], "resolves", "sources", "decision");
|
|
46
|
+
const contracts = collect(decisions.map((d) => d.id), "selects", "targets", "contract");
|
|
47
|
+
const briefs = collect(contracts.map((c) => c.id), "decomposes", "sources", "brief");
|
|
48
|
+
const evidence = collect(briefs.map((b) => b.id), "evidences", "sources", "evidence");
|
|
49
|
+
return [intent, ...decisions, ...contracts, ...briefs, ...evidence].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolves the current core Delivery lineage the node belongs to (§22).
|
|
53
|
+
* Only the five core node types exist in the graph, so rejected
|
|
54
|
+
* alternatives, review transcripts, obsolete reasoning and execution
|
|
55
|
+
* provenance can never appear; superseded records appear only under
|
|
56
|
+
* `history` when asked for.
|
|
57
|
+
*/
|
|
58
|
+
export function loadContext(project, nodeId, options = {}) {
|
|
59
|
+
const { nodes, edges } = project.graph;
|
|
60
|
+
const node = nodes.find((candidate) => candidate.id === nodeId);
|
|
61
|
+
if (node === undefined) {
|
|
62
|
+
throw new PactwrightError("unknown-node", `"${nodeId}" is not a node in this project`);
|
|
63
|
+
}
|
|
64
|
+
const intent = findIntentOf(nodeId, nodes, edges);
|
|
65
|
+
if (intent === undefined) {
|
|
66
|
+
throw new PactwrightError("unlinked-node", `${node.type} "${nodeId}" is not linked to any intent; it has no Delivery lineage`);
|
|
67
|
+
}
|
|
68
|
+
const lineage = deriveLineage(intent.id, nodes, edges);
|
|
69
|
+
if (lineage === undefined) {
|
|
70
|
+
// The loader rejects ambiguous lineages, so this cannot happen for a loaded project.
|
|
71
|
+
throw new PactwrightError("ambiguous-lineage", `intent "${intent.id}" has an ambiguous lineage`);
|
|
72
|
+
}
|
|
73
|
+
const current = [
|
|
74
|
+
lineage.intent,
|
|
75
|
+
lineage.decision,
|
|
76
|
+
lineage.contract,
|
|
77
|
+
lineage.brief,
|
|
78
|
+
lineage.evidence,
|
|
79
|
+
].filter((record) => record !== undefined);
|
|
80
|
+
const currentIds = new Set(current.map((record) => record.id));
|
|
81
|
+
const extensions = {};
|
|
82
|
+
for (const contribute of options.contributors ?? []) {
|
|
83
|
+
const contribution = contribute({ project, lineage, history: options.history === true });
|
|
84
|
+
if (contribution === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
if (contribution.namespace in extensions) {
|
|
87
|
+
throw new PactwrightError("duplicate-context-namespace", `extension context namespace "${contribution.namespace}" was contributed twice`);
|
|
88
|
+
}
|
|
89
|
+
extensions[contribution.namespace] = contribution.context;
|
|
90
|
+
}
|
|
91
|
+
const base = {
|
|
92
|
+
requested: nodeId,
|
|
93
|
+
intent: intent.id,
|
|
94
|
+
state: lineage.state,
|
|
95
|
+
lineage: current,
|
|
96
|
+
requestedIsCurrent: currentIds.has(nodeId),
|
|
97
|
+
extensions,
|
|
98
|
+
};
|
|
99
|
+
if (options.history !== true)
|
|
100
|
+
return base;
|
|
101
|
+
const history = lineageTree(intent, nodes, edges)
|
|
102
|
+
.filter((record) => !currentIds.has(record.id))
|
|
103
|
+
.map((record) => ({
|
|
104
|
+
node: record,
|
|
105
|
+
supersededBy: edges
|
|
106
|
+
.filter((edge) => edge.type === "supersedes" && edge.target === record.id)
|
|
107
|
+
.map((edge) => edge.source)
|
|
108
|
+
.sort(),
|
|
109
|
+
}));
|
|
110
|
+
return { ...base, history };
|
|
111
|
+
}
|