dotmd-cli 0.17.1 → 0.19.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/README.md +61 -21
- package/bin/dotmd.mjs +77 -10
- package/package.json +1 -1
- package/src/claude-commands.mjs +43 -1
- package/src/handoff.mjs +59 -0
- package/src/hud.mjs +48 -0
- package/src/lifecycle.mjs +136 -16
- package/src/new.mjs +79 -6
- package/src/render.mjs +13 -1
- package/src/util.mjs +4 -0
package/README.md
CHANGED
|
@@ -146,7 +146,10 @@ dotmd plans List all plans
|
|
|
146
146
|
dotmd stale List stale docs
|
|
147
147
|
dotmd actionable List docs with next steps
|
|
148
148
|
dotmd index [--write] Generate/update docs.md index block
|
|
149
|
-
dotmd
|
|
149
|
+
dotmd hud Three-line actionable triage (silent when clean — ideal SessionStart hook)
|
|
150
|
+
dotmd pickup <file> Pick up a plan (in-session + print body or queued handoff)
|
|
151
|
+
dotmd release [<file>] Release in-session lease (alias: unpickup)
|
|
152
|
+
dotmd handoff <file> [...] Queue a resume-prompt sidecar + release
|
|
150
153
|
dotmd finish <file> Finish a plan (done or active)
|
|
151
154
|
dotmd status <file> <status> Transition document status
|
|
152
155
|
dotmd archive <file> Archive (status + move + update refs)
|
|
@@ -397,12 +400,36 @@ dotmd bulk archive docs/old-*.md -n # preview
|
|
|
397
400
|
### Pickup & Finish
|
|
398
401
|
|
|
399
402
|
```bash
|
|
400
|
-
dotmd pickup docs/plans/my-plan.md # set in-session + print
|
|
403
|
+
dotmd pickup docs/plans/my-plan.md # set in-session + print body (or queued handoff)
|
|
401
404
|
dotmd finish docs/plans/my-plan.md # set done + bump date
|
|
402
405
|
dotmd finish docs/plans/my-plan.md active # back to active for more work
|
|
403
406
|
```
|
|
404
407
|
|
|
405
|
-
###
|
|
408
|
+
### Handoff (resume-prompts attached to plans)
|
|
409
|
+
|
|
410
|
+
When you're stopping mid-work and the next session will need to pick up where
|
|
411
|
+
you left off, write a handoff sidecar instead of printing a resume prompt to
|
|
412
|
+
chat for copy-paste:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
dotmd handoff docs/plans/foo.md "continue from validate(); next: write tests"
|
|
416
|
+
dotmd handoff docs/plans/foo.md - <<'EOF' # heredoc-friendly for Claude
|
|
417
|
+
…multi-line resume prompt…
|
|
418
|
+
EOF
|
|
419
|
+
dotmd handoff docs/plans/foo.md @/tmp/handoff.md # from file
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The handoff is written to `<repoRoot>/.dotmd/handoffs/<plan-path>` as a
|
|
423
|
+
timestamped section (append mode by default; `--replace` to overwrite). The
|
|
424
|
+
lease is released and the plan flips back to its prior status. The next
|
|
425
|
+
`dotmd pickup` of that plan prints the handoff *instead of* the plan body
|
|
426
|
+
and atomically unlinks the sidecar — single-claim, can't be consumed twice.
|
|
427
|
+
|
|
428
|
+
The included `/handoff` slash command (scaffolded under
|
|
429
|
+
`.claude/commands/handoff.md` by `dotmd init` / `dotmd doctor`) instructs
|
|
430
|
+
Claude to synthesize and queue handoffs for every plan the session holds.
|
|
431
|
+
|
|
432
|
+
### Session leases & release
|
|
406
433
|
|
|
407
434
|
`dotmd pickup` records a lease at `<repoRoot>/.dotmd/in-session.json` that
|
|
408
435
|
identifies which Claude session owns the plan. The lease enables three
|
|
@@ -416,15 +443,15 @@ distinct outcomes when a plan is already `in-session`:
|
|
|
416
443
|
- **Stale lease.** If the holder's pid is dead (or the lease is >24h old),
|
|
417
444
|
pickup refuses but suggests `--takeover`.
|
|
418
445
|
|
|
419
|
-
Releasing leases:
|
|
446
|
+
Releasing leases (both names work; `release` is the recommended verb):
|
|
420
447
|
|
|
421
448
|
```bash
|
|
422
|
-
dotmd
|
|
423
|
-
dotmd
|
|
424
|
-
dotmd
|
|
425
|
-
dotmd
|
|
426
|
-
dotmd
|
|
427
|
-
dotmd
|
|
449
|
+
dotmd release # release every lease owned by current session
|
|
450
|
+
dotmd release docs/plans/foo.md # release that one (refuses cross-session)
|
|
451
|
+
dotmd release --to planned # override target status (default: lease.oldStatus)
|
|
452
|
+
dotmd release --stale # release leases with dead pid or >24h old
|
|
453
|
+
dotmd release --all # release every lease (administrative)
|
|
454
|
+
dotmd release --json # { released: [...], skipped: [...] }
|
|
428
455
|
```
|
|
429
456
|
|
|
430
457
|
`finish`, `archive`, and `rename` auto-release / migrate the lease, so the
|
|
@@ -440,16 +467,23 @@ common closeout paths are covered without ceremony.
|
|
|
440
467
|
The session id survives `/clear` and auto-compaction, so a re-attach after
|
|
441
468
|
either is silent.
|
|
442
469
|
|
|
443
|
-
**
|
|
444
|
-
|
|
470
|
+
**Recommended Claude Code hooks** — add both to `~/.claude/settings.json`
|
|
471
|
+
(or your project's `.claude/settings.json`):
|
|
445
472
|
|
|
446
473
|
```json
|
|
447
474
|
{
|
|
448
475
|
"hooks": {
|
|
476
|
+
"SessionStart": [
|
|
477
|
+
{
|
|
478
|
+
"hooks": [
|
|
479
|
+
{ "type": "command", "command": "dotmd hud", "timeout": 5 }
|
|
480
|
+
]
|
|
481
|
+
}
|
|
482
|
+
],
|
|
449
483
|
"SessionEnd": [
|
|
450
484
|
{
|
|
451
485
|
"hooks": [
|
|
452
|
-
{ "type": "command", "command": "dotmd
|
|
486
|
+
{ "type": "command", "command": "dotmd release", "timeout": 10 }
|
|
453
487
|
]
|
|
454
488
|
}
|
|
455
489
|
]
|
|
@@ -457,16 +491,22 @@ either is silent.
|
|
|
457
491
|
}
|
|
458
492
|
```
|
|
459
493
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
494
|
+
- **SessionStart** runs `dotmd hud`, which prints up to three actionable
|
|
495
|
+
lines (held leases, queued handoffs, stale leases) and stays silent when
|
|
496
|
+
nothing is queued. Use this instead of `dotmd briefing` for the hook role
|
|
497
|
+
— `briefing` dumps per-plan next_step prose that can run to many kilobytes
|
|
498
|
+
on large repos. `hud` is the zero-pollution surface.
|
|
499
|
+
- **SessionEnd** runs `dotmd release` (the new name for `dotmd unpickup`;
|
|
500
|
+
both still work), which releases every lease owned by the ending session
|
|
501
|
+
and flips plans back to their prior status.
|
|
463
502
|
|
|
464
|
-
> The double-`hooks` nesting is correct: `hooks
|
|
465
|
-
>
|
|
466
|
-
> `permissions.allow` list as well, otherwise the
|
|
503
|
+
> The double-`hooks` nesting is correct: `hooks.<Event>[*].hooks[*]` is the
|
|
504
|
+
> schema Claude Code requires. `Bash(dotmd:*)` should be in your
|
|
505
|
+
> `permissions.allow` list as well, otherwise the hooks will be blocked.
|
|
467
506
|
|
|
468
|
-
`dotmd
|
|
469
|
-
|
|
507
|
+
`dotmd hud` (and `dotmd briefing` for the verbose case) surface a
|
|
508
|
+
`⚠ N stuck leases` line when stale leases exist, with a
|
|
509
|
+
`dotmd release --stale` suggestion.
|
|
470
510
|
|
|
471
511
|
### Touch
|
|
472
512
|
|
package/bin/dotmd.mjs
CHANGED
|
@@ -14,8 +14,9 @@ const HELP = {
|
|
|
14
14
|
_main: `dotmd v${pkg.version} — frontmatter markdown document manager
|
|
15
15
|
|
|
16
16
|
View & Query:
|
|
17
|
+
hud [--json] Three-line actionable triage (held / handoffs / stuck) — silent when clean
|
|
17
18
|
list [--verbose] [--json] List docs grouped by status (default command)
|
|
18
|
-
briefing [--json]
|
|
19
|
+
briefing [--json] Full briefing with plan status counts + next steps
|
|
19
20
|
context [--summarize] [--json] Full briefing (LLM-oriented)
|
|
20
21
|
focus [status] [--json] Detailed view for one status group
|
|
21
22
|
query [filters] [--json] Filtered search (--status, --keyword, --stale, etc.)
|
|
@@ -41,8 +42,9 @@ Validate & Fix:
|
|
|
41
42
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
42
43
|
|
|
43
44
|
Lifecycle:
|
|
44
|
-
pickup <file> [--takeover] Pick up a plan (set in-session + print)
|
|
45
|
-
|
|
45
|
+
pickup <file> [--takeover] Pick up a plan (set in-session + print body or queued handoff)
|
|
46
|
+
release [<file>] [--to <s>] Release in-session lease (alias: unpickup)
|
|
47
|
+
handoff <file> [text|-|@path] Queue a resume-prompt sidecar + release (append-mode)
|
|
46
48
|
finish <file> [done|active] Finish a plan (set done or active)
|
|
47
49
|
status <file> <status> Transition document status
|
|
48
50
|
archive <file> Archive (status + move + update refs)
|
|
@@ -116,9 +118,14 @@ Filters:
|
|
|
116
118
|
|
|
117
119
|
pickup: `dotmd pickup <file> — pick up a plan and start working
|
|
118
120
|
|
|
119
|
-
Sets the plan to in-session and prints its content
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
Sets the plan to in-session and prints its content (prefixed with a
|
|
122
|
+
"[dotmd] holding <path>" line so the fresh session knows what it holds).
|
|
123
|
+
Writes a session lease to <repoRoot>/.dotmd/in-session.json so the same
|
|
124
|
+
Claude session can re-attach silently after compaction or /clear.
|
|
125
|
+
|
|
126
|
+
If a handoff sidecar is queued for the plan (\`.dotmd/handoffs/<path>\`),
|
|
127
|
+
pickup prints the handoff *instead of* the plan body and unlinks the
|
|
128
|
+
sidecar atomically — single-claim, can't be consumed twice.
|
|
122
129
|
|
|
123
130
|
If a plan is already in-session:
|
|
124
131
|
- Same session → silent re-attach (prints body, no error).
|
|
@@ -130,7 +137,8 @@ Options:
|
|
|
130
137
|
--json Output as JSON
|
|
131
138
|
--dry-run, -n Preview without writing
|
|
132
139
|
|
|
133
|
-
If no file is given, prompts with a list of active plans
|
|
140
|
+
If no file is given, prompts with a list of active/planned plans plus
|
|
141
|
+
any plan with a queued handoff (sorted to the top).`,
|
|
134
142
|
|
|
135
143
|
unpickup: `dotmd unpickup [<file>] — release a plan from in-session
|
|
136
144
|
|
|
@@ -154,6 +162,44 @@ Options:
|
|
|
154
162
|
Manual-edit fallback: if the plan's status is in-session but no lease
|
|
155
163
|
exists, --to <status> flips it anyway with a warning.`,
|
|
156
164
|
|
|
165
|
+
release: `dotmd release [<file>] [--to <s>] — alias of dotmd unpickup
|
|
166
|
+
|
|
167
|
+
Release the in-session lease(s) and flip frontmatter back to the prior
|
|
168
|
+
status. With no file, releases every lease owned by the current session.
|
|
169
|
+
Identical behavior to \`dotmd unpickup\`; both names route to the same
|
|
170
|
+
implementation. See \`dotmd unpickup --help\` for full option list.`,
|
|
171
|
+
|
|
172
|
+
handoff: `dotmd handoff <file> [text | - | @path] — queue resume prompt + release
|
|
173
|
+
|
|
174
|
+
Writes a handoff sidecar attached to the plan, then releases the lease
|
|
175
|
+
and flips frontmatter back to the prior status. The next \`dotmd pickup\`
|
|
176
|
+
of the plan prints the handoff (instead of the plan body) and atomically
|
|
177
|
+
unlinks the sidecar — single-claim guaranteed.
|
|
178
|
+
|
|
179
|
+
Sidecars live at <repoRoot>/.dotmd/handoffs/<plan-path> and accumulate
|
|
180
|
+
timestamped sections on repeat writes. To replace the chain instead of
|
|
181
|
+
appending, pass --replace.
|
|
182
|
+
|
|
183
|
+
Sources for handoff text (pick one):
|
|
184
|
+
<text> Inline argument (best for short notes)
|
|
185
|
+
--message "<text>" Explicit --message flag (equivalent to inline)
|
|
186
|
+
- Read from stdin (heredoc-friendly for Claude)
|
|
187
|
+
@path Read from a file
|
|
188
|
+
|
|
189
|
+
Examples:
|
|
190
|
+
dotmd handoff plans/foo.md "continue from validate(); next: write tests"
|
|
191
|
+
dotmd handoff plans/foo.md - <<'EOF'
|
|
192
|
+
…multi-line handoff…
|
|
193
|
+
EOF
|
|
194
|
+
dotmd handoff plans/foo.md @/tmp/handoff.md
|
|
195
|
+
|
|
196
|
+
Options:
|
|
197
|
+
--replace Overwrite the sidecar chain instead of appending
|
|
198
|
+
--json Output as JSON
|
|
199
|
+
--dry-run, -n Preview without writing
|
|
200
|
+
|
|
201
|
+
Refuses if the plan is not currently held by this session.`,
|
|
202
|
+
|
|
157
203
|
finish: `dotmd finish <file> [done|active] — finish working on a plan
|
|
158
204
|
|
|
159
205
|
Sets the plan status to done (default) or back to active.
|
|
@@ -213,6 +259,25 @@ Shows detailed info for all docs matching the given status (default: active).
|
|
|
213
259
|
Options:
|
|
214
260
|
--json Output as JSON`,
|
|
215
261
|
|
|
262
|
+
hud: `dotmd hud — three-line actionable triage
|
|
263
|
+
|
|
264
|
+
Prints up to three lines, in order:
|
|
265
|
+
▶ You hold N plans: <slugs> (leases owned by current session)
|
|
266
|
+
▶ N handoffs queued: <slugs> (resume-prompt sidecars waiting)
|
|
267
|
+
⚠ N stuck leases >24h (suggest \`dotmd release --stale\`)
|
|
268
|
+
|
|
269
|
+
Silent when all three are empty — designed for SessionStart hooks where
|
|
270
|
+
zero noise is the right default. Distinct from \`dotmd briefing\`, which
|
|
271
|
+
dumps the full plan-status pipeline and per-plan next_step bodies (kilobytes
|
|
272
|
+
on large repos). Use hud for ergonomic session boot; use briefing for
|
|
273
|
+
explicit "give me the full picture."
|
|
274
|
+
|
|
275
|
+
Recommended SessionStart hook (in ~/.claude/settings.json):
|
|
276
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "dotmd hud", "timeout": 5 }] }]
|
|
277
|
+
|
|
278
|
+
Options:
|
|
279
|
+
--json Output as JSON ({ owned, queued, stale })`,
|
|
280
|
+
|
|
216
281
|
briefing: `dotmd briefing — compact summary for session start
|
|
217
282
|
|
|
218
283
|
Shows plan statuses with next steps, doc/research counts, and health
|
|
@@ -635,8 +700,10 @@ async function main() {
|
|
|
635
700
|
if (command === 'notion') { const { runNotion } = await import('../src/notion.mjs'); await runNotion(restArgs, config, { dryRun }); return; }
|
|
636
701
|
|
|
637
702
|
// Lifecycle commands
|
|
703
|
+
if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
|
|
638
704
|
if (command === 'pickup') { const { runPickup } = await import('../src/lifecycle.mjs'); await runPickup(restArgs, config, { dryRun }); return; }
|
|
639
|
-
if (command === 'unpickup') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
705
|
+
if (command === 'unpickup' || command === 'release') { const { runUnpickup } = await import('../src/lifecycle.mjs'); await runUnpickup(restArgs, config, { dryRun }); return; }
|
|
706
|
+
if (command === 'handoff') { const { runHandoff } = await import('../src/lifecycle.mjs'); await runHandoff(restArgs, config, { dryRun }); return; }
|
|
640
707
|
if (command === 'finish') { const { runFinish } = await import('../src/lifecycle.mjs'); await runFinish(restArgs, config, { dryRun }); return; }
|
|
641
708
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
|
642
709
|
if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
|
|
@@ -895,8 +962,8 @@ async function main() {
|
|
|
895
962
|
|
|
896
963
|
// Unknown command — suggest closest match
|
|
897
964
|
const allCommands = [
|
|
898
|
-
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context',
|
|
899
|
-
'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
|
|
965
|
+
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
|
|
966
|
+
'focus', 'query', 'plans', 'stale', 'actionable', 'index', 'pickup', 'release', 'handoff', 'finish', 'status', 'archive', 'bulk', 'touch', 'doctor',
|
|
900
967
|
'unblocks', 'health', 'glossary',
|
|
901
968
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
902
969
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -16,6 +16,9 @@ function generatePlansCommand(config) {
|
|
|
16
16
|
lines.push('');
|
|
17
17
|
lines.push('Plan-specific commands:');
|
|
18
18
|
lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
|
|
19
|
+
lines.push('- `dotmd pickup <file>` — pick up a plan (set in-session + print body or queued handoff)');
|
|
20
|
+
lines.push('- `dotmd release` — release current session\'s leases (alias: unpickup)');
|
|
21
|
+
lines.push('- `dotmd handoff <file> -` — queue a resume-prompt sidecar + release (see /handoff)');
|
|
19
22
|
lines.push('- `dotmd health` — plan velocity, aging, checklist progress, pipeline view');
|
|
20
23
|
lines.push('- `dotmd unblocks <file>` — what depends on / is blocked by a plan');
|
|
21
24
|
lines.push('- `dotmd next` — ready plans with next steps (what to promote)');
|
|
@@ -39,6 +42,44 @@ function generatePlansCommand(config) {
|
|
|
39
42
|
return lines.join('\n');
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
function generateHandoffCommand(config) {
|
|
46
|
+
const lines = [VERSION_MARKER, ''];
|
|
47
|
+
lines.push('Queue a resume-prompt sidecar for every plan this session is currently holding. Use this whenever the user asks for a "resume prompt" / "handoff" / "context dump", or when you are about to stop mid-work and the next session will need to pick up where you left off. NEVER print the resume prompt into chat for copy-paste — write it as a handoff sidecar so the next `dotmd pickup` can consume it cleanly.');
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push('Steps:');
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push('1. Identify which plans this session is holding by running:');
|
|
52
|
+
lines.push(' ```');
|
|
53
|
+
lines.push(' dotmd plans --status in-session --json');
|
|
54
|
+
lines.push(' ```');
|
|
55
|
+
lines.push(' Filter to leases owned by the current session (compare `lease.session` against `$CLAUDE_CODE_SESSION_ID` if needed). If the user passed a specific plan path as an argument, use only that one.');
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push('2. For each held plan, synthesize a handoff prompt covering:');
|
|
58
|
+
lines.push(' - One-line summary of where the session got to');
|
|
59
|
+
lines.push(' - What is already done (and verified working)');
|
|
60
|
+
lines.push(' - What is in flight / partial / needs verification');
|
|
61
|
+
lines.push(' - Concrete next step the resuming session should take');
|
|
62
|
+
lines.push(' - Key files touched (with paths) and any non-obvious gotchas');
|
|
63
|
+
lines.push(' - Open questions or decisions deferred');
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push('3. Write each handoff via Bash heredoc:');
|
|
66
|
+
lines.push(' ```bash');
|
|
67
|
+
lines.push(' dotmd handoff <plan-path> - <<\'EOF\'');
|
|
68
|
+
lines.push(' <synthesized handoff body>');
|
|
69
|
+
lines.push(' EOF');
|
|
70
|
+
lines.push(' ```');
|
|
71
|
+
lines.push(' Append-mode is the default. The sidecar accumulates timestamped sections, so a session that writes multiple handoffs over time builds up a chronicle the next session reads in order.');
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push('4. Report back which plans got handoffs queued and where their sidecars live (the `▶ Handoff queued: <path>` output line). The user closes their window; the next session runs `claude "$(dotmd pickup <plan>)"` and lands seeded with the handoff content.');
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push('Rules:');
|
|
76
|
+
lines.push('- If a plan is not held by this session, do not write a handoff for it (dotmd will refuse anyway).');
|
|
77
|
+
lines.push('- If `dotmd handoff` errors with a recoverable message (existing sidecar conflict, etc.), read the path it cites, merge mentally, then re-run with `--replace`.');
|
|
78
|
+
lines.push('- Never print the resume text into the conversation as a copy-paste block.');
|
|
79
|
+
lines.push('');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
function generateDocsCommand(config) {
|
|
43
84
|
const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
|
|
44
85
|
const rootCount = roots.length;
|
|
@@ -104,6 +145,7 @@ export function scaffoldClaudeCommands(cwd, config) {
|
|
|
104
145
|
const files = [
|
|
105
146
|
{ name: 'plans.md', generate: () => generatePlansCommand(config) },
|
|
106
147
|
{ name: 'docs.md', generate: () => generateDocsCommand(config) },
|
|
148
|
+
{ name: 'handoff.md', generate: () => generateHandoffCommand(config) },
|
|
107
149
|
];
|
|
108
150
|
|
|
109
151
|
for (const { name, generate } of files) {
|
|
@@ -136,7 +178,7 @@ export function checkClaudeCommands(cwd) {
|
|
|
136
178
|
if (!existsSync(commandsDir)) return [];
|
|
137
179
|
|
|
138
180
|
const warnings = [];
|
|
139
|
-
for (const name of ['plans.md', 'docs.md']) {
|
|
181
|
+
for (const name of ['plans.md', 'docs.md', 'handoff.md']) {
|
|
140
182
|
const filePath = path.join(commandsDir, name);
|
|
141
183
|
const installedVersion = getInstalledVersion(filePath);
|
|
142
184
|
if (installedVersion && installedVersion !== pkg.version) {
|
package/src/handoff.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const HANDOFF_DIR = path.join('.dotmd', 'handoffs');
|
|
5
|
+
|
|
6
|
+
export function handoffPath(config, repoPath) {
|
|
7
|
+
return path.join(config.repoRoot, HANDOFF_DIR, repoPath);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hasHandoff(config, repoPath) {
|
|
11
|
+
return existsSync(handoffPath(config, repoPath));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readHandoff(config, repoPath) {
|
|
15
|
+
const file = handoffPath(config, repoPath);
|
|
16
|
+
if (!existsSync(file)) return null;
|
|
17
|
+
return readFileSync(file, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function appendHandoff(config, repoPath, text, opts = {}) {
|
|
21
|
+
const file = handoffPath(config, repoPath);
|
|
22
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
24
|
+
const block = `## ${stamp}\n\n${text.trimEnd()}\n`;
|
|
25
|
+
if (opts.replace || !existsSync(file)) {
|
|
26
|
+
writeFileSync(file, block + '\n', 'utf8');
|
|
27
|
+
} else {
|
|
28
|
+
const prior = readFileSync(file, 'utf8').trimEnd();
|
|
29
|
+
writeFileSync(file, `${prior}\n\n${block}\n`, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function consumeHandoff(config, repoPath) {
|
|
35
|
+
const file = handoffPath(config, repoPath);
|
|
36
|
+
if (!existsSync(file)) return null;
|
|
37
|
+
const body = readFileSync(file, 'utf8');
|
|
38
|
+
unlinkSync(file);
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listQueuedHandoffs(config) {
|
|
43
|
+
const root = path.join(config.repoRoot, HANDOFF_DIR);
|
|
44
|
+
if (!existsSync(root)) return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
function walk(dir) {
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
+
const full = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) { walk(full); continue; }
|
|
50
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
51
|
+
const repoPath = path.relative(root, full).split(path.sep).join('/');
|
|
52
|
+
const st = statSync(full);
|
|
53
|
+
out.push({ repoPath, path: full, mtimeMs: st.mtimeMs });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
walk(root);
|
|
57
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
package/src/hud.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readLeases, findStaleLeases, currentSessionId } from './lease.mjs';
|
|
3
|
+
import { listQueuedHandoffs } from './handoff.mjs';
|
|
4
|
+
import { green, yellow, dim } from './color.mjs';
|
|
5
|
+
|
|
6
|
+
const MAX_PREVIEW = 5;
|
|
7
|
+
|
|
8
|
+
function slug(repoPath) { return path.basename(repoPath, '.md'); }
|
|
9
|
+
|
|
10
|
+
function previewList(items, max = MAX_PREVIEW) {
|
|
11
|
+
const slugs = items.slice(0, max).map(slug);
|
|
12
|
+
const more = items.length > max ? `, +${items.length - max} more` : '';
|
|
13
|
+
return slugs.join(', ') + more;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildHud(config) {
|
|
17
|
+
const session = currentSessionId();
|
|
18
|
+
const leases = readLeases(config);
|
|
19
|
+
const owned = Object.values(leases).filter(l => l.session === session).map(l => l.path);
|
|
20
|
+
const queued = listQueuedHandoffs(config).map(h => h.repoPath);
|
|
21
|
+
const stale = findStaleLeases(config).map(l => l.path);
|
|
22
|
+
|
|
23
|
+
return { owned, queued, stale };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runHud(argv, config) {
|
|
27
|
+
const json = argv.includes('--json');
|
|
28
|
+
const hud = buildHud(config);
|
|
29
|
+
|
|
30
|
+
if (json) {
|
|
31
|
+
process.stdout.write(JSON.stringify(hud, null, 2) + '\n');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lines = [];
|
|
36
|
+
if (hud.owned.length > 0) {
|
|
37
|
+
lines.push(green(`▶ You hold ${hud.owned.length} plan${hud.owned.length === 1 ? '' : 's'}: ${previewList(hud.owned)}`));
|
|
38
|
+
}
|
|
39
|
+
if (hud.queued.length > 0) {
|
|
40
|
+
lines.push(green(`▶ ${hud.queued.length} handoff${hud.queued.length === 1 ? '' : 's'} queued: ${previewList(hud.queued)} ${dim('(resume: dotmd pickup)')}`));
|
|
41
|
+
}
|
|
42
|
+
if (hud.stale.length > 0) {
|
|
43
|
+
lines.push(yellow(`⚠ ${hud.stale.length} stuck lease${hud.stale.length === 1 ? '' : 's'} >24h ${dim('(run: dotmd release --stale)')}`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (lines.length === 0) return; // silent when clean
|
|
47
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
48
|
+
}
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso } from './util.mjs';
|
|
5
5
|
import { gitMv, getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
currentSessionId,
|
|
17
17
|
migrateLease,
|
|
18
18
|
} from './lease.mjs';
|
|
19
|
+
import { hasHandoff, consumeHandoff, appendHandoff, handoffPath, listQueuedHandoffs } from './handoff.mjs';
|
|
19
20
|
|
|
20
21
|
function findFileRoot(filePath, config) {
|
|
21
22
|
const roots = config.docsRoots || [config.docsRoot];
|
|
@@ -70,7 +71,7 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
const today =
|
|
74
|
+
const today = nowIso();
|
|
74
75
|
const archiveDir = path.join(fileRoot, config.archiveDir);
|
|
75
76
|
const relFromRoot = path.relative(fileRoot, filePath);
|
|
76
77
|
const inArchive = relFromRoot.startsWith(config.archiveDir + '/') || relFromRoot.startsWith(config.archiveDir + path.sep);
|
|
@@ -136,17 +137,22 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
136
137
|
const takeover = argv.includes('--takeover');
|
|
137
138
|
let input = argv.find(a => !a.startsWith('-'));
|
|
138
139
|
|
|
139
|
-
// Interactive: pick from active plans
|
|
140
|
+
// Interactive: pick from active/planned plans + anything with a queued handoff
|
|
140
141
|
if (!input) {
|
|
141
142
|
if (!isInteractive()) die('Usage: dotmd pickup <file>');
|
|
142
143
|
const index = buildIndex(config);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const queued = new Set(listQueuedHandoffs(config).map(h => h.repoPath));
|
|
145
|
+
const candidates = index.docs.filter(d =>
|
|
146
|
+
d.type === 'plan' && (d.status === 'active' || d.status === 'planned' || queued.has(d.path))
|
|
147
|
+
);
|
|
148
|
+
if (candidates.length === 0) die('No active/planned plans and no queued handoffs.');
|
|
149
|
+
candidates.sort((a, b) => Number(queued.has(b.path)) - Number(queued.has(a.path)));
|
|
150
|
+
const labelFor = (d) => `${queued.has(d.path) ? '▶ handoff queued · ' : ''}${d.title} (${d.status}) — ${d.path}`;
|
|
151
|
+
const choice = await promptChoice('Pick a plan:', candidates.map(labelFor));
|
|
146
152
|
if (!choice) die('No plan selected.');
|
|
147
|
-
const idx =
|
|
153
|
+
const idx = candidates.findIndex(d => choice === labelFor(d));
|
|
148
154
|
if (idx === -1) die('No plan selected.');
|
|
149
|
-
input =
|
|
155
|
+
input = candidates[idx].path;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
const filePath = resolveDocPath(input, config);
|
|
@@ -175,10 +181,11 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
const handoffQueued = hasHandoff(config, repoPath);
|
|
178
185
|
const pickupable = new Set(['active', 'planned', 'in-session']);
|
|
179
|
-
if (oldStatus && !pickupable.has(oldStatus)) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
|
|
186
|
+
if (oldStatus && !pickupable.has(oldStatus) && !handoffQueued) die(`Cannot pick up a plan with status '${oldStatus}'. Must be active or planned.\n ${repoPath}`);
|
|
180
187
|
|
|
181
|
-
const today =
|
|
188
|
+
const today = nowIso();
|
|
182
189
|
const leaseOldStatus = oldStatus === 'in-session' ? 'active' : (oldStatus ?? 'active');
|
|
183
190
|
let leaseOutcome = 'acquired';
|
|
184
191
|
|
|
@@ -204,12 +211,18 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
204
211
|
}
|
|
205
212
|
}
|
|
206
213
|
|
|
214
|
+
let handoffBody = null;
|
|
215
|
+
if (handoffQueued && !dryRun) {
|
|
216
|
+
try { handoffBody = consumeHandoff(config, repoPath); } catch (err) { warn(`Could not consume handoff for ${repoPath}: ${err.message}`); }
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
if (json) {
|
|
208
220
|
process.stdout.write(JSON.stringify({
|
|
209
221
|
path: repoPath, oldStatus, newStatus: 'in-session', title,
|
|
210
222
|
reattached: leaseOutcome === 'reattached',
|
|
211
223
|
takenOver: leaseOutcome === 'taken-over',
|
|
212
|
-
|
|
224
|
+
handoffConsumed: handoffBody !== null,
|
|
225
|
+
body: (handoffBody ?? body)?.trim() ?? '',
|
|
213
226
|
}, null, 2) + '\n');
|
|
214
227
|
} else {
|
|
215
228
|
if (leaseOutcome === 'reattached') {
|
|
@@ -219,7 +232,12 @@ export async function runPickup(argv, config, opts = {}) {
|
|
|
219
232
|
} else {
|
|
220
233
|
process.stderr.write(`${green('▶ Picked up')}: ${repoPath} (${oldStatus ?? 'unset'} → in-session)\n\n`);
|
|
221
234
|
}
|
|
222
|
-
|
|
235
|
+
const header = handoffBody
|
|
236
|
+
? `[dotmd] holding ${repoPath} — consumed handoff. Release with: dotmd release ${repoPath}\n---\n`
|
|
237
|
+
: `[dotmd] holding ${repoPath} — release with: dotmd release ${repoPath}\n---\n`;
|
|
238
|
+
process.stdout.write(header);
|
|
239
|
+
const content = (handoffBody ?? body ?? '').trim();
|
|
240
|
+
if (content) process.stdout.write(content + '\n');
|
|
223
241
|
}
|
|
224
242
|
|
|
225
243
|
try { config.hooks.onPickup?.({ path: repoPath, oldStatus, newStatus: 'in-session' }); } catch (err) { warn(`Hook 'onPickup' threw: ${err.message}`); }
|
|
@@ -284,7 +302,7 @@ export async function runUnpickup(argv, config, opts = {}) {
|
|
|
284
302
|
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
285
303
|
const cur = asString(parsedFm.status);
|
|
286
304
|
if (cur === 'in-session') {
|
|
287
|
-
const today =
|
|
305
|
+
const today = nowIso();
|
|
288
306
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
289
307
|
}
|
|
290
308
|
// If frontmatter is no longer in-session (manual flip), leave it alone.
|
|
@@ -394,7 +412,7 @@ export async function runFinish(argv, config, opts = {}) {
|
|
|
394
412
|
|
|
395
413
|
if (oldStatus !== 'in-session') die(`Plan is not in-session (current: ${oldStatus}).\n ${repoPath}`);
|
|
396
414
|
|
|
397
|
-
const today =
|
|
415
|
+
const today = nowIso();
|
|
398
416
|
|
|
399
417
|
if (dryRun) {
|
|
400
418
|
process.stderr.write(`${dim('[dry-run]')} Would update: status: in-session → ${targetStatus}, updated: ${today}\n`);
|
|
@@ -433,7 +451,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
433
451
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
434
452
|
const oldStatus = asString(parsed.status) ?? 'unknown';
|
|
435
453
|
|
|
436
|
-
const today =
|
|
454
|
+
const today = nowIso();
|
|
437
455
|
const targetDir = path.join(archiveFileRoot, config.archiveDir);
|
|
438
456
|
const targetPath = path.join(targetDir, path.basename(filePath));
|
|
439
457
|
const oldRepoPath = toRepoPath(filePath, config.repoRoot);
|
|
@@ -590,7 +608,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
590
608
|
const filePath = resolveDocPath(input, config);
|
|
591
609
|
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
592
610
|
|
|
593
|
-
const today =
|
|
611
|
+
const today = nowIso();
|
|
594
612
|
|
|
595
613
|
if (dryRun) {
|
|
596
614
|
process.stdout.write(`${dim('[dry-run]')} Would touch: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
@@ -706,6 +724,108 @@ function countRefsToUpdate(oldPath, newPath, config) {
|
|
|
706
724
|
return count;
|
|
707
725
|
}
|
|
708
726
|
|
|
727
|
+
function readHandoffInput(source) {
|
|
728
|
+
if (source === '-') {
|
|
729
|
+
const chunks = [];
|
|
730
|
+
try { chunks.push(readFileSync(0, 'utf8')); } catch (err) { die(`Could not read handoff from stdin: ${err.message}`); }
|
|
731
|
+
return chunks.join('');
|
|
732
|
+
}
|
|
733
|
+
if (typeof source === 'string' && source.startsWith('@')) {
|
|
734
|
+
const file = source.slice(1);
|
|
735
|
+
if (!existsSync(file)) die(`Handoff file not found: ${file}`);
|
|
736
|
+
return readFileSync(file, 'utf8');
|
|
737
|
+
}
|
|
738
|
+
return source;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export async function runHandoff(argv, config, opts = {}) {
|
|
742
|
+
const { dryRun } = opts;
|
|
743
|
+
const json = argv.includes('--json');
|
|
744
|
+
const replace = argv.includes('--replace');
|
|
745
|
+
const messageIdx = argv.indexOf('--message');
|
|
746
|
+
const messageFlag = messageIdx >= 0 ? argv[messageIdx + 1] : null;
|
|
747
|
+
const positional = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--message');
|
|
748
|
+
|
|
749
|
+
let input = positional[0];
|
|
750
|
+
let text = messageFlag !== null ? messageFlag : positional[1];
|
|
751
|
+
|
|
752
|
+
// Interactive: pick from in-session plans owned by current session
|
|
753
|
+
if (!input) {
|
|
754
|
+
if (!isInteractive()) die('Usage: dotmd handoff <file> [text | - | @path] (or --message "text")');
|
|
755
|
+
const leases = readLeases(config);
|
|
756
|
+
const session = currentSessionId();
|
|
757
|
+
const owned = Object.values(leases).filter(l => l.session === session);
|
|
758
|
+
if (owned.length === 0) die('No in-session plans owned by this session.');
|
|
759
|
+
if (owned.length === 1) {
|
|
760
|
+
input = owned[0].path;
|
|
761
|
+
process.stderr.write(`${dim(`Auto-selected: ${input}`)}\n`);
|
|
762
|
+
} else {
|
|
763
|
+
const choice = await promptChoice('Handoff which plan:', owned.map(l => l.path));
|
|
764
|
+
if (!choice) die('No plan selected.');
|
|
765
|
+
input = choice;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const filePath = resolveDocPath(input, config);
|
|
770
|
+
if (!filePath) die(`File not found: ${input}`);
|
|
771
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
772
|
+
|
|
773
|
+
// Resolve text: explicit arg/stdin/@file, else die (no $EDITOR fallback — sessions are non-interactive)
|
|
774
|
+
if (text === undefined || text === null) {
|
|
775
|
+
die('Missing handoff text. Pass inline, --message "text", - for stdin, or @path for a file.');
|
|
776
|
+
}
|
|
777
|
+
const body = readHandoffInput(text);
|
|
778
|
+
if (!body.trim()) die('Handoff text is empty.');
|
|
779
|
+
|
|
780
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
781
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
782
|
+
const parsedFm = parseSimpleFrontmatter(fmRaw);
|
|
783
|
+
const oldStatus = asString(parsedFm.status);
|
|
784
|
+
|
|
785
|
+
// Must be holding this plan in current session
|
|
786
|
+
const leases = readLeases(config);
|
|
787
|
+
const lease = leases[repoPath];
|
|
788
|
+
const session = currentSessionId();
|
|
789
|
+
if (!lease || lease.session !== session) {
|
|
790
|
+
die(`Not held by this session: ${repoPath}\n Run \`dotmd pickup ${repoPath}\` first.`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const today = nowIso();
|
|
794
|
+
const targetStatus = lease.oldStatus || 'active';
|
|
795
|
+
|
|
796
|
+
if (dryRun) {
|
|
797
|
+
process.stderr.write(`${dim('[dry-run]')} Would ${replace ? 'replace' : 'append to'} ${toRepoPath(handoffPath(config, repoPath), config.repoRoot)}\n`);
|
|
798
|
+
process.stderr.write(`${dim('[dry-run]')} Would release lease and flip status: in-session → ${targetStatus}\n`);
|
|
799
|
+
if (json) process.stdout.write(JSON.stringify({ path: repoPath, handoff: toRepoPath(handoffPath(config, repoPath), config.repoRoot), bytes: body.length, replace, dryRun: true }, null, 2) + '\n');
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Order: write handoff first, then release. A crash between leaves a queued
|
|
804
|
+
// handoff with a held lease (recoverable) instead of a released lease with
|
|
805
|
+
// no handoff (silently lost).
|
|
806
|
+
const written = appendHandoff(config, repoPath, body, { replace });
|
|
807
|
+
|
|
808
|
+
if (oldStatus === 'in-session') {
|
|
809
|
+
updateFrontmatter(filePath, { status: targetStatus, updated: today });
|
|
810
|
+
}
|
|
811
|
+
releaseLease(config, repoPath, { force: true });
|
|
812
|
+
|
|
813
|
+
if (json) {
|
|
814
|
+
process.stdout.write(JSON.stringify({
|
|
815
|
+
path: repoPath,
|
|
816
|
+
handoff: toRepoPath(written, config.repoRoot),
|
|
817
|
+
bytes: body.length,
|
|
818
|
+
replace,
|
|
819
|
+
newStatus: targetStatus,
|
|
820
|
+
}, null, 2) + '\n');
|
|
821
|
+
} else {
|
|
822
|
+
process.stdout.write(`${green('▶ Handoff queued')}: ${toRepoPath(written, config.repoRoot)} (${body.length} bytes${replace ? ', replaced' : ', appended'})\n`);
|
|
823
|
+
process.stdout.write(`${green('↩ Released')}: ${repoPath} (in-session → ${targetStatus})\n`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
try { config.hooks.onUnpickup?.({ path: repoPath, oldStatus: 'in-session', newStatus: targetStatus }); } catch (err) { warn(`Hook 'onUnpickup' threw: ${err.message}`); }
|
|
827
|
+
}
|
|
828
|
+
|
|
709
829
|
export function updateFrontmatter(filePath, updates) {
|
|
710
830
|
const raw = readFileSync(filePath, 'utf8');
|
|
711
831
|
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { toRepoPath, die, warn } from './util.mjs';
|
|
3
|
+
import { toRepoPath, die, warn, nowIso } from './util.mjs';
|
|
4
4
|
import { green, dim, bold } from './color.mjs';
|
|
5
5
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
6
6
|
|
|
@@ -11,9 +11,82 @@ const BUILTIN_TEMPLATES = {
|
|
|
11
11
|
body: (t) => `\n# ${t}\n`,
|
|
12
12
|
},
|
|
13
13
|
plan: {
|
|
14
|
-
description: 'Execution plan with
|
|
15
|
-
frontmatter: (s, d) =>
|
|
16
|
-
|
|
14
|
+
description: 'Execution plan — build-up shape (Problem → Phases → Closeout) with phase status markers and Version History',
|
|
15
|
+
frontmatter: (s, d) => [
|
|
16
|
+
'type: plan',
|
|
17
|
+
`status: ${s}`,
|
|
18
|
+
`created: ${d}`,
|
|
19
|
+
`updated: ${d}`,
|
|
20
|
+
'surfaces: []',
|
|
21
|
+
'modules: []',
|
|
22
|
+
'domain:',
|
|
23
|
+
'audience: internal',
|
|
24
|
+
'parent_plan:',
|
|
25
|
+
'related_plans: []',
|
|
26
|
+
'related_docs: []',
|
|
27
|
+
'current_state:',
|
|
28
|
+
'next_step:',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
body: (t, ctx) => `
|
|
31
|
+
# ${t}
|
|
32
|
+
|
|
33
|
+
> One-paragraph problem statement: what this plan is for, why now.
|
|
34
|
+
|
|
35
|
+
## Problem
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Goals
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Non-Goals
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
## What Exists Today
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## Constraints
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Decisions
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Open Questions
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Phases
|
|
64
|
+
|
|
65
|
+
<!--
|
|
66
|
+
Status markers (put in heading text):
|
|
67
|
+
⬜ not started
|
|
68
|
+
🟡 in progress (pickup targets this)
|
|
69
|
+
✅ shipped (history; pickup skips)
|
|
70
|
+
⏭ skipped (with reason in body)
|
|
71
|
+
🚧 blocked (link to blocker)
|
|
72
|
+
-->
|
|
73
|
+
|
|
74
|
+
### Phase 1 — <title> ⬜
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Deferred
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
## Version History
|
|
83
|
+
|
|
84
|
+
- **${ctx?.today ?? ''}** Created.
|
|
85
|
+
|
|
86
|
+
## Closeout
|
|
87
|
+
|
|
88
|
+
<!-- Filled on archive: what shipped, key commits, deferrals dispositioned. -->
|
|
89
|
+
`,
|
|
17
90
|
},
|
|
18
91
|
adr: {
|
|
19
92
|
description: 'Architecture Decision Record',
|
|
@@ -115,7 +188,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
115
188
|
die(`File already exists: ${repoPath}`);
|
|
116
189
|
}
|
|
117
190
|
|
|
118
|
-
const today =
|
|
191
|
+
const today = nowIso();
|
|
119
192
|
|
|
120
193
|
// Generate content
|
|
121
194
|
let content;
|
|
@@ -123,7 +196,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
123
196
|
content = template(name, { status, title: docTitle, today });
|
|
124
197
|
} else {
|
|
125
198
|
const fm = template.frontmatter(status, today);
|
|
126
|
-
const body = template.body(docTitle);
|
|
199
|
+
const body = template.body(docTitle, { today, status });
|
|
127
200
|
content = `---\n${fm}\n---\n${body}`;
|
|
128
201
|
}
|
|
129
202
|
|
package/src/render.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { extractFrontmatter } from './frontmatter.mjs';
|
|
|
5
5
|
import { summarizeDocBody } from './ai.mjs';
|
|
6
6
|
import { bold, red, yellow, green, dim } from './color.mjs';
|
|
7
7
|
import { findStaleLeases } from './lease.mjs';
|
|
8
|
+
import { listQueuedHandoffs } from './handoff.mjs';
|
|
8
9
|
|
|
9
10
|
export function renderCompactList(index, config) {
|
|
10
11
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
@@ -265,6 +266,17 @@ function _renderContext(index, config, opts = {}) {
|
|
|
265
266
|
|
|
266
267
|
export function renderBriefing(index, config) {
|
|
267
268
|
const lines = [];
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const queued = listQueuedHandoffs(config);
|
|
272
|
+
if (queued.length > 0) {
|
|
273
|
+
const preview = queued.slice(0, 3).map(h => path.basename(h.repoPath, '.md')).join(', ');
|
|
274
|
+
const more = queued.length > 3 ? `, +${queued.length - 3} more` : '';
|
|
275
|
+
lines.push(green(`▶ ${queued.length} handoff${queued.length === 1 ? '' : 's'} queued: ${preview}${more}`));
|
|
276
|
+
lines.push(dim(` Resume: claude "$(dotmd pickup <plan>)" or just: dotmd pickup`));
|
|
277
|
+
}
|
|
278
|
+
} catch {}
|
|
279
|
+
|
|
268
280
|
const plans = index.docs.filter(d => d.type === 'plan');
|
|
269
281
|
const docs = index.docs.filter(d => d.type === 'doc');
|
|
270
282
|
const research = index.docs.filter(d => d.type === 'research');
|
|
@@ -301,7 +313,7 @@ export function renderBriefing(index, config) {
|
|
|
301
313
|
try {
|
|
302
314
|
const staleLeases = findStaleLeases(config);
|
|
303
315
|
if (staleLeases.length > 0) {
|
|
304
|
-
lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd
|
|
316
|
+
lines.push(yellow(`Stuck in-session: ${staleLeases.length} (>1d or dead pid, run \`dotmd release --stale\`)`));
|
|
305
317
|
}
|
|
306
318
|
} catch {}
|
|
307
319
|
|
package/src/util.mjs
CHANGED
|
@@ -55,6 +55,10 @@ export function toRepoPath(absolutePath, repoRoot) {
|
|
|
55
55
|
return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export function nowIso() {
|
|
59
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
export function warn(message) {
|
|
59
63
|
process.stderr.write(`${dim(message)}\n`);
|
|
60
64
|
}
|