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 +12 -2
- package/dist/cli.js +11 -2
- package/dist/commands/check.js +94 -56
- package/dist/commands/init.js +7 -3
- package/dist/commands/wire.js +58 -0
- package/dist/lib/harness.js +73 -0
- package/dist/lib/hooks.js +6 -2
- package/dist/lib/styles.js +9 -1
- package/package.json +1 -1
- package/templates/targets/AGENTS.md +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/runward) [](LICENSE) [](https://github.com/stranxik/designing-and-running-agentic-systems) [](https://ko-fi.com/stranxik)
|
|
5
|
+
[](https://www.npmjs.com/package/runward) [](https://github.com/marketplace/actions/runward-gate) [](LICENSE) [](https://github.com/stranxik/designing-and-running-agentic-systems) [](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
|
|
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,
|
|
5
|
-
* compliance (evidence pack), manifest (table plumbing),
|
|
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)")
|
package/dist/commands/check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
71
|
+
log(section(phase.spec.label));
|
|
59
72
|
for (const { artifact, state } of phase.artifacts) {
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
log(` ${status.success(`${label}: ${expected.length} rule(s) accounted for`)}`);
|
|
84
98
|
}
|
|
85
99
|
else {
|
|
86
|
-
for (const v of violations)
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
log(section("Evidence seal (--strict)"));
|
|
99
115
|
if (seal.violations.length === 0) {
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
for (const u of unratified)
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
+
log(` ${c.darkGray("prove behavior with:")} ${c.white(bp.command)}`);
|
|
128
148
|
if (bp.artifact) {
|
|
129
149
|
if (!bp.present)
|
|
130
|
-
|
|
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
|
-
|
|
153
|
+
log(` ${c.success("✓")} ${c.darkGray("proof artifact")} ${c.white(bp.artifact)} ${c.darkGray(`(${bp.date})`)}${fresh}`);
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
|
-
|
|
156
|
+
log(" " + c.darkGray("advisory — runward reports presence and freshness, never runs or reads the result. You cross on both proofs."));
|
|
137
157
|
}
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
+
log(` ${c.primaryBold("Deliverables")} ${c.white(`${filled}/${totalArt} filled`)}`);
|
|
161
181
|
const dc = decisionCoverage(mission);
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
+
log(section("Evidence seal — freeze (ADR-0021)"));
|
|
194
215
|
if (gaps || strictGaps || hookFailed) {
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
? [
|
|
43
|
+
? []
|
|
40
44
|
: await checkbox({
|
|
41
|
-
message: "Tool profiles (AGENTS.md
|
|
42
|
-
choices: TOOL_PROFILES.map((t) => ({ value: t.id, name: t.label, checked:
|
|
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
|
|
23
|
+
execSync(cmd, { cwd, stdio });
|
|
20
24
|
}
|
|
21
25
|
catch {
|
|
22
26
|
result.failed.push(cmd);
|
package/dist/lib/styles.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
|