@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## [5.7.0] - 2026-08-31
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- feat: registry-mapped ~/.planu demolisher and storage isolation guard (SPEC-1709)
|
|
5
|
+
- feat(lifecycle): SPEC-1703 declared architectural-premise drift routes reconcile_spec demotion
|
|
6
|
+
- feat: add done-drift missing-files compliance check (SPEC-1701)
|
|
7
|
+
- feat(telemetry): SPEC-1704 anonymous event envelope v1 and consent CLI
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
- fix: serialize prepublish-guard suite to avoid full-load spawn flake
|
|
11
|
+
- fix: declare telemetry and test-home env vars in schema and serialize lock-lease suite
|
|
12
|
+
- fix: reuse shared pathExists helper in legacy planu demolisher (SPEC-1709)
|
|
13
|
+
- fix: harden legacy planu demolisher (symlinks, canonical root) SPEC-1709
|
|
14
|
+
- fix: enforce mcp trust boundary and bind drift source into receipt digest (SPEC-1703)
|
|
15
|
+
- fix: exclude globs and directory refs from done-drift check, wire early return (SPEC-1701)
|
|
16
|
+
- fix(telemetry): close legacy properties, validate ids, truncate show output (SPEC-1704)
|
|
17
|
+
- fix(gates): destination-aware premise matching and tool-level AC1 coverage (SPEC-1702 review fixes)
|
|
18
|
+
- fix(gates): SPEC-1702 cross-spec premise contradiction detection
|
|
19
|
+
|
|
20
|
+
|
|
1
21
|
## [5.6.0] - 2026-08-31
|
|
2
22
|
|
|
3
23
|
### Features
|
package/dist/.planu-build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"commit":"
|
|
1
|
+
{"schemaVersion":1,"commit":"e145c9f04adb18813ddff699db636c390b20a776"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// cli/commands/telemetry.ts — planu telemetry status|enable|disable|show (SPEC-1704)
|
|
2
|
+
import { isTelemetryEnabled, readTelemetryConfig, enableTelemetry, disableTelemetry, } from '../../engine/telemetry/telemetry-store.js';
|
|
3
|
+
import { buildTelemetryEnvelope, TELEMETRY_SESSION_ID, } from '../../engine/telemetry/event-envelope.js';
|
|
4
|
+
import { bold, dim, green, red } from '../colors.js';
|
|
5
|
+
const NOTICE = 'Planu telemetry, when enabled, sends anonymous usage events (tool name, result, duration ' +
|
|
6
|
+
'bucket, operating system, Node major version, Planu version) tagged with a random per-install ' +
|
|
7
|
+
'id. It never sends prompts, tool arguments or results, spec content, source code, file paths, ' +
|
|
8
|
+
'usernames, hostnames, repository names, environment variables, tokens, or raw stack traces. ' +
|
|
9
|
+
'Run `planu telemetry disable` to opt out anytime.';
|
|
10
|
+
const TRUNCATED_ID_LENGTH = 8;
|
|
11
|
+
const SAMPLE_INSTALLATION_ID = '00000000-0000-4000-8000-000000000000';
|
|
12
|
+
const SAMPLE_DURATION_MS = 450;
|
|
13
|
+
function truncateId(id) {
|
|
14
|
+
if (!id) {
|
|
15
|
+
return '(none)';
|
|
16
|
+
}
|
|
17
|
+
return `${id.slice(0, TRUNCATED_ID_LENGTH)}…`;
|
|
18
|
+
}
|
|
19
|
+
function activeSuppressionFlags() {
|
|
20
|
+
const active = [];
|
|
21
|
+
if (process.env.PLANU_TELEMETRY_DISABLED === '1') {
|
|
22
|
+
active.push('PLANU_TELEMETRY_DISABLED=1');
|
|
23
|
+
}
|
|
24
|
+
if (process.env.DO_NOT_TRACK === '1') {
|
|
25
|
+
active.push('DO_NOT_TRACK=1');
|
|
26
|
+
}
|
|
27
|
+
if (process.env.CI === 'true') {
|
|
28
|
+
active.push('CI=true');
|
|
29
|
+
}
|
|
30
|
+
if (process.env.PLANU_TELEMETRY === 'off') {
|
|
31
|
+
active.push('PLANU_TELEMETRY=off');
|
|
32
|
+
}
|
|
33
|
+
return active;
|
|
34
|
+
}
|
|
35
|
+
async function runStatus(flags) {
|
|
36
|
+
const enabled = await isTelemetryEnabled();
|
|
37
|
+
const config = await readTelemetryConfig();
|
|
38
|
+
const suppressionFlagsActive = activeSuppressionFlags();
|
|
39
|
+
if (flags?.json) {
|
|
40
|
+
process.stdout.write(JSON.stringify({
|
|
41
|
+
enabled,
|
|
42
|
+
consentVersion: config?.consentVersion,
|
|
43
|
+
anonymousInstallationId: truncateId(config?.anonymousInstallationId),
|
|
44
|
+
suppressionFlagsActive,
|
|
45
|
+
}) + '\n');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const lines = [
|
|
49
|
+
`${bold('Telemetry:')} ${enabled ? green('enabled') : red('disabled')}`,
|
|
50
|
+
`${dim('Installation id:')} ${truncateId(config?.anonymousInstallationId)}`,
|
|
51
|
+
];
|
|
52
|
+
if (suppressionFlagsActive.length > 0) {
|
|
53
|
+
lines.push(`${dim('Suppressed by:')} ${suppressionFlagsActive.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
56
|
+
}
|
|
57
|
+
async function runEnable(flags) {
|
|
58
|
+
const config = await enableTelemetry();
|
|
59
|
+
if (flags?.json) {
|
|
60
|
+
process.stdout.write(JSON.stringify({
|
|
61
|
+
enabled: true,
|
|
62
|
+
anonymousInstallationId: truncateId(config.anonymousInstallationId),
|
|
63
|
+
}) + '\n');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
process.stdout.write(`${green('Telemetry enabled.')} ${NOTICE}\n`);
|
|
67
|
+
}
|
|
68
|
+
async function runDisable(flags) {
|
|
69
|
+
await disableTelemetry();
|
|
70
|
+
if (flags?.json) {
|
|
71
|
+
process.stdout.write(JSON.stringify({ enabled: false }) + '\n');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
process.stdout.write(`${red('Telemetry disabled.')} No anonymous usage events will be sent.\n`);
|
|
75
|
+
}
|
|
76
|
+
async function runShow() {
|
|
77
|
+
const config = await readTelemetryConfig();
|
|
78
|
+
const envelope = buildTelemetryEnvelope('mcp_tool_completed', {
|
|
79
|
+
anonymousInstallationId: SAMPLE_INSTALLATION_ID,
|
|
80
|
+
sessionId: TELEMETRY_SESSION_ID,
|
|
81
|
+
toolName: 'planu_status',
|
|
82
|
+
result: 'success',
|
|
83
|
+
durationMs: SAMPLE_DURATION_MS,
|
|
84
|
+
});
|
|
85
|
+
process.stdout.write(`${dim('Current installation id:')} ${truncateId(config?.anonymousInstallationId)}\n`);
|
|
86
|
+
const displayEnvelope = {
|
|
87
|
+
...envelope,
|
|
88
|
+
anonymousInstallationId: truncateId(envelope.anonymousInstallationId),
|
|
89
|
+
};
|
|
90
|
+
process.stdout.write(JSON.stringify(displayEnvelope, null, 2) + '\n');
|
|
91
|
+
}
|
|
92
|
+
export const telemetryCommand = {
|
|
93
|
+
name: 'telemetry',
|
|
94
|
+
description: 'Manage local telemetry consent (status | enable | disable | show)',
|
|
95
|
+
usage: 'planu telemetry <status|enable|disable|show> [--json]',
|
|
96
|
+
async run(args, flags) {
|
|
97
|
+
const subcommand = args[0] ?? 'status';
|
|
98
|
+
switch (subcommand) {
|
|
99
|
+
case 'status':
|
|
100
|
+
await runStatus(flags);
|
|
101
|
+
return;
|
|
102
|
+
case 'enable':
|
|
103
|
+
await runEnable(flags);
|
|
104
|
+
return;
|
|
105
|
+
case 'disable':
|
|
106
|
+
await runDisable(flags);
|
|
107
|
+
return;
|
|
108
|
+
case 'show':
|
|
109
|
+
await runShow();
|
|
110
|
+
return;
|
|
111
|
+
default:
|
|
112
|
+
process.stderr.write(`${red('Error:')} Unknown telemetry subcommand "${subcommand}"\n`);
|
|
113
|
+
process.stderr.write(`Usage: ${telemetryCommand.usage}\n`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=telemetry.js.map
|
package/dist/cli/router.js
CHANGED
|
@@ -4,7 +4,7 @@ import { PLANU_VERSION } from '../config/version.js';
|
|
|
4
4
|
import { red, bold, cyan, dim } from './colors.js';
|
|
5
5
|
// Lazy-load commands to avoid importing tool handlers when not needed
|
|
6
6
|
async function loadCommands() {
|
|
7
|
-
const [{ initCommand }, { createCommand }, { listCommand }, { statusCommand }, { estimateCommand }, { validateCommand }, { dashboardCommand }, { auditCommand }, { serveCommand }, { handoffCommand }, { packageHandoffCommand }, { watchCommand }, { installCommand }, { doctorCommand }, { uninstallCommand }, { specCommand }, { healthCommand }, { releaseCommand }, { storageCommand }, { inspectCommand }, { offlineCommand }, { jobCommand },] = await Promise.all([
|
|
7
|
+
const [{ initCommand }, { createCommand }, { listCommand }, { statusCommand }, { estimateCommand }, { validateCommand }, { dashboardCommand }, { auditCommand }, { serveCommand }, { handoffCommand }, { packageHandoffCommand }, { watchCommand }, { installCommand }, { doctorCommand }, { uninstallCommand }, { specCommand }, { healthCommand }, { releaseCommand }, { storageCommand }, { inspectCommand }, { offlineCommand }, { jobCommand }, { telemetryCommand },] = await Promise.all([
|
|
8
8
|
import('./commands/init.js'),
|
|
9
9
|
import('./commands/create.js'),
|
|
10
10
|
import('./commands/list.js'),
|
|
@@ -27,6 +27,7 @@ async function loadCommands() {
|
|
|
27
27
|
import('./commands/inspect.js'),
|
|
28
28
|
import('./commands/offline.js'),
|
|
29
29
|
import('./commands/job.js'),
|
|
30
|
+
import('./commands/telemetry.js'),
|
|
30
31
|
]);
|
|
31
32
|
const cmds = [
|
|
32
33
|
initCommand,
|
|
@@ -51,6 +52,7 @@ async function loadCommands() {
|
|
|
51
52
|
inspectCommand,
|
|
52
53
|
offlineCommand,
|
|
53
54
|
jobCommand,
|
|
55
|
+
telemetryCommand,
|
|
54
56
|
];
|
|
55
57
|
const map = new Map();
|
|
56
58
|
for (const cmd of cmds) {
|
|
@@ -218,6 +218,13 @@
|
|
|
218
218
|
"sensitivity": "private",
|
|
219
219
|
"default": null
|
|
220
220
|
},
|
|
221
|
+
{
|
|
222
|
+
"name": "PLANU_TELEMETRY_DISABLED",
|
|
223
|
+
"type": "boolean",
|
|
224
|
+
"scope": "runtime",
|
|
225
|
+
"sensitivity": "public",
|
|
226
|
+
"default": null
|
|
227
|
+
},
|
|
221
228
|
{
|
|
222
229
|
"name": "PLANU_TELEMETRY_ENDPOINT",
|
|
223
230
|
"type": "string",
|
|
@@ -232,6 +239,13 @@
|
|
|
232
239
|
"sensitivity": "secret",
|
|
233
240
|
"default": null
|
|
234
241
|
},
|
|
242
|
+
{
|
|
243
|
+
"name": "PLANU_TEST_HOME",
|
|
244
|
+
"type": "string",
|
|
245
|
+
"scope": "test",
|
|
246
|
+
"sensitivity": "public",
|
|
247
|
+
"default": null
|
|
248
|
+
},
|
|
235
249
|
{
|
|
236
250
|
"name": "PLANU_TIMING",
|
|
237
251
|
"type": "boolean",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConflictReport, Contradiction, SpecAnalysisInput } from '../types/index.js';
|
|
1
|
+
import type { ConflictReport, Contradiction, CrossSpecPremiseFinding, SpecAnalysisInput } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Detect semantic contradictions between a list of specs.
|
|
4
4
|
* Each spec is compared against every other spec for all known patterns.
|
|
@@ -12,4 +12,5 @@ export declare function buildConflictReport(specs: SpecAnalysisInput[]): Conflic
|
|
|
12
12
|
* Format a ConflictReport as a markdown string.
|
|
13
13
|
*/
|
|
14
14
|
export declare function formatConflictReport(report: ConflictReport): string;
|
|
15
|
+
export declare function detectCrossSpecPremiseContradictions(targetSpecId: string, targetRawContent: string, projectPath: string | undefined): Promise<CrossSpecPremiseFinding[]>;
|
|
15
16
|
//# sourceMappingURL=contradiction-detector.d.ts.map
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// engine/contradiction-detector.ts — Semantic contradiction detection (SPEC-038)
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { findMarkdownSectionRange } from './spec-format/markdown-sections.js';
|
|
5
|
+
import { getUniqueTopLevelFrontmatterString, stripFrontmatter } from './frontmatter-parser.js';
|
|
1
6
|
// ---------------------------------------------------------------------------
|
|
2
7
|
// Contradiction patterns
|
|
3
8
|
// ---------------------------------------------------------------------------
|
|
@@ -155,4 +160,214 @@ export function formatConflictReport(report) {
|
|
|
155
160
|
}
|
|
156
161
|
return lines.join('\n');
|
|
157
162
|
}
|
|
163
|
+
const DIRECTIONAL_PHRASE_PAIRS = [
|
|
164
|
+
{ phraseA: 'out of', phraseB: 'into' },
|
|
165
|
+
{ phraseA: 're-roots at', phraseB: 'relocates to' },
|
|
166
|
+
{ phraseA: 'removes', phraseB: 'adds' },
|
|
167
|
+
{ phraseA: 'enables', phraseB: 'disables' },
|
|
168
|
+
];
|
|
169
|
+
const SIGNIFICANT_WORD_STOPWORDS = new Set([
|
|
170
|
+
'this',
|
|
171
|
+
'that',
|
|
172
|
+
'with',
|
|
173
|
+
'from',
|
|
174
|
+
'into',
|
|
175
|
+
'spec',
|
|
176
|
+
'specs',
|
|
177
|
+
'after',
|
|
178
|
+
'before',
|
|
179
|
+
'while',
|
|
180
|
+
'their',
|
|
181
|
+
'them',
|
|
182
|
+
'about',
|
|
183
|
+
'have',
|
|
184
|
+
'will',
|
|
185
|
+
'when',
|
|
186
|
+
'then',
|
|
187
|
+
'also',
|
|
188
|
+
'were',
|
|
189
|
+
'been',
|
|
190
|
+
'these',
|
|
191
|
+
'those',
|
|
192
|
+
]);
|
|
193
|
+
function splitBodyIntoSentenceFragments(body) {
|
|
194
|
+
const sentences = [];
|
|
195
|
+
for (const line of body.split('\n')) {
|
|
196
|
+
const trimmedLine = line.trim();
|
|
197
|
+
if (trimmedLine.length === 0) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const clause of trimmedLine.split(/(?<=[.!?])\s+/)) {
|
|
201
|
+
const sentence = clause.trim();
|
|
202
|
+
if (sentence.length > 0) {
|
|
203
|
+
sentences.push(sentence);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return sentences;
|
|
208
|
+
}
|
|
209
|
+
/** Extracts distinct "SPEC-NNNN" references from body text, excluding the spec's own ID. */
|
|
210
|
+
function extractReferencedSpecIds(body, excludeSpecId) {
|
|
211
|
+
const ids = new Set();
|
|
212
|
+
for (const match of body.matchAll(/\bSPEC-(\d+)\b/gi)) {
|
|
213
|
+
const number = match[1];
|
|
214
|
+
if (!number) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const id = `SPEC-${number}`;
|
|
218
|
+
if (id.toUpperCase() !== excludeSpecId.toUpperCase()) {
|
|
219
|
+
ids.add(id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return [...ids];
|
|
223
|
+
}
|
|
224
|
+
/** Extracts backtick-quoted file paths declared under a spec's "## Files" section. */
|
|
225
|
+
function extractFilesSectionPaths(body) {
|
|
226
|
+
const section = findMarkdownSectionRange(body, 'Files');
|
|
227
|
+
const paths = new Set();
|
|
228
|
+
if (!section) {
|
|
229
|
+
return paths;
|
|
230
|
+
}
|
|
231
|
+
const sectionText = body.slice(section.contentStart, section.end);
|
|
232
|
+
for (const match of sectionText.matchAll(/`([\w./-]+\.[A-Za-z0-9]+)`/g)) {
|
|
233
|
+
const path = match[1];
|
|
234
|
+
if (path) {
|
|
235
|
+
paths.add(path);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return paths;
|
|
239
|
+
}
|
|
240
|
+
function extractSignificantWords(sentence) {
|
|
241
|
+
const words = sentence.toLowerCase().match(/[a-z][a-z0-9._/-]{3,}/g) ?? [];
|
|
242
|
+
return new Set(words.filter((word) => !SIGNIFICANT_WORD_STOPWORDS.has(word)));
|
|
243
|
+
}
|
|
244
|
+
function shareSignificantWord(sentenceA, sentenceB) {
|
|
245
|
+
const wordsA = extractSignificantWords(sentenceA);
|
|
246
|
+
for (const word of extractSignificantWords(sentenceB)) {
|
|
247
|
+
if (wordsA.has(word)) {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const DESTINATION_TOKEN_PATTERN = /^`([^`]+)`|^([\w][\w./-]*)/;
|
|
254
|
+
function normalizeDestinationToken(token) {
|
|
255
|
+
return token.replace(/`/g, '').replace(/\/+$/, '').toLowerCase();
|
|
256
|
+
}
|
|
257
|
+
function extractDestinationToken(sentence, phrase) {
|
|
258
|
+
const phraseIndex = sentence.toLowerCase().indexOf(phrase);
|
|
259
|
+
if (phraseIndex === -1) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const after = sentence.slice(phraseIndex + phrase.length).trimStart();
|
|
263
|
+
const match = DESTINATION_TOKEN_PATTERN.exec(after);
|
|
264
|
+
const token = match?.[1] ?? match?.[2];
|
|
265
|
+
if (!token) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const isBacktickQuoted = match?.[1] !== undefined;
|
|
269
|
+
if (!isBacktickQuoted && !/[./]/.test(token)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return normalizeDestinationToken(token);
|
|
273
|
+
}
|
|
274
|
+
function sameDestination(targetSentence, targetPhrase, siblingSentence, siblingPhrase) {
|
|
275
|
+
const targetDestination = extractDestinationToken(targetSentence, targetPhrase);
|
|
276
|
+
const siblingDestination = extractDestinationToken(siblingSentence, siblingPhrase);
|
|
277
|
+
return (targetDestination !== null &&
|
|
278
|
+
siblingDestination !== null &&
|
|
279
|
+
targetDestination === siblingDestination);
|
|
280
|
+
}
|
|
281
|
+
function findOppositeDirectionSentence(targetSentence, siblingSentences) {
|
|
282
|
+
const lowerTarget = targetSentence.toLowerCase();
|
|
283
|
+
for (const pair of DIRECTIONAL_PHRASE_PAIRS) {
|
|
284
|
+
const targetHasA = lowerTarget.includes(pair.phraseA);
|
|
285
|
+
const targetPhrase = targetHasA ? pair.phraseA : pair.phraseB;
|
|
286
|
+
const oppositePhrase = targetHasA ? pair.phraseB : pair.phraseA;
|
|
287
|
+
if (!targetHasA && !lowerTarget.includes(pair.phraseB)) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (const siblingSentence of siblingSentences) {
|
|
291
|
+
if (siblingSentence.toLowerCase().includes(oppositePhrase) &&
|
|
292
|
+
shareSignificantWord(targetSentence, siblingSentence) &&
|
|
293
|
+
!sameDestination(targetSentence, targetPhrase, siblingSentence, oppositePhrase)) {
|
|
294
|
+
return siblingSentence;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
async function readSiblingSpecMarkdown(projectPath, siblingSpecId) {
|
|
301
|
+
const specsDir = join(projectPath, 'planu', 'specs');
|
|
302
|
+
let entries;
|
|
303
|
+
try {
|
|
304
|
+
entries = await readdir(specsDir);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const dirPrefix = `${siblingSpecId}-`.toLowerCase();
|
|
310
|
+
const dirName = entries.find((entry) => entry.toLowerCase() === siblingSpecId.toLowerCase() ||
|
|
311
|
+
entry.toLowerCase().startsWith(dirPrefix));
|
|
312
|
+
if (!dirName) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
return await readFile(join(specsDir, dirName, 'spec.md'), 'utf-8');
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export async function detectCrossSpecPremiseContradictions(targetSpecId, targetRawContent, projectPath) {
|
|
323
|
+
if (!projectPath) {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
const targetBody = stripFrontmatter(targetRawContent);
|
|
327
|
+
const referencedSpecIds = extractReferencedSpecIds(targetBody, targetSpecId);
|
|
328
|
+
if (referencedSpecIds.length === 0) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const targetFilePaths = extractFilesSectionPaths(targetBody);
|
|
332
|
+
if (targetFilePaths.size === 0) {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const targetSentences = splitBodyIntoSentenceFragments(targetBody);
|
|
336
|
+
const findings = [];
|
|
337
|
+
for (const siblingSpecId of referencedSpecIds) {
|
|
338
|
+
const siblingRawContent = await readSiblingSpecMarkdown(projectPath, siblingSpecId);
|
|
339
|
+
if (!siblingRawContent) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const siblingStatus = getUniqueTopLevelFrontmatterString(siblingRawContent, 'status');
|
|
343
|
+
if (siblingStatus !== 'done' && siblingStatus !== 'approved') {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const siblingBody = stripFrontmatter(siblingRawContent);
|
|
347
|
+
const siblingFilePaths = extractFilesSectionPaths(siblingBody);
|
|
348
|
+
const sharedFilePath = [...targetFilePaths].find((path) => siblingFilePaths.has(path));
|
|
349
|
+
if (!sharedFilePath) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const claimSentences = targetSentences.filter((sentence) => sentence.toUpperCase().includes(siblingSpecId.toUpperCase()));
|
|
353
|
+
if (claimSentences.length === 0) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const siblingSentences = splitBodyIntoSentenceFragments(siblingBody);
|
|
357
|
+
for (const targetSentence of claimSentences) {
|
|
358
|
+
const siblingSentence = findOppositeDirectionSentence(targetSentence, siblingSentences);
|
|
359
|
+
if (siblingSentence) {
|
|
360
|
+
findings.push({
|
|
361
|
+
targetSpecId,
|
|
362
|
+
siblingSpecId,
|
|
363
|
+
targetSentence,
|
|
364
|
+
siblingSentence,
|
|
365
|
+
sharedFilePath,
|
|
366
|
+
});
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return findings;
|
|
372
|
+
}
|
|
158
373
|
//# sourceMappingURL=contradiction-detector.js.map
|
|
@@ -168,6 +168,10 @@ export const ReconciliationReceiptV1Schema = z.object({
|
|
|
168
168
|
transitionId: z.string().min(1).optional(),
|
|
169
169
|
phase: z.enum(['prepared', 'status-committed', 'spec-synced', 'audit-appended']).optional(),
|
|
170
170
|
auditPending: z.boolean().optional(),
|
|
171
|
+
driftSource: z
|
|
172
|
+
.literal('declared-architectural-premise')
|
|
173
|
+
.optional()
|
|
174
|
+
.describe('Set when the reconciliation demotion evidence is an orchestrator-declared drift rather than an automated validation-report finding.'),
|
|
171
175
|
});
|
|
172
176
|
// ---------------------------------------------------------------------------
|
|
173
177
|
// Schema registry
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { LegacyPlanuDemolisherOptions, LegacyPlanuDemolitionReport } from '../../types/housekeeping.js';
|
|
2
|
+
export declare function demolishLegacyPlanuRoot(options?: LegacyPlanuDemolisherOptions): Promise<LegacyPlanuDemolitionReport>;
|
|
3
|
+
//# sourceMappingURL=legacy-planu-demolisher.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// engine/housekeeping/legacy-planu-demolisher.ts — SPEC-1709
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { lstat, readdir, realpath, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
6
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
|
|
7
|
+
import { globalDataDir, projectDataDir } from '../../storage/base-store.js';
|
|
8
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
|
|
9
|
+
import { getRegistry, readLogicalProjectId } from '../../storage/global-projects-store.js';
|
|
10
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
|
|
11
|
+
import { migrateCanonicalStorage } from '../../storage/migrations/canonical-storage.js';
|
|
12
|
+
// eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1709
|
|
13
|
+
import { resolveStorageLayout } from '../../storage/storage-layout.js';
|
|
14
|
+
import { pathExistsStrictByStat as pathExists } from '../../core/shared/fs.js';
|
|
15
|
+
const CANONICAL_ROOT_COLLISION_REASON = 'legacy root coincides with the canonical storage root; refusing to demolish';
|
|
16
|
+
function sha256Hex(value) {
|
|
17
|
+
return createHash('sha256').update(value).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
function isInsideDirectory(parent, candidate) {
|
|
20
|
+
const rel = relative(parent, candidate);
|
|
21
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
22
|
+
}
|
|
23
|
+
async function realpathOrSelf(path) {
|
|
24
|
+
try {
|
|
25
|
+
return await realpath(path);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function isRealPathContained(parentReal, candidate) {
|
|
32
|
+
const candidateReal = await realpathOrSelf(candidate);
|
|
33
|
+
return candidateReal === parentReal || isInsideDirectory(parentReal, candidateReal);
|
|
34
|
+
}
|
|
35
|
+
async function canonicalRootCollidesWithLegacy(legacyRoot) {
|
|
36
|
+
const legacyReal = await realpathOrSelf(legacyRoot);
|
|
37
|
+
const canonicalCandidates = [resolveStorageLayout().data, globalDataDir()];
|
|
38
|
+
for (const candidate of canonicalCandidates) {
|
|
39
|
+
if (await isRealPathContained(legacyReal, candidate)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
async function directorySizeBytes(root) {
|
|
46
|
+
let total = 0;
|
|
47
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const path = join(root, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
total += await directorySizeBytes(path);
|
|
52
|
+
}
|
|
53
|
+
else if (entry.isFile()) {
|
|
54
|
+
total += (await stat(path)).size;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return total;
|
|
58
|
+
}
|
|
59
|
+
async function defaultResolveDestination(project) {
|
|
60
|
+
const logicalProjectId = await readLogicalProjectId(project.path);
|
|
61
|
+
const projectId = logicalProjectId !== undefined
|
|
62
|
+
? logicalProjectId.replaceAll('-', '').toLowerCase()
|
|
63
|
+
: project.hash;
|
|
64
|
+
return projectDataDir(projectId);
|
|
65
|
+
}
|
|
66
|
+
async function buildDestinationMap(registry, resolveDestination) {
|
|
67
|
+
const map = new Map();
|
|
68
|
+
for (const project of registry.projects) {
|
|
69
|
+
const destination = await resolveDestination(project);
|
|
70
|
+
const fullDigest = sha256Hex(project.path);
|
|
71
|
+
map.set(fullDigest, destination);
|
|
72
|
+
map.set(fullDigest.slice(0, 16), destination);
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
async function migrateOrRecordFailure(dirName, dirPath, destination, migrated, failures) {
|
|
77
|
+
const sizeBeforeMigration = await directorySizeBytes(dirPath);
|
|
78
|
+
try {
|
|
79
|
+
const migration = await migrateCanonicalStorage({
|
|
80
|
+
legacyRoots: [dirPath],
|
|
81
|
+
destinationRoot: destination,
|
|
82
|
+
});
|
|
83
|
+
if (migration.status === 'error' || migration.status === 'rolled-back') {
|
|
84
|
+
failures.push({ dir: dirName, reason: migration.reason ?? migration.status });
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
migrated.push(dirName);
|
|
88
|
+
return sizeBeforeMigration;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
failures.push({ dir: dirName, reason: error instanceof Error ? error.message : String(error) });
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function sweepLegacyProjectsDir(legacyProjectsDir, trustedProjectsDirReal, destinationMap) {
|
|
96
|
+
const migrated = [];
|
|
97
|
+
const failures = [];
|
|
98
|
+
let demolishedUnmappable = 0;
|
|
99
|
+
let freedBytes = 0;
|
|
100
|
+
if (!(await pathExists(legacyProjectsDir))) {
|
|
101
|
+
return { migrated, demolishedUnmappable, failures, freedBytes };
|
|
102
|
+
}
|
|
103
|
+
const entries = await readdir(legacyProjectsDir, { withFileTypes: true });
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
const dirPath = join(legacyProjectsDir, entry.name);
|
|
106
|
+
if (entry.isSymbolicLink()) {
|
|
107
|
+
freedBytes += (await lstat(dirPath)).size;
|
|
108
|
+
await rm(dirPath, { force: true });
|
|
109
|
+
demolishedUnmappable += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!entry.isDirectory()) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!(await isRealPathContained(trustedProjectsDirReal, dirPath))) {
|
|
116
|
+
failures.push({ dir: entry.name, reason: 'path escapes the legacy projects directory' });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const destination = destinationMap.get(entry.name);
|
|
120
|
+
if (destination !== undefined) {
|
|
121
|
+
freedBytes += await migrateOrRecordFailure(entry.name, dirPath, destination, migrated, failures);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
freedBytes += await directorySizeBytes(dirPath);
|
|
125
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
126
|
+
demolishedUnmappable += 1;
|
|
127
|
+
}
|
|
128
|
+
return { migrated, demolishedUnmappable, failures, freedBytes };
|
|
129
|
+
}
|
|
130
|
+
async function remainingProjectEntryCount(legacyProjectsDir) {
|
|
131
|
+
if (!(await pathExists(legacyProjectsDir))) {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const entries = await readdir(legacyProjectsDir, { withFileTypes: true });
|
|
135
|
+
return entries.length;
|
|
136
|
+
}
|
|
137
|
+
export async function demolishLegacyPlanuRoot(options = {}) {
|
|
138
|
+
const legacyRoot = options.legacyRoot ?? join(homedir(), '.planu');
|
|
139
|
+
if (await canonicalRootCollidesWithLegacy(legacyRoot)) {
|
|
140
|
+
return {
|
|
141
|
+
status: 'retained',
|
|
142
|
+
migrated: [],
|
|
143
|
+
demolishedUnmappable: 0,
|
|
144
|
+
failures: [{ dir: '.', reason: CANONICAL_ROOT_COLLISION_REASON }],
|
|
145
|
+
freedBytes: 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!(await pathExists(legacyRoot))) {
|
|
149
|
+
return { status: 'absent', migrated: [], demolishedUnmappable: 0, failures: [], freedBytes: 0 };
|
|
150
|
+
}
|
|
151
|
+
const registry = options.registry ?? (await getRegistry());
|
|
152
|
+
const resolveDestination = options.resolveDestination ?? defaultResolveDestination;
|
|
153
|
+
const destinationMap = await buildDestinationMap(registry, resolveDestination);
|
|
154
|
+
const legacyProjectsDir = join(legacyRoot, 'data', 'projects');
|
|
155
|
+
const trustedProjectsDirReal = join(await realpathOrSelf(legacyRoot), 'data', 'projects');
|
|
156
|
+
const { migrated, demolishedUnmappable, failures, freedBytes: sweptBytes, } = await sweepLegacyProjectsDir(legacyProjectsDir, trustedProjectsDirReal, destinationMap);
|
|
157
|
+
if ((await remainingProjectEntryCount(legacyProjectsDir)) > 0) {
|
|
158
|
+
return { status: 'retained', migrated, demolishedUnmappable, failures, freedBytes: 0 };
|
|
159
|
+
}
|
|
160
|
+
const freedBytes = sweptBytes + (await directorySizeBytes(legacyRoot));
|
|
161
|
+
await rm(legacyRoot, { recursive: true, force: true });
|
|
162
|
+
return { status: 'demolished', migrated, demolishedUnmappable, failures, freedBytes };
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=legacy-planu-demolisher.js.map
|