@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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. 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 four heuristics:
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 rest = content.slice(headingStart + headingText.length);
101
- const stopPattern = new RegExp(`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
102
- let end = content.length;
103
- let fc = null;
104
- let fl = 0;
105
- let off = 0;
106
- for (const line of rest.split('\n')) {
107
- const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
108
- if (fm) {
109
- const ch = fm[1][0];
110
- const ln = fm[1].length;
111
- const trailing = fm[2] || '';
112
- if (!fc) {
113
- fc = ch;
114
- fl = ln;
115
- }
116
- else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
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 end;
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 restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
378
- const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
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
- let fenceChar = null;
381
- let fenceLen = 0;
382
- let charOffset = 0;
383
- for (const line of restContent.split('\n')) {
384
- const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
385
- if (fenceMatch) {
386
- const char = fenceMatch[1][0];
387
- const len = fenceMatch[1].length;
388
- const trailing = fenceMatch[2] || '';
389
- if (!fenceChar) {
390
- fenceChar = char;
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
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
409
- const roadmapUnfenced = stripFencedLines(roadmap);
410
- let m;
411
- while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
412
- milestonePhaseNums.add(m[1]);
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
- // probe each candidate under base; return first that exists
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
- return (existingProgressExceedsDerived(existing, derived, 'total_phases') ||
157
- existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
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
  }