runward 0.7.0 → 0.8.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 +1 -1
- package/dist/cli.js +1 -0
- package/dist/commands/check.js +31 -2
- package/dist/commands/doctor.js +7 -1
- package/dist/lib/conformance.js +68 -7
- package/dist/lib/constants.js +4 -0
- package/dist/lib/hooks.js +26 -0
- package/dist/lib/paths.js +1 -1
- package/dist/lib/rule-migrations.js +7 -0
- package/package.json +1 -1
- package/templates/workflows/verify.md +33 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Your agent builds fast. Runward frames what it builds — six gated phases your
|
|
|
8
8
|
|
|
9
9
|
Runward is a delivery framework for agentic systems. It covers the entire mission, from framing to handover — it opens before any spec in greenfield, or picks up where the spec tools stop, and carries through to the run and the handover either way. Three ways in: day one of a new project (Frame is the first gate, before any spec exists), from an existing spec (Spec Kit, OpenSpec or in-house — it becomes the input of framing), or from an existing prototype (brownfield: characterize before touching anything). Then: floor first (the floor: the smallest *running* system that proves value on real traffic), evolution on evidence, governance from day zero, and a handover that makes your team autonomous.
|
|
10
10
|
|
|
11
|
-
> Spec Kit, OpenSpec and BMAD
|
|
11
|
+
> Spec Kit, OpenSpec and BMAD take you to tested, sometimes merged code. Runward pilots the whole mission — and picks up their output if you use them. What none of them *structures* is the run: governed memory, resilience, execution security, continuous evaluation, transmission.
|
|
12
12
|
|
|
13
13
|
## Why
|
|
14
14
|
|
package/dist/cli.js
CHANGED
|
@@ -58,6 +58,7 @@ program
|
|
|
58
58
|
.description("can I cross the gate — gate audit, exit 1 on gaps (CI-friendly)")
|
|
59
59
|
.option("-p, --path <path>", "project directory")
|
|
60
60
|
.option("--strict", "also verify the floor rule-conformance manifest (deterministic)")
|
|
61
|
+
.option("--hooks", "run operator hooks from runward/hooks.json around the audit (opt-in)")
|
|
61
62
|
.action(checkCommand);
|
|
62
63
|
program
|
|
63
64
|
.command("status")
|
package/dist/commands/check.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { analyze, findMissionRoot } from "../lib/mission.js";
|
|
3
|
-
import { conformance } from "../lib/conformance.js";
|
|
3
|
+
import { conformance, driftReport } from "../lib/conformance.js";
|
|
4
|
+
import { runHooks } from "../lib/hooks.js";
|
|
4
5
|
import { c, createHeader, section, status } from "../lib/styles.js";
|
|
5
6
|
import { VERSION } from "../lib/paths.js";
|
|
6
7
|
/**
|
|
@@ -21,6 +22,15 @@ export async function checkCommand(opts) {
|
|
|
21
22
|
const mission = join(root, "runward");
|
|
22
23
|
const report = analyze(mission);
|
|
23
24
|
console.log(createHeader(`Runward v${VERSION} — gate audit`, root));
|
|
25
|
+
let hookFailed = 0;
|
|
26
|
+
if (opts.hooks) {
|
|
27
|
+
const before = runHooks(mission, "before", root);
|
|
28
|
+
if (before.ran > 0) {
|
|
29
|
+
console.log(section("Hooks · before"));
|
|
30
|
+
console.log(" " + (before.failed.length ? status.error(`${before.failed.length}/${before.ran} failed`) : status.success(`${before.ran} ok`)));
|
|
31
|
+
hookFailed += before.failed.length;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
const glyph = {
|
|
25
35
|
"filled": c.success("✓"),
|
|
26
36
|
"in-progress": c.warning("◐"),
|
|
@@ -51,7 +61,10 @@ export async function checkCommand(opts) {
|
|
|
51
61
|
];
|
|
52
62
|
console.log(section("Rule conformance (--strict)"));
|
|
53
63
|
let checked = 0;
|
|
64
|
+
const drift = [];
|
|
54
65
|
for (const { phase, deliverable, label } of CONFORMANCE) {
|
|
66
|
+
for (const d of driftReport(mission, deliverable))
|
|
67
|
+
drift.push(`${label} · ${d.rule} — ${d.problem}`);
|
|
55
68
|
const { expected, violations } = conformance(mission, phase, deliverable);
|
|
56
69
|
if (expected.length === 0)
|
|
57
70
|
continue;
|
|
@@ -67,11 +80,25 @@ export async function checkCommand(opts) {
|
|
|
67
80
|
}
|
|
68
81
|
if (checked === 0)
|
|
69
82
|
console.log(" " + c.darkGray("no CRITICAL/HIGH rules mapped to a build phase"));
|
|
83
|
+
if (drift.length > 0) {
|
|
84
|
+
console.log(section("Drift (advisory)"));
|
|
85
|
+
for (const d of drift)
|
|
86
|
+
console.log(` ${c.warning("◑")} ${c.white(d)}`);
|
|
87
|
+
console.log(" " + c.darkGray("advisory — an applied pointer no longer resolves; verify it. Does not fail the gate."));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (opts.hooks) {
|
|
91
|
+
const after = runHooks(mission, "after", root);
|
|
92
|
+
if (after.ran > 0) {
|
|
93
|
+
console.log(section("Hooks · after"));
|
|
94
|
+
console.log(" " + (after.failed.length ? status.error(`${after.failed.length}/${after.ran} failed`) : status.success(`${after.ran} ok`)));
|
|
95
|
+
hookFailed += after.failed.length;
|
|
96
|
+
}
|
|
70
97
|
}
|
|
71
98
|
console.log(section("Summary"));
|
|
72
99
|
console.log(` ${c.primaryBold("Current gate")} ${c.white(report.currentPhase)}`);
|
|
73
100
|
console.log(` ${c.primaryBold("ADRs")} ${c.white(String(report.adrCount))}${report.adrCount === 0 ? c.warning(" — no structural decision locked yet") : ""}`);
|
|
74
|
-
if (gaps === 0 && strictGaps === 0) {
|
|
101
|
+
if (gaps === 0 && strictGaps === 0 && hookFailed === 0) {
|
|
75
102
|
console.log("\n" + status.success("All expected deliverables are filled. Cross gates on evidence, not on paperwork."));
|
|
76
103
|
}
|
|
77
104
|
else {
|
|
@@ -80,6 +107,8 @@ export async function checkCommand(opts) {
|
|
|
80
107
|
parts.push(`${gaps} deliverable(s) not filled`);
|
|
81
108
|
if (strictGaps)
|
|
82
109
|
parts.push(`${strictGaps} floor rule-conformance gap(s)`);
|
|
110
|
+
if (hookFailed)
|
|
111
|
+
parts.push(`${hookFailed} hook(s) failed`);
|
|
83
112
|
console.log("\n" + status.warning(`${parts.join(" · ")}. No phase closes without its artifact — and, under --strict, without its CRITICAL/HIGH rules accounted for.`));
|
|
84
113
|
process.exitCode = 1;
|
|
85
114
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,8 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { TEMPLATES, MISSION_LAYOUT, VERSION, WORKFLOWS } from "../lib/paths.js";
|
|
5
|
-
import { EXPECTED_RULES } from "../lib/constants.js";
|
|
5
|
+
import { EXPECTED_RULES, EXPECTED_MAPPED } from "../lib/constants.js";
|
|
6
|
+
import { expectedRules } from "../lib/conformance.js";
|
|
6
7
|
import { findMissionRoot } from "../lib/mission.js";
|
|
7
8
|
import { createHeader, section, status } from "../lib/styles.js";
|
|
8
9
|
/**
|
|
@@ -34,6 +35,11 @@ export async function doctorCommand() {
|
|
|
34
35
|
const rulesDir = join(TEMPLATES, "rules");
|
|
35
36
|
const ruleCount = existsSync(rulesDir) ? readdirSync(rulesDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
36
37
|
ruleCount === EXPECTED_RULES ? ok(`${ruleCount} craft rules`) : fail(`craft rules mismatch: ${ruleCount}/${EXPECTED_RULES}`);
|
|
38
|
+
// Routed-count floor (ADR-0002): the phases: mapping must not be stripped below its pinned minimum.
|
|
39
|
+
const belowFloor = Object.entries(EXPECTED_MAPPED).filter(([phase, floor]) => expectedRules(TEMPLATES, phase).length < floor);
|
|
40
|
+
belowFloor.length === 0
|
|
41
|
+
? ok(`rule mapping floors met (${Object.entries(EXPECTED_MAPPED).map(([p, f]) => `${p}≥${f}`).join(", ")})`)
|
|
42
|
+
: fail(`rule mapping below floor: ${belowFloor.map(([p, f]) => `${p} ${expectedRules(TEMPLATES, p).length}/${f}`).join(", ")}`);
|
|
37
43
|
console.log(section("Current directory"));
|
|
38
44
|
const root = findMissionRoot(process.cwd());
|
|
39
45
|
if (!root) {
|
package/dist/lib/conformance.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
3
|
import { TEMPLATES } from "./paths.js";
|
|
4
|
+
import { EXPECTED_MAPPED } from "./constants.js";
|
|
5
|
+
import { RULE_MIGRATIONS } from "./rule-migrations.js";
|
|
4
6
|
const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
|
|
5
7
|
const VALID_STATUS = new Set(["applied", "deviated", "n/a"]);
|
|
8
|
+
/** An n/a reason must be more than a placeholder: real length, not a bracketed template token. */
|
|
9
|
+
function trivialReason(s) {
|
|
10
|
+
const t = s.trim();
|
|
11
|
+
return t.length < 8 || /^\[.*\]$/.test(t);
|
|
12
|
+
}
|
|
6
13
|
function parseRuleMeta(content) {
|
|
7
14
|
const fm = content.match(FRONTMATTER)?.[1] ?? "";
|
|
8
15
|
const impact = (fm.match(/^impact:\s*(.+)$/m)?.[1] ?? "").trim();
|
|
@@ -28,6 +35,14 @@ export function expectedRules(missionDir, phaseId) {
|
|
|
28
35
|
.map((f) => f.replace(/\.md$/, ""))
|
|
29
36
|
.sort();
|
|
30
37
|
}
|
|
38
|
+
/** Every rule slug in the set (mission's own, else the package) — the universe a manifest row must belong to. */
|
|
39
|
+
export function allRules(missionDir) {
|
|
40
|
+
const missionRules = join(missionDir, "rules");
|
|
41
|
+
const dir = existsSync(missionRules) ? missionRules : join(TEMPLATES, "rules");
|
|
42
|
+
if (!existsSync(dir))
|
|
43
|
+
return [];
|
|
44
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
45
|
+
}
|
|
31
46
|
/** Parse the "## Rule conformance" markdown table from a deliverable. */
|
|
32
47
|
export function parseManifest(content) {
|
|
33
48
|
const lines = content.split("\n");
|
|
@@ -58,19 +73,65 @@ function adrExists(missionDir, evidence) {
|
|
|
58
73
|
const dir = join(missionDir, "adr");
|
|
59
74
|
return existsSync(dir) && readdirSync(dir).some((f) => f.toUpperCase().startsWith(id));
|
|
60
75
|
}
|
|
76
|
+
// A path token: a file with a known code/doc extension (excludes version numbers like v1.0, "§2").
|
|
77
|
+
const PATH_TOKEN = /[\w./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|sql|sh|css|scss|html|txt)\b/g;
|
|
78
|
+
/** Advisory drift (ADR-0004): applied pointers whose file path no longer resolves. Existence only. */
|
|
79
|
+
export function driftReport(missionDir, deliverable) {
|
|
80
|
+
const path = join(missionDir, deliverable);
|
|
81
|
+
if (!existsSync(path))
|
|
82
|
+
return [];
|
|
83
|
+
const bases = [dirname(missionDir), missionDir, dirname(path)];
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const row of parseManifest(readFileSync(path, "utf8"))) {
|
|
86
|
+
if (row.status !== "applied")
|
|
87
|
+
continue;
|
|
88
|
+
const tokens = row.evidence.match(PATH_TOKEN) ?? [];
|
|
89
|
+
if (tokens.length === 0)
|
|
90
|
+
continue; // pure prose reference — the operator's judgment
|
|
91
|
+
const resolves = tokens.some((t) => bases.some((b) => existsSync(join(b, t))));
|
|
92
|
+
if (!resolves)
|
|
93
|
+
out.push({ rule: row.rule, problem: `applied pointer does not resolve (drift?): ${row.evidence} — update the pointer or remove the row` });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
61
97
|
/** Verify a build phase's conformance manifest against its expected rule set. */
|
|
62
98
|
export function conformance(missionDir, phaseId, deliverable) {
|
|
63
99
|
const expected = expectedRules(missionDir, phaseId);
|
|
64
100
|
const violations = [];
|
|
101
|
+
// Non-vacuity (ADR-0002): the mapping cannot be stripped below its pinned floor.
|
|
102
|
+
const floor = EXPECTED_MAPPED[phaseId];
|
|
103
|
+
if (floor !== undefined && expected.length < floor) {
|
|
104
|
+
violations.push({ rule: "(mapping)", problem: `only ${expected.length} CRITICAL/HIGH rules mapped to '${phaseId}', floor is ${floor} — the mapping may have been stripped; restore the phases: [...] frontmatter on this phase's rules` });
|
|
105
|
+
}
|
|
65
106
|
const path = join(missionDir, deliverable);
|
|
66
107
|
if (!existsSync(path)) {
|
|
67
108
|
return { expected, violations: expected.map((rule) => ({ rule, problem: `${deliverable} missing` })) };
|
|
68
109
|
}
|
|
69
|
-
const
|
|
110
|
+
const rows = parseManifest(readFileSync(path, "utf8"));
|
|
111
|
+
// Form-lint (ADR-0003): well-formedness before the semantic check. Skip template placeholder tokens.
|
|
112
|
+
const known = new Set(allRules(missionDir));
|
|
113
|
+
const counts = new Map();
|
|
114
|
+
for (const r of rows) {
|
|
115
|
+
if (/^\[.*\]$/.test(r.rule))
|
|
116
|
+
continue;
|
|
117
|
+
counts.set(r.rule, (counts.get(r.rule) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
for (const [rule, n] of counts) {
|
|
120
|
+
if (!known.has(rule)) {
|
|
121
|
+
const m = RULE_MIGRATIONS[rule];
|
|
122
|
+
const hint = m
|
|
123
|
+
? (m.to ? ` — renamed to '${m.to}' in ${m.since} (${m.reason})` : ` — removed in ${m.since} (${m.reason})`)
|
|
124
|
+
: " (typo? not in runward/rules/)";
|
|
125
|
+
violations.push({ rule, problem: `unknown rule${hint}` });
|
|
126
|
+
}
|
|
127
|
+
if (n > 1)
|
|
128
|
+
violations.push({ rule, problem: `listed ${n} times in the manifest — keep a single row per rule` });
|
|
129
|
+
}
|
|
130
|
+
const byRule = new Map(rows.map((r) => [r.rule, r]));
|
|
70
131
|
for (const rule of expected) {
|
|
71
132
|
const row = byRule.get(rule);
|
|
72
133
|
if (!row) {
|
|
73
|
-
violations.push({ rule, problem: "not accounted for in the Rule conformance manifest" });
|
|
134
|
+
violations.push({ rule, problem: "not accounted for in the Rule conformance manifest — add a row: applied with a file:line/test, deviated with an ADR, or n/a with a reason" });
|
|
74
135
|
continue;
|
|
75
136
|
}
|
|
76
137
|
if (!VALID_STATUS.has(row.status)) {
|
|
@@ -78,11 +139,11 @@ export function conformance(missionDir, phaseId, deliverable) {
|
|
|
78
139
|
continue;
|
|
79
140
|
}
|
|
80
141
|
if (row.status === "applied" && !row.evidence)
|
|
81
|
-
violations.push({ rule, problem: "applied without an evidence pointer" });
|
|
142
|
+
violations.push({ rule, problem: "applied without an evidence pointer — put a file:line or a test in the Evidence column" });
|
|
82
143
|
if (row.status === "deviated" && !adrExists(missionDir, row.evidence))
|
|
83
|
-
violations.push({ rule, problem: "deviated but no matching ADR in runward/adr/" });
|
|
84
|
-
if (row.status === "n/a" &&
|
|
85
|
-
violations.push({ rule, problem: "n/a
|
|
144
|
+
violations.push({ rule, problem: "deviated but no matching ADR in runward/adr/ — reference an ADR that exists there" });
|
|
145
|
+
if (row.status === "n/a" && trivialReason(row.evidence))
|
|
146
|
+
violations.push({ rule, problem: "n/a with an empty or placeholder reason — give a real one-line reason why it does not apply here" });
|
|
86
147
|
}
|
|
87
148
|
return { expected, violations };
|
|
88
149
|
}
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
/** Total number of craft rules shipped under templates/rules/. */
|
|
2
2
|
export const EXPECTED_RULES = 51;
|
|
3
|
+
/** Routed-count floor: minimum CRITICAL/HIGH rules mapped to each build phase (ADR-0002).
|
|
4
|
+
* Lowering a floor is a deliberate, tracked edit — the `phases:` mapping cannot be silently
|
|
5
|
+
* stripped to make `check --strict` pass vacuously. */
|
|
6
|
+
export const EXPECTED_MAPPED = { architect: 5, floor: 10, govern: 7 };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function runHooks(missionDir, phase, cwd) {
|
|
5
|
+
const result = { ran: 0, failed: [] };
|
|
6
|
+
const path = join(missionDir, "hooks.json");
|
|
7
|
+
if (!existsSync(path))
|
|
8
|
+
return result;
|
|
9
|
+
let cfg;
|
|
10
|
+
try {
|
|
11
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
for (const cmd of cfg[phase] ?? []) {
|
|
17
|
+
result.ran++;
|
|
18
|
+
try {
|
|
19
|
+
execSync(cmd, { cwd, stdio: "inherit" });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
result.failed.push(cmd);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
package/dist/lib/paths.js
CHANGED
|
@@ -24,5 +24,5 @@ export const MISSION_LAYOUT = {
|
|
|
24
24
|
};
|
|
25
25
|
export const WORKFLOWS = [
|
|
26
26
|
"method", "frame", "architect", "floor", "iterate",
|
|
27
|
-
"govern", "handover", "brownfield", "review", "decision-loop",
|
|
27
|
+
"govern", "handover", "brownfield", "review", "decision-loop", "verify",
|
|
28
28
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runward",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "After the spec: ship and run. A delivery framework for agentic systems — floor first, evolution on evidence, governance from day zero, handover with proof.",
|
|
5
5
|
"keywords": ["agentic", "ai-agents", "spec-driven", "delivery", "governance", "llm", "architecture"],
|
|
6
6
|
"author": "Thibault Souris",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Verify — Advisory Cite-vs-Apply Review (above the gate, never in it)
|
|
2
|
+
|
|
3
|
+
## When to use
|
|
4
|
+
|
|
5
|
+
Use this **after** `runward check --strict` is green, and before you cross the gate as the operator. The deterministic gate proves a decision was *traced* for every CRITICAL/HIGH rule (applied with a pointer, deviated with an ADR, n/a with a reason). It cannot prove the code an `applied` row points at **actually applies** the rule rather than merely citing it — that is a semantic judgment. This workflow makes that judgment, adversarially, to inform yours.
|
|
6
|
+
|
|
7
|
+
It is **advisory**. It produces findings, never a verdict that gates. `runward check --strict` stays the load-bearing decision; you cross it. See [ADR-0007].
|
|
8
|
+
|
|
9
|
+
## Inputs
|
|
10
|
+
|
|
11
|
+
- The `Rule conformance` manifests (`floor.md`, `architecture.md`, `governance/threat-model.md`).
|
|
12
|
+
- The code each `applied` row points at.
|
|
13
|
+
|
|
14
|
+
## Procedure
|
|
15
|
+
|
|
16
|
+
**Challenge each `applied` claim, adversarially.** For every `applied` row, open its evidence (the `file:line` or test) and default to skepticism: *does this code actually apply the rule it names, or does it only cite it?* Judge through distinct lenses — correctness, does-it-reproduce, security-relevance — and, where you can, run this pass on a **different model** than the one that built the floor, so it does not agree with itself.
|
|
17
|
+
|
|
18
|
+
**Return a finding per row**: `confirmed` (the code applies the rule), `cited-not-applied` (the row claims applied but the code does not carry the rule), or `uncertain` (needs a human look). Deduplicate overlapping findings; group them by severity.
|
|
19
|
+
|
|
20
|
+
**Hand the findings to the operator.** They are input to the operator's gate decision, not a gate of their own. A `cited-not-applied` finding is a prompt to fix the code (or downgrade the row to `deviated`/`n/a`) and re-run `check --strict` — never a silent override of the deterministic gate.
|
|
21
|
+
|
|
22
|
+
## Definition of Done
|
|
23
|
+
|
|
24
|
+
- Every `applied` row reviewed, with a confirmed / cited-not-applied / uncertain finding.
|
|
25
|
+
- Findings handed to the operator; no exit code produced, no gate blocked.
|
|
26
|
+
- Any `cited-not-applied` finding routed back to the code and re-checked deterministically.
|
|
27
|
+
|
|
28
|
+
## Anti-patterns
|
|
29
|
+
|
|
30
|
+
- Treating a clean `verify` pass as "the gate passed" — it is advisory; the deterministic `check --strict` is the gate.
|
|
31
|
+
- Wiring `verify` into CI as a blocking step, or into an exit code — it never blocks.
|
|
32
|
+
- Running `verify` **instead of** `check --strict` — it sits above the deterministic gate, it does not replace it.
|
|
33
|
+
- Running it on the same model that wrote the code and trusting the agreement — use a different lens, ideally a different model.
|