@planu/cli 5.3.5 → 5.3.7

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/config/runtime-policy.json +1 -0
  3. package/dist/engine/convention-scanner/codebase-scanner.js +1 -1
  4. package/dist/engine/hooks/handlers/on-impl-change.d.ts +17 -4
  5. package/dist/engine/hooks/handlers/on-impl-change.js +43 -44
  6. package/dist/engine/living-spec-analyzer.d.ts +5 -2
  7. package/dist/engine/living-spec-analyzer.js +5 -2
  8. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  9. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  10. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  11. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  12. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  14. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  16. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  18. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  20. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  22. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  23. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  24. package/dist/engine/runtime-policy.js +7 -0
  25. package/dist/engine/scope-boundaries/contradiction-checker.js +28 -20
  26. package/dist/engine/spec-format/acceptance-criteria.js +5 -2
  27. package/dist/engine/spec-migrator/filesystem-import.js +113 -73
  28. package/dist/engine/spec-migrator/frontmatter-parser.js +15 -13
  29. package/dist/tools/auto-reconcile.js +1 -1
  30. package/dist/tools/challenge-spec/challenge-report.js +1 -1
  31. package/dist/tools/init-project/portable-index-reconciler.js +2 -1
  32. package/dist/tools/reconcile-spec.js +3 -1
  33. package/dist/tools/safe-handler.js +0 -2
  34. package/dist/tools/start-hooks/engine.d.ts +2 -3
  35. package/dist/tools/start-hooks/engine.js +50 -33
  36. package/dist/tools/start-hooks.d.ts +1 -1
  37. package/dist/tools/start-hooks.js +1 -2
  38. package/dist/tools/tool-registry/group-infra.js +36 -0
  39. package/dist/tools/update-status/transition-guard.js +17 -4
  40. package/dist/tools/validate-assurance.js +1 -1
  41. package/dist/tools/validate-lint.js +1 -1
  42. package/dist/tools/validate.d.ts +1 -0
  43. package/dist/tools/validate.js +21 -2
  44. package/dist/types/file-hooks.d.ts +4 -6
  45. package/dist/types/index.d.ts +0 -1
  46. package/dist/types/index.js +0 -1
  47. package/dist/types/mcp.d.ts +0 -19
  48. package/dist/types/runtime-policy.d.ts +1 -0
  49. package/dist/types/spec/core.d.ts +5 -0
  50. package/dist/types/spec/inputs.d.ts +16 -0
  51. package/package.json +9 -9
  52. package/planu-native.json +1 -1
  53. package/planu-plugin.json +1 -1
  54. package/dist/engine/hooks/agent-hook-migrator.d.ts +0 -9
  55. package/dist/engine/hooks/agent-hook-migrator.js +0 -140
  56. package/dist/types/hook-status-update.d.ts +0 -10
  57. package/dist/types/hook-status-update.js +0 -3
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.5",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.7",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.5",
10
+ "version": "5.3.7",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "0116080409d8d34a70bcd11545d83ef0055475bac5338e1390eb2694296f64e1"
14
+ "content": "bf270b2b6a40ba2211487f2da514183717cdc24b28cbee53f8d62af0462317bd"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.5",
1109
+ "ref": "pkg:cargo/planu-core@5.3.7",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.5",
4
- "engineVersion": "5.3.5",
5
- "buildId": "1-5.3.5-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.7",
4
+ "engineVersion": "5.3.7",
5
+ "buildId": "1-5.3.7-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "69f0804044cbbf83b24eba6c91ba56052d502fdf605cb87de1e372606a1ff030",
19
+ "artifactSha256": "847e2a53bcb46266d1795f1888e17bcadcd07dedfd09c2f6380e448f04c840e3",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "69209ba0e316391778bbe7204080e36bb793efc060e77a9007b9d954081545d7"
23
+ "sha256": "1221ffeb6dfc3a3fe1870d6ec5d19849358bc274a446c9cc8f82928bcd75840b"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "1ebfd1609a251513993d8635170963f6dab1420e",
27
+ "sourceCommit": "2ec2f2db1b564d710e56bee0b7637b49c540ca32",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "CBuCyMiMXamGF99MF2n+dvmeHDR3NBXjUAgiutEjRmTakq+qdh6fPiGXQKli5kRhd5O4nfNWobIsaWJ+vSCdBA=="
36
+ "value": "/fpd0zTa2Tum1iVlKiMFfDiMgZivT8GSHaBXHIrSnG1to4HblxZzbvVtB1t6SzT/VazXFaJrsVN6mgi5PM+0Bw=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.5",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.7",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.5",
10
+ "version": "5.3.7",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "69f0804044cbbf83b24eba6c91ba56052d502fdf605cb87de1e372606a1ff030"
14
+ "content": "847e2a53bcb46266d1795f1888e17bcadcd07dedfd09c2f6380e448f04c840e3"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.5",
1109
+ "ref": "pkg:cargo/planu-core@5.3.7",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.5",
4
- "engineVersion": "5.3.5",
5
- "buildId": "1-5.3.5-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.7",
4
+ "engineVersion": "5.3.7",
5
+ "buildId": "1-5.3.7-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "266c514673274fe29f5d625cf33524e203371741f320e6933c0231824506e433",
19
+ "artifactSha256": "60ce324d882d46aaf9ce76f136fc1690133f02da345141846014ad69c64b0d1c",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "ba40d6627f3bacbc78c068a377d420a1d6cfebe1e75e7e90b4b94c726f7b71ea"
23
+ "sha256": "959a4bc45bae58e8b9152af89c6ef6da56f762017677b93ceb1f82c7f245fd27"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "1ebfd1609a251513993d8635170963f6dab1420e",
27
+ "sourceCommit": "2ec2f2db1b564d710e56bee0b7637b49c540ca32",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "zQa9uJfhQpDFLD3/iWUJQGPjUjlqm66DKR9VX+2kbc7dGmYODAxXYLxxJKnFKnpjyaToedkLmurajIE4zMODAA=="
36
+ "value": "ma06pJyrfthlcVjNUlZW0ledijIuGYUE77VdST+TzST4J5dzTOO7eMYjJbideY/xL+FUcwqLYquiApnRJQvBAw=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.5",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.7",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.5",
10
+ "version": "5.3.7",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "266c514673274fe29f5d625cf33524e203371741f320e6933c0231824506e433"
14
+ "content": "60ce324d882d46aaf9ce76f136fc1690133f02da345141846014ad69c64b0d1c"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.5",
1109
+ "ref": "pkg:cargo/planu-core@5.3.7",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -47,6 +47,7 @@ const runtimePolicySchema = z
47
47
  .object({
48
48
  defaultDeadlineMs: z.number().int().positive(),
49
49
  durableLintTimeoutMs: z.number().int().positive(),
50
+ perGateTimeoutMs: z.number().int().positive(),
50
51
  complianceCommandTimeoutMs: z.number().int().positive(),
51
52
  noProgressTimeoutMs: z.number().int().positive(),
52
53
  submissionLockWaitMs: z.number().int().positive(),
@@ -72,6 +73,12 @@ const runtimePolicySchema = z
72
73
  })
73
74
  .refine((value) => value.durableLintTimeoutMs < value.complianceCommandTimeoutMs, {
74
75
  message: 'durableLintTimeoutMs must be below complianceCommandTimeoutMs',
76
+ })
77
+ .refine((value) => value.durableLintTimeoutMs < value.perGateTimeoutMs, {
78
+ message: 'durableLintTimeoutMs must be below perGateTimeoutMs',
79
+ })
80
+ .refine((value) => value.perGateTimeoutMs < value.noProgressTimeoutMs, {
81
+ message: 'perGateTimeoutMs must be below noProgressTimeoutMs',
75
82
  })
76
83
  .refine((value) => value.complianceCommandTimeoutMs + PROCESS_TERMINATION_GRACE_MS <
77
84
  value.noProgressTimeoutMs, {
@@ -47,6 +47,8 @@ function keywords(phrase) {
47
47
  function preservesBoundary(text) {
48
48
  const normalized = normalize(text);
49
49
  return [
50
+ /\balongside the (?:existing|current)\b/,
51
+ /\balready (?:targets?|does|do|supports?|covers?|handles?)\b/,
50
52
  /\bremains? unchanged\b/,
51
53
  /\bkeeps?\b.{0,80}\bunchanged\b/,
52
54
  /\bpreserves?\b.{0,80}\b(?:behavior|contract|boundary|semantics|state)\b/,
@@ -55,18 +57,21 @@ function preservesBoundary(text) {
55
57
  /\bno changes? to\b/,
56
58
  ].some((pattern) => pattern.test(normalized));
57
59
  }
60
+ const CLAUSE_KEYWORD_OR_PUNCTUATION = /\b(?:but|while|although|however)\b|[,;]/i;
61
+ const SENTENCE_ENDING_PERIOD = /\.(?=\s|$)/;
58
62
  function splitClauses(text) {
59
63
  return text
60
- .split(/\b(?:but|while|although|however)\b|[,;.]/i)
64
+ .split(CLAUSE_KEYWORD_OR_PUNCTUATION)
65
+ .flatMap((segment) => segment.split(SENTENCE_ENDING_PERIOD))
61
66
  .map((clause) => clause.trim())
62
67
  .filter(Boolean);
63
68
  }
64
69
  function hasAffirmativeDrift(text, outOfScopeItem) {
65
- const action = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?)\b/g;
70
+ const action = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/g;
66
71
  const actionSegments = text.split(/\band\b/i).map(normalize);
67
72
  for (const [index, segment] of actionSegments.entries()) {
68
73
  const inheritsRelevantSubject = index > 0 &&
69
- /^(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?)\b/.test(segment) &&
74
+ /^(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/.test(segment) &&
70
75
  clauseMatchesScope(actionSegments[index - 1] ?? '', outOfScopeItem);
71
76
  for (const match of segment.matchAll(action)) {
72
77
  const before = segment.slice(Math.max(0, match.index - 24), match.index);
@@ -84,6 +89,12 @@ function hasAffirmativeDrift(text, outOfScopeItem) {
84
89
  }
85
90
  return false;
86
91
  }
92
+ function containsKeyword(normalizedHaystack, keyword) {
93
+ return new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(normalizedHaystack);
94
+ }
95
+ function isDirectPhraseMatch(normCriterion, normScope) {
96
+ return containsKeyword(normCriterion, normScope) || containsKeyword(normScope, normCriterion);
97
+ }
87
98
  function clauseMatchesScope(clause, outOfScopeItem) {
88
99
  const normalizedClause = normalize(clause);
89
100
  const normalizedScope = normalize(outOfScopeItem);
@@ -91,10 +102,16 @@ function clauseMatchesScope(clause, outOfScopeItem) {
91
102
  return true;
92
103
  }
93
104
  const scopeKeywords = keywords(outOfScopeItem);
94
- const matchCount = scopeKeywords.filter((keyword) => normalizedClause.includes(keyword)).length;
105
+ const matchCount = scopeKeywords.filter((keyword) => containsKeyword(normalizedClause, keyword)).length;
95
106
  const threshold = scopeKeywords.length <= 2 ? scopeKeywords.length : 2;
96
107
  return scopeKeywords.length > 0 && matchCount >= threshold;
97
108
  }
109
+ function clauseMentionsScope(clause, outOfScopeItem) {
110
+ const normalizedClause = normalize(clause);
111
+ const scopeKeywords = keywords(outOfScopeItem);
112
+ const mentionCandidates = scopeKeywords.length > 0 ? scopeKeywords : [normalize(outOfScopeItem)];
113
+ return mentionCandidates.some((keyword) => containsKeyword(normalizedClause, keyword));
114
+ }
98
115
  /**
99
116
  * Determine whether a criterion text contradicts an out-of-scope item.
100
117
  * Uses substring match first, then keyword overlap as fuzzy fallback.
@@ -102,30 +119,21 @@ function clauseMatchesScope(clause, outOfScopeItem) {
102
119
  function contradicts(criterionText, outOfScopeItem) {
103
120
  const normCriterion = normalize(criterionText);
104
121
  const normScope = normalize(outOfScopeItem);
105
- const relevantClauses = splitClauses(criterionText).filter((clause) => clauseMatchesScope(clause, outOfScopeItem));
122
+ const clauses = splitClauses(criterionText);
123
+ const relevantClauses = clauses.filter((clause) => clauseMentionsScope(clause, outOfScopeItem));
106
124
  const hasConditionalException = /\b(?:unless|except)\b/i.test(criterionText);
107
125
  if (!hasConditionalException &&
108
- relevantClauses.length > 0 &&
109
- relevantClauses.every((clause) => preservesBoundary(clause) && !hasAffirmativeDrift(clause, outOfScopeItem))) {
126
+ relevantClauses.some(preservesBoundary) &&
127
+ !relevantClauses.some((clause) => hasAffirmativeDrift(clause, outOfScopeItem))) {
110
128
  return false;
111
129
  }
112
- // 1. Direct substring match
113
- if (normCriterion.includes(normScope)) {
114
- return true;
115
- }
116
- // 2. Reverse — the out-of-scope item contains the whole criterion (unlikely but safe)
117
- if (normScope.includes(normCriterion)) {
130
+ if (isDirectPhraseMatch(normCriterion, normScope)) {
118
131
  return true;
119
132
  }
120
- // 3. Keyword overlap: if 2+ significant keywords from outOfScope appear in criterion
121
- const scopeKws = keywords(outOfScopeItem);
122
- if (scopeKws.length === 0) {
133
+ if (keywords(outOfScopeItem).length === 0) {
123
134
  return false;
124
135
  }
125
- const matchCount = scopeKws.filter((kw) => normCriterion.includes(kw)).length;
126
- // For 1 keyword: require it to match; for 2 keywords: require both; for 3+: require 2
127
- const threshold = scopeKws.length === 1 ? 1 : scopeKws.length === 2 ? 2 : 2;
128
- return matchCount >= threshold;
136
+ return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem));
129
137
  }
130
138
  /**
131
139
  * Check acceptance criteria against the spec's outOfScope declarations.
@@ -5,7 +5,7 @@ import { stripFencedBlocks } from './text-fences.js';
5
5
  import { createCriterionIdentity, normalizeCriterionText } from '../criterion-identity.js';
6
6
  const BDD_STEP = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
7
7
  const BDD_START = /^(GIVEN|DADO)\b/i;
8
- const TECHNICAL_MARKER = /^(FILES|FUNCTIONS|TEST|OWNER|NOTE):/i;
8
+ const TECHNICAL_MARKER = /^(FILES|FUNCTIONS|TEST|OWNER|NOTE|AB\s*-?\s*\d+|ANTICIPATED TEST BREAK|TEST BREAK):/i;
9
9
  const EXPLICIT_CRITERION_HEADING = /^#{3,6}\s+((?:AC|AB)\s*-?\s*\d+)\b/i;
10
10
  function extractBddBlocks(section) {
11
11
  const lines = section.split('\n').map((line) => line.trim());
@@ -93,7 +93,10 @@ function extractSectionCriteria(section) {
93
93
  const listItems = extractListItems(section).filter((item) => !TECHNICAL_MARKER.test(item));
94
94
  if (listItems.length > 0) {
95
95
  const nonStepItems = listItems.filter((item) => !BDD_STEP.test(item));
96
- return nonStepItems.length > 0 ? nonStepItems : extractBddBlocks(section);
96
+ if (nonStepItems.length === listItems.length) {
97
+ return nonStepItems.length > 0 ? nonStepItems : extractBddBlocks(section);
98
+ }
99
+ return [...extractBddBlocks(section), ...nonStepItems];
97
100
  }
98
101
  return extractBddBlocks(section);
99
102
  }
@@ -1,7 +1,39 @@
1
1
  // spec-migrator/filesystem-import.ts — Import specs from disk into the store
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { calculateEstimation } from '../estimator.js';
4
5
  import { parseFrontmatterLocal, validSpecType, validSpecScope, validSpecStatus, validRiskLevel, validSpecTarget, validDifficulty, } from './frontmatter-parser.js';
6
+ const RECOMMENDED_MODELS = new Set(['opus', 'sonnet', 'mixed']);
7
+ const EXECUTION_MODES = new Set(['local', 'template', 'light-llm', 'full-llm']);
8
+ function isPlainObject(value) {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+ function isFiniteNonNegative(value) {
12
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
13
+ }
14
+ function isValidTokenStrategy(value) {
15
+ return (isPlainObject(value) &&
16
+ typeof value.mode === 'string' &&
17
+ EXECUTION_MODES.has(value.mode) &&
18
+ typeof value.reasoning === 'string' &&
19
+ isFiniteNonNegative(value.estimatedTokens) &&
20
+ typeof value.savings === 'string');
21
+ }
22
+ function isValidPortableEstimation(value) {
23
+ return (isPlainObject(value) &&
24
+ isFiniteNonNegative(value.devHours) &&
25
+ value.devHours > 0 &&
26
+ isFiniteNonNegative(value.reviewHours) &&
27
+ typeof value.recommendedModel === 'string' &&
28
+ RECOMMENDED_MODELS.has(value.recommendedModel) &&
29
+ isFiniteNonNegative(value.tokensOpus) &&
30
+ isFiniteNonNegative(value.tokensSonnet) &&
31
+ isFiniteNonNegative(value.apiCostUsd) &&
32
+ isFiniteNonNegative(value.hourlyRate) &&
33
+ isFiniteNonNegative(value.humanCostUsd) &&
34
+ isFiniteNonNegative(value.totalCostUsd) &&
35
+ isValidTokenStrategy(value.tokenOptimization));
36
+ }
5
37
  const SPEC_FOLDER_ID_RE = /^(SPEC-\d{3,})/;
6
38
  function optionalString(value) {
7
39
  return typeof value === 'string' && value.length > 0 ? value : undefined;
@@ -50,14 +82,20 @@ function generationFromMetadata(metadata, fallbackGeneratedAt) {
50
82
  ...(modelId ? { modelId } : {}),
51
83
  };
52
84
  }
53
- // eslint-disable-next-line max-lines-per-function, complexity -- portable metadata is normalized at one boundary
54
- function buildSpecFromMetadata(specId, entryName, projectPath, projectId, metadata) {
85
+ // eslint-disable-next-line complexity -- portable metadata is normalized at one boundary
86
+ function buildSpecFromMetadata(specId, entryName, projectPath, projectId, metadata, globalConfig) {
55
87
  const now = new Date().toISOString();
56
88
  const folder = join(projectPath, 'planu', 'specs', entryName);
57
89
  const specPath = join(folder, 'spec.md');
58
90
  const slug = entryName.replace(/^SPEC-\d{3,}-/, '');
59
91
  const stringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
60
92
  const generation = generationFromMetadata(metadata, now);
93
+ const type = validSpecType(metadata.type) ?? 'feature';
94
+ const scope = validSpecScope(metadata.scope) ?? 'feature';
95
+ const difficulty = validDifficulty(metadata.difficulty) ?? 3;
96
+ const estimation = isValidPortableEstimation(metadata.estimation)
97
+ ? metadata.estimation
98
+ : calculateEstimation(type, scope, difficulty, globalConfig);
61
99
  return {
62
100
  id: specId,
63
101
  ...(optionalString(metadata.uuid) ? { uuid: optionalString(metadata.uuid) } : {}),
@@ -71,10 +109,10 @@ function buildSpecFromMetadata(specId, entryName, projectPath, projectId, metada
71
109
  title: typeof metadata.title === 'string' ? metadata.title : specId,
72
110
  slug,
73
111
  ...(optionalString(metadata.feature) ? { feature: optionalString(metadata.feature) } : {}),
74
- type: validSpecType(metadata.type) ?? 'feature',
75
- scope: validSpecScope(metadata.scope) ?? 'feature',
112
+ type,
113
+ scope,
76
114
  status: validSpecStatus(metadata.status) ?? 'draft',
77
- difficulty: validDifficulty(metadata.difficulty) ?? 3,
115
+ difficulty,
78
116
  risk: validRiskLevel(metadata.risk) ?? 'medium',
79
117
  target: validSpecTarget(metadata.target) ?? 'backend',
80
118
  projectId,
@@ -83,23 +121,7 @@ function buildSpecFromMetadata(specId, entryName, projectPath, projectId, metada
83
121
  specPath,
84
122
  technicalPath: specPath,
85
123
  progressPath: '',
86
- estimation: {
87
- devHours: 0,
88
- reviewHours: 0,
89
- recommendedModel: 'sonnet',
90
- tokensOpus: 0,
91
- tokensSonnet: 0,
92
- apiCostUsd: 0,
93
- hourlyRate: 0,
94
- humanCostUsd: 0,
95
- totalCostUsd: 0,
96
- tokenOptimization: {
97
- mode: 'local',
98
- reasoning: 'imported from filesystem',
99
- estimatedTokens: 0,
100
- savings: '',
101
- },
102
- },
124
+ estimation,
103
125
  actuals: null,
104
126
  tags: stringList(metadata.tags),
105
127
  dependencies: stringList(metadata.dependencies),
@@ -125,6 +147,60 @@ function buildSpecFromMetadata(specId, entryName, projectPath, projectId, metada
125
147
  impactAnalysis: null,
126
148
  };
127
149
  }
150
+ async function resolveGlobalConfig(deps) {
151
+ return (await deps.getGlobalConfig?.()) ?? {};
152
+ }
153
+ async function importSpecEntry(entry, ctx) {
154
+ const idMatch = SPEC_FOLDER_ID_RE.exec(entry.name);
155
+ if (!idMatch?.[1]) {
156
+ return;
157
+ }
158
+ const specId = idMatch[1];
159
+ const specMdPath = join(ctx.specsRoot, entry.name, 'spec.md');
160
+ const relativeSpecPath = `planu/specs/${entry.name}/spec.md`;
161
+ let spec;
162
+ try {
163
+ const content = await readFile(specMdPath, 'utf-8');
164
+ const { metadata } = parseFrontmatterLocal(content);
165
+ if (ctx.options.strictPortableContract === true && !isValidPortableMetadata(specId, metadata)) {
166
+ ctx.failures.push({ specId, path: relativeSpecPath, code: 'INVALID_CONTRACT' });
167
+ return;
168
+ }
169
+ spec = buildSpecFromMetadata(specId, entry.name, ctx.projectPath, ctx.projectId, metadata, ctx.globalConfig);
170
+ }
171
+ catch {
172
+ ctx.failures.push({ specId, path: relativeSpecPath, code: 'READ_FAILED' });
173
+ return;
174
+ }
175
+ const existing = ctx.specsById.get(specId);
176
+ if (existing !== undefined) {
177
+ if (!compatibleIdentity(existing, spec)) {
178
+ ctx.failures.push({ specId, path: relativeSpecPath, code: 'INCOMPATIBLE_IDENTITY' });
179
+ }
180
+ return;
181
+ }
182
+ if (ctx.storeSpecs.some((stored) => hasAnyUuidIdentity(stored, spec))) {
183
+ ctx.failures.push({ specId, path: relativeSpecPath, code: 'INCOMPATIBLE_IDENTITY' });
184
+ return;
185
+ }
186
+ try {
187
+ await ctx.deps.createSpec(ctx.projectId, spec);
188
+ ctx.specsById.set(specId, spec);
189
+ ctx.importedSpecIds.push(specId);
190
+ }
191
+ catch {
192
+ const winner = await ctx.deps.getSpecFresh?.(ctx.projectId, specId).catch(() => null);
193
+ if (winner !== undefined && winner !== null && compatibleIdentity(winner, spec)) {
194
+ ctx.specsById.set(specId, winner);
195
+ return;
196
+ }
197
+ ctx.failures.push({
198
+ specId,
199
+ path: relativeSpecPath,
200
+ code: winner ? 'INCOMPATIBLE_IDENTITY' : 'CREATE_FAILED',
201
+ });
202
+ }
203
+ }
128
204
  /**
129
205
  * Import specs that exist on disk (planu/specs/) but are missing from the store.
130
206
  * Reads frontmatter from each spec.md and creates a minimal Spec record in the store.
@@ -136,6 +212,7 @@ export async function importFilesystemSpecs(projectPath, deps, projectId, option
136
212
  const importedSpecIds = [];
137
213
  const storeSpecs = await deps.listSpecs(projectId);
138
214
  const specsById = new Map(storeSpecs.map((spec) => [spec.id, spec]));
215
+ const globalConfig = await resolveGlobalConfig(deps);
139
216
  let entries;
140
217
  try {
141
218
  entries = await readdir(specsRoot, { withFileTypes: true });
@@ -143,58 +220,21 @@ export async function importFilesystemSpecs(projectPath, deps, projectId, option
143
220
  catch {
144
221
  return { importedCount: 0, importedSpecIds, failures, errors: [] };
145
222
  }
223
+ const ctx = {
224
+ projectPath,
225
+ projectId,
226
+ deps,
227
+ options,
228
+ specsRoot,
229
+ globalConfig,
230
+ storeSpecs,
231
+ specsById,
232
+ importedSpecIds,
233
+ failures,
234
+ };
146
235
  for (const entry of entries) {
147
- if (!entry.isDirectory()) {
148
- continue;
149
- }
150
- const idMatch = SPEC_FOLDER_ID_RE.exec(entry.name);
151
- if (!idMatch?.[1]) {
152
- continue;
153
- }
154
- const specId = idMatch[1];
155
- const specMdPath = join(specsRoot, entry.name, 'spec.md');
156
- const relativeSpecPath = `planu/specs/${entry.name}/spec.md`;
157
- let spec;
158
- try {
159
- const content = await readFile(specMdPath, 'utf-8');
160
- const { metadata } = parseFrontmatterLocal(content);
161
- if (options.strictPortableContract === true && !isValidPortableMetadata(specId, metadata)) {
162
- failures.push({ specId, path: relativeSpecPath, code: 'INVALID_CONTRACT' });
163
- continue;
164
- }
165
- spec = buildSpecFromMetadata(specId, entry.name, projectPath, projectId, metadata);
166
- }
167
- catch {
168
- failures.push({ specId, path: relativeSpecPath, code: 'READ_FAILED' });
169
- continue;
170
- }
171
- const existing = specsById.get(specId);
172
- if (existing !== undefined) {
173
- if (!compatibleIdentity(existing, spec)) {
174
- failures.push({ specId, path: relativeSpecPath, code: 'INCOMPATIBLE_IDENTITY' });
175
- }
176
- continue;
177
- }
178
- if (storeSpecs.some((stored) => hasAnyUuidIdentity(stored, spec))) {
179
- failures.push({ specId, path: relativeSpecPath, code: 'INCOMPATIBLE_IDENTITY' });
180
- continue;
181
- }
182
- try {
183
- await deps.createSpec(projectId, spec);
184
- specsById.set(specId, spec);
185
- importedSpecIds.push(specId);
186
- }
187
- catch {
188
- const winner = await deps.getSpecFresh?.(projectId, specId).catch(() => null);
189
- if (winner !== undefined && winner !== null && compatibleIdentity(winner, spec)) {
190
- specsById.set(specId, winner);
191
- continue;
192
- }
193
- failures.push({
194
- specId,
195
- path: relativeSpecPath,
196
- code: winner ? 'INCOMPATIBLE_IDENTITY' : 'CREATE_FAILED',
197
- });
236
+ if (entry.isDirectory()) {
237
+ await importSpecEntry(entry, ctx);
198
238
  }
199
239
  }
200
240
  return {
@@ -14,8 +14,8 @@ function parseScalar(val) {
14
14
  if (val === 'null') {
15
15
  return null;
16
16
  }
17
- if (/^\d+$/.test(val)) {
18
- return parseInt(val, 10);
17
+ if (/^-?\d+(\.\d+)?$/.test(val)) {
18
+ return Number(val);
19
19
  }
20
20
  return val.replace(/^["']|["']$/g, '');
21
21
  }
@@ -25,29 +25,31 @@ export function parseFrontmatterLocal(content) {
25
25
  return { metadata: {} };
26
26
  }
27
27
  const metadata = {};
28
- let activeObject = null;
28
+ const stack = [];
29
29
  for (const line of match[1].split('\n')) {
30
30
  const indent = line.length - line.trimStart().length;
31
31
  const trimmed = line.trim();
32
+ if (trimmed.length === 0) {
33
+ continue;
34
+ }
35
+ let top = stack.at(-1);
36
+ while (top !== undefined && indent <= top.indent) {
37
+ stack.pop();
38
+ top = stack.at(-1);
39
+ }
40
+ const container = top?.value ?? metadata;
32
41
  const object = FM_OBJECT_RE.exec(trimmed);
33
42
  if (object?.[1]) {
34
43
  const value = {};
35
- metadata[object[1]] = value;
36
- activeObject = { indent, value };
44
+ container[object[1]] = value;
45
+ stack.push({ indent, value });
37
46
  continue;
38
47
  }
39
48
  const kv = FM_KV_RE.exec(trimmed);
40
49
  if (!kv?.[1] || kv[2] === undefined) {
41
50
  continue;
42
51
  }
43
- const key = kv[1];
44
- const val = kv[2].trim();
45
- if (activeObject !== null && indent > activeObject.indent) {
46
- activeObject.value[key] = parseScalar(val);
47
- continue;
48
- }
49
- activeObject = null;
50
- metadata[key] = parseScalar(val);
52
+ container[kv[1]] = parseScalar(kv[2].trim());
51
53
  }
52
54
  return { metadata };
53
55
  }
@@ -49,7 +49,7 @@ async function reconcileSingleSpec(projectId, specId) {
49
49
  // Run living-spec analysis for completion update
50
50
  const knowledge = await knowledgeStore.getKnowledge(projectId);
51
51
  if (knowledge?.projectPath) {
52
- const report = await analyzeLivingSpec(spec, knowledge.projectPath);
52
+ const report = await analyzeLivingSpec(spec, knowledge.projectPath, { writeProgress: true });
53
53
  // Completion percentage update is a safe change
54
54
  changes.push(createLogEntry(specId, 'completion-update', `Completion updated to ${String(report.completionPercent)}%`, 'unknown', `${String(report.completionPercent)}%`));
55
55
  // Timestamp update is safe
@@ -102,7 +102,7 @@ export function buildChallengeReport(input) {
102
102
  ], scenarioNames);
103
103
  const resolvedScenarios = new Set(resolutionEvidence.map((item) => item.scenario));
104
104
  const addressedCount = resolvedScenarios.size;
105
- const requiredCount = requiredResolvedChallenges(input.scenarios.length);
105
+ const requiredCount = requiredResolvedChallenges(scenarioNames.size);
106
106
  return {
107
107
  runAt: input.runAt ?? new Date().toISOString(),
108
108
  totalScenarios: input.scenarios.length,
@@ -1,10 +1,11 @@
1
- import { specStore } from '../../storage/index.js';
1
+ import { globalStore, specStore } from '../../storage/index.js';
2
2
  import { importFilesystemSpecs } from '../../engine/spec-migrator/filesystem-import.js';
3
3
  /** Rebuild the mutable external index from the repository-owned portable contracts. */
4
4
  export async function reconcilePortableSpecIndex(projectPath, projectId, deps = {
5
5
  listSpecs: specStore.listSpecs,
6
6
  createSpec: specStore.createSpec,
7
7
  getSpecFresh: specStore.getSpecFresh,
8
+ getGlobalConfig: globalStore.getGlobalConfig,
8
9
  }) {
9
10
  const result = await importFilesystemSpecs(projectPath, deps, projectId, {
10
11
  strictPortableContract: true,
@@ -464,7 +464,9 @@ export async function handleReconcileSpec(params, server) {
464
464
  if (livingSpec) {
465
465
  const projectKnowledge = await knowledgeStore.getKnowledge(projectId);
466
466
  if (projectKnowledge?.projectPath) {
467
- livingSpecReport = await analyzeLivingSpec(spec, projectKnowledge.projectPath);
467
+ livingSpecReport = await analyzeLivingSpec(spec, projectKnowledge.projectPath, {
468
+ writeProgress: true,
469
+ });
468
470
  }
469
471
  }
470
472
  const appliedChangesReport = appliedChanges.map((c) => ({