@planu/cli 5.3.45 → 5.3.47

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,22 @@
1
+ ## [5.3.47] - 2026-08-24
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1599): make handoff ownership and spec-review approval errors self-serviceable
5
+
6
+ ### Chores
7
+ - chore(planu): record SPEC-1599 done and file SPEC-1600 dogfood spec
8
+
9
+
10
+ ## [5.3.46] - 2026-08-24
11
+
12
+ ### Bug Fixes
13
+ - fix(spec-1285): meta filter sees backticked tool token; harden AC1 coverage
14
+ - fix(spec-1285): stop local privacy specs activating auth/meta challenge families
15
+
16
+ ### Chores
17
+ - chore(planu): record SPEC-1285 done and SPEC-1598 discarded (merged into SPEC-1285)
18
+
19
+
1
20
  ## [5.3.45] - 2026-08-24
2
21
 
3
22
  ### Bug Fixes
@@ -239,7 +239,7 @@ function isSafeOwnershipResidual(value) {
239
239
  remaining = remaining.slice(connector.length);
240
240
  continue;
241
241
  }
242
- if (',;&'.includes(remaining[0] ?? '')) {
242
+ if (',;&.'.includes(remaining[0] ?? '')) {
243
243
  remaining = remaining.slice(1);
244
244
  continue;
245
245
  }
@@ -259,8 +259,30 @@ function isSafeOwnershipResidual(value) {
259
259
  }
260
260
  return true;
261
261
  }
262
- function ownershipBlocker(path, classifications) {
263
- return `handoff_ownership_invalid path=${path} classifications=${classifications.join(',')}`;
262
+ const OWNERSHIP_LINE_SHAPE_HINT = 'Use exactly one backticked full path: - Create|Modify: `exact/canonical/path` (pending) — plain description. No path in prose, no extra backticked tokens.';
263
+ const OWNERSHIP_DUPLICATE_HINT = 'Path is declared twice under the same section; list it once.';
264
+ const OWNERSHIP_CONFLICT_HINT = 'Path is declared under both ### Create and ### Modify; keep it in only one.';
265
+ function ownershipMissingSectionHint(classification) {
266
+ return `Add a ### ${classification} subsection under ## Files with a - \`path\` bullet, or - None. if there are none.`;
267
+ }
268
+ function ownershipMalformedSectionHint(classification) {
269
+ return `The ### ${classification} subsection has no list items; add a - \`path\` bullet or - None.`;
270
+ }
271
+ function ownershipNoneConflictHint(classification) {
272
+ return `The ### ${classification} subsection mixes - None. with real paths; use one or the other.`;
273
+ }
274
+ function ownershipAgreementHint(canonicalClass, workPlanClass, path) {
275
+ if (canonicalClass === 'Absent') {
276
+ return `Declared as ${workPlanClass} in ### File-Level Work Plan with no matching entry in ## Files; add "- ${workPlanClass}: \`${path}\`" under ### ${workPlanClass} in ## Files, or remove the work-plan line.`;
277
+ }
278
+ if (workPlanClass === 'Absent') {
279
+ return `Declared under ### ${canonicalClass} in ## Files; add the line "- ${canonicalClass}: \`${path}\` (pending) — <description>" to ### File-Level Work Plan.`;
280
+ }
281
+ return `Declared under ### ${canonicalClass} in ## Files but as ${workPlanClass} in ### File-Level Work Plan; align the verb in one of the two sections.`;
282
+ }
283
+ function ownershipBlocker(path, classifications, hint) {
284
+ const base = `handoff_ownership_invalid path=${path} classifications=${classifications.join(',')}`;
285
+ return hint ? `${base} hint=${JSON.stringify(hint)}` : base;
264
286
  }
265
287
  function stripInertOwnershipMarkup(content) {
266
288
  let fence = null;
@@ -312,7 +334,7 @@ function collectStrictOwnershipEntry(rawValue, classification, state) {
312
334
  const residualIsSafe = isSafeOwnershipResidual(residual);
313
335
  if (backtickedPaths.some((path) => path === null) ||
314
336
  (backtickedValues.length > 0 && !residualIsSafe)) {
315
- state.blockers.push(ownershipBlocker('<redacted>', [classification, 'InvalidPath']));
337
+ state.blockers.push(ownershipBlocker('<redacted>', [classification, 'InvalidPath'], OWNERSHIP_LINE_SHAPE_HINT));
316
338
  return;
317
339
  }
318
340
  const directPath = normalizeStrictOwnedPath(rawPath);
@@ -323,7 +345,7 @@ function collectStrictOwnershipEntry(rawValue, classification, state) {
323
345
  : [directPath];
324
346
  if (paths.length === 0) {
325
347
  const diagnosticPath = /^`[^`]+`$/u.test(rawPath) ? '<redacted>' : rawPath || '<empty>';
326
- state.blockers.push(ownershipBlocker(diagnosticPath, [classification, 'InvalidPath']));
348
+ state.blockers.push(ownershipBlocker(diagnosticPath, [classification, 'InvalidPath'], OWNERSHIP_LINE_SHAPE_HINT));
327
349
  return;
328
350
  }
329
351
  for (const path of paths) {
@@ -334,11 +356,11 @@ function collectStrictOwnershipPath(path, classification, state) {
334
356
  const own = classification === 'Create' ? state.create : state.modify;
335
357
  const other = classification === 'Create' ? state.modify : state.create;
336
358
  if (own.has(path)) {
337
- state.blockers.push(ownershipBlocker(path, [classification, classification]));
359
+ state.blockers.push(ownershipBlocker(path, [classification, classification], OWNERSHIP_DUPLICATE_HINT));
338
360
  return;
339
361
  }
340
362
  if (other.has(path)) {
341
- state.blockers.push(ownershipBlocker(path, ['Create', 'Modify']));
363
+ state.blockers.push(ownershipBlocker(path, ['Create', 'Modify'], OWNERSHIP_CONFLICT_HINT));
342
364
  own.delete(path);
343
365
  other.delete(path);
344
366
  return;
@@ -354,7 +376,7 @@ function extractStrictCanonicalOwnership(content) {
354
376
  const parseSection = (classification) => {
355
377
  const subsection = sectionContentAtLevel(filesSection, classification, 3);
356
378
  if (subsection === null) {
357
- state.blockers.push(ownershipBlocker('<missing>', [classification, 'MissingSection']));
379
+ state.blockers.push(ownershipBlocker('<missing>', [classification, 'MissingSection'], ownershipMissingSectionHint(classification)));
358
380
  return;
359
381
  }
360
382
  const items = subsection
@@ -362,14 +384,14 @@ function extractStrictCanonicalOwnership(content) {
362
384
  .map((line) => /^\s*[-*]\s+(.+)$/u.exec(line)?.[1]?.trim())
363
385
  .filter((item) => Boolean(item));
364
386
  if (items.length === 0) {
365
- state.blockers.push(ownershipBlocker('<empty>', [classification, 'Malformed']));
387
+ state.blockers.push(ownershipBlocker('<empty>', [classification, 'Malformed'], ownershipMalformedSectionHint(classification)));
366
388
  return;
367
389
  }
368
390
  let explicitNone = false;
369
391
  for (const item of items) {
370
392
  if (/^none\.?$/iu.test(item.trim())) {
371
393
  if (explicitNone) {
372
- state.blockers.push(ownershipBlocker('<none>', [classification, classification]));
394
+ state.blockers.push(ownershipBlocker('<none>', [classification, classification], ownershipNoneConflictHint(classification)));
373
395
  }
374
396
  explicitNone = true;
375
397
  continue;
@@ -378,7 +400,7 @@ function extractStrictCanonicalOwnership(content) {
378
400
  }
379
401
  const own = classification === 'Create' ? state.create : state.modify;
380
402
  if (explicitNone && own.size > 0) {
381
- state.blockers.push(ownershipBlocker('<none>', [classification, classification]));
403
+ state.blockers.push(ownershipBlocker('<none>', [classification, classification], ownershipNoneConflictHint(classification)));
382
404
  }
383
405
  };
384
406
  parseSection('Create');
@@ -432,7 +454,9 @@ function validateOwnershipAgreement(canonical, workPlan, testDeclaredPaths) {
432
454
  const workPlanClass = classification(workPlan, path);
433
455
  return canonicalClass === workPlanClass
434
456
  ? []
435
- : [ownershipBlocker(path, [`Canonical:${canonicalClass}`, `WorkPlan:${workPlanClass}`])];
457
+ : [
458
+ ownershipBlocker(path, [`Canonical:${canonicalClass}`, `WorkPlan:${workPlanClass}`], ownershipAgreementHint(canonicalClass, workPlanClass, path)),
459
+ ];
436
460
  });
437
461
  }
438
462
  function extractBacktickedFiles(content) {
@@ -25,6 +25,7 @@ const EXPECTED_META_ANALYSIS_RE = /^\s*expected\s*:(?=[^.!?。!?]{0,240}\b(?
25
25
  const QUALIFIED_EVIDENCE_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|when|then|and)\s+)?(?=[^.!?。!?]{0,240}\b(?:positive|negative|affirmative|meta-analysis(?:-only)?|regression|table-driven)\b)(?=[^.!?。!?]{0,240}\b(?:fixtures?|controls?|tests?|examples?|assertions?|findings?)\b)[^.!?。!?]{1,240}$/iu;
26
26
  const CONTRACT_ANALYSIS_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|when|then|and)\s+)?(?:(?:the|this|that|a|an|any|each|our|existing|current)\s+)?(?:[\p{L}\p{N}_-]+\s+){0,2}contract\s+(?:evidence|inference|requirements?)\b/iu;
27
27
  const CONTRACT_ANALYSIS_PREDICATE_RE = /\b(?:(?:is|are)\s+(?:inferred|expected|selected|classified|matched|detected|discussed|reported|recorded)|expects?|records?|describes?)\b|\b(?:detector|classifier|matcher)\s+evidence\b|\b(?:positive[ -]?control|expected\s+absence|meta-analysis(?:-only)?)\b/iu;
28
+ const META_ANALYSIS_TOOL_OUTPUT_RE = /\b(?:challenge[_\s]spec|challenge\s+tool)\b[^.!?\n]{0,120}?\bcall\b[^.!?\n]{0,120}?\b(?:emitted|returned|produced|reported|surfaced)\b[^.!?\n]{0,120}?\bscenarios?\b/iu;
28
29
  function stripExcludedMarkdownSections(text) {
29
30
  const lines = text.split('\n');
30
31
  const kept = [];
@@ -71,7 +72,8 @@ export function stripMetaAnalysisText(text) {
71
72
  !META_ANALYSIS_EVIDENCE_RE.test(clause) &&
72
73
  !EXPECTED_META_ANALYSIS_RE.test(clause) &&
73
74
  !QUALIFIED_EVIDENCE_SUBJECT_RE.test(clause) &&
74
- !(CONTRACT_ANALYSIS_SUBJECT_RE.test(clause) && CONTRACT_ANALYSIS_PREDICATE_RE.test(clause)))
75
+ !(CONTRACT_ANALYSIS_SUBJECT_RE.test(clause) && CONTRACT_ANALYSIS_PREDICATE_RE.test(clause)) &&
76
+ !META_ANALYSIS_TOOL_OUTPUT_RE.test(clause))
75
77
  .join('\n');
76
78
  }
77
79
  function clauseBefore(text, index) {
@@ -16,7 +16,7 @@ const CAPABILITY_SIGNALS = {
16
16
  ],
17
17
  authentication: [/\b(?:login|sign[ -]?in|password|jwt|oauth2?|session\s+auth)\b/i],
18
18
  authorization: [
19
- /\b(?:authori[sz]ation|permission|rbac|role-based|access\s+control|resource\s+ownership|tenant)\b/i,
19
+ /\b(?:authori[sz]ation|permission\s+(?:check|model|grant|denied|scope|boundary|set|policy)|access\s+permission|role\s+permission|rbac|role-based|access\s+control|resource\s+ownership|tenant)\b/i,
20
20
  ],
21
21
  userInput: [
22
22
  /\b(?:user|client)\s+input\b/i,
@@ -133,7 +133,7 @@ export function contentMentions(content, keywords) {
133
133
  export function detectChallengeCapabilities(spec, specContent) {
134
134
  const contractSource = stripReaderPrefix(spec, specContent);
135
135
  const source = contractSource.trim().length > 0 ? contractSource : spec.title;
136
- const contract = stripMetaAnalysisText(stripNonContractText(source));
136
+ const contract = stripNonContractText(stripMetaAnalysisText(source));
137
137
  const capabilities = Object.fromEntries(Object.entries(CAPABILITY_SIGNALS).map(([capability, patterns]) => [
138
138
  capability,
139
139
  hasAnyAffirmedMatch(contract, patterns),
@@ -581,7 +581,7 @@ export async function checkSpecReviewGate(specId, projectId, _forceApprove) {
581
581
  error: 'spec_review_stub',
582
582
  message: 'Spec review feedback is a lifecycle-generated stub, not evidence from a real reviewer.',
583
583
  blockers: [],
584
- fixHint: 'Replace the stub with genuine planu-spec-reviewer evidence in the handoff store, then retry approval.',
584
+ fixHint: `Write handoffs/${specId}/review_feedback.md as a substantive ReviewFeedbackV1 JSON: schema_version 1.0.0, verdict, reviewer.kind spec-review-agent, reviewer.agent planu-spec-reviewer, and a body covering scope reviewed, findings, and verdict rationale (not the lifecycle stub body). It must be written by an independent reviewer that is not the spec author.`,
585
585
  });
586
586
  }
587
587
  if (review.verdict !== 'approved' || review.reviewer.verdict !== 'approved') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.45",
3
+ "version": "5.3.47",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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.45",
5
+ "version": "5.3.47",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",