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,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/flush-today.js — reconciles docs/today.md checkbox edits back into tasks.yml.
|
|
3
|
+
// Archives the processed file to docs/today-archive/YYYY-MM-DD.md.
|
|
4
|
+
//
|
|
5
|
+
// Recognized checkbox states (Obsidian Tasks plugin conventions):
|
|
6
|
+
// [ ] open
|
|
7
|
+
// [x] done
|
|
8
|
+
// [X] done (case-insensitive)
|
|
9
|
+
// [-] cancelled
|
|
10
|
+
// [/] in-progress (treated as open; surfaces to today section)
|
|
11
|
+
//
|
|
12
|
+
// Tolerated inline metadata (emitted by Obsidian Tasks plugin):
|
|
13
|
+
// ✅ YYYY-MM-DD -> done date (overrides today)
|
|
14
|
+
// 📅 YYYY-MM-DD -> due date (updates if changed)
|
|
15
|
+
// 🛫 YYYY-MM-DD -> snooze-until (sets status=snoozed)
|
|
16
|
+
// ⏫ / 🔼 / 🔽 -> priority (updates if changed)
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const tasks = require('./lib/tasks');
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(__dirname, '../..');
|
|
23
|
+
const TODAY_MD = path.join(ROOT, 'docs/today.md');
|
|
24
|
+
const ARCHIVE_DIR = path.join(ROOT, 'docs/today-archive');
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(TODAY_MD)) {
|
|
27
|
+
console.error(`No ${path.relative(ROOT, TODAY_MD)} to flush. Run render-today.js first.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const text = fs.readFileSync(TODAY_MD, 'utf8');
|
|
32
|
+
const lines = text.split('\n');
|
|
33
|
+
|
|
34
|
+
const state = tasks.load();
|
|
35
|
+
const byId = new Map(state.tasks.map(t => [t.id, t]));
|
|
36
|
+
|
|
37
|
+
const LINE_RE = /^\s*-\s+\[([ xX\-/])\]\s+TASK-(\d+)\b/;
|
|
38
|
+
const DUE_RE = /📅\s*(\d{4}-\d{2}-\d{2})/;
|
|
39
|
+
const DONE_RE = /✅\s*(\d{4}-\d{2}-\d{2})/;
|
|
40
|
+
const SNOOZE_RE = /🛫\s*(\d{4}-\d{2}-\d{2})/;
|
|
41
|
+
|
|
42
|
+
function mapCheckbox(ch) {
|
|
43
|
+
if (ch === ' ') return 'open';
|
|
44
|
+
if (ch === 'x' || ch === 'X') return 'done';
|
|
45
|
+
if (ch === '-') return 'cancelled';
|
|
46
|
+
if (ch === '/') return 'in_progress';
|
|
47
|
+
return 'open';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let changed = 0;
|
|
51
|
+
const changeLog = [];
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const m = line.match(LINE_RE);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const state_ch = m[1];
|
|
57
|
+
const id = parseInt(m[2], 10);
|
|
58
|
+
const task = byId.get(id);
|
|
59
|
+
if (!task) {
|
|
60
|
+
console.warn(`⚠️ TASK-${id} in today.md not found in tasks.yml — skipping`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const newStatus = mapCheckbox(state_ch);
|
|
65
|
+
const dueMatch = line.match(DUE_RE);
|
|
66
|
+
const doneMatch = line.match(DONE_RE);
|
|
67
|
+
const snoozeMatch = line.match(SNOOZE_RE);
|
|
68
|
+
|
|
69
|
+
const prevStatus = task.status;
|
|
70
|
+
const patch = {};
|
|
71
|
+
|
|
72
|
+
if (snoozeMatch) {
|
|
73
|
+
patch.status = 'snoozed';
|
|
74
|
+
patch.snoozed_until = snoozeMatch[1];
|
|
75
|
+
} else if (newStatus === 'done') {
|
|
76
|
+
patch.status = 'done';
|
|
77
|
+
patch.done_at = doneMatch ? doneMatch[1] : tasks.today();
|
|
78
|
+
} else if (newStatus === 'cancelled') {
|
|
79
|
+
patch.status = 'cancelled';
|
|
80
|
+
patch.done_at = doneMatch ? doneMatch[1] : tasks.today();
|
|
81
|
+
} else {
|
|
82
|
+
// newStatus is 'open' or 'in_progress'. Resurrect terminal-state tasks and
|
|
83
|
+
// honor open ↔ in_progress transitions.
|
|
84
|
+
if (prevStatus === 'done' || prevStatus === 'cancelled' || prevStatus === 'snoozed') {
|
|
85
|
+
patch.status = newStatus;
|
|
86
|
+
patch.done_at = null;
|
|
87
|
+
patch.snoozed_until = null;
|
|
88
|
+
} else if (prevStatus !== newStatus) {
|
|
89
|
+
patch.status = newStatus;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (dueMatch && dueMatch[1] !== task.due) {
|
|
94
|
+
patch.due = dueMatch[1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let thisChanged = false;
|
|
98
|
+
for (const k of Object.keys(patch)) {
|
|
99
|
+
if (task[k] !== patch[k]) {
|
|
100
|
+
task[k] = patch[k];
|
|
101
|
+
thisChanged = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (thisChanged) {
|
|
106
|
+
changed++;
|
|
107
|
+
changeLog.push(` TASK-${id} ${prevStatus} → ${task.status}${task.done_at ? ' (' + task.done_at + ')' : ''} · ${task.title}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (changed === 0) {
|
|
112
|
+
console.log('No checkbox changes to flush.');
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
tasks.save(state);
|
|
117
|
+
|
|
118
|
+
if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
119
|
+
const archivePath = path.join(ARCHIVE_DIR, `${tasks.today()}.md`);
|
|
120
|
+
fs.copyFileSync(TODAY_MD, archivePath);
|
|
121
|
+
|
|
122
|
+
console.log(`✓ Flushed ${changed} change(s) to tasks.yml`);
|
|
123
|
+
for (const entry of changeLog) console.log(entry);
|
|
124
|
+
console.log(`✓ Archived today.md → ${path.relative(ROOT, archivePath)}`);
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log('Run `node tools/tasks/render-today.js` to regenerate docs/today.md.');
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const TASKS_PATH = path.resolve(__dirname, '../../../tasks.yml');
|
|
5
|
+
|
|
6
|
+
const VALID_STATUS = new Set(['open', 'done', 'cancelled', 'snoozed', 'in_progress']);
|
|
7
|
+
const VALID_PRIORITY = new Set([1, 2, 3]);
|
|
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
|
+
|
|
14
|
+
// Minimal YAML reader/writer for our constrained schema.
|
|
15
|
+
// Schema:
|
|
16
|
+
// last_updated: YYYY-MM-DD
|
|
17
|
+
// next_id: N
|
|
18
|
+
// last_triage_at: YYYY-MM-DD # optional — set by /weekly-triage to suppress SessionStart nudge
|
|
19
|
+
// tasks:
|
|
20
|
+
// - id: N
|
|
21
|
+
// created: YYYY-MM-DD
|
|
22
|
+
// due: YYYY-MM-DD | null
|
|
23
|
+
// status: open|done|cancelled|snoozed|in_progress
|
|
24
|
+
// priority: 1|2|3
|
|
25
|
+
// lane: build|outreach|discovery|infra|fundraising|meta
|
|
26
|
+
// tags: [a, b]
|
|
27
|
+
// title: "..."
|
|
28
|
+
// context: "..."
|
|
29
|
+
// done_at: YYYY-MM-DD | null
|
|
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
|
|
33
|
+
|
|
34
|
+
const FIELD_ORDER = [
|
|
35
|
+
'id', 'created', 'due', 'status', 'priority', 'lane', 'tags',
|
|
36
|
+
'title', 'context', 'done_at', 'snoozed_until', 'implications', 'blocked_by'
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function today() {
|
|
40
|
+
return new Date().toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseScalar(raw) {
|
|
44
|
+
const s = raw.trim();
|
|
45
|
+
if (s === '' || s === 'null' || s === '~') return null;
|
|
46
|
+
if (s === 'true') return true;
|
|
47
|
+
if (s === 'false') return false;
|
|
48
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
49
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
50
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
51
|
+
}
|
|
52
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
53
|
+
const inner = s.slice(1, -1).trim();
|
|
54
|
+
if (!inner) return [];
|
|
55
|
+
return inner.split(',').map(x => parseScalar(x.trim()));
|
|
56
|
+
}
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serializeScalar(v) {
|
|
61
|
+
if (v === null || v === undefined) return 'null';
|
|
62
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
63
|
+
if (Array.isArray(v)) {
|
|
64
|
+
if (v.length === 0) return '[]';
|
|
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(', ') + ']';
|
|
68
|
+
}
|
|
69
|
+
return serializeString(v);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function serializeString(s) {
|
|
73
|
+
s = String(s);
|
|
74
|
+
if (s === '' || /^(null|true|false|~)$/.test(s) || /^-?\d+$/.test(s)) {
|
|
75
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
76
|
+
}
|
|
77
|
+
if (/[:#\[\]{},&*!|>'"%@`\n]|^[\s-?]/.test(s)) {
|
|
78
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
79
|
+
}
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function load() {
|
|
84
|
+
if (!fs.existsSync(TASKS_PATH)) {
|
|
85
|
+
return { last_updated: today(), next_id: 1, tasks: [] };
|
|
86
|
+
}
|
|
87
|
+
const text = fs.readFileSync(TASKS_PATH, 'utf8');
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
|
|
90
|
+
const result = { last_updated: today(), next_id: 1, tasks: [] };
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < lines.length) {
|
|
93
|
+
const line = lines[i];
|
|
94
|
+
if (/^\s*#/.test(line) || line.trim() === '') { i++; continue; }
|
|
95
|
+
if (/^tasks\s*:\s*$/.test(line)) { i++; break; }
|
|
96
|
+
const m = line.match(/^(\w+)\s*:\s*(.*)$/);
|
|
97
|
+
if (m) {
|
|
98
|
+
result[m[1]] = parseScalar(m[2]);
|
|
99
|
+
}
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let cur = null;
|
|
104
|
+
for (; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i];
|
|
106
|
+
if (/^\s*#/.test(line) || line.trim() === '') continue;
|
|
107
|
+
const listItem = line.match(/^\s*-\s+(\w+)\s*:\s*(.*)$/);
|
|
108
|
+
if (listItem) {
|
|
109
|
+
if (cur) result.tasks.push(cur);
|
|
110
|
+
cur = {};
|
|
111
|
+
cur[listItem[1]] = parseScalar(listItem[2]);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const field = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
|
|
115
|
+
if (field && cur) {
|
|
116
|
+
cur[field[1]] = parseScalar(field[2]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (cur) result.tasks.push(cur);
|
|
120
|
+
|
|
121
|
+
result.next_id = typeof result.next_id === 'number' ? result.next_id : parseInt(result.next_id, 10) || 1;
|
|
122
|
+
result.tasks.forEach(t => {
|
|
123
|
+
if (typeof t.id === 'string') t.id = parseInt(t.id, 10);
|
|
124
|
+
if (typeof t.priority === 'string') t.priority = parseInt(t.priority, 10);
|
|
125
|
+
if (!Array.isArray(t.tags)) t.tags = t.tags ? [t.tags] : [];
|
|
126
|
+
});
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function save(state) {
|
|
131
|
+
state.last_updated = today();
|
|
132
|
+
const maxId = state.tasks.reduce((m, t) => Math.max(m, t.id || 0), 0);
|
|
133
|
+
if (state.next_id <= maxId) state.next_id = maxId + 1;
|
|
134
|
+
|
|
135
|
+
const out = [];
|
|
136
|
+
out.push('# tasks.yml — structured task log. Source of truth for docs/today.md.');
|
|
137
|
+
out.push('# Never hand-edit while today.md has unflushed checkbox changes — flush first.');
|
|
138
|
+
out.push(`last_updated: ${state.last_updated}`);
|
|
139
|
+
out.push(`next_id: ${state.next_id}`);
|
|
140
|
+
if (state.last_triage_at) out.push(`last_triage_at: ${state.last_triage_at}`);
|
|
141
|
+
out.push('tasks:');
|
|
142
|
+
for (const t of state.tasks) {
|
|
143
|
+
let first = true;
|
|
144
|
+
for (const k of FIELD_ORDER) {
|
|
145
|
+
if (!(k in t)) continue;
|
|
146
|
+
const prefix = first ? ' - ' : ' ';
|
|
147
|
+
out.push(`${prefix}${k}: ${serializeScalar(t[k])}`);
|
|
148
|
+
first = false;
|
|
149
|
+
}
|
|
150
|
+
if (first) continue;
|
|
151
|
+
}
|
|
152
|
+
fs.writeFileSync(TASKS_PATH, out.join('\n') + '\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function nextId(state) {
|
|
156
|
+
const id = state.next_id;
|
|
157
|
+
state.next_id = id + 1;
|
|
158
|
+
return id;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveSnoozed(state) {
|
|
162
|
+
const t = today();
|
|
163
|
+
let changed = 0;
|
|
164
|
+
for (const task of state.tasks) {
|
|
165
|
+
if (task.status === 'snoozed' && task.snoozed_until && task.snoozed_until <= t) {
|
|
166
|
+
task.status = 'open';
|
|
167
|
+
task.snoozed_until = null;
|
|
168
|
+
changed++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return changed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
TASKS_PATH, VALID_STATUS, VALID_PRIORITY, VALID_LANES, FIELD_ORDER,
|
|
176
|
+
today, load, save, nextId, resolveSnoozed
|
|
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
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/render-today.js — generates docs/today.md from tasks.yml (+ metrics.yml + optional outreach).
|
|
3
|
+
// Idempotent. Safe to run multiple times per day.
|
|
4
|
+
// Flags: --all (include full backlog instead of first 5), --quiet
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const tasks = require('./lib/tasks');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '../..');
|
|
11
|
+
const TODAY_MD = path.join(ROOT, 'docs/today.md');
|
|
12
|
+
const METRICS_YML = path.join(ROOT, 'metrics.yml');
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const showAll = args.includes('--all');
|
|
16
|
+
const quiet = args.includes('--quiet');
|
|
17
|
+
|
|
18
|
+
function readMetrics() {
|
|
19
|
+
if (!fs.existsSync(METRICS_YML)) return {};
|
|
20
|
+
const text = fs.readFileSync(METRICS_YML, 'utf8');
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const line of text.split('\n')) {
|
|
23
|
+
const m = line.match(/^(\w+)\s*:\s*(.+?)\s*(#.*)?$/);
|
|
24
|
+
if (m) {
|
|
25
|
+
const v = m[2].trim().replace(/^["']|["']$/g, '');
|
|
26
|
+
out[m[1]] = /^-?\d+$/.test(v) ? parseInt(v, 10) : v;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readLeadsLight() {
|
|
33
|
+
// Optional — only available when the outreach add-on is installed.
|
|
34
|
+
const leadsCsv = path.join(ROOT, 'outreach/leads.csv');
|
|
35
|
+
const leadsLib = path.join(ROOT, 'tools/outreach/lib/leads.js');
|
|
36
|
+
if (!fs.existsSync(leadsCsv) || !fs.existsSync(leadsLib)) return [];
|
|
37
|
+
try {
|
|
38
|
+
const leads = require(leadsLib);
|
|
39
|
+
return leads.load();
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function todayStr() {
|
|
46
|
+
return new Date().toISOString().slice(0, 10);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function weekdayName(date) {
|
|
50
|
+
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function priorityEmoji(p) {
|
|
54
|
+
if (p === 1) return '⏫';
|
|
55
|
+
if (p === 2) return '🔼';
|
|
56
|
+
if (p === 3) return '🔽';
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
|
|
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) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
const box = t.status === 'in_progress' ? '/' : ' ';
|
|
80
|
+
parts.push(`- [${box}] TASK-${t.id} ${t.title}`);
|
|
81
|
+
if (t.due) parts.push(`📅 ${t.due}`);
|
|
82
|
+
const pe = priorityEmoji(t.priority);
|
|
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
|
+
}
|
|
94
|
+
if (Array.isArray(t.tags) && t.tags.length) {
|
|
95
|
+
parts.push(t.tags.map(x => '#' + x).join(' '));
|
|
96
|
+
}
|
|
97
|
+
return parts.join(' ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildCallsSection(leads) {
|
|
101
|
+
const today = todayStr();
|
|
102
|
+
const calls = leads.filter(r => r.call_at && r.call_at.startsWith(today) && ['call_booked', 'replied'].includes(r.status));
|
|
103
|
+
if (calls.length === 0) return null;
|
|
104
|
+
const lines = ['## Calls & meetings'];
|
|
105
|
+
for (const c of calls) {
|
|
106
|
+
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || '(unnamed)';
|
|
107
|
+
const time = c.call_at.slice(11, 16) || 'TBD';
|
|
108
|
+
const note = c.title ? ` (${c.title})` : '';
|
|
109
|
+
lines.push(`- ${time} — ${name} / ${c.company}${note}`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildPulseSection(leads, metrics) {
|
|
115
|
+
if (!leads.length && !Object.keys(metrics).length) return null;
|
|
116
|
+
const lines = ['## Pulse'];
|
|
117
|
+
if (leads.length) {
|
|
118
|
+
const pipeline = leads.filter(r => ['replied', 'call_booked', 'call_done', 'verbal', 'loi'].includes(r.status));
|
|
119
|
+
const by = s => pipeline.filter(r => r.status === s).length;
|
|
120
|
+
lines.push(`- Active: ${pipeline.length} (${by('call_booked')} call_booked · ${by('call_done')} call_done · ${by('verbal')} verbal · ${by('replied')} replied)`);
|
|
121
|
+
}
|
|
122
|
+
if (metrics.outreach_sent !== undefined) {
|
|
123
|
+
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`);
|
|
124
|
+
}
|
|
125
|
+
if (leads.length) {
|
|
126
|
+
const warm = leads.filter(r => r.status === 'replied' && r.replied_at);
|
|
127
|
+
const stale = warm.filter(r => {
|
|
128
|
+
const d = new Date(r.replied_at);
|
|
129
|
+
const age = (Date.now() - d.getTime()) / (1000 * 60 * 60 * 24);
|
|
130
|
+
return age >= 2;
|
|
131
|
+
}).slice(0, 6);
|
|
132
|
+
if (stale.length) {
|
|
133
|
+
const names = stale.map(r => [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || r.company).slice(0, 6);
|
|
134
|
+
lines.push(`- Needs reply (replied ≥2d ago): ${names.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return lines.length > 1 ? lines.join('\n') : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildQuerySections(state) {
|
|
141
|
+
const sections = [];
|
|
142
|
+
const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
143
|
+
const byP = p => open.filter(t => t.priority === p).length;
|
|
144
|
+
|
|
145
|
+
const commonOpts = [
|
|
146
|
+
'path includes today.md',
|
|
147
|
+
'not done',
|
|
148
|
+
'sort by due',
|
|
149
|
+
'hide backlink',
|
|
150
|
+
'hide edit button',
|
|
151
|
+
'hide task count'
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
if (byP(1) > 0) {
|
|
155
|
+
sections.push([
|
|
156
|
+
'## Today',
|
|
157
|
+
'```tasks',
|
|
158
|
+
...commonOpts,
|
|
159
|
+
'priority is high',
|
|
160
|
+
'```'
|
|
161
|
+
].join('\n'));
|
|
162
|
+
}
|
|
163
|
+
if (byP(2) > 0) {
|
|
164
|
+
sections.push([
|
|
165
|
+
'## This week',
|
|
166
|
+
'```tasks',
|
|
167
|
+
...commonOpts,
|
|
168
|
+
'priority is medium',
|
|
169
|
+
'```'
|
|
170
|
+
].join('\n'));
|
|
171
|
+
}
|
|
172
|
+
if (byP(3) > 0) {
|
|
173
|
+
sections.push([
|
|
174
|
+
'## Backlog',
|
|
175
|
+
'```tasks',
|
|
176
|
+
...commonOpts,
|
|
177
|
+
'priority is low',
|
|
178
|
+
'```'
|
|
179
|
+
].join('\n'));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const snoozed = state.tasks.filter(t => t.status === 'snoozed');
|
|
183
|
+
if (snoozed.length) {
|
|
184
|
+
snoozed.sort((a, b) => (a.snoozed_until || '').localeCompare(b.snoozed_until || ''));
|
|
185
|
+
const lines = snoozed.slice(0, 5).map(t => `- TASK-${t.id} ${t.title} *(resurfaces ${t.snoozed_until || 'TBD'})*`);
|
|
186
|
+
sections.push('## Snoozed\n' + lines.join('\n'));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return sections;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildTaskDataSection(state) {
|
|
193
|
+
const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
|
|
194
|
+
const openIds = new Set(open.map(t => t.id));
|
|
195
|
+
|
|
196
|
+
// Group by priority, then lane.
|
|
197
|
+
const byPriority = { 1: [], 2: [], 3: [] };
|
|
198
|
+
for (const t of open) {
|
|
199
|
+
const p = byPriority[t.priority] || (byPriority[t.priority] = []);
|
|
200
|
+
p.push(t);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const out = [
|
|
204
|
+
'---',
|
|
205
|
+
'',
|
|
206
|
+
'## Task data',
|
|
207
|
+
'',
|
|
208
|
+
'*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.*',
|
|
209
|
+
''
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const priority of [1, 2, 3]) {
|
|
213
|
+
let pTasks = byPriority[priority] || [];
|
|
214
|
+
if (pTasks.length === 0) continue;
|
|
215
|
+
|
|
216
|
+
// P3 (backlog) is collapsed to first 5 unless --all.
|
|
217
|
+
if (priority === 3 && !showAll) pTasks = pTasks.slice(0, 5);
|
|
218
|
+
|
|
219
|
+
out.push(PRIORITY_HEADERS[priority] || `### P${priority}`);
|
|
220
|
+
out.push('');
|
|
221
|
+
|
|
222
|
+
// Group by lane within priority.
|
|
223
|
+
const byLane = {};
|
|
224
|
+
for (const t of pTasks) {
|
|
225
|
+
const lane = t.lane || 'meta';
|
|
226
|
+
(byLane[lane] = byLane[lane] || []).push(t);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const orderedLanes = [
|
|
230
|
+
...LANE_ORDER.filter(l => byLane[l]),
|
|
231
|
+
...Object.keys(byLane).filter(l => !LANE_ORDER.includes(l))
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
for (const lane of orderedLanes) {
|
|
235
|
+
const laneTasks = byLane[lane];
|
|
236
|
+
laneTasks.sort((a, b) => {
|
|
237
|
+
if (a.due && b.due) return a.due.localeCompare(b.due);
|
|
238
|
+
if (a.due) return -1;
|
|
239
|
+
if (b.due) return 1;
|
|
240
|
+
return a.id - b.id;
|
|
241
|
+
});
|
|
242
|
+
out.push(`**${LANE_DISPLAY[lane] || lane}** (${laneTasks.length})`);
|
|
243
|
+
for (const t of laneTasks) out.push(renderTaskLine(t, openIds));
|
|
244
|
+
out.push('');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return out.join('\n').trimEnd();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Main ---
|
|
252
|
+
|
|
253
|
+
const state = tasks.load();
|
|
254
|
+
const resurfaced = tasks.resolveSnoozed(state);
|
|
255
|
+
if (resurfaced > 0) {
|
|
256
|
+
tasks.save(state);
|
|
257
|
+
if (!quiet) console.log(`↑ Resurfaced ${resurfaced} snoozed task(s) whose date has passed.`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const metrics = readMetrics();
|
|
261
|
+
const leads = readLeadsLight();
|
|
262
|
+
|
|
263
|
+
const date = new Date();
|
|
264
|
+
const header = `# Today · ${todayStr()} ${weekdayName(date)}`;
|
|
265
|
+
|
|
266
|
+
const sections = [];
|
|
267
|
+
sections.push(header);
|
|
268
|
+
|
|
269
|
+
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.*`;
|
|
270
|
+
sections.push(preamble);
|
|
271
|
+
|
|
272
|
+
const callsBlock = buildCallsSection(leads);
|
|
273
|
+
if (callsBlock) sections.push(callsBlock);
|
|
274
|
+
|
|
275
|
+
const pulseBlock = buildPulseSection(leads, metrics);
|
|
276
|
+
if (pulseBlock) sections.push(pulseBlock);
|
|
277
|
+
|
|
278
|
+
for (const s of buildQuerySections(state)) sections.push(s);
|
|
279
|
+
|
|
280
|
+
sections.push(buildTaskDataSection(state));
|
|
281
|
+
|
|
282
|
+
const now = new Date();
|
|
283
|
+
const stamp = `${todayStr()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
284
|
+
sections.push(`*Generated ${stamp}. Queries above are live — edit checkboxes anywhere, then \`node tools/tasks/flush-today.js\`.*`);
|
|
285
|
+
|
|
286
|
+
const content = sections.join('\n\n') + '\n';
|
|
287
|
+
if (!fs.existsSync(path.dirname(TODAY_MD))) fs.mkdirSync(path.dirname(TODAY_MD), { recursive: true });
|
|
288
|
+
fs.writeFileSync(TODAY_MD, content);
|
|
289
|
+
if (!quiet) {
|
|
290
|
+
const openCount = state.tasks.filter(t => t.status === 'open').length;
|
|
291
|
+
const ipCount = state.tasks.filter(t => t.status === 'in_progress').length;
|
|
292
|
+
console.log(`✓ Wrote ${path.relative(ROOT, TODAY_MD)} (${openCount} open + ${ipCount} in_progress)`);
|
|
293
|
+
}
|