scrumrun 2.4.0 → 2.5.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/CHANGELOG.md +27 -0
- package/CORE.md +2 -0
- package/README.md +1 -1
- package/SPEC.md +2 -1
- package/bin/scrumrun.js +31 -11
- package/docs/QUICKSTART.md +6 -0
- package/lib/commands/manifest.js +2 -1
- package/lib/commands/normalize-legacy.js +214 -0
- package/lib/commands/pretty-intake.js +272 -0
- package/lib/v2/conformance.js +6 -2
- package/package.json +1 -1
- package/templates/shared/skills/scrumrun/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes follow Semantic Versioning.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.5.0 - 2026-07-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `sc plan run --normalize-legacy [--dry-run]` collapses malformed Run ledgers (invalid `type`, unknown evidence `kind`, missing snapshot invariant, unparseable `task` reference, empty ledger) into a single valid `snapshot` event that preserves the fact of the Run without inventing transitions. Byte-exact originals are moved to `.scrumrun/.migration-backup/runs/RUN-NNN.md` (numbered suffix if a backup already exists). Coherent with ADR-021: ambiguous history becomes evidence, never invented state.
|
|
12
|
+
- Invariant **I-24**: canonical Runs must pass ledger validation. Hand-written Runs surface as a `high` `RUN_WRITE_BYPASS` finding in `doctor --strict`, with a pointer to the recovery command.
|
|
13
|
+
- New module `lib/commands/normalize-legacy` exposes `normalizeLegacyRuns`, `analyze`, `apply`, `renderReport`, `needsNormalization`, and `scanRuns` for library consumers.
|
|
14
|
+
- CORE and the shared SKILL now open with a hard instruction: **never write Run events by hand**. Only the CLI mutates `runs/*.md`. Direct writes produce invalid vocabulary and break conformance; if the CLI does not expose the shape you need, propose a spec change instead of inventing.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Manifest declares `--normalize-legacy` and `--dry-run` on `sc plan run`.
|
|
19
|
+
- SPEC.md conformance range extended to `I-01 through I-24`.
|
|
20
|
+
- Test suite grew from 184 to 192 passing.
|
|
21
|
+
|
|
22
|
+
## 2.4.1 - 2026-07-23
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `sc plan intake` now renders a **pretty terminal layout** in interactive TTYs: a boxed intake pipeline with streaming stages, aligned Classification / Why / Risk / Deferred-guardrails fields, and a highlighted approval-command box that mirrors the LP replay. Zero-dep — pure ANSI + Unicode. Falls back automatically to the existing Markdown summary when stdout is piped, `NO_COLOR` is set, or `--plain` is passed. `--json` returns the full structured plan for automation.
|
|
27
|
+
- New module `lib/commands/pretty-intake` exposes `canRenderPretty`, `renderIntake`, and `renderIntakePlain` for library consumers.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Manifest declares `--plain` and `--json` on `sc plan intake`.
|
|
32
|
+
- Test suite grew from 177 to 184 passing (added contract tests for TTY detection, `NO_COLOR` / `FORCE_COLOR` overrides, backwards-compatible plain output, and blocked-plan rendering).
|
|
33
|
+
|
|
7
34
|
## 2.4.0 - 2026-07-23
|
|
8
35
|
|
|
9
36
|
### Added
|
package/CORE.md
CHANGED
|
@@ -84,6 +84,8 @@ AGENTS.md
|
|
|
84
84
|
|
|
85
85
|
**Before querying project state, read `.scrumrun/method.json`.** Its `paths` block is the authoritative index of every canonical location in this project. Navigate by that index; if a path is not declared there, it is not canonical truth. Directory listing and grep are fallbacks — never the first step. A ScrumRun-aware agent must never search for `goals/`, `backlog.md`, `sprint.md`, or any legacy layout: those are absent by design once migration completes and are surfaced only through `.scrumrun/.migration-backup/`.
|
|
86
86
|
|
|
87
|
+
**Never write Run events by hand.** Runs are only mutated through the CLI: `sc plan run --validate | --learn | --complete | --resume | --fail | --block | --satisfy-guardrail | --authorize-mutation | --record-mutation`. Editing `runs/RUN-NNN.md` directly bypasses schema validation, produces invalid ledger events (invalid `type`, unknown evidence `kind`, missing snapshot, wrong `from`), and breaks conformance for the entire project. If the CLI does not expose the shape you need, propose a spec change through an ADR — do not invent event vocabulary. Existing hand-written Runs can be recovered with `sc plan run --normalize-legacy` (byte-exact original preserved in `.scrumrun/.migration-backup/runs/`).
|
|
88
|
+
|
|
87
89
|
Canonical truth is Markdown. SQLite/cache data stores only rebuildable indexes, symbol projections, relations, and bounded context packages. Deleting `.cache/` must never delete authored truth.
|
|
88
90
|
|
|
89
91
|
`state.md` and the semantic index use two-tier freshness checks. Matching path/stat watch fingerprints avoid rereading unchanged sources; any metadata drift falls back to complete content hashing. A cache schema mismatch rebuilds the disposable index once. Watch metadata is only an optimization and never authority.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
ScrumRun gives an agent a small command surface and a precise project memory: what should be done, how each attempt happened, which decisions constrain the code, and why the architecture exists in its current form.
|
|
6
6
|
|
|
7
|
-
**Package:** `2.
|
|
7
|
+
**Package:** `2.5.0` · **Method target:** `2.0.0` · **Runtime:** Node.js `>=22.13.0` · **License:** MIT
|
|
8
8
|
|
|
9
9
|
**New here?** Read the [Quickstart](docs/QUICKSTART.md) — first Run in under 10 minutes, no `SPEC.md` reading required. Full docs map in [`docs/INDEX.md`](docs/INDEX.md).
|
|
10
10
|
|
package/SPEC.md
CHANGED
|
@@ -310,6 +310,7 @@ Legacy sprint entries become Tasks. History entries become Runs only with an evi
|
|
|
310
310
|
- **I-21** Material mutations are policy-bound, path-scoped, hash-verified, append-only, and fail closed on bypass; unresolved Guardrail obligations block completion.
|
|
311
311
|
- **I-22** The semantic index's declared search backend must match the runtime capabilities of the current Node.js SQLite build; conformance flags a mismatch instead of relying on lazy runtime fallback.
|
|
312
312
|
- **I-23** `method.json` declares the canonical path index for every artifact family so agents navigate by declaration, not by search. Missing or drifted paths fail conformance; grep and directory scans are fallbacks, never the first step.
|
|
313
|
+
- **I-24** Runs are only mutated through the CLI. A canonical Run whose ledger fails validation is a bypass — conformance reports `RUN_WRITE_BYPASS` with a pointer to the recovery command `sc plan run --normalize-legacy`, which collapses the bad ledger into a single `snapshot` event and preserves the byte-exact original under `.scrumrun/.migration-backup/runs/`.
|
|
313
314
|
|
|
314
315
|
## 11. Command grammar
|
|
315
316
|
|
|
@@ -327,7 +328,7 @@ Unknown syntax fails deterministically and never guesses a mutation.
|
|
|
327
328
|
|
|
328
329
|
An implementation may claim ScrumRun method 2.0.0 only when it:
|
|
329
330
|
|
|
330
|
-
1. passes positive and negative tests for I-01 through I-
|
|
331
|
+
1. passes positive and negative tests for I-01 through I-24;
|
|
331
332
|
2. enforces every exposed state machine and schema;
|
|
332
333
|
3. proves read-only intake and dry-run migration through full-tree fingerprints;
|
|
333
334
|
4. proves migration failure recovery, rollback safety, and vault exclusion;
|
package/bin/scrumrun.js
CHANGED
|
@@ -1351,19 +1351,18 @@ function executeRootRoute(route) {
|
|
|
1351
1351
|
}
|
|
1352
1352
|
const request = routeArgs[0] === "--request" ? routeArgs.slice(1).join(" ") : routeArgs.join(" ");
|
|
1353
1353
|
const plan = planRequest(process.cwd(), request);
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
console.log(`Risk: ${plan.risk.level} — ${plan.risk.reasons.join("; ")}`);
|
|
1358
|
-
console.log(`Policy: ${plan.policy.status} (${plan.policy.checked.length} checked; ${plan.policy.deferred.length} deferred)`);
|
|
1359
|
-
for (const violation of plan.policy.violations) console.log(`BLOCKED: ${violation}`);
|
|
1360
|
-
for (const result of plan.policy.evaluations.filter((item) => item.status === "deferred")) {
|
|
1361
|
-
console.log(`DEFERRED: ${result.guardrail} ${result.code}: ${result.message}`);
|
|
1354
|
+
if (routeArgs.includes("--json")) {
|
|
1355
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
1356
|
+
return;
|
|
1362
1357
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1358
|
+
const { canRenderPretty, renderIntake, renderIntakePlain } = require(path.join(root, "lib", "commands", "pretty-intake"));
|
|
1359
|
+
const forcePlain = routeArgs.includes("--plain");
|
|
1360
|
+
if (!forcePlain && canRenderPretty()) {
|
|
1361
|
+
console.log(renderIntake(plan));
|
|
1362
|
+
} else {
|
|
1363
|
+
console.log(renderIntakePlain(plan));
|
|
1366
1364
|
}
|
|
1365
|
+
for (const warning of plan.context.warnings) console.log(`WARNING: ${warning}`);
|
|
1367
1366
|
return;
|
|
1368
1367
|
}
|
|
1369
1368
|
if (noun === "plan" && subject === "task" && routeArgs[0] === "--retry") {
|
|
@@ -1387,6 +1386,27 @@ function executeRootRoute(route) {
|
|
|
1387
1386
|
}
|
|
1388
1387
|
return;
|
|
1389
1388
|
}
|
|
1389
|
+
if (noun === "plan" && subject === "run" && routeArgs[0] === "--normalize-legacy") {
|
|
1390
|
+
const { normalizeLegacyRuns, renderReport } = require(path.join(root, "lib", "commands", "normalize-legacy"));
|
|
1391
|
+
const dryRun = routeArgs.includes("--dry-run");
|
|
1392
|
+
const scrumDir = path.join(process.cwd(), ".scrumrun");
|
|
1393
|
+
try {
|
|
1394
|
+
const result = normalizeLegacyRuns(scrumDir, { dryRun });
|
|
1395
|
+
console.log(renderReport(result.plan, result.applied));
|
|
1396
|
+
if (!dryRun && result.applied && result.applied.length) {
|
|
1397
|
+
refreshState(scrumDir);
|
|
1398
|
+
console.log(`\nNormalized ${result.applied.length} Run(s). Byte-exact originals live under .scrumrun/.migration-backup/runs/.`);
|
|
1399
|
+
} else if (dryRun && result.plan.malformed) {
|
|
1400
|
+
console.log(`\nDry-run only. Re-run without --dry-run to apply the plan.`);
|
|
1401
|
+
} else if (!result.plan.malformed) {
|
|
1402
|
+
console.log(`\nEvery Run already conforms to the current ledger schema. Nothing to normalize.`);
|
|
1403
|
+
}
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
console.error(error.message);
|
|
1406
|
+
process.exitCode = 1;
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1390
1410
|
if (noun === "plan" && subject === "run" && routeArgs[0] === "--stats") {
|
|
1391
1411
|
const { computeStats, renderStats } = require(path.join(root, "lib", "commands", "run-stats"));
|
|
1392
1412
|
const filters = {
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -58,6 +58,12 @@ proposals. Approving is one command:
|
|
|
58
58
|
npx scrumrun@latest sc plan intake --approve <token>
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
In an interactive terminal, the output is rendered as a boxed intake
|
|
62
|
+
pipeline with the exact structure shown on the landing page. Pipe the
|
|
63
|
+
command (`| less`, `> intake.txt`) or set `NO_COLOR=1` to get the plain
|
|
64
|
+
Markdown summary instead; add `--json` for a fully structured payload
|
|
65
|
+
you can feed to CI or a downstream tool.
|
|
66
|
+
|
|
61
67
|
Only then does a Task and a Run get created.
|
|
62
68
|
|
|
63
69
|
## Watching the Run
|
package/lib/commands/manifest.js
CHANGED
|
@@ -14,6 +14,7 @@ const nouns = Object.freeze({
|
|
|
14
14
|
"--show",
|
|
15
15
|
"--render <RUN-NNN>",
|
|
16
16
|
"--stats [--task <TASK-NNN>] [--feature <FEAT-NNN>] [--sprint <SPRINT-NNN>] [--json]",
|
|
17
|
+
"--normalize-legacy [--dry-run]",
|
|
17
18
|
"--authorize-mutation <RUN-NNN> --path <relative-path>",
|
|
18
19
|
"--record-mutation <RUN-NNN> --permit <MUT-id> [--note] [--actor]",
|
|
19
20
|
"--satisfy-guardrail <RUN-NNN> --guardrail <GR-NNN> [--note] [--evidence] [--review] [--migration] [--actor]",
|
|
@@ -24,7 +25,7 @@ const nouns = Object.freeze({
|
|
|
24
25
|
"--fail [--note] [--evidence] [--risk] [--test] [--actor] [--at]",
|
|
25
26
|
"--block [--note] [--evidence] [--risk] [--actor] [--at]"
|
|
26
27
|
],
|
|
27
|
-
intake: ["<request>", "--request", "--approve"],
|
|
28
|
+
intake: ["<request>", "--request", "--approve", "--plain", "--json"],
|
|
28
29
|
challenge: ["<question>"]
|
|
29
30
|
}
|
|
30
31
|
},
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// `sc plan run --normalize-legacy [--dry-run]`
|
|
4
|
+
//
|
|
5
|
+
// Reads every Run under `.scrumrun/runs/` and validates its ledger
|
|
6
|
+
// against the current schema. When a Run fails validation (invalid
|
|
7
|
+
// event type, unknown evidence kind, missing snapshot, bad transition,
|
|
8
|
+
// empty ledger, etc.), the whole ledger is collapsed into a single
|
|
9
|
+
// snapshot event that preserves the fact of the Run without inventing
|
|
10
|
+
// transitions. The original body is preserved byte-exact under
|
|
11
|
+
// `.scrumrun/.migration-backup/runs/RUN-NNN.md` (append `.NN` when a
|
|
12
|
+
// backup already exists so history is never overwritten).
|
|
13
|
+
//
|
|
14
|
+
// This is coherent with ADR-021: ambiguous history becomes evidence,
|
|
15
|
+
// never invented transitions.
|
|
16
|
+
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
|
|
21
|
+
const { parseRunLedger, validateRunLedger, renderRunEvent } = require("../runtime/run-ledger");
|
|
22
|
+
const { RUN_LEDGER_VERSION } = require("../v2/schema");
|
|
23
|
+
|
|
24
|
+
const RUN_FILENAME_PATTERN = /^RUN-\d{3,}\.md$/;
|
|
25
|
+
const TASK_REF_PATTERN = /^TASK-\d{3,}$/;
|
|
26
|
+
|
|
27
|
+
function readFrontmatterAndBody(text) {
|
|
28
|
+
const match = String(text || "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
29
|
+
if (!match) return { frontmatter: {}, header: "", body: String(text || "") };
|
|
30
|
+
const header = match[1];
|
|
31
|
+
const body = match[2] || "";
|
|
32
|
+
const frontmatter = {};
|
|
33
|
+
for (const line of header.split(/\r?\n/)) {
|
|
34
|
+
const kv = line.match(/^([a-z_][a-z0-9_]*):\s*(.*)$/i);
|
|
35
|
+
if (!kv) continue;
|
|
36
|
+
let value = kv[2].trim();
|
|
37
|
+
if (value === "null" || value === "") value = null;
|
|
38
|
+
else if (/^-?\d+$/.test(value)) value = Number(value);
|
|
39
|
+
frontmatter[kv[1]] = value;
|
|
40
|
+
}
|
|
41
|
+
return { frontmatter, header, body };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractTitle(body, fallback) {
|
|
45
|
+
const match = String(body || "").match(/^# ([^\r\n]+)$/m);
|
|
46
|
+
return match ? match[1] : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serializeFrontmatter(frontmatter) {
|
|
50
|
+
const lines = Object.entries(frontmatter).map(([key, value]) => {
|
|
51
|
+
if (value === null || value === undefined) return `${key}: null`;
|
|
52
|
+
return `${key}: ${value}`;
|
|
53
|
+
});
|
|
54
|
+
return `---\n${lines.join("\n")}\n---`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ensureDir(dir) {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reserveBackupPath(backupDir, runId) {
|
|
62
|
+
ensureDir(backupDir);
|
|
63
|
+
const base = path.join(backupDir, `${runId}.md`);
|
|
64
|
+
if (!fs.existsSync(base)) return base;
|
|
65
|
+
for (let n = 1; n <= 99; n++) {
|
|
66
|
+
const candidate = path.join(backupDir, `${runId}.md.${String(n).padStart(2, "0")}`);
|
|
67
|
+
if (!fs.existsSync(candidate)) return candidate;
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Cannot reserve backup slot for ${runId}: 100 backups already exist.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function needsNormalization(record, body) {
|
|
73
|
+
if (!record) return { needs: true, reason: "frontmatter is malformed or missing" };
|
|
74
|
+
if (typeof record.task === "string" && record.task && !TASK_REF_PATTERN.test(record.task)) {
|
|
75
|
+
return { needs: true, reason: `task field does not match TASK-NNN (was ${JSON.stringify(record.task)})` };
|
|
76
|
+
}
|
|
77
|
+
const result = validateRunLedger(record, body);
|
|
78
|
+
const errors = result.errors || [];
|
|
79
|
+
if (errors.length) return { needs: true, reason: `ledger validation failed (${errors.length} error${errors.length === 1 ? "" : "s"})`, errors };
|
|
80
|
+
return { needs: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildNormalizedBody({ record, originalBody, backupRelative, reason, errors }) {
|
|
84
|
+
const title = extractTitle(originalBody, `Run ${record.id}`);
|
|
85
|
+
const sha = crypto.createHash("sha256").update(originalBody).digest("hex");
|
|
86
|
+
const normalizedRecord = {
|
|
87
|
+
id: record.id,
|
|
88
|
+
kind: "run",
|
|
89
|
+
status: record.status || "partial",
|
|
90
|
+
created: record.created,
|
|
91
|
+
updated: new Date().toISOString().slice(0, 10),
|
|
92
|
+
method: "2.0.0",
|
|
93
|
+
task: TASK_REF_PATTERN.test(record.task || "") ? record.task : null,
|
|
94
|
+
sprint: record.sprint || null,
|
|
95
|
+
attempt: record.attempt || 1,
|
|
96
|
+
ledger: RUN_LEDGER_VERSION
|
|
97
|
+
};
|
|
98
|
+
const evidence = [
|
|
99
|
+
{ kind: "legacy", ref: backupRelative, summary: `byte-exact original preserved (sha256:${sha.slice(0, 12)}…)` },
|
|
100
|
+
{ kind: "note", summary: reason }
|
|
101
|
+
];
|
|
102
|
+
if (errors && errors.length) {
|
|
103
|
+
const sampled = errors.slice(0, 5).map((error) => `- ${error}`).join("; ");
|
|
104
|
+
evidence.push({ kind: "note", summary: `Pre-normalization validation errors: ${sampled}${errors.length > 5 ? `; +${errors.length - 5} more` : ""}` });
|
|
105
|
+
}
|
|
106
|
+
const event = {
|
|
107
|
+
schema: RUN_LEDGER_VERSION,
|
|
108
|
+
id: `${record.id}-EVT-001`,
|
|
109
|
+
sequence: 1,
|
|
110
|
+
type: "snapshot",
|
|
111
|
+
occurred_at: `${normalizedRecord.created || normalizedRecord.updated}T00:00:00.000Z`,
|
|
112
|
+
timestamp_precision: "date",
|
|
113
|
+
actor: "migration",
|
|
114
|
+
from: null,
|
|
115
|
+
to: normalizedRecord.status,
|
|
116
|
+
reason: `Legacy Run ledger normalized to preserve history without inventing canonical transitions. See ${backupRelative}.`,
|
|
117
|
+
evidence
|
|
118
|
+
};
|
|
119
|
+
const front = serializeFrontmatter(normalizedRecord);
|
|
120
|
+
const bodyText = `# ${title}\n\n## Events\n\n${renderRunEvent(event)}\n`;
|
|
121
|
+
return `${front}\n\n${bodyText}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scanRuns(scrumDir) {
|
|
125
|
+
const runsDir = path.join(scrumDir, "runs");
|
|
126
|
+
if (!fs.existsSync(runsDir) || !fs.lstatSync(runsDir).isDirectory()) return [];
|
|
127
|
+
return fs
|
|
128
|
+
.readdirSync(runsDir)
|
|
129
|
+
.filter((name) => RUN_FILENAME_PATTERN.test(name))
|
|
130
|
+
.sort()
|
|
131
|
+
.map((name) => path.join(runsDir, name));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function analyze(scrumDir) {
|
|
135
|
+
const plan = { runs: [], safe: 0, malformed: 0 };
|
|
136
|
+
for (const file of scanRuns(scrumDir)) {
|
|
137
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
138
|
+
const { frontmatter } = readFrontmatterAndBody(raw);
|
|
139
|
+
const check = needsNormalization(frontmatter, raw);
|
|
140
|
+
plan.runs.push({ id: frontmatter.id || path.basename(file, ".md"), file, needs: check.needs, reason: check.reason || "ledger validates", errors: check.errors || [] });
|
|
141
|
+
if (check.needs) plan.malformed += 1;
|
|
142
|
+
else plan.safe += 1;
|
|
143
|
+
}
|
|
144
|
+
return plan;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function apply(scrumDir, plan) {
|
|
148
|
+
const backupDir = path.join(scrumDir, ".migration-backup", "runs");
|
|
149
|
+
const applied = [];
|
|
150
|
+
for (const entry of plan.runs) {
|
|
151
|
+
if (!entry.needs) continue;
|
|
152
|
+
const raw = fs.readFileSync(entry.file, "utf8");
|
|
153
|
+
const { frontmatter } = readFrontmatterAndBody(raw);
|
|
154
|
+
const backupPath = reserveBackupPath(backupDir, entry.id);
|
|
155
|
+
fs.writeFileSync(backupPath, raw);
|
|
156
|
+
const backupRelative = path.relative(scrumDir, backupPath);
|
|
157
|
+
const normalized = buildNormalizedBody({
|
|
158
|
+
record: frontmatter,
|
|
159
|
+
originalBody: raw,
|
|
160
|
+
backupRelative,
|
|
161
|
+
reason: entry.reason,
|
|
162
|
+
errors: entry.errors
|
|
163
|
+
});
|
|
164
|
+
fs.writeFileSync(entry.file, normalized);
|
|
165
|
+
applied.push({ id: entry.id, backup: backupRelative, reason: entry.reason });
|
|
166
|
+
}
|
|
167
|
+
return applied;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderReport(plan, applied) {
|
|
171
|
+
const lines = [];
|
|
172
|
+
lines.push("## Run ledger normalization plan");
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(`Runs scanned: ${plan.runs.length}`);
|
|
175
|
+
lines.push(`Already valid: ${plan.safe}`);
|
|
176
|
+
lines.push(`Needs normalize: ${plan.malformed}`);
|
|
177
|
+
if (plan.malformed) {
|
|
178
|
+
lines.push("");
|
|
179
|
+
lines.push("Malformed Runs:");
|
|
180
|
+
for (const entry of plan.runs) {
|
|
181
|
+
if (!entry.needs) continue;
|
|
182
|
+
lines.push(` - ${entry.id}: ${entry.reason}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (applied) {
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push("Applied:");
|
|
188
|
+
if (!applied.length) lines.push(" (no changes)");
|
|
189
|
+
for (const entry of applied) {
|
|
190
|
+
lines.push(` - ${entry.id} → normalized (backup ${entry.backup})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeLegacyRuns(scrumDir, { dryRun = false } = {}) {
|
|
197
|
+
if (!fs.existsSync(scrumDir) || !fs.lstatSync(scrumDir).isDirectory()) {
|
|
198
|
+
throw new Error(".scrumrun/ not found; run this inside a ScrumRun project.");
|
|
199
|
+
}
|
|
200
|
+
const plan = analyze(scrumDir);
|
|
201
|
+
if (dryRun) return { plan, applied: null };
|
|
202
|
+
const applied = apply(scrumDir, plan);
|
|
203
|
+
return { plan, applied };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
normalizeLegacyRuns,
|
|
208
|
+
analyze,
|
|
209
|
+
apply,
|
|
210
|
+
renderReport,
|
|
211
|
+
buildNormalizedBody,
|
|
212
|
+
needsNormalization,
|
|
213
|
+
scanRuns
|
|
214
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Pretty terminal renderer for `sc plan intake` and related outputs.
|
|
4
|
+
// Zero-dep: pure ANSI escapes plus Unicode box drawing. Automatically
|
|
5
|
+
// falls back to a plain, machine-friendly format when the environment
|
|
6
|
+
// is not an interactive TTY (piped, redirected, NO_COLOR, --json, ...).
|
|
7
|
+
//
|
|
8
|
+
// Contract:
|
|
9
|
+
// canRenderPretty(stream?) -> boolean
|
|
10
|
+
// renderIntake(plan) -> string (pretty; assumes canRenderPretty)
|
|
11
|
+
// renderIntakePlain(plan) -> string (existing Markdown-style summary)
|
|
12
|
+
|
|
13
|
+
const RESET = "\x1b[0m";
|
|
14
|
+
|
|
15
|
+
const STYLES = {
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
dim: "\x1b[2m",
|
|
18
|
+
underline: "\x1b[4m"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const FG = {
|
|
22
|
+
black: "\x1b[30m",
|
|
23
|
+
red: "\x1b[31m",
|
|
24
|
+
green: "\x1b[32m",
|
|
25
|
+
yellow: "\x1b[33m",
|
|
26
|
+
blue: "\x1b[34m",
|
|
27
|
+
magenta: "\x1b[35m",
|
|
28
|
+
cyan: "\x1b[36m",
|
|
29
|
+
white: "\x1b[37m",
|
|
30
|
+
gray: "\x1b[90m",
|
|
31
|
+
brightGreen: "\x1b[92m",
|
|
32
|
+
brightYellow: "\x1b[93m",
|
|
33
|
+
brightRed: "\x1b[91m",
|
|
34
|
+
brightCyan: "\x1b[96m"
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Signature acid lime (#c9ff5c) via 24-bit truecolor when available.
|
|
38
|
+
const ACID_FG = "\x1b[38;2;201;255;92m";
|
|
39
|
+
const DIM_ACID_FG = "\x1b[38;2;140;180;60m";
|
|
40
|
+
|
|
41
|
+
function canRenderPretty(stream = process.stdout) {
|
|
42
|
+
if (process.env.NO_COLOR) return false;
|
|
43
|
+
if (process.env.FORCE_COLOR === "0") return false;
|
|
44
|
+
if (process.env.FORCE_COLOR && Number(process.env.FORCE_COLOR) > 0) return true;
|
|
45
|
+
return Boolean(stream && stream.isTTY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function paint(color, text) {
|
|
49
|
+
return `${color}${text}${RESET}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stripAnsi(text) {
|
|
53
|
+
return String(text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function visibleLength(text) {
|
|
57
|
+
return stripAnsi(text).length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function padEnd(text, width) {
|
|
61
|
+
const visible = visibleLength(text);
|
|
62
|
+
if (visible >= width) return text;
|
|
63
|
+
return text + " ".repeat(width - visible);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function terminalWidth(min = 60, max = 90) {
|
|
67
|
+
const columns = (process.stdout && process.stdout.columns) || 80;
|
|
68
|
+
return Math.max(min, Math.min(max, columns - 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wrap(text, width) {
|
|
72
|
+
if (!text) return [""];
|
|
73
|
+
const words = String(text).split(/\s+/);
|
|
74
|
+
const lines = [];
|
|
75
|
+
let current = "";
|
|
76
|
+
for (const word of words) {
|
|
77
|
+
if (!current) {
|
|
78
|
+
current = word;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if ((current + " " + word).length <= width) {
|
|
82
|
+
current += " " + word;
|
|
83
|
+
} else {
|
|
84
|
+
lines.push(current);
|
|
85
|
+
current = word;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (current) lines.push(current);
|
|
89
|
+
return lines.length ? lines : [""];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function boxTop(width, title) {
|
|
93
|
+
const label = ` ${title} `;
|
|
94
|
+
const remaining = width - visibleLength(label) - 4;
|
|
95
|
+
const left = "╭─";
|
|
96
|
+
const right = "─".repeat(Math.max(0, remaining)) + "─╮";
|
|
97
|
+
return paint(FG.gray, `${left}${paint(ACID_FG, label)}${paint(FG.gray, right)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function boxBottom(width) {
|
|
101
|
+
return paint(FG.gray, `╰${"─".repeat(width - 2)}╯`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function boxBlank(width) {
|
|
105
|
+
return paint(FG.gray, `│`) + " ".repeat(width - 2) + paint(FG.gray, `│`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function boxLine(width, content) {
|
|
109
|
+
const paddedInterior = padEnd(content, width - 4);
|
|
110
|
+
return paint(FG.gray, `│ `) + paddedInterior + paint(FG.gray, ` │`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const RISK_TONE = {
|
|
114
|
+
low: FG.brightGreen,
|
|
115
|
+
medium: FG.brightYellow,
|
|
116
|
+
high: FG.brightRed
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const CLASSIFICATION_ALIAS = {
|
|
120
|
+
task: "Task",
|
|
121
|
+
fix: "Task (fix)",
|
|
122
|
+
sprint: "Sprint",
|
|
123
|
+
feature: "Feature",
|
|
124
|
+
backlog: "Backlog Task",
|
|
125
|
+
knowledge: "Knowledge discovery",
|
|
126
|
+
reject: "Rejected"
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function classificationLabel(classification) {
|
|
130
|
+
const alias = CLASSIFICATION_ALIAS[classification.type];
|
|
131
|
+
return alias || classification.type;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function classificationLaneLabel(classification) {
|
|
135
|
+
const alias = CLASSIFICATION_ALIAS[classification.type];
|
|
136
|
+
if (alias) return alias.toLowerCase() + " lane";
|
|
137
|
+
return `${classification.type} lane`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pipelineDot(stage, blocked) {
|
|
141
|
+
const color = blocked && stage === "BLOCKED" ? FG.brightRed : ACID_FG;
|
|
142
|
+
return paint(color, "●");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fieldLine(label, value, width, valueColor = FG.white, labelColor = FG.gray) {
|
|
146
|
+
const labelText = paint(labelColor, padEnd(label.padEnd(15).toUpperCase(), 15));
|
|
147
|
+
const wrapWidth = Math.max(20, width - 4 - 15 - 2);
|
|
148
|
+
const lines = wrap(value, wrapWidth);
|
|
149
|
+
const rendered = [];
|
|
150
|
+
for (let i = 0; i < lines.length; i++) {
|
|
151
|
+
const prefix = i === 0 ? labelText : padEnd("", 15);
|
|
152
|
+
rendered.push(boxLine(width, `${prefix} ${paint(valueColor, lines[i])}`));
|
|
153
|
+
}
|
|
154
|
+
return rendered;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function commandBox(interiorWidth, command) {
|
|
158
|
+
const inside = ` $ ${command} `;
|
|
159
|
+
const boxWidth = Math.min(interiorWidth, visibleLength(inside) + 4);
|
|
160
|
+
const top = paint(DIM_ACID_FG, `┌${"─".repeat(boxWidth - 2)}┐`);
|
|
161
|
+
const bottom = paint(DIM_ACID_FG, `└${"─".repeat(boxWidth - 2)}┘`);
|
|
162
|
+
const content = padEnd(inside, boxWidth - 2);
|
|
163
|
+
const middle = `${paint(DIM_ACID_FG, "│")}${paint(ACID_FG, content)}${paint(DIM_ACID_FG, "│")}`;
|
|
164
|
+
return [top, middle, bottom];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pipelineDetail(stage, plan) {
|
|
168
|
+
if (stage === "POLICY") {
|
|
169
|
+
return `${plan.policy.checked.length} checked · ${plan.policy.deferred.length} deferred`;
|
|
170
|
+
}
|
|
171
|
+
if (stage === "RISK") {
|
|
172
|
+
return `${plan.risk.level} · ${plan.risk.reasons[0] || ""}`.replace(/·\s*$/, "").trim();
|
|
173
|
+
}
|
|
174
|
+
if (stage === "CLASSIFICATION") {
|
|
175
|
+
return classificationLabel(plan.classification).toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
if (stage === "AWAITING_APPROVAL") return "";
|
|
178
|
+
if (stage === "BLOCKED") return "policy denied — no token issued";
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderIntake(plan) {
|
|
183
|
+
const width = terminalWidth();
|
|
184
|
+
const isBlocked = plan.state === "blocked";
|
|
185
|
+
const title = `intake pipeline · ${classificationLaneLabel(plan.classification)}`;
|
|
186
|
+
const lines = [];
|
|
187
|
+
|
|
188
|
+
lines.push(boxTop(width, title));
|
|
189
|
+
lines.push(boxBlank(width));
|
|
190
|
+
|
|
191
|
+
// Owner prompt
|
|
192
|
+
const promptLines = wrap(plan.request, width - 6);
|
|
193
|
+
for (let i = 0; i < promptLines.length; i++) {
|
|
194
|
+
const marker = i === 0 ? paint(ACID_FG, "❯ ") : " ";
|
|
195
|
+
lines.push(boxLine(width, ` ${marker}${paint(FG.white, promptLines[i])}`));
|
|
196
|
+
}
|
|
197
|
+
lines.push(boxBlank(width));
|
|
198
|
+
|
|
199
|
+
// Pipeline
|
|
200
|
+
for (const stage of plan.pipeline) {
|
|
201
|
+
const upper = stage.toUpperCase();
|
|
202
|
+
const detail = pipelineDetail(upper, plan);
|
|
203
|
+
const dot = pipelineDot(upper, isBlocked);
|
|
204
|
+
const label = paint(isBlocked && upper === "BLOCKED" ? FG.brightRed : FG.white, padEnd(upper, 18));
|
|
205
|
+
const suffix = detail ? paint(FG.gray, detail) : "";
|
|
206
|
+
lines.push(boxLine(width, ` ${dot} ${label} ${suffix}`));
|
|
207
|
+
}
|
|
208
|
+
lines.push(boxBlank(width));
|
|
209
|
+
|
|
210
|
+
// Field summary
|
|
211
|
+
const classificationText = classificationLabel(plan.classification);
|
|
212
|
+
lines.push(...fieldLine("Classification", classificationText, width, ACID_FG));
|
|
213
|
+
lines.push(...fieldLine("Why", plan.classification.reason, width, FG.gray));
|
|
214
|
+
const riskColor = RISK_TONE[plan.risk.level] || FG.white;
|
|
215
|
+
lines.push(...fieldLine("Risk", `${plan.risk.level} · ${plan.risk.reasons.join("; ")}`, width, riskColor));
|
|
216
|
+
|
|
217
|
+
// Deferred guardrails
|
|
218
|
+
const deferred = plan.policy.evaluations.filter((entry) => entry.status === "deferred");
|
|
219
|
+
if (deferred.length) {
|
|
220
|
+
lines.push(boxBlank(width));
|
|
221
|
+
lines.push(boxLine(width, ` ${paint(FG.gray, "DEFERRED GUARDRAILS")}`));
|
|
222
|
+
for (const entry of deferred) {
|
|
223
|
+
const gr = paint(FG.brightYellow, entry.guardrail);
|
|
224
|
+
const code = paint(FG.gray, entry.code);
|
|
225
|
+
const short = entry.message.length > width - 30 ? entry.message.slice(0, width - 33) + "…" : entry.message;
|
|
226
|
+
lines.push(boxLine(width, ` ${gr} ${code} ${paint(FG.white, short)}`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Approval or blocked
|
|
231
|
+
lines.push(boxBlank(width));
|
|
232
|
+
if (isBlocked) {
|
|
233
|
+
lines.push(boxLine(width, ` ${paint(FG.brightRed, "BLOCKED")} ${paint(FG.gray, "no approval token issued")}`));
|
|
234
|
+
if (plan.policy.violations && plan.policy.violations.length) {
|
|
235
|
+
for (const violation of plan.policy.violations) {
|
|
236
|
+
lines.push(boxLine(width, ` ${paint(FG.brightRed, "!")} ${paint(FG.white, violation)}`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else if (plan.approvalToken) {
|
|
240
|
+
const command = `scrumrun sc plan intake --approve ${plan.approvalToken}`;
|
|
241
|
+
const truncated = command.length > width - 8 ? `scrumrun sc plan intake --approve ${plan.approvalToken.slice(0, 20)}…` : command;
|
|
242
|
+
for (const boxRow of commandBox(width - 6, truncated)) {
|
|
243
|
+
lines.push(boxLine(width, ` ${boxRow}`));
|
|
244
|
+
}
|
|
245
|
+
lines.push(boxBlank(width));
|
|
246
|
+
lines.push(boxLine(width, ` ${paint(FG.gray, "[ awaiting owner approval ]")}`));
|
|
247
|
+
if (command.length !== truncated.length) {
|
|
248
|
+
lines.push(boxLine(width, ` ${paint(FG.gray, "(full token above is truncated for display; copy from --json if needed)")}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
lines.push(boxBlank(width));
|
|
252
|
+
lines.push(boxBottom(width));
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderIntakePlain(plan) {
|
|
257
|
+
const lines = [];
|
|
258
|
+
lines.push("# ScrumRun Intake");
|
|
259
|
+
lines.push(`State: ${plan.state}`);
|
|
260
|
+
lines.push(`Classification: ${plan.classification.type} (${plan.classification.reason})`);
|
|
261
|
+
lines.push(`Risk: ${plan.risk.level} — ${plan.risk.reasons.join("; ")}`);
|
|
262
|
+
lines.push(`Policy: ${plan.policy.status} (${plan.policy.checked.length} checked; ${plan.policy.deferred.length} deferred)`);
|
|
263
|
+
for (const violation of plan.policy.violations || []) lines.push(`BLOCKED: ${violation}`);
|
|
264
|
+
for (const result of plan.policy.evaluations.filter((entry) => entry.status === "deferred")) {
|
|
265
|
+
lines.push(`DEFERRED: ${result.guardrail} ${result.code}: ${result.message}`);
|
|
266
|
+
}
|
|
267
|
+
for (const warning of plan.context.warnings) lines.push(`WARNING: ${warning}`);
|
|
268
|
+
if (plan.approvalToken) lines.push(`Approval: scrumrun sc plan intake --approve ${plan.approvalToken}`);
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { canRenderPretty, renderIntake, renderIntakePlain };
|
package/lib/v2/conformance.js
CHANGED
|
@@ -38,7 +38,8 @@ const INVARIANTS = Object.freeze([
|
|
|
38
38
|
{ id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
|
|
39
39
|
{ id: "I-21", summary: "material mutations are scoped, policy-bound, and fail closed", tests: ["Mutation Gateway rejects bypass and out-of-scope writes", "Run completion rejects unresolved Guardrail obligations"] },
|
|
40
40
|
{ id: "I-22", summary: "declared search backend matches observed runtime capabilities", tests: ["conformance detects a semantic index that declares fts5 when the runtime does not provide it"] },
|
|
41
|
-
{ id: "I-23", summary: "method.json declares canonical paths so agents navigate by index, not by search", tests: ["method.json path index is present, well-formed, and matches the canonical layout"] }
|
|
41
|
+
{ id: "I-23", summary: "method.json declares canonical paths so agents navigate by index, not by search", tests: ["method.json path index is present, well-formed, and matches the canonical layout"] },
|
|
42
|
+
{ id: "I-24", summary: "every canonical Run passes ledger validation; hand-written events surface as bypass findings", tests: ["conformance flags Run write bypass when the ledger uses invalid vocabulary or misses the snapshot invariant"] }
|
|
42
43
|
]);
|
|
43
44
|
|
|
44
45
|
function finding(severity, code, message, file = null) {
|
|
@@ -139,7 +140,10 @@ function auditProject(projectRoot) {
|
|
|
139
140
|
}
|
|
140
141
|
if (run.record && run.record.ledger === RUN_LEDGER_VERSION) {
|
|
141
142
|
const ledger = validateRunLedger(run.record, run.body);
|
|
142
|
-
|
|
143
|
+
if (ledger.errors.length) {
|
|
144
|
+
findings.push(finding("high", "RUN_WRITE_BYPASS", `${run.record.id}: ledger has ${ledger.errors.length} validation error(s); this Run was likely written without going through the CLI. Recover with \`sc plan run --normalize-legacy --dry-run\` then re-run without --dry-run.`, run.file));
|
|
145
|
+
for (const error of ledger.errors) findings.push(finding("high", "RUN_LEDGER_INVALID", `${run.record.id}: ${error}`, run.file));
|
|
146
|
+
}
|
|
143
147
|
} else if (run.record) {
|
|
144
148
|
const declared = methodMarker && methodMarker.schemas && methodMarker.schemas.run_ledger === RUN_LEDGER_VERSION;
|
|
145
149
|
findings.push(finding(declared ? "high" : "warning", "RUN_LEDGER_LEGACY", `${run.record.id} requires the Run ledger ${RUN_LEDGER_VERSION} migration.`, run.file));
|
package/package.json
CHANGED
|
@@ -36,6 +36,8 @@ Normal hot path:
|
|
|
36
36
|
5. follow the ids/pointers to only the relevant canonical artifacts;
|
|
37
37
|
6. load `.scrumrun/core.md` when the method contract or an exceptional transition is needed.
|
|
38
38
|
|
|
39
|
+
**Never write Run events by hand.** Only the CLI mutates `runs/RUN-NNN.md`: `sc plan run --validate | --learn | --complete | --resume | --fail | --block | --satisfy-guardrail | --authorize-mutation | --record-mutation`. Direct edits produce invalid ledger events (unknown `type` like `execution`/`validation`/`learning`, unknown evidence `kind` like `guardrail-check`/`build`/`task-status`, missing snapshot invariant) and break project conformance. If the CLI does not expose the shape you need, propose a spec change instead of inventing vocabulary. Recover hand-written Runs via `sc plan run --normalize-legacy` — originals are preserved byte-exact under `.scrumrun/.migration-backup/runs/`.
|
|
40
|
+
|
|
39
41
|
Lean mode is a read policy, not an incomplete store. Generated files and `.scrumrun/.cache/` are never authoritative.
|
|
40
42
|
|
|
41
43
|
Generated state and semantic indexes use a metadata-watch fast path with a full content-hash fallback. Treat cache-schema mismatch as a request to rebuild the disposable projection, never as permission to rewrite canonical Markdown.
|