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.
@@ -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
+ }
@@ -1,17 +1,33 @@
1
1
  #!/usr/bin/env node
2
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]
3
+ // Usage: node tools/tasks/add.js "title" [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3]
4
+ // [--lane LANE] [--implication PATH] [--blocked-by N]
5
+ // [--context "..."] [--snooze YYYY-MM-DD]
6
+ // --blocked-by: TASK-ID (number) that must close first. Repeatable; comma-separated also accepted.
4
7
 
5
8
  const tasks = require('./lib/tasks');
6
9
 
7
10
  function parseArgs(argv) {
8
- const args = { title: null, due: null, tags: [], priority: 2, context: null, snooze: null };
11
+ const args = {
12
+ title: null, due: null, tags: [], priority: 2,
13
+ lane: 'meta', implications: [], blockedBy: [],
14
+ context: null, snooze: null
15
+ };
9
16
  const positional = [];
10
17
  for (let i = 0; i < argv.length; i++) {
11
18
  const a = argv[i];
12
19
  if (a === '--due') args.due = argv[++i];
13
20
  else if (a === '--tag') args.tags.push(argv[++i]);
14
21
  else if (a === '--priority') args.priority = parseInt(argv[++i], 10);
22
+ else if (a === '--lane') args.lane = argv[++i];
23
+ else if (a === '--implication') args.implications.push(argv[++i]);
24
+ else if (a === '--blocked-by') {
25
+ const v = argv[++i];
26
+ for (const part of String(v).split(',')) {
27
+ const n = parseInt(part.trim(), 10);
28
+ if (!isNaN(n)) args.blockedBy.push(n);
29
+ }
30
+ }
15
31
  else if (a === '--context') args.context = argv[++i];
16
32
  else if (a === '--snooze') args.snooze = argv[++i];
17
33
  else positional.push(a);
@@ -22,13 +38,17 @@ function parseArgs(argv) {
22
38
 
23
39
  const args = parseArgs(process.argv.slice(2));
24
40
  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]');
41
+ console.error('Usage: node tools/tasks/add.js "title" [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3] [--lane LANE] [--implication PATH] [--blocked-by N] [--context "..."] [--snooze YYYY-MM-DD]');
26
42
  process.exit(1);
27
43
  }
28
44
  if (!tasks.VALID_PRIORITY.has(args.priority)) {
29
45
  console.error(`Invalid priority ${args.priority} — must be 1, 2, or 3.`);
30
46
  process.exit(1);
31
47
  }
48
+ if (!tasks.VALID_LANES.has(args.lane)) {
49
+ console.error(`Invalid lane "${args.lane}" — must be one of: ${[...tasks.VALID_LANES].join(', ')}.`);
50
+ process.exit(1);
51
+ }
32
52
  if (args.due && !/^\d{4}-\d{2}-\d{2}$/.test(args.due)) {
33
53
  console.error(`Invalid --due ${args.due} — must be YYYY-MM-DD.`);
34
54
  process.exit(1);
@@ -42,16 +62,35 @@ const task = {
42
62
  due: args.due || null,
43
63
  status: args.snooze ? 'snoozed' : 'open',
44
64
  priority: args.priority,
65
+ lane: args.lane,
45
66
  tags: args.tags,
46
67
  title: args.title,
47
68
  context: args.context || null,
48
69
  done_at: null,
49
70
  snoozed_until: args.snooze || null
50
71
  };
72
+ if (args.implications.length) task.implications = args.implications;
73
+ if (args.blockedBy.length) {
74
+ const knownIds = new Set(state.tasks.map(t => t.id));
75
+ const unknown = args.blockedBy.filter(n => !knownIds.has(n));
76
+ if (unknown.length) {
77
+ console.error(`Invalid --blocked-by: TASK-${unknown.join(', TASK-')} not found in tasks.yml.`);
78
+ process.exit(1);
79
+ }
80
+ task.blocked_by = args.blockedBy;
81
+ }
51
82
  state.tasks.push(task);
52
83
  tasks.save(state);
53
84
 
54
- console.log(`✓ Added TASK-${id} (priority ${args.priority}${args.due ? ', due ' + args.due : ''}${args.tags.length ? ', tags ' + args.tags.join(',') : ''})`);
85
+ const flagSummary = [
86
+ `priority ${args.priority}`,
87
+ `lane ${args.lane}`,
88
+ args.due && `due ${args.due}`,
89
+ args.tags.length && `tags ${args.tags.join(',')}`,
90
+ args.implications.length && `implications ${args.implications.join(',')}`,
91
+ args.blockedBy.length && `blocked-by TASK-${args.blockedBy.join(' TASK-')}`
92
+ ].filter(Boolean).join(', ');
93
+ console.log(`✓ Added TASK-${id} (${flagSummary})`);
55
94
  console.log(` ${args.title}`);
56
95
  console.log('');
57
96
  console.log('Run `node tools/tasks/render-today.js` to regenerate docs/today.md.');
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // tools/tasks/archive.js — move done/cancelled tasks older than N days from tasks.yml → tasks-archive.yaml
3
+ //
4
+ // Purpose: keep tasks.yml lean. Audit trail preserved in tasks-archive.yaml (same schema).
5
+ //
6
+ // Usage:
7
+ // node tools/tasks/archive.js # default: archive done/cancelled with done_at < today - 14d
8
+ // node tools/tasks/archive.js --days N # custom threshold
9
+ // node tools/tasks/archive.js --dry-run # preview, no writes
10
+ // node tools/tasks/archive.js --all # archive ALL done/cancelled regardless of age
11
+ //
12
+ // Idempotent. Safe to run on every /wrap-up.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const tasks = require('./lib/tasks');
17
+
18
+ const ARCHIVE_PATH = path.resolve(__dirname, '../../tasks-archive.yaml');
19
+ const DEFAULT_DAYS = 14;
20
+
21
+ const args = process.argv.slice(2);
22
+ const dryRun = args.includes('--dry-run');
23
+ const archiveAll = args.includes('--all');
24
+ const daysArg = args.indexOf('--days');
25
+ const days = daysArg >= 0 && args[daysArg + 1] ? parseInt(args[daysArg + 1], 10) : DEFAULT_DAYS;
26
+
27
+ function today() {
28
+ return new Date().toISOString().slice(0, 10);
29
+ }
30
+
31
+ function daysAgo(n) {
32
+ const d = new Date();
33
+ d.setDate(d.getDate() - n);
34
+ return d.toISOString().slice(0, 10);
35
+ }
36
+
37
+ function serializeScalar(v) {
38
+ if (v === null || v === undefined) return 'null';
39
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
40
+ if (Array.isArray(v)) {
41
+ if (v.length === 0) return '[]';
42
+ return '[' + v.map(x => (typeof x === 'number' || typeof x === 'boolean') ? String(x) : serializeString(x)).join(', ') + ']';
43
+ }
44
+ return serializeString(v);
45
+ }
46
+
47
+ function serializeString(s) {
48
+ s = String(s);
49
+ if (s === '' || /^(null|true|false|~)$/.test(s) || /^-?\d+$/.test(s)) {
50
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
51
+ }
52
+ if (/[:#\[\]{},&*!|>'"%@`\n]|^[\s-?]/.test(s)) {
53
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
54
+ }
55
+ return s;
56
+ }
57
+
58
+ function parseScalar(raw) {
59
+ const s = raw.trim();
60
+ if (s === '' || s === 'null' || s === '~') return null;
61
+ if (s === 'true') return true;
62
+ if (s === 'false') return false;
63
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
64
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
65
+ return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
66
+ }
67
+ if (s.startsWith('[') && s.endsWith(']')) {
68
+ const inner = s.slice(1, -1).trim();
69
+ if (!inner) return [];
70
+ return inner.split(',').map(x => parseScalar(x.trim()));
71
+ }
72
+ return s;
73
+ }
74
+
75
+ function loadArchive() {
76
+ if (!fs.existsSync(ARCHIVE_PATH)) {
77
+ return { tasks: [] };
78
+ }
79
+ const text = fs.readFileSync(ARCHIVE_PATH, 'utf8');
80
+ const lines = text.split('\n');
81
+ const result = { tasks: [] };
82
+ let i = 0;
83
+ while (i < lines.length) {
84
+ const line = lines[i];
85
+ if (/^\s*#/.test(line) || line.trim() === '') { i++; continue; }
86
+ if (/^tasks\s*:\s*$/.test(line)) { i++; break; }
87
+ i++;
88
+ }
89
+ let cur = null;
90
+ for (; i < lines.length; i++) {
91
+ const line = lines[i];
92
+ if (/^\s*#/.test(line) || line.trim() === '') continue;
93
+ const listItem = line.match(/^\s*-\s+(\w+)\s*:\s*(.*)$/);
94
+ if (listItem) {
95
+ if (cur) result.tasks.push(cur);
96
+ cur = {};
97
+ cur[listItem[1]] = parseScalar(listItem[2]);
98
+ continue;
99
+ }
100
+ const field = line.match(/^\s+(\w+)\s*:\s*(.*)$/);
101
+ if (field && cur) {
102
+ cur[field[1]] = parseScalar(field[2]);
103
+ }
104
+ }
105
+ if (cur) result.tasks.push(cur);
106
+ result.tasks.forEach(t => {
107
+ if (typeof t.id === 'string') t.id = parseInt(t.id, 10);
108
+ if (typeof t.priority === 'string') t.priority = parseInt(t.priority, 10);
109
+ if (!Array.isArray(t.tags)) t.tags = t.tags ? [t.tags] : [];
110
+ });
111
+ return result;
112
+ }
113
+
114
+ function saveArchive(state) {
115
+ state.tasks.sort((a, b) => {
116
+ const ad = a.done_at || '9999-99-99';
117
+ const bd = b.done_at || '9999-99-99';
118
+ if (ad !== bd) return ad.localeCompare(bd);
119
+ return (a.id || 0) - (b.id || 0);
120
+ });
121
+
122
+ const out = [];
123
+ out.push('# tasks-archive.yaml — done/cancelled tasks moved out of tasks.yml. Audit trail.');
124
+ out.push('# Same schema as tasks.yml. Sorted by done_at ascending.');
125
+ out.push(`last_updated: ${today()}`);
126
+ out.push('tasks:');
127
+ for (const t of state.tasks) {
128
+ let first = true;
129
+ for (const k of tasks.FIELD_ORDER) {
130
+ if (!(k in t)) continue;
131
+ const prefix = first ? ' - ' : ' ';
132
+ out.push(`${prefix}${k}: ${serializeScalar(t[k])}`);
133
+ first = false;
134
+ }
135
+ }
136
+ fs.writeFileSync(ARCHIVE_PATH, out.join('\n') + '\n');
137
+ }
138
+
139
+ const state = tasks.load();
140
+ const archive = loadArchive();
141
+ const t0 = today();
142
+ const cutoff = archiveAll ? '9999-99-99' : daysAgo(days);
143
+
144
+ const toArchive = [];
145
+ const toKeep = [];
146
+
147
+ for (const t of state.tasks) {
148
+ const isClosed = t.status === 'done' || t.status === 'cancelled';
149
+ const closedDate = t.done_at;
150
+
151
+ if (isClosed && closedDate && (archiveAll || closedDate < cutoff)) {
152
+ toArchive.push(t);
153
+ } else if (isClosed && !closedDate) {
154
+ // Backfill safety: stamp today and keep one cycle.
155
+ t.done_at = t0;
156
+ toKeep.push(t);
157
+ } else {
158
+ toKeep.push(t);
159
+ }
160
+ }
161
+
162
+ const archivedIds = new Set(archive.tasks.map(t => t.id));
163
+ const newToArchive = toArchive.filter(t => !archivedIds.has(t.id));
164
+ const dupes = toArchive.length - newToArchive.length;
165
+
166
+ if (dryRun) {
167
+ console.log(`[DRY RUN] Would archive ${newToArchive.length} task(s) (cutoff: done_at < ${cutoff})`);
168
+ if (dupes > 0) console.log(` ${dupes} already in archive (skipped)`);
169
+ for (const t of newToArchive) {
170
+ console.log(` - TASK-${t.id} [${t.status}] done_at=${t.done_at}: ${(t.title || '').slice(0, 80)}`);
171
+ }
172
+ console.log(`\nWould keep ${toKeep.length} task(s) in tasks.yml.`);
173
+ process.exit(0);
174
+ }
175
+
176
+ if (newToArchive.length === 0) {
177
+ const closedCount = state.tasks.filter(t => t.status === 'done' || t.status === 'cancelled').length;
178
+ console.log(`No tasks to archive (cutoff: done_at < ${cutoff}). ${closedCount} closed task(s) still within retention window.`);
179
+ process.exit(0);
180
+ }
181
+
182
+ archive.tasks.push(...newToArchive);
183
+ saveArchive(archive);
184
+
185
+ state.tasks = toKeep;
186
+ tasks.save(state);
187
+
188
+ console.log(`✓ Archived ${newToArchive.length} task(s) to tasks-archive.yaml`);
189
+ console.log(` tasks.yml: ${toKeep.length} remaining (was ${toKeep.length + newToArchive.length})`);
190
+ if (dupes > 0) console.log(` Skipped ${dupes} already-archived task(s).`);
191
+
192
+ const oldest = newToArchive.reduce((a, b) => ((a.done_at || '9') < (b.done_at || '9') ? a : b));
193
+ const newest = newToArchive.reduce((a, b) => ((a.done_at || '0') > (b.done_at || '0') ? a : b));
194
+ console.log(` Date range: ${oldest.done_at} → ${newest.done_at}`);
@@ -43,7 +43,7 @@ function mapCheckbox(ch) {
43
43
  if (ch === ' ') return 'open';
44
44
  if (ch === 'x' || ch === 'X') return 'done';
45
45
  if (ch === '-') return 'cancelled';
46
- if (ch === '/') return 'open';
46
+ if (ch === '/') return 'in_progress';
47
47
  return 'open';
48
48
  }
49
49
 
@@ -79,10 +79,14 @@ for (const line of lines) {
79
79
  patch.status = 'cancelled';
80
80
  patch.done_at = doneMatch ? doneMatch[1] : tasks.today();
81
81
  } else {
82
+ // newStatus is 'open' or 'in_progress'. Resurrect terminal-state tasks and
83
+ // honor open ↔ in_progress transitions.
82
84
  if (prevStatus === 'done' || prevStatus === 'cancelled' || prevStatus === 'snoozed') {
83
- patch.status = 'open';
85
+ patch.status = newStatus;
84
86
  patch.done_at = null;
85
87
  patch.snoozed_until = null;
88
+ } else if (prevStatus !== newStatus) {
89
+ patch.status = newStatus;
86
90
  }
87
91
  }
88
92