@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.
- package/CHANGELOG.md +50 -0
- package/dist/config/runtime-policy.json +1 -0
- package/dist/engine/convention-scanner/codebase-scanner.js +1 -1
- 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/runtime-policy.js +7 -0
- package/dist/engine/scope-boundaries/contradiction-checker.js +28 -20
- 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/tools/validate-assurance.js +1 -1
- package/dist/tools/validate-lint.js +1 -1
- package/dist/tools/validate.d.ts +1 -0
- package/dist/tools/validate.js +21 -2
- 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/runtime-policy.d.ts +1 -0
- 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
|
@@ -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.7",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.7",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
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.
|
|
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.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
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": "
|
|
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": "
|
|
23
|
+
"sha256": "1221ffeb6dfc3a3fe1870d6ec5d19849358bc274a446c9cc8f82928bcd75840b"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
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": "
|
|
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.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.7",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.7",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
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.
|
|
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.
|
|
4
|
-
"engineVersion": "5.3.
|
|
5
|
-
"buildId": "1-5.3.
|
|
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": "
|
|
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": "
|
|
23
|
+
"sha256": "959a4bc45bae58e8b9152af89c6ef6da56f762017677b93ceb1f82c7f245fd27"
|
|
24
24
|
},
|
|
25
25
|
"provenance": {
|
|
26
26
|
"builder": "scripts/build-rust-local.sh",
|
|
27
|
-
"sourceCommit": "
|
|
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": "
|
|
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.
|
|
7
|
+
"bom-ref": "pkg:cargo/planu-core@5.3.7",
|
|
8
8
|
"type": "library",
|
|
9
9
|
"name": "planu-core",
|
|
10
|
-
"version": "5.3.
|
|
10
|
+
"version": "5.3.7",
|
|
11
11
|
"hashes": [
|
|
12
12
|
{
|
|
13
13
|
"alg": "SHA-256",
|
|
14
|
-
"content": "
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
109
|
-
relevantClauses.
|
|
126
|
+
relevantClauses.some(preservesBoundary) &&
|
|
127
|
+
!relevantClauses.some((clause) => hasAffirmativeDrift(clause, outOfScopeItem))) {
|
|
110
128
|
return false;
|
|
111
129
|
}
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
const scopeKws = keywords(outOfScopeItem);
|
|
122
|
-
if (scopeKws.length === 0) {
|
|
133
|
+
if (keywords(outOfScopeItem).length === 0) {
|
|
123
134
|
return false;
|
|
124
135
|
}
|
|
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;
|
|
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
|
-
|
|
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) => ({
|