@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.
- package/CHANGELOG.md +17 -1
- package/dist/cli/commands/spec.js +20 -1
- package/dist/cli/commands/status.js +18 -1
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/ai-integration/agents-md/generator.js +4 -1
- package/dist/engine/ai-integration/cline/clinerules-generator.js +7 -2
- package/dist/engine/ai-integration/codex/agents-md-generator.js +2 -0
- package/dist/engine/ai-integration/codex/hooks-generator.js +1 -0
- package/dist/engine/ai-integration/cursor/cursorrules-generator.js +7 -2
- package/dist/engine/ai-integration/gemini/settings-generator.js +4 -1
- package/dist/engine/ai-integration/kiro/hooks-generator.js +2 -1
- package/dist/engine/ai-integration/windsurf/windsurfrules-generator.js +7 -2
- package/dist/engine/autopilot/action-registry.js +5 -14
- package/dist/engine/autopilot/state-updater.js +13 -10
- package/dist/engine/cascade-hooks/hooks/git-auto-stage.hook.js +3 -0
- package/dist/engine/cascade-hooks/hooks/html-regen.hook.js +1 -1
- package/dist/engine/cascade-hooks/hooks/status-json.hook.js +1 -1
- package/dist/engine/cascade-hooks/state-drift-detector.d.ts +1 -1
- package/dist/engine/cascade-hooks/state-drift-detector.js +15 -12
- package/dist/engine/git/planu-autocommit.d.ts +1 -0
- package/dist/engine/git/planu-autocommit.js +6 -0
- package/dist/engine/git-hook-injector.js +3 -3
- package/dist/engine/handoff-artifacts/io.js +3 -2
- package/dist/engine/handoff-packager.js +2 -1
- package/dist/engine/hooks/full-spectrum-generator.d.ts +2 -1
- package/dist/engine/hooks/full-spectrum-generator.js +5 -3
- package/dist/engine/marketplace-fetcher/anthropic-source.js +2 -0
- package/dist/engine/opencode/config-scaffold.js +4 -0
- package/dist/engine/release/postmortem-generator.d.ts +1 -1
- package/dist/engine/release/postmortem-generator.js +3 -2
- package/dist/engine/rules-generator/index.js +2 -0
- package/dist/engine/rules-reconciler.js +2 -0
- package/dist/engine/safety/cross-process-lock.js +2 -2
- package/dist/engine/session/checkpoint-writer.js +0 -1
- package/dist/engine/session-context-generator.js +4 -1
- package/dist/engine/skill-bootstrap/skill-writer.js +2 -0
- package/dist/engine/skill-generation/multi-agent-writer.js +2 -0
- package/dist/engine/spec-audit/index.js +2 -2
- package/dist/engine/spec-audit/report-writer.d.ts +1 -1
- package/dist/engine/spec-audit/report-writer.js +5 -4
- package/dist/engine/spec-language/english-only.d.ts +8 -7
- package/dist/engine/spec-language/english-only.js +27 -3
- package/dist/engine/spec-migrator/index.d.ts +1 -0
- package/dist/engine/spec-migrator/index.js +1 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +9 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.js +62 -0
- package/dist/engine/spec-migrator/planu-root-cleaner.js +18 -94
- package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +6 -0
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +199 -0
- package/dist/engine/spec-summary-html.d.ts +5 -5
- package/dist/engine/spec-summary-html.js +7 -32
- package/dist/engine/universal-rules/host-writer.js +8 -2
- package/dist/engine/universal-rules/rules/planu-english-specs.js +9 -5
- package/dist/hosts/claude-code/ux/skills-writer.js +2 -0
- package/dist/hosts/codex/config-scaffold.js +5 -0
- package/dist/hosts/gemini/config-scaffold.js +4 -0
- package/dist/storage/gaps-log.js +4 -4
- package/dist/storage/transition-log.js +3 -2
- package/dist/tools/audit-specs-drift.js +3 -3
- package/dist/tools/create-skill.js +21 -0
- package/dist/tools/create-spec/post-creation.d.ts +2 -1
- package/dist/tools/create-spec/post-creation.js +9 -11
- package/dist/tools/create-spec/spec-builder.js +1 -1
- package/dist/tools/create-spec.js +42 -18
- package/dist/tools/flag-spec-gap.d.ts +1 -1
- package/dist/tools/flag-spec-gap.js +1 -1
- package/dist/tools/generate-dashboard.js +3 -3
- package/dist/tools/housekeeping-sweep.js +16 -0
- package/dist/tools/init-project/agents-md-writer.js +2 -0
- package/dist/tools/init-project/conventions-writer.js +2 -0
- package/dist/tools/init-project/find-skills-writer.js +2 -0
- package/dist/tools/init-project/git-setup.js +11 -2
- package/dist/tools/init-project/handler.js +1 -27
- package/dist/tools/init-project/helpers.js +5 -0
- package/dist/tools/init-project/migration-runner.js +8 -0
- package/dist/tools/init-project/per-client-files-writer.js +2 -0
- package/dist/tools/init-project/planu-workflow-generator.js +2 -0
- package/dist/tools/init-project/rules-generator.js +7 -1
- package/dist/tools/init-project/rules-writer.js +3 -0
- package/dist/tools/init-project/skills-multi-teammate-review-writer.js +2 -0
- package/dist/tools/init-project/skills-writer.js +2 -0
- package/dist/tools/license-gate.d.ts +1 -0
- package/dist/tools/license-gate.js +5 -1
- package/dist/tools/list-specs.js +13 -0
- package/dist/tools/register-sdd-tools.d.ts +1 -1
- package/dist/tools/register-sdd-tools.js +1 -0
- package/dist/tools/register-spec-tools/core-spec-tools.js +16 -0
- package/dist/tools/spec-lock-handler.js +1 -1
- package/dist/tools/tool-registry/group-misc.js +4 -4
- package/dist/tools/update-status/batch.d.ts +3 -0
- package/dist/tools/update-status/batch.js +96 -0
- package/dist/tools/update-status/dod-gates.js +1 -1
- package/dist/tools/update-status/file-sync.js +3 -1
- package/dist/tools/update-status/index.js +15 -2
- package/dist/tools/update-status-actions.js +2 -6
- package/dist/tools/validate.js +27 -0
- package/dist/tools/workspace-dashboard-handler.js +6 -9
- package/dist/types/git.d.ts +1 -1
- package/dist/types/spec-format.d.ts +26 -0
- package/dist/types/spec-language.d.ts +8 -0
- package/dist/types/spec-language.js +2 -0
- 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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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');
|
package/dist/storage/gaps-log.js
CHANGED
|
@@ -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(
|
|
15
|
-
|
|
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(
|
|
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
|
|
11
|
-
'and appends a drift_review entry to
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 */
|