@alphazede/bearing-lite 0.2.0 → 0.2.2
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/README.md +17 -1
- package/hooks/assurance-budget.cjs +1 -19
- package/hooks/closeout.cjs +33 -0
- package/hooks/owner-stops.cjs +154 -0
- 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-host.cjs +25 -1
- package/hooks/transition-order.cjs +12 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/schemas/authority.schema.json +55 -34
- package/schemas/event.schema.json +59 -0
- package/schemas/implementation.schema.json +41 -18
- package/schemas/journey.schema.json +142 -35
- package/skills/bearing-lite/SKILL.md +14 -15
- package/skills/bearing-lite/references/lineups.md +31 -0
- package/skills/bearing-lite/references/owner-stops.md +122 -0
- package/skills/bearing-lite/references/review-policy.md +5 -0
- package/skills/bearing-lite/templates/task.md +4 -1
- package/skills/explorer/SKILL.md +3 -3
- package/skills/light-implementer/SKILL.md +53 -0
- package/skills/map-the-route/SKILL.md +5 -3
- package/skills/map-the-route/references/artifact-grammar.md +23 -6
- package/skills/plan-integrator/SKILL.md +11 -2
- package/skills/requirements-engineer/SKILL.md +57 -0
- package/skills/scribe/SKILL.md +4 -0
- package/skills/set-bearings/SKILL.md +16 -7
- package/skills/set-bearings/templates/workspace.md +28 -0
- package/skills/test-engineer/SKILL.md +2 -0
- package/skills/bearing-lite/templates/default-role-lineup.md +0 -32
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ register hooks. That path remains first-class. See
|
|
|
118
118
|
|
|
119
119
|
Bearing Lite never selects models, providers, credentials, or launchers. The
|
|
120
120
|
owner provides each role's primary/fallback agent or harness, model, and
|
|
121
|
-
reasoning level in `~/.agents/bearing-lite/
|
|
121
|
+
reasoning level in `~/.agents/bearing-lite/lineups.json`, then confirms
|
|
122
122
|
the applicable Journey snapshot before implementation.
|
|
123
123
|
|
|
124
124
|
## Routes and scaling
|
|
@@ -151,12 +151,23 @@ explain orientation; they never authorize a transition.
|
|
|
151
151
|
|
|
152
152
|
## Roles and authority
|
|
153
153
|
|
|
154
|
+
Owner questions follow [the owner-stop policy](skills/bearing-lite/references/owner-stops.md).
|
|
155
|
+
The integrated approval records bounded continuation with exclusions and expiry.
|
|
156
|
+
The Router applies approved policy and fallbacks, batches nonblocking questions,
|
|
157
|
+
and continues ready independent work while dependent work waits. Approval shows
|
|
158
|
+
the first-package summary or subsequent changes with the complete package available.
|
|
159
|
+
Local receipt metrics distinguish questions, round trips, response intervals and
|
|
160
|
+
fully blocked time; no telemetry leaves the checkout. The transition preflight
|
|
161
|
+
and scheduling remain procedural, including on skills-only hosts. A legacy
|
|
162
|
+
Journey without explicit timing coverage reports unavailable, not zero wait.
|
|
163
|
+
|
|
154
164
|
| Role | What it is | Executes | Notes |
|
|
155
165
|
|---|---|---|---|
|
|
156
166
|
| **Router** | Stateful planning controller | no | User-facing; planning-state writer; Expedition sequencing |
|
|
157
167
|
| **Navigator** | Compatibility diagnostic | no | Not a normal role; existing plans reroute to Router |
|
|
158
168
|
| **Explorer** | One-wave controller | no | Dispatches Crewmates; owns proven-independent lanes |
|
|
159
169
|
| **Crewmate** | Bounded implementer | yes | Split test-writing versus product; neither self-certifies |
|
|
170
|
+
| **Light Implementer** | Mechanical implementer | yes | `work_class: light` slices only; verified by the packet's command; no repair loop |
|
|
160
171
|
| **Scribe** | Event side lane | no | Transcribes; cannot activate authority |
|
|
161
172
|
| **Plan Integrator** | Artifact reconciliation | no | Generates `implementation.json` and `review.html` |
|
|
162
173
|
| **Systems Modeler** | Engineering views | no | After requirements; before design finalization |
|
|
@@ -201,6 +212,11 @@ Ordinary execution corrections remain bounded. The assurance gate allows one
|
|
|
201
212
|
review-directed repair, followed by deterministic coordinator verification and
|
|
202
213
|
no second review. Diagrams never create state or authorize transitions.
|
|
203
214
|
|
|
215
|
+
`hooks/reconcile.cjs` deterministically applies evidence events to Journey state.
|
|
216
|
+
It is a short-lived Router-run invocation, not a daemon or host event adapter;
|
|
217
|
+
hosts emit no events today, so invocation remains a procedural limitation.
|
|
218
|
+
It observes merge and issue closure but never grants acceptance, merges, or closes issues.
|
|
219
|
+
|
|
204
220
|
## Implementation process (explanatory)
|
|
205
221
|
|
|
206
222
|
Default packet completion is author self-check plus coordinator confirmation.
|
|
@@ -9,25 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
const fs = require("node:fs");
|
|
11
11
|
|
|
12
|
-
const POLICY =
|
|
13
|
-
budget_scope: "per_declared_phase_or_wave",
|
|
14
|
-
review_rounds: 1,
|
|
15
|
-
aggregated_repairs_max: 1,
|
|
16
|
-
post_repair_gate: "deterministic_PASS",
|
|
17
|
-
automatic_phase_or_wave_end_review: "required",
|
|
18
|
-
automatic_per_slice_review: "prohibited",
|
|
19
|
-
post_repair_rereview: "prohibited",
|
|
20
|
-
automatic_rereview_of_same_unit: "prohibited",
|
|
21
|
-
budget_reset_on_candidate_change: false,
|
|
22
|
-
budget_reset_on_model_change: false,
|
|
23
|
-
budget_reset_on_harness_change: false,
|
|
24
|
-
budget_reset_on_role_change: false,
|
|
25
|
-
budget_reset_on_session_change: false,
|
|
26
|
-
budget_reset_on_resume: false,
|
|
27
|
-
budget_reset_on_alias_or_rename: false,
|
|
28
|
-
budget_reset_condition:
|
|
29
|
-
"next_distinct_declared_phase_or_wave_present_in_the_frozen_declaration",
|
|
30
|
-
});
|
|
12
|
+
const { ASSURANCE_BUDGET_POLICY: POLICY } = require("./policy.cjs");
|
|
31
13
|
|
|
32
14
|
/** The sentinel unit when the frozen declaration names neither waves nor phases. */
|
|
33
15
|
const DIRECT = "direct";
|
package/hooks/closeout.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
+
const { ownerWaitMetrics } = require("./owner-stops.cjs");
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Bearing Lite return/closeout adapter (CONTRACT-HOOK-01).
|
|
6
8
|
* Advisory for ordinary handoff reminders; may BLOCK only protected completion.
|
|
@@ -39,6 +41,19 @@ const VERDICT_VALUES = new Set([
|
|
|
39
41
|
"WAITING_ON",
|
|
40
42
|
]);
|
|
41
43
|
|
|
44
|
+
/** Closed verdicts that refuse protected completion (#55). */
|
|
45
|
+
const NEGATIVE_VERDICTS = new Set([
|
|
46
|
+
"BLOCK",
|
|
47
|
+
"FAIL",
|
|
48
|
+
"GAPS",
|
|
49
|
+
"NEEDS_MORE_EVIDENCE",
|
|
50
|
+
"OWNER_DECISION_REQUIRED",
|
|
51
|
+
"PARTIAL",
|
|
52
|
+
"REPAIR_REQUIRED",
|
|
53
|
+
"REROUTED",
|
|
54
|
+
"WAITING_ON",
|
|
55
|
+
]);
|
|
56
|
+
|
|
42
57
|
const RECOVERY_UNAVAILABLE =
|
|
43
58
|
"Report UNAVAILABLE, complete the handoff checklist manually, and do not request protected completion until required fields and assurance are present";
|
|
44
59
|
const RECOVERY_HANDOFF =
|
|
@@ -141,6 +156,21 @@ function evaluate(input) {
|
|
|
141
156
|
return result("ADVISE", "channel_open", RECOVERY_CHANNEL);
|
|
142
157
|
}
|
|
143
158
|
|
|
159
|
+
// Explicit invocation by Router; native Stop mappings do not supply Journey JSON.
|
|
160
|
+
if (input.action_kind === "owner_wait_summary") {
|
|
161
|
+
if (input.mode === "protected_completion" || input.protected_completion === true) {
|
|
162
|
+
return unavailable("owner_wait_summary_is_not_completion");
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const metrics = ownerWaitMetrics(input.journey, input.as_of);
|
|
166
|
+
return { ...result("ADVISE", "owner_wait:" + metrics.status,
|
|
167
|
+
"Render metrics and coverage in the wave or closeout receipt; no authority is granted"),
|
|
168
|
+
owner_wait: metrics };
|
|
169
|
+
} catch {
|
|
170
|
+
return unavailable("owner_wait_records_invalid");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
144
174
|
const mode =
|
|
145
175
|
input.mode === "protected_completion" || input.protected_completion === true
|
|
146
176
|
? "protected_completion"
|
|
@@ -169,6 +199,8 @@ function evaluate(input) {
|
|
|
169
199
|
const blockers = [];
|
|
170
200
|
const handoffProblems = [...missing, ...invalid];
|
|
171
201
|
if (handoffProblems.length > 0) blockers.push("handoff:" + handoffProblems.join(","));
|
|
202
|
+
const verdict = String((isPlainObject(input.handoff) ? input.handoff : input).verdict ?? "").trim();
|
|
203
|
+
if (NEGATIVE_VERDICTS.has(verdict)) blockers.push("verdict:" + verdict);
|
|
172
204
|
if (missingAssurance.length > 0) {
|
|
173
205
|
blockers.push("assurance:" + missingAssurance.join(","));
|
|
174
206
|
}
|
|
@@ -283,6 +315,7 @@ module.exports = {
|
|
|
283
315
|
ENFORCEMENT,
|
|
284
316
|
HANDOFF_FIELDS,
|
|
285
317
|
VERDICT_VALUES,
|
|
318
|
+
NEGATIVE_VERDICTS,
|
|
286
319
|
evaluate,
|
|
287
320
|
};
|
|
288
321
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Procedural owner-stop preflight and local receipt metrics; never grants authority.
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
6
|
+
const text = (v) => typeof v === "string" && v.trim().length > 0;
|
|
7
|
+
const classes = ["A", "B", "C", "D", "E", "F"];
|
|
8
|
+
const exclusions = ["scope_change", "budget_exhaustion", "owner_hold", "owner_only_actions"];
|
|
9
|
+
|
|
10
|
+
function timestamp(value) {
|
|
11
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) {
|
|
12
|
+
throw new Error("invalid_utc_timestamp");
|
|
13
|
+
}
|
|
14
|
+
const n = Date.parse(value);
|
|
15
|
+
if (!Number.isFinite(n) || new Date(n).toISOString() !== (value.includes(".") ? value : value.replace("Z", ".000Z"))) {
|
|
16
|
+
throw new Error("invalid_utc_timestamp");
|
|
17
|
+
}
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function evaluateOwnerStop(input) {
|
|
22
|
+
const unknown = (reason) => ({ disposition: "NEEDS_MORE_EVIDENCE", reason });
|
|
23
|
+
if (!object(input) || !classes.includes(input.class) || typeof input.blocking !== "boolean" || !text(input.evidence_ref)) {
|
|
24
|
+
return unknown("owner_stop_input_missing");
|
|
25
|
+
}
|
|
26
|
+
if (["C", "E", "F"].includes(input.class)) {
|
|
27
|
+
return { disposition: input.blocking ? "ASK" : "QUEUE", reason: "owner_boundary" };
|
|
28
|
+
}
|
|
29
|
+
const a = input.authority;
|
|
30
|
+
const grant = a?.continuation;
|
|
31
|
+
if (!object(a) || a.effective !== true || a.state !== "active" || !text(a.id) ||
|
|
32
|
+
!object(a.approval_receipt) || Object.keys(a.approval_receipt).length === 0 ||
|
|
33
|
+
!object(grant) || grant.granted !== true || !text(grant.owner_decision_id) ||
|
|
34
|
+
!Array.isArray(a.granting_owner_decisions) || !a.granting_owner_decisions.includes(grant.owner_decision_id) ||
|
|
35
|
+
!Array.isArray(a.expiry_conditions) || !a.expiry_conditions.length || !a.expiry_conditions.every(text) ||
|
|
36
|
+
!Array.isArray(grant.exclusions) || !exclusions.every((item) => grant.exclusions.includes(item))) {
|
|
37
|
+
return unknown("continuation_grant_missing");
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const now = timestamp(input.checked_at);
|
|
41
|
+
if (grant.expires_at !== null && timestamp(grant.expires_at) <= now) return unknown("continuation_expired");
|
|
42
|
+
} catch {
|
|
43
|
+
return unknown("continuation_expiry_unverified");
|
|
44
|
+
}
|
|
45
|
+
const checks = ["scope", "budget", "exclusions", "expiry", "owner_hold"];
|
|
46
|
+
if (input.class === "A") checks.push("policy");
|
|
47
|
+
if (input.class === "B") checks.push("fallback");
|
|
48
|
+
if (!object(input.checks) || !checks.every((key) => input.checks[key] === true) || !text(input.resolution_ref)) {
|
|
49
|
+
return unknown("continuation_conditions_unverified");
|
|
50
|
+
}
|
|
51
|
+
return { disposition: "CONTINUE", reason: "approved_resolution", authority_id: a.id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unionLength(intervals) {
|
|
55
|
+
let total = 0;
|
|
56
|
+
let end = -Infinity;
|
|
57
|
+
for (const [start, stop] of intervals.sort((a, b) => a[0] - b[0])) {
|
|
58
|
+
total += Math.max(0, stop - Math.max(start, end));
|
|
59
|
+
end = Math.max(end, stop);
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ownerWaitMetrics(journey, asOf) {
|
|
65
|
+
if (!object(journey)) throw new Error("journey_missing");
|
|
66
|
+
if (journey.owner_wait_tracking === undefined || journey.owner_wait_tracking === false) {
|
|
67
|
+
return { status: "unavailable", reason: "owner_wait_not_tracked" };
|
|
68
|
+
}
|
|
69
|
+
if (journey.owner_wait_tracking !== true || !Array.isArray(journey.decisions) ||
|
|
70
|
+
!Array.isArray(journey.open_decisions) || !Array.isArray(journey.owner_blocked_intervals)) {
|
|
71
|
+
throw new Error("owner_wait_records_missing");
|
|
72
|
+
}
|
|
73
|
+
const now = timestamp(asOf);
|
|
74
|
+
const counts = Object.fromEntries(classes.map((key) => [key, 0]));
|
|
75
|
+
const metrics = { status: "measured", as_of: asOf, decisions_asked: 0, round_trips: 0,
|
|
76
|
+
queued: 0, pending: 0, cancelled: 0, by_class: counts,
|
|
77
|
+
response_ms: 0, pending_response_ms: 0, response_window_ms: 0, blocked_ms: 0 };
|
|
78
|
+
const ids = new Set();
|
|
79
|
+
const rounds = new Map();
|
|
80
|
+
const responses = [];
|
|
81
|
+
const windows = new Map();
|
|
82
|
+
for (const q of [...journey.decisions, ...journey.open_decisions]) {
|
|
83
|
+
if (!object(q) || q.record_type !== "owner_stop") continue;
|
|
84
|
+
if (!text(q.id) || ids.has(q.id)) throw new Error("duplicate_or_missing_decision_id");
|
|
85
|
+
ids.add(q.id);
|
|
86
|
+
if (!classes.includes(q.class) || !["queued", "asked", "answered", "cancelled"].includes(q.status) ||
|
|
87
|
+
!text(q.question) || !text(q.evidence_ref) || !text(q.why_owner) ||
|
|
88
|
+
typeof q.blocking !== "boolean" || !Array.isArray(q.affected_slices) || !q.affected_slices.every(text) ||
|
|
89
|
+
!["asked_at", "answered_at", "cancelled_at", "round_trip_id"].every((key) => Object.hasOwn(q, key))) {
|
|
90
|
+
throw new Error("invalid_owner_stop_record");
|
|
91
|
+
}
|
|
92
|
+
const asked = q.asked_at === null ? null : timestamp(q.asked_at);
|
|
93
|
+
const answered = q.answered_at === null ? null : timestamp(q.answered_at);
|
|
94
|
+
const cancelled = q.cancelled_at === null ? null : timestamp(q.cancelled_at);
|
|
95
|
+
const created = timestamp(q.created_at);
|
|
96
|
+
if (created > now || (asked !== null && (asked < created || asked > now))) throw new Error("invalid_question_time");
|
|
97
|
+
if (q.status === "queued" && (asked !== null || answered !== null || cancelled !== null)) throw new Error("invalid_queued_question");
|
|
98
|
+
if (q.status === "asked" && (asked === null || answered !== null || cancelled !== null)) throw new Error("invalid_pending_question");
|
|
99
|
+
if (q.status === "answered" && (asked === null || answered === null || cancelled !== null || !text(q.answer_ref))) throw new Error("invalid_answered_question");
|
|
100
|
+
if (q.status === "cancelled" && (cancelled === null || answered !== null)) throw new Error("invalid_cancelled_question");
|
|
101
|
+
const end = answered ?? cancelled ?? now;
|
|
102
|
+
if (end < (asked ?? created) || end > now) throw new Error("invalid_response_time");
|
|
103
|
+
if (q.status === "queued") metrics.queued++;
|
|
104
|
+
if (q.status === "asked") metrics.pending++;
|
|
105
|
+
if (q.status === "cancelled") metrics.cancelled++;
|
|
106
|
+
if (asked === null) {
|
|
107
|
+
if (q.round_trip_id !== null) throw new Error("unasked_round_trip");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!text(q.round_trip_id)) throw new Error("round_trip_missing");
|
|
111
|
+
if (rounds.has(q.round_trip_id) && rounds.get(q.round_trip_id) !== asked) {
|
|
112
|
+
throw new Error("inconsistent_round_trip_time");
|
|
113
|
+
}
|
|
114
|
+
rounds.set(q.round_trip_id, asked);
|
|
115
|
+
metrics.decisions_asked++;
|
|
116
|
+
counts[q.class]++;
|
|
117
|
+
responses.push([asked, end]);
|
|
118
|
+
windows.set(q.id, [asked, end]);
|
|
119
|
+
if (answered !== null) metrics.response_ms += end - asked;
|
|
120
|
+
if (q.status === "asked") metrics.pending_response_ms += now - asked;
|
|
121
|
+
}
|
|
122
|
+
const blocked = [];
|
|
123
|
+
for (const interval of journey.owner_blocked_intervals) {
|
|
124
|
+
if (!object(interval) || !Array.isArray(interval.decision_ids) || !interval.decision_ids.length ||
|
|
125
|
+
!interval.decision_ids.every(text) || new Set(interval.decision_ids).size !== interval.decision_ids.length ||
|
|
126
|
+
!Object.hasOwn(interval, "ended_at")) throw new Error("invalid_blocked_interval");
|
|
127
|
+
const start = timestamp(interval.started_at);
|
|
128
|
+
const end = interval.ended_at === null ? now : timestamp(interval.ended_at);
|
|
129
|
+
if (start > end || end > now) throw new Error("invalid_blocked_time");
|
|
130
|
+
for (const id of interval.decision_ids) {
|
|
131
|
+
const window = windows.get(id);
|
|
132
|
+
if (!window || start < window[0] || end > window[1]) throw new Error("blocked_interval_outside_question");
|
|
133
|
+
}
|
|
134
|
+
blocked.push([start, end]);
|
|
135
|
+
}
|
|
136
|
+
metrics.round_trips = rounds.size;
|
|
137
|
+
metrics.response_window_ms = unionLength(responses);
|
|
138
|
+
metrics.blocked_ms = unionLength(blocked);
|
|
139
|
+
return metrics;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { evaluateOwnerStop, ownerWaitMetrics };
|
|
143
|
+
|
|
144
|
+
if (require.main === module) {
|
|
145
|
+
try {
|
|
146
|
+
const [file, asOf] = process.argv.slice(2);
|
|
147
|
+
if (!file || !asOf) throw new Error("usage: owner-stops.cjs <journey.json> <as-of-UTC>");
|
|
148
|
+
console.log(JSON.stringify(ownerWaitMetrics(JSON.parse(fs.readFileSync(file, "utf8")), asOf)));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.log(JSON.stringify({ status: "invalid", reason: error.code ? "journey_unreadable" :
|
|
151
|
+
/^[a-z_]+$/.test(error.message) ? error.message : "invalid_journey_input" }));
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -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 };
|