scrumrun 2.4.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 +12 -0
- package/README.md +1 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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
|
+
|
|
7
19
|
## 2.4.0 - 2026-07-23
|
|
8
20
|
|
|
9
21
|
### Added
|
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.4.
|
|
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/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 };
|