create-battle-plan 1.1.2 → 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/good-morning.md +8 -3
- 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 +85 -0
- package/template/docs/today-archive/.gitkeep +0 -0
- package/template/tasks.yml +5 -0
- package/template/tools/tasks/add.js +96 -0
- package/template/tools/tasks/archive.js +194 -0
- package/template/tools/tasks/flush-today.js +126 -0
- package/template/tools/tasks/lib/tasks.js +177 -0
- package/template/tools/tasks/migrate-lanes.js +104 -0
- package/template/tools/tasks/render-today.js +293 -0
- package/template/tools/tasks/triage-due.js +74 -0
- package/template/tools/tasks/triage.js +302 -0
- package/template/tools/verify-cascade.sh +16 -0
package/package.json
CHANGED
|
@@ -63,11 +63,15 @@ Then show a **compact metrics table** (all zeros, no targets yet) and transition
|
|
|
63
63
|
|
|
64
64
|
*Skip this on first run (Step 0 handles it). On all subsequent runs, start here.*
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
- `
|
|
68
|
-
- `
|
|
66
|
+
Run these in parallel:
|
|
67
|
+
- `node tools/tasks/render-today.js --quiet` — regenerate `docs/today.md` from `tasks.yml`
|
|
68
|
+
- Read `metrics.yml` — current numbers
|
|
69
|
+
- Read `docs/today.md` — user's daily surface (open tasks, calls, pulse)
|
|
70
|
+
- Read `docs/battle-plan.md` TL;DR + latest day log *only if needed for deep context*
|
|
69
71
|
- Run `git log --oneline -15` — what changed since last session
|
|
70
72
|
|
|
73
|
+
The battle plan is your orientation layer — read it on demand, not by default. `docs/today.md` is what the user sees, so lead with that.
|
|
74
|
+
|
|
71
75
|
## Step 2: Present the Briefing
|
|
72
76
|
|
|
73
77
|
Print a compact morning report with these sections:
|
|
@@ -99,6 +103,7 @@ End with 2-3 short questions:
|
|
|
99
103
|
## Step 4: Prep the Day
|
|
100
104
|
|
|
101
105
|
After the user answers:
|
|
106
|
+
- If they drop new tasks verbally, run `node tools/tasks/add.js "..." [--due ...] [--tag ...] [--priority 1|2|3]` for each, then re-run `render-today.js`.
|
|
102
107
|
- If they report any updates → run the full cascade (Steps 0-4 from CLAUDE.md)
|
|
103
108
|
- Update the battle plan day log with today's plan
|
|
104
109
|
|
|
@@ -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
|
@@ -4,6 +4,91 @@ You are helping manage an interconnected documentation system. Every document st
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## Two-View Model — Read This First
|
|
8
|
+
|
|
9
|
+
**The cascade is your orientation layer. `docs/today.md` is the user's operating surface. The chat is the user's only UI.**
|
|
10
|
+
|
|
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.
|
|
15
|
+
|
|
16
|
+
**Rules:**
|
|
17
|
+
- Never grow the battle plan's TL;DR into a wall of prose. Keep header blocks terse; append Daily Log entries chronologically.
|
|
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.
|
|
20
|
+
- `verify-cascade.sh` Check 6 confirms `today.md` is not stale relative to `tasks.yml`.
|
|
21
|
+
|
|
22
|
+
---
|
|
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
|
+
|
|
7
92
|
## The Cascade Protocol
|
|
8
93
|
|
|
9
94
|
**Trigger:** Any incoming information that relates to the project — calls, messages, research, signals, status changes, decisions.
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tools/tasks/add.js — CLI to append a task to tasks.yml.
|
|
3
|
+
// Usage: node tools/tasks/add.js "title" [--due YYYY-MM-DD] [--tag X] [--priority 1|2|3]
|
|
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.
|
|
7
|
+
|
|
8
|
+
const tasks = require('./lib/tasks');
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = {
|
|
12
|
+
title: null, due: null, tags: [], priority: 2,
|
|
13
|
+
lane: 'meta', implications: [], blockedBy: [],
|
|
14
|
+
context: null, snooze: null
|
|
15
|
+
};
|
|
16
|
+
const positional = [];
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a === '--due') args.due = argv[++i];
|
|
20
|
+
else if (a === '--tag') args.tags.push(argv[++i]);
|
|
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
|
+
}
|
|
31
|
+
else if (a === '--context') args.context = argv[++i];
|
|
32
|
+
else if (a === '--snooze') args.snooze = argv[++i];
|
|
33
|
+
else positional.push(a);
|
|
34
|
+
}
|
|
35
|
+
args.title = positional.join(' ');
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const args = parseArgs(process.argv.slice(2));
|
|
40
|
+
if (!args.title) {
|
|
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]');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (!tasks.VALID_PRIORITY.has(args.priority)) {
|
|
45
|
+
console.error(`Invalid priority ${args.priority} — must be 1, 2, or 3.`);
|
|
46
|
+
process.exit(1);
|
|
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
|
+
}
|
|
52
|
+
if (args.due && !/^\d{4}-\d{2}-\d{2}$/.test(args.due)) {
|
|
53
|
+
console.error(`Invalid --due ${args.due} — must be YYYY-MM-DD.`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const state = tasks.load();
|
|
58
|
+
const id = tasks.nextId(state);
|
|
59
|
+
const task = {
|
|
60
|
+
id,
|
|
61
|
+
created: tasks.today(),
|
|
62
|
+
due: args.due || null,
|
|
63
|
+
status: args.snooze ? 'snoozed' : 'open',
|
|
64
|
+
priority: args.priority,
|
|
65
|
+
lane: args.lane,
|
|
66
|
+
tags: args.tags,
|
|
67
|
+
title: args.title,
|
|
68
|
+
context: args.context || null,
|
|
69
|
+
done_at: null,
|
|
70
|
+
snoozed_until: args.snooze || null
|
|
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
|
+
}
|
|
82
|
+
state.tasks.push(task);
|
|
83
|
+
tasks.save(state);
|
|
84
|
+
|
|
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})`);
|
|
94
|
+
console.log(` ${args.title}`);
|
|
95
|
+
console.log('');
|
|
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}`);
|