@haiyangbg/buildbeat 1.21.0 → 2.0.0-beta.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.en.md +4 -4
  3. package/README.md +4 -4
  4. package/bin/buildbeat-v2.js +6 -0
  5. package/docs/BuildBeat v2/357/274/232AI /345/216/237/347/224/237/350/275/257/344/273/266/344/272/244/344/273/230/346/216/247/345/210/266/345/271/263/351/235/242.md" +2053 -0
  6. package/docs/CAPABILITY-MATRIX.md +4 -4
  7. package/docs/CLI.md +4 -4
  8. package/docs/EXECUTION-PLAN.md +1 -1
  9. package/docs/RELEASING.md +5 -5
  10. package/docs/ROADMAP.md +4 -1
  11. package/docs/V1.21-RELEASE-EVIDENCE-2026-08-25.md +55 -0
  12. package/docs/V2-D2-DECISION-CARD.md +37 -0
  13. package/docs/V2-DECISIONS.md +11 -0
  14. package/docs/V2-ITERATION-01.md +60 -0
  15. package/docs/V2-ITERATION-02.md +32 -0
  16. package/docs/V2-ITERATION-03.md +30 -0
  17. package/docs/V2-ITERATION-04.md +29 -0
  18. package/docs/V2-ITERATION-05.md +20 -0
  19. package/docs/V2-ITERATION-06.md +18 -0
  20. package/docs/V2-ITERATION-07.md +36 -0
  21. package/docs/V2-PLAN.md +333 -0
  22. package/docs/V2-PROPOSAL.md +319 -0
  23. package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +41 -0
  24. package/docs/v2/M1-ACCEPTANCE-2026-08-28.md +38 -0
  25. package/docs/v2/M2-DOD-2026-08-28.md +34 -0
  26. package/docs/v2/M4-CHICKAI-PILOT-2026-08-28.md +44 -0
  27. package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +46 -0
  28. package/docs/v2/M4-SELFHOST-2026-08-28.md +53 -0
  29. package/docs/v2/RFC-0001-product-definition.md +92 -0
  30. package/docs/v2/RFC-0002-domain-model.md +149 -0
  31. package/docs/v2/RFC-0003-workflow-policy.md +204 -0
  32. package/docs/v2/SPEC-0001-events-v1.md +98 -0
  33. package/docs/v2/guide/01-quickstart.md +92 -0
  34. package/docs/v2/guide/02-workflow-guide.md +42 -0
  35. package/docs/v2/guide/03-policy-guide.md +53 -0
  36. package/docs/v2/guide/04-adapter-guide.md +45 -0
  37. package/docs/v2/guide/05-worker-contract.md +37 -0
  38. package/docs/v2/guide/06-evidence-guide.md +38 -0
  39. package/docs/v2/guide/07-approval-guide.md +34 -0
  40. package/docs/v2/guide/08-migration-v1.md +68 -0
  41. package/docs/v2/guide/09-security-boundaries.md +28 -0
  42. package/docs/v2/guide/10-recovery.md +55 -0
  43. package/docs/v2/guide/README.md +18 -0
  44. package/example/.buildbeat/manifest.json +2 -2
  45. package/example/BUILDBEAT.md +1 -1
  46. package/package.json +4 -2
  47. package/src/constants.js +4 -1
  48. package/src/project.js +6 -1
  49. package/src/v2/adapters/mock.js +67 -0
  50. package/src/v2/adapters/shell.js +78 -0
  51. package/src/v2/cli/run.js +494 -0
  52. package/src/v2/domain/event-registry.js +100 -0
  53. package/src/v2/domain/model.js +61 -0
  54. package/src/v2/engine/reducer.js +253 -0
  55. package/src/v2/engine/risk-preset.js +48 -0
  56. package/src/v2/engine/workflow.js +201 -0
  57. package/src/v2/engine/yaml-subset.js +194 -0
  58. package/src/v2/evidence/collector.js +63 -0
  59. package/src/v2/observe/observe-config.js +194 -0
  60. package/src/v2/observe/observe-reducer.js +117 -0
  61. package/src/v2/observe/observe.js +420 -0
  62. package/src/v2/policy/policy.js +302 -0
  63. package/src/v2/presets/observe.yaml +45 -0
  64. package/src/v2/presets/policies/ui-render-gate.yaml +13 -0
  65. package/src/v2/presets/risk/controlled.yaml +39 -0
  66. package/src/v2/presets/risk/fast.yaml +19 -0
  67. package/src/v2/presets/risk/legacy-four-gates.yaml +44 -0
  68. package/src/v2/presets/risk/standard.yaml +28 -0
  69. package/src/v2/presets/software-delivery.yaml +39 -0
  70. package/src/v2/runtime/decisions.js +288 -0
  71. package/src/v2/runtime/metrics.js +140 -0
  72. package/src/v2/runtime/orchestrator.js +754 -0
  73. package/src/v2/runtime/run-record.js +55 -0
  74. package/src/v2/storage/event-ledger.js +154 -0
  75. package/src/v2/workspace/workspace-manager.js +146 -0
@@ -0,0 +1,194 @@
1
+ // Fail-closed strict YAML subset parser for BuildBeat v2 config files.
2
+ // Supports exactly what the official presets need: nested maps, block lists,
3
+ // and plain/quoted scalars with space indentation. Everything else — tabs,
4
+ // anchors, aliases, tags, block/flow scalars, multi-document streams,
5
+ // duplicate keys — is rejected with a line number, never guessed at.
6
+
7
+ export class YamlSubsetError extends Error {
8
+ constructor(message, lineNo) {
9
+ super(lineNo ? `line ${lineNo}: ${message}` : message);
10
+ this.name = "YamlSubsetError";
11
+ this.lineNo = lineNo ?? null;
12
+ }
13
+ }
14
+
15
+ const KEY_PATTERN = /^[A-Za-z0-9_.-]+$/;
16
+ const FORBIDDEN_SCALAR_START = ["&", "*", "!", "|", ">", "{", "[", "%", "@", "`"];
17
+
18
+ function parseScalar(raw, lineNo) {
19
+ const text = raw.trim();
20
+ if (text.startsWith('"')) {
21
+ if (!text.endsWith('"') || text.length < 2) {
22
+ throw new YamlSubsetError(`unterminated double-quoted string: ${text}`, lineNo);
23
+ }
24
+ try {
25
+ return JSON.parse(text);
26
+ } catch {
27
+ throw new YamlSubsetError(`invalid double-quoted string: ${text}`, lineNo);
28
+ }
29
+ }
30
+ if (text.startsWith("'")) {
31
+ if (!text.endsWith("'") || text.length < 2) {
32
+ throw new YamlSubsetError(`unterminated single-quoted string: ${text}`, lineNo);
33
+ }
34
+ const inner = text.slice(1, -1);
35
+ if (inner.includes("'")) {
36
+ throw new YamlSubsetError("single-quoted escapes are not supported", lineNo);
37
+ }
38
+ return inner;
39
+ }
40
+ if (FORBIDDEN_SCALAR_START.includes(text[0])) {
41
+ throw new YamlSubsetError(`unsupported YAML syntax at: ${text}`, lineNo);
42
+ }
43
+ if (text.includes(" #")) {
44
+ throw new YamlSubsetError("trailing comments are not supported; move the comment to its own line", lineNo);
45
+ }
46
+ if (text === "true") return true;
47
+ if (text === "false") return false;
48
+ if (text === "null" || text === "~") return null;
49
+ if (/^-?\d+$/.test(text)) return Number.parseInt(text, 10);
50
+ if (/^-?\d+\.\d+$/.test(text)) return Number.parseFloat(text);
51
+ return text;
52
+ }
53
+
54
+ function splitKeyValue(content, lineNo) {
55
+ const colon = content.indexOf(":");
56
+ if (colon === -1) {
57
+ throw new YamlSubsetError(`expected "key: value", got: ${content}`, lineNo);
58
+ }
59
+ const key = content.slice(0, colon);
60
+ if (!KEY_PATTERN.test(key)) {
61
+ throw new YamlSubsetError(`unsupported map key: ${key}`, lineNo);
62
+ }
63
+ const rest = content.slice(colon + 1);
64
+ if (rest === "") {
65
+ return { key, value: null };
66
+ }
67
+ if (!rest.startsWith(" ")) {
68
+ throw new YamlSubsetError(`expected a space after "${key}:"`, lineNo);
69
+ }
70
+ return { key, value: rest.slice(1) };
71
+ }
72
+
73
+ function toLines(text) {
74
+ const lines = [];
75
+ const rawLines = text.split("\n");
76
+ for (const [index, raw] of rawLines.entries()) {
77
+ const lineNo = index + 1;
78
+ if (raw.includes("\t")) {
79
+ throw new YamlSubsetError("tabs are not allowed", lineNo);
80
+ }
81
+ const withoutCr = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
82
+ const trimmed = withoutCr.trim();
83
+ if (trimmed === "" || trimmed.startsWith("#")) {
84
+ continue;
85
+ }
86
+ if (trimmed === "---" || trimmed === "...") {
87
+ throw new YamlSubsetError("multi-document YAML is not supported", lineNo);
88
+ }
89
+ const indent = withoutCr.length - withoutCr.trimStart().length;
90
+ lines.push({ indent, content: trimmed, lineNo });
91
+ }
92
+ return lines;
93
+ }
94
+
95
+ function parseNode(lines, start, indent) {
96
+ const first = lines[start];
97
+ if (first.content === "-" || first.content.startsWith("- ")) {
98
+ return parseList(lines, start, indent);
99
+ }
100
+ return parseMap(lines, start, indent);
101
+ }
102
+
103
+ function parseList(lines, start, indent) {
104
+ const result = [];
105
+ let index = start;
106
+ while (index < lines.length && lines[index].indent >= indent) {
107
+ const line = lines[index];
108
+ if (line.indent !== indent) {
109
+ throw new YamlSubsetError(`bad indentation in list (expected ${indent} spaces)`, line.lineNo);
110
+ }
111
+ if (line.content !== "-" && !line.content.startsWith("- ")) {
112
+ break;
113
+ }
114
+ const itemIndent = indent + 2;
115
+ const inline = line.content === "-" ? "" : line.content.slice(2).trim();
116
+ const childLines = [];
117
+ if (inline !== "") {
118
+ childLines.push({ indent: itemIndent, content: inline, lineNo: line.lineNo });
119
+ }
120
+ let cursor = index + 1;
121
+ while (cursor < lines.length && lines[cursor].indent >= itemIndent) {
122
+ childLines.push(lines[cursor]);
123
+ cursor += 1;
124
+ }
125
+ if (childLines.length === 0) {
126
+ throw new YamlSubsetError("empty list item", line.lineNo);
127
+ }
128
+ const first = childLines[0].content;
129
+ const quotedScalar = first.startsWith('"') || first.startsWith("'");
130
+ if (childLines.length === 1 && (quotedScalar || !first.includes(":"))) {
131
+ result.push(parseScalar(first, childLines[0].lineNo));
132
+ } else if (!quotedScalar && first.includes(":")) {
133
+ result.push(parseMapFromLines(childLines, itemIndent));
134
+ } else {
135
+ throw new YamlSubsetError("unsupported list item shape", line.lineNo);
136
+ }
137
+ index = cursor;
138
+ }
139
+ return { value: result, next: index };
140
+ }
141
+
142
+ function parseMapFromLines(childLines, indent) {
143
+ const { value, next } = parseMap(childLines, 0, indent);
144
+ if (next !== childLines.length) {
145
+ throw new YamlSubsetError("unexpected trailing content in map", childLines[next].lineNo);
146
+ }
147
+ return value;
148
+ }
149
+
150
+ function parseMap(lines, start, indent) {
151
+ const result = {};
152
+ let index = start;
153
+ while (index < lines.length && lines[index].indent >= indent) {
154
+ const line = lines[index];
155
+ if (line.indent !== indent) {
156
+ throw new YamlSubsetError(`bad indentation in map (expected ${indent} spaces)`, line.lineNo);
157
+ }
158
+ if (line.content === "-" || line.content.startsWith("- ")) {
159
+ break;
160
+ }
161
+ const { key, value } = splitKeyValue(line.content, line.lineNo);
162
+ if (Object.hasOwn(result, key)) {
163
+ throw new YamlSubsetError(`duplicate map key: ${key}`, line.lineNo);
164
+ }
165
+ if (value !== null) {
166
+ result[key] = parseScalar(value, line.lineNo);
167
+ index += 1;
168
+ continue;
169
+ }
170
+ const childStart = index + 1;
171
+ if (childStart >= lines.length || lines[childStart].indent <= indent) {
172
+ throw new YamlSubsetError(`key "${key}" has no value`, line.lineNo);
173
+ }
174
+ const parsed = parseNode(lines, childStart, lines[childStart].indent);
175
+ result[key] = parsed.value;
176
+ index = parsed.next;
177
+ }
178
+ return { value: result, next: index };
179
+ }
180
+
181
+ export function parseYamlSubset(text) {
182
+ const lines = toLines(text);
183
+ if (lines.length === 0) {
184
+ throw new YamlSubsetError("empty document");
185
+ }
186
+ if (lines[0].indent !== 0) {
187
+ throw new YamlSubsetError("document must start at column 0", lines[0].lineNo);
188
+ }
189
+ const { value, next } = parseNode(lines, 0, 0);
190
+ if (next !== lines.length) {
191
+ throw new YamlSubsetError("unexpected trailing content", lines[next].lineNo);
192
+ }
193
+ return value;
194
+ }
@@ -0,0 +1,63 @@
1
+ // Evidence Collector v0 per docs/v2/RFC-0002-domain-model.md §4: evidence is
2
+ // what the runner read back (exit codes, logs, git state), never what a
3
+ // worker said. Raw logs stay in the runtime plane; the returned record is the
4
+ // contract-shaped fact the ledger and manifests reference.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export function collectCommandEvidence({
11
+ runtimeDir,
12
+ runId,
13
+ step,
14
+ attempt,
15
+ execResult,
16
+ subject,
17
+ kind = "command",
18
+ grade = "L2",
19
+ coverage = null,
20
+ }) {
21
+ const logsDir = join(runtimeDir, "runs", runId, "logs");
22
+ mkdirSync(logsDir, { recursive: true });
23
+ const logName = `${step}-${attempt}.log`;
24
+ const logPath = join(logsDir, logName);
25
+ const logBody = [
26
+ `command: ${execResult.command}`,
27
+ `exitCode: ${execResult.exitCode}`,
28
+ `signal: ${execResult.signal}`,
29
+ `timedOut: ${execResult.timedOut}`,
30
+ `spawnError: ${execResult.spawnError}`,
31
+ "--- stdout ---",
32
+ execResult.stdout,
33
+ "--- stderr ---",
34
+ execResult.stderr,
35
+ ].join("\n");
36
+ writeFileSync(logPath, logBody, "utf8");
37
+ const digest = `sha256:${createHash("sha256").update(logBody, "utf8").digest("hex")}`;
38
+
39
+ let status;
40
+ if (execResult.spawnError) {
41
+ status = "unverified";
42
+ } else if (execResult.exitCode === 0 && !execResult.timedOut && !execResult.signal) {
43
+ status = "passed";
44
+ } else {
45
+ status = "failed";
46
+ }
47
+
48
+ return {
49
+ kind,
50
+ subject,
51
+ producer: "runner",
52
+ command: execResult.command,
53
+ exitCode: execResult.exitCode,
54
+ startedAt: execResult.startedAt,
55
+ finishedAt: execResult.finishedAt,
56
+ digest,
57
+ location: logPath,
58
+ coverage,
59
+ status,
60
+ adapter: execResult.adapter,
61
+ grade,
62
+ };
63
+ }
@@ -0,0 +1,194 @@
1
+ // observe.yaml loader per docs/v2/RFC-0003-workflow-policy.md §8 (frozen
2
+ // shape, block form — the strict YAML subset rejects inline {}/[]). Fail
3
+ // closed: any shape violation is an error, never a default.
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+ import { basename, dirname, resolve } from "node:path";
8
+
9
+ import {
10
+ OBSERVE_BAND_LEVELS,
11
+ OBSERVE_SEVERITIES,
12
+ TRIAGE_ACTIONS,
13
+ } from "../domain/model.js";
14
+ import { parseYamlSubset } from "../engine/yaml-subset.js";
15
+
16
+ export class ObserveConfigError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "ObserveConfigError";
20
+ }
21
+ }
22
+
23
+ const WHEN_PATTERN = /^severity\s*>=\s*(info|warn|error|critical)$/;
24
+
25
+ export function severityRank(severity) {
26
+ const rank = OBSERVE_SEVERITIES.indexOf(severity);
27
+ if (rank === -1) {
28
+ throw new ObserveConfigError(`unknown severity: ${severity}`);
29
+ }
30
+ return rank;
31
+ }
32
+
33
+ function requireString(value, label) {
34
+ if (typeof value !== "string" || value.length === 0) {
35
+ throw new ObserveConfigError(`${label} must be a non-empty string`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function parseSeverityMap(raw, providerId) {
41
+ const map = { failed: "error", unverified: "warn" };
42
+ if (raw === undefined) {
43
+ return map;
44
+ }
45
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
46
+ throw new ObserveConfigError(`provider ${providerId}: severity must be a map`);
47
+ }
48
+ for (const [key, value] of Object.entries(raw)) {
49
+ if (key !== "failed" && key !== "unverified") {
50
+ throw new ObserveConfigError(`provider ${providerId}: severity.${key} is not a known key`);
51
+ }
52
+ if (!OBSERVE_SEVERITIES.includes(value)) {
53
+ throw new ObserveConfigError(
54
+ `provider ${providerId}: severity.${key} must be one of ${OBSERVE_SEVERITIES.join("|")}`,
55
+ );
56
+ }
57
+ map[key] = value;
58
+ }
59
+ return map;
60
+ }
61
+
62
+ function parseProvider(raw, index) {
63
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
64
+ throw new ObserveConfigError(`providers[${index}] must be a map`);
65
+ }
66
+ const id = requireString(raw.id, `providers[${index}].id`);
67
+ const command = requireString(raw.command, `provider ${id}: command`);
68
+ const args = raw.args ?? [];
69
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" && typeof arg !== "number")) {
70
+ throw new ObserveConfigError(`provider ${id}: args must be a list of scalars`);
71
+ }
72
+ if (raw.evidence === null || typeof raw.evidence !== "object" || Array.isArray(raw.evidence)) {
73
+ throw new ObserveConfigError(`provider ${id}: evidence must be a map with kind and subject`);
74
+ }
75
+ const evidence = {
76
+ kind: requireString(raw.evidence.kind, `provider ${id}: evidence.kind`),
77
+ subject: requireString(raw.evidence.subject, `provider ${id}: evidence.subject`),
78
+ };
79
+ let diagnose = null;
80
+ if (raw.diagnose !== undefined) {
81
+ if (raw.diagnose === null || typeof raw.diagnose !== "object" || Array.isArray(raw.diagnose)) {
82
+ throw new ObserveConfigError(`provider ${id}: diagnose must be a map with command`);
83
+ }
84
+ const diagnoseArgs = raw.diagnose.args ?? [];
85
+ if (!Array.isArray(diagnoseArgs)) {
86
+ throw new ObserveConfigError(`provider ${id}: diagnose.args must be a list`);
87
+ }
88
+ diagnose = {
89
+ command: requireString(raw.diagnose.command, `provider ${id}: diagnose.command`),
90
+ args: diagnoseArgs.map((arg) => String(arg)),
91
+ timeoutMs: raw.diagnose.timeoutMs,
92
+ };
93
+ }
94
+ return {
95
+ id,
96
+ command,
97
+ args: args.map((arg) => String(arg)),
98
+ schedule: raw.schedule === undefined ? "manual" : String(raw.schedule),
99
+ evidence,
100
+ severity: parseSeverityMap(raw.severity, id),
101
+ diagnose,
102
+ timeoutMs: raw.timeoutMs,
103
+ };
104
+ }
105
+
106
+ function parseBands(raw) {
107
+ if (!Array.isArray(raw) || raw.length !== OBSERVE_BAND_LEVELS.length) {
108
+ throw new ObserveConfigError(
109
+ `bands must list exactly the three levels ${OBSERVE_BAND_LEVELS.join(", ")} in order`,
110
+ );
111
+ }
112
+ return raw.map((band, index) => {
113
+ const expectedLevel = OBSERVE_BAND_LEVELS[index];
114
+ if (band === null || typeof band !== "object" || Array.isArray(band)) {
115
+ throw new ObserveConfigError(`bands[${index}] must be a map`);
116
+ }
117
+ if (band.level !== expectedLevel) {
118
+ throw new ObserveConfigError(
119
+ `bands[${index}].level must be ${expectedLevel} (levels are fixed), got: ${band.level}`,
120
+ );
121
+ }
122
+ const when = requireString(band.when, `band ${expectedLevel}: when`);
123
+ const match = WHEN_PATTERN.exec(when.trim());
124
+ if (!match) {
125
+ throw new ObserveConfigError(
126
+ `band ${expectedLevel}: when must match "severity >= <level>", got: ${when}`,
127
+ );
128
+ }
129
+ return { level: expectedLevel, minSeverity: match[1] };
130
+ });
131
+ }
132
+
133
+ function parseTriage(raw) {
134
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
135
+ throw new ObserveConfigError("triage must be a map");
136
+ }
137
+ const actions = raw.actions;
138
+ if (
139
+ !Array.isArray(actions) ||
140
+ actions.length !== TRIAGE_ACTIONS.length ||
141
+ TRIAGE_ACTIONS.some((action, index) => actions[index] !== action)
142
+ ) {
143
+ throw new ObserveConfigError(
144
+ `triage.actions must be exactly [${TRIAGE_ACTIONS.join(", ")}] (closed set)`,
145
+ );
146
+ }
147
+ if (raw.dismissFeedback !== "bands") {
148
+ throw new ObserveConfigError('triage.dismissFeedback must be "bands"');
149
+ }
150
+ return { actions: [...TRIAGE_ACTIONS], dismissFeedback: "bands" };
151
+ }
152
+
153
+ export function loadObserveConfig(configPath) {
154
+ const absolutePath = resolve(configPath);
155
+ const text = readFileSync(absolutePath, "utf8");
156
+ const raw = parseYamlSubset(text);
157
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
158
+ throw new ObserveConfigError("observe config must be a YAML map");
159
+ }
160
+ if (raw.kind !== "workflow") {
161
+ throw new ObserveConfigError(`kind must be workflow, got: ${raw.kind}`);
162
+ }
163
+ if (raw.version !== 1) {
164
+ throw new ObserveConfigError(`unsupported observe config version: ${raw.version}`);
165
+ }
166
+ if (raw.name !== "observe") {
167
+ throw new ObserveConfigError(`name must be observe, got: ${raw.name}`);
168
+ }
169
+ if (!Array.isArray(raw.providers) || raw.providers.length === 0) {
170
+ throw new ObserveConfigError("providers must be a non-empty list");
171
+ }
172
+ const providers = raw.providers.map((provider, index) => parseProvider(provider, index));
173
+ const ids = new Set();
174
+ for (const provider of providers) {
175
+ if (ids.has(provider.id)) {
176
+ throw new ObserveConfigError(`duplicate provider id: ${provider.id}`);
177
+ }
178
+ ids.add(provider.id);
179
+ }
180
+ const configDir = dirname(absolutePath);
181
+ const repoRoot = raw.repo
182
+ ? resolve(configDir, String(raw.repo))
183
+ : basename(configDir) === ".buildbeat"
184
+ ? dirname(configDir)
185
+ : configDir;
186
+ return {
187
+ configPath: absolutePath,
188
+ configDigest: `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`,
189
+ repoRoot,
190
+ providers,
191
+ bands: parseBands(raw.bands),
192
+ triage: parseTriage(raw.triage),
193
+ };
194
+ }
@@ -0,0 +1,117 @@
1
+ // Deterministic reducer for the observe ledger (RFC-0003 §8, implemented M5).
2
+ // Shares envelope/chain rules with the run ledger via EventLedger's pluggable
3
+ // reducer; state here is a runtime view only — triage terminal state lives in
4
+ // the Git-plane intent draft files (invariant 23: runtime is deletable).
5
+
6
+ export class IllegalObserveEventError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "IllegalObserveEventError";
10
+ }
11
+ }
12
+
13
+ export function observeInitialState() {
14
+ return {
15
+ seq: 0,
16
+ cycles: 0,
17
+ activeCycle: null,
18
+ providers: {},
19
+ evidence: [],
20
+ bands: [],
21
+ intents: {},
22
+ triage: [],
23
+ };
24
+ }
25
+
26
+ export function applyObserveEvent(state, event) {
27
+ if (event.seq !== state.seq + 1) {
28
+ throw new IllegalObserveEventError(`expected seq ${state.seq + 1}, got ${event.seq}`);
29
+ }
30
+ const { type, data } = event;
31
+ const next = structuredClone(state);
32
+ next.seq = event.seq;
33
+
34
+ switch (type) {
35
+ case "OBSERVE_CYCLE_STARTED": {
36
+ if (state.activeCycle !== null) {
37
+ // A crashed cycle never finished; the new cycle supersedes it. Facts
38
+ // already appended stand; nothing is repaired.
39
+ next.activeCycle = null;
40
+ }
41
+ if (data.cycle !== state.cycles + 1) {
42
+ throw new IllegalObserveEventError(
43
+ `expected cycle ${state.cycles + 1}, got ${data.cycle}`,
44
+ );
45
+ }
46
+ next.activeCycle = { cycle: data.cycle, configDigest: data.configDigest, ts: event.ts };
47
+ next.cycles = data.cycle;
48
+ break;
49
+ }
50
+ case "OBSERVE_CYCLE_FINISHED": {
51
+ if (!state.activeCycle || state.activeCycle.cycle !== data.cycle) {
52
+ throw new IllegalObserveEventError(`no active cycle ${data.cycle} to finish`);
53
+ }
54
+ next.activeCycle = null;
55
+ break;
56
+ }
57
+ case "EVIDENCE_RECORDED": {
58
+ next.evidence.push({
59
+ ref: data.evidenceRef,
60
+ kind: data.kind,
61
+ subject: data.subject,
62
+ digest: data.digest,
63
+ status: data.status,
64
+ grade: data.grade,
65
+ ...(data.provider ? { provider: data.provider } : {}),
66
+ ...(data.cycle !== undefined ? { cycle: data.cycle } : {}),
67
+ });
68
+ if (data.provider) {
69
+ next.providers[data.provider] = {
70
+ lastStatus: data.status,
71
+ lastKind: data.kind,
72
+ lastSubject: data.subject,
73
+ lastEvidenceRef: data.evidenceRef,
74
+ lastTs: event.ts,
75
+ };
76
+ }
77
+ break;
78
+ }
79
+ case "BAND_TRIGGERED": {
80
+ next.bands.push({
81
+ provider: data.provider,
82
+ band: data.band,
83
+ severity: data.severity,
84
+ fingerprint: data.fingerprint,
85
+ ts: event.ts,
86
+ });
87
+ break;
88
+ }
89
+ case "INTENT_DRAFTED": {
90
+ next.intents[data.fingerprint] = {
91
+ intentRef: data.intentRef,
92
+ provider: data.provider,
93
+ severity: data.severity,
94
+ ts: event.ts,
95
+ };
96
+ break;
97
+ }
98
+ case "TRIAGE_RECORDED": {
99
+ next.triage.push({
100
+ intentRef: data.intentRef,
101
+ action: data.action,
102
+ fingerprint: data.fingerprint,
103
+ ts: event.ts,
104
+ });
105
+ break;
106
+ }
107
+ default:
108
+ // Unknown type written by a newer version: preserved, skipped.
109
+ break;
110
+ }
111
+ return next;
112
+ }
113
+
114
+ export const OBSERVE_REDUCER = {
115
+ initialState: observeInitialState,
116
+ applyEvent: applyObserveEvent,
117
+ };