create-battle-plan 1.1.2 → 1.3.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 +8 -3
- package/template/.claude/commands/weekly-triage.md +93 -0
- package/template/.claude/commands/wrap-up.md +23 -0
- package/template/.claude/settings.json +14 -1
- package/template/CLAUDE.md +85 -0
- package/template/docs/today-archive/.gitkeep +0 -0
- package/template/tasks.yml +5 -0
- package/template/tools/tasks/add.js +96 -0
- package/template/tools/tasks/archive.js +194 -0
- package/template/tools/tasks/flush-today.js +126 -0
- package/template/tools/tasks/lib/tasks.js +177 -0
- package/template/tools/tasks/migrate-lanes.js +104 -0
- package/template/tools/tasks/render-today.js +293 -0
- package/template/tools/tasks/triage-due.js +74 -0
- package/template/tools/tasks/triage.js +302 -0
- package/template/tools/verify-cascade.sh +16 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/triage-due.js — lightweight SessionStart-hook nudge.
|
|
3
|
+
//
|
|
4
|
+
// Reads tasks.yml only. No git scans, no LLM calls, no per-doc checks.
|
|
5
|
+
// Silent by default — outputs nothing when triage isn't due.
|
|
6
|
+
// Only surfaces when one of the trigger conditions actually fires.
|
|
7
|
+
//
|
|
8
|
+
// Trigger conditions:
|
|
9
|
+
// 1. Time-based: last_triage_at >= TRIAGE_INTERVAL_DAYS ago, or never triaged with ≥30 open tasks
|
|
10
|
+
// 2. Stale-task: ≥ STALE_TASK_THRESHOLD open tasks with age ≥ STALE_AGE_DAYS
|
|
11
|
+
// 3. Volume: ≥ VOLUME_THRESHOLD open total
|
|
12
|
+
//
|
|
13
|
+
// Adjust thresholds for your project's task scale.
|
|
14
|
+
//
|
|
15
|
+
// Flags:
|
|
16
|
+
// --explain write all signals to stderr (debug)
|
|
17
|
+
// --quiet suppress nudge output (testing)
|
|
18
|
+
|
|
19
|
+
const tasks = require('./lib/tasks');
|
|
20
|
+
|
|
21
|
+
const TRIAGE_INTERVAL_DAYS = 7;
|
|
22
|
+
const STALE_TASK_THRESHOLD = 20;
|
|
23
|
+
const STALE_AGE_DAYS = 14;
|
|
24
|
+
const VOLUME_THRESHOLD = 60;
|
|
25
|
+
const NEVER_TRIAGED_MIN_OPEN = 30;
|
|
26
|
+
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const explain = argv.includes('--explain');
|
|
29
|
+
const quiet = argv.includes('--quiet');
|
|
30
|
+
|
|
31
|
+
function daysBetween(a, b) {
|
|
32
|
+
const ad = new Date(a);
|
|
33
|
+
const bd = new Date(b);
|
|
34
|
+
return Math.floor((bd - ad) / 86400000);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const state = tasks.load();
|
|
38
|
+
const today = tasks.today();
|
|
39
|
+
const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
40
|
+
const stale = open.filter(t => {
|
|
41
|
+
if (!t.created) return false;
|
|
42
|
+
return daysBetween(t.created, today) >= STALE_AGE_DAYS;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const triggers = [];
|
|
46
|
+
|
|
47
|
+
if (state.last_triage_at) {
|
|
48
|
+
const sinceDays = daysBetween(state.last_triage_at, today);
|
|
49
|
+
if (sinceDays >= TRIAGE_INTERVAL_DAYS) {
|
|
50
|
+
triggers.push({ kind: 'time', detail: `last triage ${sinceDays}d ago (interval ${TRIAGE_INTERVAL_DAYS}d)` });
|
|
51
|
+
}
|
|
52
|
+
} else if (open.length >= NEVER_TRIAGED_MIN_OPEN) {
|
|
53
|
+
triggers.push({ kind: 'never_triaged', detail: `no last_triage_at recorded, ${open.length} open tasks` });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (stale.length >= STALE_TASK_THRESHOLD) {
|
|
57
|
+
triggers.push({ kind: 'stale', detail: `${stale.length} open tasks ≥${STALE_AGE_DAYS}d old` });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (open.length >= VOLUME_THRESHOLD) {
|
|
61
|
+
triggers.push({ kind: 'volume', detail: `${open.length} open tasks (threshold ${VOLUME_THRESHOLD})` });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (explain) {
|
|
65
|
+
process.stderr.write(`triage-due check @ ${today}\n`);
|
|
66
|
+
process.stderr.write(` open: ${open.length}, stale (≥${STALE_AGE_DAYS}d): ${stale.length}\n`);
|
|
67
|
+
process.stderr.write(` last_triage_at: ${state.last_triage_at || '(never)'}\n`);
|
|
68
|
+
process.stderr.write(` triggers: ${triggers.length ? triggers.map(t => t.kind).join(', ') : 'none'}\n`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (triggers.length === 0 || quiet) process.exit(0);
|
|
72
|
+
|
|
73
|
+
const reasons = triggers.map(t => t.detail).join('; ');
|
|
74
|
+
process.stdout.write(`📋 Weekly triage is due — ${reasons}. Run \`/weekly-triage\` when convenient.\n`);
|
|
@@ -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
|
+
}
|
|
@@ -136,6 +136,22 @@ while IFS= read -r doc; do
|
|
|
136
136
|
done < <(grep -oE '\(→ [^)]+\)' "$doc" 2>/dev/null || true)
|
|
137
137
|
done < <(find "$DOCS_DIR" -name "*.md" -not -path "*/examples/*" 2>/dev/null)
|
|
138
138
|
|
|
139
|
+
# --- Check 6: today.md freshness (task subsystem) ---
|
|
140
|
+
echo ""
|
|
141
|
+
echo "--- Check 6: today.md Freshness ---"
|
|
142
|
+
|
|
143
|
+
TASKS_YML="$REPO_ROOT/tasks.yml"
|
|
144
|
+
TODAY_MD="$DOCS_DIR/today.md"
|
|
145
|
+
if [ -f "$TASKS_YML" ] && [ -f "$TODAY_MD" ]; then
|
|
146
|
+
if [ "$TASKS_YML" -nt "$TODAY_MD" ]; then
|
|
147
|
+
echo "WARNING: tasks.yml is newer than docs/today.md — run \`node tools/tasks/render-today.js\`"
|
|
148
|
+
WARNINGS=$((WARNINGS + 1))
|
|
149
|
+
fi
|
|
150
|
+
elif [ -f "$TASKS_YML" ] && [ ! -f "$TODAY_MD" ]; then
|
|
151
|
+
echo "WARNING: tasks.yml exists but docs/today.md does not — run \`node tools/tasks/render-today.js\`"
|
|
152
|
+
WARNINGS=$((WARNINGS + 1))
|
|
153
|
+
fi
|
|
154
|
+
|
|
139
155
|
# --- Summary ---
|
|
140
156
|
echo ""
|
|
141
157
|
echo "========================="
|