runward 0.9.1 → 0.10.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
@@ -4,7 +4,9 @@
4
4
 
5
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)
6
6
 
7
- **From business need to a system that holds in production.**
7
+ **After the spec, the hard part starts. Runward ships it and runs it.**
8
+
9
+ *Run-grade engineering for agentic systems — from business need to a system that holds in production.*
8
10
 
9
11
  Your agent builds fast. Runward frames what it builds — six gated phases your agent executes and one human signs off — so what ships actually holds.
10
12
 
@@ -56,9 +58,11 @@ New here? Follow [your first mission in 15 minutes](docs/first-mission.md).
56
58
  |---|---|
57
59
  | `runward init` | Scaffold the mission structure (wizard: entry mode, stopping tier, tool profiles) |
58
60
  | `runward check` | **Gate audit**: which deliverable, expected at which phase, is missing, started, or filled — exit code 1 on gaps |
61
+ | `runward check --strict` | **Conformance gate**: verifies each phase's rule-conformance manifest — every CRITICAL/HIGH craft rule mapped to the phase is accounted for (applied with a `file:line`/test, deviated with an ADR, or reasoned `n/a`). **Deterministic and zero-LLM**: it proves a decision was traced, never the code's quality, and cannot be jailbroken by injection — rerun it and get the same verdict |
59
62
  | `runward status` | Mission snapshot: current gate, decision journal (ADRs), workflows |
60
63
  | `runward doctor` | Environment and installation checks |
61
64
  | `runward update` | Refresh `runward/workflows/` from the package — mission state never touched, local edits preserved unless `--force` |
65
+ | `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 |
62
66
 
63
67
  Global flags: `--yes`, `--dry-run`, `--verbose`, `--no-color`. Exit codes: 0 success, 1 gaps/warnings, 2 missing prerequisite.
64
68
 
@@ -107,6 +111,9 @@ Phase 5 is transverse: it starts at day zero, not after the incident.
107
111
 
108
112
  ## What makes it different
109
113
 
114
+ - **The gate you own — deterministic and zero-LLM.** `runward check --strict` is a non-probabilistic, operator-owned exit-code gate: it cannot be jailbroken by injection (no model in the gate path) and reruns byte-for-byte. Every competitor gates on LLM generation plus human prose review; this one does not depend on an LLM to pass.
115
+ - **Audit-ready evidence, not compliance theatre.** Craft rules carry an OWASP ASI (Top 10 for Agentic Applications) mapping — a universal, region-agnostic security posture — so the conformance manifest reads as supporting evidence. Security-only by default; it maps to your regime when you need one — ISO/IEC 42001 (global), NIST AI RMF (US), or the EU AI Act art. 13 technical file (EU) — see [`docs/compliance/`](docs/compliance/). An input to that work — never a conformity assessment, never a claim to certify.
116
+ - **Never a runtime.** Runward writes nothing into `.git/`, installs nothing, and runs nothing of yours — it frames, gates and hands off. Your runtime and model are swappable adapters behind a port; the thing you depend on is never Runward.
110
117
  - **Floor, not MVP deck.** The floor is the smallest *running* system that proves value on real traffic. A presentation is not a floor.
111
118
  - **Evolution on evidence.** Multi-agent, long-term memory, microservices, a bigger model: each has a sober default and an explicit trigger. No trigger, no complexity. Every switch is an ADR.
112
119
  - **Security by architecture, not detection.** Prompt injection is constrained structurally (lethal trifecta, 2-of-3 rule), not filtered heuristically.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
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.
4
+ * Commands: init (wizard), check (gate audit), status, doctor, update, characterize.
5
5
  */
6
6
  import { Command } from "commander";
7
7
  import { VERSION } from "./lib/paths.js";
@@ -11,6 +11,7 @@ import { checkCommand } from "./commands/check.js";
11
11
  import { statusCommand } from "./commands/status.js";
12
12
  import { doctorCommand } from "./commands/doctor.js";
13
13
  import { updateCommand } from "./commands/update.js";
14
+ import { characterizeCommand } from "./commands/characterize.js";
14
15
  // Exit codes: 0 = success · 1 = gaps/warnings · 2 = missing prerequisite
15
16
  process.on("uncaughtException", (err) => {
16
17
  if (err.name === "ExitPromptError")
@@ -59,6 +60,7 @@ program
59
60
  .option("-p, --path <path>", "project directory")
60
61
  .option("--strict", "also verify the floor rule-conformance manifest (deterministic)")
61
62
  .option("--hooks", "run operator hooks from runward/hooks.json around the audit (opt-in)")
63
+ .option("--coverage", "advisory: report deliverable + decision-ratification coverage (does not gate)")
62
64
  .action(checkCommand);
63
65
  program
64
66
  .command("status")
@@ -75,4 +77,10 @@ program
75
77
  .option("-p, --path <path>", "project directory")
76
78
  .option("--force", "overwrite locally modified workflows")
77
79
  .action(updateCommand);
80
+ program
81
+ .command("characterize")
82
+ .description("read-only inventory of an existing codebase → runward/characterization.md (brownfield/retro-doc)")
83
+ .option("-p, --path <path>", "project directory (default: .)")
84
+ .option("--mine", "also propose candidate retroactive ADRs as DRAFT hypotheses (deterministic git archaeology, no model call)")
85
+ .action(characterizeCommand);
78
86
  program.parseAsync();
@@ -0,0 +1,63 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { buildInventory, renderCharacterization, mineDrafts, renderDraft } from "../lib/characterize.js";
4
+ import { makeWriter } from "../lib/write.js";
5
+ import { c, createHeader, isNonInteractive, section, status } from "../lib/styles.js";
6
+ import { VERSION } from "../lib/paths.js";
7
+ /**
8
+ * Characterize an existing codebase (ADR-0014) — read-only, deterministic, zero-LLM.
9
+ * Emits runward/characterization.md: a factual inventory (dependencies, entrypoints,
10
+ * CI, tests, git-log shape). It parses artifacts at rest; it never runs, builds,
11
+ * installs, or writes to the target — only into runward/. The output is facts, not
12
+ * decisions: reconstructing the *why* stays the operator's job (ADR-0013).
13
+ * Exit codes: 0 = inventory produced, 2 = no readable target directory.
14
+ */
15
+ export async function characterizeCommand(opts) {
16
+ const root = resolve(process.cwd(), opts.path ?? ".");
17
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
18
+ console.error(status.error(`No readable directory at ${root}.`));
19
+ process.exit(2);
20
+ }
21
+ console.log(createHeader(`Runward v${VERSION} — characterize`, root));
22
+ console.log(section("Reading (read-only)"));
23
+ const inv = buildInventory(root);
24
+ const dryRun = process.env.RUNWARD_DRY_RUN === "1";
25
+ const generatedAt = isNonInteractive() && process.env.RUNWARD_NOW ? process.env.RUNWARD_NOW : new Date().toISOString().slice(0, 10);
26
+ const md = renderCharacterization(inv, generatedAt);
27
+ // Generated artifact, not mission state: always refresh it (idempotent).
28
+ const w = makeWriter({ force: true, dryRun, root });
29
+ w.write(join(root, "runward", "characterization.md"), md);
30
+ // --mine: deterministic git archaeology → candidate DRAFT ADRs (no model call, ADR-0014).
31
+ // force:false — never clobber a DRAFT the operator has started editing.
32
+ if (opts.mine) {
33
+ console.log(section("Candidate ADR-mining (--mine)"));
34
+ const candidates = mineDrafts(root, inv);
35
+ if (candidates.length === 0) {
36
+ console.log(" " + status.skip("no candidate decision found in the evidence at rest."));
37
+ }
38
+ else {
39
+ const dw = makeWriter({ force: false, dryRun, root });
40
+ for (const cand of candidates)
41
+ dw.write(join(root, "runward", "adr", `DRAFT-${cand.slug}.md`), renderDraft(cand, generatedAt));
42
+ console.log(" " + status.info(`${candidates.length} candidate decision(s) proposed as DRAFT hypotheses — ratify or delete each (they are not decisions until you own them).`));
43
+ }
44
+ }
45
+ console.log(section("Inventory"));
46
+ console.log(` ${c.primaryBold("Ecosystems")} ${c.white(inv.ecosystems.map((e) => e.name.split(" ")[0]).join(", ") || "none")}`);
47
+ console.log(` ${c.primaryBold("Entrypoints")} ${c.white(String(inv.entrypoints.length))}`);
48
+ console.log(` ${c.primaryBold("CI")} ${c.white(String(inv.ci.length))}`);
49
+ console.log(` ${c.primaryBold("Tests")} ${c.white(String(inv.tests.files))} file(s)`);
50
+ console.log(` ${c.primaryBold("Git")} ${c.white(inv.git ? `${inv.git.commits} commit(s), ${inv.git.authors} author(s)` : "not a git repo")}`);
51
+ console.log(section("Next steps"));
52
+ console.log(" " + c.white("1.") + " Review " + c.primary("runward/characterization.md") + c.darkGray(" — facts (confidence: high), not decisions."));
53
+ if (opts.mine) {
54
+ console.log(" " + c.white("2.") + " Review the " + c.primary("runward/adr/DRAFT-*.md") + " candidates with your agent: for each, write the real");
55
+ console.log(" " + c.white("why") + " and a trigger, set " + c.white("Status: accepted") + " and rename to " + c.primary("ADR-NNNN-*.md") + c.darkGray(", or delete it."));
56
+ }
57
+ else {
58
+ console.log(" " + c.white("2.") + " Run the " + c.primary("brownfield") + " workflow with your agent: reconstruct the architecture note and");
59
+ console.log(" retroactive ADRs. Each is a " + c.warning("hypothesis") + " until you confirm its " + c.white("why") + " and set its trigger.");
60
+ }
61
+ console.log(" " + c.white("3.") + " Then " + c.primary("runward check --strict") + c.darkGray(" — the gate stays red until you ratify each reconstructed decision."));
62
+ console.log();
63
+ }
@@ -1,6 +1,6 @@
1
- import { join } from "node:path";
1
+ import { join, resolve } from "node:path";
2
2
  import { analyze, findMissionRoot } from "../lib/mission.js";
3
- import { conformance, driftReport } from "../lib/conformance.js";
3
+ import { conformance, driftReport, unratifiedAdrs, decisionCoverage } from "../lib/conformance.js";
4
4
  import { runHooks } from "../lib/hooks.js";
5
5
  import { c, createHeader, section, status } from "../lib/styles.js";
6
6
  import { VERSION } from "../lib/paths.js";
@@ -14,7 +14,7 @@ import { VERSION } from "../lib/paths.js";
14
14
  * Exit codes: 0 = current gate clean, 1 = gaps, 2 = no mission found.
15
15
  */
16
16
  export async function checkCommand(opts) {
17
- const root = findMissionRoot(join(process.cwd(), opts.path ?? "."));
17
+ const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
18
18
  if (!root) {
19
19
  console.error(status.error("No runward/ mission found here or above. Run `runward init` first."));
20
20
  process.exit(2);
@@ -86,6 +86,30 @@ export async function checkCommand(opts) {
86
86
  console.log(` ${c.warning("◑")} ${c.white(d)}`);
87
87
  console.log(" " + c.darkGray("advisory — an applied pointer no longer resolves; verify it. Does not fail the gate."));
88
88
  }
89
+ const unratified = unratifiedAdrs(mission);
90
+ if (unratified.length > 0) {
91
+ console.log(section("Reconstruction lifecycle (--strict)"));
92
+ for (const u of unratified)
93
+ console.log(` ${c.error("✗")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
94
+ 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."));
95
+ strictGaps += unratified.length;
96
+ }
97
+ }
98
+ if (opts.coverage) {
99
+ console.log(section("Documentation coverage (advisory)"));
100
+ let filled = 0, totalArt = 0;
101
+ for (const phase of report.phases)
102
+ for (const { state } of phase.artifacts) {
103
+ totalArt++;
104
+ if (state === "filled")
105
+ filled++;
106
+ }
107
+ console.log(` ${c.primaryBold("Deliverables")} ${c.white(`${filled}/${totalArt} filled`)}`);
108
+ const dc = decisionCoverage(mission);
109
+ console.log(` ${c.primaryBold("Decisions")} ${c.white(`${dc.ratified}/${dc.total} ratified`)}${dc.unratified.length ? c.warning(` (${dc.unratified.length} to ratify)`) : ""}`);
110
+ for (const u of dc.unratified)
111
+ console.log(` ${c.warning("◑")} ${c.white(u.file)}${c.darkGray(" — " + u.reason)}`);
112
+ console.log(" " + c.darkGray("advisory — a ratio of what is documented and ratified, not a claim of completeness. Does not affect the verdict."));
89
113
  }
90
114
  if (opts.hooks) {
91
115
  const after = runHooks(mission, "after", root);
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, resolve } from "node:path";
2
2
  import { readdirSync } from "node:fs";
3
3
  import { checkbox, input, select } from "@inquirer/prompts";
4
4
  import { TEMPLATES, MISSION_LAYOUT, VERSION } from "../lib/paths.js";
@@ -44,7 +44,7 @@ export async function initCommand(opts) {
44
44
  for (const t of unknown)
45
45
  console.log(status.warning(`Unknown tool profile "${t}" — supported: ${TOOL_IDS.join(", ")}`));
46
46
  // ── Write ─────────────────────────────────────────────────────────
47
- const root = join(process.cwd(), dir);
47
+ const root = resolve(process.cwd(), dir);
48
48
  const mission = join(root, "runward");
49
49
  const w = makeWriter({ force: opts.force ?? false, dryRun, root });
50
50
  console.log(section("Mission structure"));
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { analyze, findMissionRoot } from "../lib/mission.js";
4
4
  import { c, createHeader, section } from "../lib/styles.js";
5
5
  import { VERSION, WORKFLOWS } from "../lib/paths.js";
6
6
  /** Mission snapshot: phase, ADR journal, framing summary, workflow presence. */
7
7
  export async function statusCommand(opts) {
8
- const root = findMissionRoot(join(process.cwd(), opts.path ?? "."));
8
+ const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
9
9
  if (!root) {
10
10
  console.error(c.error("✗ ") + "No runward/ mission found. Run `runward init` first.");
11
11
  process.exit(2);
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { TEMPLATES, VERSION } from "../lib/paths.js";
4
4
  import { findMissionRoot } from "../lib/mission.js";
5
5
  import { makeWriter } from "../lib/write.js";
@@ -10,7 +10,7 @@ import { c, createHeader, section, status } from "../lib/styles.js";
10
10
  * Local edits are preserved unless --force.
11
11
  */
12
12
  export async function updateCommand(opts) {
13
- const root = findMissionRoot(join(process.cwd(), opts.path ?? "."));
13
+ const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
14
14
  if (!root) {
15
15
  console.error(status.error("No runward/ mission found. Run `runward init` first."));
16
16
  process.exit(2);
@@ -0,0 +1,337 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ const SKIP_DIRS = new Set([
5
+ ".git", "node_modules", "dist", "build", "out", "coverage", "vendor",
6
+ ".next", ".nuxt", ".venv", "venv", "__pycache__", "target", ".turbo", ".cache",
7
+ ]);
8
+ /** Bounded recursive walk (skips heavy dirs, caps depth) — read-only. */
9
+ function walk(dir, depth, onFile) {
10
+ if (depth < 0)
11
+ return;
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(dir);
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const name of entries) {
20
+ const p = join(dir, name);
21
+ let st;
22
+ try {
23
+ st = statSync(p);
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ if (st.isDirectory()) {
29
+ if (SKIP_DIRS.has(name))
30
+ continue;
31
+ walk(p, depth - 1, onFile);
32
+ }
33
+ else {
34
+ onFile(p, name);
35
+ }
36
+ }
37
+ }
38
+ function firstExisting(root, names) {
39
+ for (const n of names)
40
+ if (existsSync(join(root, n)))
41
+ return n;
42
+ return null;
43
+ }
44
+ function detectEcosystems(root) {
45
+ const out = [];
46
+ // Node / TypeScript — parsed in full.
47
+ if (existsSync(join(root, "package.json"))) {
48
+ let runtime = 0, dev = 0, names = [];
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
51
+ names = Object.keys(pkg.dependencies ?? {});
52
+ runtime = names.length;
53
+ dev = Object.keys(pkg.devDependencies ?? {}).length;
54
+ }
55
+ catch { /* malformed manifest: still recorded, deps unknown */ }
56
+ out.push({
57
+ name: "Node / JavaScript / TypeScript",
58
+ manifest: "package.json",
59
+ lockfile: firstExisting(root, ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"]),
60
+ runtimeDeps: runtime, devDeps: dev, depNames: names,
61
+ });
62
+ }
63
+ // Python.
64
+ const pyManifest = firstExisting(root, ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"]);
65
+ if (pyManifest) {
66
+ let runtime = 0;
67
+ if (pyManifest === "requirements.txt") {
68
+ try {
69
+ runtime = readFileSync(join(root, "requirements.txt"), "utf8").split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")).length;
70
+ }
71
+ catch { /* unreadable */ }
72
+ }
73
+ out.push({ name: "Python", manifest: pyManifest, lockfile: firstExisting(root, ["poetry.lock", "Pipfile.lock", "uv.lock"]), runtimeDeps: runtime, devDeps: 0, depNames: [] });
74
+ }
75
+ // Go.
76
+ if (existsSync(join(root, "go.mod"))) {
77
+ let runtime = 0;
78
+ try {
79
+ runtime = readFileSync(join(root, "go.mod"), "utf8").split("\n").filter((l) => /^\s+[\w.\/-]+\s+v\d/.test(l)).length;
80
+ }
81
+ catch { /* unreadable */ }
82
+ out.push({ name: "Go", manifest: "go.mod", lockfile: firstExisting(root, ["go.sum"]), runtimeDeps: runtime, devDeps: 0, depNames: [] });
83
+ }
84
+ // Rust, Java, Ruby, PHP — presence + lockfile (rough).
85
+ const simple = [
86
+ ["Rust", "Cargo.toml", ["Cargo.lock"]],
87
+ ["Java (Maven)", "pom.xml", []],
88
+ ["Java (Gradle)", "build.gradle", ["gradle.lockfile"]],
89
+ ["Ruby", "Gemfile", ["Gemfile.lock"]],
90
+ ["PHP", "composer.json", ["composer.lock"]],
91
+ ];
92
+ for (const [name, manifest, locks] of simple) {
93
+ if (existsSync(join(root, manifest))) {
94
+ out.push({ name, manifest, lockfile: firstExisting(root, locks), runtimeDeps: 0, devDeps: 0, depNames: [] });
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+ function detectEntrypoints(root) {
100
+ const found = new Set();
101
+ // Declared entrypoints in package.json.
102
+ try {
103
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
104
+ if (typeof pkg.main === "string")
105
+ found.add(pkg.main);
106
+ if (typeof pkg.bin === "string")
107
+ found.add(pkg.bin);
108
+ else if (pkg.bin && typeof pkg.bin === "object")
109
+ for (const v of Object.values(pkg.bin))
110
+ if (typeof v === "string")
111
+ found.add(v);
112
+ }
113
+ catch { /* no/invalid package.json */ }
114
+ // Common convention files.
115
+ for (const f of ["index.ts", "index.js", "src/index.ts", "src/index.js", "src/main.ts", "main.py", "app.py", "src/main.rs", "cmd", "main.go"]) {
116
+ if (existsSync(join(root, f)))
117
+ found.add(f);
118
+ }
119
+ return [...found];
120
+ }
121
+ function detectCI(root) {
122
+ const out = [];
123
+ const wf = join(root, ".github", "workflows");
124
+ if (existsSync(wf)) {
125
+ try {
126
+ for (const f of readdirSync(wf))
127
+ if (/\.ya?ml$/.test(f))
128
+ out.push(`.github/workflows/${f}`);
129
+ }
130
+ catch { /* unreadable */ }
131
+ }
132
+ for (const f of [".gitlab-ci.yml", "azure-pipelines.yml", ".circleci/config.yml", "Jenkinsfile", ".drone.yml"]) {
133
+ if (existsSync(join(root, f)))
134
+ out.push(f);
135
+ }
136
+ return out;
137
+ }
138
+ function detectContainers(root) {
139
+ const out = [];
140
+ for (const f of ["Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yaml", "vercel.json", "netlify.toml", "fly.toml", "Procfile", "Chart.yaml"]) {
141
+ if (existsSync(join(root, f)))
142
+ out.push(f);
143
+ }
144
+ return out;
145
+ }
146
+ function detectTests(root) {
147
+ const dirs = new Set();
148
+ let files = 0;
149
+ for (const d of ["test", "tests", "__tests__", "spec"])
150
+ if (existsSync(join(root, d)))
151
+ dirs.add(d);
152
+ walk(root, 5, (_p, name) => {
153
+ if (/\.(test|spec)\.[jt]sx?$/.test(name) || /_test\.go$/.test(name) || /^test_.*\.py$/.test(name) || /Test\.java$/.test(name))
154
+ files++;
155
+ });
156
+ return { dirs: [...dirs], files };
157
+ }
158
+ function gitShape(root) {
159
+ const git = (args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
160
+ try {
161
+ git(["rev-parse", "--is-inside-work-tree"]); // throws if not a repo / git absent
162
+ const commits = parseInt(git(["rev-list", "--count", "HEAD"]), 10) || 0;
163
+ const last = git(["log", "-1", "--format=%as"]);
164
+ const first = git(["log", "--max-parents=0", "-1", "--format=%as"]);
165
+ const authors = git(["shortlog", "-sne", "HEAD"]).split("\n").filter((l) => l.trim()).length;
166
+ return { commits, first, last, authors };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /** Build the read-only inventory of an existing codebase. */
173
+ export function buildInventory(root) {
174
+ let fileCount = 0;
175
+ walk(root, 6, () => { fileCount++; });
176
+ return {
177
+ root,
178
+ ecosystems: detectEcosystems(root),
179
+ entrypoints: detectEntrypoints(root),
180
+ ci: detectCI(root),
181
+ containers: detectContainers(root),
182
+ tests: detectTests(root),
183
+ git: gitShape(root),
184
+ fileCount,
185
+ };
186
+ }
187
+ /** Render the inventory as `characterization.md` — facts only, never decisions. */
188
+ export function renderCharacterization(inv, generatedAt) {
189
+ const L = [];
190
+ L.push(`# Characterization — ${basename(inv.root) || "system"}`);
191
+ L.push("");
192
+ L.push(`> \`confidence: high\` · generated by \`runward characterize\` on ${generatedAt} · **read-only, deterministic (no LLM).**`);
193
+ L.push("> This is a factual inventory of an existing system, not a set of decisions. Nothing here explains *why* the");
194
+ L.push("> system is the way it is — reconstructing that is the operator's job (see next steps). Re-run to refresh.");
195
+ L.push("");
196
+ L.push("## Languages & build");
197
+ L.push("");
198
+ if (inv.ecosystems.length === 0)
199
+ L.push("_No dependency manifest found at the root._");
200
+ else {
201
+ L.push("| Ecosystem | Manifest | Lockfile | Runtime deps | Dev deps |");
202
+ L.push("|---|---|---|---|---|");
203
+ for (const e of inv.ecosystems) {
204
+ L.push(`| ${e.name} | \`${e.manifest}\` | ${e.lockfile ? `\`${e.lockfile}\`` : "**none** (unpinned)"} | ${e.runtimeDeps || "—"} | ${e.devDeps || "—"} |`);
205
+ }
206
+ }
207
+ L.push("");
208
+ const withNames = inv.ecosystems.find((e) => e.depNames.length);
209
+ if (withNames) {
210
+ L.push(`## Runtime dependencies (${withNames.manifest})`);
211
+ L.push("");
212
+ L.push(withNames.depNames.map((n) => `\`${n}\``).join(" · "));
213
+ L.push("");
214
+ }
215
+ L.push("## Entrypoints");
216
+ L.push("");
217
+ L.push(inv.entrypoints.length ? inv.entrypoints.map((e) => `- \`${e}\``).join("\n") : "_None detected by convention._");
218
+ L.push("");
219
+ L.push("## CI / pipelines");
220
+ L.push("");
221
+ L.push(inv.ci.length ? inv.ci.map((f) => `- \`${f}\``).join("\n") : "_No CI configuration detected._");
222
+ L.push("");
223
+ L.push("## Containers / deployment");
224
+ L.push("");
225
+ L.push(inv.containers.length ? inv.containers.map((f) => `- \`${f}\``).join("\n") : "_No container/deploy descriptor detected._");
226
+ L.push("");
227
+ L.push("## Tests");
228
+ L.push("");
229
+ L.push(`- Test directories: ${inv.tests.dirs.length ? inv.tests.dirs.map((d) => `\`${d}/\``).join(", ") : "_none_"}`);
230
+ L.push(`- Test files (by naming convention): **${inv.tests.files}**`);
231
+ L.push("");
232
+ L.push("## Git history (shape only)");
233
+ L.push("");
234
+ if (inv.git) {
235
+ L.push(`- Commits: **${inv.git.commits}** · Span: ${inv.git.first} → ${inv.git.last} · Distinct authors: **${inv.git.authors}**`);
236
+ L.push("- Counts only — the log tells you *what* changed and *when*, rarely *why*. The *why* is a decision to reconstruct, not a fact to read.");
237
+ }
238
+ else {
239
+ L.push("_Not a git repository, or git unavailable — history shape could not be read._");
240
+ }
241
+ L.push("");
242
+ L.push(`_Scanned ~${inv.fileCount} files (heavy directories skipped)._`);
243
+ L.push("");
244
+ L.push("## Next steps (operator)");
245
+ L.push("");
246
+ L.push("1. This inventory is **facts**, not decisions. Nothing here has been ratified.");
247
+ L.push("2. Run the **brownfield** workflow (`runward/workflows/brownfield.md`) with your agent: reconstruct the architecture note and the retroactive ADRs — each starts as a *hypothesis* until you confirm its *why* and set its re-evaluation trigger.");
248
+ L.push("3. Then `runward check` (and `--strict` once you have manifests) — the gate stays red until each reconstructed decision is ratified by you, not your agent.");
249
+ L.push("");
250
+ return L.join("\n") + "\n";
251
+ }
252
+ const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
253
+ const DEP_FAMILIES = [
254
+ { match: /^(express|fastify|koa|@nestjs\/core|next|nuxt|react|vue|svelte|@angular\/core|django|flask|fastapi|rails|sinatra|laravel|symfony|spring-boot)/i, family: "web framework / UI" },
255
+ { match: /^(pg|mysql2?|mongodb|mongoose|@prisma\/client|prisma|typeorm|sequelize|redis|ioredis|sqlalchemy|gorm|diesel)/i, family: "data store / ORM" },
256
+ { match: /^(bullmq|bull|amqplib|kafkajs|celery|sidekiq|nats)/i, family: "queue / messaging" },
257
+ { match: /^(openai|@anthropic-ai\/sdk|anthropic|langchain|@langchain\/|llamaindex|@ai-sdk\/|cohere-ai)/i, family: "AI / model provider" },
258
+ { match: /^(@aws-sdk\/|aws-sdk|@google-cloud\/|@azure\/|firebase|stripe)/i, family: "external service SDK" },
259
+ ];
260
+ function firstSeen(root, file) {
261
+ try {
262
+ const out = execFileSync("git", ["-C", root, "log", "--diff-filter=A", "--reverse", "--format=%as", "--", file], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
263
+ return out.split("\n")[0] || null;
264
+ }
265
+ catch {
266
+ return null;
267
+ }
268
+ }
269
+ /**
270
+ * Propose *candidate* structural decisions from evidence at rest — deterministic, no model call.
271
+ * Covers the stack/language choice, containerization, the CI pipeline, and notable dependency
272
+ * families. Each is a hypothesis with provenance; the *why* is left for the operator (ADR-0013).
273
+ */
274
+ export function mineDrafts(root, inv) {
275
+ const out = [];
276
+ for (const eco of inv.ecosystems) {
277
+ const seen = firstSeen(root, eco.manifest);
278
+ out.push({
279
+ slug: `stack-${slugify(eco.name.split(/[ /]/)[0])}`,
280
+ title: `Adopt ${eco.name} as the core stack`,
281
+ evidence: [`\`${eco.manifest}\`${eco.lockfile ? ` + \`${eco.lockfile}\`` : " (no lockfile — unpinned)"}`, seen ? `first seen ${seen}` : "manifest at repo root"],
282
+ });
283
+ }
284
+ if (inv.containers.length) {
285
+ out.push({ slug: "deployment-target", title: `Deploy/package via ${inv.containers[0]}`, evidence: inv.containers.map((f) => `\`${f}\``) });
286
+ }
287
+ if (inv.ci.length) {
288
+ out.push({ slug: "ci-pipeline", title: "Gate delivery through a CI pipeline", evidence: inv.ci.map((f) => `\`${f}\``) });
289
+ }
290
+ let depCount = 0;
291
+ for (const eco of inv.ecosystems) {
292
+ for (const dep of eco.depNames) {
293
+ if (depCount >= 8)
294
+ break;
295
+ const fam = DEP_FAMILIES.find((f) => f.match.test(dep));
296
+ if (!fam)
297
+ continue;
298
+ depCount++;
299
+ out.push({ slug: `dep-${slugify(dep)}`, title: `Depend on ${dep} (${fam.family})`, evidence: [`\`${dep}\` in \`${eco.manifest}\``] });
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+ /** Render one candidate as a DRAFT-*.md — a hypothesis with provenance, `why: UNKNOWN`, no verdict. */
305
+ export function renderDraft(cand, generatedAt) {
306
+ const L = [];
307
+ L.push(`# DRAFT — ${cand.title}`);
308
+ L.push("");
309
+ L.push("**Status**: hypothesis");
310
+ L.push(`**Date**: ${generatedAt}`);
311
+ L.push("**Deciders**: (unratified — the operator must own this)");
312
+ L.push("");
313
+ L.push("> Reconstructed by `runward characterize --mine` from evidence at rest — a **candidate decision**,");
314
+ L.push("> not a fact. Ratify it: write the real *why* and the alternatives you rejected, set a re-evaluation");
315
+ L.push("> trigger, put your name in Deciders, set `Status: accepted`, and rename this file to");
316
+ L.push("> `ADR-NNNN-<slug>.md`. Or delete it if it was never a real decision. `runward check --strict` stays");
317
+ L.push("> red until you do.");
318
+ L.push("");
319
+ L.push("## Evidence");
320
+ L.push("");
321
+ for (const e of cand.evidence)
322
+ L.push(`- ${e}`);
323
+ L.push("");
324
+ L.push("## Decision (reconstructed — confirm or correct)");
325
+ L.push("");
326
+ L.push(`${cand.title}.`);
327
+ L.push("");
328
+ L.push("## Why");
329
+ L.push("");
330
+ L.push("why: UNKNOWN — the operator must supply the rationale; it is not recoverable from code or history.");
331
+ L.push("");
332
+ L.push("## Reevaluation trigger (mandatory)");
333
+ L.push("");
334
+ L.push("TODO — under what objective signal should this decision be reopened?");
335
+ L.push("");
336
+ return L.join("\n") + "\n";
337
+ }
@@ -73,6 +73,53 @@ function adrExists(missionDir, evidence) {
73
73
  const dir = join(missionDir, "adr");
74
74
  return existsSync(dir) && readdirSync(dir).some((f) => f.toUpperCase().startsWith(id));
75
75
  }
76
+ /**
77
+ * The reconstruction lifecycle (ADR-0013/0014). A retroactively reconstructed decision is a
78
+ * *hypothesis* until the operator ratifies it — writes the real *why*, sets a re-evaluation
79
+ * trigger, and marks it accepted. This returns the ADRs in runward/adr/ still unratified, by
80
+ * deterministic marker: a DRAFT- filename, a `Status: hypothesis`, or a `why: UNKNOWN` left in
81
+ * place. The gate fails while any remain — an agent's guess must not pass as a decision.
82
+ */
83
+ export function unratifiedAdrs(missionDir) {
84
+ const dir = join(missionDir, "adr");
85
+ if (!existsSync(dir))
86
+ return [];
87
+ const out = [];
88
+ for (const f of readdirSync(dir)) {
89
+ if (!f.endsWith(".md"))
90
+ continue;
91
+ if (/^DRAFT-/i.test(f)) {
92
+ out.push({ file: f, reason: "DRAFT — reconstructed decision not yet ratified" });
93
+ continue;
94
+ }
95
+ let body = "";
96
+ try {
97
+ body = readFileSync(join(dir, f), "utf8");
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ if (/^\s*(?:\*\*status\*\*|status)\s*:\s*hypothesis\b/im.test(body))
103
+ out.push({ file: f, reason: "Status: hypothesis" });
104
+ else if (/why\s*:\s*UNKNOWN\b/i.test(body))
105
+ out.push({ file: f, reason: "why: UNKNOWN — the operator must supply it" });
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * Decision-ratification coverage (ADR-0013): how many recorded decisions are ratified vs still
111
+ * hypotheses. Advisory — a deterministic ratio, never a claim of completeness. Excludes the
112
+ * scaffolded ADR-0000 template and any README.
113
+ */
114
+ export function decisionCoverage(missionDir) {
115
+ const dir = join(missionDir, "adr");
116
+ const unratified = unratifiedAdrs(missionDir);
117
+ let total = 0;
118
+ if (existsSync(dir)) {
119
+ total = readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "ADR-0000-template.md" && f.toUpperCase() !== "README.MD").length;
120
+ }
121
+ return { total, ratified: Math.max(0, total - unratified.length), unratified };
122
+ }
76
123
  // A path token: a file with a known code/doc extension (excludes version numbers like v1.0, "§2").
77
124
  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
125
  /** Advisory drift (ADR-0004): applied pointers whose file path no longer resolves. Existence only. */
package/dist/lib/paths.js CHANGED
@@ -15,6 +15,7 @@ export const MISSION_LAYOUT = {
15
15
  "reference-stack.md": "reference-stack.md",
16
16
  "shared-bricks.md": "shared-bricks.md",
17
17
  "floor.md": "floor.md",
18
+ "gap-analysis.md": "gap-analysis.md",
18
19
  "adr/ADR-0000-template.md": "adr/ADR-0000-template.md",
19
20
  "threat-model.md": "governance/threat-model.md",
20
21
  "evaluation-rubric.md": "governance/evaluation-rubric.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runward",
3
- "version": "0.9.1",
3
+ "version": "0.10.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": [
6
6
  "agentic",
@@ -0,0 +1,44 @@
1
+ # Gap analysis — [system or mission name]
2
+
3
+ > Brownfield / retro-documentation artifact (workflow `brownfield.md`, mode M3). A **section-by-section
4
+ > audit of an existing system**: what is there, what is missing, and — for each gap — the trigger that
5
+ > forces closing it and the reconstructed decision that records the choice. Start it from the facts in
6
+ > `characterization.md` (run `runward characterize`). Everything here is a **finding or a hypothesis**
7
+ > until you, the operator, ratify it — never a claim that the system is safe or complete.
8
+
9
+ ## How to fill this
10
+
11
+ For each dimension below: state what **exists** in the current system (fact, from characterization or
12
+ observation), what is **missing or unknown** (the gap), the **trigger** under which the gap must be
13
+ closed (a date, an audit, a next change), and the **ADR** that records the reconstructed decision (a
14
+ `DRAFT-*` hypothesis until you ratify it — write the real *why* + a re-evaluation trigger and set
15
+ `Status: accepted`). Leave `why: UNKNOWN` where you cannot yet source the rationale; `runward check
16
+ --strict` will keep the gate red until it is filled.
17
+
18
+ | Dimension | What exists (fact) | Gap / unknown | Trigger to close | Reconstructed ADR |
19
+ |---|---|---|---|---|
20
+ | **Boundaries & architecture** | | | | |
21
+ | **Ports & contracts** | | | | |
22
+ | **Model / non-determinism boundary** | | | | |
23
+ | **State & memory** | | | | |
24
+ | **Governance — threat model** | | | | |
25
+ | **Governance — evaluation** | | | | |
26
+ | **Observability & tracing** | | | | |
27
+ | **Security — untrusted input / trifecta** | | | | |
28
+ | **Resilience & cost controls** | | | | |
29
+ | **Tests / characterization** | | | | |
30
+ | **Handover / transmission** | | | | |
31
+
32
+ ## Findings summary
33
+
34
+ - **Critical gaps** (block production / audit): …
35
+ - **Deferred gaps** (named, each with a trigger): …
36
+ - **Reconstructed decisions to ratify**: list the `DRAFT-*` ADRs still `Status: hypothesis` — the
37
+ operator owns each *why* and trigger before it counts (see the ratification loop). The reconstruction
38
+ is the cheap part; owning it is the deliverable.
39
+
40
+ ## Re-entry point
41
+
42
+ After this audit, re-enter the six-phase chain at the right rung (`brownfield.md`): typically Architect
43
+ (reconstruct boundaries and ports) then Govern (retrofit the threat model, evaluation, observability),
44
+ proving each on real behavior before touching anything.