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
|
@@ -3,28 +3,37 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const TASKS_PATH = path.resolve(__dirname, '../../../tasks.yml');
|
|
5
5
|
|
|
6
|
-
const VALID_STATUS = new Set(['open', 'done', 'cancelled', 'snoozed']);
|
|
6
|
+
const VALID_STATUS = new Set(['open', 'done', 'cancelled', 'snoozed', 'in_progress']);
|
|
7
7
|
const VALID_PRIORITY = new Set([1, 2, 3]);
|
|
8
8
|
|
|
9
|
+
// Default lane vocabulary. Adapt this to your project — lanes group tasks by
|
|
10
|
+
// primary action, not topic. Update the LANE_KEYWORDS table in
|
|
11
|
+
// tools/tasks/migrate-lanes.js if you change this set.
|
|
12
|
+
const VALID_LANES = new Set(['build', 'outreach', 'discovery', 'infra', 'fundraising', 'meta']);
|
|
13
|
+
|
|
9
14
|
// Minimal YAML reader/writer for our constrained schema.
|
|
10
15
|
// Schema:
|
|
11
16
|
// last_updated: YYYY-MM-DD
|
|
12
17
|
// next_id: N
|
|
18
|
+
// last_triage_at: YYYY-MM-DD # optional — set by /weekly-triage to suppress SessionStart nudge
|
|
13
19
|
// tasks:
|
|
14
20
|
// - id: N
|
|
15
21
|
// created: YYYY-MM-DD
|
|
16
22
|
// due: YYYY-MM-DD | null
|
|
17
|
-
// status: open|done|cancelled|snoozed
|
|
23
|
+
// status: open|done|cancelled|snoozed|in_progress
|
|
18
24
|
// priority: 1|2|3
|
|
25
|
+
// lane: build|outreach|discovery|infra|fundraising|meta
|
|
19
26
|
// tags: [a, b]
|
|
20
27
|
// title: "..."
|
|
21
28
|
// context: "..."
|
|
22
29
|
// done_at: YYYY-MM-DD | null
|
|
23
30
|
// snoozed_until: YYYY-MM-DD | null
|
|
31
|
+
// implications: [docs/path-a.md, docs/path-b.md] # optional — docs that should change when this task closes
|
|
32
|
+
// blocked_by: [TASK-IDs] # optional — task IDs that must close before this one is actionable
|
|
24
33
|
|
|
25
34
|
const FIELD_ORDER = [
|
|
26
|
-
'id', 'created', 'due', 'status', 'priority', 'tags',
|
|
27
|
-
'title', 'context', 'done_at', 'snoozed_until'
|
|
35
|
+
'id', 'created', 'due', 'status', 'priority', 'lane', 'tags',
|
|
36
|
+
'title', 'context', 'done_at', 'snoozed_until', 'implications', 'blocked_by'
|
|
28
37
|
];
|
|
29
38
|
|
|
30
39
|
function today() {
|
|
@@ -53,7 +62,9 @@ function serializeScalar(v) {
|
|
|
53
62
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
54
63
|
if (Array.isArray(v)) {
|
|
55
64
|
if (v.length === 0) return '[]';
|
|
56
|
-
|
|
65
|
+
// Preserve number/boolean primitives so int-arrays (e.g. blocked_by) don't
|
|
66
|
+
// get re-quoted as strings on round-trip.
|
|
67
|
+
return '[' + v.map(x => (typeof x === 'number' || typeof x === 'boolean') ? String(x) : serializeString(x)).join(', ') + ']';
|
|
57
68
|
}
|
|
58
69
|
return serializeString(v);
|
|
59
70
|
}
|
|
@@ -126,6 +137,7 @@ function save(state) {
|
|
|
126
137
|
out.push('# Never hand-edit while today.md has unflushed checkbox changes — flush first.');
|
|
127
138
|
out.push(`last_updated: ${state.last_updated}`);
|
|
128
139
|
out.push(`next_id: ${state.next_id}`);
|
|
140
|
+
if (state.last_triage_at) out.push(`last_triage_at: ${state.last_triage_at}`);
|
|
129
141
|
out.push('tasks:');
|
|
130
142
|
for (const t of state.tasks) {
|
|
131
143
|
let first = true;
|
|
@@ -160,6 +172,6 @@ function resolveSnoozed(state) {
|
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
module.exports = {
|
|
163
|
-
TASKS_PATH, VALID_STATUS, VALID_PRIORITY, FIELD_ORDER,
|
|
175
|
+
TASKS_PATH, VALID_STATUS, VALID_PRIORITY, VALID_LANES, FIELD_ORDER,
|
|
164
176
|
today, load, save, nextId, resolveSnoozed
|
|
165
177
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/migrate-lanes.js — one-shot heuristic backfill of `lane` field.
|
|
3
|
+
// Idempotent: skips tasks that already have a valid lane.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node tools/tasks/migrate-lanes.js # apply heuristic, write tasks.yml
|
|
7
|
+
// node tools/tasks/migrate-lanes.js --dry # show classifications, write nothing
|
|
8
|
+
//
|
|
9
|
+
// Heuristic is intentionally generic. ADAPT the LANE_KEYWORDS table below to
|
|
10
|
+
// your project's vocabulary. Two-pass design: tags first (curated, high
|
|
11
|
+
// signal-to-noise), then title (less curated). Context is NEVER consulted —
|
|
12
|
+
// it often contains incidental words that contaminate classification.
|
|
13
|
+
//
|
|
14
|
+
// Tasks that match no keyword bucket fall back to `meta`. Re-run anytime —
|
|
15
|
+
// already-laned tasks stay put.
|
|
16
|
+
|
|
17
|
+
const tasks = require('./lib/tasks');
|
|
18
|
+
|
|
19
|
+
// Adapt these keyword buckets to your project. Lane order matters when a task
|
|
20
|
+
// matches multiple — the first lane in this object wins.
|
|
21
|
+
const LANE_KEYWORDS = {
|
|
22
|
+
build: [
|
|
23
|
+
'mvp', 'demo', 'arch', 'architecture', 'integrations', 'product',
|
|
24
|
+
'feature', 'build', 'ship', 'design', 'frontend', 'backend', 'api', 'ui'
|
|
25
|
+
],
|
|
26
|
+
outreach: [
|
|
27
|
+
'outreach', 'blitz', 'cold-email', 'cold-dm', 'linkedin', 'template',
|
|
28
|
+
'cadence', 'pitch-copy', 'content', 'post', 'campaign', 'sequence'
|
|
29
|
+
],
|
|
30
|
+
discovery: [
|
|
31
|
+
'warm', 'warm-intro', 'intro', 'intros', 'discovery', 'door-opener',
|
|
32
|
+
'follow-up', 'lead', 'customer', 'prospect', 'champion'
|
|
33
|
+
],
|
|
34
|
+
infra: [
|
|
35
|
+
'infra', 'gcp', 'aws', 'dns', 'domain', 'ci', 'deploy', 'env',
|
|
36
|
+
'secrets', 'database', 'monitoring', 'logging'
|
|
37
|
+
],
|
|
38
|
+
fundraising: [
|
|
39
|
+
'fundraising', 'investor', 'vc', 'angel', 'pitch', 'fund',
|
|
40
|
+
'accelerator', 'yc', 'spc', 'demo-day'
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const argv = process.argv.slice(2);
|
|
45
|
+
const dryRun = argv.includes('--dry');
|
|
46
|
+
|
|
47
|
+
function matchLane(needle) {
|
|
48
|
+
if (!needle) return null;
|
|
49
|
+
const lower = needle.toLowerCase();
|
|
50
|
+
for (const [lane, keywords] of Object.entries(LANE_KEYWORDS)) {
|
|
51
|
+
for (const kw of keywords) {
|
|
52
|
+
const re = new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
|
|
53
|
+
if (re.test(lower)) return lane;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classify(task) {
|
|
60
|
+
if (task.lane && tasks.VALID_LANES.has(task.lane)) return { lane: task.lane, source: 'preserved' };
|
|
61
|
+
// Pass 1: tags (curated, high signal).
|
|
62
|
+
const tagsNeedle = (Array.isArray(task.tags) ? task.tags : []).join(' ');
|
|
63
|
+
const fromTags = matchLane(tagsNeedle);
|
|
64
|
+
if (fromTags) return { lane: fromTags, source: 'tags' };
|
|
65
|
+
// Pass 2: title (less curated).
|
|
66
|
+
const fromTitle = matchLane(task.title || '');
|
|
67
|
+
if (fromTitle) return { lane: fromTitle, source: 'title' };
|
|
68
|
+
return { lane: 'meta', source: 'fallback' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const state = tasks.load();
|
|
72
|
+
let changed = 0;
|
|
73
|
+
let kept = 0;
|
|
74
|
+
const summary = { tags: 0, title: 0, fallback: 0, preserved: 0 };
|
|
75
|
+
|
|
76
|
+
for (const t of state.tasks) {
|
|
77
|
+
const { lane, source } = classify(t);
|
|
78
|
+
summary[source]++;
|
|
79
|
+
if (source === 'preserved') {
|
|
80
|
+
kept++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (t.lane !== lane) {
|
|
84
|
+
if (!dryRun) t.lane = lane;
|
|
85
|
+
changed++;
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
console.log(`[dry] TASK-${t.id} → ${lane} (via ${source}): ${t.title}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!dryRun && changed > 0) tasks.save(state);
|
|
93
|
+
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(`Migration ${dryRun ? '(dry-run) ' : ''}complete.`);
|
|
96
|
+
console.log(` Preserved (already laned): ${kept}`);
|
|
97
|
+
console.log(` Classified via tags: ${summary.tags}`);
|
|
98
|
+
console.log(` Classified via title: ${summary.title}`);
|
|
99
|
+
console.log(` Fell back to meta: ${summary.fallback}`);
|
|
100
|
+
console.log(` Total updated: ${changed}`);
|
|
101
|
+
if (dryRun) {
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log('Re-run without --dry to apply.');
|
|
104
|
+
}
|
|
@@ -57,34 +57,73 @@ function priorityEmoji(p) {
|
|
|
57
57
|
return '';
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// Lane vocabulary (mirrors VALID_LANES in lib/tasks.js). Drives display order
|
|
61
|
+
// in today.md and the human-readable label per lane group. Adapt to your project.
|
|
62
|
+
const LANE_DISPLAY = {
|
|
63
|
+
build: 'Build',
|
|
64
|
+
outreach: 'Outreach',
|
|
65
|
+
discovery: 'Discovery',
|
|
66
|
+
infra: 'Infra',
|
|
67
|
+
fundraising: 'Fundraising',
|
|
68
|
+
meta: 'Meta'
|
|
69
|
+
};
|
|
70
|
+
const LANE_ORDER = ['build', 'outreach', 'discovery', 'infra', 'fundraising', 'meta'];
|
|
71
|
+
const PRIORITY_HEADERS = {
|
|
72
|
+
1: '### ⏫ Today (P1)',
|
|
73
|
+
2: '### 🔼 This week (P2)',
|
|
74
|
+
3: '### 🔽 Backlog (P3)'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function renderTaskLine(t, openIds) {
|
|
61
78
|
const parts = [];
|
|
62
|
-
|
|
79
|
+
const box = t.status === 'in_progress' ? '/' : ' ';
|
|
80
|
+
parts.push(`- [${box}] TASK-${t.id} ${t.title}`);
|
|
63
81
|
if (t.due) parts.push(`📅 ${t.due}`);
|
|
64
82
|
const pe = priorityEmoji(t.priority);
|
|
65
83
|
if (pe) parts.push(pe);
|
|
84
|
+
// Nested hashtag — Obsidian Tasks plugin treats this as a filterable lane group.
|
|
85
|
+
if (t.lane) parts.push(`#lane/${t.lane}`);
|
|
86
|
+
// Surface still-open blockers only — once the blocker closes, the token disappears
|
|
87
|
+
// from the daily surface. flush-today.js ignores this token (LINE_RE matches checkbox + TASK-N).
|
|
88
|
+
if (Array.isArray(t.blocked_by) && t.blocked_by.length) {
|
|
89
|
+
const stillOpen = openIds ? t.blocked_by.filter(id => openIds.has(id)) : t.blocked_by;
|
|
90
|
+
if (stillOpen.length) {
|
|
91
|
+
parts.push(`🚧 blocked-by:TASK-${stillOpen.join(',TASK-')}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
66
94
|
if (Array.isArray(t.tags) && t.tags.length) {
|
|
67
95
|
parts.push(t.tags.map(x => '#' + x).join(' '));
|
|
68
96
|
}
|
|
69
97
|
return parts.join(' ');
|
|
70
98
|
}
|
|
71
99
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
const
|
|
100
|
+
function loadEventsLib() {
|
|
101
|
+
// events.yml is part of the base template, but be defensive in case someone removed it.
|
|
102
|
+
const eventsLib = path.join(ROOT, 'tools/events/lib/events.js');
|
|
103
|
+
if (!fs.existsSync(eventsLib)) return null;
|
|
104
|
+
try { return require(eventsLib); } catch (e) { return null; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildCallsSection() {
|
|
108
|
+
// events.yml is the single source of truth for time-based events.
|
|
109
|
+
const events = loadEventsLib();
|
|
110
|
+
if (!events) return null;
|
|
111
|
+
const calls = events.todayEvents();
|
|
75
112
|
if (calls.length === 0) return null;
|
|
76
113
|
const lines = ['## Calls & meetings'];
|
|
77
|
-
for (const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const note = c.title ? ` (${c.title})` : '';
|
|
81
|
-
lines.push(`- ${time} — ${name} / ${c.company}${note}`);
|
|
114
|
+
for (const e of calls) {
|
|
115
|
+
const time = e.start && e.start.length >= 16 ? e.start.slice(11, 16) : 'TBD';
|
|
116
|
+
lines.push(`- ${time} — ${e.title}`);
|
|
82
117
|
}
|
|
83
118
|
return lines.join('\n');
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
function buildPulseSection(leads, metrics) {
|
|
87
|
-
|
|
122
|
+
const events = loadEventsLib();
|
|
123
|
+
const upcomingEvents = events
|
|
124
|
+
? events.upcoming().filter(e => e.start && e.start.slice(0, 10) > todayStr())
|
|
125
|
+
: [];
|
|
126
|
+
if (!leads.length && !Object.keys(metrics).length && !upcomingEvents.length) return null;
|
|
88
127
|
const lines = ['## Pulse'];
|
|
89
128
|
if (leads.length) {
|
|
90
129
|
const pipeline = leads.filter(r => ['replied', 'call_booked', 'call_done', 'verbal', 'loi'].includes(r.status));
|
|
@@ -94,6 +133,10 @@ function buildPulseSection(leads, metrics) {
|
|
|
94
133
|
if (metrics.outreach_sent !== undefined) {
|
|
95
134
|
lines.push(`- Total sent: ${metrics.outreach_sent}${metrics.connections_sent !== undefined ? ` (${metrics.connections_sent} conn + ${metrics.inmails_sent} inmail)` : ''} · ${metrics.responses || 0} replies · ${metrics.discovery_calls || 0} calls · ${metrics.verbal_commitments || 0} verbal`);
|
|
96
135
|
}
|
|
136
|
+
if (upcomingEvents.length) {
|
|
137
|
+
const fmt = upcomingEvents.slice(0, 5).map(e => `${e.start.slice(0, 10)} ${e.title}`).join(', ');
|
|
138
|
+
lines.push(`- Upcoming events (${upcomingEvents.length}): ${fmt}`);
|
|
139
|
+
}
|
|
97
140
|
if (leads.length) {
|
|
98
141
|
const warm = leads.filter(r => r.status === 'replied' && r.replied_at);
|
|
99
142
|
const stale = warm.filter(r => {
|
|
@@ -111,7 +154,7 @@ function buildPulseSection(leads, metrics) {
|
|
|
111
154
|
|
|
112
155
|
function buildQuerySections(state) {
|
|
113
156
|
const sections = [];
|
|
114
|
-
const open = state.tasks.filter(t => t.status === 'open');
|
|
157
|
+
const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
115
158
|
const byP = p => open.filter(t => t.priority === p).length;
|
|
116
159
|
|
|
117
160
|
const commonOpts = [
|
|
@@ -162,32 +205,62 @@ function buildQuerySections(state) {
|
|
|
162
205
|
}
|
|
163
206
|
|
|
164
207
|
function buildTaskDataSection(state) {
|
|
165
|
-
const open = state.tasks.filter(t => t.status === 'open');
|
|
166
|
-
open.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
let rendered = open;
|
|
175
|
-
if (!showAll) {
|
|
176
|
-
const highMed = open.filter(t => t.priority <= 2);
|
|
177
|
-
const low = open.filter(t => t.priority === 3).slice(0, 5);
|
|
178
|
-
rendered = [...highMed, ...low];
|
|
208
|
+
const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
209
|
+
const openIds = new Set(open.map(t => t.id));
|
|
210
|
+
|
|
211
|
+
// Group by priority, then lane.
|
|
212
|
+
const byPriority = { 1: [], 2: [], 3: [] };
|
|
213
|
+
for (const t of open) {
|
|
214
|
+
const p = byPriority[t.priority] || (byPriority[t.priority] = []);
|
|
215
|
+
p.push(t);
|
|
179
216
|
}
|
|
180
217
|
|
|
181
|
-
const
|
|
182
|
-
return [
|
|
218
|
+
const out = [
|
|
183
219
|
'---',
|
|
184
220
|
'',
|
|
185
221
|
'## Task data',
|
|
186
222
|
'',
|
|
187
223
|
'*Source rows for the queries above. `flush-today.js` reads these. Editable — checkboxes, due dates, priorities, snooze (`🛫 YYYY-MM-DD`) all round-trip. Run `node tools/tasks/flush-today.js` after edits.*',
|
|
188
|
-
''
|
|
189
|
-
|
|
190
|
-
|
|
224
|
+
''
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const priority of [1, 2, 3]) {
|
|
228
|
+
let pTasks = byPriority[priority] || [];
|
|
229
|
+
if (pTasks.length === 0) continue;
|
|
230
|
+
|
|
231
|
+
// P3 (backlog) is collapsed to first 5 unless --all.
|
|
232
|
+
if (priority === 3 && !showAll) pTasks = pTasks.slice(0, 5);
|
|
233
|
+
|
|
234
|
+
out.push(PRIORITY_HEADERS[priority] || `### P${priority}`);
|
|
235
|
+
out.push('');
|
|
236
|
+
|
|
237
|
+
// Group by lane within priority.
|
|
238
|
+
const byLane = {};
|
|
239
|
+
for (const t of pTasks) {
|
|
240
|
+
const lane = t.lane || 'meta';
|
|
241
|
+
(byLane[lane] = byLane[lane] || []).push(t);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const orderedLanes = [
|
|
245
|
+
...LANE_ORDER.filter(l => byLane[l]),
|
|
246
|
+
...Object.keys(byLane).filter(l => !LANE_ORDER.includes(l))
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const lane of orderedLanes) {
|
|
250
|
+
const laneTasks = byLane[lane];
|
|
251
|
+
laneTasks.sort((a, b) => {
|
|
252
|
+
if (a.due && b.due) return a.due.localeCompare(b.due);
|
|
253
|
+
if (a.due) return -1;
|
|
254
|
+
if (b.due) return 1;
|
|
255
|
+
return a.id - b.id;
|
|
256
|
+
});
|
|
257
|
+
out.push(`**${LANE_DISPLAY[lane] || lane}** (${laneTasks.length})`);
|
|
258
|
+
for (const t of laneTasks) out.push(renderTaskLine(t, openIds));
|
|
259
|
+
out.push('');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return out.join('\n').trimEnd();
|
|
191
264
|
}
|
|
192
265
|
|
|
193
266
|
// --- Main ---
|
|
@@ -211,7 +284,7 @@ sections.push(header);
|
|
|
211
284
|
const preamble = `> *Your daily surface. Check the boxes in Obsidian, then flush with \`node tools/tasks/flush-today.js\`.*\n> *Underlying source of truth: \`tasks.yml\`. Never hand-edit that file while this doc has unflushed changes.*`;
|
|
212
285
|
sections.push(preamble);
|
|
213
286
|
|
|
214
|
-
const callsBlock = buildCallsSection(
|
|
287
|
+
const callsBlock = buildCallsSection();
|
|
215
288
|
if (callsBlock) sections.push(callsBlock);
|
|
216
289
|
|
|
217
290
|
const pulseBlock = buildPulseSection(leads, metrics);
|
|
@@ -228,4 +301,8 @@ sections.push(`*Generated ${stamp}. Queries above are live — edit checkboxes a
|
|
|
228
301
|
const content = sections.join('\n\n') + '\n';
|
|
229
302
|
if (!fs.existsSync(path.dirname(TODAY_MD))) fs.mkdirSync(path.dirname(TODAY_MD), { recursive: true });
|
|
230
303
|
fs.writeFileSync(TODAY_MD, content);
|
|
231
|
-
if (!quiet)
|
|
304
|
+
if (!quiet) {
|
|
305
|
+
const openCount = state.tasks.filter(t => t.status === 'open').length;
|
|
306
|
+
const ipCount = state.tasks.filter(t => t.status === 'in_progress').length;
|
|
307
|
+
console.log(`✓ Wrote ${path.relative(ROOT, TODAY_MD)} (${openCount} open + ${ipCount} in_progress)`);
|
|
308
|
+
}
|
|
@@ -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`);
|