@planu/cli 4.10.3 → 4.10.5
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 +17 -0
- package/dist/config/version.js +1 -1
- package/dist/config/version.ts +1 -1
- package/dist/engine/project-graph/extractors/decision-store-extractor.js +1 -1
- package/dist/engine/project-graph/extractors/git-extractor.js +1 -1
- package/dist/engine/project-graph/extractors/handoff-extractor.js +1 -1
- package/dist/engine/project-graph/extractors/spec-extractor.js +1 -1
- package/dist/engine/project-graph/extractors/validation-extractor.js +1 -1
- package/dist/engine/project-graph/index.d.ts +2 -1
- package/dist/engine/project-graph/index.js +2 -1
- package/dist/engine/project-graph/query.d.ts +1 -2
- package/dist/engine/project-graph/query.js +1 -10
- package/dist/engine/project-graph/redaction.d.ts +4 -0
- package/dist/engine/project-graph/redaction.js +11 -0
- package/dist/engine/spec-format/lean-spec-generator.js +0 -5
- package/dist/engine/validator/validation-report-writer.js +40 -8
- package/dist/tools/design-schema-sql/migrations.js +9 -3
- package/dist/tools/design-schema-sql/tables.js +35 -10
- package/dist/tools/init-project/result-builder.js +13 -0
- package/dist/tools/testimonial-handler.js +13 -12
- package/dist/tools/update-status/dod-gates.d.ts +1 -1
- package/dist/tools/update-status/dod-gates.js +4 -3
- package/dist/tools/update-status/index.js +1 -1
- package/dist/tools/validate-lint.js +1 -1
- package/dist/tools/validate.js +67 -29
- package/dist/types/schema.d.ts +1 -0
- package/package.json +13 -52
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/engine/context-artifacts/index.d.ts +0 -2
- package/dist/engine/context-artifacts/index.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## [4.10.5] - 2026-07-07
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(SPEC-1109): resolve audit backlog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
## [4.10.4] - 2026-07-06
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
- fix: resolve Supabase testimonials and Planu bug backlog
|
|
11
|
+
|
|
12
|
+
### Chores
|
|
13
|
+
- chore(deps): update tsc-alias
|
|
14
|
+
- chore(planu): clear released pending specs
|
|
15
|
+
- chore(format): apply prettier baseline
|
|
16
|
+
|
|
17
|
+
|
|
1
18
|
## [4.10.3] - 2026-07-02
|
|
2
19
|
|
|
3
20
|
### Bug Fixes
|
package/dist/config/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// version.ts — reads from package.json at runtime; updated
|
|
1
|
+
// version.ts — reads from package.json at runtime; updated by the local release flow
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
package/dist/config/version.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { safeJsonParse } from '../../../storage/base-store.js';
|
|
2
2
|
import { hashText } from '../cache.js';
|
|
3
|
-
import { redactGraphText } from '../
|
|
3
|
+
import { redactGraphText } from '../redaction.js';
|
|
4
4
|
function isDecision(value) {
|
|
5
5
|
return (value !== null &&
|
|
6
6
|
typeof value === 'object' &&
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promisify } from 'node:util';
|
|
2
2
|
import { hashText } from '../cache.js';
|
|
3
|
-
import { redactGraphText } from '../
|
|
3
|
+
import { redactGraphText } from '../redaction.js';
|
|
4
4
|
export async function extractGitGraph(args) {
|
|
5
5
|
try {
|
|
6
6
|
const childProcess = await import('node:child_process');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { buildProjectKnowledgeGraph } from './builder.js';
|
|
2
|
-
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint,
|
|
2
|
+
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, } from './query.js';
|
|
3
|
+
export { redactGraphText } from './redaction.js';
|
|
3
4
|
export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
|
|
4
5
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { buildProjectKnowledgeGraph } from './builder.js';
|
|
2
|
-
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint,
|
|
2
|
+
export { queryProjectGraphSlice, formatProjectGraphContext, getProjectGraphFreshnessHint, } from './query.js';
|
|
3
|
+
export { redactGraphText } from './redaction.js';
|
|
3
4
|
export { getProjectGraphFreshness, loadProjectGraphPolicy, projectGraphPath, projectGraphCachePath, } from './cache.js';
|
|
4
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function redactGraphText(text: string, policy: ProjectGraphPolicy, maxChars?: number): string;
|
|
1
|
+
import type { ProjectGraphQueryInput, ProjectGraphSlice } from '../../types/project-knowledge-graph.js';
|
|
3
2
|
export declare function queryProjectGraphSlice(input: ProjectGraphQueryInput): Promise<ProjectGraphSlice | null>;
|
|
4
3
|
export declare function formatProjectGraphContext(slice: ProjectGraphSlice): Promise<string>;
|
|
5
4
|
export declare function getProjectGraphFreshnessHint(args: {
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { hashProjectPath } from '../../storage/base-store.js';
|
|
2
2
|
import { buildProjectKnowledgeGraph } from './builder.js';
|
|
3
3
|
import { getProjectGraphFreshness, loadProjectGraphPolicy, readProjectGraph } from './cache.js';
|
|
4
|
-
|
|
5
|
-
return text.replace(/</g, '<').replace(/>/g, '>');
|
|
6
|
-
}
|
|
7
|
-
export function redactGraphText(text, policy, maxChars = policy.redaction.maxSnippetChars) {
|
|
8
|
-
const truncated = text.slice(0, maxChars);
|
|
9
|
-
return policy.redaction.redactPatterns.reduce((current, pattern) => {
|
|
10
|
-
const re = new RegExp(pattern, 'gi');
|
|
11
|
-
return current.replace(re, '[redacted]');
|
|
12
|
-
}, truncated);
|
|
13
|
-
}
|
|
4
|
+
import { escapeInert, redactGraphText } from './redaction.js';
|
|
14
5
|
function sanitizeLabel(label, policy) {
|
|
15
6
|
return escapeInert(redactGraphText(label, policy));
|
|
16
7
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ProjectGraphPolicy } from '../../types/project-knowledge-graph.js';
|
|
2
|
+
export declare function escapeInert(text: string): string;
|
|
3
|
+
export declare function redactGraphText(text: string, policy: ProjectGraphPolicy, maxChars?: number): string;
|
|
4
|
+
//# sourceMappingURL=redaction.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function escapeInert(text) {
|
|
2
|
+
return text.replace(/</g, '<').replace(/>/g, '>');
|
|
3
|
+
}
|
|
4
|
+
export function redactGraphText(text, policy, maxChars = policy.redaction.maxSnippetChars) {
|
|
5
|
+
const truncated = text.slice(0, maxChars);
|
|
6
|
+
return policy.redaction.redactPatterns.reduce((current, pattern) => {
|
|
7
|
+
const re = new RegExp(pattern, 'gi');
|
|
8
|
+
return current.replace(re, '[redacted]');
|
|
9
|
+
}, truncated);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=redaction.js.map
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Output: ~30-50 lines. No generic sections, no OWASP, no STRIDE, no resilience criteria.
|
|
3
3
|
import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd } from './bdd-parser.js';
|
|
4
4
|
import { deriveModelBudget } from './model-budget-deriver.js';
|
|
5
|
-
import { renderHistoryYaml } from '../spec-versioning/render-history.js';
|
|
6
5
|
import { renderGroundingFrontmatter, renderTechnicalReferenceGroundingFrontmatter, } from '../spec-grounding/contract.js';
|
|
7
6
|
/** Strip redundant "SPEC-XXX — " or "SPEC-XXX: " prefix from spec titles.
|
|
8
7
|
* Titles must not repeat the ID since it is already stored in the `id` field. */
|
|
@@ -60,15 +59,11 @@ export function generateLeanSpecContent(input) {
|
|
|
60
59
|
else {
|
|
61
60
|
acLines = buildCheckboxLines(description, extraCriteria, input.criteriaOverride);
|
|
62
61
|
}
|
|
63
|
-
// SPEC-717: render empty version history for new specs
|
|
64
|
-
const historyYaml = renderHistoryYaml([]);
|
|
65
62
|
const lines = [
|
|
66
63
|
...frontmatterBase,
|
|
67
64
|
...acLines,
|
|
68
65
|
'history:',
|
|
69
66
|
...history.map((h) => ` - date: ${h.date}\n event: ${h.event}`),
|
|
70
|
-
// SPEC-717: version history (empty for new specs)
|
|
71
|
-
historyYaml,
|
|
72
67
|
'---',
|
|
73
68
|
'',
|
|
74
69
|
description.trim(),
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { appendArtifact } from '../handoff-artifacts/io.js';
|
|
1
|
+
import { appendArtifact, readArtifact } from '../handoff-artifacts/io.js';
|
|
2
2
|
import { runSpecCompliance, } from './spec-compliance-runner.js';
|
|
3
3
|
export async function writeImplementationReviewReport(input) {
|
|
4
4
|
const specCompliance = await runSpecCompliance(input.spec, input.projectPath);
|
|
5
5
|
const gates = buildGates({
|
|
6
6
|
score: input.score,
|
|
7
|
-
lintPassed: input.lintPassed
|
|
7
|
+
lintPassed: input.lintPassed,
|
|
8
8
|
conventionRegression: input.conventionRegression ?? false,
|
|
9
9
|
specCompliance,
|
|
10
10
|
minimalityReport: input.minimalityReport,
|
|
11
11
|
});
|
|
12
|
-
const
|
|
12
|
+
const mergedGates = await preserveUnobservedBlockingGates(input, gates);
|
|
13
|
+
const passed = mergedGates.every((gate) => gate.passed);
|
|
13
14
|
const reviewer = {
|
|
14
15
|
kind: 'implementation-review-agent',
|
|
15
16
|
agent: 'planu-implementation-reviewer',
|
|
@@ -20,7 +21,7 @@ export async function writeImplementationReviewReport(input) {
|
|
|
20
21
|
schema_version: '1.0.0',
|
|
21
22
|
specId: input.specId,
|
|
22
23
|
passed,
|
|
23
|
-
gates,
|
|
24
|
+
gates: mergedGates,
|
|
24
25
|
reviewer,
|
|
25
26
|
specCompliance: reportCompliance,
|
|
26
27
|
minimalityReport: input.minimalityReport,
|
|
@@ -40,7 +41,7 @@ export async function writeImplementationReviewReport(input) {
|
|
|
40
41
|
sha: artifact.sha,
|
|
41
42
|
reviewer,
|
|
42
43
|
passed,
|
|
43
|
-
gates,
|
|
44
|
+
gates: mergedGates,
|
|
44
45
|
specCompliance: reportCompliance,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
@@ -50,11 +51,38 @@ export async function writeImplementationReviewReport(input) {
|
|
|
50
51
|
error: err instanceof Error ? err.message : String(err),
|
|
51
52
|
reviewer,
|
|
52
53
|
passed,
|
|
53
|
-
gates,
|
|
54
|
+
gates: mergedGates,
|
|
54
55
|
specCompliance: reportCompliance,
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
async function preserveUnobservedBlockingGates(input, nextGates) {
|
|
60
|
+
const currentGateNames = new Set(nextGates.map((gate) => gate.name));
|
|
61
|
+
const explicitlyObserved = new Set([
|
|
62
|
+
...(input.lintPassed !== undefined ? ['lint'] : []),
|
|
63
|
+
...(input.conventionRegression !== undefined ? ['convention-regression'] : []),
|
|
64
|
+
...(input.minimalityReport !== undefined ? ['minimal-implementation'] : []),
|
|
65
|
+
]);
|
|
66
|
+
const existing = await readArtifact({
|
|
67
|
+
projectId: input.projectId,
|
|
68
|
+
specId: input.specId,
|
|
69
|
+
kind: 'validation-report',
|
|
70
|
+
}).catch(() => null);
|
|
71
|
+
if (!existing?.ok) {
|
|
72
|
+
return nextGates;
|
|
73
|
+
}
|
|
74
|
+
const preserved = existing.payload.gates.filter((gate) => !gate.passed &&
|
|
75
|
+
currentGateNames.has(gate.name) &&
|
|
76
|
+
!explicitlyObserved.has(gate.name) &&
|
|
77
|
+
(gate.name === 'lint' ||
|
|
78
|
+
gate.name === 'convention-regression' ||
|
|
79
|
+
gate.name === 'minimal-implementation'));
|
|
80
|
+
if (preserved.length === 0) {
|
|
81
|
+
return nextGates;
|
|
82
|
+
}
|
|
83
|
+
const preservedByName = new Map(preserved.map((gate) => [gate.name, gate]));
|
|
84
|
+
return nextGates.map((gate) => preservedByName.get(gate.name) ?? gate);
|
|
85
|
+
}
|
|
58
86
|
function buildGates(args) {
|
|
59
87
|
return [
|
|
60
88
|
{
|
|
@@ -66,8 +94,12 @@ function buildGates(args) {
|
|
|
66
94
|
},
|
|
67
95
|
{
|
|
68
96
|
name: 'lint',
|
|
69
|
-
passed: args.lintPassed,
|
|
70
|
-
reason: args.lintPassed
|
|
97
|
+
passed: args.lintPassed === true,
|
|
98
|
+
reason: args.lintPassed === true
|
|
99
|
+
? undefined
|
|
100
|
+
: args.lintPassed === false
|
|
101
|
+
? 'Configured lint command failed.'
|
|
102
|
+
: 'Lint result was not provided; report cannot claim lint passed.',
|
|
71
103
|
},
|
|
72
104
|
{
|
|
73
105
|
name: 'convention-regression',
|
|
@@ -62,11 +62,17 @@ export function generateMigrations(tables, indexes, rlsPolicies, engine) {
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
for (const policy of rlsPolicies) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
65
|
+
const rolesClause = policy.roles?.length ? ` TO ${policy.roles.join(', ')}` : '';
|
|
66
|
+
const usingClause = policy.operation === 'INSERT' ? '' : ` USING (${policy.using})`;
|
|
67
|
+
const withCheckClause = policy.withCheck || policy.operation === 'INSERT'
|
|
68
|
+
? ` WITH CHECK (${policy.withCheck ?? policy.using})`
|
|
69
|
+
: '';
|
|
70
|
+
const policyClauses = [rolesClause, usingClause, withCheckClause]
|
|
71
|
+
.filter((clause) => clause.length > 0)
|
|
72
|
+
.join('');
|
|
67
73
|
migrations.push({
|
|
68
74
|
order: order++,
|
|
69
|
-
sql: `CREATE POLICY ${policy.name} ON ${policy.table} FOR ${policy.operation}
|
|
75
|
+
sql: `CREATE POLICY ${policy.name} ON ${policy.table} FOR ${policy.operation}${policyClauses};`,
|
|
70
76
|
reversible: true,
|
|
71
77
|
reverseSql: `DROP POLICY IF EXISTS ${policy.name} ON ${policy.table};`,
|
|
72
78
|
dataLoss: false,
|
|
@@ -170,43 +170,68 @@ export function generateIndexes(tables, _engine) {
|
|
|
170
170
|
export function generateRLSPolicies(tables) {
|
|
171
171
|
const policies = [];
|
|
172
172
|
for (const table of tables) {
|
|
173
|
+
const ownerCol = table.columns.find((c) => c.name === 'user_id' || c.name === 'owner_id' || c.name === 'created_by');
|
|
174
|
+
const ownerPredicate = ownerCol ? `(select auth.uid()) = ${ownerCol.name}` : 'true';
|
|
173
175
|
policies.push({
|
|
174
176
|
name: `${table.name}_select_authenticated`,
|
|
175
177
|
table: table.name,
|
|
176
178
|
operation: 'SELECT',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
roles: ['authenticated'],
|
|
180
|
+
using: ownerPredicate,
|
|
181
|
+
justification: ownerCol
|
|
182
|
+
? 'Authenticated users can only read records they own'
|
|
183
|
+
: 'Only authenticated users can read records',
|
|
179
184
|
});
|
|
180
|
-
const ownerCol = table.columns.find((c) => c.name === 'user_id' || c.name === 'owner_id' || c.name === 'created_by');
|
|
181
185
|
if (ownerCol) {
|
|
182
186
|
policies.push({
|
|
183
187
|
name: `${table.name}_insert_owner`,
|
|
184
188
|
table: table.name,
|
|
185
189
|
operation: 'INSERT',
|
|
190
|
+
roles: ['authenticated'],
|
|
186
191
|
using: 'true',
|
|
187
|
-
withCheck:
|
|
192
|
+
withCheck: ownerPredicate,
|
|
188
193
|
justification: 'Users can only insert records they own',
|
|
189
194
|
}, {
|
|
190
195
|
name: `${table.name}_update_owner`,
|
|
191
196
|
table: table.name,
|
|
192
197
|
operation: 'UPDATE',
|
|
193
|
-
|
|
198
|
+
roles: ['authenticated'],
|
|
199
|
+
using: ownerPredicate,
|
|
200
|
+
withCheck: ownerPredicate,
|
|
194
201
|
justification: 'Users can only update their own records',
|
|
195
202
|
}, {
|
|
196
203
|
name: `${table.name}_delete_owner`,
|
|
197
204
|
table: table.name,
|
|
198
205
|
operation: 'DELETE',
|
|
199
|
-
|
|
206
|
+
roles: ['authenticated'],
|
|
207
|
+
using: ownerPredicate,
|
|
200
208
|
justification: 'Users can only delete their own records',
|
|
201
209
|
});
|
|
202
210
|
}
|
|
203
211
|
else {
|
|
204
212
|
policies.push({
|
|
205
|
-
name: `${table.name}
|
|
213
|
+
name: `${table.name}_insert_authenticated`,
|
|
214
|
+
table: table.name,
|
|
215
|
+
operation: 'INSERT',
|
|
216
|
+
roles: ['authenticated'],
|
|
217
|
+
using: 'true',
|
|
218
|
+
withCheck: 'true',
|
|
219
|
+
justification: 'Authenticated users can insert records',
|
|
220
|
+
}, {
|
|
221
|
+
name: `${table.name}_update_authenticated`,
|
|
206
222
|
table: table.name,
|
|
207
|
-
operation: '
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
operation: 'UPDATE',
|
|
224
|
+
roles: ['authenticated'],
|
|
225
|
+
using: 'true',
|
|
226
|
+
withCheck: 'true',
|
|
227
|
+
justification: 'Authenticated users can update records',
|
|
228
|
+
}, {
|
|
229
|
+
name: `${table.name}_delete_authenticated`,
|
|
230
|
+
table: table.name,
|
|
231
|
+
operation: 'DELETE',
|
|
232
|
+
roles: ['authenticated'],
|
|
233
|
+
using: 'true',
|
|
234
|
+
justification: 'Authenticated users can delete records',
|
|
210
235
|
});
|
|
211
236
|
}
|
|
212
237
|
}
|
|
@@ -181,6 +181,8 @@ export function buildInitProjectResult(input) {
|
|
|
181
181
|
healthScore: input.healthReport.score,
|
|
182
182
|
healthSummary: input.healthReport.summary,
|
|
183
183
|
items: input.healthReport.items,
|
|
184
|
+
conventionScanStatus: input.conventionScanStatus ?? 'skipped',
|
|
185
|
+
conventionWarning: buildConventionHealthWarning(input),
|
|
184
186
|
}
|
|
185
187
|
: null,
|
|
186
188
|
feedbackHint: 'Found a bug or have a suggestion? Just say "I want to report a bug" or "I have a suggestion" and Planu will track it for you.',
|
|
@@ -256,4 +258,15 @@ export function buildInitProjectResult(input) {
|
|
|
256
258
|
},
|
|
257
259
|
};
|
|
258
260
|
}
|
|
261
|
+
function buildConventionHealthWarning(input) {
|
|
262
|
+
const status = input.conventionScanStatus ?? 'skipped';
|
|
263
|
+
if (status === 'failed') {
|
|
264
|
+
return 'Convention scan failed; project health may not include all convention violations.';
|
|
265
|
+
}
|
|
266
|
+
const conventionItems = input.healthReport?.items.filter((item) => /convention|rule/i.test(item.category) || /convention|rule/i.test(item.message)) ?? [];
|
|
267
|
+
if (conventionItems.length === 0) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return `${String(conventionItems.length)} convention-related health warning(s) detected.`;
|
|
271
|
+
}
|
|
259
272
|
//# sourceMappingURL=result-builder.js.map
|
|
@@ -13,6 +13,9 @@ function formatTestimonial(t) {
|
|
|
13
13
|
const github = t.authorGithub ? ` (@${t.authorGithub})` : '';
|
|
14
14
|
return `- "${t.quote}" — ${t.authorName}${github}, ${t.authorRole}${featured} (${t.rating}/5, ${t.locale})`;
|
|
15
15
|
}
|
|
16
|
+
function nonBlank(value) {
|
|
17
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
18
|
+
}
|
|
16
19
|
export async function handleApproveTestimonial(input) {
|
|
17
20
|
const config = await getSupabaseConfig(input.projectPath);
|
|
18
21
|
if (!config) {
|
|
@@ -26,23 +29,21 @@ export async function handleApproveTestimonial(input) {
|
|
|
26
29
|
isError: true,
|
|
27
30
|
};
|
|
28
31
|
}
|
|
29
|
-
|
|
30
|
-
const hasManualData = Boolean(input.authorName) && Boolean(input.quote);
|
|
31
|
-
if (!hasFeedbackId && !hasManualData) {
|
|
32
|
+
if (!nonBlank(input.authorName) || !nonBlank(input.quote)) {
|
|
32
33
|
return {
|
|
33
34
|
content: [
|
|
34
35
|
{
|
|
35
36
|
type: 'text',
|
|
36
|
-
text: 'Validation failed:
|
|
37
|
+
text: 'Validation failed: authorName and quote are required before approving a testimonial. feedbackId-only approval is not supported yet.',
|
|
37
38
|
},
|
|
38
39
|
],
|
|
39
40
|
isError: true,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
const payload = {
|
|
43
|
-
author_name: input.authorName
|
|
44
|
-
author_role: input.authorRole ?? '',
|
|
45
|
-
quote: input.quote
|
|
44
|
+
author_name: input.authorName.trim(),
|
|
45
|
+
author_role: input.authorRole?.trim() ?? '',
|
|
46
|
+
quote: input.quote.trim(),
|
|
46
47
|
rating: input.rating ?? 5,
|
|
47
48
|
source: input.source ?? 'manual',
|
|
48
49
|
approved: true,
|
|
@@ -50,19 +51,19 @@ export async function handleApproveTestimonial(input) {
|
|
|
50
51
|
locale: input.locale ?? 'en',
|
|
51
52
|
};
|
|
52
53
|
if (input.authorAvatarUrl !== undefined) {
|
|
53
|
-
payload.author_avatar_url = input.authorAvatarUrl;
|
|
54
|
+
payload.author_avatar_url = input.authorAvatarUrl.trim();
|
|
54
55
|
}
|
|
55
56
|
if (input.authorGithub !== undefined) {
|
|
56
|
-
payload.author_github = input.authorGithub;
|
|
57
|
+
payload.author_github = input.authorGithub.trim();
|
|
57
58
|
}
|
|
58
59
|
if (input.feedbackId !== undefined) {
|
|
59
|
-
payload.feedback_id = input.feedbackId;
|
|
60
|
+
payload.feedback_id = input.feedbackId.trim();
|
|
60
61
|
}
|
|
61
62
|
if (input.projectType !== undefined) {
|
|
62
|
-
payload.project_type = input.projectType;
|
|
63
|
+
payload.project_type = input.projectType.trim();
|
|
63
64
|
}
|
|
64
65
|
if (input.companySize !== undefined) {
|
|
65
|
-
payload.company_size = input.companySize;
|
|
66
|
+
payload.company_size = input.companySize.trim();
|
|
66
67
|
}
|
|
67
68
|
const response = await fetch(TESTIMONIALS_URL(config.projectUrl), {
|
|
68
69
|
method: 'POST',
|
|
@@ -87,7 +87,7 @@ export declare function checkSpecReviewGate(specId: string, projectId: string, _
|
|
|
87
87
|
* When force=true, gates are not blocking but failing items are recorded in
|
|
88
88
|
* the project's force-bypass-log.json for traceability (guard: done ≠ implemented).
|
|
89
89
|
*/
|
|
90
|
-
export declare function checkDoneGates(spec: Spec, specId: string, projectId: string, projectPath: string | undefined, force: boolean | undefined): Promise<DoneGateResult>;
|
|
90
|
+
export declare function checkDoneGates(spec: Spec, specId: string, projectId: string, projectPath: string | undefined, force: boolean | undefined, forceReason?: string): Promise<DoneGateResult>;
|
|
91
91
|
export interface ComplianceGateResult {
|
|
92
92
|
skipped: boolean;
|
|
93
93
|
score: number | null;
|
|
@@ -240,7 +240,7 @@ function forceDoneBypassLogPath(projectId) {
|
|
|
240
240
|
* Writes an audit record when force=true bypasses DoD gates on 'done' transition.
|
|
241
241
|
* Never throws — audit failures must not block transitions.
|
|
242
242
|
*/
|
|
243
|
-
async function recordForceDoneBypass(specId, projectId, failingItems, warningItems) {
|
|
243
|
+
async function recordForceDoneBypass(specId, projectId, reason, failingItems, warningItems) {
|
|
244
244
|
try {
|
|
245
245
|
const logPath = forceDoneBypassLogPath(projectId);
|
|
246
246
|
const entries = await readJson(logPath, []);
|
|
@@ -250,6 +250,7 @@ async function recordForceDoneBypass(specId, projectId, failingItems, warningIte
|
|
|
250
250
|
action: 'force_done_bypass',
|
|
251
251
|
specId,
|
|
252
252
|
projectId,
|
|
253
|
+
reason,
|
|
253
254
|
failingItems,
|
|
254
255
|
warningItems,
|
|
255
256
|
});
|
|
@@ -448,7 +449,7 @@ function specReviewGateError(args) {
|
|
|
448
449
|
* When force=true, gates are not blocking but failing items are recorded in
|
|
449
450
|
* the project's force-bypass-log.json for traceability (guard: done ≠ implemented).
|
|
450
451
|
*/
|
|
451
|
-
export async function checkDoneGates(spec, specId, projectId, projectPath, force) {
|
|
452
|
+
export async function checkDoneGates(spec, specId, projectId, projectPath, force, forceReason = 'No force reason provided') {
|
|
452
453
|
if (!force) {
|
|
453
454
|
const dodError = await checkDodGate(spec, specId, projectId, projectPath, false);
|
|
454
455
|
if (dodError) {
|
|
@@ -472,7 +473,7 @@ export async function checkDoneGates(spec, specId, projectId, projectPath, force
|
|
|
472
473
|
.map((i) => i.description);
|
|
473
474
|
if (failingItems.length > 0) {
|
|
474
475
|
// Record bypass in audit log (fire-and-forget)
|
|
475
|
-
void recordForceDoneBypass(specId, projectId, failingItems, warningItems);
|
|
476
|
+
void recordForceDoneBypass(specId, projectId, forceReason, failingItems, warningItems);
|
|
476
477
|
forcedBypassWarning =
|
|
477
478
|
`⚠️ force bypass: ${String(failingItems.length)} DoD item(s) were failing ` +
|
|
478
479
|
`(${failingItems.slice(0, 2).join('; ')}${failingItems.length > 2 ? '…' : ''}). ` +
|
|
@@ -481,7 +481,7 @@ export async function handleUpdateStatus(params, server) {
|
|
|
481
481
|
: Promise.resolve(null),
|
|
482
482
|
// Done gates: only relevant for 'done'
|
|
483
483
|
newStatus === 'done'
|
|
484
|
-
? checkDoneGates(spec, specId, projectId, effectiveGatePath, params.force)
|
|
484
|
+
? checkDoneGates(spec, specId, projectId, effectiveGatePath, params.force, params.forceStatusReason ?? params.reason ?? 'No force reason provided')
|
|
485
485
|
: Promise.resolve(null),
|
|
486
486
|
]);
|
|
487
487
|
// Process code reality result
|
|
@@ -55,7 +55,7 @@ export function runLintCheck(projectPath, lintCommand) {
|
|
|
55
55
|
const output = `Lint command timed out after ${String(seconds)}s and was skipped so validate can complete within MCP request limits. ` +
|
|
56
56
|
`Run \`${plan.executedCommand}\` manually before marking the spec done.`;
|
|
57
57
|
console.warn(`[Planu] lintCheck timed out: command="${plan.executedCommand}" cwd="${projectPath}" timeoutMs=${String(LINT_CHECK_TIMEOUT_MS)}`);
|
|
58
|
-
return { passed:
|
|
58
|
+
return { passed: false, command, issueCount: 1, output };
|
|
59
59
|
}
|
|
60
60
|
const raw = commandOutput(err);
|
|
61
61
|
const output = formatLintFailureDiagnostics(plan, raw);
|
package/dist/tools/validate.js
CHANGED
|
@@ -76,30 +76,7 @@ export async function handleValidate(args, server) {
|
|
|
76
76
|
const implementationQualityScore = calcQualityScore(result.qualityIssues);
|
|
77
77
|
const auditedFiles = [...new Set(result.qualityIssues.map((i) => i.file))];
|
|
78
78
|
// 6. SPEC-190: Convention compliance check
|
|
79
|
-
|
|
80
|
-
let regressionDetected = false;
|
|
81
|
-
try {
|
|
82
|
-
const rules = parseConventions(projectPath);
|
|
83
|
-
if (rules.length > 0) {
|
|
84
|
-
conventionViolations = scanConventions(projectPath, rules);
|
|
85
|
-
// Check regression against baseline
|
|
86
|
-
const comparison = await compareWithBaseline(projectId, {
|
|
87
|
-
rules,
|
|
88
|
-
violations: conventionViolations,
|
|
89
|
-
summary: {
|
|
90
|
-
totalRules: rules.length,
|
|
91
|
-
totalViolations: conventionViolations.length,
|
|
92
|
-
bySeverity: {},
|
|
93
|
-
byCategory: {},
|
|
94
|
-
},
|
|
95
|
-
scannedAt: new Date().toISOString(),
|
|
96
|
-
});
|
|
97
|
-
regressionDetected = comparison.regressionDetected;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
/* best-effort — don't fail validation */
|
|
102
|
-
}
|
|
79
|
+
const { conventionViolations, regressionDetected } = await scanProjectConventions(projectId, projectPath);
|
|
103
80
|
// 7. Lint check (best-effort — non-blocking)
|
|
104
81
|
const lintCheck = runLintCheck(projectPath, knowledge.lintCommand ?? null);
|
|
105
82
|
const assuranceGates = runAssuranceGates(projectPath);
|
|
@@ -123,6 +100,14 @@ export async function handleValidate(args, server) {
|
|
|
123
100
|
conventionRegression: regressionDetected,
|
|
124
101
|
minimalityReport: minimalityReport ?? undefined,
|
|
125
102
|
});
|
|
103
|
+
const qualityGateSummary = buildQualityGateSummary({
|
|
104
|
+
conventionViolations,
|
|
105
|
+
regressionDetected,
|
|
106
|
+
lintPassed: lintCheck.passed,
|
|
107
|
+
assurancePassed: assuranceGates.newCode.passed,
|
|
108
|
+
minimalityBlocked: minimalityReport?.blocked === true,
|
|
109
|
+
score: result.score,
|
|
110
|
+
});
|
|
126
111
|
// 8. Build output
|
|
127
112
|
const output = {
|
|
128
113
|
specId,
|
|
@@ -198,6 +183,7 @@ export async function handleValidate(args, server) {
|
|
|
198
183
|
},
|
|
199
184
|
lintCheck,
|
|
200
185
|
assuranceGates,
|
|
186
|
+
qualityGateSummary,
|
|
201
187
|
validationReport,
|
|
202
188
|
minimalityReport,
|
|
203
189
|
graphCoverage,
|
|
@@ -304,11 +290,13 @@ export async function handleValidate(args, server) {
|
|
|
304
290
|
const passedCount = result.fieldsImplemented;
|
|
305
291
|
const failedCount = totalCriteria - passedCount;
|
|
306
292
|
const indeterminate = result.indeterminate ?? [];
|
|
293
|
+
const effectiveScore = qualityGateSummary.effectiveScore ?? scoreValue;
|
|
307
294
|
const compactText = buildCompactValidateText({
|
|
308
|
-
score:
|
|
295
|
+
score: effectiveScore,
|
|
309
296
|
passedCount,
|
|
310
297
|
failedCount,
|
|
311
298
|
totalCriteria,
|
|
299
|
+
qualityFailures: qualityGateSummary.failures,
|
|
312
300
|
missing: result.missing,
|
|
313
301
|
indeterminate,
|
|
314
302
|
commitReminder,
|
|
@@ -316,7 +304,7 @@ export async function handleValidate(args, server) {
|
|
|
316
304
|
});
|
|
317
305
|
const graphText = formatGraphCoverageText(graphCoverage);
|
|
318
306
|
// SPEC-512: Compact structuredContent — essential fields only at top level
|
|
319
|
-
const compactSummary = buildCompactSummary(specId, result.score, passedCount, failedCount, totalCriteria);
|
|
307
|
+
const compactSummary = buildCompactSummary(specId, result.score === null ? null : effectiveScore, passedCount, failedCount + (qualityGateSummary.passed ? 0 : 1), totalCriteria);
|
|
320
308
|
const structuredBase = { ...outputWithSuggestions, summary: compactSummary, indeterminate };
|
|
321
309
|
// SPEC-595: Elicit next action when validation fails and a server is available
|
|
322
310
|
if (server !== undefined && failedCount > 0) {
|
|
@@ -358,6 +346,50 @@ export async function handleValidate(args, server) {
|
|
|
358
346
|
structuredContent: structuredBase,
|
|
359
347
|
};
|
|
360
348
|
}
|
|
349
|
+
async function scanProjectConventions(projectId, projectPath) {
|
|
350
|
+
try {
|
|
351
|
+
const rules = parseConventions(projectPath);
|
|
352
|
+
if (rules.length === 0) {
|
|
353
|
+
return { conventionViolations: [], regressionDetected: false };
|
|
354
|
+
}
|
|
355
|
+
const conventionViolations = scanConventions(projectPath, rules);
|
|
356
|
+
const comparison = await compareWithBaseline(projectId, {
|
|
357
|
+
rules,
|
|
358
|
+
violations: conventionViolations,
|
|
359
|
+
summary: {
|
|
360
|
+
totalRules: rules.length,
|
|
361
|
+
totalViolations: conventionViolations.length,
|
|
362
|
+
bySeverity: {},
|
|
363
|
+
byCategory: {},
|
|
364
|
+
},
|
|
365
|
+
scannedAt: new Date().toISOString(),
|
|
366
|
+
});
|
|
367
|
+
return { conventionViolations, regressionDetected: comparison.regressionDetected };
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return { conventionViolations: [], regressionDetected: false };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function buildQualityGateSummary(input) {
|
|
374
|
+
const blockingConventionViolations = input.conventionViolations.filter((violation) => violation.severity === 'critical');
|
|
375
|
+
const failures = [
|
|
376
|
+
...(input.regressionDetected ? ['convention-regression'] : []),
|
|
377
|
+
...(blockingConventionViolations.length > 0 ? ['blocking-convention-violations'] : []),
|
|
378
|
+
...(!input.lintPassed ? ['lint'] : []),
|
|
379
|
+
...(!input.assurancePassed ? ['assurance'] : []),
|
|
380
|
+
...(input.minimalityBlocked ? ['minimality'] : []),
|
|
381
|
+
];
|
|
382
|
+
return {
|
|
383
|
+
passed: failures.length === 0,
|
|
384
|
+
effectiveScore: failures.length === 0 ? input.score : Math.min(input.score ?? 0, 99),
|
|
385
|
+
failures,
|
|
386
|
+
conventionRegression: input.regressionDetected,
|
|
387
|
+
blockingConventionViolations: blockingConventionViolations.length,
|
|
388
|
+
lintPassed: input.lintPassed,
|
|
389
|
+
assurancePassed: input.assurancePassed,
|
|
390
|
+
minimalityPassed: !input.minimalityBlocked,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
361
393
|
function buildCompactSummary(specId, score, passedCount, failedCount, totalCriteria) {
|
|
362
394
|
return {
|
|
363
395
|
specId,
|
|
@@ -369,13 +401,18 @@ function buildCompactSummary(specId, score, passedCount, failedCount, totalCrite
|
|
|
369
401
|
};
|
|
370
402
|
}
|
|
371
403
|
function buildCompactValidateText(args) {
|
|
372
|
-
const { score, passedCount, failedCount, totalCriteria, missing, indeterminate, commitReminder, planuReminder, } = args;
|
|
373
|
-
if (failedCount === 0) {
|
|
404
|
+
const { score, passedCount, failedCount, totalCriteria, qualityFailures, missing, indeterminate, commitReminder, planuReminder, } = args;
|
|
405
|
+
if (failedCount === 0 && qualityFailures.length === 0) {
|
|
374
406
|
// All passing — single line
|
|
375
407
|
return (`✅ Validate: ${String(score)}/100 — ${String(passedCount)}/${String(totalCriteria)} criteria passing` +
|
|
376
408
|
commitReminder +
|
|
377
409
|
planuReminder);
|
|
378
410
|
}
|
|
411
|
+
if (failedCount === 0) {
|
|
412
|
+
return (`❌ Validate: ${String(score)}/100 — failing; ${String(passedCount)}/${String(totalCriteria)} criteria passing; failing gates: ${qualityFailures.join(', ')}` +
|
|
413
|
+
commitReminder +
|
|
414
|
+
planuReminder);
|
|
415
|
+
}
|
|
379
416
|
// Failures exist — list only the failing ones (max 5)
|
|
380
417
|
const MAX_FAILURES = 5;
|
|
381
418
|
const labelledFailures = [
|
|
@@ -392,7 +429,8 @@ function buildCompactValidateText(args) {
|
|
|
392
429
|
.slice(0, 3)
|
|
393
430
|
.map((failure) => `${failure.label}: ${failure.criterion}`)
|
|
394
431
|
.join(', ');
|
|
395
|
-
const
|
|
432
|
+
const gateSuffix = qualityFailures.length > 0 ? `; failing gates: ${qualityFailures.join(', ')}` : '';
|
|
433
|
+
const summaryLine = `❌ Validate: ${String(score)}/100 — failing; ${String(failedCount)} failing criteria: [${failNames}${remainingCount + shownFailures.length > 3 ? '...' : ''}]${gateSuffix}`;
|
|
396
434
|
return summaryLine + '\n' + failureList + moreLine + commitReminder + planuReminder;
|
|
397
435
|
}
|
|
398
436
|
function scoreToGrade(score) {
|
package/dist/types/schema.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "4.10.
|
|
3
|
+
"version": "4.10.5",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,19 +34,19 @@
|
|
|
34
34
|
"packageName": "@planu/core"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
|
-
"@planu/core-darwin-arm64": "4.10.
|
|
38
|
-
"@planu/core-darwin-x64": "4.10.
|
|
39
|
-
"@planu/core-linux-arm64-gnu": "4.10.
|
|
40
|
-
"@planu/core-linux-arm64-musl": "4.10.
|
|
41
|
-
"@planu/core-linux-x64-gnu": "4.10.
|
|
42
|
-
"@planu/core-linux-x64-musl": "4.10.
|
|
43
|
-
"@planu/core-win32-arm64-msvc": "4.10.
|
|
44
|
-
"@planu/core-win32-x64-msvc": "4.10.
|
|
37
|
+
"@planu/core-darwin-arm64": "4.10.5",
|
|
38
|
+
"@planu/core-darwin-x64": "4.10.5",
|
|
39
|
+
"@planu/core-linux-arm64-gnu": "4.10.5",
|
|
40
|
+
"@planu/core-linux-arm64-musl": "4.10.5",
|
|
41
|
+
"@planu/core-linux-x64-gnu": "4.10.5",
|
|
42
|
+
"@planu/core-linux-x64-musl": "4.10.5",
|
|
43
|
+
"@planu/core-win32-arm64-msvc": "4.10.5",
|
|
44
|
+
"@planu/core-win32-x64-msvc": "4.10.5"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.0.0"
|
|
48
48
|
},
|
|
49
|
-
"packageManager": "pnpm@
|
|
49
|
+
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build:rust": "bash scripts/build-rust-local.sh --host",
|
|
52
52
|
"build:rust:all": "bash scripts/build-rust-local.sh --all",
|
|
@@ -135,38 +135,6 @@
|
|
|
135
135
|
"yaml": "^2.9.0",
|
|
136
136
|
"zod": "^4.4.3"
|
|
137
137
|
},
|
|
138
|
-
"pnpm": {
|
|
139
|
-
"overrides": {
|
|
140
|
-
"flatted": ">=3.4.2",
|
|
141
|
-
"path-to-regexp": ">=8.4.0",
|
|
142
|
-
"brace-expansion": ">=5.0.5",
|
|
143
|
-
"handlebars": ">=4.7.9",
|
|
144
|
-
"picomatch": ">=4.0.4",
|
|
145
|
-
"yaml": ">=2.8.3",
|
|
146
|
-
"lodash": ">=4.18.0",
|
|
147
|
-
"lodash-es": ">=4.18.0",
|
|
148
|
-
"hono": ">=4.12.14",
|
|
149
|
-
"postcss": ">=8.5.10",
|
|
150
|
-
"esbuild": "0.28.1",
|
|
151
|
-
"fast-uri": ">=3.1.2",
|
|
152
|
-
"qs": ">=6.15.2"
|
|
153
|
-
},
|
|
154
|
-
"supportedArchitectures": {
|
|
155
|
-
"os": [
|
|
156
|
-
"darwin",
|
|
157
|
-
"linux",
|
|
158
|
-
"win32"
|
|
159
|
-
],
|
|
160
|
-
"cpu": [
|
|
161
|
-
"arm64",
|
|
162
|
-
"x64"
|
|
163
|
-
],
|
|
164
|
-
"libc": [
|
|
165
|
-
"glibc",
|
|
166
|
-
"musl"
|
|
167
|
-
]
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
138
|
"devDependencies": {
|
|
171
139
|
"@commitlint/cli": "^21.2.0",
|
|
172
140
|
"@commitlint/config-conventional": "^21.2.0",
|
|
@@ -174,18 +142,12 @@
|
|
|
174
142
|
"@napi-rs/cli": "^3.7.2",
|
|
175
143
|
"@secretlint/secretlint-rule-no-homedir": "^13.0.2",
|
|
176
144
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.2",
|
|
177
|
-
"@semantic-release/changelog": "^6.0.3",
|
|
178
|
-
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
179
|
-
"@semantic-release/git": "^10.0.1",
|
|
180
|
-
"@semantic-release/github": "^12.0.9",
|
|
181
|
-
"@semantic-release/npm": "^13.1.5",
|
|
182
|
-
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
183
145
|
"@stryker-mutator/core": "^9.6.1",
|
|
184
146
|
"@stryker-mutator/vitest-runner": "^9.6.1",
|
|
185
147
|
"@supabase/supabase-js": "^2.110.0",
|
|
186
148
|
"@types/node": "^26.1.0",
|
|
187
149
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
188
|
-
"@vitest/coverage-v8": "^4.1.
|
|
150
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
189
151
|
"@vue/test-utils": "^2.4.11",
|
|
190
152
|
"eslint": "^10.6.0",
|
|
191
153
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -199,13 +161,12 @@
|
|
|
199
161
|
"madge": "^8.0.0",
|
|
200
162
|
"prettier": "^3.9.4",
|
|
201
163
|
"secretlint": "^13.0.2",
|
|
202
|
-
"
|
|
203
|
-
"tsc-alias": "^1.8.17",
|
|
164
|
+
"tsc-alias": "^1.9.0",
|
|
204
165
|
"type-coverage": "^2.29.7",
|
|
205
166
|
"typescript": "^6.0.3",
|
|
206
167
|
"typescript-eslint": "^8.62.1",
|
|
207
168
|
"vite": "^8.1.3",
|
|
208
|
-
"vitest": "^4.1.
|
|
169
|
+
"vitest": "^4.1.10",
|
|
209
170
|
"vue": "^3.5.39"
|
|
210
171
|
}
|
|
211
172
|
}
|
package/planu-native.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": "4.10.
|
|
5
|
+
"version": "4.10.5",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": [
|
|
8
8
|
"npx",
|