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 +1 -1
- package/template/.claude/commands/weekly-triage.md +93 -0
- package/template/.claude/commands/wrap-up.md +23 -0
- package/template/.claude/settings.json +14 -1
- package/template/CLAUDE.md +75 -5
- package/template/tools/tasks/add.js +43 -4
- package/template/tools/tasks/archive.js +194 -0
- package/template/tools/tasks/flush-today.js +6 -2
- package/template/tools/tasks/lib/tasks.js +18 -6
- package/template/tools/tasks/migrate-lanes.js +104 -0
- package/template/tools/tasks/render-today.js +85 -23
- package/template/tools/tasks/triage-due.js +74 -0
- package/template/tools/tasks/triage.js +302 -0
package/package.json
CHANGED
|
@@ -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
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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]
|
|
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 = {
|
|
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
|
-
|
|
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 '
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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)
|
|
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
|
+
}
|