create-battle-plan 1.2.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-battle-plan",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold a Battle Plan project — a markdown-based context system for LLM-powered project management",
5
5
  "bin": {
6
6
  "create-battle-plan": "./bin/cli.js"
@@ -0,0 +1,93 @@
1
+ ---
2
+ description: Walk through every open task lane-by-lane with a multi-choice action menu. Closes stale items, merges duplicates, re-lanes mis-classified work. Stamps last_triage_at when done.
3
+ ---
4
+
5
+ # Weekly Triage
6
+
7
+ Run this once a week to keep `tasks.yml` honest. The user clicks through each open task with arrow-key choices; you apply each decision to `tasks.yml` immediately.
8
+
9
+ ## Step 0 — Anchor to real time
10
+
11
+ Run `date` to anchor today's date. Don't infer from session context.
12
+
13
+ ## Step 1 — Gather state
14
+
15
+ Run `node tools/tasks/triage.js --json` and parse the output. Also run `git log --oneline -30` for context on recent commits.
16
+
17
+ ## Step 2 — Briefing
18
+
19
+ Tell the user the headline numbers from the report's `stats` block:
20
+
21
+ - Total open
22
+ - Overdue
23
+ - Stale (≥ stale_threshold_days, not overdue)
24
+ - Distribution by lane
25
+
26
+ Surface anything notable: the worst overdue, the most stale lane, any drift flags.
27
+
28
+ Ask:
29
+
30
+ > Walk through lane-by-lane in order (build → outreach → discovery → infra → fundraising → meta), or start with a specific lane?
31
+
32
+ ## Step 3 — Walk one task at a time
33
+
34
+ For each open task in the chosen order:
35
+
36
+ 1. Show 3-4 lines: title, key flags, a one-line context snippet, and the script's `suggestion` if any.
37
+ 2. Use `AskUserQuestion` with these standard options (label them concisely — these are the most common decisions):
38
+ - **Done** — task is complete
39
+ - **Snooze 7 days** — defer; resurfaces in a week
40
+ - **Demote** — drop priority by one
41
+ - **Merge into TASK-X** — kill this task, fold into another (ask for X)
42
+ 3. The user can pick "Other" to type a custom action: `delete`, `promote`, `keep`, `lane <LANE>`, `priority <N>`, `snooze <N>` for a custom snooze window, etc.
43
+
44
+ **Pacing:** present **one task at a time**. If the user says "go faster" or "skip ahead", batch 3-5 per message. If they say "keep all of these", apply `keep` to the whole lane.
45
+
46
+ ## Step 4 — Apply decisions immediately (no batching)
47
+
48
+ For each user choice, Edit `tasks.yml` right away. Map decisions:
49
+
50
+ | Choice | Field changes |
51
+ |---|---|
52
+ | `done` | `status: done`, `done_at: <today>` |
53
+ | `snooze N` | `status: snoozed`, `snoozed_until: <today + N days>` |
54
+ | `demote` | `priority: priority + 1` (capped at 3) |
55
+ | `promote` | `priority: priority - 1` (floored at 1) |
56
+ | `merge X` | `status: cancelled`, prepend `"Merged into TASK-X — <today>"` to context |
57
+ | `delete` | `status: cancelled`, prepend `"Deleted via triage <today>"` to context |
58
+ | `lane <LANE>` | `lane: <LANE>` (validate against VALID_LANES in lib/tasks.js) |
59
+ | `priority <N>` | `priority: <N>` |
60
+ | `keep` | no change |
61
+
62
+ The "Merge into X" action is high-leverage when triaging a long pile — collapse scattered concerns into a single owner with consolidated context.
63
+
64
+ ## Step 5 — Wrap up
65
+
66
+ Three things, in order:
67
+
68
+ ### 5a. Stamp `last_triage_at`
69
+
70
+ This suppresses the SessionStart triage-due nudge until the next cycle. Without this, the nudge keeps firing on every session start and becomes spam.
71
+
72
+ ```bash
73
+ node -e "const t=require('./tools/tasks/lib/tasks'); const s=t.load(); s.last_triage_at='$(date +%Y-%m-%d)'; t.save(s);"
74
+ ```
75
+
76
+ ### 5b. Regenerate today.md
77
+
78
+ ```bash
79
+ node tools/tasks/render-today.js
80
+ ```
81
+
82
+ ### 5c. Print summary
83
+
84
+ - Tasks closed: N
85
+ - Tasks snoozed: N
86
+ - Tasks merged: N
87
+ - Tasks re-laned: N
88
+ - Open tasks remaining: N (was N before)
89
+ - Implications-drift heads-up: list any tasks where the linked doc still hasn't moved and the user kept the task open
90
+
91
+ ## Tone
92
+
93
+ Direct. Bounded decisions. Don't editorialize on each task — you're a UI, not a coach.
@@ -42,6 +42,28 @@ With all info gathered, run the full cascade from CLAUDE.md:
42
42
  4. Run `tools/touch-date.sh` on every modified file
43
43
  5. Run `tools/verify-cascade.sh` — fix any errors
44
44
 
45
+ ## Step 4.5: Task hygiene — REQUIRED daily
46
+
47
+ This step keeps `tasks.yml` honest. Run before regenerating `today.md` so any closures land in the day's surface.
48
+
49
+ **4.5a — Detect drift (tasks that should be closed but aren't):**
50
+
51
+ Run `node tools/tasks/triage.js --json` and scan the output for any open task with non-empty `recent_commits` (commits mentioning `TASK-N` since the task was created). For each:
52
+ - Surface to the user: "TASK-{id} ({title}) — recent commit '{subject}' suggests it's done. Mark closed?"
53
+ - If yes → set `status: done`, `done_at: <today>` via Edit on `tasks.yml`. If no → leave it.
54
+
55
+ Also surface any open task with `implications_drift` flagged (linked doc untouched since task created) — the user may have closed the work without updating the doc, OR the doc work is genuinely pending.
56
+
57
+ **4.5b — Archive old closed tasks:**
58
+
59
+ Run `node tools/tasks/archive.js`. This moves any `status: done|cancelled` row with `done_at < today - 14d` into `tasks-archive.yaml` (created on first run). Idempotent. Default retention = 14 days; pass `--days N` to override or `--all` to archive everything closed.
60
+
61
+ **4.5c — Regenerate today.md:**
62
+
63
+ Run `node tools/tasks/render-today.js --quiet` so today's surface reflects any closures from 4.5a.
64
+
65
+ If 4.5a/4.5b changed anything, list it in Step 5 ("Task hygiene: N closed via git-drift, M archived").
66
+
45
67
  ## Step 5: Report
46
68
 
47
69
  Print:
@@ -49,6 +71,7 @@ Print:
49
71
  - **Docs updated** (list of files touched)
50
72
  - **Verification warnings** (if any)
51
73
  - **Tomorrow's top priorities** (carry-forwards + known agenda items)
74
+ - **Task hygiene** (if Step 4.5 changed anything)
52
75
 
53
76
  ## Step 6: Commit
54
77
 
@@ -1,3 +1,16 @@
1
1
  {
2
- "hooks": {}
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node tools/tasks/triage-due.js 2>/dev/null || true",
10
+ "timeout": 5
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
3
16
  }
@@ -6,19 +6,89 @@ You are helping manage an interconnected documentation system. Every document st
6
6
 
7
7
  ## Two-View Model — Read This First
8
8
 
9
- **The cascade is your orientation layer. `docs/today.md` is the user's operating surface.**
9
+ **The cascade is your orientation layer. `docs/today.md` is the user's operating surface. The chat is the user's only UI.**
10
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.
11
+ The user should never have to look at the cascade, the battle plan, `tasks.yml`, or any internal markdown to operate the system. The chat with you is the UI; the cascade is your memory; `docs/today.md` is a thin clickable surface in their editor for ticking through the day. Everything else exists for *you*, not them.
12
+
13
+ - **Your view (the cascade):** `docs/battle-plan.md` at the top, source docs below it, `metrics.yml` as numeric truth, `tasks.yml` as the structured task log. Narrative, deep, linked. You read and write this freely — it is how you reconstruct project state and cascade new information.
14
+ - **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 (lane-grouped within each priority bucket). The user checks boxes in Obsidian; `tools/tasks/flush-today.js` reconciles those edits back into `tasks.yml`. When they want deep context, they ask you in chat — you traverse the cascade on their behalf.
13
15
 
14
16
  **Rules:**
15
17
  - 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
+ - When the user drops new tasks, add them via `node tools/tasks/add.js "..." [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3] [--lane LANE] [--implication PATH]`. Don't bury tasks in battle plan prose.
19
+ - After any task mutation (add, complete, snooze, triage), run `node tools/tasks/render-today.js` so `docs/today.md` stays fresh.
18
20
  - `verify-cascade.sh` Check 6 confirms `today.md` is not stale relative to `tasks.yml`.
19
21
 
20
22
  ---
21
23
 
24
+ ## Task Lanes — what goes where
25
+
26
+ Every task has a `lane` (groups by *primary action*, not topic). The default vocabulary:
27
+
28
+ | Lane | Primary action |
29
+ |---|---|
30
+ | `build` | Build, design, or document the product itself (MVP, demos, architecture, integrations) |
31
+ | `outreach` | Cold DMs / InMails / posts / templates / cold-email infra-as-pipeline |
32
+ | `discovery` | Chase or nurture a *named human relationship* — warm intros, follow-ups, scheduling specific calls |
33
+ | `infra` | Plumbing — DNS, GCP/AWS, deploy, env, secrets, cold-email domain warm-up |
34
+ | `fundraising` | Apply to or maintain relationships with accelerators / VCs / angels |
35
+ | `meta` | Doc/process work with no other natural lane (default fallback) |
36
+
37
+ **Adapt this set to your project.** Lanes are configurable in `tools/tasks/lib/tasks.js` (`VALID_LANES`) — when you change them, also update `tools/tasks/migrate-lanes.js` keyword buckets and the `LANE_DISPLAY` / `LANE_ORDER` tables in `tools/tasks/render-today.js`.
38
+
39
+ **Personalities don't get their own lane.** A specific advisor or co-founder's input flows into all the action lanes. A task like "ask <advisor> about X" is `discovery` (relationship), `build` (product feedback), or `meta` (process), depending on what *closing* the task produces.
40
+
41
+ ### Strategic vs routine — what belongs in `tasks.yml`
42
+
43
+ `tasks.yml` is for **ad-hoc strategic / build / discovery-protocol / high-stakes individual conversations.** Examples: a milestone call with a specific stakeholder where multiple people join; a piece of pitch copy that needs to be written by a specific date; a load-bearing architecture decision that gates other work.
44
+
45
+ Routine lead-by-name follow-ups ("X accepted, send DM") belong in the **outreach blitz pipeline**, not in `tasks.yml`. The blitz already surfaces those through `daily-targets.js` + `leads.csv` flags + accepted-not-replied detection on timers. Don't duplicate that work as tasks.
46
+
47
+ When uncertain, default to the blitz. `tasks.yml` is a journal of strategic intent, not a worklist.
48
+
49
+ ---
50
+
51
+ ## Weekly Triage — `/weekly-triage`
52
+
53
+ Run weekly to keep `tasks.yml` honest. The skill (`.claude/commands/weekly-triage.md`) walks the user through every open task one at a time using `AskUserQuestion`'s arrow-key UI. Each decision (`done` / `snooze N` / `demote` / `merge X` / `delete` / `lane LANE` / `priority N` / `keep`) is applied to `tasks.yml` immediately — no batching.
54
+
55
+ Two automation layers support this:
56
+
57
+ - **`tools/tasks/triage.js`** — read-only data layer. Surfaces overdue/stale tasks, recent commits mentioning each task ID (signals "this is probably done"), and *implications drift* (when a task's linked doc hasn't been modified since the task was created — meaning the cascade hasn't actually reached the doc). Output as Markdown by default, JSON via `--json` for programmatic consumers.
58
+ - **`tools/tasks/triage-due.js`** — lightweight SessionStart-hook nudge. Silent unless one of three thresholds trips: time-based (≥7d since last triage), stale-task (≥20 open tasks ≥14d old), or volume (≥60 open). Wired in `.claude/settings.json`. The skill stamps `last_triage_at` in `tasks.yml` on completion to suppress the nudge until the next cycle.
59
+
60
+ When you see the SessionStart nudge fire, mention it in chat ("📋 Last triage was 9d ago — want to run `/weekly-triage`?") but never auto-invoke. The user decides.
61
+
62
+ ### Implications field
63
+
64
+ Tasks may carry `implications: [docs/path-a.md, docs/path-b.md]` — a list of docs that should change when the task closes. `triage.js` flags drift when a linked doc's last git commit predates the task: the status flip happened, but the cascaded doc-update didn't. When you create a task whose closure should mutate a specific doc, pass `--implication path/to/doc.md` to `add.js` so triage can hold you accountable later.
65
+
66
+ ### `blocked_by` field
67
+
68
+ Tasks may also carry `blocked_by: [TASK-IDs]` — an array of TASK-IDs that must close before this task is actionable. Set via `add.js --blocked-by N` (repeatable; comma-separated also accepted; each ID validated against existing rows). When at least one blocker is still open:
69
+
70
+ - `triage.js` shows a `🚧 Blocked by:` line listing each blocker's id, status, and title (open ones flagged 🚧, closed ones ✅).
71
+ - The stale-flag and snooze-or-demote suggestion are **suppressed** — a task waiting on a deliberate blocker shouldn't be penalized for not progressing.
72
+ - The replacement suggestion becomes "blocked — chase blocker(s) or demote".
73
+ - Stats gain a `Blocked by another open task: N` line.
74
+ - `render-today.js` emits a `🚧 blocked-by:TASK-N,TASK-M` token on the task's line — but only for *still-open* blockers, so the token disappears once the blocker closes.
75
+
76
+ When the user describes a task that genuinely depends on another, set the blocker explicitly (`--blocked-by N`) instead of letting the dependency live in prose. Closure of the blocker doesn't auto-close the dependent — the user picks the action during the next triage.
77
+
78
+ ---
79
+
80
+ ## Task archive — `node tools/tasks/archive.js`
81
+
82
+ `tasks.yml` is append-only by design (audit trail), but it shouldn't grow forever. The archive script moves any `status: done|cancelled` row with `done_at < today - 14d` into `tasks-archive.yaml` (created on first run, same schema, sorted by `done_at` ascending).
83
+
84
+ - **Default retention:** 14 days. Pass `--days N` to override, or `--all` to archive every closed row regardless of age.
85
+ - **Idempotent:** dedups by `id` against the existing archive. Safe to run on every `/wrap-up`.
86
+ - **Wired into `/wrap-up` Step 4.5b** — runs daily as part of end-of-day routine.
87
+ - **Backfill safety:** closed rows missing `done_at` get stamped today and kept one cycle (so a date-less row doesn't get archived without chronological position).
88
+ - **Re-importing a task:** intentional friction. Manually move the row from `tasks-archive.yaml` → `tasks.yml` and set `status: open`.
89
+
90
+ ---
91
+
22
92
  ## The Cascade Protocol
23
93
 
24
94
  **Trigger:** Any incoming information that relates to the project — calls, messages, research, signals, status changes, decisions.
@@ -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
 
@@ -3,28 +3,37 @@ const path = require('path');
3
3
 
4
4
  const TASKS_PATH = path.resolve(__dirname, '../../../tasks.yml');
5
5
 
6
- const VALID_STATUS = new Set(['open', 'done', 'cancelled', 'snoozed']);
6
+ const VALID_STATUS = new Set(['open', 'done', 'cancelled', 'snoozed', 'in_progress']);
7
7
  const VALID_PRIORITY = new Set([1, 2, 3]);
8
8
 
9
+ // Default lane vocabulary. Adapt this to your project — lanes group tasks by
10
+ // primary action, not topic. Update the LANE_KEYWORDS table in
11
+ // tools/tasks/migrate-lanes.js if you change this set.
12
+ const VALID_LANES = new Set(['build', 'outreach', 'discovery', 'infra', 'fundraising', 'meta']);
13
+
9
14
  // Minimal YAML reader/writer for our constrained schema.
10
15
  // Schema:
11
16
  // last_updated: YYYY-MM-DD
12
17
  // next_id: N
18
+ // last_triage_at: YYYY-MM-DD # optional — set by /weekly-triage to suppress SessionStart nudge
13
19
  // tasks:
14
20
  // - id: N
15
21
  // created: YYYY-MM-DD
16
22
  // due: YYYY-MM-DD | null
17
- // status: open|done|cancelled|snoozed
23
+ // status: open|done|cancelled|snoozed|in_progress
18
24
  // priority: 1|2|3
25
+ // lane: build|outreach|discovery|infra|fundraising|meta
19
26
  // tags: [a, b]
20
27
  // title: "..."
21
28
  // context: "..."
22
29
  // done_at: YYYY-MM-DD | null
23
30
  // snoozed_until: YYYY-MM-DD | null
31
+ // implications: [docs/path-a.md, docs/path-b.md] # optional — docs that should change when this task closes
32
+ // blocked_by: [TASK-IDs] # optional — task IDs that must close before this one is actionable
24
33
 
25
34
  const FIELD_ORDER = [
26
- 'id', 'created', 'due', 'status', 'priority', 'tags',
27
- 'title', 'context', 'done_at', 'snoozed_until'
35
+ 'id', 'created', 'due', 'status', 'priority', 'lane', 'tags',
36
+ 'title', 'context', 'done_at', 'snoozed_until', 'implications', 'blocked_by'
28
37
  ];
29
38
 
30
39
  function today() {
@@ -53,7 +62,9 @@ function serializeScalar(v) {
53
62
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
54
63
  if (Array.isArray(v)) {
55
64
  if (v.length === 0) return '[]';
56
- return '[' + v.map(x => serializeString(x)).join(', ') + ']';
65
+ // Preserve number/boolean primitives so int-arrays (e.g. blocked_by) don't
66
+ // get re-quoted as strings on round-trip.
67
+ return '[' + v.map(x => (typeof x === 'number' || typeof x === 'boolean') ? String(x) : serializeString(x)).join(', ') + ']';
57
68
  }
58
69
  return serializeString(v);
59
70
  }
@@ -126,6 +137,7 @@ function save(state) {
126
137
  out.push('# Never hand-edit while today.md has unflushed checkbox changes — flush first.');
127
138
  out.push(`last_updated: ${state.last_updated}`);
128
139
  out.push(`next_id: ${state.next_id}`);
140
+ if (state.last_triage_at) out.push(`last_triage_at: ${state.last_triage_at}`);
129
141
  out.push('tasks:');
130
142
  for (const t of state.tasks) {
131
143
  let first = true;
@@ -160,6 +172,6 @@ function resolveSnoozed(state) {
160
172
  }
161
173
 
162
174
  module.exports = {
163
- TASKS_PATH, VALID_STATUS, VALID_PRIORITY, FIELD_ORDER,
175
+ TASKS_PATH, VALID_STATUS, VALID_PRIORITY, VALID_LANES, FIELD_ORDER,
164
176
  today, load, save, nextId, resolveSnoozed
165
177
  };
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // tools/tasks/migrate-lanes.js — one-shot heuristic backfill of `lane` field.
3
+ // Idempotent: skips tasks that already have a valid lane.
4
+ //
5
+ // Usage:
6
+ // node tools/tasks/migrate-lanes.js # apply heuristic, write tasks.yml
7
+ // node tools/tasks/migrate-lanes.js --dry # show classifications, write nothing
8
+ //
9
+ // Heuristic is intentionally generic. ADAPT the LANE_KEYWORDS table below to
10
+ // your project's vocabulary. Two-pass design: tags first (curated, high
11
+ // signal-to-noise), then title (less curated). Context is NEVER consulted —
12
+ // it often contains incidental words that contaminate classification.
13
+ //
14
+ // Tasks that match no keyword bucket fall back to `meta`. Re-run anytime —
15
+ // already-laned tasks stay put.
16
+
17
+ const tasks = require('./lib/tasks');
18
+
19
+ // Adapt these keyword buckets to your project. Lane order matters when a task
20
+ // matches multiple — the first lane in this object wins.
21
+ const LANE_KEYWORDS = {
22
+ build: [
23
+ 'mvp', 'demo', 'arch', 'architecture', 'integrations', 'product',
24
+ 'feature', 'build', 'ship', 'design', 'frontend', 'backend', 'api', 'ui'
25
+ ],
26
+ outreach: [
27
+ 'outreach', 'blitz', 'cold-email', 'cold-dm', 'linkedin', 'template',
28
+ 'cadence', 'pitch-copy', 'content', 'post', 'campaign', 'sequence'
29
+ ],
30
+ discovery: [
31
+ 'warm', 'warm-intro', 'intro', 'intros', 'discovery', 'door-opener',
32
+ 'follow-up', 'lead', 'customer', 'prospect', 'champion'
33
+ ],
34
+ infra: [
35
+ 'infra', 'gcp', 'aws', 'dns', 'domain', 'ci', 'deploy', 'env',
36
+ 'secrets', 'database', 'monitoring', 'logging'
37
+ ],
38
+ fundraising: [
39
+ 'fundraising', 'investor', 'vc', 'angel', 'pitch', 'fund',
40
+ 'accelerator', 'yc', 'spc', 'demo-day'
41
+ ]
42
+ };
43
+
44
+ const argv = process.argv.slice(2);
45
+ const dryRun = argv.includes('--dry');
46
+
47
+ function matchLane(needle) {
48
+ if (!needle) return null;
49
+ const lower = needle.toLowerCase();
50
+ for (const [lane, keywords] of Object.entries(LANE_KEYWORDS)) {
51
+ for (const kw of keywords) {
52
+ const re = new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
53
+ if (re.test(lower)) return lane;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function classify(task) {
60
+ if (task.lane && tasks.VALID_LANES.has(task.lane)) return { lane: task.lane, source: 'preserved' };
61
+ // Pass 1: tags (curated, high signal).
62
+ const tagsNeedle = (Array.isArray(task.tags) ? task.tags : []).join(' ');
63
+ const fromTags = matchLane(tagsNeedle);
64
+ if (fromTags) return { lane: fromTags, source: 'tags' };
65
+ // Pass 2: title (less curated).
66
+ const fromTitle = matchLane(task.title || '');
67
+ if (fromTitle) return { lane: fromTitle, source: 'title' };
68
+ return { lane: 'meta', source: 'fallback' };
69
+ }
70
+
71
+ const state = tasks.load();
72
+ let changed = 0;
73
+ let kept = 0;
74
+ const summary = { tags: 0, title: 0, fallback: 0, preserved: 0 };
75
+
76
+ for (const t of state.tasks) {
77
+ const { lane, source } = classify(t);
78
+ summary[source]++;
79
+ if (source === 'preserved') {
80
+ kept++;
81
+ continue;
82
+ }
83
+ if (t.lane !== lane) {
84
+ if (!dryRun) t.lane = lane;
85
+ changed++;
86
+ if (dryRun) {
87
+ console.log(`[dry] TASK-${t.id} → ${lane} (via ${source}): ${t.title}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ if (!dryRun && changed > 0) tasks.save(state);
93
+
94
+ console.log('');
95
+ console.log(`Migration ${dryRun ? '(dry-run) ' : ''}complete.`);
96
+ console.log(` Preserved (already laned): ${kept}`);
97
+ console.log(` Classified via tags: ${summary.tags}`);
98
+ console.log(` Classified via title: ${summary.title}`);
99
+ console.log(` Fell back to meta: ${summary.fallback}`);
100
+ console.log(` Total updated: ${changed}`);
101
+ if (dryRun) {
102
+ console.log('');
103
+ console.log('Re-run without --dry to apply.');
104
+ }
@@ -57,12 +57,40 @@ function priorityEmoji(p) {
57
57
  return '';
58
58
  }
59
59
 
60
- function renderTaskLine(t) {
60
+ // Lane vocabulary (mirrors VALID_LANES in lib/tasks.js). Drives display order
61
+ // in today.md and the human-readable label per lane group. Adapt to your project.
62
+ const LANE_DISPLAY = {
63
+ build: 'Build',
64
+ outreach: 'Outreach',
65
+ discovery: 'Discovery',
66
+ infra: 'Infra',
67
+ fundraising: 'Fundraising',
68
+ meta: 'Meta'
69
+ };
70
+ const LANE_ORDER = ['build', 'outreach', 'discovery', 'infra', 'fundraising', 'meta'];
71
+ const PRIORITY_HEADERS = {
72
+ 1: '### ⏫ Today (P1)',
73
+ 2: '### 🔼 This week (P2)',
74
+ 3: '### 🔽 Backlog (P3)'
75
+ };
76
+
77
+ function renderTaskLine(t, openIds) {
61
78
  const parts = [];
62
- parts.push(`- [ ] TASK-${t.id} ${t.title}`);
79
+ const box = t.status === 'in_progress' ? '/' : ' ';
80
+ parts.push(`- [${box}] TASK-${t.id} ${t.title}`);
63
81
  if (t.due) parts.push(`📅 ${t.due}`);
64
82
  const pe = priorityEmoji(t.priority);
65
83
  if (pe) parts.push(pe);
84
+ // Nested hashtag — Obsidian Tasks plugin treats this as a filterable lane group.
85
+ if (t.lane) parts.push(`#lane/${t.lane}`);
86
+ // Surface still-open blockers only — once the blocker closes, the token disappears
87
+ // from the daily surface. flush-today.js ignores this token (LINE_RE matches checkbox + TASK-N).
88
+ if (Array.isArray(t.blocked_by) && t.blocked_by.length) {
89
+ const stillOpen = openIds ? t.blocked_by.filter(id => openIds.has(id)) : t.blocked_by;
90
+ if (stillOpen.length) {
91
+ parts.push(`🚧 blocked-by:TASK-${stillOpen.join(',TASK-')}`);
92
+ }
93
+ }
66
94
  if (Array.isArray(t.tags) && t.tags.length) {
67
95
  parts.push(t.tags.map(x => '#' + x).join(' '));
68
96
  }
@@ -111,7 +139,7 @@ function buildPulseSection(leads, metrics) {
111
139
 
112
140
  function buildQuerySections(state) {
113
141
  const sections = [];
114
- const open = state.tasks.filter(t => t.status === 'open');
142
+ const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
115
143
  const byP = p => open.filter(t => t.priority === p).length;
116
144
 
117
145
  const commonOpts = [
@@ -162,32 +190,62 @@ function buildQuerySections(state) {
162
190
  }
163
191
 
164
192
  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];
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);
179
201
  }
180
202
 
181
- const raw = rendered.map(renderTaskLine).join('\n');
182
- return [
203
+ const out = [
183
204
  '---',
184
205
  '',
185
206
  '## Task data',
186
207
  '',
187
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.*',
188
- '',
189
- raw
190
- ].join('\n');
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();
191
249
  }
192
250
 
193
251
  // --- Main ---
@@ -228,4 +286,8 @@ sections.push(`*Generated ${stamp}. Queries above are live — edit checkboxes a
228
286
  const content = sections.join('\n\n') + '\n';
229
287
  if (!fs.existsSync(path.dirname(TODAY_MD))) fs.mkdirSync(path.dirname(TODAY_MD), { recursive: true });
230
288
  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))`);
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
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // tools/tasks/triage-due.js — lightweight SessionStart-hook nudge.
3
+ //
4
+ // Reads tasks.yml only. No git scans, no LLM calls, no per-doc checks.
5
+ // Silent by default — outputs nothing when triage isn't due.
6
+ // Only surfaces when one of the trigger conditions actually fires.
7
+ //
8
+ // Trigger conditions:
9
+ // 1. Time-based: last_triage_at >= TRIAGE_INTERVAL_DAYS ago, or never triaged with ≥30 open tasks
10
+ // 2. Stale-task: ≥ STALE_TASK_THRESHOLD open tasks with age ≥ STALE_AGE_DAYS
11
+ // 3. Volume: ≥ VOLUME_THRESHOLD open total
12
+ //
13
+ // Adjust thresholds for your project's task scale.
14
+ //
15
+ // Flags:
16
+ // --explain write all signals to stderr (debug)
17
+ // --quiet suppress nudge output (testing)
18
+
19
+ const tasks = require('./lib/tasks');
20
+
21
+ const TRIAGE_INTERVAL_DAYS = 7;
22
+ const STALE_TASK_THRESHOLD = 20;
23
+ const STALE_AGE_DAYS = 14;
24
+ const VOLUME_THRESHOLD = 60;
25
+ const NEVER_TRIAGED_MIN_OPEN = 30;
26
+
27
+ const argv = process.argv.slice(2);
28
+ const explain = argv.includes('--explain');
29
+ const quiet = argv.includes('--quiet');
30
+
31
+ function daysBetween(a, b) {
32
+ const ad = new Date(a);
33
+ const bd = new Date(b);
34
+ return Math.floor((bd - ad) / 86400000);
35
+ }
36
+
37
+ const state = tasks.load();
38
+ const today = tasks.today();
39
+ const open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
40
+ const stale = open.filter(t => {
41
+ if (!t.created) return false;
42
+ return daysBetween(t.created, today) >= STALE_AGE_DAYS;
43
+ });
44
+
45
+ const triggers = [];
46
+
47
+ if (state.last_triage_at) {
48
+ const sinceDays = daysBetween(state.last_triage_at, today);
49
+ if (sinceDays >= TRIAGE_INTERVAL_DAYS) {
50
+ triggers.push({ kind: 'time', detail: `last triage ${sinceDays}d ago (interval ${TRIAGE_INTERVAL_DAYS}d)` });
51
+ }
52
+ } else if (open.length >= NEVER_TRIAGED_MIN_OPEN) {
53
+ triggers.push({ kind: 'never_triaged', detail: `no last_triage_at recorded, ${open.length} open tasks` });
54
+ }
55
+
56
+ if (stale.length >= STALE_TASK_THRESHOLD) {
57
+ triggers.push({ kind: 'stale', detail: `${stale.length} open tasks ≥${STALE_AGE_DAYS}d old` });
58
+ }
59
+
60
+ if (open.length >= VOLUME_THRESHOLD) {
61
+ triggers.push({ kind: 'volume', detail: `${open.length} open tasks (threshold ${VOLUME_THRESHOLD})` });
62
+ }
63
+
64
+ if (explain) {
65
+ process.stderr.write(`triage-due check @ ${today}\n`);
66
+ process.stderr.write(` open: ${open.length}, stale (≥${STALE_AGE_DAYS}d): ${stale.length}\n`);
67
+ process.stderr.write(` last_triage_at: ${state.last_triage_at || '(never)'}\n`);
68
+ process.stderr.write(` triggers: ${triggers.length ? triggers.map(t => t.kind).join(', ') : 'none'}\n`);
69
+ }
70
+
71
+ if (triggers.length === 0 || quiet) process.exit(0);
72
+
73
+ const reasons = triggers.map(t => t.detail).join('; ');
74
+ process.stdout.write(`📋 Weekly triage is due — ${reasons}. Run \`/weekly-triage\` when convenient.\n`);
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ // tools/tasks/triage.js — read-only triage report.
3
+ // Surfaces overdue/stale tasks, recent commits mentioning each task,
4
+ // and "implications drift" (linked doc untouched since task created).
5
+ //
6
+ // Usage:
7
+ // node tools/tasks/triage.js # markdown to stdout
8
+ // node tools/tasks/triage.js --json # JSON for programmatic consumers
9
+ // node tools/tasks/triage.js --lane build # filter to one lane
10
+ // node tools/tasks/triage.js --stale-days 14 # tighten staleness threshold (default 7)
11
+ //
12
+ // Strictly read-only. Does NOT mutate tasks.yml.
13
+ // Decisions are applied by the /weekly-triage skill via Edit calls.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execSync } = require('child_process');
18
+ const tasks = require('./lib/tasks');
19
+
20
+ const ROOT = path.resolve(__dirname, '../..');
21
+
22
+ const argv = process.argv.slice(2);
23
+ const asJson = argv.includes('--json');
24
+ const laneIdx = argv.indexOf('--lane');
25
+ const laneFilter = laneIdx >= 0 ? argv[laneIdx + 1] : null;
26
+ const staleIdx = argv.indexOf('--stale-days');
27
+ const STALE_DAYS = staleIdx >= 0 ? parseInt(argv[staleIdx + 1], 10) : 7;
28
+
29
+ function daysBetween(a, b) {
30
+ const ad = new Date(a);
31
+ const bd = new Date(b);
32
+ return Math.floor((bd - ad) / 86400000);
33
+ }
34
+
35
+ function fileLastMod(relPath) {
36
+ const abs = path.join(ROOT, relPath);
37
+ if (!fs.existsSync(abs)) return null;
38
+ try {
39
+ const out = execSync(
40
+ `git -C "${ROOT}" log -1 --format=%cI -- "${relPath}"`,
41
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
42
+ ).trim();
43
+ return out ? out.slice(0, 10) : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function commitsMentioning(id) {
50
+ try {
51
+ const out = execSync(
52
+ `git -C "${ROOT}" log --since="60 days ago" --pretty=format:"%h %s" --grep="TASK-${id}\\b" -i`,
53
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
54
+ );
55
+ return out.trim().split('\n').filter(Boolean).slice(0, 3);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ // Build a {YYYY-MM-DD: "Day N — title"} map by scanning docs/battle-plan.md for headings.
62
+ // Three formats accepted (covers our actual heading conventions and most plausible variants).
63
+ let _battlePlanDayMap = null;
64
+ function battlePlanDayMap() {
65
+ if (_battlePlanDayMap !== null) return _battlePlanDayMap;
66
+ _battlePlanDayMap = {};
67
+ const bp = path.join(ROOT, 'docs/battle-plan.md');
68
+ if (!fs.existsSync(bp)) return _battlePlanDayMap;
69
+ const text = fs.readFileSync(bp, 'utf8');
70
+ for (const line of text.split('\n')) {
71
+ // Format A: "### Day N — Weekday Month D *(... · YYYY-MM-DD)*"
72
+ let m = line.match(/^#{2,4}\s+Day\s+(\d+)\s*[—-]\s*([^*]+?)\s*\*\([^·]*·\s*(\d{4}-\d{2}-\d{2})\)\*\s*$/);
73
+ if (m) { _battlePlanDayMap[m[3]] = `Day ${m[1]} — ${m[2].trim()}`; continue; }
74
+ // Format B: "## Day N (YYYY-MM-DD)" or "### Day N (YYYY-MM-DD) — title"
75
+ m = line.match(/^#{2,4}\s+Day\s+(\d+)\s*\((\d{4}-\d{2}-\d{2})\)\s*(?:[—-]\s*(.+))?$/);
76
+ if (m) { _battlePlanDayMap[m[2]] = `Day ${m[1]}${m[3] ? ' — ' + m[3].trim() : ''}`; continue; }
77
+ // Format C: "## YYYY-MM-DD — title"
78
+ m = line.match(/^#{2,4}\s+(\d{4}-\d{2}-\d{2})\s*(?:[—-]\s*(.+))?$/);
79
+ if (m) { _battlePlanDayMap[m[1]] = m[2] ? m[2].trim() : 'battle-plan entry'; }
80
+ }
81
+ return _battlePlanDayMap;
82
+ }
83
+
84
+ // Extract transcript paths referenced from a task's tags + context.
85
+ function transcriptRefs(t) {
86
+ const haystack = [t.context || '', ...(t.tags || [])].join(' ');
87
+ const matches = haystack.match(/docs\/archive\/validation\/transcripts\/[^\s,;)]+/g);
88
+ return matches ? Array.from(new Set(matches)) : [];
89
+ }
90
+
91
+ // Source/origin context — where did this task come from?
92
+ function sourceContext(t) {
93
+ const out = [];
94
+ if (t.created) {
95
+ const dayLabel = battlePlanDayMap()[t.created];
96
+ if (dayLabel) out.push(`battle-plan: ${dayLabel}`);
97
+ }
98
+ for (const tr of transcriptRefs(t)) out.push(`transcript: ${tr}`);
99
+ const hintTags = (t.tags || []).filter(tag => /^(spawned-by-|from-|call-|h\d+)/i.test(tag));
100
+ if (hintTags.length) out.push(`tags: ${hintTags.join(', ')}`);
101
+ return out.length ? out : null;
102
+ }
103
+
104
+ // Resolve blocked_by IDs to {id, status, title, open} entries.
105
+ function resolveBlockedBy(t, allTasks) {
106
+ if (!Array.isArray(t.blocked_by) || !t.blocked_by.length) return null;
107
+ const byId = new Map(allTasks.map(x => [x.id, x]));
108
+ return t.blocked_by.map(id => {
109
+ const b = byId.get(id);
110
+ if (!b) return { id, status: 'unknown', title: '?', open: false };
111
+ const open = b.status === 'open' || b.status === 'in_progress';
112
+ return { id, status: b.status, title: (b.title || '').slice(0, 60), open };
113
+ });
114
+ }
115
+
116
+ function implicationsDrift(t) {
117
+ if (!Array.isArray(t.implications) || !t.implications.length) return null;
118
+ const drift = [];
119
+ for (const docPath of t.implications) {
120
+ const lastMod = fileLastMod(docPath);
121
+ if (!lastMod) {
122
+ drift.push(`${docPath} (file missing)`);
123
+ continue;
124
+ }
125
+ if (t.created && lastMod < t.created) {
126
+ drift.push(`${docPath} (last modified ${lastMod}, predates task)`);
127
+ }
128
+ }
129
+ return drift.length ? drift : null;
130
+ }
131
+
132
+ function suggestion(flags, t) {
133
+ if (flags.blockedByOpen) return 'blocked — chase blocker(s) or demote';
134
+ if (flags.overdue && flags.overdueDays > 14) return 'demote (overdue >14d — losing momentum)';
135
+ if (flags.stale && !t.due) return `snooze ${STALE_DAYS}d or demote (open ${flags.ageDays}d, no due)`;
136
+ if (flags.commitMentions) return 'check if recent commits closed this — done?';
137
+ if (flags.drift) return 'close (no longer needed) or promote (chase the doc)';
138
+ return null;
139
+ }
140
+
141
+ function buildReport() {
142
+ const state = tasks.load();
143
+ const today = tasks.today();
144
+
145
+ let open = state.tasks.filter(t => t.status === 'open' || t.status === 'in_progress');
146
+ if (laneFilter) open = open.filter(t => t.lane === laneFilter);
147
+
148
+ const items = [];
149
+ for (const t of open) {
150
+ const ageDays = t.created ? daysBetween(t.created, today) : 0;
151
+ const overdueDays = t.due ? daysBetween(t.due, today) : 0;
152
+ const blockers = resolveBlockedBy(t, state.tasks);
153
+ const blockedByOpen = blockers ? blockers.some(b => b.open) : false;
154
+ const flags = {
155
+ overdue: t.due && overdueDays > 0,
156
+ overdueDays,
157
+ // Suppress the stale flag when the only reason a task is sitting open is a deliberate blocker.
158
+ stale: !t.due && ageDays >= STALE_DAYS && !blockedByOpen,
159
+ ageDays,
160
+ commitMentions: false,
161
+ drift: false,
162
+ blockedByOpen
163
+ };
164
+ const commits = commitsMentioning(t.id);
165
+ flags.commitMentions = commits.length > 0;
166
+ const drift = implicationsDrift(t);
167
+ flags.drift = drift !== null;
168
+ const source = sourceContext(t);
169
+
170
+ items.push({
171
+ id: t.id,
172
+ title: t.title,
173
+ context: t.context || null,
174
+ tags: t.tags || [],
175
+ lane: t.lane || 'meta',
176
+ priority: t.priority,
177
+ status: t.status,
178
+ due: t.due || null,
179
+ created: t.created || null,
180
+ ageDays,
181
+ overdueDays,
182
+ flags,
183
+ commits,
184
+ drift,
185
+ blockers: blockers || [],
186
+ source_context: source || [],
187
+ suggestion: suggestion(flags, t)
188
+ });
189
+ }
190
+
191
+ items.sort((a, b) => {
192
+ if (a.flags.overdue !== b.flags.overdue) return a.flags.overdue ? -1 : 1;
193
+ if (a.priority !== b.priority) return a.priority - b.priority;
194
+ return b.ageDays - a.ageDays;
195
+ });
196
+
197
+ const stats = {
198
+ total_open: open.length,
199
+ overdue: items.filter(i => i.flags.overdue).length,
200
+ stale: items.filter(i => i.flags.stale && !i.flags.overdue).length,
201
+ blocked: items.filter(i => i.flags.blockedByOpen).length,
202
+ by_lane: {}
203
+ };
204
+ for (const i of items) {
205
+ stats.by_lane[i.lane] = (stats.by_lane[i.lane] || 0) + 1;
206
+ }
207
+
208
+ return {
209
+ generated_at: today,
210
+ last_triage_at: state.last_triage_at || null,
211
+ stale_threshold_days: STALE_DAYS,
212
+ stats,
213
+ items
214
+ };
215
+ }
216
+
217
+ function renderMarkdown(report) {
218
+ const out = [];
219
+ out.push(`# Task Triage Report — ${report.generated_at}`);
220
+ out.push('');
221
+ if (report.last_triage_at) {
222
+ const since = daysBetween(report.last_triage_at, report.generated_at);
223
+ out.push(`*Last triage: ${report.last_triage_at} (${since}d ago).*`);
224
+ } else {
225
+ out.push('*No prior triage recorded.*');
226
+ }
227
+ out.push('');
228
+ out.push('## Stats');
229
+ out.push('');
230
+ out.push(`- Total open: ${report.stats.total_open}`);
231
+ out.push(`- Overdue: ${report.stats.overdue}`);
232
+ out.push(`- Stale (≥${report.stale_threshold_days}d, not overdue, not blocked): ${report.stats.stale}`);
233
+ out.push(`- Blocked by another open task: ${report.stats.blocked}`);
234
+ out.push('- By lane:');
235
+ for (const [lane, n] of Object.entries(report.stats.by_lane)) {
236
+ out.push(` - ${lane}: ${n}`);
237
+ }
238
+ out.push('');
239
+ out.push('---');
240
+ out.push('');
241
+
242
+ for (const i of report.items) {
243
+ const flagBits = [];
244
+ if (i.flags.overdue) flagBits.push(`\`overdue ${i.flags.overdueDays}d\``);
245
+ if (i.flags.stale && !i.flags.overdue) flagBits.push(`\`open ${i.flags.ageDays}d\``);
246
+ if (i.flags.blockedByOpen) {
247
+ const openIds = i.blockers.filter(b => b.open).map(b => b.id);
248
+ flagBits.push(`\`blocked by TASK-${openIds.join(', TASK-')}\``);
249
+ }
250
+ if (i.flags.drift) flagBits.push('`drift`');
251
+ if (i.flags.commitMentions) flagBits.push('`commit-mentioned`');
252
+ const flagStr = flagBits.length ? ' — ' + flagBits.join(' ') : '';
253
+ const dueStr = i.due ? `due ${i.due}` : 'no due';
254
+ out.push(`### TASK-${i.id} (P${i.priority} · ${i.lane} · ${dueStr} · age ${i.ageDays}d)${flagStr}`);
255
+ out.push('');
256
+ out.push(`**${i.title}**`);
257
+ if (i.source_context && i.source_context.length) {
258
+ out.push('');
259
+ out.push(`*Source:* ${i.source_context.join(' · ')}`);
260
+ }
261
+ if (i.context) {
262
+ out.push('');
263
+ out.push(`> ${i.context}`);
264
+ }
265
+ if (i.tags.length) {
266
+ out.push('');
267
+ out.push(`Tags: ${i.tags.map(t => '`' + t + '`').join(' ')}`);
268
+ }
269
+ if (i.blockers && i.blockers.length) {
270
+ const labels = i.blockers.map(b => `TASK-${b.id} [${b.status}]${b.open ? ' 🚧' : ' ✅'} ${b.title}`);
271
+ out.push('');
272
+ out.push(`*🚧 Blocked by:* ${labels.join(' · ')}`);
273
+ }
274
+ if (i.drift) {
275
+ out.push('');
276
+ out.push(`*⚠️ Implications drift:*`);
277
+ for (const d of i.drift) out.push(`- ${d}`);
278
+ }
279
+ if (i.commits.length) {
280
+ out.push('');
281
+ out.push('*Recent commits mentioning this task:*');
282
+ for (const c of i.commits) out.push(`- ${c}`);
283
+ }
284
+ if (i.suggestion) {
285
+ out.push('');
286
+ out.push(`*Suggestion:* ${i.suggestion}`);
287
+ }
288
+ out.push('');
289
+ out.push('Actions: `[done]` · `[snooze N]` · `[demote]` · `[promote]` · `[merge X]` · `[delete]` · `[keep]` · `[lane LANE]` · `[priority N]`');
290
+ out.push('');
291
+ out.push('---');
292
+ out.push('');
293
+ }
294
+ return out.join('\n');
295
+ }
296
+
297
+ const report = buildReport();
298
+ if (asJson) {
299
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
300
+ } else {
301
+ process.stdout.write(renderMarkdown(report));
302
+ }