@opengsd/gsd-core 1.4.2 → 1.4.4

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-core",
3
3
  "displayName": "GSD Core",
4
- "version": "1.4.2",
4
+ "version": "1.4.4",
5
5
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "open-gsd",
package/bin/install.js CHANGED
@@ -8496,6 +8496,52 @@ function uninstall(isGlobal, runtime = 'claude') {
8496
8496
  }
8497
8497
  }
8498
8498
 
8499
+ // 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
8500
+ // GSD-managed files only: enumerate the exact set the installer writes.
8501
+ // Any file NOT in this set is user-owned and must survive uninstall.
8502
+ // After removing GSD files, attempt to rmdir — if the directory is still
8503
+ // non-empty (user has custom helpers) it stays; otherwise it goes cleanly.
8504
+ const GSD_CHANGESET_FILES = [
8505
+ 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs',
8506
+ 'github-release-notes.cjs', 'lint.cjs', 'new.cjs',
8507
+ 'README.md', // documentation only — not user-authored
8508
+ ];
8509
+ const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs'];
8510
+
8511
+ const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset');
8512
+ if (fs.existsSync(changesetUninstallDir)) {
8513
+ let removedChangeset = 0;
8514
+ for (const file of GSD_CHANGESET_FILES) {
8515
+ const fp = path.join(changesetUninstallDir, file);
8516
+ try { fs.unlinkSync(fp); removedChangeset++; } catch (_) { /* best-effort */ }
8517
+ }
8518
+ // Remove directory if empty after our cleanup
8519
+ try { fs.rmdirSync(changesetUninstallDir); } catch (_) { /* Not empty — user content present */ }
8520
+ if (removedChangeset > 0) {
8521
+ removedCount++;
8522
+ console.log(` ${green}✓${reset} Removed scripts/changeset/ GSD files`);
8523
+ }
8524
+ }
8525
+ const scriptsLibUninstallDir = path.join(targetDir, 'scripts', 'lib');
8526
+ if (fs.existsSync(scriptsLibUninstallDir)) {
8527
+ let removedScriptsLib = 0;
8528
+ for (const file of GSD_SCRIPTS_LIB_FILES) {
8529
+ const fp = path.join(scriptsLibUninstallDir, file);
8530
+ try { fs.unlinkSync(fp); removedScriptsLib++; } catch (_) { /* best-effort */ }
8531
+ }
8532
+ // Remove directory if empty after our cleanup
8533
+ try { fs.rmdirSync(scriptsLibUninstallDir); } catch (_) { /* Not empty — user content present */ }
8534
+ if (removedScriptsLib > 0) {
8535
+ removedCount++;
8536
+ console.log(` ${green}✓${reset} Removed scripts/lib/ GSD files`);
8537
+ }
8538
+ }
8539
+ // If scripts/ dir is now empty, remove it too
8540
+ const scriptsUninstallDir = path.join(targetDir, 'scripts');
8541
+ if (fs.existsSync(scriptsUninstallDir)) {
8542
+ try { fs.rmdirSync(scriptsUninstallDir); } catch (_) { /* Not empty — leave it */ }
8543
+ }
8544
+
8499
8545
  // 5. Remove GSD package.json (CommonJS mode marker)
8500
8546
  const pkgJsonPath = path.join(targetDir, 'package.json');
8501
8547
  if (fs.existsSync(pkgJsonPath)) {
@@ -9169,6 +9215,24 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
9169
9215
  }
9170
9216
  }
9171
9217
 
9218
+ // Track scripts/changeset/ and scripts/lib/ so saveLocalPatches() can detect drift
9219
+ const changesetInstallDir = path.join(configDir, 'scripts', 'changeset');
9220
+ if (fs.existsSync(changesetInstallDir)) {
9221
+ for (const file of fs.readdirSync(changesetInstallDir)) {
9222
+ if (file.endsWith('.cjs')) {
9223
+ manifest.files['scripts/changeset/' + file] = fileHash(path.join(changesetInstallDir, file));
9224
+ }
9225
+ }
9226
+ }
9227
+ const scriptsLibInstallDir = path.join(configDir, 'scripts', 'lib');
9228
+ if (fs.existsSync(scriptsLibInstallDir)) {
9229
+ for (const file of fs.readdirSync(scriptsLibInstallDir)) {
9230
+ if (file.endsWith('.cjs')) {
9231
+ manifest.files['scripts/lib/' + file] = fileHash(path.join(scriptsLibInstallDir, file));
9232
+ }
9233
+ }
9234
+ }
9235
+
9172
9236
  fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
9173
9237
  return manifest;
9174
9238
  }
@@ -10446,6 +10510,64 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10446
10510
  console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
10447
10511
  }
10448
10512
 
10513
+ // Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
10514
+ // so that `node "$GSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime.
10515
+ //
10516
+ // The changeset CLI (scripts/changeset/cli.cjs) is invoked by the update
10517
+ // workflow (gsd-core/workflows/update.md) to extract changelog ranges for
10518
+ // the /gsd-update preview step. It was previously only present in the npm
10519
+ // tarball root but never copied to the runtime config dir, causing the
10520
+ // preview to always silently fail (#935).
10521
+ //
10522
+ // cli.cjs requires:
10523
+ // - sibling files in scripts/changeset/ (parse/render/serialize/github-release-notes)
10524
+ // - ../lib/cli-exit.cjs → scripts/lib/cli-exit.cjs
10525
+ // - ../../gsd-core/bin/lib/semver-compare.cjs (already installed under gsd-core/)
10526
+ // - ../../gsd-core/bin/lib/package-identity.cjs (already installed under gsd-core/)
10527
+ //
10528
+ // All runtimes that use the update workflow need this, so we copy unconditionally
10529
+ // (same scope as gsd-core/ itself — every runtime that installs workflows gets it).
10530
+ const changesetSrc = path.join(src, 'scripts', 'changeset');
10531
+ const scriptsLibSrc = path.join(src, 'scripts', 'lib');
10532
+ if (!fs.existsSync(changesetSrc)) {
10533
+ // The changeset CLI source is missing from the package — mark as a hard failure
10534
+ // so the user knows the changelog preview will not work rather than silently degrading.
10535
+ failures.push('scripts/changeset/ (source missing from package — reinstall from npm)');
10536
+ } else {
10537
+ const changesetDest = path.join(targetDir, 'scripts', 'changeset');
10538
+ const scriptsLibDest = path.join(targetDir, 'scripts', 'lib');
10539
+ fs.mkdirSync(changesetDest, { recursive: true });
10540
+ fs.mkdirSync(scriptsLibDest, { recursive: true });
10541
+ // Copy scripts/changeset/ — all .cjs and .md files
10542
+ for (const entry of fs.readdirSync(changesetSrc)) {
10543
+ const srcFile = path.join(changesetSrc, entry);
10544
+ if (fs.statSync(srcFile).isFile()) {
10545
+ fs.copyFileSync(srcFile, path.join(changesetDest, entry));
10546
+ }
10547
+ }
10548
+ // Copy scripts/lib/ — cli-exit.cjs (required by cli.cjs) and any future lib helpers.
10549
+ // Hard-fail if missing: without cli-exit.cjs the installed CLI throws MODULE_NOT_FOUND.
10550
+ if (!fs.existsSync(scriptsLibSrc)) {
10551
+ failures.push('scripts/lib/ (source missing from package — reinstall from npm)');
10552
+ } else {
10553
+ for (const entry of fs.readdirSync(scriptsLibSrc)) {
10554
+ const srcFile = path.join(scriptsLibSrc, entry);
10555
+ if (fs.statSync(srcFile).isFile()) {
10556
+ fs.copyFileSync(srcFile, path.join(scriptsLibDest, entry));
10557
+ }
10558
+ }
10559
+ // Verify the critical dep cli-exit.cjs landed
10560
+ if (!verifyFileInstalled(path.join(scriptsLibDest, 'cli-exit.cjs'), 'scripts/lib/cli-exit.cjs')) {
10561
+ failures.push('scripts/lib/cli-exit.cjs');
10562
+ }
10563
+ }
10564
+ if (verifyFileInstalled(path.join(changesetDest, 'cli.cjs'), 'scripts/changeset/cli.cjs')) {
10565
+ console.log(` ${green}✓${reset} Installed scripts/changeset/ (changelog preview CLI)`);
10566
+ } else {
10567
+ failures.push('scripts/changeset/cli.cjs');
10568
+ }
10569
+ }
10570
+
10449
10571
  // Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
10450
10572
  // cleanupLegacyGsdCc handles both the legacy shared cache and the per-package
10451
10573
  // cache (formerly an inline unlinkSync here). A cleanup failure must never
@@ -17,11 +17,11 @@ requires: [phase, review]
17
17
  Cross-AI plan convergence loop — an outer revision gate around gsd-review and gsd-planner.
18
18
  Repeatedly: review plans with external AI CLIs → if HIGH concerns found → replan with --reviews feedback → re-review. Stops when no HIGH concerns remain or max cycles reached.
19
19
 
20
- **Flow:** Agent→Skill("gsd-plan-phase") → Agent→Skill("gsd-review") → check HIGHs → Agent→Skill("gsd-plan-phase --reviews") → Agent→Skill("gsd-review") → ... → Converge or escalate
20
+ **Flow:** Skill("gsd-plan-phase") → Agent→Skill("gsd-review") → check HIGHs → Skill("gsd-plan-phase --reviews") → Agent→Skill("gsd-review") → ... → Converge or escalate
21
21
 
22
- Replaces gsd-plan-phase's internal gsd-plan-checker with external AI reviewers (codex, gemini, etc.). Each step runs inside an isolated Agent that calls the corresponding existing Skill — orchestrator only does loop control.
22
+ Replaces gsd-plan-phase's internal gsd-plan-checker with external AI reviewers (codex, gemini, etc.). Plan-phase runs **inline** (bare Skill at depth 0) so it can spawn gsd-planner/gsd-plan-checker at depth 1. Review runs inside an isolated Agent (gsd-review is a Bash leaf — no sub-agents needed). Orchestrator only does loop control.
23
23
 
24
- **Orchestrator role:** Parse arguments, validate phase, spawn Agents for existing Skills, check HIGHs, stall detection, escalation gate.
24
+ **Orchestrator role:** Parse arguments, validate phase, run plan-phase inline (Skill at depth 0), spawn an Agent for gsd-review, check HIGHs, stall detection, escalation gate.
25
25
  </objective>
26
26
 
27
27
  <execution_context>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-core",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * Installer migration 004: remove stale gsd-pristine/get-shit-done/ snapshot // gsd-allow-legacy-name
4
+ * files after the get-shit-done → gsd-core rename (#604, #934). // gsd-allow-legacy-name
5
+ *
6
+ * Background: migration 003 removed legacy runtime files from
7
+ * get-shit-done/ but did not touch gsd-pristine/get-shit-done/, the // gsd-allow-legacy-name
8
+ * parallel directory that holds pristine snapshots captured before the rename.
9
+ * These snapshot files are GSD-managed (written by the installer, never by the
10
+ * user) and reference stale get-shit-done/... key paths that no longer exist // gsd-allow-legacy-name
11
+ * in the active layout. When verify-reapply-patches.cjs looks up a backup entry
12
+ * keyed under gsd-core/... it finds no matching gsd-pristine/ snapshot, falls
13
+ * to over-broad mode, and reports false FAIL_INSTALLED_MISSING / // gsd-allow-legacy-name
14
+ * FAIL_USER_LINES_MISSING for every backed-up pre-rename file (#934).
15
+ *
16
+ * Fix: walk gsd-pristine/get-shit-done/ and emit remove-managed for each file. // gsd-allow-legacy-name
17
+ * These files are always GSD-written snapshots — users never place their own
18
+ * files inside gsd-pristine/ — so the classification override
19
+ * (managed-pristine) is safe: there is no user content to protect.
20
+ *
21
+ * Checksum safety: migration 003's body is left untouched. Adding this
22
+ * separate migration avoids modifying 003's checksum, which would break
23
+ * upgrade state for any user who already applied 003 (root cause of #670).
24
+ *
25
+ * Per-file approach: the migration framework has no recursive directory-removal
26
+ * primitive — all actions operate on individual files. Empty directory shells
27
+ * left after removal can be cleaned up manually; this is the intentional ADR-0008
28
+ * limitation.
29
+ */
30
+ var __importDefault = (this && this.__importDefault) || function (mod) {
31
+ return (mod && mod.__esModule) ? mod : { "default": mod };
32
+ };
33
+ const node_fs_1 = __importDefault(require("node:fs"));
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ function walkPristineFiles(root, relDir, baseResolved, results) {
36
+ const dir = node_path_1.default.join(root, relDir);
37
+ let entries;
38
+ try {
39
+ entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
40
+ }
41
+ catch {
42
+ return; // directory absent or unreadable — nothing to do
43
+ }
44
+ for (const entry of entries) {
45
+ // Do not follow symlinks — skip to avoid out-of-tree traversal.
46
+ if (entry.isSymbolicLink())
47
+ continue;
48
+ const relPath = node_path_1.default.posix.join(relDir, entry.name);
49
+ // Bounds check: ensure the resolved path stays under configDir.
50
+ const resolved = node_path_1.default.resolve(root, relPath);
51
+ if (resolved !== baseResolved && !resolved.startsWith(baseResolved + node_path_1.default.sep))
52
+ continue;
53
+ if (entry.isDirectory()) {
54
+ walkPristineFiles(root, relPath, baseResolved, results);
55
+ }
56
+ else if (entry.isFile()) {
57
+ results.push(relPath);
58
+ }
59
+ }
60
+ }
61
+ const REASON = 'stale pristine snapshot from legacy get-shit-done/ dir, orphaned by rename migration 003 (#604, #934)'; // gsd-allow-legacy-name
62
+ const migration = {
63
+ id: '2026-06-09-prune-stale-pristine-get-shit-done', // gsd-allow-legacy-name
64
+ title: 'Remove stale gsd-pristine/get-shit-done/ snapshot files (#934)', // gsd-allow-legacy-name
65
+ description: 'Migration 003 removed runtime files from get-shit-done/ but left the matching pristine snapshot ' + // gsd-allow-legacy-name
66
+ 'directory gsd-pristine/get-shit-done/ intact. Those snapshots reference stale key paths and cause ' + // gsd-allow-legacy-name
67
+ 'verify-reapply-patches false positives (#934). Remove all files under gsd-pristine/get-shit-done/ ' + // gsd-allow-legacy-name
68
+ 'as they are GSD-managed snapshots, never user content.',
69
+ introducedIn: '1.4.3',
70
+ scopes: ['global', 'local'],
71
+ destructive: true,
72
+ plan(ctx) {
73
+ const pristineGsdRoot = node_path_1.default.join(ctx.configDir, 'gsd-pristine', 'get-shit-done'); // gsd-allow-legacy-name
74
+ // Idempotency: if the stale pristine subdir doesn't exist, nothing to do.
75
+ if (!node_fs_1.default.existsSync(pristineGsdRoot))
76
+ return [];
77
+ // Safety: reject symlinks in ANY ancestor component of the path we will walk
78
+ // to prevent following a symlink out of configDir. Check both gsd-pristine/
79
+ // and gsd-pristine/get-shit-done/ — either being a symlink could redirect // gsd-allow-legacy-name
80
+ // the walk to an out-of-tree location.
81
+ const pristineParent = node_path_1.default.join(ctx.configDir, 'gsd-pristine');
82
+ try {
83
+ if (node_fs_1.default.lstatSync(pristineParent).isSymbolicLink())
84
+ return [];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ try {
90
+ if (node_fs_1.default.lstatSync(pristineGsdRoot).isSymbolicLink())
91
+ return []; // gsd-allow-legacy-name
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ const baseResolved = node_path_1.default.resolve(ctx.configDir);
97
+ const relPaths = [];
98
+ walkPristineFiles(ctx.configDir, node_path_1.default.posix.join('gsd-pristine', 'get-shit-done'), baseResolved, relPaths); // gsd-allow-legacy-name
99
+ const actions = [];
100
+ for (const relPath of relPaths) {
101
+ // Bounds-check each relPath before emitting any action.
102
+ const resolved = node_path_1.default.resolve(ctx.configDir, relPath);
103
+ if (resolved !== baseResolved && !resolved.startsWith(baseResolved + node_path_1.default.sep))
104
+ continue;
105
+ // These files are GSD-managed pristine snapshots — the installer writes
106
+ // them during install/upgrade; users never place personal files inside
107
+ // gsd-pristine/. Pass classification: 'managed-pristine' explicitly so
108
+ // the framework does not downgrade remove-managed to preserve-user when
109
+ // the manifest has no entry (these paths were never in the manifest since
110
+ // they live under gsd-pristine/, not the tracked runtime dir).
111
+ actions.push({
112
+ type: 'remove-managed',
113
+ relPath,
114
+ reason: REASON,
115
+ ownershipEvidence: 'GSD-written pristine snapshot under gsd-pristine/get-shit-done/; ' + // gsd-allow-legacy-name
116
+ 'installer is the sole author of gsd-pristine/ contents; no user content lives here',
117
+ classification: 'managed-pristine',
118
+ });
119
+ }
120
+ return actions;
121
+ },
122
+ };
123
+ module.exports = migration;
@@ -89,6 +89,11 @@
89
89
  "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-4-6" }, "high": { "model": "claude-opus-4-8" } },
90
90
  "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-4-6" } }
91
91
  },
92
+ "anthropic-fable": {
93
+ "opus": { "low": { "model": "claude-opus-4-5" }, "medium": { "model": "claude-opus-4-8" }, "high": { "model": "claude-fable-5" } },
94
+ "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-4-6" }, "high": { "model": "claude-fable-5" } },
95
+ "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-4-6" } }
96
+ },
92
97
  "openai": {
93
98
  "opus": { "low": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" }, "medium": { "model": "gpt-5.5", "reasoning_effort": "high" }, "high": { "model": "gpt-5.5", "reasoning_effort": "xhigh" } },
94
99
  "sonnet": { "low": { "model": "gpt-5.4-mini", "reasoning_effort": "low" }, "medium": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" }, "high": { "model": "gpt-5.5", "reasoning_effort": "medium" } },
@@ -165,6 +165,19 @@ const REASON = Object.freeze({
165
165
  // resolve this file; the guard here ensures the gate does not report spurious
166
166
  // failures in the meantime.
167
167
  OK_PRISTINE_DRIFT_DETECTED: 'ok_pristine_drift_detected',
168
+ // Bug #934: backup-meta.json records a pristine_hash for this file but the
169
+ // gsd-pristine/ file is absent from disk. This happens on post-#604-rename
170
+ // installs where saveLocalPatches discarded the only pristine candidate
171
+ // because its hash did not match the old-release hash (the file changed
172
+ // upstream between releases). Without a baseline the verifier cannot
173
+ // distinguish user-added lines from upstream-changed lines, so falling to
174
+ // over-broad mode would produce FAIL_USER_LINES_MISSING false positives for
175
+ // every upstream-removed line. The correct posture is advisory/non-blocking:
176
+ // report OK_NO_BASELINE so the caller can log a warning without halting the
177
+ // gate on a spurious failure. This is a bounded "cannot reason → do not
178
+ // block" rather than "ignore everything" — it only applies when the hash was
179
+ // recorded (modern installer) but the file is absent (specific gap).
180
+ OK_NO_BASELINE: 'ok_no_baseline',
168
181
  FAIL_INSTALLED_MISSING: 'fail_installed_missing',
169
182
  FAIL_INSTALLED_NOT_REGULAR_FILE: 'fail_installed_not_regular_file',
170
183
  FAIL_READ_ERROR: 'fail_read_error',
@@ -208,11 +221,25 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
208
221
  return result;
209
222
  }
210
223
 
224
+ // Normalize to forward slashes so the key lookup matches on Windows
225
+ // where path.join produces backslash-separated relPath values but
226
+ // backup-meta.json stores keys written with forward slashes.
227
+ const hashKey = relPath.replace(/\\/g, '/');
228
+ const recordedHash = pristineHashes && pristineHashes[hashKey];
229
+
211
230
  let pristineContent = null;
212
231
  if (pristineDir) {
213
232
  const pristinePath = path.join(pristineDir, relPath);
233
+ // Bug #934: track whether the pristine path EXISTS on disk (stat did not
234
+ // throw ENOENT). A regular file that fails to read, or a non-file path
235
+ // (e.g. a directory accidentally placed at the pristine path), is treated
236
+ // as "present but unusable" — we fall to over-broad mode (safe side).
237
+ // OK_NO_BASELINE is reserved for the strictly absent case: stat throws,
238
+ // meaning the file was never written (the gap the bug describes).
239
+ let pristinePathExists = false;
214
240
  try {
215
241
  const stat = fs.statSync(pristinePath);
242
+ pristinePathExists = true; // path exists (any type)
216
243
  if (stat.isFile()) {
217
244
  const candidate = fs.readFileSync(pristinePath, 'utf8');
218
245
  // Bug #3657: if backup-meta.json recorded a pristine_hash for this
@@ -226,11 +253,6 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
226
253
  // Over-broad mode never false-fails for a different reason because all
227
254
  // backup lines that are genuinely user-added will still be present in a
228
255
  // correctly merged install.
229
- // Normalize to forward slashes so the key lookup matches on Windows
230
- // where path.join produces backslash-separated relPath values but
231
- // backup-meta.json stores keys written with forward slashes.
232
- const hashKey = relPath.replace(/\\/g, '/');
233
- const recordedHash = pristineHashes && pristineHashes[hashKey];
234
256
  if (recordedHash) {
235
257
  if (sha256(candidate) === recordedHash) {
236
258
  // Hash matches: the on-disk pristine is the correct baseline.
@@ -251,8 +273,28 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
251
273
  pristineContent = candidate;
252
274
  }
253
275
  }
276
+ // Non-file at pristinePath (e.g. a directory): stat succeeded so
277
+ // pristinePathExists is true; we fall through to over-broad mode below,
278
+ // which is safe and conservative.
254
279
  } catch {
255
- // Pristine missing or unreadable — fall through to over-broad mode.
280
+ // Pristine stat threw — path is absent (ENOENT) or inaccessible.
281
+ // pristinePathExists stays false.
282
+ }
283
+
284
+ // Bug #934: recordedHash is present (modern installer) but the pristine
285
+ // path does not exist on disk at all (stat threw above). This means
286
+ // saveLocalPatches recorded a hash but could not write the corresponding
287
+ // gsd-pristine/ file (the only candidate was discarded because it was from
288
+ // a newer release). Falling to over-broad mode here would treat every
289
+ // upstream-changed line as a "user-added line that must survive", producing
290
+ // false FAIL_USER_LINES_MISSING for each upstream removal. Since we
291
+ // cannot reason correctly without a baseline, the safe answer is advisory/
292
+ // non-blocking: return OK_NO_BASELINE and let the caller decide.
293
+ // NOTE: this guard fires ONLY when stat threw (path absent), not when the
294
+ // path is present but non-file — in that case over-broad mode is safer.
295
+ if (!pristinePathExists && recordedHash) {
296
+ result.reason = REASON.OK_NO_BASELINE;
297
+ return result;
256
298
  }
257
299
  }
258
300
 
@@ -316,9 +358,17 @@ function main() {
316
358
  const drifted = driftedResults.length;
317
359
  const drifted_files = driftedResults.map((r) => r.file);
318
360
 
361
+ // Bug #934: aggregate no-baseline files into top-level report fields so the
362
+ // workflow can log a warning about files that could not be verified. Like
363
+ // drift, this is NOT a failure (exit code stays 0) but gives the caller
364
+ // structured data to surface the advisory condition.
365
+ const noBaselineResults = results.filter((r) => r.reason === REASON.OK_NO_BASELINE);
366
+ const no_baseline = noBaselineResults.length;
367
+ const no_baseline_files = noBaselineResults.map((r) => r.file);
368
+
319
369
  if (opts.json) {
320
370
  process.stdout.write(
321
- JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, results }, null, 2) + '\n',
371
+ JSON.stringify({ checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, results }, null, 2) + '\n',
322
372
  );
323
373
  } else {
324
374
  process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
@@ -1,7 +1,8 @@
1
1
  <purpose>
2
2
  Cross-AI plan convergence loop — automates the manual chain:
3
3
  gsd-plan-phase N → gsd-review N --codex → gsd-plan-phase N --reviews → gsd-review N --codex → ...
4
- Each step runs inside an isolated Agent that calls the corresponding Skill.
4
+ Plan-phase runs inline (bare Skill at depth 0) so it can spawn gsd-planner/gsd-plan-checker at depth 1.
5
+ Review runs inside an isolated Agent (leaf skill — Bash only, no sub-agents needed).
5
6
  Orchestrator only does: init, loop control, parse CYCLE_SUMMARY for HIGH count, stall detection, escalation.
6
7
  </purpose>
7
8
 
@@ -98,21 +99,15 @@ Display startup banner:
98
99
 
99
100
  **If `has_plans` is false:**
100
101
 
101
- Display: `◆ No plans found — spawning initial planning agent... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)`
102
+ Display: `◆ No plans found — running initial planning inline... (plan-phase runs here in the orchestrator — no output until planning is complete, ~1–5 min; expected, not a freeze)`
102
103
 
103
104
  ```text
104
- Agent(
105
- description="Initial planning Phase {PHASE}",
106
- prompt="Run /gsd:plan-phase for Phase {PHASE}.
107
-
108
- Execute: Skill(skill='gsd-plan-phase', args='{PHASE} {GSD_WS}')
109
-
110
- Complete the full planning workflow. Do NOT return until planning is complete and PLAN.md files are committed.",
111
- mode="auto"
112
- )
105
+ Skill(skill="gsd-plan-phase", args="{PHASE} {GSD_WS}")
113
106
  ```
114
107
 
115
- After agent returns, verify plans were created:
108
+ Run plan-phase **inline** (do NOT wrap it in Agent()). The convergence orchestrator runs at depth 0 with Agent available, so inline plan-phase can spawn gsd-planner and gsd-plan-checker at depth 1 — the one level of nesting that works on Claude Code. Wrapping plan-phase in Agent() would push it to depth 1 where the Agent tool is absent, preventing it from spawning any sub-agents. Wait until plan-phase completes and PLAN.md files are committed before continuing.
109
+
110
+ After plan-phase completes, verify plans were created:
116
111
  ```bash
117
112
  PLAN_COUNT=$(ls ${phase_dir}/${padded_phase}-*-PLAN.md 2>/dev/null | wc -l)
118
113
  ```
@@ -302,44 +297,35 @@ To restart loop: /gsd:plan-review-convergence {PHASE} {REVIEWER_FLAGS}
302
297
  ```
303
298
  Exit workflow.
304
299
 
305
- ### 5d. Replan (Spawn Agent)
300
+ ### 5d. Replan (Inline)
306
301
 
307
302
  **If under max cycles:**
308
303
 
309
304
  Update `prev_high_count = HIGH_COUNT`.
310
305
 
311
- Display: `◆ Spawning replan agent with review feedback... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)`
306
+ Display: `◆ Replanning inline with review feedback... (plan-phase runs here in the orchestrator — no output until replanning is complete, ~1–5 min; expected, not a freeze)`
312
307
 
313
308
  ```text
314
- Agent(
315
- description="Replan Phase {PHASE} with review feedback cycle {cycle}",
316
- prompt="Run /gsd:plan-phase with --reviews for Phase {PHASE}.
317
-
318
- Execute: Skill(skill='gsd-plan-phase', args='{PHASE} --reviews --skip-research {GSD_WS}')
319
-
320
- This will replan incorporating cross-AI review feedback from REVIEWS.md.
321
- Do NOT return until replanning is complete and updated PLAN.md files are committed.
322
-
323
- IMPORTANT: When gsd-plan-phase outputs '## PLANNING COMPLETE', that means replanning is done. Return at that point.",
324
- mode="auto"
325
- )
309
+ Skill(skill="gsd-plan-phase", args="{PHASE} --reviews --skip-research {GSD_WS}")
326
310
  ```
327
311
 
328
- After agent returns → go back to **step 5a** (review again).
312
+ Run plan-phase **inline** (do NOT wrap it in Agent()). Same rationale as step 4: the convergence orchestrator runs at depth 0 with Agent available, so inline plan-phase can spawn gsd-planner and gsd-plan-checker at depth 1. Wrapping in Agent() pushes plan-phase to depth 1 where the Agent tool is absent — the replan loop can never produce a revised plan when HIGHs are found. This is the root cause of bug #936. Wait until plan-phase completes (outputs '## PLANNING COMPLETE') and updated PLAN.md files are committed before continuing.
313
+
314
+ After plan-phase completes → go back to **step 5a** (review again).
329
315
 
330
316
  </process>
331
317
 
332
318
  <success_criteria>
333
319
  - [ ] Config gate checked before running — exits with enable instructions if workflow.plan_review_convergence is false
334
- - [ ] Initial planning via Agent → Skill("gsd-plan-phase") if no plans exist
335
- - [ ] Review via Agent → Skill("gsd-review") — isolated, not inline; {GSD_WS} forwarded
336
- - [ ] Replan via Agent → Skill("gsd-plan-phase --reviews") — isolated, not inline
320
+ - [ ] Initial planning via inline Skill("gsd-plan-phase") if no plans exist — NOT wrapped in Agent() (bug #936: depth-1 Agent has no Agent tool)
321
+ - [ ] Review via Agent → Skill("gsd-review") — isolated Agent is correct; gsd-review is a Bash leaf with no sub-agent spawns; {GSD_WS} forwarded
322
+ - [ ] Replan via inline Skill("gsd-plan-phase --reviews") — NOT wrapped in Agent(); inline lets plan-phase spawn gsd-planner/gsd-plan-checker at depth 1
337
323
  - [ ] Orchestrator only does: init, config gate, loop control, parse CYCLE_SUMMARY for HIGH count, stall detection, escalation
338
324
  - [ ] HIGH count extracted from review agent's CYCLE_SUMMARY return message (not by grepping REVIEWS.md)
339
325
  - [ ] Review agent prompt defines CYCLE_SUMMARY: current_high=<N> contract with PARTIALLY/FULLY RESOLVED definitions
340
326
  - [ ] Abort with clear error if CYCLE_SUMMARY is absent; distinguish malformed from absent
341
327
  - [ ] Warn if HIGH_COUNT > 0 but ## Current HIGH Concerns section is absent from return message
342
- - [ ] Each Agent fully completes its Skill before returning
328
+ - [ ] The review Agent fully completes gsd-review before returning (plan-phase runs inline — no Agent wrap)
343
329
  - [ ] Loop exits on: no HIGH concerns (converged) OR max cycles (escalation)
344
330
  - [ ] Stall detection reported when HIGH count not decreasing
345
331
  - [ ] STATE.md updated on convergence completion
@@ -299,11 +299,28 @@ VERIFY_OUTPUT="$(node "${GSD_HOME}/gsd-core/bin/verify-reapply-patches.cjs" "${V
299
299
  VERIFY_STATUS=$?
300
300
  ```
301
301
 
302
- **Step 5a: drift check** — even when `VERIFY_STATUS` is 0, the report may signal that one or more files were skipped due to pristine-snapshot drift (Bug #3657). Parse the JSON and check:
302
+ **Step 5a: drift check** — even when `VERIFY_STATUS` is 0, the report may signal that one or more files were skipped due to pristine-snapshot drift (Bug #3657) or a missing baseline (Bug #934). Parse the JSON and check:
303
303
 
304
304
  ```bash
305
305
  DRIFTED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.drifted||0))")"
306
306
  DRIFTED_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.drifted_files||[]).forEach(f=>process.stdout.write(f+'\n'))")"
307
+ NO_BASELINE_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(String(d.no_baseline||0))")"
308
+ NO_BASELINE_FILES="$(echo "$VERIFY_OUTPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));(d.no_baseline_files||[]).forEach(f=>process.stdout.write(f+'\n'))")"
309
+ ```
310
+
311
+ **If `NO_BASELINE_COUNT` is greater than 0**, emit an advisory warning (non-blocking — the gate still exits 0 for these files). Do NOT halt:
312
+
313
+ ```text
314
+ ADVISORY: {NO_BASELINE_COUNT} file(s) could not be diff-verified because no pristine
315
+ baseline exists on disk despite a hash being recorded in backup-meta.json (Bug #934:
316
+ the installer discarded the only pristine candidate because it was from a newer release).
317
+ These files were skipped rather than false-failed; their user customisations may or
318
+ may not have survived the merge.
319
+
320
+ Unverified files:
321
+ {each path in NO_BASELINE_FILES, one per line, indented two spaces}
322
+
323
+ Recommended: manually inspect each file above and confirm your customisations survived.
307
324
  ```
308
325
 
309
326
  **If `DRIFTED_COUNT` is greater than 0**, STOP and report to the user, then set `DRIFT_DETECTED=true` and halt — do not proceed to 5b or cleanup:
@@ -84,7 +84,7 @@ Runtime Model Tiers:
84
84
  - `model_profile_overrides.<runtime>.haiku` (default: built-in for the runtime, or absent)
85
85
 
86
86
  Model Policy:
87
- - `model_policy.provider` (default: `null` — known values: anthropic, openai, google, qwen)
87
+ - `model_policy.provider` (default: `null` — known values: anthropic, anthropic-fable, openai, google, qwen)
88
88
  - `model_policy.budget` (default: `null` — known values: high, medium, low)
89
89
  - `model_policy.high` (default: `null` — model ID for the high-cost tier; used by generic provider path)
90
90
  - `model_policy.medium` (default: `null` — model ID for the medium-cost tier; used by generic provider path)
@@ -624,15 +624,16 @@ AskUserQuestion([
624
624
  multiSelect: false,
625
625
  options: [
626
626
  { label: "anthropic", description: "claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5 (Anthropic / Claude)" },
627
+ { label: "anthropic-fable", description: "claude-fable-5 / claude-sonnet-4-6 / claude-haiku-4-5 (Anthropic / Claude Fable opt-in)" },
627
628
  { label: "openai", description: "gpt-5.5 / gpt-5.3-codex / gpt-5.4-mini (OpenAI / Codex)" },
628
- { label: "google", description: "gemini-3-pro / gemini-3-flash / gemini-2.5-flash-lite (Google Gemini)" },
629
- { label: "qwen", description: "qwen3-max-2026-01-23 / qwen3-coder-plus / qwen3-coder-next (Qwen)" }
629
+ { label: "Other known provider", description: "Type google or qwen; both still use the canonical tier mapping." }
630
630
  ]
631
631
  }
632
632
  ])
633
633
  ```
634
634
 
635
- After the user picks a provider, ask:
635
+ If the user selects "Other known provider", ask them to type `google` or `qwen`.
636
+ Use the typed value as the provider. After the user picks or types a provider, ask:
636
637
 
637
638
  ```text
638
639
  AskUserQuestion([
@@ -653,9 +654,12 @@ Canonical tier mappings by provider and budget:
653
654
 
654
655
  | Provider | Budget | high | medium | low |
655
656
  |-----------|--------|----------------------------|----------------------------|----------------------------|
656
- | anthropic | high | claude-opus-4-8 | claude-opus-4-8 | claude-opus-4-8 |
657
+ | anthropic | high | claude-opus-4-8 | claude-opus-4-8 | claude-sonnet-4-6 |
657
658
  | anthropic | medium | claude-opus-4-8 | claude-sonnet-4-6 | claude-haiku-4-5 |
658
659
  | anthropic | low | claude-haiku-4-5 | claude-haiku-4-5 | claude-haiku-4-5 |
660
+ | anthropic-fable | high | claude-fable-5 | claude-fable-5 | claude-sonnet-4-6 |
661
+ | anthropic-fable | medium | claude-opus-4-8 | claude-sonnet-4-6 | claude-haiku-4-5 |
662
+ | anthropic-fable | low | claude-haiku-4-5 | claude-haiku-4-5 | claude-haiku-4-5 |
659
663
  | openai | high | gpt-5.5 | gpt-5.5 | gpt-5.5 |
660
664
  | openai | medium | gpt-5.5 | gpt-5.3-codex | gpt-5.4-mini |
661
665
  | openai | low | gpt-5.4-mini | gpt-5.4-mini | gpt-5.4-mini |
@@ -713,7 +717,7 @@ Proceed to Step E.
713
717
 
714
718
  ```bash
715
719
  # Known-provider path — write all four keys atomically:
716
- gsd_run query config-set model_policy.provider "<provider>" # e.g., anthropic / openai / google / qwen
720
+ gsd_run query config-set model_policy.provider "<provider>" # e.g., anthropic / anthropic-fable / openai / google / qwen
717
721
  gsd_run query config-set model_policy.budget "<budget>" # high / medium / low
718
722
  gsd_run query config-set model_policy.high "<high-id>"
719
723
  gsd_run query config-set model_policy.medium "<medium-id>"
@@ -775,7 +779,7 @@ Display:
775
779
  | fast_mode.routing_tier_defaults.standard | {true/false} |
776
780
  | fast_mode.routing_tier_defaults.heavy | {true/false} |
777
781
  | fast_mode.agent_overrides.<agent-id> | {true/false} |
778
- | model_policy.provider | {anthropic/openai/google/qwen/custom/null} |
782
+ | model_policy.provider | {anthropic/anthropic-fable/openai/google/qwen/custom/null} |
779
783
  | model_policy.budget | {high/medium/low/null} |
780
784
  | model_policy.high | {model-id/null} |
781
785
  | model_policy.medium | {model-id/null} |
@@ -195,24 +195,29 @@ CHANGELOG_TMP="/tmp/gsd-changelog-$$.md"
195
195
  curl -fsSL "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \
196
196
  || wget -qO "$CHANGELOG_TMP" "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md" 2>/dev/null
197
197
 
198
- EXTRACT_JSON=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \
199
- --from "$INSTALLED_VERSION" \
200
- --to "$LATEST_VERSION" \
201
- --changelog "$CHANGELOG_TMP" \
202
- --json 2>/dev/null)
203
- EXTRACT_EXIT=$?
204
-
205
- if [ "$EXTRACT_EXIT" -eq 2 ]; then
206
- # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse)
207
- CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}."
208
- elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then
209
- CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)"
198
+ GSD_CHANGESET_CLI="$GSD_DIR/scripts/changeset/cli.cjs"
199
+ if [ ! -f "$GSD_CHANGESET_CLI" ]; then
200
+ CHANGELOG_PREVIEW="(Changelog CLI not found at $GSD_CHANGESET_CLI — reinstall GSD to restore preview. Update will still proceed.)"
210
201
  else
211
- # Re-run without --json to get the human-readable markdown for display
212
- CHANGELOG_PREVIEW=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \
202
+ EXTRACT_JSON=$(node "$GSD_CHANGESET_CLI" extract \
213
203
  --from "$INSTALLED_VERSION" \
214
204
  --to "$LATEST_VERSION" \
215
- --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)")
205
+ --changelog "$CHANGELOG_TMP" \
206
+ --json 2>&1)
207
+ EXTRACT_EXIT=$?
208
+
209
+ if [ "$EXTRACT_EXIT" -eq 2 ]; then
210
+ # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse)
211
+ CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}."
212
+ elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then
213
+ CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)"
214
+ else
215
+ # Re-run without --json to get the human-readable markdown for display
216
+ CHANGELOG_PREVIEW=$(node "$GSD_CHANGESET_CLI" extract \
217
+ --from "$INSTALLED_VERSION" \
218
+ --to "$LATEST_VERSION" \
219
+ --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)")
220
+ fi
216
221
  fi
217
222
  # Clean up temp changelog now that both extract runs are done
218
223
  rm -f "$CHANGELOG_TMP"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "bin": {
6
6
  "gsd-core": "bin/install.js",
@@ -324,7 +324,7 @@ function resolveChangelogPath(opts) {
324
324
  * 1 — I/O error or missing required flags.
325
325
  *
326
326
  * Fix for #3496: provides a deterministic range-aware helper so the
327
- * `/gsd:update` show_changes_and_confirm step no longer relies on
327
+ * `/gsd-update` show_changes_and_confirm step no longer relies on
328
328
  * vague/manual extraction that can silently skip intermediate versions.
329
329
  */
330
330
  function cmdExtract(opts) {