Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Each entry links the GitHub issue (the canonical spec) and the merge PR (the shi

### Changed

- **sprint-init.js debug surfaces deleted** — `--dry-run`, `--json`, the structured refusal JSON, and the vestigial `issueCount` / `placeholderIssue` fields are gone (247 lines with tests); only tests consumed them. Overlap refusal and `--component` validation are unchanged. Closes [#457](https://github.com/sungjunlee/dev-backlog/issues/457).
- **Repo-only helpers leave the shipped bundle** — `doc-drift-check.js` (this repo's lint over its own docs) and `bash-runtime.js` (a test helper that locates bash) moved from `skills/dev-backlog/scripts/` to `tests/tools/` with their tests; no operator script or SKILL.md rail used them. The shipped script inventory is 14 → 12 files. Closes [#460](https://github.com/sungjunlee/dev-backlog/issues/460).
- **Doctor advisory checks and dead lib.js readers deleted** — `backlog-doctor.js` drops `in_flight_staleness` (`--stale-days`) and `context_bloat`; both re-derive from `status.sh --json` `age_days` and `wc -l` and nothing consumed them. `lib.js` drops `parseMarkerMonth` and the triage config readers orphaned by #433 (259 lines with tests). The three guard checks and their JSON shapes are unchanged. Closes [#458](https://github.com/sungjunlee/dev-backlog/issues/458).
- **status.sh trailing sections deleted** — the Local Files block (counted `tasks/*.md` mirrors by a Backlog.md `status:` field, a format parked at `v0.11.0`), the Relay Runs block (re-derived another tool's run-directory slug and grepped its manifests), and the Past sprints line are gone (54 lines with their smoke fixture); each was one command away and nothing consumed them. Output now ends after the Active Sprint block; `--json` and `--track` are unchanged. Closes [#459](https://github.com/sungjunlee/dev-backlog/issues/459).
Expand Down
231 changes: 51 additions & 180 deletions skills/dev-backlog/scripts/sprint-init.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
#!/usr/bin/env node
/**
* Generate an empty sprint file skeleton.
* Generate an empty sprint file skeleton. Filename: YYYY-MM-<topic>.md
*
* Usage: ./scripts/sprint-init.js "auth-system"
* ./scripts/sprint-init.js "auth-system" --milestone "Sprint W13"
* ./scripts/sprint-init.js "auth-system" --component "sprint-execution"
* ./scripts/sprint-init.js "auth-system" --scope "src/auth/**"
* ./scripts/sprint-init.js "auth-system" --dry-run
* ./scripts/sprint-init.js "auth-system" --json
*
* First arg is the topic name. Milestone defaults to topic if not specified
* and is written to frontmatter verbatim; nothing is read from GitHub.
* Filename: YYYY-MM-<topic>.md
*
* The Plan is written empty (#445): listing Issues is a `gh issue list` the
* session runs itself, and the model writes the batches.
*
* Multi-track (#292): a second active sprint is refused only when its scope
* overlaps an existing active track (shared scopesOverlap from lib.js);
* disjoint tracks are created without refusal; any scopeless track in a
* multi-track portfolio warns and allows (cannot prove overlap).
*
* Spec-free (#426): nothing here reads spec/. `--component` is a free
* track-scope string compared only by scopesOverlap; `objectives:` is never
* emitted (optional human-authored metadata the parser still tolerates).
* Milestone defaults to topic. Plan is empty (#445). Overlap uses shared
* scopesOverlap (#292). Nothing reads spec/; `--component` is a free string
* (#426); `objectives:` is never emitted.
*/

const fs = require("fs");
Expand All @@ -32,7 +18,7 @@ const { slugify, sprintScopeKey, scopesOverlap } = require("./lib");
const { defaultSprintsDir } = require("./execution-root.js");
const { parseFrontmatter } = require("./sprint-state.js");

const USAGE = 'Usage: sprint-init.js "topic" [--milestone "Milestone Name"] [--component "slug" | --scope "glob[,glob]"] [--dry-run] [--json]';
const USAGE = 'Usage: sprint-init.js "topic" [--milestone "Milestone Name"] [--component "slug" | --scope "glob[,glob]"]';

function parseTrackAxis(args) {
const componentIdx = args.indexOf("--component");
Expand All @@ -56,40 +42,35 @@ function parseTrackAxis(args) {
}

function parseArgs(args) {
const dryRun = args.includes("--dry-run");
const json = args.includes("--json");
const filteredArgs = args.filter((a) => a !== "--dry-run" && a !== "--json");

if (!filteredArgs.length) {
return { error: USAGE };
if (!args.length) return { error: USAGE };
const topic = args[0];
if (topic.startsWith("--")) return { error: USAGE };

const KNOWN_FLAGS = ["--milestone", "--component", "--scope"];
for (let i = 1; i < args.length; i += 1) {
if (args[i].startsWith("--") && !KNOWN_FLAGS.includes(args[i])) {
return { error: `Unknown argument: ${args[i]}. ${USAGE}` };
}
if (KNOWN_FLAGS.includes(args[i])) i += 1;
}

const topic = filteredArgs[0];
if (topic.startsWith("--")) {
return { error: USAGE };
}
// Explicit only (D2): the track axis is never inferred from touched paths.
const trackAxis = parseTrackAxis(args);
if (trackAxis.error) return trackAxis;

let milestone = topic;
const msIdx = filteredArgs.indexOf("--milestone");
if (msIdx !== -1 && filteredArgs[msIdx + 1]) {
milestone = filteredArgs[msIdx + 1];
const msIdx = args.indexOf("--milestone");
if (msIdx !== -1) {
if (!args[msIdx + 1] || args[msIdx + 1].startsWith("--")) {
return { error: `Missing value for --milestone. ${USAGE}` };
}
milestone = args[msIdx + 1];
}

// Explicit only (D2): the track axis is never inferred from touched paths.
const trackAxis = parseTrackAxis(filteredArgs);
if (trackAxis.error) return { ...trackAxis, dryRun, json };
return { topic, milestone, dryRun, json, ...trackAxis };
return { topic, milestone, ...trackAxis };
Comment on lines 57 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject the removed dry-run flag before creating a sprint

When an existing caller still invokes sprint-init.js topic --dry-run, parseArgs now silently ignores the unrecognized flag and main creates an active sprint file, reversing an explicitly non-mutating command into a mutation. Reject removed/unknown flags so stale callers fail without modifying sprint state.

AGENTS.md reference: AGENTS.md:L39-L41

Useful? React with 👍 / 👎.

}

// component: is emitted only when --component was given (#426). It is a free
// track-scope string, resolved against nothing; naming a capability heading is
// a routing convention for relay Learnings, not a checked contract.
// `objectives:` is never generated — it stays optional human-authored metadata
// that the parser tolerates. Existing sprints carrying `objectives: []` /
// `component: ""` remain valid; this is omission-on-generate, not a migration.
// component: is a free track-scope string (charter rev 18); it must survive the
// frontmatter round-trip unchanged, so reject anything that could break the
// line (whitespace, quotes, newlines) before any file is written.
// component: only when --component is given (#426). Reject tokens that cannot
// round-trip through frontmatter (whitespace, quotes, newlines).
const COMPONENT_RE = /^[^\s"]+$/;
function buildComponentFrontmatterLine(component) {
if (!component) return "";
Expand All @@ -99,21 +80,13 @@ function buildComponentFrontmatterLine(component) {
return `component: "${component}"\n`;
}

// scope: is emitted only when explicitly requested (--scope, D2) — a track's
// partition axis is declared, never inferred, and absent scope is never an error.
// scope: emitted only when --scope is given (D2); absent scope is never an error.
function buildScopeFrontmatterLine(scope) {
if (!Array.isArray(scope) || scope.length === 0) return "";
return `scope: [${scope.map((glob) => `"${glob}"`).join(", ")}]\n`;
}

function buildSprintContent({
milestone,
started,
due,
topic,
component,
scope,
}) {
function buildSprintContent({ milestone, started, due, topic, component, scope }) {
const componentLine = buildComponentFrontmatterLine(component);
const scopeLine = buildScopeFrontmatterLine(scope);

Expand Down Expand Up @@ -142,7 +115,6 @@ ${scopeLine}${componentLine}---

function listActiveSprintFiles(sprintsDir) {
if (!fs.existsSync(sprintsDir)) return [];

return fs.readdirSync(sprintsDir)
.filter((file) => file.endsWith(".md") && file !== "_context.md")
.filter((file) => {
Expand All @@ -152,49 +124,13 @@ function listActiveSprintFiles(sprintsDir) {
.sort();
}

function createSprintResult({
topic,
milestone,
dryRun,
sprintFile,
started,
due,
content,
existingFile,
component,
warnings = [],
}) {
return {
action: "sprint-init",
dryRun,
topic,
milestone,
sprintFile,
started,
due,
issueCount: 0,
placeholderIssue: Boolean(content),
existingFile,
created: !dryRun && !existingFile,
...(component ? { component } : {}),
warnings,
content,
};
}

// Track records for the existing active sprints: file name + parsed frontmatter.
function loadActiveTrackRecords(sprintsDir) {
return listActiveSprintFiles(sprintsDir).map((file) => ({
// Overlap is the one shared scopesOverlap predicate. A scopeless track in a
// multi-track portfolio cannot be proven disjoint — warn-and-allow.
function checkTrackDisjointness({ sprintsDir, component, scope, newTrackFile = "new sprint" }) {
const activeTracks = listActiveSprintFiles(sprintsDir).map((file) => ({
file,
frontmatter: parseFrontmatter(fs.readFileSync(path.join(sprintsDir, file), "utf-8")),
}));
}

// Refusal is per-track overlap (via the ONE shared scopesOverlap predicate),
// no longer "any second active sprint". A scopeless track in a multi-track
// portfolio cannot be proven disjoint — warn-and-allow, matching the doctor.
function checkTrackDisjointness({ sprintsDir, component, scope, newTrackFile = "new sprint" }) {
const activeTracks = loadActiveTrackRecords(sprintsDir);
const newFrontmatter = component
? { component }
: (Array.isArray(scope) && scope.length ? { scope } : {});
Expand Down Expand Up @@ -225,11 +161,7 @@ function checkTrackDisjointness({ sprintsDir, component, scope, newTrackFile = "
}

function createSprintFile({
topic,
milestone,
component,
scope,
dryRun,
topic, milestone, component, scope,
sprintsDir = defaultSprintsDir(),
today = new Date(),
fileExists = fs.existsSync,
Expand All @@ -244,115 +176,54 @@ function createSprintFile({
const started = today.toISOString().slice(0, 10);
const topicSlug = slugify(topic) || "sprint";
const sprintFile = path.join(sprintsDir, `${datePrefix}-${topicSlug}.md`);
const existingFile = fileExists(sprintFile);

if (existingFile && !dryRun) {
if (fileExists(sprintFile)) {
throw new Error(`Sprint file already exists: ${sprintFile}`);
}

const warnings = existingFile
? []
: checkTrackDisjointness({
sprintsDir,
component,
scope,
newTrackFile: path.basename(sprintFile),
});
const warnings = checkTrackDisjointness({
sprintsDir, component, scope, newTrackFile: path.basename(sprintFile),
});

// `due:` is human-owned metadata (#445): nothing is read from GitHub here,
// so sprint-init never touches the network and never fails on provider state.
// `due:` is human-owned (#445): sprint-init never reads GitHub.
const due = "TBD";
const content = existingFile
? null
: buildSprintContent({
milestone,
started,
due,
topic,
component,
scope,
});

if (!dryRun && !existingFile) {
mkdir(sprintsDir);
writeFile(sprintFile, content);
}
const content = buildSprintContent({ milestone, started, due, topic, component, scope });
mkdir(sprintsDir);
writeFile(sprintFile, content);

return createSprintResult({
return {
action: "sprint-init",
topic,
milestone,
dryRun,
sprintFile,
started,
due,
content,
existingFile,
component,
existingFile: false,
created: true,
...(component ? { component } : {}),
warnings,
});
}

function refusalResult(parsed, error) {
return {
action: "sprint-init",
dryRun: parsed.dryRun,
component: parsed.component ?? null,
created: false,
refusalReason: error.message,
content,
};
}

function printResult(result) {
if (result.existingFile && result.dryRun) {
console.log(`[dry-run] File already exists: ${result.sprintFile}`);
return;
}

for (const warning of result.warnings || []) {
console.log(`Warning: ${warning}`);
}

if (result.placeholderIssue) {
console.log("The Plan is empty: add `- [ ] #N Title` items for the work this track carries.");
}

if (result.dryRun) {
console.log(`[dry-run] Would create: ${result.sprintFile}\n`);
} else {
console.log(`Created: ${result.sprintFile}\n`);
}

console.log("The Plan is empty: add `- [ ] #N Title` items for the work this track carries.");
console.log(`Created: ${result.sprintFile}\n`);
console.log(result.content);
}

// --- Main execution ---

function main() {
const args = process.argv.slice(2);
const parsed = parseArgs(args);
if (parsed.error) {
if (parsed.json && args.includes("--component")) {
console.log(JSON.stringify(refusalResult(parsed, new Error(parsed.error)), null, 2));
process.exit(1);
}
console.log(parsed.error);
process.exit(1);
}

try {
const result = createSprintFile(parsed);

if (parsed.json) {
console.log(JSON.stringify(result, null, 2));
return;
}

printResult(result);
printResult(createSprintFile(parsed));
} catch (error) {
if (parsed.json && parsed.component) {
console.log(JSON.stringify(refusalResult(parsed, error), null, 2));
process.exit(1);
}
console.error(error.message);
process.exit(1);
}
Expand Down
7 changes: 3 additions & 4 deletions tests/dev-backlog/github-resilience.acceptance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,11 @@ describe("sprint-init makes no provider call at all (#445)", () => {
it(`${failMode}: the sprint file is still written and no gh call is made`, (t) => {
const fixture = prepareFixture(t, { failMode });
const result = run(process.execPath, [
SPRINT_INIT_PATH, "cycle", "--milestone", "Cycle Milestone", "--json",
SPRINT_INIT_PATH, "cycle", "--milestone", "Cycle Milestone",
], fixture);
assert.equal(result.status, 0, `sprint init must not need gh:\n${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.created, true);
assert.equal(parsed.due, "TBD");
assert.match(result.stdout, /Created:/);
assert.match(result.stdout, /due: TBD/);
assert.deepEqual(fixture.calls(), []);
});
}
Expand Down
Loading
Loading