create-battle-plan 1.2.0 → 1.4.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/package.json +1 -1
- package/template/.claude/commands/good-morning.md +13 -0
- package/template/.claude/commands/weekly-triage.md +93 -0
- package/template/.claude/commands/wrap-up.md +59 -0
- package/template/.claude/settings.json +14 -1
- package/template/CLAUDE.md +144 -179
- package/template/CLAUDE.md.backup-2026-05-11 +310 -0
- package/template/events-archive.yml +5 -0
- package/template/events.yml +5 -0
- package/template/tools/events/add.js +64 -0
- package/template/tools/events/archive.js +55 -0
- package/template/tools/events/due-for-gate.js +31 -0
- package/template/tools/events/lib/events.js +221 -0
- package/template/tools/events/migrate-from-csv.js +90 -0
- package/template/tools/events/upcoming.js +33 -0
- package/template/tools/tasks/add.js +43 -4
- package/template/tools/tasks/archive.js +194 -0
- package/template/tools/tasks/flush-today.js +6 -2
- package/template/tools/tasks/lib/tasks.js +18 -6
- package/template/tools/tasks/migrate-lanes.js +104 -0
- package/template/tools/tasks/render-today.js +110 -33
- package/template/tools/tasks/triage-due.js +74 -0
- package/template/tools/tasks/triage.js +302 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/triage.js — read-only triage report.
|
|
3
|
+
// Surfaces overdue/stale tasks, recent commits mentioning each task,
|
|
4
|
+
// and "implications drift" (linked doc untouched since task created).
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// node tools/tasks/triage.js # markdown to stdout
|
|
8
|
+
// node tools/tasks/triage.js --json # JSON for programmatic consumers
|
|
9
|
+
// node tools/tasks/triage.js --lane build # filter to one lane
|
|
10
|
+
// node tools/tasks/triage.js --stale-days 14 # tighten staleness threshold (default 7)
|
|
11
|
+
//
|
|
12
|
+
// Strictly read-only. Does NOT mutate tasks.yml.
|
|
13
|
+
// Decisions are applied by the /weekly-triage skill via Edit calls.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { execSync } = require('child_process');
|
|
18
|
+
const tasks = require('./lib/tasks');
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(__dirname, '../..');
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const asJson = argv.includes('--json');
|
|
24
|
+
const laneIdx = argv.indexOf('--lane');
|
|
25
|
+
const laneFilter = laneIdx >= 0 ? argv[laneIdx + 1] : null;
|
|
26
|
+
const staleIdx = argv.indexOf('--stale-days');
|
|
27
|
+
const STALE_DAYS = staleIdx >= 0 ? parseInt(argv[staleIdx + 1], 10) : 7;
|
|
28
|
+
|
|
29
|
+
function daysBetween(a, b) {
|
|
30
|
+
const ad = new Date(a);
|
|
31
|
+
const bd = new Date(b);
|
|
32
|
+
return Math.floor((bd - ad) / 86400000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fileLastMod(relPath) {
|
|
36
|
+
const abs = path.join(ROOT, relPath);
|
|
37
|
+
if (!fs.existsSync(abs)) return null;
|
|
38
|
+
try {
|
|
39
|
+
const out = execSync(
|
|
40
|
+
`git -C "${ROOT}" log -1 --format=%cI -- "${relPath}"`,
|
|
41
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
42
|
+
).trim();
|
|
43
|
+
return out ? out.slice(0, 10) : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function commitsMentioning(id) {
|
|
50
|
+
try {
|
|
51
|
+
const out = execSync(
|
|
52
|
+
`git -C "${ROOT}" log --since="60 days ago" --pretty=format:"%h %s" --grep="TASK-${id}\\b" -i`,
|
|
53
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
54
|
+
);
|
|
55
|
+
return out.trim().split('\n').filter(Boolean).slice(0, 3);
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build a {YYYY-MM-DD: "Day N — title"} map by scanning docs/battle-plan.md for headings.
|
|
62
|
+
// Three formats accepted (covers our actual heading conventions and most plausible variants).
|
|
63
|
+
let _battlePlanDayMap = null;
|
|
64
|
+
function battlePlanDayMap() {
|
|
65
|
+
if (_battlePlanDayMap !== null) return _battlePlanDayMap;
|
|
66
|
+
_battlePlanDayMap = {};
|
|
67
|
+
const bp = path.join(ROOT, 'docs/battle-plan.md');
|
|
68
|
+
if (!fs.existsSync(bp)) return _battlePlanDayMap;
|
|
69
|
+
const text = fs.readFileSync(bp, 'utf8');
|
|
70
|
+
for (const line of text.split('\n')) {
|
|
71
|
+
// Format A: "### Day N — Weekday Month D *(... · YYYY-MM-DD)*"
|
|
72
|
+
let m = line.match(/^#{2,4}\s+Day\s+(\d+)\s*[—-]\s*([^*]+?)\s*\*\([^·]*·\s*(\d{4}-\d{2}-\d{2})\)\*\s*$/);
|
|
73
|
+
if (m) { _battlePlanDayMap[m[3]] = `Day ${m[1]} — ${m[2].trim()}`; continue; }
|
|
74
|
+
// Format B: "## Day N (YYYY-MM-DD)" or "### Day N (YYYY-MM-DD) — title"
|
|
75
|
+
m = line.match(/^#{2,4}\s+Day\s+(\d+)\s*\((\d{4}-\d{2}-\d{2})\)\s*(?:[—-]\s*(.+))?$/);
|
|
76
|
+
if (m) { _battlePlanDayMap[m[2]] = `Day ${m[1]}${m[3] ? ' — ' + m[3].trim() : ''}`; continue; }
|
|
77
|
+
// Format C: "## YYYY-MM-DD — title"
|
|
78
|
+
m = line.match(/^#{2,4}\s+(\d{4}-\d{2}-\d{2})\s*(?:[—-]\s*(.+))?$/);
|
|
79
|
+
if (m) { _battlePlanDayMap[m[1]] = m[2] ? m[2].trim() : 'battle-plan entry'; }
|
|
80
|
+
}
|
|
81
|
+
return _battlePlanDayMap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Extract transcript paths referenced from a task's tags + context.
|
|
85
|
+
function transcriptRefs(t) {
|
|
86
|
+
const haystack = [t.context || '', ...(t.tags || [])].join(' ');
|
|
87
|
+
const matches = haystack.match(/docs\/archive\/validation\/transcripts\/[^\s,;)]+/g);
|
|
88
|
+
return matches ? Array.from(new Set(matches)) : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Source/origin context — where did this task come from?
|
|
92
|
+
function sourceContext(t) {
|
|
93
|
+
const out = [];
|
|
94
|
+
if (t.created) {
|
|
95
|
+
const dayLabel = battlePlanDayMap()[t.created];
|
|
96
|
+
if (dayLabel) out.push(`battle-plan: ${dayLabel}`);
|
|
97
|
+
}
|
|
98
|
+
for (const tr of transcriptRefs(t)) out.push(`transcript: ${tr}`);
|
|
99
|
+
const hintTags = (t.tags || []).filter(tag => /^(spawned-by-|from-|call-|h\d+)/i.test(tag));
|
|
100
|
+
if (hintTags.length) out.push(`tags: ${hintTags.join(', ')}`);
|
|
101
|
+
return out.length ? out : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve blocked_by IDs to {id, status, title, open} entries.
|
|
105
|
+
function resolveBlockedBy(t, allTasks) {
|
|
106
|
+
if (!Array.isArray(t.blocked_by) || !t.blocked_by.length) return null;
|
|
107
|
+
const byId = new Map(allTasks.map(x => [x.id, x]));
|
|
108
|
+
return t.blocked_by.map(id => {
|
|
109
|
+
const b = byId.get(id);
|
|
110
|
+
if (!b) return { id, status: 'unknown', title: '?', open: false };
|
|
111
|
+
const open = b.status === 'open' || b.status === 'in_progress';
|
|
112
|
+
return { id, status: b.status, title: (b.title || '').slice(0, 60), open };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function implicationsDrift(t) {
|
|
117
|
+
if (!Array.isArray(t.implications) || !t.implications.length) return null;
|
|
118
|
+
const drift = [];
|
|
119
|
+
for (const docPath of t.implications) {
|
|
120
|
+
const lastMod = fileLastMod(docPath);
|
|
121
|
+
if (!lastMod) {
|
|
122
|
+
drift.push(`${docPath} (file missing)`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (t.created && lastMod < t.created) {
|
|
126
|
+
drift.push(`${docPath} (last modified ${lastMod}, predates task)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return drift.length ? drift : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function suggestion(flags, t) {
|
|
133
|
+
if (flags.blockedByOpen) return 'blocked — chase blocker(s) or demote';
|
|
134
|
+
if (flags.overdue && flags.overdueDays > 14) return 'demote (overdue >14d — losing momentum)';
|
|
135
|
+
if (flags.stale && !t.due) return `snooze ${STALE_DAYS}d or demote (open ${flags.ageDays}d, no due)`;
|
|
136
|
+
if (flags.commitMentions) return 'check if recent commits closed this — done?';
|
|
137
|
+
if (flags.drift) return 'close (no longer needed) or promote (chase the doc)';
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildReport() {
|
|
142
|
+
const state = tasks.load();
|
|
143
|
+
const today = tasks.today();
|
|
144
|
+
|
|
145
|
+
let open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
146
|
+
if (laneFilter) open = open.filter(t => t.lane === laneFilter);
|
|
147
|
+
|
|
148
|
+
const items = [];
|
|
149
|
+
for (const t of open) {
|
|
150
|
+
const ageDays = t.created ? daysBetween(t.created, today) : 0;
|
|
151
|
+
const overdueDays = t.due ? daysBetween(t.due, today) : 0;
|
|
152
|
+
const blockers = resolveBlockedBy(t, state.tasks);
|
|
153
|
+
const blockedByOpen = blockers ? blockers.some(b => b.open) : false;
|
|
154
|
+
const flags = {
|
|
155
|
+
overdue: t.due && overdueDays > 0,
|
|
156
|
+
overdueDays,
|
|
157
|
+
// Suppress the stale flag when the only reason a task is sitting open is a deliberate blocker.
|
|
158
|
+
stale: !t.due && ageDays >= STALE_DAYS && !blockedByOpen,
|
|
159
|
+
ageDays,
|
|
160
|
+
commitMentions: false,
|
|
161
|
+
drift: false,
|
|
162
|
+
blockedByOpen
|
|
163
|
+
};
|
|
164
|
+
const commits = commitsMentioning(t.id);
|
|
165
|
+
flags.commitMentions = commits.length > 0;
|
|
166
|
+
const drift = implicationsDrift(t);
|
|
167
|
+
flags.drift = drift !== null;
|
|
168
|
+
const source = sourceContext(t);
|
|
169
|
+
|
|
170
|
+
items.push({
|
|
171
|
+
id: t.id,
|
|
172
|
+
title: t.title,
|
|
173
|
+
context: t.context || null,
|
|
174
|
+
tags: t.tags || [],
|
|
175
|
+
lane: t.lane || 'meta',
|
|
176
|
+
priority: t.priority,
|
|
177
|
+
status: t.status,
|
|
178
|
+
due: t.due || null,
|
|
179
|
+
created: t.created || null,
|
|
180
|
+
ageDays,
|
|
181
|
+
overdueDays,
|
|
182
|
+
flags,
|
|
183
|
+
commits,
|
|
184
|
+
drift,
|
|
185
|
+
blockers: blockers || [],
|
|
186
|
+
source_context: source || [],
|
|
187
|
+
suggestion: suggestion(flags, t)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
items.sort((a, b) => {
|
|
192
|
+
if (a.flags.overdue !== b.flags.overdue) return a.flags.overdue ? -1 : 1;
|
|
193
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
194
|
+
return b.ageDays - a.ageDays;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const stats = {
|
|
198
|
+
total_open: open.length,
|
|
199
|
+
overdue: items.filter(i => i.flags.overdue).length,
|
|
200
|
+
stale: items.filter(i => i.flags.stale && !i.flags.overdue).length,
|
|
201
|
+
blocked: items.filter(i => i.flags.blockedByOpen).length,
|
|
202
|
+
by_lane: {}
|
|
203
|
+
};
|
|
204
|
+
for (const i of items) {
|
|
205
|
+
stats.by_lane[i.lane] = (stats.by_lane[i.lane] || 0) + 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
generated_at: today,
|
|
210
|
+
last_triage_at: state.last_triage_at || null,
|
|
211
|
+
stale_threshold_days: STALE_DAYS,
|
|
212
|
+
stats,
|
|
213
|
+
items
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderMarkdown(report) {
|
|
218
|
+
const out = [];
|
|
219
|
+
out.push(`# Task Triage Report — ${report.generated_at}`);
|
|
220
|
+
out.push('');
|
|
221
|
+
if (report.last_triage_at) {
|
|
222
|
+
const since = daysBetween(report.last_triage_at, report.generated_at);
|
|
223
|
+
out.push(`*Last triage: ${report.last_triage_at} (${since}d ago).*`);
|
|
224
|
+
} else {
|
|
225
|
+
out.push('*No prior triage recorded.*');
|
|
226
|
+
}
|
|
227
|
+
out.push('');
|
|
228
|
+
out.push('## Stats');
|
|
229
|
+
out.push('');
|
|
230
|
+
out.push(`- Total open: ${report.stats.total_open}`);
|
|
231
|
+
out.push(`- Overdue: ${report.stats.overdue}`);
|
|
232
|
+
out.push(`- Stale (≥${report.stale_threshold_days}d, not overdue, not blocked): ${report.stats.stale}`);
|
|
233
|
+
out.push(`- Blocked by another open task: ${report.stats.blocked}`);
|
|
234
|
+
out.push('- By lane:');
|
|
235
|
+
for (const [lane, n] of Object.entries(report.stats.by_lane)) {
|
|
236
|
+
out.push(` - ${lane}: ${n}`);
|
|
237
|
+
}
|
|
238
|
+
out.push('');
|
|
239
|
+
out.push('---');
|
|
240
|
+
out.push('');
|
|
241
|
+
|
|
242
|
+
for (const i of report.items) {
|
|
243
|
+
const flagBits = [];
|
|
244
|
+
if (i.flags.overdue) flagBits.push(`\`overdue ${i.flags.overdueDays}d\``);
|
|
245
|
+
if (i.flags.stale && !i.flags.overdue) flagBits.push(`\`open ${i.flags.ageDays}d\``);
|
|
246
|
+
if (i.flags.blockedByOpen) {
|
|
247
|
+
const openIds = i.blockers.filter(b => b.open).map(b => b.id);
|
|
248
|
+
flagBits.push(`\`blocked by TASK-${openIds.join(', TASK-')}\``);
|
|
249
|
+
}
|
|
250
|
+
if (i.flags.drift) flagBits.push('`drift`');
|
|
251
|
+
if (i.flags.commitMentions) flagBits.push('`commit-mentioned`');
|
|
252
|
+
const flagStr = flagBits.length ? ' — ' + flagBits.join(' ') : '';
|
|
253
|
+
const dueStr = i.due ? `due ${i.due}` : 'no due';
|
|
254
|
+
out.push(`### TASK-${i.id} (P${i.priority} · ${i.lane} · ${dueStr} · age ${i.ageDays}d)${flagStr}`);
|
|
255
|
+
out.push('');
|
|
256
|
+
out.push(`**${i.title}**`);
|
|
257
|
+
if (i.source_context && i.source_context.length) {
|
|
258
|
+
out.push('');
|
|
259
|
+
out.push(`*Source:* ${i.source_context.join(' · ')}`);
|
|
260
|
+
}
|
|
261
|
+
if (i.context) {
|
|
262
|
+
out.push('');
|
|
263
|
+
out.push(`> ${i.context}`);
|
|
264
|
+
}
|
|
265
|
+
if (i.tags.length) {
|
|
266
|
+
out.push('');
|
|
267
|
+
out.push(`Tags: ${i.tags.map(t => '`' + t + '`').join(' ')}`);
|
|
268
|
+
}
|
|
269
|
+
if (i.blockers && i.blockers.length) {
|
|
270
|
+
const labels = i.blockers.map(b => `TASK-${b.id} [${b.status}]${b.open ? ' 🚧' : ' ✅'} ${b.title}`);
|
|
271
|
+
out.push('');
|
|
272
|
+
out.push(`*🚧 Blocked by:* ${labels.join(' · ')}`);
|
|
273
|
+
}
|
|
274
|
+
if (i.drift) {
|
|
275
|
+
out.push('');
|
|
276
|
+
out.push(`*⚠️ Implications drift:*`);
|
|
277
|
+
for (const d of i.drift) out.push(`- ${d}`);
|
|
278
|
+
}
|
|
279
|
+
if (i.commits.length) {
|
|
280
|
+
out.push('');
|
|
281
|
+
out.push('*Recent commits mentioning this task:*');
|
|
282
|
+
for (const c of i.commits) out.push(`- ${c}`);
|
|
283
|
+
}
|
|
284
|
+
if (i.suggestion) {
|
|
285
|
+
out.push('');
|
|
286
|
+
out.push(`*Suggestion:* ${i.suggestion}`);
|
|
287
|
+
}
|
|
288
|
+
out.push('');
|
|
289
|
+
out.push('Actions: `[done]` · `[snooze N]` · `[demote]` · `[promote]` · `[merge X]` · `[delete]` · `[keep]` · `[lane LANE]` · `[priority N]`');
|
|
290
|
+
out.push('');
|
|
291
|
+
out.push('---');
|
|
292
|
+
out.push('');
|
|
293
|
+
}
|
|
294
|
+
return out.join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const report = buildReport();
|
|
298
|
+
if (asJson) {
|
|
299
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
300
|
+
} else {
|
|
301
|
+
process.stdout.write(renderMarkdown(report));
|
|
302
|
+
}
|