claude-prism 1.6.1 → 1.7.1
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -2
- package/README.md +21 -4
- package/bin/cli.mjs +49 -1
- package/hooks/task-plan-sync.mjs +52 -0
- package/lib/config.mjs +15 -1
- package/lib/installer.mjs +2 -2
- package/lib/messages.mjs +3 -0
- package/lib/pipeline.mjs +11 -7
- package/lib/plan-lifecycle.mjs +245 -0
- package/package.json +1 -1
- package/templates/commands/claude-prism/plan.md +83 -1
- package/templates/runners/precompact.mjs +4 -2
- package/templates/runners/session-end.mjs +4 -2
- package/templates/runners/subagent-start.mjs +4 -2
- package/templates/runners/task-completed.mjs +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,10 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [1.
|
|
8
|
+
## [1.7.1] — 2026-03-06
|
|
9
9
|
|
|
10
10
|
### Fixed
|
|
11
|
-
- **
|
|
11
|
+
- **Monorepo hook compatibility** — hooks now use `input.cwd` from Claude Code instead of `process.cwd()` to resolve the correct project root
|
|
12
|
+
- `findProjectRoot()` upward search for nearest `.prism/config.json` — prevents wrong config in monorepo setups
|
|
13
|
+
- `config.projectRoot` injection for all hook rules — existing `config.projectRoot || process.cwd()` fallbacks now receive the correct value
|
|
14
|
+
- All 4 template runners (`precompact`, `session-end`, `subagent-start`, `task-completed`) updated to use `findProjectRoot(input.cwd)`
|
|
15
|
+
- `pipeline.mjs` — `runPipeline()`, `runPipelineAsync()`, `loadCustomRules()` all resolve project root from hook input
|
|
16
|
+
|
|
17
|
+
## [1.7.0] — 2026-03-06
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Plan Lifecycle Management** — 6 states (`draft`, `active`, `blocked`, `completed`, `archived`, `abandoned`) with validated state machine transitions
|
|
21
|
+
- **Plan History Log** — `.prism/plans/.history.jsonl` records all status changes and progress milestones as timestamped JSONL events
|
|
22
|
+
- **8 new `/plan` subcommands** — `complete`, `archive`, `block`, `unblock`, `abandon`, `reopen`, `history`, `status`
|
|
23
|
+
- **Auto-complete** — plan auto-transitions to `completed` when all tasks are checked (via task-plan-sync hook)
|
|
24
|
+
- **Draft-to-active** — plan auto-transitions from `draft` to `active` on first task check
|
|
25
|
+
- **Progress milestones** — 25%, 50%, 75% progress events recorded to history log
|
|
26
|
+
- `lib/plan-lifecycle.mjs` — core lifecycle functions (`validateTransition`, `updatePlanStatus`, `appendHistory`, `readHistory`, `resolvePlan`)
|
|
27
|
+
- `STATUS_ICONS` export — emoji mapping for all 6 plan statuses
|
|
28
|
+
- 3 new message templates (`plan-lifecycle.completed`, `plan-lifecycle.status-changed`, `plan-lifecycle.auto-activated`)
|
|
29
|
+
- **Plan Discovery** — `prism init`/`prism update` automatically scans `docs/`, `docs/plans/` for existing plan files and offers to import them into `.prism/plans/` (copy, originals preserved). Plans without frontmatter get auto-assigned status based on task progress (draft/active/completed).
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- `hooks/task-plan-sync.mjs` — integrates lifecycle auto-transitions (draft→active, active→completed, progress milestones)
|
|
33
|
+
- `lib/installer.mjs` — installs 9 lib files (was 8, added `plan-lifecycle.mjs`)
|
|
34
|
+
- Backward compatible: plans without frontmatter default to `active` status
|
|
35
|
+
|
|
36
|
+
## [1.6.1] — 2026-03-06
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- Version sync with npm registry (1.6.0 content, version bump only)
|
|
12
40
|
|
|
13
41
|
## [1.6.0] — 2026-03-05
|
|
14
42
|
|
package/README.md
CHANGED
|
@@ -90,9 +90,16 @@ Injected into `CLAUDE.md`, EUDEC is a behavioral framework that corrects how AI
|
|
|
90
90
|
- **Streamlined verification**: 3-level fallback ladder (Tests → Build → Diff)
|
|
91
91
|
- **Adaptive checkpoints**: no pause for small tasks, summary for medium, full for large
|
|
92
92
|
|
|
93
|
-
**New in v1.
|
|
93
|
+
**New in v1.7.0:**
|
|
94
|
+
- **Plan Lifecycle Management** — 6 states (`draft` → `active` → `completed` → `archived`, plus `blocked` and `abandoned`) with validated state machine transitions
|
|
95
|
+
- **Auto-transitions** — plans auto-activate on first task check, auto-complete when all tasks done, with progress milestones (25/50/75%) logged
|
|
96
|
+
- **Plan History** — `.prism/plans/.history.jsonl` records all status changes and milestones as timestamped events
|
|
97
|
+
- **8 new `/plan` subcommands** — `complete`, `archive`, `block`, `unblock`, `abandon`, `reopen`, `history`, `status`
|
|
98
|
+
- **Plan Discovery** — `prism init`/`update` scans `docs/` for existing plan files and offers to import them (originals preserved, frontmatter auto-derived from task progress)
|
|
99
|
+
|
|
100
|
+
**v1.6.0:**
|
|
94
101
|
- **Session Bootstrap** — agents auto-read `PROJECT-MEMORY.md`, `HANDOFF.md`, active plans, and registry on session start
|
|
95
|
-
- **Plan
|
|
102
|
+
- **Plan Frontmatter** — frontmatter (`status`, `created`, `depends_on`), `/plan check` for cross-plan file conflict detection
|
|
96
103
|
- **Docs Scaffolding** — `prism init --docs` creates `docs/` with templates + `.prism/registry.json`
|
|
97
104
|
- **Lightweight Recording** — even small tasks append a 1-line summary to `docs/PROJECT-MEMORY.md`
|
|
98
105
|
|
|
@@ -134,7 +141,7 @@ The original three hooks (commit-guard, test-tracker, plan-enforcement) are dete
|
|
|
134
141
|
|---------|---------|
|
|
135
142
|
| `/claude-prism:prism` | Run full EUDEC cycle |
|
|
136
143
|
| `/claude-prism:checkpoint` | Check batch progress with plan-reality sync |
|
|
137
|
-
| `/claude-prism:plan` |
|
|
144
|
+
| `/claude-prism:plan` | Plan lifecycle (list/create/complete/archive/block/unblock/abandon/reopen/history/status) |
|
|
138
145
|
| `/claude-prism:analytics` | Show usage analytics (blocks, warns, tests) |
|
|
139
146
|
| `/claude-prism:doctor` | Diagnose installation health |
|
|
140
147
|
| `/claude-prism:stats` | Version, hooks, plan count |
|
|
@@ -215,7 +222,7 @@ your-project/
|
|
|
215
222
|
│ ├── hooks/ # 6 runners (pre-tool, post-tool, precompact,
|
|
216
223
|
│ │ # session-end, subagent-start, task-completed)
|
|
217
224
|
│ ├── rules/ # 7 rule modules
|
|
218
|
-
│ ├── lib/ #
|
|
225
|
+
│ ├── lib/ # 9 shared dependencies
|
|
219
226
|
│ └── settings.json # Hook registration (6 events)
|
|
220
227
|
|
|
221
228
|
~/.claude/ # (global install / HUD)
|
|
@@ -313,6 +320,16 @@ Prism auto-detects [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claud
|
|
|
313
320
|
|
|
314
321
|
## Upgrading
|
|
315
322
|
|
|
323
|
+
### To v1.7.0
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
npx claude-prism update
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
v1.7.0 adds plan lifecycle management with 6 states and auto-transitions. During `update`, Prism will scan `docs/` and `docs/plans/` for existing plan files and offer to import them into `.prism/plans/` (originals are preserved). Plans without frontmatter get auto-assigned status based on task progress.
|
|
330
|
+
|
|
331
|
+
New lib file (`plan-lifecycle.mjs`) is installed automatically. No manual steps needed.
|
|
332
|
+
|
|
316
333
|
### To v1.4.0
|
|
317
334
|
|
|
318
335
|
```bash
|
package/bin/cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { init, check, uninstall, update, doctor, stats, reset, initGlobal, uninstallGlobal, installHud, uninstallHud, hudStatus } from '../lib/installer.mjs';
|
|
15
|
+
import { discoverPlans, importPlans, STATUS_ICONS } from '../lib/plan-lifecycle.mjs';
|
|
15
16
|
|
|
16
17
|
const args = process.argv.slice(2);
|
|
17
18
|
const command = args[0];
|
|
@@ -101,6 +102,9 @@ switch (command) {
|
|
|
101
102
|
console.log(`✅ HUD enabled → ${scriptPath}`);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// Plan discovery — find existing plan files in known paths
|
|
106
|
+
await promptPlanDiscovery(cwd);
|
|
107
|
+
|
|
104
108
|
console.log('\n🌈 Done. Use /prism before complex tasks.');
|
|
105
109
|
break;
|
|
106
110
|
}
|
|
@@ -243,6 +247,10 @@ switch (command) {
|
|
|
243
247
|
}
|
|
244
248
|
console.log('✅ Commands updated');
|
|
245
249
|
console.log('✅ Commit guard updated');
|
|
250
|
+
|
|
251
|
+
// Plan discovery — find existing plan files in known paths
|
|
252
|
+
await promptPlanDiscovery(cwd);
|
|
253
|
+
|
|
246
254
|
console.log('\n🌈 Prism updated to latest.');
|
|
247
255
|
break;
|
|
248
256
|
}
|
|
@@ -341,7 +349,7 @@ Options:
|
|
|
341
349
|
}
|
|
342
350
|
}
|
|
343
351
|
} catch (err) {
|
|
344
|
-
const msg = err
|
|
352
|
+
const msg = err?.message || String(err);
|
|
345
353
|
process.stderr.write(`🌈 Prism Error: ${msg}\n`);
|
|
346
354
|
|
|
347
355
|
if (/EACCES|permission/i.test(msg)) {
|
|
@@ -354,3 +362,43 @@ Options:
|
|
|
354
362
|
|
|
355
363
|
process.exit(1);
|
|
356
364
|
}
|
|
365
|
+
|
|
366
|
+
// ─── Plan Discovery Helper ───
|
|
367
|
+
|
|
368
|
+
async function promptPlanDiscovery(projectDir) {
|
|
369
|
+
const found = discoverPlans(projectDir);
|
|
370
|
+
if (found.length === 0) return;
|
|
371
|
+
|
|
372
|
+
console.log(`\n📋 Plan discovery: ${found.length} plan file(s) found outside .prism/plans/\n`);
|
|
373
|
+
for (const p of found) {
|
|
374
|
+
const icon = STATUS_ICONS[p.status] || '📄';
|
|
375
|
+
const pct = p.total > 0 ? Math.round((p.done / p.total) * 100) : 0;
|
|
376
|
+
const progress = p.total > 0 ? ` — ${p.done}/${p.total} (${pct}%)` : '';
|
|
377
|
+
const fm = p.hasFrontmatter ? '' : ' (no frontmatter)';
|
|
378
|
+
console.log(` ${icon} ${p.source}${p.file}${progress}${fm}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!process.stdin.isTTY) {
|
|
382
|
+
console.log('\n Run interactively to import, or use: prism import-plans');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const { createInterface } = await import('readline');
|
|
387
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
388
|
+
const answer = await new Promise(resolve =>
|
|
389
|
+
rl.question('\n Import these plans into .prism/plans/? Originals will be preserved. (y/N): ', a => {
|
|
390
|
+
rl.close();
|
|
391
|
+
resolve(a.trim().toLowerCase());
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (answer === 'y' || answer === 'yes') {
|
|
396
|
+
const result = importPlans(projectDir, found);
|
|
397
|
+
console.log(` ✅ ${result.imported} plan(s) imported, ${result.skipped} skipped`);
|
|
398
|
+
if (result.imported > 0) {
|
|
399
|
+
console.log(' Plans without frontmatter were assigned status based on task progress.');
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
console.log(' ⏭️ Plan import skipped');
|
|
403
|
+
}
|
|
404
|
+
}
|
package/hooks/task-plan-sync.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { dispatchWebhook } from '../lib/webhook.mjs';
|
|
9
9
|
import { getMessage } from '../lib/messages.mjs';
|
|
10
|
+
import { updatePlanStatus, appendHistory } from '../lib/plan-lifecycle.mjs';
|
|
11
|
+
import { parseFrontmatter } from '../lib/handoff.mjs';
|
|
10
12
|
|
|
11
13
|
export const planSync = {
|
|
12
14
|
name: 'task-plan-sync',
|
|
@@ -101,6 +103,56 @@ export const planSync = {
|
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
106
|
+
|
|
107
|
+
// ── Plan lifecycle auto-transitions ──
|
|
108
|
+
try {
|
|
109
|
+
const planContent = readFileSync(planPath, 'utf8');
|
|
110
|
+
const fm = parseFrontmatter(planContent);
|
|
111
|
+
const currentStatus = fm.status || 'active';
|
|
112
|
+
const planFile = planFiles[0];
|
|
113
|
+
|
|
114
|
+
// draft → active: first task checked
|
|
115
|
+
if (done === 1 && (currentStatus === 'draft' || !fm.status)) {
|
|
116
|
+
const result = updatePlanStatus(planPath, 'active');
|
|
117
|
+
if (result.success) {
|
|
118
|
+
appendHistory(projectRoot, {
|
|
119
|
+
plan: planFile, event: 'status_change',
|
|
120
|
+
from: result.oldStatus, to: 'active',
|
|
121
|
+
actor: 'hook:task-plan-sync',
|
|
122
|
+
detail: 'First task checked'
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// active → completed: all tasks done
|
|
128
|
+
if (done === total && total > 0 && currentStatus !== 'completed') {
|
|
129
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
130
|
+
const result = updatePlanStatus(planPath, 'completed', { completed_at: today });
|
|
131
|
+
if (result.success) {
|
|
132
|
+
appendHistory(projectRoot, {
|
|
133
|
+
plan: planFile, event: 'status_change',
|
|
134
|
+
from: currentStatus, to: 'completed',
|
|
135
|
+
actor: 'hook:task-plan-sync',
|
|
136
|
+
detail: `All ${total} tasks completed`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Progress milestones (25%, 50%, 75%)
|
|
142
|
+
if (total > 0) {
|
|
143
|
+
const prevPct = Math.round(((done - 1) / total) * 100);
|
|
144
|
+
for (const m of [25, 50, 75]) {
|
|
145
|
+
if (pct >= m && prevPct < m) {
|
|
146
|
+
appendHistory(projectRoot, {
|
|
147
|
+
plan: planFile, event: 'progress',
|
|
148
|
+
actor: 'hook:task-plan-sync',
|
|
149
|
+
detail: `Progress: ${done}/${total} (${pct}%)`
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch { /* lifecycle errors should not break the hook */ }
|
|
155
|
+
|
|
104
156
|
return {
|
|
105
157
|
hookSpecificOutput: {
|
|
106
158
|
hookEventName: 'TaskCompleted',
|
package/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { readFileSync, existsSync } from 'fs';
|
|
7
|
-
import { join } from 'path';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
8
|
|
|
9
9
|
const DEFAULTS = {
|
|
10
10
|
rulesMode: 'full',
|
|
@@ -23,6 +23,20 @@ const DEFAULTS = {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Search upward from startDir for nearest .prism/config.json
|
|
28
|
+
* @param {string} startDir - Directory to start searching from
|
|
29
|
+
* @returns {string} Project root with .prism/config.json, or startDir as fallback
|
|
30
|
+
*/
|
|
31
|
+
export function findProjectRoot(startDir) {
|
|
32
|
+
let current = startDir;
|
|
33
|
+
while (current !== dirname(current)) {
|
|
34
|
+
if (existsSync(join(current, '.prism', 'config.json'))) return current;
|
|
35
|
+
current = dirname(current);
|
|
36
|
+
}
|
|
37
|
+
return startDir;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export function loadConfig(projectRoot) {
|
|
27
41
|
const configPath = join(projectRoot, '.prism', 'config.json');
|
|
28
42
|
|
package/lib/installer.mjs
CHANGED
|
@@ -71,7 +71,7 @@ export async function init(projectDir, options = {}) {
|
|
|
71
71
|
const libDestDir = join(claudeDir, 'lib');
|
|
72
72
|
mkdirSync(libDestDir, { recursive: true });
|
|
73
73
|
const libSourceDir = join(__dirname);
|
|
74
|
-
for (const file of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs']) {
|
|
74
|
+
for (const file of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs', 'plan-lifecycle.mjs']) {
|
|
75
75
|
copyFileSync(join(libSourceDir, file), join(libDestDir, file));
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -670,7 +670,7 @@ export function dryRun(projectDir, options = {}) {
|
|
|
670
670
|
});
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
-
for (const lib of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs']) {
|
|
673
|
+
for (const lib of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs', 'plan-lifecycle.mjs']) {
|
|
674
674
|
const target = join(claudeDir, 'lib', lib);
|
|
675
675
|
actions.push({
|
|
676
676
|
type: 'lib',
|
package/lib/messages.mjs
CHANGED
|
@@ -12,6 +12,9 @@ const MESSAGES = {
|
|
|
12
12
|
'session-end-handler.info.saved': '🌈 Prism > Session summary saved to PROJECT-MEMORY.md.',
|
|
13
13
|
'subagent-scope-injector.info.scope': '🌈 Prism Scope >',
|
|
14
14
|
'task-plan-sync.info.updated': '🌈 Prism > Plan updated: {task}. Progress: {done}/{total} ({pct}%)',
|
|
15
|
+
'plan-lifecycle.completed': '🌈 Prism > Plan completed: {plan}. All {total} tasks done.',
|
|
16
|
+
'plan-lifecycle.status-changed': '🌈 Prism > Plan {plan}: {from} → {to}',
|
|
17
|
+
'plan-lifecycle.auto-activated': '🌈 Prism > Plan {plan} activated (first task checked)',
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export function getMessage(_lang, key, params = {}) {
|
package/lib/pipeline.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { readFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { sanitizeId } from './utils.mjs';
|
|
9
|
-
import { loadConfig } from './config.mjs';
|
|
9
|
+
import { loadConfig, findProjectRoot } from './config.mjs';
|
|
10
10
|
import { getStateDir } from './state.mjs';
|
|
11
11
|
import { logEvent } from './session.mjs';
|
|
12
12
|
|
|
@@ -68,8 +68,10 @@ export function runPipeline(rules, hookEventName) {
|
|
|
68
68
|
const input = parseInput();
|
|
69
69
|
if (!input) process.exit(0);
|
|
70
70
|
|
|
71
|
-
//
|
|
72
|
-
const
|
|
71
|
+
// Resolve project root from hook input's cwd (monorepo-safe)
|
|
72
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
73
|
+
const fullConfig = loadConfig(projectRoot);
|
|
74
|
+
fullConfig.projectRoot = projectRoot;
|
|
73
75
|
|
|
74
76
|
const ctx = toContext(input, hookEventName);
|
|
75
77
|
const stateDir = getStateDir(ctx.sessionId, ctx.agentId);
|
|
@@ -128,13 +130,13 @@ export function runPipeline(rules, hookEventName) {
|
|
|
128
130
|
* @param {string[]} customRulePaths - Paths relative to project root
|
|
129
131
|
* @returns {Promise<Array<{name: string, rule: Object}>>}
|
|
130
132
|
*/
|
|
131
|
-
export async function loadCustomRules(builtInRules, customRulePaths) {
|
|
133
|
+
export async function loadCustomRules(builtInRules, customRulePaths, projectRoot) {
|
|
132
134
|
if (!customRulePaths || customRulePaths.length === 0) return builtInRules;
|
|
133
135
|
|
|
134
136
|
const rules = [...builtInRules];
|
|
135
137
|
for (const rulePath of customRulePaths) {
|
|
136
138
|
try {
|
|
137
|
-
const absPath = join(process.cwd(), rulePath);
|
|
139
|
+
const absPath = join(projectRoot || process.cwd(), rulePath);
|
|
138
140
|
const mod = await import(absPath);
|
|
139
141
|
const rule = mod.default || mod[Object.keys(mod)[0]];
|
|
140
142
|
if (rule && typeof rule.evaluate === 'function') {
|
|
@@ -156,9 +158,11 @@ export async function runPipelineAsync(builtInRules, hookEventName) {
|
|
|
156
158
|
const input = parseInput();
|
|
157
159
|
if (!input) process.exit(0);
|
|
158
160
|
|
|
159
|
-
const
|
|
161
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
162
|
+
const fullConfig = loadConfig(projectRoot);
|
|
163
|
+
fullConfig.projectRoot = projectRoot;
|
|
160
164
|
const customRulePaths = fullConfig.customRules || [];
|
|
161
|
-
const rules = await loadCustomRules(builtInRules, customRulePaths);
|
|
165
|
+
const rules = await loadCustomRules(builtInRules, customRulePaths, projectRoot);
|
|
162
166
|
|
|
163
167
|
const ctx = toContext(input, hookEventName);
|
|
164
168
|
const stateDir = getStateDir(ctx.sessionId, ctx.agentId);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-prism — Plan Lifecycle Management
|
|
3
|
+
* State machine, status transitions, and history logging for plan files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { parseFrontmatter, getAllPlans, parsePlanContent } from './handoff.mjs';
|
|
9
|
+
|
|
10
|
+
// Valid state transitions
|
|
11
|
+
export const TRANSITIONS = {
|
|
12
|
+
draft: ['active', 'blocked', 'abandoned'],
|
|
13
|
+
active: ['completed', 'blocked', 'abandoned'],
|
|
14
|
+
blocked: ['active', 'abandoned'],
|
|
15
|
+
completed: ['archived', 'active'], // active = reopen
|
|
16
|
+
// archived, abandoned = terminal (no transitions)
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const STATUS_ICONS = {
|
|
20
|
+
draft: '📝', active: '📋', blocked: '🚫',
|
|
21
|
+
completed: '✅', archived: '📦', abandoned: '🗑️'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a status transition is valid
|
|
26
|
+
* @param {string} from - Current status (null/undefined treated as 'active' for backward compat)
|
|
27
|
+
* @param {string} to - Target status
|
|
28
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
29
|
+
*/
|
|
30
|
+
export function validateTransition(from, to) {
|
|
31
|
+
const fromStatus = from || 'active';
|
|
32
|
+
const allowed = TRANSITIONS[fromStatus];
|
|
33
|
+
if (!allowed) return { valid: false, reason: `Terminal status: ${fromStatus}` };
|
|
34
|
+
if (!allowed.includes(to)) return { valid: false, reason: `${fromStatus} → ${to} not allowed` };
|
|
35
|
+
return { valid: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Update a plan file's frontmatter status
|
|
40
|
+
* @param {string} planPath - Absolute path to plan file
|
|
41
|
+
* @param {string} newStatus - Target status
|
|
42
|
+
* @param {Object} extra - Additional frontmatter fields to set
|
|
43
|
+
* @returns {{ success: boolean, oldStatus: string, newStatus: string, error?: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function updatePlanStatus(planPath, newStatus, extra = {}) {
|
|
46
|
+
const content = readFileSync(planPath, 'utf8');
|
|
47
|
+
const fm = parseFrontmatter(content);
|
|
48
|
+
const oldStatus = fm.status || 'active';
|
|
49
|
+
|
|
50
|
+
const validation = validateTransition(oldStatus, newStatus);
|
|
51
|
+
if (!validation.valid) return { success: false, oldStatus, newStatus, error: validation.reason };
|
|
52
|
+
|
|
53
|
+
// Build updated frontmatter fields
|
|
54
|
+
const fields = { ...fm, status: newStatus, ...extra };
|
|
55
|
+
|
|
56
|
+
// Remove fields explicitly set to null (e.g., removing blocked_reason on unblock)
|
|
57
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
58
|
+
if (v === null) delete fields[k];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fmStr = Object.entries(fields)
|
|
62
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? JSON.stringify(v) : v}`)
|
|
63
|
+
.join('\n');
|
|
64
|
+
|
|
65
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
66
|
+
let newContent;
|
|
67
|
+
if (fmMatch) {
|
|
68
|
+
newContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${fmStr}\n---`);
|
|
69
|
+
} else {
|
|
70
|
+
newContent = `---\n${fmStr}\n---\n\n${content}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
writeFileSync(planPath, newContent);
|
|
74
|
+
return { success: true, oldStatus, newStatus };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Append an event to the plan history log
|
|
79
|
+
* @param {string} projectRoot - Project root directory
|
|
80
|
+
* @param {Object} event - Event data (plan, event type, from, to, actor, detail)
|
|
81
|
+
*/
|
|
82
|
+
export function appendHistory(projectRoot, event) {
|
|
83
|
+
const dir = join(projectRoot, '.prism', 'plans');
|
|
84
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
85
|
+
const historyPath = join(dir, '.history.jsonl');
|
|
86
|
+
const entry = { ts: new Date().toISOString(), ...event };
|
|
87
|
+
appendFileSync(historyPath, JSON.stringify(entry) + '\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read plan history events
|
|
92
|
+
* @param {string} projectRoot - Project root directory
|
|
93
|
+
* @param {string} [planFile] - Optional filter by plan filename
|
|
94
|
+
* @returns {Array<Object>} History events
|
|
95
|
+
*/
|
|
96
|
+
export function readHistory(projectRoot, planFile) {
|
|
97
|
+
const historyPath = join(projectRoot, '.prism', 'plans', '.history.jsonl');
|
|
98
|
+
if (!existsSync(historyPath)) return [];
|
|
99
|
+
const lines = readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
100
|
+
const events = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
101
|
+
if (planFile) return events.filter(e => e.plan === planFile);
|
|
102
|
+
return events;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a plan by name or find the most recent active plan
|
|
107
|
+
* @param {string} projectRoot - Project root directory
|
|
108
|
+
* @param {string} [planName] - Optional plan filename or partial match
|
|
109
|
+
* @returns {Object|null} Plan object or null
|
|
110
|
+
*/
|
|
111
|
+
export function resolvePlan(projectRoot, planName) {
|
|
112
|
+
const plans = getAllPlans(projectRoot);
|
|
113
|
+
if (!plans.length) return null;
|
|
114
|
+
if (planName) {
|
|
115
|
+
const match = plans.find(p => p.file === planName || p.file.includes(planName));
|
|
116
|
+
if (match) return match;
|
|
117
|
+
}
|
|
118
|
+
// Default: most recent active plan
|
|
119
|
+
return plans.find(p => (p.status || 'active') === 'active') || plans[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Plan Discovery & Import ───
|
|
123
|
+
|
|
124
|
+
const PLAN_PATTERN = /^\d{4}-\d{2}-\d{2}-.+\.md$/;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Scan known paths for plan-like files not yet in .prism/plans/
|
|
128
|
+
* @param {string} projectRoot - Project root directory
|
|
129
|
+
* @returns {Array<{ path: string, file: string, source: string, hasFrontmatter: boolean, status: string, total: number, done: number }>}
|
|
130
|
+
*/
|
|
131
|
+
export function discoverPlans(projectRoot) {
|
|
132
|
+
const plansDir = join(projectRoot, '.prism', 'plans');
|
|
133
|
+
const existing = new Set();
|
|
134
|
+
if (existsSync(plansDir)) {
|
|
135
|
+
for (const f of readdirSync(plansDir).filter(f => f.endsWith('.md'))) {
|
|
136
|
+
existing.add(f);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const discovered = [];
|
|
141
|
+
|
|
142
|
+
// Known paths to scan
|
|
143
|
+
const scanPaths = [
|
|
144
|
+
{ dir: join(projectRoot, 'docs'), source: 'docs/' },
|
|
145
|
+
{ dir: join(projectRoot, 'docs', 'plans'), source: 'docs/plans/' },
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
for (const { dir, source } of scanPaths) {
|
|
149
|
+
if (!existsSync(dir)) continue;
|
|
150
|
+
let files;
|
|
151
|
+
try { files = readdirSync(dir).filter(f => f.endsWith('.md')); } catch { continue; }
|
|
152
|
+
|
|
153
|
+
for (const f of files) {
|
|
154
|
+
// Skip if already in .prism/plans/
|
|
155
|
+
if (existing.has(f)) continue;
|
|
156
|
+
|
|
157
|
+
const fullPath = join(dir, f);
|
|
158
|
+
let content;
|
|
159
|
+
try { content = readFileSync(fullPath, 'utf8'); } catch { continue; }
|
|
160
|
+
|
|
161
|
+
// Detect plan-like files: filename pattern OR content with checkboxes + batch headers
|
|
162
|
+
const isNameMatch = PLAN_PATTERN.test(f);
|
|
163
|
+
const hasCheckboxes = /^[-*]\s+\[[ x]\]/m.test(content);
|
|
164
|
+
const hasBatchHeader = /^#{1,3}\s+Batch\s+\d+/im.test(content);
|
|
165
|
+
const isPlanLike = isNameMatch || (hasCheckboxes && hasBatchHeader);
|
|
166
|
+
|
|
167
|
+
if (!isPlanLike) continue;
|
|
168
|
+
|
|
169
|
+
// Skip generic docs (HANDOFF.md, PROJECT-MEMORY.md, etc.)
|
|
170
|
+
const skipFiles = ['HANDOFF.md', 'PROJECT-MEMORY.md', 'README.md', 'CHANGELOG.md'];
|
|
171
|
+
if (skipFiles.includes(f)) continue;
|
|
172
|
+
|
|
173
|
+
const fm = parseFrontmatter(content);
|
|
174
|
+
const progress = parsePlanContent(content, f);
|
|
175
|
+
|
|
176
|
+
discovered.push({
|
|
177
|
+
path: fullPath,
|
|
178
|
+
file: f,
|
|
179
|
+
source,
|
|
180
|
+
hasFrontmatter: !!fm.status,
|
|
181
|
+
status: fm.status || 'unknown',
|
|
182
|
+
total: progress.total,
|
|
183
|
+
done: progress.done,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return discovered;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Import discovered plans into .prism/plans/ (copy, original preserved)
|
|
193
|
+
* Adds frontmatter if missing, derives status from checkbox state
|
|
194
|
+
* @param {string} projectRoot - Project root directory
|
|
195
|
+
* @param {Array<{ path: string, file: string }>} plans - Plans to import
|
|
196
|
+
* @returns {{ imported: number, skipped: number }}
|
|
197
|
+
*/
|
|
198
|
+
export function importPlans(projectRoot, plans) {
|
|
199
|
+
const plansDir = join(projectRoot, '.prism', 'plans');
|
|
200
|
+
mkdirSync(plansDir, { recursive: true });
|
|
201
|
+
|
|
202
|
+
let imported = 0;
|
|
203
|
+
let skipped = 0;
|
|
204
|
+
|
|
205
|
+
for (const plan of plans) {
|
|
206
|
+
const destPath = join(plansDir, plan.file);
|
|
207
|
+
|
|
208
|
+
// Skip if already exists
|
|
209
|
+
if (existsSync(destPath)) {
|
|
210
|
+
skipped++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let content = readFileSync(plan.path, 'utf8');
|
|
215
|
+
const fm = parseFrontmatter(content);
|
|
216
|
+
|
|
217
|
+
// Add frontmatter if missing
|
|
218
|
+
if (!fm.status) {
|
|
219
|
+
const progress = parsePlanContent(content, plan.file);
|
|
220
|
+
let status = 'active';
|
|
221
|
+
if (progress.total > 0 && progress.done === progress.total) {
|
|
222
|
+
status = 'completed';
|
|
223
|
+
} else if (progress.done === 0) {
|
|
224
|
+
status = 'draft';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
228
|
+
const fmBlock = `---\nstatus: ${status}\ncreated: ${today}\nimported_from: ${plan.source || 'unknown'}${plan.file}\n---\n\n`;
|
|
229
|
+
content = fmBlock + content;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeFileSync(destPath, content);
|
|
233
|
+
|
|
234
|
+
appendHistory(projectRoot, {
|
|
235
|
+
plan: plan.file,
|
|
236
|
+
event: 'imported',
|
|
237
|
+
actor: 'cli:plan-discovery',
|
|
238
|
+
detail: `Imported from ${plan.source || ''}${plan.file}`,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
imported++;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { imported, skipped };
|
|
245
|
+
}
|
package/package.json
CHANGED
|
@@ -79,7 +79,7 @@ If user requests a conflict check:
|
|
|
79
79
|
- Recommendation: check dependency order or merge plans
|
|
80
80
|
5. If no overlaps found: "✅ No file conflicts across active plans."
|
|
81
81
|
|
|
82
|
-
## View
|
|
82
|
+
## View [plan]
|
|
83
83
|
|
|
84
84
|
If user specifies a plan file:
|
|
85
85
|
|
|
@@ -87,3 +87,85 @@ If user specifies a plan file:
|
|
|
87
87
|
2. **Show progress** with completion percentage
|
|
88
88
|
3. **Highlight** current batch (first batch with incomplete tasks)
|
|
89
89
|
4. **List blockers** from "Risks / Open Questions" section
|
|
90
|
+
|
|
91
|
+
## Complete [plan]
|
|
92
|
+
|
|
93
|
+
1. Resolve target plan (argument or most recent active)
|
|
94
|
+
2. Read frontmatter, validate transition: active → completed
|
|
95
|
+
3. Update frontmatter: `status: completed`, `completed_at: YYYY-MM-DD`
|
|
96
|
+
4. Append to `.prism/plans/.history.jsonl`
|
|
97
|
+
5. Report: "✅ Plan completed: <plan> (N/N tasks done)"
|
|
98
|
+
|
|
99
|
+
## Archive [plan]
|
|
100
|
+
|
|
101
|
+
1. Resolve target plan
|
|
102
|
+
2. Validate: must be `completed` status
|
|
103
|
+
3. Update frontmatter: `status: archived`, `archived_at: YYYY-MM-DD`
|
|
104
|
+
4. Append history event
|
|
105
|
+
5. Report: "📦 Plan archived: <plan>"
|
|
106
|
+
|
|
107
|
+
## Block [plan] [reason]
|
|
108
|
+
|
|
109
|
+
1. Resolve target plan
|
|
110
|
+
2. Validate: `active` or `draft` status
|
|
111
|
+
3. Update frontmatter: `status: blocked`, `blocked_reason: <reason>`
|
|
112
|
+
4. Append history event
|
|
113
|
+
5. Report: "🚫 Plan blocked: <plan> — <reason>"
|
|
114
|
+
|
|
115
|
+
## Unblock [plan]
|
|
116
|
+
|
|
117
|
+
1. Resolve target plan
|
|
118
|
+
2. Validate: must be `blocked` status
|
|
119
|
+
3. Update frontmatter: `status: active`, remove `blocked_reason`
|
|
120
|
+
4. Append history event
|
|
121
|
+
5. Report: "📋 Plan unblocked: <plan>"
|
|
122
|
+
|
|
123
|
+
## Abandon [plan]
|
|
124
|
+
|
|
125
|
+
1. Resolve target plan
|
|
126
|
+
2. Validate: not already terminal (archived/abandoned)
|
|
127
|
+
3. Update frontmatter: `status: abandoned`, `abandoned_at: YYYY-MM-DD`
|
|
128
|
+
4. Append history event
|
|
129
|
+
5. Report: "🗑️ Plan abandoned: <plan>"
|
|
130
|
+
|
|
131
|
+
## Reopen [plan]
|
|
132
|
+
|
|
133
|
+
1. Resolve target plan
|
|
134
|
+
2. Validate: must be `completed` status
|
|
135
|
+
3. Update frontmatter: `status: active`, remove `completed_at`
|
|
136
|
+
4. Append history event
|
|
137
|
+
5. Report: "📋 Plan reopened: <plan>"
|
|
138
|
+
|
|
139
|
+
## History [plan]
|
|
140
|
+
|
|
141
|
+
1. Read `.prism/plans/.history.jsonl`
|
|
142
|
+
2. If plan specified, filter by plan filename
|
|
143
|
+
3. Format as timeline:
|
|
144
|
+
```
|
|
145
|
+
📜 Plan History: <plan>
|
|
146
|
+
[2026-03-06 12:00] 📝 Created
|
|
147
|
+
[2026-03-06 12:05] 📋 draft → active (First task checked)
|
|
148
|
+
[2026-03-06 14:00] 📊 Progress: 5/8 (62%)
|
|
149
|
+
[2026-03-06 15:00] ✅ active → completed (All 8 tasks done)
|
|
150
|
+
```
|
|
151
|
+
4. If no plan specified, show last 20 events across all plans
|
|
152
|
+
|
|
153
|
+
## Status
|
|
154
|
+
|
|
155
|
+
1. Read all plans via getAllPlans()
|
|
156
|
+
2. Group by status
|
|
157
|
+
3. Display dashboard:
|
|
158
|
+
```
|
|
159
|
+
📊 Plan Status Dashboard
|
|
160
|
+
|
|
161
|
+
📋 Active (2)
|
|
162
|
+
• 2026-03-06-feature-x.md — 60% (6/10)
|
|
163
|
+
• 2026-03-05-bugfix-y.md — 30% (3/10)
|
|
164
|
+
|
|
165
|
+
🚫 Blocked (1)
|
|
166
|
+
• 2026-03-04-migration.md — reason: waiting for API v2
|
|
167
|
+
|
|
168
|
+
✅ Completed (3)
|
|
169
|
+
📦 Archived (5)
|
|
170
|
+
🗑️ Abandoned (1)
|
|
171
|
+
```
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'fs';
|
|
7
7
|
import { precompactHandler } from '../rules/precompact-handler.mjs';
|
|
8
|
-
import { loadConfig } from '../lib/config.mjs';
|
|
8
|
+
import { loadConfig, findProjectRoot } from '../lib/config.mjs';
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
12
|
-
const
|
|
12
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
13
|
+
const config = loadConfig(projectRoot);
|
|
14
|
+
config.projectRoot = projectRoot;
|
|
13
15
|
const result = precompactHandler.evaluate(input, config);
|
|
14
16
|
if (result) {
|
|
15
17
|
process.stdout.write(JSON.stringify(result));
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'fs';
|
|
7
7
|
import { sessionEndHandler } from '../rules/session-end-handler.mjs';
|
|
8
|
-
import { loadConfig } from '../lib/config.mjs';
|
|
8
|
+
import { loadConfig, findProjectRoot } from '../lib/config.mjs';
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
12
|
-
const
|
|
12
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
13
|
+
const config = loadConfig(projectRoot);
|
|
14
|
+
config.projectRoot = projectRoot;
|
|
13
15
|
const result = sessionEndHandler.evaluate(input, config);
|
|
14
16
|
if (result) {
|
|
15
17
|
process.stdout.write(JSON.stringify(result));
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'fs';
|
|
7
7
|
import { scopeInjector } from '../rules/subagent-scope-injector.mjs';
|
|
8
|
-
import { loadConfig } from '../lib/config.mjs';
|
|
8
|
+
import { loadConfig, findProjectRoot } from '../lib/config.mjs';
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
12
|
-
const
|
|
12
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
13
|
+
const config = loadConfig(projectRoot);
|
|
14
|
+
config.projectRoot = projectRoot;
|
|
13
15
|
const result = scopeInjector.evaluate(input, config);
|
|
14
16
|
if (result) {
|
|
15
17
|
process.stdout.write(JSON.stringify(result));
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'fs';
|
|
7
7
|
import { planSync } from '../rules/task-plan-sync.mjs';
|
|
8
|
-
import { loadConfig } from '../lib/config.mjs';
|
|
8
|
+
import { loadConfig, findProjectRoot } from '../lib/config.mjs';
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
12
|
-
const
|
|
12
|
+
const projectRoot = findProjectRoot(input.cwd || process.cwd());
|
|
13
|
+
const config = loadConfig(projectRoot);
|
|
14
|
+
config.projectRoot = projectRoot;
|
|
13
15
|
const result = planSync.evaluate(input, config);
|
|
14
16
|
if (result) {
|
|
15
17
|
process.stdout.write(JSON.stringify(result));
|