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.
@@ -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
- return '[' + v.map(x => serializeString(x)).join(', ') + ']';
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
- function renderTaskLine(t) {
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
- parts.push(`- [ ] TASK-${t.id} ${t.title}`);
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 buildCallsSection(leads) {
73
- const today = todayStr();
74
- const calls = leads.filter(r => r.call_at && r.call_at.startsWith(today) && ['call_booked', 'replied'].includes(r.status));
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 c of calls) {
78
- const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || '(unnamed)';
79
- const time = c.call_at.slice(11, 16) || 'TBD';
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
- if (!leads.length && !Object.keys(metrics).length) return null;
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.sort((a, b) => {
167
- if (a.priority !== b.priority) return a.priority - b.priority;
168
- if (a.due && b.due) return a.due.localeCompare(b.due);
169
- if (a.due) return -1;
170
- if (b.due) return 1;
171
- return a.id - b.id;
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 raw = rendered.map(renderTaskLine).join('\n');
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
- raw
190
- ].join('\n');
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(leads);
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) console.log(`✓ Wrote ${path.relative(ROOT, TODAY_MD)} (${state.tasks.filter(t => t.status === 'open').length} open task(s))`);
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`);