@planu/cli 5.0.0 → 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 (112) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/dist/cli/commands/doctor.d.ts +22 -0
  3. package/dist/cli/commands/doctor.js +176 -2
  4. package/dist/cli/commands/spec.js +10 -1
  5. package/dist/core/spec-validator.js +32 -18
  6. package/dist/engine/autopilot/bootstrap.js +27 -0
  7. package/dist/engine/core-bridge.d.ts +28 -0
  8. package/dist/engine/core-bridge.js +67 -0
  9. package/dist/engine/drift-monitor.js +16 -18
  10. package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
  11. package/dist/engine/evidence-gates/artifact-reader.js +59 -2
  12. package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
  13. package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
  14. package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
  15. package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
  16. package/dist/engine/execution/operation-journal.js +10 -4
  17. package/dist/engine/living-spec/hash-tracker.js +26 -28
  18. package/dist/engine/minimality/policy-loader.js +247 -6
  19. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
  21. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
  23. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
  25. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
  27. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  28. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
  29. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  30. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
  31. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  32. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
  33. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  34. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
  35. package/dist/engine/reverse-engineer/api-detector.js +2 -13
  36. package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
  37. package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
  38. package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
  39. package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
  40. package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
  41. package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
  42. package/dist/engine/spec-format/acceptance-criteria.js +13 -12
  43. package/dist/engine/spec-format/text-fences.js +20 -2
  44. package/dist/engine/spec-language/english-only.d.ts +14 -0
  45. package/dist/engine/spec-language/english-only.js +58 -0
  46. package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
  47. package/dist/engine/spec-migrator/drift-detector.js +10 -12
  48. package/dist/engine/spec-state-syncer.js +1 -1
  49. package/dist/engine/timing/budget.js +5 -1
  50. package/dist/engine/vector-store/tfidf.d.ts +13 -9
  51. package/dist/engine/vector-store/tfidf.js +26 -0
  52. package/dist/engine/worker-config-loader.d.ts +1 -1
  53. package/dist/engine/worker-config-loader.js +1 -11
  54. package/dist/engine/workers/schema.d.ts +0 -8
  55. package/dist/engine/workers/schema.js +0 -1
  56. package/dist/i18n/index.d.ts +18 -0
  57. package/dist/i18n/index.js +40 -1
  58. package/dist/server/routes/specs.js +7 -5
  59. package/dist/storage/global-store.d.ts +9 -0
  60. package/dist/storage/global-store.js +23 -0
  61. package/dist/storage/semantic-index-store.d.ts +23 -0
  62. package/dist/storage/semantic-index-store.js +105 -0
  63. package/dist/storage/status-store/self-healing.js +15 -17
  64. package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
  65. package/dist/tools/challenge-spec-helpers.d.ts +10 -1
  66. package/dist/tools/challenge-spec-helpers.js +63 -22
  67. package/dist/tools/challenge-spec.js +18 -3
  68. package/dist/tools/check-readiness.js +37 -13
  69. package/dist/tools/create-spec/spec-builder.d.ts +7 -0
  70. package/dist/tools/create-spec/spec-builder.js +19 -4
  71. package/dist/tools/create-spec.js +216 -101
  72. package/dist/tools/init-project/handler.js +78 -25
  73. package/dist/tools/learn.js +10 -8
  74. package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
  75. package/dist/tools/registry/auth.js +1 -11
  76. package/dist/tools/semantic-search-handler.js +5 -6
  77. package/dist/tools/status-handler.js +19 -6
  78. package/dist/tools/sync-spec-state-handler.js +49 -1
  79. package/dist/tools/update-status/batch.d.ts +6 -2
  80. package/dist/tools/update-status/batch.js +58 -1
  81. package/dist/tools/update-status/dod-gates.d.ts +16 -1
  82. package/dist/tools/update-status/dod-gates.js +191 -1
  83. package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
  84. package/dist/tools/update-status/done-receipt-verifier.js +37 -2
  85. package/dist/tools/update-status/evidence-gate.d.ts +4 -0
  86. package/dist/tools/update-status/evidence-gate.js +67 -2
  87. package/dist/tools/update-status/file-sync.d.ts +2 -2
  88. package/dist/tools/update-status/index.d.ts +23 -1
  89. package/dist/tools/update-status/index.js +201 -24
  90. package/dist/tools/update-status/transition-guard.js +13 -1
  91. package/dist/tools/validation-loop-handler.js +16 -15
  92. package/dist/tools/workspace-dashboard-handler.js +38 -0
  93. package/dist/types/evidence-autofill.d.ts +34 -0
  94. package/dist/types/evidence-autofill.js +2 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/index.js +1 -0
  97. package/dist/types/spec/core.d.ts +6 -0
  98. package/dist/types/spec/inputs.d.ts +7 -2
  99. package/dist/types/spec-format.d.ts +1 -1
  100. package/dist/types/spec-language-translation.d.ts +18 -0
  101. package/dist/types/spec-language-translation.js +5 -0
  102. package/dist/types/spec-registry.d.ts +0 -2
  103. package/dist/types/status.d.ts +2 -0
  104. package/dist/types/transition-log.d.ts +1 -1
  105. package/dist/types/validation.d.ts +8 -2
  106. package/dist/types/vector-store.d.ts +18 -0
  107. package/dist/types/workers.d.ts +0 -3
  108. package/package.json +11 -10
  109. package/planu-native.json +8 -29
  110. package/planu-plugin.json +1 -1
  111. package/dist/engine/security/cve-refresher.d.ts +0 -12
  112. package/dist/engine/security/cve-refresher.js +0 -128
@@ -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
  // ---------------------------------------------------------------------------
@@ -26,9 +26,7 @@ const VALID_STATUSES = [
26
26
  'discarded',
27
27
  ];
28
28
  function containsReconciliationAuthorityFields(body) {
29
- return ('reconciliationRequestId' in body ||
30
- 'expectedImplementingTransitionId' in body ||
31
- 'implementationReviewDigest' in body);
29
+ return 'reconciliationRequestId' in body || 'expectedImplementingTransitionId' in body;
32
30
  }
33
31
  /** Resolve projectPath from the request Host header or fallback to process.cwd(). */
34
32
  function getProjectPath(ctx) {
@@ -192,10 +190,13 @@ export async function handleUpdateSpecStatus(ctx) {
192
190
  sendBadRequest(ctx.res, 'Request body must be a JSON object');
193
191
  return;
194
192
  }
195
- const { status } = body;
193
+ const { status, implementationReviewDigest } = body;
196
194
  if (containsReconciliationAuthorityFields(body)) {
197
195
  ctx.res.writeHead(403, { 'Content-Type': 'application/json' });
198
- ctx.res.end(JSON.stringify({ error: 'Reconciliation is available only through local MCP.' }));
196
+ ctx.res.end(JSON.stringify({
197
+ error: 'Reconciliation is available only through local MCP.',
198
+ code: 'RECONCILIATION_UNTRUSTED_SURFACE',
199
+ }));
199
200
  return;
200
201
  }
201
202
  if (typeof status !== 'string' || !VALID_STATUSES.includes(status)) {
@@ -219,6 +220,7 @@ export async function handleUpdateSpecStatus(ctx) {
219
220
  projectPath,
220
221
  trigger: 'http',
221
222
  actor: 'http-api',
223
+ implementationReviewDigest: implementationReviewDigest,
222
224
  });
223
225
  if (result.isError) {
224
226
  const reason = result.content[0]?.type === 'text' ? result.content[0].text : 'Gate blocked transition';
@@ -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,
@@ -1,5 +1,14 @@
1
1
  import type { FailureScenario, ConcurrencyAnalysis, RiskLevel, Spec, ProjectKnowledge } from '../types/index.js';
2
- export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysis;
2
+ /**
3
+ * Result of generateConcurrencyAnalysis. `suppressedCount` tracks candidates
4
+ * that matched a domain keyword (counter/status/file/session/cache) but were
5
+ * dropped because the spec content contains no corroborating concurrency
6
+ * context (e.g. "concurrent", "simultaneously", "multiple users") — SPEC-1350.
7
+ */
8
+ export interface ConcurrencyAnalysisResult extends ConcurrencyAnalysis {
9
+ suppressedCount: number;
10
+ }
11
+ export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysisResult;
3
12
  export declare function buildScalabilityAssessment(spec: Spec, knowledge: ProjectKnowledge, scenarios: FailureScenario[]): string;
4
13
  export declare function calculateOverallRisk(scenarios: FailureScenario[], concurrency: ConcurrencyAnalysis): RiskLevel;
5
14
  export declare function readSpecContent(spec: Spec): Promise<string>;
@@ -1,55 +1,96 @@
1
1
  // tools/challenge-spec-helpers.ts — Concurrency, risk, and spec-reader helpers for challenge-spec
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { readSpecTechnicalSection } from '../engine/spec-format/read-technical-section.js';
4
- // --- Concurrency analysis ---
4
+ import { hasAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../engine/text-signal-boundaries.js';
5
+ // Bare keyword matches (counter, status, draft, ...) are common in unrelated
6
+ // prose (variable names, file paths, section headers, bug narratives). They
7
+ // only ground a race-condition/shared-state finding when the spec content
8
+ // also affirms an explicit concurrency context — otherwise they are boilerplate.
9
+ const CONCURRENCY_CONTEXT_PATTERN = /\b(?:concurrent(?:ly)?|simultaneous(?:ly)?|in\s+parallel|parallel\s+writes?|race\s+condition|multiple\s+users?|at\s+the\s+same\s+time|two\s+users|shared\s+state|atomic(?:ally)?|thread-safe|synchroniz(?:e|es|ed|ing|ation)|multiple\s+(?:tabs|devices|requests|clients))\b/i;
10
+ const AUTH_PATTERN = /\b(?:login|auth)\b/i;
11
+ const LIST_SEARCH_PATTERN = /\b(?:list|search|feed)\b/i;
12
+ const WRITE_PATTERN = /\b(?:submit|create|update)\b/i;
13
+ const COUNTER_PATTERN = /\b(?:counter|increment|balance|stock|quantity|vote)\b/i;
14
+ const STATUS_PATTERN = /\bstatus\b/i;
15
+ const TRANSITION_PATTERN = /\b(?:update|transition)\b/i;
16
+ const FILE_PATTERN = /\b(?:file|upload|resource)\b/i;
17
+ const SESSION_PATTERN = /\b(?:session|cart|draft)\b/i;
18
+ const CACHE_PATTERN = /\b(?:cache|global|config)\b/i;
19
+ /** Grounded, boundary-aware, negation-aware match against contract prose only. */
20
+ function affirms(content, pattern) {
21
+ return hasAffirmedMatch(content, pattern);
22
+ }
5
23
  export function generateConcurrencyAnalysis(_spec, content, _knowledge) {
6
- const lower = content.toLowerCase();
24
+ // Drop frontmatter, code fences, and quoted/backticked examples before
25
+ // keyword inference — mirrors detectChallengeCapabilities so this generator
26
+ // reads the same "contract prose" the capability gate already reads.
27
+ const contract = stripMetaAnalysisText(stripNonContractText(content));
7
28
  const hotPaths = [];
8
29
  const raceConditions = [];
9
30
  const sharedState = [];
10
31
  const recommendations = [];
11
- // Detect hot paths
12
- if (lower.includes('login') || lower.includes('auth')) {
32
+ let suppressedCount = 0;
33
+ // Hot paths are informational only (they do not feed overallRisk), so a
34
+ // plain boundary-aware match is sufficient here — no context gate needed.
35
+ if (affirms(contract, AUTH_PATTERN)) {
13
36
  hotPaths.push('Authentication endpoint (high-frequency, shared session state)');
14
37
  }
15
- if (lower.includes('list') || lower.includes('search') || lower.includes('feed')) {
38
+ if (affirms(contract, LIST_SEARCH_PATTERN)) {
16
39
  hotPaths.push('List/search endpoints (high read frequency)');
17
40
  }
18
- if (lower.includes('submit') || lower.includes('create') || lower.includes('update')) {
41
+ if (affirms(contract, WRITE_PATTERN)) {
19
42
  hotPaths.push('Write endpoints (mutation-heavy, lock-prone)');
20
43
  }
21
- // Detect race conditions
22
- if (lower.includes('counter') ||
23
- lower.includes('increment') ||
24
- lower.includes('balance') ||
25
- lower.includes('stock') ||
26
- lower.includes('quantity') ||
27
- lower.includes('vote')) {
44
+ const hasConcurrencyContext = affirms(contract, CONCURRENCY_CONTEXT_PATTERN);
45
+ const counterMatched = affirms(contract, COUNTER_PATTERN);
46
+ if (counterMatched && hasConcurrencyContext) {
28
47
  raceConditions.push('Counter/balance updates may lose increments under concurrent writes');
29
48
  recommendations.push('Use database-level atomic increments (UPDATE SET count = count + 1) instead of read-modify-write');
30
49
  }
31
- if (lower.includes('status') && (lower.includes('update') || lower.includes('transition'))) {
50
+ else if (counterMatched) {
51
+ suppressedCount += 1;
52
+ }
53
+ const statusMatched = affirms(contract, STATUS_PATTERN) && affirms(contract, TRANSITION_PATTERN);
54
+ if (statusMatched && hasConcurrencyContext) {
32
55
  raceConditions.push('Status transitions may conflict when two users update simultaneously');
33
56
  recommendations.push('Use optimistic locking (version column) or pessimistic locking (SELECT FOR UPDATE)');
34
57
  }
35
- if (lower.includes('file') || lower.includes('upload') || lower.includes('resource')) {
58
+ else if (statusMatched) {
59
+ suppressedCount += 1;
60
+ }
61
+ const fileMatched = affirms(contract, FILE_PATTERN);
62
+ if (fileMatched && hasConcurrencyContext) {
36
63
  raceConditions.push('Concurrent file writes to same resource may corrupt data');
37
64
  recommendations.push('Use advisory locks or rename-and-swap pattern for file operations');
38
65
  }
39
- // Detect shared state
40
- if (lower.includes('session') || lower.includes('cart') || lower.includes('draft')) {
66
+ else if (fileMatched) {
67
+ suppressedCount += 1;
68
+ }
69
+ const sessionMatched = affirms(contract, SESSION_PATTERN);
70
+ if (sessionMatched && hasConcurrencyContext) {
41
71
  sharedState.push('User session state (may be accessed from multiple tabs/devices)');
42
72
  }
43
- if (lower.includes('cache') || lower.includes('global') || lower.includes('config')) {
73
+ else if (sessionMatched) {
74
+ suppressedCount += 1;
75
+ }
76
+ const cacheMatched = affirms(contract, CACHE_PATTERN);
77
+ if (cacheMatched && hasConcurrencyContext) {
44
78
  sharedState.push('Global cache or configuration (requires synchronized access)');
45
79
  }
46
- // General recommendations
80
+ else if (cacheMatched) {
81
+ suppressedCount += 1;
82
+ }
83
+ // General recommendations — only emitted once a race condition is actually
84
+ // grounded, otherwise a formatting-only spec would still surface generic
85
+ // "use database transactions" boilerplate (SPEC-1350 AC1).
47
86
  if (raceConditions.length === 0) {
48
87
  recommendations.push('No obvious race conditions detected, but test concurrent scenarios in integration tests');
49
88
  }
50
- recommendations.push('Use database transactions for multi-step operations');
51
- recommendations.push('Implement idempotency keys for all mutation endpoints');
52
- return { hotPaths, raceConditions, sharedState, recommendations };
89
+ else {
90
+ recommendations.push('Use database transactions for multi-step operations');
91
+ recommendations.push('Implement idempotency keys for all mutation endpoints');
92
+ }
93
+ return { hotPaths, raceConditions, sharedState, recommendations, suppressedCount };
53
94
  }
54
95
  // --- Scalability and risk ---
55
96
  export function buildScalabilityAssessment(spec, knowledge, scenarios) {
@@ -171,9 +171,24 @@ export async function handleChallengeSpec(args, server) {
171
171
  // SPEC-615 AC3: Check criteria against prior decisions for technology contradictions
172
172
  await runPriorDecisionContradictions(projectId, spec.title, spec.tags, specContent, failureScenarios);
173
173
  // 6. Generate concurrency analysis
174
- const concurrencyAnalysis = focusAreas.includes('concurrency') && capabilities.concurrency
174
+ const concurrencyResult = focusAreas.includes('concurrency') && capabilities.concurrency
175
175
  ? generateConcurrencyAnalysis(spec, specContent, knowledge)
176
- : { hotPaths: [], raceConditions: [], sharedState: [], recommendations: [] };
176
+ : {
177
+ hotPaths: [],
178
+ raceConditions: [],
179
+ sharedState: [],
180
+ recommendations: [],
181
+ suppressedCount: 0,
182
+ };
183
+ // SPEC-1350: concurrency candidates that matched a domain keyword but lacked
184
+ // a corroborating concurrency context are suppressed before risk/summary math.
185
+ const concurrencySuppressedCount = concurrencyResult.suppressedCount;
186
+ const concurrencyAnalysis = {
187
+ hotPaths: concurrencyResult.hotPaths,
188
+ raceConditions: concurrencyResult.raceConditions,
189
+ sharedState: concurrencyResult.sharedState,
190
+ recommendations: concurrencyResult.recommendations,
191
+ };
177
192
  // Unsupported domain templates are removed before prioritization and risk calculation.
178
193
  const actionableFailureScenarios = failureScenarios.filter((scenario) => isScenarioSupportedByCapabilities(scenario, capabilities));
179
194
  const suppressedScenarioCount = failureScenarios.length - actionableFailureScenarios.length;
@@ -219,7 +234,7 @@ export async function handleChallengeSpec(args, server) {
219
234
  summary: {
220
235
  totalScenarios: failureScenarios.length,
221
236
  actionableScenarios: failureScenariosScored.length,
222
- suppressedLowRelevanceScenarios: suppressedScenarioCount,
237
+ suppressedLowRelevanceScenarios: suppressedScenarioCount + concurrencySuppressedCount,
223
238
  shownByDefault: 3,
224
239
  mustAddressBeforeCoding: prioritizedSummary,
225
240
  criticalImpact: actionableFailureScenarios.filter((s) => s.impact === 'critical').length,