@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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/build-identity.json +1 -0
  4. package/dist/cancellation.js +138 -0
  5. package/dist/cli.js +47 -0
  6. package/dist/dispatch.js +70 -0
  7. package/dist/help.js +47 -0
  8. package/dist/internal/agent/adapter/conformance.js +53 -0
  9. package/dist/internal/agent/adapter/hook.js +113 -0
  10. package/dist/internal/agent/adapter/install.js +30 -0
  11. package/dist/internal/agent/adapter/scope.js +55 -0
  12. package/dist/internal/agent/adapter/session.js +63 -0
  13. package/dist/internal/agent/adapter/settings.js +51 -0
  14. package/dist/internal/agent/cli.js +79 -0
  15. package/dist/internal/agent/evals/index.js +111 -0
  16. package/dist/internal/agent/index.js +15 -0
  17. package/dist/internal/agent/ledger-read.js +27 -0
  18. package/dist/internal/agent/routing/cli.js +104 -0
  19. package/dist/internal/agent/routing/index.js +123 -0
  20. package/dist/internal/agent/telemetry/index.js +97 -0
  21. package/dist/internal/orchestration/activity/index.js +111 -0
  22. package/dist/internal/orchestration/analyze/index.js +237 -0
  23. package/dist/internal/orchestration/cli.js +475 -0
  24. package/dist/internal/orchestration/config/index.js +66 -0
  25. package/dist/internal/orchestration/doctor/index.js +202 -0
  26. package/dist/internal/orchestration/forge/index.js +121 -0
  27. package/dist/internal/orchestration/git/index.js +181 -0
  28. package/dist/internal/orchestration/harness/cli.js +378 -0
  29. package/dist/internal/orchestration/harness/index.js +186 -0
  30. package/dist/internal/orchestration/harness/shim.js +182 -0
  31. package/dist/internal/orchestration/index.js +29 -0
  32. package/dist/internal/orchestration/loop/index.js +1107 -0
  33. package/dist/internal/orchestration/prompts/index.js +199 -0
  34. package/dist/internal/orchestration/report/index.js +125 -0
  35. package/dist/internal/orchestration/routes/index.js +85 -0
  36. package/dist/internal/orchestration/run/index.js +221 -0
  37. package/dist/internal/orchestration/runner/index.js +90 -0
  38. package/dist/internal/orchestration/runners/claude/index.js +246 -0
  39. package/dist/internal/orchestration/runners/claude/sdk.js +198 -0
  40. package/dist/internal/orchestration/runners/claude/wiring.js +98 -0
  41. package/dist/internal/orchestration/runners/codex/exec.js +114 -0
  42. package/dist/internal/orchestration/runners/codex/index.js +72 -0
  43. package/dist/internal/orchestration/runners/conformance/index.js +169 -0
  44. package/dist/internal/orchestration/runners/cursor/exec.js +531 -0
  45. package/dist/internal/orchestration/runners/cursor/index.js +184 -0
  46. package/dist/internal/orchestration/runners/stub/index.js +168 -0
  47. package/dist/internal/orchestration/runners/telemetry.js +36 -0
  48. package/dist/internal/orchestration/runstate/index.js +213 -0
  49. package/dist/internal/orchestration/runstate/publish.js +115 -0
  50. package/dist/internal/orchestration/worktree/index.js +89 -0
  51. package/dist/internal/system/adapters/index.js +19 -0
  52. package/dist/internal/system/agentops/index.js +16 -0
  53. package/dist/internal/system/agentops/scope.js +67 -0
  54. package/dist/internal/system/assets.js +45 -0
  55. package/dist/internal/system/board/cli.js +56 -0
  56. package/dist/internal/system/board/index.js +276 -0
  57. package/dist/internal/system/board/live.js +277 -0
  58. package/dist/internal/system/calibration/cli.js +45 -0
  59. package/dist/internal/system/calibration/index.js +141 -0
  60. package/dist/internal/system/cli.js +174 -0
  61. package/dist/internal/system/corpus/replay.js +79 -0
  62. package/dist/internal/system/digest/cli.js +23 -0
  63. package/dist/internal/system/digest/index.js +145 -0
  64. package/dist/internal/system/doors/classify.js +94 -0
  65. package/dist/internal/system/doors/cli.js +184 -0
  66. package/dist/internal/system/doors/index.js +249 -0
  67. package/dist/internal/system/doors/metrics.js +47 -0
  68. package/dist/internal/system/escapes/cli.js +45 -0
  69. package/dist/internal/system/escapes/index.js +81 -0
  70. package/dist/internal/system/explore/cli.js +77 -0
  71. package/dist/internal/system/explore/index.js +91 -0
  72. package/dist/internal/system/gates/cli.js +43 -0
  73. package/dist/internal/system/gates/detectors.js +55 -0
  74. package/dist/internal/system/gates/pre.js +51 -0
  75. package/dist/internal/system/hub/cli.js +51 -0
  76. package/dist/internal/system/hub/index.js +146 -0
  77. package/dist/internal/system/init/index.js +83 -0
  78. package/dist/internal/system/ledger/cli.js +54 -0
  79. package/dist/internal/system/ledger/index.js +176 -0
  80. package/dist/internal/system/manifest/index.js +207 -0
  81. package/dist/internal/system/provenance/index.js +13 -0
  82. package/dist/internal/system/report/index.js +15 -0
  83. package/dist/internal/system/runner/index.js +74 -0
  84. package/dist/internal/system/runstatus/cli.js +86 -0
  85. package/dist/internal/system/runstatus/index.js +168 -0
  86. package/dist/internal/system/sequencer/briefing.js +75 -0
  87. package/dist/internal/system/sequencer/cli.js +170 -0
  88. package/dist/internal/system/sequencer/index.js +156 -0
  89. package/dist/internal/system/spec/cli.js +110 -0
  90. package/dist/internal/system/spec/index.js +212 -0
  91. package/dist/internal/system/spec/materialize.js +111 -0
  92. package/dist/internal/system/spec/openspec-parse.js +83 -0
  93. package/dist/internal/system/spec/project.js +208 -0
  94. package/dist/internal/system/substrate/index.js +14 -0
  95. package/dist/internal/system/system/index.js +159 -0
  96. package/dist/internal/system/units/index.js +87 -0
  97. package/dist/internal/system/verify/cli.js +103 -0
  98. package/dist/internal/system/verify/declined-reasons.js +61 -0
  99. package/dist/internal/system/verify/index.js +188 -0
  100. package/dist/internal/system/verify/signing.js +79 -0
  101. package/dist/paths.js +50 -0
  102. package/dist/routes.js +93 -0
  103. package/dist/version.js +30 -0
  104. package/dist/win32-process-group.js +360 -0
  105. package/package.json +41 -0
  106. package/profiles/python/bindings.toml +59 -0
  107. package/profiles/terraform/bindings.toml +66 -0
  108. package/profiles/typescript/bindings.toml +64 -0
  109. package/profiles/typescript/smoke/README.md +8 -0
  110. package/schema/baseline.schema.json +26 -0
  111. package/schema/decision-classes.yaml +45 -0
  112. package/schema/envelope.schema.json +186 -0
  113. package/schema/ledger-event.schema.json +235 -0
  114. package/schema/manifest.schema.json +119 -0
  115. package/schema/routes.schema.json +55 -0
  116. package/schema/run-status.schema.json +162 -0
  117. package/schema/session-event.schema.json +74 -0
  118. package/schema/spec.schema.json +114 -0
  119. package/schema/system.schema.json +142 -0
  120. package/templates/ci/verify.yml +23 -0
  121. package/templates/target-kit/README.md +78 -0
  122. package/templates/target-kit/component.toml +20 -0
  123. package/templates/target-kit/orchestration.json +9 -0
  124. package/templates/target-kit/profiles/target/bindings.toml +6 -0
  125. package/templates/target-kit/routes.toml +5 -0
  126. package/templates/target-kit/specs/first-unit.yaml +11 -0
  127. 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