@alphazede/bearing-lite 0.1.11 → 0.2.0
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 +39 -13
- package/hooks/assurance-budget.cjs +167 -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/te-capability.cjs +137 -0
- package/hooks/te-host.cjs +620 -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/implementation.schema.json +127 -0
- package/schemas/journey.schema.json +88 -0
- package/schemas/lineups.schema.json +145 -0
- package/schemas/seit.schema.json +108 -0
- package/skills/bearing-lite/SKILL.md +15 -15
- package/skills/bearing-lite/references/assurance-policy.md +66 -0
- package/skills/bearing-lite/references/lineups.md +81 -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/default-role-lineup.md +7 -1
- package/skills/bearing-lite/templates/task.md +16 -10
- 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/map-the-route/SKILL.md +7 -6
- package/skills/map-the-route/references/artifact-grammar.md +47 -29
- package/skills/park-ranger/SKILL.md +14 -9
- package/skills/plan-integrator/SKILL.md +37 -0
- package/skills/scribe/SKILL.md +34 -0
- package/skills/surveyor/SKILL.md +15 -9
- package/skills/systems-modeler/SKILL.md +35 -0
- package/skills/test-engineer/SKILL.md +46 -0
- package/skills/validator/SKILL.md +20 -27
- package/skills/validator/references/grading-rubric.md +5 -4
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test Engineering host adapter (SEIT-EMV-017, SEIT-EMV-036).
|
|
6
|
+
*
|
|
7
|
+
* Two additional hook classes, `te_test_write` and `te_completion`. They do
|
|
8
|
+
* not replace, rename, or extend the original four classes, and they do not
|
|
9
|
+
* reuse the original four outcome tokens.
|
|
10
|
+
*
|
|
11
|
+
* Lite owns the adapter, never the policy. This module maps the host event to
|
|
12
|
+
* a class, builds the request from real state — the installed Git checkout and
|
|
13
|
+
* the coordinator-authored plan, never the writer's own tool payload — and
|
|
14
|
+
* hands the request to the evaluator loaded by capability. The verdict comes
|
|
15
|
+
* back verbatim. When the capability is inactive the class fails open; when it
|
|
16
|
+
* is activated but unavailable the result is a typed gap, never silent
|
|
17
|
+
* success.
|
|
18
|
+
*
|
|
19
|
+
* Always exit 0 (RISK-12 / RISK-20). Host blocking, where a host supports it
|
|
20
|
+
* natively, is JSON-only — never process status. A valid completion verdict is
|
|
21
|
+
* not user acceptance and grants no planning or publication authority.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const path = require("node:path");
|
|
26
|
+
const { spawnSync } = require("node:child_process");
|
|
27
|
+
|
|
28
|
+
const capability = require("./te-capability.cjs");
|
|
29
|
+
|
|
30
|
+
const TE_TEST_WRITE = "te_test_write";
|
|
31
|
+
const TE_COMPLETION = "te_completion";
|
|
32
|
+
const TE_CLASSES = Object.freeze([TE_TEST_WRITE, TE_COMPLETION]);
|
|
33
|
+
|
|
34
|
+
const ALLOW = "ALLOW";
|
|
35
|
+
const UNAVAILABLE = "UNAVAILABLE";
|
|
36
|
+
const DENY_ROUTE_TO_TE = "DENY_ROUTE_TO_TE";
|
|
37
|
+
const DENY_RECEIPT_REQUIRED = "DENY_RECEIPT_REQUIRED";
|
|
38
|
+
const VERDICTS = Object.freeze([
|
|
39
|
+
ALLOW,
|
|
40
|
+
DENY_ROUTE_TO_TE,
|
|
41
|
+
DENY_RECEIPT_REQUIRED,
|
|
42
|
+
UNAVAILABLE,
|
|
43
|
+
]);
|
|
44
|
+
const DENY_VERDICTS = new Set([DENY_ROUTE_TO_TE, DENY_RECEIPT_REQUIRED]);
|
|
45
|
+
|
|
46
|
+
const NATIVE = "native";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Frozen host honesty table. `native` means the host exposes a real, verified
|
|
50
|
+
* hard deny for that channel. Everything else is `UNAVAILABLE`: the class is
|
|
51
|
+
* procedural there and this adapter must not advertise enforcement it cannot
|
|
52
|
+
* perform.
|
|
53
|
+
*
|
|
54
|
+
* Cursor has a write-time deny. A Cursor `stop` `followup_message` is guidance,
|
|
55
|
+
* not a hard completion block, and Cursor exposes no distinct hard child-stop
|
|
56
|
+
* deny, so both completion channels stay `UNAVAILABLE`.
|
|
57
|
+
*/
|
|
58
|
+
const FULL_NATIVE = Object.freeze({
|
|
59
|
+
write_time_deny: NATIVE,
|
|
60
|
+
completion_deny: NATIVE,
|
|
61
|
+
child_stop_deny: NATIVE,
|
|
62
|
+
});
|
|
63
|
+
const NO_NATIVE = Object.freeze({
|
|
64
|
+
write_time_deny: UNAVAILABLE,
|
|
65
|
+
completion_deny: UNAVAILABLE,
|
|
66
|
+
child_stop_deny: UNAVAILABLE,
|
|
67
|
+
});
|
|
68
|
+
const HOST_SUPPORT = Object.freeze({
|
|
69
|
+
grok: FULL_NATIVE,
|
|
70
|
+
codex: FULL_NATIVE,
|
|
71
|
+
"claude-code": FULL_NATIVE,
|
|
72
|
+
cursor: Object.freeze({
|
|
73
|
+
write_time_deny: NATIVE,
|
|
74
|
+
completion_deny: UNAVAILABLE,
|
|
75
|
+
child_stop_deny: UNAVAILABLE,
|
|
76
|
+
}),
|
|
77
|
+
kimi: NO_NATIVE,
|
|
78
|
+
pi: NO_NATIVE,
|
|
79
|
+
agy: NO_NATIVE,
|
|
80
|
+
deepcode: NO_NATIVE,
|
|
81
|
+
});
|
|
82
|
+
const DEFAULT_HOST = "claude-code";
|
|
83
|
+
|
|
84
|
+
/** Derived runtime state. Never tracked, never a candidate change. */
|
|
85
|
+
const RUNTIME_DIR = ".bearing";
|
|
86
|
+
const STORE_SEGMENTS = Object.freeze([RUNTIME_DIR, "test-engineering"]);
|
|
87
|
+
const HANDOFF_FILE = "handoff.json";
|
|
88
|
+
const RECEIPT_FILE = "receipt.json";
|
|
89
|
+
|
|
90
|
+
const MAX_PLAN_FILES = 20;
|
|
91
|
+
const MAX_PLAN_BYTES = 256 * 1024;
|
|
92
|
+
|
|
93
|
+
const WRITE_EVENTS = new Set([
|
|
94
|
+
"pretooluse",
|
|
95
|
+
"beforeshellexecution",
|
|
96
|
+
"applypatch",
|
|
97
|
+
]);
|
|
98
|
+
const COMPLETION_EVENTS = new Set(["stop", "subagentstop"]);
|
|
99
|
+
const CHILD_STOP_EVENTS = new Set(["subagentstop"]);
|
|
100
|
+
|
|
101
|
+
const TASK_LINE = /^###\s*task_id:\s*(\S+)\s*$/;
|
|
102
|
+
const FIELD_LINE =
|
|
103
|
+
/^-\s*(assigned_role|role_instance|status|scope|authority|type):\s*(.*)$/;
|
|
104
|
+
const LEASE_REVISION = /^\s*-\s*candidate_revision:\s*(\S+)\s*$/m;
|
|
105
|
+
const TASK_MARK = /(?:^|\n)###\s*task_id:\s*\S+/;
|
|
106
|
+
const LEASE_MARK = /^\s*-\s*checkout_lease:/m;
|
|
107
|
+
|
|
108
|
+
function isPlainObject(value) {
|
|
109
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function presentString(value) {
|
|
113
|
+
if (typeof value !== "string") return undefined;
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
if (!trimmed || trimmed === "unassigned" || /^<[^>]+>$/.test(trimmed)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Normalize Claude, Codex, Grok, Cursor, and Kimi event spellings. */
|
|
122
|
+
function normalizeEvent(name) {
|
|
123
|
+
if (typeof name !== "string") return "";
|
|
124
|
+
return name.trim().replace(/[_\-\s]/g, "").toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {unknown} event
|
|
129
|
+
* @returns {"te_test_write"|"te_completion"|null}
|
|
130
|
+
*/
|
|
131
|
+
function classForEvent(event) {
|
|
132
|
+
const key = normalizeEvent(event);
|
|
133
|
+
if (!key) return null;
|
|
134
|
+
if (WRITE_EVENTS.has(key)) return TE_TEST_WRITE;
|
|
135
|
+
if (COMPLETION_EVENTS.has(key)) return TE_COMPLETION;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Which host honesty channel this event needs. */
|
|
140
|
+
function supportChannel(hookClass, event) {
|
|
141
|
+
if (hookClass === TE_TEST_WRITE) return "write_time_deny";
|
|
142
|
+
return CHILD_STOP_EVENTS.has(normalizeEvent(event))
|
|
143
|
+
? "child_stop_deny"
|
|
144
|
+
: "completion_deny";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run the installed native Git executable with argv and shell=false.
|
|
149
|
+
* @returns {string|null} trimmed stdout, or null when Git could not answer
|
|
150
|
+
*/
|
|
151
|
+
function git(root, ...args) {
|
|
152
|
+
let result;
|
|
153
|
+
try {
|
|
154
|
+
result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (!result || result.status !== 0 || typeof result.stdout !== "string") {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return result.stdout.trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rankPlan(name) {
|
|
165
|
+
const n = name.toLowerCase();
|
|
166
|
+
if (n === "plan.md" || n === "progress.md") return 0;
|
|
167
|
+
if (n.includes("plan")) return 1;
|
|
168
|
+
return 5;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Bounded, top-level Markdown scan of the workspace root. */
|
|
172
|
+
function planFiles(root) {
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
176
|
+
} catch {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
return entries
|
|
180
|
+
.filter((e) => e.isFile() && /\.(md|markdown)$/i.test(e.name))
|
|
181
|
+
.map((e) => e.name)
|
|
182
|
+
.sort((a, b) => rankPlan(a) - rankPlan(b) || a.localeCompare(b))
|
|
183
|
+
.slice(0, MAX_PLAN_FILES)
|
|
184
|
+
.map((name) => path.join(root, name));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseTasks(text) {
|
|
188
|
+
const tasks = [];
|
|
189
|
+
let current = null;
|
|
190
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
191
|
+
const taskMatch = line.match(TASK_LINE);
|
|
192
|
+
if (taskMatch) {
|
|
193
|
+
if (current) tasks.push(current);
|
|
194
|
+
current = { task_id: taskMatch[1] };
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!current) continue;
|
|
198
|
+
const fieldMatch = line.match(FIELD_LINE);
|
|
199
|
+
if (fieldMatch) {
|
|
200
|
+
const value = presentString(fieldMatch[2]);
|
|
201
|
+
if (value !== undefined) current[fieldMatch[1]] = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (current) tasks.push(current);
|
|
205
|
+
return tasks;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function splitScope(value) {
|
|
209
|
+
return String(value === undefined ? "" : value)
|
|
210
|
+
.split(",")
|
|
211
|
+
.map((entry) => entry.trim())
|
|
212
|
+
.filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const EMPTY_ASSIGNMENT = Object.freeze({
|
|
216
|
+
task_id: null,
|
|
217
|
+
assigned_role: null,
|
|
218
|
+
role_instance: null,
|
|
219
|
+
write_set: Object.freeze([]),
|
|
220
|
+
authority: null,
|
|
221
|
+
authority_id: null,
|
|
222
|
+
candidate_revision: null,
|
|
223
|
+
type: null,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Trusted assignment and diff base, taken only from the coordinator-authored
|
|
228
|
+
* plan in the workspace. The writer's own tool payload and transcript are
|
|
229
|
+
* never a source here.
|
|
230
|
+
*
|
|
231
|
+
* `authority_id` and `candidate_revision` are the evaluator's spellings for
|
|
232
|
+
* the plan's own `authority` and checkout-lease revision. They are the same
|
|
233
|
+
* trusted values under the names the consumer reads; the writer's payload
|
|
234
|
+
* still never reaches them.
|
|
235
|
+
*/
|
|
236
|
+
function readTrustedPlan(root) {
|
|
237
|
+
for (const file of planFiles(root)) {
|
|
238
|
+
let text;
|
|
239
|
+
try {
|
|
240
|
+
const stat = fs.statSync(file);
|
|
241
|
+
if (!stat.isFile() || stat.size > MAX_PLAN_BYTES) continue;
|
|
242
|
+
text = fs.readFileSync(file, "utf8");
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const hasLease = LEASE_MARK.test(text);
|
|
247
|
+
if (!TASK_MARK.test(text) && !hasLease) continue;
|
|
248
|
+
|
|
249
|
+
const tasks = parseTasks(text);
|
|
250
|
+
const active =
|
|
251
|
+
tasks.find((task) => {
|
|
252
|
+
const status = presentString(task.status);
|
|
253
|
+
return status && status !== "COMPLETE" && status !== "CANCELLED";
|
|
254
|
+
}) ||
|
|
255
|
+
tasks[0] ||
|
|
256
|
+
null;
|
|
257
|
+
const revision = text.match(LEASE_REVISION);
|
|
258
|
+
const diffBase = revision ? revision[1] : null;
|
|
259
|
+
const authority = active ? presentString(active.authority) ?? null : null;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
diff_base: diffBase,
|
|
263
|
+
assignment: {
|
|
264
|
+
task_id: active ? presentString(active.task_id) ?? null : null,
|
|
265
|
+
assigned_role: active ? presentString(active.assigned_role) ?? null : null,
|
|
266
|
+
role_instance: active ? presentString(active.role_instance) ?? null : null,
|
|
267
|
+
write_set: splitScope(active ? active.scope : ""),
|
|
268
|
+
authority,
|
|
269
|
+
authority_id: authority,
|
|
270
|
+
candidate_revision: diffBase,
|
|
271
|
+
type: active ? presentString(active.type) ?? null : null,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { diff_base: null, assignment: { ...EMPTY_ASSIGNMENT, write_set: [] } };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* A path is in the candidate only when the trusted write set covers it. An
|
|
280
|
+
* empty trusted scope covers nothing, so unrelated shared dirt stays out.
|
|
281
|
+
*/
|
|
282
|
+
function inScope(relPath, scopePaths) {
|
|
283
|
+
return scopePaths.some((entry) => {
|
|
284
|
+
const scope = entry.replace(/\/+$/, "");
|
|
285
|
+
if (!scope) return false;
|
|
286
|
+
return relPath === scope || relPath.startsWith(scope + "/");
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function porcelainPath(line) {
|
|
291
|
+
const raw = line.slice(3).trim();
|
|
292
|
+
if (!raw) return "";
|
|
293
|
+
const rename = raw.indexOf(" -> ");
|
|
294
|
+
const target = rename >= 0 ? raw.slice(rename + 4) : raw;
|
|
295
|
+
return target.replace(/^"|"$/g, "");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Real candidate state from the installed Git checkout: the committed
|
|
300
|
+
* `diff_base..HEAD` change plus the working tree, scoped to the trusted write
|
|
301
|
+
* set. Derived runtime state is never a candidate change.
|
|
302
|
+
*/
|
|
303
|
+
function readCandidate(root, diffBase, scopePaths) {
|
|
304
|
+
const revision = git(root, "rev-parse", "HEAD");
|
|
305
|
+
const branch = git(root, "rev-parse", "--abbrev-ref", "HEAD");
|
|
306
|
+
const changed = new Set();
|
|
307
|
+
|
|
308
|
+
if (diffBase && revision) {
|
|
309
|
+
const committed = git(root, "diff", "--name-only", diffBase, "HEAD");
|
|
310
|
+
if (committed) {
|
|
311
|
+
for (const line of committed.split("\n")) {
|
|
312
|
+
const value = line.trim();
|
|
313
|
+
if (value) changed.add(value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const status = git(root, "status", "--porcelain", "--untracked-files=all");
|
|
319
|
+
if (status) {
|
|
320
|
+
for (const line of status.split("\n")) {
|
|
321
|
+
const value = porcelainPath(line);
|
|
322
|
+
if (value) changed.add(value);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const changedPaths = [...changed]
|
|
327
|
+
.filter((entry) => entry.split("/")[0] !== RUNTIME_DIR)
|
|
328
|
+
.filter((entry) => inScope(entry, scopePaths))
|
|
329
|
+
.sort();
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
checkout: root,
|
|
333
|
+
branch: branch || null,
|
|
334
|
+
revision: revision || null,
|
|
335
|
+
diff_base: diffBase || null,
|
|
336
|
+
scope_paths: scopePaths,
|
|
337
|
+
changed_paths: changedPaths,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function relativeTarget(root, value) {
|
|
342
|
+
const target = presentString(value);
|
|
343
|
+
if (!target) return null;
|
|
344
|
+
return path.isAbsolute(target) ? path.relative(root, target) : target;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Paths the host event names. Advisory only: never a source of authority. */
|
|
348
|
+
function readTargetPaths(input, root, resolvedToolInput) {
|
|
349
|
+
const toolInput = resolvedToolInput || {};
|
|
350
|
+
const out = new Set();
|
|
351
|
+
for (const value of [
|
|
352
|
+
toolInput.file_path,
|
|
353
|
+
toolInput.filePath,
|
|
354
|
+
toolInput.path,
|
|
355
|
+
toolInput.notebook_path,
|
|
356
|
+
input.file_path,
|
|
357
|
+
input.filePath,
|
|
358
|
+
]) {
|
|
359
|
+
const target = relativeTarget(root, value);
|
|
360
|
+
if (target) out.add(target);
|
|
361
|
+
}
|
|
362
|
+
for (const list of [toolInput.file_paths, toolInput.paths]) {
|
|
363
|
+
if (!Array.isArray(list)) continue;
|
|
364
|
+
for (const value of list) {
|
|
365
|
+
const target = relativeTarget(root, value);
|
|
366
|
+
if (target) out.add(target);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return [...out].sort();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Read a Test Engineering artifact from the derived runtime store. Presence is
|
|
374
|
+
* reported, never interpreted: only the evaluator decides what a handoff or a
|
|
375
|
+
* receipt means.
|
|
376
|
+
*/
|
|
377
|
+
function readArtifact(root, file) {
|
|
378
|
+
const relative = path.join(...STORE_SEGMENTS, file);
|
|
379
|
+
const target = path.join(root, relative);
|
|
380
|
+
let stat;
|
|
381
|
+
try {
|
|
382
|
+
stat = fs.statSync(target);
|
|
383
|
+
} catch {
|
|
384
|
+
return { path: relative, exists: false, raw: null };
|
|
385
|
+
}
|
|
386
|
+
if (!stat.isFile()) return { path: relative, exists: false, raw: null };
|
|
387
|
+
let raw = null;
|
|
388
|
+
try {
|
|
389
|
+
raw = fs.readFileSync(target, "utf8");
|
|
390
|
+
} catch {
|
|
391
|
+
raw = null;
|
|
392
|
+
}
|
|
393
|
+
return { path: relative, exists: true, raw };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* One request, spelled the way the evaluator reads it. `workspaceRoot`,
|
|
398
|
+
* `tool_name`, `tool_input`, `revision` and `diff_base` are the supported
|
|
399
|
+
* consumer fields (S25-INT-001); each carries a value Lite already computed
|
|
400
|
+
* from the envelope, the trusted plan, or the real checkout, so nothing here
|
|
401
|
+
* is a second source of truth. The existing `workspace_root` field and the
|
|
402
|
+
* nested `candidate`, `assignment`, `target_paths`, `handoff` and `receipt`
|
|
403
|
+
* records stay for Lite's own consumers; `workspace_root` and `workspaceRoot`
|
|
404
|
+
* are the one resolved envelope root under both spellings.
|
|
405
|
+
*/
|
|
406
|
+
function buildRequest(hookClass, event, input, root, host) {
|
|
407
|
+
const plan = readTrustedPlan(root);
|
|
408
|
+
const candidate = readCandidate(root, plan.diff_base, plan.assignment.write_set);
|
|
409
|
+
const toolInput = isPlainObject(input.tool_input)
|
|
410
|
+
? input.tool_input
|
|
411
|
+
: isPlainObject(input.toolInput)
|
|
412
|
+
? input.toolInput
|
|
413
|
+
: null;
|
|
414
|
+
return {
|
|
415
|
+
hook_class: hookClass,
|
|
416
|
+
event: typeof event === "string" ? event.trim() : "",
|
|
417
|
+
workspace_root: root,
|
|
418
|
+
workspaceRoot: root,
|
|
419
|
+
candidate,
|
|
420
|
+
assignment: plan.assignment,
|
|
421
|
+
revision: candidate.revision,
|
|
422
|
+
diff_base: candidate.diff_base,
|
|
423
|
+
tool_name: presentString(input.tool_name) ?? presentString(input.toolName) ?? null,
|
|
424
|
+
tool_input: toolInput,
|
|
425
|
+
target_paths: readTargetPaths(input, root, toolInput),
|
|
426
|
+
handoff: readArtifact(root, HANDOFF_FILE),
|
|
427
|
+
receipt: readArtifact(root, RECEIPT_FILE),
|
|
428
|
+
host,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function hostOutput(hookClass, verdict, reason, extra) {
|
|
433
|
+
const body = {
|
|
434
|
+
hookSpecificOutput: {
|
|
435
|
+
capability: capability.CAPABILITY,
|
|
436
|
+
hookClass: hookClass || null,
|
|
437
|
+
verdict,
|
|
438
|
+
reason,
|
|
439
|
+
invented: false,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
for (const [key, value] of Object.entries(extra || {})) {
|
|
443
|
+
if (value !== undefined && value !== null) body.hookSpecificOutput[key] = value;
|
|
444
|
+
}
|
|
445
|
+
return body;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function unavailable(hookClass, reason, extra) {
|
|
449
|
+
return hostOutput(hookClass, UNAVAILABLE, reason, extra);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Translate the evaluator verdict into host JSON. A deny uses the host's own
|
|
454
|
+
* native mechanism and nothing else; the process exit stays 0.
|
|
455
|
+
*/
|
|
456
|
+
function toHostResponse(hookClass, verdict, reason, extra) {
|
|
457
|
+
const body = hostOutput(hookClass, verdict, reason, extra);
|
|
458
|
+
if (!DENY_VERDICTS.has(verdict)) return body;
|
|
459
|
+
const text = `Bearing Lite ${hookClass}: ${verdict} — ${reason}`;
|
|
460
|
+
if (hookClass === TE_TEST_WRITE) {
|
|
461
|
+
body.hookSpecificOutput.permissionDecision = "deny";
|
|
462
|
+
body.hookSpecificOutput.permissionDecisionReason = text;
|
|
463
|
+
} else {
|
|
464
|
+
body.decision = "block";
|
|
465
|
+
body.reason = text;
|
|
466
|
+
}
|
|
467
|
+
return body;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {unknown} envelope host stdin payload
|
|
472
|
+
* @param {{ selected?: boolean, required?: boolean, host?: string }} [options]
|
|
473
|
+
*/
|
|
474
|
+
function handle(envelope, options) {
|
|
475
|
+
const opts = isPlainObject(options) ? options : {};
|
|
476
|
+
let input = envelope;
|
|
477
|
+
if (typeof input === "string") {
|
|
478
|
+
try {
|
|
479
|
+
input = JSON.parse(input);
|
|
480
|
+
} catch {
|
|
481
|
+
return unavailable(null, "malformed_input: host envelope is not JSON");
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (!isPlainObject(input)) {
|
|
485
|
+
return unavailable(null, "malformed_input: host envelope is not an object");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const event = input.hook_event_name || input.hookEventName || input.event || "";
|
|
489
|
+
const eventName = typeof event === "string" ? event.trim() : "";
|
|
490
|
+
const hookClass = classForEvent(event);
|
|
491
|
+
if (!hookClass) {
|
|
492
|
+
return unavailable(
|
|
493
|
+
null,
|
|
494
|
+
"unmapped_host_event: no test-engineering class maps this event; fail open",
|
|
495
|
+
{ hookEventName: eventName || undefined }
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const host = presentString(opts.host) || DEFAULT_HOST;
|
|
500
|
+
const channel = supportChannel(hookClass, event);
|
|
501
|
+
const support = HOST_SUPPORT[host];
|
|
502
|
+
if (!support || support[channel] !== NATIVE) {
|
|
503
|
+
return unavailable(
|
|
504
|
+
hookClass,
|
|
505
|
+
`host_enforcement_unavailable: host "${host}" has no native ${channel}; ` +
|
|
506
|
+
`${hookClass} is unsupported here and stays procedural`,
|
|
507
|
+
{ hookEventName: eventName, host, code: "host_enforcement_unavailable" }
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const root = path.resolve(
|
|
512
|
+
presentString(input.cwd) || presentString(input.workspaceRoot) || process.cwd()
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const resolved = capability.resolve({
|
|
516
|
+
workspaceRoot: root,
|
|
517
|
+
selected: opts.selected === true,
|
|
518
|
+
required: opts.required === true,
|
|
519
|
+
});
|
|
520
|
+
if (resolved.status === "INACTIVE") {
|
|
521
|
+
return unavailable(
|
|
522
|
+
hookClass,
|
|
523
|
+
"capability_inactive: test-engineering is unselected and not required; " +
|
|
524
|
+
"the class fails open and this is not a failure",
|
|
525
|
+
{ hookEventName: eventName, code: resolved.code }
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (resolved.status !== "ACTIVE" || !resolved.evaluator) {
|
|
529
|
+
return unavailable(hookClass, resolved.message || "typed_capability_gap", {
|
|
530
|
+
hookEventName: eventName,
|
|
531
|
+
code: resolved.code || "typed_capability_gap",
|
|
532
|
+
recovery: resolved.recovery,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let result;
|
|
537
|
+
try {
|
|
538
|
+
const request = buildRequest(hookClass, eventName, input, root, host);
|
|
539
|
+
result =
|
|
540
|
+
hookClass === TE_TEST_WRITE
|
|
541
|
+
? resolved.evaluator.evaluateTestWrite(request)
|
|
542
|
+
: resolved.evaluator.evaluateCompletion(request);
|
|
543
|
+
} catch {
|
|
544
|
+
return unavailable(
|
|
545
|
+
hookClass,
|
|
546
|
+
"evaluator_failure: the test-engineering evaluator did not return a " +
|
|
547
|
+
"verdict; enforcement is unavailable for this event",
|
|
548
|
+
{ hookEventName: eventName, code: "evaluator_failure" }
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const verdict = isPlainObject(result) ? result.verdict : null;
|
|
553
|
+
if (!VERDICTS.includes(verdict)) {
|
|
554
|
+
return unavailable(
|
|
555
|
+
hookClass,
|
|
556
|
+
"evaluator_verdict_unrecognized: enforcement is unavailable for this event",
|
|
557
|
+
{ hookEventName: eventName, code: "evaluator_verdict_unrecognized" }
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const reason =
|
|
562
|
+
(isPlainObject(result) ? presentString(result.reason) : undefined) ||
|
|
563
|
+
"test_engineering_verdict";
|
|
564
|
+
return toHostResponse(hookClass, verdict, reason, { hookEventName: eventName });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function flag(value) {
|
|
568
|
+
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function argValue(name) {
|
|
572
|
+
const prefix = `--${name}=`;
|
|
573
|
+
const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix));
|
|
574
|
+
return hit ? hit.slice(prefix.length) : undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function readStdinSync() {
|
|
578
|
+
try {
|
|
579
|
+
return fs.readFileSync(0, "utf8");
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const code = err && err.code;
|
|
582
|
+
if (code === "EAGAIN" || code === "EOF") return "";
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function main() {
|
|
588
|
+
let raw = "";
|
|
589
|
+
let response;
|
|
590
|
+
try {
|
|
591
|
+
raw = readStdinSync();
|
|
592
|
+
response = handle(raw, {
|
|
593
|
+
selected: flag(process.env.BEARING_TEST_ENGINEERING_SELECTED),
|
|
594
|
+
required: flag(process.env.BEARING_TEST_ENGINEERING_REQUIRED),
|
|
595
|
+
host: argValue("host") || process.env.BEARING_HOST,
|
|
596
|
+
});
|
|
597
|
+
} catch {
|
|
598
|
+
response = unavailable(
|
|
599
|
+
null,
|
|
600
|
+
"adapter_failure: the test-engineering adapter could not run; unavailable"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
604
|
+
process.exit(0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = {
|
|
608
|
+
TE_CLASSES,
|
|
609
|
+
VERDICTS,
|
|
610
|
+
HOST_SUPPORT,
|
|
611
|
+
DEFAULT_HOST,
|
|
612
|
+
classForEvent,
|
|
613
|
+
supportChannel,
|
|
614
|
+
handle,
|
|
615
|
+
toHostResponse,
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
if (require.main === module) {
|
|
619
|
+
main();
|
|
620
|
+
}
|
|
@@ -11,11 +11,14 @@ const HOOK_CLASS = "transition";
|
|
|
11
11
|
const OUTCOMES = Object.freeze(["ADVISE", "REROUTE", "BLOCK", "UNAVAILABLE"]);
|
|
12
12
|
const ENFORCEMENT = "procedural";
|
|
13
13
|
const { evaluatePlanningReview } = require("./planning-review.cjs");
|
|
14
|
+
const { evaluateAssuranceBudget } = require("./assurance-budget.cjs");
|
|
14
15
|
|
|
15
16
|
const RECOVERY_UNAVAILABLE =
|
|
16
17
|
"Keep the current state, run the transition checklist procedurally, and record the result before retrying";
|
|
17
18
|
const RECOVERY_ALLOW =
|
|
18
19
|
"Proceed with the requested transition and update the project plan as the only task record";
|
|
20
|
+
const RECOVERY_ASSURANCE =
|
|
21
|
+
"Keep the declared phase or wave closed; the next distinct declared unit carries its own budget";
|
|
19
22
|
const RECOVERY_CHANNEL =
|
|
20
23
|
"Keep repair, status, owner communication, and safe rollback available; do not treat them as sequence violations";
|
|
21
24
|
|
|
@@ -89,11 +92,14 @@ function normalizeRoleKey(role) {
|
|
|
89
92
|
|
|
90
93
|
/**
|
|
91
94
|
* Map the next missing assurance role to the state that should receive it.
|
|
92
|
-
*
|
|
95
|
+
* Assurance Test Engineer (or the retired Validator label) -> VALIDATING,
|
|
96
|
+
* Park Ranger -> REVIEWING, Surveyor -> ACCEPTANCE.
|
|
93
97
|
*/
|
|
94
98
|
function assuranceTargetState(role) {
|
|
95
99
|
const key = normalizeRoleKey(role);
|
|
96
|
-
if (key === "validator"
|
|
100
|
+
if (key === "validator" || key === "assurance-test-engineer" || key === "test-engineer") {
|
|
101
|
+
return "VALIDATING";
|
|
102
|
+
}
|
|
97
103
|
if (key === "park-ranger" || key === "parkranger") return "REVIEWING";
|
|
98
104
|
if (key === "surveyor") return "ACCEPTANCE";
|
|
99
105
|
return null;
|
|
@@ -138,6 +144,18 @@ function evaluate(input) {
|
|
|
138
144
|
);
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
if (input.action_kind === "assurance_transition") {
|
|
148
|
+
const budget = evaluateAssuranceBudget(input.assurance);
|
|
149
|
+
if (budget.outcome === "PASS") {
|
|
150
|
+
return result("ADVISE", "assurance_budget:PASS", RECOVERY_ALLOW);
|
|
151
|
+
}
|
|
152
|
+
return result(
|
|
153
|
+
budget.outcome === "NEEDS_MORE_EVIDENCE" ? "REROUTE" : "BLOCK",
|
|
154
|
+
"assurance_budget:" + budget.outcome + ":" + budget.reason,
|
|
155
|
+
RECOVERY_ASSURANCE
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
const from = typeof input.from_state === "string" ? input.from_state.trim() : "";
|
|
142
160
|
const to = typeof input.to_state === "string" ? input.to_state.trim() : "";
|
|
143
161
|
if (!from || !to) {
|
|
@@ -197,6 +215,18 @@ function evaluate(input) {
|
|
|
197
215
|
);
|
|
198
216
|
}
|
|
199
217
|
|
|
218
|
+
// The legal edge stays legal; the per-declared-unit budget bounds it.
|
|
219
|
+
if (from === "EVIDENCE_READY" && to === "REVIEWING") {
|
|
220
|
+
const budget = evaluateAssuranceBudget(input.assurance);
|
|
221
|
+
if (budget.outcome !== "PASS") {
|
|
222
|
+
return result(
|
|
223
|
+
"REROUTE",
|
|
224
|
+
"assurance_budget:" + budget.outcome + ":" + budget.reason,
|
|
225
|
+
RECOVERY_ASSURANCE
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
200
230
|
// Completing while required assurance is still open.
|
|
201
231
|
if (to === "COMPLETE" && missingAssurance.length > 0) {
|
|
202
232
|
if (explicitSkip) {
|
package/lineups.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema_version":1,"lineups":{}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alphazede/bearing-lite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Skills-first Agent Plugin for planning, routing, bounded execution, and independent review of repository work—without CLI, MCP, server, or hidden runtime state.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-plugins",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"CODE_OF_CONDUCT.md",
|
|
38
38
|
"CONTRIBUTING.md",
|
|
39
39
|
"SECURITY.md",
|
|
40
|
-
"LICENSE-APACHE"
|
|
40
|
+
"LICENSE-APACHE",
|
|
41
|
+
"lineups.json",
|
|
42
|
+
"schemas/"
|
|
41
43
|
],
|
|
42
44
|
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
|
43
45
|
}
|