@planu/cli 3.9.14 → 4.1.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/cli/commands/spec.js +20 -1
  3. package/dist/cli/commands/status.js +18 -1
  4. package/dist/config/license-plans.json +1 -0
  5. package/dist/engine/ai-integration/agents-md/generator.js +4 -1
  6. package/dist/engine/ai-integration/cline/clinerules-generator.js +7 -2
  7. package/dist/engine/ai-integration/codex/agents-md-generator.js +2 -0
  8. package/dist/engine/ai-integration/codex/hooks-generator.js +1 -0
  9. package/dist/engine/ai-integration/cursor/cursorrules-generator.js +7 -2
  10. package/dist/engine/ai-integration/gemini/settings-generator.js +4 -1
  11. package/dist/engine/ai-integration/kiro/hooks-generator.js +2 -1
  12. package/dist/engine/ai-integration/windsurf/windsurfrules-generator.js +7 -2
  13. package/dist/engine/autopilot/action-registry.js +5 -14
  14. package/dist/engine/autopilot/state-updater.js +13 -10
  15. package/dist/engine/cascade-hooks/hooks/git-auto-stage.hook.js +3 -0
  16. package/dist/engine/cascade-hooks/hooks/html-regen.hook.js +1 -1
  17. package/dist/engine/cascade-hooks/hooks/status-json.hook.js +1 -1
  18. package/dist/engine/cascade-hooks/state-drift-detector.d.ts +1 -1
  19. package/dist/engine/cascade-hooks/state-drift-detector.js +15 -12
  20. package/dist/engine/git/planu-autocommit.d.ts +1 -0
  21. package/dist/engine/git/planu-autocommit.js +6 -0
  22. package/dist/engine/git-hook-injector.js +3 -3
  23. package/dist/engine/handoff-artifacts/io.js +3 -2
  24. package/dist/engine/handoff-packager.js +2 -1
  25. package/dist/engine/hooks/full-spectrum-generator.d.ts +2 -1
  26. package/dist/engine/hooks/full-spectrum-generator.js +5 -3
  27. package/dist/engine/marketplace-fetcher/anthropic-source.js +2 -0
  28. package/dist/engine/opencode/config-scaffold.js +4 -0
  29. package/dist/engine/release/postmortem-generator.d.ts +1 -1
  30. package/dist/engine/release/postmortem-generator.js +3 -2
  31. package/dist/engine/rules-generator/index.js +2 -0
  32. package/dist/engine/rules-reconciler.js +2 -0
  33. package/dist/engine/safety/cross-process-lock.js +2 -2
  34. package/dist/engine/session/checkpoint-writer.js +0 -1
  35. package/dist/engine/session-context-generator.js +4 -1
  36. package/dist/engine/skill-bootstrap/skill-writer.js +2 -0
  37. package/dist/engine/skill-generation/multi-agent-writer.js +2 -0
  38. package/dist/engine/spec-audit/index.js +2 -2
  39. package/dist/engine/spec-audit/report-writer.d.ts +1 -1
  40. package/dist/engine/spec-audit/report-writer.js +5 -4
  41. package/dist/engine/spec-language/english-only.d.ts +8 -7
  42. package/dist/engine/spec-language/english-only.js +27 -3
  43. package/dist/engine/spec-migrator/index.d.ts +1 -0
  44. package/dist/engine/spec-migrator/index.js +1 -0
  45. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +9 -0
  46. package/dist/engine/spec-migrator/planu-canonical-policy.js +62 -0
  47. package/dist/engine/spec-migrator/planu-root-cleaner.js +18 -94
  48. package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +6 -0
  49. package/dist/engine/spec-migrator/strict-planu-cleanup.js +199 -0
  50. package/dist/engine/spec-summary-html.d.ts +5 -5
  51. package/dist/engine/spec-summary-html.js +7 -32
  52. package/dist/engine/universal-rules/host-writer.js +8 -2
  53. package/dist/engine/universal-rules/rules/planu-english-specs.js +9 -5
  54. package/dist/hosts/claude-code/ux/skills-writer.js +2 -0
  55. package/dist/hosts/codex/config-scaffold.js +5 -0
  56. package/dist/hosts/gemini/config-scaffold.js +4 -0
  57. package/dist/storage/gaps-log.js +4 -4
  58. package/dist/storage/transition-log.js +3 -2
  59. package/dist/tools/audit-specs-drift.js +3 -3
  60. package/dist/tools/create-skill.js +21 -0
  61. package/dist/tools/create-spec/post-creation.d.ts +2 -1
  62. package/dist/tools/create-spec/post-creation.js +9 -11
  63. package/dist/tools/create-spec/spec-builder.js +1 -1
  64. package/dist/tools/create-spec.js +42 -18
  65. package/dist/tools/flag-spec-gap.d.ts +1 -1
  66. package/dist/tools/flag-spec-gap.js +1 -1
  67. package/dist/tools/generate-dashboard.js +3 -3
  68. package/dist/tools/housekeeping-sweep.js +16 -0
  69. package/dist/tools/init-project/agents-md-writer.js +2 -0
  70. package/dist/tools/init-project/conventions-writer.js +2 -0
  71. package/dist/tools/init-project/find-skills-writer.js +2 -0
  72. package/dist/tools/init-project/git-setup.js +11 -2
  73. package/dist/tools/init-project/handler.js +1 -27
  74. package/dist/tools/init-project/helpers.js +5 -0
  75. package/dist/tools/init-project/migration-runner.js +8 -0
  76. package/dist/tools/init-project/per-client-files-writer.js +2 -0
  77. package/dist/tools/init-project/planu-workflow-generator.js +2 -0
  78. package/dist/tools/init-project/rules-generator.js +7 -1
  79. package/dist/tools/init-project/rules-writer.js +3 -0
  80. package/dist/tools/init-project/skills-multi-teammate-review-writer.js +2 -0
  81. package/dist/tools/init-project/skills-writer.js +2 -0
  82. package/dist/tools/license-gate.d.ts +1 -0
  83. package/dist/tools/license-gate.js +5 -1
  84. package/dist/tools/list-specs.js +13 -0
  85. package/dist/tools/register-sdd-tools.d.ts +1 -1
  86. package/dist/tools/register-sdd-tools.js +1 -0
  87. package/dist/tools/register-spec-tools/core-spec-tools.js +16 -0
  88. package/dist/tools/spec-lock-handler.js +1 -1
  89. package/dist/tools/tool-registry/group-misc.js +4 -4
  90. package/dist/tools/update-status/batch.d.ts +3 -0
  91. package/dist/tools/update-status/batch.js +96 -0
  92. package/dist/tools/update-status/dod-gates.js +1 -1
  93. package/dist/tools/update-status/file-sync.js +3 -1
  94. package/dist/tools/update-status/index.js +15 -2
  95. package/dist/tools/update-status-actions.js +2 -6
  96. package/dist/tools/validate.js +27 -0
  97. package/dist/tools/workspace-dashboard-handler.js +6 -9
  98. package/dist/types/git.d.ts +1 -1
  99. package/dist/types/spec-format.d.ts +26 -0
  100. package/dist/types/spec-language.d.ts +8 -0
  101. package/dist/types/spec-language.js +2 -0
  102. package/package.json +20 -20
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [4.1.0] - 2026-05-21
2
+
3
+ ### Features
4
+ - Enforce English-only persisted Planu artifacts across specs, skills, agent instructions, and rules.
5
+ - Gate host-aware init scaffolding for `AGENTS.md`, `CLAUDE.md`, Cursor, Windsurf, Cline, Gemini, Codex, and OpenCode generated AI docs.
6
+ - Keep user-authored host file content intact while validating only Planu-owned generated blocks.
7
+
8
+ ### Tests
9
+ - Add coverage for skill, rule, and agent instruction language gates across core writers and host generators.
10
+
11
+
12
+ ## [4.0.0] - 2026-05-20
13
+
14
+ **Tarball SHA-256:** `8c00d74f48ed5614197000a967b103cc17653150aadf876fcfd18d0174263017`
15
+
16
+
1
17
  ## [3.9.12] - 2026-05-19
2
18
 
3
19
  **Tarball SHA-256:** `cd07a22fdfc0c982726a918c1e47f147ca300ecad710e8377f1751ef993fea60`
@@ -3787,4 +3803,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
3787
3803
  - Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
3788
3804
  - Multi-language i18n (EN/ES/PT) for generated specs
3789
3805
  - Clean Architecture (hexagonal) — engine, tools, storage, types layers
3790
- - 10,857 tests with ≥95% coverage
3806
+ - 10,857 tests with ≥95% coverage
@@ -10,6 +10,7 @@ import { resolve } from 'node:path';
10
10
  import { handleCreateSpec } from '../../tools/create-spec.js';
11
11
  import { handleListSpecs } from '../../tools/list-specs.js';
12
12
  import { handleUpdateStatus } from '../../tools/update-status.js';
13
+ import { handleUpdateStatusBatch } from '../../tools/update-status/batch.js';
13
14
  import { formatToolResult } from '../formatter.js';
14
15
  import { detectProjectId } from '../project-detector.js';
15
16
  import { bold, cyan, green, red, dim } from '../colors.js';
@@ -178,12 +179,15 @@ async function runStatus(args, flags) {
178
179
  options: {
179
180
  'project-id': { type: 'string' },
180
181
  notes: { type: 'string', short: 'n' },
182
+ batch: { type: 'boolean' },
183
+ set: { type: 'string', short: 's' },
181
184
  },
182
185
  strict: false,
183
186
  allowPositionals: true,
184
187
  });
188
+ const batch = values.batch === true;
185
189
  const specId = positionals[0];
186
- const status = positionals[1];
190
+ const status = batch ? values.set : positionals[1];
187
191
  if (!specId) {
188
192
  process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: planu spec status SPEC-NNN <status>\n`);
189
193
  process.exitCode = 1;
@@ -195,6 +199,21 @@ async function runStatus(args, flags) {
195
199
  return;
196
200
  }
197
201
  const projectId = values['project-id'] ?? detectProjectId();
202
+ if (batch) {
203
+ const result = await handleUpdateStatusBatch({
204
+ specIds: positionals,
205
+ projectId,
206
+ status: status,
207
+ reviewNotes: values.notes ?? undefined,
208
+ });
209
+ if (result.isError) {
210
+ process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+ process.stdout.write(formatToolResult(result, flags) + '\n');
215
+ return;
216
+ }
198
217
  const result = await handleUpdateStatus({
199
218
  specId,
200
219
  projectId,
@@ -1,13 +1,14 @@
1
1
  // cli/commands/status.ts — planu status <specId> [--set implementing] (SPEC-124)
2
2
  import { parseArgs } from 'node:util';
3
3
  import { handleUpdateStatus } from '../../tools/update-status.js';
4
+ import { handleUpdateStatusBatch } from '../../tools/update-status/batch.js';
4
5
  import { formatToolResult } from '../formatter.js';
5
6
  import { detectProjectId } from '../project-detector.js';
6
7
  import { red, green } from '../colors.js';
7
8
  export const statusCommand = {
8
9
  name: 'status',
9
10
  description: 'Update the status of a spec',
10
- usage: 'planu status <specId> --set <status> [--project-id ID]',
11
+ usage: 'planu status <specId> --set <status> [--project-id ID] | planu status batch --set <status> SPEC-001 SPEC-002',
11
12
  async run(args, flags) {
12
13
  const { values, positionals } = parseArgs({
13
14
  args,
@@ -19,6 +20,7 @@ export const statusCommand = {
19
20
  strict: false,
20
21
  allowPositionals: true,
21
22
  });
23
+ const isBatch = positionals[0] === 'batch';
22
24
  const specId = positionals[0];
23
25
  if (!specId) {
24
26
  process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: ${statusCommand.usage}\n`);
@@ -32,6 +34,21 @@ export const statusCommand = {
32
34
  return;
33
35
  }
34
36
  const projectId = values['project-id'] ?? detectProjectId();
37
+ if (isBatch) {
38
+ const result = await handleUpdateStatusBatch({
39
+ specIds: positionals.slice(1),
40
+ projectId,
41
+ status: status,
42
+ reviewNotes: values.notes ?? undefined,
43
+ });
44
+ if (result.isError) {
45
+ process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ process.stdout.write(formatToolResult(result, flags) + '\n');
50
+ return;
51
+ }
35
52
  const result = await handleUpdateStatus({
36
53
  specId,
37
54
  projectId,
@@ -94,6 +94,7 @@
94
94
  "unlock_spec",
95
95
  "unregister_project_path",
96
96
  "update_status",
97
+ "update_status_batch",
97
98
  "validate",
98
99
  "worker_status",
99
100
  "security_scan",
@@ -1,6 +1,7 @@
1
1
  // engine/ai-integration/agents-md/generator.ts — Universal AGENTS.md generator (SPEC-269)
2
2
  import { writeFileSync, readFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { assertEnglishOnlyArtifactText } from '../../spec-language/english-only.js';
4
5
  const PLANU_SECTION_START = '<!-- planu-sdd:start -->';
5
6
  const PLANU_SECTION_END = '<!-- planu-sdd:end -->';
6
7
  function buildActiveSpecBlock(activeSpecId, activeSpecTitle) {
@@ -105,7 +106,9 @@ function buildSddSection(options) {
105
106
  parts.push('', approvedQueueBlock);
106
107
  }
107
108
  parts.push('', toolsBlock, '', branchBlock, '', architectureBlock, '', PLANU_SECTION_END);
108
- return parts.join('\n');
109
+ const content = parts.join('\n');
110
+ assertEnglishOnlyArtifactText(content, 'agent');
111
+ return content;
109
112
  }
110
113
  /** Generates the full universal AGENTS.md content for a project using Planu SDD. */
111
114
  export function generateUniversalAgentsMd(options) {
@@ -1,6 +1,7 @@
1
1
  // engine/ai-integration/cline/clinerules-generator.ts — .clinerules generator (SPEC-271)
2
2
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { assertEnglishOnlyArtifactText } from '../../spec-language/english-only.js';
4
5
  const SECTION_START = '<!-- planu-sdd:start -->';
5
6
  const SECTION_END = '<!-- planu-sdd:end -->';
6
7
  function buildPlanuSection(options) {
@@ -52,12 +53,16 @@ function buildPlanuSection(options) {
52
53
  lines.push('Place in `.clinerules` at project root or in `.clinerules/` directory.');
53
54
  lines.push('');
54
55
  lines.push(SECTION_END);
55
- return lines.join('\n');
56
+ const content = lines.join('\n');
57
+ assertEnglishOnlyArtifactText(content, 'agent');
58
+ return content;
56
59
  }
57
60
  export function generateClineRules(options) {
58
61
  const stackStr = options.stack.length > 0 ? options.stack.join(', ') : 'Not specified';
59
62
  const header = [`# Cline Rules — ${options.projectName}`, '', `Stack: ${stackStr}`, ''].join('\n');
60
- return header + buildPlanuSection(options);
63
+ const content = header + buildPlanuSection(options);
64
+ assertEnglishOnlyArtifactText(content, 'agent');
65
+ return content;
61
66
  }
62
67
  export function writeClineRules(projectPath, options) {
63
68
  const filePath = join(projectPath, '.clinerules');
@@ -1,5 +1,6 @@
1
1
  import { writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { assertEnglishOnlyArtifactText } from '../../spec-language/english-only.js';
3
4
  const PLANU_SECTION_START = '<!-- planu:start -->';
4
5
  const PLANU_SECTION_END = '<!-- planu:end -->';
5
6
  function buildWorkflowSection() {
@@ -91,6 +92,7 @@ export function generateAgentsMd(options) {
91
92
  architectureSection,
92
93
  PLANU_SECTION_END,
93
94
  ].join('\n');
95
+ assertEnglishOnlyArtifactText(planuBlock, 'agent');
94
96
  return planuBlock;
95
97
  }
96
98
  /**
@@ -8,6 +8,7 @@ const SESSION_START_COMMAND = [
8
8
  ].join(' ');
9
9
  const STOP_COMMAND = [
10
10
  'if [ -d "planu/" ]; then',
11
+ ' if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi;',
11
12
  ' git add planu/specs/ planu/conventions.json 2>/dev/null;',
12
13
  ' git diff --staged --quiet || git commit -m "chore: auto-save planu state" 2>/dev/null;',
13
14
  'fi',
@@ -1,6 +1,7 @@
1
1
  // engine/ai-integration/cursor/cursorrules-generator.ts — .cursorrules generator (SPEC-268)
2
2
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { assertEnglishOnlyArtifactText } from '../../spec-language/english-only.js';
4
5
  const SECTION_START = '<!-- planu-sdd:start -->';
5
6
  const SECTION_END = '<!-- planu-sdd:end -->';
6
7
  function buildPlanuSection(options) {
@@ -46,12 +47,16 @@ function buildPlanuSection(options) {
46
47
  lines.push('');
47
48
  }
48
49
  lines.push(SECTION_END);
49
- return lines.join('\n');
50
+ const content = lines.join('\n');
51
+ assertEnglishOnlyArtifactText(content, 'agent');
52
+ return content;
50
53
  }
51
54
  export function generateCursorRules(options) {
52
55
  const stackStr = options.stack.length > 0 ? options.stack.join(', ') : 'Not specified';
53
56
  const header = [`# Cursor Rules — ${options.projectName}`, '', `Stack: ${stackStr}`, ''].join('\n');
54
- return header + buildPlanuSection(options);
57
+ const content = header + buildPlanuSection(options);
58
+ assertEnglishOnlyArtifactText(content, 'agent');
59
+ return content;
55
60
  }
56
61
  export function writeCursorRules(projectPath, options) {
57
62
  const filePath = join(projectPath, '.cursorrules');
@@ -17,7 +17,10 @@ const HOOK_SCRIPT_SESSION_START = [
17
17
  ].join('\n');
18
18
  const HOOK_SCRIPT_SESSION_END = [
19
19
  '#!/usr/bin/env bash',
20
- '# session-end.sh — auto-commit planu/ changes',
20
+ '# session-end.sh — optionally auto-commit planu/ changes',
21
+ 'if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then',
22
+ ' exit 0',
23
+ 'fi',
21
24
  'if git diff --quiet HEAD -- planu/ 2>/dev/null; then',
22
25
  ' exit 0',
23
26
  'fi',
@@ -18,6 +18,7 @@ const POST_TOOL_SCRIPT = [
18
18
  const STOP_SCRIPT = [
19
19
  'planu session_checkpoint --auto 2>/dev/null;',
20
20
  'if [ -d "planu/" ]; then',
21
+ ' if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi;',
21
22
  ' git add planu/specs/ planu/conventions.json 2>/dev/null;',
22
23
  ' git diff --staged --quiet || git commit -m "chore: auto-save planu state [kiro]" 2>/dev/null;',
23
24
  'fi',
@@ -40,7 +41,7 @@ export function generateKiroHooks() {
40
41
  {
41
42
  id: 'planu-stop',
42
43
  event: 'stop',
43
- description: 'Save session checkpoint and auto-commit planu state on session end',
44
+ description: 'Save session checkpoint and optionally auto-commit planu state on session end',
44
45
  script: STOP_SCRIPT,
45
46
  },
46
47
  ];
@@ -1,6 +1,7 @@
1
1
  // engine/ai-integration/windsurf/windsurfrules-generator.ts — .windsurfrules generator (SPEC-268)
2
2
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { assertEnglishOnlyArtifactText } from '../../spec-language/english-only.js';
4
5
  const SECTION_START = '<!-- planu-sdd:start -->';
5
6
  const SECTION_END = '<!-- planu-sdd:end -->';
6
7
  function buildPlanuSection(options) {
@@ -46,12 +47,16 @@ function buildPlanuSection(options) {
46
47
  lines.push('');
47
48
  }
48
49
  lines.push(SECTION_END);
49
- return lines.join('\n');
50
+ const content = lines.join('\n');
51
+ assertEnglishOnlyArtifactText(content, 'agent');
52
+ return content;
50
53
  }
51
54
  export function generateWindsurfRules(options) {
52
55
  const stackStr = options.stack.length > 0 ? options.stack.join(', ') : 'Not specified';
53
56
  const header = [`# Windsurf Rules — ${options.projectName}`, '', `Stack: ${stackStr}`, ''].join('\n');
54
- return header + buildPlanuSection(options);
57
+ const content = header + buildPlanuSection(options);
58
+ assertEnglishOnlyArtifactText(content, 'agent');
59
+ return content;
55
60
  }
56
61
  export function writeWindsurfRules(projectPath, options) {
57
62
  const filePath = join(projectPath, '.windsurfrules');
@@ -202,25 +202,16 @@ const ACTION_HANDLERS = {
202
202
  const specs = await specStore.listSpecs(ctx.projectId);
203
203
  const { generateChangelog } = await import('../doc-generator/support-generators.js');
204
204
  const doc = generateChangelog(specs, 'en');
205
- // Write changelog to disk inside planu/ directory
205
+ // Generated changelog is returned to callers; strict Planu mode forbids planu/CHANGELOG.md.
206
206
  const { writeFile, mkdir } = await import('node:fs/promises');
207
207
  const { join } = await import('node:path');
208
- const changelogPath = join(ctx.projectPath, 'planu', 'CHANGELOG.md');
209
- await mkdir(join(ctx.projectPath, 'planu'), { recursive: true });
208
+ const { projectDataDir } = await import('../../storage/base-store.js');
209
+ const changelogPath = join(projectDataDir(ctx.projectId), 'generated', 'CHANGELOG.md');
210
+ await mkdir(join(projectDataDir(ctx.projectId), 'generated'), { recursive: true });
210
211
  await writeFile(changelogPath, doc.content, 'utf-8');
211
- // Auto-commit planu/ changes so CHANGELOG.md is never left unstaged
212
- void (async () => {
213
- try {
214
- const { planuAutoCommit } = await import('../git/planu-autocommit.js');
215
- await planuAutoCommit({ projectPath: ctx.projectPath, reason: 'sync-release' });
216
- }
217
- catch {
218
- /* best-effort */
219
- }
220
- })();
221
212
  return {
222
213
  success: true,
223
- summary: `Changelog written to planu/CHANGELOG.md (${doc.content.length} chars)`,
214
+ summary: `Changelog written to external Planu project data (${doc.content.length} chars)`,
224
215
  durationMs: 0,
225
216
  };
226
217
  }),
@@ -1,26 +1,29 @@
1
1
  // @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
2
- // engine/autopilot/state-updater.ts — SPEC-459: Auto-update planu/status.json on every status change
2
+ // engine/autopilot/state-updater.ts — SPEC-459: Auto-update external project status on every status change
3
3
  // Fire-and-forget: never blocks the event pipeline, never throws to callers.
4
4
  // SPEC-753: File-lock applied to all writes; self-healing on corrupt reads.
5
5
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
6
6
  import { join, dirname } from 'node:path';
7
7
  import { withStatusLock } from '../../storage/status-store/file-lock.js';
8
- import { validateStatusJson, quarantineCorruptStatus, rebuildStatusFromFrontmatters, writeRebuiltStatus, } from '../../storage/status-store/self-healing.js';
8
+ import { validateStatusJson, rebuildStatusFromFrontmatters, writeRebuiltStatus, } from '../../storage/status-store/self-healing.js';
9
9
  import { syncVersionField } from '../../storage/status-store/version-sync.js';
10
- const STATUS_FILENAME = 'planu/status.json';
10
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
11
+ const STATUS_FILENAME = 'status.json';
11
12
  const MAX_RECENT_CHANGES = 20;
13
+ function getStatusPath(projectPath) {
14
+ return join(projectDataDir(hashProjectPath(projectPath)), STATUS_FILENAME);
15
+ }
12
16
  /**
13
17
  * SPEC-753: Load status.json with self-healing.
14
18
  * If corrupt: quarantine → rebuild from frontmatters → return fresh state.
15
19
  */
16
20
  async function loadStatus(projectPath) {
17
- const filePath = join(projectPath, STATUS_FILENAME);
21
+ const filePath = getStatusPath(projectPath);
18
22
  // SPEC-753: Validate before parsing
19
23
  const validation = await validateStatusJson(filePath);
20
24
  if (!validation.ok && validation.error !== 'ENOENT') {
21
- // Corrupt quarantine and rebuild
25
+ // Corrupt external state is rebuilt in place; no project-tree quarantine.
22
26
  try {
23
- await quarantineCorruptStatus(filePath, projectPath);
24
27
  const rebuilt = await rebuildStatusFromFrontmatters({ projectPath });
25
28
  await writeRebuiltStatus(filePath, rebuilt);
26
29
  return rebuilt;
@@ -48,7 +51,7 @@ async function loadStatus(projectPath) {
48
51
  }
49
52
  }
50
53
  async function saveStatus(projectPath, status) {
51
- const filePath = join(projectPath, STATUS_FILENAME);
54
+ const filePath = getStatusPath(projectPath);
52
55
  await mkdir(dirname(filePath), { recursive: true });
53
56
  await writeFile(filePath, JSON.stringify(status, null, 2), 'utf-8');
54
57
  }
@@ -58,7 +61,7 @@ async function saveStatus(projectPath, status) {
58
61
  * SPEC-753: Wrapped in file-lock to prevent concurrent write corruption.
59
62
  */
60
63
  export async function recordStatusChange(projectPath, _projectId, specId, fromStatus, toStatus) {
61
- const filePath = join(projectPath, STATUS_FILENAME);
64
+ const filePath = getStatusPath(projectPath);
62
65
  try {
63
66
  await withStatusLock(filePath, async () => {
64
67
  const status = await loadStatus(projectPath);
@@ -91,7 +94,7 @@ export async function recordStatusChange(projectPath, _projectId, specId, fromSt
91
94
  * SPEC-753: Wrapped in file-lock.
92
95
  */
93
96
  export async function refreshProjectStatus(projectPath, projectId) {
94
- const filePath = join(projectPath, STATUS_FILENAME);
97
+ const filePath = getStatusPath(projectPath);
95
98
  try {
96
99
  await withStatusLock(filePath, async () => {
97
100
  const { specStore } = await import('../../storage/index.js');
@@ -124,7 +127,7 @@ export async function refreshProjectStatus(projectPath, projectId) {
124
127
  * SPEC-753: Wrapped in file-lock.
125
128
  */
126
129
  export async function incrementSpecCount(projectPath) {
127
- const filePath = join(projectPath, STATUS_FILENAME);
130
+ const filePath = getStatusPath(projectPath);
128
131
  try {
129
132
  await withStatusLock(filePath, async () => {
130
133
  const status = await loadStatus(projectPath);
@@ -5,6 +5,9 @@ async function handler(ctx) {
5
5
  if (!projectPath) {
6
6
  return;
7
7
  }
8
+ if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
9
+ return;
10
+ }
8
11
  // Guard: only auto-stage when validateScore is non-null (matches original inline guard)
9
12
  if (validateScore === null) {
10
13
  return;
@@ -13,7 +13,7 @@ export const htmlRegenHook = {
13
13
  id: 'html-regen',
14
14
  triggers: ['done'], // only on done
15
15
  requiresProjectPath: true,
16
- label: 'Regenerate planu/index.html on done (SPEC-464)',
16
+ label: 'Run legacy dashboard compatibility hook without writing project HTML',
17
17
  handler,
18
18
  };
19
19
  //# sourceMappingURL=html-regen.hook.js.map
@@ -28,7 +28,7 @@ export const statusJsonHook = {
28
28
  id: 'status-json',
29
29
  triggers: [], // any status change + release_completed
30
30
  requiresProjectPath: true,
31
- label: 'Auto-update planu/status.json with spec counts (SPEC-459/776)',
31
+ label: 'Auto-update external project status with spec counts (SPEC-459/776)',
32
32
  handler,
33
33
  };
34
34
  //# sourceMappingURL=status-json.hook.js.map
@@ -1,6 +1,6 @@
1
1
  import type { DriftReport } from '../../types/cascade-hooks.js';
2
2
  /**
3
- * SPEC-776: Compare in-memory spec counts vs planu/status.json and
3
+ * SPEC-776: Compare in-memory spec counts vs external status.json and
4
4
  * recently-done spec vs planu/session-context.md.
5
5
  * Returns a DriftReport with any detected mismatches.
6
6
  */
@@ -1,32 +1,35 @@
1
1
  // engine/cascade-hooks/state-drift-detector.ts — SPEC-776
2
- // Detects drift between in-memory spec store and planu/ state files.
2
+ // Detects drift between in-memory spec store and canonical Planu state files.
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { specStore } from '../../storage/index.js';
6
- import { hashProjectPath } from '../../storage/base-store.js';
7
- /** Read and parse planu/status.json counts field; returns null if missing or invalid. */
6
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
7
+ function externalStatusPath(projectPath) {
8
+ return join(projectDataDir(hashProjectPath(projectPath)), 'status.json');
9
+ }
10
+ /** Read and parse external status counts; returns null if missing or invalid. */
8
11
  async function readStatusJsonDoneCounts(projectPath) {
9
12
  try {
10
- const raw = await readFile(join(projectPath, 'planu', 'status.json'), 'utf-8');
13
+ const raw = await readFile(externalStatusPath(projectPath), 'utf-8');
11
14
  const parsed = JSON.parse(raw);
12
15
  if (parsed === null || typeof parsed !== 'object') {
13
16
  return null;
14
17
  }
15
- const counts = parsed.counts;
16
- if (counts === null || typeof counts !== 'object') {
18
+ const byStatus = parsed.byStatus;
19
+ if (byStatus === null || typeof byStatus !== 'object') {
17
20
  return null;
18
21
  }
19
- const doneVal = counts.done;
22
+ const doneVal = byStatus.done;
20
23
  return typeof doneVal === 'number' ? doneVal : null;
21
24
  }
22
25
  catch {
23
26
  return null;
24
27
  }
25
28
  }
26
- /** Returns true if planu/status.json exists (even if counts are missing). */
29
+ /** Returns true if external status.json exists (even if counts are missing). */
27
30
  async function statusJsonExists(projectPath) {
28
31
  try {
29
- await readFile(join(projectPath, 'planu', 'status.json'), 'utf-8');
32
+ await readFile(externalStatusPath(projectPath), 'utf-8');
30
33
  return true;
31
34
  }
32
35
  catch {
@@ -57,7 +60,7 @@ function extractLastDoneSpecFromContext(content) {
57
60
  return idMatch ? idMatch[0] : null;
58
61
  }
59
62
  /**
60
- * SPEC-776: Compare in-memory spec counts vs planu/status.json and
63
+ * SPEC-776: Compare in-memory spec counts vs external status.json and
61
64
  * recently-done spec vs planu/session-context.md.
62
65
  * Returns a DriftReport with any detected mismatches.
63
66
  */
@@ -72,12 +75,12 @@ export async function verifyStateFiles(projectPath) {
72
75
  return { drifted: false, alerts: [] };
73
76
  }
74
77
  const expectedDoneCount = specs.filter((s) => s.status === 'done').length;
75
- // --- Check planu/status.json ---
78
+ // --- Check external status.json ---
76
79
  const exists = await statusJsonExists(projectPath);
77
80
  if (!exists) {
78
81
  alerts.push({
79
82
  kind: 'state_drift',
80
- message: 'state_drift: planu/status.json missing or unreadable',
83
+ message: 'state_drift: external status.json missing or unreadable',
81
84
  severity: 'warning',
82
85
  fix: 'reconcile_status_json(projectPath)',
83
86
  });
@@ -1,4 +1,5 @@
1
1
  import type { PlanuAutocommitOptions, PlanuAutocommitResult } from '../../types/index.js';
2
+ export declare function isPlanuAutocommitEnabled(): boolean;
2
3
  /**
3
4
  * Idempotent autocommit for staged planu/ docs.
4
5
  *
@@ -4,6 +4,9 @@ import { promisify } from 'node:util';
4
4
  import { access } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  const execFile = promisify(execFileCb);
7
+ export function isPlanuAutocommitEnabled() {
8
+ return process.env.PLANU_ENABLE_AUTOCOMMIT === 'true';
9
+ }
7
10
  /** Run git with execFile (no shell injection risk). */
8
11
  async function runGit(cwd, args) {
9
12
  const result = await execFile('git', args, {
@@ -101,6 +104,9 @@ async function unstageFiles(projectPath) {
101
104
  */
102
105
  export async function planuAutoCommit(opts) {
103
106
  const { projectPath, specId, reason } = opts;
107
+ if (!isPlanuAutocommitEnabled()) {
108
+ return { committed: false, skipped: 'disabled' };
109
+ }
104
110
  // 1. Safety check: refuse if mid-merge
105
111
  const midMerge = await isMidMerge(projectPath);
106
112
  if (midMerge) {
@@ -3,7 +3,7 @@
3
3
  // SPEC-347: auto-stage planu HTML files so they are never left out of commits
4
4
  import { readFile, writeFile, access, constants } from 'node:fs/promises';
5
5
  import { join, resolve } from 'node:path';
6
- const PLANU_SNIPPET = `\n# Planu: auto-stage specs (SPEC-466 — only source-of-truth files)\ngit add planu/specs/ planu/conventions.json 2>/dev/null || true\n`;
6
+ const PLANU_SNIPPET = `\n# Planu: optional auto-stage specs (SPEC-466 — only source-of-truth files)\nif [ "$PLANU_ENABLE_AUTOCOMMIT" = "true" ]; then\n git add planu/specs/ planu/conventions.json 2>/dev/null || true\nfi\n`;
7
7
  const PLANU_MARKER = 'git add planu/';
8
8
  async function fileExists(filePath) {
9
9
  try {
@@ -74,8 +74,8 @@ export async function injectPlanuAutoStage(projectPath) {
74
74
  }
75
75
  // lefthook / simple-git-hooks: cannot auto-inject, return manual instructions
76
76
  const manualInstructions = hookSystem === 'lefthook'
77
- ? `Add to lefthook.yml under pre-commit commands:\n - run: git add planu/specs/ planu/conventions.json 2>/dev/null || true`
78
- : `Add to package.json "simple-git-hooks"."pre-commit":\n "git add planu/specs/ planu/conventions.json 2>/dev/null || true"`;
77
+ ? `Add to lefthook.yml under pre-commit commands:\n - run: '[ "$PLANU_ENABLE_AUTOCOMMIT" = "true" ] && git add planu/specs/ planu/conventions.json 2>/dev/null || true'`
78
+ : `Add to package.json "simple-git-hooks"."pre-commit":\n "[ \\"$PLANU_ENABLE_AUTOCOMMIT\\" = \\"true\\" ] && git add planu/specs/ planu/conventions.json 2>/dev/null || true"`;
79
79
  return {
80
80
  injected: false,
81
81
  hookSystem,
@@ -7,6 +7,7 @@ import { acquireLock, releaseLock } from '../safety/cross-process-lock.js';
7
7
  import { appendTransitionEvent } from '../../storage/transition-log.js';
8
8
  import { validateArtifact } from './schemas.js';
9
9
  import { resolveCompat } from './version-policy.js';
10
+ import { projectDataDir } from '../../storage/base-store.js';
10
11
  // Current schema version for version-policy checks
11
12
  const CURRENT_SCHEMA_VERSION = '1.0.0';
12
13
  // ---------------------------------------------------------------------------
@@ -14,14 +15,14 @@ const CURRENT_SCHEMA_VERSION = '1.0.0';
14
15
  // ---------------------------------------------------------------------------
15
16
  /**
16
17
  * Returns the file path for a handoff artifact.
17
- * Layout: planu/data/projects/<projectId>/handoffs/<specId>/<kind>
18
+ * Layout: ~/.planu/data/projects/<projectId>/handoffs/<specId>/<kind>
18
19
  *
19
20
  * Note: kind values like 'spec.lock', 'review_feedback', etc. are used directly as filenames.
20
21
  */
21
22
  function artifactPath(projectId, specId, kind) {
22
23
  // review_feedback is a .md file, rest are .json
23
24
  const filename = kind === 'review_feedback' ? 'review_feedback.md' : `${kind}.json`;
24
- return join('planu', 'data', 'projects', projectId, 'handoffs', specId, filename);
25
+ return join(projectDataDir(projectId), 'handoffs', specId, filename);
25
26
  }
26
27
  /** Map artifact kind to the transition-log eventType */
27
28
  function eventTypeForKind(kind) {
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { stripFrontmatter } from './frontmatter-parser.js';
6
+ import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
6
7
  // ── Parsing helpers ──────────────────────────────────────────────────────────
7
8
  async function safeReadFile(path) {
8
9
  if (!path) {
@@ -293,7 +294,7 @@ async function persistHandoffIfPossible(pkg, knowledge) {
293
294
  if (!knowledge.projectPath) {
294
295
  return pkg;
295
296
  }
296
- const handoffDir = join(knowledge.projectPath, 'planu', 'handoffs');
297
+ const handoffDir = join(projectDataDir(hashProjectPath(knowledge.projectPath)), 'handoffs');
297
298
  const handoffPath = join(handoffDir, `${pkg.specId}.md`);
298
299
  const sessionContextPath = join(knowledge.projectPath, 'planu', 'session-context.md');
299
300
  const markdown = renderPersistedHandoff(pkg);
@@ -1,6 +1,7 @@
1
1
  import type { FullSpectrumHookConfig, HookScript } from '../../types/hooks-advanced.js';
2
2
  /**
3
- * Generates the Stop hook script auto-commits planu/ on session end.
3
+ * Generates the Stop hook script. Planu housekeeping is opt-in so generated
4
+ * hooks cannot silently stage or commit user branches.
4
5
  */
5
6
  export declare function generateStopHookScript(projectPath: string): string;
6
7
  /**
@@ -3,12 +3,14 @@
3
3
  // Individual script builders
4
4
  // ---------------------------------------------------------------------------
5
5
  /**
6
- * Generates the Stop hook script auto-commits planu/ on session end.
6
+ * Generates the Stop hook script. Planu housekeeping is opt-in so generated
7
+ * hooks cannot silently stage or commit user branches.
7
8
  */
8
9
  export function generateStopHookScript(projectPath) {
9
10
  return `#!/bin/bash
10
- # Auto-commit planu/ if there are changes (Planu Stop hook)
11
+ # Optionally auto-commit planu/ if explicitly enabled (Planu Stop hook)
11
12
  cd "${projectPath}"
13
+ if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi
12
14
  if git diff --quiet planu/ 2>/dev/null; then exit 0; fi
13
15
  git add planu/specs/ planu/conventions.json && git commit -m "chore(planu): auto-commit session state [skip ci]" --no-verify
14
16
  `;
@@ -73,7 +75,7 @@ export function generateFullSpectrumHooks(config, projectPath = '$CLAUDE_PROJECT
73
75
  scripts.push({
74
76
  eventType: 'Stop',
75
77
  scriptContent: generateStopHookScript(projectPath),
76
- description: 'Auto-commits planu/ changes at session end.',
78
+ description: 'Optionally auto-commits planu/ changes at session end when enabled.',
77
79
  });
78
80
  }
79
81
  if (config.preCompactHook) {