runward 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![Runward — the working frame for your agent, from idea to production](assets/og-image-en.jpg)
4
4
 
5
- [![npm](https://img.shields.io/npm/v/runward?style=for-the-badge&color=0a0a0a&label=npm)](https://www.npmjs.com/package/runward) [![License: MIT](https://img.shields.io/badge/License-MIT-0a0a0a?style=for-the-badge)](LICENSE) [![Doctrine: CC BY-ND 4.0](https://img.shields.io/badge/Doctrine-CC%20BY--ND%204.0-C9A45C?style=for-the-badge)](https://github.com/stranxik/designing-and-running-agentic-systems) [![Support via Ko-fi](https://img.shields.io/badge/%E2%98%95%20Support%20via%20Ko--fi-orange?style=for-the-badge)](https://ko-fi.com/stranxik)
5
+ [![npm](https://img.shields.io/npm/v/runward?style=for-the-badge&color=0a0a0a&label=npm)](https://www.npmjs.com/package/runward) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-runward%20gate-2ea44f?style=for-the-badge&logo=github)](https://github.com/marketplace/actions/runward-gate) [![License: MIT](https://img.shields.io/badge/License-MIT-0a0a0a?style=for-the-badge)](LICENSE) [![Doctrine: CC BY-ND 4.0](https://img.shields.io/badge/Doctrine-CC%20BY--ND%204.0-C9A45C?style=for-the-badge)](https://github.com/stranxik/designing-and-running-agentic-systems) [![Support via Ko-fi](https://img.shields.io/badge/%E2%98%95%20Support%20via%20Ko--fi-orange?style=for-the-badge)](https://ko-fi.com/stranxik)
6
6
 
7
7
  **After the spec, the hard part starts. Runward ships it and runs it.**
8
8
 
@@ -53,6 +53,15 @@ npx runward init --tools claude,cursor,copilot,gemini,windsurf
53
53
 
54
54
  **Fastest way to see what runward does:** `npx runward init --example` scaffolds the `request-triage` reference mission already filled, so `runward check` passes green and `runward compliance iso-42001` emits an audit-ready OSCAL pack out of the box. Then `npx runward init` (no `--example`) starts your own mission from blank templates.
55
55
 
56
+ **Install the gate where you already work — honestly tiered by how hard it blocks:**
57
+
58
+ - **Hard, at merge (CI):** the [GitHub Action](https://github.com/marketplace/actions/runward-gate) — `uses: stranxik/runward@<sha>` as a required status check.
59
+ - **Hard, at turn end:** plugins/hooks for Claude Code (`/plugin marketplace add stranxik/runward` → `/plugin install runward-gate@runward`), Gemini CLI, Codex, and Copilot.
60
+ - **Soft, per-tool:** Cursor and Kiro (their end-of-turn seam can't hard-block; the per-tool hook can).
61
+ - **Discovery only, never a gate:** an MCP descriptor — an MCP tool is model-controlled, so a check behind it is skippable, and a skippable check is not a gate (ADR-0029).
62
+
63
+ The full per-channel map, with install commands, is [`docs/distribution.md`](docs/distribution.md). The operator installs; none is privileged.
64
+
56
65
  New here? Follow [your first mission in 15 minutes](docs/first-mission.md).
57
66
 
58
67
  ### Commands
@@ -68,6 +77,7 @@ New here? Follow [your first mission in 15 minutes](docs/first-mission.md).
68
77
  | `runward explain <rule>` | **The why, inline**: a rule's contract (impact, phases, ASI, signature, rationale) and its full text — no doctrine excavation; renamed slugs answer with their migration |
69
78
  | `runward status` | Mission snapshot: current gate, decision journal (ADRs), workflows |
70
79
  | `runward doctor` | Environment and installation checks |
80
+ | `runward wire` | **Harness → channel**: detect the AI harness running the command (via a runtime signal, else a config file) and recommend the matching auto-trigger channel. Read-only — it detects and points at the inert sample, it never wires anything; the operator does (ADR-0012). On `undetermined`, the agent asks the operator which tool they use. `--json` for agent consumption |
71
81
  | `runward update` | Refresh `runward/workflows/` from the package — mission state never touched, local edits preserved unless `--force` |
72
82
  | `runward characterize` | **Read-only inventory** of an existing codebase (brownfield / retro-documentation) → `runward/characterization.md`: dependencies and lockfiles, entrypoints, CI, tests, git-log shape. Deterministic and zero-LLM; it parses artifacts at rest and never runs, builds, or writes to your code. Facts, not decisions — reconstructing the *why* stays yours |
73
83
  | `runward compliance <regime>` | **Assemble a regime-framed evidence pack** (`iso-42001`, `nist-ai-rmf`, `eu-ai-act`) from the mission's artifacts → `runward/compliance/…`: a human readiness *draft* (OWASP ASI coverage, rule-conformance status, ADR journal, with the operator-only sections flagged) **and a machine-readable OSCAL** component-definition to feed your GRC/auditor tool. Deterministic, read-only, zero-LLM — supporting evidence, never a compliance claim. The regime mapping is **versioned data** (`--regime-version` pins the lens; the pack stamps which version produced it). The mapping itself is a published, citable mini-spec: [docs/spec/runward-oscal-mapping.md](docs/spec/runward-oscal-mapping.md) |
@@ -76,7 +86,7 @@ Global flags: `--yes`, `--dry-run`, `--verbose`, `--no-color`. Exit codes: 0 suc
76
86
 
77
87
  The gate's exit code is a **port**: `runward/adapters/` ships inert sample wiring so the gate runs at each harness's natural moment.
78
88
 
79
- **Install runward from where you already work.** Beyond the copy-in adapters, runward publishes distributable packagings a **GitHub Action** (`uses: stranxik/runward@<sha>` as a required check, the hard governance gate at merge), a **Claude Code plugin** (`/plugin marketplace add stranxik/runward`), and siblings for Gemini CLI, Codex, Copilot, Cursor and Kiro each honestly tiered by how hard its gate can block, plus an MCP descriptor for discovery only (an MCP tool is model-controlled — findable, never a gate). The full, honestly-tiered map is [`docs/distribution.md`](docs/distribution.md). The operator installs; none is privileged. The git `pre-commit` and CI adapters are **agent-agnostic** — they gate whatever agent produced the code (Codex, Claude, Cursor, Copilot, Gemini); the Claude Code `Stop`-hook is one example of a per-harness turn-end hook, not a privileged one. You copy an adapter in; runward never installs or runs one for you.
89
+ **Install runward from where you already work.** Beyond the copy-in adapters, runward publishes distributable packagings across every channel that can carry a real gate — see the install block above and the full map in [`docs/distribution.md`](docs/distribution.md). The git `pre-commit` and CI adapters are **agent-agnostic** — they gate whatever agent produced the code (Codex, Claude, Cursor, Copilot, Gemini); the Claude Code `Stop`-hook is one example of a per-harness turn-end hook, not a privileged one. You copy an adapter in; runward never installs or runs one for you.
80
90
 
81
91
  `init` creates:
82
92
 
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Runward CLI — after the spec: ship and run.
4
- * Commands: init (wizard), check (gate audit), status, doctor, update, characterize,
5
- * compliance (evidence pack), manifest (table plumbing), rules / explain (rule-set surface).
4
+ * Commands: init (wizard), check (gate audit), status, doctor, wire (harness→channel),
5
+ * update, characterize, compliance (evidence pack), manifest (table plumbing),
6
+ * rules / explain (rule-set surface).
6
7
  */
7
8
  import { Command } from "commander";
8
9
  import { VERSION } from "./lib/paths.js";
@@ -11,6 +12,7 @@ import { initCommand } from "./commands/init.js";
11
12
  import { checkCommand } from "./commands/check.js";
12
13
  import { statusCommand } from "./commands/status.js";
13
14
  import { doctorCommand } from "./commands/doctor.js";
15
+ import { wireCommand } from "./commands/wire.js";
14
16
  import { updateCommand } from "./commands/update.js";
15
17
  import { characterizeCommand } from "./commands/characterize.js";
16
18
  import { complianceCommand } from "./commands/compliance.js";
@@ -68,6 +70,7 @@ program
68
70
  .option("--freeze", "seal a green strict gate: hash the evidence into runward/evidence-lock.json (implies --strict)")
69
71
  .option("--hooks", "run operator hooks from runward/hooks.json around the audit (opt-in)")
70
72
  .option("--coverage", "advisory: report deliverable + decision-ratification coverage (does not gate)")
73
+ .option("--json", "machine output: verdict, current gate, deliverable states, conformance gaps (stable contract, for agent-driven runs)")
71
74
  .action(checkCommand);
72
75
  program
73
76
  .command("status")
@@ -78,6 +81,12 @@ program
78
81
  .command("doctor")
79
82
  .description("environment and installation checks")
80
83
  .action(doctorCommand);
84
+ program
85
+ .command("wire")
86
+ .description("recommend the auto-trigger channel for the AI harness running this command (read-only; never wires — the operator does, ADR-0012)")
87
+ .option("-p, --path <path>", "project directory")
88
+ .option("--json", "machine output: detection status, harness, recommended + candidate channels (stable contract)")
89
+ .action(wireCommand);
81
90
  program
82
91
  .command("update")
83
92
  .description("refresh runward/workflows/ and runward/rules/ from this package version (mission state untouched)")
@@ -19,25 +19,35 @@ import { VERSION } from "../lib/paths.js";
19
19
  * operator's judgment at the gate.
20
20
  * With --freeze (implies --strict), a green gate is sealed: the resolvable
21
21
  * evidence files are hashed into runward/evidence-lock.json (ADR-0021).
22
+ * With --json, the same verdict is emitted as a stable machine contract (ADR-0030)
23
+ * so an agent drives on data, not scraped text — the exit-code contract is unchanged.
22
24
  * Exit codes: 0 = current gate clean, 1 = gaps, 2 = no mission found.
23
25
  */
24
26
  export async function checkCommand(opts) {
25
27
  if (opts.freeze)
26
28
  opts.strict = true; // a seal certifies a strict crossing
29
+ // In --json mode every human line is suppressed; the sole output is one JSON object at the end.
30
+ const log = (s = "") => { if (!opts.json)
31
+ console.log(s); };
27
32
  const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
28
33
  if (!root) {
29
- console.error(status.error("No runward/ mission found here or above. Run `runward init` first."));
34
+ if (opts.json) {
35
+ process.stdout.write(JSON.stringify({ runward: VERSION, mission: null, verdict: "no-mission", exitCode: 2 }) + "\n");
36
+ }
37
+ else {
38
+ console.error(status.error("No runward/ mission found here or above. Run `runward init` first."));
39
+ }
30
40
  process.exit(2);
31
41
  }
32
42
  const mission = join(root, "runward");
33
43
  const report = analyze(mission);
34
- console.log(createHeader(`Runward v${VERSION} — gate audit`, root));
44
+ log(createHeader(`Runward v${VERSION} — gate audit`, root));
35
45
  let hookFailed = 0;
36
46
  if (opts.hooks) {
37
- const before = runHooks(mission, "before", root);
47
+ const before = runHooks(mission, "before", root, { quietStdout: !!opts.json });
38
48
  if (before.ran > 0) {
39
- console.log(section("Hooks · before"));
40
- console.log(" " + (before.failed.length ? status.error(`${before.failed.length}/${before.ran} failed`) : status.success(`${before.ran} ok`)));
49
+ log(section("Hooks · before"));
50
+ log(" " + (before.failed.length ? status.error(`${before.failed.length}/${before.ran} failed`) : status.success(`${before.ran} ok`)));
41
51
  hookFailed += before.failed.length;
42
52
  }
43
53
  }
@@ -53,18 +63,22 @@ export async function checkCommand(opts) {
53
63
  "untouched": c.darkGray(" — raw template"),
54
64
  "missing": c.error(" — file missing"),
55
65
  };
66
+ // Structured collectors for --json (ADR-0030) — populated alongside the human render.
67
+ const deliverablesData = [];
68
+ const conformanceData = [];
56
69
  let gaps = 0;
57
70
  for (const phase of report.phases) {
58
- console.log(section(phase.spec.label));
71
+ log(section(phase.spec.label));
59
72
  for (const { artifact, state } of phase.artifacts) {
60
- console.log(` ${glyph[state]} ${c.white(artifact.label)} ${c.darkGray(`(runward/${artifact.relPath})`)}${legendNote[state]}`);
73
+ log(` ${glyph[state]} ${c.white(artifact.label)} ${c.darkGray(`(runward/${artifact.relPath})`)}${legendNote[state]}`);
61
74
  if (state !== "filled")
62
75
  gaps++;
76
+ deliverablesData.push({ phase: phase.spec.label, artifact: artifact.label, relPath: artifact.relPath, state });
63
77
  }
64
78
  }
65
79
  let strictGaps = 0;
66
80
  if (opts.strict) {
67
- console.log(section("Rule conformance (--strict)"));
81
+ log(section("Rule conformance (--strict)"));
68
82
  let checked = 0;
69
83
  const signatures = ruleSignatures(mission);
70
84
  for (const { phase, deliverable, label } of GATED_DELIVERABLES) {
@@ -80,76 +94,82 @@ export async function checkCommand(opts) {
80
94
  continue;
81
95
  checked++;
82
96
  if (violations.length === 0) {
83
- console.log(` ${status.success(`${label}: ${expected.length} rule(s) accounted for`)}`);
97
+ log(` ${status.success(`${label}: ${expected.length} rule(s) accounted for`)}`);
84
98
  }
85
99
  else {
86
- for (const v of violations)
87
- console.log(` ${c.error("✗")} ${c.darkGray(label + " · ")}${c.white(v.rule)}${c.darkGray(" — " + v.problem)}`);
100
+ for (const v of violations) {
101
+ log(` ${c.error("✗")} ${c.darkGray(label + " · ")}${c.white(v.rule)}${c.darkGray(" — " + v.problem)}`);
102
+ conformanceData.push({ scope: label, rule: v.rule, problem: v.problem });
103
+ }
88
104
  strictGaps += violations.length;
89
105
  }
90
106
  }
91
107
  if (checked === 0)
92
- console.log(" " + c.darkGray("no CRITICAL/HIGH rules mapped to a build phase"));
108
+ log(" " + c.darkGray("no CRITICAL/HIGH rules mapped to a build phase"));
93
109
  // Under --freeze the old seal is being replaced, not verified — otherwise a changed
94
110
  // sealed file would make re-sealing impossible (the seal violation reddens the gate
95
111
  // that freeze requires green). Everything else must still be green to seal.
96
112
  const seal = opts.freeze ? { present: false, count: 0, violations: [] } : verifyEvidenceLock(mission);
97
113
  if (seal.present) {
98
- console.log(section("Evidence seal (--strict)"));
114
+ log(section("Evidence seal (--strict)"));
99
115
  if (seal.violations.length === 0) {
100
- console.log(` ${status.success(`seal intact — ${seal.count} evidence file(s), sealed ${seal.sealedAt ?? "?"}`)}`);
116
+ log(` ${status.success(`seal intact — ${seal.count} evidence file(s), sealed ${seal.sealedAt ?? "?"}`)}`);
101
117
  }
102
118
  else {
103
- for (const v of seal.violations)
104
- console.log(` ${c.error("✗")} ${c.white(v.rule)}${c.darkGray(" — " + v.problem)}`);
119
+ for (const v of seal.violations) {
120
+ log(` ${c.error("✗")} ${c.white(v.rule)}${c.darkGray(" — " + v.problem)}`);
121
+ conformanceData.push({ scope: "evidence-seal", rule: v.rule, problem: v.problem });
122
+ }
105
123
  strictGaps += seal.violations.length;
106
124
  }
107
125
  }
108
126
  const unratified = unratifiedAdrs(mission);
109
127
  if (unratified.length > 0) {
110
- console.log(section("Reconstruction lifecycle (--strict)"));
111
- for (const u of unratified)
112
- console.log(` ${c.error("✗")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
113
- console.log(" " + c.darkGray("ratify each: write the real why + a re-evaluation trigger and set Status: accepted (rename DRAFT→ADR), or remove it. A hypothesis is not a decision."));
128
+ log(section("Reconstruction lifecycle (--strict)"));
129
+ for (const u of unratified) {
130
+ log(` ${c.error("✗")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
131
+ conformanceData.push({ scope: "reconstruction", rule: u.file, problem: u.reason });
132
+ }
133
+ log(" " + c.darkGray("ratify each: write the real why + a re-evaluation trigger and set Status: accepted (rename DRAFT→ADR), or remove it. A hypothesis is not a decision."));
114
134
  strictGaps += unratified.length;
115
135
  }
116
136
  if (checked > 0 && strictGaps === 0) {
117
137
  // The two proofs, made legible together: this gate is the DOCUMENTARY proof (decisions traced);
118
138
  // the BEHAVIORAL proof is the operator's test suite. runward reads the pointer, never runs the code.
119
- console.log(section("Behavioral proof (advisory, above the gate)"));
120
- console.log(" " + c.darkGray("this gate is the documentary proof: the decisions are traced. runward did not run your code — it is not a runtime. The behavioral proof is your test suite."));
139
+ log(section("Behavioral proof (advisory, above the gate)"));
140
+ log(" " + c.darkGray("this gate is the documentary proof: the decisions are traced. runward did not run your code — it is not a runtime. The behavioral proof is your test suite."));
121
141
  const bp = behavioralProof(mission, root);
122
142
  if (!bp.declared) {
123
- console.log(" " + c.darkGray("no behavioral proof declared — add `Behavioral proof: <command>` to runward/floor.md §2 (and optionally `Proof artifact: <path>`)."));
143
+ log(" " + c.darkGray("no behavioral proof declared — add `Behavioral proof: <command>` to runward/floor.md §2 (and optionally `Proof artifact: <path>`)."));
124
144
  }
125
145
  else {
126
146
  if (bp.command)
127
- console.log(` ${c.darkGray("prove behavior with:")} ${c.white(bp.command)}`);
147
+ log(` ${c.darkGray("prove behavior with:")} ${c.white(bp.command)}`);
128
148
  if (bp.artifact) {
129
149
  if (!bp.present)
130
- console.log(` ${c.warning("◑")} ${c.darkGray("proof artifact")} ${c.white(bp.artifact)} ${c.warning("missing — run the command to produce it")}`);
150
+ log(` ${c.warning("◑")} ${c.darkGray("proof artifact")} ${c.white(bp.artifact)} ${c.warning("missing — run the command to produce it")}`);
131
151
  else {
132
152
  const fresh = bp.fresh === undefined ? "" : bp.fresh ? c.success(" · fresh") : c.warning(" · stale (older than the code — re-run)");
133
- console.log(` ${c.success("✓")} ${c.darkGray("proof artifact")} ${c.white(bp.artifact)} ${c.darkGray(`(${bp.date})`)}${fresh}`);
153
+ log(` ${c.success("✓")} ${c.darkGray("proof artifact")} ${c.white(bp.artifact)} ${c.darkGray(`(${bp.date})`)}${fresh}`);
134
154
  }
135
155
  }
136
- console.log(" " + c.darkGray("advisory — runward reports presence and freshness, never runs or reads the result. You cross on both proofs."));
156
+ log(" " + c.darkGray("advisory — runward reports presence and freshness, never runs or reads the result. You cross on both proofs."));
137
157
  }
138
- console.log(section("Semantic check (advisory, above the gate)"));
139
- console.log(" " + c.darkGray("the gate proved every CRITICAL/HIGH rule was traced — not that the code applies it. Before you cross, run the verify workflow (runward/workflows/verify.md): an adversarial cite-vs-apply pass, ideally on a different model. Advisory, agent-executed, never blocks the gate (ADR-0007)."));
158
+ log(section("Semantic check (advisory, above the gate)"));
159
+ log(" " + c.darkGray("the gate proved every CRITICAL/HIGH rule was traced — not that the code applies it. Before you cross, run the verify workflow (runward/workflows/verify.md): an adversarial cite-vs-apply pass, ideally on a different model. Advisory, agent-executed, never blocks the gate (ADR-0007)."));
140
160
  const vf = verifyFindings(mission);
141
161
  if (!vf.present) {
142
- console.log(" " + c.darkGray(`no verify findings recorded yet — the workflow writes them to runward/${VERIFY_FINDINGS}.`));
162
+ log(" " + c.darkGray(`no verify findings recorded yet — the workflow writes them to runward/${VERIFY_FINDINGS}.`));
143
163
  }
144
164
  else {
145
165
  const freshness = vf.fresh ? c.success(" · fresh") : c.warning(" · stale (a gated manifest changed since — re-run the verify workflow)");
146
- console.log(` ${c.success("✓")} ${c.darkGray("verify findings")} ${c.white(`runward/${VERIFY_FINDINGS}`)} ${c.darkGray(`(${vf.date})`)}${freshness}`);
147
- console.log(" " + c.darkGray("advisory — runward reports presence and freshness, never reads a verdict. The findings inform your crossing; they never gate it."));
166
+ log(` ${c.success("✓")} ${c.darkGray("verify findings")} ${c.white(`runward/${VERIFY_FINDINGS}`)} ${c.darkGray(`(${vf.date})`)}${freshness}`);
167
+ log(" " + c.darkGray("advisory — runward reports presence and freshness, never reads a verdict. The findings inform your crossing; they never gate it."));
148
168
  }
149
169
  }
150
170
  }
151
171
  if (opts.coverage) {
152
- console.log(section("Documentation coverage (advisory)"));
172
+ log(section("Documentation coverage (advisory)"));
153
173
  let filled = 0, totalArt = 0;
154
174
  for (const phase of report.phases)
155
175
  for (const { state } of phase.artifacts) {
@@ -157,26 +177,27 @@ export async function checkCommand(opts) {
157
177
  if (state === "filled")
158
178
  filled++;
159
179
  }
160
- console.log(` ${c.primaryBold("Deliverables")} ${c.white(`${filled}/${totalArt} filled`)}`);
180
+ log(` ${c.primaryBold("Deliverables")} ${c.white(`${filled}/${totalArt} filled`)}`);
161
181
  const dc = decisionCoverage(mission);
162
- console.log(` ${c.primaryBold("Decisions")} ${c.white(`${dc.ratified}/${dc.total} ratified`)}${dc.unratified.length ? c.warning(` (${dc.unratified.length} to ratify)`) : ""}`);
182
+ log(` ${c.primaryBold("Decisions")} ${c.white(`${dc.ratified}/${dc.total} ratified`)}${dc.unratified.length ? c.warning(` (${dc.unratified.length} to ratify)`) : ""}`);
163
183
  for (const u of dc.unratified)
164
- console.log(` ${c.warning("◑")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
165
- console.log(" " + c.darkGray("advisory — a ratio of what is documented and ratified, not a claim of completeness. Does not affect the verdict."));
184
+ log(` ${c.warning("◑")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
185
+ log(" " + c.darkGray("advisory — a ratio of what is documented and ratified, not a claim of completeness. Does not affect the verdict."));
166
186
  }
167
187
  if (opts.hooks) {
168
- const after = runHooks(mission, "after", root);
188
+ const after = runHooks(mission, "after", root, { quietStdout: !!opts.json });
169
189
  if (after.ran > 0) {
170
- console.log(section("Hooks · after"));
171
- console.log(" " + (after.failed.length ? status.error(`${after.failed.length}/${after.ran} failed`) : status.success(`${after.ran} ok`)));
190
+ log(section("Hooks · after"));
191
+ log(" " + (after.failed.length ? status.error(`${after.failed.length}/${after.ran} failed`) : status.success(`${after.ran} ok`)));
172
192
  hookFailed += after.failed.length;
173
193
  }
174
194
  }
175
- console.log(section("Summary"));
176
- console.log(` ${c.primaryBold("Current gate")} ${c.white(report.currentPhase)}`);
177
- console.log(` ${c.primaryBold("ADRs")} ${c.white(String(report.adrCount))}${report.adrCount === 0 ? c.warning(" — no structural decision locked yet") : ""}`);
178
- if (gaps === 0 && strictGaps === 0 && hookFailed === 0) {
179
- console.log("\n" + status.success("All expected deliverables are filled. Cross gates on evidence, not on paperwork."));
195
+ const clean = gaps === 0 && strictGaps === 0 && hookFailed === 0;
196
+ log(section("Summary"));
197
+ log(` ${c.primaryBold("Current gate")} ${c.white(report.currentPhase)}`);
198
+ log(` ${c.primaryBold("ADRs")} ${c.white(String(report.adrCount))}${report.adrCount === 0 ? c.warning(" — no structural decision locked yet") : ""}`);
199
+ if (clean) {
200
+ log("\n" + status.success("All expected deliverables are filled. Cross gates on evidence, not on paperwork."));
180
201
  }
181
202
  else {
182
203
  const parts = [];
@@ -186,35 +207,52 @@ export async function checkCommand(opts) {
186
207
  parts.push(`${strictGaps} floor rule-conformance gap(s)`);
187
208
  if (hookFailed)
188
209
  parts.push(`${hookFailed} hook(s) failed`);
189
- console.log("\n" + status.warning(`${parts.join(" · ")}. No phase closes without its artifact — and, under --strict, without its CRITICAL/HIGH rules accounted for.`));
210
+ log("\n" + status.warning(`${parts.join(" · ")}. No phase closes without its artifact — and, under --strict, without its CRITICAL/HIGH rules accounted for.`));
190
211
  process.exitCode = 1;
191
212
  }
192
213
  if (opts.freeze) {
193
- console.log(section("Evidence seal — freeze (ADR-0021)"));
214
+ log(section("Evidence seal — freeze (ADR-0021)"));
194
215
  if (gaps || strictGaps || hookFailed) {
195
- console.log(" " + status.error("refusing to seal a red gate — a seal certifies a crossing, not a hope. Close the gaps above, then re-run `runward check --freeze`."));
216
+ log(" " + status.error("refusing to seal a red gate — a seal certifies a crossing, not a hope. Close the gaps above, then re-run `runward check --freeze`."));
196
217
  }
197
218
  else {
198
219
  const sealedAt = generationDate();
199
220
  const content = renderEvidenceLock(mission, sealedAt);
200
221
  const count = Object.keys(JSON.parse(content).files).length;
201
222
  if (process.env.RUNWARD_DRY_RUN === "1") {
202
- console.log(" " + c.darkGray(`dry-run — would seal ${count} evidence file(s) into runward/${EVIDENCE_LOCK}`));
223
+ log(" " + c.darkGray(`dry-run — would seal ${count} evidence file(s) into runward/${EVIDENCE_LOCK}`));
203
224
  }
204
225
  else {
205
226
  writeFileSync(join(mission, EVIDENCE_LOCK), content);
206
- console.log(` ${status.success(`sealed ${count} evidence file(s) into runward/${EVIDENCE_LOCK} — commit it`)}`);
207
- console.log(" " + c.darkGray("a sealed file that later changes or disappears fails `check --strict` until you re-verify and re-seal."));
227
+ log(` ${status.success(`sealed ${count} evidence file(s) into runward/${EVIDENCE_LOCK} — commit it`)}`);
228
+ log(" " + c.darkGray("a sealed file that later changes or disappears fails `check --strict` until you re-verify and re-seal."));
208
229
  }
209
230
  }
210
231
  }
211
232
  // Transmission surface: name the next gesture, so the operating agent can hand the human a decision.
212
- console.log(section("Next"));
213
- if (gaps === 0 && strictGaps === 0 && hookFailed === 0) {
214
- console.log(` Assemble the evidence pack with ${c.primary("runward compliance <regime>")} ${c.darkGray("(iso-42001 · nist-ai-rmf · eu-ai-act), or")} ${c.primary("runward status")} ${c.darkGray("for a handover snapshot.")}`);
233
+ log(section("Next"));
234
+ if (clean) {
235
+ log(` Assemble the evidence pack with ${c.primary("runward compliance <regime>")} ${c.darkGray("(iso-42001 · nist-ai-rmf · eu-ai-act), or")} ${c.primary("runward status")} ${c.darkGray("for a handover snapshot.")}`);
215
236
  }
216
237
  else {
217
- console.log(` Fill the deliverable(s) named above, then re-run ${c.primary("runward check")}. ${c.primary("runward status")} ${c.darkGray("names exactly what is open at the current gate.")}`);
238
+ log(` Fill the deliverable(s) named above, then re-run ${c.primary("runward check")}. ${c.primary("runward status")} ${c.darkGray("names exactly what is open at the current gate.")}`);
239
+ }
240
+ log();
241
+ // ── Machine contract (ADR-0030) ──────────────────────────────────────
242
+ // One deterministic JSON object; the exit code (set above) stays the primary signal.
243
+ if (opts.json) {
244
+ const payload = {
245
+ runward: VERSION,
246
+ mission: root,
247
+ currentGate: report.currentPhase,
248
+ adrCount: report.adrCount,
249
+ strict: !!opts.strict,
250
+ verdict: clean ? "clean" : "gaps",
251
+ exitCode: clean ? 0 : 1,
252
+ gaps: { deliverables: gaps, conformance: strictGaps, hooks: hookFailed },
253
+ deliverables: deliverablesData,
254
+ ...(opts.strict ? { conformance: conformanceData } : {}),
255
+ };
256
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
218
257
  }
219
- console.log();
220
258
  }
@@ -33,13 +33,17 @@ export async function initCommand(opts) {
33
33
  message: "What are you building? (one line — it seeds your framing note)",
34
34
  default: "",
35
35
  });
36
+ // Vendor-neutral by default (ADR-0030): with no explicit --tools, write only the neutral
37
+ // baseline (AGENTS.md + .agents/skills). No harness is privileged — under --yes the profile
38
+ // list is empty, and the wizard pre-checks nothing. A per-harness channel is an opt-in the
39
+ // operator adds afterward (the agent may recommend one for the detected harness, on approval).
36
40
  const tools = opts.tools !== undefined
37
41
  ? opts.tools.split(",").map((s) => s.trim()).filter(Boolean)
38
42
  : yes
39
- ? ["claude"]
43
+ ? []
40
44
  : await checkbox({
41
- message: "Tool profiles (AGENTS.md is always written)",
42
- choices: TOOL_PROFILES.map((t) => ({ value: t.id, name: t.label, checked: t.id === "claude" })),
45
+ message: "Tool profiles (optional — AGENTS.md + .agents/skills are always written)",
46
+ choices: TOOL_PROFILES.map((t) => ({ value: t.id, name: t.label, checked: false })),
43
47
  });
44
48
  const entryMode = (yes || example) ? "greenfield" : await select({
45
49
  message: "Entry mode",
@@ -0,0 +1,58 @@
1
+ import { resolve } from "node:path";
2
+ import { findMissionRoot } from "../lib/mission.js";
3
+ import { detectHarness } from "../lib/harness.js";
4
+ import { c, createHeader, section, status } from "../lib/styles.js";
5
+ import { VERSION } from "../lib/paths.js";
6
+ /**
7
+ * Recommend the auto-trigger channel for the AI harness running this command (ADR-0030).
8
+ * Read-only: it detects and prints, it never wires anything — the operator installs (ADR-0012).
9
+ * It never prompts, so an agent-driven run never hangs; `undetermined` is a normal outcome the
10
+ * agent resolves by asking the operator (doctrine in AGENTS.md / the SKILL.md). Exit code: always 0.
11
+ */
12
+ export async function wireCommand(opts) {
13
+ const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
14
+ const det = detectHarness(process.env, root);
15
+ if (opts.json) {
16
+ process.stdout.write(JSON.stringify({ runward: VERSION, mission: root, ...det }, null, 2) + "\n");
17
+ return;
18
+ }
19
+ const renderChannel = (ch) => {
20
+ const target = ch.sample ? c.white(ch.sample) : c.darkGray(ch.note ?? "via distribution packaging");
21
+ return ` ${c.primary(ch.channel.padEnd(18))} ${target}`;
22
+ };
23
+ console.log(createHeader(`Runward v${VERSION} — wire`, root ?? "no mission here"));
24
+ console.log(section("Harness"));
25
+ if (det.status === "detected") {
26
+ console.log(" " + status.success(`${det.label}`) + c.darkGray(` (runtime signal ${det.signal})`));
27
+ }
28
+ else if (det.status === "config-detected") {
29
+ console.log(" " + status.info(`${det.label}`) + c.darkGray(" (config file present — weaker than a runtime signal; confirm with the operator)"));
30
+ }
31
+ else {
32
+ console.log(" " + status.warning("undetermined — no runtime signal, no known config file"));
33
+ console.log(" " + c.darkGray("expected for Copilot CLI, Windsurf, Kiro, Continue, Aider, Trae"));
34
+ }
35
+ if (det.recommendedChannel) {
36
+ console.log(section("Recommended channel"));
37
+ console.log(renderChannel(det.recommendedChannel));
38
+ if (det.recommendedChannel.note)
39
+ console.log(" " + c.darkGray(det.recommendedChannel.note));
40
+ }
41
+ console.log(section("Always available (any harness)"));
42
+ for (const ch of det.candidateChannels)
43
+ console.log(renderChannel(ch));
44
+ console.log(section("Next"));
45
+ if (det.status === "undetermined") {
46
+ console.log(" " + c.white("Ask the operator which AI tool this is, then wire the matching sample on their approval."));
47
+ }
48
+ else if (det.recommendedChannel) {
49
+ console.log(" " + c.white(`Offer to wire the sample above from ${c.primary("runward/adapters/")}, on the operator's approval.`));
50
+ }
51
+ else {
52
+ console.log(" " + c.white(`No turn-end sample ships for ${det.label} — use a universal channel above (pre-commit or CI), on the operator's approval.`));
53
+ }
54
+ console.log(" " + c.darkGray("runward wires nothing — you are the operator's hands (ADR-0012). The baseline `runward check` already runs here with nothing wired."));
55
+ if (!root)
56
+ console.log(" " + c.warning("no runward/ mission here — run `runward init` first; the adapter samples live in runward/adapters/."));
57
+ console.log();
58
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Runtime signals — verified environment markers injected into the child process (survey 2026-07-16).
5
+ * `CLAUDECODE=1` covers Claude Code CLI *and* Cowork: Cowork runs the same Claude Code family and
6
+ * exposes no distinct marker, so it is named but never branched on a guessed value. `CURSOR_AGENT=1`
7
+ * is the agent-mode marker (not `CURSOR_TRACE_ID`, which is broader than agent mode).
8
+ */
9
+ const RUNTIME_SIGNALS = [
10
+ { env: "CLAUDECODE", value: "1", harness: "claude-code", label: "Claude Code / Cowork", family: "claude" },
11
+ { env: "GEMINI_CLI", value: "1", harness: "gemini-cli", label: "Gemini CLI", family: "gemini" },
12
+ { env: "CURSOR_AGENT", value: "1", harness: "cursor", label: "Cursor (agent mode)", family: "cursor" },
13
+ ];
14
+ /** Config-file markers a tool profile already laid down (mirrors doctor's detection). Weaker signal. */
15
+ const CONFIG_MARKERS = [
16
+ { marker: ".claude/commands/rw-*", family: "claude", harness: "claude-code", label: "Claude Code / Cowork",
17
+ present: (r) => existsSync(join(r, ".claude", "commands")) && readdirSync(join(r, ".claude", "commands")).some((f) => f.startsWith("rw-")) },
18
+ { marker: ".cursor/rules/runward.mdc", family: "cursor", harness: "cursor", label: "Cursor",
19
+ present: (r) => existsSync(join(r, ".cursor", "rules", "runward.mdc")) },
20
+ { marker: "GEMINI.md", family: "gemini", harness: "gemini-cli", label: "Gemini CLI",
21
+ present: (r) => existsSync(join(r, "GEMINI.md")) },
22
+ { marker: ".github/copilot-instructions.md", family: "copilot", harness: "copilot", label: "GitHub Copilot",
23
+ present: (r) => existsSync(join(r, ".github", "copilot-instructions.md")) },
24
+ { marker: ".windsurf/rules/runward.md", family: "windsurf", harness: "windsurf", label: "Windsurf",
25
+ present: (r) => existsSync(join(r, ".windsurf", "rules", "runward.md")) },
26
+ { marker: ".kiro/steering/", family: "kiro", harness: "kiro", label: "Kiro",
27
+ present: (r) => existsSync(join(r, ".kiro", "steering")) },
28
+ ];
29
+ /**
30
+ * The harness-specific auto-trigger channel. `sample` points at an inert mission adapter where one
31
+ * ships; where runward ships no mission adapter (gemini/cursor/copilot), the channel lives in the
32
+ * distribution packaging and `sample` is null with a note. Never a promise that runward wires it.
33
+ */
34
+ const FAMILY_CHANNEL = {
35
+ claude: { channel: "turn-end-hook", sample: "runward/adapters/claude-code-settings.json" },
36
+ kiro: { channel: "per-tool-hook", sample: "runward/adapters/kiro-hooks.json" },
37
+ gemini: { channel: "turn-end-hook", sample: null, note: "the Gemini extension (packaging/gemini) carries the AfterAgent hook" },
38
+ cursor: { channel: "advisory-hook", sample: null, note: "Cursor's hook is advisory; its blocking seam is per-tool, deliberately not shipped" },
39
+ copilot: { channel: "turn-end-hook", sample: null, note: "copy packaging/copilot/hooks/runward-gate.json into .github/hooks/" },
40
+ };
41
+ /** Universal hard channels — present in every mission after init, valid whatever the harness. */
42
+ export const UNIVERSAL_CHANNELS = [
43
+ { channel: "pre-commit", sample: "runward/adapters/pre-commit" },
44
+ { channel: "ci-required-check", sample: "runward/adapters/github-actions.yml" },
45
+ ];
46
+ /** Detect the harness from the environment (runtime signal) and, failing that, the repo (config file). */
47
+ export function detectHarness(env, root) {
48
+ const base = { schemaVersion: 1, candidateChannels: UNIVERSAL_CHANNELS, wires: false };
49
+ for (const s of RUNTIME_SIGNALS) {
50
+ if (env[s.env] === s.value) {
51
+ return {
52
+ ...base, status: "detected", harness: s.harness, label: s.label, family: s.family,
53
+ detectedVia: "runtime-signal", signal: s.env,
54
+ recommendedChannel: FAMILY_CHANNEL[s.family] ?? null, operatorAction: "offer-to-wire-sample",
55
+ };
56
+ }
57
+ }
58
+ if (root) {
59
+ for (const m of CONFIG_MARKERS) {
60
+ if (m.present(root)) {
61
+ return {
62
+ ...base, status: "config-detected", harness: m.harness, label: m.label, family: m.family,
63
+ detectedVia: "config-file", signal: null,
64
+ recommendedChannel: FAMILY_CHANNEL[m.family] ?? null, operatorAction: "offer-to-wire-sample",
65
+ };
66
+ }
67
+ }
68
+ }
69
+ return {
70
+ ...base, status: "undetermined", harness: null, label: null, family: null,
71
+ detectedVia: null, signal: null, recommendedChannel: null, operatorAction: "ask-operator-which-harness",
72
+ };
73
+ }
package/dist/lib/hooks.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- export function runHooks(missionDir, phase, cwd) {
4
+ export function runHooks(missionDir, phase, cwd, opts = {}) {
5
5
  const result = { ran: 0, failed: [] };
6
6
  const path = join(missionDir, "hooks.json");
7
7
  if (!existsSync(path))
@@ -13,10 +13,14 @@ export function runHooks(missionDir, phase, cwd) {
13
13
  catch {
14
14
  return result;
15
15
  }
16
+ // Under --json (quietStdout), route the hook's own stdout to the parent's stderr (fd 2): a
17
+ // subprocess writing to stdout would otherwise corrupt the single-JSON-object contract, which
18
+ // log() cannot suppress. The hook's output stays visible on stderr; stdout carries only the JSON.
19
+ const stdio = opts.quietStdout ? ["inherit", 2, "inherit"] : "inherit";
16
20
  for (const cmd of cfg[phase] ?? []) {
17
21
  result.ran++;
18
22
  try {
19
- execSync(cmd, { cwd, stdio: "inherit" });
23
+ execSync(cmd, { cwd, stdio });
20
24
  }
21
25
  catch {
22
26
  result.failed.push(cmd);
@@ -52,7 +52,15 @@ export function section(title) {
52
52
  return "\n" + c.primaryBold(title) + "\n" + c.darkGray("─".repeat(Math.min(title.length + 8, 60)));
53
53
  }
54
54
  export function isNonInteractive() {
55
- return process.env.RUNWARD_YES === "1" || !process.stdout.isTTY;
55
+ // An autonomous agent must never hang on a prompt it cannot answer (ADR-0030). Beyond the
56
+ // explicit --yes, treat as non-interactive when CI is set or either standard stream is not a
57
+ // TTY: a prompt reads stdin, so a non-TTY stdin (piped, or none) cannot answer it.
58
+ if (process.env.RUNWARD_YES === "1")
59
+ return true;
60
+ const ci = process.env.CI;
61
+ if (ci && ci !== "0" && ci.toLowerCase() !== "false")
62
+ return true;
63
+ return !process.stdout.isTTY || !process.stdin.isTTY;
56
64
  }
57
65
  /** Today (UTC, YYYY-MM-DD), or the RUNWARD_NOW override in non-interactive runs. The override is
58
66
  * VALIDATED as a real YYYY-MM-DD date: an unvalidated value flowed straight into the OSCAL and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runward",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "After the spec: ship and run. A delivery framework for agentic systems \u2014 floor first, evolution on evidence, governance from day zero, handover with proof.",
5
5
  "keywords": [
6
6
  "agentic",
@@ -17,7 +17,7 @@ This project is delivered with the Runward method: floor first, evolution on evi
17
17
  - Before any structural decision, run `runward/workflows/decision-loop.md`: verify in the real code, check the sourced state of the art, challenge the source, take a durable position, lock it in an ADR — only then edit.
18
18
  - One ADR per structural decision, in `runward/adr/`, with a dated re-evaluation trigger. Use the template `runward/adr/ADR-0000-template.md`.
19
19
  - Current phase and gates: see `runward/framing.md` (Definition of Ready) and each workflow's Definition of Done. Do not skip a gate on assertion; pass it on evidence.
20
- - Offer to wire the gate at your harness's lifecycle seam — git `pre-commit`, a CI required check, or a turn-end hook — from the inert samples in `runward/adapters/` (its README has the exact command). Propose it to the operator and act only on their explicit approval; never wire it silently. runward installs nothing itself (ADR-0012): you are the operator's hands, and the operator owns the gate.
20
+ - Offer to wire the gate at your harness's lifecycle seam — git `pre-commit`, a CI required check, or a turn-end hook — from the inert samples in `runward/adapters/`. Run `runward wire` (or `runward wire --json`) to identify the channel for the harness running you: if it reports a harness (`status: detected` / `config-detected`), offer to wire its recommended sample. **If it reports `status: undetermined`, do not guess — ask the operator, in plain language, which AI tool they use, then wire the matching sample.** Propose it and act only on explicit approval; never wire it silently. `runward wire` is read-only and runward installs nothing itself (ADR-0012): you are the operator's hands, and the operator owns the gate.
21
21
  - Show before you build: for any deliverable meant for humans, produce a reviewable preview first.
22
22
  - Never mark a phase done if its Definition of Done is not demonstrably met.
23
23