create-battle-plan 1.3.0 → 1.4.1
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/wrap-up.md +36 -0
- package/template/CLAUDE.md +135 -240
- 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/render-today.js +25 -10
- package/template/tools/verify-cascade.sh +289 -7
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/events/archive.js — move gated/completed/cancelled events from events.yml → events-archive.yml.
|
|
3
|
+
// Mirrors tools/tasks/archive.js.
|
|
4
|
+
//
|
|
5
|
+
// An event is archivable when:
|
|
6
|
+
// - status ∈ {done, cancelled, no_show, rescheduled} AND gate_completed_at is set (normal path), OR
|
|
7
|
+
// - end-time > 14 days ago (auto-no-show sweep for stale scheduled events)
|
|
8
|
+
//
|
|
9
|
+
// Flags: --dry-run, --days N (override stale threshold)
|
|
10
|
+
|
|
11
|
+
const E = require('./lib/events');
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const dry = args.includes('--dry-run');
|
|
15
|
+
const daysIdx = args.indexOf('--days');
|
|
16
|
+
const staleDays = daysIdx >= 0 ? parseInt(args[daysIdx + 1], 10) : 14;
|
|
17
|
+
|
|
18
|
+
const cutoff = new Date(Date.now() - staleDays * 86400 * 1000).toISOString();
|
|
19
|
+
const live = E.load();
|
|
20
|
+
const archive = E.loadArchive();
|
|
21
|
+
|
|
22
|
+
const archivable = [];
|
|
23
|
+
const remaining = [];
|
|
24
|
+
for (const e of live.events) {
|
|
25
|
+
const terminal = ['done', 'cancelled', 'no_show', 'rescheduled'].includes(e.status);
|
|
26
|
+
const gated = !!e.gate_completed_at;
|
|
27
|
+
const ref = e.end || e.start;
|
|
28
|
+
const stale = ref && ref < cutoff;
|
|
29
|
+
if (terminal && (gated || stale)) {
|
|
30
|
+
archivable.push(e);
|
|
31
|
+
} else if (!terminal && stale) {
|
|
32
|
+
e.status = 'no_show';
|
|
33
|
+
archivable.push(e);
|
|
34
|
+
} else {
|
|
35
|
+
remaining.push(e);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`Archivable: ${archivable.length} · Remaining in events.yml: ${remaining.length}`);
|
|
40
|
+
for (const e of archivable) {
|
|
41
|
+
console.log(` EVT-${e.id} ${e.start.slice(0, 16).replace('T', ' ')} [${e.status}] ${e.title}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (dry) { console.log('\nDry-run.'); process.exit(0); }
|
|
45
|
+
if (!archivable.length) process.exit(0);
|
|
46
|
+
|
|
47
|
+
const archiveIds = new Set(archive.events.map(e => e.id));
|
|
48
|
+
for (const e of archivable) {
|
|
49
|
+
if (!archiveIds.has(e.id)) archive.events.push(e);
|
|
50
|
+
}
|
|
51
|
+
live.events = remaining;
|
|
52
|
+
|
|
53
|
+
E.save(live);
|
|
54
|
+
E.saveArchive(archive);
|
|
55
|
+
console.log(`\nMoved ${archivable.length} event(s) to events-archive.yml.`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/events/due-for-gate.js — events whose end-time has passed but no gate_completed_at yet.
|
|
3
|
+
// The wrap-up skill walks these one-by-one and asks for: transcript path, hypothesis impacts,
|
|
4
|
+
// spawned tasks, lead status change. Then stamps gate_completed_at and archives.
|
|
5
|
+
// Flags: --json
|
|
6
|
+
// Used by wrap-up (mandatory gate) and good-morning (warns when context-debt > 2 days old).
|
|
7
|
+
|
|
8
|
+
const E = require('./lib/events');
|
|
9
|
+
|
|
10
|
+
const asJson = process.argv.includes('--json');
|
|
11
|
+
const events = E.dueForGate();
|
|
12
|
+
|
|
13
|
+
if (asJson) {
|
|
14
|
+
console.log(JSON.stringify(events, null, 2));
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!events.length) {
|
|
19
|
+
console.log('(no events due for wrap-up gate)');
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(`${events.length} event(s) past end-time, awaiting wrap-up gate:`);
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
const start = e.start.slice(0, 16).replace('T', ' ');
|
|
26
|
+
console.log(` EVT-${e.id} ${start} [${e.type}] ${e.title}`);
|
|
27
|
+
if (e.lead_id) console.log(` lead: ${e.lead_id}`);
|
|
28
|
+
if (!e.transcript_path) console.log(' · transcript: MISSING');
|
|
29
|
+
if (!e.hypothesis_impacts || !e.hypothesis_impacts.length) console.log(' · hypothesis impacts: MISSING');
|
|
30
|
+
if (!e.spawned_tasks || !e.spawned_tasks.length) console.log(' · spawned tasks: none recorded');
|
|
31
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// tools/events/lib/events.js — YAML reader/writer for events.yml + events-archive.yml.
|
|
2
|
+
// Mirrors the shape of tools/tasks/lib/tasks.js — self-contained, no external YAML deps.
|
|
3
|
+
// An "event" is something with a start datetime AND a counterparty (calls, demos, meetings,
|
|
4
|
+
// dentist appointments). Deadline-only items (no time-of-day, no attendee) belong in tasks.yml.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(__dirname, '../../..');
|
|
10
|
+
const EVENTS_PATH = path.join(ROOT, 'events.yml');
|
|
11
|
+
const ARCHIVE_PATH = path.join(ROOT, 'events-archive.yml');
|
|
12
|
+
|
|
13
|
+
const VALID_STATUS = new Set(['scheduled', 'in_progress', 'done', 'cancelled', 'no_show', 'rescheduled']);
|
|
14
|
+
// VALID_TYPE is intentionally permissive — adapt per project. The set below lists common types;
|
|
15
|
+
// any string passes through (add.js validates against this set only for known categories).
|
|
16
|
+
const VALID_TYPE = new Set(['demo', 'discovery', 'investor', 'admin', 'personal', 'unspecified', 'other']);
|
|
17
|
+
|
|
18
|
+
const FIELD_ORDER = [
|
|
19
|
+
'id', 'title', 'start', 'end', 'type', 'status',
|
|
20
|
+
'attendees', 'lead_id', 'location', 'notes',
|
|
21
|
+
'source', 'created_at',
|
|
22
|
+
'transcript_path', 'spawned_tasks', 'hypothesis_impacts', 'gate_completed_at'
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function today() { return new Date().toISOString().slice(0, 10); }
|
|
26
|
+
function nowIso() { return new Date().toISOString(); }
|
|
27
|
+
|
|
28
|
+
function parseScalar(raw) {
|
|
29
|
+
const s = raw.trim();
|
|
30
|
+
if (s === '' || s === 'null' || s === '~') return null;
|
|
31
|
+
if (s === 'true') return true;
|
|
32
|
+
if (s === 'false') return false;
|
|
33
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
34
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
35
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
36
|
+
}
|
|
37
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
38
|
+
const inner = s.slice(1, -1).trim();
|
|
39
|
+
if (!inner) return [];
|
|
40
|
+
return inner.split(',').map(x => parseScalar(x.trim()));
|
|
41
|
+
}
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function serializeString(s) {
|
|
46
|
+
s = String(s);
|
|
47
|
+
if (s === '' || /^(null|true|false|~)$/.test(s) || /^-?\d+$/.test(s)) {
|
|
48
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
49
|
+
}
|
|
50
|
+
if (/[:#\[\]{},&*!|>'"%@`\n]|^[\s-?]/.test(s)) {
|
|
51
|
+
return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
52
|
+
}
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function serializeScalar(v) {
|
|
57
|
+
if (v === null || v === undefined) return 'null';
|
|
58
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
59
|
+
if (Array.isArray(v)) {
|
|
60
|
+
if (v.length === 0) return '[]';
|
|
61
|
+
return '[' + v.map(x => (typeof x === 'number' || typeof x === 'boolean') ? String(x) : serializeString(x)).join(', ') + ']';
|
|
62
|
+
}
|
|
63
|
+
return serializeString(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadFile(p) {
|
|
67
|
+
if (!fs.existsSync(p)) {
|
|
68
|
+
return { last_updated: today(), next_id: 1, events: [] };
|
|
69
|
+
}
|
|
70
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
const result = { last_updated: today(), next_id: 1, events: [] };
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < lines.length) {
|
|
75
|
+
const line = lines[i];
|
|
76
|
+
if (/^\s*#/.test(line) || line.trim() === '') { i++; continue; }
|
|
77
|
+
if (/^events\s*:\s*$/.test(line)) { i++; break; }
|
|
78
|
+
const m = line.match(/^(\w+)\s*:\s*(.*)$/);
|
|
79
|
+
if (m) result[m[1]] = parseScalar(m[2]);
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
let cur = null;
|
|
83
|
+
for (; i < lines.length; i++) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
if (/^\s*#/.test(line) || line.trim() === '') continue;
|
|
86
|
+
const listItem = line.match(/^\s*-\s+(\w+)\s*:\s*(.*)$/);
|
|
87
|
+
if (listItem) {
|
|
88
|
+
if (cur) result.events.push(cur);
|
|
89
|
+
cur = {};
|
|
90
|
+
cur[listItem[1]] = parseScalar(listItem[2]);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const field = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
|
|
94
|
+
if (field && cur) cur[field[1]] = parseScalar(field[2]);
|
|
95
|
+
}
|
|
96
|
+
if (cur) result.events.push(cur);
|
|
97
|
+
result.next_id = typeof result.next_id === 'number' ? result.next_id : parseInt(result.next_id, 10) || 1;
|
|
98
|
+
result.events.forEach(e => {
|
|
99
|
+
if (typeof e.id === 'string') e.id = parseInt(e.id, 10);
|
|
100
|
+
for (const k of ['attendees', 'spawned_tasks', 'hypothesis_impacts']) {
|
|
101
|
+
if (e[k] && !Array.isArray(e[k])) e[k] = [e[k]];
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function saveFile(p, state, header) {
|
|
108
|
+
state.last_updated = today();
|
|
109
|
+
const maxId = state.events.reduce((m, e) => Math.max(m, e.id || 0), 0);
|
|
110
|
+
if (state.next_id <= maxId) state.next_id = maxId + 1;
|
|
111
|
+
const out = [];
|
|
112
|
+
out.push(header);
|
|
113
|
+
out.push(`last_updated: ${state.last_updated}`);
|
|
114
|
+
out.push(`next_id: ${state.next_id}`);
|
|
115
|
+
out.push('events:');
|
|
116
|
+
for (const e of state.events) {
|
|
117
|
+
let first = true;
|
|
118
|
+
for (const k of FIELD_ORDER) {
|
|
119
|
+
if (!(k in e)) continue;
|
|
120
|
+
const prefix = first ? ' - ' : ' ';
|
|
121
|
+
out.push(`${prefix}${k}: ${serializeScalar(e[k])}`);
|
|
122
|
+
first = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
fs.writeFileSync(p, out.join('\n') + '\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function load() { return loadFile(EVENTS_PATH); }
|
|
129
|
+
function loadArchive() { return loadFile(ARCHIVE_PATH); }
|
|
130
|
+
|
|
131
|
+
function save(state) {
|
|
132
|
+
saveFile(EVENTS_PATH, state,
|
|
133
|
+
'# events.yml — single source of truth for future time-based events (calls, demos, meetings).\n# Past/completed events live in events-archive.yml. Tasks (deadline-only, no time-span) live in tasks.yml.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveArchive(state) {
|
|
137
|
+
saveFile(ARCHIVE_PATH, state,
|
|
138
|
+
'# events-archive.yml — past/completed events. Mirrors events.yml schema.\n# Wrap-up gate moves events here after transcript + insights captured.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nextId(state) {
|
|
142
|
+
// Shared ID space across events.yml + events-archive.yml. An event's ID never changes when archived.
|
|
143
|
+
const cur = state.next_id || 1;
|
|
144
|
+
const archived = loadArchive();
|
|
145
|
+
const maxArch = archived.events.reduce((m, e) => Math.max(m, e.id || 0), 0);
|
|
146
|
+
const id = Math.max(cur, maxArch + 1);
|
|
147
|
+
state.next_id = id + 1;
|
|
148
|
+
return id;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Idempotent: matches on (id) first, then (lead_id + start) tuple. Safe to call from
|
|
152
|
+
// retried flush-* scripts without creating duplicate rows for the same real-world event.
|
|
153
|
+
function upsert(state, event) {
|
|
154
|
+
let idx = -1;
|
|
155
|
+
if (event.id) idx = state.events.findIndex(e => e.id === event.id);
|
|
156
|
+
if (idx < 0 && event.lead_id && event.start) {
|
|
157
|
+
idx = state.events.findIndex(e => e.lead_id === event.lead_id && e.start === event.start);
|
|
158
|
+
}
|
|
159
|
+
if (idx >= 0) {
|
|
160
|
+
state.events[idx] = { ...state.events[idx], ...event };
|
|
161
|
+
return { action: 'updated', event: state.events[idx] };
|
|
162
|
+
}
|
|
163
|
+
if (!event.id) event.id = nextId(state);
|
|
164
|
+
if (!event.created_at) event.created_at = nowIso();
|
|
165
|
+
state.events.push(event);
|
|
166
|
+
return { action: 'inserted', event };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function upcoming({ from = nowIso() } = {}) {
|
|
170
|
+
return load().events
|
|
171
|
+
.filter(e => e.start && e.start >= from && (e.status === 'scheduled' || e.status === 'in_progress' || e.status === 'done'))
|
|
172
|
+
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function todayEvents({ on = today() } = {}) {
|
|
176
|
+
return load().events
|
|
177
|
+
.filter(e => e.start && String(e.start).startsWith(on)
|
|
178
|
+
&& (e.status === 'scheduled' || e.status === 'in_progress' || e.status === 'done'))
|
|
179
|
+
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dueForGate({ now = nowIso() } = {}) {
|
|
183
|
+
return load().events
|
|
184
|
+
.filter(e => {
|
|
185
|
+
if (e.gate_completed_at) return false;
|
|
186
|
+
if (e.status === 'cancelled' || e.status === 'rescheduled') return false;
|
|
187
|
+
const endOrStart = e.end || e.start;
|
|
188
|
+
return endOrStart && endOrStart < now;
|
|
189
|
+
})
|
|
190
|
+
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Lead-level lookup — used by metrics scripts to know if a lead ever had a call.
|
|
194
|
+
// Unions events.yml + events-archive.yml so historical calls survive after archival.
|
|
195
|
+
function leadHadCall(leadId) {
|
|
196
|
+
if (!leadId) return false;
|
|
197
|
+
const all = [...load().events, ...loadArchive().events];
|
|
198
|
+
return all.some(e => e.lead_id === leadId
|
|
199
|
+
&& (e.status === 'done' || e.status === 'in_progress'
|
|
200
|
+
|| (e.status === 'scheduled' && e.start && e.start < nowIso())));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function eventsByLead(leadId) {
|
|
204
|
+
if (!leadId) return [];
|
|
205
|
+
return [...load().events, ...loadArchive().events].filter(e => e.lead_id === leadId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function defaultEnd(startIso, durationMin = 30) {
|
|
209
|
+
if (!startIso) return null;
|
|
210
|
+
const d = new Date(startIso);
|
|
211
|
+
if (isNaN(d.getTime())) return null;
|
|
212
|
+
d.setMinutes(d.getMinutes() + durationMin);
|
|
213
|
+
return d.toISOString();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
EVENTS_PATH, ARCHIVE_PATH, VALID_STATUS, VALID_TYPE, FIELD_ORDER,
|
|
218
|
+
today, nowIso, defaultEnd,
|
|
219
|
+
load, loadArchive, save, saveArchive, nextId, upsert,
|
|
220
|
+
upcoming, todayEvents, dueForGate, leadHadCall, eventsByLead
|
|
221
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/events/migrate-from-csv.js — one-shot migration for installs that previously stored
|
|
3
|
+
// scheduled calls as outreach/leads.csv:call_at. Splits the column into events.yml (future)
|
|
4
|
+
// + events-archive.yml (past), then strips the call_at values from leads.csv.
|
|
5
|
+
//
|
|
6
|
+
// Idempotent — keys on (lead_id + start). Safe to re-run; already-present rows are skipped.
|
|
7
|
+
// Profile A (base only) installs without leads.csv: the script no-ops with a helpful message.
|
|
8
|
+
//
|
|
9
|
+
// Run with --commit to persist; otherwise dry-run.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const E = require('./lib/events');
|
|
14
|
+
|
|
15
|
+
const commit = process.argv.includes('--commit');
|
|
16
|
+
const todayStr = E.today();
|
|
17
|
+
|
|
18
|
+
const LEADS_LIB = path.resolve(__dirname, '../outreach/lib/leads.js');
|
|
19
|
+
if (!fs.existsSync(LEADS_LIB)) {
|
|
20
|
+
console.log('No outreach/lib/leads.js found — this install has no CSV to migrate from.');
|
|
21
|
+
console.log('events.yml + events-archive.yml will be created on first add.');
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const L = require('../outreach/lib/leads');
|
|
26
|
+
const rows = L.load();
|
|
27
|
+
const withCallAt = rows.filter(r => r.call_at && String(r.call_at).trim() !== '');
|
|
28
|
+
|
|
29
|
+
console.log(`Found ${withCallAt.length} leads with call_at set.`);
|
|
30
|
+
if (withCallAt.length === 0) {
|
|
31
|
+
console.log('Nothing to migrate.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const future = E.load();
|
|
36
|
+
const archive = E.loadArchive();
|
|
37
|
+
|
|
38
|
+
let nFuture = 0, nArchive = 0, nSkipped = 0;
|
|
39
|
+
|
|
40
|
+
for (const r of withCallAt) {
|
|
41
|
+
const raw = String(r.call_at).trim();
|
|
42
|
+
// Bare YYYY-MM-DD → assume UTC midnight so date doesn't shift. Adapt offset if relevant.
|
|
43
|
+
const start = raw.length === 10 ? `${raw}T00:00:00+00:00` : raw;
|
|
44
|
+
const dateOnly = raw.slice(0, 10);
|
|
45
|
+
const isFuture = dateOnly >= todayStr;
|
|
46
|
+
const target = isFuture ? future : archive;
|
|
47
|
+
const status = isFuture ? 'scheduled' : 'done';
|
|
48
|
+
const name = [r.first_name, r.last_name].filter(Boolean).join(' ') || r.company || r.linkedin_url;
|
|
49
|
+
const title = `${status === 'done' ? 'Call' : 'Call (scheduled)'} — ${name}${r.company ? ` / ${r.company}` : ''}`;
|
|
50
|
+
|
|
51
|
+
const event = {
|
|
52
|
+
title,
|
|
53
|
+
start,
|
|
54
|
+
end: isFuture ? E.defaultEnd(start, 30) : null,
|
|
55
|
+
type: 'unspecified',
|
|
56
|
+
status,
|
|
57
|
+
attendees: [],
|
|
58
|
+
lead_id: r.linkedin_url || null,
|
|
59
|
+
location: null,
|
|
60
|
+
notes: null,
|
|
61
|
+
source: 'migrate-from-csv',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const existingIdx = target.events.findIndex(e => e.lead_id === event.lead_id && e.start === event.start);
|
|
65
|
+
if (existingIdx >= 0) { nSkipped++; continue; }
|
|
66
|
+
|
|
67
|
+
E.upsert(target, event);
|
|
68
|
+
if (isFuture) nFuture++; else nArchive++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(`Future events to insert: ${nFuture}`);
|
|
72
|
+
console.log(`Archive events to insert: ${nArchive}`);
|
|
73
|
+
console.log(`Already-present skipped: ${nSkipped}`);
|
|
74
|
+
|
|
75
|
+
if (!commit) {
|
|
76
|
+
console.log('\nDry-run. Re-run with --commit to write events.yml + events-archive.yml + strip call_at.');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
E.save(future);
|
|
81
|
+
E.saveArchive(archive);
|
|
82
|
+
console.log('\nWrote events.yml + events-archive.yml.');
|
|
83
|
+
|
|
84
|
+
console.log('\nStripping call_at values from leads.csv...');
|
|
85
|
+
let stripped = 0;
|
|
86
|
+
for (const r of rows) {
|
|
87
|
+
if (r.call_at && r.call_at !== '') { r.call_at = ''; stripped++; }
|
|
88
|
+
}
|
|
89
|
+
L.save(rows);
|
|
90
|
+
console.log(`Cleared call_at on ${stripped} rows. The column itself is dropped on next save once HEADERS is updated.`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/events/upcoming.js — list scheduled events from now forward.
|
|
3
|
+
// Flags: --json | --today (only events starting today) | --days N (next N days)
|
|
4
|
+
// Used by render-today.js, good-morning, wrap-up.
|
|
5
|
+
|
|
6
|
+
const E = require('./lib/events');
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const asJson = args.includes('--json');
|
|
10
|
+
const onlyToday = args.includes('--today');
|
|
11
|
+
const daysIdx = args.indexOf('--days');
|
|
12
|
+
const days = daysIdx >= 0 ? parseInt(args[daysIdx + 1], 10) : null;
|
|
13
|
+
|
|
14
|
+
let events;
|
|
15
|
+
if (onlyToday) {
|
|
16
|
+
events = E.todayEvents();
|
|
17
|
+
} else if (days) {
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const until = new Date(now.getTime() + days * 86400 * 1000).toISOString();
|
|
20
|
+
events = E.upcoming().filter(e => e.start <= until);
|
|
21
|
+
} else {
|
|
22
|
+
events = E.upcoming();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (asJson) {
|
|
26
|
+
console.log(JSON.stringify(events, null, 2));
|
|
27
|
+
} else {
|
|
28
|
+
if (!events.length) { console.log('(no upcoming events)'); process.exit(0); }
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
const time = e.start.length >= 16 ? e.start.slice(0, 16).replace('T', ' ') : e.start.slice(0, 10);
|
|
31
|
+
console.log(`EVT-${e.id} ${time} [${e.type}/${e.status}] ${e.title}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -97,22 +97,33 @@ function renderTaskLine(t, openIds) {
|
|
|
97
97
|
return parts.join(' ');
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
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();
|
|
103
112
|
if (calls.length === 0) return null;
|
|
104
113
|
const lines = ['## Calls & meetings'];
|
|
105
|
-
for (const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const note = c.title ? ` (${c.title})` : '';
|
|
109
|
-
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}`);
|
|
110
117
|
}
|
|
111
118
|
return lines.join('\n');
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
function buildPulseSection(leads, metrics) {
|
|
115
|
-
|
|
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;
|
|
116
127
|
const lines = ['## Pulse'];
|
|
117
128
|
if (leads.length) {
|
|
118
129
|
const pipeline = leads.filter(r => ['replied', 'call_booked', 'call_done', 'verbal', 'loi'].includes(r.status));
|
|
@@ -122,6 +133,10 @@ function buildPulseSection(leads, metrics) {
|
|
|
122
133
|
if (metrics.outreach_sent !== undefined) {
|
|
123
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`);
|
|
124
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
|
+
}
|
|
125
140
|
if (leads.length) {
|
|
126
141
|
const warm = leads.filter(r => r.status === 'replied' && r.replied_at);
|
|
127
142
|
const stale = warm.filter(r => {
|
|
@@ -269,7 +284,7 @@ sections.push(header);
|
|
|
269
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.*`;
|
|
270
285
|
sections.push(preamble);
|
|
271
286
|
|
|
272
|
-
const callsBlock = buildCallsSection(
|
|
287
|
+
const callsBlock = buildCallsSection();
|
|
273
288
|
if (callsBlock) sections.push(callsBlock);
|
|
274
289
|
|
|
275
290
|
const pulseBlock = buildPulseSection(leads, metrics);
|