@planu/cli 5.3.41 → 5.3.43
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 +16 -0
- package/dist/cli/commands/status.js +6 -1
- package/dist/config/minimal-implementation-gate.json +31 -0
- package/dist/engine/minimality/analyzer.js +43 -1
- package/dist/engine/pii-detector/core.js +24 -6
- package/dist/engine/scope-boundaries/scope-validator.js +2 -0
- package/dist/engine/validator/analyzer.js +63 -2
- package/dist/tools/response-helpers.js +20 -1
- package/dist/types/minimal-implementation-gate.d.ts +5 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [5.3.43] - 2026-08-24
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(spec-1293): gate minimality install-command patterns by provenance/negation context
|
|
5
|
+
- fix(spec-1277): match PII vocabulary by identifier segments and exact equality
|
|
6
|
+
- fix(spec-1225): make formatSuccess idempotent to prevent double-wrapped success titles
|
|
7
|
+
- fix(spec-1299): scan only comment tokens for debt markers, ignore string literals
|
|
8
|
+
- fix(spec-1298): exclude generic test/path tokens from scope-boundary matching
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## [5.3.42] - 2026-08-23
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
- fix(spec-1310): resolve relative --project-path at CLI boundary before lifecycle delegation
|
|
15
|
+
|
|
16
|
+
|
|
1
17
|
## [5.3.41] - 2026-08-23
|
|
2
18
|
|
|
3
19
|
### Bug Fixes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// cli/commands/status.ts — planu status <specId> [--set implementing] (SPEC-124)
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
3
4
|
import { handleUpdateStatus } from '../../tools/update-status.js';
|
|
4
5
|
import { handleUpdateStatusBatch } from '../../tools/update-status/batch.js';
|
|
5
6
|
import { handlePlanStatus } from '../../tools/status-handler.js';
|
|
@@ -59,7 +60,11 @@ function parseStatusArgs(args) {
|
|
|
59
60
|
strict: false,
|
|
60
61
|
allowPositionals: true,
|
|
61
62
|
});
|
|
62
|
-
|
|
63
|
+
const values = parsed.values;
|
|
64
|
+
if (values['project-path']) {
|
|
65
|
+
values['project-path'] = resolve(values['project-path']);
|
|
66
|
+
}
|
|
67
|
+
return { values, positionals: parsed.positionals };
|
|
63
68
|
}
|
|
64
69
|
async function runProjectStatus(values, flags) {
|
|
65
70
|
const result = await handlePlanStatus({
|
|
@@ -114,5 +114,36 @@
|
|
|
114
114
|
"excludedPathPatterns": ["dist/**", "coverage/**", "node_modules/**", "pnpm-lock.yaml", "*.snap"],
|
|
115
115
|
"debtEvidence": {
|
|
116
116
|
"requiredFields": ["specId", "files", "ceiling", "upgradeTrigger", "reviewRationale"]
|
|
117
|
+
},
|
|
118
|
+
"provenance": {
|
|
119
|
+
"nonProposingSectionKeywords": [
|
|
120
|
+
"problem",
|
|
121
|
+
"reproducer",
|
|
122
|
+
"observed",
|
|
123
|
+
"expected",
|
|
124
|
+
"goal",
|
|
125
|
+
"user outcome",
|
|
126
|
+
"out of scope",
|
|
127
|
+
"non-goal",
|
|
128
|
+
"forbidden",
|
|
129
|
+
"anticipated test break",
|
|
130
|
+
"edge case",
|
|
131
|
+
"failure mode",
|
|
132
|
+
"challenge resolution"
|
|
133
|
+
],
|
|
134
|
+
"negationCues": [
|
|
135
|
+
"do not",
|
|
136
|
+
"don't",
|
|
137
|
+
"must not",
|
|
138
|
+
"no new",
|
|
139
|
+
"add no",
|
|
140
|
+
"without adding",
|
|
141
|
+
"prohibit",
|
|
142
|
+
"no dependency",
|
|
143
|
+
"not add",
|
|
144
|
+
"never add",
|
|
145
|
+
"avoid adding",
|
|
146
|
+
"reuse "
|
|
147
|
+
]
|
|
117
148
|
}
|
|
118
149
|
}
|
|
@@ -24,6 +24,33 @@ function trimEvidence(value, maxChars) {
|
|
|
24
24
|
function lineForMatch(content, index) {
|
|
25
25
|
return content.slice(0, index).split('\n').length;
|
|
26
26
|
}
|
|
27
|
+
function lineTextAt(content, index) {
|
|
28
|
+
const start = content.lastIndexOf('\n', index - 1) + 1;
|
|
29
|
+
const stop = content.indexOf('\n', index);
|
|
30
|
+
return content.slice(start, stop < 0 ? content.length : stop);
|
|
31
|
+
}
|
|
32
|
+
function sectionHeadingForIndex(content, index) {
|
|
33
|
+
const linesBefore = content.slice(0, index).split('\n');
|
|
34
|
+
for (let i = linesBefore.length - 1; i >= 0; i -= 1) {
|
|
35
|
+
const line = linesBefore[i] ?? '';
|
|
36
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
37
|
+
return line
|
|
38
|
+
.replace(/^#{1,6}\s+/, '')
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function isNonProposingContext(content, index, provenance) {
|
|
46
|
+
const heading = sectionHeadingForIndex(content, index);
|
|
47
|
+
if (heading !== null &&
|
|
48
|
+
provenance.nonProposingSectionKeywords.some((keyword) => heading.includes(keyword.toLowerCase()))) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const line = lineTextAt(content, index).toLowerCase();
|
|
52
|
+
return provenance.negationCues.some((cue) => line.includes(cue.toLowerCase()));
|
|
53
|
+
}
|
|
27
54
|
function ruleBlocksDone(rule, specRisk) {
|
|
28
55
|
if (rule.severity !== 'blocker') {
|
|
29
56
|
return false;
|
|
@@ -36,9 +63,24 @@ function ruleBlocksDone(rule, specRisk) {
|
|
|
36
63
|
function makeEvidence(input, rule) {
|
|
37
64
|
return [...(input.evidence ?? []), { source: 'policy', key: rule.id }];
|
|
38
65
|
}
|
|
66
|
+
function findProposingMatchIndex(content, pattern, provenance) {
|
|
67
|
+
const lowerContent = content.toLowerCase();
|
|
68
|
+
const lowerPattern = pattern.toLowerCase();
|
|
69
|
+
let searchFrom = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const index = lowerContent.indexOf(lowerPattern, searchFrom);
|
|
72
|
+
if (index < 0) {
|
|
73
|
+
return -1;
|
|
74
|
+
}
|
|
75
|
+
if (provenance === undefined || !isNonProposingContext(content, index, provenance)) {
|
|
76
|
+
return index;
|
|
77
|
+
}
|
|
78
|
+
searchFrom = index + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
39
81
|
function scanText(args) {
|
|
40
82
|
for (const pattern of args.rule.patterns) {
|
|
41
|
-
const index = args.content.
|
|
83
|
+
const index = findProposingMatchIndex(args.content, pattern, args.policy.provenance);
|
|
42
84
|
if (index < 0) {
|
|
43
85
|
continue;
|
|
44
86
|
}
|
|
@@ -13,13 +13,31 @@ function normalizeField(name) {
|
|
|
13
13
|
return name.toLowerCase().replace(/[_\-\s.]/g, '');
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Splits an identifier into its snake/kebab/dotted/camelCase segments,
|
|
17
|
+
* lowercased and deduplicated, preserving the original boundaries.
|
|
18
18
|
*/
|
|
19
|
-
function
|
|
19
|
+
function splitIdentifierSegments(value) {
|
|
20
|
+
const withCamelBoundaries = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
21
|
+
const segments = new Set();
|
|
22
|
+
for (const part of withCamelBoundaries.split(/[_\-\s.]+/)) {
|
|
23
|
+
const lower = part.toLowerCase();
|
|
24
|
+
if (lower.length > 0) {
|
|
25
|
+
segments.add(lower);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return Array.from(segments);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Determines if a candidate identifier matches any PII vocabulary term.
|
|
32
|
+
* A term matches only the full normalized identifier or one of its
|
|
33
|
+
* segments, by equality — never by containment.
|
|
34
|
+
*/
|
|
35
|
+
function matchVocabulary(candidate) {
|
|
36
|
+
const full = normalizeField(candidate);
|
|
37
|
+
const segments = splitIdentifierSegments(candidate);
|
|
20
38
|
for (const [category, terms] of Object.entries(patterns.vocabulary)) {
|
|
21
39
|
for (const term of terms) {
|
|
22
|
-
if (
|
|
40
|
+
if (term === full || segments.includes(term)) {
|
|
23
41
|
return category;
|
|
24
42
|
}
|
|
25
43
|
}
|
|
@@ -97,7 +115,7 @@ function extractCandidates(text) {
|
|
|
97
115
|
if (word.length < 3) {
|
|
98
116
|
continue;
|
|
99
117
|
}
|
|
100
|
-
if (matchVocabulary(
|
|
118
|
+
if (matchVocabulary(word) === null) {
|
|
101
119
|
continue;
|
|
102
120
|
}
|
|
103
121
|
const wordIndex = lower.indexOf(word.toLowerCase());
|
|
@@ -127,7 +145,7 @@ export function detectPIIInSpec(specTitle, specDescription, criteria) {
|
|
|
127
145
|
const seen = new Set();
|
|
128
146
|
for (const { field, context } of candidates) {
|
|
129
147
|
const normalized = normalizeField(field);
|
|
130
|
-
const category = matchVocabulary(
|
|
148
|
+
const category = matchVocabulary(field);
|
|
131
149
|
if (!category) {
|
|
132
150
|
continue;
|
|
133
151
|
}
|
|
@@ -300,6 +300,66 @@ export function classifyDriftSeverity(criterion) {
|
|
|
300
300
|
}
|
|
301
301
|
return 'low';
|
|
302
302
|
}
|
|
303
|
+
function commentSyntaxFor(ext) {
|
|
304
|
+
if (ext === '.py' || ext === '.rb') {
|
|
305
|
+
return { lineTokens: ['#'], block: false };
|
|
306
|
+
}
|
|
307
|
+
if (ext === '.php') {
|
|
308
|
+
return { lineTokens: ['//', '#'], block: true };
|
|
309
|
+
}
|
|
310
|
+
return { lineTokens: ['//'], block: true };
|
|
311
|
+
}
|
|
312
|
+
function extractCommentSegments(lines, syntax) {
|
|
313
|
+
const segments = [];
|
|
314
|
+
let inBlock = false;
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
let comment = '';
|
|
317
|
+
let inString = null;
|
|
318
|
+
let j = 0;
|
|
319
|
+
while (j < line.length) {
|
|
320
|
+
const ch = line.charAt(j);
|
|
321
|
+
if (inBlock) {
|
|
322
|
+
if (ch === '*' && line.charAt(j + 1) === '/') {
|
|
323
|
+
inBlock = false;
|
|
324
|
+
j += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
comment += ch;
|
|
328
|
+
j++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (inString !== null) {
|
|
332
|
+
if (ch === '\\') {
|
|
333
|
+
j += 2;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === inString) {
|
|
337
|
+
inString = null;
|
|
338
|
+
}
|
|
339
|
+
j++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const lineTok = syntax.lineTokens.find((t) => line.startsWith(t, j));
|
|
343
|
+
if (lineTok !== undefined) {
|
|
344
|
+
comment += line.slice(j + lineTok.length);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (syntax.block && ch === '/' && line.charAt(j + 1) === '*') {
|
|
348
|
+
inBlock = true;
|
|
349
|
+
j += 2;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
353
|
+
inString = ch;
|
|
354
|
+
j++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
j++;
|
|
358
|
+
}
|
|
359
|
+
segments.push(comment);
|
|
360
|
+
}
|
|
361
|
+
return segments;
|
|
362
|
+
}
|
|
303
363
|
/**
|
|
304
364
|
* Run lightweight quality checks on a single file and return any findings.
|
|
305
365
|
*/
|
|
@@ -337,9 +397,10 @@ export async function quickQualityCheck(file, projectPath) {
|
|
|
337
397
|
});
|
|
338
398
|
}
|
|
339
399
|
// Check for TODO/FIXME/HACK
|
|
400
|
+
const commentSegments = extractCommentSegments(lines, commentSyntaxFor(ext));
|
|
340
401
|
for (let i = 0; i < lines.length; i++) {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
402
|
+
const segment = commentSegments[i];
|
|
403
|
+
if (segment !== undefined && /\b(TODO|FIXME|HACK|XXX)\b/.exec(segment)) {
|
|
343
404
|
findings.push({
|
|
344
405
|
file,
|
|
345
406
|
line: i + 1,
|
|
@@ -2,12 +2,31 @@
|
|
|
2
2
|
import { t, ti } from '../i18n/index.js';
|
|
3
3
|
import { compactJson } from './output-formatter.js';
|
|
4
4
|
import { getDirectiveText, getHumanSummaryPrefix } from '../engine/host-rules-templates/index.js';
|
|
5
|
+
function stripSuccessDecoration(title) {
|
|
6
|
+
let current = title.trim();
|
|
7
|
+
let changed = true;
|
|
8
|
+
while (changed) {
|
|
9
|
+
changed = false;
|
|
10
|
+
if (current.startsWith('✅')) {
|
|
11
|
+
current = current.slice(1).trimStart();
|
|
12
|
+
changed = true;
|
|
13
|
+
}
|
|
14
|
+
if (current.startsWith('**') && current.endsWith('**') && current.length >= 4) {
|
|
15
|
+
const inner = current.slice(2, -2);
|
|
16
|
+
if (!inner.includes('**')) {
|
|
17
|
+
current = inner.trim();
|
|
18
|
+
changed = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
5
24
|
/**
|
|
6
25
|
* Format a success response with optional next steps.
|
|
7
26
|
* Output: ✅ **{title}**\n\n{body}\n\n🚀 **Next Steps**\n1. ...\n2. ...
|
|
8
27
|
*/
|
|
9
28
|
export function formatSuccess(title, body, nextSteps) {
|
|
10
|
-
const header = `✅ **${title}**`;
|
|
29
|
+
const header = `✅ **${stripSuccessDecoration(title)}**`;
|
|
11
30
|
const parts = [header, '', body];
|
|
12
31
|
if (nextSteps && nextSteps.length > 0) {
|
|
13
32
|
parts.push('', t('tools.response.nextSteps'));
|
|
@@ -19,6 +19,10 @@ export interface MinimalImplementationRule {
|
|
|
19
19
|
replacementGuidance: string;
|
|
20
20
|
blockWhenRiskAtLeast?: 'low' | 'medium' | 'high' | 'max';
|
|
21
21
|
}
|
|
22
|
+
export interface MinimalImplementationProvenance {
|
|
23
|
+
nonProposingSectionKeywords: string[];
|
|
24
|
+
negationCues: string[];
|
|
25
|
+
}
|
|
22
26
|
export interface MinimalImplementationPolicy {
|
|
23
27
|
version: 1;
|
|
24
28
|
enabled: boolean;
|
|
@@ -36,6 +40,7 @@ export interface MinimalImplementationPolicy {
|
|
|
36
40
|
debtEvidence: {
|
|
37
41
|
requiredFields: string[];
|
|
38
42
|
};
|
|
43
|
+
provenance?: MinimalImplementationProvenance;
|
|
39
44
|
}
|
|
40
45
|
export interface LoadedMinimalImplementationPolicy {
|
|
41
46
|
policy: MinimalImplementationPolicy;
|
package/package.json
CHANGED
package/planu-plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "5.3.
|
|
5
|
+
"version": "5.3.43",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|