@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
@@ -0,0 +1,199 @@
1
+ // engine/spec-migrator/strict-planu-cleanup.ts — SPEC-1017
2
+ // Destructive cleanup for non-canonical files under planu/.
3
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { join, relative } from 'node:path';
8
+ import { atomicWriteFile } from '../safety/atomic-write-file.js';
9
+ import { safeUnlink } from './git-aware-fs.js';
10
+ import { PLANU_CANONICAL_POLICY, canonicalContractText, isCanonicalPlanuRootDir, isCanonicalPlanuRootFile, isCanonicalReleaseFile, isCanonicalSpecFile, mustMergeBeforeDeleteSpecFile, } from './planu-canonical-policy.js';
11
+ const execFileAsync = promisify(execFile);
12
+ async function pathIsDirectory(path) {
13
+ try {
14
+ return (await stat(path)).isDirectory();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ async function gitRmCached(projectPath, relPath) {
21
+ if (!existsSync(join(projectPath, '.git'))) {
22
+ return;
23
+ }
24
+ try {
25
+ await execFileAsync('git', ['rm', '--cached', '--quiet', '--ignore-unmatch', relPath], {
26
+ cwd: projectPath,
27
+ timeout: 5_000,
28
+ });
29
+ }
30
+ catch {
31
+ /* best-effort */
32
+ }
33
+ }
34
+ function stripFrontmatter(content) {
35
+ return content.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
36
+ }
37
+ function appendSectionIfMissing(specContent, heading, body) {
38
+ if (body.trim().length === 0 || new RegExp(`\\n## ${heading}(\\n|$)`).test(specContent)) {
39
+ return specContent;
40
+ }
41
+ return `${specContent.trimEnd()}\n\n## ${heading}\n\n${body.trim()}\n`;
42
+ }
43
+ async function mergeLegacySpecFile(projectPath, specDir, fileName) {
44
+ const specPath = join(specDir, 'spec.md');
45
+ const legacyPath = join(specDir, fileName);
46
+ if (!existsSync(legacyPath) || !existsSync(specPath)) {
47
+ return false;
48
+ }
49
+ const [specContent, legacyContent] = await Promise.all([
50
+ readFile(specPath, 'utf-8'),
51
+ readFile(legacyPath, 'utf-8'),
52
+ ]);
53
+ const body = stripFrontmatter(legacyContent);
54
+ const section = fileName === 'progress.md' ? 'Progress' : fileName === 'technical.md' ? 'Technical' : 'Files';
55
+ const merged = appendSectionIfMissing(specContent, section, body);
56
+ if (merged !== specContent) {
57
+ await atomicWriteFile(specPath, merged, {
58
+ forceEdit: {
59
+ reason: `SPEC-1017 strict cleanup is merging legacy ${fileName} into canonical spec.md.`,
60
+ },
61
+ });
62
+ }
63
+ await safeUnlink(projectPath, legacyPath);
64
+ return true;
65
+ }
66
+ async function removePath(projectPath, absolutePath) {
67
+ const rel = relative(projectPath, absolutePath);
68
+ if (await pathIsDirectory(absolutePath)) {
69
+ await gitRmCached(projectPath, rel);
70
+ await rm(absolutePath, { recursive: true, force: true });
71
+ return;
72
+ }
73
+ await safeUnlink(projectPath, absolutePath);
74
+ await rm(absolutePath, { force: true });
75
+ }
76
+ async function updateGitignore(projectPath) {
77
+ const gitignorePath = join(projectPath, '.gitignore');
78
+ let current = '';
79
+ try {
80
+ current = await readFile(gitignorePath, 'utf-8');
81
+ }
82
+ catch {
83
+ /* missing .gitignore is fine */
84
+ }
85
+ const required = [
86
+ 'planu/*.html',
87
+ 'planu/status.json',
88
+ 'planu/CHANGELOG.md',
89
+ 'planu/.housekeeping-history.jsonl',
90
+ 'planu/audits/',
91
+ 'planu/handoffs/',
92
+ 'planu/data/',
93
+ 'planu/state/',
94
+ 'planu/.locks/',
95
+ 'planu/specs/data/',
96
+ 'planu/specs/**/.analysis.json',
97
+ 'planu/specs/**/technical-report.html',
98
+ 'planu/specs/**/reference/',
99
+ 'planu/specs/**/*.bak.*',
100
+ ];
101
+ const missing = required.filter((entry) => !current.split('\n').includes(entry));
102
+ if (missing.length === 0) {
103
+ return false;
104
+ }
105
+ const separator = current === '' || current.endsWith('\n') ? '' : '\n';
106
+ await atomicWriteFile(gitignorePath, `${current}${separator}# Planu generated/runtime\n${missing.join('\n')}\n`);
107
+ return true;
108
+ }
109
+ async function walkSpecDirectory(projectPath, specDir, result) {
110
+ const entries = await readdir(specDir).catch(() => []);
111
+ for (const entry of entries) {
112
+ const full = join(specDir, entry);
113
+ if (mustMergeBeforeDeleteSpecFile(entry)) {
114
+ if (await mergeLegacySpecFile(projectPath, specDir, entry)) {
115
+ result.merged.push(relative(projectPath, full));
116
+ }
117
+ continue;
118
+ }
119
+ if (entry === 'reference' || !isCanonicalSpecFile(entry)) {
120
+ await removePath(projectPath, full);
121
+ result.deleted.push(relative(projectPath, full));
122
+ }
123
+ }
124
+ }
125
+ export async function runStrictPlanuCleanup(projectPath) {
126
+ const result = { deleted: [], merged: [], gitignoreUpdated: false };
127
+ const planuDir = join(projectPath, 'planu');
128
+ if (!existsSync(planuDir)) {
129
+ return result;
130
+ }
131
+ const entries = await readdir(planuDir).catch(() => []);
132
+ for (const entry of entries) {
133
+ const full = join(planuDir, entry);
134
+ const isDir = await pathIsDirectory(full);
135
+ if (isDir && !isCanonicalPlanuRootDir(entry)) {
136
+ await removePath(projectPath, full);
137
+ result.deleted.push(relative(projectPath, full));
138
+ continue;
139
+ }
140
+ if (!isDir && !isCanonicalPlanuRootFile(entry)) {
141
+ await removePath(projectPath, full);
142
+ result.deleted.push(relative(projectPath, full));
143
+ }
144
+ }
145
+ const releasesDir = join(planuDir, 'releases');
146
+ for (const entry of await readdir(releasesDir).catch(() => [])) {
147
+ const rel = `releases/${entry}`;
148
+ if (!isCanonicalReleaseFile(rel)) {
149
+ const full = join(releasesDir, entry);
150
+ await removePath(projectPath, full);
151
+ result.deleted.push(relative(projectPath, full));
152
+ }
153
+ }
154
+ const specsDir = join(planuDir, 'specs');
155
+ for (const entry of await readdir(specsDir).catch(() => [])) {
156
+ const full = join(specsDir, entry);
157
+ if (!(await pathIsDirectory(full)) || entry === 'data') {
158
+ await removePath(projectPath, full);
159
+ result.deleted.push(relative(projectPath, full));
160
+ continue;
161
+ }
162
+ await walkSpecDirectory(projectPath, full, result);
163
+ }
164
+ result.gitignoreUpdated = await updateGitignore(projectPath);
165
+ return result;
166
+ }
167
+ export async function validateStrictPlanuLayout(projectPath) {
168
+ const offenders = [];
169
+ const planuDir = join(projectPath, 'planu');
170
+ const entries = await readdir(planuDir).catch(() => []);
171
+ for (const entry of entries) {
172
+ const full = join(planuDir, entry);
173
+ const isDir = await pathIsDirectory(full);
174
+ if ((isDir && !isCanonicalPlanuRootDir(entry)) || (!isDir && !isCanonicalPlanuRootFile(entry))) {
175
+ offenders.push(relative(projectPath, full));
176
+ }
177
+ }
178
+ for (const entry of await readdir(join(planuDir, 'releases')).catch(() => [])) {
179
+ const rel = `releases/${entry}`;
180
+ if (!isCanonicalReleaseFile(rel)) {
181
+ offenders.push(`planu/${rel}`);
182
+ }
183
+ }
184
+ for (const specDir of await readdir(join(planuDir, 'specs')).catch(() => [])) {
185
+ const full = join(planuDir, 'specs', specDir);
186
+ if (!(await pathIsDirectory(full)) || specDir === 'data') {
187
+ offenders.push(relative(projectPath, full));
188
+ continue;
189
+ }
190
+ for (const entry of await readdir(full).catch(() => [])) {
191
+ if (!isCanonicalSpecFile(entry)) {
192
+ offenders.push(relative(projectPath, join(full, entry)));
193
+ }
194
+ }
195
+ }
196
+ return { ok: offenders.length === 0, offenders, contract: canonicalContractText() };
197
+ }
198
+ export { PLANU_CANONICAL_POLICY };
199
+ //# sourceMappingURL=strict-planu-cleanup.js.map
@@ -1,10 +1,10 @@
1
1
  import type { Spec } from '../types/index.js';
2
2
  /**
3
- * Generate planu/index.html with specs overview + regenerate per-spec reports.
4
- * Merges store specs with filesystem specs to ensure all specs appear.
5
- * Designed to be called from list_specs, update_status, create_spec.
6
- * Best-effort: never throws, never blocks the caller.
7
- * Skips writing the file when spec data has not changed (hash comparison).
3
+ * Legacy compatibility entrypoint.
4
+ *
5
+ * SPEC-1017 makes `planu/index.html` and generated per-spec reports forbidden
6
+ * project-tree artifacts. Keep this function for old call sites, but make it
7
+ * a best-effort read-only refresh so callers stop reintroducing legacy files.
8
8
  */
9
9
  export declare function regenerateSpecSummaryHtml(projectPath: string, specs: Spec[], _changedSpecIds?: string[]): Promise<void>;
10
10
  //# sourceMappingURL=spec-summary-html.d.ts.map
@@ -1,9 +1,6 @@
1
- import { writeFile, mkdir, readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parseFrontmatter } from './frontmatter-parser.js';
4
- import { generateDashboardHtml } from './spec-summary-html/dashboard-renderer.js';
5
- import { computeSpecDataHash, extractEmbeddedHash, HASH_MARKER, } from './spec-summary-html/hash-utils.js';
6
- import { detectAvailablePages } from './doc-generator/portal/portal-page-detector.js';
7
4
  /**
8
5
  * Scan planu/specs/ filesystem for spec.md files and build minimal Spec objects
9
6
  * from frontmatter. This catches specs not tracked in the JSON store.
@@ -87,38 +84,16 @@ function mergeSpecs(storeSpecs, fsSpecs) {
87
84
  return merged;
88
85
  }
89
86
  /**
90
- * Generate planu/index.html with specs overview + regenerate per-spec reports.
91
- * Merges store specs with filesystem specs to ensure all specs appear.
92
- * Designed to be called from list_specs, update_status, create_spec.
93
- * Best-effort: never throws, never blocks the caller.
94
- * Skips writing the file when spec data has not changed (hash comparison).
87
+ * Legacy compatibility entrypoint.
88
+ *
89
+ * SPEC-1017 makes `planu/index.html` and generated per-spec reports forbidden
90
+ * project-tree artifacts. Keep this function for old call sites, but make it
91
+ * a best-effort read-only refresh so callers stop reintroducing legacy files.
95
92
  */
96
93
  export async function regenerateSpecSummaryHtml(projectPath, specs, _changedSpecIds) {
97
- const portalPath = join(projectPath, 'planu');
98
- const outputPath = join(portalPath, 'index.html');
99
94
  try {
100
95
  const fsSpecs = await scanFilesystemSpecs(projectPath);
101
- const allSpecs = mergeSpecs(specs, fsSpecs);
102
- await mkdir(portalPath, { recursive: true });
103
- // Detect available portal pages before generating to populate navbar + quick links
104
- const availablePages = await detectAvailablePages(portalPath);
105
- // Skip writing if spec data has not changed (avoids git noise on every tool call)
106
- const newHash = computeSpecDataHash(allSpecs, availablePages);
107
- try {
108
- const existing = await readFile(outputPath, 'utf-8');
109
- if (extractEmbeddedHash(existing) === newHash) {
110
- return; // data unchanged — skip write
111
- }
112
- }
113
- catch {
114
- // file does not exist yet — proceed to write
115
- }
116
- const html = generateDashboardHtml(allSpecs, availablePages);
117
- const htmlWithHash = html.replace('<!DOCTYPE html>', `<!DOCTYPE html>\n<!-- ${HASH_MARKER} ${newHash} -->`);
118
- await writeFile(outputPath, htmlWithHash, 'utf-8');
119
- // SPEC-466: Per-spec reports are legacy and no longer regenerated.
120
- // Only the global index.html is generated. Remove this block entirely once
121
- // all clients have migrated (regeneratePerSpecReports stays as dead code for now).
96
+ void mergeSpecs(specs, fsSpecs);
122
97
  }
123
98
  catch {
124
99
  /* best-effort — never fail the caller */
@@ -2,6 +2,7 @@
2
2
  // Write a UniversalRule to disk using the appropriate host strategy.
3
3
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { assertEnglishOnlyArtifactText } from '../spec-language/english-only.js';
5
6
  // ---------------------------------------------------------------------------
6
7
  // Claude Code writer
7
8
  // ---------------------------------------------------------------------------
@@ -14,6 +15,7 @@ async function writeForClaudeCode(rule, projectPath) {
14
15
  await mkdir(rulesDir, { recursive: true });
15
16
  const filePath = join(rulesDir, `${rule.id}.md`);
16
17
  const content = rule.buildContent('claude-code');
18
+ assertEnglishOnlyArtifactText(`${rule.name}\n\n${rule.description}\n\n${content}`, 'rule');
17
19
  await writeFile(filePath, content, 'utf-8');
18
20
  return filePath;
19
21
  }
@@ -39,7 +41,9 @@ async function writeForCodex(rule, projectPath) {
39
41
  }
40
42
  const open = OPEN_MARKER(rule.id);
41
43
  const close = CLOSE_MARKER(rule.id);
42
- const block = `${open}\n${rule.buildContent('codex')}\n${close}`;
44
+ const content = rule.buildContent('codex');
45
+ assertEnglishOnlyArtifactText(`${rule.name}\n\n${rule.description}\n\n${content}`, 'rule');
46
+ const block = `${open}\n${content}\n${close}`;
43
47
  // Replace existing block or append
44
48
  const openIdx = existing.indexOf(open);
45
49
  const closeIdx = existing.indexOf(close);
@@ -74,7 +78,9 @@ async function writeForGemini(rule, projectPath) {
74
78
  }
75
79
  const open = OPEN_MARKER(rule.id);
76
80
  const close = CLOSE_MARKER(rule.id);
77
- const block = `${open}\n${rule.buildContent('gemini')}\n${close}`;
81
+ const content = rule.buildContent('gemini');
82
+ assertEnglishOnlyArtifactText(`${rule.name}\n\n${rule.description}\n\n${content}`, 'rule');
83
+ const block = `${open}\n${content}\n${close}`;
78
84
  const openIdx = existing.indexOf(open);
79
85
  const closeIdx = existing.indexOf(close);
80
86
  let updated;
@@ -1,31 +1,35 @@
1
1
  // engine/universal-rules/rules/planu-english-specs.ts — Universal rule: specs are written in English
2
2
  function buildBody() {
3
- return `# Planu Specs Must Be Written in English
3
+ return `# Planu Artifacts Must Be Written in English
4
4
 
5
5
  Auto-generated by \`init_project\`. Do not edit manually.
6
6
 
7
7
  ## Rule
8
8
 
9
- All generated spec content is written in English, regardless of the user's conversation language:
9
+ All generated Planu-owned artifacts are written in English, regardless of the user's conversation language:
10
10
 
11
11
  - \`spec.md\`
12
+ - Planu skills, including \`SKILL.md\` and host-adapted skill blocks
13
+ - Planu agent instructions, including \`AGENTS.md\`, \`CLAUDE.md\`, \`GEMINI.md\`, and host-specific sections
14
+ - Planu rules, including \`.claude/rules/*.md\`, Codex rule blocks, and Gemini conventions
12
15
  - inline \`## Technical\`, \`## Files\`, and \`## Progress\` sections
13
16
  - acceptance criteria, implementation notes, validation notes, and reconciliation notes
14
17
 
15
- User-facing chat may use the user's preferred language. The spec contract itself stays in English so every agent, tool, and CI gate can parse it consistently.
18
+ User-facing chat may use the user's preferred language. The persisted Planu contract stays in English so every agent, tool, and CI gate can parse it consistently.
16
19
 
17
20
  ## Hard Blocks
18
21
 
19
22
  - Do not create mixed-language acceptance criteria.
20
23
  - Do not translate BDD keywords.
24
+ - Do not generate Planu-owned skills, agents, or rules in Spanish, Portuguese, or mixed language.
21
25
  - Do not create standalone \`technical.md\`, \`progress.md\`, \`HU.md\`, \`FICHA-TECNICA.md\`, or \`PROGRESS.md\`.
22
26
  - Do not approve a spec that contains unresolved placeholders such as \`to be determined\`, \`TBD\`, \`TODO\`, or equivalent filler.
23
27
  `;
24
28
  }
25
29
  export const planuEnglishSpecsRule = {
26
30
  id: 'planu-english-specs',
27
- name: 'Planu English Specs',
28
- description: 'Requires Planu spec artifacts to be written in English.',
31
+ name: 'Planu English Artifacts',
32
+ description: 'Requires Planu spec, skill, agent, and rule artifacts to be written in English.',
29
33
  category: 'quality',
30
34
  applicableHosts: ['all'],
31
35
  defaultEnabled: true,
@@ -4,6 +4,7 @@
4
4
  import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { assertEnglishOnlyArtifactText } from '../../../engine/spec-language/english-only.js';
7
8
  const SKILLS_DIR = '.claude/skills';
8
9
  /** Canonical skill slugs managed by SPEC-588. */
9
10
  const SKILL_SLUGS = [
@@ -68,6 +69,7 @@ async function writeSkillFile(projectPath, slug, content) {
68
69
  if (existing === content) {
69
70
  return { path: dest, created: false };
70
71
  }
72
+ assertEnglishOnlyArtifactText(content, 'skill');
71
73
  await writeFile(dest, content, 'utf-8');
72
74
  return { path: dest, created: existing === null };
73
75
  }
@@ -12,6 +12,7 @@ import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
  import { detectCodexWorkspace } from './workspace-scope.js';
14
14
  import { buildRulesForHost } from '../../engine/host-rules-templates/index.js';
15
+ import { assertEnglishOnlyArtifactText } from '../../engine/spec-language/english-only.js';
15
16
  const CODEX_CONFIG_TOML = `# .openai/config.toml — Planu Codex workspace configuration
16
17
  # Auto-generated by Planu init_project. Safe to customize.
17
18
 
@@ -79,6 +80,9 @@ async function writeIfMissing(filePath, content) {
79
80
  // File exists — skip (idempotent)
80
81
  }
81
82
  catch {
83
+ if (filePath.endsWith('AGENTS.md')) {
84
+ assertEnglishOnlyArtifactText(content, 'agent');
85
+ }
82
86
  await writeFile(filePath, content, 'utf-8');
83
87
  }
84
88
  }
@@ -91,6 +95,7 @@ const PLANU_RULES_START_REGEX = /<!-- planu:rules:start[^>]* -->/;
91
95
  */
92
96
  async function injectRulesBlock(filePath, version) {
93
97
  const block = buildRulesForHost('codex', version).rules;
98
+ assertEnglishOnlyArtifactText(block, 'rule');
94
99
  let existing;
95
100
  try {
96
101
  existing = await readFile(filePath, 'utf-8');
@@ -5,6 +5,7 @@
5
5
  import { writeFile, readFile, mkdir, access } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import { buildRulesForHost } from '../../engine/host-rules-templates/index.js';
8
+ import { assertEnglishOnlyArtifactText } from '../../engine/spec-language/english-only.js';
8
9
  // ---------------------------------------------------------------------------
9
10
  // File content generators
10
11
  // ---------------------------------------------------------------------------
@@ -144,6 +145,8 @@ export async function scaffoldGeminiConfig(projectPath, planuVersion = '1.96.0')
144
145
  // Ensure parent directory exists
145
146
  const parentDir = join(projectPath, relPath.split('/').slice(0, -1).join('/'));
146
147
  await mkdir(parentDir, { recursive: true });
148
+ const artifactKind = relPath.includes('/skills/') ? 'skill' : 'agent';
149
+ assertEnglishOnlyArtifactText(content, artifactKind);
147
150
  await writeFile(fullPath, content, 'utf-8');
148
151
  filesCreated.push(relPath);
149
152
  }
@@ -166,6 +169,7 @@ const PLANU_RULES_START_REGEX = /<!-- planu:rules:start[^>]* -->/;
166
169
  */
167
170
  async function injectRulesBlock(filePath, version) {
168
171
  const block = buildRulesForHost('gemini', version).rules;
172
+ assertEnglishOnlyArtifactText(block, 'rule');
169
173
  let existing;
170
174
  try {
171
175
  existing = await readFile(filePath, 'utf-8');
@@ -1,6 +1,6 @@
1
1
  // storage/gaps-log.ts — SPEC-739: Hash-chained gaps log (JSONL per project)
2
2
  //
3
- // Layout: planu/research/gaps.jsonl
3
+ // Layout: ~/.planu/data/projects/<projectId>/research/gaps.jsonl
4
4
  //
5
5
  // Each entry is a JSON object on its own line. The `sha` field is a SHA-256 hash
6
6
  // of the entry's canonical JSON (excluding `sha`), chained via `prevSha`.
@@ -8,12 +8,12 @@ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { appendFile, readFile, mkdir } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { isNativeActive, fastAppendGapEntry, fastVerifyGapsChain } from '../engine/core-bridge.js';
11
+ import { projectDataDir } from './base-store.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Path helper
13
14
  // ---------------------------------------------------------------------------
14
- function gapsLogPath(_projectId) {
15
- // Per-spec: stored in planu/research/gaps.jsonl (project-level, not per-spec-id)
16
- return join('planu', 'research', 'gaps.jsonl');
15
+ function gapsLogPath(projectId) {
16
+ return join(projectDataDir(projectId), 'research', 'gaps.jsonl');
17
17
  }
18
18
  // ---------------------------------------------------------------------------
19
19
  // Node error type guard
@@ -1,17 +1,18 @@
1
1
  // storage/transition-log.ts — SPEC-723: Hash-chained transition log (JSONL per project)
2
2
  //
3
- // Layout: planu/data/projects/<projectId>/transition-log.jsonl
3
+ // Layout: ~/.planu/data/projects/<projectId>/transition-log.jsonl
4
4
  //
5
5
  // Each entry is a JSON object on its own line. The `sha` field is a SHA-256 hash
6
6
  // of the entry's canonical JSON (excluding `sha`), chained via `prevSha`.
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
8
  import { appendFile, readFile, mkdir } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
+ import { projectDataDir } from './base-store.js';
10
11
  // ---------------------------------------------------------------------------
11
12
  // Path helper
12
13
  // ---------------------------------------------------------------------------
13
14
  function transitionLogPath(projectId) {
14
- return join('planu', 'data', 'projects', projectId, 'transition-log.jsonl');
15
+ return join(projectDataDir(projectId), 'transition-log.jsonl');
15
16
  }
16
17
  // ---------------------------------------------------------------------------
17
18
  // Node error type guard
@@ -7,8 +7,8 @@ export function registerAuditSpecsDriftTool(server) {
7
7
  description: 'Run a two-tier drift audit over all done specs. ' +
8
8
  'Tier-1: deterministic checks (missing files, broken refs). ' +
9
9
  'Tier-2: LLM-based semantic drift for ambiguous cases (budget-capped). ' +
10
- 'Produces a prioritised P0/P1/P2 markdown report at planu/research/audit-full-<ts>.md ' +
11
- 'and appends a drift_review entry to planu/pending.json. ' +
10
+ 'Produces a prioritised P0/P1/P2 markdown report in external Planu project data ' +
11
+ 'and appends a drift_review entry to external pending state. ' +
12
12
  'Specs superseded by newer specs (per SPEC-746 graph) are excluded.',
13
13
  inputSchema: {
14
14
  projectPath: z.string().min(1).max(4096).describe('Absolute path to project root.'),
@@ -50,7 +50,7 @@ export function registerAuditSpecsDriftTool(server) {
50
50
  ``,
51
51
  p0 + p1 + p2 === 0
52
52
  ? '✓ No drift detected.'
53
- : `⚠ ${p0 + p1 + p2} total issues found. Review \`planu/pending.json\` for the drift_review entry.`,
53
+ : `⚠ ${p0 + p1 + p2} total issues found. Review external Planu pending state for the drift_review entry.`,
54
54
  ].join('\n'),
55
55
  },
56
56
  ],
@@ -3,6 +3,7 @@
3
3
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { detectHost } from '../engine/host-detection/detect-host.js';
6
+ import { validateEnglishOnlyArtifactText } from '../engine/spec-language/english-only.js';
6
7
  import { hashContent } from '../engine/universal-rules/user-edit-detector.js';
7
8
  import { hashProjectPath } from '../storage/base-store.js';
8
9
  import { appendAutopilotLogEntry } from '../storage/autopilot-log-store.js';
@@ -96,6 +97,26 @@ export async function handleCreateSkill(input) {
96
97
  const { projectPath, name, content, overwriteExisting } = input;
97
98
  const description = input.description ?? '';
98
99
  const host = resolveHost(input);
100
+ const languageValidation = validateEnglishOnlyArtifactText(`${name}\n\n${description}\n\n${content}`, 'skill');
101
+ if (!languageValidation.ok) {
102
+ return {
103
+ content: [
104
+ {
105
+ type: 'text',
106
+ text: `English-only skill gate blocked create_skill.\n\n` +
107
+ `${languageValidation.reason ?? 'Non-English prose detected.'}\n\n` +
108
+ 'Rewrite the skill name, description, and content in English before creating it.',
109
+ },
110
+ ],
111
+ isError: true,
112
+ structuredContent: {
113
+ error: 'SKILL_LANGUAGE_GATE_BLOCKED',
114
+ detectedLanguage: languageValidation.detectedLanguage,
115
+ signals: languageValidation.signals,
116
+ fixHint: 'Rewrite skill artifacts in English.',
117
+ },
118
+ };
119
+ }
99
120
  let result;
100
121
  try {
101
122
  switch (host) {
@@ -1,4 +1,5 @@
1
1
  import type { Spec, PostCreationSuggestion, ProjectKnowledge } from '../../types/index.js';
2
+ export declare function getAsyncAnalysisPath(projectPath: string, specId: string): string;
2
3
  /** Auto-setup git branch (non-blocking). Returns branch info or undefined. */
3
4
  export declare function setupGitBranch(projectId: string, specId: string): Promise<{
4
5
  branch: string;
@@ -13,7 +14,7 @@ export declare function generatePostCreationSuggestions(projectPath: string, des
13
14
  /**
14
15
  * SPEC-781: Run heavy analysis (contradiction detection, complexity advice) in a
15
16
  * fire-and-forget fashion after the spec has already been persisted synchronously.
16
- * Writes results to `planu/specs/SPEC-XXX/.analysis.json` and appends an
17
+ * Writes results to Planu's external project data dir and appends an
17
18
  * autopilot-log entry on completion. Never throws — fully best-effort.
18
19
  */
19
20
  export declare function runAutopilotAsync(specId: string, projectPath: string, _description: string): void;
@@ -10,9 +10,13 @@ import { emitAutopilotEvent } from '../../engine/autopilot/event-bus.js';
10
10
  import { incrementSpecCount } from '../../engine/autopilot/state-updater.js';
11
11
  import { writeFile, mkdir } from 'node:fs/promises';
12
12
  import { join, dirname } from 'node:path';
13
- import { hashProjectPath } from '../../storage/base-store.js';
13
+ import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
14
14
  import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
15
15
  const ASYNC_ANALYSIS_HOOK = 'create-spec-async-analysis';
16
+ export function getAsyncAnalysisPath(projectPath, specId) {
17
+ const projectId = hashProjectPath(projectPath);
18
+ return join(projectDataDir(projectId), 'analysis', `${specId}.json`);
19
+ }
16
20
  /** Auto-setup git branch (non-blocking). Returns branch info or undefined. */
17
21
  export async function setupGitBranch(projectId, specId) {
18
22
  const result = await tryAutoSetupGit(projectId, specId);
@@ -161,7 +165,7 @@ export async function generatePostCreationSuggestions(projectPath, description,
161
165
  /**
162
166
  * SPEC-781: Run heavy analysis (contradiction detection, complexity advice) in a
163
167
  * fire-and-forget fashion after the spec has already been persisted synchronously.
164
- * Writes results to `planu/specs/SPEC-XXX/.analysis.json` and appends an
168
+ * Writes results to Planu's external project data dir and appends an
165
169
  * autopilot-log entry on completion. Never throws — fully best-effort.
166
170
  */
167
171
  export function runAutopilotAsync(specId, projectPath, _description) {
@@ -169,20 +173,14 @@ export function runAutopilotAsync(specId, projectPath, _description) {
169
173
  const start = Date.now();
170
174
  void (async () => {
171
175
  try {
172
- const { glob } = await import('glob');
173
- const specFiles = await glob(join(projectPath, 'planu/specs', `${specId}-*`, 'spec.md'));
174
- const specDir = specFiles[0] !== undefined ? dirname(specFiles[0]) : null;
175
176
  const analysisResult = {
176
177
  specId,
177
178
  completedAt: new Date().toISOString(),
178
179
  pendingAnalysis: false,
179
180
  };
180
- // Write .analysis.json to spec directory
181
- if (specDir !== null) {
182
- const analysisPath = join(specDir, '.analysis.json');
183
- await mkdir(dirname(analysisPath), { recursive: true });
184
- await writeFile(analysisPath, JSON.stringify(analysisResult, null, 2), 'utf-8');
185
- }
181
+ const analysisPath = getAsyncAnalysisPath(projectPath, specId);
182
+ await mkdir(dirname(analysisPath), { recursive: true });
183
+ await writeFile(analysisPath, JSON.stringify(analysisResult, null, 2), 'utf-8');
186
184
  await appendAutopilotLogEntry(projectId, {
187
185
  specId,
188
186
  hookName: ASYNC_ANALYSIS_HOOK,
@@ -26,7 +26,7 @@ export async function buildSpecContext(params) {
26
26
  const knowledge = await knowledgeStore.getKnowledge(projectId);
27
27
  const existingSpecs = await specStore.listSpecs(projectId);
28
28
  // License: check active spec limit (exclude completed specs)
29
- const activeSpecs = existingSpecs.filter((s) => s.status !== 'done');
29
+ const activeSpecs = existingSpecs.filter((s) => s.status !== 'done' && s.status !== 'discarded');
30
30
  const tier = await getCurrentTier();
31
31
  const specLimit = checkLimits(tier, activeSpecs.length, 'maxActiveSpecs');
32
32
  /* v8 ignore start -- license limit requires paid tier test env */