create-battle-plan 1.1.2 → 1.2.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.md +15 -0
- package/template/docs/today-archive/.gitkeep +0 -0
- package/template/tasks.yml +5 -0
- package/template/tools/tasks/add.js +57 -0
- package/template/tools/tasks/flush-today.js +122 -0
- package/template/tools/tasks/lib/tasks.js +165 -0
- package/template/tools/tasks/render-today.js +231 -0
- package/template/tools/verify-cascade.sh +16 -0
package/package.json
CHANGED
|
@@ -63,11 +63,15 @@ Then show a **compact metrics table** (all zeros, no targets yet) and transition
|
|
|
63
63
|
|
|
64
64
|
*Skip this on first run (Step 0 handles it). On all subsequent runs, start here.*
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
66
|
+
Run these in parallel:
|
|
67
|
+
- `node tools/tasks/render-today.js --quiet` — regenerate `docs/today.md` from `tasks.yml`
|
|
68
|
+
- Read `metrics.yml` — current numbers
|
|
69
|
+
- Read `docs/today.md` — user's daily surface (open tasks, calls, pulse)
|
|
70
|
+
- Read `docs/battle-plan.md` TL;DR + latest day log *only if needed for deep context*
|
|
69
71
|
- Run `git log --oneline -15` — what changed since last session
|
|
70
72
|
|
|
73
|
+
The battle plan is your orientation layer — read it on demand, not by default. `docs/today.md` is what the user sees, so lead with that.
|
|
74
|
+
|
|
71
75
|
## Step 2: Present the Briefing
|
|
72
76
|
|
|
73
77
|
Print a compact morning report with these sections:
|
|
@@ -99,6 +103,7 @@ End with 2-3 short questions:
|
|
|
99
103
|
## Step 4: Prep the Day
|
|
100
104
|
|
|
101
105
|
After the user answers:
|
|
106
|
+
- If they drop new tasks verbally, run `node tools/tasks/add.js "..." [--due ...] [--tag ...] [--priority 1|2|3]` for each, then re-run `render-today.js`.
|
|
102
107
|
- If they report any updates → run the full cascade (Steps 0-4 from CLAUDE.md)
|
|
103
108
|
- Update the battle plan day log with today's plan
|
|
104
109
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -4,6 +4,21 @@ You are helping manage an interconnected documentation system. Every document st
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## Two-View Model — Read This First
|
|
8
|
+
|
|
9
|
+
**The cascade is your orientation layer. `docs/today.md` is the user's operating surface.**
|
|
10
|
+
|
|
11
|
+
- **Your view (the cascade):** `docs/battle-plan.md` at the top, source docs below it, `metrics.yml` as numeric truth. Narrative, deep, linked. You read and write this freely — it is how you reconstruct project state and cascade new information.
|
|
12
|
+
- **User's view:** `docs/today.md`, generated by `tools/tasks/render-today.js` from `tasks.yml`. Rendered in Obsidian Tasks plugin format — query blocks project pill-styled lists over a raw `## Task data` section at the bottom. The user checks boxes in Obsidian; `tools/tasks/flush-today.js` reconciles those edits back into `tasks.yml`. The user's other primary surface is chat — when they want deep context, they ask, and you traverse the cascade on their behalf.
|
|
13
|
+
|
|
14
|
+
**Rules:**
|
|
15
|
+
- Never grow the battle plan's TL;DR into a wall of prose. Keep header blocks terse; append Daily Log entries chronologically.
|
|
16
|
+
- When the user drops new tasks, add them via `node tools/tasks/add.js "..." [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3]`. Don't bury tasks in battle plan prose.
|
|
17
|
+
- After any task mutation (add, complete, snooze), run `node tools/tasks/render-today.js` so `docs/today.md` stays fresh.
|
|
18
|
+
- `verify-cascade.sh` Check 6 confirms `today.md` is not stale relative to `tasks.yml`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
7
22
|
## The Cascade Protocol
|
|
8
23
|
|
|
9
24
|
**Trigger:** Any incoming information that relates to the project — calls, messages, research, signals, status changes, decisions.
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/add.js — CLI to append a task to tasks.yml.
|
|
3
|
+
// Usage: node tools/tasks/add.js "title" [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3] [--context "..."] [--snooze YYYY-MM-DD]
|
|
4
|
+
|
|
5
|
+
const tasks = require('./lib/tasks');
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const args = { title: null, due: null, tags: [], priority: 2, context: null, snooze: null };
|
|
9
|
+
const positional = [];
|
|
10
|
+
for (let i = 0; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
if (a === '--due') args.due = argv[++i];
|
|
13
|
+
else if (a === '--tag') args.tags.push(argv[++i]);
|
|
14
|
+
else if (a === '--priority') args.priority = parseInt(argv[++i], 10);
|
|
15
|
+
else if (a === '--context') args.context = argv[++i];
|
|
16
|
+
else if (a === '--snooze') args.snooze = argv[++i];
|
|
17
|
+
else positional.push(a);
|
|
18
|
+
}
|
|
19
|
+
args.title = positional.join(' ');
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const args = parseArgs(process.argv.slice(2));
|
|
24
|
+
if (!args.title) {
|
|
25
|
+
console.error('Usage: node tools/tasks/add.js "title" [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3] [--context "..."] [--snooze YYYY-MM-DD]');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
if (!tasks.VALID_PRIORITY.has(args.priority)) {
|
|
29
|
+
console.error(`Invalid priority ${args.priority} — must be 1, 2, or 3.`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (args.due && !/^\d{4}-\d{2}-\d{2}$/.test(args.due)) {
|
|
33
|
+
console.error(`Invalid --due ${args.due} — must be YYYY-MM-DD.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const state = tasks.load();
|
|
38
|
+
const id = tasks.nextId(state);
|
|
39
|
+
const task = {
|
|
40
|
+
id,
|
|
41
|
+
created: tasks.today(),
|
|
42
|
+
due: args.due || null,
|
|
43
|
+
status: args.snooze ? 'snoozed' : 'open',
|
|
44
|
+
priority: args.priority,
|
|
45
|
+
tags: args.tags,
|
|
46
|
+
title: args.title,
|
|
47
|
+
context: args.context || null,
|
|
48
|
+
done_at: null,
|
|
49
|
+
snoozed_until: args.snooze || null
|
|
50
|
+
};
|
|
51
|
+
state.tasks.push(task);
|
|
52
|
+
tasks.save(state);
|
|
53
|
+
|
|
54
|
+
console.log(`✓ Added TASK-${id} (priority ${args.priority}${args.due ? ', due ' + args.due : ''}${args.tags.length ? ', tags ' + args.tags.join(',') : ''})`);
|
|
55
|
+
console.log(` ${args.title}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log('Run `node tools/tasks/render-today.js` to regenerate docs/today.md.');
|
|
@@ -0,0 +1,122 @@
|
|
|
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 'open';
|
|
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
|
+
if (prevStatus === 'done' || prevStatus === 'cancelled' || prevStatus === 'snoozed') {
|
|
83
|
+
patch.status = 'open';
|
|
84
|
+
patch.done_at = null;
|
|
85
|
+
patch.snoozed_until = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (dueMatch && dueMatch[1] !== task.due) {
|
|
90
|
+
patch.due = dueMatch[1];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let thisChanged = false;
|
|
94
|
+
for (const k of Object.keys(patch)) {
|
|
95
|
+
if (task[k] !== patch[k]) {
|
|
96
|
+
task[k] = patch[k];
|
|
97
|
+
thisChanged = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (thisChanged) {
|
|
102
|
+
changed++;
|
|
103
|
+
changeLog.push(` TASK-${id} ${prevStatus} → ${task.status}${task.done_at ? ' (' + task.done_at + ')' : ''} · ${task.title}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (changed === 0) {
|
|
108
|
+
console.log('No checkbox changes to flush.');
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tasks.save(state);
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(ARCHIVE_DIR)) fs.mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
115
|
+
const archivePath = path.join(ARCHIVE_DIR, `${tasks.today()}.md`);
|
|
116
|
+
fs.copyFileSync(TODAY_MD, archivePath);
|
|
117
|
+
|
|
118
|
+
console.log(`✓ Flushed ${changed} change(s) to tasks.yml`);
|
|
119
|
+
for (const entry of changeLog) console.log(entry);
|
|
120
|
+
console.log(`✓ Archived today.md → ${path.relative(ROOT, archivePath)}`);
|
|
121
|
+
console.log('');
|
|
122
|
+
console.log('Run `node tools/tasks/render-today.js` to regenerate docs/today.md.');
|
|
@@ -0,0 +1,165 @@
|
|
|
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']);
|
|
7
|
+
const VALID_PRIORITY = new Set([1, 2, 3]);
|
|
8
|
+
|
|
9
|
+
// Minimal YAML reader/writer for our constrained schema.
|
|
10
|
+
// Schema:
|
|
11
|
+
// last_updated: YYYY-MM-DD
|
|
12
|
+
// next_id: N
|
|
13
|
+
// tasks:
|
|
14
|
+
// - id: N
|
|
15
|
+
// created: YYYY-MM-DD
|
|
16
|
+
// due: YYYY-MM-DD | null
|
|
17
|
+
// status: open|done|cancelled|snoozed
|
|
18
|
+
// priority: 1|2|3
|
|
19
|
+
// tags: [a, b]
|
|
20
|
+
// title: "..."
|
|
21
|
+
// context: "..."
|
|
22
|
+
// done_at: YYYY-MM-DD | null
|
|
23
|
+
// snoozed_until: YYYY-MM-DD | null
|
|
24
|
+
|
|
25
|
+
const FIELD_ORDER = [
|
|
26
|
+
'id', 'created', 'due', 'status', 'priority', 'tags',
|
|
27
|
+
'title', 'context', 'done_at', 'snoozed_until'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function today() {
|
|
31
|
+
return new Date().toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseScalar(raw) {
|
|
35
|
+
const s = raw.trim();
|
|
36
|
+
if (s === '' || s === 'null' || s === '~') return null;
|
|
37
|
+
if (s === 'true') return true;
|
|
38
|
+
if (s === 'false') return false;
|
|
39
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
40
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
41
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
42
|
+
}
|
|
43
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
44
|
+
const inner = s.slice(1, -1).trim();
|
|
45
|
+
if (!inner) return [];
|
|
46
|
+
return inner.split(',').map(x => parseScalar(x.trim()));
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function serializeScalar(v) {
|
|
52
|
+
if (v === null || v === undefined) return 'null';
|
|
53
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
54
|
+
if (Array.isArray(v)) {
|
|
55
|
+
if (v.length === 0) return '[]';
|
|
56
|
+
return '[' + v.map(x => serializeString(x)).join(', ') + ']';
|
|
57
|
+
}
|
|
58
|
+
return serializeString(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serializeString(s) {
|
|
62
|
+
s = String(s);
|
|
63
|
+
if (s === '' || /^(null|true|false|~)$/.test(s) || /^-?\d+$/.test(s)) {
|
|
64
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
65
|
+
}
|
|
66
|
+
if (/[:#\[\]{},&*!|>'"%@`\n]|^[\s-?]/.test(s)) {
|
|
67
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function load() {
|
|
73
|
+
if (!fs.existsSync(TASKS_PATH)) {
|
|
74
|
+
return { last_updated: today(), next_id: 1, tasks: [] };
|
|
75
|
+
}
|
|
76
|
+
const text = fs.readFileSync(TASKS_PATH, 'utf8');
|
|
77
|
+
const lines = text.split('\n');
|
|
78
|
+
|
|
79
|
+
const result = { last_updated: today(), next_id: 1, tasks: [] };
|
|
80
|
+
let i = 0;
|
|
81
|
+
while (i < lines.length) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
if (/^\s*#/.test(line) || line.trim() === '') { i++; continue; }
|
|
84
|
+
if (/^tasks\s*:\s*$/.test(line)) { i++; break; }
|
|
85
|
+
const m = line.match(/^(\w+)\s*:\s*(.*)$/);
|
|
86
|
+
if (m) {
|
|
87
|
+
result[m[1]] = parseScalar(m[2]);
|
|
88
|
+
}
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let cur = null;
|
|
93
|
+
for (; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
if (/^\s*#/.test(line) || line.trim() === '') continue;
|
|
96
|
+
const listItem = line.match(/^\s*-\s+(\w+)\s*:\s*(.*)$/);
|
|
97
|
+
if (listItem) {
|
|
98
|
+
if (cur) result.tasks.push(cur);
|
|
99
|
+
cur = {};
|
|
100
|
+
cur[listItem[1]] = parseScalar(listItem[2]);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const field = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
|
|
104
|
+
if (field && cur) {
|
|
105
|
+
cur[field[1]] = parseScalar(field[2]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (cur) result.tasks.push(cur);
|
|
109
|
+
|
|
110
|
+
result.next_id = typeof result.next_id === 'number' ? result.next_id : parseInt(result.next_id, 10) || 1;
|
|
111
|
+
result.tasks.forEach(t => {
|
|
112
|
+
if (typeof t.id === 'string') t.id = parseInt(t.id, 10);
|
|
113
|
+
if (typeof t.priority === 'string') t.priority = parseInt(t.priority, 10);
|
|
114
|
+
if (!Array.isArray(t.tags)) t.tags = t.tags ? [t.tags] : [];
|
|
115
|
+
});
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function save(state) {
|
|
120
|
+
state.last_updated = today();
|
|
121
|
+
const maxId = state.tasks.reduce((m, t) => Math.max(m, t.id || 0), 0);
|
|
122
|
+
if (state.next_id <= maxId) state.next_id = maxId + 1;
|
|
123
|
+
|
|
124
|
+
const out = [];
|
|
125
|
+
out.push('# tasks.yml — structured task log. Source of truth for docs/today.md.');
|
|
126
|
+
out.push('# Never hand-edit while today.md has unflushed checkbox changes — flush first.');
|
|
127
|
+
out.push(`last_updated: ${state.last_updated}`);
|
|
128
|
+
out.push(`next_id: ${state.next_id}`);
|
|
129
|
+
out.push('tasks:');
|
|
130
|
+
for (const t of state.tasks) {
|
|
131
|
+
let first = true;
|
|
132
|
+
for (const k of FIELD_ORDER) {
|
|
133
|
+
if (!(k in t)) continue;
|
|
134
|
+
const prefix = first ? ' - ' : ' ';
|
|
135
|
+
out.push(`${prefix}${k}: ${serializeScalar(t[k])}`);
|
|
136
|
+
first = false;
|
|
137
|
+
}
|
|
138
|
+
if (first) continue;
|
|
139
|
+
}
|
|
140
|
+
fs.writeFileSync(TASKS_PATH, out.join('\n') + '\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function nextId(state) {
|
|
144
|
+
const id = state.next_id;
|
|
145
|
+
state.next_id = id + 1;
|
|
146
|
+
return id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveSnoozed(state) {
|
|
150
|
+
const t = today();
|
|
151
|
+
let changed = 0;
|
|
152
|
+
for (const task of state.tasks) {
|
|
153
|
+
if (task.status === 'snoozed' && task.snoozed_until && task.snoozed_until <= t) {
|
|
154
|
+
task.status = 'open';
|
|
155
|
+
task.snoozed_until = null;
|
|
156
|
+
changed++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return changed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
TASKS_PATH, VALID_STATUS, VALID_PRIORITY, FIELD_ORDER,
|
|
164
|
+
today, load, save, nextId, resolveSnoozed
|
|
165
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
function renderTaskLine(t) {
|
|
61
|
+
const parts = [];
|
|
62
|
+
parts.push(`- [ ] TASK-${t.id} ${t.title}`);
|
|
63
|
+
if (t.due) parts.push(`📅 ${t.due}`);
|
|
64
|
+
const pe = priorityEmoji(t.priority);
|
|
65
|
+
if (pe) parts.push(pe);
|
|
66
|
+
if (Array.isArray(t.tags) && t.tags.length) {
|
|
67
|
+
parts.push(t.tags.map(x => '#' + x).join(' '));
|
|
68
|
+
}
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|
|
71
|
+
|
|
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));
|
|
75
|
+
if (calls.length === 0) return null;
|
|
76
|
+
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}`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildPulseSection(leads, metrics) {
|
|
87
|
+
if (!leads.length && !Object.keys(metrics).length) return null;
|
|
88
|
+
const lines = ['## Pulse'];
|
|
89
|
+
if (leads.length) {
|
|
90
|
+
const pipeline = leads.filter(r => ['replied', 'call_booked', 'call_done', 'verbal', 'loi'].includes(r.status));
|
|
91
|
+
const by = s => pipeline.filter(r => r.status === s).length;
|
|
92
|
+
lines.push(`- Active: ${pipeline.length} (${by('call_booked')} call_booked · ${by('call_done')} call_done · ${by('verbal')} verbal · ${by('replied')} replied)`);
|
|
93
|
+
}
|
|
94
|
+
if (metrics.outreach_sent !== undefined) {
|
|
95
|
+
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
|
+
}
|
|
97
|
+
if (leads.length) {
|
|
98
|
+
const warm = leads.filter(r => r.status === 'replied' && r.replied_at);
|
|
99
|
+
const stale = warm.filter(r => {
|
|
100
|
+
const d = new Date(r.replied_at);
|
|
101
|
+
const age = (Date.now() - d.getTime()) / (1000 * 60 * 60 * 24);
|
|
102
|
+
return age >= 2;
|
|
103
|
+
}).slice(0, 6);
|
|
104
|
+
if (stale.length) {
|
|
105
|
+
const names = stale.map(r => [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || r.company).slice(0, 6);
|
|
106
|
+
lines.push(`- Needs reply (replied ≥2d ago): ${names.join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.length > 1 ? lines.join('\n') : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildQuerySections(state) {
|
|
113
|
+
const sections = [];
|
|
114
|
+
const open = state.tasks.filter(t => t.status === 'open');
|
|
115
|
+
const byP = p => open.filter(t => t.priority === p).length;
|
|
116
|
+
|
|
117
|
+
const commonOpts = [
|
|
118
|
+
'path includes today.md',
|
|
119
|
+
'not done',
|
|
120
|
+
'sort by due',
|
|
121
|
+
'hide backlink',
|
|
122
|
+
'hide edit button',
|
|
123
|
+
'hide task count'
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
if (byP(1) > 0) {
|
|
127
|
+
sections.push([
|
|
128
|
+
'## Today',
|
|
129
|
+
'```tasks',
|
|
130
|
+
...commonOpts,
|
|
131
|
+
'priority is high',
|
|
132
|
+
'```'
|
|
133
|
+
].join('\n'));
|
|
134
|
+
}
|
|
135
|
+
if (byP(2) > 0) {
|
|
136
|
+
sections.push([
|
|
137
|
+
'## This week',
|
|
138
|
+
'```tasks',
|
|
139
|
+
...commonOpts,
|
|
140
|
+
'priority is medium',
|
|
141
|
+
'```'
|
|
142
|
+
].join('\n'));
|
|
143
|
+
}
|
|
144
|
+
if (byP(3) > 0) {
|
|
145
|
+
sections.push([
|
|
146
|
+
'## Backlog',
|
|
147
|
+
'```tasks',
|
|
148
|
+
...commonOpts,
|
|
149
|
+
'priority is low',
|
|
150
|
+
'```'
|
|
151
|
+
].join('\n'));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const snoozed = state.tasks.filter(t => t.status === 'snoozed');
|
|
155
|
+
if (snoozed.length) {
|
|
156
|
+
snoozed.sort((a, b) => (a.snoozed_until || '').localeCompare(b.snoozed_until || ''));
|
|
157
|
+
const lines = snoozed.slice(0, 5).map(t => `- TASK-${t.id} ${t.title} *(resurfaces ${t.snoozed_until || 'TBD'})*`);
|
|
158
|
+
sections.push('## Snoozed\n' + lines.join('\n'));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return sections;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
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];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const raw = rendered.map(renderTaskLine).join('\n');
|
|
182
|
+
return [
|
|
183
|
+
'---',
|
|
184
|
+
'',
|
|
185
|
+
'## Task data',
|
|
186
|
+
'',
|
|
187
|
+
'*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');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- Main ---
|
|
194
|
+
|
|
195
|
+
const state = tasks.load();
|
|
196
|
+
const resurfaced = tasks.resolveSnoozed(state);
|
|
197
|
+
if (resurfaced > 0) {
|
|
198
|
+
tasks.save(state);
|
|
199
|
+
if (!quiet) console.log(`↑ Resurfaced ${resurfaced} snoozed task(s) whose date has passed.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const metrics = readMetrics();
|
|
203
|
+
const leads = readLeadsLight();
|
|
204
|
+
|
|
205
|
+
const date = new Date();
|
|
206
|
+
const header = `# Today · ${todayStr()} ${weekdayName(date)}`;
|
|
207
|
+
|
|
208
|
+
const sections = [];
|
|
209
|
+
sections.push(header);
|
|
210
|
+
|
|
211
|
+
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
|
+
sections.push(preamble);
|
|
213
|
+
|
|
214
|
+
const callsBlock = buildCallsSection(leads);
|
|
215
|
+
if (callsBlock) sections.push(callsBlock);
|
|
216
|
+
|
|
217
|
+
const pulseBlock = buildPulseSection(leads, metrics);
|
|
218
|
+
if (pulseBlock) sections.push(pulseBlock);
|
|
219
|
+
|
|
220
|
+
for (const s of buildQuerySections(state)) sections.push(s);
|
|
221
|
+
|
|
222
|
+
sections.push(buildTaskDataSection(state));
|
|
223
|
+
|
|
224
|
+
const now = new Date();
|
|
225
|
+
const stamp = `${todayStr()} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
226
|
+
sections.push(`*Generated ${stamp}. Queries above are live — edit checkboxes anywhere, then \`node tools/tasks/flush-today.js\`.*`);
|
|
227
|
+
|
|
228
|
+
const content = sections.join('\n\n') + '\n';
|
|
229
|
+
if (!fs.existsSync(path.dirname(TODAY_MD))) fs.mkdirSync(path.dirname(TODAY_MD), { recursive: true });
|
|
230
|
+
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))`);
|
|
@@ -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 "========================="
|