@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.
- package/CHANGELOG.md +76 -1
- package/dist/cli/commands/doctor.d.ts +22 -0
- package/dist/cli/commands/doctor.js +176 -2
- package/dist/cli/commands/spec.js +10 -1
- package/dist/core/spec-validator.js +32 -18
- package/dist/engine/autopilot/bootstrap.js +27 -0
- package/dist/engine/core-bridge.d.ts +28 -0
- package/dist/engine/core-bridge.js +67 -0
- package/dist/engine/drift-monitor.js +16 -18
- package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
- package/dist/engine/evidence-gates/artifact-reader.js +59 -2
- package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
- package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
- package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
- package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
- package/dist/engine/execution/operation-journal.js +10 -4
- package/dist/engine/living-spec/hash-tracker.js +26 -28
- package/dist/engine/minimality/policy-loader.js +247 -6
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
- package/dist/engine/reverse-engineer/api-detector.js +2 -13
- package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
- package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
- package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
- package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
- package/dist/engine/spec-format/acceptance-criteria.js +13 -12
- package/dist/engine/spec-format/text-fences.js +20 -2
- package/dist/engine/spec-language/english-only.d.ts +14 -0
- package/dist/engine/spec-language/english-only.js +58 -0
- package/dist/engine/spec-migrator/criteria-scanner.js +11 -13
- package/dist/engine/spec-migrator/drift-detector.js +10 -12
- package/dist/engine/spec-state-syncer.js +1 -1
- package/dist/engine/timing/budget.js +5 -1
- package/dist/engine/vector-store/tfidf.d.ts +13 -9
- package/dist/engine/vector-store/tfidf.js +26 -0
- package/dist/engine/worker-config-loader.d.ts +1 -1
- package/dist/engine/worker-config-loader.js +1 -11
- package/dist/engine/workers/schema.d.ts +0 -8
- package/dist/engine/workers/schema.js +0 -1
- package/dist/i18n/index.d.ts +18 -0
- package/dist/i18n/index.js +40 -1
- package/dist/server/routes/specs.js +7 -5
- package/dist/storage/global-store.d.ts +9 -0
- package/dist/storage/global-store.js +23 -0
- package/dist/storage/semantic-index-store.d.ts +23 -0
- package/dist/storage/semantic-index-store.js +105 -0
- package/dist/storage/status-store/self-healing.js +15 -17
- package/dist/tools/challenge-spec/scenarios-utils.js +5 -1
- package/dist/tools/challenge-spec-helpers.d.ts +10 -1
- package/dist/tools/challenge-spec-helpers.js +63 -22
- package/dist/tools/challenge-spec.js +18 -3
- package/dist/tools/check-readiness.js +37 -13
- package/dist/tools/create-spec/spec-builder.d.ts +7 -0
- package/dist/tools/create-spec/spec-builder.js +19 -4
- package/dist/tools/create-spec.js +216 -101
- package/dist/tools/init-project/handler.js +78 -25
- package/dist/tools/learn.js +10 -8
- package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
- package/dist/tools/registry/auth.js +1 -11
- package/dist/tools/semantic-search-handler.js +5 -6
- package/dist/tools/status-handler.js +19 -6
- package/dist/tools/sync-spec-state-handler.js +49 -1
- package/dist/tools/update-status/batch.d.ts +6 -2
- package/dist/tools/update-status/batch.js +58 -1
- package/dist/tools/update-status/dod-gates.d.ts +16 -1
- package/dist/tools/update-status/dod-gates.js +191 -1
- package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
- package/dist/tools/update-status/done-receipt-verifier.js +37 -2
- package/dist/tools/update-status/evidence-gate.d.ts +4 -0
- package/dist/tools/update-status/evidence-gate.js +67 -2
- package/dist/tools/update-status/file-sync.d.ts +2 -2
- package/dist/tools/update-status/index.d.ts +23 -1
- package/dist/tools/update-status/index.js +201 -24
- package/dist/tools/update-status/transition-guard.js +13 -1
- package/dist/tools/validation-loop-handler.js +16 -15
- package/dist/tools/workspace-dashboard-handler.js +38 -0
- package/dist/types/evidence-autofill.d.ts +34 -0
- package/dist/types/evidence-autofill.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +6 -0
- package/dist/types/spec/inputs.d.ts +7 -2
- package/dist/types/spec-format.d.ts +1 -1
- package/dist/types/spec-language-translation.d.ts +18 -0
- package/dist/types/spec-language-translation.js +5 -0
- package/dist/types/spec-registry.d.ts +0 -2
- package/dist/types/status.d.ts +2 -0
- package/dist/types/transition-log.d.ts +1 -1
- package/dist/types/validation.d.ts +8 -2
- package/dist/types/vector-store.d.ts +18 -0
- package/dist/types/workers.d.ts +0 -3
- package/package.json +11 -10
- package/planu-native.json +8 -29
- package/planu-plugin.json +1 -1
- package/dist/engine/security/cve-refresher.d.ts +0 -12
- 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)
|
|
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'),
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/i18n/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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({
|
|
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 {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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 (
|
|
38
|
+
if (affirms(contract, LIST_SEARCH_PATTERN)) {
|
|
16
39
|
hotPaths.push('List/search endpoints (high read frequency)');
|
|
17
40
|
}
|
|
18
|
-
if (
|
|
41
|
+
if (affirms(contract, WRITE_PATTERN)) {
|
|
19
42
|
hotPaths.push('Write endpoints (mutation-heavy, lock-prone)');
|
|
20
43
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
40
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
174
|
+
const concurrencyResult = focusAreas.includes('concurrency') && capabilities.concurrency
|
|
175
175
|
? generateConcurrencyAnalysis(spec, specContent, knowledge)
|
|
176
|
-
: {
|
|
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,
|