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 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 stop at authoring: specs, plans, implementation. 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.
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")
@@ -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
  }
@@ -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) {
@@ -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 byRule = new Map(parseManifest(readFileSync(path, "utf8")).map((r) => [r.rule, r]));
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" && !row.evidence)
85
- violations.push({ rule, problem: "n/a without a reason" });
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
  }
@@ -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
  ];
@@ -0,0 +1,7 @@
1
+ export const RULE_MIGRATIONS = {
2
+ "hexa-llm-boundary-principle": {
3
+ to: "hexa-move-deterministic-out",
4
+ reason: "the name is reserved for the founding inversion",
5
+ since: "v0.7.0",
6
+ },
7
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runward",
3
- "version": "0.7.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.