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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-prism",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "AI agent harness implementing the EUDEC methodology",
5
5
  "author": { "name": "lazysaturday91" },
6
6
  "repository": "https://github.com/lazysaturday91/claude-prism",
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.6.1] — 2026-03-05
8
+ ## [1.7.1] — 2026-03-06
9
9
 
10
10
  ### Fixed
11
- - **Re-publish of v1.6.0** — npm ghost publish recovery (v1.6.0 was internally registered but never propagated to CDN)
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.6.0:**
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 Lifecycle** — frontmatter (`status`, `created`, `depends_on`), `/plan check` for cross-plan file conflict detection
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` | List/create/view plan files |
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/ # 8 shared dependencies
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.message || String(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
+ }
@@ -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
- // Read config ONCE
72
- const fullConfig = loadConfig(process.cwd());
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 fullConfig = loadConfig(process.cwd());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-prism",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "AI agent harness implementing the EUDEC methodology — Essence, Understand, Decompose, Execute, Checkpoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 Specific Plan
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 config = loadConfig(process.cwd());
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 config = loadConfig(process.cwd());
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 config = loadConfig(process.cwd());
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 config = loadConfig(process.cwd());
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));