@planu/cli 4.3.18 → 4.3.20

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [4.3.20] - 2026-05-27
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1073): make lifecycle state writes idempotent
5
+
6
+
7
+ ## [4.3.19] - 2026-05-27
8
+
9
+ ### Bug Fixes
10
+ - fix(SPEC-1072): clean spec advisory surfaces
11
+
12
+
1
13
  ## [4.3.13] - 2026-05-25
2
14
 
3
15
  ### Bug Fixes
@@ -7,7 +7,7 @@ import { specStore } from '../../../storage/index.js';
7
7
  const CORE_ACTION_BUDGET_MS = 2000;
8
8
  async function actuallyAppendReleasesPending(ctx, opts) {
9
9
  const { projectPath, projectId, specId } = ctx;
10
- if (!projectPath) {
10
+ if (!projectPath || ctx.newStatus !== 'done') {
11
11
  return;
12
12
  }
13
13
  if (opts.signal.aborted) {
@@ -34,6 +34,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
34
34
  /* file doesn't exist yet — start fresh */
35
35
  }
36
36
  const completedAt = new Date().toISOString().substring(0, 10);
37
+ pendingList = pendingList.filter((entry) => entry.specId !== specId);
37
38
  pendingList.push({ specId, title: spec?.title ?? specId, completedAt });
38
39
  await writeFile(pendingPath, JSON.stringify(pendingList, null, 2), 'utf-8');
39
40
  // Auto-commit planu/ changes so pending.json is never left unstaged
@@ -6,9 +6,10 @@ import { appendReleasesPending } from './core/append-releases.js';
6
6
  const FAST_HOOK_BUDGET_MS = 2000;
7
7
  export async function runCascade(ctx, opts) {
8
8
  // Core actions: awaited in parallel, each with its own 2-second budget
9
+ const releasePendingPromise = ctx.newStatus === 'done' ? appendReleasesPending(ctx) : Promise.resolve({ ok: true });
9
10
  const [sessionResult, releasesResult] = await Promise.all([
10
11
  writeSessionJson(ctx),
11
- appendReleasesPending(ctx),
12
+ releasePendingPromise,
12
13
  ]);
13
14
  const dispatched = [];
14
15
  const skipped = [];
@@ -1,5 +1,6 @@
1
1
  // pii-detector/core.ts — PII detection in spec text (SPEC-030)
2
2
  import { createRequire } from 'node:module';
3
+ import { stripNonContractText } from '../text-signal-boundaries.js';
3
4
  const require = createRequire(import.meta.url);
4
5
  export const patterns = require('../../config/pii-patterns.json');
5
6
  // ---------------------------------------------------------------------------
@@ -33,6 +34,16 @@ function isBlacklistedContext(modelContext) {
33
34
  const lower = modelContext.toLowerCase();
34
35
  return patterns.modelContextBlacklist.some((b) => lower.includes(b.toLowerCase()));
35
36
  }
37
+ function isWhitelistedContext(modelContext) {
38
+ const lower = modelContext.toLowerCase();
39
+ return patterns.modelContextWhitelist.some((w) => lower.includes(w.toLowerCase()));
40
+ }
41
+ function hasFieldCue(contextWindow) {
42
+ return /\b(field|fields|column|columns|property|properties|attribute|schema|model|entity|table|class|interface)\b/i.test(contextWindow);
43
+ }
44
+ function hasCollectionCue(contextWindow) {
45
+ return /\b(collect|store|process|persist|save|record|provide|must have|includes?|contains?)\b/i.test(contextWindow);
46
+ }
36
47
  /**
37
48
  * Determines if a detected PII field is a GDPR Art. 9 special category.
38
49
  */
@@ -77,18 +88,26 @@ function inferModelContext(contextWindow) {
77
88
  */
78
89
  function extractCandidates(text) {
79
90
  const candidates = [];
80
- const lower = text.toLowerCase();
91
+ const cleaned = stripNonContractText(text);
92
+ const lower = cleaned.toLowerCase();
81
93
  /* v8 ignore start */
82
- const words = text.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) ?? [];
94
+ const words = cleaned.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) ?? [];
83
95
  /* v8 ignore stop */
84
96
  for (const word of words) {
85
97
  if (word.length < 3) {
86
98
  continue;
87
99
  }
100
+ if (matchVocabulary(normalizeField(word)) === null) {
101
+ continue;
102
+ }
88
103
  const wordIndex = lower.indexOf(word.toLowerCase());
89
- const contextWindow = text.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
104
+ const contextWindow = cleaned.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
90
105
  const context = inferModelContext(contextWindow);
91
- candidates.push({ field: word, context });
106
+ if (isWhitelistedContext(context) ||
107
+ (hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
108
+ (hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow))) {
109
+ candidates.push({ field: word, context });
110
+ }
92
111
  }
93
112
  return candidates;
94
113
  }
@@ -135,6 +135,10 @@ export async function updateEpicProgressInSession(projectPath, specs) {
135
135
  catch {
136
136
  /* start fresh */
137
137
  }
138
+ const terminalSpecIds = new Set(specs
139
+ .filter((spec) => spec.status === 'done' || spec.status === 'discarded')
140
+ .map((spec) => spec.id));
141
+ index.activeSpecs = index.activeSpecs.filter((specId) => !terminalSpecIds.has(specId));
138
142
  index.epicProgress = epicProgress;
139
143
  index.updatedAt = new Date().toISOString();
140
144
  await writeFile(filePath, JSON.stringify(index, null, 2), 'utf-8');
@@ -0,0 +1,6 @@
1
+ /** Remove fenced code and quoted examples before keyword-driven inference. */
2
+ export declare function stripNonContractText(text: string): string;
3
+ /** True only when a regex match appears in contract prose and is not negated/excluded. */
4
+ export declare function hasAffirmedMatch(text: string, pattern: RegExp): boolean;
5
+ export declare function hasAnyAffirmedMatch(text: string, patterns: RegExp[]): boolean;
6
+ //# sourceMappingURL=text-signal-boundaries.d.ts.map
@@ -0,0 +1,62 @@
1
+ // engine/text-signal-boundaries.ts — Boundary-aware keyword matching for advisory signals.
2
+ const NEGATION_WINDOW_WORDS = 5;
3
+ const NEGATION_TERMS = new Set([
4
+ 'no',
5
+ 'not',
6
+ 'without',
7
+ 'avoid',
8
+ 'exclude',
9
+ 'excluding',
10
+ 'except',
11
+ 'skip',
12
+ 'remove',
13
+ 'disable',
14
+ 'disallow',
15
+ 'never',
16
+ ]);
17
+ const OUT_OF_SCOPE_RE = /\b(?:out\s+of\s+scope|not\s+in\s+scope|no\s+scope|do\s+not|don't)\b/i;
18
+ /** Remove fenced code and quoted examples before keyword-driven inference. */
19
+ export function stripNonContractText(text) {
20
+ return text
21
+ .replace(/```[\s\S]*?```/g, ' ')
22
+ .replace(/`[^`\n]+`/g, ' ')
23
+ .replace(/"[^"\n]+"/g, ' ')
24
+ .replace(/'[^'\n]+'/g, ' ');
25
+ }
26
+ function wordTokensBefore(text, index) {
27
+ return text
28
+ .slice(Math.max(0, index - 160), index)
29
+ .toLowerCase()
30
+ .split(/[^a-z0-9]+/i)
31
+ .filter(Boolean);
32
+ }
33
+ function isNegatedAt(text, index) {
34
+ const before = wordTokensBefore(text, index);
35
+ const recent = before.slice(-NEGATION_WINDOW_WORDS);
36
+ return recent.some((word) => NEGATION_TERMS.has(word));
37
+ }
38
+ function isOutOfScopeLine(line, indexInLine) {
39
+ return OUT_OF_SCOPE_RE.test(line.slice(0, Math.max(indexInLine, 0) + 80));
40
+ }
41
+ /** True only when a regex match appears in contract prose and is not negated/excluded. */
42
+ export function hasAffirmedMatch(text, pattern) {
43
+ const cleaned = stripNonContractText(text);
44
+ const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
45
+ const re = new RegExp(pattern.source, flags);
46
+ let match = re.exec(cleaned);
47
+ while (match) {
48
+ const index = match.index;
49
+ const lineStart = cleaned.lastIndexOf('\n', index) + 1;
50
+ const lineEnd = cleaned.indexOf('\n', index);
51
+ const line = cleaned.slice(lineStart, lineEnd === -1 ? cleaned.length : lineEnd);
52
+ if (!isNegatedAt(cleaned, index) && !isOutOfScopeLine(line, index - lineStart)) {
53
+ return true;
54
+ }
55
+ match = re.exec(cleaned);
56
+ }
57
+ return false;
58
+ }
59
+ export function hasAnyAffirmedMatch(text, patterns) {
60
+ return patterns.some((pattern) => hasAffirmedMatch(text, pattern));
61
+ }
62
+ //# sourceMappingURL=text-signal-boundaries.js.map
@@ -1,14 +1,18 @@
1
1
  // tools/challenge-spec/scenarios-data.ts — Data consistency and partial write scenarios
2
- import { contentMentions } from './scenarios-utils.js';
2
+ import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
3
+ const WRITE_SIGNALS = [
4
+ /\b(transaction|persist|database write|save record|create record|update record|delete record)\b/i,
5
+ /\b(order|checkout|submit form|mutation|multi-step write)\b/i,
6
+ ];
7
+ const CACHE_SIGNALS = [/\b(cache|real-time|realtime|concurrent|websocket|stale data)\b/i];
8
+ const DUPLICATE_OPERATION_SIGNALS = [
9
+ /\b(submit|form|checkout|order|payment|create record|mutation|button click)\b/i,
10
+ ];
3
11
  export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
4
12
  const scenarios = [];
5
13
  const lower = content.toLowerCase();
6
14
  // Partial writes
7
- if (lower.includes('transaction') ||
8
- lower.includes('update') ||
9
- lower.includes('create') ||
10
- lower.includes('modify') ||
11
- lower.includes('save')) {
15
+ if (hasAnyAffirmedMatch(content, WRITE_SIGNALS)) {
12
16
  scenarios.push({
13
17
  scenario: 'Partial write: server crashes mid-transaction',
14
18
  probability: 'low',
@@ -22,7 +26,7 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
22
26
  });
23
27
  }
24
28
  // Stale data
25
- if (lower.includes('cache') || lower.includes('real-time') || lower.includes('concurrent')) {
29
+ if (hasAnyAffirmedMatch(content, CACHE_SIGNALS)) {
26
30
  scenarios.push({
27
31
  scenario: 'Stale data served from cache after underlying data changes',
28
32
  probability: 'high',
@@ -35,18 +39,20 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
35
39
  userExperience: 'Show "last updated" timestamp. Offer manual refresh option.',
36
40
  });
37
41
  }
38
- // Duplicate operations
39
- scenarios.push({
40
- scenario: 'User double-clicks submit, causing duplicate records',
41
- probability: 'high',
42
- impact: 'medium',
43
- currentHandling: contentMentions(lower, ['idempoten', 'debounce', 'disable', 'lock'])
44
- ? 'Some prevention mentioned'
45
- : 'Not addressed',
46
- requiredHandling: 'Implement idempotency keys for mutations. Disable submit button on click. Use unique constraints in DB.',
47
- dataConsistency: 'No duplicate records from repeated submissions.',
48
- userExperience: 'Button disabled during submission. Clear success/error feedback.',
49
- });
42
+ // Duplicate operations only apply to explicit user-triggered mutations.
43
+ if (hasAnyAffirmedMatch(content, DUPLICATE_OPERATION_SIGNALS)) {
44
+ scenarios.push({
45
+ scenario: 'User double-clicks submit, causing duplicate records',
46
+ probability: 'high',
47
+ impact: 'medium',
48
+ currentHandling: contentMentions(lower, ['idempoten', 'debounce', 'disable', 'lock'])
49
+ ? 'Some prevention mentioned'
50
+ : 'Not addressed',
51
+ requiredHandling: 'Implement idempotency keys for mutations. Disable submit button on click. Use unique constraints in DB.',
52
+ dataConsistency: 'No duplicate records from repeated submissions.',
53
+ userExperience: 'Button disabled during submission. Clear success/error feedback.',
54
+ });
55
+ }
50
56
  return scenarios;
51
57
  }
52
58
  //# sourceMappingURL=scenarios-data.js.map
@@ -1,14 +1,20 @@
1
1
  // tools/challenge-spec/scenarios-security.ts — Authentication, injection, and CSRF scenarios
2
- import { contentMentions } from './scenarios-utils.js';
2
+ import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
3
+ const AUTH_SIGNALS = [
4
+ /\b(auth|authentication|login|session|token|jwt|oauth|permission|role|rbac)\b/i,
5
+ ];
6
+ const INPUT_SIGNALS = [
7
+ /\b(user input|form|query param|search input|request body|payload|html input)\b/i,
8
+ ];
9
+ const STATE_CHANGING_WEB_SIGNALS = [
10
+ /\b(form submit|post request|put request|delete request|mutation|state-changing|cookie auth)\b/i,
11
+ ];
3
12
  export function generateSecurityScenarios(_spec, content, _knowledge) {
4
13
  const scenarios = [];
5
14
  const lower = content.toLowerCase();
15
+ const text = content;
6
16
  // Authentication bypass
7
- if (lower.includes('auth') ||
8
- lower.includes('login') ||
9
- lower.includes('user') ||
10
- lower.includes('permission') ||
11
- lower.includes('role')) {
17
+ if (hasAnyAffirmedMatch(text, AUTH_SIGNALS)) {
12
18
  scenarios.push({
13
19
  scenario: 'Unauthorized access attempt / authentication bypass',
14
20
  probability: 'high',
@@ -22,11 +28,7 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
22
28
  });
23
29
  }
24
30
  // Injection attacks
25
- if (lower.includes('input') ||
26
- lower.includes('form') ||
27
- lower.includes('query') ||
28
- lower.includes('search') ||
29
- lower.includes('user')) {
31
+ if (hasAnyAffirmedMatch(text, INPUT_SIGNALS)) {
30
32
  scenarios.push({
31
33
  scenario: 'SQL injection / XSS / command injection via user input',
32
34
  probability: 'high',
@@ -39,18 +41,22 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
39
41
  userExperience: 'Transparent to user. Invalid input is silently sanitized.',
40
42
  });
41
43
  }
42
- // CSRF / rate limiting
43
- scenarios.push({
44
- scenario: 'CSRF attack or brute-force attempt',
45
- probability: 'medium',
46
- impact: 'high',
47
- currentHandling: contentMentions(lower, ['csrf', 'rate limit', 'throttle', 'captcha'])
48
- ? 'Some protection mentioned'
49
- : 'Not addressed',
50
- requiredHandling: 'Implement CSRF tokens for state-changing operations. Rate-limit sensitive endpoints. Add CAPTCHA for auth endpoints.',
51
- dataConsistency: 'Prevent mass data modifications from automated attacks.',
52
- userExperience: 'Show rate limit message. Require CAPTCHA after N failed attempts.',
53
- });
44
+ // CSRF / rate limiting only applies when the spec explicitly involves auth or
45
+ // state-changing web input. Tooling/spec-cleanup tasks should not get this as noise.
46
+ if (hasAnyAffirmedMatch(text, AUTH_SIGNALS) ||
47
+ hasAnyAffirmedMatch(text, STATE_CHANGING_WEB_SIGNALS)) {
48
+ scenarios.push({
49
+ scenario: 'CSRF attack or brute-force attempt',
50
+ probability: 'medium',
51
+ impact: 'high',
52
+ currentHandling: contentMentions(lower, ['csrf', 'rate limit', 'throttle', 'captcha'])
53
+ ? 'Some protection mentioned'
54
+ : 'Not addressed',
55
+ requiredHandling: 'Implement CSRF tokens for state-changing operations. Rate-limit sensitive endpoints. Add CAPTCHA for auth endpoints.',
56
+ dataConsistency: 'Prevent mass data modifications from automated attacks.',
57
+ userExperience: 'Show rate limit message. Require CAPTCHA after N failed attempts.',
58
+ });
59
+ }
54
60
  return scenarios;
55
61
  }
56
62
  //# sourceMappingURL=scenarios-security.js.map
@@ -3,4 +3,5 @@
3
3
  * lowerContent is expected to already be lowercased by the caller.
4
4
  */
5
5
  export declare function contentMentions(lowerContent: string, keywords: string[]): boolean;
6
+ export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
6
7
  //# sourceMappingURL=scenarios-utils.d.ts.map
@@ -6,4 +6,5 @@
6
6
  export function contentMentions(lowerContent, keywords) {
7
7
  return keywords.some((kw) => lowerContent.includes(kw.toLowerCase()));
8
8
  }
9
+ export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
9
10
  //# sourceMappingURL=scenarios-utils.js.map
@@ -182,6 +182,15 @@ const EN_STOPWORDS = new Set([
182
182
  'it',
183
183
  'be',
184
184
  'as',
185
+ 'spec',
186
+ 'create',
187
+ 'workflow',
188
+ 'files',
189
+ 'file',
190
+ 'example',
191
+ 'avoid',
192
+ 'planu',
193
+ 'feature',
185
194
  ]);
186
195
  /** Infer meaningful tags from title and description when no tags were provided. */
187
196
  export function inferTagsFromContent(title, description, type) {
@@ -213,13 +222,13 @@ export function inferTagsFromContent(title, description, type) {
213
222
  }
214
223
  return tags;
215
224
  }
216
- /** Build the final tags array for a spec, enforcing minimum 3 tags. */
217
- function buildTags(tags, feature, title, description, type) {
218
- const result = feature ? [...new Set([...tags, slugify(feature)])] : [...tags];
219
- if (result.length === 0) {
220
- return inferTagsFromContent(title, description, type);
221
- }
222
- return result;
225
+ /** Build the final tags array from explicit user/tool input only. */
226
+ function buildTags(tags, feature, _title, _description, _type) {
227
+ const explicitTags = tags
228
+ .map((tag) => slugify(tag))
229
+ .filter((tag) => tag.length > 0 && !EN_STOPWORDS.has(tag));
230
+ const result = feature ? [...new Set([...explicitTags, slugify(feature)])] : explicitTags;
231
+ return [...new Set(result)];
223
232
  }
224
233
  /* v8 ignore start -- filesystem scanning is best-effort, tested via integration */
225
234
  /** Scan filesystem for SPEC-XXX directories to find max ID globally. */
@@ -14,7 +14,7 @@ import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostC
14
14
  import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
15
15
  import { compactObj } from '../engine/compact-obj.js';
16
16
  import { buildCreateSpecSummary } from '../engine/human-summary.js';
17
- import { runAutoPostCreatePipeline, formatPipelineLines } from './create-spec/auto-pipeline.js';
17
+ import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
18
18
  import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
19
19
  import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
20
20
  import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
@@ -133,6 +133,16 @@ function runSimplicityCheck(text, hours) {
133
133
  return null;
134
134
  }
135
135
  }
136
+ function makeAdvisorySignal(args) {
137
+ return { ...args, persistable: false };
138
+ }
139
+ function buildAdvisoryCompat() {
140
+ return {
141
+ legacyTopLevelAdvisoryFields: true,
142
+ deprecatedAfter: 'vNext+2',
143
+ canonicalField: 'advisorySignals',
144
+ };
145
+ }
136
146
  /** SPEC-612: resolve outOfScope items (provided by user, or auto-suggested from description). */
137
147
  function resolveOutOfScope(description, provided) {
138
148
  if (provided !== undefined && provided.length > 0) {
@@ -181,18 +191,6 @@ async function runComplexityAdvice(projectId, currentSpecId, tags, targetCriteri
181
191
  return null;
182
192
  }
183
193
  }
184
- function formatSimplicityLines(r) {
185
- const icon = r.recommendation === 'simplify' ? '🔴' : r.recommendation === 'review' ? '🟡' : '🟢';
186
- const signalNames = r.signals.map((s) => s.type).join(', ');
187
- const lines = [
188
- '',
189
- `${icon} **Simplicity**: score ${String(r.score)}/100 — ${signalNames !== '' ? signalNames : 'no signals'}`,
190
- ];
191
- if (r.recommendation !== 'approve' && r.simpleAlternative !== null) {
192
- lines.push(` Suggestion: ${r.simpleAlternative}`);
193
- }
194
- return lines;
195
- }
196
194
  /** SPEC-783: Synthesize agent team findings into unified spec.md ## Technical section. */
197
195
  async function handleAgentTeamSynthesis(specId, projectPath, findings) {
198
196
  try {
@@ -723,9 +721,21 @@ export async function handleCreateSpec(inputParams, server) {
723
721
  /* v8 ignore next -- requires real git repo */
724
722
  ...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
725
723
  };
724
+ const advisorySignals = [];
726
725
  const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
727
726
  if (splitResult) {
728
727
  result.splitSuggestion = splitResult;
728
+ advisorySignals.push(makeAdvisorySignal({
729
+ key: 'split-suggestion',
730
+ kind: 'complexity',
731
+ message: 'Spec may benefit from splitting.',
732
+ source: 'heuristic',
733
+ evidence: ['spec-splitter heuristic'],
734
+ confidence: 0.5,
735
+ surface: 'structuredContent',
736
+ value: splitResult,
737
+ deprecatedAlias: 'splitSuggestion',
738
+ }));
729
739
  }
730
740
  // Dispatch hook event (fire-and-forget)
731
741
  fireSpecCreatedHook(projectId, spec, params.projectPath ?? '');
@@ -780,31 +790,97 @@ export async function handleCreateSpec(inputParams, server) {
780
790
  const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
781
791
  if (qualityValue !== null) {
782
792
  result.qualityScore = qualityValue;
793
+ advisorySignals.push(makeAdvisorySignal({
794
+ key: 'quality-score',
795
+ kind: 'quality',
796
+ message: `Quality score ${String(qualityValue.total)}/100 (${qualityValue.grade}).`,
797
+ source: 'validator',
798
+ evidence: ['spec-quality-scorer'],
799
+ confidence: 0.7,
800
+ surface: 'structuredContent',
801
+ value: qualityValue,
802
+ deprecatedAlias: 'qualityScore',
803
+ }));
783
804
  }
784
805
  // SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
785
806
  const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
786
807
  if (simplicityResult) {
787
808
  result.simplicityCheck = simplicityResult;
809
+ advisorySignals.push(makeAdvisorySignal({
810
+ key: 'simplicity-check',
811
+ kind: 'simplicity',
812
+ message: `Simplicity recommendation: ${simplicityResult.recommendation}.`,
813
+ source: 'heuristic',
814
+ evidence: simplicityResult.signals.map((signal) => signal.type),
815
+ confidence: 0.55,
816
+ surface: 'structuredContent',
817
+ value: simplicityResult,
818
+ deprecatedAlias: 'simplicityCheck',
819
+ }));
788
820
  }
789
821
  // SPEC-514: duplicate results
790
822
  const possibleDuplicates = unwrapBudget(duplicatesResult, [], budgetWarnings);
791
823
  if (possibleDuplicates.length > 0) {
792
824
  result.possibleDuplicates = possibleDuplicates;
825
+ advisorySignals.push(makeAdvisorySignal({
826
+ key: 'possible-duplicates',
827
+ kind: 'duplicate',
828
+ message: `${String(possibleDuplicates.length)} possible duplicate spec(s) detected.`,
829
+ source: 'heuristic',
830
+ evidence: possibleDuplicates.map((dup) => dup.specId),
831
+ confidence: 0.5,
832
+ surface: 'structuredContent',
833
+ value: possibleDuplicates,
834
+ deprecatedAlias: 'possibleDuplicates',
835
+ }));
793
836
  }
794
837
  // SPEC-222 Trigger 2: Auto-challenge hint for high-risk specs
795
838
  if (spec.risk === 'high' || spec.difficulty >= 4) {
796
839
  result.riskWarning = HIGH_RISK_WARNING;
840
+ advisorySignals.push(makeAdvisorySignal({
841
+ key: 'risk-warning',
842
+ kind: 'challenge',
843
+ message: 'High-risk heuristic warning generated.',
844
+ source: 'heuristic',
845
+ evidence: [`risk:${spec.risk}`, `difficulty:${String(spec.difficulty)}`],
846
+ confidence: 0.6,
847
+ surface: 'structuredContent',
848
+ value: HIGH_RISK_WARNING,
849
+ deprecatedAlias: 'riskWarning',
850
+ }));
797
851
  }
798
852
  // SPEC-614 AC4: complexity advice
799
853
  const cAdvice = unwrapBudget(complexityResult, null, budgetWarnings);
800
854
  if (cAdvice) {
801
855
  result.complexityAdvice = cAdvice;
856
+ advisorySignals.push(makeAdvisorySignal({
857
+ key: 'complexity-advice',
858
+ kind: 'complexity',
859
+ message: cAdvice.reasoning,
860
+ source: 'history',
861
+ evidence: [`similarSpecs:${String(cAdvice.similarSpecsCount)}`],
862
+ confidence: 0.6,
863
+ surface: 'structuredContent',
864
+ value: cAdvice,
865
+ deprecatedAlias: 'complexityAdvice',
866
+ }));
802
867
  }
803
868
  // SPEC-615: prior decisions
804
869
  const priorLinks = unwrapBudget(priorLinksResult, [], budgetWarnings);
805
870
  if (priorLinks.length > 0) {
806
871
  result.priorDecisions = priorLinks.map((l) => l.decisionId);
807
872
  spec.priorDecisions = result.priorDecisions;
873
+ advisorySignals.push(makeAdvisorySignal({
874
+ key: 'prior-decisions',
875
+ kind: 'prior-decision',
876
+ message: `${String(priorLinks.length)} prior decision link(s) found.`,
877
+ source: 'history',
878
+ evidence: priorLinks.map((link) => link.decisionId),
879
+ confidence: 0.65,
880
+ surface: 'structuredContent',
881
+ value: result.priorDecisions,
882
+ deprecatedAlias: 'priorDecisions',
883
+ }));
808
884
  }
809
885
  // Post-creation suggestions
810
886
  const nextSteps = unwrapBudget(nextStepsResult, [], budgetWarnings);
@@ -871,30 +947,13 @@ export async function handleCreateSpec(inputParams, server) {
871
947
  if (contradictionHint) {
872
948
  lines.push('', `⚠️ ${contradictionHint}`);
873
949
  }
874
- if (possibleDuplicates.length > 0) {
875
- lines.push('', '⚠️ **Possible Duplicates** (semantic similarity > 30%):');
876
- for (const dup of possibleDuplicates) {
877
- const pct = Math.round(dup.score * 100);
878
- lines.push(`- **${dup.specId}** — ${dup.title} (${String(pct)}% similar)`);
879
- }
880
- }
881
- if (result.riskWarning) {
882
- lines.push('', `⚠️ ${result.riskWarning}`);
883
- }
884
- if (splitResult) {
885
- lines.push('', `💡 ${splitResult.formatted ?? 'Consider splitting this spec.'}`);
886
- }
887
- // SPEC-492: Quality score output
888
- if (result.qualityScore) {
889
- const qs = result.qualityScore;
890
- lines.push('', `📊 **Quality score**: ${String(qs.total)}/100 (${qs.grade})`);
891
- }
892
- // SPEC-485: Simplicity autopilot output
893
- if (simplicityResult) {
894
- lines.push(...formatSimplicityLines(simplicityResult));
950
+ if (possibleDuplicates.length > 0 ||
951
+ result.riskWarning ||
952
+ splitResult ||
953
+ result.qualityScore ||
954
+ simplicityResult) {
955
+ lines.push('', 'Advisory signals were computed and are available in structuredContent.advisorySignals.');
895
956
  }
896
- // Append auto-pipeline output (SPEC-445)
897
- lines.push(...formatPipelineLines(pipelineResult));
898
957
  const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
899
958
  const markdownText = allNextSteps.length > 0
900
959
  ? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
@@ -904,15 +963,54 @@ export async function handleCreateSpec(inputParams, server) {
904
963
  // SPEC-612: Record outOfScope auto-suggestion in autopilot summary
905
964
  if (outOfScopeSuggestionMsg !== null) {
906
965
  collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
966
+ advisorySignals.push(makeAdvisorySignal({
967
+ key: 'out-of-scope-suggestions',
968
+ kind: 'out-of-scope',
969
+ message: outOfScopeSuggestionMsg,
970
+ source: 'heuristic',
971
+ evidence: ['scope-boundaries suggester'],
972
+ confidence: 0.45,
973
+ surface: 'structuredContent',
974
+ value: spec.outOfScope ?? [],
975
+ }));
907
976
  }
908
977
  if (autopilot.detectedPatterns.length > 0) {
909
978
  collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
979
+ advisorySignals.push(makeAdvisorySignal({
980
+ key: 'detected-patterns',
981
+ kind: 'domain',
982
+ message: `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`,
983
+ source: 'heuristic',
984
+ evidence: autopilot.detectedPatterns,
985
+ confidence: 0.5,
986
+ surface: 'structuredContent',
987
+ value: autopilot.detectedPatterns,
988
+ }));
910
989
  }
911
990
  const totalSuggestedFiles = autopilot.suggestedFiles.create.length +
912
991
  autopilot.suggestedFiles.modify.length +
913
992
  autopilot.suggestedFiles.test.length;
914
993
  if (totalSuggestedFiles > 0) {
915
994
  collector.pushOk('file-analysis', `Suggested ${String(totalSuggestedFiles)} files (${String(autopilot.suggestedFiles.create.length)} create, ${String(autopilot.suggestedFiles.modify.length)} modify, ${String(autopilot.suggestedFiles.test.length)} test)`);
995
+ advisorySignals.push(makeAdvisorySignal({
996
+ key: 'suggested-files',
997
+ kind: 'file',
998
+ message: `${String(totalSuggestedFiles)} possible related file(s) detected.`,
999
+ source: 'heuristic',
1000
+ evidence: [
1001
+ ...autopilot.suggestedFiles.create.map((f) => f.path),
1002
+ ...autopilot.suggestedFiles.modify.map((f) => f.path),
1003
+ ...autopilot.suggestedFiles.test.map((f) => f.path),
1004
+ ],
1005
+ confidence: 0.45,
1006
+ surface: 'structuredContent',
1007
+ value: {
1008
+ create: autopilot.suggestedFiles.create.map((f) => f.path),
1009
+ modify: autopilot.suggestedFiles.modify.map((f) => f.path),
1010
+ test: autopilot.suggestedFiles.test.map((f) => f.path),
1011
+ },
1012
+ deprecatedAlias: 'autopilotSummary.suggestedFiles',
1013
+ }));
916
1014
  }
917
1015
  if (filteredCriteria.length > 0) {
918
1016
  collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
@@ -926,15 +1024,37 @@ export async function handleCreateSpec(inputParams, server) {
926
1024
  ? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
927
1025
  : '';
928
1026
  collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
1027
+ advisorySignals.push(makeAdvisorySignal({
1028
+ key: 'readiness-score',
1029
+ kind: 'readiness',
1030
+ message: `Readiness score: ${String(pipelineResult.readinessScore)}/100.`,
1031
+ source: 'validator',
1032
+ evidence: ['auto post-creation pipeline'],
1033
+ confidence: 0.6,
1034
+ surface: 'structuredContent',
1035
+ value: pipelineResult.readinessScore,
1036
+ }));
929
1037
  }
930
1038
  else if (spec.difficulty >= 3) {
931
1039
  // SPEC-629: always surface the Opus hint for high-difficulty specs
932
1040
  collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
1041
+ advisorySignals.push(makeAdvisorySignal({
1042
+ key: 'model-recommendation',
1043
+ kind: 'model',
1044
+ message: 'High-difficulty spec may benefit from a stronger model for review.',
1045
+ source: 'heuristic',
1046
+ evidence: [`difficulty:${String(spec.difficulty)}`],
1047
+ confidence: 0.4,
1048
+ surface: 'structuredContent',
1049
+ value: { recommendedTier: 'max' },
1050
+ }));
933
1051
  }
934
1052
  if (calibrationEnrichment.calibrationNote) {
935
1053
  collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
936
1054
  }
937
1055
  const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
1056
+ result.advisorySignals = advisorySignals;
1057
+ result.compat = buildAdvisoryCompat();
938
1058
  const compactResult = compactObj(result);
939
1059
  // SPEC-722: Issue a planner token for this spec creation.
940
1060
  // Best-effort — token failure never blocks spec creation.
@@ -965,6 +1085,8 @@ export async function handleCreateSpec(inputParams, server) {
965
1085
  : undefined;
966
1086
  const baseResult = toolResult(markdownText, {
967
1087
  ...compactResult,
1088
+ advisorySignals,
1089
+ compat: buildAdvisoryCompat(),
968
1090
  humanSummary,
969
1091
  ...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
970
1092
  ...(suggestedFilesPayload !== undefined
@@ -289,42 +289,6 @@ export async function runDoneActions(projectId, specId, gitBranch) {
289
289
  clearPending(specId, 'generateSessionContext');
290
290
  }
291
291
  })(),
292
- // SPEC-649: Append spec to releases/pending.json — changelog written only at release time
293
- // SPEC-660: Dedup — pending.json append runs once per spec within 5s window
294
- (async () => {
295
- if (hasPending(specId, 'appendPending')) {
296
- return;
297
- }
298
- markPending(specId, 'appendPending');
299
- try {
300
- const { join } = await import('node:path');
301
- const { readFile, writeFile, mkdir } = await import('node:fs/promises');
302
- const releasesDir = join(projectPath, 'planu', 'releases');
303
- const pendingPath = join(releasesDir, 'pending.json');
304
- await mkdir(releasesDir, { recursive: true });
305
- const spec = await specStore.getSpec(projectId, specId);
306
- let pendingList = [];
307
- try {
308
- const raw = await readFile(pendingPath, 'utf-8');
309
- const parsed = JSON.parse(raw);
310
- if (Array.isArray(parsed)) {
311
- pendingList = parsed;
312
- }
313
- }
314
- catch {
315
- /* file doesn't exist yet — start fresh */
316
- }
317
- const completedAt = new Date().toISOString().substring(0, 10);
318
- pendingList.push({ specId, title: spec?.title ?? specId, completedAt });
319
- await writeFile(pendingPath, JSON.stringify(pendingList, null, 2), 'utf-8');
320
- }
321
- catch {
322
- /* best-effort */
323
- }
324
- finally {
325
- clearPending(specId, 'appendPending');
326
- }
327
- })(),
328
292
  // SPEC-629: Delete ephemeral prompt.md — file served its purpose once implementing starts
329
293
  (async () => {
330
294
  /* v8 ignore start */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.3.18",
3
+ "version": "4.3.20",
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,14 +34,14 @@
34
34
  "packageName": "@planu/core"
35
35
  },
36
36
  "optionalDependencies": {
37
- "@planu/core-darwin-arm64": "4.3.18",
38
- "@planu/core-darwin-x64": "4.3.18",
39
- "@planu/core-linux-arm64-gnu": "4.3.18",
40
- "@planu/core-linux-arm64-musl": "4.3.18",
41
- "@planu/core-linux-x64-gnu": "4.3.18",
42
- "@planu/core-linux-x64-musl": "4.3.18",
43
- "@planu/core-win32-arm64-msvc": "4.3.18",
44
- "@planu/core-win32-x64-msvc": "4.3.18"
37
+ "@planu/core-darwin-arm64": "4.3.20",
38
+ "@planu/core-darwin-x64": "4.3.20",
39
+ "@planu/core-linux-arm64-gnu": "4.3.20",
40
+ "@planu/core-linux-arm64-musl": "4.3.20",
41
+ "@planu/core-linux-x64-gnu": "4.3.20",
42
+ "@planu/core-linux-x64-musl": "4.3.20",
43
+ "@planu/core-win32-arm64-msvc": "4.3.20",
44
+ "@planu/core-win32-x64-msvc": "4.3.20"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,20 +1,26 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.3.18",
4
+ "version": "4.3.20",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
8
8
  "requiresMcp": false,
9
9
  "requiresDaemon": false,
10
- "hosts": ["codex", "claude-code"],
10
+ "hosts": [
11
+ "codex",
12
+ "claude-code"
13
+ ],
11
14
  "commands": [
12
15
  {
13
16
  "id": "planu.status",
14
17
  "title": "Project status",
15
18
  "description": "Show the compact Planu project snapshot without loading the MCP tool graph.",
16
19
  "invocation": "planu status",
17
- "hosts": ["codex", "claude-code"],
20
+ "hosts": [
21
+ "codex",
22
+ "claude-code"
23
+ ],
18
24
  "requiresMcp": false,
19
25
  "requiresDaemon": false,
20
26
  "mapsTo": "handlePlanStatus"
@@ -24,7 +30,10 @@
24
30
  "title": "Create spec",
25
31
  "description": "Create a new spec through the CLI-backed SDD contract.",
26
32
  "invocation": "planu spec create \"<title>\"",
27
- "hosts": ["codex", "claude-code"],
33
+ "hosts": [
34
+ "codex",
35
+ "claude-code"
36
+ ],
28
37
  "requiresMcp": false,
29
38
  "requiresDaemon": false,
30
39
  "mapsTo": "handleCreateSpec"
@@ -34,7 +43,10 @@
34
43
  "title": "List specs",
35
44
  "description": "List specs in the current project with optional status/type filters.",
36
45
  "invocation": "planu spec list",
37
- "hosts": ["codex", "claude-code"],
46
+ "hosts": [
47
+ "codex",
48
+ "claude-code"
49
+ ],
38
50
  "requiresMcp": false,
39
51
  "requiresDaemon": false,
40
52
  "mapsTo": "handleListSpecs"
@@ -44,7 +56,10 @@
44
56
  "title": "Validate spec",
45
57
  "description": "Validate a spec against the current codebase from the native CLI surface.",
46
58
  "invocation": "planu spec validate SPEC-001",
47
- "hosts": ["codex", "claude-code"],
59
+ "hosts": [
60
+ "codex",
61
+ "claude-code"
62
+ ],
48
63
  "requiresMcp": false,
49
64
  "requiresDaemon": false,
50
65
  "mapsTo": "handleValidate"
@@ -54,7 +69,10 @@
54
69
  "title": "Audit technical debt",
55
70
  "description": "Run the read-only project audit path for lightweight debt checks.",
56
71
  "invocation": "planu audit debt",
57
- "hosts": ["codex", "claude-code"],
72
+ "hosts": [
73
+ "codex",
74
+ "claude-code"
75
+ ],
58
76
  "requiresMcp": false,
59
77
  "requiresDaemon": false,
60
78
  "mapsTo": "handleAudit"
@@ -64,7 +82,10 @@
64
82
  "title": "Check release readiness",
65
83
  "description": "Check local branch cleanliness and main/develop/release sync readiness.",
66
84
  "invocation": "planu release check",
67
- "hosts": ["codex", "claude-code"],
85
+ "hosts": [
86
+ "codex",
87
+ "claude-code"
88
+ ],
68
89
  "requiresMcp": false,
69
90
  "requiresDaemon": false,
70
91
  "mapsTo": "releaseCommand"
package/planu-plugin.json CHANGED
@@ -2,9 +2,12 @@
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.3.18",
5
+ "version": "4.3.20",
6
6
  "icon": "assets/plugin/icon.svg",
7
- "command": ["npx", "@planu/cli@latest"],
7
+ "command": [
8
+ "npx",
9
+ "@planu/cli@latest"
10
+ ],
8
11
  "packageName": "@planu/cli",
9
12
  "capabilities": {
10
13
  "tools": [
@@ -23,17 +26,42 @@
23
26
  "create_skill",
24
27
  "skill_search"
25
28
  ],
26
- "resources": ["planu://specs/list", "planu://specs/{id}", "planu://project/status", "planu://roadmap"],
27
- "prompts": ["create-spec-from-idea", "review-spec-readiness", "generate-implementation-plan"],
28
- "subagents": ["sdd-orchestrator", "spec-challenger", "test-generator"]
29
+ "resources": [
30
+ "planu://specs/list",
31
+ "planu://specs/{id}",
32
+ "planu://project/status",
33
+ "planu://roadmap"
34
+ ],
35
+ "prompts": [
36
+ "create-spec-from-idea",
37
+ "review-spec-readiness",
38
+ "generate-implementation-plan"
39
+ ],
40
+ "subagents": [
41
+ "sdd-orchestrator",
42
+ "spec-challenger",
43
+ "test-generator"
44
+ ]
29
45
  },
30
46
  "compatibility": {
31
47
  "minimumHostVersion": "1.0.0",
32
- "requiredFeatures": ["mcp-tools", "file-editing"]
48
+ "requiredFeatures": [
49
+ "mcp-tools",
50
+ "file-editing"
51
+ ]
33
52
  },
34
53
  "repository": "https://github.com/planu-dev/planu",
35
54
  "author": "Planu",
36
55
  "license": "MIT",
37
56
  "homepage": "https://planu.dev",
38
- "keywords": ["sdd", "spec-driven-development", "mcp", "specs", "planning", "ai", "bdd", "tdd"]
57
+ "keywords": [
58
+ "sdd",
59
+ "spec-driven-development",
60
+ "mcp",
61
+ "specs",
62
+ "planning",
63
+ "ai",
64
+ "bdd",
65
+ "tdd"
66
+ ]
39
67
  }