@alphazede/bearing-lite 0.1.11 → 0.2.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/CONTRIBUTING.md +25 -0
- package/README.md +46 -14
- package/hooks/assurance-budget.cjs +149 -0
- package/hooks/closeout.cjs +16 -0
- package/hooks/com.anthropic.claude-code/mapping.md +72 -0
- package/hooks/com.cursor/hooks.json +11 -1
- package/hooks/hooks.json +45 -1
- package/hooks/plan-package.cjs +177 -0
- package/hooks/planning-review.cjs +9 -14
- package/hooks/policy.cjs +45 -0
- package/hooks/reconcile.cjs +145 -0
- package/hooks/te-capability.cjs +137 -0
- package/hooks/te-host.cjs +644 -0
- package/hooks/transition-order.cjs +32 -2
- package/lineups.json +1 -0
- package/package.json +4 -2
- package/plugin.json +1 -1
- package/schemas/authority.schema.json +75 -0
- package/schemas/event.schema.json +59 -0
- package/schemas/implementation.schema.json +150 -0
- package/schemas/journey.schema.json +101 -0
- package/schemas/lineups.schema.json +145 -0
- package/schemas/seit.schema.json +108 -0
- package/skills/bearing-lite/SKILL.md +20 -21
- package/skills/bearing-lite/references/assurance-policy.md +66 -0
- package/skills/bearing-lite/references/lineups.md +107 -0
- package/skills/bearing-lite/references/peer-synthesis.md +7 -3
- package/skills/bearing-lite/references/role-routing.mmd +2 -2
- package/skills/bearing-lite/references/task-state.md +4 -4
- package/skills/bearing-lite/references/task-state.mmd +1 -1
- package/skills/bearing-lite/templates/task.md +20 -11
- package/skills/crewmate/SKILL.md +3 -0
- package/skills/explorer/SKILL.md +7 -6
- package/skills/gather-supplies/SKILL.md +7 -1
- package/skills/integration-engineer/SKILL.md +41 -0
- package/skills/light-implementer/SKILL.md +53 -0
- package/skills/map-the-route/SKILL.md +9 -7
- package/skills/map-the-route/references/artifact-grammar.md +67 -32
- package/skills/park-ranger/SKILL.md +14 -9
- package/skills/plan-integrator/SKILL.md +46 -0
- package/skills/requirements-engineer/SKILL.md +57 -0
- package/skills/scribe/SKILL.md +34 -0
- package/skills/set-bearings/SKILL.md +16 -7
- package/skills/set-bearings/templates/workspace.md +28 -0
- package/skills/surveyor/SKILL.md +15 -9
- package/skills/systems-modeler/SKILL.md +35 -0
- package/skills/test-engineer/SKILL.md +48 -0
- package/skills/validator/SKILL.md +20 -27
- package/skills/validator/references/grading-rubric.md +5 -4
- package/skills/bearing-lite/templates/default-role-lineup.md +0 -26
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map the Route freeze (#70, #73, #77, #80): pure checks over a planning package.
|
|
5
|
+
* checkRoles: every command a slice runs whose seit procedure names an actor
|
|
6
|
+
* must name the slice's role. checkWorkClass validates light slices;
|
|
7
|
+
* checkPlanningRoles excludes planning-only roles from Expedition slices.
|
|
8
|
+
* verifyDigests: every planning input digest
|
|
9
|
+
* embedded in seit.json matches the file on disk; the manifest digest over
|
|
10
|
+
* those inputs plus seit.json matches implementation.json's
|
|
11
|
+
* planning_review.candidate_digest; missing artifacts, inputs, digests, or a
|
|
12
|
+
* specification Journey's SDoc register fail closed.
|
|
13
|
+
* CLI: node <plugin root>/hooks/plan-package.cjs <plan dir> -> JSON, exit 1 on any finding.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
const crypto = require("node:crypto");
|
|
19
|
+
|
|
20
|
+
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
|
|
21
|
+
|
|
22
|
+
/** Walk any object graph and yield slice-like nodes. */
|
|
23
|
+
function* slices(node) {
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
for (const item of node) yield* slices(item);
|
|
26
|
+
} else if (node && typeof node === "object") {
|
|
27
|
+
const ids = [...(node.command_ids || []), ...(node.post_step_command_ids || [])];
|
|
28
|
+
if (typeof node.id === "string" && typeof node.role === "string") {
|
|
29
|
+
yield { id: node.id, role: node.role, commandIds: ids };
|
|
30
|
+
}
|
|
31
|
+
for (const value of Object.values(node)) yield* slices(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkRoles(seit, implementation) {
|
|
36
|
+
const procedures = new Map(
|
|
37
|
+
(seit?.procedures_and_commands || []).map((p) => [p.id, p])
|
|
38
|
+
);
|
|
39
|
+
const findings = [];
|
|
40
|
+
for (const slice of slices(implementation)) {
|
|
41
|
+
for (const command_id of slice.commandIds) {
|
|
42
|
+
const procedure = procedures.get(command_id);
|
|
43
|
+
if (!procedure) {
|
|
44
|
+
findings.push({ code: "unknown_command_id", step: slice.id, command_id });
|
|
45
|
+
} else if (typeof procedure.actor === "string" && procedure.actor !== slice.role) {
|
|
46
|
+
findings.push({
|
|
47
|
+
code: "actor_role_mismatch",
|
|
48
|
+
step: slice.id,
|
|
49
|
+
command_id,
|
|
50
|
+
seit_actor: procedure.actor,
|
|
51
|
+
implementation_role: slice.role,
|
|
52
|
+
files: ["seit.json", "implementation.json"],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** #77: a light slice must run at least one command and be routed to the Light Implementer. */
|
|
61
|
+
function checkWorkClass(node, findings = []) {
|
|
62
|
+
if (Array.isArray(node)) node.forEach((item) => checkWorkClass(item, findings));
|
|
63
|
+
else if (node && typeof node === "object") {
|
|
64
|
+
if (node.work_class === "light" && typeof node.id === "string") {
|
|
65
|
+
if (!(node.command_ids || []).length) findings.push({ code: "light_slice_without_command", step: node.id });
|
|
66
|
+
if (node.role !== "Light Implementer") findings.push({ code: "light_slice_role", step: node.id, role: node.role });
|
|
67
|
+
}
|
|
68
|
+
for (const value of Object.values(node)) checkWorkClass(value, findings);
|
|
69
|
+
}
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** #80: Requirements Engineer is planning-only and never an Expedition slice. */
|
|
74
|
+
function checkPlanningRoles(node, findings = []) {
|
|
75
|
+
if (Array.isArray(node)) node.forEach((item) => checkPlanningRoles(item, findings));
|
|
76
|
+
else if (node && typeof node === "object") {
|
|
77
|
+
if (typeof node.role === "string" && node.role.startsWith("Requirements Engineer")) {
|
|
78
|
+
findings.push({
|
|
79
|
+
code: "planning_role_in_expedition",
|
|
80
|
+
step: typeof node.id === "string" ? node.id : null,
|
|
81
|
+
role: node.role,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const value of Object.values(node)) checkPlanningRoles(value, findings);
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function repoRoot(dir) {
|
|
90
|
+
let current = path.resolve(dir);
|
|
91
|
+
for (;;) {
|
|
92
|
+
if (fs.existsSync(path.join(current, ".git"))) return current;
|
|
93
|
+
const parent = path.dirname(current);
|
|
94
|
+
if (parent === current) return path.resolve(dir);
|
|
95
|
+
current = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readJson(file) {
|
|
100
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function verifyDigests(dir) {
|
|
104
|
+
const findings = [];
|
|
105
|
+
for (const name of ["seit.json", "implementation.json"]) {
|
|
106
|
+
if (!fs.existsSync(path.join(dir, name))) findings.push({ code: "missing_artifact", path: name });
|
|
107
|
+
}
|
|
108
|
+
if (findings.length) return { manifest_digest: null, findings };
|
|
109
|
+
const seit = readJson(path.join(dir, "seit.json"));
|
|
110
|
+
const implementation = readJson(path.join(dir, "implementation.json"));
|
|
111
|
+
const root = repoRoot(dir);
|
|
112
|
+
const inputs = seit?.source_baseline?.planning_inputs;
|
|
113
|
+
if (!Array.isArray(inputs) || !inputs.length) {
|
|
114
|
+
findings.push({ code: "missing_planning_inputs", path: "seit.json" });
|
|
115
|
+
}
|
|
116
|
+
const actual = [];
|
|
117
|
+
for (const input of inputs || []) {
|
|
118
|
+
if (typeof input?.path !== "string" || !/^[0-9a-f]{64}$/.test(input?.sha256 || "")) {
|
|
119
|
+
findings.push({ code: "malformed_planning_input", input });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const file = path.resolve(root, input.path);
|
|
123
|
+
if (!fs.existsSync(file)) {
|
|
124
|
+
findings.push({ code: "missing_input", path: input.path });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const digest = sha256(fs.readFileSync(file));
|
|
128
|
+
actual.push(digest);
|
|
129
|
+
if (digest !== input.sha256) {
|
|
130
|
+
findings.push({ code: "digest_mismatch", path: input.path, recorded: input.sha256, actual: digest });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// The manifest binds the planning inputs and seit.json itself (#70).
|
|
134
|
+
actual.push(sha256(fs.readFileSync(path.join(dir, "seit.json"))));
|
|
135
|
+
const manifest_digest = sha256(actual.join("\n"));
|
|
136
|
+
const recorded = implementation?.journey_settings?.planning_review?.candidate_digest;
|
|
137
|
+
if (typeof recorded !== "string" || !recorded) {
|
|
138
|
+
findings.push({ code: "missing_candidate_digest", actual: manifest_digest });
|
|
139
|
+
} else if (recorded !== manifest_digest) {
|
|
140
|
+
findings.push({ code: "candidate_digest_mismatch", recorded, actual: manifest_digest });
|
|
141
|
+
}
|
|
142
|
+
// #69: a specification Journey records an existing SDoc register at planning.
|
|
143
|
+
const settings = implementation?.journey_settings || {};
|
|
144
|
+
if (settings.journey_type === "specification") {
|
|
145
|
+
const register = settings.requirement_register;
|
|
146
|
+
if (typeof register !== "string" || !register.endsWith(".sdoc") || !fs.existsSync(path.resolve(root, register))) {
|
|
147
|
+
findings.push({ code: "missing_requirement_register", recorded: register ?? null });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { manifest_digest, findings };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function freeze(dir) {
|
|
154
|
+
const digests = verifyDigests(dir);
|
|
155
|
+
const seit = readJson(path.join(dir, "seit.json"));
|
|
156
|
+
const implementation = readJson(path.join(dir, "implementation.json"));
|
|
157
|
+
const findings = [
|
|
158
|
+
...checkRoles(seit, implementation),
|
|
159
|
+
...checkWorkClass(implementation),
|
|
160
|
+
...checkPlanningRoles(implementation),
|
|
161
|
+
...digests.findings,
|
|
162
|
+
];
|
|
163
|
+
return { outcome: findings.length ? "FAIL" : "PASS", manifest_digest: digests.manifest_digest, findings };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { checkRoles, checkWorkClass, checkPlanningRoles, verifyDigests, freeze };
|
|
167
|
+
|
|
168
|
+
if (require.main === module) {
|
|
169
|
+
const dir = process.argv[2];
|
|
170
|
+
if (!dir) {
|
|
171
|
+
process.stderr.write("usage: node hooks/plan-package.cjs <plan dir>\n");
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const result = freeze(dir);
|
|
175
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
176
|
+
process.exit(result.outcome === "PASS" ? 0 : 1);
|
|
177
|
+
}
|
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const POLICY =
|
|
4
|
-
reviewer_slots_min: 2,
|
|
5
|
-
reviewer_slots_max: 2,
|
|
6
|
-
independence_required: true,
|
|
7
|
-
isolated_findings_until_aggregation: true,
|
|
8
|
-
candidate_fields: ["candidate_ref", "candidate_revision", "candidate_digest"],
|
|
9
|
-
shared_candidate_required: true,
|
|
10
|
-
review_rounds: 1,
|
|
11
|
-
aggregated_repairs_max: 1,
|
|
12
|
-
post_repair_gate: "deterministic_PASS",
|
|
13
|
-
automatic_rereview: "prohibited",
|
|
14
|
-
slot_exhaustion_outcome: "FAIL_ROUND",
|
|
15
|
-
terminal_outcomes: ["HALT", "OWNER_AMENDMENT_REQUIRED"],
|
|
16
|
-
});
|
|
3
|
+
const { PLANNING_REVIEW_POLICY: POLICY } = require("./policy.cjs");
|
|
17
4
|
|
|
18
5
|
const sameCandidate = (a, b) =>
|
|
19
6
|
POLICY.candidate_fields.every(
|
|
@@ -24,6 +11,14 @@ function evaluatePlanningReview(input) {
|
|
|
24
11
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
25
12
|
return { outcome: "NEEDS_MORE_EVIDENCE", reason: "planning_review_missing" };
|
|
26
13
|
}
|
|
14
|
+
// #69: a specification Journey gates its requirement register at planning;
|
|
15
|
+
// existence on disk is checked by hooks/plan-package.cjs at the freeze.
|
|
16
|
+
if (
|
|
17
|
+
input.journey_type === "specification" &&
|
|
18
|
+
!(typeof input.requirement_register === "string" && input.requirement_register.endsWith(".sdoc"))
|
|
19
|
+
) {
|
|
20
|
+
return { outcome: "NEEDS_MORE_EVIDENCE", reason: "missing_requirement_register" };
|
|
21
|
+
}
|
|
27
22
|
const slots = Array.isArray(input.reviewer_slots) ? input.reviewer_slots : [];
|
|
28
23
|
const ids = slots.map((slot) => slot?.slot_id);
|
|
29
24
|
const primaryRoutes = slots.map((slot) => slot?.primary_route_ref);
|
package/hooks/policy.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single policy source for the planning-review and assurance-budget
|
|
5
|
+
* evaluators (#63). The JSON blocks in
|
|
6
|
+
* skills/bearing-lite/references/review-policy.md and assurance-policy.md
|
|
7
|
+
* must equal these objects; test/policy-drift.test.mjs enforces it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PLANNING_REVIEW_POLICY = Object.freeze({
|
|
11
|
+
reviewer_slots_min: 2,
|
|
12
|
+
reviewer_slots_max: 2,
|
|
13
|
+
independence_required: true,
|
|
14
|
+
isolated_findings_until_aggregation: true,
|
|
15
|
+
candidate_fields: ["candidate_ref", "candidate_revision", "candidate_digest"],
|
|
16
|
+
shared_candidate_required: true,
|
|
17
|
+
review_rounds: 1,
|
|
18
|
+
aggregated_repairs_max: 1,
|
|
19
|
+
post_repair_gate: "deterministic_PASS",
|
|
20
|
+
automatic_rereview: "prohibited",
|
|
21
|
+
slot_exhaustion_outcome: "FAIL_ROUND",
|
|
22
|
+
terminal_outcomes: ["HALT", "OWNER_AMENDMENT_REQUIRED"],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ASSURANCE_BUDGET_POLICY = Object.freeze({
|
|
26
|
+
budget_scope: "per_declared_phase_or_wave",
|
|
27
|
+
review_rounds: 1,
|
|
28
|
+
aggregated_repairs_max: 1,
|
|
29
|
+
post_repair_gate: "deterministic_PASS",
|
|
30
|
+
automatic_phase_or_wave_end_review: "required",
|
|
31
|
+
automatic_per_slice_review: "prohibited",
|
|
32
|
+
post_repair_rereview: "prohibited",
|
|
33
|
+
automatic_rereview_of_same_unit: "prohibited",
|
|
34
|
+
budget_reset_on_candidate_change: false,
|
|
35
|
+
budget_reset_on_model_change: false,
|
|
36
|
+
budget_reset_on_harness_change: false,
|
|
37
|
+
budget_reset_on_role_change: false,
|
|
38
|
+
budget_reset_on_session_change: false,
|
|
39
|
+
budget_reset_on_resume: false,
|
|
40
|
+
budget_reset_on_alias_or_rename: false,
|
|
41
|
+
budget_reset_condition:
|
|
42
|
+
"next_distinct_declared_phase_or_wave_present_in_the_frozen_declaration",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
module.exports = { PLANNING_REVIEW_POLICY, ASSURANCE_BUDGET_POLICY };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Deterministically apply evidence events to Journey state (#64). */
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
|
|
6
|
+
const REQUIRED = [
|
|
7
|
+
"schema_version", "event_id", "source", "journey", "repository", "unit",
|
|
8
|
+
"candidate_revision", "generation", "evidence", "transition", "occurred_at",
|
|
9
|
+
];
|
|
10
|
+
const KINDS = new Set(["implementation", "verification", "assurance", "acceptance", "merge", "issue_close"]);
|
|
11
|
+
const FIELDS = {
|
|
12
|
+
implementation: "implementation",
|
|
13
|
+
verification: "verification",
|
|
14
|
+
assurance: "assurance",
|
|
15
|
+
acceptance: "acceptance",
|
|
16
|
+
merge: "observed_merge",
|
|
17
|
+
issue_close: "observed_issue_close",
|
|
18
|
+
};
|
|
19
|
+
const PREDECESSORS = { verification: "implementation", assurance: "verification", acceptance: "assurance" };
|
|
20
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
const nonempty = (value) => typeof value === "string" && value.length > 0;
|
|
22
|
+
const only = (value, names) => Object.keys(value).every((name) => names.includes(name));
|
|
23
|
+
const dateTime = (value) => nonempty(value) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
24
|
+
|
|
25
|
+
function receiptFor(event) {
|
|
26
|
+
return {
|
|
27
|
+
event_id: event?.event_id ?? null,
|
|
28
|
+
applied: false,
|
|
29
|
+
code: "invalid_event",
|
|
30
|
+
transition: event?.transition?.kind ?? null,
|
|
31
|
+
slice: event?.unit?.slice ?? null,
|
|
32
|
+
candidate_revision: event?.candidate_revision ?? null,
|
|
33
|
+
evidence_sha256: event?.evidence?.sha256 ?? null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function structuralError(event) {
|
|
38
|
+
if (!object(event) || !REQUIRED.every((name) => Object.hasOwn(event, name)) || !only(event, REQUIRED)) return true;
|
|
39
|
+
if (event.schema_version !== 1 || !nonempty(event.event_id) || !nonempty(event.source) ||
|
|
40
|
+
!nonempty(event.journey) || !nonempty(event.repository) || !nonempty(event.candidate_revision) ||
|
|
41
|
+
!Number.isInteger(event.generation) || event.generation < 0 || !dateTime(event.occurred_at)) return true;
|
|
42
|
+
if (!object(event.unit) || !only(event.unit, ["wave", "slice"]) || !nonempty(event.unit.slice) ||
|
|
43
|
+
(Object.hasOwn(event.unit, "wave") && !nonempty(event.unit.wave))) return true;
|
|
44
|
+
if (!object(event.evidence) || !only(event.evidence, ["ref", "sha256"])) return true;
|
|
45
|
+
if (!object(event.transition) || !only(event.transition, ["kind", "verdict"]) ||
|
|
46
|
+
!KINDS.has(event.transition.kind) || !nonempty(event.transition.verdict)) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reject(state, receipt, code) {
|
|
51
|
+
return { state, receipt: { ...receipt, code, reason: code } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function applyEvent(state, event, options = {}) {
|
|
55
|
+
const receipt = receiptFor(event);
|
|
56
|
+
if (structuralError(event)) return reject(state, receipt, "invalid_event");
|
|
57
|
+
if (options.expected_version !== undefined && options.expected_version !== state.version) {
|
|
58
|
+
return reject(state, receipt, "stale_write");
|
|
59
|
+
}
|
|
60
|
+
if (state.applied_event_ids.includes(event.event_id)) return reject(state, receipt, "duplicate_event");
|
|
61
|
+
if (event.journey !== state.journey || event.repository !== state.repository) {
|
|
62
|
+
return reject(state, receipt, "unrelated_journey");
|
|
63
|
+
}
|
|
64
|
+
if (event.generation < state.generation) return reject(state, receipt, "stale_generation");
|
|
65
|
+
|
|
66
|
+
const hasEvidence = nonempty(event.evidence.ref) && nonempty(event.evidence.sha256);
|
|
67
|
+
if (!hasEvidence && event.transition.verdict === "PASS") return reject(state, receipt, "bare_pass");
|
|
68
|
+
if (!hasEvidence) return reject(state, receipt, "missing_evidence");
|
|
69
|
+
if (!/^[0-9a-f]{64}$/.test(event.evidence.sha256)) return reject(state, receipt, "invalid_event");
|
|
70
|
+
|
|
71
|
+
const kind = event.transition.kind;
|
|
72
|
+
const slice = state.slices[event.unit.slice] || {};
|
|
73
|
+
const field = FIELDS[kind];
|
|
74
|
+
const prior = slice[field];
|
|
75
|
+
const history = prior ? {
|
|
76
|
+
...prior.history,
|
|
77
|
+
[prior.candidate_revision]: { verdict: prior.verdict, sha256: prior.evidence.sha256 },
|
|
78
|
+
} : {};
|
|
79
|
+
const h = Object.hasOwn(history, event.candidate_revision) ? history[event.candidate_revision] : undefined;
|
|
80
|
+
if (h && (h.verdict !== event.transition.verdict || h.sha256 !== event.evidence.sha256)) {
|
|
81
|
+
return reject(state, receipt, "conflicting_receipt");
|
|
82
|
+
}
|
|
83
|
+
if (h) {
|
|
84
|
+
return reject(state, receipt, event.candidate_revision === prior.candidate_revision ? "duplicate_event" : "stale_candidate");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const implementation = slice.implementation;
|
|
88
|
+
if (["verification", "assurance", "acceptance"].includes(kind) &&
|
|
89
|
+
implementation?.candidate_revision !== event.candidate_revision) {
|
|
90
|
+
return reject(state, receipt, implementation ? "stale_candidate" : "predecessor_missing");
|
|
91
|
+
}
|
|
92
|
+
const predecessor = PREDECESSORS[kind];
|
|
93
|
+
if (predecessor && slice[predecessor]?.candidate_revision !== event.candidate_revision) {
|
|
94
|
+
return reject(state, receipt, "predecessor_missing");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const entry = {
|
|
98
|
+
candidate_revision: event.candidate_revision,
|
|
99
|
+
verdict: event.transition.verdict,
|
|
100
|
+
evidence: { ...event.evidence },
|
|
101
|
+
event_id: event.event_id,
|
|
102
|
+
history: { ...history, [event.candidate_revision]: { verdict: event.transition.verdict, sha256: event.evidence.sha256 } },
|
|
103
|
+
};
|
|
104
|
+
const next = {
|
|
105
|
+
...state,
|
|
106
|
+
generation: Math.max(state.generation, event.generation),
|
|
107
|
+
version: state.version + 1,
|
|
108
|
+
applied_event_ids: [...state.applied_event_ids, event.event_id],
|
|
109
|
+
slices: { ...state.slices, [event.unit.slice]: { ...slice, [field]: entry } },
|
|
110
|
+
};
|
|
111
|
+
return { state: next, receipt: { ...receipt, applied: true, code: "applied" } };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function replay(state, events) {
|
|
115
|
+
const receipts = [];
|
|
116
|
+
let current = state;
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
const result = applyEvent(current, event);
|
|
119
|
+
current = result.state;
|
|
120
|
+
receipts.push(result.receipt);
|
|
121
|
+
}
|
|
122
|
+
return { state: current, receipts };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function main(args = process.argv.slice(2)) {
|
|
126
|
+
const [stateFile, eventsFile] = args;
|
|
127
|
+
try {
|
|
128
|
+
if (!stateFile || !eventsFile) throw new Error("usage: node hooks/reconcile.cjs <state.json> <events.ndjson>");
|
|
129
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
130
|
+
const events = fs.readFileSync(eventsFile, "utf8").split(/\r?\n/).filter(Boolean).map(JSON.parse);
|
|
131
|
+
const result = replay(state, events);
|
|
132
|
+
const temporary = `${stateFile}.tmp-${process.pid}`;
|
|
133
|
+
fs.writeFileSync(temporary, JSON.stringify(result.state, null, 2) + "\n");
|
|
134
|
+
fs.renameSync(temporary, stateFile);
|
|
135
|
+
for (const receipt of result.receipts) process.stdout.write(JSON.stringify(receipt) + "\n");
|
|
136
|
+
return 0;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
process.stderr.write(`${error.message}\n`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { applyEvent, replay };
|
|
144
|
+
|
|
145
|
+
if (require.main === module) process.exitCode = main();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test Engineering capability resolver (SEIT-EMV-017, SEIT-EMV-036).
|
|
5
|
+
*
|
|
6
|
+
* Bearing Lite ships no Test Engineering evaluator and no Test Engineering
|
|
7
|
+
* method content. This module only answers one question: is the
|
|
8
|
+
* `test-engineering` capability active for this workspace, and if so, is the
|
|
9
|
+
* real evaluator loadable at its capability path?
|
|
10
|
+
*
|
|
11
|
+
* Activation is `selected OR required`.
|
|
12
|
+
* - neither -> INACTIVE. Not a failure; the class simply does not run.
|
|
13
|
+
* - active, gone -> TYPED_GAP. Never silent success, never invented policy.
|
|
14
|
+
* - active, here -> ACTIVE, with the loaded evaluator module.
|
|
15
|
+
*
|
|
16
|
+
* `invented` is always `false`: this resolver never substitutes a local
|
|
17
|
+
* decision for the evaluator's.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require("node:fs");
|
|
21
|
+
const path = require("node:path");
|
|
22
|
+
|
|
23
|
+
const CAPABILITY = "test-engineering";
|
|
24
|
+
const STATUSES = Object.freeze(["INACTIVE", "ACTIVE", "TYPED_GAP"]);
|
|
25
|
+
/** Capability path, relative to the workspace root. */
|
|
26
|
+
const EVALUATOR_RELATIVE_PATH = path.join(
|
|
27
|
+
"skills",
|
|
28
|
+
CAPABILITY,
|
|
29
|
+
"hooks",
|
|
30
|
+
"te-evaluator.cjs"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} workspaceRoot
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function evaluatorPath(workspaceRoot) {
|
|
38
|
+
const root =
|
|
39
|
+
typeof workspaceRoot === "string" && workspaceRoot.trim()
|
|
40
|
+
? workspaceRoot.trim()
|
|
41
|
+
: process.cwd();
|
|
42
|
+
return path.resolve(root, EVALUATOR_RELATIVE_PATH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load the evaluator at the capability path. Absence, a load failure, or a
|
|
47
|
+
* module that does not implement the evaluator contract are all the same
|
|
48
|
+
* observable: the capability is unavailable.
|
|
49
|
+
* @param {unknown} workspaceRoot
|
|
50
|
+
* @returns {{ evaluateTestWrite: Function, evaluateCompletion: Function } | null}
|
|
51
|
+
*/
|
|
52
|
+
function loadEvaluator(workspaceRoot) {
|
|
53
|
+
const target = evaluatorPath(workspaceRoot);
|
|
54
|
+
let stat;
|
|
55
|
+
try {
|
|
56
|
+
stat = fs.statSync(target);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!stat.isFile()) return null;
|
|
61
|
+
let mod;
|
|
62
|
+
try {
|
|
63
|
+
mod = require(target);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
!mod ||
|
|
69
|
+
typeof mod.evaluateTestWrite !== "function" ||
|
|
70
|
+
typeof mod.evaluateCompletion !== "function"
|
|
71
|
+
) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return mod;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the `test-engineering` capability for one workspace.
|
|
79
|
+
* @param {{ workspaceRoot?: string, selected?: boolean, required?: boolean }} [input]
|
|
80
|
+
*/
|
|
81
|
+
function resolve(input) {
|
|
82
|
+
const opts = input !== null && typeof input === "object" ? input : {};
|
|
83
|
+
const selected = opts.selected === true;
|
|
84
|
+
const required = opts.required === true;
|
|
85
|
+
const base = {
|
|
86
|
+
capability: CAPABILITY,
|
|
87
|
+
selected,
|
|
88
|
+
required,
|
|
89
|
+
invented: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (!selected && !required) {
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
active: false,
|
|
96
|
+
status: "INACTIVE",
|
|
97
|
+
evaluator: null,
|
|
98
|
+
code: "capability_inactive",
|
|
99
|
+
message:
|
|
100
|
+
`capability "${CAPABILITY}" is neither selected nor required; ` +
|
|
101
|
+
"the class does not run and this is not a failure",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const evaluator = loadEvaluator(opts.workspaceRoot);
|
|
106
|
+
if (!evaluator) {
|
|
107
|
+
return {
|
|
108
|
+
...base,
|
|
109
|
+
active: true,
|
|
110
|
+
status: "TYPED_GAP",
|
|
111
|
+
evaluator: null,
|
|
112
|
+
code: "typed_capability_gap",
|
|
113
|
+
message:
|
|
114
|
+
`selected or required capability "${CAPABILITY}" is unavailable: ` +
|
|
115
|
+
`no loadable evaluator at ${EVALUATOR_RELATIVE_PATH}`,
|
|
116
|
+
recovery:
|
|
117
|
+
"install the test-engineering method capability in this workspace, " +
|
|
118
|
+
"or clear the selected/required activation for this task",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
active: true,
|
|
125
|
+
status: "ACTIVE",
|
|
126
|
+
evaluator,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
CAPABILITY,
|
|
132
|
+
STATUSES,
|
|
133
|
+
EVALUATOR_RELATIVE_PATH,
|
|
134
|
+
evaluatorPath,
|
|
135
|
+
loadEvaluator,
|
|
136
|
+
resolve,
|
|
137
|
+
};
|