@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 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/default-role-lineup.md`, then confirms
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 = Object.freeze({
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";
@@ -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 = Object.freeze({
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);
@@ -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 };