scrumrun 2.3.0 → 2.4.1
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 +26 -0
- package/CORE.md +2 -0
- package/README.md +1 -1
- package/SPEC.md +2 -1
- package/bin/scrumrun.js +10 -11
- package/docs/QUICKSTART.md +6 -0
- package/lib/commands/manifest.js +1 -1
- package/lib/commands/pretty-intake.js +272 -0
- package/lib/v2/conformance.js +31 -1
- package/lib/v2/migration.js +5 -5
- package/lib/v2/paths.js +72 -0
- package/package.json +1 -1
- package/templates/project/.scrumrun/method.json +22 -0
- package/templates/shared/skills/scrumrun/SKILL.md +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes follow Semantic Versioning.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.4.1 - 2026-07-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
- New module `lib/commands/pretty-intake` exposes `canRenderPretty`, `renderIntake`, and `renderIntakePlain` for library consumers.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Manifest declares `--plain` and `--json` on `sc plan intake`.
|
|
17
|
+
- 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).
|
|
18
|
+
|
|
19
|
+
## 2.4.0 - 2026-07-23
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `method.json` now carries a canonical `paths` block plus `paths_schema: 1`. The block declares the exact relative location of every artifact family (`guardrails`, `config`, `project`, `core`, `state_view`, `map_view`, `tasks`, `sprints`, `features`, `runs`, `reviews`, `memory.{knowledge,decisions,insights,dossiers}`, `vault_local`, `cache`) so ScrumRun-aware agents navigate by index instead of grep. Powered by a new `lib/v2/paths` module used by init, migration, and conformance so drift is impossible.
|
|
24
|
+
- Invariant **I-23** — *method.json declares canonical paths so agents navigate by index, not by search*. `doctor --strict` flags `METHOD_PATHS_MISSING`, `METHOD_PATHS_DRIFT`, and `METHOD_PATHS_SCHEMA` findings; a `high` severity blocks conformance until `update --migrate` regenerates the block.
|
|
25
|
+
- CORE and the shared SKILL now open with a hard instruction: read `.scrumrun/method.json` **before** any grep, and never search for legacy paths (`goals/`, `backlog.md`, `sprint.md`, `history.md`). Directory listing is a fallback, never a first step.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Migration output writes `method.json` through the shared `renderMethodJson` helper, so v1→v2 migrations backfill the `paths` block automatically.
|
|
30
|
+
- SPEC.md conformance range extended to `I-01 through I-23`.
|
|
31
|
+
- Test suite grew from 170 to 177 passing.
|
|
32
|
+
|
|
7
33
|
## 2.3.0 - 2026-07-23
|
|
8
34
|
|
|
9
35
|
### Added
|
package/CORE.md
CHANGED
|
@@ -82,6 +82,8 @@ AGENTS.md
|
|
|
82
82
|
contexts/
|
|
83
83
|
```
|
|
84
84
|
|
|
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
|
+
|
|
85
87
|
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.
|
|
86
88
|
|
|
87
89
|
`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.4.1` · **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
|
@@ -309,6 +309,7 @@ Legacy sprint entries become Tasks. History entries become Runs only with an evi
|
|
|
309
309
|
- **I-20** Post-validation learning proposes candidates and never blocks Task/Run completion.
|
|
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
|
+
- **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.
|
|
312
313
|
|
|
313
314
|
## 11. Command grammar
|
|
314
315
|
|
|
@@ -326,7 +327,7 @@ Unknown syntax fails deterministically and never guesses a mutation.
|
|
|
326
327
|
|
|
327
328
|
An implementation may claim ScrumRun method 2.0.0 only when it:
|
|
328
329
|
|
|
329
|
-
1. passes positive and negative tests for I-01 through I-
|
|
330
|
+
1. passes positive and negative tests for I-01 through I-23;
|
|
330
331
|
2. enforces every exposed state machine and schema;
|
|
331
332
|
3. proves read-only intake and dry-run migration through full-tree fingerprints;
|
|
332
333
|
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") {
|
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
|
@@ -24,7 +24,7 @@ const nouns = Object.freeze({
|
|
|
24
24
|
"--fail [--note] [--evidence] [--risk] [--test] [--actor] [--at]",
|
|
25
25
|
"--block [--note] [--evidence] [--risk] [--actor] [--at]"
|
|
26
26
|
],
|
|
27
|
-
intake: ["<request>", "--request", "--approve"],
|
|
27
|
+
intake: ["<request>", "--request", "--approve", "--plain", "--json"],
|
|
28
28
|
challenge: ["<question>"]
|
|
29
29
|
}
|
|
30
30
|
},
|
|
@@ -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
|
@@ -13,6 +13,7 @@ const { containsSecret, containsSecretWithAllowlist, loadSecretAllowlist } = req
|
|
|
13
13
|
const { pendingTransactionStatus } = require("./transaction");
|
|
14
14
|
const { configWeakeningAttempts, validateGuardrailDocument } = require("../runtime/policy-engine");
|
|
15
15
|
const { auditActiveWorkspace } = require("../runtime/mutation-gateway");
|
|
16
|
+
const { canonicalPaths, PATHS_SCHEMA_VERSION } = require("./paths");
|
|
16
17
|
|
|
17
18
|
const INVARIANTS = Object.freeze([
|
|
18
19
|
{ id: "I-01", summary: "pre-approval work is read-only", tests: ["intake builds bounded context without writing"] },
|
|
@@ -36,13 +37,32 @@ const INVARIANTS = Object.freeze([
|
|
|
36
37
|
{ id: "I-19", summary: "code intelligence is derived and fingerprinted", tests: ["language adapters are replaceable", "moves remap by fingerprint"] },
|
|
37
38
|
{ id: "I-20", summary: "learning candidates never block execution", tests: ["post-validation extraction creates candidate insights"] },
|
|
38
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"] },
|
|
39
|
-
{ 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"] }
|
|
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"] }
|
|
40
42
|
]);
|
|
41
43
|
|
|
42
44
|
function finding(severity, code, message, file = null) {
|
|
43
45
|
return { severity, code, message, ...(file ? { file } : {}) };
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function diffPathIndex(expected, actual, prefix = "") {
|
|
49
|
+
const drift = [];
|
|
50
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
51
|
+
const label = prefix ? `${prefix}.${key}` : key;
|
|
52
|
+
const actualValue = actual && typeof actual === "object" ? actual[key] : undefined;
|
|
53
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
54
|
+
if (!actualValue || typeof actualValue !== "object" || Array.isArray(actualValue)) {
|
|
55
|
+
drift.push({ label, expected: "<object>", actual: actualValue });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
drift.push(...diffPathIndex(value, actualValue, label));
|
|
59
|
+
} else if (actualValue !== value) {
|
|
60
|
+
drift.push({ label, expected: value, actual: actualValue });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return drift;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function auditProject(projectRoot) {
|
|
47
67
|
const scrumDir = path.join(projectRoot, ".scrumrun");
|
|
48
68
|
const findings = [];
|
|
@@ -56,6 +76,16 @@ function auditProject(projectRoot) {
|
|
|
56
76
|
if (!fs.existsSync(marker) || !fs.lstatSync(marker).isFile()) throw new Error("marker is missing, not a regular file, or is a symbolic link");
|
|
57
77
|
methodMarker = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
58
78
|
if (methodMarker.method !== METHOD_VERSION) findings.push(finding("critical", "METHOD_VERSION", `method.json must declare ${METHOD_VERSION}.`, marker));
|
|
79
|
+
const expectedPaths = canonicalPaths();
|
|
80
|
+
if (!methodMarker.paths || typeof methodMarker.paths !== "object" || Array.isArray(methodMarker.paths)) {
|
|
81
|
+
findings.push(finding("high", "METHOD_PATHS_MISSING", `method.json must declare a canonical "paths" block so agents navigate by declaration, not by search. Run \`npx scrumrun@latest update --migrate\` to backfill.`, marker));
|
|
82
|
+
} else {
|
|
83
|
+
const drift = diffPathIndex(expectedPaths, methodMarker.paths);
|
|
84
|
+
for (const entry of drift) findings.push(finding("high", "METHOD_PATHS_DRIFT", `method.json paths[${entry.label}] is ${entry.actual === undefined ? "missing" : `"${entry.actual}"`}; expected "${entry.expected}". Regenerate via \`update --migrate\`.`, marker));
|
|
85
|
+
if (methodMarker.paths_schema !== PATHS_SCHEMA_VERSION) {
|
|
86
|
+
findings.push(finding("warning", "METHOD_PATHS_SCHEMA", `method.json paths_schema is ${methodMarker.paths_schema || "missing"}; expected ${PATHS_SCHEMA_VERSION}.`, marker));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
59
89
|
} catch (error) {
|
|
60
90
|
findings.push(finding("critical", "METHOD_MARKER", `method.json is missing or malformed: ${error.message}`, marker));
|
|
61
91
|
}
|
package/lib/v2/migration.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
} = require("./artifacts");
|
|
19
19
|
const { inferEnforcement, normalizeGuardrailDocument } = require("../runtime/policy-engine");
|
|
20
20
|
const { RUN_LEDGER_VERSION } = require("./schema");
|
|
21
|
+
const { renderMethodJson } = require("./paths");
|
|
21
22
|
const { migrateLegacyRun } = require("../runtime/run-ledger");
|
|
22
23
|
|
|
23
24
|
const MIGRATION_NAME = "v1-to-v2";
|
|
@@ -927,13 +928,12 @@ function migrationPlan(projectRoot) {
|
|
|
927
928
|
}
|
|
928
929
|
|
|
929
930
|
const sourceLayout = existingIds.size ? "hybrid-v1-v2" : "1.x";
|
|
930
|
-
generated.set("method.json",
|
|
931
|
-
|
|
932
|
-
layout: "v2",
|
|
931
|
+
generated.set("method.json", renderMethodJson({
|
|
932
|
+
methodVersion: METHOD_VERSION,
|
|
933
933
|
schemas: { run_ledger: RUN_LEDGER_VERSION, guardrails: 1, run_obligations: 1, mutation_gateway: 1 },
|
|
934
|
-
|
|
934
|
+
migratedFrom: sourceLayout,
|
|
935
935
|
migration: MIGRATION_NAME
|
|
936
|
-
}
|
|
936
|
+
}));
|
|
937
937
|
|
|
938
938
|
const counts = {};
|
|
939
939
|
for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] || 0) + 1;
|
package/lib/v2/paths.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Single source of truth for the canonical layout inside `.scrumrun/`.
|
|
4
|
+
//
|
|
5
|
+
// This module powers `method.json.paths` — the machine-readable index a
|
|
6
|
+
// ScrumRun-aware agent must consult BEFORE searching. Any change here
|
|
7
|
+
// must land in the template, the migration generator, and the doctor
|
|
8
|
+
// invariant together so no drift is possible.
|
|
9
|
+
|
|
10
|
+
const CANONICAL_PATHS = Object.freeze({
|
|
11
|
+
guardrails: "guardrails.md",
|
|
12
|
+
config: "config.md",
|
|
13
|
+
project: "project.md",
|
|
14
|
+
core: "core.md",
|
|
15
|
+
state_view: "state.md",
|
|
16
|
+
map_view: "map.md",
|
|
17
|
+
tasks: "tasks/",
|
|
18
|
+
sprints: "sprints/",
|
|
19
|
+
features: "features/",
|
|
20
|
+
runs: "runs/",
|
|
21
|
+
reviews: "reviews/",
|
|
22
|
+
memory: Object.freeze({
|
|
23
|
+
knowledge: "memory/knowledge/",
|
|
24
|
+
decisions: "memory/decisions/",
|
|
25
|
+
insights: "memory/insights/",
|
|
26
|
+
dossiers: "memory/dossiers/"
|
|
27
|
+
}),
|
|
28
|
+
vault_local: "vault.local.md",
|
|
29
|
+
cache: ".cache/"
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const PATHS_SCHEMA_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
function clone(value) {
|
|
35
|
+
if (value === null || typeof value !== "object") return value;
|
|
36
|
+
if (Array.isArray(value)) return value.map(clone);
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [key, entry] of Object.entries(value)) out[key] = clone(entry);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalPaths() {
|
|
43
|
+
return clone(CANONICAL_PATHS);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function flattenPaths(paths = CANONICAL_PATHS, prefix = "") {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const [key, value] of Object.entries(paths)) {
|
|
49
|
+
const label = prefix ? `${prefix}.${key}` : key;
|
|
50
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
51
|
+
out.push(...flattenPaths(value, label));
|
|
52
|
+
} else if (typeof value === "string") {
|
|
53
|
+
out.push({ label, relative: value });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderMethodJson({ methodVersion, layout = "v2", schemas = {}, migratedFrom, migration } = {}) {
|
|
60
|
+
const payload = {
|
|
61
|
+
method: methodVersion,
|
|
62
|
+
layout,
|
|
63
|
+
paths_schema: PATHS_SCHEMA_VERSION,
|
|
64
|
+
paths: canonicalPaths(),
|
|
65
|
+
schemas
|
|
66
|
+
};
|
|
67
|
+
if (migratedFrom) payload.migrated_from = migratedFrom;
|
|
68
|
+
if (migration) payload.migration = migration;
|
|
69
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { CANONICAL_PATHS, PATHS_SCHEMA_VERSION, canonicalPaths, flattenPaths, renderMethodJson };
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"method": "2.0.0",
|
|
3
3
|
"layout": "v2",
|
|
4
|
+
"paths_schema": 1,
|
|
5
|
+
"paths": {
|
|
6
|
+
"guardrails": "guardrails.md",
|
|
7
|
+
"config": "config.md",
|
|
8
|
+
"project": "project.md",
|
|
9
|
+
"core": "core.md",
|
|
10
|
+
"state_view": "state.md",
|
|
11
|
+
"map_view": "map.md",
|
|
12
|
+
"tasks": "tasks/",
|
|
13
|
+
"sprints": "sprints/",
|
|
14
|
+
"features": "features/",
|
|
15
|
+
"runs": "runs/",
|
|
16
|
+
"reviews": "reviews/",
|
|
17
|
+
"memory": {
|
|
18
|
+
"knowledge": "memory/knowledge/",
|
|
19
|
+
"decisions": "memory/decisions/",
|
|
20
|
+
"insights": "memory/insights/",
|
|
21
|
+
"dossiers": "memory/dossiers/"
|
|
22
|
+
},
|
|
23
|
+
"vault_local": "vault.local.md",
|
|
24
|
+
"cache": ".cache/"
|
|
25
|
+
},
|
|
4
26
|
"schemas": {
|
|
5
27
|
"run_ledger": 1,
|
|
6
28
|
"guardrails": 1,
|
|
@@ -29,11 +29,12 @@ Apply instructions in this order:
|
|
|
29
29
|
|
|
30
30
|
Normal hot path:
|
|
31
31
|
|
|
32
|
-
1. read `
|
|
33
|
-
2. read
|
|
34
|
-
3. read `.scrumrun/
|
|
35
|
-
4.
|
|
36
|
-
5.
|
|
32
|
+
1. read `.scrumrun/method.json` — its `paths` block is the authoritative index of every canonical location; navigate by that index and never grep for legacy paths (`goals/`, `backlog.md`, `sprint.md`, `history.md`);
|
|
33
|
+
2. read `AGENTS.md`;
|
|
34
|
+
3. read `.scrumrun/guardrails.md`;
|
|
35
|
+
4. read `.scrumrun/state.md`;
|
|
36
|
+
5. follow the ids/pointers to only the relevant canonical artifacts;
|
|
37
|
+
6. load `.scrumrun/core.md` when the method contract or an exceptional transition is needed.
|
|
37
38
|
|
|
38
39
|
Lean mode is a read policy, not an incomplete store. Generated files and `.scrumrun/.cache/` are never authoritative.
|
|
39
40
|
|