fix(#2337): capture and surface todo severity (#2381)

add-todo.md gains a confirm-based infer_severity step (infer from the blocker/major/minor/cosmetic taxonomy, confirm via AskUserQuestion with TEXT_MODE fallback, before writing) and a severity frontmatter field. cmdListTodos and cmdInitTodos now surface severity, backward-compatible (key omitted when absent), in parity.

Closes #2337. Admin-merged (self-review bypass) with full green CI.
This commit is contained in:
Tom Boucher
2026-07-17 14:01:38 -04:00
committed by GitHub
parent dcb4954131
commit b0f672f88c
26 changed files with 185 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2381
---
**Todo severity is now captured and surfaced end-to-end** — `/gsd-capture` (add-todo) now confirms a severity (blocker/major/minor/cosmetic) before writing a todo instead of silently omitting it, and `gsd-tools list-todos` / `init todos` now include the `severity` field in their JSON output (omitted for older todos that have none), so a backlog can be triaged by severity instead of by re-reading every file. (#2337)

View File

@@ -61,6 +61,34 @@ Infer area from file paths:
Use existing area from step 2 if similar match exists.
</step>
<step name="infer_severity">
Infer a **suggested** severity from the same blocker/major/minor/cosmetic taxonomy `verify-work.md`'s `severity_inference` uses — then CONFIRM it with the user before writing. Never silently auto-assign: a mis-tagged severity silently corrupts backlog triage, which is exactly the signal this field exists to provide.
Suggest from the user's natural-language description:
| User says | Suggest |
|-----------|---------|
| "crashes", "error", "exception", "fails completely", "data loss" | blocker |
| "doesn't work", "nothing happens", "wrong behavior" | major |
| "works but...", "slow", "weird", "minor issue" | minor |
| "color", "spacing", "alignment", "looks off" | cosmetic |
Default the suggestion to **major** if unclear.
**Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in `$ARGUMENTS` OR `text_mode` from init JSON is `true`. When TEXT_MODE is active, replace the `AskUserQuestion` below with a plain-text numbered list of the four options and ask the user to type their choice number. Required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is unavailable.
Confirm with AskUserQuestion (present the suggested value first):
- header: "Severity?"
- question: "Suggested severity: [suggested]. Confirm or change:"
- options:
- "blocker" — breaks a workflow or loses data; fix first
- "major" — wrong behavior with no workaround
- "minor" — works, but with a workaround or annoyance
- "cosmetic" — visual/polish only
Carry the confirmed value into `severity` in the create_file frontmatter.
</step>
<step name="check_duplicates">
```bash
# Search for key words from title in existing todos
@@ -97,6 +125,7 @@ Write to `.planning/todos/pending/${date}-${slug}.md`:
created: [timestamp]
title: [title]
area: [area]
severity: [blocker|major|minor|cosmetic — confirmed in infer_severity step]
files:
- [file:lines]
---

View File

@@ -165,7 +165,7 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
const pendingDir = path.join(planningDir(cwd), 'todos', 'pending');
let count = 0;
const todos: Array<{ file: string; created: string; title: string; area: string; path: string }> = [];
const todos: Array<{ file: string; created: string; title: string; area: string; path: string; severity?: string }> = [];
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
@@ -176,6 +176,9 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
// #2337: surface severity when present. Omit the key entirely for todos
// with no severity line so existing consumers of this JSON are unaffected.
const severityMatch = content.match(/^severity:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
@@ -189,6 +192,7 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))),
...(severityMatch ? { severity: severityMatch[1].trim() } : {}),
});
}
} catch { /* intentionally empty */ }

View File

@@ -1164,6 +1164,9 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
const createdMatch = content.match(/^created:\s*(.+)$/m);
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
// #2337: kept in parity with cmdListTodos — surface severity when
// present, omit the key entirely for todos with no severity line.
const severityMatch = content.match(/^severity:\s*(.+)$/m);
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
if (area && todoArea !== area) continue;
@@ -1180,6 +1183,7 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
path.join(planningDir(cwd), 'todos', 'pending', file),
),
),
...(severityMatch ? { severity: severityMatch[1].trim() } : {}),
});
} catch {
/* intentionally empty */

View File

@@ -1053,6 +1053,43 @@ describe('list-todos command', () => {
assert.strictEqual(output.todos[0].area, 'general', 'missing area defaults to general');
assert.strictEqual(output.todos[0].created, 'unknown', 'missing created defaults to unknown');
});
// ── #2337: severity must be surfaced when present, omitted when absent ──────
// cmdListTodos parsed created/title/area but silently dropped severity, so
// audit-open and status summaries could not triage by blocker/major/minor/
// cosmetic even for todos an agent had correctly hand-tagged.
test('surfaces severity when the frontmatter carries it (#2337)', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'crash.md'),
'title: Fix data-loss crash\narea: core\ncreated: 2026-02-01\nseverity: blocker\n');
const result = runGsdTools('list-todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const todo = output.todos.find(t => t.file === 'crash.md');
assert.ok(todo, 'crash.md should be in results');
assert.strictEqual(todo.severity, 'blocker', 'severity must be surfaced verbatim');
});
test('omits the severity key for todos with no severity line — backward compatible (#2337)', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'legacy.md'),
'title: Legacy todo\narea: docs\ncreated: 2026-02-02\n');
const result = runGsdTools('list-todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const todo = output.todos.find(t => t.file === 'legacy.md');
assert.ok(todo, 'legacy.md should be in results');
assert.ok(!('severity' in todo),
'severity key must be ABSENT (not null/empty) for a todo with no severity line');
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,64 @@
// allow-test-rule: source-text-is-the-product #2337
//
// The add-todo.md workflow IS the runtime contract an agent follows when
// capturing a todo. #2337: it had no severity step and no severity frontmatter
// field, so severity discipline depended on an out-of-band convention the agent
// had to remember — and production todos landed with no `severity:` at all.
//
// These assertions guard the specific must-haves (issue #2337 acceptance
// criteria 1): a confirm-based `infer_severity` step positioned BEFORE the todo
// is written, and a `severity` field in the create_file frontmatter template.
// The golden-install-parity hash catches ANY change to this file, but not
// WHICH change — this test pins the two structural guarantees so a future edit
// can't silently drop the step while still updating the golden.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ADD_TODO = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-todo.md');
describe('add-todo.md severity capture (#2337)', () => {
const md = fs.readFileSync(ADD_TODO, 'utf8');
test('defines an infer_severity step', () => {
assert.match(md, /<step name="infer_severity">/,
'add-todo.md must define an infer_severity step');
});
test('infer_severity runs before the file is written (create_file)', () => {
const sev = md.indexOf('<step name="infer_severity">');
const create = md.indexOf('<step name="create_file">');
assert.ok(sev !== -1 && create !== -1, 'both steps must exist');
assert.ok(sev < create,
'infer_severity must precede create_file so severity is confirmed before write');
});
test('infer_severity confirms with the user — never a silent auto-assignment', () => {
const stepStart = md.indexOf('<step name="infer_severity">');
const stepEnd = md.indexOf('</step>', stepStart);
const step = md.slice(stepStart, stepEnd);
assert.match(step, /AskUserQuestion/,
'severity must be confirmed via AskUserQuestion (with the TEXT_MODE fallback), not auto-assigned');
assert.match(step, /TEXT_MODE/,
'must provide the non-Claude-runtime numbered-list fallback');
});
test('the create_file frontmatter template carries a severity field', () => {
assert.match(md, /^severity:\s*\[blocker\|major\|minor\|cosmetic/m,
'create_file frontmatter template must include a severity field');
});
test('severity taxonomy matches verify-work.md (blocker/major/minor/cosmetic)', () => {
const stepStart = md.indexOf('<step name="infer_severity">');
const stepEnd = md.indexOf('</step>', stepStart);
const step = md.slice(stepStart, stepEnd);
for (const level of ['blocker', 'major', 'minor', 'cosmetic']) {
assert.match(step, new RegExp(level),
`severity taxonomy must include "${level}" to stay aligned with verify-work.md's severity_inference`);
}
});
});

View File

@@ -200,7 +200,7 @@
"gsd-core/workflows/add-backlog.md": "8cc2e884a021b79b",
"gsd-core/workflows/add-phase.md": "f82afa2f93be19fa",
"gsd-core/workflows/add-tests.md": "ad6d49634e04ca7c",
"gsd-core/workflows/add-todo.md": "de1ac76acfcc0133",
"gsd-core/workflows/add-todo.md": "e410a72c168e43c0",
"gsd-core/workflows/ai-integration-phase.md": "22f5466085c47c00",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "3ecffffe78021e0a",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "1bc7377b105194fc",
"gsd-core/workflows/add-phase.md": "46e0551ffdd8ce1a",
"gsd-core/workflows/add-tests.md": "24b0d8157a9ccb8b",
"gsd-core/workflows/add-todo.md": "cc0efe270004c8fb",
"gsd-core/workflows/add-todo.md": "953314cdfef56bc4",
"gsd-core/workflows/ai-integration-phase.md": "40c217869a06981f",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -270,7 +270,7 @@
"gsd-core/workflows/add-backlog.md": "8d05775f367d1e48",
"gsd-core/workflows/add-phase.md": "7285e6e8894a41c5",
"gsd-core/workflows/add-tests.md": "ffbaf55a25f455a2",
"gsd-core/workflows/add-todo.md": "1fc850476cd8340d",
"gsd-core/workflows/add-todo.md": "0a0f4d718ea50bea",
"gsd-core/workflows/ai-integration-phase.md": "3503f52a7356caf0",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "816c71b0ef2d8c1d",

View File

@@ -199,7 +199,7 @@
"gsd-core/workflows/add-backlog.md": "485c4b6673ddf832",
"gsd-core/workflows/add-phase.md": "0d90083e9c17bec1",
"gsd-core/workflows/add-tests.md": "c4b133a102d36a27",
"gsd-core/workflows/add-todo.md": "8488f10f56ac24ca",
"gsd-core/workflows/add-todo.md": "2c5176263ad4ae8f",
"gsd-core/workflows/ai-integration-phase.md": "a898d99b8d844215",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -203,7 +203,7 @@
"gsd-core/workflows/add-backlog.md": "6c4393113fa9d209",
"gsd-core/workflows/add-phase.md": "cac9b86f66ab26f5",
"gsd-core/workflows/add-tests.md": "0354376bffdc0080",
"gsd-core/workflows/add-todo.md": "b71e80304fe484eb",
"gsd-core/workflows/add-todo.md": "aa89018230c87f13",
"gsd-core/workflows/ai-integration-phase.md": "5159c6bdf102f74b",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "9bb427cd3b4075d5",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "1bc7377b105194fc",
"gsd-core/workflows/add-phase.md": "46e0551ffdd8ce1a",
"gsd-core/workflows/add-tests.md": "24b0d8157a9ccb8b",
"gsd-core/workflows/add-todo.md": "cc0efe270004c8fb",
"gsd-core/workflows/add-todo.md": "953314cdfef56bc4",
"gsd-core/workflows/ai-integration-phase.md": "40c217869a06981f",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -306,7 +306,7 @@
"gsd-core/workflows/add-backlog.md": "62431b27424ff1ff",
"gsd-core/workflows/add-phase.md": "27f282bf5b8af59d",
"gsd-core/workflows/add-tests.md": "5cdb15508c4b6075",
"gsd-core/workflows/add-todo.md": "097e171f797737df",
"gsd-core/workflows/add-todo.md": "776e554602e3d1b7",
"gsd-core/workflows/ai-integration-phase.md": "1dfa15d8f28c022d",
"gsd-core/workflows/analyze-dependencies.md": "f799abc00907377f",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -201,7 +201,7 @@
"gsd-core/workflows/add-backlog.md": "98f2dbb7c7cc93c2",
"gsd-core/workflows/add-phase.md": "34e6dd38c25b5b61",
"gsd-core/workflows/add-tests.md": "7f5da4d2a9fa8a1a",
"gsd-core/workflows/add-todo.md": "10253591112b7d06",
"gsd-core/workflows/add-todo.md": "f86f4efa25a5d0d6",
"gsd-core/workflows/ai-integration-phase.md": "ef0c2474cee76b00",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "9fdfe8a7fbcd1417",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "2926457ed07b4500",
"gsd-core/workflows/add-phase.md": "0d90083e9c17bec1",
"gsd-core/workflows/add-tests.md": "37f031c4b3236a32",
"gsd-core/workflows/add-todo.md": "d483ebe8edcf193a",
"gsd-core/workflows/add-todo.md": "1ee0525664fb062f",
"gsd-core/workflows/ai-integration-phase.md": "b79f320d59a68773",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -200,7 +200,7 @@
"gsd-core/workflows/add-backlog.md": "79a453a2270483fb",
"gsd-core/workflows/add-phase.md": "b4c4b32c111a116a",
"gsd-core/workflows/add-tests.md": "02f9e11ad2371c00",
"gsd-core/workflows/add-todo.md": "5fe3ddc3227e0e22",
"gsd-core/workflows/add-todo.md": "8ba173153be63a40",
"gsd-core/workflows/ai-integration-phase.md": "ddab2912d025db65",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "2f21bd575bcedaf1",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "485c4b6673ddf832",
"gsd-core/workflows/add-phase.md": "0d90083e9c17bec1",
"gsd-core/workflows/add-tests.md": "6e54f3d71a202671",
"gsd-core/workflows/add-todo.md": "5082510bf0449204",
"gsd-core/workflows/add-todo.md": "31bc520d77b14139",
"gsd-core/workflows/ai-integration-phase.md": "c85c6afdc64f0da6",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -264,7 +264,7 @@
"gsd-core/workflows/add-backlog.md": "1bc7377b105194fc",
"gsd-core/workflows/add-phase.md": "46e0551ffdd8ce1a",
"gsd-core/workflows/add-tests.md": "24b0d8157a9ccb8b",
"gsd-core/workflows/add-todo.md": "cc0efe270004c8fb",
"gsd-core/workflows/add-todo.md": "953314cdfef56bc4",
"gsd-core/workflows/ai-integration-phase.md": "40c217869a06981f",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "08289088a88d410f",
"gsd-core/workflows/add-phase.md": "5154a5fb3e0d4370",
"gsd-core/workflows/add-tests.md": "b4deb88c74d491c7",
"gsd-core/workflows/add-todo.md": "72fcef9fd0cafda5",
"gsd-core/workflows/add-todo.md": "36b6d7fec9631ec7",
"gsd-core/workflows/ai-integration-phase.md": "90e5f97018715b18",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "2871c5d0a4b671fa",

View File

@@ -167,7 +167,7 @@
"gsd-core/workflows/add-backlog.md": "1bc7377b105194fc",
"gsd-core/workflows/add-phase.md": "46e0551ffdd8ce1a",
"gsd-core/workflows/add-tests.md": "24b0d8157a9ccb8b",
"gsd-core/workflows/add-todo.md": "cc0efe270004c8fb",
"gsd-core/workflows/add-todo.md": "953314cdfef56bc4",
"gsd-core/workflows/ai-integration-phase.md": "40c217869a06981f",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -200,7 +200,7 @@
"gsd-core/workflows/add-backlog.md": "cee41d5fc52d4e37",
"gsd-core/workflows/add-phase.md": "cc63a3e108c9a065",
"gsd-core/workflows/add-tests.md": "7cd945118e83246e",
"gsd-core/workflows/add-todo.md": "5e037b444e657557",
"gsd-core/workflows/add-todo.md": "83426131a56b8e77",
"gsd-core/workflows/ai-integration-phase.md": "afdc7c15f03a95fc",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "aaa1b74e001322b6",

View File

@@ -200,7 +200,7 @@
"gsd-core/workflows/add-backlog.md": "1851e97996d445d5",
"gsd-core/workflows/add-phase.md": "09bdebbc62f3310e",
"gsd-core/workflows/add-tests.md": "8ad08d29415f8f43",
"gsd-core/workflows/add-todo.md": "0fe07cbd29ec252b",
"gsd-core/workflows/add-todo.md": "fefa923ed596225f",
"gsd-core/workflows/ai-integration-phase.md": "d469eb120e52de0f",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "76607add3257cb37",

View File

@@ -200,7 +200,7 @@
"gsd-core/workflows/add-backlog.md": "61169973a10b1321",
"gsd-core/workflows/add-phase.md": "f9b1f7cc229b57f8",
"gsd-core/workflows/add-tests.md": "d51e69bb433535a4",
"gsd-core/workflows/add-todo.md": "cab0d8215579fbdd",
"gsd-core/workflows/add-todo.md": "50d17053c6cfbc31",
"gsd-core/workflows/ai-integration-phase.md": "8948988717506320",
"gsd-core/workflows/analyze-dependencies.md": "77aff48f97fa6f1c",
"gsd-core/workflows/audit-fix.md": "aa052e15623d759d",

View File

@@ -271,7 +271,7 @@
"gsd-core/workflows/add-backlog.md": "1bc7377b105194fc",
"gsd-core/workflows/add-phase.md": "46e0551ffdd8ce1a",
"gsd-core/workflows/add-tests.md": "24b0d8157a9ccb8b",
"gsd-core/workflows/add-todo.md": "cc0efe270004c8fb",
"gsd-core/workflows/add-todo.md": "953314cdfef56bc4",
"gsd-core/workflows/ai-integration-phase.md": "40c217869a06981f",
"gsd-core/workflows/analyze-dependencies.md": "52942af10f140717",
"gsd-core/workflows/audit-fix.md": "eedb2da4bffb7575",

View File

@@ -1013,6 +1013,28 @@ describe('cmdInitTodos', () => {
assert.strictEqual(task1.path, '.planning/todos/pending/task-1.md');
});
// ── #2337: init todos must surface severity too, in parity with list-todos ──
test('surfaces severity when present, omits the key when absent (#2337)', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'tagged.md'),
'title: Crash on save\narea: core\ncreated: 2026-02-25\nseverity: blocker');
fs.writeFileSync(path.join(pendingDir, 'untagged.md'),
'title: Old todo\narea: docs\ncreated: 2026-02-24');
const result = runGsdTools('init todos', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const tagged = output.todos.find(t => t.file === 'tagged.md');
const untagged = output.todos.find(t => t.file === 'untagged.md');
assert.ok(tagged && untagged, 'both todos should be present');
assert.strictEqual(tagged.severity, 'blocker', 'severity surfaced verbatim when present');
assert.ok(!('severity' in untagged),
'severity key ABSENT (backward compatible) for a todo with no severity line');
});
test('area filter returns only matching todos', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });

View File

@@ -2,7 +2,7 @@
"add-backlog.md": 7176,
"add-phase.md": 7247,
"add-tests.md": 16961,
"add-todo.md": 8996,
"add-todo.md": 10734,
"ai-integration-phase.md": 14805,
"analyze-dependencies.md": 3887,
"audit-fix.md": 11717,