@planu/cli 5.6.0 → 5.7.1
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 +41 -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/detectors/cache-db-detector.js +2 -2
- package/dist/engine/detectors/newsql-db-detector.js +2 -2
- package/dist/engine/detectors/search-engine-detector.js +2 -2
- package/dist/engine/detectors/vector-db-detector.js +2 -2
- package/dist/engine/detectors/widecolumn-db-detector.js +2 -2
- 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/reliability-gate.d.ts +4 -0
- package/dist/engine/validator/reliability-gate.js +93 -0
- 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/storage/migrations/canonical-storage.js +22 -9
- 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/tools/validate.js +36 -1
- 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 +18 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -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
|
|
@@ -110,6 +110,41 @@ async function completeForwardReconciliation(args) {
|
|
|
110
110
|
await args.persistReceipt(args.receiptPath, committed);
|
|
111
111
|
return committed;
|
|
112
112
|
}
|
|
113
|
+
async function resolveReviewDigest(args) {
|
|
114
|
+
const { input, spec, reason } = args;
|
|
115
|
+
if (input.declaredDriftKind === 'architectural-premise') {
|
|
116
|
+
const reviewDigest = digest(reason.trim());
|
|
117
|
+
if (reviewDigest !== input.implementationReviewDigest) {
|
|
118
|
+
return failure('declared drift review digest does not match the declared reason', 'RECONCILIATION_STALE');
|
|
119
|
+
}
|
|
120
|
+
return { reviewDigest, driftSource: 'declared-architectural-premise' };
|
|
121
|
+
}
|
|
122
|
+
const reportPath = join(projectDataDir(args.projectId), 'handoffs', spec.id, 'validation-report.json');
|
|
123
|
+
let reportBytes;
|
|
124
|
+
try {
|
|
125
|
+
const status = await lstat(reportPath);
|
|
126
|
+
if (status.isSymbolicLink() || !status.isFile()) {
|
|
127
|
+
return failure('validation report is unsafe');
|
|
128
|
+
}
|
|
129
|
+
reportBytes = await readFile(reportPath);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return failure('validation report is missing');
|
|
133
|
+
}
|
|
134
|
+
const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
|
|
135
|
+
if (!report.success ||
|
|
136
|
+
report.data.passed ||
|
|
137
|
+
report.data.reviewer.kind !== 'automation' ||
|
|
138
|
+
report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
|
|
139
|
+
report.data.reviewer.verdict !== 'changes-requested') {
|
|
140
|
+
return failure('validation report is not an automated changes-requested finding');
|
|
141
|
+
}
|
|
142
|
+
const reviewDigest = digest(reportBytes);
|
|
143
|
+
if (reviewDigest !== input.implementationReviewDigest) {
|
|
144
|
+
return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
|
|
145
|
+
}
|
|
146
|
+
return { reviewDigest };
|
|
147
|
+
}
|
|
113
148
|
async function validateRequest(args) {
|
|
114
149
|
const { input, spec, context } = args;
|
|
115
150
|
if (context.surface !== 'local-mcp') {
|
|
@@ -136,42 +171,41 @@ async function validateRequest(args) {
|
|
|
136
171
|
if (!latest?.transitionId || latest.transitionId !== input.expectedImplementingTransitionId) {
|
|
137
172
|
return failure('expected implementing transition is stale', 'RECONCILIATION_STALE');
|
|
138
173
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
return failure('validation report is missing');
|
|
150
|
-
}
|
|
151
|
-
const report = ValidationReportV1Schema.safeParse(JSON.parse(reportBytes.toString('utf8')));
|
|
152
|
-
if (!report.success ||
|
|
153
|
-
report.data.passed ||
|
|
154
|
-
report.data.reviewer.kind !== 'automation' ||
|
|
155
|
-
report.data.reviewer.agent !== AUTOMATED_VALIDATOR_AGENT ||
|
|
156
|
-
report.data.reviewer.verdict !== 'changes-requested') {
|
|
157
|
-
return failure('validation report is not an automated changes-requested finding');
|
|
158
|
-
}
|
|
159
|
-
const reviewDigest = digest(reportBytes);
|
|
160
|
-
if (reviewDigest !== input.implementationReviewDigest) {
|
|
161
|
-
return failure('implementation review digest does not match validation report', 'RECONCILIATION_STALE');
|
|
174
|
+
const resolution = await resolveReviewDigest({
|
|
175
|
+
input,
|
|
176
|
+
spec,
|
|
177
|
+
projectId: args.projectId,
|
|
178
|
+
reason: input.reason,
|
|
179
|
+
});
|
|
180
|
+
if (!('reviewDigest' in resolution) || typeof resolution.reviewDigest !== 'string') {
|
|
181
|
+
return resolution;
|
|
162
182
|
}
|
|
183
|
+
const resolved = resolution;
|
|
163
184
|
return {
|
|
164
|
-
reviewDigest,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
reviewDigest: resolved.reviewDigest,
|
|
186
|
+
driftSource: resolved.driftSource,
|
|
187
|
+
bindingDigest: computeBindingDigest({
|
|
188
|
+
projectId: args.projectId,
|
|
189
|
+
specId: spec.id,
|
|
190
|
+
requestId: input.reconciliationRequestId,
|
|
191
|
+
transitionId: latest.transitionId,
|
|
192
|
+
reviewDigest: resolved.reviewDigest,
|
|
193
|
+
reasonDigest: digest(input.reason.trim()),
|
|
194
|
+
driftSource: resolved.driftSource,
|
|
195
|
+
}),
|
|
173
196
|
};
|
|
174
197
|
}
|
|
198
|
+
function computeBindingDigest(args) {
|
|
199
|
+
return digest([
|
|
200
|
+
args.projectId,
|
|
201
|
+
args.specId,
|
|
202
|
+
args.requestId,
|
|
203
|
+
args.transitionId,
|
|
204
|
+
args.reviewDigest,
|
|
205
|
+
args.reasonDigest,
|
|
206
|
+
...(args.driftSource ? [args.driftSource] : []),
|
|
207
|
+
].join('\0'));
|
|
208
|
+
}
|
|
175
209
|
function validateDurableReceiptBinding(args) {
|
|
176
210
|
const { input, receipt } = args;
|
|
177
211
|
if (input.expectedImplementingTransitionId !== receipt.sourceImplementingTransitionId ||
|
|
@@ -180,14 +214,15 @@ function validateDurableReceiptBinding(args) {
|
|
|
180
214
|
digest(input.reason.trim()) !== receipt.reasonDigest) {
|
|
181
215
|
return failure('reconciliation retry binding conflicts with the durable receipt', 'RECONCILIATION_BINDING_CONFLICT');
|
|
182
216
|
}
|
|
183
|
-
const bindingDigest =
|
|
184
|
-
args.projectId,
|
|
185
|
-
args.specId,
|
|
186
|
-
receipt.requestId,
|
|
187
|
-
receipt.sourceImplementingTransitionId,
|
|
188
|
-
receipt.implementationReviewDigest,
|
|
189
|
-
receipt.reasonDigest,
|
|
190
|
-
|
|
217
|
+
const bindingDigest = computeBindingDigest({
|
|
218
|
+
projectId: args.projectId,
|
|
219
|
+
specId: args.specId,
|
|
220
|
+
requestId: receipt.requestId,
|
|
221
|
+
transitionId: receipt.sourceImplementingTransitionId,
|
|
222
|
+
reviewDigest: receipt.implementationReviewDigest,
|
|
223
|
+
reasonDigest: receipt.reasonDigest,
|
|
224
|
+
driftSource: receipt.driftSource,
|
|
225
|
+
});
|
|
191
226
|
if (bindingDigest !== receipt.bindingDigest) {
|
|
192
227
|
return failure('durable reconciliation binding is invalid', 'RECONCILIATION_BINDING_CONFLICT');
|
|
193
228
|
}
|
|
@@ -198,6 +233,11 @@ async function validateStoredBinding(args) {
|
|
|
198
233
|
if (durableBindingError) {
|
|
199
234
|
return durableBindingError;
|
|
200
235
|
}
|
|
236
|
+
if (args.receipt.driftSource === 'declared-architectural-premise') {
|
|
237
|
+
// No validation-report.json exists for a declared drift; the durable binding
|
|
238
|
+
// check above already re-derives and verifies the receipt's bindingDigest.
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
201
241
|
const reportPath = join(projectDataDir(args.projectId), 'handoffs', args.specId, 'validation-report.json');
|
|
202
242
|
try {
|
|
203
243
|
const reportBytes = await readFile(reportPath);
|
|
@@ -220,6 +260,11 @@ async function validateStoredBinding(args) {
|
|
|
220
260
|
export async function readCommittedReconciliations(projectId, specId) {
|
|
221
261
|
return readCommittedReconciliationReceipts(projectId, specId);
|
|
222
262
|
}
|
|
263
|
+
function extractDriftSource(validated) {
|
|
264
|
+
return 'driftSource' in validated && validated.driftSource === 'declared-architectural-premise'
|
|
265
|
+
? validated.driftSource
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
223
268
|
function receiptMatchesRequest(receipt, projectId, specId, requestId) {
|
|
224
269
|
return (receipt.projectId === projectId && receipt.specId === specId && receipt.requestId === requestId);
|
|
225
270
|
}
|
|
@@ -315,6 +360,7 @@ export async function reconcileImplementingSpec(args) {
|
|
|
315
360
|
typeof validated.reviewDigest !== 'string') {
|
|
316
361
|
return validated;
|
|
317
362
|
}
|
|
363
|
+
const driftSource = extractDriftSource(validated);
|
|
318
364
|
const expectedTransitionId = args.input.expectedImplementingTransitionId;
|
|
319
365
|
const reason = args.input.reason;
|
|
320
366
|
if (!expectedTransitionId || !reason) {
|
|
@@ -338,6 +384,7 @@ export async function reconcileImplementingSpec(args) {
|
|
|
338
384
|
stateHistory: [{ state: 'prepared', at: preparedAt }],
|
|
339
385
|
preparedAt,
|
|
340
386
|
phase: 'prepared',
|
|
387
|
+
...(driftSource ? { driftSource } : {}),
|
|
341
388
|
};
|
|
342
389
|
})();
|
|
343
390
|
if (!existing) {
|
|
@@ -10,6 +10,7 @@ import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validat
|
|
|
10
10
|
import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
|
|
11
11
|
import { evaluateImplementationContract } from './implementation-contract/index.js';
|
|
12
12
|
import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
|
|
13
|
+
import { detectCrossSpecPremiseContradictions } from './contradiction-detector.js';
|
|
13
14
|
// ── SPEC-784: Technical section quality constants ─────────────────────────────
|
|
14
15
|
const TECHNICAL_MIN_CHARS = 500;
|
|
15
16
|
// Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
|
|
@@ -454,7 +455,18 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
|
|
|
454
455
|
// concrete file paths, function names, or anticipated test breaks.
|
|
455
456
|
const specificityBlockers = checkSpecificityGate(spec, specificityEvidenceLines, fichaContent, anticipatedTestBreaksContent);
|
|
456
457
|
allBlockers.push(...specificityBlockers);
|
|
457
|
-
const
|
|
458
|
+
const scoreAfterSpecificityGate = specificityBlockers.length > 0 && spec.difficulty >= 3 ? Math.min(totalScore, 60) : totalScore;
|
|
459
|
+
// SPEC-1702: strict mode cross-checks premise claims about done/approved sibling
|
|
460
|
+
// specs against those siblings' own contracts and caps the score below 100.
|
|
461
|
+
const crossSpecPremiseFindings = mode === 'strict'
|
|
462
|
+
? await detectCrossSpecPremiseContradictions(spec.id, huRaw, projectPathFromSpecPath(spec.specPath))
|
|
463
|
+
: [];
|
|
464
|
+
for (const finding of crossSpecPremiseFindings) {
|
|
465
|
+
allBlockers.push(`cross-spec-premise: ${finding.targetSpecId} claims "${finding.targetSentence}" but ${finding.siblingSpecId} states "${finding.siblingSentence}" (shared file: ${finding.sharedFilePath})`);
|
|
466
|
+
}
|
|
467
|
+
const effectiveScore = crossSpecPremiseFindings.length > 0
|
|
468
|
+
? Math.min(scoreAfterSpecificityGate, 90)
|
|
469
|
+
: scoreAfterSpecificityGate;
|
|
458
470
|
const breakdown = {
|
|
459
471
|
hu: hu.points,
|
|
460
472
|
criteria: criteria.points,
|
|
@@ -8,20 +8,20 @@ export declare function isErrorReportingEnabled(): boolean;
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function sanitizeErrorMessage(message: string): string;
|
|
10
10
|
/**
|
|
11
|
-
* Classifies a telemetry event as
|
|
12
|
-
* Gate blocks (DoD, validate score, spec locked, invalid transition) →
|
|
13
|
-
* Unhandled exceptions →
|
|
11
|
+
* Classifies a telemetry event as blocked or error, mapping onto the envelope's `result` field.
|
|
12
|
+
* Gate blocks (DoD, validate score, spec locked, invalid transition) → blocked.
|
|
13
|
+
* Unhandled exceptions → error.
|
|
14
14
|
*/
|
|
15
|
-
export declare function classifyToolEvent(errorMessage: string, errorType?: string): '
|
|
15
|
+
export declare function classifyToolEvent(errorMessage: string, errorType?: string): 'error' | 'blocked';
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Emits an mcp_tool_failed envelope for an unhandled exception. Fire-and-forget — never throws.
|
|
18
|
+
* The envelope has no message field in Phase 1, so no error text is ever transmitted.
|
|
18
19
|
* Call this from safe-handler catch blocks after returning isError:true to the user.
|
|
19
20
|
*/
|
|
20
|
-
export declare function reportToolError(toolName: string,
|
|
21
|
+
export declare function reportToolError(toolName: string, _error: unknown): void;
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* Other expected errors (missing params, not-found, etc.) emit tool_error.
|
|
23
|
+
* Emits an mcp_tool_failed envelope for a business-logic validation failure, classifying
|
|
24
|
+
* `result` as blocked (gate/policy blocks) or error (other expected failures).
|
|
25
25
|
* Fire-and-forget — never throws.
|
|
26
26
|
*/
|
|
27
27
|
export declare function reportToolValidationError(toolName: string, message: string): void;
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// Fire-and-forget. Sends sanitized error info to Supabase on unhandled tool exceptions.
|
|
4
4
|
// Opt-out: set PLANU_TELEMETRY=off to disable remote reporting.
|
|
5
5
|
// No PII: only tool name, sanitized error message, version, and Node version are sent.
|
|
6
|
-
import {
|
|
7
|
-
import { PLANU_VERSION } from '../../config/version.js';
|
|
6
|
+
import { sendTelemetryEnvelopeEvent } from './telemetry-client.js';
|
|
8
7
|
import { redactText } from '../../security/redactor.js';
|
|
9
8
|
import { TELEMETRY_CONSENT_VERSION } from './telemetry-store.js';
|
|
10
9
|
/**
|
|
@@ -31,58 +30,40 @@ const BLOCKED_PATTERNS = [
|
|
|
31
30
|
'does not meet',
|
|
32
31
|
];
|
|
33
32
|
/**
|
|
34
|
-
* Classifies a telemetry event as
|
|
35
|
-
* Gate blocks (DoD, validate score, spec locked, invalid transition) →
|
|
36
|
-
* Unhandled exceptions →
|
|
33
|
+
* Classifies a telemetry event as blocked or error, mapping onto the envelope's `result` field.
|
|
34
|
+
* Gate blocks (DoD, validate score, spec locked, invalid transition) → blocked.
|
|
35
|
+
* Unhandled exceptions → error.
|
|
37
36
|
*/
|
|
38
37
|
export function classifyToolEvent(errorMessage, errorType) {
|
|
39
38
|
if (errorType === 'validation' || errorType === 'gate') {
|
|
40
|
-
return '
|
|
39
|
+
return 'blocked';
|
|
41
40
|
}
|
|
42
41
|
if (BLOCKED_PATTERNS.some((pattern) => errorMessage.includes(pattern))) {
|
|
43
|
-
return '
|
|
42
|
+
return 'blocked';
|
|
44
43
|
}
|
|
45
|
-
return '
|
|
44
|
+
return 'error';
|
|
46
45
|
}
|
|
47
46
|
/**
|
|
48
|
-
*
|
|
47
|
+
* Emits an mcp_tool_failed envelope for an unhandled exception. Fire-and-forget — never throws.
|
|
48
|
+
* The envelope has no message field in Phase 1, so no error text is ever transmitted.
|
|
49
49
|
* Call this from safe-handler catch blocks after returning isError:true to the user.
|
|
50
50
|
*/
|
|
51
|
-
export function reportToolError(toolName,
|
|
51
|
+
export function reportToolError(toolName, _error) {
|
|
52
52
|
if (process.env.PLANU_TELEMETRY === 'off') {
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
sendTelemetryEvent({
|
|
57
|
-
event: 'tool_error',
|
|
58
|
-
properties: {
|
|
59
|
-
tool: toolName,
|
|
60
|
-
errorType: 'exception',
|
|
61
|
-
errorClass,
|
|
62
|
-
planVersion: PLANU_VERSION,
|
|
63
|
-
nodeVersion: process.version,
|
|
64
|
-
},
|
|
65
|
-
});
|
|
55
|
+
sendTelemetryEnvelopeEvent('mcp_tool_failed', { toolName, result: 'error' });
|
|
66
56
|
}
|
|
67
57
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* Other expected errors (missing params, not-found, etc.) emit tool_error.
|
|
58
|
+
* Emits an mcp_tool_failed envelope for a business-logic validation failure, classifying
|
|
59
|
+
* `result` as blocked (gate/policy blocks) or error (other expected failures).
|
|
71
60
|
* Fire-and-forget — never throws.
|
|
72
61
|
*/
|
|
73
62
|
export function reportToolValidationError(toolName, message) {
|
|
74
63
|
if (process.env.PLANU_TELEMETRY === 'off') {
|
|
75
64
|
return;
|
|
76
65
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
event,
|
|
80
|
-
properties: {
|
|
81
|
-
tool: toolName,
|
|
82
|
-
errorType: 'validation',
|
|
83
|
-
planVersion: PLANU_VERSION,
|
|
84
|
-
nodeVersion: process.version,
|
|
85
|
-
},
|
|
86
|
-
});
|
|
66
|
+
const result = classifyToolEvent(message, 'validation');
|
|
67
|
+
sendTelemetryEnvelopeEvent('mcp_tool_failed', { toolName, result });
|
|
87
68
|
}
|
|
88
69
|
//# sourceMappingURL=error-reporter.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TelemetryEnvelopeV1, TelemetryEnvelopeEventName, TelemetryEnvelopeInput, TelemetryEnvelopeBuildOptions, TelemetryDurationBucket } from '../../types/index.js';
|
|
2
|
+
/** Ephemeral per-process session id, generated once at module load. */
|
|
3
|
+
export declare const TELEMETRY_SESSION_ID: `${string}-${string}-${string}-${string}-${string}`;
|
|
4
|
+
export declare function bucketDuration(durationMs: number): TelemetryDurationBucket;
|
|
5
|
+
/**
|
|
6
|
+
* Builds a schemaVersion-1 telemetry envelope with exactly the allowlisted fields.
|
|
7
|
+
* Unknown fields on `fields` and unknown enum values are rejected (throw), never stored.
|
|
8
|
+
* `options.uuid`/`options.clock` make eventId/occurredAt deterministic for tests and `show`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildTelemetryEnvelope(name: TelemetryEnvelopeEventName, fields: TelemetryEnvelopeInput, options?: TelemetryEnvelopeBuildOptions): TelemetryEnvelopeV1;
|
|
11
|
+
//# sourceMappingURL=event-envelope.d.ts.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// engine/telemetry/event-envelope.ts — SPEC-1704: versioned, allowlisted telemetry envelope v1.
|
|
2
|
+
// Every field is typed and closed; unknown fields or enum values are rejected rather than
|
|
3
|
+
// stored, so no free-form data (paths, args, spec content) has a code path into the envelope.
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { PLANU_VERSION } from '../../config/version.js';
|
|
6
|
+
/** Ephemeral per-process session id, generated once at module load. */
|
|
7
|
+
export const TELEMETRY_SESSION_ID = randomUUID();
|
|
8
|
+
const EVENT_NAMES = new Set([
|
|
9
|
+
'installation_activated',
|
|
10
|
+
'mcp_server_started',
|
|
11
|
+
'mcp_tool_completed',
|
|
12
|
+
'mcp_tool_failed',
|
|
13
|
+
'spec_lifecycle_transitioned',
|
|
14
|
+
'release_check_completed',
|
|
15
|
+
]);
|
|
16
|
+
const RESULTS = new Set([
|
|
17
|
+
'success',
|
|
18
|
+
'error',
|
|
19
|
+
'blocked',
|
|
20
|
+
'cancelled',
|
|
21
|
+
]);
|
|
22
|
+
const MCP_HOSTS = new Set([
|
|
23
|
+
'claude-code',
|
|
24
|
+
'claude-desktop',
|
|
25
|
+
'cursor',
|
|
26
|
+
'codex',
|
|
27
|
+
'other',
|
|
28
|
+
'unknown',
|
|
29
|
+
]);
|
|
30
|
+
const OPERATING_SYSTEMS = new Set(['darwin', 'linux', 'win32']);
|
|
31
|
+
const ALLOWED_INPUT_KEYS = new Set([
|
|
32
|
+
'anonymousInstallationId',
|
|
33
|
+
'sessionId',
|
|
34
|
+
'toolName',
|
|
35
|
+
'result',
|
|
36
|
+
'durationMs',
|
|
37
|
+
'mcpHost',
|
|
38
|
+
]);
|
|
39
|
+
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
40
|
+
const MAX_TOOL_NAME_LENGTH = 64;
|
|
41
|
+
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;
|
|
42
|
+
const FAST_MS = 100;
|
|
43
|
+
const MEDIUM_MS = 1_000;
|
|
44
|
+
const SLOW_MS = 5_000;
|
|
45
|
+
export function bucketDuration(durationMs) {
|
|
46
|
+
if (durationMs < FAST_MS) {
|
|
47
|
+
return 'lt_100ms';
|
|
48
|
+
}
|
|
49
|
+
if (durationMs < MEDIUM_MS) {
|
|
50
|
+
return '100ms_1s';
|
|
51
|
+
}
|
|
52
|
+
if (durationMs < SLOW_MS) {
|
|
53
|
+
return '1s_5s';
|
|
54
|
+
}
|
|
55
|
+
return 'gte_5s';
|
|
56
|
+
}
|
|
57
|
+
function resolveOperatingSystem() {
|
|
58
|
+
return OPERATING_SYSTEMS.has(process.platform)
|
|
59
|
+
? process.platform
|
|
60
|
+
: 'other';
|
|
61
|
+
}
|
|
62
|
+
function resolveNodeMajor() {
|
|
63
|
+
return Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10);
|
|
64
|
+
}
|
|
65
|
+
function rejectUnknownFields(fields) {
|
|
66
|
+
for (const key of Object.keys(fields)) {
|
|
67
|
+
if (!ALLOWED_INPUT_KEYS.has(key)) {
|
|
68
|
+
throw new Error(`Rejected telemetry event: unknown field "${key}"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function rejectUnknownEnumValues(fields) {
|
|
73
|
+
if (fields.result !== undefined && !RESULTS.has(fields.result)) {
|
|
74
|
+
throw new Error(`Rejected telemetry event: unknown result "${fields.result}"`);
|
|
75
|
+
}
|
|
76
|
+
if (fields.mcpHost !== undefined && !MCP_HOSTS.has(fields.mcpHost)) {
|
|
77
|
+
throw new Error(`Rejected telemetry event: unknown mcpHost "${fields.mcpHost}"`);
|
|
78
|
+
}
|
|
79
|
+
if (fields.toolName !== undefined &&
|
|
80
|
+
fields.toolName !== '' &&
|
|
81
|
+
(fields.toolName.length > MAX_TOOL_NAME_LENGTH || !TOOL_NAME_PATTERN.test(fields.toolName))) {
|
|
82
|
+
throw new Error(`Rejected telemetry event: unknown toolName "${fields.toolName}"`);
|
|
83
|
+
}
|
|
84
|
+
if (!UUID_V4_PATTERN.test(fields.anonymousInstallationId)) {
|
|
85
|
+
throw new Error('Rejected telemetry event: malformed anonymousInstallationId');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Builds a schemaVersion-1 telemetry envelope with exactly the allowlisted fields.
|
|
90
|
+
* Unknown fields on `fields` and unknown enum values are rejected (throw), never stored.
|
|
91
|
+
* `options.uuid`/`options.clock` make eventId/occurredAt deterministic for tests and `show`.
|
|
92
|
+
*/
|
|
93
|
+
export function buildTelemetryEnvelope(name, fields, options = {}) {
|
|
94
|
+
if (!EVENT_NAMES.has(name)) {
|
|
95
|
+
throw new Error(`Rejected telemetry event: unknown eventName "${name}"`);
|
|
96
|
+
}
|
|
97
|
+
rejectUnknownFields(fields);
|
|
98
|
+
rejectUnknownEnumValues(fields);
|
|
99
|
+
const uuid = options.uuid ?? randomUUID;
|
|
100
|
+
const clock = options.clock ?? (() => new Date());
|
|
101
|
+
const envelope = {
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
eventId: uuid(),
|
|
104
|
+
eventName: name,
|
|
105
|
+
occurredAt: clock().toISOString(),
|
|
106
|
+
anonymousInstallationId: fields.anonymousInstallationId,
|
|
107
|
+
sessionId: fields.sessionId,
|
|
108
|
+
planuVersion: PLANU_VERSION,
|
|
109
|
+
operatingSystem: resolveOperatingSystem(),
|
|
110
|
+
nodeMajor: resolveNodeMajor(),
|
|
111
|
+
mcpHost: fields.mcpHost ?? 'unknown',
|
|
112
|
+
};
|
|
113
|
+
if (fields.toolName !== undefined && fields.toolName !== '') {
|
|
114
|
+
envelope.toolName = fields.toolName;
|
|
115
|
+
}
|
|
116
|
+
if (fields.result !== undefined) {
|
|
117
|
+
envelope.result = fields.result;
|
|
118
|
+
}
|
|
119
|
+
if (fields.durationMs !== undefined) {
|
|
120
|
+
envelope.durationBucket = bucketDuration(fields.durationMs);
|
|
121
|
+
}
|
|
122
|
+
return envelope;
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=event-envelope.js.map
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type { TelemetryEvent } from '../../types/index.js';
|
|
1
|
+
import type { TelemetryEvent, TelemetryEnvelopeEventName, TelemetryEnvelopeInput } from '../../types/index.js';
|
|
2
2
|
/** Fire-and-forget; absence or staleness of opt-in prevents any network access. */
|
|
3
3
|
export declare function sendTelemetryEvent(event: TelemetryEvent): void;
|
|
4
|
+
/**
|
|
5
|
+
* Builds a schemaVersion-1 envelope and emits it through sendTelemetryEvent.
|
|
6
|
+
* Fire-and-forget: never awaited by callers, and every failure (disabled consent, no
|
|
7
|
+
* installation id yet, an unknown enum value) resolves to a silent no-op — telemetry
|
|
8
|
+
* never blocks or delays the operation it is describing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sendTelemetryEnvelopeEvent(name: TelemetryEnvelopeEventName, fields: Omit<TelemetryEnvelopeInput, 'anonymousInstallationId' | 'sessionId'>): void;
|
|
4
11
|
//# sourceMappingURL=telemetry-client.d.ts.map
|