@planu/cli 5.3.5 → 5.3.6
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 +32 -0
- package/dist/engine/hooks/handlers/on-impl-change.d.ts +17 -4
- package/dist/engine/hooks/handlers/on-impl-change.js +43 -44
- package/dist/engine/living-spec-analyzer.d.ts +5 -2
- package/dist/engine/living-spec-analyzer.js +5 -2
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/scope-boundaries/contradiction-checker.js +20 -13
- package/dist/engine/spec-format/acceptance-criteria.js +5 -2
- package/dist/engine/spec-migrator/filesystem-import.js +113 -73
- package/dist/engine/spec-migrator/frontmatter-parser.js +15 -13
- package/dist/tools/auto-reconcile.js +1 -1
- package/dist/tools/challenge-spec/challenge-report.js +1 -1
- package/dist/tools/init-project/portable-index-reconciler.js +2 -1
- package/dist/tools/reconcile-spec.js +3 -1
- package/dist/tools/safe-handler.js +0 -2
- package/dist/tools/start-hooks/engine.d.ts +2 -3
- package/dist/tools/start-hooks/engine.js +50 -33
- package/dist/tools/start-hooks.d.ts +1 -1
- package/dist/tools/start-hooks.js +1 -2
- package/dist/tools/tool-registry/group-infra.js +36 -0
- package/dist/tools/update-status/transition-guard.js +17 -4
- package/dist/types/file-hooks.d.ts +4 -6
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/dist/types/mcp.d.ts +0 -19
- package/dist/types/spec/core.d.ts +5 -0
- package/dist/types/spec/inputs.d.ts +16 -0
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/engine/hooks/agent-hook-migrator.d.ts +0 -9
- package/dist/engine/hooks/agent-hook-migrator.js +0 -140
- package/dist/types/hook-status-update.d.ts +0 -10
- package/dist/types/hook-status-update.js +0 -3
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"cliVersion": "5.3.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
3
|
+
"cliVersion": "5.3.6",
|
|
4
|
+
"engineVersion": "5.3.6",
|
|
5
|
+
"buildId": "1-5.3.6-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": "
|
|
19
|
+
"artifactSha256": "16659d578e84674eeab01f7f53943e46ae8b2c1836fef583eb126a4eee15c2ff",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-arm64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "411f35f08ebc1f8dc65a665bc355e2b10dc0b8db2bb279e33bb1a4d49f1bad34"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "2dae5f5028632004802bad4c143eb86d8abba774",
|
|
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": "
|
|
36
|
+
"value": "om/6aNSNCXZ8rbF+dw+8mWFhr1Zj0+z06pFccowCC3dcXtzDkLwDuquUlcHq8OSmFJNyjXFJ5i+OopjRPXKqCg=="
|
|
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.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.6",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.6",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "16659d578e84674eeab01f7f53943e46ae8b2c1836fef583eb126a4eee15c2ff"
|
|
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.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.6",
|
|
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.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
3
|
+
"cliVersion": "5.3.6",
|
|
4
|
+
"engineVersion": "5.3.6",
|
|
5
|
+
"buildId": "1-5.3.6-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": "
|
|
19
|
+
"artifactSha256": "e6750baae63b969de18daae8150796a785638847bb7b3645b214b252f0bf32d3",
|
|
20
20
|
"sbom": {
|
|
21
21
|
"format": "CycloneDX-1.5",
|
|
22
22
|
"path": "planu-core.win32-x64-msvc.node.sbom.json",
|
|
23
|
-
"sha256": "
|
|
23
|
+
"sha256": "323e01b4f75869ec688a26524c5e4aa756a6ad5c0e7fd968a9090ad6e58a75bf"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
27
|
+
"sourceCommit": "2dae5f5028632004802bad4c143eb86d8abba774",
|
|
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": "
|
|
36
|
+
"value": "YmufISTxo4Tx7ceRSzMxv6fXdoi19oHTokfKkevZzBkaCi3msjDRWJqerPQjjDr5Vkip8LB+2oQUS2DC3Ea+AQ=="
|
|
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.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.6",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.6",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
14
|
+
"content": "e6750baae63b969de18daae8150796a785638847bb7b3645b214b252f0bf32d3"
|
|
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.
|
|
1109
|
+
"ref": "pkg:cargo/planu-core@5.3.6",
|
|
1110
1110
|
"dependsOn": [
|
|
1111
1111
|
"pkg:cargo/core-foundation@0.10.1",
|
|
1112
1112
|
"pkg:cargo/hmac@0.12.1",
|
|
@@ -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/,
|
|
@@ -62,11 +64,11 @@ function splitClauses(text) {
|
|
|
62
64
|
.filter(Boolean);
|
|
63
65
|
}
|
|
64
66
|
function hasAffirmativeDrift(text, outOfScopeItem) {
|
|
65
|
-
const action = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables
|
|
67
|
+
const action = /\b(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/g;
|
|
66
68
|
const actionSegments = text.split(/\band\b/i).map(normalize);
|
|
67
69
|
for (const [index, segment] of actionSegments.entries()) {
|
|
68
70
|
const inheritsRelevantSubject = index > 0 &&
|
|
69
|
-
/^(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables
|
|
71
|
+
/^(?:changes?|modif(?:y|ies)|alters?|adds?|introduces?|implements?|enables?|exposes?|exposed)\b/.test(segment) &&
|
|
70
72
|
clauseMatchesScope(actionSegments[index - 1] ?? '', outOfScopeItem);
|
|
71
73
|
for (const match of segment.matchAll(action)) {
|
|
72
74
|
const before = segment.slice(Math.max(0, match.index - 24), match.index);
|
|
@@ -84,6 +86,9 @@ function hasAffirmativeDrift(text, outOfScopeItem) {
|
|
|
84
86
|
}
|
|
85
87
|
return false;
|
|
86
88
|
}
|
|
89
|
+
function containsKeyword(normalizedHaystack, keyword) {
|
|
90
|
+
return new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(normalizedHaystack);
|
|
91
|
+
}
|
|
87
92
|
function clauseMatchesScope(clause, outOfScopeItem) {
|
|
88
93
|
const normalizedClause = normalize(clause);
|
|
89
94
|
const normalizedScope = normalize(outOfScopeItem);
|
|
@@ -91,10 +96,14 @@ function clauseMatchesScope(clause, outOfScopeItem) {
|
|
|
91
96
|
return true;
|
|
92
97
|
}
|
|
93
98
|
const scopeKeywords = keywords(outOfScopeItem);
|
|
94
|
-
const matchCount = scopeKeywords.filter((keyword) => normalizedClause
|
|
99
|
+
const matchCount = scopeKeywords.filter((keyword) => containsKeyword(normalizedClause, keyword)).length;
|
|
95
100
|
const threshold = scopeKeywords.length <= 2 ? scopeKeywords.length : 2;
|
|
96
101
|
return scopeKeywords.length > 0 && matchCount >= threshold;
|
|
97
102
|
}
|
|
103
|
+
function clauseMentionsScope(clause, outOfScopeItem) {
|
|
104
|
+
const normalizedClause = normalize(clause);
|
|
105
|
+
return keywords(outOfScopeItem).some((keyword) => containsKeyword(normalizedClause, keyword));
|
|
106
|
+
}
|
|
98
107
|
/**
|
|
99
108
|
* Determine whether a criterion text contradicts an out-of-scope item.
|
|
100
109
|
* Uses substring match first, then keyword overlap as fuzzy fallback.
|
|
@@ -102,11 +111,12 @@ function clauseMatchesScope(clause, outOfScopeItem) {
|
|
|
102
111
|
function contradicts(criterionText, outOfScopeItem) {
|
|
103
112
|
const normCriterion = normalize(criterionText);
|
|
104
113
|
const normScope = normalize(outOfScopeItem);
|
|
105
|
-
const
|
|
114
|
+
const clauses = splitClauses(criterionText);
|
|
115
|
+
const relevantClauses = clauses.filter((clause) => clauseMentionsScope(clause, outOfScopeItem));
|
|
106
116
|
const hasConditionalException = /\b(?:unless|except)\b/i.test(criterionText);
|
|
107
117
|
if (!hasConditionalException &&
|
|
108
|
-
relevantClauses.
|
|
109
|
-
relevantClauses.
|
|
118
|
+
relevantClauses.some(preservesBoundary) &&
|
|
119
|
+
!relevantClauses.some((clause) => hasAffirmativeDrift(clause, outOfScopeItem))) {
|
|
110
120
|
return false;
|
|
111
121
|
}
|
|
112
122
|
// 1. Direct substring match
|
|
@@ -117,15 +127,12 @@ function contradicts(criterionText, outOfScopeItem) {
|
|
|
117
127
|
if (normScope.includes(normCriterion)) {
|
|
118
128
|
return true;
|
|
119
129
|
}
|
|
120
|
-
// 3. Keyword overlap: if 2+ significant keywords from
|
|
121
|
-
|
|
122
|
-
if (
|
|
130
|
+
// 3. Keyword overlap, scoped to a single clause: if 2+ significant keywords from
|
|
131
|
+
// outOfScope appear within the same clause of the criterion
|
|
132
|
+
if (keywords(outOfScopeItem).length === 0) {
|
|
123
133
|
return false;
|
|
124
134
|
}
|
|
125
|
-
|
|
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;
|
|
135
|
+
return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem));
|
|
129
136
|
}
|
|
130
137
|
/**
|
|
131
138
|
* 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
|
-
|
|
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
|
|
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
|
|
75
|
-
scope
|
|
112
|
+
type,
|
|
113
|
+
scope,
|
|
76
114
|
status: validSpecStatus(metadata.status) ?? 'draft',
|
|
77
|
-
difficulty
|
|
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 (
|
|
148
|
-
|
|
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 (
|
|
18
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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) => ({
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// instead of crashing the server. Includes cancellation-aware execution deadlines.
|
|
4
4
|
import { withUsageTracking } from './usage-tracking.js';
|
|
5
5
|
import { sanitizePath } from './sanitize-path.js';
|
|
6
|
-
import { ensureHooksStarted } from './start-hooks.js';
|
|
7
6
|
import { ensureWorkersStarted } from '../engine/workers/index.js';
|
|
8
7
|
import { recordError } from '../storage/error-telemetry-store.js';
|
|
9
8
|
import { hashProjectPath } from '../storage/base-store.js';
|
|
@@ -158,7 +157,6 @@ function sanitizeArgs(args, toolName) {
|
|
|
158
157
|
// planu_status is a read-only snapshot. Starting background services here made
|
|
159
158
|
// its latency depend on repository size and violated the MCP readOnlyHint.
|
|
160
159
|
if (toolName !== 'planu_status') {
|
|
161
|
-
ensureHooksStarted(obj.projectPath);
|
|
162
160
|
ensureWorkersStarted(obj.projectPath);
|
|
163
161
|
}
|
|
164
162
|
// Read-only check: track whether planu.json exists (no file writes)
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { ToolResult } from '../../types/index.js';
|
|
2
|
-
import type { StartHooksInput, StopHooksInput } from '../../types/file-hooks.js';
|
|
2
|
+
import type { StartHooksInput, StopHooksInput, HookStatusInput } from '../../types/file-hooks.js';
|
|
3
3
|
import { HookEngine } from '../../engine/hooks/hook-engine.js';
|
|
4
4
|
import { FileWatcher } from '../../engine/hooks/file-watcher.js';
|
|
5
5
|
import { ConfigLoader } from '../../engine/hooks/config-loader.js';
|
|
6
6
|
export declare function getEngineInstance(): HookEngine | null;
|
|
7
7
|
export declare function getWatcherInstance(): FileWatcher | null;
|
|
8
8
|
export declare function getConfigLoaderInstance(): ConfigLoader | null;
|
|
9
|
-
export declare function ensureHooksStarted(projectPath: string): void;
|
|
10
9
|
export declare function handleStartHooks(args: StartHooksInput): Promise<ToolResult>;
|
|
11
10
|
export declare function handleStopHooks(_args: StopHooksInput): Promise<ToolResult>;
|
|
12
|
-
export declare function handleHookStatus(): Promise<ToolResult>;
|
|
11
|
+
export declare function handleHookStatus(args?: HookStatusInput): Promise<ToolResult>;
|
|
13
12
|
//# sourceMappingURL=engine.d.ts.map
|