Merge branch 'next' into fix/1477-surface-source-marker
This commit is contained in:
5
.changeset/1367-claude-local-flat-command-layout.md
Normal file
5
.changeset/1367-claude-local-flat-command-layout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1367
|
||||
---
|
||||
**Project-local Claude Code install now produces `/gsd-<cmd>` (hyphen) slash commands** — the installer was writing command files to `.claude/commands/gsd/<cmd>.md` (subdirectory with bare names), causing Claude Code to namespace them as `/gsd:<cmd>` (colon form). The fix writes flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code registers `/gsd-<cmd>` (hyphen form), matching hooks, statusline, and all cross-command references. Legacy `commands/gsd/` directories from prior installs are cleaned up on reinstall and uninstall, with `dev-preferences.md` preserved. (#1367)
|
||||
5
.changeset/1369-wave-stale-base-recheck.md
Normal file
5
.changeset/1369-wave-stale-base-recheck.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1369
|
||||
---
|
||||
**`execute-phase` now re-checks the worktree fork base at the start of every wave and resets the wave manifest between waves (#1369)** — two compounding issues caused wave N+1 worktrees to be created from the stale pre-wave-N commit. First, the `worktree.base-check` auto-degrade only ran once at initialize time; after Wave N merged and tracking commits advanced orchestrator HEAD past `origin/HEAD`, Wave N+1 worktrees were still forked from `origin/HEAD` (Claude Code's "fresh" base), causing both agents to immediately halt with `FATAL: worktree base mismatch` from the `worktree_branch_check` guard. Second, `WAVE_WORKTREE_MANIFEST` was never unset between waves, so wave N+1 would reuse the consumed wave-N manifest file, causing the step 5.5 manifest guard (#3384) to block on subsequent waves. Two safeguards fix this: step 0.5 in the `execute_waves` "For each wave" loop re-runs `worktree.base-check` before every wave's dispatch (when divergence is detected, `USE_WORKTREES` is overridden to `false` for that wave); step 7c between waves unsets `WAVE_WORKTREE_MANIFEST` so wave N+1 creates a fresh per-wave manifest, and re-asserts `worktree.baseRef:"head"` (idempotent) so the Claude Code harness re-reads the live HEAD on the next dispatch. The permanent fix remains setting `worktree.baseRef:"head"` in `.claude/settings.local.json` (see #683).
|
||||
10
.changeset/297bb145.md
Normal file
10
.changeset/297bb145.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1483
|
||||
---
|
||||
|
||||
fix(#1472): validate health is now workstream-aware — PROJECT.md and config.json are resolved from .planning/ root, while ROADMAP.md, STATE.md, and phases/ follow the workstream-scoped path; previously both sets were routed through planningDir() causing false E002/E003/E004/W003 when GSD_WORKSTREAM is set.
|
||||
|
||||
fix(#1454): validate health W017 no longer suggests removing the active session's worktree — stale-worktree findings are now skipped when the worktree path matches or is an ancestor of process.cwd().
|
||||
|
||||
<!-- docs-exempt: internal verify.cts fix — no public user-facing API or CLI change; behavior correction for workstream-scoped health validation -->
|
||||
5
.changeset/curious-eagles-purr.md
Normal file
5
.changeset/curious-eagles-purr.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1500
|
||||
---
|
||||
**`workflow.mvp_mode` now accepted by `config-set`; three undocumented workflow keys added to references** — `workflow.mvp_mode`, `workflow.code_review_command`, and `workflow.plan_chunked` were consumed by planning-pipeline code but could not be set via `config-set` (they were missing from `VALID_CONFIG_KEYS`) or discovered via reference docs. All three are now in the schema and documented in `references/planning-config.md`. (#1500)
|
||||
5
.changeset/feat-1463-capability-outdated.md
Normal file
5
.changeset/feat-1463-capability-outdated.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1488
|
||||
---
|
||||
**Added `gsd capability outdated`** — a new subcommand that light-peeks each installed overlay capability's recorded source for the latest version that re-resolving that source would install and reports which have an update available (ADR-1244 D6 per-source matrix: git `ls-remote --tags`, npm `view … version`, local re-read; tarball → `manual`, registry → `unknown`). A capability is reported `outdated` only if re-resolving its recorded source would fetch a newer version: an npm range (`@^1`) resolves to the highest version **matching the range** (read from each `npm view` line's canonical version field, so a version-like substring in the package name never poisons the result), and a source pinned to an immutable ref (git `#sha:`/`#tag:`) or an exact npm version is reported `pinned` — never `outdated`, since `update` will not move it. A bare git ref (`#<ref>`) is classified at the remote with a bounded `git ls-remote`: a ref that resolves to a tag is `pinned`, while a **mutable branch** ref is never `pinned` (it degrades to `unknown`, since the installed commit is not recorded to compare against). Each capability is classified `outdated` / `current` / `pinned` / `manual` / `unknown`; subprocesses are bounded (git ≤30s, npm ≤60s) and a failing or unsupported peek degrades that row to `unknown` instead of crashing the command. `--json` emits the records array; the default prints a table. (#1463)
|
||||
9
.changeset/fix-1422-1447-projectroot-milestone-guards.md
Normal file
9
.changeset/fix-1422-1447-projectroot-milestone-guards.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1484
|
||||
---
|
||||
**`findProjectRoot` now respects explicit `sub_repos` config over implicit `.git`** — when a parent workspace's `.planning/config.json` lists a child directory in `sub_repos`, that declaration takes precedence over the child's own `.git/` directory. Previously, if the child had both `.planning/` and `.git/`, the `.git` heuristic fired first and resolved to the child rather than the parent workspace, making the `sub_repos` declaration ineffective. (#1422)
|
||||
|
||||
**`phases clear` now refuses to delete phase directories with uncommitted changes** — `cmdPhasesClear` runs `git status --porcelain` over the phases directory before executing any deletion. If uncommitted or staged-but-not-committed files are found it aborts with a clear error message, preventing silent data loss at `new-milestone` time. Pass `--force` to bypass the guard when archival is already complete. Non-git projects are unaffected. (#1447, data-loss fix)
|
||||
|
||||
<!-- docs-exempt: internal CLI guard in milestone.cts — no public docs surface change; --force flag is an operator escape hatch, not a user-visible API change -->
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1490
|
||||
---
|
||||
|
||||
**999.x backlog phases are now excluded from `total_phases`, and `total_phases` can correct downward** — `deriveProgressFromRoadmap` counted all progress-table rows whose phase cell started with a digit, so a `999.1 Backlog` row inflated `total_phases` by one per entry (#1445). The same overcounting occurred in `getMilestonePhaseFilter` (which feeds `isDirInMilestone` and `phaseDirs`) and in the `roadmapPhaseCount` loop in `buildStateFrontmatter`. All three sites now filter phase tokens matching `/^999\b/`, consistent with the existing exclusion in `init.cts`. Additionally, `shouldPreserveExistingProgress` included `total_phases` in its ratchet check, preventing the counter from decreasing once set too high — e.g. after a 999.x fix or a ROADMAP correction (#1446). `total_phases` is now always taken from the freshly derived value; only `completed_phases`, `total_plans`, and `completed_plans` retain ratchet behaviour.
|
||||
8
.changeset/fix-planner-verify-gate-gaps-5f59a168.md
Normal file
8
.changeset/fix-planner-verify-gate-gaps-5f59a168.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1482
|
||||
---
|
||||
|
||||
fix(#1478,#1479,#1480): prohibit ungrounded baselines, error-suppressing fallbacks, and stale-artifact authority in planner verify blocks
|
||||
|
||||
<!-- docs-exempt: instruction-text updates to agents/gsd-planner.md, agents/gsd-plan-checker.md, and references/planner-antipatterns.md are themselves the documentation — no public user-facing API or CLI change -->
|
||||
7
.changeset/patient-tunas-jump.md
Normal file
7
.changeset/patient-tunas-jump.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1499
|
||||
---
|
||||
**`npm version` no longer leaves `capability-registry.cjs` stale** — the `version` npm lifecycle script now regenerates and stages the capability registry after stamping new version strings into all capability manifests, preventing the 1.6.0-rc regression where `gen-capability-registry.cjs --check` failed. (#1498)
|
||||
|
||||
<!-- docs-exempt: internal release-tooling fix; no user-facing command or API change -->
|
||||
5
.changeset/sturdy-wasps-sprint.md
Normal file
5
.changeset/sturdy-wasps-sprint.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1453
|
||||
---
|
||||
clean up stale get-shit-done paths in Codex and Kimi skill mirrors on upgrade (#1453)
|
||||
5
.changeset/sturdy-wasps-swim.md
Normal file
5
.changeset/sturdy-wasps-swim.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1437
|
||||
---
|
||||
add phase.list-plans to gsd-tools — the command was referenced in agents/gsd-plan-checker.md but was missing from the router, causing 'Unknown phase subcommand' on every invocation
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
needs: validate-version
|
||||
if: inputs.action == 'create'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -118,6 +118,10 @@ jobs:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: npm ci --silent && npm run build:lib
|
||||
|
||||
- name: Check branch doesn't already exist
|
||||
env:
|
||||
@@ -358,6 +362,9 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: npm ci --silent && npm run build:lib
|
||||
|
||||
- name: Bump to pre-release version
|
||||
env:
|
||||
PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }}
|
||||
@@ -523,6 +530,9 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Install dependencies and build
|
||||
run: npm ci --silent && npm run build:lib
|
||||
|
||||
- name: Set final version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
@@ -847,7 +847,7 @@ gsd-core/
|
||||
pattern: workflows/<name>/modes/*.md +
|
||||
workflows/<name>/templates/*. Parent dispatches
|
||||
to mode files. See workflows/discuss-phase/ as
|
||||
the canonical example (#2551). New modes for
|
||||
the canonical example (the discuss-phase/modes split, #717). New modes for
|
||||
discuss-phase land in
|
||||
workflows/discuss-phase/modes/<mode>.md.
|
||||
Per-file sizes are pinned by a committed baseline
|
||||
|
||||
@@ -647,6 +647,40 @@ issue:
|
||||
fix_hint: "Add auth middleware pattern from PATTERNS.md ## Shared Patterns to plan"
|
||||
```
|
||||
|
||||
## Dimension: Verify Command Format Sanity (#1478, #1479)
|
||||
|
||||
**Question:** Do `<verify>` commands use patterns that can actually match the tool's output? Are numeric counts measured? Are errors suppressed into comparison-feeding defaults?
|
||||
|
||||
**Red flags — BLOCKER:**
|
||||
- `pnpm ls … | grep -E '^package'` — `^` anchor on tree-formatted package manager output (never matches tree-prefixed lines)
|
||||
- Any verify block with `VAR=$(cmd 2>/dev/null || echo "0"); [ "$VAR" = ... ]` — swallowed error feeds passing comparison
|
||||
- `|| true` or `|| :` as right-hand side of assignments that feed comparisons
|
||||
|
||||
**Red flags — WARNING:**
|
||||
- Hard-coded count assertion (`grep '52 test files'`, `grep '714 passed'`) with no measurement provenance in the plan
|
||||
|
||||
**Process:**
|
||||
1. For each `<automated>` block piping a package-manager list command into grep with a `^` anchor: BLOCKER.
|
||||
2. For each `<automated>` block containing `2>/dev/null || echo` where the result feeds a `[ "$VAR" = ... ]` comparison: BLOCKER.
|
||||
3. For each `<automated>` block asserting a specific numeric count not cited as measured in this plan: WARNING.
|
||||
|
||||
## Dimension: Numeric/Factual Claim Authority (#1480)
|
||||
|
||||
**Rule:** RESEARCH.md is produced at research time and may be stale. Numeric claims (test counts, file counts, version numbers) and factual state claims ("feature X is implemented") in RESEARCH.md may not reflect the current codebase. The plan may be more current. RESEARCH.md is authoritative for architectural decisions and constraints — not for measurements.
|
||||
|
||||
**Process when a plan's numeric/factual claim conflicts with RESEARCH.md:**
|
||||
|
||||
1. **Attempt live measurement first** with a targeted read-only command (e.g., `find . -name '*.test.*' | wc -l`). Run it. Use the result as ground truth:
|
||||
- Measurement confirms plan → WARNING: RESEARCH.md is stale; recommend updating it.
|
||||
- Measurement contradicts plan → BLOCKER: plan value is wrong; prescribe the measured value.
|
||||
|
||||
2. **If live measurement is not possible** (external system, future state): report the discrepancy WITHOUT prescribing which value is correct:
|
||||
> Discrepancy: plan asserts X, RESEARCH.md asserts Y. Cannot determine ground truth without live measurement. Verify manually and update the stale artifact.
|
||||
|
||||
**NEVER** prescribe a specific value by assuming RESEARCH.md is authoritative for a numeric/factual claim.
|
||||
|
||||
**Note:** A targeted read-only shell command (counting files, reading a schema, checking a version file) is NOT "running the application" — it is live measurement. Such commands are permitted under this dimension even when the anti-pattern block says "DO NOT run the application."
|
||||
|
||||
</verification_dimensions>
|
||||
|
||||
<verification_process>
|
||||
|
||||
@@ -200,6 +200,8 @@ Full rules + worked examples: @gsd-core/references/planner-antipatterns.md ("Com
|
||||
|
||||
<region_scoped_negative_gate>
|
||||
**Region-scoped negative gates (WARN, #968):** Region-scope a file-wide negative grep when a sibling task needs that construct elsewhere in the same file; `validate_plan` WARNS. See: @gsd-core/references/planner-antipatterns.md ("Region-Scoped Negative Gates").
|
||||
|
||||
**Verify-gate hygiene (#1478/#1479):** See @gsd-core/references/planner-antipatterns.md.
|
||||
</region_scoped_negative_gate>
|
||||
|
||||
**<done>:** Acceptance criteria - measurable state of completion.
|
||||
|
||||
142
bin/install.js
142
bin/install.js
@@ -7118,18 +7118,23 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
|
||||
* @param {'global'|'local'} [scope]
|
||||
*/
|
||||
function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
||||
// Claude global / Qwen: commands/gsd/ is a legacy location (global Claude
|
||||
// uses skills/ now; Qwen always uses skills/). Remove whole directory.
|
||||
// Claude local: commands/gsd/ is the primary current location — skip here,
|
||||
// let layout's _removeGsdEntries handle gsd-prefixed file removal.
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
|
||||
// Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
|
||||
// After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
|
||||
// block (1c) handles removal of flat files; this function handles the legacy
|
||||
// commands/gsd/ directory for all Claude scopes (global was already included,
|
||||
// local is now added since that layout is also legacy post-#1367).
|
||||
// #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
|
||||
// before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
|
||||
// is deferred and returned so the caller can apply it AFTER layout-driven
|
||||
// removal — this prevents the layout's gsd-* prefix removal from wiping the
|
||||
// freshly created skill dir (same pattern as _runLegacyInstallMigrations).
|
||||
let savedLegacyArtifacts = null;
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude-global.
|
||||
// Claude-local commands/gsd/ is the primary current location — skip here.
|
||||
// commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
|
||||
// Claude local is intentionally excluded: the inline uninstall block (1c) handles
|
||||
// commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
|
||||
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
|
||||
// (which would redirect to skills/) conflicts with the test contract for local installs.
|
||||
const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
|
||||
if (isLegacyCommandsGsd) {
|
||||
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||
@@ -8066,23 +8071,37 @@ function uninstall(isGlobal, runtime = 'claude') {
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// 1c. Claude local: remove commands/gsd/ (primary local install location).
|
||||
// The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to
|
||||
// flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside
|
||||
// the namespaced directory, so layout does not remove them. Handle inline.
|
||||
// Preserve dev-preferences.md across the wipe (#1423).
|
||||
// 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
|
||||
// #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
|
||||
if (!isGlobal && runtime === 'claude') {
|
||||
const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(gsdCommandsDir)) {
|
||||
const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md');
|
||||
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
|
||||
fs.rmSync(gsdCommandsDir, { recursive: true });
|
||||
const commandsDir = path.join(targetDir, 'commands');
|
||||
// Remove flat gsd-*.md files (current layout after #1367 fix)
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
let removed = 0;
|
||||
for (const f of fs.readdirSync(commandsDir)) {
|
||||
if (f.startsWith('gsd-') && f.endsWith('.md')) {
|
||||
fs.rmSync(path.join(commandsDir, f), { force: true });
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed ${removed} flat gsd-*.md commands from commands/`);
|
||||
}
|
||||
}
|
||||
// Remove legacy commands/gsd/ subdirectory if it still exists (pre-#1367 layout).
|
||||
// Preserve user-owned dev-preferences.md if present (#1423 parity).
|
||||
const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd');
|
||||
if (fs.existsSync(legacyGsdCommandsDir)) {
|
||||
const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md');
|
||||
const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null;
|
||||
fs.rmSync(legacyGsdCommandsDir, { recursive: true });
|
||||
removedCount++;
|
||||
console.log(` ${green}✓${reset} Removed commands/gsd/`);
|
||||
if (preservedDevPrefs) {
|
||||
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
|
||||
if (savedDevPrefs) {
|
||||
try {
|
||||
fs.mkdirSync(gsdCommandsDir, { recursive: true });
|
||||
fs.writeFileSync(devPrefsPath, preservedDevPrefs);
|
||||
fs.mkdirSync(legacyGsdCommandsDir, { recursive: true });
|
||||
fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs);
|
||||
console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`);
|
||||
} catch (err) {
|
||||
console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`);
|
||||
@@ -8849,7 +8868,11 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
||||
const isKimi = runtime === 'kimi';
|
||||
const isHermes = runtime === 'hermes';
|
||||
const gsdDir = path.join(configDir, 'gsd-core');
|
||||
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
|
||||
// commandsDir points to the old location for Gemini (which still uses commands/gsd/).
|
||||
// Claude local uses flatCommandsDir instead for manifest recording.
|
||||
const commandsDir = path.join(configDir, 'commands', 'gsd');
|
||||
const flatCommandsDir = path.join(configDir, 'commands');
|
||||
const opencodeCommandDir = path.join(configDir, 'command');
|
||||
// Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
|
||||
// All other runtimes that use the Codex-style skills layout use a flat skills/ root.
|
||||
@@ -8875,17 +8898,27 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
|
||||
if (USER_OWNED_ARTIFACTS.includes(rel)) continue;
|
||||
manifest.files['gsd-core/' + rel] = hash;
|
||||
}
|
||||
// Record commands/gsd/ for any runtime that emits it (Gemini globally,
|
||||
// Claude Code locally — see #2923). Manifest must reflect everything on
|
||||
// disk so saveLocalPatches() can detect user edits and so per-runtime
|
||||
// assertions about minimal-mode emit can read manifest.files instead of
|
||||
// re-walking the dir.
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
// Record commands surface for runtimes that emit it:
|
||||
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
|
||||
// Claude local (#1367 fix): flat gsd-<cmd>.md at commands/ level
|
||||
// Manifest must reflect everything on disk so saveLocalPatches() can detect
|
||||
// user edits and per-runtime minimal-mode assertions can read manifest.files.
|
||||
if (isGemini && fs.existsSync(commandsDir)) {
|
||||
const cmdHashes = generateManifest(commandsDir);
|
||||
for (const [rel, hash] of Object.entries(cmdHashes)) {
|
||||
manifest.files['commands/gsd/' + rel] = hash;
|
||||
}
|
||||
}
|
||||
// Claude local (#1367): flat gsd-*.md files at commands/ level.
|
||||
// Only claude local writes gsd-*.md here; global installs don't emit commands,
|
||||
// so this branch is a no-op for global (no matching files to find).
|
||||
if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) {
|
||||
for (const file of fs.readdirSync(flatCommandsDir)) {
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
|
||||
for (const file of fs.readdirSync(opencodeCommandDir)) {
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
@@ -9985,18 +10018,59 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Claude Code local: commands/gsd/ format — Claude Code reads local project
|
||||
// commands from .claude/commands/gsd/, not .claude/skills/
|
||||
// Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
|
||||
// commands from .claude/commands/ using the filename stem as the command
|
||||
// name, so gsd-<cmd>.md produces the /gsd-<cmd> hyphen form used everywhere
|
||||
// in the framework. The old commands/gsd/<cmd>.md subdirectory layout caused
|
||||
// Claude Code to namespace commands as /gsd:<cmd> (colon form). (#1367)
|
||||
const commandsDir = path.join(targetDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
const gsdSrc = _stageSkills(_commandsDir);
|
||||
const gsdDest = path.join(commandsDir, 'gsd');
|
||||
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
|
||||
if (verifyInstalled(gsdDest, 'commands/gsd')) {
|
||||
const count = fs.readdirSync(gsdDest).filter(f => f.endsWith('.md')).length;
|
||||
console.log(` ${green}✓${reset} Installed ${count} commands to commands/gsd/`);
|
||||
const cmdNames = readGsdCommandNames();
|
||||
|
||||
// Remove stale gsd-*.md files before writing new ones (clean install)
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
for (const f of fs.readdirSync(commandsDir)) {
|
||||
if (f.startsWith('gsd-') && f.endsWith('.md')) {
|
||||
fs.unlinkSync(path.join(commandsDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write each command as gsd-<stem>.md (flat, hyphen-prefixed)
|
||||
let cmdCount = 0;
|
||||
if (fs.existsSync(gsdSrc)) {
|
||||
for (const entry of fs.readdirSync(gsdSrc, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
let content = fs.readFileSync(path.join(gsdSrc, entry.name), 'utf8');
|
||||
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
|
||||
content = normalizeAgentBodyForRuntime(content, runtime, cmdNames);
|
||||
fs.writeFileSync(path.join(commandsDir, `gsd-${stem}.md`), content);
|
||||
cmdCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdCount > 0) {
|
||||
console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/ (gsd-<cmd>.md flat form)`);
|
||||
} else {
|
||||
failures.push('commands/gsd');
|
||||
failures.push('commands/gsd-*');
|
||||
}
|
||||
|
||||
// Legacy cleanup: remove old commands/gsd/ subdirectory from prior installs
|
||||
// that used the namespaced layout (wrote bare-name files under commands/gsd/).
|
||||
const legacyGsdDir = path.join(commandsDir, 'gsd');
|
||||
if (fs.existsSync(legacyGsdDir)) {
|
||||
// Preserve user-owned dev-preferences.md before wiping
|
||||
const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md');
|
||||
const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null;
|
||||
fs.rmSync(legacyGsdDir, { recursive: true });
|
||||
console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-<cmd>.md layout)`);
|
||||
if (preservedDevPrefs) {
|
||||
// Migrate dev-preferences to the new flat form
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs);
|
||||
console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any stale skills/ from a previous local install
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"local": [
|
||||
{
|
||||
"kind": "commands",
|
||||
"destSubpath": "commands/gsd",
|
||||
"destSubpath": "commands",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
|
||||
@@ -148,7 +148,7 @@ Orchestration logic that commands reference. Contains the step-by-step process i
|
||||
Workflow files are loaded verbatim into Claude's context every time the
|
||||
corresponding `/gsd-*` command is invoked. The workflow size budget enforced by
|
||||
`tests/workflow-size-budget.test.cjs` keeps each file bounded, mirroring the
|
||||
agent budget from #2361. The budget is measured in **bytes** (#717), not lines:
|
||||
the agent size-budget convention. The budget is measured in **bytes** (#717), not lines:
|
||||
line count over-penalizes prose and under-catches token-dense tables and code
|
||||
blocks, whereas bytes are deterministic and match the unit our vendors bound on
|
||||
— Codex truncates instruction docs past 32,768 bytes (`project_doc_max_bytes`).
|
||||
@@ -180,7 +180,7 @@ that is still eagerly `@`-imported shrinks the measured file without shrinking
|
||||
loaded context, which games the proxy rather than serving the goal.
|
||||
|
||||
`workflows/discuss-phase.md` is held to a stricter <30,000-byte ceiling per
|
||||
issue #2551 (originally <500 lines; re-based to bytes for #717). When a workflow grows
|
||||
the discuss-phase byte budget (#717; the discuss-phase/modes split keeps it ≈32000 bytes). When a workflow grows
|
||||
beyond its tier, extract per-mode bodies into
|
||||
`workflows/<workflow>/modes/<mode>.md`, templates into
|
||||
`workflows/<workflow>/templates/`, and shared knowledge into
|
||||
|
||||
@@ -1117,7 +1117,7 @@ Toggle which skills are surfaced — apply a profile, list, or disable a cluster
|
||||
|
||||
### `gsd capability`
|
||||
|
||||
Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI form `gsd capability <subcommand>` (slash form `gsd:capability` on slash-command runtimes). See the [`gsd capability` command reference](reference/gsd-capability-command.md) for the full contract, source-spec forms, and install layout.
|
||||
Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI form `gsd capability <subcommand>`. See the [`gsd capability` command reference](reference/gsd-capability-command.md) for the full contract, source-spec forms, and install layout.
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
@@ -1125,6 +1125,7 @@ Manage GSD capabilities — first-party (shipped) and third-party overlays. CLI
|
||||
| `update [<id> \| --all] [--scope …] [--yes]` | Re-resolve a capability's recorded source and upgrade it (atomic stage-then-swap) |
|
||||
| `remove <id> [--purge-data] [--scope …]` | Remove an installed overlay capability's files + marker-isolated shared edits (first-party cannot be removed here) |
|
||||
| `list [--json]` | List first-party + installed overlay capabilities as a JSON array |
|
||||
| `outdated [--json] [--scope …]` | Light-peek each installed overlay's recorded source and report which have a newer version available (per-source matrix; npm ranges resolve the highest matching version; `pinned` for immutable/explicit git refs or exact npm versions; `manual`/`unknown` for sources that can't be auto-checked) |
|
||||
| `disable <id>` / `enable <id>` | Toggle a capability's activation state (same as `capability set <id> --off`/`--on`) |
|
||||
| `state` / `set <id> …` | Inspect resolved capability state / set activation + per-hook gates |
|
||||
|
||||
@@ -1133,8 +1134,9 @@ gsd capability list --json # All capabilities as JSON
|
||||
gsd capability install ./my-cap --scope project # Install a local capability into the project
|
||||
gsd capability install npm:@org/gsd-cap-x@^1 --yes # Install from npm, granting executable-surface consent
|
||||
gsd capability update my-cap # Upgrade from its recorded source
|
||||
gsd capability disable my-cap # Turn it off without removing it
|
||||
gsd capability remove my-cap # Remove the overlay capability
|
||||
gsd capability outdated --json # Which installed overlays have a newer version?
|
||||
gsd capability disable ui # Turn a FIRST-PARTY capability off (disable/enable/set are first-party only)
|
||||
gsd capability remove my-cap --scope project # Turn the installed overlay off — remove it from the scope it was installed in
|
||||
```
|
||||
|
||||
**Programmatic access:** `node gsd-tools.cjs capability <subcommand>` — see [CLI Tools Reference](CLI-TOOLS.md).
|
||||
@@ -1670,9 +1672,11 @@ The check is also run as part of `npm test` via `tests/enh-2789-description-budg
|
||||
|
||||
## Capability commands (third-party)
|
||||
|
||||
A capability can ship its own command family by declaring `commands: [{ family, module, router }]` in its `capability.json` (ADR-1244 D7). Once the capability is **installed and consented** (a committed entry exists in the per-runtime `.gsd-capabilities.json` ledger), running `gsd-tools <family> …` (equivalently the `gsd <family>` wrapper) dispatches to the capability's router. The first-party families `graphify`, `intel`, and `audit-uat`/`audit-open` use exactly this registry-driven seam.
|
||||
A capability can ship its own command family by declaring `commands: [{ family, module, router }]` in its `capability.json` (ADR-1244 D7). Once the capability is **active**, running `gsd-tools <family> …` (equivalently the `gsd <family>` wrapper) dispatches to the capability's router. The first-party families `graphify`, `intel`, and `audit-uat`/`audit-open` use exactly this registry-driven seam.
|
||||
|
||||
Dispatch is gated for safety: the router module is loaded **only from the capability's own install root** (a bare `.cjs` basename, traversal- and symlink-confined), and a capability that is merely present on disk **without** a committed ledger entry is **not** command-dispatchable (its declarative skills/agents/config still load). A project-scoped capability's commands are only as trustworthy as the repository they ship in — see [The capability trust model](explanation/capability-trust-model.md).
|
||||
For a **project-scoped** third-party capability, "active" is decided by the **user-owned consent store** (`${GSD_HOME:-~}/.gsd/consent.json`), not by the in-repo ledger. Since #1459, the authoritative project-scope activation gate is a consent record on **this machine**, bound to the project root and the exact bundle content; a forged or cloned in-repo `.gsd-capabilities.json` ledger that *looks* committed activates nothing on its own — see [The capability trust model](explanation/capability-trust-model.md#the-project-scope-trust-boundary). A **global** capability (under your own home) is trusted without a per-project record.
|
||||
|
||||
Command dispatch is then gated **twice**. Beyond that primary activation gate, the router module is loaded **only from the capability's own install root** (a bare `.cjs` basename, traversal- and symlink-confined), and dispatch additionally requires a **committed** (non-`_pending`) entry in the per-runtime `.gsd-capabilities.json` ledger — a *secondary* signal that the install actually completed. A capability that is merely present on disk without a committed ledger entry is not command-dispatchable; a project-scoped one is not even *active* without the consent record. (A project ledger lives in the repo tree and is only as trustworthy as the repository — which is precisely why the consent store, not the ledger, is the project-scope activation gate.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.plan_chunked` | boolean | `false` | Enable chunked planning mode. When `true` (or when `--chunked` flag is passed to `/gsd-plan-phase`), the orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3-5 min each). Each plan is committed individually for crash resilience. If a Task hangs and the terminal is force-killed, rerunning with `--chunked` resumes from the last completed plan. Particularly useful on Windows where long-lived Tasks may hang on stdio. Added in v1.38 |
|
||||
| `workflow.code_review_command` | string | (none) | Shell command for external code review integration in `/gsd-ship`. Receives changed file paths via stdin. Non-zero exit blocks the ship workflow. Added in v1.36 |
|
||||
| `workflow.tdd_mode` | boolean | `false` | Enable TDD pipeline as a first-class execution mode. When `true`, the planner aggressively applies `type: tdd` to eligible tasks (business logic, APIs, validations, algorithms) and the executor enforces RED/GREEN/REFACTOR gate sequence. An end-of-phase collaborative review checkpoint verifies gate compliance. Added in v1.36 |
|
||||
| `workflow.mvp_mode` | boolean | `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability instead of a horizontal layer. |
|
||||
| `workflow.human_verify_mode` | string | `'end-of-phase'` | Controls human verification checkpoints. `'end-of-phase'` (default since #3309) suppresses `checkpoint:human-verify` tasks and embeds checks into `<verify><human-check>` blocks for end-of-phase review. `'mid-flight'` restores blocking checkpoint tasks. `checkpoint:decision` and `checkpoint:human-action` are unaffected. See [Checkpoints Reference](../gsd-core/references/checkpoints.md#checkpoint_types). |
|
||||
| `workflow.cross_ai_execution` | boolean | `false` | Delegate phase execution to an external AI CLI instead of spawning local executor agents. Useful for leveraging a different model's strengths for specific phases. Added in v1.36 |
|
||||
| `workflow.cross_ai_command` | string | (none) | Shell command template for cross-AI execution. Receives the phase prompt via stdin. Must produce SUMMARY.md-compatible output. Required when `cross_ai_execution` is `true`. Added in v1.36 |
|
||||
|
||||
@@ -3189,15 +3189,16 @@ The load-bearing wire is the `plan-phase` lift into `must_haves.prohibitions`, s
|
||||
|
||||
### 147. Capability Management Command
|
||||
|
||||
**Command:** `gsd capability install | update | remove | list | disable | enable`
|
||||
**Command:** `gsd capability install | update | remove | list | outdated | disable | enable`
|
||||
|
||||
**Purpose:** The user-facing CLI for the ADR-1244 capability ecosystem — install, upgrade, remove, list, and toggle GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source. Wires the Phase-3/4 lifecycle library (source resolver, install ledger, trust gate) to a command users actually run.
|
||||
**Purpose:** The user-facing CLI for the ADR-1244 capability ecosystem — install, upgrade, remove, list, check for updates, and toggle GSD capabilities (first-party and third-party overlays) from a registry / git / npm / tarball / local source. Wires the Phase-3/4 lifecycle library (source resolver, install ledger, trust gate) to a command users actually run.
|
||||
|
||||
**Behavior:**
|
||||
- `install <spec> [--integrity sha512-…] [--scope global|project] [--yes] [--shared-file <rel>]…` — resolve (copy-only) → verify integrity / SHA pin → `engines.gsd` gate → disclose executable surfaces → consent (`--yes` grants; without it an executable install aborts after printing the disclosure and writes nothing) → validate → extract → record the ledger.
|
||||
- `update [<id> | --all] [--scope] [--yes]` — re-resolve the capability's recorded source and upgrade via atomic stage-then-swap; re-consent when the executable set changed; `--all` reports a per-capability outcome and exits non-zero on any partial failure.
|
||||
- `remove <id> [--purge-data] [--scope]` — strip the ledger-recorded files + marker-isolated shared edits; first-party capabilities are rejected (use the product uninstaller).
|
||||
- `list [--json]` — first-party + installed overlay capabilities (both scopes) as a JSON array.
|
||||
- `outdated [--json] [--scope]` — light remote peek of each installed overlay's recorded source (ADR-1244 D6 per-source matrix: git `ls-remote --tags`, npm `view … version` resolving the highest version matching the recorded range, local re-read; tarball → `manual`, registry → `unknown`) reporting `outdated` / `current` / `pinned` / `manual` / `unknown` per capability. A source pinned to an immutable ref (git `#sha:` or `#tag:`, or an exact npm version) is reported `pinned`. A bare git `#<ref>` is classified at the remote: if it resolves exclusively under `refs/tags/` it is an immutable tag → `pinned`; if it resolves to a mutable branch (or is ambiguous) it is `unknown`. Bounded subprocesses (git ≤30s, npm ≤60s) and a failing peek degrades that row to `unknown` without crashing the command. `--json` for machine output, default for a table.
|
||||
- `disable | enable <id>` — toggle activation state (equivalent to `gsd capability set <id> --off` / `--on`).
|
||||
|
||||
**Trust boundary:** install never executes capability code (copy-only staging); executable surfaces require explicit consent; sources are gated by the **project-scoped** `capabilities.strict_known_registries` policy (fail-closed on a malformed/unparseable value); every shared-config write/delete is realpath-confined to the scope root, and a name collision with a user's `mcpServers` entry is never clobbered.
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"domain-probes.md",
|
||||
"edge-probe.md",
|
||||
"execute-mvp-tdd.md",
|
||||
"execute-phase-between-wave-reset.md",
|
||||
"execute-phase-wave-guard.md",
|
||||
"executor-examples.md",
|
||||
"gate-prompts.md",
|
||||
"gates.md",
|
||||
|
||||
@@ -307,7 +307,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum
|
||||
| `prohibition-probe.md` | Spec-phase prohibition-completeness probe — the two-stage adversarial-recall → precision protocol that surfaces the unwritten *must-NOT* constraints (values/safety/ethics), with status×verification (`test`/`judgment`) tiering and canon-referral breadcrumbs (Step 5.6); second adapter of the `probe-core` resolution model. |
|
||||
| `gate-prompts.md` | Gate/checkpoint prompt templates. |
|
||||
| `loop-hook-dispatch.md` | Generic dispatch contract for consuming `gsd_run loop render-hooks <point> --raw` output in any host-loop workflow — envelope shape, per-kind dispatch rules (contribution/step/gate), and liveness banner. |
|
||||
| `scout-codebase.md` | Phase-type→codebase-map selection table for discuss-phase scout step (extracted via #2551). |
|
||||
| `scout-codebase.md` | Phase-type→codebase-map selection table for discuss-phase scout step (extracted via the discuss-phase/modes progressive-disclosure split, #717). |
|
||||
| `revision-loop.md` | Plan revision iteration patterns. |
|
||||
| `universal-anti-patterns.md` | Universal anti-patterns to detect and avoid. |
|
||||
| `worktree-branch-check.md` | Canonical spawn-time worktree HEAD/base guard (worktree_branch_check): verify-only and fail-closed — per-agent-branch assertion, protected-ref refusal (#2924), and an exact-base assertion that halts with `exit 42` on mismatch so the orchestrator (worktree lifecycle owner) performs recovery (#48). Embedded into worktree sub-agent prompts at dispatch. |
|
||||
|
||||
@@ -10,6 +10,8 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
|
||||
- [Your first project](tutorials/your-first-project.md) — install to first shipped phase, one guaranteed path
|
||||
- [Onboarding an existing codebase](tutorials/onboarding-an-existing-codebase.md) — bring GSD Core to a brownfield repo
|
||||
- [Build your first capability](tutorials/build-your-first-capability.md) — author a tiny declarative capability and watch it act in the loop
|
||||
- [Install your first capability](tutorials/install-your-first-capability.md) — install a third-party capability end-to-end: consent, verify, check for updates, remove
|
||||
|
||||
---
|
||||
|
||||
@@ -69,6 +71,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Multi-agent orchestration](explanation/multi-agent-orchestration.md) — how subagents are spawned, scoped, and coordinated
|
||||
- [Security model](explanation/security-model.md) — trust boundaries, permissions, and safe automation
|
||||
- [The capability trust model](explanation/capability-trust-model.md) — why third-party capabilities are gated by consent + integrity + reversibility, not a sandbox
|
||||
- [How overlay capabilities compose](explanation/capability-overlay-model.md) — why first-party always wins and how the loader resolves precedence, conflicts, and fail-closed gates
|
||||
- [Architecture](ARCHITECTURE.md) — system architecture, agent model, and data flow
|
||||
- [Discuss modes](workflow-discuss-mode.md) — assumptions mode vs interview mode for `/gsd-discuss-phase`
|
||||
- [Context monitoring](context-monitor.md) — context window monitoring hook architecture
|
||||
|
||||
@@ -82,7 +82,7 @@ from day-to-day to last-resort:
|
||||
| **New-file cap** | A workflow not yet in the baseline must stay under `32768` bytes (the Codex `project_doc_max_bytes` anchor) unless explicitly tiered into `XL_WORKFLOWS`/`LARGE_WORKFLOWS` in the same PR. Keeps net-new orchestrators from being born oversized. | `NEW_FILE_CAP` |
|
||||
|
||||
`discuss-phase.md` additionally has a thin-dispatcher target of `< 32000` bytes
|
||||
(issue [#2551](https://github.com/open-gsd/gsd-core/issues/2551)).
|
||||
(the discuss-phase progressive-disclosure split, #717).
|
||||
|
||||
**Agents** (`tests/agent-size-budget.test.cjs`) use the same per-agent baseline
|
||||
(`tests/agent-size-baseline.json`) + loose tier hard caps — `XL ≤ 57344` /
|
||||
|
||||
285
docs/explanation/capability-overlay-model.md
Normal file
285
docs/explanation/capability-overlay-model.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# How overlay capabilities compose
|
||||
|
||||
> **Explanation** — This document describes *why* GSD composes first-party and
|
||||
> third-party capabilities the way it does, and *what the precedence and conflict
|
||||
> rules are*. It is not a step-by-step guide; for the consumer lifecycle see
|
||||
> [Install your first capability](../tutorials/install-your-first-capability.md),
|
||||
> and for the field-level rules see the
|
||||
> [capability manifest reference](../reference/capability-manifest.md). For the
|
||||
> security side of the same boundary, see
|
||||
> [the capability trust model](capability-trust-model.md). For the decision
|
||||
> record, see
|
||||
> [ADR-1244 D2](../adr/1244-capability-ecosystem.md#d2--runtime-capability-registry-overlay).
|
||||
|
||||
---
|
||||
|
||||
## The central idea: the registry is a module, not a data file
|
||||
|
||||
GSD's capabilities — first-party and third-party alike — are described by a single
|
||||
**capability registry**: a composed object that every consumer (the loop resolver,
|
||||
the config loader, the surface command, `gsd capability list`) reads to learn which
|
||||
skills, agents, config keys, and loop hooks exist.
|
||||
|
||||
The first-party registry is *frozen and generated*: it is built at release time from
|
||||
the shipped `capabilities/*/capability.json` manifests into a committed
|
||||
`capability-registry.cjs`, and it never changes at runtime. Third-party capabilities
|
||||
cannot be baked into that file — they are installed on the user's machine, after the
|
||||
release. So the registry is not consumed as a static data file. It is consumed through
|
||||
a function:
|
||||
|
||||
```text
|
||||
loadRegistry({ includeInstalled: true }) → composed registry
|
||||
```
|
||||
|
||||
`loadRegistry` reads the frozen first-party registry and, when asked, composes a
|
||||
**validated installed overlay** on top of it: the third-party capability manifests
|
||||
found at runtime under the per-scope install roots. The result is one registry that
|
||||
covers first-party and third-party capabilities identically — every derived view
|
||||
(`bySkill`, `byAgent`, `byLoopPoint`, `configKeys`, the cluster map) spans both. The
|
||||
whole point of the overlay model is that an installed capability is *not* a
|
||||
second-class citizen: once it composes cleanly, it participates in the loop exactly
|
||||
as a shipped one does.
|
||||
|
||||
The interesting question is everything that can go wrong while composing two sources
|
||||
that were authored independently — and what GSD does about each case. That is the rest
|
||||
of this document.
|
||||
|
||||
---
|
||||
|
||||
## The activation chain
|
||||
|
||||
Before a third-party capability contributes anything to your loop, it passes through
|
||||
four distinct stages. They are worth naming because they fail in different ways and at
|
||||
different times — and the order matters: **the consent gate runs during composition,
|
||||
before surface and config**, not after them.
|
||||
|
||||
1. **Install** writes the capability into a scope root and records it in the ledger.
|
||||
This is the lifecycle's job; it never runs capability code (see the trust model).
|
||||
The capability now exists *on disk*.
|
||||
2. **Load / compose (with the project-scope consent gate)** is what `loadRegistry`
|
||||
does. As it composes each overlay it applies the composition gates — id/skill/agent/
|
||||
config/family collisions, the `engines.gsd` re-check, and, for a *project-scoped*
|
||||
overlay, **the project-scope consent gate**. That gate runs *inside* `loadRegistry`,
|
||||
before any of the overlay's fragments are even materialised: a project overlay is
|
||||
inert (discovered-but-inactive) until a matching record exists in your user-owned
|
||||
consent store. This is the security gate described in
|
||||
[the trust model](capability-trust-model.md#the-project-scope-trust-boundary). A
|
||||
capability that fails any composition gate — consent included — never enters the
|
||||
registry the rest of GSD reads, so it cannot reach the later stages at all.
|
||||
3. **Surface** decides which of the *composed* registry's skills are projected into the
|
||||
host runtime. This is the install-profile and `/gsd:surface` layer — a capability's
|
||||
skills can be on the surface or held back without uninstalling it. It only ever sees
|
||||
capabilities that already cleared composition.
|
||||
4. **Config activation** decides, per loop hook, whether it fires. A hook's `when`
|
||||
key (a dotted config key) gates it: a `step` or `gate` whose key is falsy does not
|
||||
run. This is the `gsd capability set <id> --gate <key>=<bool>` and `/gsd:settings`
|
||||
layer — again, only for capabilities that survived composition.
|
||||
|
||||
This document is about what `loadRegistry` does at the moment of composition — stage 2,
|
||||
which sits between install and the later surface/config stages and contains the consent
|
||||
gate. A capability that is installed but skipped at composition (including for missing
|
||||
consent) never reaches the surface or config stages, because it is not in the registry
|
||||
the rest of GSD reads.
|
||||
|
||||
---
|
||||
|
||||
## Where overlays come from, and the order they are considered
|
||||
|
||||
`loadRegistry` scans two install roots, in this order:
|
||||
|
||||
- **Global** — `$GSD_HOME/.gsd/capabilities/<id>/` (where `GSD_HOME` defaults to your
|
||||
home directory). This is under your own control and is trusted without a per-project
|
||||
record.
|
||||
- **Project** — `<projectRoot>/.gsd/capabilities/<id>/`. This lives inside a repository
|
||||
and is therefore only as trustworthy as the repository; it is gated by the consent
|
||||
store.
|
||||
|
||||
The roots are deduplicated by their *canonical* (symlink-resolved) physical path, so a
|
||||
single directory is never scanned twice — and, crucially, so a symlinked `GSD_HOME`
|
||||
that physically *is* the project root cannot smuggle an in-repo bundle into the trusted
|
||||
global slot. When the global and project roots resolve to the same physical directory
|
||||
(or distinctness cannot be proven), the surviving scope escalates to the more
|
||||
restrictive `project` — consent-required. This is a deliberately conservative choice:
|
||||
when GSD cannot prove a global root is distinct from your project tree, it treats it as
|
||||
project-scoped rather than risk granting trusted-global activation to repo-plantable
|
||||
content.
|
||||
|
||||
Within this ordering, the composition rules below decide which overlays survive.
|
||||
|
||||
---
|
||||
|
||||
## First-party always wins
|
||||
|
||||
The single load-bearing precedence rule is: **first-party always wins.** When a
|
||||
third-party overlay collides with a first-party capability, the overlay is rejected —
|
||||
never the other way round.
|
||||
|
||||
Collision is defined broadly, because impersonation can happen along several axes. An
|
||||
overlay is rejected if it collides on any of:
|
||||
|
||||
- **`id`** — the capability identifier. Two capabilities cannot share an id; a
|
||||
first-party id always keeps it.
|
||||
- **A skill or agent stem** — exactly one capability may own each skill/agent stem
|
||||
across the entire merged registry. An overlay that claims a stem already owned
|
||||
(by first-party *or* by an already-accepted overlay) is rejected.
|
||||
- **A federated config key** — a key declared in the overlay's `config` slice that
|
||||
already exists in the central config schema or in another capability's slice.
|
||||
- **A command family** — the `family` of a declared command module, if another
|
||||
capability already owns it.
|
||||
|
||||
Two further rules protect the first-party namespace directly:
|
||||
|
||||
- **Reserved prefixes.** The `gsd-`, `gsd-core-`, and `anthropic-` id prefixes are
|
||||
reserved. An overlay whose id begins with one is rejected outright — a third party
|
||||
cannot publish `gsd-security` and borrow the implicit trust of the GSD namespace.
|
||||
- **Cross-capability invariants.** Each candidate overlay is added to the merged
|
||||
capability map and the *full* cross-capability validation suite (contract roles,
|
||||
`consumes`-satisfiability, owner uniqueness, config-key exclusivity, `requires`
|
||||
acyclicity and tier-monotonicity) is re-run. First-party alone is always clean, so
|
||||
any new error is provably the candidate's fault, and the candidate is dropped.
|
||||
|
||||
### Why this asymmetry
|
||||
|
||||
The asymmetry is intentional and follows directly from the trust model's central
|
||||
thesis — *artifact parity is not trust parity*. A third-party capability is allowed to
|
||||
ship the same kinds of artifacts as GSD Core, but first-party capabilities carry an
|
||||
authority third-party ones do not: their provenance is the GSD release process itself.
|
||||
If a collision could let an overlay shadow a first-party skill, agent, or command, then
|
||||
installing a capability could silently *replace* a shipped behaviour — the install would
|
||||
be the attack. By making first-party unconditionally win every collision, GSD
|
||||
guarantees that no installed capability can ever redefine what GSD Core does. An overlay
|
||||
can only *add*; it can never *override*.
|
||||
|
||||
---
|
||||
|
||||
## When a single overlay fails: skip, don't crash
|
||||
|
||||
Overlays are untrusted, independently authored, and read at runtime from a possibly
|
||||
repo-plantable directory. A malformed one must never bring down the loop. So the second
|
||||
rule of composition is: **a bad overlay is skipped with a warning; the loop always gets
|
||||
a usable registry.**
|
||||
|
||||
A capability is skipped (and a warning recorded in the registry's `_overlay.warnings`)
|
||||
for any of these reasons:
|
||||
|
||||
- its `capability.json` is missing, unreadable, non-regular (a planted FIFO/device), or
|
||||
oversized;
|
||||
- it fails structural or cross-capability validation;
|
||||
- it collides with first-party or an already-accepted overlay (the precedence rule
|
||||
above);
|
||||
- its `engines.gsd` range does not satisfy the running GSD version (the load-time
|
||||
re-gate, which mirrors the install-time gate so an upgrade of GSD itself can retire an
|
||||
incompatible overlay);
|
||||
- it carries an in-flight `_pending` install/upgrade marker (deferred until
|
||||
reconciliation completes);
|
||||
- (for a project overlay) it has no matching consent record on this machine — it is
|
||||
*discovered but inactive*.
|
||||
|
||||
The composition body is total: even an unexpected throw from a validator or a
|
||||
fragment-materialisation step is caught per-candidate, turned into a skip, and the next
|
||||
candidate is processed. A single broken overlay cannot poison the rest of the set.
|
||||
|
||||
---
|
||||
|
||||
## The one place where skipping is dangerous: gates
|
||||
|
||||
Skipping a broken overlay is the safe default for most surfaces — but not for *gates*.
|
||||
|
||||
A capability's loop hooks come in three kinds:
|
||||
|
||||
- a **step** adds an independent unit of work at an extension point;
|
||||
- a **contribution** injects a prompt fragment into an agent role;
|
||||
- a **gate** checks a condition and can *block* the loop from proceeding.
|
||||
|
||||
For steps and contributions, skipping a capability means the loop simply proceeds
|
||||
**without** that addition. That is *fail-open*, and it is correct: the loop is missing an
|
||||
optional step, not doing something unsafe.
|
||||
|
||||
A gate is the opposite. The whole purpose of a gate is to *stop* the loop when a
|
||||
condition is not met — a deploy gate, a house-style verification gate, a safety check. If
|
||||
GSD skipped a broken gate-declaring capability and proceeded, it would behave exactly as
|
||||
if the gate had *passed* — silently waving through the very thing the gate existed to
|
||||
block. That is a fail-open on a security-relevant control, and it is unacceptable.
|
||||
|
||||
So composition treats gates asymmetrically from steps and contributions. When a
|
||||
capability that declares a gate is skipped, GSD records its gate points in
|
||||
`_overlay.incompatibleGateCapIds` and `_overlay.blockedGates`, and the loop resolver
|
||||
**injects a synthetic blocking gate** at each of those extension points. The loop
|
||||
**fails closed**: rather than proceed as if the gate passed, it halts with a message
|
||||
naming the skipped capability and why its gate could not be evaluated.
|
||||
|
||||
The discriminator is therefore *not* "is this overlay broken?" but "what does failing
|
||||
to load it mean?" — and for a gate, failing to load it means you must not proceed.
|
||||
|
||||
---
|
||||
|
||||
## When the whole compose fails: fall back to first-party
|
||||
|
||||
There is one more failure layer above the per-candidate skip. A set of overlays can
|
||||
each pass every per-candidate check yet still trip a stricter whole-set check when the
|
||||
canonical builder (`buildRegistry`) materialises the merged registry — a topological
|
||||
cycle that only appears across the combined set, a config-slice shape problem, a format
|
||||
mismatch. An unguarded failure there would crash every consumer of the registry.
|
||||
|
||||
The fallback is uncompromising: if the whole-set build fails, GSD **discards every
|
||||
overlay** and returns the frozen first-party registry, plus a warning recording why. The
|
||||
loop keeps running with exactly the shipped capabilities and none of the overlays. Two
|
||||
details make this safe rather than merely convenient:
|
||||
|
||||
- Every accepted overlay's **command root is cleared**, so no dropped overlay can leave
|
||||
behind a path that a runtime dispatcher might `require()` a command module from.
|
||||
- Every dropped overlay's **gates are recorded as blocked** — using the same extraction
|
||||
as the per-candidate path — so a gate-declaring overlay that vanishes in the fallback
|
||||
still **fails closed**, never open.
|
||||
|
||||
The principle is the same at every layer: when GSD cannot compose an overlay, it removes
|
||||
the overlay's *additions* but never weakens a *control*.
|
||||
|
||||
---
|
||||
|
||||
## Why compose through one builder
|
||||
|
||||
A subtle but important design choice: the merged registry is materialised by the **same**
|
||||
`buildRegistry` function that produces the first-party registry, run over a map of
|
||||
first-party capabilities *plus* the accepted overlays. GSD does not have one code path
|
||||
that builds the first-party views and a separate path that bolts overlay views on.
|
||||
|
||||
The reason is drift. Every derived view — `bySkill`, `byLoopPoint`, the config schema,
|
||||
the cluster map, profile membership — is a projection of the capability set. If overlays
|
||||
were projected by a different builder, those projections could diverge from the
|
||||
first-party ones in subtle ways, and an overlay capability might behave *almost* like a
|
||||
first-party one but not quite. By forcing both through the single canonical builder, GSD
|
||||
guarantees that an accepted overlay is indistinguishable from a first-party capability in
|
||||
every derived view — which is exactly the artifact-parity promise the platform makes.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The overlay model rests on a few rules applied consistently:
|
||||
|
||||
- The registry is composed at runtime by `loadRegistry`, not read as a static file.
|
||||
- **First-party always wins** every collision — id, skill/agent stem, config key,
|
||||
command family, reserved prefix. An overlay can only add, never override.
|
||||
- A bad overlay is **skipped, not crashed** — the loop always gets a usable registry.
|
||||
- Skipping **fails open** for steps and contributions (a missing optional addition) but
|
||||
**fails closed** for gates (a missing control must block, not pass).
|
||||
- A whole-set compose failure **falls back to first-party**, clearing command roots and
|
||||
still blocking dropped gates.
|
||||
- One canonical builder materialises both first-party and overlay views, so an accepted
|
||||
overlay has true parity with a shipped capability.
|
||||
|
||||
Every one of these choices answers the same question — *what does it mean if this
|
||||
composition step fails?* — and resolves it in favour of first-party authority and a
|
||||
fail-closed security posture.
|
||||
|
||||
---
|
||||
|
||||
## Related documents
|
||||
|
||||
- [ADR-1244 D2 — Runtime Capability Registry overlay](../adr/1244-capability-ecosystem.md#d2--runtime-capability-registry-overlay)
|
||||
- [The capability trust model](capability-trust-model.md) — the security side of the same boundary
|
||||
- [Capability Overlay (Configuration)](../CONFIGURATION.md#capability-overlay-installed-third-party-capabilities) — the operator-facing view of the same rules
|
||||
- [Capability manifest reference](../reference/capability-manifest.md) — the field-level conformance invariants
|
||||
- [`gsd capability` command reference](../reference/gsd-capability-command.md)
|
||||
- [Install your first capability](../tutorials/install-your-first-capability.md)
|
||||
@@ -53,6 +53,7 @@ At minimum, a feature Capability declares:
|
||||
{
|
||||
"id": "example",
|
||||
"role": "feature",
|
||||
"version": "0.1.0",
|
||||
"title": "Example",
|
||||
"description": "Adds an example planning step.",
|
||||
"tier": "standard",
|
||||
|
||||
@@ -40,8 +40,6 @@ gsd capability install https://example.com/releases/gsd-cap-example-1.0.0.tgz
|
||||
gsd capability install ./path/to/capability
|
||||
```
|
||||
|
||||
You can also use the slash command form inside a supported runtime (surfaced as `gsd:capability install <spec>` — without the leading `/` in the command palette).
|
||||
|
||||
---
|
||||
|
||||
## Read the pre-install summary
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
This guide covers two distinct operations: **removing** a capability (deletes its files and cleans up all shared configuration it wrote) and **disabling** a capability (toggles it off without touching any files). Choose the one that fits your intent.
|
||||
|
||||
> **Which one applies depends on where the capability came from.** `disable`/`enable` work **only** on first-party capabilities shipped inside GSD. An **installed third-party overlay** (added with `gsd capability install …`) cannot be disabled — its only off-switch is `remove` (re-install to restore it).
|
||||
|
||||
---
|
||||
|
||||
## Disable a capability (reversible, files kept)
|
||||
## Disable a first-party capability (reversible, files kept)
|
||||
|
||||
If you want to stop a capability from participating in the loop but may want it back later, disable it:
|
||||
`disable`/`enable`/`set` are for **first-party** capabilities only — the ones that ship inside GSD (for example `ui`, `code-review`, `research`). They validate `<id>` against GSD's **build-time** capability registry, so an **installed third-party overlay** (anything you added with `gsd capability install …`) is **not** in that registry and is rejected with `unknown capability: "<id>"`. For an installed overlay there is no `disable`; the off-switch is `remove` (and you re-install to bring it back) — see [Remove a capability](#remove-a-capability) below.
|
||||
|
||||
If you want to stop a **first-party** capability from participating in the loop but may want it back later, disable it:
|
||||
|
||||
```bash
|
||||
gsd capability disable <id>
|
||||
```
|
||||
|
||||
Disabling is a toggle: no files are deleted, no shared configuration is modified. The capability's hooks stop firing, its skills leave the active surface, and its command modules stop responding. To re-activate it:
|
||||
Disabling is a toggle: no files are deleted, no shared configuration is modified. It acts on the **runtime surface and hook activation** of a skill-owning first-party capability: the capability's hooks stop firing and its skills leave the active surface. Disabling does **not** unregister first-party command families — those are dispatched from the generated capability registry, which `disable` does not consult, so any commands the capability owns continue to respond. To re-activate the surface and hooks:
|
||||
|
||||
```bash
|
||||
gsd capability enable <id>
|
||||
@@ -43,7 +47,7 @@ GSD uses the **ledger** — a per-runtime record written at install time (for ex
|
||||
### What is NOT removed
|
||||
|
||||
- **Shared files themselves.** Files such as `settings.json` and `hooks.json` are edited in place, not deleted. Only the capability's specific entries are excised.
|
||||
- **Persistent capability data.** Any data the capability wrote during use (databases, caches, runtime artefacts stored outside the install root) is **not** auto-deleted. You must pass `--purge-data` to remove it, and GSD will prompt for confirmation before doing so:
|
||||
- **Persistent capability data.** Any data the capability wrote during use (databases, caches, runtime artefacts stored outside the install root) is **not** auto-deleted by default. Pass `--purge-data` to delete it as part of the removal:
|
||||
|
||||
```bash
|
||||
gsd capability remove <id> --purge-data
|
||||
@@ -51,16 +55,14 @@ GSD uses the **ledger** — a per-runtime record written at install time (for ex
|
||||
|
||||
If you want to keep your data, omit `--purge-data`. The capability's runtime data will remain on disk even after the capability itself is removed.
|
||||
|
||||
### Prompts and confirmation
|
||||
### No prompt — `remove` is non-interactive
|
||||
|
||||
`gsd capability remove` will ask you to confirm before proceeding. Pass `--yes` to skip the prompt in scripts or non-interactive contexts:
|
||||
`gsd capability remove` is **non-interactive**: it does not prompt, and there is no `--yes` flag. It acts immediately on the scope you give it. `--purge-data` likewise deletes the capability's data directly, with no confirmation step — so be sure before you pass it. The full contract is:
|
||||
|
||||
```bash
|
||||
gsd capability remove <id> --yes
|
||||
gsd capability remove <id> [--purge-data] [--scope global|project]
|
||||
```
|
||||
|
||||
If the capability also ships persistent data and you pass `--purge-data`, GSD prompts once more specifically for the data deletion, regardless of `--yes`, because that action is irreversible.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
@@ -94,10 +96,11 @@ gsd capability remove <id> --scope project
|
||||
|
||||
| | `disable` | `remove` |
|
||||
|---|---|---|
|
||||
| Applies to | First-party only | Installed overlays (and reconcile of orphaned first-party state) |
|
||||
| Files deleted | No | Yes (ledger-recorded files only) |
|
||||
| Shared config entries removed | No | Yes (capability's entries only) |
|
||||
| Federated config keys dropped | No | Yes |
|
||||
| Persistent data deleted | No | Only with `--purge-data` + prompt |
|
||||
| Persistent data deleted | No | Only with `--purge-data` (deleted directly, no prompt) |
|
||||
| Reversible without reinstall | Yes (`enable`) | No |
|
||||
| Use when | You want it back later | You no longer need it |
|
||||
|
||||
|
||||
@@ -2,80 +2,131 @@
|
||||
|
||||
This guide shows you how to switch a GSD capability off so it stops taking part in the loop — and stays off — and how to switch off a single feature of a capability without disabling the whole thing.
|
||||
|
||||
GSD resolves one capability state from three places: whether the capability is installed, whether it is surfaced, and whether each of its hooks is gated in config. "Off" means off across all three. For why the model works this way, see [Develop a Capability for GSD 1.5+](develop-a-capability.md).
|
||||
GSD resolves one capability state from three places: whether the capability is installed, whether it is surfaced, and whether each of its hooks is gated in config. "Off" means off across all three. For why the model works this way, see [Develop a Capability for GSD 1.6.0+](develop-a-capability.md).
|
||||
|
||||
> **First-party vs. installed: pick the right off-switch.** The path depends on where the capability came from.
|
||||
>
|
||||
> - A **first-party** capability — one that ships with GSD (for example `ui`, `code-review`, `research`) — is turned off with `gsd capability disable <id>` or gated with `gsd capability set <id> --gate …`. These verbs validate `<id>` against the built-in capability registry.
|
||||
> - An **installed third-party overlay** — one you added with `gsd capability install …` — is **not** in that build-time registry, so `disable`/`enable`/`set` reject it with `unknown capability: "<id>"`. The off-switch for an installed overlay is `gsd capability remove <id> --scope <scope>`.
|
||||
>
|
||||
> The rest of this guide covers first-party capabilities. For installed overlays, jump to [Turn off an installed third-party capability](#turn-off-an-installed-third-party-capability).
|
||||
|
||||
The reliable, fully general way to change first-party capability state is the `capability` command. The `/gsd:surface` and `/gsd:settings` slash commands are convenient interactive front-ends, but they operate on **skill clusters**, not arbitrary capabilities — so reach for the CLI when you want a precise, scriptable, per-capability switch.
|
||||
|
||||
---
|
||||
|
||||
## Turn a whole capability off
|
||||
## Turn a whole first-party capability off
|
||||
|
||||
Use the runtime surface — the on/off switch. It is reversible and needs no reinstall:
|
||||
Disable the capability by id:
|
||||
|
||||
```
|
||||
/gsd:surface disable <capability>
|
||||
```bash
|
||||
gsd capability disable <id>
|
||||
```
|
||||
|
||||
For example, to stop the UI capability:
|
||||
|
||||
```
|
||||
/gsd:surface disable ui
|
||||
```bash
|
||||
gsd capability disable ui
|
||||
```
|
||||
|
||||
The capability's skills leave the surface and all of its hooks go inactive. Check the result with:
|
||||
This unsurfaces the capability's skills and makes all of its hooks inactive. It is reversible and needs no reinstall — the bundle stays on disk and your hook gates are preserved. `gsd capability disable <id>` is exactly `gsd capability set <id> --off`; re-enable with `gsd capability enable <id>` (i.e. `--on`).
|
||||
|
||||
`disable`/`enable`/`set` only accept ids the built-in registry knows about. Run them against an installed third-party overlay and you get `unknown capability: "<id>"` — see [Turn off an installed third-party capability](#turn-off-an-installed-third-party-capability) for that case.
|
||||
|
||||
Check the result:
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs capability state --raw
|
||||
gsd capability state --raw
|
||||
```
|
||||
|
||||
The capability now reports `enabled: false` and every hook `active: false`. To turn it back on, `/gsd:surface enable ui` — your earlier hook gates are preserved.
|
||||
The capability now reports `enabled: false` and every hook `active: false`.
|
||||
|
||||
---
|
||||
|
||||
## Turn off one feature of a capability
|
||||
|
||||
To keep a capability on but switch off a single hook, gate that hook instead of disabling the capability. Use `/gsd:settings`, or set the key directly:
|
||||
To keep a capability on but switch off a single hook, gate that hook instead of disabling the capability. A **gate** is a dotted config key declared in the capability's `config` slice whose boolean value controls whether one of its hooks fires. Set it to `false`:
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs capability set code-review --gate workflow.code_review=false
|
||||
gsd capability set code-review --gate workflow.code_review=false
|
||||
```
|
||||
|
||||
The capability stays enabled; only that hook stops firing.
|
||||
The capability stays enabled; only that hook stops firing. `--gate` is repeatable, so you can set several gates in one call. See the [`set` reference](../reference/gsd-capability-command.md#set) for the full contract.
|
||||
|
||||
---
|
||||
|
||||
## Capabilities that own no skills
|
||||
|
||||
Some capabilities (for example, research) contribute only hooks and agents — they have no skills to unsurface, so `/gsd:surface disable` does not affect them. Switch these off by gating their hooks:
|
||||
Some capabilities (for example, `research`) contribute only hooks and agents — they have no skills to unsurface, so disabling them via the surface has no effect. Switch these off by gating their hooks instead:
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs capability set research --gate workflow.research=false
|
||||
gsd capability set research --gate workflow.research=false
|
||||
```
|
||||
|
||||
If you gate every hook of a capability off while it is still surfaced, `gsd-tools capability state` flags it as surfaced-but-inactive — a sign you probably meant to disable the capability itself.
|
||||
If you gate every hook of a capability off while it is still surfaced, `gsd capability state` flags it as surfaced-but-inactive — a sign you probably meant to disable the capability itself.
|
||||
|
||||
---
|
||||
|
||||
## Turn off an installed third-party capability
|
||||
|
||||
A capability you added with `gsd capability install …` is an **installed overlay**, not a first-party capability. It is not present in the build-time registry that `disable`/`enable`/`set` validate against, so those verbs reject it:
|
||||
|
||||
```bash
|
||||
gsd capability disable my-overlay
|
||||
# error: unknown capability: "my-overlay"
|
||||
```
|
||||
|
||||
**Remove it.** This is the deactivation path for an installed overlay — it strips the overlay's files and edits for the chosen scope:
|
||||
|
||||
```bash
|
||||
gsd capability remove my-overlay --scope global # default scope is global
|
||||
gsd capability remove my-overlay --scope project # for a project-scoped install
|
||||
```
|
||||
|
||||
`--scope` defaults to `global`, so pass `--scope project` for a project install. Add `--purge-data` to also delete the overlay's persisted data. If the id is not installed in the chosen scope you get `capability "my-overlay" is not installed in <scope> scope`. (Trying to `remove` a first-party id instead reports that it cannot be removed here — use the product uninstaller, `gsd --uninstall`.)
|
||||
|
||||
> The `/gsd:surface` clusters described below are derived from the **built-in** capability registry, so they cover first-party skill-owning capabilities. For an installed overlay, `remove` is the off-switch.
|
||||
|
||||
See [Remove a capability](remove-a-capability.md) for the full removal flow and [`gsd capability remove`](../reference/gsd-capability-command.md#remove) for every flag and output field.
|
||||
|
||||
---
|
||||
|
||||
## The interactive paths (`/gsd:surface` and `/gsd:settings`)
|
||||
|
||||
The slash commands are the interactive equivalents, useful when you are working inside an agent session rather than scripting:
|
||||
|
||||
- **`/gsd:surface disable <cluster>`** toggles a whole skill **cluster** on or off and re-stages the surface. Its argument is validated against the fixed set of cluster names — one of `core_loop`, `audit_review`, `milestone`, `research_ideate`, `workspace_state`, `docs`, `ui`, `ai_eval`, `ns_meta`, `utility` (the command rejects anything else and lists these). A few of these names coincide with first-party skill-owning capability ids (for example `ui`), so `/gsd:surface disable ui` works — but the command does **not** accept an arbitrary capability id, including an installed overlay's id. To switch off a specific capability by id, use the CLI (`gsd capability disable <id>` for first-party, `gsd capability remove <id>` for an installed overlay). Reverse a cluster with `/gsd:surface enable <cluster>`.
|
||||
- **`/gsd:settings`** is the interactive prompt for GSD's workflow toggles (the `workflow.*` config keys that gate hooks). Use it to turn workflow features on or off conversationally; it writes the same config keys that `gsd capability set … --gate` writes.
|
||||
|
||||
For anything you want to be exact about — a specific capability id, a single named gate, or a step in a script or CI job — prefer the CLI.
|
||||
|
||||
---
|
||||
|
||||
## Scripting it
|
||||
|
||||
`/gsd:surface` and `/gsd:settings` are the interactive paths. To mutate capability state directly (in scripts or CI), call the underlying command:
|
||||
To mutate capability state directly (in scripts or CI), call the command non-interactively. The first three verbs work on **first-party** ids; the last works on **installed overlays**:
|
||||
|
||||
```bash
|
||||
# Disable via surface
|
||||
node gsd-tools.cjs capability set <id> --off
|
||||
# Disable a whole first-party capability
|
||||
gsd capability disable <id> # equivalently: gsd capability set <id> --off
|
||||
|
||||
# Re-enable
|
||||
node gsd-tools.cjs capability set <id> --on
|
||||
gsd capability enable <id> # equivalently: gsd capability set <id> --on
|
||||
|
||||
# Toggle one hook gate
|
||||
node gsd-tools.cjs capability set <id> --gate <key>=<true|false>
|
||||
gsd capability set <id> --gate <key>=<true|false>
|
||||
|
||||
# Deactivate an installed third-party overlay (disable/set would reject it)
|
||||
gsd capability remove <id> --scope <global|project>
|
||||
```
|
||||
|
||||
See [CLI tools — Capability Commands](../CLI-TOOLS.md#capability-commands) for the full reference.
|
||||
See the [`gsd capability` command reference](../reference/gsd-capability-command.md) for every subcommand, flag, and output shape.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Develop a Capability for GSD 1.5+](develop-a-capability.md)
|
||||
- [`gsd capability` command reference](../reference/gsd-capability-command.md) — `disable`, `enable`, `set`, and the rest of the family
|
||||
- [Develop a Capability for GSD 1.6.0+](develop-a-capability.md)
|
||||
- [Install a minimal GSD and add skills later](install-minimal-and-add-skills.md)
|
||||
- [CLI tools reference — Capability Commands](../CLI-TOOLS.md#capability-commands)
|
||||
- [docs index](../README.md)
|
||||
|
||||
@@ -80,7 +80,7 @@ npm version 1.2.0
|
||||
npm publish
|
||||
```
|
||||
|
||||
**New tarball.** Upload the new archive at a URL and communicate the URL to consumers. GSD cannot auto-detect updates for tarball sources — consumers must run `gsd capability update <id> <new-url>` manually after you announce the new URL. If you anticipate frequent updates, consider switching to a git or npm source.
|
||||
**New tarball.** Upload the new archive at a URL and communicate the URL to consumers. GSD cannot auto-detect updates for tarball sources, and `gsd capability update` only ever re-resolves the URL **already recorded** in the ledger — it takes no new-URL argument. To move a tarball install to a new URL, the consumer **re-installs from the new URL** (`gsd capability install <new-url> …`), which overwrites the recorded source. If you anticipate frequent updates, consider switching to a git or npm source so `gsd capability update <id>` can pick up new versions automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,11 +99,11 @@ GSD contacts the source of each installed capability and reports which ones have
|
||||
| Source | Auto-detectable? |
|
||||
|---|---|
|
||||
| Git (tags / manifest) | Yes — GSD fetches available tags. |
|
||||
| Registry | Yes — GSD queries the catalogue. |
|
||||
| npm | Yes — GSD checks `dist-tags`. |
|
||||
| Tarball URL | **No** — a tarball exposes one version; updates must be applied manually when the author announces a new URL. |
|
||||
| Tarball URL | **No** — a tarball exposes one version; updates must be applied manually by re-installing from a new URL. |
|
||||
| Registry (`<name>@<registry>`) | **Not yet** — the registry source kind is reserved but unimplemented today; `outdated` reports `status: unknown` for it and `update` cannot re-resolve it. |
|
||||
|
||||
If a capability is installed from a tarball and the author publishes a new version at a different URL, you will need to run `gsd capability update <id> <new-url>` yourself once the author communicates the new address.
|
||||
If a capability is installed from a tarball and the author publishes a new version at a different URL, `gsd capability update <id>` will not help — it only re-resolves the URL already recorded at install time, and takes no new-URL argument. Once the author communicates the new address, **re-install from it** with `gsd capability install <new-url> …`; that overwrites the recorded source with the new version.
|
||||
|
||||
### Apply an update
|
||||
|
||||
@@ -123,11 +123,13 @@ Updates are **atomic**: GSD fully fetches and validates the new version before s
|
||||
|
||||
### Consent when the executable surface changes
|
||||
|
||||
If the new version adds or removes hooks, MCP server entries, or command modules compared to the version you have installed, GSD will pause and present a summary of the changes before proceeding. You must confirm explicitly; declining leaves the current version in place.
|
||||
The CLI is **non-interactive** — it never stops to ask a question. If the new version adds or removes hooks, MCP server entries, or command modules compared to the version you have installed, `gsd capability update <id>` **aborts** rather than swapping: it prints the disclosed surface change and instructs you to re-run with `--yes`, leaving the current version fully in place. Re-running with `--yes` grants consent for the new surface and completes the swap:
|
||||
|
||||
This re-prompt applies even if you previously consented to auto-update. The consent mechanism is scoped to the declared executable surface of a specific version, so a changed surface is always a fresh decision.
|
||||
```bash
|
||||
gsd capability update <id> --yes
|
||||
```
|
||||
|
||||
Auto-update is **off by default** for third-party capabilities. If you enable it, the re-prompt on executable-surface change still applies.
|
||||
This re-consent is required every time the surface changes, scoped to the declared executable surface of a specific version, so a changed surface is always a fresh `--yes`. (A version whose executable surface is unchanged updates without `--yes`.)
|
||||
|
||||
### When `engines.gsd` no longer matches
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ eager なスキルリストはターンごとの 2 つの主要コストの一
|
||||
|
||||
#### ワークフローのプログレッシブディスクロージャー
|
||||
|
||||
ワークフローファイルは、対応する `/gsd-*` コマンドが呼び出されるたびに Claude のコンテキストにそのまま読み込まれます。そのコストを制限するため、`tests/workflow-size-budget.test.cjs` で強制されるワークフローサイズバジェットは #2361 のエージェントバジェットを反映します:
|
||||
ワークフローファイルは、対応する `/gsd-*` コマンドが呼び出されるたびに Claude のコンテキストにそのまま読み込まれます。そのコストを制限するため、`tests/workflow-size-budget.test.cjs` で強制されるワークフローサイズバジェットはエージェントサイズバジェット規則を反映します:
|
||||
|
||||
| ティア | ファイルごとの行数制限 |
|
||||
|-----------|--------------------|
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
| `continuation-format.md` | セッション継続/再開フォーマット。 |
|
||||
| `domain-probes.md` | discuss-phase 向けのドメイン固有のプロービング質問。 |
|
||||
| `gate-prompts.md` | ゲート/チェックポイントのプロンプトテンプレート。 |
|
||||
| `scout-codebase.md` | discuss-phase スカウトステップ向けのフェーズタイプ→コードベースマップ選択テーブル(#2551 で抽出)。 |
|
||||
| `scout-codebase.md` | discuss-phase スカウトステップ向けのフェーズタイプ→コードベースマップ選択テーブル(discuss-phase/modes プログレッシブディスクロージャー分割により抽出、#717)。 |
|
||||
| `revision-loop.md` | プラン修正の反復パターン。 |
|
||||
| `universal-anti-patterns.md` | 検出して避けるべきユニバーサルアンチパターン。 |
|
||||
| `worktree-path-safety.md` | ワークツリーガードスイート: HEAD アサーション、cwd ドリフトセンチネル(ステップ 0a、#3097)、絶対パスガード(ステップ 0b、#3099)— `<execution_context>` 経由でエグゼキュータースポーンプロンプトに読み込まれる。 |
|
||||
|
||||
@@ -144,7 +144,7 @@ GSD Core는 사용자와 AI 코딩 에이전트(Claude Code, Gemini CLI, OpenCod
|
||||
|
||||
#### 워크플로우를 위한 점진적 공개
|
||||
|
||||
워크플로우 파일은 해당 `/gsd-*` 명령어가 호출될 때마다 Claude의 컨텍스트에 그대로 로드된다. 이 비용을 제한하기 위해 `tests/workflow-size-budget.test.cjs`가 시행하는 워크플로우 크기 예산은 #2361의 에이전트 예산을 반영한다:
|
||||
워크플로우 파일은 해당 `/gsd-*` 명령어가 호출될 때마다 Claude의 컨텍스트에 그대로 로드된다. 이 비용을 제한하기 위해 `tests/workflow-size-budget.test.cjs`가 시행하는 워크플로우 크기 예산은 에이전트 크기 예산 관례를 반영한다:
|
||||
|
||||
| 등급 | 파일당 줄 제한 |
|
||||
|-----------|--------------------|
|
||||
@@ -152,7 +152,7 @@ GSD Core는 사용자와 AI 코딩 에이전트(Claude Code, Gemini CLI, OpenCod
|
||||
| `LARGE` | 1500 — 다단계 플래너 및 대형 기능 워크플로우 |
|
||||
| `DEFAULT` | 1000 — 집중된 단일 목적 워크플로우 (목표 등급) |
|
||||
|
||||
`workflows/discuss-phase.md`는 이슈 #2551에 따라 더 엄격한 <500줄 상한을 유지한다. 워크플로우가 등급을 초과하면 모드별 본문은 `workflows/<workflow>/modes/<mode>.md`로, 템플릿은 `workflows/<workflow>/templates/`로, 공유 지식은 `get-shit-done/references/`로 추출한다. 부모 파일은 현재 호출에 필요한 모드 및 템플릿 파일만 읽는 얇은 디스패처가 된다.
|
||||
`workflows/discuss-phase.md`는 discuss-phase 바이트 예산(#717; discuss-phase/modes 분할로 ≈32000 바이트 유지)에 따라 더 엄격한 상한을 유지한다. 워크플로우가 등급을 초과하면 모드별 본문은 `workflows/<workflow>/modes/<mode>.md`로, 템플릿은 `workflows/<workflow>/templates/`로, 공유 지식은 `get-shit-done/references/`로 추출한다. 부모 파일은 현재 호출에 필요한 모드 및 템플릿 파일만 읽는 얇은 디스패처가 된다.
|
||||
|
||||
`workflows/discuss-phase/`가 이 패턴의 정규 예시이다 — 부모는 디스패치하고, modes/는 플래그별 동작(`power.md`, `all.md`, `auto.md`, `chain.md`, `text.md`, `batch.md`, `analyze.md`, `default.md`, `advisor.md`)을 담으며, templates/는 해당 출력 파일이 작성될 때만 읽히는 CONTEXT.md, DISCUSSION-LOG.md, checkpoint.json 스키마를 담는다.
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
| `continuation-format.md` | 세션 연속/재개 포맷. |
|
||||
| `domain-probes.md` | discuss-phase를 위한 도메인별 탐색 질문. |
|
||||
| `gate-prompts.md` | 게이트/체크포인트 프롬프트 템플릿. |
|
||||
| `scout-codebase.md` | discuss-phase 스카우트 단계를 위한 단계 유형→코드베이스 맵 선택 테이블(#2551로 추출). |
|
||||
| `scout-codebase.md` | discuss-phase 스카우트 단계를 위한 단계 유형→코드베이스 맵 선택 테이블(discuss-phase/modes 프로그레시브 디스클로저 분할을 통해 추출, #717). |
|
||||
| `revision-loop.md` | 계획 수정 반복 패턴. |
|
||||
| `universal-anti-patterns.md` | 감지하고 피해야 할 보편적인 안티패턴. |
|
||||
| `worktree-path-safety.md` | 워크트리 가드 스위트: HEAD 어설션, cwd-드리프트 센티널(0a단계, #3097), 절대 경로 가드(0b단계, #3099) — `<execution_context>`를 통해 executor 스폰 프롬프트에 로드됨. |
|
||||
|
||||
@@ -149,7 +149,7 @@ Lógica de orquestração que os comandos referenciam. Contém o processo passo
|
||||
Os arquivos de workflow são carregados verbatim no contexto do Claude cada vez que o
|
||||
comando `/gsd-*` correspondente é invocado. Para manter esse custo limitado, o
|
||||
orçamento de tamanho de workflow aplicado por `tests/workflow-size-budget.test.cjs`
|
||||
espelha o orçamento de agentes de #2361:
|
||||
espelha a convenção de orçamento de tamanho de agentes:
|
||||
|
||||
| Tier | Limite de linhas por arquivo |
|
||||
|-----------|------------------------------|
|
||||
@@ -157,8 +157,8 @@ espelha o orçamento de agentes de #2361:
|
||||
| `LARGE` | 1500 — planejadores com múltiplas etapas e workflows de funcionalidades grandes |
|
||||
| `DEFAULT` | 1000 — workflows simples e de propósito único (o tier alvo) |
|
||||
|
||||
`workflows/discuss-phase.md` é mantido em um teto mais restrito de <500 linhas conforme
|
||||
a issue #2551. Quando um workflow cresce além de seu tier, extraia os corpos por modo
|
||||
`workflows/discuss-phase.md` é mantido em um teto mais restrito conforme
|
||||
o orçamento de bytes do discuss-phase (#717; a divisão discuss-phase/modes mantém ≈32000 bytes). Quando um workflow cresce além de seu tier, extraia os corpos por modo
|
||||
em `workflows/<workflow>/modes/<mode>.md`, templates em
|
||||
`workflows/<workflow>/templates/`, e conhecimento compartilhado em
|
||||
`get-shit-done/references/`. O arquivo pai se torna um despachante leve que
|
||||
|
||||
@@ -298,7 +298,7 @@ Registro completo em `get-shit-done/references/*.md`. Referências são document
|
||||
| `continuation-format.md` | Formato de continuação/retomada de sessão. |
|
||||
| `domain-probes.md` | Perguntas de sondagem específicas de domínio para a discuss-phase. |
|
||||
| `gate-prompts.md` | Templates de prompt de portão/checkpoint. |
|
||||
| `scout-codebase.md` | Tabela de seleção de tipo de fase → mapa de base de código para a etapa de scout da discuss-phase (extraída via #2551). |
|
||||
| `scout-codebase.md` | Tabela de seleção de tipo de fase → mapa de base de código para a etapa de scout da discuss-phase (extraída via a divisão progressiva discuss-phase/modes, #717). |
|
||||
| `revision-loop.md` | Padrões de iteração de revisão de plano. |
|
||||
| `universal-anti-patterns.md` | Antipadrões universais a detectar e evitar. |
|
||||
| `worktree-path-safety.md` | Suite de guarda do worktree: asserção de HEAD, sentinela de drift de cwd (etapa 0a, #3097) e guarda de caminho absoluto (etapa 0b, #3099) — carregados nos prompts de spawn do executor via `<execution_context>`. |
|
||||
|
||||
@@ -17,11 +17,12 @@ These fields are present for both `role: "feature"` and `role: "runtime"` capabi
|
||||
| `id` | string (kebab-case) | Yes | Unique identifier; **must equal the folder name**. The prefix `gsd-`, `gsd-core-`, and `anthropic-` are reserved for first-party use. |
|
||||
| `role` | `"feature"` \| `"runtime"` | Yes | Discriminator that selects the body schema. |
|
||||
| `version` | semver string | Yes (1.6.0+) | Semantic version of this capability. The registry rejects a manifest without one. |
|
||||
| `title` | string | No | Short human-readable label. |
|
||||
| `description` | string | No | Longer summary sentence. |
|
||||
| `title` | string | Yes | Short human-readable label. Must be a non-empty string. |
|
||||
| `description` | string | Yes | Longer summary sentence. Must be a non-empty string. |
|
||||
| `tier` | `"core"` \| `"standard"` \| `"full"` | Yes | **Source of truth** for install-profile membership and surface cluster assignment. `tier` propagates via the `requires`-closure; install profiles are generated from it. |
|
||||
| `requires` | string[] | No | Capability `id` values this capability depends on. Must exist in the registry, be acyclic, and be tier-monotone (a `core` capability may not require a `standard` or `full` capability; a `standard` capability may not require a `full` capability). |
|
||||
| `requires` | string[] | Yes | Capability `id` values this capability depends on. Must be present as an array (use `[]` when there are no dependencies). Each entry must exist in the registry, be acyclic, and be tier-monotone (a `core` capability may not require a `standard` or `full` capability; a `standard` capability may not require a `full` capability). |
|
||||
| `engines` | object | No | Host-compatibility constraint. Sub-field: `gsd` — semver range string (e.g. `">=1.6.0 <3.0.0"`). Acts as a hard gate at install **and** at load; a mismatch blocks installation and causes the overlay to be skipped with a warning at load time. |
|
||||
| `runtimeCompat` | object | Yes (`role: "feature"`) | Declares which host runtimes this capability can surface through. Validated for every `role: "feature"` capability (a feature manifest without it fails validation). Sub-fields: `supported` — a **non-empty** array of kebab-case runtime ids, or the single wildcard `["*"]` for a runtime-agnostic capability; `unsupported` — an array of kebab-case runtime ids (the wildcard is **not** permitted here); `notes` — optional object mapping a runtime id (or `"*"`) to a non-empty explanatory string. The wildcard `"*"` may not be mixed with concrete ids in the same array, and the reserved names `__proto__`/`constructor`/`prototype` are rejected. |
|
||||
| `compatVersions` | object | No | Graceful-downgrade table mapping `"<capVersion>"` to `"<min gsd version>"`. Only meaningful for sources that enumerate versions (git tags, registry, npm); a bare tarball URL carries one version and simply blocks on incompatibility. |
|
||||
| `integrity` | string | No | `sha512-<base64>` hash of the capability bundle. Verified before extraction when present; mismatch aborts install. |
|
||||
| `provenance` | object | No | `{ sourceRepo: string, commit: string }`. Emitted in CI for first-party and curated capabilities. |
|
||||
@@ -68,38 +69,41 @@ The `config` field is an object whose keys are federated configuration keys cont
|
||||
|
||||
Steps run at a loop extension point as independent units. Ordering within a point is derived from `produces`/`consumes` (topological sort; capability-id is the tiebreak).
|
||||
|
||||
| Sub-field | Type | Description |
|
||||
|---|---|---|
|
||||
| `point` | string | One of the 12 valid loop extension point identifiers (see table below). |
|
||||
| `ref` | object | Either `{ "skill": "<stem>" }` or `{ "agent": "<stem>" }`. |
|
||||
| `produces` | string[] | Artefact names this step produces. No two capability steps may produce the same artefact at the same point. |
|
||||
| `consumes` | string[] | Artefact names this step consumes. |
|
||||
| `when` | string | Dotted config key; the step is active only when the key is truthy. Evaluated deterministically at render time; phase-context applicability is the skill's own responsibility. |
|
||||
| `onError` | `"skip"` \| `"halt"` | Behaviour on failure. `"skip"` is the default. Steps are purely additive — they never halt or redirect the host workflow on their own; a blocking precondition is expressed as a `gate`. |
|
||||
| Sub-field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `point` | string | Yes | One of the 12 valid loop extension point identifiers (see table below). |
|
||||
| `ref` | object | Yes | The dispatch target. Exactly one of `{ "skill": "<stem>" }`, `{ "agent": "<stem>" }`, or `{ "command": "<name>" }` (the three are mutually exclusive). A `skill`/`agent` stem must be declared in this capability's `skills`/`agents` array. |
|
||||
| `produces` | string[] | Yes | Artefact names this step produces. Must be present as an array (use `[]` when it produces none); an omitted `produces` fails validation. No two capability steps may produce the same artefact at the same point. |
|
||||
| `consumes` | string[] | Yes | Artefact names this step consumes. Must be present as an array (use `[]` when it consumes none); an omitted `consumes` fails validation. |
|
||||
| `onError` | `"skip"` \| `"halt"` | Yes | Behaviour on failure; must be present and one of `"skip"` or `"halt"` (an omitted `onError` fails validation). Steps are purely additive — they never halt or redirect the host workflow on their own; a blocking precondition is expressed as a `gate`. |
|
||||
| `when` | string | No | Dotted config key; the step is active only when the key is truthy. Evaluated deterministically at render time; phase-context applicability is the skill's own responsibility. |
|
||||
| `fragment` | object | No | Optional inline-or-file prompt fragment attached to the step, with the **same** `{ "path": "<relative path>" }` or `{ "inline": "<string>" }` semantics as a contribution's `fragment`. A `path` is materialised (read and inlined) at load time, resolved against the capability directory and confined to it (`..` traversal is rejected). |
|
||||
|
||||
### `contributions`
|
||||
|
||||
Contributions inject a fragment into a named agent role's prompt at a loop extension point. Multiple contributions into the same agent role render as ordered labelled blocks (`<contribution from="<id>">…</contribution>`).
|
||||
|
||||
| Sub-field | Type | Description |
|
||||
|---|---|---|
|
||||
| `point` | string | One of the 12 valid loop extension point identifiers. |
|
||||
| `into` | string | Agent role name. Must be a role published by that loop extension point in the host contract. |
|
||||
| `fragment` | object | Either `{ "path": "<relative path>" }` (file content) or `{ "inline": "<string>" }` (literal text). |
|
||||
| `when` | string | Dotted config key; activates the contribution conditionally. |
|
||||
| `onError` | `"skip"` \| `"halt"` | Behaviour on failure. |
|
||||
| Sub-field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `point` | string | Yes | One of the 12 valid loop extension point identifiers. |
|
||||
| `into` | string | Yes | Agent role name. Must be a role published by that loop extension point in the host contract. |
|
||||
| `produces` | string[] | Yes | Artefact names this contribution produces. Use `[]` when it produces none. |
|
||||
| `consumes` | string[] | Yes | Artefact names this contribution reads. Use `[]` when it reads none. |
|
||||
| `fragment` | object | Yes | Either `{ "path": "<relative path>" }` (file content) or `{ "inline": "<string>" }` (literal text). |
|
||||
| `when` | string | No | Dotted config key; activates the contribution conditionally. |
|
||||
| `onError` | `"skip"` \| `"halt"` | No | Behaviour on failure. |
|
||||
|
||||
### `gates`
|
||||
|
||||
Gates check a condition at a loop extension point and optionally block progression.
|
||||
|
||||
| Sub-field | Type | Description |
|
||||
|---|---|---|
|
||||
| `point` | string | One of the 12 valid loop extension point identifiers. |
|
||||
| `check` | object | One of three forms (see table below). |
|
||||
| `when` | string | Dotted config key; activates the gate conditionally. |
|
||||
| `blocking` | boolean | When `true`, a failed check halts the loop at this point. |
|
||||
| `onError` | `"skip"` \| `"halt"` | Behaviour when the check itself errors. |
|
||||
| Sub-field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `point` | string | Yes | One of the 12 valid loop extension point identifiers. |
|
||||
| `check` | object | Yes | One of three forms (see table below). Must be present as an object; an omitted `check` fails validation. |
|
||||
| `blocking` | boolean | Yes | Must be present and a boolean; an omitted `blocking` fails validation. When `true`, a failed check halts the loop at this point. |
|
||||
| `onError` | `"skip"` \| `"halt"` | Yes | Behaviour when the check itself errors; must be present and one of `"skip"` or `"halt"` (an omitted `onError` fails validation). |
|
||||
| `when` | string | No | Dotted config key; activates the gate conditionally. |
|
||||
|
||||
**`check` forms:**
|
||||
|
||||
@@ -187,6 +191,7 @@ The following is the canonical UI design-contract capability from ADR-894. It il
|
||||
"tier": "standard",
|
||||
"requires": [],
|
||||
"engines": { "gsd": ">=1.6.0" },
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": ["ui-phase", "ui-review"],
|
||||
"agents": ["gsd-ui-checker", "gsd-ui-auditor"],
|
||||
"hooks": [],
|
||||
@@ -241,4 +246,4 @@ The following is the canonical UI design-contract capability from ADR-894. It il
|
||||
Notes on this example:
|
||||
- `when` on each hook references its own config key; whether the phase is actually a frontend phase is decided inside `ui-phase` (self-gate).
|
||||
- The `plan:pre` step self-skips on non-frontend phases, producing no `UI-SPEC.md`; the `execute:wave:post` gate's `ui.safety-gate` query passes gracefully when no `UI-SPEC.md` exists.
|
||||
- A `contribution` follows this shape: `{ "point": "plan:pre", "into": "planner", "fragment": { "path": "loop/threat-model.md" }, "when": "workflow.security_enforcement" }`.
|
||||
- A `contribution` follows this shape: `{ "point": "plan:pre", "into": "planner", "produces": [], "consumes": [], "fragment": { "path": "loop/threat-model.md" }, "when": "workflow.security_enforcement" }` (`produces` and `consumes` are required arrays — use `[]` when empty).
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# `gsd capability` Command Reference
|
||||
|
||||
> **Slash form:** `gsd:capability` (surfaced as a slash command on slash-command runtimes)
|
||||
> **CLI form:** `gsd capability`
|
||||
> **Canonical ADR:** [ADR-1244](../adr/1244-capability-ecosystem.md)
|
||||
> **See also:** [Capability Manifest Reference](capability-manifest.md) · [How to develop a capability](../how-to/develop-a-capability.md) · [The capability trust model](../explanation/capability-trust-model.md)
|
||||
|
||||
The `capability` family manages the installation, upgrade, removal, and inspection of GSD capabilities — both first-party (shipped) and third-party overlays. A row for this command also appears in [docs/COMMANDS.md](../COMMANDS.md) (that file is not edited here).
|
||||
|
||||
**Implemented in 1.6.0:** `install`, `update`, `remove`, `list`, `trust`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands). **Planned (not yet implemented):** `outdated` — see [Planned subcommands](#planned-subcommands).
|
||||
**Implemented:** `install`, `update`, `remove`, `list`, `outdated`, `trust`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands).
|
||||
|
||||
---
|
||||
|
||||
@@ -81,11 +80,11 @@ For third-party capabilities, a version whose executable set (hooks, command mod
|
||||
|
||||
| Source kind | Re-resolution behaviour |
|
||||
|---|---|
|
||||
| `<name>@<registry>` | Registry catalogue query |
|
||||
| git (`https://…/repo.git#<tag>`) | Remote tag fetch |
|
||||
| npm (`npm:@org/pkg@<range>`) | `npm dist-tags` / range resolution |
|
||||
| tarball (`https://…/cap-x.y.z.tgz`) | Re-fetch of the recorded URL |
|
||||
| local (`./local/path`) | Re-read of the recorded filesystem path |
|
||||
| registry (`<name>@<registry>`) | **Not yet implemented** — the registry source kind is reserved; re-resolution throws, so an overlay recorded from a registry spec cannot currently be updated. |
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +127,11 @@ gsd capability disable <id> [--config-dir <path>] [--runtime <r>] [--scope <s>]
|
||||
|
||||
**Behaviour**
|
||||
|
||||
Marks the capability **inactive** in the runtime activation state — identical to `gsd capability set <id> --off`. A disabled capability stays on disk; it is excluded from the active surface and contributes no hooks, config keys, or loop extension registrations until re-enabled. This toggles the capability-state layer (the runtime config), not the install ledger. The id must be a capability known to the registry; activation toggling of an installed **third-party overlay** by id is not yet wired through this path — remove an overlay with `gsd capability remove`. `enable` reverses a disable without re-fetching.
|
||||
Marks the capability **inactive** in the runtime activation state — identical to `gsd capability set <id> --off`. A disabled capability stays on disk; it is excluded from the active surface and contributes no hooks, config keys, or loop extension registrations until re-enabled. This toggles the capability-state layer (the runtime config), not the install ledger.
|
||||
|
||||
> **Scope: first-party capabilities only.** `<id>` is validated against the **build-time first-party registry** (the generated `capability-registry.cjs`). An installed **third-party overlay** — one added with `gsd capability install …` — is **not** in that registry, so `disable` rejects it with `unknown capability: "<id>"`. Deactivate an installed overlay with [`gsd capability remove <id> --scope <scope>`](#remove) instead.
|
||||
|
||||
`enable` reverses a disable without re-fetching.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,6 +147,44 @@ gsd capability enable <id> [--config-dir <path>] [--runtime <r>] [--scope <s>]
|
||||
|
||||
Clears the inactive flag for `<id>` in the runtime activation state — identical to `gsd capability set <id> --on`. On the next GSD invocation the capability is included in the active surface again, subject to its `engines.gsd` range (an incompatible capability is still skipped with a warning at load time).
|
||||
|
||||
> **Scope: first-party capabilities only.** Like `disable`, `enable` validates `<id>` against the build-time first-party registry and rejects an installed overlay with `unknown capability: "<id>"`. There is no `enable` for an installed overlay — re-install it with [`gsd capability install …`](#install) if it was removed.
|
||||
|
||||
---
|
||||
|
||||
### `set`
|
||||
|
||||
**Synopsis**
|
||||
|
||||
```
|
||||
gsd capability set <id> [--on | --enable | --off | --disable] [--gate <key>=<bool>]… [--config-dir <path>] [--runtime <r>] [--scope <s>]
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--on` / `--enable` | Surface the capability (activate its skills). Mutually exclusive with `--off`/`--disable`. |
|
||||
| `--off` / `--disable` | Unsurface the capability (deactivate its skills). Mutually exclusive with `--on`/`--enable`. |
|
||||
| `--gate <key>=<bool>` | Set one capability **gate** to `true` or `false`. Repeatable to set several gates in one call. `<bool>` must be the literal `true` or `false`; any other value is rejected. |
|
||||
| `--config-dir <path>` | Override the runtime config directory the surface state is read from and written to. |
|
||||
| `--runtime <r>` | When given, re-materialise the surface (rewrite skill files) for runtime `<r>` after the state change. |
|
||||
| `--scope <s>` | The materialise scope (`global` or `project`); only meaningful together with `--runtime`. Defaults to `global`. |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
`set` is the single write verb behind the capability **activation** axes. It mutates two independent layers and then re-resolves and reports the capability's state:
|
||||
|
||||
- The **enabled** axis (`--on`/`--off`) toggles whether the capability's skills are on the runtime surface (the same mechanism `disable`/`enable` use; `disable`/`enable` are thin aliases for `set … --off`/`--on`).
|
||||
- The **gate** axis (`--gate`) writes capability-owned config keys into `.planning/config.json`.
|
||||
|
||||
> **Scope: first-party capabilities only.** `set` validates `<id>` against the **build-time first-party registry** (the generated `capability-registry.cjs`); an unrecognized id — including any installed **third-party overlay** — is rejected with `unknown capability: "<id>"` and no writes are performed. `set` is for the activation/gate axes of first-party capabilities; to turn off an installed overlay use [`gsd capability remove`](#remove).
|
||||
|
||||
A **gate** is a dotted config key declared in the capability's `config` slice whose boolean value controls whether one of the capability's loop hooks fires. Setting a gate to `false` stops that hook running while leaving the capability surfaced; setting it to `true` re-arms it. A `--gate <key>=…` whose `<key>` is not a declared config key of `<id>`, or whose value is not boolean, is rejected and **no** writes are performed (the whole operation is validated before any state is written).
|
||||
|
||||
The command is **fail-closed on intent**: if you ask to enable a capability whose skills are not in the install profile, or whose surface/profile does not actually carry it, the operation reports an error rather than silently no-op'ing. Enabling a capability that owns no skills is an advisory warning (use gates to toggle its hooks instead). Surfacing a capability whose every hook is gated off is reported as a warning ("surfaced but every hook is gated off — did you mean `--off`?").
|
||||
|
||||
In `--raw` mode the full `{ capabilities, warnings, errors }` envelope is emitted as JSON and the process exits non-zero when `errors` is non-empty; in human mode warnings and errors are written to stderr and a one-line summary of the target capability (`enabled`, `surfaced`, `installed`, active-hook count) is printed.
|
||||
|
||||
---
|
||||
|
||||
### `list`
|
||||
@@ -151,7 +192,7 @@ Clears the inactive flag for `<id>` in the runtime activation state — identica
|
||||
**Synopsis**
|
||||
|
||||
```
|
||||
gsd capability list [--json]
|
||||
gsd capability list [--json] [--scope global|project]
|
||||
```
|
||||
|
||||
**Flags**
|
||||
@@ -159,10 +200,11 @@ gsd capability list [--json]
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--json` | Currently a **no-op**: `list` always emits the JSON array regardless of this flag. The flag is accepted for forward compatibility — a formatted human-readable table is planned, at which point `--json` will select the JSON form. Do not rely on omitting `--json` to get non-JSON output today. |
|
||||
| `--scope` | Read only the given scope's overlay ledger (`global` or `project`). When omitted, both overlay scopes are swept. First-party capabilities are always listed regardless of `--scope`. |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
Lists capabilities visible to the current session: first-party capabilities (from the registry) plus installed overlay capabilities in both the `global` and `project` scopes. Emits a JSON array of descriptors.
|
||||
Lists capabilities visible to the current session: first-party capabilities (from the registry) plus installed overlay capabilities. With no `--scope`, both the `global` and `project` overlay scopes are swept; with `--scope`, only that scope's overlay ledger is read. Emits a JSON array of descriptors.
|
||||
|
||||
**Output shape**
|
||||
|
||||
@@ -190,12 +232,78 @@ Lists capabilities visible to the current session: first-party capabilities (fro
|
||||
| `incompatible` | An overlay whose `engines.gsd` range does not satisfy the current GSD version; skipped with a warning at load time. |
|
||||
| `inactive` | A **project-scope** overlay that is present on disk (and may have a committed-looking project ledger) but has **no user consent record on this machine** (#1459). It is *discovered but not activated*: it contributes no surfaces and runs nothing. The accompanying `reason` field explains why. Consent it by re-installing through the lifecycle (`gsd capability install … --scope project`). |
|
||||
|
||||
The `reason` field is `null` for active/incompatible rows and carries a short explanation for `inactive` rows.
|
||||
The `reason` field is present on **overlay** rows: `null` for active/incompatible overlays and a short explanation for `inactive` ones. First-party rows omit `reason` (and `scope`/`source`/`status` are always `first-party`/`first-party`/`active`).
|
||||
|
||||
> Whether a capability has been turned off via `disable` is reported by `gsd capability state` (the activation-state view), not by `list`.
|
||||
|
||||
---
|
||||
|
||||
### `outdated`
|
||||
|
||||
**Synopsis**
|
||||
|
||||
```
|
||||
gsd capability outdated [--json] [--scope global|project]
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--json` | Emit the records array as JSON (machine output). When omitted, a human-readable table is printed instead (columns: `ID`, `Source`, `Current`, `Latest`, `Status`). |
|
||||
| `--scope` | Read only the given scope's ledger (`global` or `project`). When omitted, both scopes are swept (mirroring `list`). |
|
||||
|
||||
**Behaviour**
|
||||
|
||||
For every installed overlay capability in the chosen scope(s), `outdated` performs a **light remote peek** of the capability's **recorded source** (the `source` stored in its ledger entry at install time) to learn the latest version that re-resolving that source would install, then compares it (numeric `major.minor.patch`) with the installed version. It is a metadata-only read — it never re-clones, re-packs, or re-extracts a bundle. A failing, timed-out, or unsupported peek **degrades** that row to `status: unknown`; it never crashes the command, and a single bad entry never suppresses the others.
|
||||
|
||||
A capability is reported `outdated` **only if** re-resolving its recorded source (exactly what `update` does) would fetch a **newer** version than the one installed. A source pinned to an **immutable** ref — a git `#sha:<commit>` or `#tag:<tag>` fragment, or an **exact** npm version (`npm:@org/pkg@1.2.3`) — is never `outdated`: `update` re-resolves to the same commit/tag/version, so the row is reported `status: pinned` instead.
|
||||
|
||||
A **bare** git ref fragment (`…repo.git#<ref>`) is **ambiguous** — it may name an immutable tag or a **mutable branch**. `outdated` resolves it at the remote with a bounded `git ls-remote <url> <ref>` (the same safe argv-only seam, no shell): a ref that resolves under `refs/tags/` is an immutable tag → `status: pinned`; a ref that resolves under `refs/heads/` is a **mutable branch** (`update` re-clones and checks out the branch HEAD, which can move) and is therefore **never** reported `pinned`. Because the ledger does not record the commit a git source was installed at, a moved branch HEAD cannot be compared against the installed commit, so a branch-tracked source degrades to `status: unknown`. An unresolvable / ambiguous / errored / timed-out classification also degrades to `unknown`.
|
||||
|
||||
The per-source "update available?" matrix (ADR-1244 D6):
|
||||
|
||||
| Source kind | Latest-version peek | Bound |
|
||||
|---|---|---|
|
||||
| git, **unpinned** (`https://…/repo.git`, tracks default branch) | `git ls-remote --tags` → highest **stable** semver tag (`v`-prefix and `^{}` peeled entries handled; prerelease/junk tags ignored) | ≤ 30s |
|
||||
| git, **pinned** (`…repo.git#sha:…` / `#tag:…`) | immutable ref → `status: pinned` (no peek; `update` will not move it) | — |
|
||||
| git, **bare ref** (`…repo.git#<ref>`) | `git ls-remote <url> <ref>` classifies the ref: `refs/tags/…` → `status: pinned` (immutable tag); `refs/heads/…` → **mutable branch**, never `pinned` (no installed commit recorded to compare against → `status: unknown`); unresolvable/ambiguous → `status: unknown` | ≤ 30s |
|
||||
| npm **range** (`npm:@org/pkg@^1`) | `npm view <pkg>@<range> version` → **highest version matching the recorded range** (npm prints one line per match; the numeric max satisfying the range is what `update` installs) | ≤ 60s |
|
||||
| npm **latest** (`npm:@org/pkg`, no version) | `npm view <pkg> version` → the single `latest` dist-tag version | ≤ 60s |
|
||||
| npm **exact** (`npm:@org/pkg@1.2.3`) | pinned exact version → `status: pinned` (no peek; `update` re-installs the same version) | — |
|
||||
| local (`./path` or absolute) | re-read of `capability.json` at the recorded path | — |
|
||||
| tarball (`https://…/cap-x.y.z.tgz`) | **not auto-detectable** — one immutable URL → `status: manual` | — |
|
||||
| registry (`<name>@<registry>`) | registry adapter not yet implemented → `status: unknown` | — |
|
||||
|
||||
**Output shape** (`--json`)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"sourceKind": "git | npm | local | tarball | registry | unknown",
|
||||
"current": "semver | null",
|
||||
"latest": "semver | null",
|
||||
"status": "outdated | current | pinned | manual | unknown",
|
||||
"scope": "global | project"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`status` values:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `outdated` | Re-resolving the recorded source would fetch a newer version than the installed one (for an npm range, the latest version **matching the recorded range**). Run `gsd capability update <id>` to upgrade. |
|
||||
| `current` | The installed version is the latest the recorded source would resolve to (or newer). |
|
||||
| `pinned` | The recorded source is pinned to an **immutable** ref (git `#sha:`/`#tag:`, or a bare git `#<ref>` that resolves to a tag) or an exact npm version; `update` re-resolves to the same commit/tag/version, so it can never be `outdated`. A bare git ref that resolves to a **mutable branch** is never `pinned`. |
|
||||
| `manual` | The source (a bare tarball URL) cannot be auto-checked; re-install from a new URL to upgrade. |
|
||||
| `unknown` | The peek failed (network error / timeout / non-zero exit / unparseable output), the source kind is not auto-checkable (registry), or the source tracks a mutable git branch whose installed commit was not recorded (so a moved branch HEAD cannot be compared). |
|
||||
|
||||
An empty (or missing) ledger reports nothing: `--json` emits `[]`; the table notes that there are no installed overlay capabilities.
|
||||
|
||||
---
|
||||
|
||||
### `trust`
|
||||
|
||||
Manage the **user-owned consent store** (#1459) that gates project-scope third-party capability activation. The store lives at `${GSD_HOME||homedir()}/.gsd/consent.json` — **outside any repository** — and records, per `(realpath(projectRoot), capability id)`, the bundle integrity and disclosure signature you consented to **on this machine**. A project-scope overlay is inactive until such a record exists (so a forged or cloned in-repo project ledger activates nothing on its own); installing a project-scope capability through the lifecycle writes the record, and removing it revokes the record.
|
||||
@@ -216,6 +324,8 @@ gsd capability trust revoke <id> [--project <path>]
|
||||
"scope": "project",
|
||||
"projectRoot": "/abs/realpath/of/project",
|
||||
"integrity": "sha512-… | (empty)",
|
||||
"disclosureSignature": "string",
|
||||
"contentHash": "sha512-…",
|
||||
"consentedAt": "ISO-8601 timestamp"
|
||||
}
|
||||
]
|
||||
@@ -227,27 +337,17 @@ gsd capability trust revoke <id> [--project <path>]
|
||||
|
||||
---
|
||||
|
||||
## Planned subcommands
|
||||
|
||||
These appear in ADR-1244's command surface but are **not implemented in 1.6.0**. They are documented here so the surface is explicit; invoking them returns the unknown-subcommand error listing the available set.
|
||||
|
||||
| Subcommand | Intended behaviour |
|
||||
|---|---|
|
||||
| `outdated` | Query each installed overlay's source and report those with a newer version available (`--json` for machine output). Until it ships, `update --all` re-resolves every recorded source and reports what changed. |
|
||||
|
||||
---
|
||||
|
||||
## Source specifications
|
||||
|
||||
The `install` subcommand accepts the following source specification forms.
|
||||
|
||||
| Form | Example | Adapter | `--integrity` |
|
||||
|---|---|---|---|
|
||||
| Registry name | `my-cap@gsd-registry` | Registry — fetches the capability bundle from the named registry; `integrity` is populated from the registry catalogue. | Verified over the fetched bundle. |
|
||||
| Git URL with tag | `https://github.com/org/repo.git#v1.2.0` | Git — clones/fetches at the specified tag; `#sha:<40-hex>` pins a specific commit. | **Rejected** — a clone is a directory tree, not a single hashable artifact. Pin the commit with `#sha:<commit>` instead. |
|
||||
| npm package | `npm:@org/gsd-capability-foo@^1.0.0` | npm — resolves via `npm dist-tags` / semver range; installs with `--ignore-scripts`. | Verified over the `npm pack` `.tgz` bytes (same SRI sha512 domain as a tarball). |
|
||||
| Tarball URL | `https://host/path/cap-x.y.z.tgz` | Tarball — fetches over HTTPS. | Verified over the downloaded `.tgz` bytes. |
|
||||
| Local path | `./local/path` (or an absolute path) | Local — copies from the filesystem path. Auto-update detection is not available for this form. | **Rejected** — a local directory has no single hashable artifact; integrity pinning is not supported for local sources. |
|
||||
| Registry name | `my-cap@gsd-registry` | **Reserved — not yet implemented.** The spec form parses, but there is no first-party registry endpoint, so resolution throws and the install fails. Use a git, npm, tarball, or local source today. | n/a |
|
||||
|
||||
Which source forms are *permitted* is governed by the `capabilities.strict_known_registries` policy (see [Configuration](../CONFIGURATION.md) and [the capability trust model](../explanation/capability-trust-model.md)): `null`/absent is permissive, `[]` is lockdown (no third-party sources), and a host allowlist permits only matching registries. This policy is **project-scoped** — it is read from the current project's `.planning/config.json` and applied to installs run in that project regardless of `--scope`; there is no machine-wide source allowlist. (A present-but-unparseable config fails **closed** — external installs are blocked until it is fixed.)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ In this tutorial you will build a tiny, fully declarative GSD capability from sc
|
||||
|
||||
No code is required. Declarative capabilities — those that own only prompt fragments and hook declarations, with no executable hook scripts or MCP servers — require no trust prompt at install time.
|
||||
|
||||
We will build a capability called `hello-note`. It registers a `step` at the `plan:pre` extension point that injects a short greeting fragment into the planner's context and declares that it produces a file called `HELLO.md`.
|
||||
We will build a capability called `hello-note`. It registers a `contribution` at the `plan:pre` extension point that injects a short greeting fragment into the planner's prompt and declares that it produces a file called `HELLO.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,7 +46,7 @@ Your project tree now looks like this:
|
||||
|
||||
## Step 2 — Write the prompt fragment
|
||||
|
||||
The fragment is a short Markdown file that will be injected into the planner's context when the `plan:pre` hook fires. Create it:
|
||||
The fragment is a short Markdown file that will be injected into the planner's prompt when the `plan:pre` hook fires. Create it:
|
||||
|
||||
```bash
|
||||
cat > capabilities/hello-note/fragments/plan-pre.md << 'EOF'
|
||||
@@ -57,7 +57,7 @@ Record a brief note in HELLO.md summarising the plan goal in one sentence.
|
||||
EOF
|
||||
```
|
||||
|
||||
Notice that the fragment is plain prose. The capability system inlines it into the agent prompt at dispatch time.
|
||||
Notice that the fragment is plain prose. The capability system reads this file and inlines its text when the capability is loaded, then renders it into the planner's prompt when the loop reaches `plan:pre`.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +71,7 @@ Create the manifest at `capabilities/hello-note/capability.json`:
|
||||
"role": "feature",
|
||||
"version": "0.1.0",
|
||||
"title": "Hello Note",
|
||||
"description": "Injects a greeting note step at plan:pre and produces HELLO.md.",
|
||||
"description": "Injects a greeting note at plan:pre and produces HELLO.md.",
|
||||
"tier": "standard",
|
||||
"requires": [],
|
||||
"engines": { "gsd": ">=1.6.0" },
|
||||
@@ -79,16 +79,17 @@ Create the manifest at `capabilities/hello-note/capability.json`:
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"config": {},
|
||||
"steps": [
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": { "path": "fragments/plan-pre.md" },
|
||||
"produces": ["HELLO.md"],
|
||||
"consumes": [],
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
}
|
||||
```
|
||||
@@ -97,11 +98,13 @@ A few things to notice:
|
||||
|
||||
- `version` is required in 1.6.0. Use semver.
|
||||
- `engines.gsd` is a hard gate: GSD will refuse to install or load this capability on any version older than 1.6.0.
|
||||
- `role: "feature"` means this capability adds optional behaviour to the loop — it is not a runtime descriptor.
|
||||
- The single entry in `steps` attaches at `plan:pre`. `produces` tells the registry that this step writes `HELLO.md`, which lets the registry order hooks and detect unsatisfied dependencies in more complex setups.
|
||||
- `onError: "skip"` means the loop continues even if this step fails. For a first capability that is the safe choice.
|
||||
- `role: "feature"` means this capability adds optional behaviour to the loop — it is not a runtime descriptor. A `feature` capability must declare `runtimeCompat`; `{ "supported": ["*"] }` means "every runtime".
|
||||
- This is a **contribution**, not a **step**. A contribution injects a prompt fragment into a named agent role (`into`) and needs no dispatch target. A step, by contrast, *must* carry a `ref` with exactly one of `skill`, `agent`, or `command` — so a fragment-only injection is always a contribution. That is why `steps` is left empty here.
|
||||
- `into: "planner"` names the agent role that receives the fragment. `planner` is one of the roles published by the `plan:pre` extension point (alongside `researcher` and `checker`); the value must be a role that point publishes or the manifest fails validation.
|
||||
- `produces` tells the registry that this contribution writes `HELLO.md`, which lets the registry order hooks and detect unsatisfied dependencies in more complex setups.
|
||||
- `onError: "skip"` means the loop continues even if this contribution fails. For a first capability that is the safe choice.
|
||||
|
||||
No `ref.agent` or `ref.skill` is declared here because this is a fragment-only step: the planner receives the fragment text inline and acts on it. This keeps the capability completely declarative.
|
||||
The fragment is referenced by `path`. At load time GSD reads the file and inlines its text into the registry, so the contribution carries the materialised content wherever the loop renders it. This keeps the capability completely declarative — no executable code is involved.
|
||||
|
||||
---
|
||||
|
||||
@@ -113,18 +116,21 @@ Install from the local path with `--scope project` so it is scoped only to this
|
||||
gsd capability install ./capabilities/hello-note --scope project
|
||||
```
|
||||
|
||||
You will see output similar to:
|
||||
The command emits a JSON result:
|
||||
|
||||
```
|
||||
Installing hello-note 0.1.0 …
|
||||
Role : feature
|
||||
Scope : project
|
||||
Hooks : 1 (plan:pre step)
|
||||
Executable surfaces : none
|
||||
✔ hello-note installed.
|
||||
```json
|
||||
{
|
||||
"status": "installed",
|
||||
"id": "hello-note",
|
||||
"version": "0.1.0",
|
||||
"scope": "project",
|
||||
"disclosure": [
|
||||
"This capability ships no executable surfaces (declarative only)."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Because `hello-note` declares no executable surfaces (no hook scripts, no MCP servers, no command modules) GSD copies the files to the project capability ledger without displaying a consent prompt. That is intentional — declarative capabilities are safe to install without reviewing runnable code.
|
||||
GSD copies the bundle into `.gsd/capabilities/hello-note/` and records it in the project ledger at `.gsd-capabilities.json`. Because `hello-note` declares no executable surfaces (no hook scripts, no MCP servers, no command modules) it installs without a consent prompt — the `disclosure` line confirms there was no runnable code to review. That is intentional: declarative capabilities are safe to install without reviewing executable code.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,78 +140,102 @@ Because `hello-note` declares no executable surfaces (no hook scripts, no MCP se
|
||||
gsd capability list
|
||||
```
|
||||
|
||||
You will see at least one row for `hello-note`:
|
||||
|
||||
```
|
||||
id version role scope status
|
||||
hello-note 0.1.0 feature project enabled
|
||||
```
|
||||
|
||||
You can also query the active hook set for the `plan:pre` point:
|
||||
|
||||
```bash
|
||||
gsd capability hooks plan:pre
|
||||
```
|
||||
|
||||
Expected output (abbreviated):
|
||||
`list` emits a JSON array of every capability GSD can see — the first-party ones that ship with GSD, plus any you have installed. Your `hello-note` entry appears at the end:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"capability": "hello-note",
|
||||
"point": "plan:pre",
|
||||
"kind": "step",
|
||||
"produces": ["HELLO.md"],
|
||||
"fragment": { "inline": "## Hello from hello-note\n…" }
|
||||
}
|
||||
]
|
||||
{
|
||||
"id": "hello-note",
|
||||
"role": "feature",
|
||||
"version": "0.1.0",
|
||||
"tier": "standard",
|
||||
"source": "./capabilities/hello-note",
|
||||
"scope": "project",
|
||||
"status": "active",
|
||||
"reason": null,
|
||||
"title": "Hello Note"
|
||||
}
|
||||
```
|
||||
|
||||
Notice that `fragment.inline` now contains the materialised text from `fragments/plan-pre.md`. The capability system inlined it at install time.
|
||||
`status` is `active` — the capability is installed, compatible with your GSD version, and will fire. (The other status values are `incompatible`, when the host GSD version is outside the capability's `engines.gsd` range, and `inactive`, when a project-scoped capability has not been consented on this machine.)
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Trigger the loop step
|
||||
|
||||
Start a planning session. The planner will receive the `hello-note` fragment as part of its context:
|
||||
|
||||
```bash
|
||||
gsd plan
|
||||
```
|
||||
|
||||
Watch the planner output. You will see a line noting that `hello-note` contributed a `plan:pre` step. The planner will produce `HELLO.md` in your project's planning directory as directed by the fragment.
|
||||
|
||||
If you are running in an environment where the planner agent is not configured, you can inspect what the resolver would dispatch without running the full agent:
|
||||
You can also query the active hook set for the `plan:pre` point:
|
||||
|
||||
```bash
|
||||
gsd loop render-hooks plan:pre --raw
|
||||
```
|
||||
|
||||
The JSON output will include your `hello-note` step with its inlined fragment, confirming that the capability is wired into the loop.
|
||||
The envelope is `{ point, activeHooks, rendered }`. Your contribution appears in `activeHooks` (alongside any first-party hooks active at this point):
|
||||
|
||||
```json
|
||||
{
|
||||
"capId": "hello-note",
|
||||
"kind": "contribution",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"inline": "## Hello from hello-note\n\nThis planning session was started with the hello-note capability active.\nRecord a brief note in HELLO.md summarising the plan goal in one sentence.\n",
|
||||
"path": "fragments/plan-pre.md"
|
||||
},
|
||||
"produces": ["HELLO.md"],
|
||||
"onError": "skip"
|
||||
}
|
||||
```
|
||||
|
||||
Notice that `fragment.inline` now holds the materialised text from `fragments/plan-pre.md` — GSD inlined it at load time, while keeping the original `path` for reference. The top-level `rendered` field of the envelope contains the same fragment formatted as a `<contribution from="hello-note" into="planner">…</contribution>` block, which is what the planner actually receives.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Disable the capability
|
||||
## Step 6 — See the contribution reach the planner
|
||||
|
||||
When you want to stop the step from firing, disable the capability:
|
||||
Planning is driven by a slash command, not a `gsd` subcommand. In your AI assistant, start a planning session for a phase with:
|
||||
|
||||
```bash
|
||||
gsd capability disable hello-note
|
||||
```text
|
||||
/gsd:plan-phase
|
||||
```
|
||||
|
||||
Run `gsd capability list` again. The `status` column will now show `disabled`. Run `gsd loop render-hooks plan:pre --raw` and you will see that `hello-note` is absent from the active hook set. Disabled capabilities are removed from the resolver output by construction — there is nothing feature-specific for the loop to run.
|
||||
When the planner runs, the `plan:pre` hook set is rendered into its prompt, so it receives the `hello-note` contribution and, following the fragment's instruction, records a one-line note in `HELLO.md`.
|
||||
|
||||
To re-enable it:
|
||||
You do not need to run a full planning session to confirm the wiring, though. The `loop render-hooks` command shows exactly what the loop would hand the planner — the same output you saw in Step 5:
|
||||
|
||||
```bash
|
||||
gsd capability enable hello-note
|
||||
gsd loop render-hooks plan:pre --raw
|
||||
```
|
||||
|
||||
Find `hello-note` in `activeHooks` and read the `rendered` field: the `<contribution from="hello-note" into="planner">` block is the literal text the planner receives. That confirms the capability is wired into the loop, without dispatching a single agent.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Remove the capability
|
||||
|
||||
When you want to stop the contribution from firing, remove the capability from the project:
|
||||
|
||||
```bash
|
||||
gsd capability remove hello-note --scope project
|
||||
```
|
||||
|
||||
This emits a JSON result describing what was removed:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "removed",
|
||||
"id": "hello-note",
|
||||
"scope": "project",
|
||||
"removedFiles": [
|
||||
".gsd/capabilities/hello-note"
|
||||
],
|
||||
"strippedEdits": 0,
|
||||
"dataPreserved": true
|
||||
}
|
||||
```
|
||||
|
||||
Run `gsd capability list` again and `hello-note` is gone from the array. Run `gsd loop render-hooks plan:pre --raw` and you will see it is absent from `activeHooks`: a removed capability contributes nothing to the loop.
|
||||
|
||||
Removing the installed bundle does not touch the source folder you authored under `capabilities/hello-note/` — that is your copy. To reinstall, just run the Step 4 command again.
|
||||
|
||||
---
|
||||
|
||||
## You have built your first capability
|
||||
|
||||
You scaffolded a capability folder, wrote a manifest with a single `plan:pre` step, installed it into a project-scoped ledger without a trust prompt, confirmed it in the active hook set, watched it contribute to the planning loop, and disabled it cleanly.
|
||||
You scaffolded a capability folder, wrote a manifest with a single `plan:pre` contribution, installed it into a project-scoped ledger without a trust prompt, confirmed it in the active hook set, saw it reach the planning loop, and removed it cleanly.
|
||||
|
||||
The capability you built is fully declarative: it owns a prompt fragment and a hook declaration, and no executable code was involved at any point.
|
||||
|
||||
|
||||
291
docs/tutorials/install-your-first-capability.md
Normal file
291
docs/tutorials/install-your-first-capability.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Install Your First Capability
|
||||
|
||||
In this tutorial you will install a third-party GSD capability into a project, grant it consent, confirm it is active, check whether a newer version is available, and remove it again. By the end you will have driven the whole consumer-side lifecycle once, from the command line, with every step working.
|
||||
|
||||
This is the *install* side of capabilities. If you want to *author* one, see [Build your first capability](build-your-first-capability.md) — that tutorial builds a capability; this one consumes one.
|
||||
|
||||
So that the lesson is self-contained and reproducible offline, you will first create a tiny capability bundle on disk, then install it from a local path exactly as you would install any third-party capability. The capability is called `acme-greet`. It declares a single lifecycle **hook** — an executable surface — so that you see the consent gate fire for real.
|
||||
|
||||
---
|
||||
|
||||
## Before you begin
|
||||
|
||||
You need:
|
||||
|
||||
- **GSD 1.6.0 or later** (`gsd --version`). Capability install and management is a 1.6.0 feature, and the capability you build below declares `engines.gsd: ">=1.6.0"`. On an older host the install **hard-blocks** with an `engines` error before anything is staged — it does not partially install. If `gsd --version` reports an earlier version, upgrade GSD before continuing.
|
||||
- A throwaway working directory. Create one now:
|
||||
|
||||
```bash
|
||||
mkdir ~/cap-consumer-demo && cd ~/cap-consumer-demo
|
||||
```
|
||||
|
||||
You will work inside `~/cap-consumer-demo` for the rest of this tutorial. You do **not** need to run `gsd init` or have an existing settings file — the install in Step 2 creates the host settings file (and its parent directory) for you when you pass `--shared-file`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create the capability bundle you will install
|
||||
|
||||
A third-party capability is a folder containing a `capability.json` manifest and its declared files. Create one now:
|
||||
|
||||
```bash
|
||||
mkdir -p ./acme-greet/hooks
|
||||
```
|
||||
|
||||
Write the manifest at `./acme-greet/capability.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme-greet",
|
||||
"role": "feature",
|
||||
"version": "1.0.0",
|
||||
"title": "Acme Greeter",
|
||||
"description": "Prints a greeting on a lifecycle event.",
|
||||
"tier": "standard",
|
||||
"requires": [],
|
||||
"engines": { "gsd": ">=1.6.0" },
|
||||
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"config": {},
|
||||
"hooks": [
|
||||
{ "event": "Stop", "script": "hooks/greet.sh" }
|
||||
],
|
||||
"steps": [],
|
||||
"contributions": [],
|
||||
"gates": []
|
||||
}
|
||||
```
|
||||
|
||||
The `"engines": { "gsd": ">=1.6.0" }` line is the host-compatibility gate: GSD checks it against your running version at install time, and an older host is hard-blocked with an `engines` error (see [Before you begin](#before-you-begin)). Leave it as-is.
|
||||
|
||||
Write the hook script it declares at `./acme-greet/hooks/greet.sh`:
|
||||
|
||||
```bash
|
||||
cat > ./acme-greet/hooks/greet.sh << 'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "Hello from acme-greet"
|
||||
EOF
|
||||
chmod +x ./acme-greet/hooks/greet.sh
|
||||
```
|
||||
|
||||
You now have a complete, installable bundle:
|
||||
|
||||
```text
|
||||
~/cap-consumer-demo/
|
||||
acme-greet/
|
||||
capability.json
|
||||
hooks/
|
||||
greet.sh
|
||||
```
|
||||
|
||||
Because `acme-greet` declares a `hooks` entry, it has an **executable surface**: installing it would register a script that runs on a lifecycle event. GSD will not activate that without your explicit consent. That is the gate you will see next.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Try to install it, and meet the consent gate
|
||||
|
||||
Install from the local path with `--scope project`, so the capability is scoped to this project only. Because the capability declares a runtime hook, also pass `--shared-file .claude/settings.json` — that tells GSD **which** host settings file to splice the hook registration into. (`--shared-file` is relative to the scope root, which for `--scope project` is your project directory. The file does not need to exist yet: when the install actually writes the hook, GSD creates `.claude/settings.json` and its parent directory if absent, and merges the hook into whatever is already there otherwise.) Without it, the bundle would still be staged, but its hook would never be wired into any runtime config — see Step 5:
|
||||
|
||||
```bash
|
||||
gsd capability install ./acme-greet --scope project --shared-file .claude/settings.json
|
||||
```
|
||||
|
||||
The install does **not** complete. You will see a disclosure of the executable surface and a prompt to grant consent, similar to:
|
||||
|
||||
```
|
||||
Error: This capability declares executable surfaces and needs your consent before install:
|
||||
This capability ships executable surfaces that will run in your agent runtime:
|
||||
hooks (1): run as runtime hook commands
|
||||
- Stop -> hooks/greet.sh
|
||||
Re-run with --yes to grant consent and install.
|
||||
```
|
||||
|
||||
This is intentional and is the heart of the capability trust model: **install never runs capability code**. The bundle is first copied into an isolated staging directory and its manifest is validated — still without executing anything — and then, before the capability is activated, any executable surface it declares is disclosed and must be consented to. Consent gates *activation*: nothing is promoted into place, no ledger entry or consent record is committed, and no host settings file is touched until you grant it. To understand why GSD draws the line here, read [The capability trust model](../explanation/capability-trust-model.md).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Grant consent and install
|
||||
|
||||
Re-run the same command with `--yes` to grant consent for the disclosed surface:
|
||||
|
||||
```bash
|
||||
gsd capability install ./acme-greet --scope project --shared-file .claude/settings.json --yes
|
||||
```
|
||||
|
||||
This time the install completes. You will see a confirmation naming the capability, its version, the scope, and the executable surface you consented to:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "installed",
|
||||
"id": "acme-greet",
|
||||
"version": "1.0.0",
|
||||
"scope": "project",
|
||||
"disclosure": [
|
||||
"This capability ships executable surfaces that will run in your agent runtime:",
|
||||
" hooks (1): run as runtime hook commands",
|
||||
" - Stop -> hooks/greet.sh"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Three things happened. The bundle was copied into the project's capability root at `.gsd/capabilities/acme-greet/`; the declared `Stop` hook was spliced into the `--shared-file` you named (`.claude/settings.json`); and — because this is a project-scope install — a **consent record** was written to your user-owned consent store at `${GSD_HOME:-~}/.gsd/consent.json`, bound to this project and this exact bundle. That record, not the in-repo ledger, is what lets the capability activate on this machine. The reasoning behind that split is explained in [The capability trust model](../explanation/capability-trust-model.md#the-project-scope-trust-boundary).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Confirm it loaded
|
||||
|
||||
List the capabilities visible to this project:
|
||||
|
||||
```bash
|
||||
gsd capability list
|
||||
```
|
||||
|
||||
`list` emits a JSON array. The first-party capabilities are listed first; your installed overlay `acme-greet` appears as the last entry, with `source: "./acme-greet"`, the `project` scope, and `status: "active"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme-greet",
|
||||
"role": "feature",
|
||||
"version": "1.0.0",
|
||||
"tier": "standard",
|
||||
"source": "./acme-greet",
|
||||
"scope": "project",
|
||||
"status": "active",
|
||||
"reason": null,
|
||||
"title": "Acme Greeter"
|
||||
}
|
||||
```
|
||||
|
||||
`status: "active"` is the signal that the capability is both compatible with your GSD version *and* backed by a consent record on this machine. Had you copied a bundle into `.gsd/capabilities/` by hand — with no consent record — the same row would read `status: "inactive"` with a `reason`, and the capability would contribute nothing.
|
||||
|
||||
You can also inspect what you consented to. List your project consent records:
|
||||
|
||||
```bash
|
||||
gsd capability trust list
|
||||
```
|
||||
|
||||
You will see one record for `acme-greet`, keyed by the project root, recording the bundle integrity and disclosure signature you approved:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "acme-greet",
|
||||
"scope": "project",
|
||||
"projectRoot": "/Users/you/cap-consumer-demo",
|
||||
"integrity": "",
|
||||
"disclosureSignature": "…",
|
||||
"contentHash": "…",
|
||||
"consentedAt": "2026-06-20T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
(The `integrity` field is empty for a local install — a directory has no single hashable artifact — but the `contentHash` still binds the record to the exact bundle content you installed.)
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Confirm the hook was registered
|
||||
|
||||
Because you installed with `--shared-file .claude/settings.json`, GSD spliced the capability's `Stop` hook into that file at install time. That is the step that actually wires the hook into the runtime — installing the bundle alone does **not** register a hook; only the `--shared-file` splice does. Look at the file:
|
||||
|
||||
```bash
|
||||
cat .claude/settings.json
|
||||
```
|
||||
|
||||
You will see a `hooks.Stop` entry stamped with a `_gsdCapability` marker naming the owning capability, whose `command` is the realpath-confined absolute path to the bundle's own `greet.sh`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"_gsdCapability": "acme-greet",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "'/Users/you/cap-consumer-demo/.gsd/capabilities/acme-greet/hooks/greet.sh'" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That entry is what makes the `Stop` hook run — printing `Hello from acme-greet` — the next time the runtime fires its `Stop` lifecycle event. The `_gsdCapability` marker is also what lets `remove` strip *exactly* this entry later without touching anything else in `settings.json` (you will see that in Step 7).
|
||||
|
||||
Had you installed **without** `--shared-file`, the bundle would still be on disk and `list` would still show it `active`, but `.claude/settings.json` would carry no `Stop` entry — the hook would be declared but never wired in. The `--shared-file` flag is what turns a declared hook into a registered one.
|
||||
|
||||
> **`disable`/`enable` do not apply to an installed overlay.** Those verbs validate the id against GSD's **built-in** capability registry, which does not contain capabilities you installed yourself. Running `gsd capability disable acme-greet` fails:
|
||||
>
|
||||
> ```text
|
||||
> capability set: error: unknown capability: "acme-greet"
|
||||
> Error: capability set: 1 error(s) — see above
|
||||
> ```
|
||||
>
|
||||
> `disable`/`enable`/`set` are for first-party capabilities. The off-switch for an installed overlay like `acme-greet` is `gsd capability remove` — which you will use in Step 7. (For the difference between the two paths, see [Turn a capability off](../how-to/turn-a-capability-off.md).) For now, leave `acme-greet` installed.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Check whether an update is available
|
||||
|
||||
Ask GSD whether any installed overlay capability has a newer version available:
|
||||
|
||||
```bash
|
||||
gsd capability outdated
|
||||
```
|
||||
|
||||
This prints a table with one row per installed overlay capability. For a **local** source, `outdated` re-reads the `capability.json` at the recorded path and compares its version with the installed one. The bundle you installed from is still on disk at version `1.0.0`, so the row reports `current` — there is nothing newer to fetch:
|
||||
|
||||
```
|
||||
ID Source Current Latest Status
|
||||
---------- ------ ------- ------ -------
|
||||
acme-greet local 1.0.0 1.0.0 current
|
||||
```
|
||||
|
||||
For a capability installed from a git URL or npm, `outdated` performs a metadata-only remote peek instead and reports `outdated`, `pinned`, or — when the source cannot be auto-checked — `manual` or `unknown`. It never re-clones or re-extracts a bundle, and a single failing peek never crashes the command. See the [`outdated` reference](../reference/gsd-capability-command.md#outdated) for the full per-source matrix.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Remove it
|
||||
|
||||
Remove the capability completely. Because you installed it with `--scope project`, you must remove it from the same scope — `remove` defaults to `global`, so pass `--scope project` here too:
|
||||
|
||||
```bash
|
||||
gsd capability remove acme-greet --scope project
|
||||
```
|
||||
|
||||
(Omitting `--scope` would look in the `global` scope and report `capability "acme-greet" is not installed in global scope`.)
|
||||
|
||||
You will see a confirmation listing exactly what was removed:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "removed",
|
||||
"id": "acme-greet",
|
||||
"scope": "project",
|
||||
"removedFiles": [
|
||||
".gsd/capabilities/acme-greet"
|
||||
],
|
||||
"strippedEdits": 1,
|
||||
"dataPreserved": true
|
||||
}
|
||||
```
|
||||
|
||||
`strippedEdits` is the **count** of marker-isolated fragments stripped from shared files — `1` here, because removal excised the `Stop` hook entry you saw in `.claude/settings.json` in Step 5. Removal strips **only** entries carrying this capability's `_gsdCapability` marker, so anything else in that file (your own hooks, other settings) is left untouched. `dataPreserved` is `true` because you did not pass `--purge-data` — any runtime data the capability created would be left in place; add `--purge-data` to delete it too.
|
||||
|
||||
Removal does three things, leaving no orphaned state: it deletes the bundle from `.gsd/capabilities/` and strips its hook entry from `.claude/settings.json`, removes the ledger entry, and — because this was a project-scope capability — **revokes the consent record** in your consent store. Run `cat .claude/settings.json` and the `acme-greet` `Stop` entry is gone; run `gsd capability trust list` again and the `acme-greet` record is gone; run `gsd capability list` and the `acme-greet` row is gone.
|
||||
|
||||
---
|
||||
|
||||
## You have installed your first capability
|
||||
|
||||
You created a third-party capability bundle, hit the consent gate on an executable surface, granted consent and installed it project-scoped, confirmed it activated by both `list` and `trust list`, checked for updates, and removed it cleanly — consent and all.
|
||||
|
||||
The lifecycle you just drove — *disclose, consent, activate, audit, revoke* — is the same one you would use for any capability fetched from a git URL, an npm package, or a tarball. The only difference is where the bundle comes from.
|
||||
|
||||
---
|
||||
|
||||
## Where next
|
||||
|
||||
- [Import a capability from a URL](../how-to/import-a-capability-from-a-url.md) — install a third-party capability from a git URL, tarball, or npm package.
|
||||
- [Turn a capability off](../how-to/turn-a-capability-off.md) — disable a capability or gate a single one of its hooks.
|
||||
- [Remove a capability](../how-to/remove-a-capability.md) — the full removal task, including `--purge-data`.
|
||||
- [The capability trust model](../explanation/capability-trust-model.md) — *why* install never runs code, and how consent and integrity work.
|
||||
- [How overlay capabilities compose](../explanation/capability-overlay-model.md) — *why* first-party always wins and how precedence is resolved.
|
||||
- [`gsd capability` command reference](../reference/gsd-capability-command.md) — every subcommand, flag, and output shape.
|
||||
@@ -144,7 +144,7 @@ GSD Core 是一个**元提示框架**,位于用户与 AI 编码 Agent(Claude
|
||||
|
||||
#### 工作流的渐进式披露
|
||||
|
||||
工作流文件在每次调用对应的 `/gsd-*` 命令时会被完整加载到 Claude 的上下文中。为控制该成本,`tests/workflow-size-budget.test.cjs` 强制执行的工作流大小预算与 #2361 中的 Agent 预算保持一致:
|
||||
工作流文件在每次调用对应的 `/gsd-*` 命令时会被完整加载到 Claude 的上下文中。为控制该成本,`tests/workflow-size-budget.test.cjs` 强制执行的工作流大小预算与 Agent 大小预算惯例保持一致:
|
||||
|
||||
| 层级 | 每文件行数限制 |
|
||||
|-----------|--------------------|
|
||||
@@ -152,7 +152,7 @@ GSD Core 是一个**元提示框架**,位于用户与 AI 编码 Agent(Claude
|
||||
| `LARGE` | 1500 — 多步骤规划器和大型功能工作流 |
|
||||
| `DEFAULT` | 1000 — 聚焦于单一目的的工作流(目标层级) |
|
||||
|
||||
根据 issue #2551,`workflows/discuss-phase.md` 须严格遵守 <500 行上限。当工作流超出其层级时,应将各模式的主体提取到 `workflows/<workflow>/modes/<mode>.md`,将模板提取到 `workflows/<workflow>/templates/`,将共享知识提取到 `get-shit-done/references/`。父文件成为轻量级调度器,仅读取当前调用所需的模式和模板文件。
|
||||
根据 discuss-phase 字节预算(#717;discuss-phase/modes 分割使其保持在 ≈32000 字节),`workflows/discuss-phase.md` 须严格遵守更严格的上限。当工作流超出其层级时,应将各模式的主体提取到 `workflows/<workflow>/modes/<mode>.md`,将模板提取到 `workflows/<workflow>/templates/`,将共享知识提取到 `get-shit-done/references/`。父文件成为轻量级调度器,仅读取当前调用所需的模式和模板文件。
|
||||
|
||||
`workflows/discuss-phase/` 是该模式的典型示例——父文件负责调度,`modes/` 存放各标志的行为(`power.md`、`all.md`、`auto.md`、`chain.md`、`text.md`、`batch.md`、`analyze.md`、`default.md`、`advisor.md`),`templates/` 存放 CONTEXT.md、DISCUSSION-LOG.md 以及仅在写入对应输出文件时才读取的 checkpoint.json schema。
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
| `continuation-format.md` | 会话续传/恢复格式。 |
|
||||
| `domain-probes.md` | discuss-phase 的领域特定探究问题。 |
|
||||
| `gate-prompts.md` | 关卡/检查点提示模板。 |
|
||||
| `scout-codebase.md` | discuss-phase 侦察步骤的阶段类型→代码库映射选择表(通过 #2551 提取)。 |
|
||||
| `scout-codebase.md` | discuss-phase 侦察步骤的阶段类型→代码库映射选择表(通过 discuss-phase/modes 渐进式披露分割提取,#717)。 |
|
||||
| `revision-loop.md` | 计划修订迭代模式。 |
|
||||
| `universal-anti-patterns.md` | 需要检测和避免的通用反模式。 |
|
||||
| `worktree-path-safety.md` | Worktree 守卫套件:HEAD 断言、cwd 漂移哨兵(步骤 0a,#3097)和绝对路径守卫(步骤 0b,#3099)— 通过 `<execution_context>` 加载到执行器生成提示中。 |
|
||||
|
||||
@@ -1968,6 +1968,40 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
{ enabled: capSubcommand === 'enable', runtime: capFlagValue('--runtime'), scope: capFlagValue('--scope') },
|
||||
raw,
|
||||
);
|
||||
} else if (capSubcommand === 'outdated') {
|
||||
// capability outdated [--json] [--scope global|project] — ADR-1244 D6 "Update available?".
|
||||
// For each installed overlay in the chosen scope(s), LIGHT-PEEK its recorded source for the
|
||||
// latest available version and report whether a newer one exists. This never re-clones/re-packs;
|
||||
// a failing/unsupported peek DEGRADES that row to status 'unknown' (the verb never crashes).
|
||||
const lifecycle = require('./lib/capability-lifecycle.cjs');
|
||||
const outdatedScopeArg = capFlagValue('--scope');
|
||||
if (outdatedScopeArg && outdatedScopeArg !== 'global' && outdatedScopeArg !== 'project') {
|
||||
error(`Invalid --scope "${outdatedScopeArg}": must be "global" or "project"`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
// Honor --scope (read only that scope's ledger); default sweeps both, mirroring `list`.
|
||||
const outdatedScopes = outdatedScopeArg ? [outdatedScopeArg] : ['global', 'project'];
|
||||
const records = [];
|
||||
for (const sc of outdatedScopes) {
|
||||
const { runtimeDir } = capResolveScope(sc);
|
||||
// outdatedCapabilities is read-only + non-throwing (returns [] on a missing/corrupt ledger).
|
||||
const scRecords = lifecycle.outdatedCapabilities({ runtimeDir });
|
||||
for (const r of scRecords) records.push({ ...r, scope: sc });
|
||||
}
|
||||
const asJson = raw || capHasFlag('--json');
|
||||
if (asJson) {
|
||||
output(records, false); // machine output: the records array (JSON).
|
||||
} else {
|
||||
// Human-readable table: ID | Source | Current | Latest | Status.
|
||||
const headers = ['ID', 'Source', 'Current', 'Latest', 'Status'];
|
||||
const cell = (v) => (v === null || v === undefined ? '-' : String(v));
|
||||
const tableRows = records.map((r) => [cell(r.id), cell(r.sourceKind), cell(r.current), cell(r.latest), cell(r.status)]);
|
||||
const widths = headers.map((h, i) => Math.max(h.length, ...tableRows.map((row) => row[i].length), 0));
|
||||
const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(' ').replace(/\s+$/, '');
|
||||
const lines = [fmt(headers), widths.map((w) => '-'.repeat(w)).join(' ').replace(/\s+$/, '')];
|
||||
for (const row of tableRows) lines.push(fmt(row));
|
||||
if (tableRows.length === 0) lines.push('(no installed overlay capabilities)');
|
||||
output(records, true, lines.join('\n') + '\n');
|
||||
}
|
||||
} else if (capSubcommand === 'trust') {
|
||||
// capability trust list [--scope project] [--json]
|
||||
// capability trust revoke <id> [--project <path>]
|
||||
@@ -2026,7 +2060,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
}
|
||||
} else {
|
||||
error(
|
||||
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, trust, disable, enable, state, set`,
|
||||
`Unknown capability subcommand: ${capSubcommand}. Available: install, update, remove, list, outdated, trust, disable, enable, state, set`,
|
||||
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ const capabilities = {
|
||||
"local": [
|
||||
{
|
||||
"kind": "commands",
|
||||
"destSubpath": "commands/gsd",
|
||||
"destSubpath": "commands",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
@@ -2881,7 +2881,7 @@ const runtimes = {
|
||||
"local": [
|
||||
{
|
||||
"kind": "commands",
|
||||
"destSubpath": "commands/gsd",
|
||||
"destSubpath": "commands",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
|
||||
@@ -37,6 +37,33 @@ const OLD_PACKAGE_SIGNAL = 'gsd-core' + '-cc';
|
||||
*/
|
||||
const GSD_MANAGED_SUBTREES = ['hooks', 'commands'];
|
||||
|
||||
/**
|
||||
* Substring that identifies a skill file as referencing the pre-rename GSD
|
||||
* runtime config subdirectory. Assembled from parts to avoid self-flagging.
|
||||
*
|
||||
* Old installs wrote skill bodies that embed the path to the GSD runtime
|
||||
* directory — e.g. `@$HOME/.codex/get-shit-done/workflows/plan.md`. After // gsd-allow-legacy-name
|
||||
* the rename to `gsd-core/` (#604), those embedded paths are stale and the
|
||||
* skill file must be removed so the runtime does not pick up the wrong copy.
|
||||
*
|
||||
* Issue: #1453
|
||||
*/
|
||||
const LEGACY_SKILL_PATH_SIGNAL = 'get-shit-done'; // gsd-allow-legacy-name
|
||||
|
||||
/**
|
||||
* Prefix that identifies a skill directory as GSD-managed.
|
||||
* Only `gsd-*` subdirectories under the `skills/` subtree are scanned; user
|
||||
* skill directories with other prefixes are never touched.
|
||||
*/
|
||||
const GSD_SKILL_DIR_PREFIX = 'gsd-';
|
||||
|
||||
/**
|
||||
* File extensions eligible for the stale-skill-path scan.
|
||||
* SKILL.md is the only file in a codex/cursor/kilo/etc skill directory that
|
||||
* embeds an @-import path to the GSD runtime config tree.
|
||||
*/
|
||||
const SKILL_MD_EXTENSIONS = new Set(['.md']);
|
||||
|
||||
/**
|
||||
* Extensions eligible for the content-reference scan.
|
||||
*
|
||||
@@ -113,6 +140,24 @@ function fileContainsOldPackageSignal(absPath, fsMod) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the file at `absPath` contains the legacy skill path signal
|
||||
* (`get-shit-done` as a path component inside an @-import or similar reference). // gsd-allow-legacy-name
|
||||
* Skips unreadable files (returns false on any error).
|
||||
*
|
||||
* @param {string} absPath
|
||||
* @param {object} fsMod
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function fileContainsLegacySkillPathSignal(absPath, fsMod) {
|
||||
try {
|
||||
const content = fsMod.readFileSync(absPath, 'utf8');
|
||||
return content.includes('/' + LEGACY_SKILL_PATH_SIGNAL + '/'); // gsd-allow-legacy-name
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -122,6 +167,9 @@ function fileContainsOldPackageSignal(absPath, fsMod) {
|
||||
* Possible reasons in returned entries:
|
||||
* - 'content-references-old-package': a code file whose content contains
|
||||
* the old package name signal (hooks/ and commands/ subtrees only).
|
||||
* - 'stale-get-shit-done-path': a skill markdown file whose content contains
|
||||
* a path reference to the pre-rename `get-shit-done/` runtime directory // gsd-allow-legacy-name
|
||||
* (skills/ subtree, gsd-* directories only). Issue #1453.
|
||||
* - 'legacy-shared-cache': the old package's shared update-check cache file.
|
||||
*
|
||||
* @param {string[]} configDirs - absolute paths to runtime config dirs to scan
|
||||
@@ -164,6 +212,54 @@ function planLegacyCleanup(configDirs, opts = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #1453: Scan skills/gsd-* directories for stale get-shit-done path references. // gsd-allow-legacy-name
|
||||
//
|
||||
// Background: older GSD installs wrote SKILL.md files that embedded a path to
|
||||
// the GSD runtime config directory, e.g.:
|
||||
// @$HOME/.codex/get-shit-done/workflows/docs-update.md // gsd-allow-legacy-name
|
||||
//
|
||||
// After the rename to gsd-core/ (#604), the correct path is:
|
||||
// @$HOME/.codex/gsd-core/workflows/docs-update.md
|
||||
//
|
||||
// When Codex upgrades to gsd-core 1.5.0 it writes fresh skill files to
|
||||
// ~/.codex/skills/ but does NOT remove stale copies that an older install
|
||||
// may have placed under OTHER discoverable skill roots (e.g. ~/.agents/skills/,
|
||||
// ~/.config/agents/skills/). Codex can pick up either copy and the stale one
|
||||
// breaks the session (#1453).
|
||||
//
|
||||
// This scan removes GSD-managed skill files (under gsd-* subdirs) that still
|
||||
// reference the old path. Only .md files are scanned (SKILL.md is the sole
|
||||
// embedded-path carrier in a skill dir). The skills/ dir itself is not deleted;
|
||||
// user-owned non-gsd-* skill dirs are never touched.
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
let skillDirEntries;
|
||||
try {
|
||||
skillDirEntries = fsMod.readdirSync(skillsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
skillDirEntries = null;
|
||||
}
|
||||
if (skillDirEntries) {
|
||||
for (const entry of skillDirEntries) {
|
||||
// Only process gsd-* subdirectories (GSD-managed skill dirs).
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!entry.name.startsWith(GSD_SKILL_DIR_PREFIX)) continue;
|
||||
|
||||
const skillDir = path.join(skillsDir, entry.name);
|
||||
const files = collectFilesUnder(skillDir, fsMod);
|
||||
|
||||
for (const absPath of files) {
|
||||
// Never flag user-authored dev-preferences artifacts
|
||||
if (isDevPreferencesPath(absPath)) continue;
|
||||
|
||||
// Only scan .md files for the stale path signal.
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
if (SKILL_MD_EXTENSIONS.has(ext) && fileContainsLegacySkillPathSignal(absPath, fsMod)) {
|
||||
addCandidate(absPath, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy shared cache (fixed name from the old package)
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"workflow.subagent_timeout",
|
||||
"workflow.test_command",
|
||||
"workflow.build_command",
|
||||
"workflow.mvp_mode",
|
||||
"executor.stall_detect_interval_minutes",
|
||||
"executor.stall_threshold_minutes",
|
||||
"workflow.inline_plan_threshold",
|
||||
|
||||
43
gsd-core/references/execute-phase-between-wave-reset.md
Normal file
43
gsd-core/references/execute-phase-between-wave-reset.md
Normal file
@@ -0,0 +1,43 @@
|
||||
7b. **Pre-wave dependency check (waves 2+ only):**
|
||||
Before wave N+1, run `gsd-tools.cjs query verify.key-links {phase_dir}/{plan}-PLAN.md` for each upcoming plan.
|
||||
If any PRIOR-wave artifact link fails, present:
|
||||
- `## Cross-Plan Wiring Gap` with plan/link/from/pattern rows
|
||||
- Options: investigate+fix before continue, or continue with cascade risk
|
||||
Skip key-links that reference files in the CURRENT (upcoming) wave.
|
||||
|
||||
7c. **Between-wave manifest reset and worktree base refresh (waves 2+ only — #1369):**
|
||||
|
||||
**REQUIRED before each wave transition when `USE_WORKTREES != "false"` and `RUNTIME = "claude"`.**
|
||||
|
||||
Wave N's `WAVE_WORKTREE_MANIFEST` was consumed by `worktree.cleanup-wave` in step 5.5. It must be
|
||||
unset so wave N+1's step 3 creates a fresh manifest for the new wave's worktrees. Without this,
|
||||
the wave N+1 manifest guard (step 5.5, #3384) blocks on the stale/empty consumed file.
|
||||
|
||||
After wave N merges and tracking commits, the orchestrator HEAD has advanced past the commit the
|
||||
Claude Code harness may have cached as the worktree fork base at session start. New worktrees
|
||||
spawned for wave N+1 could fork from the stale pre-wave-N HEAD, causing every executor to trip the
|
||||
`worktree_branch_check` FATAL guard immediately (symptom: `HEAD is <old-sha>, expected <new-sha>`).
|
||||
|
||||
```bash
|
||||
# Unset per-wave manifest so wave N+1 creates a fresh one (#3384, #1369).
|
||||
unset WAVE_WORKTREE_MANIFEST
|
||||
|
||||
# Between-wave base refresh (#1369): after wave N merges and tracking commits, HEAD has
|
||||
# advanced. Re-assert worktree.baseRef:"head" (idempotent — no-op if already set) so the
|
||||
# Claude Code harness re-reads the live HEAD on the next Agent(isolation="worktree") call
|
||||
# rather than using a cached session-start commit as the fork base.
|
||||
if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
gsd_run query worktree.set-baseref 2>/dev/null || true
|
||||
|
||||
# Safety re-check: evaluate degradation AFTER the wave N commits. If HEAD has diverged
|
||||
# from origin/HEAD and baseRef is NOT "head", degrade remaining waves to sequential to
|
||||
# avoid the base-mismatch FATAL in executor agents.
|
||||
_BETWEEN_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || echo "false")
|
||||
if [ "$_BETWEEN_DEGRADE" = "true" ]; then
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
[ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
|
||||
printf 'Degrading to sequential mode for remaining waves: HEAD advanced past worktree fork base after wave %s merge (#1369).\n' "${N}" >&2
|
||||
USE_WORKTREES=false
|
||||
fi
|
||||
fi
|
||||
```
|
||||
33
gsd-core/references/execute-phase-wave-guard.md
Normal file
33
gsd-core/references/execute-phase-wave-guard.md
Normal file
@@ -0,0 +1,33 @@
|
||||
0.5. **Inter-wave worktree base re-check (wave N+1 guard — #1369):**
|
||||
|
||||
After Wave N merges and tracking commits advance orchestrator HEAD, Claude Code's
|
||||
`isolation="worktree"` still forks new worktrees from `origin/HEAD` (the "fresh" base),
|
||||
not the live HEAD. This means Wave N+1 worktrees would be created from the stale
|
||||
pre-Wave-N base, causing the `worktree_branch_check` guard inside each executor to halt
|
||||
immediately with a base-mismatch fatal.
|
||||
|
||||
**Run this check at the start of every wave when `USE_WORKTREES != "false"` and
|
||||
`RUNTIME = "claude"`**, including Wave 1 (where it mirrors the initialize-step check):
|
||||
|
||||
```bash
|
||||
if [ "$RUNTIME" = "claude" ] && [ "${USE_WORKTREES:-true}" != "false" ]; then
|
||||
_WAVE_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_WAVE_DEGRADE" = "true" ]; then
|
||||
_WAVE_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
[ -n "$_WAVE_DEGRADE_MSG" ] && printf '%s\n' "$_WAVE_DEGRADE_MSG" >&2
|
||||
echo "⚠ [#1369] Worktree fork base diverged from orchestrator HEAD (wave merges advanced HEAD past origin/HEAD). Auto-degrading to sequential mode for this wave to avoid base-mismatch halts." >&2
|
||||
USE_WORKTREES=false
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
If `shouldDegrade` is `true`, override `USE_WORKTREES=false` for **this wave only** —
|
||||
all plans in this wave execute sequentially on the main working tree. Later waves re-run
|
||||
this check and may re-enable worktree isolation if `origin/HEAD` is updated (e.g. via
|
||||
`git fetch` or `worktree.baseRef:"head"` config).
|
||||
|
||||
**To avoid this degrade across all waves:** set `worktree.baseRef:"head"` in
|
||||
`.claude/settings.local.json` (or run `gsd-tools worktree set-baseref`). This tells
|
||||
Claude Code to fork from the live HEAD instead of `origin/HEAD`, so each wave's new
|
||||
worktrees always start from the correct post-merge base. See #683 for the base-ref
|
||||
configuration detail.
|
||||
@@ -174,3 +174,51 @@ If region-scoping is genuinely impractical and the file split is intentional, su
|
||||
```
|
||||
|
||||
One marker per pattern. The marker exempts only the exact pattern it names. Prefer region-scoping over suppression.
|
||||
|
||||
## CLI Output Format Anchor Mismatch (#1478)
|
||||
|
||||
`pnpm ls vite | grep -E '^vite@7\.'` looks correct but silently fails. `pnpm ls` uses tree characters as line prefixes:
|
||||
```
|
||||
my-project@1.0.0
|
||||
└── vite@7.3.5
|
||||
```
|
||||
Lines begin with `└──`, not `vite`. The `^` anchor matches line start, which is a tree character — the grep finds nothing.
|
||||
|
||||
**Bad:** `pnpm ls vite | grep -E '^vite@7\.'`
|
||||
**Good:** `pnpm ls vite | grep -E 'vite@7\.'`
|
||||
**Good (strict):** `pnpm ls vite | grep -E '(└|├)── vite@7\.'`
|
||||
|
||||
Same trap: `npm ls`, `yarn list`, `docker ps` column output, `kubectl get` table output.
|
||||
|
||||
## Fabricated Numeric Baselines (#1478)
|
||||
|
||||
Never emit `grep '714 tests'` or `grep '52 test files'` unless you ran the count command in this session. Model-recalled counts are stale from training.
|
||||
|
||||
**Bad:** `npm test 2>&1 | grep '714 passed'`
|
||||
**Good:** `npm test 2>&1 | grep -E '[0-9]+ passed'` or just `npm test`
|
||||
|
||||
## Error-Suppressing Fallbacks in Verify Gates (#1479)
|
||||
|
||||
`2>/dev/null || echo "0"` in an assignment that feeds a comparison converts any failure into a passing gate that measures nothing.
|
||||
|
||||
**Bad — both sides default to "0" when files are missing:**
|
||||
```bash
|
||||
EN_KEYS=$(jq 'keys | length' i18n/en.json 2>/dev/null || echo "0")
|
||||
DE_KEYS=$(jq 'keys | length' i18n/de.json 2>/dev/null || echo "0")
|
||||
[ "$EN_KEYS" = "$DE_KEYS" ] && echo "ok"
|
||||
```
|
||||
If files don't exist (wrong path, etc.), both sides become `"0"`. Comparison passes. Gate certifies parity while measuring nothing.
|
||||
|
||||
**Good — let failure propagate:**
|
||||
```bash
|
||||
EN_KEYS=$(jq 'keys | length' src/i18n/en.json)
|
||||
DE_KEYS=$(jq 'keys | length' src/i18n/de.json)
|
||||
[ "$EN_KEYS" = "$DE_KEYS" ] && echo "ok"
|
||||
```
|
||||
|
||||
**Good — explicit guard:**
|
||||
```bash
|
||||
test -f src/i18n/en.json && test -f src/i18n/de.json || { echo "missing input files"; exit 1; }
|
||||
```
|
||||
|
||||
**When `|| echo "default"` is acceptable:** only when absence is semantically the default AND the result is NOT used in a comparison that should detect absence.
|
||||
|
||||
@@ -266,6 +266,9 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research":
|
||||
| `workflow.subagent_timeout` | number | `300000` | Any positive integer (ms) | Timeout for parallel subagent tasks (default: 5 minutes) |
|
||||
| `workflow.test_command` | string\|null | `null` | Any shell command | Regression/test gate command run by verify-phase, execute-phase, audit-fix, and post-merge-gate. Unset → GSD auto-detects (Makefile / package.json / Cargo.toml / go.mod / pyproject.toml). |
|
||||
| `workflow.build_command` | string\|null | `null` | Any shell command | Build gate command run by the post-merge gate. Unset → build step auto-detected/skipped. |
|
||||
| `workflow.mvp_mode` | boolean | `false` | `true`, `false` | Persist the MVP-mode flag in config so every phase defaults to MVP framing without requiring `--mvp` on the CLI. Resolved via the chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → this config value → `false`. When `true`, the planner, executor, verifier, and discovery surfaces (progress, stats, graphify) all treat the phase as an MVP vertical slice (UI → API → DB) of one user-visible capability. |
|
||||
| `workflow.plan_chunked` | boolean | `false` | `true`, `false` | Enable chunked planning mode. When `true`, the plan-phase orchestrator splits the single long-lived planner Task into a short outline Task followed by N short per-plan Tasks (~3–5 min each). Each plan is committed individually for crash resilience. Particularly useful on Windows where long-lived Tasks may hang on stdio. Also activated by the `--chunked` flag. |
|
||||
| `workflow.code_review_command` | string\|null | `null` | Any shell command | External code-review command integrated into `/gsd:ship`. The diff is piped to the command via stdin; the command must output JSON with a `verdict` field (`"APPROVED"` or `"REVISE"`). Non-zero exit or `"REVISE"` verdict blocks the ship workflow. When unset, the built-in review flow runs. Example: `my-review-tool --review`. |
|
||||
| `workflow.inline_plan_threshold` | number | `2` | `0`–`10` | Plans with ≤N tasks execute inline instead of spawning a subagent |
|
||||
| `workflow.code_review` | boolean | `true` | `true`, `false` | Enable built-in code review step in the ship workflow |
|
||||
| `workflow.code_review_depth` | string | `"standard"` | `"light"`, `"standard"`, `"deep"` | Depth level for code review analysis in the ship workflow |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Codebase scout — map selection table
|
||||
|
||||
> Lazy-loaded reference for the `scout_codebase` step in
|
||||
> `workflows/discuss-phase.md` (extracted via #2551 progressive-disclosure
|
||||
> refactor). Read this only when prior `.planning/codebase/*.md` maps exist
|
||||
> `workflows/discuss-phase.md` (extracted via the discuss-phase/modes progressive-disclosure split, #717).
|
||||
> Read this only when prior `.planning/codebase/*.md` maps exist
|
||||
> and the workflow needs to pick which 2–3 to load.
|
||||
|
||||
## Phase-type → recommended maps
|
||||
|
||||
@@ -19,8 +19,7 @@ You are a thinking partner, not an interviewer. The user is the visionary — yo
|
||||
|
||||
<progressive_disclosure>
|
||||
**Per-mode bodies, templates, and the advisor flow are lazy-loaded** to keep
|
||||
this file under the 500-line workflow budget (#2551, mirrors #2361's agent
|
||||
budget). Read only the files needed for the current invocation:
|
||||
this file under the discuss-phase byte budget (32000 bytes, #717; mirrors the agent size-budget convention). Read only the files needed for the current invocation:
|
||||
|
||||
| When | Read |
|
||||
|---|---|
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> `workflows/discuss-phase.md`, immediately before writing
|
||||
> `${phase_dir}/${padded_phase}-CONTEXT.md`. Do not put a reference to this
|
||||
> file in `<required_reading>` — that defeats the progressive-disclosure
|
||||
> savings introduced by issue #2551.
|
||||
> savings from the discuss-phase/modes split (#717).
|
||||
|
||||
## Variable substitutions
|
||||
|
||||
|
||||
@@ -491,6 +491,8 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
|
||||
**For each wave:**
|
||||
|
||||
@~/.claude/gsd-core/references/execute-phase-wave-guard.md
|
||||
|
||||
1. **Intra-wave files_modified overlap check (BEFORE spawning):**
|
||||
|
||||
Before spawning any agents for this wave, inspect the `files_modified` list of all plans
|
||||
@@ -1038,12 +1040,8 @@ increases monotonically across waves. `{status}` is `complete` (success),
|
||||
**Step 7.3 — `class == "unknown-failure"`:**
|
||||
Report failed plan and ask Continue/Stop; continuing may cascade into dependent plan failures.
|
||||
|
||||
7b. **Pre-wave dependency check (waves 2+ only):**
|
||||
Before wave N+1, run `gsd-tools.cjs query verify.key-links {phase_dir}/{plan}-PLAN.md` for each upcoming plan.
|
||||
If any PRIOR-wave artifact link fails, present:
|
||||
- `## Cross-Plan Wiring Gap` with plan/link/from/pattern rows
|
||||
- Options: investigate+fix before continue, or continue with cascade risk
|
||||
Skip key-links that reference files in the CURRENT (upcoming) wave.
|
||||
@~/.claude/gsd-core/references/execute-phase-between-wave-reset.md
|
||||
|
||||
8. **Execute checkpoint plans between waves** — see `<checkpoint_handling>`.
|
||||
9. **Proceed to next wave.**
|
||||
</step>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
"gen:capability-registry": "node scripts/gen-capability-registry.cjs --write",
|
||||
"prepack": "npm run build:lib",
|
||||
"prepare": "npm run build:lib",
|
||||
"version": "node scripts/sync-manifest-versions.cjs --stage",
|
||||
"version": "node scripts/sync-manifest-versions.cjs --stage && node scripts/gen-capability-registry.cjs --write && git add gsd-core/bin/lib/capability-registry.cjs",
|
||||
"prepublishOnly": "npm run build:lib && npm run build:hooks",
|
||||
"pretest": "npm run build:lib && npm run lint:skill-deps",
|
||||
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"bug-10-semver-policy-consolidation.test.cjs",
|
||||
"bug-130-finishinstall-opencode-testmode.test.cjs",
|
||||
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
||||
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
||||
"bug-14-progress-auto-flag-dropped.test.cjs",
|
||||
"bug-167-query-meta-command.test.cjs",
|
||||
"bug-17-askuserquestion-option-cap.test.cjs",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
},
|
||||
"phase": {
|
||||
"files": [
|
||||
"fix-1437-phase-list-plans.test.cjs",
|
||||
"bug-214-phase-researcher-write-truncation-contract.test.cjs",
|
||||
"phase-dependency-levels.test.cjs",
|
||||
"phase.test.cjs"
|
||||
@@ -174,6 +175,14 @@
|
||||
"external-job-waiting.test.cjs"
|
||||
],
|
||||
"issue": "#1165"
|
||||
},
|
||||
"docs": {
|
||||
"files": [
|
||||
"docs-parity-live-registry.test.cjs",
|
||||
"docs-update.test.cjs",
|
||||
"fix-1464-docs-manifest-validation.test.cjs"
|
||||
],
|
||||
"issue": "1496"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ const sourceMod = require('./capability-source.cjs') as {
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<{ id: string; version: string; stagedDir: string; integrity: string | null; source: string }>;
|
||||
parseSpec: (spec: string) => { kind: string; raw: string; target: string; ref?: string };
|
||||
// #1463 D6 "Update available?" per-source latest-version peek. NEVER throws — returns a status the
|
||||
// `outdated` aggregation maps onto a record. The exec seam mirrors the resolver's execOverrides.
|
||||
peekLatestVersion: (
|
||||
source: string,
|
||||
opts?: { execOverrides?: Record<string, unknown> },
|
||||
) => { status: 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown'; version: string | null; reason?: string };
|
||||
};
|
||||
const ledgerMod = require('./capability-ledger.cjs') as {
|
||||
readLedger: (runtimeDir: string) => LedgerFile | null;
|
||||
@@ -80,6 +86,11 @@ const lockMod = require('./capability-lock.cjs') as {
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs') as {
|
||||
platformWriteSync: (filePath: string, content: string) => void;
|
||||
};
|
||||
// #1463: numeric major.minor.patch comparison for the outdated check (the SAME compare the resolver
|
||||
// and capability list use). -1 (a<b), 0 (equal), 1 (a>b).
|
||||
const semverMod = require('./semver-compare.cjs') as {
|
||||
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1626,6 +1637,101 @@ function reconcileCapabilities(opts: { runtimeDir: string; scope?: 'global' | 'p
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// outdatedCapabilities (ADR-1244 D6 "Update available?"; #1463)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One row of the `outdated` report: the installed capability vs. its source's latest version. */
|
||||
interface OutdatedRecord {
|
||||
id: string;
|
||||
/** Source kind discriminant (git | npm | local | tarball | registry | unknown). */
|
||||
sourceKind: string;
|
||||
/** Installed version (from the ledger entry). */
|
||||
current: string | null;
|
||||
/** Latest available version at the source, or null when not resolvable. */
|
||||
latest: string | null;
|
||||
/**
|
||||
* outdated (latest > current) | current (latest <= current) | pinned (recorded source pinned to an
|
||||
* immutable/explicit ref or exact version — update will not move it) | manual (tarball) | unknown
|
||||
* (peek failed/unsupported).
|
||||
*/
|
||||
status: 'outdated' | 'current' | 'pinned' | 'manual' | 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463 (ADR-1244 D6): for every installed overlay in `runtimeDir`'s ledger, peek its recorded source
|
||||
* for the latest available version and classify it. This is a LIGHT remote read per entry (the source
|
||||
* module's metadata-only peek); it NEVER throws on a single bad entry — that entry is reported with
|
||||
* status 'unknown'. Status rules:
|
||||
* - peek 'ok' → compare latest vs current (compareSemverCore): latest > current ⇒ 'outdated', else 'current'.
|
||||
* - peek 'pinned' → 'pinned' (#1463: source pinned to an immutable/explicit git ref or exact npm
|
||||
* version — `update` re-resolves the SAME ref/version, so it is NEVER outdated; the
|
||||
* peek's optional `version` is informational only).
|
||||
* - peek 'manual' → 'manual' (tarball: not auto-detectable per D6).
|
||||
* - peek 'unsupported'/'unknown' → 'unknown' (registry unimplemented, or the peek failed/timed out).
|
||||
*
|
||||
* An empty/missing ledger yields an empty array (non-throwing — readLedger returns null on a missing or
|
||||
* corrupt-present ledger; the `outdated` report is read-only and degrades to "nothing to report").
|
||||
*
|
||||
* @param opts.runtimeDir the scope root holding `.gsd-capabilities.json`.
|
||||
* @param opts.execOverrides threaded to the source peek (test seam — mock git ls-remote / npm view).
|
||||
*/
|
||||
function outdatedCapabilities(opts: {
|
||||
runtimeDir: string;
|
||||
execOverrides?: Record<string, unknown>;
|
||||
}): OutdatedRecord[] {
|
||||
const { runtimeDir, execOverrides } = opts;
|
||||
const records: OutdatedRecord[] = [];
|
||||
const ledger = ledgerMod.readLedger(runtimeDir);
|
||||
if (!ledger || !ledger.entries) return records;
|
||||
|
||||
for (const id of Object.keys(ledger.entries)) {
|
||||
const entry = ledger.entries[id];
|
||||
// Defensive: a hostile/partial ledger entry must never crash the sweep — report it 'unknown'.
|
||||
const current = entry && typeof entry.version === 'string' ? entry.version : null;
|
||||
const source = entry && typeof entry.source === 'string' ? entry.source : '';
|
||||
|
||||
let sourceKind = 'unknown';
|
||||
try {
|
||||
sourceKind = sourceMod.parseSpec(source).kind;
|
||||
} catch { /* unparseable source — leave kind 'unknown' */ }
|
||||
|
||||
let peek: { status: string; version: string | null };
|
||||
try {
|
||||
peek = sourceMod.peekLatestVersion(source, execOverrides ? { execOverrides } : undefined);
|
||||
} catch (err) {
|
||||
// peekLatestVersion is contractually non-throwing, but belt-and-suspenders: a single bad entry
|
||||
// must never abort the whole report.
|
||||
records.push({ id, sourceKind, current, latest: null, status: 'unknown' });
|
||||
void err;
|
||||
continue;
|
||||
}
|
||||
|
||||
let status: OutdatedRecord['status'];
|
||||
let latest: string | null = peek.version;
|
||||
if (peek.status === 'pinned') {
|
||||
// #1463: the recorded source is pinned (immutable/explicit git ref or exact npm version). `update`
|
||||
// re-resolves the SAME ref/version, so it can never be outdated. `latest` carries the peek's
|
||||
// informational version when one is known (exact-pinned npm), else null (a pinned git ref is not
|
||||
// peeked for a tag).
|
||||
status = 'pinned';
|
||||
} else if (peek.status === 'manual') {
|
||||
status = 'manual';
|
||||
} else if (peek.status === 'ok' && peek.version && current) {
|
||||
status = semverMod.compareSemverCore(peek.version, current) > 0 ? 'outdated' : 'current';
|
||||
} else if (peek.status === 'ok' && peek.version && !current) {
|
||||
// We have a latest but no recorded current — cannot compare; treat as unknown (no false 'outdated').
|
||||
status = 'unknown';
|
||||
} else {
|
||||
// unsupported / unknown / ok-but-empty → unknown.
|
||||
status = 'unknown';
|
||||
latest = peek.version ?? null;
|
||||
}
|
||||
records.push({ id, sourceKind, current, latest, status });
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1635,6 +1741,7 @@ export = {
|
||||
upgradeCapability,
|
||||
removeCapability,
|
||||
reconcileCapabilities,
|
||||
outdatedCapabilities,
|
||||
applyCapabilitySharedEdits,
|
||||
stripCapabilitySharedEdits,
|
||||
// #1460 CONF-2: exported so the ancestor-symlink confinement is locked in by a regression test.
|
||||
|
||||
@@ -42,6 +42,8 @@ const capValidator = require('./capability-validator.cjs') as ValidatorModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const semverMod = require('./semver-compare.cjs') as {
|
||||
semverSatisfies: (version: unknown, range: unknown) => boolean;
|
||||
compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1;
|
||||
isStableTripletSemver: (v: unknown) => boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -1089,6 +1091,357 @@ async function resolveCapabilitySource(spec: string, opts: ResolveOptions = {}):
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latest-version peek (ADR-1244 D6 "Update available?" per-source matrix; #1463)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* #1463: timeouts for the LIGHT remote peek the `outdated` verb performs. These are deliberately the
|
||||
* SAME bounds the resolve path uses for the analogous heavy operations (CONTEXT.md "every git/npm
|
||||
* subprocess needs a timeout"): a hung registry/remote must DEGRADE the verb (status 'unknown'), never
|
||||
* hang it. The peek is a metadata-only read (`git ls-remote --tags`, `npm view … version`), NOT a
|
||||
* clone / pack / extract.
|
||||
*/
|
||||
const PEEK_GIT_TIMEOUT_MS = 30_000;
|
||||
const PEEK_NPM_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Status of a single per-source latest-version peek.
|
||||
* - ok a latest version was resolved (compare it to installed).
|
||||
* - pinned the recorded source is pinned to an immutable/explicit ref (git `#sha:`/`#tag:`/`#<ref>`)
|
||||
* or an EXACT npm version (`npm:<pkg>@1.2.3`). `update` re-resolves to the SAME ref/version,
|
||||
* so it can never be "outdated" — version (when known) is informational only.
|
||||
* - manual a bare tarball URL — one immutable artifact, no catalogue to query.
|
||||
* - unsupported the source kind has no implemented peek (registry).
|
||||
* - unknown the peek failed / timed out / returned unparseable output (DEGRADE, never thrown).
|
||||
*/
|
||||
type PeekStatus = 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown';
|
||||
|
||||
/** Result of peekLatestVersion: a status discriminant + the resolved version when status==='ok'. */
|
||||
interface PeekResult {
|
||||
status: PeekStatus;
|
||||
/** The latest version string when status==='ok'; null otherwise. */
|
||||
version: string | null;
|
||||
/** Optional human-readable reason for a non-ok status (DEGRADE diagnostics, never thrown). */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: parse the output of `git ls-remote --tags <url>` and return the HIGHEST stable-triplet semver
|
||||
* tag, or null when no parseable semver tag exists. UNTRUSTED-DATA RULE: the remote's ref names are
|
||||
* treated purely as data — each line is `<sha>\t<ref>` (e.g. `<sha>\trefs/tags/v1.2.0`); we strip
|
||||
* `refs/tags/`, ignore the `^{}` peeled-annotation entries (they would otherwise double-count and the
|
||||
* `^{}` suffix is not a version), strip a leading `v`, and keep only STABLE x.y.z triplets
|
||||
* (isStableTripletSemver) so a `-rc`/junk tag never wins. The max is selected via compareSemverCore so
|
||||
* 1.10.0 correctly beats 1.2.0 (numeric, not lexical). Pure + deterministic → property-tested.
|
||||
*/
|
||||
function pickHighestSemverTag(lsRemoteOutput: string): string | null {
|
||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') return null;
|
||||
let best: string | null = null;
|
||||
for (const rawLine of lsRemoteOutput.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
// `<sha>\t<ref>` — take the ref (last whitespace-delimited token); a line without a tab/ref is junk.
|
||||
const tabIdx = line.search(/\s/);
|
||||
const ref = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
|
||||
if (!ref.startsWith('refs/tags/')) continue;
|
||||
let tag = ref.slice('refs/tags/'.length);
|
||||
// Ignore the peeled-annotation entry `refs/tags/<tag>^{}` — same tag, not a distinct version.
|
||||
if (tag.endsWith('^{}')) continue;
|
||||
if (tag.startsWith('v')) tag = tag.slice(1);
|
||||
// Keep only stable x.y.z triplets — a prerelease/junk tag is not an "available stable version".
|
||||
if (!semverMod.isStableTripletSemver(tag)) continue;
|
||||
if (best === null || semverMod.compareSemverCore(tag, best) > 0) best = tag;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const NPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
/**
|
||||
* #1463: split an npm package spec (the `parsed.target` for an `npm:` source) into its package NAME and
|
||||
* its trailing version/range selector. The selector is everything after the `@` that separates name from
|
||||
* version — for a SCOPED package (`@scope/name@<sel>`) that is the LAST `@`, NOT the leading scope `@`;
|
||||
* for an unscoped package (`name@<sel>`) it is the single non-leading `@`. A spec with no such `@`
|
||||
* (`@scope/name`, `name`) has selector `''` (tracks the npm `latest` dist-tag).
|
||||
*
|
||||
* Pure string parse on an already-shell-safe spec (assertSafeNpmSpec ran in parseSpec). Used ONLY to
|
||||
* classify the recorded source (exact-pin vs range vs latest) and to range-filter `npm view` output —
|
||||
* the subprocess invocation still passes the FULL `parsed.target` unchanged.
|
||||
*/
|
||||
function splitNpmSpec(target: string): { name: string; selector: string } {
|
||||
// Find the `@` that introduces the version selector: search from the END, but stop at index 0 (the
|
||||
// leading `@` of a scope is never a version separator).
|
||||
const at = target.lastIndexOf('@');
|
||||
if (at <= 0) return { name: target, selector: '' };
|
||||
return { name: target.slice(0, at), selector: target.slice(at + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: pull the ONE canonical version token out of a single `npm view <spec> version` output line, or
|
||||
* null when the line carries no version in a canonical position. UNTRUSTED-DATA RULE: the line is data.
|
||||
*
|
||||
* #1463 Fix 1 (R Medium): the version MUST come from its CANONICAL position, NOT from "any x.y.z token on
|
||||
* the line" — a package NAME can itself contain a version-like substring (`@scope/cap-1.2.3@1.0.0`) and
|
||||
* the old any-token scan returned the NAME's `1.2.3` instead of the resolved `1.0.0`. npm prints lines
|
||||
* shaped `<name>@<version> '<version>'` (range/multi-match) or a single bare `<version>` token (latest
|
||||
* dist-tag). Canonical extraction:
|
||||
* 1. Prefer the QUOTED token (`'x.y.z'`) when present — that is npm's explicit version annotation.
|
||||
* 2. Else take the token after the LAST `@` of the leading `name@version` segment (the first
|
||||
* whitespace-delimited field), mirroring splitNpmSpec's scoped last-`@` rule so a scope `@` is not
|
||||
* mistaken for the version separator.
|
||||
* 3. Else (a single bare line with no `@` and no quotes) treat the whole first field as the version.
|
||||
* The candidate is validated against NPM_VERSION_RE; a version-like substring embedded in the NAME is
|
||||
* never consulted. Returns the canonical version (unvalidated against any range) or null. Pure.
|
||||
*/
|
||||
function extractNpmLineVersion(line: string): string | null {
|
||||
// 1. Quoted annotation `'x.y.z'` — npm's explicit version field.
|
||||
const quoted = line.match(/'([^']+)'/);
|
||||
if (quoted && NPM_VERSION_RE.test(quoted[1])) return quoted[1];
|
||||
|
||||
// 2/3. The leading `name@version` (or bare `version`) field is the first whitespace-delimited token.
|
||||
const head = line.split(/\s+/, 1)[0];
|
||||
if (head === undefined || head === '') return null;
|
||||
// Last `@` that is not a leading scope `@` (index 0) separates name from version; no such `@` ⇒ the
|
||||
// whole head is the candidate (a bare `version` line). Never read a substring inside the NAME portion.
|
||||
const at = head.lastIndexOf('@');
|
||||
const candidate = at > 0 ? head.slice(at + 1) : head;
|
||||
return NPM_VERSION_RE.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: return the HIGHEST version across `npm view <spec> version` stdout that satisfies `range`
|
||||
* (compareSemverCore for max; semverSatisfies for the range bound), or null when none parse / match.
|
||||
* UNTRUSTED-DATA RULE: the output is treated purely as data. npm prints ONE annotated line per matching
|
||||
* version for a multi-version range, e.g.:
|
||||
* @org/pkg@1.0.0 '1.0.0'
|
||||
* @org/pkg@1.10.0 '1.10.0'
|
||||
* and a single bare line for a single match. Each line yields at most ONE canonical version (via
|
||||
* extractNpmLineVersion — Fix 1: the version's canonical position, NOT any token, so a version-like
|
||||
* substring in the package NAME never poisons the result). We keep only versions satisfying the recorded
|
||||
* range and pick the numeric max so 1.10.0 beats 1.2.0. An empty selector means "no range constraint"
|
||||
* (track latest) → every parseable version qualifies. Pure + deterministic.
|
||||
*/
|
||||
function pickHighestNpmVersion(viewOutput: string, range: string): string | null {
|
||||
if (typeof viewOutput !== 'string') return null;
|
||||
let best: string | null = null;
|
||||
for (const rawLine of viewOutput.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
const tok = extractNpmLineVersion(line);
|
||||
if (tok === null) continue;
|
||||
// Empty range = no constraint (track latest); otherwise the version must satisfy the recorded range.
|
||||
if (range !== '' && !semverMod.semverSatisfies(tok, range)) continue;
|
||||
if (best === null || semverMod.compareSemverCore(tok, best) > 0) best = tok;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463 Fix 2 (R Medium): classify the git ref FRAGMENT (`parsed.ref`, the raw text after `#`) by KIND.
|
||||
* parseSpec captures the WHOLE `#…` fragment as a raw string and does NOT split the kind, so we parse the
|
||||
* `sha:` / `tag:` prefix here. The kind decides mutability:
|
||||
* - 'sha' (`#sha:<commit>`) → IMMUTABLE pin (a commit never moves).
|
||||
* - 'tag' (`#tag:<name>`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag).
|
||||
* - 'bare' (`#<ref>`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The
|
||||
* caller must resolve it remotely (git ls-remote <url> <ref>) before
|
||||
* deciding pinned-vs-not — a bare branch ref is NEVER pinned.
|
||||
* - 'none' → no `#<ref>`: tracks the default branch (peek highest tag).
|
||||
* Pure string parse on an already-shell-safe ref (parseSpec asserted it). `sha:`/`tag:` are matched
|
||||
* case-insensitively with optional surrounding whitespace; the prefix's value is returned for diagnostics.
|
||||
*/
|
||||
function classifyGitRef(parsed: ParsedSpec): { kind: 'none' | 'sha' | 'tag' | 'bare'; value: string } {
|
||||
const raw = typeof parsed.ref === 'string' ? parsed.ref.trim() : '';
|
||||
if (raw === '') return { kind: 'none', value: '' };
|
||||
const shaMatch = /^sha:(.+)$/i.exec(raw);
|
||||
if (shaMatch) return { kind: 'sha', value: shaMatch[1].trim() };
|
||||
const tagMatch = /^tag:(.+)$/i.exec(raw);
|
||||
if (tagMatch) return { kind: 'tag', value: tagMatch[1].trim() };
|
||||
return { kind: 'bare', value: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463 Fix 2 (R Medium): resolve a bare ambiguous git ref to its KIND at the remote with a bounded
|
||||
* `git ls-remote <url> <ref>` (the SAME safe seam as the tag peek: argv + `--`, never a shell string).
|
||||
* ls-remote prints `<sha>\t<full-ref>` lines for every matching ref. A ref that matches under
|
||||
* `refs/tags/` is an immutable TAG; one under `refs/heads/` is a MUTABLE branch. UNTRUSTED-DATA RULE:
|
||||
* the remote's ref strings are data — we only test the canonical `refs/tags/` vs `refs/heads/` prefix on
|
||||
* the ref column (last whitespace-delimited token of each line). Returns:
|
||||
* 'tag' — at least one matching ref under refs/tags/ (and none ambiguous-conflicting branch).
|
||||
* 'branch' — at least one matching ref under refs/heads/.
|
||||
* 'unknown' — ls-remote error / timeout / non-zero / empty / unresolvable / conflicting output.
|
||||
* NEVER throws (DEGRADE). Bounded by PEEK_GIT_TIMEOUT_MS (≤30s).
|
||||
*/
|
||||
function classifyBareGitRefRemote(
|
||||
url: string,
|
||||
ref: string,
|
||||
execGit: (args: string[], o?: { timeout?: number }) => SpawnResult,
|
||||
): 'tag' | 'branch' | 'unknown' {
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Metadata-only ref lookup; `--` terminates options so a hostile URL/ref can't be read as a flag
|
||||
// (both are already transport-/shell-safe via parseSpec). The ref filters ls-remote server-side.
|
||||
r = execGit(['ls-remote', '--', url, ref], { timeout: PEEK_GIT_TIMEOUT_MS });
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) return 'unknown';
|
||||
let sawTag = false;
|
||||
let sawBranch = false;
|
||||
for (const rawLine of (r.stdout || '').split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '') continue;
|
||||
const tabIdx = line.indexOf('\t');
|
||||
const refName = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
|
||||
if (refName.startsWith('refs/tags/')) sawTag = true;
|
||||
else if (refName.startsWith('refs/heads/')) sawBranch = true;
|
||||
}
|
||||
// A clean single-kind resolution wins; anything ambiguous (both, or neither) degrades to unknown so a
|
||||
// mutable branch is never silently treated as an immutable tag (and vice-versa).
|
||||
if (sawTag && !sawBranch) return 'tag';
|
||||
if (sawBranch && !sawTag) return 'branch';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* #1463: resolve the LATEST available version for a recorded capability source string, per ADR-1244 D6
|
||||
* ("Update available?" is a per-source matrix). This is a LIGHT remote PEEK — metadata only — never a
|
||||
* re-clone / re-pack / re-extract. It NEVER throws: every error / timeout / unsupported source DEGRADES
|
||||
* to a status the `outdated` verb can render. Per-kind behaviour:
|
||||
*
|
||||
* - git `git ls-remote --tags <url>` → highest stable semver tag (pickHighestSemverTag). status 'ok'.
|
||||
* - npm `npm view <pkg> version` (latest dist-tag) → the reported version. status 'ok'.
|
||||
* - local re-read capability.json at the path (bounded reader) → its `version`. status 'ok'.
|
||||
* - tarball one immutable URL, not auto-detectable per D6 → status 'manual' (no version).
|
||||
* - registry resolveCapabilitySource throws (unimplemented) → status 'unsupported'.
|
||||
*
|
||||
* BOUNDED SUBPROCESSES (CONTEXT.md): git ls-remote ≤30s, npm view ≤60s; on timeout / non-zero / error /
|
||||
* empty-or-unparseable output → status 'unknown' (DEGRADE, never crash the verb).
|
||||
*
|
||||
* The exec seam mirrors the resolver: opts.execOverrides.{git,npm} (or the default shell seam) so a test
|
||||
* can mock the remote PEEK with no network I/O.
|
||||
*/
|
||||
function peekLatestVersion(
|
||||
source: string,
|
||||
opts: {
|
||||
execOverrides?: { git?: (args: string[], o?: { timeout?: number }) => SpawnResult; npm?: (args: string[], o?: { timeout?: number }) => SpawnResult };
|
||||
} = {},
|
||||
): PeekResult {
|
||||
let parsed: ParsedSpec;
|
||||
try {
|
||||
parsed = parseSpec(source);
|
||||
} catch (err) {
|
||||
// An unparseable recorded source cannot be peeked — DEGRADE (do not throw).
|
||||
return { status: 'unknown', version: null, reason: `unparseable source: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
switch (parsed.kind) {
|
||||
case 'git': {
|
||||
const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
|
||||
// #1463 Fix 2 (R Medium): classify the recorded ref by KIND before deciding pinned-vs-not. An
|
||||
// IMMUTABLE pin (`#sha:`/`#tag:`) is NEVER outdated — `update` re-resolves the SAME commit/tag, so
|
||||
// a newer remote tag is irrelevant; report 'pinned' WITHOUT any peek. A BARE `#<ref>` is ambiguous
|
||||
// (tag OR branch): we MUST classify it remotely so a MUTABLE branch is never falsely 'pinned'.
|
||||
const refKind = classifyGitRef(parsed);
|
||||
if (refKind.kind === 'sha' || refKind.kind === 'tag') {
|
||||
return { status: 'pinned', version: null, reason: `git source pinned to ${refKind.kind} "${refKind.value}"; update will not move it` };
|
||||
}
|
||||
if (refKind.kind === 'bare') {
|
||||
// Resolve the ambiguous ref at the remote (same safe execGit seam: argv + `--`).
|
||||
const resolved = classifyBareGitRefRemote(parsed.target, refKind.value, execGit);
|
||||
if (resolved === 'tag') {
|
||||
// An immutable tag → pinned (the ref the user recorded is a tag, not a moving branch).
|
||||
return { status: 'pinned', version: null, reason: `git source ref "${refKind.value}" resolves to an immutable tag; update will not move it` };
|
||||
}
|
||||
// A branch (MUTABLE) or an unresolvable/ambiguous result. The ledger records NO installed commit
|
||||
// sha for git sources (integrity is null), so a moved branch HEAD cannot be compared against the
|
||||
// installed commit → DEGRADE to 'unknown'. The HARD INVARIANT holds: a branch is NEVER 'pinned'.
|
||||
const reason = resolved === 'branch'
|
||||
? `git source tracks mutable branch "${refKind.value}"; no installed commit recorded to compare against`
|
||||
: `git source ref "${refKind.value}" could not be classified (tag vs branch) at the remote`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
// refKind.kind === 'none' — no `#<ref>`, tracks the default branch: peek the highest remote tag.
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Metadata-only: ls-remote lists refs without cloning. `--` terminates options so a hostile
|
||||
// URL cannot be read as a flag (the URL is already transport-allowlisted by parseSpec).
|
||||
r = execGit(['ls-remote', '--tags', '--', parsed.target], { timeout: PEEK_GIT_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `git ls-remote error: ${(err as Error).message}` };
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) {
|
||||
const reason = r && r.signal ? `git ls-remote timed out (signal ${r.signal})`
|
||||
: `git ls-remote exit ${r ? r.exitCode : 'n/a'}`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
const latest = pickHighestSemverTag(r.stdout || '');
|
||||
if (latest === null) return { status: 'unknown', version: null, reason: 'no semver tags at remote' };
|
||||
return { status: 'ok', version: latest };
|
||||
}
|
||||
case 'npm': {
|
||||
// #1463: classify the recorded npm spec — what would `update` resolve it to?
|
||||
// exact version (`@1.2.3`) → PINNED: update re-installs the SAME version, never outdated.
|
||||
// range (`@^1`, `@~1.2`, …) → peek and pick the HIGHEST version satisfying the range (multi-line).
|
||||
// no version (bare name) → peek the single `latest` dist-tag version.
|
||||
const { selector } = splitNpmSpec(parsed.target);
|
||||
// An EXACT version selector is an immutable pin (a single x.y.z[-pre], no range operator/wildcard).
|
||||
if (selector !== '' && NPM_VERSION_RE.test(selector)) {
|
||||
return { status: 'pinned', version: selector, reason: `npm source pinned to exact version "${selector}"; update will not move it` };
|
||||
}
|
||||
const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm;
|
||||
let r: SpawnResult;
|
||||
try {
|
||||
// Mirrors scripts/check-latest-version.cjs (checkLatestVersion): `npm view <spec> version` reports
|
||||
// the matching version(s). parsed.target is the npm package spec (parseSpec asserted it is free of
|
||||
// shell metacharacters) and is passed UNCHANGED — for a range npm prints every matching version,
|
||||
// for a bare name the single latest. `--` terminates options.
|
||||
r = execNpm(['view', '--', parsed.target, 'version'], { timeout: PEEK_NPM_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `npm view error: ${(err as Error).message}` };
|
||||
}
|
||||
if (!r || r.exitCode !== 0 || r.signal) {
|
||||
const reason = r && r.signal ? `npm view timed out (signal ${r.signal})`
|
||||
: `npm view exit ${r ? r.exitCode : 'n/a'}`;
|
||||
return { status: 'unknown', version: null, reason };
|
||||
}
|
||||
// Treat the OUTPUT as untrusted: extract every version token (npm prints one annotated line per
|
||||
// matching version for a range, a bare token for a single match) and pick the HIGHEST that
|
||||
// satisfies the recorded range (empty selector = no constraint → latest). Garbage / no match →
|
||||
// DEGRADE to 'unknown'.
|
||||
const version = pickHighestNpmVersion(r.stdout || '', selector);
|
||||
if (version === null) {
|
||||
return { status: 'unknown', version: null, reason: `npm view returned no matching semver version: ${(r.stdout || '').trim() || '(empty)'}` };
|
||||
}
|
||||
return { status: 'ok', version };
|
||||
}
|
||||
case 'local': {
|
||||
// Re-read the recorded local capability.json (bounded reader) for its current declared version.
|
||||
let cap: Record<string, unknown>;
|
||||
try {
|
||||
const manifestPath = path.join(path.resolve(parsed.target), 'capability.json');
|
||||
cap = readManifestBounded(manifestPath, `local capability.json not readable: ${parsed.target}`);
|
||||
} catch (err) {
|
||||
return { status: 'unknown', version: null, reason: `local re-read failed: ${(err as Error).message}` };
|
||||
}
|
||||
const version = typeof cap['version'] === 'string' ? cap['version'] : '';
|
||||
if (!version) return { status: 'unknown', version: null, reason: 'local capability.json missing version' };
|
||||
return { status: 'ok', version };
|
||||
}
|
||||
case 'tarball':
|
||||
// D6: a bare tarball URL is one immutable artifact — there is no catalogue to query, so update
|
||||
// availability cannot be auto-detected. Surface 'manual' (the user must point install at a new URL).
|
||||
return { status: 'manual', version: null, reason: 'tarball sources cannot be auto-checked; re-install from a new URL' };
|
||||
case 'registry':
|
||||
// The registry adapter is unimplemented (resolveCapabilitySource throws for it).
|
||||
return { status: 'unsupported', version: null, reason: 'registry source kind is not yet implemented' };
|
||||
default: {
|
||||
const _never: never = parsed.kind;
|
||||
return { status: 'unknown', version: null, reason: `unknown source kind: ${String(_never)}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1101,8 +1454,16 @@ export = {
|
||||
// #1461 finding 3 test seam: the exact bounded reader stageValidated uses on the COPIED manifest, so
|
||||
// a test can exercise the staged re-read directly (not just the local pre-read that shadows it).
|
||||
_readManifestBounded: readManifestBounded,
|
||||
// #1463: D6 "Update available?" per-source latest-version peek + the pure parsers it composes (the
|
||||
// git highest-semver-tag parser, the npm spec splitter, and the npm-view range/version picker).
|
||||
peekLatestVersion,
|
||||
pickHighestSemverTag,
|
||||
splitNpmSpec,
|
||||
pickHighestNpmVersion,
|
||||
MAX_RESPONSE_BYTES,
|
||||
MANIFEST_MAX_BYTES,
|
||||
MAX_STAGED_BUNDLE_BYTES,
|
||||
MAX_STAGED_BUNDLE_ENTRIES,
|
||||
PEEK_GIT_TIMEOUT_MS,
|
||||
PEEK_NPM_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
@@ -458,6 +458,14 @@ export const PHASE_COMMAND_ALIASES: CommandAlias[] = [
|
||||
],
|
||||
"subcommand": "scaffold",
|
||||
"mutation": true
|
||||
},
|
||||
{
|
||||
"canonical": "phase.list-plans",
|
||||
"aliases": [
|
||||
"phase list-plans"
|
||||
],
|
||||
"subcommand": "list-plans",
|
||||
"mutation": false
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformEnsureDir, execGit } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
@@ -390,6 +390,9 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const confirm = Array.isArray(args) && args.includes('--confirm');
|
||||
// --force bypasses the uncommitted-changes guard. Only use when the caller
|
||||
// has already archived or explicitly accepts loss of uncommitted work. (#1447)
|
||||
const force = Array.isArray(args) && args.includes('--force');
|
||||
let cleared = 0;
|
||||
|
||||
if (fs.existsSync(phasesDir)) {
|
||||
@@ -403,6 +406,45 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Guard (#1447): refuse to hard-delete phase directories that contain
|
||||
// uncommitted changes. This prevents data loss when `new-milestone` runs
|
||||
// `phases.clear --confirm` before the operator has archived or committed
|
||||
// phase work from the outgoing milestone.
|
||||
// Use `--force` to bypass this guard only when you have verified that
|
||||
// archive or commit of the outgoing phases is already done.
|
||||
if (dirs.length > 0 && !force) {
|
||||
// Compute the path relative to cwd for git status
|
||||
let relPhasesDir: string;
|
||||
try {
|
||||
relPhasesDir = path.relative(cwd, phasesDir);
|
||||
} catch {
|
||||
relPhasesDir = phasesDir;
|
||||
}
|
||||
|
||||
let gitStatusOutput = '';
|
||||
try {
|
||||
const gitResult = execGit(['status', '--porcelain', relPhasesDir], { cwd, timeout: 10_000 });
|
||||
if (gitResult.exitCode === 0) {
|
||||
gitStatusOutput = gitResult.stdout ?? '';
|
||||
}
|
||||
// If git is not available or this is not a git repo, skip the guard
|
||||
// (gitResult.exitCode non-zero → not a git repo → no uncommitted changes to protect).
|
||||
} catch {
|
||||
// git unavailable — skip guard
|
||||
}
|
||||
|
||||
const uncommittedLines = gitStatusOutput
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0);
|
||||
if (uncommittedLines.length > 0) {
|
||||
error(
|
||||
`phases clear aborted: ${uncommittedLines.length} uncommitted change${uncommittedLines.length === 1 ? '' : 's'} detected in phase directories. ` +
|
||||
`Archive or commit outgoing phase work before running this command, ` +
|
||||
`or pass --force to skip this check and permanently delete the phase directories. (#1447)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of dirs) {
|
||||
fs.rmSync(path.join(phasesDir, entry.name), { recursive: true, force: true });
|
||||
|
||||
@@ -33,6 +33,7 @@ interface PhaseHandlers {
|
||||
cmdPhaseRemove: (cwd: string, phaseNum: string, opts: { force: boolean }, raw: boolean) => void;
|
||||
cmdPhaseComplete: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
|
||||
cmdPhaseUatPassed: (cwd: string, phaseNum: string | undefined, raw: boolean, opts?: { policy?: { requireVerification?: boolean } }) => void;
|
||||
cmdPhaseListPlans: (cwd: string, phaseNum: string | undefined, raw: boolean) => void;
|
||||
}
|
||||
|
||||
interface RoutePhaseCommandOptions {
|
||||
@@ -182,6 +183,11 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
|
||||
phase.cmdPhaseUatPassed(cwd, positional[0], raw, { policy: { requireVerification } });
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
// #1437 — list plan files for a phase
|
||||
'list-plans': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseListPlans(cwd, args[2], raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -54,10 +54,16 @@ export function deriveProgressFromRoadmap(roadmapContent: string): RoadmapProgre
|
||||
);
|
||||
if (progressTableMatch) {
|
||||
const tableText = progressTableMatch[0];
|
||||
// Count data rows (rows starting with pipe then a phase number)
|
||||
const dataRowPattern = /^\|\s*\d+/gm;
|
||||
const dataRows = tableText.match(dataRowPattern);
|
||||
totalPhases = dataRows ? dataRows.length : null;
|
||||
// Count data rows (rows starting with pipe then a phase number),
|
||||
// excluding 999.x backlog phases. Mirrors init.cts /^999(?:\.|$)/ filter.
|
||||
const dataRowPattern = /^\|\s*(\d+[^|]*)\|/gm;
|
||||
let dataRowCount = 0;
|
||||
let drm: RegExpExecArray | null;
|
||||
while ((drm = dataRowPattern.exec(tableText)) !== null) {
|
||||
if (/^999\b/.test(drm[1].trim())) continue;
|
||||
dataRowCount++;
|
||||
}
|
||||
totalPhases = dataRowCount > 0 ? dataRowCount : null;
|
||||
}
|
||||
|
||||
// Sum plan counts from M/N columns in progress table
|
||||
|
||||
@@ -1825,6 +1825,40 @@ function cmdPhaseUatPassed(
|
||||
output({ phase: phaseNum, ...report }, raw);
|
||||
}
|
||||
|
||||
// #1437 — phase.list-plans: list plan files for a given phase number.
|
||||
// Returns the full scan result from scanPhasePlans so callers can read plan
|
||||
// paths without re-discovering the phase directory themselves.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||
import planScanMod = require('./plan-scan.cjs');
|
||||
const { scanPhasePlans } = planScanMod;
|
||||
|
||||
function cmdPhaseListPlans(cwd: string, phaseNum: string | undefined, raw: boolean): void {
|
||||
if (!phaseNum) {
|
||||
error('phase number required for phase list-plans');
|
||||
}
|
||||
|
||||
const phaseInfo = findPhaseInternal(cwd, phaseNum!);
|
||||
if (!phaseInfo) {
|
||||
output({ phase: phaseNum, plan_count: 0, has_plans: false, plans: [], phase_dir: null }, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseDir = path.join(cwd, (phaseInfo as unknown as Record<string, unknown>)['directory'] as string);
|
||||
const scan = scanPhasePlans(phaseDir);
|
||||
const phaseRel = (phaseInfo as unknown as Record<string, unknown>)['directory'] as string;
|
||||
|
||||
// Build absolute-usable relative paths for each plan file.
|
||||
const plans = scan.planFiles.map((f: string) => toPosixPath(path.join(phaseRel, f)));
|
||||
|
||||
output({
|
||||
phase: phaseNum,
|
||||
phase_dir: phaseRel,
|
||||
plan_count: scan.planCount,
|
||||
has_plans: scan.planCount > 0,
|
||||
plans,
|
||||
}, raw);
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdPhasesList,
|
||||
cmdPhaseNextDecimal,
|
||||
@@ -1837,5 +1871,6 @@ export = {
|
||||
cmdPhaseRemove,
|
||||
cmdPhaseComplete,
|
||||
cmdPhaseUatPassed,
|
||||
cmdPhaseListPlans,
|
||||
computeDependencyLevels,
|
||||
};
|
||||
|
||||
@@ -102,8 +102,43 @@ export function findProjectRoot(startDir: string): string {
|
||||
// config.json missing or unparseable — fall through to .git heuristic.
|
||||
}
|
||||
if (matched) return parent;
|
||||
// Heuristic: parent has .planning/ and we're inside a git repo.
|
||||
// Heuristic (3): parent has .planning/ and we're inside a git repo.
|
||||
// Before returning, check if any further ancestor has sub_repos that explicitly
|
||||
// claims our startDir — explicit sub_repos config takes precedence over the
|
||||
// implicit .git signal. (#1422)
|
||||
if (isInsideGitRepo(parent)) {
|
||||
// Lookahead: walk ancestors above `parent` to find a sub_repos claim.
|
||||
let ancestor = path.dirname(parent);
|
||||
let ancestorDepth = 0;
|
||||
while (ancestor !== fsRoot && ancestor !== home && ancestorDepth < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
||||
const ancestorPlanning = ancestor + path.sep + '.planning';
|
||||
try {
|
||||
if (fs.existsSync(ancestorPlanning) && fs.statSync(ancestorPlanning).isDirectory()) {
|
||||
const ancestorConfig = ancestor + path.sep + '.planning' + path.sep + 'config.json';
|
||||
const rawA = fs.readFileSync(ancestorConfig, 'utf-8');
|
||||
const cfgA = JSON.parse(rawA) as Record<string, unknown>;
|
||||
const subReposValueA =
|
||||
cfgA['sub_repos'] ??
|
||||
(cfgA['planning'] && typeof cfgA['planning'] === 'object'
|
||||
? (cfgA['planning'] as Record<string, unknown>)['sub_repos']
|
||||
: undefined);
|
||||
const subReposA = Array.isArray(subReposValueA) ? (subReposValueA as unknown[]) : [];
|
||||
if (subReposA.length > 0) {
|
||||
const relPathA = path.relative(ancestor, resolvedStart);
|
||||
const topSegmentA = relPathA.split(path.sep)[0];
|
||||
if (subReposA.includes(topSegmentA)) {
|
||||
return ancestor;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — config missing or unparseable, keep walking
|
||||
}
|
||||
const nextAncestor = path.dirname(ancestor);
|
||||
if (nextAncestor === ancestor) break;
|
||||
ancestor = nextAncestor;
|
||||
ancestorDepth += 1;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
for (const h of tokenizeHeadings(roadmap)) {
|
||||
if (h.level < 2 || h.level > 4) continue;
|
||||
const pm = phaseHeadingPattern.exec(h.text);
|
||||
if (pm) milestonePhaseNums.add(pm[1]);
|
||||
// Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
|
||||
if (pm && !/^999\b/.test(pm[1])) milestonePhaseNums.add(pm[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
|
||||
@@ -171,8 +171,10 @@ export function shouldPreserveExistingProgress(existingProgress: unknown, derive
|
||||
return false;
|
||||
const existing = existingProgress as ProgressRecord;
|
||||
const derived = derivedProgress as ProgressRecord;
|
||||
return (existingProgressExceedsDerived(existing, derived, 'total_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
|
||||
// total_phases is intentionally excluded from the ratchet: it must always
|
||||
// take the freshly derived value so it can correct downward (#1446).
|
||||
// Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
|
||||
return (existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_plans'));
|
||||
}
|
||||
|
||||
@@ -1402,7 +1402,8 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
// pure-word section headings (Overview, Details) while keeping
|
||||
// numeric phases (01, 05.1) and project-code IDs (PROJ-42).
|
||||
if (/\d/.test(m[1])) roadmapPhaseCount++;
|
||||
// Also exclude 999.x backlog phases. Mirrors init.cts filter.
|
||||
if (/\d/.test(m[1]) && !/^999\b/.test(m[1])) roadmapPhaseCount++;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through: phaseDirs.length used as sole count */ }
|
||||
|
||||
@@ -48,7 +48,7 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
|
||||
import worktreeSafetyMod = require('./worktree-safety.cjs');
|
||||
const { inspectWorktreeHealth } = worktreeSafetyMod;
|
||||
|
||||
const { planningDir } = planningWorkspace;
|
||||
const { planningDir, planningRoot } = planningWorkspace;
|
||||
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
|
||||
const { writeStateMd } = stateMod;
|
||||
const { MODEL_PROFILES } = modelProfilesMod;
|
||||
@@ -1235,12 +1235,18 @@ function cmdValidateHealth(
|
||||
return;
|
||||
}
|
||||
|
||||
const planBase = planningDir(cwd);
|
||||
const projectPath = path.join(planBase, 'PROJECT.md');
|
||||
const roadmapPath = path.join(planBase, 'ROADMAP.md');
|
||||
const statePath = path.join(planBase, 'STATE.md');
|
||||
const configPath = path.join(planBase, 'config.json');
|
||||
const phasesDir = path.join(planBase, 'phases');
|
||||
// rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here)
|
||||
// wsBase resolves to .planning/workstreams/<ws>/ when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/)
|
||||
const rootBase = planningRoot(cwd);
|
||||
const wsBase = planningDir(cwd);
|
||||
// planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.)
|
||||
// that are already parameterised on the workstream-aware path.
|
||||
const planBase = wsBase;
|
||||
const projectPath = path.join(rootBase, 'PROJECT.md');
|
||||
const roadmapPath = path.join(wsBase, 'ROADMAP.md');
|
||||
const statePath = path.join(wsBase, 'STATE.md');
|
||||
const configPath = path.join(rootBase, 'config.json');
|
||||
const phasesDir = path.join(wsBase, 'phases');
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string;
|
||||
|
||||
@@ -1262,7 +1268,7 @@ function cmdValidateHealth(
|
||||
else info.push(issue);
|
||||
};
|
||||
|
||||
if (!fs.existsSync(planBase)) {
|
||||
if (!fs.existsSync(rootBase)) {
|
||||
addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`);
|
||||
output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw);
|
||||
return;
|
||||
@@ -1683,11 +1689,21 @@ function cmdValidateHealth(
|
||||
}
|
||||
|
||||
if (finding['kind'] === 'stale') {
|
||||
// Do not flag the active session's worktree — removing it would be harmful.
|
||||
const worktreePath = finding['path'] as string;
|
||||
const activeCwd = process.cwd();
|
||||
const normalizedWorktree = path.resolve(worktreePath);
|
||||
const normalizedCwd = path.resolve(activeCwd);
|
||||
// Skip if the worktree IS the cwd or is an ancestor of it.
|
||||
const isActiveWorktree =
|
||||
normalizedCwd === normalizedWorktree ||
|
||||
normalizedCwd.startsWith(normalizedWorktree + path.sep);
|
||||
if (isActiveWorktree) continue;
|
||||
addIssue(
|
||||
'warning',
|
||||
'W017',
|
||||
`Stale git worktree: ${finding['path'] as string} (last modified ${finding['ageMinutes'] as number} minutes ago)`,
|
||||
`Run: git worktree remove ${finding['path'] as string} --force`,
|
||||
`Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes'] as number} minutes ago)`,
|
||||
`Run: git worktree remove ${worktreePath} --force`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1727,8 +1743,8 @@ function cmdValidateHealth(
|
||||
/* W021 check is advisory — skip on error */
|
||||
}
|
||||
|
||||
const milestonesPath = path.join(planBase, 'MILESTONES.md');
|
||||
const milestonesArchiveDir = path.join(planBase, 'milestones');
|
||||
const milestonesPath = path.join(rootBase, 'MILESTONES.md');
|
||||
const milestonesArchiveDir = path.join(rootBase, 'milestones');
|
||||
const missingFromRegistry: string[] = [];
|
||||
try {
|
||||
if (fs.existsSync(milestonesArchiveDir)) {
|
||||
@@ -1764,7 +1780,7 @@ function cmdValidateHealth(
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(planBase, { withFileTypes: true });
|
||||
const entries = fs.readdirSync(rootBase, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
@@ -1868,7 +1884,7 @@ function cmdValidateHealth(
|
||||
}
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const projectRef = path
|
||||
.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md'))
|
||||
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
let stateContent = `# Session State\n\n`;
|
||||
|
||||
@@ -386,7 +386,7 @@ describe('VERIFY: data-flow trace, environment audit, and behavioral spot-checks
|
||||
|
||||
describe('DISCUSS: discussion log generation', () => {
|
||||
test('discuss-phase workflow references DISCUSSION-LOG.md generation', () => {
|
||||
// After #2551 progressive-disclosure refactor, the DISCUSSION-LOG.md template
|
||||
// After the discuss-phase progressive-disclosure split (#717), the DISCUSSION-LOG.md template
|
||||
// body lives in workflows/discuss-phase/templates/discussion-log.md and is
|
||||
// read at the git_commit step. Both files together must satisfy the
|
||||
// documentation contract.
|
||||
@@ -402,7 +402,7 @@ describe('DISCUSS: discussion log generation', () => {
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('Audit trail only'),
|
||||
'discuss-phase (or its discussion-log template after #2551) must mark discussion log as audit-only'
|
||||
'discuss-phase (or its discussion-log template after the discuss-phase/modes split) must mark discussion log as audit-only'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"gsd-nyquist-auditor.md": 7255,
|
||||
"gsd-pattern-mapper.md": 12487,
|
||||
"gsd-phase-researcher.md": 40638,
|
||||
"gsd-plan-checker.md": 42003,
|
||||
"gsd-planner.md": 49216,
|
||||
"gsd-plan-checker.md": 44646,
|
||||
"gsd-planner.md": 49306,
|
||||
"gsd-project-researcher.md": 22014,
|
||||
"gsd-research-synthesizer.md": 13653,
|
||||
"gsd-roadmapper.md": 21781,
|
||||
|
||||
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal file
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal file
@@ -0,0 +1,162 @@
|
||||
// allow-test-rule: source-text-is-the-product #1367
|
||||
// Installed command `.md` files — their on-disk path determines the slash-command
|
||||
// namespace registered by Claude Code. Asserting the layout (flat vs. subdirectory)
|
||||
// IS a behavioral test of the deploy contract, not source-grep theater.
|
||||
|
||||
/**
|
||||
* Regression for #1367 — project-local Claude Code install writes command files to
|
||||
* `.claude/commands/gsd/<cmd>.md` (subdirectory, bare names), causing Claude Code
|
||||
* to register them as `/gsd:<cmd>` (colon namespace). The fix changes the layout to
|
||||
* write flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code
|
||||
* registers `/gsd-<cmd>` (hyphen form, matching hooks, statusline, and cross-command
|
||||
* references everywhere in the framework).
|
||||
*
|
||||
* Root cause: `bin/install.js` (the `else` branch for claude local) wrote to a
|
||||
* `commands/gsd/` subdirectory using `copyWithPathReplacement`. Claude Code treats
|
||||
* the directory name as a namespace, so `commands/gsd/update.md` became `/gsd:update`.
|
||||
*
|
||||
* Fix: write each command as `gsd-<stem>.md` directly in `commands/` (flat layout).
|
||||
* This is the same approach used for OpenCode/Kilo (see `copyFlattenedCommands`).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js --claude --local --no-sdk` in cwd.
|
||||
* GSD_TEST_MODE must be cleared so the install() main block executes.
|
||||
*/
|
||||
function runClaudeLocalInstall(cwd) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite — #1367 regression: flat gsd-<cmd>.md layout for claude local install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bug #1367 — Claude local install uses flat gsd-<cmd>.md command layout', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-'));
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('L0: commands/ directory exists after local claude install', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
`commands/ must be created by local claude install at ${commandsDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L1: command files use flat gsd-<cmd>.md names (not bare names in a subdirectory)', () => {
|
||||
// The fix: commands land as .claude/commands/gsd-<cmd>.md (flat, hyphen-prefixed).
|
||||
// Claude Code reads the stem of each file in commands/ as the command name,
|
||||
// so gsd-update.md → /gsd-update (hyphen). The old layout (commands/gsd/update.md)
|
||||
// made Claude Code use the directory as a namespace → /gsd:update (colon).
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
|
||||
|
||||
const flatGsdFiles = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
|
||||
|
||||
assert.ok(
|
||||
flatGsdFiles.length > 0,
|
||||
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md, gsd-update.md). ` +
|
||||
`Found none. Install may still be writing to commands/gsd/<cmd>.md subdirectory ` +
|
||||
`which causes /gsd:<cmd> colon namespace in Claude Code.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L2: known commands land as flat gsd-<cmd>.md files', () => {
|
||||
// Spot-check: the three commands mentioned in the issue must be present
|
||||
// as flat hyphen-prefixed files.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const knownCommands = ['gsd-update.md', 'gsd-plan-phase.md', 'gsd-help.md'];
|
||||
for (const name of knownCommands) {
|
||||
const filePath = path.join(commandsDir, name);
|
||||
assert.ok(
|
||||
fs.existsSync(filePath),
|
||||
`${name} must exist as a flat file at commands/${name}. ` +
|
||||
`If missing, the flat layout is not being written correctly.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('L3: commands/gsd/ subdirectory does NOT exist (old colon-namespace layout)', () => {
|
||||
// The old layout wrote to commands/gsd/<cmd>.md. That directory must not
|
||||
// exist after a fresh install with the fix applied.
|
||||
const oldSubdir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
`commands/gsd/ subdir must NOT exist after install. ` +
|
||||
`Its presence means the old layout is still being used — Claude Code would ` +
|
||||
`register commands as /gsd:<cmd> (colon) instead of /gsd-<cmd> (hyphen).`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L4: total flat command file count matches the staged source', () => {
|
||||
// There should be a substantial number of commands (not 0, not 1).
|
||||
// The exact count varies with profile but must be >= 20 for a full install.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const count = fs.readdirSync(commandsDir, { withFileTypes: true })
|
||||
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'))
|
||||
.length;
|
||||
assert.ok(
|
||||
count >= 20,
|
||||
`commands/ must have >= 20 flat gsd-*.md files for a full install. ` +
|
||||
`Got ${count}. Install may be silently dropping commands.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('L5: legacy migration — re-install on a pre-#1367 tree removes old commands/gsd/ subdir', () => {
|
||||
// Simulate a pre-#1367 install: create a commands/gsd/ subdirectory with a bare-name file.
|
||||
// Then re-run the installer and verify the old subdir is cleaned up.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const legacyDir = path.join(commandsDir, 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'update.md'), '# legacy update');
|
||||
|
||||
// Re-run install — should remove commands/gsd/ and write flat gsd-*.md
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyDir),
|
||||
`commands/gsd/ legacy subdir must be removed by re-install. ` +
|
||||
`The installer's legacy cleanup must remove old commands/gsd/ on upgrade.`,
|
||||
);
|
||||
// Flat form must still be present
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'gsd-update.md')),
|
||||
`gsd-update.md must exist as flat file after re-install.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,15 @@
|
||||
*
|
||||
* After a fresh local install (`--claude --local`), all /gsd-* commands
|
||||
* except /gsd-help return "Unknown skill: gsd-quick" because
|
||||
* .claude/commands/gsd/ is not populated. Claude Code reads local project
|
||||
* commands from .claude/commands/gsd/ (the commands/ format), not from
|
||||
* .claude/skills/ — only the global ~/.claude/skills/ is used for skills.
|
||||
* .claude/commands/gsd/ was not populated. Claude Code reads local project
|
||||
* commands from .claude/commands/ (one level up) using the file stem as the
|
||||
* command name.
|
||||
*
|
||||
* #1367 follow-up: the fix changed the layout from the old commands/gsd/<cmd>.md
|
||||
* (which caused /gsd:<cmd> colon namespace) to flat commands/gsd-<cmd>.md
|
||||
* (which produces /gsd-<cmd> hyphen form). This test has been updated to assert
|
||||
* the new flat layout while preserving the core invariant from #1736: commands
|
||||
* must be present and usable after a local install.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -37,9 +43,9 @@ before(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1736: local install deploys commands/gsd/ ─────────────────────────────
|
||||
// ─── #1736 + #1367: local install deploys commands in flat gsd-<cmd>.md layout ───
|
||||
|
||||
describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
|
||||
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.md layout, #1367)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -52,48 +58,63 @@ describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local install creates .claude/commands/gsd/ directory', (t) => {
|
||||
test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => {
|
||||
// #1736 invariant: commands must be deployed.
|
||||
// #1367 fix: commands land as flat gsd-<cmd>.md at commands/ (not commands/gsd/<cmd>.md).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/gsd/ directory must exist after local install'
|
||||
'.claude/commands/ directory must exist after local install'
|
||||
);
|
||||
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
flatFiles.length > 0,
|
||||
`.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}`
|
||||
);
|
||||
// The old commands/gsd/ subdirectory must NOT exist (#1367)
|
||||
const oldSubdir = path.join(commandsDir, 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
'.claude/commands/gsd/ subdir must NOT exist — flat gsd-<cmd>.md layout required (#1367)'
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys at least one .md command file to .claude/commands/gsd/', (t) => {
|
||||
test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/gsd/ must exist'
|
||||
'.claude/commands/ must exist'
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
files.length > 0,
|
||||
`.claude/commands/gsd/ must contain at least one .md file, found: ${JSON.stringify(files)}`
|
||||
`.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys quick.md to .claude/commands/gsd/', (t) => {
|
||||
test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => {
|
||||
// Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form).
|
||||
// Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd', 'quick.md');
|
||||
const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md');
|
||||
assert.ok(
|
||||
fs.existsSync(quickCmd),
|
||||
'.claude/commands/gsd/quick.md must exist after local install'
|
||||
'.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,14 +21,14 @@ const path = require('node:path');
|
||||
const DISCUSS_PHASE = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md',
|
||||
);
|
||||
// After #2551 progressive-disclosure refactor, the scout_codebase phase-type
|
||||
// After the discuss-phase progressive-disclosure split (#717), the scout_codebase phase-type
|
||||
// table and split-reads warning live in references/scout-codebase.md.
|
||||
const SCOUT_REF = path.join(
|
||||
__dirname, '..', 'gsd-core', 'references', 'scout-codebase.md',
|
||||
);
|
||||
|
||||
function readDiscussContext() {
|
||||
// Both files are required after #2551 — fail loudly if either is missing
|
||||
// Both files are required after the discuss-phase/modes split — fail loudly if either is missing
|
||||
// rather than silently weakening the regression coverage.
|
||||
for (const p of [DISCUSS_PHASE, SCOUT_REF]) {
|
||||
assert.ok(fs.existsSync(p), `Required discuss-phase context source missing: ${p}`);
|
||||
@@ -42,7 +42,7 @@ describe('discuss-phase context fixes (#2549, #2550, #2552)', () => {
|
||||
assert.ok(fs.existsSync(DISCUSS_PHASE), 'discuss-phase.md must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(SCOUT_REF),
|
||||
'references/scout-codebase.md must exist after #2551 extraction',
|
||||
'references/scout-codebase.md must exist after the discuss-phase/modes progressive-disclosure split',
|
||||
);
|
||||
src = readDiscussContext();
|
||||
});
|
||||
|
||||
@@ -138,7 +138,14 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
|
||||
// ---------------------------------------------------------------------------
|
||||
// E — Integration: real local claude install produces clean command bodies
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('E — integration: staged commands/gsd/*.md files contain no colon-namespace refs', () => {
|
||||
// E — integration: flat gsd-*.md layout + clean bodies (#1367 fix)
|
||||
//
|
||||
// Prior to #1367: commands wrote to commands/gsd/<cmd>.md (bare names in a
|
||||
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
|
||||
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
|
||||
// Code registers them as /gsd-<cmd> (hyphen form, matching all framework refs).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => {
|
||||
let tmpDir;
|
||||
const cmdNames = readCmdNames();
|
||||
const rosterRegex = buildRosterRegex(cmdNames);
|
||||
@@ -152,36 +159,45 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('E0: staged commands/gsd/ directory exists after install', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => {
|
||||
// After #1367 fix: commands land at .claude/commands/gsd-<cmd>.md (flat,
|
||||
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.md subdirectory
|
||||
// layout must NOT be created.
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
`commands/gsd/ must be created by local claude install at ${commandsDir}`,
|
||||
`commands/ must be created by local claude install at ${commandsDir}`,
|
||||
);
|
||||
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
flatFiles.length > 0,
|
||||
`commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` +
|
||||
`Found none — install may still be using the old commands/gsd/<cmd>.md subdirectory layout.`,
|
||||
);
|
||||
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
|
||||
const oldSubdir = path.join(commandsDir, 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
|
||||
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
|
||||
);
|
||||
});
|
||||
|
||||
test('E1: no staged command body contains /gsd:<known-cmd> colon refs', () => {
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/gsd/ must exist for this check to be meaningful');
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful');
|
||||
|
||||
const offenders = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (rosterRegex.test(content)) {
|
||||
const rel = path.relative(tmpDir, fullPath);
|
||||
offenders.push(rel);
|
||||
}
|
||||
}
|
||||
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
const fullPath = path.join(commandsDir, entry.name);
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (rosterRegex.test(content)) {
|
||||
offenders.push(path.relative(tmpDir, fullPath));
|
||||
}
|
||||
};
|
||||
|
||||
walk(commandsDir);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
@@ -193,29 +209,22 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install)
|
||||
|
||||
test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => {
|
||||
// Run install a second time; if the normalizer double-applies it would
|
||||
// produce garbled output like /gsd--execute-phase. Verify the directory
|
||||
// still passes the same cleanliness check after a second install.
|
||||
// produce garbled output like /gsd--execute-phase. Verify the commands
|
||||
// still pass the same cleanliness check after a second install.
|
||||
runClaudeLocalInstall(tmpDir);
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd');
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
const doubleRewriteRegex = /\/gsd--[a-z]/;
|
||||
const garbled = [];
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (doubleRewriteRegex.test(content)) {
|
||||
garbled.push(path.relative(tmpDir, fullPath));
|
||||
}
|
||||
}
|
||||
for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8');
|
||||
if (doubleRewriteRegex.test(content)) {
|
||||
garbled.push(entry.name);
|
||||
}
|
||||
};
|
||||
|
||||
walk(commandsDir);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
garbled,
|
||||
|
||||
@@ -324,10 +324,79 @@ describe('capability disable / enable', () => {
|
||||
// ─── unknown subcommand ───────────────────────────────────────────────────────
|
||||
|
||||
describe('capability (unknown)', () => {
|
||||
test('an unknown subcommand lists the full available set', () => {
|
||||
test('an unknown subcommand lists the full available set (incl. outdated)', () => {
|
||||
const r = runGsdTools(['capability', 'bogus'], makeCwd());
|
||||
assert.equal(r.success, false);
|
||||
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, trust, disable, enable, state, set/);
|
||||
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, outdated, trust, disable, enable, state, set/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── outdated (#1463) ───────────────────────────────────────────────────────
|
||||
|
||||
describe('capability outdated', () => {
|
||||
test('empty ledger → --json empty array; default table shows the empty marker', () => {
|
||||
const home = tmpDir('cap-cli-home-');
|
||||
const json = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
|
||||
assert.equal(json.success, true, `outdated --json failed: ${json.error || json.output}`);
|
||||
assert.deepEqual(parse(json.output), []);
|
||||
const table = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
|
||||
assert.equal(table.success, true, `outdated table failed: ${table.error || table.output}`);
|
||||
assert.match(table.output, /no installed overlay capabilities/i);
|
||||
});
|
||||
|
||||
test('local source whose path now declares a newer version → status outdated (records shape)', () => {
|
||||
const home = tmpDir('cap-cli-home-');
|
||||
const src = writeCapSource('outcap', { version: '1.0.0' });
|
||||
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
|
||||
// Bump the recorded LOCAL source to a newer version — the peek re-reads it.
|
||||
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
|
||||
cap.version = '2.0.0';
|
||||
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
|
||||
|
||||
const r = runGsdTools(['capability', 'outdated', '--scope', 'global', '--json'], makeCwd(), scopeEnv(home));
|
||||
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
|
||||
const rows = parse(r.output);
|
||||
const row = rows.find((x) => x.id === 'outcap');
|
||||
assert.ok(row, 'installed capability reported by outdated');
|
||||
// revert-fails: with the comparison inverted this would be 'current', not 'outdated'.
|
||||
assert.equal(row.status, 'outdated');
|
||||
assert.equal(row.current, '1.0.0');
|
||||
assert.equal(row.latest, '2.0.0');
|
||||
assert.equal(row.sourceKind, 'local');
|
||||
assert.equal(row.scope, 'global');
|
||||
});
|
||||
|
||||
test('local source at the same version → status current; default emits a table with the row', () => {
|
||||
const home = tmpDir('cap-cli-home-');
|
||||
const src = writeCapSource('samecap', { version: '1.0.0' });
|
||||
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
|
||||
const r = runGsdTools(['capability', 'outdated', '--scope', 'global'], makeCwd(), scopeEnv(home));
|
||||
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
|
||||
// Table form: header columns + the capability row with status current.
|
||||
assert.match(r.output, /ID\s+Source\s+Current\s+Latest\s+Status/);
|
||||
assert.match(r.output, /samecap\s+local\s+1\.0\.0\s+1\.0\.0\s+current/);
|
||||
});
|
||||
|
||||
test('tarball source → status manual (not auto-detectable)', () => {
|
||||
// Plant a project-scope ledger entry with a tarball source directly (install would need network).
|
||||
const cwd = makeCwd();
|
||||
const ledgerMod = require('../gsd-core/bin/lib/capability-ledger.cjs');
|
||||
ledgerMod.recordInstall(cwd, {
|
||||
id: 'tarcap', version: '1.0.0', source: 'https://host/path/cap-1.0.0.tgz',
|
||||
integrity: '', files: ['.gsd/capabilities/tarcap'], sharedEdits: [],
|
||||
});
|
||||
const r = runGsdTools(['capability', 'outdated', '--scope', 'project', '--json'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
|
||||
assert.equal(r.success, true, `outdated failed: ${r.error || r.output}`);
|
||||
const row = parse(r.output).find((x) => x.id === 'tarcap');
|
||||
assert.ok(row, 'tarball capability reported');
|
||||
assert.equal(row.status, 'manual');
|
||||
assert.equal(row.latest, null);
|
||||
});
|
||||
|
||||
test('invalid --scope is rejected', () => {
|
||||
const r = runGsdTools(['capability', 'outdated', '--scope', 'bogus'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
|
||||
assert.equal(r.success, false);
|
||||
assert.match(`${r.error}\n${r.output}`, /Invalid --scope/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3133,3 +3133,179 @@ test('IC-05/WIN-2: a consent-store write failure leaves the install status:insta
|
||||
assert.match(buf, /could not write the consent record/i, 'the warning explains the write failure');
|
||||
assert.match(buf, new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'the warning names the consent store path');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1463: outdatedCapabilities (ADR-1244 D6 "Update available?")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Plant a ledger entry with a given source string and installed version. */
|
||||
function plantEntry(dir, id, version, source) {
|
||||
ledgerMod.recordInstall(dir, {
|
||||
id, version, source, integrity: '',
|
||||
files: [`.gsd/capabilities/${id}`], sharedEdits: [],
|
||||
});
|
||||
}
|
||||
/** A git ls-remote --tags line for a tag. */
|
||||
function lsLine(tag) {
|
||||
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
|
||||
}
|
||||
|
||||
test('#1463 outdated: empty ledger → empty records (no throw)', () => {
|
||||
const dir = runtime();
|
||||
assert.deepStrictEqual(lifecycle.outdatedCapabilities({ runtimeDir: dir }), []);
|
||||
});
|
||||
|
||||
test('#1463 outdated: git source with a newer tag → status outdated; latest reported', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'gitcap', '1.0.0', 'https://github.com/org/repo.git');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.2.0')].join('\n'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
// revert-fails: an inverted comparison would report 'current' here.
|
||||
assert.strictEqual(rec.status, 'outdated');
|
||||
assert.strictEqual(rec.latest, '1.2.0');
|
||||
assert.strictEqual(rec.current, '1.0.0');
|
||||
assert.strictEqual(rec.sourceKind, 'git');
|
||||
});
|
||||
|
||||
test('#1463 outdated: git installed == latest → current', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'gitcap', '1.2.0', 'https://github.com/org/repo.git');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.2.0'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'current');
|
||||
});
|
||||
|
||||
test('#1463 outdated: git installed > latest → current (not outdated)', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'gitcap', '2.0.0', 'https://github.com/org/repo.git');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'current');
|
||||
});
|
||||
|
||||
test('#1463 outdated: npm newer → outdated; npm peek error → unknown, other caps still reported', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'npmgood', '1.0.0', 'npm:@org/good@^1');
|
||||
plantEntry(dir, 'npmbad', '1.0.0', 'npm:@org/bad@^1');
|
||||
// revert-fails: without timeout/error→unknown handling, the failing peek crashes the whole verb and
|
||||
// npmgood would never be reported.
|
||||
// #1463: the good peek returns npm's REAL multi-line range output (one line per matching version); the
|
||||
// highest version satisfying `^1` is 1.9.0 (a 2.x would be OUT of range and must NOT be chosen).
|
||||
const fakeNpm = (args) => {
|
||||
const pkg = args[args.indexOf('view') + 2]; // ['view','--',<pkg>,'version']
|
||||
if (pkg === '@org/good@^1') {
|
||||
const out = ["@org/good@1.4.0 '1.4.0'", "@org/good@1.9.0 '1.9.0'"].join('\n') + '\n';
|
||||
return { exitCode: 0, stdout: out, stderr: '', signal: null, error: null };
|
||||
}
|
||||
return { exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null };
|
||||
};
|
||||
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
|
||||
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
|
||||
assert.strictEqual(byId.npmgood.status, 'outdated');
|
||||
assert.strictEqual(byId.npmgood.latest, '1.9.0', 'highest version satisfying the recorded ^1 range');
|
||||
assert.strictEqual(byId.npmbad.status, 'unknown', 'a failing peek degrades that row only');
|
||||
assert.strictEqual(recs.length, 2, 'both capabilities are still reported');
|
||||
});
|
||||
|
||||
test('#1463 outdated: tarball → manual; registry → unknown', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'tarcap', '1.0.0', 'https://host/path/cap-1.0.0.tgz');
|
||||
plantEntry(dir, 'regcap', '1.0.0', 'my-cap@gsd-registry');
|
||||
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
|
||||
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
|
||||
assert.strictEqual(byId.tarcap.status, 'manual');
|
||||
assert.strictEqual(byId.regcap.status, 'unknown');
|
||||
});
|
||||
|
||||
test('#1463 outdated: local newer/equal → outdated/current (re-read of recorded path)', () => {
|
||||
const dir = runtime();
|
||||
const srcNew = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localnew-'));
|
||||
const srcSame = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-out-localsame-'));
|
||||
cleanups.push(srcNew, srcSame);
|
||||
fs.writeFileSync(path.join(srcNew, 'capability.json'), JSON.stringify(declarativeCap('locnew', '2.0.0')));
|
||||
fs.writeFileSync(path.join(srcSame, 'capability.json'), JSON.stringify(declarativeCap('locsame', '1.0.0')));
|
||||
plantEntry(dir, 'locnew', '1.0.0', srcNew);
|
||||
plantEntry(dir, 'locsame', '1.0.0', srcSame);
|
||||
const recs = lifecycle.outdatedCapabilities({ runtimeDir: dir });
|
||||
const byId = Object.fromEntries(recs.map((r) => [r.id, r]));
|
||||
assert.strictEqual(byId.locnew.status, 'outdated');
|
||||
assert.strictEqual(byId.locnew.latest, '2.0.0');
|
||||
assert.strictEqual(byId.locsame.status, 'current');
|
||||
});
|
||||
|
||||
test('#1463 outdated: git source pinned to a commit SHA is NEVER outdated even with a newer remote tag → status pinned', () => {
|
||||
const dir = runtime();
|
||||
// A `#sha:<commit>`-pinned source: `update` re-resolves to the SAME commit, so a newer tag at the
|
||||
// remote is irrelevant. revert-fails: without the parsed.ref pinned check, peek returns the highest
|
||||
// tag 'ok' and outdatedCapabilities compares 9.9.9 > 1.0.0 ⇒ 'outdated', failing this 'pinned' assert.
|
||||
plantEntry(dir, 'pinnedsha', '1.0.0', 'https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v9.9.9')].join('\n'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'pinned');
|
||||
assert.strictEqual(rec.sourceKind, 'git');
|
||||
});
|
||||
|
||||
test('#1463 outdated: git source pinned to an explicit tag → status pinned (not outdated)', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'pinnedtag', '1.0.0', 'https://github.com/org/repo.git#tag:v1.0.0');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v2.0.0')].join('\n'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'pinned');
|
||||
});
|
||||
|
||||
test('#1463 outdated: UNPINNED git source (no #ref) with a newer tag → still outdated', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'unpinned', '1.0.0', 'https://github.com/org/repo.git');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: [lsLine('v1.0.0'), lsLine('v1.5.0')].join('\n'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'outdated');
|
||||
assert.strictEqual(rec.latest, '1.5.0');
|
||||
});
|
||||
|
||||
test('#1463 outdated: UNPINNED git source at latest → current', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'unpinnedcur', '1.5.0', 'https://github.com/org/repo.git');
|
||||
const fakeGit = () => ({ exitCode: 0, stdout: lsLine('v1.5.0'), stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(rec.status, 'current');
|
||||
});
|
||||
|
||||
test('#1463 outdated: npm RANGE source picks highest matching (real multi-line output) → outdated when installed below it', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'npmrange', '1.0.0', 'npm:@org/cap@^1');
|
||||
// npm's REAL range output: one annotated line per matching version (not a single bare token).
|
||||
// revert-fails: the old single-token parse degrades this to 'unknown', so the 'outdated' assert fails.
|
||||
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
|
||||
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
|
||||
assert.strictEqual(rec.status, 'outdated');
|
||||
assert.strictEqual(rec.latest, '1.10.0', 'highest matching version (numeric, not lexical) is what update installs');
|
||||
});
|
||||
|
||||
test('#1463 outdated: npm RANGE source installed == highest matching → current', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'npmrangecur', '1.10.0', 'npm:@org/cap@^1');
|
||||
const multiLine = ["@org/cap@1.2.0 '1.2.0'", "@org/cap@1.10.0 '1.10.0'"].join('\n') + '\n';
|
||||
const fakeNpm = () => ({ exitCode: 0, stdout: multiLine, stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
|
||||
assert.strictEqual(rec.status, 'current');
|
||||
});
|
||||
|
||||
test('#1463 outdated: npm NO-version source (tracks latest) → outdated/current via single latest', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'npmlatest', '1.0.0', 'npm:@org/cap');
|
||||
const fakeNpm = () => ({ exitCode: 0, stdout: '2.0.0\n', stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
|
||||
assert.strictEqual(rec.status, 'outdated');
|
||||
assert.strictEqual(rec.latest, '2.0.0');
|
||||
});
|
||||
|
||||
test('#1463 outdated: npm EXACT-pinned source (@1.2.3) → status pinned (update will not move it)', () => {
|
||||
const dir = runtime();
|
||||
plantEntry(dir, 'npmpinned', '1.2.3', 'npm:@org/cap@1.2.3');
|
||||
// revert-fails: without the exact-pin → 'pinned' branch, peek runs npm view and the row classifies
|
||||
// by comparison; a registry that advertised 9.9.9 would render it 'outdated', failing this assert.
|
||||
const fakeNpm = () => ({ exitCode: 0, stdout: '9.9.9\n', stderr: '', signal: null, error: null });
|
||||
const [rec] = lifecycle.outdatedCapabilities({ runtimeDir: dir, execOverrides: { npm: fakeNpm } });
|
||||
assert.strictEqual(rec.status, 'pinned');
|
||||
});
|
||||
|
||||
@@ -30,12 +30,26 @@ const {
|
||||
parseSpec,
|
||||
_setCapabilitySourceHttpGet,
|
||||
_setHttpsGetImpl,
|
||||
peekLatestVersion,
|
||||
pickHighestSemverTag,
|
||||
splitNpmSpec,
|
||||
pickHighestNpmVersion,
|
||||
MAX_RESPONSE_BYTES,
|
||||
MANIFEST_MAX_BYTES,
|
||||
MAX_STAGED_BUNDLE_BYTES,
|
||||
MAX_STAGED_BUNDLE_ENTRIES,
|
||||
} = capSource;
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fc = require('fast-check');
|
||||
|
||||
/** Build a `git ls-remote --tags` style stdout line for a tag. */
|
||||
function lsRemoteLine(tag) {
|
||||
return `0000000000000000000000000000000000000000\trefs/tags/${tag}`;
|
||||
}
|
||||
/** A SpawnResult-shaped success. */
|
||||
function spawnOk(stdout) {
|
||||
return { exitCode: 0, stdout, stderr: '', signal: null, error: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -1410,3 +1424,405 @@ describe('#1461 finding 2 — tar header-size parse removed; NAME/TYPE guards re
|
||||
assert.ok(fs.existsSync(result.stagedDir), 'staged dir exists after extraction');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1463: pickHighestSemverTag — pure highest-stable-semver-tag parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1463 pickHighestSemverTag (git ls-remote --tags parser)', () => {
|
||||
test('BOUNDARY: picks the NUMERIC max across v1.1.0/v1.2.0/v1.10.0 + junk (1.10.0, not 1.2.0)', () => {
|
||||
// revert-fails: a lexical (string) compare would pick "1.2.0" > "1.10.0"; the numeric compare
|
||||
// (compareSemverCore) must pick 1.10.0. Junk + a non-semver tag must be ignored.
|
||||
const out = [
|
||||
lsRemoteLine('v1.1.0'),
|
||||
lsRemoteLine('v1.2.0'),
|
||||
lsRemoteLine('v1.10.0'),
|
||||
lsRemoteLine('not-a-version'),
|
||||
lsRemoteLine('release-candidate'),
|
||||
].join('\n');
|
||||
assert.strictEqual(pickHighestSemverTag(out), '1.10.0');
|
||||
});
|
||||
|
||||
test('ignores ^{} peeled-annotation entries (same tag, not a distinct version)', () => {
|
||||
const out = [
|
||||
lsRemoteLine('v2.0.0'),
|
||||
lsRemoteLine('v2.0.0^{}'),
|
||||
lsRemoteLine('v1.5.0'),
|
||||
].join('\n');
|
||||
assert.strictEqual(pickHighestSemverTag(out), '2.0.0');
|
||||
});
|
||||
|
||||
test('ignores prerelease/non-triplet tags; bare (no-v) triplets accepted', () => {
|
||||
const out = [
|
||||
lsRemoteLine('v1.0.0-rc.1'),
|
||||
lsRemoteLine('1.4.2'),
|
||||
lsRemoteLine('v2'),
|
||||
lsRemoteLine('v1.0'),
|
||||
].join('\n');
|
||||
assert.strictEqual(pickHighestSemverTag(out), '1.4.2');
|
||||
});
|
||||
|
||||
test('no parseable semver tags → null', () => {
|
||||
assert.strictEqual(pickHighestSemverTag('0000\trefs/heads/main\n0000\trefs/tags/latest'), null);
|
||||
assert.strictEqual(pickHighestSemverTag(''), null);
|
||||
});
|
||||
|
||||
test('PROPERTY (fc): result is the numeric max of the injected stable triplets, ignoring junk', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.tuple(fc.nat(50), fc.nat(50), fc.nat(50)), { minLength: 1, maxLength: 12 }),
|
||||
(triplets) => {
|
||||
const tags = triplets.map(([a, b, c]) => `v${a}.${b}.${c}`);
|
||||
// Interleave non-semver junk that must be ignored.
|
||||
const lines = [];
|
||||
for (const t of tags) {
|
||||
lines.push(lsRemoteLine(t));
|
||||
lines.push(lsRemoteLine('junk-' + t)); // non-triplet → ignored
|
||||
lines.push(lsRemoteLine(`${t}-rc.1`)); // prerelease → ignored
|
||||
}
|
||||
const got = pickHighestSemverTag(lines.join('\n'));
|
||||
// Expected max computed numerically (not lexically).
|
||||
const expected = triplets
|
||||
.slice()
|
||||
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
|
||||
.pop();
|
||||
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1463: peekLatestVersion — per-source latest-version peek (ADR-1244 D6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1463 peekLatestVersion (D6 per-source matrix)', () => {
|
||||
test('git: highest remote tag returned as latest (status ok)', () => {
|
||||
const fakeGit = (args) => {
|
||||
assert.ok(args.includes('ls-remote'), 'must use ls-remote (metadata only — no clone)');
|
||||
assert.ok(!args.includes('clone'), 'must NOT clone for a peek');
|
||||
return spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.3.0'), lsRemoteLine('v1.10.0')].join('\n'));
|
||||
};
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
|
||||
});
|
||||
|
||||
test('git: ls-remote non-zero exit → status unknown (DEGRADE, no throw)', () => {
|
||||
const fakeGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(r.status, 'unknown');
|
||||
assert.strictEqual(r.version, null);
|
||||
});
|
||||
|
||||
test('git: ls-remote timeout (signal set) → status unknown', () => {
|
||||
// revert-fails: without the timeout→unknown branch, a killed peek (signal) would not degrade.
|
||||
const fakeGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(r.status, 'unknown');
|
||||
});
|
||||
|
||||
test('git: bounded timeout is passed to execGit (≤30s)', () => {
|
||||
let seenTimeout;
|
||||
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk(lsRemoteLine('v1.0.0')); };
|
||||
peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
|
||||
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `git peek must be bounded ≤30s (got ${seenTimeout})`);
|
||||
});
|
||||
|
||||
test('git: source pinned to a commit SHA (#sha:…) → status pinned, NEVER outdated (update stays on the ref)', () => {
|
||||
// A `#sha:<commit>` pin is immutable: re-resolving the recorded source checks out the SAME commit,
|
||||
// so a newer remote tag is irrelevant. revert-fails: without the parsed.ref pinned check the peek
|
||||
// returns the highest tag with status 'ok', which outdatedCapabilities renders 'outdated' — this
|
||||
// assert (status 'pinned') then fails.
|
||||
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v9.9.9')].join('\n'));
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git#sha:abcdef1234567890abcdef1234567890abcdef12', { execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(r.status, 'pinned');
|
||||
});
|
||||
|
||||
test('git: source pinned to an explicit tag (#tag:…) → status pinned', () => {
|
||||
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v2.0.0')].join('\n'));
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git#tag:v1.0.0', { execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(r.status, 'pinned');
|
||||
});
|
||||
|
||||
test('git: bare ref (#<ref>) resolving to a TAG (refs/tags/…) → status pinned (immutable tag)', () => {
|
||||
// #1463 Fix 2 (R Medium): a bare `#<ref>` is ambiguous (tag OR branch). We classify it with a
|
||||
// bounded `git ls-remote <url> <ref>`. When the remote resolves it under refs/tags/ it is an
|
||||
// immutable tag → pinned. The ls-remote query is the SAME safe seam (argv + `--`).
|
||||
const fakeGit = (args) => {
|
||||
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote (metadata only)');
|
||||
assert.ok(args.includes('--'), 'argv must terminate options with `--`');
|
||||
assert.ok(args.includes('release-1'), 'the bare ref is passed to ls-remote for classification');
|
||||
// ls-remote <url> <ref> prints the matching ref line(s).
|
||||
return spawnOk('1111111111111111111111111111111111111111\trefs/tags/release-1');
|
||||
};
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git#release-1', { execOverrides: { git: fakeGit } });
|
||||
assert.strictEqual(r.status, 'pinned');
|
||||
});
|
||||
|
||||
test('git: bare ref (#main) resolving to a BRANCH (refs/heads/…) → NEVER pinned (mutable; no installed sha ⇒ unknown)', () => {
|
||||
// #1463 Fix 2 (R Medium) — THE bug: `repo.git#main` is a MUTABLE branch (`update` re-clones and
|
||||
// checks out the ref, so it can move). The OLD isGitRefPinned reported ANY non-empty parsed.ref as
|
||||
// 'pinned', so this asserted 'pinned' and was WRONG. The ledger records NO installed commit sha for
|
||||
// git sources (integrity is null), so a moved branch HEAD cannot be compared → DEGRADE to 'unknown'.
|
||||
// revert-fails: with the old blanket isGitRefPinned this returns 'pinned' and the !== 'pinned'
|
||||
// assert below fails (and the === 'unknown' assert fails too).
|
||||
const fakeGit = (args) => {
|
||||
assert.ok(args.includes('ls-remote'), 'classification must use ls-remote');
|
||||
return spawnOk('2222222222222222222222222222222222222222\trefs/heads/main');
|
||||
};
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
|
||||
assert.notStrictEqual(r.status, 'pinned', 'a mutable branch ref must NEVER be reported pinned');
|
||||
assert.strictEqual(r.status, 'unknown', 'no installed sha recorded ⇒ cannot compare branch HEAD ⇒ unknown');
|
||||
});
|
||||
|
||||
test('git: bare ref classification — ls-remote error/timeout → status unknown (DEGRADE, never pinned/crash)', () => {
|
||||
const errGit = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
|
||||
const r1 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: errGit } });
|
||||
assert.notStrictEqual(r1.status, 'pinned');
|
||||
assert.strictEqual(r1.status, 'unknown');
|
||||
const killGit = () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null });
|
||||
const r2 = peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: killGit } });
|
||||
assert.strictEqual(r2.status, 'unknown');
|
||||
});
|
||||
|
||||
test('git: bare ref classification — unresolvable/empty ls-remote output → status unknown (never pinned)', () => {
|
||||
const fakeGit = () => spawnOk('');
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git#mystery-ref', { execOverrides: { git: fakeGit } });
|
||||
assert.notStrictEqual(r.status, 'pinned');
|
||||
assert.strictEqual(r.status, 'unknown');
|
||||
});
|
||||
|
||||
test('git: bare ref classification — ls-remote returns BOTH refs/tags/<r> AND refs/heads/<r> (true ambiguity) → status unknown (NOT pinned)', () => {
|
||||
// #1463 accuracy fix: when ls-remote resolves a bare ref under BOTH refs/tags/ AND refs/heads/
|
||||
// the ref is genuinely ambiguous (a tag and a branch share the same name). The classifier must
|
||||
// NOT prefer the tag and report 'pinned' — the mutable branch reading means the ref could move.
|
||||
// The safe fallback is 'unknown'.
|
||||
// revert-fails: a classifier that scans lines and picks the FIRST refs/tags/ hit (or any tag-wins
|
||||
// strategy) would return 'pinned' here, making the notStrictEqual('pinned') assert below fail.
|
||||
const ambiguousRef = 'release-1';
|
||||
const fakeGit = (args) => {
|
||||
assert.ok(args.includes('ls-remote'), 'must use ls-remote for bare-ref classification');
|
||||
assert.ok(args.includes(ambiguousRef), 'the bare ref must be passed to ls-remote');
|
||||
// ls-remote output: same name exists as BOTH a tag and a branch head.
|
||||
return spawnOk(
|
||||
`1111111111111111111111111111111111111111\trefs/tags/${ambiguousRef}\n` +
|
||||
`2222222222222222222222222222222222222222\trefs/heads/${ambiguousRef}\n`,
|
||||
);
|
||||
};
|
||||
const r = peekLatestVersion(`https://github.com/org/repo.git#${ambiguousRef}`, { execOverrides: { git: fakeGit } });
|
||||
assert.notStrictEqual(r.status, 'pinned', 'ambiguous tag+branch ref must NEVER be reported pinned');
|
||||
assert.strictEqual(r.status, 'unknown', 'ambiguous ref degrades to unknown (safe fallback)');
|
||||
});
|
||||
|
||||
test('git: bare ref classification — bounded timeout (≤30s) passed to the ls-remote classify call', () => {
|
||||
let seenTimeout;
|
||||
const fakeGit = (_args, o) => { seenTimeout = o && o.timeout; return spawnOk('33\trefs/heads/main'); };
|
||||
peekLatestVersion('https://github.com/org/repo.git#main', { execOverrides: { git: fakeGit } });
|
||||
assert.ok(typeof seenTimeout === 'number' && seenTimeout <= 30_000, `classify peek must be bounded ≤30s (got ${seenTimeout})`);
|
||||
});
|
||||
|
||||
test('git: UNPINNED source (no #ref, tracks default branch) → highest tag with status ok (NOT pinned)', () => {
|
||||
// revert-fails-guard for over-pinning: an unpinned source must STILL peek and resolve a version.
|
||||
const fakeGit = () => spawnOk([lsRemoteLine('v1.0.0'), lsRemoteLine('v1.4.0')].join('\n'));
|
||||
const r = peekLatestVersion('https://github.com/org/repo.git', { execOverrides: { git: fakeGit } });
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '1.4.0' });
|
||||
});
|
||||
|
||||
test('npm: RANGE spec — npm view prints EVERY matching version (real multi-line output) → highest matching is chosen', () => {
|
||||
// npm's REAL behaviour for `npm view <pkg>@<range> version`: when the range matches multiple
|
||||
// versions it prints one annotated line PER matching version, e.g.
|
||||
// @org/gsd-cap-foo@1.0.0 '1.0.0'
|
||||
// @org/gsd-cap-foo@1.3.0 '1.3.0'
|
||||
// @org/gsd-cap-foo@1.10.0 '1.10.0'
|
||||
// (NOT a single bare token). The peek must parse ALL tokens and pick the HIGHEST numerically.
|
||||
// revert-fails: the old single-token NPM_VERSION_RE.test(stdout.trim()) parse sees multi-line
|
||||
// output as non-semver and DEGRADES to status 'unknown' — this assert then fails.
|
||||
const multiLine = [
|
||||
"@org/gsd-cap-foo@1.0.0 '1.0.0'",
|
||||
"@org/gsd-cap-foo@1.3.0 '1.3.0'",
|
||||
"@org/gsd-cap-foo@1.10.0 '1.10.0'",
|
||||
].join('\n') + '\n';
|
||||
const fakeNpm = (args, o) => {
|
||||
assert.ok(args.includes('view'), 'must use npm view');
|
||||
assert.ok(typeof o.timeout === 'number' && o.timeout <= 60_000, 'npm peek must be bounded ≤60s');
|
||||
// Invocation shape is unchanged: ['view','--',<target>,'version'] with the range still on target.
|
||||
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo@^1', 'version']);
|
||||
return spawnOk(multiLine);
|
||||
};
|
||||
const r = peekLatestVersion('npm:@org/gsd-cap-foo@^1', { execOverrides: { npm: fakeNpm } });
|
||||
// 1.10.0 must beat 1.3.0 numerically (not lexically), and it satisfies ^1.
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '1.10.0' });
|
||||
});
|
||||
|
||||
test('npm: RANGE spec — highest MATCHING version is bounded by the range (out-of-range versions ignored)', () => {
|
||||
// ^1 must NOT pick a 2.x even if npm happened to print one; the chosen version must satisfy the range.
|
||||
const multiLine = [
|
||||
"@org/cap@1.4.0 '1.4.0'",
|
||||
"@org/cap@1.9.0 '1.9.0'",
|
||||
"@org/cap@2.0.0 '2.0.0'",
|
||||
].join('\n') + '\n';
|
||||
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '1.9.0' });
|
||||
});
|
||||
|
||||
test('npm: RANGE spec — installed < highest matching ⇒ caller sees newer; installed == highest ⇒ same', () => {
|
||||
// Two ledgers: the peek itself only resolves the highest-matching version; the outdated/current
|
||||
// decision lives in outdatedCapabilities. Here we lock the peek's resolution (the input to that).
|
||||
const multiLine = [
|
||||
"@org/cap@1.2.0 '1.2.0'",
|
||||
"@org/cap@1.5.0 '1.5.0'",
|
||||
].join('\n') + '\n';
|
||||
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk(multiLine) } });
|
||||
assert.strictEqual(r.version, '1.5.0', 'highest matching is the version update would install');
|
||||
});
|
||||
|
||||
test('npm: NO-version spec (tracks latest) — single bare latest line → status ok', () => {
|
||||
const fakeNpm = (args) => {
|
||||
// No version on the spec ⇒ target is just the bare name; npm view prints a single latest token.
|
||||
assert.deepStrictEqual(args, ['view', '--', '@org/gsd-cap-foo', 'version']);
|
||||
return spawnOk('2.4.1\n');
|
||||
};
|
||||
const r = peekLatestVersion('npm:@org/gsd-cap-foo', { execOverrides: { npm: fakeNpm } });
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '2.4.1' });
|
||||
});
|
||||
|
||||
test('npm: EXACT-pinned spec (@1.2.3) → status pinned (update re-resolves to the SAME version, never outdated)', () => {
|
||||
// revert-fails: without the exact-pin → 'pinned' branch, the npm peek would run npm view and
|
||||
// compare, so a pinned source could be reported outdated; this assert requires status 'pinned'.
|
||||
let called = false;
|
||||
const fakeNpm = () => { called = true; return spawnOk('9.9.9\n'); };
|
||||
const r = peekLatestVersion('npm:@org/gsd-cap-foo@1.2.3', { execOverrides: { npm: fakeNpm } });
|
||||
assert.strictEqual(r.status, 'pinned');
|
||||
assert.strictEqual(r.version, '1.2.3', 'pinned reports the pinned exact version');
|
||||
assert.strictEqual(called, false, 'an exact-pinned npm source needs no remote peek (update will not move it)');
|
||||
});
|
||||
|
||||
test('npm: npm view error/timeout → status unknown (no crash)', () => {
|
||||
const r1 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: 1, stdout: '', stderr: 'E404', signal: null, error: null }) } });
|
||||
assert.strictEqual(r1.status, 'unknown');
|
||||
const r2 = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => ({ exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: null }) } });
|
||||
assert.strictEqual(r2.status, 'unknown');
|
||||
});
|
||||
|
||||
test('npm: non-semver output → status unknown (untrusted output)', () => {
|
||||
const r = peekLatestVersion('npm:@org/cap@^1', { execOverrides: { npm: () => spawnOk('not a version\n') } });
|
||||
assert.strictEqual(r.status, 'unknown');
|
||||
});
|
||||
|
||||
test('local: re-reads capability.json version (status ok)', () => {
|
||||
const dir = makeLocalCap(featureCap('local-peek', { version: '3.1.0' }));
|
||||
try {
|
||||
const r = peekLatestVersion(dir);
|
||||
assert.deepStrictEqual(r, { status: 'ok', version: '3.1.0' });
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('local: missing path → status unknown (no throw)', () => {
|
||||
const r = peekLatestVersion('/no/such/path/that/exists');
|
||||
assert.strictEqual(r.status, 'unknown');
|
||||
});
|
||||
|
||||
test('tarball: not auto-detectable → status manual (no version)', () => {
|
||||
const r = peekLatestVersion('https://host/path/cap-1.0.0.tgz');
|
||||
assert.strictEqual(r.status, 'manual');
|
||||
assert.strictEqual(r.version, null);
|
||||
});
|
||||
|
||||
test('registry: unimplemented → status unsupported', () => {
|
||||
const r = peekLatestVersion('my-cap@gsd-registry');
|
||||
assert.strictEqual(r.status, 'unsupported');
|
||||
assert.strictEqual(r.version, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1463: pure npm-spec / npm-view parsers (splitNpmSpec, pickHighestNpmVersion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#1463 splitNpmSpec (name vs version-selector)', () => {
|
||||
test('scoped package with exact version → split at the LAST @ (not the scope @)', () => {
|
||||
assert.deepStrictEqual(splitNpmSpec('@org/pkg@1.2.3'), { name: '@org/pkg', selector: '1.2.3' });
|
||||
});
|
||||
test('scoped package with range → selector is the range', () => {
|
||||
assert.deepStrictEqual(splitNpmSpec('@org/pkg@^1'), { name: '@org/pkg', selector: '^1' });
|
||||
});
|
||||
test('scoped package, no version → empty selector (tracks latest)', () => {
|
||||
assert.deepStrictEqual(splitNpmSpec('@org/pkg'), { name: '@org/pkg', selector: '' });
|
||||
});
|
||||
test('unscoped package with version → split at the single @', () => {
|
||||
assert.deepStrictEqual(splitNpmSpec('pkg@2.0.0'), { name: 'pkg', selector: '2.0.0' });
|
||||
});
|
||||
test('unscoped package, no version → empty selector', () => {
|
||||
assert.deepStrictEqual(splitNpmSpec('pkg'), { name: 'pkg', selector: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1463 pickHighestNpmVersion (robust multi-line range parse)', () => {
|
||||
test('multi-line annotated range output → highest matching (numeric, not lexical)', () => {
|
||||
const out = ["@org/pkg@1.2.0 '1.2.0'", "@org/pkg@1.10.0 '1.10.0'", "@org/pkg@1.3.0 '1.3.0'"].join('\n');
|
||||
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.10.0');
|
||||
});
|
||||
test('range bound is honored — out-of-range versions ignored', () => {
|
||||
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.0.0 '2.0.0'"].join('\n');
|
||||
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.9.0');
|
||||
});
|
||||
test('empty selector = no constraint → overall max', () => {
|
||||
const out = ["@org/pkg@1.9.0 '1.9.0'", "@org/pkg@2.4.0 '2.4.0'"].join('\n');
|
||||
assert.strictEqual(pickHighestNpmVersion(out, ''), '2.4.0');
|
||||
});
|
||||
test('single bare token (latest dist-tag) parses', () => {
|
||||
assert.strictEqual(pickHighestNpmVersion('2.4.1\n', ''), '2.4.1');
|
||||
});
|
||||
test('garbage / no version tokens → null (DEGRADE)', () => {
|
||||
assert.strictEqual(pickHighestNpmVersion('not a version\n', ''), null);
|
||||
assert.strictEqual(pickHighestNpmVersion('', '^1'), null);
|
||||
});
|
||||
test('no token satisfies the range → null', () => {
|
||||
const out = ["@org/pkg@2.0.0 '2.0.0'", "@org/pkg@3.0.0 '3.0.0'"].join('\n');
|
||||
assert.strictEqual(pickHighestNpmVersion(out, '^1'), null);
|
||||
});
|
||||
|
||||
test('package NAME contains a version-like substring → resolves the RESOLVED version, not the name token', () => {
|
||||
// #1463 Fix 1 (R Medium): npm view (range) prints `<name>@<version> '<version>'`. When the package
|
||||
// NAME itself contains an `x.y.z`-shaped substring (`@scope/cap-1.2.3`), the version must come from
|
||||
// its CANONICAL position (the quoted token / the token after the LAST `@`), NOT any token on the line.
|
||||
// revert-fails: the old any-token regex matches `1.2.3` from the NAME first and returns it (the
|
||||
// highest token that satisfies ^1 is `1.5.0`, but `1.2.3` < `1.5.0`, so a name-poisoned parse could
|
||||
// also wrongly surface `1.2.3` as a candidate). With both lines present the CORRECT answer is 1.5.0.
|
||||
const out = [
|
||||
"@scope/cap-1.2.3@1.0.0 '1.0.0'",
|
||||
"@scope/cap-1.2.3@1.5.0 '1.5.0'",
|
||||
].join('\n');
|
||||
assert.strictEqual(pickHighestNpmVersion(out, '^1'), '1.5.0');
|
||||
});
|
||||
|
||||
test('single name-poisoned line → resolves the resolved version (not the name substring)', () => {
|
||||
// revert-fails: with one line `@scope/cap-1.2.3@1.0.0 '1.0.0'` and range `^1.0.0`, the any-token
|
||||
// regex picks `1.2.3` (the FIRST/HIGHEST satisfying token, from the NAME); the canonical parse must
|
||||
// return `1.0.0` (the resolved version). 1.2.3 !== 1.0.0 so the assert flips on revert.
|
||||
const out = "@scope/cap-1.2.3@1.0.0 '1.0.0'";
|
||||
assert.strictEqual(pickHighestNpmVersion(out, '^1.0.0'), '1.0.0');
|
||||
});
|
||||
|
||||
test('property: with no range constraint, picks the numeric max of the printed versions', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.tuple(fc.nat(40), fc.nat(40), fc.nat(40)), { minLength: 1, maxLength: 12 }),
|
||||
(triplets) => {
|
||||
const lines = triplets.map(([a, b, c]) => `@org/pkg@${a}.${b}.${c} '${a}.${b}.${c}'`);
|
||||
const got = pickHighestNpmVersion(lines.join('\n'), '');
|
||||
const expected = triplets
|
||||
.slice()
|
||||
.sort((x, y) => (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]))
|
||||
.pop();
|
||||
return got === `${expected[0]}.${expected[1]}.${expected[2]}`;
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,13 +21,13 @@ const path = require('path');
|
||||
describe('plan-phase chain flag preservation (#1620)', () => {
|
||||
const planPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-phase.md');
|
||||
const discussPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md');
|
||||
// After #2551, discuss-phase chain logic moved to modes/chain.md.
|
||||
// After the discuss-phase/modes split (#717), discuss-phase chain logic moved to modes/chain.md.
|
||||
const discussChainPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'chain.md');
|
||||
const readDiscuss = () => {
|
||||
// Fail loudly if either source is missing — silent filtering would let a
|
||||
// regression that deletes modes/chain.md pass this whole suite.
|
||||
assert.ok(fs.existsSync(discussPath), `discuss-phase.md missing: ${discussPath}`);
|
||||
assert.ok(fs.existsSync(discussChainPath), `discuss-phase/modes/chain.md missing after #2551 split: ${discussChainPath}`);
|
||||
assert.ok(fs.existsSync(discussChainPath), `discuss-phase/modes/chain.md missing after discuss-phase/modes split: ${discussChainPath}`);
|
||||
return [discussPath, discussChainPath].map(p => fs.readFileSync(p, 'utf8')).join('\n');
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('plan-phase chain flag preservation (#1620)', () => {
|
||||
);
|
||||
assert.ok(
|
||||
discussContent.includes(guardPattern),
|
||||
'discuss-phase (or discuss-phase/modes/chain.md after #2551 split) should use the dual-flag guard pattern'
|
||||
'discuss-phase (or discuss-phase/modes/chain.md after the discuss-phase/modes split) should use the dual-flag guard pattern'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const path = require('path');
|
||||
|
||||
describe('discuss-phase incremental checkpoint saves (#1485)', () => {
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase.md');
|
||||
// After #2551 progressive-disclosure refactor, checkpoint logic lives in the
|
||||
// After the discuss-phase progressive-disclosure split (#717), checkpoint logic lives in the
|
||||
// default mode file and the JSON schema lives in the templates directory.
|
||||
const defaultModePath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'default.md');
|
||||
const checkpointTplPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'templates', 'checkpoint.json');
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('discuss-phase power user mode (#1513)', () => {
|
||||
|
||||
describe('main workflow file (discuss-phase.md)', () => {
|
||||
test('has power_user_mode section or references discuss-phase-power.md', () => {
|
||||
// After #2551, the power dispatch lives in discuss-phase/modes/power.md and
|
||||
// After the discuss-phase/modes split (#717), the power dispatch lives in discuss-phase/modes/power.md and
|
||||
// the parent references it via the dispatch table.
|
||||
const parentContent = fs.readFileSync(workflowPath, 'utf8');
|
||||
const powerModePath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'discuss-phase', 'modes', 'power.md');
|
||||
@@ -52,7 +52,7 @@ describe('discuss-phase power user mode (#1513)', () => {
|
||||
const hasReference = content.includes('discuss-phase-power');
|
||||
assert.ok(
|
||||
hasPowerSection || hasReference,
|
||||
'discuss-phase.md (or modes/power.md after #2551) should have power_user_mode section or reference discuss-phase-power.md'
|
||||
'discuss-phase.md (or modes/power.md after the discuss-phase/modes split) should have power_user_mode section or reference discuss-phase-power.md'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
118
tests/enh-1494-workflow-config-key-docs.test.cjs
Normal file
118
tests/enh-1494-workflow-config-key-docs.test.cjs
Normal file
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parity assertions for #1494: workflow config keys that are consumed by
|
||||
* planning-pipeline code must be (a) accepted by VALID_CONFIG_KEYS and
|
||||
* (b) documented in references/planning-config.md.
|
||||
*
|
||||
* Per DEFECT.GENERATIVE-FIX: a shared constant / key-list that spans two
|
||||
* surfaces requires a parity assertion that fails when the surfaces diverge.
|
||||
*/
|
||||
|
||||
const { describe, test, before, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs');
|
||||
const PLANNING_CONFIG_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md');
|
||||
|
||||
describe('VALID_CONFIG_KEYS parity — #1494 orphan-undocumented keys', () => {
|
||||
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH);
|
||||
|
||||
test('workflow.mvp_mode is in VALID_CONFIG_KEYS', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.mvp_mode'),
|
||||
'workflow.mvp_mode is read by config-loader.cts and plan-phase.md but was missing from VALID_CONFIG_KEYS (#1494)'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.code_review_command is in VALID_CONFIG_KEYS', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.code_review_command'),
|
||||
'workflow.code_review_command must be in VALID_CONFIG_KEYS'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.plan_chunked is in VALID_CONFIG_KEYS', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.plan_chunked'),
|
||||
'workflow.plan_chunked must be in VALID_CONFIG_KEYS'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.test_command is in VALID_CONFIG_KEYS', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.test_command'),
|
||||
'workflow.test_command must be in VALID_CONFIG_KEYS'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.build_command is in VALID_CONFIG_KEYS', () => {
|
||||
assert.ok(
|
||||
VALID_CONFIG_KEYS.has('workflow.build_command'),
|
||||
'workflow.build_command must be in VALID_CONFIG_KEYS'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config-set accepts workflow.mvp_mode (#1494)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); });
|
||||
|
||||
test('config-set workflow.mvp_mode true succeeds and stores the value', () => {
|
||||
tmpDir = createTempProject();
|
||||
const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'true'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`config-set workflow.mvp_mode must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
||||
);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.updated, true, 'response must have updated:true');
|
||||
assert.strictEqual(parsed.key, 'workflow.mvp_mode', 'response must echo the key');
|
||||
});
|
||||
|
||||
test('config-set workflow.mvp_mode false succeeds', () => {
|
||||
tmpDir = createTempProject();
|
||||
const result = runGsdTools(['config-set', 'workflow.mvp_mode', 'false'], tmpDir);
|
||||
assert.ok(
|
||||
result.success,
|
||||
`config-set workflow.mvp_mode false must succeed; got:\nstdout: ${result.output}\nstderr: ${result.error}`
|
||||
);
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.updated, true);
|
||||
});
|
||||
});
|
||||
|
||||
// allow-test-rule: source-text-is-the-product — planning-config.md is the deployed reference contract (#1494)
|
||||
describe('planning-config.md documents #1494 keys', () => {
|
||||
let content;
|
||||
before(() => { content = fs.readFileSync(PLANNING_CONFIG_PATH, 'utf-8'); });
|
||||
|
||||
const KEYS = [
|
||||
'workflow.mvp_mode',
|
||||
'workflow.code_review_command',
|
||||
'workflow.plan_chunked',
|
||||
'workflow.test_command',
|
||||
'workflow.build_command',
|
||||
];
|
||||
|
||||
for (const key of KEYS) {
|
||||
test(`planning-config.md documents \`${key}\``, () => {
|
||||
assert.ok(
|
||||
content.includes(`\`${key}\``),
|
||||
`planning-config.md must document \`${key}\` (#1494)`
|
||||
);
|
||||
});
|
||||
|
||||
test(`\`${key}\` appears in the Complete Field Reference section`, () => {
|
||||
const refSection = content.slice(content.indexOf('## Complete Field Reference'));
|
||||
assert.ok(
|
||||
refSection.includes(`\`${key}\``),
|
||||
`planning-config.md Complete Field Reference must include \`${key}\` (#1494)`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
166
tests/fix-1369-wave-stale-base.test.cjs
Normal file
166
tests/fix-1369-wave-stale-base.test.cjs
Normal file
@@ -0,0 +1,166 @@
|
||||
// allow-test-rule: source-text-is-the-product #1369
|
||||
// Workflow .md files are the installed AI instructions — their text IS what the runtime
|
||||
// loads. Testing text content tests the deployed contract. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1369: execute-phase worktree agents fork from stale base after
|
||||
* a wave merge advances orchestrator HEAD past origin/HEAD.
|
||||
*
|
||||
* Steps 0.5 and 7b+7c are extracted to reference files to satisfy the ADR-857 size cap.
|
||||
* execute-phase.md contains @-reference pointers; the reference files hold the content.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const WAVE_GUARD_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-wave-guard.md');
|
||||
const BETWEEN_WAVE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-phase-between-wave-reset.md');
|
||||
|
||||
describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
|
||||
test('workflow file exists', () => {
|
||||
assert.ok(fs.existsSync(WORKFLOW_PATH), 'workflows/execute-phase.md should exist');
|
||||
});
|
||||
|
||||
test('wave-guard reference file exists', () => {
|
||||
assert.ok(fs.existsSync(WAVE_GUARD_PATH), 'references/execute-phase-wave-guard.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to wave-guard (step 0.5 injected at runtime)', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-wave-guard.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-wave-guard.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow contains step 0.5 inter-wave base re-check section', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('0.5.') && content.includes('Inter-wave worktree base re-check'),
|
||||
'execute-phase-wave-guard.md must have step 0.5 "Inter-wave worktree base re-check"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 references #1369', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 0.5 must reference #1369 for traceability');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check inside the For-each-wave loop', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
assert.ok(forEachIdx !== -1, '"For each wave:" section must exist in execute-phase.md');
|
||||
assert.ok(refIdx !== -1, '@-reference to wave-guard must exist in execute-phase.md');
|
||||
assert.ok(refIdx > forEachIdx, 'wave-guard @-reference must appear AFTER "For each wave:" so step 0.5 runs per-wave');
|
||||
});
|
||||
|
||||
test('step 0.5 runs worktree.base-check command', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.base-check'), 'step 0.5 must invoke worktree.base-check');
|
||||
});
|
||||
|
||||
test('step 0.5 sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('USE_WORKTREES=false'), 'step 0.5 must override USE_WORKTREES=false when base divergence is detected');
|
||||
});
|
||||
|
||||
test('step 0.5 appears before step 1 (intra-wave overlap check)', () => {
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const forEachIdx = workflow.indexOf('**For each wave:**');
|
||||
const refIdx = workflow.indexOf('execute-phase-wave-guard.md');
|
||||
const step1Idx = workflow.indexOf('1. **Intra-wave', forEachIdx);
|
||||
assert.ok(refIdx !== -1, 'wave-guard @-reference must exist');
|
||||
assert.ok(step1Idx !== -1, 'step 1 (intra-wave overlap check) must exist');
|
||||
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
|
||||
});
|
||||
|
||||
test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 0.5 must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 0.5 explains root cause: wave merges advance HEAD past origin/HEAD', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('origin/HEAD'), 'step 0.5 must name origin/HEAD as the stale fork base');
|
||||
});
|
||||
|
||||
test('step 0.5 cross-references #683 for worktree.baseRef configuration', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#683'), 'step 0.5 must cross-reference #683');
|
||||
});
|
||||
|
||||
test('step 0.5 mentions worktree.baseRef:"head" as permanent fix', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('worktree.baseRef') && content.includes('head'),
|
||||
'step 0.5 must mention worktree.baseRef:"head"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
|
||||
test('between-wave reference file exists', () => {
|
||||
assert.ok(fs.existsSync(BETWEEN_WAVE_PATH), 'references/execute-phase-between-wave-reset.md should exist');
|
||||
});
|
||||
|
||||
test('workflow contains @-reference pointer to between-wave-reset', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('execute-phase-between-wave-reset.md'),
|
||||
'execute-phase.md must have an @-reference to execute-phase-between-wave-reset.md'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c exists with between-wave manifest reset (#1369)', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('7c.') && content.includes('Between-wave manifest reset'),
|
||||
'execute-phase-between-wave-reset.md must have step 7c "Between-wave manifest reset"'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 7c unsets WAVE_WORKTREE_MANIFEST between waves', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('unset WAVE_WORKTREE_MANIFEST'), 'step 7c must unset WAVE_WORKTREE_MANIFEST');
|
||||
});
|
||||
|
||||
test('step 7c references #1369 and #3384 for traceability', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('#1369'), 'step 7c must reference #1369');
|
||||
assert.ok(content.includes('#3384'), 'step 7c must reference #3384');
|
||||
});
|
||||
|
||||
test('step 7c calls worktree.set-baseref to re-assert head config', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(content.includes('worktree.set-baseref'), 'step 7c must call worktree.set-baseref');
|
||||
});
|
||||
|
||||
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {
|
||||
const ref = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const idx7b = ref.indexOf('7b.');
|
||||
const idx7c = ref.indexOf('7c.');
|
||||
const refPtr = workflow.indexOf('execute-phase-between-wave-reset.md');
|
||||
const idx8 = workflow.indexOf('8. **Execute checkpoint', refPtr);
|
||||
assert.ok(idx7b !== -1, 'step 7b must exist in between-wave reference file');
|
||||
assert.ok(idx7c !== -1, 'step 7c must exist in between-wave reference file');
|
||||
assert.ok(idx8 !== -1, 'step 8 must exist in execute-phase.md after the between-wave @-reference');
|
||||
assert.ok(idx7b < idx7c, 'step 7c must appear after step 7b');
|
||||
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
|
||||
});
|
||||
|
||||
test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 7c must guard on RUNTIME=claude'
|
||||
);
|
||||
});
|
||||
});
|
||||
125
tests/fix-1437-phase-list-plans.test.cjs
Normal file
125
tests/fix-1437-phase-list-plans.test.cjs
Normal file
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for `gsd-tools query phase.list-plans <N>` (#1437).
|
||||
*
|
||||
* Prior to this fix, `phase.list-plans` was not registered in the
|
||||
* phase-command-router, so any invocation produced:
|
||||
* "Error: Unknown phase subcommand. Available: uat-passed, next-decimal, ..."
|
||||
*
|
||||
* These tests exercise the full dispatch path:
|
||||
* gsd-tools → phase-command-router → phase.cmdPhaseListPlans
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function setupProject(phaseSlug = '01-feature') {
|
||||
const tmpDir = createTempProject();
|
||||
// Minimal ROADMAP so findPhaseInternal can resolve the phase directory
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 1: Feature',
|
||||
'',
|
||||
'### Phase 1: Feature',
|
||||
'**Goal:** Build feature',
|
||||
'**Plans:** 1 plans',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', phaseSlug);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
return { tmpDir, phaseDir };
|
||||
}
|
||||
|
||||
function touch(dir, ...files) {
|
||||
for (const f of files) {
|
||||
fs.writeFileSync(path.join(dir, f), '');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
const proj = setupProject('01-feature');
|
||||
tmpDir = proj.tmpDir;
|
||||
phaseDir = proj.phaseDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
|
||||
test('command no longer returns Unknown phase subcommand error', () => {
|
||||
touch(phaseDir, '01-01-PLAN.md');
|
||||
const result = runGsdTools(['query', 'phase.list-plans', '1'], tmpDir);
|
||||
// Previously this would fail with "Unknown phase subcommand"
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
assert.ok(!result.error || !result.error.includes('Unknown phase subcommand'),
|
||||
`got unexpected error: ${result.error}`);
|
||||
});
|
||||
|
||||
test('returns JSON with plan_count and plans array when plans exist', () => {
|
||||
touch(phaseDir, '01-01-PLAN.md', '01-02-PLAN.md');
|
||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.equal(data.plan_count, 2, 'plan_count should be 2');
|
||||
assert.equal(data.has_plans, true, 'has_plans should be true');
|
||||
assert.ok(Array.isArray(data.plans), 'plans should be an array');
|
||||
assert.equal(data.plans.length, 2, 'plans array should have 2 entries');
|
||||
});
|
||||
|
||||
test('returns plan_count 0 and empty plans array when phase has no plan files', () => {
|
||||
// Phase directory exists but has no *-PLAN.md files
|
||||
touch(phaseDir, 'CONTEXT.md');
|
||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.equal(data.plan_count, 0);
|
||||
assert.equal(data.has_plans, false);
|
||||
assert.deepEqual(data.plans, []);
|
||||
});
|
||||
|
||||
test('returns has_plans false when phase number is not found', () => {
|
||||
// Phase 99 does not exist in the fixture
|
||||
const result = runGsdTools(['query', 'phase.list-plans', '99', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.equal(data.has_plans, false);
|
||||
assert.equal(data.plan_count, 0);
|
||||
});
|
||||
|
||||
test('plan paths are relative to project root and posix-style', () => {
|
||||
touch(phaseDir, '01-01-PLAN.md');
|
||||
const result = runGsdTools(['query', 'phase.list-plans', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.equal(data.plans.length, 1);
|
||||
// Paths must be forward-slash separated (posix) and relative (not absolute)
|
||||
const planPath = data.plans[0];
|
||||
assert.ok(!path.isAbsolute(planPath), `expected relative path, got: ${planPath}`);
|
||||
assert.ok(!planPath.includes('\\'), `expected posix path, got: ${planPath}`);
|
||||
assert.ok(planPath.includes('01-01-PLAN.md'), `expected plan filename in path: ${planPath}`);
|
||||
});
|
||||
|
||||
test('dotted form phase.list-plans (without query prefix) also works', () => {
|
||||
touch(phaseDir, '01-01-PLAN.md');
|
||||
const result = runGsdTools(['phase.list-plans', '1', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}\nOutput: ${result.output}`);
|
||||
const data = JSON.parse(result.output);
|
||||
assert.equal(data.plan_count, 1);
|
||||
});
|
||||
});
|
||||
174
tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs
Normal file
174
tests/fix-1445-999x-backlog-excluded-from-total-phases.test.cjs
Normal file
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #1445:
|
||||
* 999.x backlog phases must not be counted toward total_phases.
|
||||
*
|
||||
* Root cause:
|
||||
* deriveProgressFromRoadmap (phase-lifecycle.cts) counted ALL data rows
|
||||
* matching /^\|\s*\d+/ in the progress table, including 999.x backlog rows.
|
||||
* Similarly, state.cts's roadmapPhaseCount loop (via extractCurrentMilestone)
|
||||
* counted 999.x phase headings because it only checked /\d/.test(m[1]).
|
||||
*
|
||||
* Fix:
|
||||
* Both sites now test /^999(?:\.|$)/.test(token) and skip matching rows.
|
||||
* Mirrors the existing init.cts /^999(?:\.|$)/ filter.
|
||||
*
|
||||
* Scenarios:
|
||||
* A. deriveProgressFromRoadmap with a progress table containing a 999.x row.
|
||||
* B. state json total_phases via extractCurrentMilestone / roadmapPhaseCount.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { deriveProgressFromRoadmap } = require('../gsd-core/bin/lib/phase-lifecycle.cjs');
|
||||
|
||||
// ─── Scenario A: deriveProgressFromRoadmap unit test ────────────────────────
|
||||
|
||||
describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
|
||||
test('3 real phases + 1 999.x backlog row → total_phases: 3, not 4', () => {
|
||||
const roadmap = [
|
||||
'## Milestone v1.0: Test',
|
||||
'',
|
||||
'| Phase | Plans | Status | Completed |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 1. Alpha | 2/2 | Complete | ✅ |',
|
||||
'| 2. Beta | 1/2 | In Progress | |',
|
||||
'| 3. Gamma | 0/1 | Planned | |',
|
||||
'| 999.1 Backlog: Future Idea | 0/0 | Backlog | |',
|
||||
].join('\n');
|
||||
|
||||
const result = deriveProgressFromRoadmap(roadmap);
|
||||
assert.equal(
|
||||
result.totalPhases,
|
||||
3,
|
||||
`total_phases must be 3 (not 4) — 999.1 backlog row must be excluded. Got ${result.totalPhases}`,
|
||||
);
|
||||
assert.equal(
|
||||
result.completedPhases,
|
||||
1,
|
||||
`completed_phases must be 1. Got ${result.completedPhases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('999 exact (no dot) row is also excluded', () => {
|
||||
const roadmap = [
|
||||
'## Milestone v1.0: Test',
|
||||
'',
|
||||
'| Phase | Plans | Status | Completed |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 1. Alpha | 1/1 | Complete | ✅ |',
|
||||
'| 2. Beta | 1/1 | Complete | ✅ |',
|
||||
'| 999 Backlog | 0/0 | Backlog | |',
|
||||
].join('\n');
|
||||
|
||||
const result = deriveProgressFromRoadmap(roadmap);
|
||||
assert.equal(
|
||||
result.totalPhases,
|
||||
2,
|
||||
`total_phases must be 2 (not 3) — 999 row must be excluded. Got ${result.totalPhases}`,
|
||||
);
|
||||
assert.equal(
|
||||
result.completedPhases,
|
||||
2,
|
||||
`completed_phases must be 2. Got ${result.completedPhases}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('all-backlog table yields null total_phases (no real phases)', () => {
|
||||
const roadmap = [
|
||||
'## Milestone v1.0: Test',
|
||||
'',
|
||||
'| Phase | Plans | Status | Completed |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 999.1 Future A | 0/0 | Backlog | |',
|
||||
'| 999.2 Future B | 0/0 | Backlog | |',
|
||||
].join('\n');
|
||||
|
||||
const result = deriveProgressFromRoadmap(roadmap);
|
||||
assert.equal(
|
||||
result.totalPhases,
|
||||
null,
|
||||
`total_phases must be null when the only rows are 999.x backlog. Got ${result.totalPhases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario B: state json total_phases via roadmapPhaseCount ───────────────
|
||||
|
||||
describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => {
|
||||
let tmpDir;
|
||||
|
||||
const ROADMAP = [
|
||||
'## Milestone v1.0: Test Milestone',
|
||||
'',
|
||||
'### Phase 01: Alpha',
|
||||
'**Goal:** first',
|
||||
'',
|
||||
'### Phase 02: Beta',
|
||||
'**Goal:** second',
|
||||
'',
|
||||
'### Phase 03: Gamma',
|
||||
'**Goal:** third',
|
||||
'',
|
||||
'### Phase 999.1: Backlog Item A',
|
||||
'**Goal:** future idea, not counted',
|
||||
'',
|
||||
'### Phase 999.2: Backlog Item B',
|
||||
'**Goal:** another future idea',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('bug-1445-');
|
||||
const planning = path.join(tmpDir, '.planning');
|
||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'status: executing',
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Configuration',
|
||||
'Current Phase: 1',
|
||||
'Status: Executing Phase 1',
|
||||
'Last Activity: 2026-01-01',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||||
|
||||
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
|
||||
const dir = path.join(planning, 'phases', d);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||||
}
|
||||
// 999.x dirs should exist on disk but must not inflate total_phases
|
||||
for (const d of ['999.1-backlog-a', '999.2-backlog-b']) {
|
||||
fs.mkdirSync(path.join(planning, 'phases', d), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('state json total_phases is 3, not 5 (999.x dirs and headings excluded)', () => {
|
||||
const result = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(result.success, `state json failed: ${result.error}`);
|
||||
const state = JSON.parse(result.output);
|
||||
assert.ok(state.progress, 'state json must return a progress block');
|
||||
assert.equal(
|
||||
state.progress.total_phases,
|
||||
3,
|
||||
`total_phases must be 3 (not 5). 999.x backlog phases must be excluded. Got ${state.progress.total_phases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
156
tests/fix-1446-total-phases-corrects-downward.test.cjs
Normal file
156
tests/fix-1446-total-phases-corrects-downward.test.cjs
Normal file
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for bug #1446:
|
||||
* total_phases must correct downward when re-derived; shouldPreserveExistingProgress
|
||||
* must NOT include total_phases in its ratchet check.
|
||||
*
|
||||
* Root cause:
|
||||
* shouldPreserveExistingProgress (state-document.cts) returned true when
|
||||
* existingProgress.total_phases > derivedProgress.total_phases, making the
|
||||
* stored value sticky even when it was wrong (e.g. counted backlog phases).
|
||||
*
|
||||
* Fix:
|
||||
* total_phases is removed from the "existing exceeds derived" check.
|
||||
* Only completed_phases, total_plans, and completed_plans keep ratchet behaviour.
|
||||
*
|
||||
* Scenarios:
|
||||
* A. shouldPreserveExistingProgress unit test — returns false when only total_phases differs.
|
||||
* B. state sync re-derives a lower total_phases and writes the new value.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { shouldPreserveExistingProgress } = require('../gsd-core/bin/lib/state-document.cjs');
|
||||
|
||||
// ─── Scenario A: unit test ───────────────────────────────────────────────────
|
||||
|
||||
describe('bug #1446 — shouldPreserveExistingProgress does not ratchet total_phases', () => {
|
||||
test('existing total_phases:10 > derived total_phases:7 → returns false (no ratchet)', () => {
|
||||
const existing = { total_phases: 10, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||||
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||||
assert.equal(
|
||||
shouldPreserveExistingProgress(existing, derived),
|
||||
false,
|
||||
'total_phases downward correction must NOT trigger shouldPreserveExistingProgress',
|
||||
);
|
||||
});
|
||||
|
||||
test('existing completed_phases:5 > derived completed_phases:2 → returns true (ratchet still active)', () => {
|
||||
const existing = { total_phases: 7, completed_phases: 5, total_plans: 6, completed_plans: 3 };
|
||||
const derived = { total_phases: 7, completed_phases: 2, total_plans: 6, completed_plans: 3 };
|
||||
assert.equal(
|
||||
shouldPreserveExistingProgress(existing, derived),
|
||||
true,
|
||||
'completed_phases ratchet must still work',
|
||||
);
|
||||
});
|
||||
|
||||
test('existing total_phases:10 > derived:7 AND completed_phases matches → false (total_phases alone does not preserve)', () => {
|
||||
const existing = { total_phases: 10, completed_phases: 3 };
|
||||
const derived = { total_phases: 7, completed_phases: 3 };
|
||||
assert.equal(
|
||||
shouldPreserveExistingProgress(existing, derived),
|
||||
false,
|
||||
'only-total_phases discrepancy must not trigger preservation',
|
||||
);
|
||||
});
|
||||
|
||||
test('all derived values equal existing → returns false', () => {
|
||||
const existing = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||||
const derived = { total_phases: 7, completed_phases: 3, total_plans: 6, completed_plans: 3 };
|
||||
assert.equal(shouldPreserveExistingProgress(existing, derived), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scenario B: end-to-end state sync overwrites inflated total_phases ──────
|
||||
|
||||
describe('bug #1446 — state sync writes corrected (lower) total_phases', () => {
|
||||
let tmpDir;
|
||||
|
||||
// ROADMAP has 3 real phases only (no 999.x).
|
||||
const ROADMAP = [
|
||||
'## Milestone v1.0: Test',
|
||||
'',
|
||||
'### Phase 01: Alpha',
|
||||
'**Goal:** alpha',
|
||||
'',
|
||||
'### Phase 02: Beta',
|
||||
'**Goal:** beta',
|
||||
'',
|
||||
'### Phase 03: Gamma',
|
||||
'**Goal:** gamma',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('bug-1446-');
|
||||
const planning = path.join(tmpDir, '.planning');
|
||||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||||
|
||||
// STATE.md has a stale inflated total_phases:10 in frontmatter.
|
||||
fs.writeFileSync(
|
||||
path.join(planning, 'STATE.md'),
|
||||
[
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'status: executing',
|
||||
'progress:',
|
||||
' total_phases: 10',
|
||||
' completed_phases: 2',
|
||||
' total_plans: 6',
|
||||
' completed_plans: 4',
|
||||
' percent: 40',
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'## Configuration',
|
||||
'Current Phase: 3',
|
||||
'Status: Executing Phase 3',
|
||||
'Last Activity: 2026-01-01',
|
||||
'Progress: [████░░░░░░] 40%',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
|
||||
|
||||
for (const d of ['01-alpha', '02-beta', '03-gamma']) {
|
||||
const dir = path.join(planning, 'phases', d);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
|
||||
// Mark 01 and 02 as complete (2 summaries)
|
||||
if (d !== '03-gamma') {
|
||||
fs.writeFileSync(path.join(dir, 'PLAN-SUMMARY.md'), '# Summary\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('state sync corrects total_phases from 10 to 3', () => {
|
||||
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
|
||||
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
|
||||
|
||||
const jsonResult = runGsdTools(['state', 'json'], tmpDir);
|
||||
assert.ok(jsonResult.success, `state json failed: ${jsonResult.error}`);
|
||||
const state = JSON.parse(jsonResult.output);
|
||||
|
||||
assert.ok(state.progress, 'state json must return a progress block');
|
||||
assert.equal(
|
||||
state.progress.total_phases,
|
||||
3,
|
||||
`total_phases must be corrected to 3 (derived), not kept at 10 (stale). Got ${state.progress.total_phases}`,
|
||||
);
|
||||
// completed_phases ratchet still works: existing 2 ≥ disk-derived → keep 2
|
||||
assert.ok(
|
||||
state.progress.completed_phases >= 2,
|
||||
`completed_phases must be at least 2 (ratchet). Got ${state.progress.completed_phases}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
269
tests/fix-1464-docs-manifest-validation.test.cjs
Normal file
269
tests/fix-1464-docs-manifest-validation.test.cjs
Normal file
@@ -0,0 +1,269 @@
|
||||
// allow-test-rule: source-text-is-the-product see #1464
|
||||
// Tutorial docs are the product surface users follow. Reading JSON code blocks
|
||||
// from them and validating through validateCapability is behavioral, not
|
||||
// source-grep — it proves the manifests work, not just that they "mention" a term.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { validateCapability } = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// ─── Extractor ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Required top-level fields that distinguish a complete capability manifest
|
||||
// from a partial output snippet (list-entry, install-result, etc.).
|
||||
// Partial output snippets have id+role but lack steps/contributions/gates/config.
|
||||
const MANIFEST_REQUIRED_KEYS = new Set([
|
||||
'id', 'role', 'title', 'description', 'tier',
|
||||
'requires', 'runtimeCompat', 'skills', 'agents',
|
||||
'config', 'steps', 'contributions', 'gates',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract JSON code blocks from markdown that are complete capability manifests.
|
||||
* A complete manifest has ALL keys in MANIFEST_REQUIRED_KEYS.
|
||||
* Partial output snippets (list-entries, install-results) have only id+role and are skipped.
|
||||
*/
|
||||
function extractManifests(mdContent) {
|
||||
const manifests = [];
|
||||
const fenceRe = /```json\s*\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = fenceRe.exec(mdContent)) !== null) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(match[1]);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = new Set(Object.keys(parsed));
|
||||
if ([...MANIFEST_REQUIRED_KEYS].every((k) => keys.has(k))) {
|
||||
manifests.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifests;
|
||||
}
|
||||
|
||||
// ─── Suite 1: tutorial manifests validate ────────────────────────────────────
|
||||
|
||||
describe('docs tutorial manifests pass validateCapability (#1464 regression)', () => {
|
||||
test('build-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'build-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in build tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`build tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('install-your-first-capability.md: every manifest passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'tutorials', 'install-your-first-capability.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in install tutorial',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`install tutorial manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('capability-manifest.md reference example passes', () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(ROOT, 'docs', 'reference', 'capability-manifest.md'),
|
||||
'utf8',
|
||||
);
|
||||
const manifests = extractManifests(content);
|
||||
assert.ok(
|
||||
manifests.length > 0,
|
||||
'expected at least one capability manifest in reference doc',
|
||||
);
|
||||
for (const cap of manifests) {
|
||||
const errors = validateCapability(cap, cap.id);
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`reference manifest id="${cap.id}" failed validateCapability:\n ${errors.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: adversarial — #1464 failure modes caught ───────────────────────
|
||||
//
|
||||
// These are the EXACT failure shapes from issue #1464.
|
||||
// They must fail validateCapability — proving this test would have caught the bug.
|
||||
|
||||
describe('validateCapability catches original #1464 bug shapes', () => {
|
||||
// #1464 high-1: step missing ref → validateStep rejects it
|
||||
test('step without ref fails (the original broken tutorial step)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for #1464 regression.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
engines: { gsd: '>=1.6.0' },
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [
|
||||
{
|
||||
// Missing ref — this was the #1464 high-1 bug in the original tutorial
|
||||
point: 'plan:pre',
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.ok(errors.length > 0, 'expected validation errors for step without ref');
|
||||
assert.ok(
|
||||
errors.some((e) => /ref/.test(e)),
|
||||
`expected an error mentioning "ref"; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// #1464 shape: id must match folder name (folderId contract)
|
||||
test('id not matching folderId fails', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Test fixture for id/folderId mismatch.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'wrong-folder');
|
||||
assert.ok(errors.length > 0, 'expected id/folderId mismatch to fail validation');
|
||||
assert.ok(
|
||||
errors.some((e) => /folder/.test(e) || /equal/.test(e) || /id/.test(e)),
|
||||
`expected error about id/folderId mismatch; got: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Corrected shape: contribution with fragment + into (the PR #1495 fix)
|
||||
test('contribution with fragment.path + into passes (the PR #1495 fix shape)', () => {
|
||||
const cap = {
|
||||
id: 'hello-note',
|
||||
role: 'feature',
|
||||
version: '0.1.0',
|
||||
title: 'Hello Note',
|
||||
description: 'Injects a greeting note at plan:pre and produces HELLO.md.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [
|
||||
{
|
||||
point: 'plan:pre',
|
||||
into: 'planner',
|
||||
fragment: { path: 'fragments/plan-pre.md' },
|
||||
produces: ['HELLO.md'],
|
||||
consumes: [],
|
||||
onError: 'skip',
|
||||
},
|
||||
],
|
||||
gates: [],
|
||||
};
|
||||
const errors = validateCapability(cap, 'hello-note');
|
||||
assert.deepStrictEqual(
|
||||
errors,
|
||||
[],
|
||||
`corrected contribution manifest has unexpected errors: ${errors.join('; ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: extractManifests helper ────────────────────────────────────────
|
||||
|
||||
describe('extractManifests helper unit tests', () => {
|
||||
test('returns empty array for plain text with no JSON fences', () => {
|
||||
assert.deepStrictEqual(extractManifests('No code blocks here.'), []);
|
||||
});
|
||||
|
||||
test('skips JSON blocks without all required manifest keys', () => {
|
||||
// Partial list-entry block — only has id, role, version but not steps/contributions/etc.
|
||||
const md = '```json\n{"id":"x","role":"feature","version":"1.0.0"}\n```';
|
||||
assert.deepStrictEqual(extractManifests(md), []);
|
||||
});
|
||||
|
||||
function makeCompleteManifest(overrides) {
|
||||
return {
|
||||
id: 'test-cap', role: 'feature', title: 'T', description: 'D',
|
||||
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [], agents: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('extracts a complete manifest (all required keys present)', () => {
|
||||
const cap = makeCompleteManifest({ id: 'x' });
|
||||
const md = '```json\n' + JSON.stringify(cap, null, 2) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'x');
|
||||
});
|
||||
|
||||
test('skips malformed JSON blocks silently', () => {
|
||||
const complete = makeCompleteManifest({ id: 'y' });
|
||||
const md = '```json\n{bad json here\n```\n```json\n' + JSON.stringify(complete) + '\n```';
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(result[0].id, 'y');
|
||||
});
|
||||
|
||||
test('extracts multiple complete manifests from one doc', () => {
|
||||
const a = makeCompleteManifest({ id: 'cap-a' });
|
||||
const b = makeCompleteManifest({ id: 'cap-b' });
|
||||
const md = [
|
||||
'```json\n' + JSON.stringify(a) + '\n```',
|
||||
'```json\n' + JSON.stringify(b) + '\n```',
|
||||
].join('\n');
|
||||
const result = extractManifests(md);
|
||||
assert.strictEqual(result.length, 2);
|
||||
});
|
||||
});
|
||||
@@ -154,11 +154,19 @@ function manifestSkillSet(manifest) {
|
||||
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
|
||||
out.add(seg);
|
||||
} else if (key.startsWith('command/')) {
|
||||
// OpenCode/Kilo: command/gsd-<cmd>.md
|
||||
const file = key.split('/')[1];
|
||||
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
} else if (key.startsWith('commands/gsd/')) {
|
||||
// Gemini: commands/gsd/<cmd>.toml (nested, colon-namespaced)
|
||||
const file = key.split('/')[2];
|
||||
out.add(file.replace(/\.(md|toml)$/, ''));
|
||||
} else if (key.startsWith('commands/') && key.split('/').length === 2) {
|
||||
// Claude local (#1367 fix): flat commands/gsd-<cmd>.md
|
||||
const file = key.split('/')[1];
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -197,6 +205,15 @@ function collectSkillBasenamesOnDisk(configDir) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Claude local (#1367 fix): flat gsd-*.md files at commands/ level
|
||||
const flatCommandsDir = path.join(configDir, 'commands');
|
||||
if (fs.existsSync(flatCommandsDir)) {
|
||||
for (const file of fs.readdirSync(flatCommandsDir)) {
|
||||
if (file.startsWith('gsd-') && file.endsWith('.md')) {
|
||||
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -204,21 +204,118 @@ describe('issue-607 legacy-cleanup: planLegacyCleanup', () => {
|
||||
assert.deepEqual(paths, sorted, 'plan must be sorted by path');
|
||||
});
|
||||
|
||||
// ── only content-references-old-package and legacy-shared-cache reasons ────
|
||||
// ── only known reasons ─────────────────────────────────────────────────────
|
||||
|
||||
test('plan entries only ever have reason content-references-old-package or legacy-shared-cache', () => {
|
||||
test('plan entries only ever have known reasons', () => {
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-worker.js'), '// ' + OLD_PACKAGE_SIGNAL);
|
||||
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(cachePath, '{}');
|
||||
// Stale skill file (#1453)
|
||||
const staleSkillPath = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
|
||||
writeFile(staleSkillPath, '@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n'); // gsd-allow-legacy-name
|
||||
// User custom hook — should NOT appear
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-my-custom.js'), '// user hook, clean');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache']);
|
||||
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache', 'stale-get-shit-done-path']); // gsd-allow-legacy-name
|
||||
for (const entry of plan) {
|
||||
assert.ok(validReasons.has(entry.reason), `unexpected reason: ${entry.reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── #1453 stale skill path in ~/.agents/skills/gsd-* ──────────────────────
|
||||
|
||||
test('#1453: flags a SKILL.md whose content contains a get-shit-done path reference with reason stale-get-shit-done-path', () => { // gsd-allow-legacy-name
|
||||
// Simulate a stale ~/.agents/skills/gsd-docs-update/SKILL.md left by an
|
||||
// older GSD install that embedded the pre-rename runtime path.
|
||||
const staleSkillFile = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
|
||||
writeFile(
|
||||
staleSkillFile,
|
||||
'---\nname: gsd-docs-update\n---\n' +
|
||||
'@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n' // gsd-allow-legacy-name
|
||||
);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === staleSkillFile);
|
||||
assert.ok(entry, 'expected stale SKILL.md to appear in plan');
|
||||
assert.equal(entry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
|
||||
});
|
||||
|
||||
test('#1453: does NOT flag a SKILL.md whose content contains the new gsd-core path', () => {
|
||||
// A freshly installed SKILL.md references the new runtime directory name.
|
||||
const freshSkillFile = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
|
||||
writeFile(
|
||||
freshSkillFile,
|
||||
'---\nname: gsd-docs-update\n---\n' +
|
||||
'@$HOME/.codex/gsd-core/workflows/docs-update.md\n'
|
||||
);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === freshSkillFile);
|
||||
assert.equal(entry, undefined, 'fresh SKILL.md (gsd-core path) must NOT appear in plan');
|
||||
});
|
||||
|
||||
test('#1453: does NOT flag SKILL.md files under user-owned (non-gsd-*) skill directories', () => {
|
||||
// A user-authored skill dir with a custom name must never be touched.
|
||||
const userSkillFile = path.join(configDir, 'skills', 'my-custom-skill', 'SKILL.md');
|
||||
writeFile(
|
||||
userSkillFile,
|
||||
'---\nname: my-custom-skill\n---\n' +
|
||||
'This skill uses ' + 'get-shit-done' + ' concepts.\n' // gsd-allow-legacy-name
|
||||
);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === userSkillFile);
|
||||
assert.equal(entry, undefined, 'user-owned (non-gsd-*) SKILL.md must NOT appear in plan');
|
||||
});
|
||||
|
||||
test('#1453: flags SKILL.md with stale path but preserves SKILL.md in the same dir without stale path', () => {
|
||||
// Two skill dirs: one stale (get-shit-done ref), one fresh (gsd-core ref). // gsd-allow-legacy-name
|
||||
const staleSkill = path.join(configDir, 'skills', 'gsd-docs-update', 'SKILL.md');
|
||||
const freshSkill = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
writeFile(staleSkill, '@$HOME/.codex/' + 'get-shit-done' + '/workflows/docs-update.md\n'); // gsd-allow-legacy-name
|
||||
writeFile(freshSkill, '@$HOME/.codex/gsd-core/workflows/help.md\n');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const staleEntry = plan.find((p) => p.path === staleSkill);
|
||||
const freshEntry = plan.find((p) => p.path === freshSkill);
|
||||
|
||||
assert.ok(staleEntry, 'stale SKILL.md must appear in plan');
|
||||
assert.equal(staleEntry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
|
||||
assert.equal(freshEntry, undefined, 'fresh SKILL.md must NOT appear in plan');
|
||||
});
|
||||
|
||||
test('#1453: regression — after upgrade to gsd-core 1.5.0, stale ~/.agents/skills/gsd-docs-update/SKILL.md is removed', () => {
|
||||
// Simulate the exact scenario from issue #1453:
|
||||
// ~/.agents/skills/gsd-docs-update/SKILL.md references the old get-shit-done runtime. // gsd-allow-legacy-name
|
||||
// The upgrade installs correctly to ~/.codex/skills/ but leaves the stale
|
||||
// ~/.agents/skills/ copy which Codex can still discover.
|
||||
const agentsDir = path.join(homeDir, '.agents');
|
||||
const staleSkill = path.join(agentsDir, 'skills', 'gsd-docs-update', 'SKILL.md');
|
||||
writeFile(
|
||||
staleSkill,
|
||||
'---\nname: gsd-docs-update\n---\n' +
|
||||
'@$HOME/.Codex/' + 'get-shit-done' + '/workflows/docs-update.md\n' // gsd-allow-legacy-name
|
||||
);
|
||||
|
||||
// planLegacyCleanup receives ~/.agents as one of the configDirs (as _LEGACY_SCAN_SUBDIR_NAMES
|
||||
// includes '.agents' — the antigravity local form). The plan should flag the stale skill.
|
||||
const plan = planLegacyCleanup([agentsDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === staleSkill);
|
||||
assert.ok(entry, 'stale ~/.agents/skills/gsd-docs-update/SKILL.md must appear in plan');
|
||||
assert.equal(entry.reason, 'stale-get-shit-done-path'); // gsd-allow-legacy-name
|
||||
|
||||
// Applying the plan removes the stale file
|
||||
const result = applyLegacyCleanup(plan);
|
||||
assert.ok(result.removed.includes(staleSkill), 'stale SKILL.md must appear in removed[]');
|
||||
assert.equal(result.errors.length, 0, 'no errors expected');
|
||||
assert.equal(require('node:fs').existsSync(staleSkill), false, 'stale SKILL.md must be deleted on disk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue-607 legacy-cleanup: applyLegacyCleanup', () => {
|
||||
|
||||
@@ -341,3 +341,33 @@ describe('D: CLI --check exits 0 when manifests are in sync', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F: version script includes capability-registry regen (#1498) ─────────────
|
||||
//
|
||||
// Regression guard for #1498: the `npm version` lifecycle script must regenerate
|
||||
// capability-registry.cjs after stamping capability manifests. Without this,
|
||||
// `npm version X.Y.Z` leaves the committed registry stale (capability JSONs get
|
||||
// new version strings but the registry still has the old ones), causing the
|
||||
// `gen-capability-registry.cjs --check` test to fail in the RC workflow.
|
||||
describe('F: npm version script includes gen-capability-registry --write (#1498)', () => {
|
||||
|
||||
test('package.json "version" script regenerates capability-registry.cjs after syncing manifests', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
||||
const versionScript = pkg.scripts && pkg.scripts.version;
|
||||
assert.ok(
|
||||
typeof versionScript === 'string',
|
||||
'package.json must have a "version" script',
|
||||
);
|
||||
assert.ok(
|
||||
versionScript.includes('gen-capability-registry.cjs --write'),
|
||||
'package.json "version" script must include "gen-capability-registry.cjs --write" to keep the registry in sync after npm version bumps capability manifests. ' +
|
||||
'Got: ' + JSON.stringify(versionScript),
|
||||
);
|
||||
assert.ok(
|
||||
versionScript.includes('git add') && versionScript.includes('capability-registry.cjs'),
|
||||
'package.json "version" script must stage capability-registry.cjs with "git add" so it is included in the version-bump commit. ' +
|
||||
'Got: ' + JSON.stringify(versionScript),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* GSD Tools Tests - New Milestone Clear Phases (#1588)
|
||||
* GSD Tools Tests - New Milestone Clear Phases (#1588, #1447)
|
||||
*
|
||||
* Verifies that `phases clear` removes all phase subdirectories from
|
||||
* .planning/phases/, leaving the directory itself intact.
|
||||
*
|
||||
* Also covers the #1447 uncommitted-changes guard: phases clear must refuse
|
||||
* to delete phase directories that contain uncommitted work.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('phases clear command', () => {
|
||||
let tmpDir;
|
||||
@@ -110,3 +114,101 @@ describe('phases clear command', () => {
|
||||
assert.ok(!fs.existsSync(phase1), 'phase directory including nested content should be removed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1447: uncommitted-changes guard ───────────────────────────────────────
|
||||
|
||||
describe('phases clear: uncommitted-changes guard (#1447)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempGitProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('aborts with error when phase dirs contain uncommitted files', () => {
|
||||
// Add a phase directory with an untracked (uncommitted) file
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const phase1 = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
|
||||
// Do NOT commit — leave as untracked/uncommitted changes
|
||||
|
||||
const result = runGsdTools('phases clear --confirm', tmpDir);
|
||||
assert.ok(!result.success, 'phases clear should fail when uncommitted changes exist');
|
||||
assert.ok(
|
||||
result.error.includes('uncommitted') || result.error.includes('aborted'),
|
||||
`expected error about uncommitted changes, got: ${result.error}`
|
||||
);
|
||||
// Phase directory must still exist (was not deleted)
|
||||
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
|
||||
});
|
||||
|
||||
test('aborts when phase dirs have staged but uncommitted changes', () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const phase1 = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (staged)');
|
||||
// Stage the file but do not commit
|
||||
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('phases clear --confirm', tmpDir);
|
||||
assert.ok(!result.success, 'phases clear should fail when staged-but-uncommitted changes exist');
|
||||
assert.ok(
|
||||
result.error.includes('uncommitted') || result.error.includes('aborted'),
|
||||
`expected error about uncommitted changes, got: ${result.error}`
|
||||
);
|
||||
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
|
||||
});
|
||||
|
||||
test('--force bypasses the uncommitted-changes guard and deletes anyway', () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const phase1 = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
|
||||
// Do NOT commit
|
||||
|
||||
const result = runGsdTools('phases clear --confirm --force', tmpDir);
|
||||
assert.ok(result.success, `--force should bypass guard and succeed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
|
||||
assert.ok(!fs.existsSync(phase1), 'phase directory must be removed when --force is passed');
|
||||
});
|
||||
|
||||
test('succeeds without --force when all phase files are committed', () => {
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
const phase1 = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (committed)');
|
||||
// Commit the phase files
|
||||
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
|
||||
execSync('git commit -m "add phase"', { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const result = runGsdTools('phases clear --confirm', tmpDir);
|
||||
assert.ok(result.success, `should succeed when phase files are committed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
|
||||
assert.ok(!fs.existsSync(phase1), 'committed phase directory should be removed');
|
||||
});
|
||||
|
||||
test('guard skips gracefully when not in a git repo (no guard, proceeds normally)', () => {
|
||||
// Non-git project: createTempProject creates a plain project without git
|
||||
const nonGitDir = createTempProject();
|
||||
try {
|
||||
const phasesDir = path.join(nonGitDir, '.planning', 'phases');
|
||||
const phase1 = path.join(phasesDir, '01-foundation');
|
||||
fs.mkdirSync(phase1, { recursive: true });
|
||||
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan');
|
||||
|
||||
// Without git, the guard cannot check status — it should skip and proceed
|
||||
const result = runGsdTools('phases clear --confirm', nonGitDir);
|
||||
assert.ok(result.success, `should succeed in non-git repo: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory in non-git project');
|
||||
} finally {
|
||||
cleanup(nonGitDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
58
tests/no-phantom-issue-refs.test.cjs
Normal file
58
tests/no-phantom-issue-refs.test.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
// allow-test-rule: runtime-contract-is-the-product (see #1073) — this guard asserts the
|
||||
// ABSENCE of phantom pre-migration issue references in repo text (docs, tests,
|
||||
// workflows). The file *content* is the product surface here (#1073): dangling
|
||||
// refs like #2551/#3182 that don't exist in open-gsd/gsd-core (highest real
|
||||
// issue is in the low thousands of the redux repo, not here) mislead triage and
|
||||
// manufacture phantom blockers. This test fails CI if such a ref is reintroduced.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Phantom pre-migration (get-shit-done-redux) issue numbers with NO equivalent
|
||||
// in open-gsd/gsd-core. Matched only with a leading '#' or in an issues/ URL so
|
||||
// SSH key patterns like `id_ed25519` (which contain the digits "2551") are NOT
|
||||
// false-positives.
|
||||
const PHANTOM = ['2551', '3182', '2361'];
|
||||
const REF_RE = new RegExp(
|
||||
'(?:#(?:' + PHANTOM.join('|') + ')\\b)|(?:issues/(?:' + PHANTOM.join('|') + ')\\b)',
|
||||
);
|
||||
|
||||
const SCAN_EXT = new Set(['.md', '.cjs', '.js', '.cts', '.ts']);
|
||||
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.changeset']);
|
||||
// This guard file itself names the phantom numbers (by necessity); exclude it.
|
||||
const SELF = path.relative(ROOT, __filename);
|
||||
|
||||
function walk(dir, acc) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name), acc);
|
||||
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||
acc.push(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
test('no phantom pre-migration issue references remain in repo text (#1073)', () => {
|
||||
const offenders = [];
|
||||
for (const file of walk(ROOT, [])) {
|
||||
const rel = path.relative(ROOT, file);
|
||||
if (rel === SELF) continue;
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (REF_RE.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
|
||||
});
|
||||
}
|
||||
assert.strictEqual(
|
||||
offenders.length,
|
||||
0,
|
||||
`Phantom issue refs (${PHANTOM.map((n) => '#' + n).join('/')}) found — repoint to a real ` +
|
||||
`successor (#717/#720) or rewrite as prose (see #1073):\n` + offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
@@ -45,6 +45,7 @@ function makePhase(overrides = {}) {
|
||||
cmdPhaseInsert: () => {},
|
||||
cmdPhaseRemove: () => {},
|
||||
cmdPhaseComplete: () => {},
|
||||
cmdPhaseListPlans: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -280,26 +281,27 @@ describe('phase-command-router — result translation (error path)', () => {
|
||||
assert.ok(msg !== null);
|
||||
assert.ok(msg.includes('exactly one phase number'));
|
||||
});
|
||||
|
||||
// #1437 — phase.list-plans routing
|
||||
test('routes phase list-plans: passes cwd, phaseNum, raw to handler', () => {
|
||||
const calls = [];
|
||||
const phase = makePhase({
|
||||
cmdPhaseListPlans: (cwd, phaseNum, raw) => calls.push({ cwd, phaseNum, raw }),
|
||||
});
|
||||
|
||||
routePhaseCommand({ phase, args: ['phase', 'list-plans', '03'], cwd: '/proj', raw: false, error: (m) => { throw new Error(m); } });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].cwd, '/proj');
|
||||
assert.equal(calls[0].phaseNum, '03');
|
||||
assert.equal(calls[0].raw, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Unsupported subcommands ────────────────────────────────────────────────
|
||||
|
||||
describe('phase-command-router — unsupported subcommands', () => {
|
||||
test('phase list-plans resolves as unknown subcommand', () => {
|
||||
let msg = null;
|
||||
routePhaseCommand({
|
||||
phase: makePhase(),
|
||||
args: ['phase', 'list-plans'],
|
||||
cwd: '/p',
|
||||
raw: false,
|
||||
error: (m) => { msg = m; },
|
||||
});
|
||||
|
||||
assert.ok(msg !== null);
|
||||
assert.ok(msg.includes('Unknown phase subcommand'));
|
||||
assert.ok(msg.includes('Available:'), `expected "Available:" in: ${msg}`);
|
||||
});
|
||||
|
||||
// #1437: phase list-plans is now a supported subcommand — routing test in § 1.
|
||||
test('phase list-artifacts resolves as unknown subcommand', () => {
|
||||
let msg = null;
|
||||
routePhaseCommand({
|
||||
@@ -363,7 +365,8 @@ describe('phase-command-router — unknown subcommand', () => {
|
||||
|
||||
assert.ok(msg.includes('add'), `expected add in available list: ${msg}`);
|
||||
assert.ok(msg.includes('complete'), `expected complete in available list: ${msg}`);
|
||||
assert.ok(!msg.includes('list-plans'), `list-plans must not appear in available list: ${msg}`);
|
||||
// #1437: list-plans is now a supported command and appears in the available list
|
||||
assert.ok(msg.includes('list-plans'), `list-plans must appear in available list: ${msg}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -175,20 +175,19 @@ describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// REGRESSION (pre-existing heuristic-3 behavior, orthogonal to heuristic 4):
|
||||
// REGRESSION (#1422): sub_repos explicit config wins over .git implicit signal.
|
||||
// A sub_repos workspace where the child has BOTH its own .planning/ AND its own
|
||||
// .git/ — invoked from inside the child — RESOLVES TO THE CHILD (not the parent).
|
||||
// Pre-existing heuristic-3 precedence: a sub-repo that is itself a full project
|
||||
// (.git + .planning) resolves to itself; this is orthogonal to heuristic 4 and
|
||||
// tracked separately. Documents current behavior.
|
||||
test('sub_repos child with BOTH .planning/ and .git/ resolves to child itself (heuristic-3 precedence)', () => {
|
||||
// .git/ — invoked from inside the child — MUST resolve to the PARENT workspace
|
||||
// because the parent's config.json explicitly lists the child in sub_repos.
|
||||
// The implicit .git heuristic (heuristic-3) must not override explicit sub_repos.
|
||||
test('sub_repos child with BOTH .planning/ and .git/ resolves to PARENT workspace (sub_repos wins, #1422)', () => {
|
||||
// Layout:
|
||||
// workspaceRoot/
|
||||
// .planning/
|
||||
// config.json ← sub_repos: ['child']
|
||||
// child/
|
||||
// .planning/ ← child has own .planning/
|
||||
// .git/ ← child ALSO has own .git/ → heuristic-3 makes it self-resolving
|
||||
// .git/ ← child ALSO has own .git/ → was triggering heuristic-3 prematurely
|
||||
// src/ ← startDir
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-git-'));
|
||||
try {
|
||||
@@ -203,10 +202,33 @@ describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
|
||||
const childSrc = mkDeep(childDir, 'src');
|
||||
|
||||
const result = findProjectRoot(childSrc);
|
||||
// Pre-existing heuristic-3 precedence: child is a full project (.git + .planning)
|
||||
// → resolves to the child, not the workspace root.
|
||||
assert.strictEqual(result, childDir,
|
||||
'A sub-repo with both .planning/ and .git/ should resolve to itself (heuristic-3 precedence)');
|
||||
// Explicit sub_repos config in the ancestor workspace must take precedence
|
||||
// over the implicit .git heuristic — resolves to the workspace root.
|
||||
assert.strictEqual(result, workspaceRoot,
|
||||
'sub_repos config in parent workspace must win over child .git: should resolve to workspaceRoot (#1422)');
|
||||
} finally {
|
||||
cleanup(workspaceRoot);
|
||||
}
|
||||
});
|
||||
|
||||
// REGRESSION (#1422): sub_repos child with .git resolves to parent even when
|
||||
// startDir is nested more than one level inside the child.
|
||||
test('sub_repos child with .git: startDir nested 2+ levels inside child still resolves to parent (#1422)', () => {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-nested-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceRoot, '.planning', 'config.json'),
|
||||
JSON.stringify({ sub_repos: ['child'] })
|
||||
);
|
||||
const childDir = path.join(workspaceRoot, 'child');
|
||||
fs.mkdirSync(path.join(childDir, '.planning'), { recursive: true });
|
||||
fs.mkdirSync(path.join(childDir, '.git'), { recursive: true });
|
||||
const deepChild = mkDeep(childDir, 'src', 'lib', 'utils');
|
||||
|
||||
const result = findProjectRoot(deepChild);
|
||||
assert.strictEqual(result, workspaceRoot,
|
||||
'sub_repos config must win over .git even when startDir is deeply nested inside the child (#1422)');
|
||||
} finally {
|
||||
cleanup(workspaceRoot);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ const GOLDEN = {
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
],
|
||||
'claude/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, // #1367: flat gsd-<cmd>.md
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── cursor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,7 +29,8 @@ function tmpDir(prefix) {
|
||||
function createFixtureRuntime() {
|
||||
const base = createTempDir('gsd-surface-apply-');
|
||||
const runtimeConfigDir = base;
|
||||
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
|
||||
// #1367: claude local uses flat commands/ (not commands/gsd/) — commandsDir is commands/.
|
||||
const commandsDir = path.join(runtimeConfigDir, 'commands');
|
||||
const agentsDir = path.join(runtimeConfigDir, 'agents');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
@@ -59,6 +60,7 @@ function readFrontmatterDescription(markdown) {
|
||||
|
||||
describe('applySurface', () => {
|
||||
test('core profile: only core skills appear in commandsDir', (t) => {
|
||||
// #1367: claude local uses flat gsd-<stem>.md files at commands/ (not commands/gsd/<stem>.md).
|
||||
const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime();
|
||||
t.after(() => cleanup(base));
|
||||
writeActiveProfile(runtimeConfigDir, 'core');
|
||||
@@ -72,11 +74,14 @@ describe('applySurface', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
||||
const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
||||
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
||||
// After #1367: files are gsd-<stem>.md (not bare stem.md). Strip the gsd- prefix
|
||||
// to check against the REAL_COMMANDS_DIR (which still uses bare names).
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`);
|
||||
const bareName = file.slice('gsd-'.length); // gsd-help.md → help.md
|
||||
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)), `unexpected file: ${file} (no source: ${bareName})`);
|
||||
}
|
||||
const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort();
|
||||
const expectedCore = [...resolved.skills].map(stem => `gsd-${stem}.md`).sort();
|
||||
assert.deepStrictEqual(
|
||||
[...files].sort(),
|
||||
expectedCore,
|
||||
@@ -98,7 +103,8 @@ describe('applySurface', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
||||
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
||||
|
||||
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
||||
// #1367: files are gsd-<stem>.md in flat commands/
|
||||
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')));
|
||||
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'core',
|
||||
@@ -108,11 +114,11 @@ describe('applySurface', () => {
|
||||
});
|
||||
const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
||||
|
||||
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
||||
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')));
|
||||
|
||||
assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard');
|
||||
|
||||
const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort();
|
||||
const expectedCore = [...resolvedCore.skills].map(stem => `gsd-${stem}.md`).sort();
|
||||
assert.deepStrictEqual(
|
||||
[...afterCore].sort(),
|
||||
expectedCore,
|
||||
@@ -120,8 +126,9 @@ describe('applySurface', () => {
|
||||
);
|
||||
|
||||
for (const file of afterCore) {
|
||||
const bareName = file.slice('gsd-'.length);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(REAL_COMMANDS_DIR, file)),
|
||||
fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)),
|
||||
`file in commandsDir not a real skill: ${file}`
|
||||
);
|
||||
}
|
||||
@@ -161,13 +168,14 @@ describe('applySurface', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
||||
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
||||
|
||||
// #1367: flat gsd-<stem>.md files at commands/ (not commands/gsd/<stem>.md)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'help.md')),
|
||||
'help.md should be copied from install source'
|
||||
fs.existsSync(path.join(commandsDir, 'gsd-help.md')),
|
||||
'gsd-help.md should be copied from install source (#1367: flat hyphen layout)'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'new-project.md')),
|
||||
'new-project.md should be copied from install source'
|
||||
fs.existsSync(path.join(commandsDir, 'gsd-new-project.md')),
|
||||
'gsd-new-project.md should be copied from install source (#1367: flat hyphen layout)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -231,11 +239,12 @@ describe('applySurface', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local');
|
||||
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
|
||||
|
||||
const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent');
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(files.length > 0, 'commands/gsd should contain staged skill files');
|
||||
assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest');
|
||||
// #1367: claude local uses flat commands/ (not commands/gsd/)
|
||||
const commandsDir = path.join(runtimeConfigDir, 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ dir should be created even if initially absent');
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(files.length > 0, 'commands/ should contain staged skill files (gsd-*.md)');
|
||||
assert.ok(files.includes('gsd-help.md'), 'gsd-help.md should be present after applySurface on missing dest');
|
||||
});
|
||||
|
||||
test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('resolveRuntimeArtifactLayout — claude local', () => {
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); // #1367: flat gsd-<cmd>.md layout
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('Thinking Partner Integration (#1726)', () => {
|
||||
});
|
||||
|
||||
// Workflow integration tests
|
||||
// After #2551 progressive-disclosure refactor, the thinking-partner block
|
||||
// After the discuss-phase progressive-disclosure split (#717), the thinking-partner block
|
||||
// moved into the per-mode files (default.md, advisor.md) since the prompt
|
||||
// is mode-specific (only fires inside discuss_areas, after a user answer).
|
||||
describe('Discuss-phase integration', () => {
|
||||
|
||||
@@ -1028,3 +1028,135 @@ describe('validate health — missing phasesDir', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1472 regression — workstream-aware paths
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validate health — #1472 workstream-aware path resolution', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('reports healthy when GSD_WORKSTREAM is set and files are in the correct workstream layout', () => {
|
||||
// Shared-root files at .planning/
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Project\n\n## What This Is\n\nTest project.\n\n## Core Value\n\nCore value here.\n\n## Requirements\n\nRequirements here.\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced', commit_docs: true, workflow: { nyquist_validation: true, ai_integration_phase: true } }, null, 2)
|
||||
);
|
||||
|
||||
// Workstream-scoped files at .planning/workstreams/ws-a/
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-a');
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n### Phase 1: Setup\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(wsDir, 'STATE.md'),
|
||||
'# Session State\n\n## Current Position\n\nPhase: 1\n'
|
||||
);
|
||||
const wsPhaseDir = path.join(wsDir, 'phases', '01-setup');
|
||||
fs.mkdirSync(wsPhaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir, { GSD_WORKSTREAM: 'ws-a' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
// PROJECT.md and config.json must NOT be reported missing (E002/W003)
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E002'),
|
||||
`E002 (PROJECT.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E003'),
|
||||
`E003 (ROADMAP.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.errors.some(e => e.code === 'E004'),
|
||||
`E004 (STATE.md missing) should not fire with workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W003'),
|
||||
`W003 (config.json missing) should not fire with workstream layout: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
// Status should not be 'broken' due to path misrouting
|
||||
assert.notStrictEqual(
|
||||
output.status, 'broken',
|
||||
`Status should not be broken when files exist in the correct workstream layout: ${JSON.stringify(output)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('without GSD_WORKSTREAM, shared-root files are found at .planning/ root', () => {
|
||||
// All files at .planning/ root (no workstream sub-path)
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.errors.some(e => ['E002', 'E003', 'E004'].includes(e.code)),
|
||||
`No E002/E003/E004 should fire in standard non-workstream layout: ${JSON.stringify(output.errors)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #1454 regression — W017 must not fire for the active worktree
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('validate health — #1454 W017 excludes active worktree', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// The active-worktree exclusion guard in the SUT (src/verify.cts) compares
|
||||
// process.cwd() against each stale-finding path at runtime. The integration
|
||||
// scenario below covers the observable CLI contract; the unit-level injection
|
||||
// path is omitted here because bin/lib/verify.cjs is a gitignored tsc artifact
|
||||
// not present in a fresh worktree.
|
||||
|
||||
test('validate health completes without W017 for the cwd itself when inspected as worktree root', () => {
|
||||
// Set up a minimal healthy project at tmpDir
|
||||
writeMinimalProjectMd(tmpDir);
|
||||
writeMinimalRoadmap(tmpDir, ['1']);
|
||||
writeMinimalStateMd(tmpDir);
|
||||
writeValidConfigJson(tmpDir);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true });
|
||||
|
||||
// Run with cwd = tmpDir. The SUT will call process.cwd() which is the test runner's cwd,
|
||||
// not tmpDir, so any stale worktree that matches tmpDir (as a non-cwd) CAN legitimately
|
||||
// be flagged. The guard only protects the CURRENT process.cwd().
|
||||
// What we assert: when there is no real git repo at tmpDir, no W017 fires (git worktree
|
||||
// list will fail / return empty — the try/catch swallows it silently).
|
||||
const result = runGsdTools('validate health', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
!output.warnings.some(w => w.code === 'W017'),
|
||||
`W017 should not fire for a non-git project dir: ${JSON.stringify(output.warnings)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user