@planu/cli 5.6.0 → 5.7.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 +20 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/telemetry.d.ts +3 -0
- package/dist/cli/commands/telemetry.js +118 -0
- package/dist/cli/router.js +3 -1
- package/dist/config/environment-schema.json +14 -0
- package/dist/engine/contradiction-detector.d.ts +2 -1
- package/dist/engine/contradiction-detector.js +215 -0
- package/dist/engine/handoff-artifacts/schemas.js +4 -0
- package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
- package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
- package/dist/engine/lifecycle-reconciliation.js +87 -40
- package/dist/engine/readiness-checker.js +13 -1
- package/dist/engine/telemetry/error-reporter.d.ts +9 -9
- package/dist/engine/telemetry/error-reporter.js +15 -34
- package/dist/engine/telemetry/event-envelope.d.ts +11 -0
- package/dist/engine/telemetry/event-envelope.js +124 -0
- package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
- package/dist/engine/telemetry/telemetry-client.js +38 -20
- package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
- package/dist/engine/telemetry/telemetry-store.js +73 -2
- package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
- package/dist/engine/validator/spec-compliance-runner.js +78 -1
- package/dist/index.js +26 -0
- package/dist/tools/challenge-spec.js +25 -10
- package/dist/tools/init-project/handler.js +2 -2
- package/dist/tools/init-project/legacy-planu.d.ts +2 -0
- package/dist/tools/init-project/legacy-planu.js +18 -0
- package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
- package/dist/tools/init-project/schedule-housekeeping.js +8 -0
- package/dist/tools/reconcile-spec.js +29 -2
- package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
- package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
- package/dist/tools/safe-handler.js +6 -12
- package/dist/types/handoff-artifacts.d.ts +1 -0
- package/dist/types/housekeeping.d.ts +34 -0
- package/dist/types/housekeeping.js +0 -1
- package/dist/types/scope.d.ts +23 -0
- package/dist/types/spec/inputs.d.ts +9 -0
- package/dist/types/telemetry.d.ts +39 -1
- package/dist/types/validation-evidence.d.ts +6 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// engine/telemetry/telemetry-store.ts — SPEC-200: Read/write telemetry config from ~/.planu/
|
|
3
3
|
// Stores opt-in preference and installation ID outside the project directory.
|
|
4
4
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
|
|
7
8
|
import { resolveStorageLayout } from '../../storage/storage-layout.js';
|
|
8
9
|
const PLANU_DIR = resolveStorageLayout().config;
|
|
9
10
|
const TELEMETRY_FILE = join(PLANU_DIR, 'telemetry.json');
|
|
10
11
|
export const TELEMETRY_CONSENT_VERSION = 1;
|
|
12
|
+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
11
13
|
export async function readTelemetryConfig() {
|
|
12
14
|
try {
|
|
13
15
|
const raw = await readFile(TELEMETRY_FILE, 'utf-8');
|
|
@@ -22,6 +24,10 @@ export async function readTelemetryConfig() {
|
|
|
22
24
|
if (parsed.enableObservatory === true) {
|
|
23
25
|
config.enableObservatory = true;
|
|
24
26
|
}
|
|
27
|
+
if (typeof parsed.anonymousInstallationId === 'string' &&
|
|
28
|
+
UUID_V4_PATTERN.test(parsed.anonymousInstallationId)) {
|
|
29
|
+
config.anonymousInstallationId = parsed.anonymousInstallationId;
|
|
30
|
+
}
|
|
25
31
|
return config;
|
|
26
32
|
}
|
|
27
33
|
catch {
|
|
@@ -35,15 +41,28 @@ export async function writeTelemetryConfig(config) {
|
|
|
35
41
|
promptedAt: config.promptedAt,
|
|
36
42
|
consentVersion: config.consentVersion,
|
|
37
43
|
enableObservatory: config.enableObservatory,
|
|
44
|
+
anonymousInstallationId: config.anonymousInstallationId,
|
|
38
45
|
};
|
|
39
46
|
await writeFile(TELEMETRY_FILE, JSON.stringify(safeConfig, null, 2) + '\n', 'utf-8');
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
42
49
|
* Returns true if telemetry is enabled.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
50
|
+
* Suppression precedence (each read fresh from process.env on every call, so an
|
|
51
|
+
* env change within a process lifetime is respected immediately):
|
|
52
|
+
* PLANU_TELEMETRY_DISABLED=1 > DO_NOT_TRACK=1 > CI=true > PLANU_TELEMETRY=off
|
|
53
|
+
* > PLANU_TELEMETRY=on (with matching consent version) > stored consent.
|
|
54
|
+
* A corrupted or unparseable telemetry.json reads as consent-unknown (false), never crashes.
|
|
45
55
|
*/
|
|
46
56
|
export async function isTelemetryEnabled() {
|
|
57
|
+
if (process.env.PLANU_TELEMETRY_DISABLED === '1') {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (process.env.DO_NOT_TRACK === '1') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (process.env.CI === 'true') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
47
66
|
if (process.env.PLANU_TELEMETRY === 'off') {
|
|
48
67
|
return false;
|
|
49
68
|
}
|
|
@@ -53,6 +72,57 @@ export async function isTelemetryEnabled() {
|
|
|
53
72
|
const config = await readTelemetryConfig();
|
|
54
73
|
return config?.enabled === true && config.consentVersion === TELEMETRY_CONSENT_VERSION;
|
|
55
74
|
}
|
|
75
|
+
function promptedAtOrNow(promptedAt) {
|
|
76
|
+
return promptedAt !== undefined && promptedAt !== '' ? promptedAt : new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
/** Enables telemetry and generates a fresh anonymousInstallationId, replacing any prior one. */
|
|
79
|
+
export async function enableTelemetry() {
|
|
80
|
+
const existing = await readTelemetryConfig();
|
|
81
|
+
const config = {
|
|
82
|
+
enabled: true,
|
|
83
|
+
promptedAt: promptedAtOrNow(existing?.promptedAt),
|
|
84
|
+
consentVersion: TELEMETRY_CONSENT_VERSION,
|
|
85
|
+
enableObservatory: existing?.enableObservatory,
|
|
86
|
+
anonymousInstallationId: randomUUID(),
|
|
87
|
+
};
|
|
88
|
+
await writeTelemetryConfig(config);
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
/** Disables telemetry and deletes the stored anonymousInstallationId. */
|
|
92
|
+
export async function disableTelemetry() {
|
|
93
|
+
const existing = await readTelemetryConfig();
|
|
94
|
+
const config = {
|
|
95
|
+
enabled: false,
|
|
96
|
+
promptedAt: existing?.promptedAt ?? new Date().toISOString(),
|
|
97
|
+
consentVersion: existing?.consentVersion,
|
|
98
|
+
enableObservatory: existing?.enableObservatory,
|
|
99
|
+
};
|
|
100
|
+
await writeTelemetryConfig(config);
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns the current anonymousInstallationId when telemetry is enabled, lazily generating
|
|
105
|
+
* and persisting one if enabled but none is stored yet (e.g. enabled via PLANU_TELEMETRY=on).
|
|
106
|
+
* Returns undefined when telemetry is not enabled — never creates an id in that case.
|
|
107
|
+
*/
|
|
108
|
+
export async function getOrCreateAnonymousInstallationId() {
|
|
109
|
+
if (!(await isTelemetryEnabled())) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const config = await readTelemetryConfig();
|
|
113
|
+
if (config?.anonymousInstallationId) {
|
|
114
|
+
return config.anonymousInstallationId;
|
|
115
|
+
}
|
|
116
|
+
const anonymousInstallationId = randomUUID();
|
|
117
|
+
await writeTelemetryConfig({
|
|
118
|
+
enabled: true,
|
|
119
|
+
promptedAt: promptedAtOrNow(config?.promptedAt),
|
|
120
|
+
consentVersion: config?.consentVersion ?? TELEMETRY_CONSENT_VERSION,
|
|
121
|
+
enableObservatory: config?.enableObservatory,
|
|
122
|
+
anonymousInstallationId,
|
|
123
|
+
});
|
|
124
|
+
return anonymousInstallationId;
|
|
125
|
+
}
|
|
56
126
|
/** Returns true if the user has never been shown the opt-in prompt. */
|
|
57
127
|
export async function hasNeverBeenPrompted() {
|
|
58
128
|
const config = await readTelemetryConfig();
|
|
@@ -66,6 +136,7 @@ export async function markAsPrompted() {
|
|
|
66
136
|
promptedAt: new Date().toISOString(),
|
|
67
137
|
consentVersion: existing?.consentVersion,
|
|
68
138
|
enableObservatory: existing?.enableObservatory,
|
|
139
|
+
anonymousInstallationId: existing?.anonymousInstallationId,
|
|
69
140
|
});
|
|
70
141
|
}
|
|
71
142
|
//# sourceMappingURL=telemetry-store.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Spec } from '../../types/index.js';
|
|
2
|
-
import type { FrontmatterScenario, SpecComplianceResult } from '../../types/validation-evidence.js';
|
|
2
|
+
import type { DoneDriftMissingFileFinding, FrontmatterScenario, SpecComplianceResult } from '../../types/validation-evidence.js';
|
|
3
3
|
/** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
|
|
4
4
|
export declare class ComplianceCommandTerminalError extends Error {
|
|
5
5
|
readonly code: string;
|
|
@@ -8,4 +8,5 @@ export declare class ComplianceCommandTerminalError extends Error {
|
|
|
8
8
|
export declare function parseFrontmatterScenarios(raw: string): FrontmatterScenario[];
|
|
9
9
|
export declare function runSpecCompliance(spec: Spec, projectPath: string, signal?: AbortSignal, canonicalProjectId?: string): Promise<SpecComplianceResult>;
|
|
10
10
|
export declare function findScenariosWithoutTests(raw: string): string[];
|
|
11
|
+
export declare function findDoneDriftMissingFiles(spec: Spec, raw: string, projectPath: string, fileExists: (path: string) => boolean): DoneDriftMissingFileFinding[];
|
|
11
12
|
//# sourceMappingURL=spec-compliance-runner.d.ts.map
|
|
@@ -502,7 +502,13 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
|
|
|
502
502
|
raw = await readFile(spec.specPath, 'utf-8');
|
|
503
503
|
}
|
|
504
504
|
catch {
|
|
505
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
dimensionScore: 0,
|
|
507
|
+
perScenario: [],
|
|
508
|
+
command: '',
|
|
509
|
+
evidenceSource: 'none',
|
|
510
|
+
doneDriftFindings: [],
|
|
511
|
+
};
|
|
506
512
|
}
|
|
507
513
|
const frontmatterScenarios = parseFrontmatterScenarios(raw);
|
|
508
514
|
const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
|
|
@@ -513,6 +519,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
|
|
|
513
519
|
done: false,
|
|
514
520
|
tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
|
|
515
521
|
}));
|
|
522
|
+
const doneDriftFindings = findDoneDriftMissingFiles(spec, raw, projectPath, existsSync);
|
|
516
523
|
if (scenarios.length === 0) {
|
|
517
524
|
return {
|
|
518
525
|
dimensionScore: 0,
|
|
@@ -520,6 +527,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
|
|
|
520
527
|
command: '',
|
|
521
528
|
evidenceSource: executableEvidence.source,
|
|
522
529
|
ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
|
|
530
|
+
doneDriftFindings,
|
|
523
531
|
};
|
|
524
532
|
}
|
|
525
533
|
const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
|
|
@@ -633,6 +641,7 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
|
|
|
633
641
|
`${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
|
|
634
642
|
evidenceSource: executableEvidence.source,
|
|
635
643
|
ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
|
|
644
|
+
doneDriftFindings,
|
|
636
645
|
};
|
|
637
646
|
}
|
|
638
647
|
export function findScenariosWithoutTests(raw) {
|
|
@@ -640,4 +649,72 @@ export function findScenariosWithoutTests(raw) {
|
|
|
640
649
|
.filter((scenario) => !scenario.tests?.length)
|
|
641
650
|
.map((scenario) => scenario.title);
|
|
642
651
|
}
|
|
652
|
+
const DONE_DRIFT_ELIGIBLE_PREFIXES = ['src/', 'scripts/', 'tests/'];
|
|
653
|
+
const FILES_MARKER = /FILES:\s*([^\n]*)/g;
|
|
654
|
+
const NEXT_MARKER_KEYWORD = /\s+(?:TEST|FUNCTIONS|AND|GIVEN|WHEN|THEN):/;
|
|
655
|
+
const FILES_SECTION_HEADING = /^##\s+Files\s*$/m;
|
|
656
|
+
const MARKDOWN_SECTION_HEADING = /^##\s+\S/m;
|
|
657
|
+
const BACKTICKED_LIST_ITEM = /^-\s+`([^`]+)`/;
|
|
658
|
+
function normalizeDoneDriftCandidate(segment) {
|
|
659
|
+
const trimmed = segment.trim().replace(/^`|`$/g, '');
|
|
660
|
+
return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
|
|
661
|
+
}
|
|
662
|
+
function isDoneDriftEligiblePath(path) {
|
|
663
|
+
return DONE_DRIFT_ELIGIBLE_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
664
|
+
}
|
|
665
|
+
const DONE_DRIFT_GLOB_CHARACTER = /[*?[]/;
|
|
666
|
+
const DONE_DRIFT_FILE_EXTENSION = /\.\w{1,6}$/;
|
|
667
|
+
function isDoneDriftCheckablePath(path) {
|
|
668
|
+
return (!DONE_DRIFT_GLOB_CHARACTER.test(path) &&
|
|
669
|
+
!path.endsWith('/') &&
|
|
670
|
+
DONE_DRIFT_FILE_EXTENSION.test(path));
|
|
671
|
+
}
|
|
672
|
+
function extractFilesMarkerPaths(raw) {
|
|
673
|
+
const paths = [];
|
|
674
|
+
for (const match of raw.matchAll(FILES_MARKER)) {
|
|
675
|
+
const rest = match[1] ?? '';
|
|
676
|
+
const boundary = NEXT_MARKER_KEYWORD.exec(rest);
|
|
677
|
+
const segment = boundary ? rest.slice(0, boundary.index) : rest;
|
|
678
|
+
for (const item of segment.split(',')) {
|
|
679
|
+
const candidate = normalizeDoneDriftCandidate(item);
|
|
680
|
+
if (candidate) {
|
|
681
|
+
paths.push(candidate);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return paths;
|
|
686
|
+
}
|
|
687
|
+
function extractFilesSectionPaths(raw) {
|
|
688
|
+
const sectionMatch = FILES_SECTION_HEADING.exec(raw);
|
|
689
|
+
if (!sectionMatch) {
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
const rest = raw.slice(sectionMatch.index + sectionMatch[0].length);
|
|
693
|
+
const nextSection = MARKDOWN_SECTION_HEADING.exec(rest);
|
|
694
|
+
const body = nextSection ? rest.slice(0, nextSection.index) : rest;
|
|
695
|
+
const paths = [];
|
|
696
|
+
for (const line of body.split('\n')) {
|
|
697
|
+
const itemMatch = BACKTICKED_LIST_ITEM.exec(line.trim());
|
|
698
|
+
if (itemMatch?.[1]) {
|
|
699
|
+
paths.push(normalizeDoneDriftCandidate(itemMatch[1]));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return paths;
|
|
703
|
+
}
|
|
704
|
+
export function findDoneDriftMissingFiles(spec, raw, projectPath, fileExists) {
|
|
705
|
+
if (spec.status !== 'done') {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
const candidates = new Set([...extractFilesMarkerPaths(raw), ...extractFilesSectionPaths(raw)]);
|
|
709
|
+
const findings = [];
|
|
710
|
+
for (const candidate of candidates) {
|
|
711
|
+
if (!isDoneDriftEligiblePath(candidate) || !isDoneDriftCheckablePath(candidate)) {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (!fileExists(join(projectPath, candidate))) {
|
|
715
|
+
findings.push({ kind: 'done-drift-missing-files', specId: spec.id, missingPath: candidate });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return findings;
|
|
719
|
+
}
|
|
643
720
|
//# sourceMappingURL=spec-compliance-runner.js.map
|
package/dist/index.js
CHANGED
|
@@ -84,6 +84,27 @@ function scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasks
|
|
|
84
84
|
recoverValidateJobsAtStartup();
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
+
const MCP_HOST_MARKERS = [
|
|
88
|
+
['claude-code', 'claude-code'],
|
|
89
|
+
['claude-code', 'claude code'],
|
|
90
|
+
['claude-desktop', 'claude-desktop'],
|
|
91
|
+
['claude-desktop', 'claude desktop'],
|
|
92
|
+
['cursor', 'cursor'],
|
|
93
|
+
['codex', 'codex'],
|
|
94
|
+
];
|
|
95
|
+
/** Maps the MCP client's reported implementation name to the envelope's allowlisted mcpHost. */
|
|
96
|
+
function resolveMcpHost(clientName) {
|
|
97
|
+
if (!clientName) {
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
const normalized = clientName.toLowerCase();
|
|
101
|
+
for (const [host, marker] of MCP_HOST_MARKERS) {
|
|
102
|
+
if (normalized.includes(marker)) {
|
|
103
|
+
return host;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return 'other';
|
|
107
|
+
}
|
|
87
108
|
// Loads post-handshake modules after the MCP handshake has completed.
|
|
88
109
|
// Separated from main() to keep main() within the 80-line function budget.
|
|
89
110
|
async function setupPostHandshake(handshakeGate, officialToolNames) {
|
|
@@ -109,6 +130,11 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
|
|
|
109
130
|
if (clientVersion) {
|
|
110
131
|
setConnectedClient(clientVersion);
|
|
111
132
|
}
|
|
133
|
+
// SPEC-1704: emit the mcp_server_started envelope now that the client is known.
|
|
134
|
+
const { sendTelemetryEnvelopeEvent } = await import('./engine/telemetry/telemetry-client.js');
|
|
135
|
+
sendTelemetryEnvelopeEvent('mcp_server_started', {
|
|
136
|
+
mcpHost: resolveMcpHost(clientVersion?.name),
|
|
137
|
+
});
|
|
112
138
|
// SPEC-251: Check npm registry for newer version (fire-and-forget, 24h cache)
|
|
113
139
|
checkForUpdates().catch(() => {
|
|
114
140
|
/* best-effort */
|
|
@@ -15,6 +15,7 @@ import { t, ti } from '../i18n/index.js';
|
|
|
15
15
|
import { generateConcurrencyAnalysis, buildScalabilityAssessment, calculateOverallRisk, readSpecContent, } from './challenge-spec-helpers.js';
|
|
16
16
|
import { prioritizeScenarios, buildPrioritizedSummary } from '../engine/challenge-prioritizer.js';
|
|
17
17
|
import { checkContradictions as checkScopeContradictions } from '../engine/scope-boundaries/index.js';
|
|
18
|
+
import { detectCrossSpecPremiseContradictions } from '../engine/contradiction-detector.js';
|
|
18
19
|
import { buildChallengeSpecSummary } from '../engine/human-summary.js';
|
|
19
20
|
import { detectContradictions as detectDecisionContradictions, searchPriorDecisions, } from '../engine/prior-decisions/index.js';
|
|
20
21
|
import { getDecisions } from '../storage/decision-store.js';
|
|
@@ -85,25 +86,23 @@ function extractOutOfScopeSection(content) {
|
|
|
85
86
|
}
|
|
86
87
|
return found ? captured.join('\n') : null;
|
|
87
88
|
}
|
|
88
|
-
async function
|
|
89
|
+
async function readRawSpecMarkdown(spec, projectPath) {
|
|
89
90
|
if (!spec.specPath) {
|
|
90
|
-
return
|
|
91
|
+
return null;
|
|
91
92
|
}
|
|
92
|
-
let fileContent;
|
|
93
93
|
try {
|
|
94
94
|
const resolvedPath = !isAbsolute(spec.specPath) && projectPath
|
|
95
95
|
? await resolveVerifiedSpecPath(spec.id, spec.specPath, projectPath)
|
|
96
96
|
: spec.specPath;
|
|
97
|
-
|
|
97
|
+
return await readFile(resolvedPath, 'utf-8');
|
|
98
98
|
}
|
|
99
99
|
catch {
|
|
100
|
-
return
|
|
100
|
+
return null;
|
|
101
101
|
}
|
|
102
|
-
const sectionContent = extractOutOfScopeSection(fileContent);
|
|
103
|
-
return sectionContent === null ? [] : extractListItems(sectionContent);
|
|
104
102
|
}
|
|
105
|
-
|
|
106
|
-
const
|
|
103
|
+
function resolveOutOfScopeItems(spec, rawSpecMarkdown) {
|
|
104
|
+
const sectionContent = rawSpecMarkdown === null ? null : extractOutOfScopeSection(rawSpecMarkdown);
|
|
105
|
+
const documentItems = sectionContent === null ? [] : extractListItems(sectionContent);
|
|
107
106
|
return documentItems.length > 0 ? documentItems : (spec.outOfScope ?? []);
|
|
108
107
|
}
|
|
109
108
|
/**
|
|
@@ -230,7 +229,8 @@ export async function handleChallengeSpec(args, server) {
|
|
|
230
229
|
}
|
|
231
230
|
failureScenarios.push(...collectCapabilityScenarios({ spec, specContent, knowledge, focusAreas, capabilities }));
|
|
232
231
|
// SPEC-612: Check for contradictions between outOfScope and acceptance criteria
|
|
233
|
-
const
|
|
232
|
+
const rawSpecMarkdown = await readRawSpecMarkdown(spec, knowledge.projectPath);
|
|
233
|
+
const resolvedOutOfScope = resolveOutOfScopeItems(spec, rawSpecMarkdown);
|
|
234
234
|
if (resolvedOutOfScope.length > 0) {
|
|
235
235
|
const criteriaRange = findMarkdownSectionRange(specContent, 'Acceptance Criteria');
|
|
236
236
|
const criteriaTexts = criteriaRange === null
|
|
@@ -249,6 +249,21 @@ export async function handleChallengeSpec(args, server) {
|
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
|
+
// SPEC-1702: Check for premise contradictions against done/approved sibling specs
|
|
253
|
+
const crossSpecPremiseFindings = rawSpecMarkdown !== null
|
|
254
|
+
? await detectCrossSpecPremiseContradictions(spec.id, rawSpecMarkdown, knowledge.projectPath)
|
|
255
|
+
: [];
|
|
256
|
+
for (const finding of crossSpecPremiseFindings) {
|
|
257
|
+
failureScenarios.push({
|
|
258
|
+
scenario: `cross-spec-premise: ${finding.targetSpecId} contradicts ${finding.siblingSpecId}`,
|
|
259
|
+
probability: 'high',
|
|
260
|
+
impact: 'critical',
|
|
261
|
+
currentHandling: `${finding.targetSpecId} claims: "${finding.targetSentence}"`,
|
|
262
|
+
requiredHandling: `${finding.siblingSpecId} states: "${finding.siblingSentence}" — reconcile the premise before implementation (shared file: ${finding.sharedFilePath})`,
|
|
263
|
+
dataConsistency: 'Cross-spec premise drift',
|
|
264
|
+
userExperience: 'Implementing on a contradicted premise risks a wasted implementation round',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
252
267
|
// SPEC-615 AC3: Check criteria against prior decisions for technology contradictions
|
|
253
268
|
await runPriorDecisionContradictions(projectId, spec.title, spec.tags, specContent, failureScenarios);
|
|
254
269
|
// 6. Generate concurrency analysis
|
|
@@ -46,7 +46,7 @@ import { reconcileInteractiveQuestionHooks } from '../reconcile-interactive-ques
|
|
|
46
46
|
import { installSessionSafeguardHook } from '../reconcile-session-safeguard-hook.js';
|
|
47
47
|
import { resolveNewProjectOnboarding } from '../../engine/onboarding/new-project-resolver.js';
|
|
48
48
|
import { interactiveResult } from '../response-helpers.js';
|
|
49
|
-
import {
|
|
49
|
+
import { scheduleHousekeepingSweeps } from './schedule-housekeeping.js';
|
|
50
50
|
import { resolveLegacyMigration } from './legacy-root-migration.js';
|
|
51
51
|
/** Frontend framework groups — mutually exclusive. Two or more detected → multi-stack conflict. */
|
|
52
52
|
const FRONTEND_FRAMEWORK_GROUPS = [
|
|
@@ -219,7 +219,7 @@ export async function handleInitProject(params, server) {
|
|
|
219
219
|
const legacyMigration = await resolveLegacyMigration(isUpdate, projectPath);
|
|
220
220
|
// SPEC-1699: Sweep runtime residue on every init_project run, including the
|
|
221
221
|
// already-initialized early-return path below — not just the fresh-init path.
|
|
222
|
-
|
|
222
|
+
scheduleHousekeepingSweeps(projectPath);
|
|
223
223
|
const authorizedMigrations = params.authorizedMigrations ?? [];
|
|
224
224
|
if (isUpdate && authorizedMigrations.length === 0) {
|
|
225
225
|
const reconciliation = await reconcilePortableSpecIndex(projectPath, projectId);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// tools/init-project/legacy-planu.ts — SPEC-1709
|
|
2
|
+
import { withAudit } from '../../engine/autopilot/audit-logger.js';
|
|
3
|
+
import { demolishLegacyPlanuRoot } from '../../engine/housekeeping/legacy-planu-demolisher.js';
|
|
4
|
+
export function scheduleLegacyPlanuDemolition(projectPath) {
|
|
5
|
+
const isTestEnvironment = Boolean(process.env.VITEST) || Boolean(process.env.PLANU_TEST_HOME);
|
|
6
|
+
if (isTestEnvironment) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
void withAudit(projectPath, 'init_project', 'demolishLegacyPlanuRoot', () => demolishLegacyPlanuRoot(), (result) => ({
|
|
10
|
+
status: result.status,
|
|
11
|
+
migrated: result.migrated.length,
|
|
12
|
+
demolishedUnmappable: result.demolishedUnmappable,
|
|
13
|
+
freedBytes: result.freedBytes,
|
|
14
|
+
})).catch(() => {
|
|
15
|
+
/* best-effort */
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=legacy-planu.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// tools/init-project/schedule-housekeeping.ts — SPEC-1709
|
|
2
|
+
import { scheduleRuntimeResidueSweep } from './runtime-residue.js';
|
|
3
|
+
import { scheduleLegacyPlanuDemolition } from './legacy-planu.js';
|
|
4
|
+
export function scheduleHousekeepingSweeps(projectPath) {
|
|
5
|
+
scheduleRuntimeResidueSweep(projectPath);
|
|
6
|
+
scheduleLegacyPlanuDemolition(projectPath);
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=schedule-housekeeping.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SECTIONS_WITHOUT_LITERAL_BODY_TEXT, } from '../types/index.js';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { elicitOrFallback, buildEnumSchema } from '../engine/elicitation/elicit-helper.js';
|
|
4
4
|
import { ti, t } from '../i18n/index.js';
|
|
5
5
|
import { formatSuccess, addNextSteps } from './response-helpers.js';
|
|
@@ -14,6 +14,9 @@ import { analyzeLivingSpec } from '../engine/living-spec-analyzer.js';
|
|
|
14
14
|
import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
|
|
15
15
|
import { applyChangesToSpec } from '../engine/reconcile/apply-changes.js';
|
|
16
16
|
import { verifyWriteSucceeded } from '../engine/reconcile/verify-write.js';
|
|
17
|
+
import { reconcileImplementingSpec, currentReconciliationInvocationContext, } from '../engine/lifecycle-reconciliation.js';
|
|
18
|
+
import { reconciliationDigest } from '../engine/lifecycle-reconciliation-io.js';
|
|
19
|
+
import { syncSpecFiles } from './update-status/file-sync.js';
|
|
17
20
|
function detectEstimationDrift(spec) {
|
|
18
21
|
if (!spec.actuals) {
|
|
19
22
|
return null;
|
|
@@ -322,8 +325,29 @@ function applyConflictResolution(resolution, allChanges, pendingChanges) {
|
|
|
322
325
|
}
|
|
323
326
|
}
|
|
324
327
|
}
|
|
328
|
+
async function routeDeclaredArchitecturalDrift(spec, projectId, projectPath, declaredDrift) {
|
|
329
|
+
const reason = declaredDrift.reason;
|
|
330
|
+
const input = {
|
|
331
|
+
specId: spec.id,
|
|
332
|
+
projectId,
|
|
333
|
+
projectPath,
|
|
334
|
+
status: 'review',
|
|
335
|
+
reconciliationRequestId: randomUUID(),
|
|
336
|
+
expectedImplementingTransitionId: spec.statusHistory?.at(-1)?.transitionId,
|
|
337
|
+
implementationReviewDigest: reconciliationDigest(reason.trim()),
|
|
338
|
+
reason,
|
|
339
|
+
declaredDriftKind: declaredDrift.kind,
|
|
340
|
+
};
|
|
341
|
+
return reconcileImplementingSpec({
|
|
342
|
+
input,
|
|
343
|
+
projectId,
|
|
344
|
+
projectPath,
|
|
345
|
+
context: currentReconciliationInvocationContext(),
|
|
346
|
+
syncSpecFiles,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
325
349
|
export async function handleReconcileSpec(params, server) {
|
|
326
|
-
const { specId, projectId, autoDetect = true, livingSpec = false, changes: manualChanges, } = params;
|
|
350
|
+
const { specId, projectId, autoDetect = true, livingSpec = false, changes: manualChanges, declaredDrift, } = params;
|
|
327
351
|
try {
|
|
328
352
|
const spec = await specStore.getSpec(projectId, specId);
|
|
329
353
|
if (!spec) {
|
|
@@ -333,6 +357,9 @@ export async function handleReconcileSpec(params, server) {
|
|
|
333
357
|
};
|
|
334
358
|
}
|
|
335
359
|
const knowledge = await knowledgeStore.getKnowledge(projectId);
|
|
360
|
+
if (declaredDrift) {
|
|
361
|
+
return await routeDeclaredArchitecturalDrift(spec, projectId, knowledge?.projectPath, declaredDrift);
|
|
362
|
+
}
|
|
336
363
|
const allChanges = [];
|
|
337
364
|
if (autoDetect) {
|
|
338
365
|
const autoChanges = await autoDetectChanges(spec, knowledge);
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export declare const DeclaredDriftInputSchema: z.ZodObject<{
|
|
4
|
+
kind: z.ZodEnum<{
|
|
5
|
+
"architectural-premise": "architectural-premise";
|
|
6
|
+
}>;
|
|
7
|
+
reason: z.ZodString;
|
|
8
|
+
}, z.core.$strip>;
|
|
2
9
|
/**
|
|
3
10
|
* Registers drift detection, analysis, and discovery tools (tools 11–19) on the MCP server.
|
|
4
11
|
*/
|
|
@@ -7,6 +7,7 @@ import { handleDetectDrift } from '../detect-drift.js';
|
|
|
7
7
|
import { handleSummarizeSpec } from '../summarize-spec.js';
|
|
8
8
|
import { handleGenerateChecklist } from '../generate-checklist.js';
|
|
9
9
|
import { handleReconcileSpec } from '../reconcile-spec.js';
|
|
10
|
+
import { runWithTrustedLocalMcpContext } from '../../engine/lifecycle-reconciliation.js';
|
|
10
11
|
import { handleLearn } from '../learn.js';
|
|
11
12
|
import { handleAudit } from '../audit.js';
|
|
12
13
|
import { handleConsultDocs } from '../consult-docs.js';
|
|
@@ -17,6 +18,16 @@ import { handleSuggestMcpServer } from '../suggest-mcp-server.js';
|
|
|
17
18
|
import { handleScanOrphanSpecRefs, ScanOrphanSpecRefsInputSchema, } from '../scan-orphan-spec-refs.js';
|
|
18
19
|
import { registerGraphSpecsTool } from '../graph-specs.js';
|
|
19
20
|
import { registerAuditSpecsDriftTool } from '../audit-specs-drift.js';
|
|
21
|
+
export const DeclaredDriftInputSchema = z.object({
|
|
22
|
+
kind: z
|
|
23
|
+
.enum(['architectural-premise'])
|
|
24
|
+
.describe('Drift kind. Only "architectural-premise" is supported: an orchestrator-declared premise contradiction that produces no auto-detectable file/scope drift.'),
|
|
25
|
+
reason: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(100)
|
|
28
|
+
.max(10_000)
|
|
29
|
+
.describe('Why the implementation drifted from the approved architectural premise. Minimum 100 characters — becomes the reconciliation transition reason.'),
|
|
30
|
+
});
|
|
20
31
|
import { handleSsrBackMigration } from '../ssr-back-migration.js';
|
|
21
32
|
import { registerMigrateLegacySpecTool } from '../migrate-legacy-spec.js';
|
|
22
33
|
/**
|
|
@@ -130,13 +141,14 @@ export function registerAnalysisTools(server) {
|
|
|
130
141
|
.max(1000)
|
|
131
142
|
.optional()
|
|
132
143
|
.describe('Manual changes to apply'),
|
|
144
|
+
declaredDrift: DeclaredDriftInputSchema.optional().describe('When the spec is implementing, routes reconcile_spec into the existing implementing→review reconciliation demotion instead of auto-detected drift.'),
|
|
133
145
|
},
|
|
134
146
|
}, safeGoverned('reconcile_spec', async (args) => {
|
|
135
147
|
const pid = resolveProjectId(args);
|
|
136
148
|
if (!pid) {
|
|
137
149
|
return missingProjectIdError;
|
|
138
150
|
}
|
|
139
|
-
return handleReconcileSpec({ ...args, projectId: pid });
|
|
151
|
+
return runWithTrustedLocalMcpContext(() => handleReconcileSpec({ ...args, projectId: pid }));
|
|
140
152
|
}));
|
|
141
153
|
// 15. learn_pattern
|
|
142
154
|
server.registerTool('learn_pattern', {
|
|
@@ -7,8 +7,7 @@ import { ensureWorkersStarted } from '../engine/workers/index.js';
|
|
|
7
7
|
import { recordError } from '../storage/error-telemetry-store.js';
|
|
8
8
|
import { hashProjectPath } from '../storage/base-store.js';
|
|
9
9
|
import { reportToolError, reportToolValidationError } from '../engine/telemetry/error-reporter.js';
|
|
10
|
-
import {
|
|
11
|
-
import { PLANU_VERSION } from '../config/version.js';
|
|
10
|
+
import { sendTelemetryEnvelopeEvent } from '../engine/telemetry/telemetry-client.js';
|
|
12
11
|
import { recordToolTokens, extractOutputText } from './token-recording.js';
|
|
13
12
|
import { DriftCacheStore } from '../storage/drift-cache-store.js';
|
|
14
13
|
import { consumeUpdateBanner } from '../engine/update-notifier.js';
|
|
@@ -509,18 +508,13 @@ function safeWithTelemetry(toolName, handler) {
|
|
|
509
508
|
const outputText = extractOutputText(processedResult);
|
|
510
509
|
recordLlmTokens(toolName, inputText, outputText);
|
|
511
510
|
}
|
|
512
|
-
// Emit
|
|
511
|
+
// Emit mcp_tool_completed envelope for successful calls (fire-and-forget)
|
|
513
512
|
/* v8 ignore start */
|
|
514
513
|
if (toolName !== undefined && processedResult.isError !== true) {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
planVersion: PLANU_VERSION,
|
|
520
|
-
nodeVersion: process.version,
|
|
521
|
-
platform: process.platform,
|
|
522
|
-
durationMs: Date.now() - startTime,
|
|
523
|
-
},
|
|
514
|
+
sendTelemetryEnvelopeEvent('mcp_tool_completed', {
|
|
515
|
+
toolName,
|
|
516
|
+
result: 'success',
|
|
517
|
+
durationMs: Date.now() - startTime,
|
|
524
518
|
});
|
|
525
519
|
}
|
|
526
520
|
/* v8 ignore stop */
|
|
@@ -124,6 +124,7 @@ export interface ReconciliationReceiptV1 {
|
|
|
124
124
|
transitionId?: string;
|
|
125
125
|
phase?: 'prepared' | 'status-committed' | 'spec-synced' | 'audit-appended';
|
|
126
126
|
auditPending?: boolean;
|
|
127
|
+
driftSource?: 'declared-architectural-premise';
|
|
127
128
|
}
|
|
128
129
|
/** Injectable receipt persistence boundary used only for reconciliation crash tests. */
|
|
129
130
|
export interface ReconciliationIo {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GlobalProjectsRegistry, RegisteredProject } from './cross-repo-search.js';
|
|
1
2
|
export interface StaleBranchInfo {
|
|
2
3
|
/** Short ref name, e.g. 'tmp-foo' or 'feat/spec-100-bar'. */
|
|
3
4
|
name: string;
|
|
@@ -150,4 +151,37 @@ export interface RuntimeResidueSweepResult {
|
|
|
150
151
|
/** Absolute paths of every legacy runtime artifact removed from planu/. */
|
|
151
152
|
removed: string[];
|
|
152
153
|
}
|
|
154
|
+
export interface LegacyPlanuDemolitionFailure {
|
|
155
|
+
/** Directory name under <legacyRoot>/data/projects. */
|
|
156
|
+
dir: string;
|
|
157
|
+
/** Human-readable reason the directory could not be migrated. */
|
|
158
|
+
reason: string;
|
|
159
|
+
}
|
|
160
|
+
export type LegacyPlanuDemolitionStatus = 'demolished' | 'retained' | 'absent';
|
|
161
|
+
export interface LegacyPlanuDemolitionReport {
|
|
162
|
+
status: LegacyPlanuDemolitionStatus;
|
|
163
|
+
/** Directory names successfully migrated into their canonical destination. */
|
|
164
|
+
migrated: string[];
|
|
165
|
+
/** Count of unmappable directories deleted outright. */
|
|
166
|
+
demolishedUnmappable: number;
|
|
167
|
+
/** Per-directory migration failures; the root is retained when this is non-empty. */
|
|
168
|
+
failures: LegacyPlanuDemolitionFailure[];
|
|
169
|
+
/** Bytes freed by deleting the whole legacy root (0 unless status is 'demolished'). */
|
|
170
|
+
freedBytes: number;
|
|
171
|
+
}
|
|
172
|
+
export interface LegacyPlanuDemolisherOptions {
|
|
173
|
+
/** Legacy root to sweep. Defaults to join(homedir(), '.planu'). */
|
|
174
|
+
legacyRoot?: string;
|
|
175
|
+
/** Global cross-repo registry. Defaults to a live read via getRegistry(). */
|
|
176
|
+
registry?: GlobalProjectsRegistry;
|
|
177
|
+
/** Resolves a registered project's canonical storage destination. */
|
|
178
|
+
resolveDestination?: (project: RegisteredProject) => Promise<string>;
|
|
179
|
+
}
|
|
180
|
+
/** Internal accumulator returned by the data/projects sweep pass. */
|
|
181
|
+
export interface LegacyPlanuSweepResult {
|
|
182
|
+
migrated: string[];
|
|
183
|
+
demolishedUnmappable: number;
|
|
184
|
+
failures: LegacyPlanuDemolitionFailure[];
|
|
185
|
+
freedBytes: number;
|
|
186
|
+
}
|
|
153
187
|
//# sourceMappingURL=housekeeping.d.ts.map
|
package/dist/types/scope.d.ts
CHANGED
|
@@ -126,4 +126,27 @@ export interface ContradictionPatternDef {
|
|
|
126
126
|
/** Recommended resolution action. */
|
|
127
127
|
recommendation: string;
|
|
128
128
|
}
|
|
129
|
+
/** A pair of antonymic direction phrases used to detect cross-spec premise drift (SPEC-1702). */
|
|
130
|
+
export interface DirectionalPhrasePair {
|
|
131
|
+
/** One side of the directional claim, e.g. "re-roots at". */
|
|
132
|
+
phraseA: string;
|
|
133
|
+
/** The opposite side of the directional claim, e.g. "relocates to". */
|
|
134
|
+
phraseB: string;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A contradiction between a spec's body claim about a done/approved sibling spec
|
|
138
|
+
* and that sibling's own delivered contract (SPEC-1702).
|
|
139
|
+
*/
|
|
140
|
+
export interface CrossSpecPremiseFinding {
|
|
141
|
+
/** ID of the spec containing the premise claim (e.g. "SPEC-1698"). */
|
|
142
|
+
targetSpecId: string;
|
|
143
|
+
/** ID of the referenced sibling spec whose contract contradicts the claim (e.g. "SPEC-1695"). */
|
|
144
|
+
siblingSpecId: string;
|
|
145
|
+
/** The exact sentence in the target spec asserting the contradicted claim. */
|
|
146
|
+
targetSentence: string;
|
|
147
|
+
/** The exact sentence in the sibling spec's own body stating the opposite direction. */
|
|
148
|
+
siblingSentence: string;
|
|
149
|
+
/** File path shared between both specs' ## Files sections, evidencing the same subject. */
|
|
150
|
+
sharedFilePath: string;
|
|
151
|
+
}
|
|
129
152
|
//# sourceMappingURL=scope.d.ts.map
|