@opengsd/gsd-core 1.5.0 → 1.6.0-rc.1
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- package/scripts/sync-manifest-versions.cjs +77 -5
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* Project-Root Resolution Module — resolves a project root from a starting
|
|
4
|
-
* directory by walking the ancestor chain and applying
|
|
4
|
+
* directory by walking the ancestor chain and applying five heuristics:
|
|
5
5
|
* (0) own .planning/ guard (#1362)
|
|
6
6
|
* (1) parent .planning/config.json sub_repos
|
|
7
7
|
* (2) legacy multiRepo: true + ancestor .git
|
|
8
8
|
* (3) .git heuristic with parent .planning/
|
|
9
|
+
* (4) nearest ancestor .planning/ (#1414, Resolution Provenance P1)
|
|
9
10
|
* Bounded by FIND_PROJECT_ROOT_MAX_DEPTH ancestors. Sync I/O.
|
|
10
11
|
*
|
|
11
12
|
* ADR-457 build-at-publish: the hand-written bin/lib/project-root.cjs
|
|
@@ -17,6 +18,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
18
|
};
|
|
18
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
20
|
exports.findProjectRoot = findProjectRoot;
|
|
21
|
+
exports.consentProjectRoot = consentProjectRoot;
|
|
20
22
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
24
|
const node_os_1 = __importDefault(require("node:os"));
|
|
@@ -107,13 +109,98 @@ function findProjectRoot(startDir) {
|
|
|
107
109
|
}
|
|
108
110
|
if (matched)
|
|
109
111
|
return parent;
|
|
110
|
-
// Heuristic: parent has .planning/ and we're inside a git repo.
|
|
112
|
+
// Heuristic (3): parent has .planning/ and we're inside a git repo.
|
|
113
|
+
// Before returning, check if any further ancestor has sub_repos that explicitly
|
|
114
|
+
// claims our startDir — explicit sub_repos config takes precedence over the
|
|
115
|
+
// implicit .git signal. (#1422)
|
|
111
116
|
if (isInsideGitRepo(parent)) {
|
|
117
|
+
// Lookahead: walk ancestors above `parent` to find a sub_repos claim.
|
|
118
|
+
let ancestor = node_path_1.default.dirname(parent);
|
|
119
|
+
let ancestorDepth = 0;
|
|
120
|
+
while (ancestor !== fsRoot && ancestor !== home && ancestorDepth < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
|
121
|
+
const ancestorPlanning = ancestor + node_path_1.default.sep + '.planning';
|
|
122
|
+
try {
|
|
123
|
+
if (node_fs_1.default.existsSync(ancestorPlanning) && node_fs_1.default.statSync(ancestorPlanning).isDirectory()) {
|
|
124
|
+
const ancestorConfig = ancestor + node_path_1.default.sep + '.planning' + node_path_1.default.sep + 'config.json';
|
|
125
|
+
const rawA = node_fs_1.default.readFileSync(ancestorConfig, 'utf-8');
|
|
126
|
+
const cfgA = JSON.parse(rawA);
|
|
127
|
+
const subReposValueA = cfgA['sub_repos'] ??
|
|
128
|
+
(cfgA['planning'] && typeof cfgA['planning'] === 'object'
|
|
129
|
+
? cfgA['planning']['sub_repos']
|
|
130
|
+
: undefined);
|
|
131
|
+
const subReposA = Array.isArray(subReposValueA) ? subReposValueA : [];
|
|
132
|
+
if (subReposA.length > 0) {
|
|
133
|
+
const relPathA = node_path_1.default.relative(ancestor, resolvedStart);
|
|
134
|
+
const topSegmentA = relPathA.split(node_path_1.default.sep)[0];
|
|
135
|
+
if (subReposA.includes(topSegmentA)) {
|
|
136
|
+
return ancestor;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// ignore — config missing or unparseable, keep walking
|
|
143
|
+
}
|
|
144
|
+
const nextAncestor = node_path_1.default.dirname(ancestor);
|
|
145
|
+
if (nextAncestor === ancestor)
|
|
146
|
+
break;
|
|
147
|
+
ancestor = nextAncestor;
|
|
148
|
+
ancestorDepth += 1;
|
|
149
|
+
}
|
|
112
150
|
return parent;
|
|
113
151
|
}
|
|
114
152
|
}
|
|
115
153
|
dir = parent;
|
|
116
154
|
depth += 1;
|
|
117
155
|
}
|
|
156
|
+
// Heuristic (4): nearest ancestor .planning/ — last resort before fallback.
|
|
157
|
+
// Runs only after heuristics (1)–(3) have been exhausted without a match,
|
|
158
|
+
// ensuring sub_repos / multiRepo / .git-based resolution always wins when
|
|
159
|
+
// applicable. Walks upward again within the same FIND_PROJECT_ROOT_MAX_DEPTH
|
|
160
|
+
// bound; returns the nearest ancestor directory that contains a .planning/
|
|
161
|
+
// subdirectory so config resolves correctly when invoked from a plain
|
|
162
|
+
// descendant of a single-repo project. (#1414)
|
|
163
|
+
let dir2 = resolvedStart;
|
|
164
|
+
let depth2 = 0;
|
|
165
|
+
while (dir2 !== fsRoot && depth2 < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
|
166
|
+
const parent2 = node_path_1.default.dirname(dir2);
|
|
167
|
+
if (parent2 === dir2)
|
|
168
|
+
break;
|
|
169
|
+
try {
|
|
170
|
+
const candidatePlanning = parent2 + node_path_1.default.sep + '.planning';
|
|
171
|
+
if (node_fs_1.default.existsSync(candidatePlanning) && node_fs_1.default.statSync(candidatePlanning).isDirectory()) {
|
|
172
|
+
return parent2;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// ignore fs errors and continue walking
|
|
177
|
+
}
|
|
178
|
+
if (parent2 === home)
|
|
179
|
+
break;
|
|
180
|
+
dir2 = parent2;
|
|
181
|
+
depth2 += 1;
|
|
182
|
+
}
|
|
118
183
|
return startDir;
|
|
119
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* #1459 (IC-01 / CB-4): THE single canonical derivation of the PROJECT ROOT used to bind/lookup a
|
|
187
|
+
* project-scope consent record. Install (the CLI/lifecycle RECORD site), the loader (the LOOKUP
|
|
188
|
+
* site), and `trust revoke` (CB-4) MUST all derive the consent root through this one helper so the
|
|
189
|
+
* recorded key always matches the looked-up key — otherwise installing from a SUBDIR records consent
|
|
190
|
+
* at `realpath(subdir)` while the loader looks it up at `realpath(findProjectRoot)` and the freshly
|
|
191
|
+
* installed cap is immediately INACTIVE (install-then-inactive).
|
|
192
|
+
*
|
|
193
|
+
* The rule: `realpath(findProjectRoot(cwd))` (findProjectRoot is total — it returns `cwd` itself when
|
|
194
|
+
* no project root is found, so there is no null branch), falling back to `path.resolve(cwd)` when the
|
|
195
|
+
* resolved root cannot be realpath'd (e.g. it does not exist yet). The consent store realpaths
|
|
196
|
+
* whatever it is given, so passing the SAME logical root from every site is what guarantees the match.
|
|
197
|
+
*/
|
|
198
|
+
function consentProjectRoot(cwd) {
|
|
199
|
+
const root = findProjectRoot(cwd);
|
|
200
|
+
try {
|
|
201
|
+
return node_fs_1.default.realpathSync(root);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return node_path_1.default.resolve(root);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Resolution Convention — canonical shape for config-interpreting read verbs.
|
|
4
|
+
*
|
|
5
|
+
* Extracted as the anchor for ADR-1411 P3 (Resolution Provenance, #1416).
|
|
6
|
+
* Exports the `Resolution<T>` envelope used when a verb reads and interprets
|
|
7
|
+
* configuration (e.g. agent-skills). Not used by mutation verbs (see
|
|
8
|
+
* capability-writer's `SetCapabilityStateResult` for the mutation shape) or
|
|
9
|
+
* plain read verbs (see capability-state's `ResolveCapabilityRuntimeStateResult`).
|
|
10
|
+
*
|
|
11
|
+
* This is a pure types+builder leaf — no other src/ imports.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.makeResolution = makeResolution;
|
|
15
|
+
// ─── Builder ──────────────────────────────────────────────────────────────────
|
|
16
|
+
/**
|
|
17
|
+
* Construct a `Resolution<T>` envelope from a value and its provenance fields.
|
|
18
|
+
*/
|
|
19
|
+
function makeResolution(value, opts) {
|
|
20
|
+
return {
|
|
21
|
+
value,
|
|
22
|
+
configured: opts.configured,
|
|
23
|
+
reason: opts.reason,
|
|
24
|
+
warnings: opts.warnings,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -27,6 +27,7 @@ const { escapeRegex, phaseMarkdownRegexSource } = phaseIdModule;
|
|
|
27
27
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
28
28
|
const { planningDir } = planningWorkspace;
|
|
29
29
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
30
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
30
31
|
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
31
32
|
/**
|
|
32
33
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
@@ -97,34 +98,25 @@ function extractCurrentMilestone(content, cwd) {
|
|
|
97
98
|
const sectionStart = selected.index;
|
|
98
99
|
const computeSectionEnd = (headingText, headingStart) => {
|
|
99
100
|
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
fc = null;
|
|
118
|
-
fl = 0;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
else if (!fc && stopPattern.test(line)) {
|
|
122
|
-
end = headingStart + headingText.length + off;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
off += line.length + 1;
|
|
101
|
+
const afterHeading = headingStart + headingText.length;
|
|
102
|
+
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
103
|
+
// the next stop boundary without re-implementing fence detection. T4 seam migration.
|
|
104
|
+
const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
|
|
105
|
+
for (const h of headings) {
|
|
106
|
+
if (h.offset <= headingStart)
|
|
107
|
+
continue;
|
|
108
|
+
if (h.offset < afterHeading)
|
|
109
|
+
continue;
|
|
110
|
+
if (h.level > level)
|
|
111
|
+
continue;
|
|
112
|
+
// Mirrors old stopPattern: level-bounded, not a Phase heading, milestone marker
|
|
113
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
114
|
+
continue;
|
|
115
|
+
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
116
|
+
continue;
|
|
117
|
+
return h.offset;
|
|
126
118
|
}
|
|
127
|
-
return
|
|
119
|
+
return content.length;
|
|
128
120
|
};
|
|
129
121
|
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
|
130
122
|
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
|
@@ -278,47 +270,6 @@ function getMilestoneInfo(cwd) {
|
|
|
278
270
|
return { version: 'v1.0', name: 'milestone' };
|
|
279
271
|
}
|
|
280
272
|
}
|
|
281
|
-
// ─── Fence-aware text helper ──────────────────────────────────────────────────
|
|
282
|
-
/**
|
|
283
|
-
* Return a copy of `text` with every line that lies inside a fenced code block
|
|
284
|
-
* replaced by an empty string, using the same fence semantics as
|
|
285
|
-
* `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle;
|
|
286
|
-
* an unclosed fence treats remaining content as fenced).
|
|
287
|
-
*/
|
|
288
|
-
function stripFencedLines(text) {
|
|
289
|
-
let fenceChar = null;
|
|
290
|
-
let fenceLen = 0;
|
|
291
|
-
const lines = text.split('\n');
|
|
292
|
-
const result = [];
|
|
293
|
-
for (const line of lines) {
|
|
294
|
-
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
|
295
|
-
if (fm) {
|
|
296
|
-
const ch = fm[1][0];
|
|
297
|
-
const ln = fm[1].length;
|
|
298
|
-
const trailing = fm[2] || '';
|
|
299
|
-
if (!fenceChar) {
|
|
300
|
-
fenceChar = ch;
|
|
301
|
-
fenceLen = ln;
|
|
302
|
-
// The fence-open line itself is not a content line — blank it.
|
|
303
|
-
result.push('');
|
|
304
|
-
}
|
|
305
|
-
else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) {
|
|
306
|
-
fenceChar = null;
|
|
307
|
-
fenceLen = 0;
|
|
308
|
-
// The fence-close line — blank it.
|
|
309
|
-
result.push('');
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
// A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content.
|
|
313
|
-
result.push(fenceChar ? '' : line);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
result.push(fenceChar ? '' : line);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return result.join('\n');
|
|
321
|
-
}
|
|
322
273
|
/**
|
|
323
274
|
* Returns a filter function that checks whether a phase directory belongs
|
|
324
275
|
* to the current milestone based on ROADMAP.md phase headings.
|
|
@@ -374,42 +325,37 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
|
|
|
374
325
|
else {
|
|
375
326
|
const sectionStart = sectionMatch.index;
|
|
376
327
|
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
377
|
-
const
|
|
378
|
-
|
|
328
|
+
const afterHeading = sectionStart + sectionMatch[0].length;
|
|
329
|
+
// Use tokenizeHeadings (fence-aware, offsets into original content) to find
|
|
330
|
+
// the next milestone-boundary heading. T4 seam migration.
|
|
331
|
+
const allHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmapContent);
|
|
379
332
|
let sectionEnd = roadmapContent.length;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
fenceLen = len;
|
|
392
|
-
}
|
|
393
|
-
else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
|
394
|
-
fenceChar = null;
|
|
395
|
-
fenceLen = 0;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
else if (!fenceChar && nextMilestonePattern.test(line)) {
|
|
399
|
-
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
charOffset += line.length + 1;
|
|
333
|
+
for (const h of allHeadings) {
|
|
334
|
+
if (h.offset < afterHeading)
|
|
335
|
+
continue;
|
|
336
|
+
if (h.level > headingLevel)
|
|
337
|
+
continue;
|
|
338
|
+
if (/^Phase\s+\S/i.test(h.text))
|
|
339
|
+
continue;
|
|
340
|
+
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text))
|
|
341
|
+
continue;
|
|
342
|
+
sectionEnd = h.offset;
|
|
343
|
+
break;
|
|
403
344
|
}
|
|
404
345
|
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
|
405
346
|
roadmap = currentSection;
|
|
406
347
|
}
|
|
407
348
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
349
|
+
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
|
350
|
+
// T4 seam migration: phase headings inside fences are excluded automatically.
|
|
351
|
+
const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/i;
|
|
352
|
+
for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
|
|
353
|
+
if (h.level < 2 || h.level > 4)
|
|
354
|
+
continue;
|
|
355
|
+
const pm = phaseHeadingPattern.exec(h.text);
|
|
356
|
+
// Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
|
|
357
|
+
if (pm && !/^999\b/.test(pm[1]))
|
|
358
|
+
milestonePhaseNums.add(pm[1]);
|
|
413
359
|
}
|
|
414
360
|
}
|
|
415
361
|
catch { /* intentionally empty */ }
|
|
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.resolveConfigHomeFromDescriptor = resolveConfigHomeFromDescriptor;
|
|
38
38
|
exports.resolveAntigravityGlobalDir = resolveAntigravityGlobalDir;
|
|
39
|
+
exports.detectAntigravityDirAmbiguity = detectAntigravityDirAmbiguity;
|
|
39
40
|
exports.resolveKimiGlobalDir = resolveKimiGlobalDir;
|
|
40
41
|
exports.getGlobalConfigDir = getGlobalConfigDir;
|
|
41
42
|
exports.resolveSkillsBaseFromDescriptor = resolveSkillsBaseFromDescriptor;
|
|
@@ -97,7 +98,20 @@ function resolveConfigHomeFromDescriptor(configHome, opts = {}) {
|
|
|
97
98
|
}
|
|
98
99
|
const base = node_path_1.default.join(home, configHome.parent);
|
|
99
100
|
if (configHome.probe && configHome.probe.length > 0) {
|
|
100
|
-
//
|
|
101
|
+
// Pass 1 (marker-priority): when probeExists is declared, prefer the
|
|
102
|
+
// candidate GSD actually owns (its `<candidate>/<probeExists>` exists).
|
|
103
|
+
// This disambiguates an active-but-shadowing sibling dir (e.g. the
|
|
104
|
+
// Antigravity-IDE `~/.gemini/antigravity` dir) from the dir GSD was
|
|
105
|
+
// installed into, instead of blindly taking the first dir that exists.
|
|
106
|
+
if (configHome.probeExists) {
|
|
107
|
+
for (const candidate of configHome.probe) {
|
|
108
|
+
const resolved = node_path_1.default.join(base, candidate);
|
|
109
|
+
if (existsSyncFn(node_path_1.default.join(resolved, configHome.probeExists))) {
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Pass 2 (legacy bare-existence): first candidate dir that exists.
|
|
101
115
|
for (const candidate of configHome.probe) {
|
|
102
116
|
const resolved = node_path_1.default.join(base, candidate);
|
|
103
117
|
if (existsSyncFn(resolved))
|
|
@@ -171,8 +185,46 @@ function resolveAntigravityGlobalDir(opts = {}) {
|
|
|
171
185
|
parent: '.gemini',
|
|
172
186
|
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
|
173
187
|
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
|
188
|
+
// Prefer the candidate GSD installed into (carries gsd-core/VERSION) over
|
|
189
|
+
// a bare-existing sibling. Without this, a CLI user (antigravity-cli) who
|
|
190
|
+
// also has the IDE's ~/.gemini/antigravity dir is shadowed to the legacy
|
|
191
|
+
// dir because it is probed first. See #213/#217. The posix-slash literal
|
|
192
|
+
// matches capabilities/antigravity/capability.json; both normalize via
|
|
193
|
+
// path.join at the check site, so Windows backslash handling is covered.
|
|
194
|
+
probeExists: 'gsd-core/VERSION',
|
|
174
195
|
}, { env, home, existsSync: existsSyncFn });
|
|
175
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Detect whether the Antigravity config-dir resolution is ambiguous — i.e. more
|
|
199
|
+
* than one of ~/.gemini/{antigravity,antigravity-ide,antigravity-cli} exists, so
|
|
200
|
+
* a user upgrading from a pre-#217 install may have had GSD written into the
|
|
201
|
+
* wrong sibling dir (the legacy/IDE dir shadowing an active CLI dir).
|
|
202
|
+
*
|
|
203
|
+
* This is a pure, side-effect-free probe intended for the installer and
|
|
204
|
+
* /gsd-update to surface operator guidance (set ANTIGRAVITY_CONFIG_DIR or move
|
|
205
|
+
* gsd-core/ into the intended dir). The migration framework cannot relocate an
|
|
206
|
+
* install across sibling config dirs (it is bounded to a single configDir and
|
|
207
|
+
* has no cross-dir move primitive — see installer-migrations 004), so existing
|
|
208
|
+
* misinstalls are corrected by re-detection + operator guidance, not an
|
|
209
|
+
* automatic move.
|
|
210
|
+
*/
|
|
211
|
+
function detectAntigravityDirAmbiguity(opts = {}) {
|
|
212
|
+
const env = opts.env ?? process.env;
|
|
213
|
+
const home = opts.home ?? node_os_1.default.homedir();
|
|
214
|
+
const existsSyncFn = opts.existsSync ?? node_fs_1.default.existsSync;
|
|
215
|
+
const marker = node_path_1.default.join('gsd-core', 'VERSION');
|
|
216
|
+
const base = node_path_1.default.join(home, '.gemini');
|
|
217
|
+
const candidates = ['antigravity', 'antigravity-ide', 'antigravity-cli'].map((c) => node_path_1.default.join(base, c));
|
|
218
|
+
const presentDirs = candidates.filter((dir) => existsSyncFn(dir));
|
|
219
|
+
const gsdMarkedDirs = candidates.filter((dir) => existsSyncFn(node_path_1.default.join(dir, marker)));
|
|
220
|
+
return {
|
|
221
|
+
ambiguous: presentDirs.length > 1,
|
|
222
|
+
resolved: resolveAntigravityGlobalDir({ env, home, existsSync: existsSyncFn }),
|
|
223
|
+
presentDirs,
|
|
224
|
+
gsdMarkedDirs,
|
|
225
|
+
envOverridden: Boolean(env['ANTIGRAVITY_CONFIG_DIR']),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
176
228
|
/**
|
|
177
229
|
* Resolve Kimi's generic user root using Kimi CLI's documented first-existing
|
|
178
230
|
* generic skills directory policy:
|
|
@@ -14,6 +14,7 @@ exports.toNumericTuple = toNumericTuple;
|
|
|
14
14
|
exports.compareSemverCore = compareSemverCore;
|
|
15
15
|
exports.isSemverNewer = isSemverNewer;
|
|
16
16
|
exports.isStableTripletSemver = isStableTripletSemver;
|
|
17
|
+
exports.semverSatisfies = semverSatisfies;
|
|
17
18
|
function toNumericTuple(input) {
|
|
18
19
|
const cleaned = String(input == null ? '' : input).trim().replace(/^v/, '');
|
|
19
20
|
const base = cleaned.replace(/[-+].*$/, '');
|
|
@@ -40,3 +41,129 @@ function isSemverNewer(a, b) {
|
|
|
40
41
|
function isStableTripletSemver(v) {
|
|
41
42
|
return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
|
|
42
43
|
}
|
|
44
|
+
function compareTuples(a, b) {
|
|
45
|
+
if (a[0] !== b[0])
|
|
46
|
+
return a[0] > b[0] ? 1 : -1;
|
|
47
|
+
if (a[1] !== b[1])
|
|
48
|
+
return a[1] > b[1] ? 1 : -1;
|
|
49
|
+
if (a[2] !== b[2])
|
|
50
|
+
return a[2] > b[2] ? 1 : -1;
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
// Parse a version-ish token into a tuple + how many leading numeric parts were
|
|
54
|
+
// specified (0 = bare wildcard "*"/"x", 1 = "1", 2 = "1.2", 3 = "1.2.3").
|
|
55
|
+
// Returns null if the token is not a parseable partial/full version.
|
|
56
|
+
function parseVersionToken(token) {
|
|
57
|
+
const clean = token.trim().replace(/^v/, '').replace(/[-+].*$/, '');
|
|
58
|
+
if (clean === '' || clean === '*' || clean === 'x' || clean === 'X')
|
|
59
|
+
return { tuple: [0, 0, 0], specified: 0 };
|
|
60
|
+
const parts = clean.split('.');
|
|
61
|
+
if (parts.length > 3)
|
|
62
|
+
return null;
|
|
63
|
+
const nums = [];
|
|
64
|
+
let sawWildcard = false;
|
|
65
|
+
for (const p of parts) {
|
|
66
|
+
if (p === 'x' || p === 'X' || p === '*') {
|
|
67
|
+
sawWildcard = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// A concrete segment after a wildcard ("1.x.2", "1.*.2") is malformed → fail closed.
|
|
71
|
+
if (sawWildcard)
|
|
72
|
+
return null;
|
|
73
|
+
if (!/^\d+$/.test(p))
|
|
74
|
+
return null;
|
|
75
|
+
nums.push(Number.parseInt(p, 10));
|
|
76
|
+
}
|
|
77
|
+
if (nums.length === 0)
|
|
78
|
+
return { tuple: [0, 0, 0], specified: 0 };
|
|
79
|
+
return { tuple: [nums[0] || 0, nums[1] || 0, nums[2] || 0], specified: nums.length };
|
|
80
|
+
}
|
|
81
|
+
// Expand a single comparator into primitive (op, tuple) constraints, or null if
|
|
82
|
+
// unparseable (→ fail closed).
|
|
83
|
+
function expandComparator(c) {
|
|
84
|
+
const trimmed = c.trim();
|
|
85
|
+
if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'X')
|
|
86
|
+
return [{ op: '>=', t: [0, 0, 0] }];
|
|
87
|
+
const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed);
|
|
88
|
+
if (!m)
|
|
89
|
+
return null;
|
|
90
|
+
const op = m[1] || '';
|
|
91
|
+
const pv = parseVersionToken(m[2]);
|
|
92
|
+
if (!pv)
|
|
93
|
+
return null;
|
|
94
|
+
const { tuple, specified } = pv;
|
|
95
|
+
const [maj, min, pat] = tuple;
|
|
96
|
+
if (op === '^') {
|
|
97
|
+
let upper;
|
|
98
|
+
if (maj > 0)
|
|
99
|
+
upper = [maj + 1, 0, 0];
|
|
100
|
+
else if (min > 0)
|
|
101
|
+
upper = [0, min + 1, 0];
|
|
102
|
+
else
|
|
103
|
+
upper = [0, 0, pat + 1];
|
|
104
|
+
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
|
105
|
+
}
|
|
106
|
+
if (op === '~') {
|
|
107
|
+
const upper = specified >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0];
|
|
108
|
+
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
|
109
|
+
}
|
|
110
|
+
if (op === '' || op === '=') {
|
|
111
|
+
if (specified === 0)
|
|
112
|
+
return [{ op: '>=', t: [0, 0, 0] }]; // "*" → any
|
|
113
|
+
if (specified === 3)
|
|
114
|
+
return [{ op: '=', t: tuple }];
|
|
115
|
+
const upper = specified === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0];
|
|
116
|
+
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
|
117
|
+
}
|
|
118
|
+
// >= <= > < with an explicit version
|
|
119
|
+
if (specified === 0)
|
|
120
|
+
return null; // e.g. ">=*" is meaningless → fail closed
|
|
121
|
+
return [{ op: op, t: tuple }];
|
|
122
|
+
}
|
|
123
|
+
function satisfiesPrimitive(v, prim) {
|
|
124
|
+
const cmp = compareTuples(v, prim.t);
|
|
125
|
+
switch (prim.op) {
|
|
126
|
+
case '>=': return cmp >= 0;
|
|
127
|
+
case '<=': return cmp <= 0;
|
|
128
|
+
case '>': return cmp > 0;
|
|
129
|
+
case '<': return cmp < 0;
|
|
130
|
+
case '=': return cmp === 0;
|
|
131
|
+
default: return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// One whitespace-separated comparator set (ANDed). Fail closed if any comparator
|
|
135
|
+
// is unparseable.
|
|
136
|
+
function satisfiesSet(v, set) {
|
|
137
|
+
const trimmed = set.trim();
|
|
138
|
+
if (trimmed === '')
|
|
139
|
+
return false;
|
|
140
|
+
const comparators = trimmed.split(/\s+/).filter(Boolean);
|
|
141
|
+
if (comparators.length === 0)
|
|
142
|
+
return false;
|
|
143
|
+
for (const c of comparators) {
|
|
144
|
+
const prims = expandComparator(c);
|
|
145
|
+
if (prims === null)
|
|
146
|
+
return false; // unparseable → fail closed
|
|
147
|
+
for (const prim of prims) {
|
|
148
|
+
if (!satisfiesPrimitive(v, prim))
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Does `version` satisfy the semver `range`? OR-composed across `||`, AND-composed
|
|
156
|
+
* across whitespace. Fail-closed: an empty range, or any comparator this minimal
|
|
157
|
+
* implementation cannot parse, returns false. Comparison is on the numeric
|
|
158
|
+
* major.minor.patch core (prerelease tags are stripped, per `toNumericTuple`).
|
|
159
|
+
*/
|
|
160
|
+
function semverSatisfies(version, range) {
|
|
161
|
+
const r = String(range == null ? '' : range).trim();
|
|
162
|
+
if (r === '')
|
|
163
|
+
return false;
|
|
164
|
+
const v = toNumericTuple(version);
|
|
165
|
+
const orSets = r.split('||').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
166
|
+
if (orSets.length === 0)
|
|
167
|
+
return false;
|
|
168
|
+
return orSets.some((set) => satisfiesSet(v, set));
|
|
169
|
+
}
|
|
@@ -153,8 +153,10 @@ function shouldPreserveExistingProgress(existingProgress, derivedProgress) {
|
|
|
153
153
|
return false;
|
|
154
154
|
const existing = existingProgress;
|
|
155
155
|
const derived = derivedProgress;
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// total_phases is intentionally excluded from the ratchet: it must always
|
|
157
|
+
// take the freshly derived value so it can correct downward (#1446).
|
|
158
|
+
// Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
|
|
159
|
+
return (existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
|
|
158
160
|
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
|
|
159
161
|
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
|
|
160
162
|
}
|