@mikenguyen69/harness 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/build-identity.json +1 -0
- package/dist/cancellation.js +138 -0
- package/dist/cli.js +47 -0
- package/dist/dispatch.js +70 -0
- package/dist/help.js +47 -0
- package/dist/internal/agent/adapter/conformance.js +53 -0
- package/dist/internal/agent/adapter/hook.js +113 -0
- package/dist/internal/agent/adapter/install.js +30 -0
- package/dist/internal/agent/adapter/scope.js +55 -0
- package/dist/internal/agent/adapter/session.js +63 -0
- package/dist/internal/agent/adapter/settings.js +51 -0
- package/dist/internal/agent/cli.js +79 -0
- package/dist/internal/agent/evals/index.js +111 -0
- package/dist/internal/agent/index.js +15 -0
- package/dist/internal/agent/ledger-read.js +27 -0
- package/dist/internal/agent/routing/cli.js +104 -0
- package/dist/internal/agent/routing/index.js +123 -0
- package/dist/internal/agent/telemetry/index.js +97 -0
- package/dist/internal/orchestration/activity/index.js +111 -0
- package/dist/internal/orchestration/analyze/index.js +237 -0
- package/dist/internal/orchestration/cli.js +475 -0
- package/dist/internal/orchestration/config/index.js +66 -0
- package/dist/internal/orchestration/doctor/index.js +202 -0
- package/dist/internal/orchestration/forge/index.js +121 -0
- package/dist/internal/orchestration/git/index.js +181 -0
- package/dist/internal/orchestration/harness/cli.js +378 -0
- package/dist/internal/orchestration/harness/index.js +186 -0
- package/dist/internal/orchestration/harness/shim.js +182 -0
- package/dist/internal/orchestration/index.js +29 -0
- package/dist/internal/orchestration/loop/index.js +1107 -0
- package/dist/internal/orchestration/prompts/index.js +199 -0
- package/dist/internal/orchestration/report/index.js +125 -0
- package/dist/internal/orchestration/routes/index.js +85 -0
- package/dist/internal/orchestration/run/index.js +221 -0
- package/dist/internal/orchestration/runner/index.js +90 -0
- package/dist/internal/orchestration/runners/claude/index.js +246 -0
- package/dist/internal/orchestration/runners/claude/sdk.js +198 -0
- package/dist/internal/orchestration/runners/claude/wiring.js +98 -0
- package/dist/internal/orchestration/runners/codex/exec.js +114 -0
- package/dist/internal/orchestration/runners/codex/index.js +72 -0
- package/dist/internal/orchestration/runners/conformance/index.js +169 -0
- package/dist/internal/orchestration/runners/cursor/exec.js +531 -0
- package/dist/internal/orchestration/runners/cursor/index.js +184 -0
- package/dist/internal/orchestration/runners/stub/index.js +168 -0
- package/dist/internal/orchestration/runners/telemetry.js +36 -0
- package/dist/internal/orchestration/runstate/index.js +213 -0
- package/dist/internal/orchestration/runstate/publish.js +115 -0
- package/dist/internal/orchestration/worktree/index.js +89 -0
- package/dist/internal/system/adapters/index.js +19 -0
- package/dist/internal/system/agentops/index.js +16 -0
- package/dist/internal/system/agentops/scope.js +67 -0
- package/dist/internal/system/assets.js +45 -0
- package/dist/internal/system/board/cli.js +56 -0
- package/dist/internal/system/board/index.js +276 -0
- package/dist/internal/system/board/live.js +277 -0
- package/dist/internal/system/calibration/cli.js +45 -0
- package/dist/internal/system/calibration/index.js +141 -0
- package/dist/internal/system/cli.js +174 -0
- package/dist/internal/system/corpus/replay.js +79 -0
- package/dist/internal/system/digest/cli.js +23 -0
- package/dist/internal/system/digest/index.js +145 -0
- package/dist/internal/system/doors/classify.js +94 -0
- package/dist/internal/system/doors/cli.js +184 -0
- package/dist/internal/system/doors/index.js +249 -0
- package/dist/internal/system/doors/metrics.js +47 -0
- package/dist/internal/system/escapes/cli.js +45 -0
- package/dist/internal/system/escapes/index.js +81 -0
- package/dist/internal/system/explore/cli.js +77 -0
- package/dist/internal/system/explore/index.js +91 -0
- package/dist/internal/system/gates/cli.js +43 -0
- package/dist/internal/system/gates/detectors.js +55 -0
- package/dist/internal/system/gates/pre.js +51 -0
- package/dist/internal/system/hub/cli.js +51 -0
- package/dist/internal/system/hub/index.js +146 -0
- package/dist/internal/system/init/index.js +83 -0
- package/dist/internal/system/ledger/cli.js +54 -0
- package/dist/internal/system/ledger/index.js +176 -0
- package/dist/internal/system/manifest/index.js +207 -0
- package/dist/internal/system/provenance/index.js +13 -0
- package/dist/internal/system/report/index.js +15 -0
- package/dist/internal/system/runner/index.js +74 -0
- package/dist/internal/system/runstatus/cli.js +86 -0
- package/dist/internal/system/runstatus/index.js +168 -0
- package/dist/internal/system/sequencer/briefing.js +75 -0
- package/dist/internal/system/sequencer/cli.js +170 -0
- package/dist/internal/system/sequencer/index.js +156 -0
- package/dist/internal/system/spec/cli.js +110 -0
- package/dist/internal/system/spec/index.js +212 -0
- package/dist/internal/system/spec/materialize.js +111 -0
- package/dist/internal/system/spec/openspec-parse.js +83 -0
- package/dist/internal/system/spec/project.js +208 -0
- package/dist/internal/system/substrate/index.js +14 -0
- package/dist/internal/system/system/index.js +159 -0
- package/dist/internal/system/units/index.js +87 -0
- package/dist/internal/system/verify/cli.js +103 -0
- package/dist/internal/system/verify/declined-reasons.js +61 -0
- package/dist/internal/system/verify/index.js +188 -0
- package/dist/internal/system/verify/signing.js +79 -0
- package/dist/paths.js +50 -0
- package/dist/routes.js +93 -0
- package/dist/version.js +30 -0
- package/dist/win32-process-group.js +360 -0
- package/package.json +41 -0
- package/profiles/python/bindings.toml +59 -0
- package/profiles/terraform/bindings.toml +66 -0
- package/profiles/typescript/bindings.toml +64 -0
- package/profiles/typescript/smoke/README.md +8 -0
- package/schema/baseline.schema.json +26 -0
- package/schema/decision-classes.yaml +45 -0
- package/schema/envelope.schema.json +186 -0
- package/schema/ledger-event.schema.json +235 -0
- package/schema/manifest.schema.json +119 -0
- package/schema/routes.schema.json +55 -0
- package/schema/run-status.schema.json +162 -0
- package/schema/session-event.schema.json +74 -0
- package/schema/spec.schema.json +114 -0
- package/schema/system.schema.json +142 -0
- package/templates/ci/verify.yml +23 -0
- package/templates/target-kit/README.md +78 -0
- package/templates/target-kit/component.toml +20 -0
- package/templates/target-kit/orchestration.json +9 -0
- package/templates/target-kit/profiles/target/bindings.toml +6 -0
- package/templates/target-kit/routes.toml +5 -0
- package/templates/target-kit/specs/first-unit.yaml +11 -0
- package/templates/target-kit/system.toml +7 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runstatus — the run-status overlay's write side (P7.1). An orchestration run
|
|
3
|
+
* publishes a latest-state-wins snapshot per `(machine_id, run_id)` into the hub
|
|
4
|
+
* spool; the hub ingests it beside portfolio truth (P7.2) and the board renders
|
|
5
|
+
* it (P7.3). This is NOT a ledger channel — the document is disposable and never
|
|
6
|
+
* an input to a merge, door, or calibration decision (dashboard-plan.md).
|
|
7
|
+
*
|
|
8
|
+
* validateRunStatus(doc) — schema check (run-status.schema.json)
|
|
9
|
+
* publishRunStatus(doc, opts) — stamp machine_id, validate, write to spool
|
|
10
|
+
*
|
|
11
|
+
* A publisher never writes spool files directly — it calls
|
|
12
|
+
* `harness runstatus publish`, exactly as it appends the ledger only through
|
|
13
|
+
* `harness ledger append`.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
18
|
+
import addFormatsDefault from "ajv-formats";
|
|
19
|
+
import { packageSchemaDir } from "../assets.js";
|
|
20
|
+
const addFormats = addFormatsDefault;
|
|
21
|
+
export class RunStatusError extends Error {
|
|
22
|
+
name = "RunStatusError";
|
|
23
|
+
}
|
|
24
|
+
function schemaPath() {
|
|
25
|
+
return join(packageSchemaDir(), "run-status.schema.json");
|
|
26
|
+
}
|
|
27
|
+
let _validate;
|
|
28
|
+
function validator() {
|
|
29
|
+
if (!_validate) {
|
|
30
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
31
|
+
addFormats(ajv);
|
|
32
|
+
_validate = ajv.compile(JSON.parse(readFileSync(schemaPath(), "utf8")));
|
|
33
|
+
}
|
|
34
|
+
return _validate;
|
|
35
|
+
}
|
|
36
|
+
export function validateRunStatus(doc) {
|
|
37
|
+
const v = validator();
|
|
38
|
+
if (v(doc))
|
|
39
|
+
return { ok: true };
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
errors: (v.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "invalid"}`),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** `<machine_id>__<run_id>` with anything outside [A-Za-z0-9._-] collapsed to `-`. */
|
|
46
|
+
export function spoolKey(machineId, runId) {
|
|
47
|
+
const s = (x) => x.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
48
|
+
return `${s(machineId)}__${s(runId)}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stamp `machine_id` (a value the publisher set itself is overridden — the hub
|
|
52
|
+
* trusts only what `publish` writes), validate, and write to the spool as
|
|
53
|
+
* `<spoolDir>/<key>.json`. Latest-per-key wins: the file is overwritten.
|
|
54
|
+
*/
|
|
55
|
+
export function publishRunStatus(doc, opts) {
|
|
56
|
+
const stamped = { ...doc, machine_id: opts.machineId };
|
|
57
|
+
const check = validateRunStatus(stamped);
|
|
58
|
+
if (!check.ok) {
|
|
59
|
+
throw new RunStatusError(`run-status document is invalid:\n ${check.errors.join("\n ")}`);
|
|
60
|
+
}
|
|
61
|
+
const runId = String(stamped.run_id ?? "");
|
|
62
|
+
if (!runId)
|
|
63
|
+
throw new RunStatusError("run-status document has no run_id");
|
|
64
|
+
const key = spoolKey(opts.machineId, runId);
|
|
65
|
+
mkdirSync(opts.spoolDir, { recursive: true });
|
|
66
|
+
const path = join(opts.spoolDir, `${key}.json`);
|
|
67
|
+
writeFileSync(path, JSON.stringify(stamped, null, 2) + "\n");
|
|
68
|
+
return { path, key };
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Ingestion + staleness (P7.2) — the read side the board renders.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
/** ms since `heartbeat_at` before a run renders as `stale`, never `live`. */
|
|
74
|
+
export const DEFAULT_STALE_AFTER_MS = 90_000;
|
|
75
|
+
/** ms since `heartbeat_at` (or a terminal `outcome`) after which a run is retired. */
|
|
76
|
+
export const DEFAULT_EXPIRE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
77
|
+
/**
|
|
78
|
+
* ms a run may go without opening its first lane before it retires early. A
|
|
79
|
+
* process that crashes before ever publishing a lane leaves a zero-lane
|
|
80
|
+
* document behind that would otherwise sit at "stale" for a full day,
|
|
81
|
+
* cluttering the board at the same visual weight as a real run.
|
|
82
|
+
*/
|
|
83
|
+
export const DEFAULT_ZOMBIE_AFTER_MS = 15 * 60 * 1000;
|
|
84
|
+
/**
|
|
85
|
+
* Project the spool into the board overlay. The spool already holds
|
|
86
|
+
* latest-per-`(machine_id, run_id)` (publish overwrites `<key>.json`), so this
|
|
87
|
+
* is a pure classify + sort + optional redact. Re-projecting the same spool
|
|
88
|
+
* yields a byte-identical overlay bar `generated_at` (golden property).
|
|
89
|
+
*/
|
|
90
|
+
export function projectRuns(spoolDir, opts = {}) {
|
|
91
|
+
const now = opts.now ?? Date.now();
|
|
92
|
+
const staleAfter = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
93
|
+
const expireAfter = opts.expireAfterMs ?? DEFAULT_EXPIRE_AFTER_MS;
|
|
94
|
+
const zombieAfter = opts.zombieAfterMs ?? DEFAULT_ZOMBIE_AFTER_MS;
|
|
95
|
+
const entries = [];
|
|
96
|
+
if (existsSync(spoolDir)) {
|
|
97
|
+
for (const name of readdirSync(spoolDir).filter((n) => n.endsWith(".json"))) {
|
|
98
|
+
let doc;
|
|
99
|
+
try {
|
|
100
|
+
doc = JSON.parse(readFileSync(join(spoolDir, name), "utf8"));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue; // a half-written spool file — skip, never crash the board
|
|
104
|
+
}
|
|
105
|
+
const check = validateRunStatus(doc);
|
|
106
|
+
if (!check.ok)
|
|
107
|
+
continue;
|
|
108
|
+
const hb = Date.parse(String(doc.heartbeat_at ?? ""));
|
|
109
|
+
const age = Number.isNaN(hb) ? Infinity : now - hb;
|
|
110
|
+
const terminal = typeof doc.outcome === "string";
|
|
111
|
+
const hasLanes = Array.isArray(doc.lanes) && doc.lanes.length > 0;
|
|
112
|
+
let liveness;
|
|
113
|
+
if (terminal || age > expireAfter)
|
|
114
|
+
liveness = "retired";
|
|
115
|
+
// never opened a lane and gone quiet well past staleness — the actuator
|
|
116
|
+
// most likely crashed before its first publish; don't let it sit at
|
|
117
|
+
// "stale" for a full day at the same weight as a real run.
|
|
118
|
+
else if (!hasLanes && age > zombieAfter)
|
|
119
|
+
liveness = "retired";
|
|
120
|
+
else if (age > staleAfter)
|
|
121
|
+
liveness = "stale";
|
|
122
|
+
else
|
|
123
|
+
liveness = "live";
|
|
124
|
+
if (liveness === "retired")
|
|
125
|
+
continue; // dropped from the overlay
|
|
126
|
+
entries.push({
|
|
127
|
+
machine_id: String(doc.machine_id),
|
|
128
|
+
run_id: String(doc.run_id),
|
|
129
|
+
liveness,
|
|
130
|
+
heartbeat_age_ms: Number.isFinite(age) ? age : -1,
|
|
131
|
+
document: opts.redact ? redactRunStatus(doc) : doc,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Live before stale (the thing changing belongs above the things that
|
|
136
|
+
// aren't), freshest heartbeat first within each group, (machine_id, run_id)
|
|
137
|
+
// as the final tiebreak so a tie of identical ages is still deterministic.
|
|
138
|
+
const livenessRank = { live: 0, stale: 1, retired: 2 };
|
|
139
|
+
entries.sort((a, b) => livenessRank[a.liveness] - livenessRank[b.liveness] ||
|
|
140
|
+
a.heartbeat_age_ms - b.heartbeat_age_ms ||
|
|
141
|
+
a.machine_id.localeCompare(b.machine_id) ||
|
|
142
|
+
a.run_id.localeCompare(b.run_id));
|
|
143
|
+
return { generated_at: new Date(now).toISOString(), runs: entries };
|
|
144
|
+
}
|
|
145
|
+
/** Strip operator-facing reason text; keep the machine-actionable `kind` / `unblock`. */
|
|
146
|
+
export function redactRunStatus(doc) {
|
|
147
|
+
const clone = JSON.parse(JSON.stringify(doc));
|
|
148
|
+
const needs = clone.needs_you;
|
|
149
|
+
if (Array.isArray(needs)) {
|
|
150
|
+
for (const n of needs) {
|
|
151
|
+
if (typeof n.message === "string")
|
|
152
|
+
n.message = "[redacted]";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return clone;
|
|
156
|
+
}
|
|
157
|
+
/** Copy run-status spool files (like `hub sync` for the ledger). Latest file wins. */
|
|
158
|
+
export function syncSpool(fromDir, toDir) {
|
|
159
|
+
if (!existsSync(fromDir))
|
|
160
|
+
return [];
|
|
161
|
+
mkdirSync(toDir, { recursive: true });
|
|
162
|
+
const names = readdirSync(fromDir).filter((n) => n.endsWith(".json"));
|
|
163
|
+
for (const n of names) {
|
|
164
|
+
writeFileSync(join(toDir, n), readFileSync(join(fromDir, n), "utf8"));
|
|
165
|
+
}
|
|
166
|
+
return names;
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* briefing — assembles the `harness claim <id> --json` payload (P7.4).
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator (orchestration-harness) claims a unit and needs everything
|
|
5
|
+
* to run an implementer/verifier session off it, WITHOUT re-reading the spec
|
|
6
|
+
* DAG or guessing merge policy from a diff:
|
|
7
|
+
* - intent + acceptance_criteria + effective scope → the session prompt
|
|
8
|
+
* - resolved branch → where the work lands
|
|
9
|
+
* - component + profile → routing context
|
|
10
|
+
* - declared merge facts (door + P7.5 merge_facts) → passed to
|
|
11
|
+
* `harness merge` verbatim; NEVER derived from the diff
|
|
12
|
+
*
|
|
13
|
+
* Pure over specs/*.yaml + system.toml — no ledger writes.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { loadSpecs } from "../spec/index.js";
|
|
17
|
+
import { loadSystem } from "../system/index.js";
|
|
18
|
+
export class BriefingError extends Error {
|
|
19
|
+
name = "BriefingError";
|
|
20
|
+
}
|
|
21
|
+
function bareId(ref) {
|
|
22
|
+
return ref.includes("#") ? ref.slice(ref.indexOf("#") + 1) : ref;
|
|
23
|
+
}
|
|
24
|
+
/** `unit/<id>` with the id sanitised for git ref-name rules. */
|
|
25
|
+
export function branchFor(unitId) {
|
|
26
|
+
const slug = bareId(unitId)
|
|
27
|
+
.replaceAll(".", "-")
|
|
28
|
+
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "");
|
|
30
|
+
return `unit/${slug}`;
|
|
31
|
+
}
|
|
32
|
+
const NO_FACTS = {
|
|
33
|
+
adds_dependency: false,
|
|
34
|
+
schema_change: false,
|
|
35
|
+
security: false,
|
|
36
|
+
gate_external: false,
|
|
37
|
+
artifact_producer_change: false,
|
|
38
|
+
};
|
|
39
|
+
export function buildBriefing(unitId, opts = {}) {
|
|
40
|
+
const specsDir = opts.specsDir ?? "specs";
|
|
41
|
+
const systemToml = opts.systemToml ?? "system.toml";
|
|
42
|
+
const bare = bareId(unitId);
|
|
43
|
+
if (!existsSync(specsDir)) {
|
|
44
|
+
throw new BriefingError(`specs dir not found: ${specsDir}`);
|
|
45
|
+
}
|
|
46
|
+
const spec = loadSpecs(specsDir).find((s) => s.spec.id === bare)?.spec;
|
|
47
|
+
if (!spec) {
|
|
48
|
+
throw new BriefingError(`no spec for unit "${bare}" under ${specsDir}`);
|
|
49
|
+
}
|
|
50
|
+
let profile = null;
|
|
51
|
+
if (existsSync(systemToml)) {
|
|
52
|
+
const sys = loadSystem(systemToml);
|
|
53
|
+
profile = sys.component.find((c) => c.id === spec.component)?.profile ?? null;
|
|
54
|
+
}
|
|
55
|
+
const mf = spec.merge_facts ?? {};
|
|
56
|
+
return {
|
|
57
|
+
unit_id: bare,
|
|
58
|
+
component: spec.component,
|
|
59
|
+
profile,
|
|
60
|
+
branch: branchFor(bare),
|
|
61
|
+
intent: spec.intent,
|
|
62
|
+
acceptance_criteria: spec.acceptance_criteria,
|
|
63
|
+
scope: spec.scope,
|
|
64
|
+
door: spec.door ?? null,
|
|
65
|
+
merge_facts: {
|
|
66
|
+
...NO_FACTS,
|
|
67
|
+
adds_dependency: mf.adds_dependency ?? false,
|
|
68
|
+
schema_change: mf.schema_change ?? false,
|
|
69
|
+
security: mf.security ?? false,
|
|
70
|
+
gate_external: mf.gate_external ?? false,
|
|
71
|
+
artifact_producer_change: mf.artifact_producer_change ?? false,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=briefing.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harness next` / `harness claim` / `harness heartbeat` — the sequencer surface.
|
|
3
|
+
*
|
|
4
|
+
* harness next [--ledger <dir>] [--only <id-prefix>]
|
|
5
|
+
* [--escalated | --in-review | --merged | --all] [--json]
|
|
6
|
+
* harness claim <unit-id> --session <id> [--ledger <dir>] [--json]
|
|
7
|
+
* [--specs <dir>] [--system <system.toml>]
|
|
8
|
+
* [--resume | --resume-after-door] [--keys <dir>]
|
|
9
|
+
* harness heartbeat <unit-id> --session <id> [--ledger <dir>]
|
|
10
|
+
*
|
|
11
|
+
* `next` exits 0 with ids on stdout, or 0 with nothing when idle. `--json`
|
|
12
|
+
* emits the ready set as `[{id, component, deps, mode}]` — text stays default.
|
|
13
|
+
* `--in-review` (P7.6) lists units stranded between `unit.done` and `merge`;
|
|
14
|
+
* its `--json` rows carry `done_at` / `done_seq`.
|
|
15
|
+
* `--merged` (G2.3) lists merged units; its `--json` rows carry `landed` (the
|
|
16
|
+
* sha from `unit.merged`'s body) — the actuator's ledger-ahead-of-git sweep.
|
|
17
|
+
* `claim` exits 0 if the unit was claimed, 1 otherwise. `--json` emits the full
|
|
18
|
+
* briefing on success (P7.4), or `{"error": "<reason>"}` on a lost claim.
|
|
19
|
+
* `--resume` (P7.6) reclaims a stale in-review unit instead of a ready one.
|
|
20
|
+
* `--resume-after-door` permits immediate reclaim only when a cryptographically
|
|
21
|
+
* valid approved door covers that in-review unit.
|
|
22
|
+
*/
|
|
23
|
+
import { targetOwnedPaths, targetPath } from "../assets.js";
|
|
24
|
+
import { buildBriefing, BriefingError, } from "./briefing.js";
|
|
25
|
+
import { allUnits, claim, escalatedUnits, heartbeat, inReviewUnits, mergedUnits, readyUnits, resumeClaim, } from "./index.js";
|
|
26
|
+
import { read as ledgerRead } from "../ledger/index.js";
|
|
27
|
+
import { bareUnitId, projectDoors } from "../doors/index.js";
|
|
28
|
+
import { loadOperatorPublicKey } from "../verify/signing.js";
|
|
29
|
+
import { resolve } from "node:path";
|
|
30
|
+
function flag(args, name) {
|
|
31
|
+
const i = args.indexOf(`--${name}`);
|
|
32
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : undefined;
|
|
33
|
+
}
|
|
34
|
+
function opts(args, cwd = process.cwd()) {
|
|
35
|
+
const raw = flag(args, "ledger");
|
|
36
|
+
return {
|
|
37
|
+
ledgerDir: raw
|
|
38
|
+
? resolve(cwd, raw)
|
|
39
|
+
: targetOwnedPaths(cwd).ledgerDir,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function nextCommand(args) {
|
|
43
|
+
const only = flag(args, "only");
|
|
44
|
+
const escalated = args.includes("--escalated");
|
|
45
|
+
const inReview = args.includes("--in-review");
|
|
46
|
+
const merged = args.includes("--merged");
|
|
47
|
+
const all = args.includes("--all");
|
|
48
|
+
const list = escalated
|
|
49
|
+
? escalatedUnits(opts(args))
|
|
50
|
+
: inReview
|
|
51
|
+
? inReviewUnits(opts(args))
|
|
52
|
+
: merged
|
|
53
|
+
? mergedUnits(opts(args))
|
|
54
|
+
: all
|
|
55
|
+
? allUnits(opts(args))
|
|
56
|
+
: readyUnits(opts(args));
|
|
57
|
+
const filtered = list.filter((u) => !only || u.id.startsWith(only));
|
|
58
|
+
if (args.includes("--json")) {
|
|
59
|
+
// the set as data. An empty set is `[]` on stdout (not an error), exit 0.
|
|
60
|
+
process.stdout.write(JSON.stringify(filtered.map((u) => ({
|
|
61
|
+
id: u.id,
|
|
62
|
+
component: u.component ?? null,
|
|
63
|
+
deps: u.deps,
|
|
64
|
+
mode: u.mode ?? "build",
|
|
65
|
+
...(inReview
|
|
66
|
+
? { done_at: u.doneAt ?? null, done_seq: u.doneSeq ?? null }
|
|
67
|
+
: {}),
|
|
68
|
+
...(merged ? { landed: u.landedSha ?? null } : {}),
|
|
69
|
+
...(all
|
|
70
|
+
? {
|
|
71
|
+
state: u.state,
|
|
72
|
+
done_at: u.doneAt ?? null,
|
|
73
|
+
landed: u.landedSha ?? null,
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
}))) + "\n");
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
for (const u of filtered)
|
|
80
|
+
process.stdout.write(`${u.component ? u.component + "#" : ""}${u.id}\n`);
|
|
81
|
+
if (filtered.length === 0) {
|
|
82
|
+
process.stderr.write(escalated
|
|
83
|
+
? "no escalated units\n"
|
|
84
|
+
: inReview
|
|
85
|
+
? "no in-review units\n"
|
|
86
|
+
: merged
|
|
87
|
+
? "no merged units\n"
|
|
88
|
+
: "no ready units\n");
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
export function claimCommand(args) {
|
|
93
|
+
const unitId = args[0];
|
|
94
|
+
const session = flag(args, "session");
|
|
95
|
+
const json = args.includes("--json");
|
|
96
|
+
if (!unitId || unitId.startsWith("--") || !session) {
|
|
97
|
+
process.stderr.write("harness claim <unit-id> --session <id> [--ledger <dir>] [--json] [--specs <dir>] [--system <path>]\n");
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
const resumeAfterDoor = args.includes("--resume-after-door");
|
|
101
|
+
const resume = args.includes("--resume") || resumeAfterDoor;
|
|
102
|
+
if (resumeAfterDoor && !hasApprovedDoor(unitId, args)) {
|
|
103
|
+
if (json)
|
|
104
|
+
process.stdout.write(JSON.stringify({ error: "no-approved-door" }) + "\n");
|
|
105
|
+
else
|
|
106
|
+
process.stderr.write("not resumed: no cryptographically valid approved door covers this unit\n");
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
const r = resume
|
|
110
|
+
? resumeClaim(unitId, session, {
|
|
111
|
+
...opts(args),
|
|
112
|
+
...(resumeAfterDoor ? { heartbeatTimeoutMs: -1 } : {}),
|
|
113
|
+
})
|
|
114
|
+
: claim(unitId, session, opts(args));
|
|
115
|
+
if (!r.ok) {
|
|
116
|
+
// exit code unchanged (1); --json puts the reason on stdout as data so a
|
|
117
|
+
// caller can branch on claim-lost without scraping stderr.
|
|
118
|
+
if (json)
|
|
119
|
+
process.stdout.write(JSON.stringify({ error: r.reason }) + "\n");
|
|
120
|
+
else
|
|
121
|
+
process.stderr.write(`${resume ? "not resumed" : "not claimed"}: ${r.reason}\n`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
if (!json) {
|
|
125
|
+
process.stdout.write(`claimed ${unitId} by ${session} (seq ${r.event.seq})\n`);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
const cwd = process.cwd();
|
|
129
|
+
const specsRaw = flag(args, "specs");
|
|
130
|
+
const systemRaw = flag(args, "system");
|
|
131
|
+
let briefing;
|
|
132
|
+
try {
|
|
133
|
+
briefing = buildBriefing(unitId, {
|
|
134
|
+
...(specsRaw ? { specsDir: resolve(cwd, specsRaw) } : { specsDir: targetPath(cwd, "specs") }),
|
|
135
|
+
...(systemRaw
|
|
136
|
+
? { systemToml: resolve(cwd, systemRaw) }
|
|
137
|
+
: { systemToml: targetPath(cwd, "system.toml") }),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
// the claim already landed in the ledger; surface the assembly failure but
|
|
142
|
+
// keep exit non-zero so the caller does not proceed on a partial briefing.
|
|
143
|
+
const msg = e instanceof BriefingError ? e.message : e.message;
|
|
144
|
+
process.stdout.write(JSON.stringify({ error: `claimed but briefing unavailable: ${msg}`, seq: r.event.seq }) + "\n");
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
process.stdout.write(JSON.stringify({ ...briefing, session, seq: r.event.seq }) + "\n");
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
function hasApprovedDoor(unitId, args) {
|
|
151
|
+
const sequencer = opts(args);
|
|
152
|
+
const keyDir = flag(args, "keys")
|
|
153
|
+
? resolve(process.cwd(), flag(args, "keys"))
|
|
154
|
+
: targetOwnedPaths(process.cwd()).harnessDir;
|
|
155
|
+
const publicKey = loadOperatorPublicKey(keyDir);
|
|
156
|
+
return [...projectDoors(ledgerRead(sequencer.ledgerDir), publicKey).values()].some((door) => door.state === "approved" &&
|
|
157
|
+
bareUnitId(door.unitId) === bareUnitId(unitId));
|
|
158
|
+
}
|
|
159
|
+
export function heartbeatCommand(args) {
|
|
160
|
+
const unitId = args[0];
|
|
161
|
+
const session = flag(args, "session");
|
|
162
|
+
if (!unitId || unitId.startsWith("--") || !session) {
|
|
163
|
+
process.stderr.write("harness heartbeat <unit-id> --session <id> [--ledger <dir>]\n");
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
const e = heartbeat(unitId, session, opts(args));
|
|
167
|
+
process.stdout.write(`heartbeat ${unitId} (seq ${e.seq})\n`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequencer — "what is ready to work" is a QUERY over the ledger, not a daemon
|
|
3
|
+
* (solution design §7.3). Claiming is an atomic, CAS-guarded ledger append:
|
|
4
|
+
* exactly one of N concurrent claimers wins. A unit whose holding session has
|
|
5
|
+
* stopped heart-beating becomes reclaimable.
|
|
6
|
+
*/
|
|
7
|
+
import { append, LedgerConflict, read as ledgerRead, ZERO_HASH, } from "../ledger/index.js";
|
|
8
|
+
import { projectUnits } from "../units/index.js";
|
|
9
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
10
|
+
const CLAIM_RETRIES = 5;
|
|
11
|
+
function bareId(ref) {
|
|
12
|
+
return ref.includes("#") ? ref.slice(ref.indexOf("#") + 1) : ref;
|
|
13
|
+
}
|
|
14
|
+
function isStale(u, timeoutMs, now) {
|
|
15
|
+
if (u.state !== "executing")
|
|
16
|
+
return false;
|
|
17
|
+
const hb = u.heartbeatAt ? Date.parse(u.heartbeatAt) : 0;
|
|
18
|
+
return now - hb > timeoutMs;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Ready = not terminal ∧ not escalated ∧ every dep merged ∧
|
|
22
|
+
* (planned | ready | stale-executing). ring-2-clean is a Phase-4 predicate
|
|
23
|
+
* (Contract C) — assumed true here.
|
|
24
|
+
*
|
|
25
|
+
* An escalated unit is deliberately withheld even once its heartbeat goes
|
|
26
|
+
* stale: it is waiting on an operator decision (widen | split | abort), not on
|
|
27
|
+
* a reclaim.
|
|
28
|
+
*/
|
|
29
|
+
export function readyUnits(opts) {
|
|
30
|
+
const timeout = opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
31
|
+
const now = opts.now ?? Date.now();
|
|
32
|
+
const units = projectUnits(ledgerRead(opts.ledgerDir));
|
|
33
|
+
const merged = (id) => units.get(bareId(id))?.state === "merged";
|
|
34
|
+
return [...units.values()]
|
|
35
|
+
.filter((u) => {
|
|
36
|
+
if (u.escalated)
|
|
37
|
+
return false;
|
|
38
|
+
const claimable = u.state === "planned" ||
|
|
39
|
+
u.state === "ready" ||
|
|
40
|
+
isStale(u, timeout, now);
|
|
41
|
+
return claimable && u.deps.every(merged);
|
|
42
|
+
})
|
|
43
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
44
|
+
}
|
|
45
|
+
/** Units whose holding session escalated and are waiting on the operator. */
|
|
46
|
+
export function escalatedUnits(opts) {
|
|
47
|
+
return [...projectUnits(ledgerRead(opts.ledgerDir)).values()]
|
|
48
|
+
.filter((u) => u.escalated)
|
|
49
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Units stranded `in-review` — a lane died between `unit.done` and
|
|
53
|
+
* `harness merge` (P7.6). The list includes escalated ones (visible per P7.7);
|
|
54
|
+
* the resume claim below is what withholds them. Each carries `doneAt` / `doneSeq`.
|
|
55
|
+
*/
|
|
56
|
+
export function inReviewUnits(opts) {
|
|
57
|
+
return [...projectUnits(ledgerRead(opts.ledgerDir)).values()]
|
|
58
|
+
.filter((u) => u.state === "in-review")
|
|
59
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Merged units, with the `landed` sha from `unit.merged`'s body (G2.3). The
|
|
63
|
+
* actuator's startup sweep reads this to run the *ledger-ahead-of-git* check —
|
|
64
|
+
* every ledger-merged unit's landed sha must be an ancestor of the base branch.
|
|
65
|
+
*/
|
|
66
|
+
export function mergedUnits(opts) {
|
|
67
|
+
return [...projectUnits(ledgerRead(opts.ledgerDir)).values()]
|
|
68
|
+
.filter((u) => u.state === "merged")
|
|
69
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
70
|
+
}
|
|
71
|
+
/** Every materialised unit, for truthful run sizing and recovery diagnostics. */
|
|
72
|
+
export function allUnits(opts) {
|
|
73
|
+
return [...projectUnits(ledgerRead(opts.ledgerDir)).values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resume a stranded in-review unit (P7.6). CAS-guarded append of `unit.claimed`,
|
|
77
|
+
* which the fold takes back to `executing`. Legal ONLY when the unit is
|
|
78
|
+
* `in-review`, not escalated (P7.7), and its `unit.done` is older than the
|
|
79
|
+
* heartbeat timeout — the same staleness bar the reclaim path uses for
|
|
80
|
+
* `executing`, so a live retry is never yanked out from under itself.
|
|
81
|
+
*/
|
|
82
|
+
export function resumeClaim(unitId, session, opts) {
|
|
83
|
+
const bare = bareId(unitId);
|
|
84
|
+
const timeout = opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
85
|
+
const now = opts.now ?? Date.now();
|
|
86
|
+
for (let attempt = 0; attempt <= CLAIM_RETRIES; attempt++) {
|
|
87
|
+
const events = ledgerRead(opts.ledgerDir);
|
|
88
|
+
const tailHash = events.at(-1)?.hash ?? ZERO_HASH;
|
|
89
|
+
const system = events[0]?.system ?? bare;
|
|
90
|
+
const u = projectUnits(events).get(bare);
|
|
91
|
+
if (!u || u.state !== "in-review")
|
|
92
|
+
return { ok: false, reason: "not-in-review" };
|
|
93
|
+
if (u.escalated)
|
|
94
|
+
return { ok: false, reason: "escalated" };
|
|
95
|
+
const doneMs = u.doneAt ? Date.parse(u.doneAt) : 0;
|
|
96
|
+
if (now - doneMs <= timeout)
|
|
97
|
+
return { ok: false, reason: "too-fresh" };
|
|
98
|
+
try {
|
|
99
|
+
const event = append(opts.ledgerDir, {
|
|
100
|
+
system,
|
|
101
|
+
actor: { kind: "agent", id: session },
|
|
102
|
+
type: "unit.claimed",
|
|
103
|
+
body: { unit_id: unitId, session, resumed: true },
|
|
104
|
+
}, { expectPrevHash: tailHash });
|
|
105
|
+
return { ok: true, event };
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (e instanceof LedgerConflict)
|
|
109
|
+
continue; // ledger moved — re-evaluate
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, reason: "conflict" };
|
|
114
|
+
}
|
|
115
|
+
/** CAS-guarded append of `unit.claimed`. Exactly one concurrent claimer wins. */
|
|
116
|
+
export function claim(unitId, session, opts) {
|
|
117
|
+
const bare = bareId(unitId);
|
|
118
|
+
for (let attempt = 0; attempt <= CLAIM_RETRIES; attempt++) {
|
|
119
|
+
const events = ledgerRead(opts.ledgerDir);
|
|
120
|
+
const tailHash = events.at(-1)?.hash ?? ZERO_HASH;
|
|
121
|
+
const system = events[0]?.system ?? bare;
|
|
122
|
+
if (!readyUnits(opts).some((u) => u.id === bare)) {
|
|
123
|
+
const u = projectUnits(events).get(bare);
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: u?.state === "executing" ? "already-claimed" : "not-ready",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const event = append(opts.ledgerDir, {
|
|
131
|
+
system,
|
|
132
|
+
actor: { kind: "agent", id: session },
|
|
133
|
+
type: "unit.claimed",
|
|
134
|
+
body: { unit_id: unitId, session },
|
|
135
|
+
}, { expectPrevHash: tailHash });
|
|
136
|
+
return { ok: true, event };
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (e instanceof LedgerConflict)
|
|
140
|
+
continue; // ledger moved — re-evaluate readiness
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ok: false, reason: "conflict" };
|
|
145
|
+
}
|
|
146
|
+
/** Append a `session.heartbeat` so the sequencer keeps the unit out of the reclaim pool. */
|
|
147
|
+
export function heartbeat(unitId, session, opts) {
|
|
148
|
+
const events = ledgerRead(opts.ledgerDir);
|
|
149
|
+
return append(opts.ledgerDir, {
|
|
150
|
+
system: events[0]?.system ?? bareId(unitId),
|
|
151
|
+
actor: { kind: "agent", id: session },
|
|
152
|
+
type: "session.heartbeat",
|
|
153
|
+
body: { unit_id: unitId, session },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harness spec check` — run the `spec` gate over a specs directory.
|
|
3
|
+
*
|
|
4
|
+
* harness spec check [--specs <dir>] [--system <system.toml>]
|
|
5
|
+
*
|
|
6
|
+
* With --system, also checks each spec's `component` and `outputs` against
|
|
7
|
+
* Contract C. Exit 1 if any error-level finding.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { parse as parseToml } from "smol-toml";
|
|
12
|
+
import { targetPath } from "../assets.js";
|
|
13
|
+
import { checkSpecs } from "./index.js";
|
|
14
|
+
import { materializeSpecs } from "./materialize.js";
|
|
15
|
+
import { projectSpecs } from "./project.js";
|
|
16
|
+
function flag(args, name) {
|
|
17
|
+
const i = args.indexOf(`--${name}`);
|
|
18
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve a CLI path flag against the target repository (cwd), never the install. */
|
|
21
|
+
function targetFlag(args, name, cwd, ...defaultSegments) {
|
|
22
|
+
const raw = flag(args, name);
|
|
23
|
+
if (raw)
|
|
24
|
+
return resolve(cwd, raw);
|
|
25
|
+
return targetPath(cwd, ...defaultSegments);
|
|
26
|
+
}
|
|
27
|
+
function systemOpts(path) {
|
|
28
|
+
const raw = parseToml(readFileSync(path, "utf8"));
|
|
29
|
+
const systemComponents = (raw.component ?? [])
|
|
30
|
+
.map((c) => c.id ?? "")
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
const artifactProducers = {};
|
|
33
|
+
for (const a of raw.artifact ?? [])
|
|
34
|
+
if (a.name && a.producer)
|
|
35
|
+
artifactProducers[a.name] = a.producer;
|
|
36
|
+
return { systemComponents, artifactProducers };
|
|
37
|
+
}
|
|
38
|
+
export function specCommand(args) {
|
|
39
|
+
if (args[0] === "project") {
|
|
40
|
+
const changeDir = flag(args, "change");
|
|
41
|
+
const openspecRoot = flag(args, "openspec") ?? "openspec";
|
|
42
|
+
const outDir = flag(args, "out") ?? ".harness/projected/specs";
|
|
43
|
+
const json = args.includes("--json");
|
|
44
|
+
if (!changeDir) {
|
|
45
|
+
process.stderr.write("harness spec project --change <dir> [--openspec <dir>] [--out <dir>] [--json]\n");
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const result = projectSpecs({ openspecRoot, changeDir, outDir });
|
|
49
|
+
if (json)
|
|
50
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
51
|
+
else {
|
|
52
|
+
for (const finding of result.findings)
|
|
53
|
+
process.stdout.write(` ${finding.level === "error" ? "✗" : "⚠"} ${finding.specPath}: ${finding.message}\n`);
|
|
54
|
+
for (const unit of result.units)
|
|
55
|
+
process.stdout.write(` ✓ ${unit.id} → ${unit.path}\n`);
|
|
56
|
+
}
|
|
57
|
+
process.stdout.write(result.ok
|
|
58
|
+
? ` ✓ spec project passed (${result.units.length} unit(s))\n`
|
|
59
|
+
: `\n spec project FAILED\n`);
|
|
60
|
+
return result.ok ? 0 : 1;
|
|
61
|
+
}
|
|
62
|
+
if (args[0] === "materialize") {
|
|
63
|
+
const cwd = process.cwd();
|
|
64
|
+
const specsDir = targetFlag(args, "specs", cwd, "specs");
|
|
65
|
+
const systemPath = flag(args, "system")
|
|
66
|
+
? resolve(cwd, flag(args, "system"))
|
|
67
|
+
: undefined;
|
|
68
|
+
const ledgerDir = targetFlag(args, "ledger", cwd, ".harness", "ledger");
|
|
69
|
+
const json = args.includes("--json");
|
|
70
|
+
if (!systemPath) {
|
|
71
|
+
process.stderr.write("harness spec materialize --specs <dir> --system <system.toml> [--ledger <dir>] [--json]\n");
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
const result = materializeSpecs({ specsDir, systemToml: systemPath, ledgerDir });
|
|
75
|
+
if (json)
|
|
76
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
77
|
+
else {
|
|
78
|
+
for (const finding of result.findings)
|
|
79
|
+
process.stdout.write(` ${finding.level === "error" ? "✗" : "⚠"} ${finding.specPath}: ${finding.message}\n`);
|
|
80
|
+
for (const conflict of result.conflicts)
|
|
81
|
+
process.stdout.write(` ✗ ${conflict.message}\n`);
|
|
82
|
+
for (const unit of result.units)
|
|
83
|
+
process.stdout.write(` ${unit.status === "materialized" ? "✓" : "="} ${unit.id}: ${unit.status}\n`);
|
|
84
|
+
}
|
|
85
|
+
return result.ok ? 0 : 1;
|
|
86
|
+
}
|
|
87
|
+
if (args[0] !== "check") {
|
|
88
|
+
process.stderr.write("harness spec check [--specs <dir>] [--system <system.toml>]\n" +
|
|
89
|
+
"harness spec materialize --specs <dir> --system <system.toml> [--ledger <dir>] [--json]\n" +
|
|
90
|
+
"harness spec project --change <dir> [--openspec <dir>] [--out <dir>] [--json]\n");
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
const cwd = process.cwd();
|
|
94
|
+
const specsDir = targetFlag(args, "specs", cwd, "specs");
|
|
95
|
+
const systemRaw = flag(args, "system");
|
|
96
|
+
const systemPath = systemRaw ? resolve(cwd, systemRaw) : undefined;
|
|
97
|
+
const findings = checkSpecs(specsDir, systemPath ? systemOpts(systemPath) : {});
|
|
98
|
+
for (const f of findings) {
|
|
99
|
+
const where = f.criterionIndex !== undefined
|
|
100
|
+
? `${f.specPath}[criterion ${f.criterionIndex}]`
|
|
101
|
+
: f.specPath;
|
|
102
|
+
process.stdout.write(` ${f.level === "error" ? "✗" : "⚠"} ${where}: ${f.message}\n`);
|
|
103
|
+
}
|
|
104
|
+
const errors = findings.filter((f) => f.level === "error").length;
|
|
105
|
+
process.stdout.write(errors === 0
|
|
106
|
+
? ` ✓ spec gate passed (${findings.length} warning(s))\n`
|
|
107
|
+
: `\n spec gate FAILED — ${errors} error(s)\n`);
|
|
108
|
+
return errors === 0 ? 0 : 1;
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=cli.js.map
|