@planu/cli 5.1.1 → 5.2.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 (60) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli/commands/doctor.d.ts +22 -0
  3. package/dist/cli/commands/doctor.js +176 -2
  4. package/dist/engine/autopilot/bootstrap.js +27 -0
  5. package/dist/engine/core-bridge.d.ts +28 -0
  6. package/dist/engine/core-bridge.js +67 -0
  7. package/dist/engine/drift-monitor.js +16 -18
  8. package/dist/engine/living-spec/hash-tracker.js +26 -28
  9. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  10. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  11. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  23. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  25. package/dist/engine/spec-language/english-only.d.ts +14 -0
  26. package/dist/engine/spec-language/english-only.js +58 -0
  27. package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
  28. package/dist/engine/spec-migrator/drift-detector.js +10 -12
  29. package/dist/engine/vector-store/tfidf.d.ts +13 -9
  30. package/dist/engine/vector-store/tfidf.js +26 -0
  31. package/dist/engine/worker-config-loader.d.ts +1 -1
  32. package/dist/engine/worker-config-loader.js +1 -11
  33. package/dist/engine/workers/schema.d.ts +0 -8
  34. package/dist/engine/workers/schema.js +0 -1
  35. package/dist/i18n/index.d.ts +18 -0
  36. package/dist/i18n/index.js +40 -1
  37. package/dist/storage/global-store.d.ts +9 -0
  38. package/dist/storage/global-store.js +23 -0
  39. package/dist/storage/semantic-index-store.d.ts +23 -0
  40. package/dist/storage/semantic-index-store.js +105 -0
  41. package/dist/storage/status-store/self-healing.js +15 -17
  42. package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
  43. package/dist/tools/create-spec.js +60 -15
  44. package/dist/tools/init-project/handler.js +78 -25
  45. package/dist/tools/learn.js +10 -8
  46. package/dist/tools/registry/auth.js +1 -11
  47. package/dist/tools/semantic-search-handler.js +5 -6
  48. package/dist/tools/status-handler.js +19 -6
  49. package/dist/tools/validation-loop-handler.js +16 -15
  50. package/dist/types/spec-language-translation.d.ts +18 -0
  51. package/dist/types/spec-language-translation.js +5 -0
  52. package/dist/types/spec-registry.d.ts +0 -2
  53. package/dist/types/status.d.ts +2 -0
  54. package/dist/types/vector-store.d.ts +18 -0
  55. package/dist/types/workers.d.ts +0 -3
  56. package/package.json +9 -9
  57. package/planu-native.json +8 -29
  58. package/planu-plugin.json +1 -1
  59. package/dist/engine/security/cve-refresher.d.ts +0 -12
  60. package/dist/engine/security/cve-refresher.js +0 -128
@@ -1,6 +1,8 @@
1
1
  // engine/vector-store/tfidf.ts — TF-IDF embedding engine.
2
2
  // SPEC-075 AC-01: Tokenize, compute TF-IDF, generate dense vectors.
3
3
  import { normalizeVector } from './similarity.js';
4
+ /** Bumped when the exported state shape changes; persisted stores rebuild on mismatch. */
5
+ export const TFIDF_SCHEMA_VERSION = 1;
4
6
  const DEFAULT_STOP_WORDS = new Set([
5
7
  // English
6
8
  'the',
@@ -238,6 +240,7 @@ export class TFIDFEngine {
238
240
  /** Export state for serialization. */
239
241
  exportState() {
240
242
  return {
243
+ schemaVersion: TFIDF_SCHEMA_VERSION,
241
244
  docFreq: [...this.docFreq.entries()],
242
245
  docCount: this.docCount,
243
246
  };
@@ -251,5 +254,28 @@ export class TFIDFEngine {
251
254
  this.docCount = state.docCount;
252
255
  this.vocabDirty = true;
253
256
  }
257
+ /**
258
+ * Remove a document's contribution to the corpus (SPEC-1345: incremental
259
+ * updates). `tokens` must be the unique token set the document contributed
260
+ * when it was added via `addDocument` — callers persisting the index keep
261
+ * this alongside the content hash so a changed document can be decremented
262
+ * before its new version is added back in.
263
+ */
264
+ removeDocument(tokens) {
265
+ for (const term of new Set(tokens)) {
266
+ const freq = this.docFreq.get(term);
267
+ if (freq === undefined) {
268
+ continue;
269
+ }
270
+ if (freq <= 1) {
271
+ this.docFreq.delete(term);
272
+ }
273
+ else {
274
+ this.docFreq.set(term, freq - 1);
275
+ }
276
+ }
277
+ this.docCount = Math.max(0, this.docCount - 1);
278
+ this.vocabDirty = true;
279
+ }
254
280
  }
255
281
  //# sourceMappingURL=tfidf.js.map
@@ -36,7 +36,7 @@ export declare class WorkerConfigLoader {
36
36
  *
37
37
  * Slug-to-env: 'test-gaps' → 'TEST_GAPS'
38
38
  * Full pattern: PLANU_WORKER_{WORKER_ENV}_{SETTING}
39
- * Known settings: ENABLED (bool), COOLDOWNMS (number), PRIORITY (number), TIER (string)
39
+ * Known settings: ENABLED (bool), COOLDOWNMS (number), PRIORITY (number)
40
40
  */
41
41
  parseEnvOverride(workerName: string): WorkerOverride;
42
42
  /**
@@ -103,9 +103,6 @@ export class WorkerConfigLoader {
103
103
  if (typeof raw.priority === 'number') {
104
104
  override.priority = raw.priority;
105
105
  }
106
- if (raw.tier === 'free' || raw.tier === 'pro') {
107
- override.tier = raw.tier;
108
- }
109
106
  return override;
110
107
  }
111
108
  /**
@@ -113,7 +110,7 @@ export class WorkerConfigLoader {
113
110
  *
114
111
  * Slug-to-env: 'test-gaps' → 'TEST_GAPS'
115
112
  * Full pattern: PLANU_WORKER_{WORKER_ENV}_{SETTING}
116
- * Known settings: ENABLED (bool), COOLDOWNMS (number), PRIORITY (number), TIER (string)
113
+ * Known settings: ENABLED (bool), COOLDOWNMS (number), PRIORITY (number)
117
114
  */
118
115
  parseEnvOverride(workerName) {
119
116
  const envName = workerName.toUpperCase().replace(/-/g, '_');
@@ -137,10 +134,6 @@ export class WorkerConfigLoader {
137
134
  override.priority = parsed;
138
135
  }
139
136
  }
140
- const tierRaw = process.env[`${prefix}TIER`];
141
- if (tierRaw === 'free' || tierRaw === 'pro') {
142
- override.tier = tierRaw;
143
- }
144
137
  return override;
145
138
  }
146
139
  /**
@@ -158,9 +151,6 @@ export class WorkerConfigLoader {
158
151
  if (override.priority !== undefined) {
159
152
  result.priority = override.priority;
160
153
  }
161
- if (override.tier !== undefined) {
162
- result.tier = override.tier;
163
- }
164
154
  return result;
165
155
  }
166
156
  }
@@ -26,10 +26,6 @@ export declare const WorkerDefinitionSchema: z.ZodObject<{
26
26
  priority: z.ZodNumber;
27
27
  cooldownMs: z.ZodNumber;
28
28
  enabled: z.ZodBoolean;
29
- tier: z.ZodEnum<{
30
- free: "free";
31
- pro: "pro";
32
- }>;
33
29
  }, z.core.$strip>;
34
30
  export declare const WorkerRegistrySchema: z.ZodObject<{
35
31
  workers: z.ZodArray<z.ZodObject<{
@@ -49,10 +45,6 @@ export declare const WorkerRegistrySchema: z.ZodObject<{
49
45
  priority: z.ZodNumber;
50
46
  cooldownMs: z.ZodNumber;
51
47
  enabled: z.ZodBoolean;
52
- tier: z.ZodEnum<{
53
- free: "free";
54
- pro: "pro";
55
- }>;
56
48
  }, z.core.$strip>>;
57
49
  }, z.core.$strip>;
58
50
  //# sourceMappingURL=schema.d.ts.map
@@ -33,7 +33,6 @@ export const WorkerDefinitionSchema = z.object({
33
33
  priority: z.number().int().min(1).max(10).describe('Priority 1-10, higher = more important'),
34
34
  cooldownMs: z.number().int().min(0).describe('Minimum milliseconds between executions'),
35
35
  enabled: z.boolean().describe('Whether the worker is active'),
36
- tier: z.enum(['free', 'pro']).describe('License tier: free | pro'),
37
36
  });
38
37
  export const WorkerRegistrySchema = z.object({
39
38
  workers: z.array(WorkerDefinitionSchema).max(1000).describe('List of worker definitions'),
@@ -1,4 +1,22 @@
1
1
  import type { SupportedLocale } from '../types/index.js';
2
+ /**
3
+ * SPEC-1347: Resolve the locale from environment variables only — no I/O.
4
+ * Priority: PLANU_LOCALE env var, then the LANG/LC_ALL prefix, then 'en'.
5
+ */
6
+ export declare function resolveLocaleFromEnv(env?: NodeJS.ProcessEnv): SupportedLocale;
7
+ /**
8
+ * SPEC-1347: Resolve the locale to use at startup. Priority order:
9
+ * (1) persisted global config value, (2) PLANU_LOCALE env var,
10
+ * (3) LANG/LC_ALL env prefix mapped to a supported locale, (4) 'en' fallback.
11
+ * Never throws — unreadable/missing config falls through to env detection.
12
+ */
13
+ export declare function resolveInitialLocale(env?: NodeJS.ProcessEnv): SupportedLocale;
14
+ /**
15
+ * SPEC-1347: Recompute and apply the initial locale. Called once at module load to replace
16
+ * the previously hardcoded 'en' default; also exported so tests (and any future explicit
17
+ * re-init after a config change) can force recomputation deterministically.
18
+ */
19
+ export declare function initLocale(env?: NodeJS.ProcessEnv): SupportedLocale;
2
20
  /**
3
21
  * Get the currently active locale.
4
22
  */
@@ -4,11 +4,50 @@
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { dirname, join } from 'node:path';
7
+ import { readPersistedGlobalConfigSync } from '../storage/global-store.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Internal state
9
10
  // ---------------------------------------------------------------------------
10
11
  const SUPPORTED_LOCALES = ['en', 'es', 'pt'];
11
- let currentLocale = 'en';
12
+ /**
13
+ * SPEC-1347: Resolve the locale from environment variables only — no I/O.
14
+ * Priority: PLANU_LOCALE env var, then the LANG/LC_ALL prefix, then 'en'.
15
+ */
16
+ export function resolveLocaleFromEnv(env = process.env) {
17
+ const explicit = env.PLANU_LOCALE;
18
+ if (explicit && isSupportedLocale(explicit)) {
19
+ return explicit;
20
+ }
21
+ const langRaw = env.LANG ?? env.LC_ALL;
22
+ const prefix = langRaw?.slice(0, 2).toLowerCase();
23
+ if (prefix && isSupportedLocale(prefix)) {
24
+ return prefix;
25
+ }
26
+ return 'en';
27
+ }
28
+ /**
29
+ * SPEC-1347: Resolve the locale to use at startup. Priority order:
30
+ * (1) persisted global config value, (2) PLANU_LOCALE env var,
31
+ * (3) LANG/LC_ALL env prefix mapped to a supported locale, (4) 'en' fallback.
32
+ * Never throws — unreadable/missing config falls through to env detection.
33
+ */
34
+ export function resolveInitialLocale(env = process.env) {
35
+ const persisted = readPersistedGlobalConfigSync();
36
+ if (persisted && isSupportedLocale(persisted.defaultLocale)) {
37
+ return persisted.defaultLocale;
38
+ }
39
+ return resolveLocaleFromEnv(env);
40
+ }
41
+ /**
42
+ * SPEC-1347: Recompute and apply the initial locale. Called once at module load to replace
43
+ * the previously hardcoded 'en' default; also exported so tests (and any future explicit
44
+ * re-init after a config change) can force recomputation deterministically.
45
+ */
46
+ export function initLocale(env = process.env) {
47
+ currentLocale = resolveInitialLocale(env);
48
+ return currentLocale;
49
+ }
50
+ let currentLocale = resolveInitialLocale();
12
51
  /** Locale -> flattened key->value map */
13
52
  const messageCache = new Map();
14
53
  // ---------------------------------------------------------------------------
@@ -15,6 +15,15 @@ export declare function updateGlobalConfig(updates: Partial<GlobalConfig>): Prom
15
15
  * Set the default locale globally.
16
16
  */
17
17
  export declare function setDefaultLocale(locale: SupportedLocale): Promise<GlobalConfig>;
18
+ /**
19
+ * SPEC-1347: Synchronous, best-effort read of the persisted global config.
20
+ *
21
+ * i18n needs a locale value before any async I/O can complete (module init time), so this
22
+ * mirrors `getGlobalConfig()` but reads the file synchronously and returns `null` — instead
23
+ * of defaults — when no config has ever been persisted. That distinction lets callers tell
24
+ * "never configured" (fall back to env detection) apart from "explicitly persisted".
25
+ */
26
+ export declare function readPersistedGlobalConfigSync(): GlobalConfig | null;
18
27
  /**
19
28
  * Set the default experience level globally.
20
29
  */
@@ -1,6 +1,8 @@
1
1
  import { readJson, writeJson, globalDataDir } from './base-store.js';
2
2
  import { withFileLock } from './file-mutex.js';
3
3
  import { applyLearnedLimit, markVerified } from '../engine/registry-extender.js';
4
+ import { readFileSync } from 'node:fs';
5
+ import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
4
6
  // --- file paths ---
5
7
  function configFile() {
6
8
  return `${globalDataDir()}/config.json`;
@@ -45,6 +47,27 @@ export async function updateGlobalConfig(updates) {
45
47
  export async function setDefaultLocale(locale) {
46
48
  return updateGlobalConfig({ defaultLocale: locale });
47
49
  }
50
+ /**
51
+ * SPEC-1347: Synchronous, best-effort read of the persisted global config.
52
+ *
53
+ * i18n needs a locale value before any async I/O can complete (module init time), so this
54
+ * mirrors `getGlobalConfig()` but reads the file synchronously and returns `null` — instead
55
+ * of defaults — when no config has ever been persisted. That distinction lets callers tell
56
+ * "never configured" (fall back to env detection) apart from "explicitly persisted".
57
+ */
58
+ export function readPersistedGlobalConfigSync() {
59
+ try {
60
+ const raw = readFileSync(configFile(), 'utf-8');
61
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
62
+ }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT') {
65
+ return null;
66
+ }
67
+ reportClassifiedDegradation('GLOBAL_CONFIG_SYNC_READ_FAILURE', error);
68
+ return null;
69
+ }
70
+ }
48
71
  /**
49
72
  * Set the default experience level globally.
50
73
  */
@@ -0,0 +1,23 @@
1
+ import { TFIDFEngine } from '../engine/vector-store/tfidf.js';
2
+ import type { SemanticIndexDoc } from '../types/index.js';
3
+ /**
4
+ * Persists a TF-IDF index for one logical corpus (e.g. all specs in a search
5
+ * scope, or a pattern type) and reconciles it incrementally against the
6
+ * current documents each time it is loaded.
7
+ */
8
+ export declare class SemanticIndexStore {
9
+ private readonly filePath;
10
+ constructor(projectId: string, indexKey: string);
11
+ /**
12
+ * Load the persisted index and reconcile it with `docs`.
13
+ * - Unchanged documents are never re-embedded (`addDocument` is not called for them).
14
+ * - Changed or new documents are individually re-embedded and the index is re-persisted.
15
+ * - A missing, corrupted, schema-mismatched, or doc-removed index triggers a
16
+ * transparent full rebuild so search always returns results.
17
+ */
18
+ loadOrBuild(docs: SemanticIndexDoc[]): Promise<TFIDFEngine>;
19
+ /** Full rebuild: fresh engine, `addDocument` for every doc, persist a new snapshot. */
20
+ private rebuildFull;
21
+ private persist;
22
+ }
23
+ //# sourceMappingURL=semantic-index-store.d.ts.map
@@ -0,0 +1,105 @@
1
+ // storage/semantic-index-store.ts — Persisted TF-IDF index with incremental updates (SPEC-1345).
2
+ // Avoids full corpus rebuilds on every semantic_search / learn call: persists the TF-IDF
3
+ // engine state plus a per-document content-hash manifest, and only re-embeds documents
4
+ // whose content changed since the last snapshot. A missing, corrupted, schema-mismatched,
5
+ // or doc-removed index self-heals via a transparent full rebuild — never errors out.
6
+ import { createHash } from 'node:crypto';
7
+ import { readJson, writeJson, projectDataDir } from './base-store.js';
8
+ import { withFileLock } from './file-mutex.js';
9
+ import { TFIDFEngine, TFIDF_SCHEMA_VERSION, tokenize } from '../engine/vector-store/tfidf.js';
10
+ function hashContent(content) {
11
+ return createHash('sha256').update(content).digest('hex');
12
+ }
13
+ function uniqueTokens(content) {
14
+ return [...new Set(tokenize(content))];
15
+ }
16
+ function isValidPersistedIndex(value) {
17
+ if (!value || typeof value !== 'object') {
18
+ return false;
19
+ }
20
+ const v = value;
21
+ return (v.schemaVersion === TFIDF_SCHEMA_VERSION &&
22
+ typeof v.docHashes === 'object' &&
23
+ v.docHashes !== null &&
24
+ typeof v.docTokens === 'object' &&
25
+ v.docTokens !== null &&
26
+ typeof v.tfidfState === 'object' &&
27
+ v.tfidfState !== null);
28
+ }
29
+ /**
30
+ * Persists a TF-IDF index for one logical corpus (e.g. all specs in a search
31
+ * scope, or a pattern type) and reconciles it incrementally against the
32
+ * current documents each time it is loaded.
33
+ */
34
+ export class SemanticIndexStore {
35
+ filePath;
36
+ constructor(projectId, indexKey) {
37
+ this.filePath = `${projectDataDir(projectId)}/semantic-index-${indexKey}.json`;
38
+ }
39
+ /**
40
+ * Load the persisted index and reconcile it with `docs`.
41
+ * - Unchanged documents are never re-embedded (`addDocument` is not called for them).
42
+ * - Changed or new documents are individually re-embedded and the index is re-persisted.
43
+ * - A missing, corrupted, schema-mismatched, or doc-removed index triggers a
44
+ * transparent full rebuild so search always returns results.
45
+ */
46
+ async loadOrBuild(docs) {
47
+ const persisted = await readJson(this.filePath, null);
48
+ if (!isValidPersistedIndex(persisted)) {
49
+ return this.rebuildFull(docs);
50
+ }
51
+ const currentIds = new Set(docs.map((d) => d.id));
52
+ const hadRemoval = Object.keys(persisted.docHashes).some((id) => !currentIds.has(id));
53
+ if (hadRemoval) {
54
+ return this.rebuildFull(docs);
55
+ }
56
+ const engine = new TFIDFEngine();
57
+ engine.importState(persisted.tfidfState);
58
+ const docHashes = { ...persisted.docHashes };
59
+ const docTokens = { ...persisted.docTokens };
60
+ let changed = false;
61
+ for (const doc of docs) {
62
+ const hash = hashContent(doc.content);
63
+ if (docHashes[doc.id] === hash) {
64
+ continue;
65
+ }
66
+ const oldTokens = docTokens[doc.id];
67
+ if (oldTokens) {
68
+ engine.removeDocument(oldTokens);
69
+ }
70
+ engine.addDocument(doc.content);
71
+ docHashes[doc.id] = hash;
72
+ docTokens[doc.id] = uniqueTokens(doc.content);
73
+ changed = true;
74
+ }
75
+ if (changed) {
76
+ await this.persist(engine, docHashes, docTokens);
77
+ }
78
+ return engine;
79
+ }
80
+ /** Full rebuild: fresh engine, `addDocument` for every doc, persist a new snapshot. */
81
+ async rebuildFull(docs) {
82
+ const engine = new TFIDFEngine();
83
+ const docHashes = {};
84
+ const docTokens = {};
85
+ for (const doc of docs) {
86
+ engine.addDocument(doc.content);
87
+ docHashes[doc.id] = hashContent(doc.content);
88
+ docTokens[doc.id] = uniqueTokens(doc.content);
89
+ }
90
+ await this.persist(engine, docHashes, docTokens);
91
+ return engine;
92
+ }
93
+ async persist(engine, docHashes, docTokens) {
94
+ const state = {
95
+ schemaVersion: TFIDF_SCHEMA_VERSION,
96
+ docHashes,
97
+ docTokens,
98
+ tfidfState: engine.exportState(),
99
+ };
100
+ await withFileLock(this.filePath, async () => {
101
+ await writeJson(this.filePath, state);
102
+ });
103
+ }
104
+ }
105
+ //# sourceMappingURL=semantic-index-store.js.map
@@ -110,7 +110,7 @@ export async function quarantineCorruptStatus(statusPath, projectPath) {
110
110
  }
111
111
  return { quarantinedAt: new Date().toISOString(), quarantinePath };
112
112
  }
113
- import { isNativeActive, fastScanSpecs } from '../../engine/core-bridge.js';
113
+ import { fastScanSpecsAsync } from '../../engine/core-bridge.js';
114
114
  /**
115
115
  * Rebuild a fresh status.json by reading all spec.md frontmatters.
116
116
  * Returns the new ProjectStatus object (not written to disk — caller decides).
@@ -120,23 +120,21 @@ export async function rebuildStatusFromFrontmatters(params) {
120
120
  const byStatus = {};
121
121
  const byType = {};
122
122
  let totalSpecs = 0;
123
- if (isNativeActive()) {
124
- const briefs = fastScanSpecs(projectPath);
125
- if (briefs) {
126
- for (const b of briefs) {
127
- totalSpecs++;
128
- byStatus[b.status] = (byStatus[b.status] ?? 0) + 1;
129
- byType[b.specType] = (byType[b.specType] ?? 0) + 1;
130
- }
131
- return {
132
- updatedAt: new Date().toISOString(),
133
- totalSpecs,
134
- byStatus,
135
- byType,
136
- recentChanges: [],
137
- version: PLANU_VERSION,
138
- };
123
+ const { value: briefs } = await fastScanSpecsAsync(projectPath);
124
+ if (briefs) {
125
+ for (const b of briefs) {
126
+ totalSpecs++;
127
+ byStatus[b.status] = (byStatus[b.status] ?? 0) + 1;
128
+ byType[b.specType] = (byType[b.specType] ?? 0) + 1;
139
129
  }
130
+ return {
131
+ updatedAt: new Date().toISOString(),
132
+ totalSpecs,
133
+ byStatus,
134
+ byType,
135
+ recentChanges: [],
136
+ version: PLANU_VERSION,
137
+ };
140
138
  }
141
139
  const specsDir = join(projectPath, 'planu', 'specs');
142
140
  const byStatusResult = {};
@@ -61,7 +61,11 @@ const CAPABILITY_SIGNALS = {
61
61
  /\b(?:email\s+address|phone\s+number|social\s+security\s+number)\b/i,
62
62
  ],
63
63
  events: [
64
- /\b(?:process|handle|publish|consume)\w*(?:\s+[a-z0-9_-]+){0,3}\s+event\b/i,
64
+ // Negative lookahead excludes runtime compounds like "event-loop"/"event-driven": a
65
+ // hyphen right after "event" means the match is a prefix of a different word, not the
66
+ // noun "event" itself (SPEC-1262 — handler identifiers must not cross clause boundaries
67
+ // into unrelated event-loop prose).
68
+ /\b(?:process|handle|publish|consume)\w*(?:\s+[a-z0-9_-]+){0,3}\s+event\b(?!-)/i,
65
69
  /\b(?:event\s+(?:schema|contract|producer|consumer|handler|stream)|message\s+(?:broker|queue|consumer|producer))\b/i,
66
70
  /\b(?:kafka|rabbitmq|pubsub|nats|eventbridge|sqs|sns|dead[ -]?letter\s+queue|dlq)\b/i,
67
71
  /\b(?:publish|produce|consume)(?:es|d|r|rs|ing)?\s+(?:an?\s+)?(?:event|message)\b/i,
@@ -17,7 +17,7 @@ import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-techni
17
17
  import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
18
18
  import { buildCanonicalUnifiedSpecContent, validateUnifiedSpecCandidate, } from '../engine/spec-format/unified-spec-builder.js';
19
19
  import { buildImplementationContractSection } from '../engine/implementation-contract/index.js';
20
- import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
20
+ import { resolveEnglishOnlySpecGate } from '../engine/spec-language/english-only.js';
21
21
  import { FallbackGenerator } from '../engine/spec-generator/index.js';
22
22
  import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
23
23
  import { trackCost } from '../engine/cost-tracking/operation-tracker.js';
@@ -880,28 +880,50 @@ function validateCreateSpecEnums(input) {
880
880
  }
881
881
  const DESCRIPTION_MAX_CHARS = 10_000;
882
882
  const DESCRIPTION_EXCEED_MSG = 'Description exceeds 10000 chars. Create with a summary (<3k) and use reconcile_spec to add the rest.';
883
- function checkEnglishOnlyInput(params) {
884
- const validation = validateEnglishOnlySpecText(`${params.title}\n\n${params.description}`);
885
- if (validation.ok) {
886
- return null;
887
- }
883
+ /** SPEC-1342: Terminal rejection — only reached after one translation clarification round failed. */
884
+ function buildEnglishGateRejectionResult(gate) {
888
885
  return {
889
886
  content: [
890
887
  {
891
888
  type: 'text',
892
- text: `English-only spec gate blocked create_spec.\n\n${validation.reason ?? 'Non-English prose detected.'}\n\n` +
889
+ text: `English-only spec gate blocked create_spec.\n\n${gate.reason ?? 'Non-English prose detected.'}\n\n` +
893
890
  'Rewrite the title and description in English, then call create_spec again. User-facing responses may be localized, but spec.md must be English.',
894
891
  },
895
892
  ],
896
893
  isError: true,
897
894
  structuredContent: {
898
895
  error: 'SPEC_LANGUAGE_GATE_BLOCKED',
899
- detectedLanguage: validation.detectedLanguage,
900
- signals: validation.signals,
896
+ detectedLanguage: gate.originalLanguage,
901
897
  fixHint: 'Rewrite title and description in English before creating the spec.',
902
898
  },
903
899
  };
904
900
  }
901
+ /**
902
+ * SPEC-1342: Apply an already-resolved English-only gate outcome. Returns:
903
+ * - `{ earlyReturn: null, englishTitle?, englishDescription? }` when the caller may proceed
904
+ * (input was already English, or was successfully translated).
905
+ * - `{ earlyReturn: ToolResult }` when the caller must return early (translation clarification
906
+ * question, or terminal rejection after one failed clarification round).
907
+ */
908
+ function applyEnglishOnlyGate(gate) {
909
+ switch (gate.action) {
910
+ case 'ok':
911
+ return { earlyReturn: null };
912
+ case 'translated':
913
+ return {
914
+ earlyReturn: null,
915
+ englishTitle: gate.englishTitle,
916
+ englishDescription: gate.englishDescription,
917
+ };
918
+ case 'reject':
919
+ return { earlyReturn: buildEnglishGateRejectionResult(gate) };
920
+ case 'ask':
921
+ return {
922
+ earlyReturn: interactiveResult(gate.questions ?? [], 'The spec title/description are not in English. Provide the English translation ' +
923
+ '(e.g. via AskUserQuestion), then retry create_spec with clarificationAnswers.', 'universal'),
924
+ };
925
+ }
926
+ }
905
927
  function quotePosixShellArgument(value) {
906
928
  return `'${value.replaceAll("'", `'"'"'`)}'`;
907
929
  }
@@ -1209,15 +1231,30 @@ export async function handleCreateSpec(inputParams, server) {
1209
1231
  inputParams.specId) {
1210
1232
  return handleAgentTeamSynthesis(inputParams.specId, resolvedPath, inputParams.agentTeamFindings);
1211
1233
  }
1212
- const languageGate = checkEnglishOnlyInput(resolvedInputParams);
1213
- if (languageGate) {
1214
- return languageGate;
1215
- }
1216
- // SPEC-584: Gate check — block if pending clarification token exists and no answers provided
1234
+ // SPEC-584: Gate check — block if pending clarification token exists and no answers provided.
1235
+ // Runs before the English-only gate so a second call with no translation answer hits the
1236
+ // existing well-formed "must call AskUserQuestion" mandate instead of re-asking forever.
1217
1237
  const gateError = await runClarificationGate(resolvedPath, 'create_spec', inputParams.clarificationAnswers);
1218
1238
  if (gateError) {
1219
1239
  return gateError;
1220
1240
  }
1241
+ // SPEC-1342: English-only gate — offers a translation clarification round instead of
1242
+ // hard-rejecting non-English title/description.
1243
+ const languageGateResult = resolveEnglishOnlySpecGate(resolvedInputParams.title, resolvedInputParams.description, resolvedInputParams.clarificationAnswers);
1244
+ const languageGate = applyEnglishOnlyGate(languageGateResult);
1245
+ if (languageGate.earlyReturn) {
1246
+ if (languageGateResult.action === 'ask') {
1247
+ await persistClarificationToken(languageGate.earlyReturn, hashProjectPath(resolvedPath), 'create_spec');
1248
+ }
1249
+ return languageGate.earlyReturn;
1250
+ }
1251
+ if (languageGate.englishTitle !== undefined) {
1252
+ resolvedInputParams.title = languageGate.englishTitle;
1253
+ }
1254
+ if (languageGate.englishDescription !== undefined) {
1255
+ resolvedInputParams.description = languageGate.englishDescription;
1256
+ }
1257
+ const originalLanguage = languageGateResult.originalLanguage;
1221
1258
  const idempotencyKey = computeIdempotencyKey(inputParams.title, resolvedPath, inputParams.idempotencyKey);
1222
1259
  const cutoff = Date.now() - IDEMPOTENCY_MATCH_WINDOW_MS;
1223
1260
  const storeOnlyCandidate = await findStoreByIdempotencyKey(hashProjectPath(resolvedPath), idempotencyKey, cutoff);
@@ -1571,7 +1608,15 @@ export async function handleCreateSpec(inputParams, server) {
1571
1608
  if (!claimLifecycle.committed) {
1572
1609
  throw new Error('create_spec critical path completed without a durable commit');
1573
1610
  }
1574
- return finishRecoveredOperation(operationJournal, operationKey, criticalResult.value.data.committedResult);
1611
+ const committedResult = criticalResult.value.data.committedResult;
1612
+ // SPEC-1342: surface the original (pre-translation) language on the created spec's response.
1613
+ if (originalLanguage && !committedResult.isError) {
1614
+ committedResult.structuredContent = {
1615
+ ...committedResult.structuredContent,
1616
+ originalLanguage,
1617
+ };
1618
+ }
1619
+ return finishRecoveredOperation(operationJournal, operationKey, committedResult);
1575
1620
  }
1576
1621
  catch (error) {
1577
1622
  const message = error instanceof Error ? error.message : String(error);