runward 0.9.0 → 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 +9 -2
- package/dist/cli.js +9 -1
- package/dist/commands/characterize.js +63 -0
- package/dist/commands/check.js +27 -3
- package/dist/commands/init.js +2 -2
- package/dist/commands/status.js +2 -2
- package/dist/commands/update.js +2 -2
- package/dist/lib/characterize.js +337 -0
- package/dist/lib/conformance.js +47 -0
- package/dist/lib/paths.js +1 -0
- package/package.json +1 -1
- package/templates/adapters/README.md +3 -1
- package/templates/mission/gap-analysis.md +44 -0
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/runward) [](LICENSE) [](https://github.com/stranxik/designing-and-running-agentic-systems) [](https://ko-fi.com/stranxik)
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**After the spec, the hard part starts. Runward ships it and runs it.**
|
|
8
|
+
|
|
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,13 +58,15 @@ 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
|
|
|
65
|
-
The gate's exit code is a **port**: `runward/adapters/` ships inert sample wiring
|
|
69
|
+
The gate's exit code is a **port**: `runward/adapters/` ships inert sample wiring so the gate runs at each harness's natural moment. The git `pre-commit` and CI adapters are **agent-agnostic** — they gate whatever agent produced the code (Codex, Claude, Cursor, Copilot, Gemini); the Claude Code `Stop`-hook is one example of a per-harness turn-end hook, not a privileged one. You copy an adapter in; runward never installs or runs one for you.
|
|
66
70
|
|
|
67
71
|
`init` creates:
|
|
68
72
|
|
|
@@ -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
|
+
}
|
package/dist/commands/check.js
CHANGED
|
@@ -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(
|
|
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);
|
package/dist/commands/init.js
CHANGED
|
@@ -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 =
|
|
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"));
|
package/dist/commands/status.js
CHANGED
|
@@ -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(
|
|
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);
|
package/dist/commands/update.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist/lib/conformance.js
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -39,10 +39,12 @@ A non-zero exit aborts the commit. Bypass a single commit with `git commit --no-
|
|
|
39
39
|
|
|
40
40
|
Copy the job into a workflow under `.github/workflows/` (or merge it into an existing one). Make it a required status check on your protected branch so no gap merges. This is the audit-evidence seam for a regulated pipeline: a dated, versioned record that the gate passed on every merge.
|
|
41
41
|
|
|
42
|
-
## `claude-code-settings.json` — run the gate
|
|
42
|
+
## `claude-code-settings.json` — run the gate at the agent's turn-end (one example)
|
|
43
43
|
|
|
44
44
|
Merge the `hooks` block into your `.claude/settings.json` (or `.claude/settings.local.json`). The `Stop` hook runs the gate when the agent finishes a turn and surfaces the verdict in the loop — so an agent can no longer close out with the gate never run.
|
|
45
45
|
|
|
46
|
+
This is **one example** of a per-harness turn-end hook, not a privileged one — Claude Code just happens to expose a clean, documented seam. Any agent harness that can run a command at turn-end (Codex, and others as they add the capability) wires the *same* one line: `runward check --strict`. And where a harness offers no such seam, the `pre-commit` and CI adapters above already gate the code **whatever agent produced it** — the port is the exit code, not the agent.
|
|
47
|
+
|
|
46
48
|
---
|
|
47
49
|
|
|
48
50
|
Adapters are runward-owned templates: `runward update` refreshes them, `runward doctor` verifies them. They are never mission state. Add a new harness by dropping a new sample here — the port contract above does not change.
|
|
@@ -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.
|