runward 0.9.1 → 0.11.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,12 @@ 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 |
66
+ | `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 |
62
67
 
63
68
  Global flags: `--yes`, `--dry-run`, `--verbose`, `--no-color`. Exit codes: 0 success, 1 gaps/warnings, 2 missing prerequisite.
64
69
 
@@ -107,6 +112,9 @@ Phase 5 is transverse: it starts at day zero, not after the incident.
107
112
 
108
113
  ## What makes it different
109
114
 
115
+ - **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.
116
+ - **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.
117
+ - **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
118
  - **Floor, not MVP deck.** The floor is the smallest *running* system that proves value on real traffic. A presentation is not a floor.
111
119
  - **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
120
  - **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,8 @@ 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";
15
+ import { complianceCommand } from "./commands/compliance.js";
14
16
  // Exit codes: 0 = success · 1 = gaps/warnings · 2 = missing prerequisite
15
17
  process.on("uncaughtException", (err) => {
16
18
  if (err.name === "ExitPromptError")
@@ -59,6 +61,7 @@ program
59
61
  .option("-p, --path <path>", "project directory")
60
62
  .option("--strict", "also verify the floor rule-conformance manifest (deterministic)")
61
63
  .option("--hooks", "run operator hooks from runward/hooks.json around the audit (opt-in)")
64
+ .option("--coverage", "advisory: report deliverable + decision-ratification coverage (does not gate)")
62
65
  .action(checkCommand);
63
66
  program
64
67
  .command("status")
@@ -75,4 +78,16 @@ program
75
78
  .option("-p, --path <path>", "project directory")
76
79
  .option("--force", "overwrite locally modified workflows")
77
80
  .action(updateCommand);
81
+ program
82
+ .command("characterize")
83
+ .description("read-only inventory of an existing codebase → runward/characterization.md (brownfield/retro-doc)")
84
+ .option("-p, --path <path>", "project directory (default: .)")
85
+ .option("--mine", "also propose candidate retroactive ADRs as DRAFT hypotheses (deterministic git archaeology, no model call)")
86
+ .action(characterizeCommand);
87
+ program
88
+ .command("compliance")
89
+ .description("assemble a regime-framed evidence pack from the mission (deterministic, read-only; a readiness draft, never a compliance claim)")
90
+ .argument("[regime]", "iso-42001 (nist-ai-rmf / eu-ai-act: coming)")
91
+ .option("-p, --path <path>", "project directory")
92
+ .action(complianceCommand);
78
93
  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);
@@ -0,0 +1,60 @@
1
+ import { basename, join, resolve } from "node:path";
2
+ import { findMissionRoot } from "../lib/mission.js";
3
+ import { gatherComplianceInputs, renderIso42001Readiness, renderNistAiRmf, renderEuAiAct, renderOscal } from "../lib/compliance.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
+ * Assemble a regime-framed compliance evidence pack (ADR-0016) — deterministic, read-only, zero-LLM,
9
+ * outside the gate. Reads the mission's real artifacts (rule→OWASP ASI, conformance manifests, ADR
10
+ * journal, governance docs) and writes an assessment-readiness *draft*, explicitly flagging what only
11
+ * the operator can supply. Never a compliance claim.
12
+ * Exit codes: 0 = draft written, 2 = no mission / unsupported regime.
13
+ */
14
+ const REGIMES = {
15
+ "iso-42001": { label: "ISO/IEC 42001", render: renderIso42001Readiness, file: "iso-42001-readiness.md" },
16
+ "nist-ai-rmf": { label: "NIST AI RMF", render: renderNistAiRmf, file: "nist-ai-rmf-readiness.md" },
17
+ "eu-ai-act": { label: "EU AI Act (Annex IV)", render: renderEuAiAct, file: "eu-ai-act-readiness.md" },
18
+ };
19
+ export async function complianceCommand(regime, opts) {
20
+ const key = (regime ?? "").toLowerCase();
21
+ console.log(createHeader(`Runward v${VERSION} — compliance`, key ? REGIMES[key]?.label ?? key : "regime required"));
22
+ if (!key || !(key in REGIMES)) {
23
+ console.error(status.error(`Usage: runward compliance <regime>. Supported: ${Object.keys(REGIMES).join(", ")}.`));
24
+ console.log(" " + c.darkGray("The manifest is universal (OWASP ASI); the regime is a lens (ADR-0015). Default posture is security-only — no regime named."));
25
+ process.exit(2);
26
+ }
27
+ const spec = REGIMES[key];
28
+ if (!spec.render) {
29
+ console.error(status.error(`The ${spec.label} lens is not assembled yet — only iso-42001 for now (ADR-0016, built piece by piece).`));
30
+ console.log(" " + c.darkGray(`See the framing reference: docs/compliance/${key}.md`));
31
+ process.exit(2);
32
+ }
33
+ const root = findMissionRoot(resolve(process.cwd(), opts.path ?? "."));
34
+ if (!root) {
35
+ console.error(status.error("No runward/ mission found here or above. Run `runward init` first."));
36
+ process.exit(2);
37
+ }
38
+ const mission = join(root, "runward");
39
+ const dryRun = process.env.RUNWARD_DRY_RUN === "1";
40
+ const generatedAt = isNonInteractive() && process.env.RUNWARD_NOW ? process.env.RUNWARD_NOW : new Date().toISOString().slice(0, 10);
41
+ console.log(section("Assembling (read-only, deterministic)"));
42
+ const inputs = gatherComplianceInputs(mission);
43
+ const md = spec.render(inputs, generatedAt);
44
+ const w = makeWriter({ force: true, dryRun, root }); // generated artifacts — always refresh
45
+ w.write(join(mission, "compliance", spec.file), md);
46
+ // OSCAL export — regime-neutral, machine-readable; the interop layer (ADR-0016) so the evidence flows into GRC/auditor tools.
47
+ w.write(join(mission, "compliance", "oscal-component-definition.json"), renderOscal(inputs, basename(root), generatedAt));
48
+ const mappedAsi = [...inputs.asiCoverage.values()].filter((v) => v.length).length;
49
+ console.log(section("Assembled"));
50
+ console.log(` ${c.primaryBold("ASI coverage")} ${c.white(`${mappedAsi}/10 categories mapped to a rule`)}`);
51
+ console.log(` ${c.primaryBold("Conformance")} ${c.white(`${inputs.conformance.length} accounted rule(s)`)}`);
52
+ console.log(` ${c.primaryBold("Decisions")} ${c.white(`${inputs.adrs.length} ratified ADR(s)`)}`);
53
+ console.log(` ${c.primaryBold("Governance")} ${c.white(`threat model ${inputs.threatModel ? "present" : "missing"}, eval rubric ${inputs.evalRubric ? "present" : "missing"}`)}`);
54
+ console.log(section("Next steps"));
55
+ console.log(" " + c.white("1.") + " Review " + c.primary(`runward/compliance/${spec.file}`) + c.darkGray(" — a draft, not a compliance claim."));
56
+ console.log(" " + c.white("2.") + " Fill the " + c.warning("\"Required from the operator\"") + " sections yourself (applicability, risk acceptance, policy, sign-off).");
57
+ console.log(" " + c.white("3.") + " Machine-readable " + c.primary("runward/compliance/oscal-component-definition.json") + c.darkGray(" (OSCAL) — feed it to your GRC/auditor tool."));
58
+ console.log(" " + c.white("4.") + " Hand it over as " + c.white("supporting evidence") + c.darkGray(" — never as a certification."));
59
+ console.log();
60
+ }
@@ -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
+ }
@@ -0,0 +1,380 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseManifest } from "./conformance.js";
5
+ import { TEMPLATES } from "./paths.js";
6
+ /**
7
+ * Deterministic, read-only assembly of a regime-framed compliance evidence pack (ADR-0016).
8
+ * It reads the mission's real artifacts at rest — the rule→OWASP ASI mapping, the rule-conformance
9
+ * manifests, the ADR journal, and the governance-doc presence — and assembles an assessment-readiness
10
+ * *draft*. No model call, no live-state scraping, nothing runs. It populates the technical-evidence
11
+ * layer and its index, and explicitly lists what only the operator/organization can supply. It is
12
+ * never a compliance claim.
13
+ */
14
+ /** OWASP Top 10 for Agentic Applications (ASI01–ASI10), the universal security grammar (ADR-0009). */
15
+ export const ASI_LABELS = {
16
+ ASI01: "Agent Goal Hijack",
17
+ ASI02: "Tool Misuse & Exploitation",
18
+ ASI03: "Identity & Privilege Abuse",
19
+ ASI04: "Agentic Supply Chain Vulnerabilities",
20
+ ASI05: "Unexpected Code Execution",
21
+ ASI06: "Memory & Context Poisoning",
22
+ ASI07: "Insecure Inter-Agent Communication",
23
+ ASI08: "Cascading Failures",
24
+ ASI09: "Human-Agent Trust Exploitation",
25
+ ASI10: "Rogue Agents",
26
+ };
27
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
28
+ function readRules(missionDir) {
29
+ // The mission's own rules if present (possibly customized), else the package rules — where the
30
+ // shipped OWASP ASI mapping lives (mirrors expectedRules in conformance.ts).
31
+ const missionRules = join(missionDir, "rules");
32
+ const dir = existsSync(missionRules) ? missionRules : join(TEMPLATES, "rules");
33
+ if (!existsSync(dir))
34
+ return [];
35
+ const out = [];
36
+ for (const f of readdirSync(dir)) {
37
+ if (!f.endsWith(".md"))
38
+ continue;
39
+ let fm = "";
40
+ try {
41
+ fm = readFileSync(join(dir, f), "utf8").match(FRONTMATTER)?.[1] ?? "";
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ const title = (fm.match(/^title:\s*(.+)$/m)?.[1] ?? f.replace(/\.md$/, "")).trim();
47
+ const impact = (fm.match(/^impact:\s*(.+)$/m)?.[1] ?? "").trim();
48
+ const asiRaw = fm.match(/^asi:\s*\[(.*)\]/m)?.[1] ?? "";
49
+ const asi = asiRaw.split(",").map((s) => s.trim().toUpperCase()).filter((s) => /^ASI\d{2}$/.test(s));
50
+ out.push({ slug: f.replace(/\.md$/, ""), title, impact, asi });
51
+ }
52
+ return out;
53
+ }
54
+ /** The three build-phase deliverables that carry a `## Rule conformance` manifest (as `check --strict`). */
55
+ const CONFORMANCE_DELIVERABLES = [
56
+ ["Architect", "architecture.md"],
57
+ ["Floor", "floor.md"],
58
+ ["Govern", "governance/threat-model.md"],
59
+ ];
60
+ function readConformance(missionDir) {
61
+ const out = [];
62
+ for (const [label, deliverable] of CONFORMANCE_DELIVERABLES) {
63
+ const p = join(missionDir, deliverable);
64
+ if (!existsSync(p))
65
+ continue;
66
+ let body = "";
67
+ try {
68
+ body = readFileSync(p, "utf8");
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ for (const row of parseManifest(body)) {
74
+ if (/^\[.*\]$/.test(row.rule))
75
+ continue; // skip template placeholder rows
76
+ out.push({ ...row, source: label });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function readAdrs(missionDir) {
82
+ const dir = join(missionDir, "adr");
83
+ if (!existsSync(dir))
84
+ return [];
85
+ const out = [];
86
+ for (const f of readdirSync(dir)) {
87
+ if (!f.endsWith(".md") || f === "ADR-0000-template.md" || f.toUpperCase() === "README.MD" || /^DRAFT-/i.test(f))
88
+ continue;
89
+ let body = "";
90
+ try {
91
+ body = readFileSync(join(dir, f), "utf8");
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ const title = (body.match(/^#\s+(.+)$/m)?.[1] ?? f.replace(/\.md$/, "")).trim();
97
+ const status = (body.match(/^\*\*status\*\*\s*:\s*(.+)$/im)?.[1] ?? "").trim();
98
+ out.push({ file: f, title, status });
99
+ }
100
+ return out;
101
+ }
102
+ export function gatherComplianceInputs(missionDir) {
103
+ const rules = readRules(missionDir);
104
+ const asiCoverage = new Map();
105
+ for (const id of Object.keys(ASI_LABELS))
106
+ asiCoverage.set(id, []);
107
+ for (const r of rules)
108
+ for (const id of r.asi)
109
+ if (asiCoverage.has(id))
110
+ asiCoverage.get(id).push(r.slug);
111
+ return {
112
+ rules,
113
+ asiCoverage,
114
+ conformance: readConformance(missionDir),
115
+ adrs: readAdrs(missionDir),
116
+ threatModel: existsSync(join(missionDir, "governance", "threat-model.md")),
117
+ evalRubric: existsSync(join(missionDir, "governance", "evaluation-rubric.md")),
118
+ };
119
+ }
120
+ /** Render the ISO/IEC 42001 assessment-readiness draft — the technical-evidence layer + the human-gap list. */
121
+ export function renderIso42001Readiness(inputs, generatedAt) {
122
+ const L = [];
123
+ const counts = { applied: 0, deviated: 0, "n/a": 0 };
124
+ for (const r of inputs.conformance)
125
+ if (counts[r.status] !== undefined)
126
+ counts[r.status]++;
127
+ L.push("# ISO/IEC 42001 — assessment-readiness draft");
128
+ L.push("");
129
+ L.push(`> **Draft, incomplete — not a compliance claim.** Assembled by \`runward compliance iso-42001\` on ${generatedAt},`);
130
+ L.push("> deterministically from ratified engineering artifacts (no model call, nothing scraped or run). It populates the");
131
+ L.push("> **technical-evidence layer and its index**; the applicability, risk-acceptance, policy and management sign-off it");
132
+ L.push("> cannot invent are listed under \"Required from the operator\". This is **supporting evidence**, never certification —");
133
+ L.push("> only an accredited body certifies an AI management system. Verify the current ISO/IEC 42001 text before an audit.");
134
+ L.push("");
135
+ L.push("## 1. Agentic-risk coverage (OWASP ASI → your rules)");
136
+ L.push("");
137
+ L.push("Feeds the ISO 42001 risk assessment (6.1.2) and control selection (6.1.3): which agentic-security risks are addressed by named engineering rules.");
138
+ L.push("");
139
+ L.push("| ASI | Risk | Rules addressing it |");
140
+ L.push("|---|---|---|");
141
+ for (const id of Object.keys(ASI_LABELS)) {
142
+ const slugs = inputs.asiCoverage.get(id) ?? [];
143
+ L.push(`| ${id} | ${ASI_LABELS[id]} | ${slugs.length ? slugs.map((s) => `\`${s}\``).join(", ") : "**no rule mapped — gap to assess**"} |`);
144
+ }
145
+ L.push("");
146
+ L.push("## 2. Control-implementation status (rule conformance)");
147
+ L.push("");
148
+ L.push(`Feeds the Statement of Applicability's implementation-status + evidence columns (6.1.3). From your mission's manifests: **${counts.applied} applied · ${counts.deviated} deviated · ${counts["n/a"]} n/a** across ${inputs.conformance.length} accounted rule(s).`);
149
+ L.push("");
150
+ if (inputs.conformance.length === 0) {
151
+ L.push("_No filled `Rule conformance` manifest found yet — fill the architect/floor/govern deliverables (see `runward check --strict`)._");
152
+ }
153
+ else {
154
+ L.push("| Rule | Status | Evidence | Phase |");
155
+ L.push("|---|---|---|---|");
156
+ for (const r of inputs.conformance)
157
+ L.push(`| \`${r.rule}\` | ${r.status} | ${r.evidence || "—"} | ${r.source} |`);
158
+ }
159
+ L.push("");
160
+ L.push("## 3. Design decisions (ADR journal)");
161
+ L.push("");
162
+ L.push("The \"key design choices, alternatives, and re-evaluation triggers\" an ISO 42001 auditor expects (records under Annex A control groups).");
163
+ L.push("");
164
+ if (inputs.adrs.length === 0) {
165
+ L.push("_No ratified ADR found in `runward/adr/`._");
166
+ }
167
+ else {
168
+ L.push("| ADR | Status |");
169
+ L.push("|---|---|");
170
+ for (const a of inputs.adrs)
171
+ L.push(`| ${a.title} (\`${a.file}\`) | ${a.status || "—"} |`);
172
+ }
173
+ L.push("");
174
+ L.push("## 4. Risk & impact inputs (presence)");
175
+ L.push("");
176
+ L.push(`- Threat model (feeds risk assessment 6.1.2): ${inputs.threatModel ? "**present** — confirm it is filled, not a raw template" : "**missing**"}`);
177
+ L.push(`- Evaluation rubric (feeds impact/validation analysis): ${inputs.evalRubric ? "**present** — confirm it is filled" : "**missing**"}`);
178
+ L.push("");
179
+ L.push("## Required from the operator / organization (runward cannot produce this)");
180
+ L.push("");
181
+ L.push("These sections are managerial, legal or organizational — no tool can assemble them from engineering artifacts:");
182
+ L.push("");
183
+ L.push("- **AI policy** (5.2) and **AIMS scope** (4.3).");
184
+ L.push("- **Statement of Applicability — the applicability decisions and inclusion/exclusion justifications** (6.1.3): runward supplies the status + evidence columns; the *applicability* judgment is yours.");
185
+ L.push("- **Risk methodology, acceptance criteria and risk-acceptance decisions** (6.1.2, 8.3).");
186
+ L.push("- **AI system impact assessment report and deployment authorization** (6.1.4).");
187
+ L.push("- **Objectives and targets** (6.2), **roles and competence** (A.3, 7.2).");
188
+ L.push("- **Internal audit** (9.2) and **management review** minutes (9.3).");
189
+ L.push("- **Runtime AI event logs** (A.6.2.8) — produced by the running system, not by runward.");
190
+ L.push("");
191
+ L.push("_Regime mapping is dated engineering framing, not legal advice; ISO Annex A control counts/templates are behind the paywalled standard — confirm against the purchased text._");
192
+ L.push("");
193
+ return L.join("\n") + "\n";
194
+ }
195
+ // ── Shared table blocks (used by every regime lens) ──
196
+ function asiTableLines(inputs) {
197
+ const L = ["| ASI | Risk | Rules addressing it |", "|---|---|---|"];
198
+ for (const id of Object.keys(ASI_LABELS)) {
199
+ const slugs = inputs.asiCoverage.get(id) ?? [];
200
+ L.push(`| ${id} | ${ASI_LABELS[id]} | ${slugs.length ? slugs.map((s) => `\`${s}\``).join(", ") : "**no rule mapped — gap to assess**"} |`);
201
+ }
202
+ return L;
203
+ }
204
+ function confCounts(inputs) {
205
+ const c = { applied: 0, deviated: 0, "n/a": 0 };
206
+ for (const r of inputs.conformance)
207
+ if (c[r.status] !== undefined)
208
+ c[r.status]++;
209
+ return c;
210
+ }
211
+ function confTableLines(inputs) {
212
+ if (inputs.conformance.length === 0)
213
+ return ["_No filled `Rule conformance` manifest found yet — fill the architect/floor/govern deliverables (`runward check --strict`)._"];
214
+ const L = ["| Rule | Status | Evidence | Phase |", "|---|---|---|---|"];
215
+ for (const r of inputs.conformance)
216
+ L.push(`| \`${r.rule}\` | ${r.status} | ${r.evidence || "—"} | ${r.source} |`);
217
+ return L;
218
+ }
219
+ function adrTableLines(inputs) {
220
+ if (inputs.adrs.length === 0)
221
+ return ["_No ratified ADR found in `runward/adr/`._"];
222
+ const L = ["| ADR | Status |", "|---|---|"];
223
+ for (const a of inputs.adrs)
224
+ L.push(`| ${a.title} (\`${a.file}\`) | ${a.status || "—"} |`);
225
+ return L;
226
+ }
227
+ /** Render the NIST AI RMF assessment-readiness draft — an ASI↔AI-RMF crosswalk, the MEASURE/TEVV
228
+ * documentation, and the design decisions; GOVERN and risk-tolerance stay the operator's. */
229
+ export function renderNistAiRmf(inputs, generatedAt) {
230
+ const c = confCounts(inputs);
231
+ const L = [];
232
+ L.push("# NIST AI RMF — assessment-readiness draft");
233
+ L.push("");
234
+ L.push(`> **Draft, incomplete — not a compliance claim.** Assembled by \`runward compliance nist-ai-rmf\` on ${generatedAt},`);
235
+ L.push("> deterministically from ratified engineering artifacts (no model call). The AI RMF is **voluntary guidance**");
236
+ L.push("> with no pass/fail and no certification; this populates the MEASURE/documentation evidence and an ASI crosswalk,");
237
+ L.push("> while GOVERN, risk tolerance and go/no-go stay the operator's. Verify the current AI RMF text before use.");
238
+ L.push("");
239
+ L.push("## 1. Agentic-risk crosswalk (OWASP ASI → AI RMF)");
240
+ L.push("");
241
+ L.push("An indicative engineering crosswalk (not NIST-endorsed): each agentic-security risk lands primarily under **MEASURE** (test & evaluate, esp. security & resilience) and **MANAGE** (risk treatment). Confirm subcategory selection against AI RMF §5.");
242
+ L.push("");
243
+ L.push(...asiTableLines(inputs));
244
+ L.push("");
245
+ L.push("## 2. MEASURE / TEVV documentation");
246
+ L.push("");
247
+ L.push(`Feeds MEASURE 2.x — documented, repeatable test methodology and results. From your mission: **${c.applied} applied · ${c.deviated} deviated · ${c["n/a"]} n/a** across ${inputs.conformance.length} rule(s).`);
248
+ L.push(`- Evaluation rubric (test sets, metrics, tooling): ${inputs.evalRubric ? "**present** — confirm it is filled" : "**missing**"}`);
249
+ L.push(`- Threat model (adversarial / risk-source analysis): ${inputs.threatModel ? "**present** — confirm it is filled" : "**missing**"}`);
250
+ L.push("");
251
+ L.push(...confTableLines(inputs));
252
+ L.push("");
253
+ L.push("## 3. Design decisions (ADR journal)");
254
+ L.push("");
255
+ L.push(...adrTableLines(inputs));
256
+ L.push("");
257
+ L.push("## Required from the operator / organization (runward cannot produce this)");
258
+ L.push("");
259
+ L.push("- **GOVERN** — policies, roles, accountability, **risk tolerance** (almost entirely organizational).");
260
+ L.push("- **MAP** — intended purpose, business/legal context, use-case risk enumeration.");
261
+ L.push("- **MANAGE** — the **go/no-go acceptance** decision, resourcing, response planning.");
262
+ L.push("- **Profiles** — Current/Target selection, prioritization, the risk-tolerance choices behind them.");
263
+ L.push("");
264
+ L.push("_Indicative engineering framing, not legal advice; NIST prescribes no report template — confirm against AI 100-1 and the Playbook._");
265
+ L.push("");
266
+ return L.join("\n") + "\n";
267
+ }
268
+ /** Render the EU AI Act Annex IV assessment-readiness draft — strong on Point 2 (design/architecture/
269
+ * validation) and the design-rationale/change history (the ADR journal); RMS, standards, declaration
270
+ * and post-market plan stay the provider's. */
271
+ export function renderEuAiAct(inputs, generatedAt) {
272
+ const L = [];
273
+ L.push("# EU AI Act — Annex IV technical documentation — assessment-readiness draft");
274
+ L.push("");
275
+ L.push(`> **Draft, incomplete — not a conformity assessment.** Assembled by \`runward compliance eu-ai-act\` on ${generatedAt},`);
276
+ L.push("> deterministically from ratified engineering artifacts (no model call). High-risk obligations bind from");
277
+ L.push("> **2 August 2026** (Annex III). This populates Annex IV Point 2 (design & validation) and the design-rationale");
278
+ L.push("> history; it does **not** satisfy art. 12 runtime logging, and it is not a signed declaration of conformity.");
279
+ L.push("> Verify against the Official Journal text before filing.");
280
+ L.push("");
281
+ L.push("## Annex IV coverage map");
282
+ L.push("");
283
+ L.push("| Annex IV point | runward supplies | Required from the provider |");
284
+ L.push("|---|---|---|");
285
+ L.push("| 1. General description | UI, HW/SW/firmware notes | intended purpose, provider, versioning, distribution |");
286
+ L.push("| 2. Elements & development | **architecture, validation procedures + metrics, cybersecurity (manifest + rubric + threat model); design choices, alternatives, assumptions, pre-determined changes = the ADR journal** | third-party sourcing/licensing, sign-off on test logs |");
287
+ L.push("| 3. Monitoring & control | accuracy characterization, input-data specs, oversight tooling | fundamental-rights / discrimination risk sourcing |");
288
+ L.push("| 4. Performance metrics | metric-choice justification (rubric) | — |");
289
+ L.push("| 5. Risk management (art. 9) | technical inputs (threat model, testing) | **risk acceptance / RMS governance** |");
290
+ L.push("| 6. Lifecycle changes | engineering change record (ADR journal) | release/change-management governance |");
291
+ L.push("| 7. Harmonised standards | technical notes | **standards selection** (compliance strategy) |");
292
+ L.push("| 8. Declaration of conformity | — | **signed legal act (art. 47)** |");
293
+ L.push("| 9. Post-market monitoring | telemetry/logging backbone | **post-market monitoring plan (art. 72)** |");
294
+ L.push("");
295
+ L.push("## Point 2 — design decisions (ADR journal, near-verbatim to the Annex IV requirement)");
296
+ L.push("");
297
+ L.push(...adrTableLines(inputs));
298
+ L.push("");
299
+ L.push("## Agentic-risk coverage (OWASP ASI → Point 2 cybersecurity / Point 5 risk)");
300
+ L.push("");
301
+ L.push(...asiTableLines(inputs));
302
+ L.push("");
303
+ L.push("## Control-implementation status (feeds Point 2 validation)");
304
+ L.push("");
305
+ L.push(...confTableLines(inputs));
306
+ L.push("");
307
+ L.push("## Required from the provider (runward cannot produce this)");
308
+ L.push("");
309
+ L.push("- **Point 1** general description (intended purpose, provider, versioning) · **Point 5** RMS governance & risk acceptance (art. 9).");
310
+ L.push("- **Point 7** harmonised-standards selection · **Point 8** the signed **EU declaration of conformity** (art. 47) · **Point 9** the **post-market monitoring plan** (art. 72).");
311
+ L.push("- **Art. 12 runtime event logs** — produced by the running system, not by runward.");
312
+ L.push("");
313
+ L.push("_Engineering framing, not legal advice; Annex IV wording moves — confirm against the Official Journal (Reg. (EU) 2024/1689) before filing._");
314
+ L.push("");
315
+ return L.join("\n") + "\n";
316
+ }
317
+ // ── OSCAL export (ADR-0016) — the machine-readable interop layer, so the evidence flows into GRC/auditor tools ──
318
+ /** A deterministic RFC-4122-shaped UUID derived from a stable seed (SHA-256), so two runs on the same
319
+ * artifacts produce byte-identical OSCAL — no random UUIDs to break the determinism invariant. */
320
+ function detUuid(seed) {
321
+ const b = createHash("sha256").update(`runward-oscal:${seed}`).digest("hex").slice(0, 32).split("");
322
+ b[12] = "5"; // version 5 (name-based)
323
+ b[16] = ((parseInt(b[16], 16) & 0x3) | 0x8).toString(16); // RFC-4122 variant
324
+ const s = b.join("");
325
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
326
+ }
327
+ const OSCAL_VERSION = "1.1.2";
328
+ const ASI_CATALOG = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/";
329
+ /**
330
+ * Render the OWASP ASI control coverage as an OSCAL component-definition (JSON) — regime-neutral, the
331
+ * universal machine-readable layer. Each ASI category is a control whose implementation-status is
332
+ * derived from the mission's conformance manifest; evidence links back to the readiness draft. It is
333
+ * labelled a DRAFT / supporting evidence in the metadata remarks — never a compliance claim.
334
+ */
335
+ export function renderOscal(inputs, missionName, generatedAt) {
336
+ const ns = missionName || "mission";
337
+ const irs = Object.keys(ASI_LABELS).map((id) => {
338
+ const slugs = inputs.asiCoverage.get(id) ?? [];
339
+ const statuses = slugs.map((s) => inputs.conformance.find((c) => c.rule === s)?.status).filter(Boolean);
340
+ let impl;
341
+ if (slugs.length === 0)
342
+ impl = "planned"; // no rule maps this risk — a gap
343
+ else if (statuses.length > 0 && statuses.every((s) => s === "applied"))
344
+ impl = "implemented";
345
+ else
346
+ impl = "partial"; // mapped, but deviated / n-a / not yet in a manifest
347
+ return {
348
+ uuid: detUuid(`${ns}:ir:${id}`),
349
+ "control-id": `asi-${id.slice(3)}`,
350
+ description: `${id} ${ASI_LABELS[id]}. ${slugs.length ? "Addressed by rules: " + slugs.join(", ") + "." : "No rule mapped — gap to assess."}`,
351
+ props: [{ name: "implementation-status", value: impl }],
352
+ links: [{ href: "./iso-42001-readiness.md", rel: "reference", text: "runward assessment-readiness draft" }],
353
+ };
354
+ });
355
+ const doc = {
356
+ "component-definition": {
357
+ uuid: detUuid(`${ns}:component-definition`),
358
+ metadata: {
359
+ title: `runward — agentic-security control evidence (${missionName})`,
360
+ "last-modified": `${generatedAt}T00:00:00Z`,
361
+ version: generatedAt,
362
+ "oscal-version": OSCAL_VERSION,
363
+ remarks: "Assessment-readiness DRAFT, assembled deterministically by runward from ratified engineering artifacts (rule to OWASP ASI mapping, conformance manifest, ADR journal). Supporting evidence only — NOT a compliance claim, NOT a certification, NOT a conformity assessment. Applicability, risk acceptance and management sign-off are the operator's.",
364
+ },
365
+ components: [{
366
+ uuid: detUuid(`${ns}:component`),
367
+ type: "software",
368
+ title: missionName,
369
+ description: "The agentic system governed by this runward mission.",
370
+ "control-implementations": [{
371
+ uuid: detUuid(`${ns}:control-implementation`),
372
+ source: ASI_CATALOG,
373
+ description: "OWASP Top 10 for Agentic Applications (ASI01–ASI10) coverage, derived from the mission's craft-rule mapping and conformance manifest.",
374
+ "implemented-requirements": irs,
375
+ }],
376
+ }],
377
+ },
378
+ };
379
+ return JSON.stringify(doc, null, 2) + "\n";
380
+ }
@@ -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.11.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.