@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
@@ -2,6 +2,7 @@
2
2
  // Wraps fetchAnthropicSkillContent from anthropic-adapter.ts and converts to FetchedSkill[].
3
3
  import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
5
6
  import { fetchAnthropicSkillContent } from '../skill-registry/anthropic-adapter.js';
6
7
  import { readCache, writeCache } from './cache.js';
7
8
  // ---------------------------------------------------------------------------
@@ -107,6 +108,7 @@ async function writeSkillFile(projectPath, skillName, content) {
107
108
  const skillsDir = join(projectPath, '.claude', 'skills');
108
109
  await mkdir(skillsDir, { recursive: true });
109
110
  const filePath = join(skillsDir, `${skillName}.md`);
111
+ assertEnglishOnlyArtifactText(`${skillName}\n\n${content}`, 'skill');
110
112
  await writeFile(filePath, content, 'utf-8');
111
113
  return filePath;
112
114
  }
@@ -1,6 +1,7 @@
1
1
  // src/engine/opencode/config-scaffold.ts — SPEC-966: Generate OpenCode config files
2
2
  import { mkdirSync, writeFileSync, existsSync } from 'fs';
3
3
  import { join } from 'path';
4
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
4
5
  const PLANU_OPENCODE_RULES = `# Planu Rules for OpenCode
5
6
 
6
7
  ## SDD Workflow
@@ -73,16 +74,19 @@ export function scaffoldOpenCodeConfig(projectPath) {
73
74
  const rulesDir = join(projectPath, '.opencode', 'rules');
74
75
  mkdirSync(rulesDir, { recursive: true });
75
76
  const rulesPath = join(rulesDir, 'planu-workflow.md');
77
+ assertEnglishOnlyArtifactText(PLANU_OPENCODE_RULES, 'rule');
76
78
  writeFileSync(rulesPath, PLANU_OPENCODE_RULES, 'utf-8');
77
79
  files.push(rulesPath);
78
80
  // .opencode/skills/planu-sdd.md
79
81
  const skillsDir = join(projectPath, '.opencode', 'skills');
80
82
  mkdirSync(skillsDir, { recursive: true });
81
83
  const skillsPath = join(skillsDir, 'planu-sdd.md');
84
+ assertEnglishOnlyArtifactText(PLANU_OPENCODE_SKILLS, 'skill');
82
85
  writeFileSync(skillsPath, PLANU_OPENCODE_SKILLS, 'utf-8');
83
86
  files.push(skillsPath);
84
87
  // AGENTS.md append (or create)
85
88
  const agentsMdPath = join(projectPath, 'AGENTS.md');
89
+ assertEnglishOnlyArtifactText(PLANU_AGENTS_MD_BLOCK, 'agent');
86
90
  if (existsSync(agentsMdPath)) {
87
91
  writeFileSync(agentsMdPath, '\n' + PLANU_AGENTS_MD_BLOCK + '\n', { flag: 'a' });
88
92
  files.push(agentsMdPath + ' (appended)');
@@ -6,7 +6,7 @@ export interface PostmortemInput {
6
6
  projectRoot?: string;
7
7
  }
8
8
  /**
9
- * Write a post-mortem skeleton to planu/research/postmortems/<version>-<ts>.md
9
+ * Write a post-mortem skeleton to external Planu project data.
10
10
  * Returns the path of the written file.
11
11
  */
12
12
  export declare function writePostmortem(input: PostmortemInput): Promise<string>;
@@ -1,6 +1,7 @@
1
1
  // engine/release/postmortem-generator.ts — SPEC-737: Post-mortem skeleton writer
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Template path
6
7
  // ---------------------------------------------------------------------------
@@ -12,7 +13,7 @@ function templatePath() {
12
13
  // Public API
13
14
  // ---------------------------------------------------------------------------
14
15
  /**
15
- * Write a post-mortem skeleton to planu/research/postmortems/<version>-<ts>.md
16
+ * Write a post-mortem skeleton to external Planu project data.
16
17
  * Returns the path of the written file.
17
18
  */
18
19
  export async function writePostmortem(input) {
@@ -20,7 +21,7 @@ export async function writePostmortem(input) {
20
21
  const isoNow = new Date().toISOString();
21
22
  const tsSlug = isoNow.replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
22
23
  const filename = `${version}-${tsSlug}.md`;
23
- const outDir = join(projectRoot, 'planu', 'research', 'postmortems');
24
+ const outDir = join(projectDataDir(hashProjectPath(projectRoot)), 'research', 'postmortems');
24
25
  const outPath = join(outDir, filename);
25
26
  // Load template
26
27
  let template;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { detectAiTools, getPrimaryAiTool } from '../ai-tool-detector/index.js';
4
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
4
5
  // Stack-specific architecture rules catalog
5
6
  export const ARCHITECTURE_RULES = {
6
7
  nextjs: {
@@ -300,6 +301,7 @@ export function writeRules(rules) {
300
301
  if (dir) {
301
302
  mkdirSync(dir, { recursive: true });
302
303
  }
304
+ assertEnglishOnlyArtifactText(rule.content, 'rule');
303
305
  writeFileSync(rule.filePath, rule.content, 'utf-8');
304
306
  written.push(rule.filePath);
305
307
  }
@@ -2,6 +2,7 @@
2
2
  import { readFileSync, readdirSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { join, basename } from 'node:path';
4
4
  import { parseConventions } from './convention-scanner/convention-parser.js';
5
+ import { assertEnglishOnlyArtifactText } from './spec-language/english-only.js';
5
6
  // ── Helpers ───────────────────────────────────────────────────────────────────
6
7
  function listRulesFiles(projectPath) {
7
8
  const rulesDir = join(projectPath, '.claude', 'rules');
@@ -122,6 +123,7 @@ function writeNewRulesFile(projectPath, category, conventions) {
122
123
  const fileName = `${category}.md`;
123
124
  const filePath = join(rulesDir, fileName);
124
125
  const content = generateRuleFileContent(category, conventions);
126
+ assertEnglishOnlyArtifactText(content, 'rule');
125
127
  writeFileSync(filePath, content, 'utf-8');
126
128
  return fileName;
127
129
  }
@@ -7,7 +7,7 @@
7
7
  // SMB / NFSv3 may produce false acquires because O_EXCL is not guaranteed atomic on
8
8
  // network filesystems. For local-disk POSIX (HFS+, APFS, ext4, tmpfs) this is reliable.
9
9
  //
10
- // Lockfiles live at: <projectPath>/planu/.locks/<specId>.lock
10
+ // Lockfiles live at: <projectPath>/data/.locks/planu/<specId>.lock
11
11
  // File mode: 0o600 (owner read/write only)
12
12
  import { open, unlink, readFile, mkdir, chmod } from 'node:fs/promises';
13
13
  import { existsSync } from 'node:fs';
@@ -41,7 +41,7 @@ export class LockBusyError extends Error {
41
41
  // Internal helpers
42
42
  // ---------------------------------------------------------------------------
43
43
  function locksDir(projectPath) {
44
- return join(projectPath, 'planu', '.locks');
44
+ return join(projectPath, 'data', '.locks', 'planu');
45
45
  }
46
46
  function lockPath(projectPath, specId) {
47
47
  return join(locksDir(projectPath), `${specId}.lock`);
@@ -74,7 +74,6 @@ export async function writeCheckpoint(projectPath, snapshot) {
74
74
  const updated = replaceLatestCheckpoint(existing, newBlock);
75
75
  await mkdir(planuDir, { recursive: true });
76
76
  await writeFile(filePath, updated, 'utf8');
77
- // Auto-commit planu/ changes so session-context.md is never left unstaged
78
77
  void (async () => {
79
78
  try {
80
79
  const { planuAutoCommit } = await import('../git/planu-autocommit.js');
@@ -132,7 +132,10 @@ export async function generateSessionContext(projectPath, projectId) {
132
132
  await mkdir(planuDir, { recursive: true });
133
133
  const contextPath = join(planuDir, 'session-context.md');
134
134
  await writeFile(contextPath, content, 'utf-8');
135
- // SPEC-598: Atomic commit — git add + commit so session-context is never left unstaged/uncommitted
135
+ if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
136
+ return;
137
+ }
138
+ // SPEC-598: opt-in atomic commit for hosts that explicitly enable Planu autocommit.
136
139
  try {
137
140
  const { execFile } = await import('node:child_process');
138
141
  const { promisify } = await import('node:util');
@@ -1,6 +1,7 @@
1
1
  // engine/skill-bootstrap/skill-writer.ts — SPEC-439: Write skills to target AI tool files
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
4
5
  const AUTO_SECTION_HEADER = '## Auto-installed Skills (Planu)';
5
6
  function targetToRelativePath(target) {
6
7
  switch (target) {
@@ -42,6 +43,7 @@ export function formatSkillsForTarget(skills, target) {
42
43
  return parts.join('\n\n');
43
44
  }
44
45
  export async function writeSkillFile(projectPath, target, content, autoMerge) {
46
+ assertEnglishOnlyArtifactText(content, 'skill');
45
47
  const relativePath = targetToRelativePath(target);
46
48
  const filePath = join(projectPath, relativePath);
47
49
  await mkdir(dirname(filePath), { recursive: true });
@@ -2,6 +2,7 @@
2
2
  // Detects which AI agents are present in a project and writes adapted skill files.
3
3
  import { access, mkdir, writeFile, readFile } from 'node:fs/promises';
4
4
  import { join, dirname } from 'node:path';
5
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
5
6
  import { adaptSkillForAgent } from './multi-agent-adapter.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // Agent detection signals
@@ -92,6 +93,7 @@ async function writeNewFile(filePath, content) {
92
93
  * @param targets - Optional explicit list of targets (default: auto-detect)
93
94
  */
94
95
  export async function writeSkillToAllAgents(skillContent, metadata, projectPath, targets) {
96
+ assertEnglishOnlyArtifactText(`${metadata.name}\n\n${metadata.description}\n\n${skillContent}`, 'skill');
95
97
  const resolvedTargets = targets ?? (await detectPresentAgents(projectPath));
96
98
  const results = [];
97
99
  await Promise.all(resolvedTargets.map(async (target) => {
@@ -2,7 +2,7 @@
2
2
  // Orchestrates the two-tier spec drift audit.
3
3
  import { join } from 'node:path';
4
4
  import { listSpecs } from '../../storage/spec-store.js';
5
- import { hashProjectPath } from '../../storage/base-store.js';
5
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
6
6
  import { buildSpecGraph } from '../spec-dependency-graph/graph-builder.js';
7
7
  import { getSupersededSet, getSupersededMap } from '../spec-dependency-graph/superseded-set.js';
8
8
  import { scanSpecTier1 } from './tier1-scanner.js';
@@ -61,7 +61,7 @@ export async function auditSpecsDrift(opts) {
61
61
  }
62
62
  // Generate report path
63
63
  const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
64
- const reportPath = join(projectRoot, 'planu', 'research', `audit-full-${ts}.md`);
64
+ const reportPath = join(projectDataDir(projectId), 'research', `audit-full-${ts}.md`);
65
65
  const report = {
66
66
  generatedAt: new Date().toISOString(),
67
67
  totalSpecs: allSpecs.length,
@@ -1,5 +1,5 @@
1
1
  import type { AuditReport, DriftReviewPendingEntry } from '../../types/spec-audit.js';
2
2
  export declare function buildMarkdownReport(report: AuditReport): string;
3
- export declare function writeAuditReport(report: AuditReport, projectRoot: string): Promise<void>;
3
+ export declare function writeAuditReport(report: AuditReport, _projectRoot: string): Promise<void>;
4
4
  export declare function appendDriftReviewToPending(entry: DriftReviewPendingEntry, projectRoot: string): Promise<void>;
5
5
  //# sourceMappingURL=report-writer.d.ts.map
@@ -1,9 +1,10 @@
1
1
  // engine/spec-audit/report-writer.ts — SPEC-744
2
- // Generates the prioritised markdown drift report and appends pending.json entry.
2
+ // Generates the prioritised markdown drift report and appends pending review entries.
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { atomicWriteFile } from '../safety/atomic-write-file.js';
7
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Markdown report
9
10
  // ---------------------------------------------------------------------------
@@ -57,8 +58,8 @@ export function buildMarkdownReport(report) {
57
58
  // ---------------------------------------------------------------------------
58
59
  // Write report atomically
59
60
  // ---------------------------------------------------------------------------
60
- export async function writeAuditReport(report, projectRoot) {
61
- const dir = join(projectRoot, 'planu', 'research');
61
+ export async function writeAuditReport(report, _projectRoot) {
62
+ const dir = dirname(report.reportPath);
62
63
  await mkdir(dir, { recursive: true });
63
64
  const content = buildMarkdownReport(report);
64
65
  await atomicWriteFile(report.reportPath, content);
@@ -67,7 +68,7 @@ export async function writeAuditReport(report, projectRoot) {
67
68
  // Append to pending.json
68
69
  // ---------------------------------------------------------------------------
69
70
  export async function appendDriftReviewToPending(entry, projectRoot) {
70
- const pendingPath = join(projectRoot, 'planu', 'pending.json');
71
+ const pendingPath = join(projectDataDir(hashProjectPath(projectRoot)), 'pending.json');
71
72
  let existing = [];
72
73
  if (existsSync(pendingPath)) {
73
74
  try {
@@ -1,15 +1,16 @@
1
+ import type { EnglishOnlyArtifactKind, EnglishOnlyValidationResult } from '../../types/spec-language.js';
1
2
  /**
2
- * Validate that persisted spec documentation is authored in English.
3
+ * Validate that a Planu-owned persisted artifact is authored in English.
3
4
  *
4
5
  * The detector is intentionally conservative: it ignores code fences, inline code,
5
6
  * file paths, markdown headings, and BDD keywords before looking for common
6
7
  * Spanish/Portuguese function words. It should block obvious non-English prose
7
8
  * without punishing short technical descriptions.
8
9
  */
9
- export declare function validateEnglishOnlySpecText(text: string): {
10
- ok: boolean;
11
- detectedLanguage: 'en' | 'es' | 'pt' | 'unknown';
12
- reason?: string;
13
- signals: string[];
14
- };
10
+ export declare function validateEnglishOnlyArtifactText(text: string, kind?: EnglishOnlyArtifactKind): EnglishOnlyValidationResult;
11
+ /**
12
+ * Backward-compatible spec-specific entrypoint used by create_spec/update_status gates.
13
+ */
14
+ export declare function validateEnglishOnlySpecText(text: string): EnglishOnlyValidationResult;
15
+ export declare function assertEnglishOnlyArtifactText(text: string, kind: EnglishOnlyArtifactKind): void;
15
16
  //# sourceMappingURL=english-only.d.ts.map
@@ -79,14 +79,14 @@ const NON_ENGLISH_SIGNATURES = {
79
79
  ]),
80
80
  };
81
81
  /**
82
- * Validate that persisted spec documentation is authored in English.
82
+ * Validate that a Planu-owned persisted artifact is authored in English.
83
83
  *
84
84
  * The detector is intentionally conservative: it ignores code fences, inline code,
85
85
  * file paths, markdown headings, and BDD keywords before looking for common
86
86
  * Spanish/Portuguese function words. It should block obvious non-English prose
87
87
  * without punishing short technical descriptions.
88
88
  */
89
- export function validateEnglishOnlySpecText(text) {
89
+ export function validateEnglishOnlyArtifactText(text, kind = 'spec') {
90
90
  const tokens = tokenizeProse(text);
91
91
  if (tokens.length < MIN_PROSE_TOKENS) {
92
92
  return { ok: true, detectedLanguage: 'unknown', signals: [] };
@@ -109,10 +109,34 @@ export function validateEnglishOnlySpecText(text) {
109
109
  ok: false,
110
110
  detectedLanguage: best.lang,
111
111
  signals,
112
- reason: `Spec documents must be written in English. Detected ${languageName(best.lang)} prose ` +
112
+ reason: `${artifactLabel(kind)} must be written in English. Detected ${languageName(best.lang)} prose ` +
113
113
  `signals: ${signals.join(', ')}.`,
114
114
  };
115
115
  }
116
+ /**
117
+ * Backward-compatible spec-specific entrypoint used by create_spec/update_status gates.
118
+ */
119
+ export function validateEnglishOnlySpecText(text) {
120
+ return validateEnglishOnlyArtifactText(text, 'spec');
121
+ }
122
+ export function assertEnglishOnlyArtifactText(text, kind) {
123
+ const validation = validateEnglishOnlyArtifactText(text, kind);
124
+ if (!validation.ok) {
125
+ throw new Error(validation.reason ?? `${artifactLabel(kind)} must be written in English.`);
126
+ }
127
+ }
128
+ function artifactLabel(kind) {
129
+ switch (kind) {
130
+ case 'spec':
131
+ return 'Spec documents';
132
+ case 'skill':
133
+ return 'Skill artifacts';
134
+ case 'agent':
135
+ return 'Agent instructions';
136
+ case 'rule':
137
+ return 'Rule artifacts';
138
+ }
139
+ }
116
140
  function tokenizeProse(text) {
117
141
  const withoutCode = text
118
142
  .replace(/```[\s\S]*?```/g, ' ')
@@ -13,4 +13,5 @@ export { findLegacyMultiFileSpecs } from './find-legacy-multifile-specs.js';
13
13
  export { foldTechnicalIntoSpec } from './fold-technical.js';
14
14
  export { foldProgressIntoSpec, isBoilerplateProgress } from './fold-progress.js';
15
15
  export { runSsrBackMigration } from './ssr-back-migration.js';
16
+ export { PLANU_CANONICAL_POLICY, runStrictPlanuCleanup, validateStrictPlanuLayout, } from './strict-planu-cleanup.js';
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -26,4 +26,5 @@ export { findLegacyMultiFileSpecs } from './find-legacy-multifile-specs.js';
26
26
  export { foldTechnicalIntoSpec } from './fold-technical.js';
27
27
  export { foldProgressIntoSpec, isBoilerplateProgress } from './fold-progress.js';
28
28
  export { runSsrBackMigration } from './ssr-back-migration.js';
29
+ export { PLANU_CANONICAL_POLICY, runStrictPlanuCleanup, validateStrictPlanuLayout, } from './strict-planu-cleanup.js';
29
30
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,9 @@
1
+ import type { PlanuCanonicalPathPolicy } from '../../types/index.js';
2
+ export declare const PLANU_CANONICAL_POLICY: PlanuCanonicalPathPolicy;
3
+ export declare function isCanonicalPlanuRootFile(name: string): boolean;
4
+ export declare function isCanonicalPlanuRootDir(name: string): boolean;
5
+ export declare function isCanonicalSpecFile(name: string): boolean;
6
+ export declare function mustMergeBeforeDeleteSpecFile(name: string): boolean;
7
+ export declare function isCanonicalReleaseFile(relativeToPlanu: string): boolean;
8
+ export declare function canonicalContractText(): string;
9
+ //# sourceMappingURL=planu-canonical-policy.d.ts.map
@@ -0,0 +1,62 @@
1
+ // engine/spec-migrator/planu-canonical-policy.ts — SPEC-1017
2
+ // Single source of truth for the strict Planu managed directory contract.
3
+ export const PLANU_CANONICAL_POLICY = {
4
+ canonicalRootFiles: ['conventions.json', 'context.md', 'session-context.md', 'session.json'],
5
+ canonicalRootDirs: ['releases', 'specs'],
6
+ canonicalSpecFiles: ['spec.md'],
7
+ generatedRuntimePatterns: [
8
+ 'planu/index.html',
9
+ 'planu/roadmap.html',
10
+ 'planu/status.json',
11
+ 'planu/CHANGELOG.md',
12
+ 'planu/.housekeeping-history.jsonl',
13
+ 'planu/audits/',
14
+ 'planu/handoffs/',
15
+ 'planu/data/',
16
+ 'planu/state/',
17
+ 'planu/.locks/',
18
+ 'planu/specs/data/',
19
+ 'planu/specs/*/technical-report.html',
20
+ 'planu/specs/*/reference/',
21
+ 'planu/specs/*/test-stubs.ts',
22
+ 'planu/specs/*/.analysis.json',
23
+ 'planu/specs/*/prompt.md',
24
+ 'planu/specs/*/implementation-brief.md',
25
+ 'planu/specs/*/risk-register.md',
26
+ ],
27
+ legacyMergeBeforeDeleteFiles: ['technical.md', 'plan.md', 'PLAN.md', 'progress.md'],
28
+ };
29
+ const ROOT_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootFiles);
30
+ const ROOT_DIR_SET = new Set(PLANU_CANONICAL_POLICY.canonicalRootDirs);
31
+ const SPEC_FILE_SET = new Set(PLANU_CANONICAL_POLICY.canonicalSpecFiles);
32
+ const LEGACY_MERGE_SET = new Set(PLANU_CANONICAL_POLICY.legacyMergeBeforeDeleteFiles);
33
+ export function isCanonicalPlanuRootFile(name) {
34
+ return ROOT_FILE_SET.has(name);
35
+ }
36
+ export function isCanonicalPlanuRootDir(name) {
37
+ return ROOT_DIR_SET.has(name);
38
+ }
39
+ export function isCanonicalSpecFile(name) {
40
+ return SPEC_FILE_SET.has(name);
41
+ }
42
+ export function mustMergeBeforeDeleteSpecFile(name) {
43
+ return LEGACY_MERGE_SET.has(name);
44
+ }
45
+ export function isCanonicalReleaseFile(relativeToPlanu) {
46
+ return relativeToPlanu === 'releases/pending.json';
47
+ }
48
+ export function canonicalContractText() {
49
+ return [
50
+ 'planu/',
51
+ ' conventions.json',
52
+ ' context.md',
53
+ ' session-context.md',
54
+ ' session.json',
55
+ ' releases/',
56
+ ' pending.json',
57
+ ' specs/',
58
+ ' SPEC-XXX-slug/',
59
+ ' spec.md',
60
+ ].join('\n');
61
+ }
62
+ //# sourceMappingURL=planu-canonical-policy.js.map
@@ -1,101 +1,25 @@
1
- // engine/spec-migrator/planu-root-cleaner.ts — SPEC-464: Clean non-canonical files from planu/
2
- // Canonical structure: specs/ + status.json + conventions.json + index.html + roadmap.html
3
- import { readdir, stat } from 'node:fs/promises';
4
- import { execFileSync } from 'node:child_process';
5
- import { join, relative } from 'node:path';
6
- import { safeUnlink } from './git-aware-fs.js';
7
- /** Files allowed in planu/ root. Everything else gets deleted. */
8
- const CANONICAL_ROOT_FILES = new Set([
9
- 'status.json',
10
- 'conventions.json',
11
- 'index.html',
12
- 'roadmap.html',
13
- ]);
14
- /** Directories allowed in planu/ root. */
15
- const CANONICAL_ROOT_DIRS = new Set(['specs']);
16
- /** Files allowed inside each planu/specs/SPEC-XXX/ directory. */
17
- const CANONICAL_SPEC_FILES = new Set(['spec.md']);
18
- /** Remove a file from git tracking (best-effort, silent if not a git repo or file not tracked). */
19
- function gitRmCached(absolutePath, cwd) {
20
- try {
21
- const rel = relative(cwd, absolutePath);
22
- execFileSync('git', ['rm', '--cached', '--quiet', '--force', rel], {
23
- cwd,
24
- stdio: ['pipe', 'pipe', 'pipe'],
25
- timeout: 3_000,
26
- });
27
- }
28
- catch {
29
- // Not a git repo, file not tracked, or git not available — ignore
30
- }
31
- }
1
+ // engine/spec-migrator/planu-root-cleaner.ts — SPEC-1017 strict wrapper
2
+ import { dirname } from 'node:path';
3
+ import { runStrictPlanuCleanup } from './strict-planu-cleanup.js';
32
4
  /** Remove all non-canonical files from planu/ root and per-spec directories. */
33
5
  export async function cleanPlanuRoot(planuDir, projectPath) {
34
- const result = {
35
- deletedRootFiles: [],
36
- deletedSpecFiles: [],
37
- totalDeleted: 0,
38
- };
39
- // 1. Clean planu/ root
40
- let entries;
41
- try {
42
- entries = await readdir(planuDir);
43
- }
44
- catch {
45
- return result; // Dir doesn't exist — nothing to clean
46
- }
47
- for (const entry of entries) {
48
- const fullPath = join(planuDir, entry);
49
- try {
50
- const info = await stat(fullPath);
51
- if (info.isDirectory()) {
52
- if (!CANONICAL_ROOT_DIRS.has(entry)) {
53
- // Non-canonical directory — skip (don't delete dirs recursively for safety)
54
- continue;
55
- }
56
- }
57
- else if (!CANONICAL_ROOT_FILES.has(entry)) {
58
- gitRmCached(fullPath, planuDir);
59
- await safeUnlink(projectPath ?? planuDir, fullPath);
60
- result.deletedRootFiles.push(entry);
61
- }
6
+ const root = projectPath ?? dirname(planuDir);
7
+ const strict = await runStrictPlanuCleanup(root);
8
+ const deletedRootFiles = [];
9
+ const deletedSpecFiles = [];
10
+ for (const path of [...strict.deleted, ...strict.merged]) {
11
+ const normalized = path.replace(/^planu\//, '');
12
+ if (normalized.startsWith('specs/')) {
13
+ deletedSpecFiles.push(normalized.replace(/^specs\//, ''));
62
14
  }
63
- catch {
64
- // File disappeared between readdir and stat — ignore
15
+ else {
16
+ deletedRootFiles.push(normalized);
65
17
  }
66
18
  }
67
- // 2. Clean per-spec directories
68
- const specsDir = join(planuDir, 'specs');
69
- let specDirs;
70
- try {
71
- specDirs = await readdir(specsDir);
72
- }
73
- catch {
74
- result.totalDeleted = result.deletedRootFiles.length;
75
- return result;
76
- }
77
- for (const specDir of specDirs) {
78
- const specPath = join(specsDir, specDir);
79
- try {
80
- const info = await stat(specPath);
81
- if (!info.isDirectory()) {
82
- continue;
83
- }
84
- const files = await readdir(specPath);
85
- for (const file of files) {
86
- if (!CANONICAL_SPEC_FILES.has(file)) {
87
- const filePath = join(specPath, file);
88
- gitRmCached(filePath, planuDir);
89
- await safeUnlink(projectPath ?? planuDir, filePath);
90
- result.deletedSpecFiles.push(`${specDir}/${file}`);
91
- }
92
- }
93
- }
94
- catch {
95
- // Spec dir issue — skip
96
- }
97
- }
98
- result.totalDeleted = result.deletedRootFiles.length + result.deletedSpecFiles.length;
99
- return result;
19
+ return {
20
+ deletedRootFiles,
21
+ deletedSpecFiles,
22
+ totalDeleted: strict.deleted.length + strict.merged.length,
23
+ };
100
24
  }
101
25
  //# sourceMappingURL=planu-root-cleaner.js.map
@@ -0,0 +1,6 @@
1
+ import type { StrictPlanuCleanupResult, StrictPlanuValidationResult } from '../../types/index.js';
2
+ import { PLANU_CANONICAL_POLICY } from './planu-canonical-policy.js';
3
+ export declare function runStrictPlanuCleanup(projectPath: string): Promise<StrictPlanuCleanupResult>;
4
+ export declare function validateStrictPlanuLayout(projectPath: string): Promise<StrictPlanuValidationResult>;
5
+ export { PLANU_CANONICAL_POLICY };
6
+ //# sourceMappingURL=strict-planu-cleanup.d.ts.map