@opengsd/gsd-core 1.4.1 → 1.4.3
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/bin/install.js +122 -0
- package/commands/gsd/autonomous.md +0 -1
- package/commands/gsd/execute-phase.md +0 -1
- package/commands/gsd/plan-phase.md +0 -1
- package/commands/gsd/plan-review-convergence.md +3 -3
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +123 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +57 -7
- package/gsd-core/workflows/plan-phase.md +7 -4
- package/gsd-core/workflows/plan-review-convergence.md +17 -31
- package/gsd-core/workflows/reapply-patches.md +18 -1
- package/gsd-core/workflows/update.md +20 -15
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/gsd-context-monitor.js +2 -1
- package/package.json +1 -1
- package/scripts/changeset/cli.cjs +1 -1
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
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
name: gsd:plan-phase
|
|
3
3
|
description: Create detailed phase plan (PLAN.md) with verification loop
|
|
4
4
|
argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--reviews] [--text] [--tdd] [--mvp]"
|
|
5
|
-
context: fork
|
|
6
5
|
effort: xhigh
|
|
7
6
|
allowed-tools:
|
|
8
7
|
- Read
|
|
@@ -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:**
|
|
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.).
|
|
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
|
|
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>
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
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;
|
|
@@ -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
|
|
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`);
|
|
@@ -39,10 +39,13 @@ discuss-phase early-exit path. It does NOT authorize inline role performance for
|
|
|
39
39
|
plan-phase agents.
|
|
40
40
|
|
|
41
41
|
**Other runtimes:**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
Do not pre-judge Agent availability by introspection. Always attempt the actual
|
|
43
|
+
Agent() call for gsd-phase-researcher, gsd-planner, and gsd-plan-checker. Only
|
|
44
|
+
a real tool-unavailable error returned by Agent() is a reliable absence signal —
|
|
45
|
+
never stop based on a self-assessed "I think Agent is unavailable." If the call
|
|
46
|
+
fails with a tool-unavailable error, log the gap and stop — do NOT collapse
|
|
47
|
+
researcher/planner/checker roles inline. Independent agent contexts are required
|
|
48
|
+
for the plan-checker gate to be meaningful.
|
|
46
49
|
</runtime_compatibility>
|
|
47
50
|
|
|
48
51
|
<process>
|
|
@@ -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
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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: `◆
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
335
|
-
- [ ] Review via Agent → Skill("gsd-review") — isolated
|
|
336
|
-
- [ ] Replan via
|
|
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
|
-
- [ ]
|
|
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:
|
|
@@ -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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
@@ -182,7 +182,8 @@ process.stdin.on('end', () => {
|
|
|
182
182
|
|
|
183
183
|
const output = {
|
|
184
184
|
hookSpecificOutput: {
|
|
185
|
-
hookEventName:
|
|
185
|
+
hookEventName: (data.hook_event_name && data.hook_event_name.trim())
|
|
186
|
+
|| (process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse"),
|
|
186
187
|
additionalContext: message
|
|
187
188
|
}
|
|
188
189
|
};
|
|
@@ -182,7 +182,8 @@ process.stdin.on('end', () => {
|
|
|
182
182
|
|
|
183
183
|
const output = {
|
|
184
184
|
hookSpecificOutput: {
|
|
185
|
-
hookEventName:
|
|
185
|
+
hookEventName: (data.hook_event_name && data.hook_event_name.trim())
|
|
186
|
+
|| (process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse"),
|
|
186
187
|
additionalContext: message
|
|
187
188
|
}
|
|
188
189
|
};
|
package/package.json
CHANGED
|
@@ -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
|
|
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) {
|