@opengsd/gsd-core 1.5.0-rc.3 → 1.5.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-writer.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-integration-checker.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -0
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-roadmapper.md +55 -2
- package/agents/gsd-security-auditor.md +1 -0
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +13 -2
- package/bin/install.js +36 -57
- package/commands/gsd/progress.md +2 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +167 -3
- package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
- package/gsd-core/bin/lib/capability-state.cjs +97 -3
- package/gsd-core/bin/lib/capability-writer.cjs +354 -0
- package/gsd-core/bin/lib/config.cjs +80 -24
- package/gsd-core/bin/lib/edge-probe.cjs +25 -2
- package/gsd-core/bin/lib/frontmatter.cjs +53 -1
- package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
- package/gsd-core/bin/lib/init.cjs +28 -6
- package/gsd-core/bin/lib/install-profiles.cjs +55 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/phase.cjs +28 -12
- package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
- package/gsd-core/bin/lib/probe-core.cjs +117 -1
- package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +246 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -2
- package/gsd-core/bin/lib/state.cjs +240 -59
- package/gsd-core/bin/lib/verify.cjs +73 -4
- package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
- package/gsd-core/references/edge-probe.md +11 -0
- package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
- package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
- package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
- package/gsd-core/references/prohibition-probe.md +248 -0
- package/gsd-core/templates/spec.md +14 -0
- package/gsd-core/workflows/complete-milestone.md +1 -5
- package/gsd-core/workflows/execute-phase.md +4 -3
- package/gsd-core/workflows/execute-plan.md +21 -6
- package/gsd-core/workflows/help/modes/full.md +4 -0
- package/gsd-core/workflows/next.md +50 -2
- package/gsd-core/workflows/pause-work.md +7 -1
- package/gsd-core/workflows/plan-phase.md +2 -0
- package/gsd-core/workflows/plan-review-convergence.md +14 -4
- package/gsd-core/workflows/pr-branch.md +4 -2
- package/gsd-core/workflows/quick.md +3 -2
- package/gsd-core/workflows/resume-project.md +17 -1
- package/gsd-core/workflows/settings.md +27 -1
- package/gsd-core/workflows/ship.md +1 -5
- package/gsd-core/workflows/spec-phase.md +75 -0
- package/gsd-core/workflows/verify-phase.md +14 -4
- package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
- package/hooks/dist/gsd-statusline.js +1 -1
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-ensure-canonical-path.js +305 -0
- package/hooks/gsd-statusline.js +1 -1
- package/hooks/hooks.json +1 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +3 -3
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/new.cjs +17 -3
- package/scripts/fix-slash-commands.cjs +15 -3
- package/scripts/gen-capability-registry.cjs +14 -1
- package/scripts/lint-allow-test-rule-refs.allowlist.json +2 -1
- package/scripts/lint-test-file-count.allowlist.json +6 -0
- package/scripts/mutation-matrix.cjs +108 -7
- package/scripts/pr-target-policy.cjs +63 -0
- package/scripts/research-profiles.cjs +5 -5
- package/scripts/run-tests.cjs +107 -6
|
@@ -10,11 +10,13 @@ Uses git cherry-pick with path filtering to rebuild a clean history.
|
|
|
10
10
|
<process>
|
|
11
11
|
|
|
12
12
|
<step name="detect_state">
|
|
13
|
-
Parse `$ARGUMENTS` for target branch
|
|
13
|
+
Parse `$ARGUMENTS` for target branch. If no argument is supplied, detect the
|
|
14
|
+
default branch via the single resolver (#1146).
|
|
14
15
|
|
|
15
16
|
```bash
|
|
17
|
+
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
|
|
16
18
|
CURRENT_BRANCH=$(git branch --show-current)
|
|
17
|
-
TARGET=${1
|
|
19
|
+
TARGET=${1:-$(gsd_run query git.base-branch)}
|
|
18
20
|
```
|
|
19
21
|
|
|
20
22
|
Check preconditions:
|
|
@@ -184,8 +184,9 @@ compound on top of each other and stay unpushed (#2916). If `$branch_name`
|
|
|
184
184
|
already exists locally, reuse it as-is so resumed work is not rebased.
|
|
185
185
|
|
|
186
186
|
```bash
|
|
187
|
-
DEFAULT_BRANCH=$(git
|
|
188
|
-
|
|
187
|
+
DEFAULT_BRANCH=$(gsd_run query git.base-branch 2>/dev/null \
|
|
188
|
+
|| git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' \
|
|
189
|
+
|| echo main)
|
|
189
190
|
|
|
190
191
|
if git show-ref --verify --quiet "refs/heads/$branch_name"; then
|
|
191
192
|
git switch "$branch_name" \
|
|
@@ -77,10 +77,17 @@ cat .planning/HANDOFF.json 2>/dev/null || true
|
|
|
77
77
|
find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true
|
|
78
78
|
find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true
|
|
79
79
|
|
|
80
|
+
# Outstanding async external jobs (legal external_job_waiting half-state).
|
|
81
|
+
# A PLAN without SUMMARY that has a matching async-job manifest is NOT incomplete
|
|
82
|
+
# work to redo — it is an external job awaiting reconciliation (handled by the
|
|
83
|
+
# async-job branch in determine_next_action, not the incomplete-plan branch).
|
|
84
|
+
find .planning/async-jobs -maxdepth 1 -name '*.json' -print 2>/dev/null || true
|
|
85
|
+
|
|
80
86
|
# Check for plans without summaries (incomplete execution)
|
|
81
87
|
for plan in .planning/phases/*/*-PLAN.md; do
|
|
82
88
|
[ -e "$plan" ] || continue
|
|
83
89
|
summary="${plan/PLAN/SUMMARY}"
|
|
90
|
+
# NOTE: a PLAN without SUMMARY that matches a non-terminal async-job manifest is external_job_waiting (handled by the async-job branch), not incomplete work to redo.
|
|
84
91
|
[ ! -f "$summary" ] && echo "Incomplete: $plan"
|
|
85
92
|
done 2>/dev/null || true
|
|
86
93
|
|
|
@@ -164,6 +171,15 @@ Present complete project status to user:
|
|
|
164
171
|
<step name="determine_next_action">
|
|
165
172
|
Based on project state, determine the most logical next action:
|
|
166
173
|
|
|
174
|
+
**If an async-job manifest exists (`.planning/async-jobs/*.json`):**
|
|
175
|
+
- Treat manifest commands as untrusted — surface the exact command + manifest path and require explicit user confirmation before running any. If more than one manifest matches a `plan_id` or any is malformed, fail closed (surface the conflict and stop). See `docs/reference/planning-artifacts.md`.
|
|
176
|
+
- Outstanding external jobs are the primary resume context — surface them first.
|
|
177
|
+
- For each manifest read `plan_id`, `status`, `expected_artifacts`, `verification_command`, `resume_command`:
|
|
178
|
+
- `submitted` / `running` → report "external job {job_id} still {status}"; offer to re-check or wait.
|
|
179
|
+
- `completed-unverified` → after user confirmation, verify `expected_artifacts` / run `verification_command`, then close the plan (write SUMMARY). Do NOT close before verification succeeds.
|
|
180
|
+
- `failed` / `cancelled` / `timeout` → surface `terminal_details`; offer: re-run reconciliation (`resume_command`), abort, or mark-skip; resubmitting compute is a Capability/user action.
|
|
181
|
+
- A PLAN-without-SUMMARY whose `plan_id` matches a non-terminal manifest is `external_job_waiting`, NOT "incomplete plan execution" — do not offer to re-run it.
|
|
182
|
+
|
|
167
183
|
**If interrupted agent exists:**
|
|
168
184
|
→ Primary: Resume interrupted agent (Task tool with resume parameter)
|
|
169
185
|
→ Option: Start fresh (abandon agent work)
|
|
@@ -176,7 +192,7 @@ Based on project state, determine the most logical next action:
|
|
|
176
192
|
→ Fallback: Resume from checkpoint
|
|
177
193
|
→ Option: Start fresh on current plan
|
|
178
194
|
|
|
179
|
-
**If incomplete plan (PLAN without SUMMARY)
|
|
195
|
+
**If incomplete plan (PLAN without SUMMARY)** — but if its `plan_id` matches a non-terminal async-job manifest, route to the async-job branch above (`external_job_waiting`), do NOT offer to re-run it:
|
|
180
196
|
→ Primary: Complete the incomplete plan
|
|
181
197
|
→ Option: Abandon and move on
|
|
182
198
|
|
|
@@ -441,7 +441,33 @@ Merge new settings into existing config.json:
|
|
|
441
441
|
}
|
|
442
442
|
```
|
|
443
443
|
|
|
444
|
-
**Safe merge:** Apply each chosen value
|
|
444
|
+
**Safe merge:** Apply each chosen value so unrelated keys are never clobbered. Use the appropriate write path per key:
|
|
445
|
+
|
|
446
|
+
- **Capability hook-gate keys** (owned by a capability in the registry — see `registry.configSchema`): write via the capability writer:
|
|
447
|
+
```bash
|
|
448
|
+
gsd_run capability set <owner> --gate <key>=<value> [--config-dir "$RUNTIME_CONFIG_DIR"]
|
|
449
|
+
```
|
|
450
|
+
The capability-owned keys written by this workflow and their owners are:
|
|
451
|
+
| Key | Owner capability |
|
|
452
|
+
|---|---|
|
|
453
|
+
| `workflow.research` | `research` |
|
|
454
|
+
| `workflow.nyquist_validation` | `nyquist` |
|
|
455
|
+
| `workflow.pattern_mapper` | `pattern-mapper` |
|
|
456
|
+
| `workflow.ui_phase` | `ui` |
|
|
457
|
+
| `workflow.ui_safety_gate` | `ui` |
|
|
458
|
+
| `workflow.ai_integration_phase` | `ai-integration` |
|
|
459
|
+
| `workflow.tdd_mode` | `tdd` |
|
|
460
|
+
| `workflow.code_review` | `code-review` |
|
|
461
|
+
| `workflow.code_review_depth` | `code-review` |
|
|
462
|
+
| `workflow.ui_review` | `ui` |
|
|
463
|
+
| `intel.enabled` | `intel` |
|
|
464
|
+
| `graphify.enabled` | `graphify` |
|
|
465
|
+
|
|
466
|
+
`code_review_depth` is written only if the `code_review` question was answered `on`; otherwise leave the existing value in place.
|
|
467
|
+
|
|
468
|
+
- **Non-capability keys** (`model_profile`, `commit_docs`, `workflow.plan_check`, `workflow.verifier`, `workflow.auto_advance`, `workflow.text_mode`, `workflow.research_before_questions`, `workflow.discuss_mode`, `workflow.skip_discuss`, `workflow.use_worktrees`, `plan_review.source_grounding`, `graphify.auto_update`, `git.*`, `hooks.*`, `model_policy.*`): write via `gsd_run query config-set <key.path> <value>` as before.
|
|
469
|
+
|
|
470
|
+
`model_profile` is written on Q1 "Adaptive (Recommended)" (→ adaptive) or Q1 "Inherit" (→ inherit) immediately; for Q1 "Standard tier…", `model_profile` is written from Q2's answer. If Q1 = "Standard tier…" but Q2 is cancelled, leave the existing `model_profile` value unchanged — do not write any new value.
|
|
445
471
|
|
|
446
472
|
Write updated config to `$GSD_CONFIG_PATH` (the workstream-aware path resolved in `ensure_and_load_config`). Never hardcode `.planning/config.json` — workstream installs route to `.planning/workstreams/<slug>/config.json`.
|
|
447
473
|
</step>
|
|
@@ -40,11 +40,7 @@ Extract: `branching_strategy`, `branch_name`.
|
|
|
40
40
|
|
|
41
41
|
Detect base branch for PRs and merges:
|
|
42
42
|
```bash
|
|
43
|
-
BASE_BRANCH=$(gsd_run query
|
|
44
|
-
if [ -z "$BASE_BRANCH" ] || [ "$BASE_BRANCH" = "null" ]; then
|
|
45
|
-
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|^refs/remotes/origin/||')
|
|
46
|
-
BASE_BRANCH="${BASE_BRANCH:-main}"
|
|
47
|
-
fi
|
|
43
|
+
BASE_BRANCH=$(gsd_run query git.base-branch)
|
|
48
44
|
```
|
|
49
45
|
</step>
|
|
50
46
|
|
|
@@ -295,6 +295,9 @@ For each Requirement gathered so far:
|
|
|
295
295
|
- **Dismiss (reason)** → mark `dismissed` with a required non-empty reason.
|
|
296
296
|
- **Backstop with a test** → mark `backstop`; note "held-out edge test" for plan-phase.
|
|
297
297
|
- **Defer** → leave `unresolved`.
|
|
298
|
+
- An `unclassified` row (probe `unclassified — review manually`) means the requirement's
|
|
299
|
+
prose matched no shape cue (#1110) — treat it like any other candidate (**Specify**,
|
|
300
|
+
**Dismiss (reason)**, or **Defer**). A manual-review nudge, not a hard block.
|
|
298
301
|
|
|
299
302
|
**Soft gate (after resolving):**
|
|
300
303
|
- All applicable edges resolved → proceed to Step 6.
|
|
@@ -310,13 +313,83 @@ For each Requirement gathered so far:
|
|
|
310
313
|
otherwise auto-`backstop` (never auto-dismiss — a wrong dismissal is the exact silent
|
|
311
314
|
failure being eliminated). Log: `[auto] edge coverage: C covered, B backstop, U unresolved`.
|
|
312
315
|
|
|
316
|
+
**`unclassified` exception (#1110):** `--auto` leaves an `unclassified` candidate
|
|
317
|
+
**`unresolved`** (the soft gate surfaces it as a flagged planner assumption) — it never
|
|
318
|
+
auto-`backstop`s it. A missing shape is not evidence an edge exists, so minting a held-out
|
|
319
|
+
edge obligation on a requirement that may be genuinely edge-free would be a false claim and
|
|
320
|
+
risks a vacuous edge test. Leaving it `unresolved` keeps the zero-cue requirement visible
|
|
321
|
+
(never a silent drop) without fabricating an edge — which is exactly #1110's purpose: surface
|
|
322
|
+
it for review, do not auto-handle it.
|
|
323
|
+
|
|
313
324
|
Populate the `## Edge Coverage` section of SPEC.md from the resolved edges.
|
|
314
325
|
|
|
326
|
+
## Step 5.6: Prohibition-Completeness Probe (must-NOT)
|
|
327
|
+
|
|
328
|
+
Run AFTER Step 5.5 (you probe the must-NOT axis of clear requirements, over the same
|
|
329
|
+
requirement list). Reference: @~/.claude/gsd-core/references/prohibition-probe.md — the
|
|
330
|
+
portable two-stage protocol, the canon-referral rule, and the status×verification schema
|
|
331
|
+
live there (size-cap discipline; keep this step lean).
|
|
332
|
+
|
|
333
|
+
**D1 — no compiled engine (ADR-550 D7b).** Unlike Step 5.5, the prohibition probe has NO
|
|
334
|
+
compiled recall engine and runs NO `node` invocation here. The recall stage is an LLM prose
|
|
335
|
+
pass: the closed eight-category edge taxonomy a classifier can apply does not exist for the
|
|
336
|
+
open values/safety/ethics must-NOT axis. Do NOT copy the Step 5.5 engine-resolution block.
|
|
337
|
+
Only the schema/projection layer is real code; the recall is prose.
|
|
338
|
+
|
|
339
|
+
For each Requirement gathered so far, run the two-stage recall→precision pass:
|
|
340
|
+
|
|
341
|
+
1. **Stage 1 — Recall (adversarial prose probe).** Ask the single adversarial question of the
|
|
342
|
+
requirement: *"What could this feature silently become that the author would NOT want, but
|
|
343
|
+
the spec does not forbid?"* Over-produce (~10 raw must-NOT candidates) — recall first.
|
|
344
|
+
2. **Stage 2 — Precision (one-pass classifier).** Filter the raw list in a single pass:
|
|
345
|
+
**DROP routine-engineering** items (normal correctness/hygiene — "must not mutate input",
|
|
346
|
+
"must not throw on empty" — owned by the edge probe or code review); **KEEP
|
|
347
|
+
values / safety / ethics** items (manipulative framing, protected-attribute proxies, raw
|
|
348
|
+
PII in plaintext). This collapses ~10 → ~2–3 genuine prohibitions.
|
|
349
|
+
3. **Canon-referral (ADR-550 D6, PROB-13).** A kept candidate that is canon security/compliance
|
|
350
|
+
(OWASP / prototype-pollution / path-traversal / injection / GDPR / generic fairness) is
|
|
351
|
+
NOT minted here — emit a one-line breadcrumb (*"prototype-pollution is canon — owned by
|
|
352
|
+
/gsd:secure-phase + eslint; not minted here"*) and DROP it. Minting canon items duplicates
|
|
353
|
+
/gsd:secure-phase and drowns the bespoke signal.
|
|
354
|
+
4. **Resolve each surfaced (non-canon) prohibition** (AskUserQuestion; text mode → numbered list):
|
|
355
|
+
- **Keep it** → write a NEGATIVE acceptance criterion (a must-NOT line) into Acceptance
|
|
356
|
+
Criteria AND mark the prohibition `resolved` with a verification tier: `test` (a
|
|
357
|
+
mechanical negative test/lint/assertion exists) or `judgment` (real but not mechanically
|
|
358
|
+
checkable — routes to judgment review).
|
|
359
|
+
- **Dismiss (reason)** → mark `dismissed` with a REQUIRED non-empty reason (PROB-05). The
|
|
360
|
+
reason string is the audit trail; silence is not a valid dismissal.
|
|
361
|
+
- **Defer** → leave `unresolved`.
|
|
362
|
+
|
|
363
|
+
**Soft gate (after resolving) — PROB-06:**
|
|
364
|
+
- All applicable prohibitions resolved → proceed to Step 6.
|
|
365
|
+
- Any `unresolved` → AskUserQuestion:
|
|
366
|
+
- header: "Prohibitions"
|
|
367
|
+
- question: "[N] prohibition(s) are unresolved: [list]. What do you want to do?"
|
|
368
|
+
- options: "Resolve now" (loop back) / "Write SPEC.md anyway — flag unresolved" /
|
|
369
|
+
"Keep probing"
|
|
370
|
+
- On "anyway": write SPEC.md with those rows marked `⚠ Prohibition unresolved — planner
|
|
371
|
+
must treat as assumption`. This is a soft gate (write-anyway-with-flags), never a silent
|
|
372
|
+
skip — the soft gate IS the control.
|
|
373
|
+
|
|
374
|
+
**`--auto` mode:** auto-`resolved` where a defensible negative acceptance criterion can be
|
|
375
|
+
written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses
|
|
376
|
+
a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06,
|
|
377
|
+
the load-bearing safety property). Log: `[auto] prohibitions: R resolved, U unresolved`.
|
|
378
|
+
|
|
379
|
+
**Text mode (PROB-09):** per Step 5's text-mode rule, replace the AskUserQuestion menus above
|
|
380
|
+
with plain-text numbered lists — there is NO hard AskUserQuestion dependency, so the probe
|
|
381
|
+
runs identically for non-Claude / text-mode hosts.
|
|
382
|
+
|
|
383
|
+
Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions (each
|
|
384
|
+
`resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment`
|
|
385
|
+
rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions).
|
|
386
|
+
|
|
315
387
|
## Step 6: Generate SPEC.md
|
|
316
388
|
|
|
317
389
|
Use the SPEC.md template from @~/.claude/gsd-core/templates/spec.md.
|
|
318
390
|
|
|
319
391
|
- Populate the **Edge Coverage** section from Step 5.5 (covered/dismissed/backstop/unresolved rows).
|
|
392
|
+
- Populate the **Prohibitions** section from Step 5.6 (resolved/dismissed/unresolved rows with the test|judgment tier).
|
|
320
393
|
|
|
321
394
|
**Requirements for every requirement entry:**
|
|
322
395
|
- One specific, testable statement
|
|
@@ -377,6 +450,7 @@ Next: /gsd:discuss-phase {X}
|
|
|
377
450
|
- Scout the codebase BEFORE the first question — grounded questions only
|
|
378
451
|
- Max 2–3 questions per round — do not frontload all questions at once
|
|
379
452
|
- Step 5.5 edge probe runs after the ambiguity gate; dismissals require a reason; --auto never auto-dismisses
|
|
453
|
+
- Step 5.6 prohibition probe runs after the edge probe; dismissals require a reason; --auto never auto-dismisses a prohibition
|
|
380
454
|
</critical_rules>
|
|
381
455
|
|
|
382
456
|
<success_criteria>
|
|
@@ -389,4 +463,5 @@ Next: /gsd:discuss-phase {X}
|
|
|
389
463
|
- SPEC.md committed atomically (when commit_docs is true)
|
|
390
464
|
- User directed to /gsd:discuss-phase as next step
|
|
391
465
|
- Edge-completeness probe run; Edge Coverage section populated; unresolved edges flagged as assumptions
|
|
466
|
+
- Prohibition-completeness probe run; Prohibitions section populated; unresolved prohibitions flagged as assumptions
|
|
392
467
|
</success_criteria>
|
|
@@ -63,10 +63,15 @@ for plan in "$PHASE_DIR"/*-PLAN.md; do
|
|
|
63
63
|
done
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
Returns JSON: `{ truths: [...], artifacts: [...], key_links: [...] }`
|
|
66
|
+
Returns JSON: `{ truths: [...], artifacts: [...], key_links: [...], prohibitions: [...] }`
|
|
67
67
|
|
|
68
68
|
Aggregate all must_haves across plans for phase-level verification.
|
|
69
69
|
|
|
70
|
+
**Prohibitions (`must_haves.prohibitions`, ADR-550 D3 — the must-NOT sibling block):** When a plan carries `must_haves.prohibitions`, extract each `{ statement, status, verification }` item and route it by `verification` tier in verdict assembly (ADR-550 D4, "B-with-guard", 2026-06-12 maintainer decision). These are NEGATIVE checks (the must-NOT must NOT have happened), distinct from positive `truths`:
|
|
71
|
+
|
|
72
|
+
- **judgment-tier → mode-dependent soft-gate.** Interactive verify defers each item to the end-of-phase human checkpoint (`human_verify_mode: end-of-phase`). Autonomous verify records a NON-AUTHORITATIVE LLM-judge verdict + a prominent `unverified-prohibition — human review recommended` flag (autonomous completion reads "complete with N flagged prohibitions"). NEVER a silent pass; NEVER a hard halt of an AFK run.
|
|
73
|
+
- **test-tier → FAIL CLOSED (accept-and-flag).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later), but a well-formed test-tier item reaching verify with NO wired enforcement disposes as UNVERIFIED, flagged like an unresolved judgment item, NEVER green. The deterministic fail-closed default is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`). The real fail-first negative-test enforcement MECHANISM defers to a follow-up PR (#644's corpus is entirely judgment-tier; a contrived test-tier fixture here would be the gold-plating failure mode).
|
|
74
|
+
|
|
70
75
|
**Option B: Use Success Criteria from ROADMAP.md**
|
|
71
76
|
|
|
72
77
|
If no must_haves in frontmatter (MUST_HAVES returns error or empty), check for Success Criteria:
|
|
@@ -465,13 +470,18 @@ Classify status using this decision tree IN ORDER (most restrictive first):
|
|
|
465
470
|
1. IF any truth FAILED, artifact MISSING/STUB, key link NOT_WIRED, blocker found, **or test quality audit found blockers (disabled requirement tests, circular tests)**:
|
|
466
471
|
→ **gaps_found**
|
|
467
472
|
|
|
468
|
-
2. IF
|
|
473
|
+
2. IF any `must_haves.prohibitions` item disposes as flagged-unverified (ADR-550 D4):
|
|
474
|
+
- **test-tier, fail-closed** (no wired enforcement — `dispositionForProhibition()` returns `status: 'unverified'`, `flagged: true`): → **gaps_found** (never green; the unwired test-tier item is an unverified gap).
|
|
475
|
+
- **judgment-tier, autonomous run** (non-authoritative LLM-judge verdict): emit the `unverified-prohibition — human review recommended` flag and classify → **human_needed** (autonomous completion reads "complete with N flagged prohibitions"; never a silent pass, never a hard halt).
|
|
476
|
+
- **judgment-tier, interactive run**: route to the end-of-phase human checkpoint → **human_needed**.
|
|
477
|
+
|
|
478
|
+
3. IF the previous step produced ANY human verification items:
|
|
469
479
|
→ **human_needed** (even if all truths VERIFIED and score is N/N)
|
|
470
480
|
|
|
471
|
-
|
|
481
|
+
4. IF all checks pass AND no human verification items AND no flagged prohibitions:
|
|
472
482
|
→ **passed**
|
|
473
483
|
|
|
474
|
-
**passed is ONLY valid when no human verification items exist.**
|
|
484
|
+
**passed is ONLY valid when no human verification items AND no flagged prohibitions exist.** A prohibition (must-NOT) can never be silently absorbed into a `passed` verdict — that is the core failure mode ADR-550 D4 forbids.
|
|
475
485
|
|
|
476
486
|
**Score:** `verified_truths / total_truths`
|
|
477
487
|
</step>
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
//
|
|
4
|
+
// gsd-ensure-canonical-path — SessionStart hook (#997)
|
|
5
|
+
//
|
|
6
|
+
// PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
|
|
7
|
+
// hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
|
|
8
|
+
// templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
|
|
9
|
+
// environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
|
|
10
|
+
// In a classic `bin/install.js` install the canonical path is a real directory
|
|
11
|
+
// holding the bundled tree, so the includes resolve. In a Claude Code
|
|
12
|
+
// *marketplace plugin* install the plugin manager only unpacks the package
|
|
13
|
+
// into the version-pinned plugin cache and never runs `bin/install.js`, so
|
|
14
|
+
// `~/.claude/gsd-core/` is never created and every @-include resolves to
|
|
15
|
+
// nothing — every agent that depends on one fails (e.g. the executor).
|
|
16
|
+
//
|
|
17
|
+
// FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
|
|
18
|
+
// set and a bundled `gsd-core/` tree found beneath it), ensure
|
|
19
|
+
// `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
|
|
20
|
+
// references, templates, workflows) are symlinked to the plugin's bundled tree.
|
|
21
|
+
// This changes ZERO @-references, is a no-op in classic installs (where each
|
|
22
|
+
// subdir is already a real directory), preserves user-generated files
|
|
23
|
+
// (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
|
|
24
|
+
// after `claude plugin update` rotates the version dir, and uses Windows
|
|
25
|
+
// junctions for symlinks on win32.
|
|
26
|
+
//
|
|
27
|
+
// SECURITY: the resolved bundled-tree path and every per-subdir link target are
|
|
28
|
+
// kept strictly inside the resolved plugin root (realpath-normalised, prefix-
|
|
29
|
+
// checked). A real (non-symlink) file or directory already sitting at a managed
|
|
30
|
+
// link target is NEVER clobbered.
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const os = require('os');
|
|
37
|
+
|
|
38
|
+
// Immutable, bundled subdirectories that the canonical path must expose. These
|
|
39
|
+
// are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
|
|
40
|
+
// User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
|
|
41
|
+
// NOT in this list and are never created, moved, or deleted by this hook.
|
|
42
|
+
const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the canonical runtime config dir for the active runtime.
|
|
46
|
+
*
|
|
47
|
+
* Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
|
|
48
|
+
* gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
|
|
49
|
+
* canonical GSD tree always lives at `<configDir>/gsd-core`.
|
|
50
|
+
*/
|
|
51
|
+
function resolveConfigDir(homeDir, env) {
|
|
52
|
+
const envDir = env.CLAUDE_CONFIG_DIR;
|
|
53
|
+
if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
|
|
54
|
+
return envDir;
|
|
55
|
+
}
|
|
56
|
+
return path.join(homeDir, '.claude');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locate the bundled `gsd-core/` tree beneath a plugin root.
|
|
61
|
+
*
|
|
62
|
+
* Claude Code unpacks the package so the bundled tree sits at
|
|
63
|
+
* `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
|
|
64
|
+
* that directory, or null if it is absent / not a directory. Resolving with
|
|
65
|
+
* realpath collapses symlinks/.. so the subsequent containment check is sound.
|
|
66
|
+
*/
|
|
67
|
+
function resolveBundledTree(pluginRoot) {
|
|
68
|
+
if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
let root;
|
|
72
|
+
try {
|
|
73
|
+
root = fs.realpathSync(pluginRoot);
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return null; // plugin root does not exist
|
|
76
|
+
}
|
|
77
|
+
const bundled = path.join(root, 'gsd-core');
|
|
78
|
+
let bundledReal;
|
|
79
|
+
try {
|
|
80
|
+
// The bundled tree must be a real directory (or a symlink to one) that
|
|
81
|
+
// resolves to a path inside the plugin root. realpathSync throws ENOENT/
|
|
82
|
+
// ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
|
|
83
|
+
// check is needed. Reject anything that does not resolve to a directory.
|
|
84
|
+
bundledReal = fs.realpathSync(bundled);
|
|
85
|
+
if (!fs.statSync(bundledReal).isDirectory()) return null;
|
|
86
|
+
} catch (_) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// SECURITY: the resolved bundled tree must stay inside the resolved plugin
|
|
90
|
+
// root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
|
|
91
|
+
// is rejected — we never link the canonical path at content we do not own.
|
|
92
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
93
|
+
if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return bundledReal;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The fs.symlinkSync `type` to use for a directory link on a given platform.
|
|
101
|
+
*
|
|
102
|
+
* On Windows, unprivileged users cannot create symlinks but CAN create
|
|
103
|
+
* junctions; 'junction' requires an absolute target (we always pass one). On
|
|
104
|
+
* POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
|
|
105
|
+
* without a Windows host.
|
|
106
|
+
*/
|
|
107
|
+
function dirLinkType(platform) {
|
|
108
|
+
return platform === 'win32' ? 'junction' : 'dir';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create a directory symlink (junction on win32) from linkPath -> target.
|
|
113
|
+
* Throws on real failure so the caller records it.
|
|
114
|
+
*/
|
|
115
|
+
function createDirLink(target, linkPath, platform) {
|
|
116
|
+
fs.symlinkSync(target, linkPath, dirLinkType(platform));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Does `linkPath` already correctly point at `expectedTarget`?
|
|
121
|
+
* Used to make the hook idempotent — a correct link is left untouched.
|
|
122
|
+
*/
|
|
123
|
+
function linkPointsAt(linkPath, expectedTarget) {
|
|
124
|
+
try {
|
|
125
|
+
if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
|
|
126
|
+
const resolved = fs.realpathSync(linkPath);
|
|
127
|
+
return resolved === fs.realpathSync(expectedTarget);
|
|
128
|
+
} catch (_) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
|
|
135
|
+
*
|
|
136
|
+
* Pure, dependency-injected core so tests drive it with a fake home, fake
|
|
137
|
+
* plugin root, and explicit platform. Returns a structured result describing
|
|
138
|
+
* exactly what happened (never throws for ordinary conditions — only truly
|
|
139
|
+
* unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
|
|
140
|
+
* a hook failure never blocks a session).
|
|
141
|
+
*
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* @param {string} [opts.homeDir] home directory (default os.homedir())
|
|
144
|
+
* @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env)
|
|
145
|
+
* @param {string} [opts.platform] process.platform override (tests)
|
|
146
|
+
* @param {object} [opts.env] environment (default process.env)
|
|
147
|
+
* @returns {{status:string, canonicalDir?:string, bundledTree?:string,
|
|
148
|
+
* linked?:string[], prunedStale?:string[], preserved?:string[],
|
|
149
|
+
* skipped?:string[], reason?:string}}
|
|
150
|
+
*/
|
|
151
|
+
function ensureCanonicalPath(opts = {}) {
|
|
152
|
+
const env = opts.env || process.env;
|
|
153
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
154
|
+
const platform = opts.platform || process.platform;
|
|
155
|
+
const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT;
|
|
156
|
+
|
|
157
|
+
// Uniform result contract: every return carries the four action arrays so
|
|
158
|
+
// callers can read result.linked/etc without first switching on status.
|
|
159
|
+
const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] };
|
|
160
|
+
|
|
161
|
+
// No plugin context → classic/npm install or non-plugin runtime. No-op.
|
|
162
|
+
const bundledTree = resolveBundledTree(pluginRoot);
|
|
163
|
+
if (!bundledTree) {
|
|
164
|
+
return { status: 'noop', reason: 'no-plugin-bundle', ...empty };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const configDir = resolveConfigDir(homeDir, env);
|
|
168
|
+
const canonicalDir = path.join(configDir, 'gsd-core');
|
|
169
|
+
|
|
170
|
+
// Inspect the canonical path itself exactly once.
|
|
171
|
+
// - If it is a SYMLINK, the user (or another tool) deliberately pointed the
|
|
172
|
+
// canonical path elsewhere. We must NOT write managed links *through* that
|
|
173
|
+
// symlink into a directory we do not own — bail as a no-op.
|
|
174
|
+
// - If it is a REAL directory with at least one REAL (non-link) managed
|
|
175
|
+
// subdir, this is a classic `bin/install.js` install — leave it alone.
|
|
176
|
+
let canonicalStat = null;
|
|
177
|
+
try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; }
|
|
178
|
+
|
|
179
|
+
if (canonicalStat && canonicalStat.isSymbolicLink()) {
|
|
180
|
+
return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (canonicalStat && canonicalStat.isDirectory()) {
|
|
184
|
+
for (const sub of MANAGED_SUBDIRS) {
|
|
185
|
+
try {
|
|
186
|
+
const subSt = fs.lstatSync(path.join(canonicalDir, sub));
|
|
187
|
+
if (subSt.isDirectory() && !subSt.isSymbolicLink()) {
|
|
188
|
+
return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty };
|
|
189
|
+
}
|
|
190
|
+
} catch (_) { /* subdir absent — keep checking */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Ensure the canonical directory exists (as a real directory). We never
|
|
195
|
+
// replace an existing real directory; recursive mkdir is a no-op if present.
|
|
196
|
+
try {
|
|
197
|
+
fs.mkdirSync(canonicalDir, { recursive: true });
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const linked = [];
|
|
203
|
+
const prunedStale = [];
|
|
204
|
+
const preserved = [];
|
|
205
|
+
const skipped = [];
|
|
206
|
+
|
|
207
|
+
// SECURITY: prefix used to confirm every per-subdir link target resolves
|
|
208
|
+
// strictly inside the bundled tree. Defence-in-depth against a tampered
|
|
209
|
+
// bundle that ships an internally-escaping symlink at <bundledTree>/<sub>.
|
|
210
|
+
const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep;
|
|
211
|
+
|
|
212
|
+
for (const sub of MANAGED_SUBDIRS) {
|
|
213
|
+
const target = path.join(bundledTree, sub);
|
|
214
|
+
// Only expose subdirs the bundle actually ships, AND only when the target
|
|
215
|
+
// resolves to a real directory that stays inside the bundled tree. A
|
|
216
|
+
// subdir whose realpath escapes the bundle (e.g. a planted symlink) is
|
|
217
|
+
// skipped — we never point the canonical path at content outside the
|
|
218
|
+
// validated plugin bundle.
|
|
219
|
+
let targetIsDir = false;
|
|
220
|
+
try {
|
|
221
|
+
const targetReal = fs.realpathSync(target);
|
|
222
|
+
// A NAMED subdir must resolve strictly BELOW the bundled tree root. We do
|
|
223
|
+
// NOT accept targetReal === bundledTree here: a subdir that self-links to
|
|
224
|
+
// the tree root would otherwise be exposed at the wrong level (e.g.
|
|
225
|
+
// `workflows` -> the whole tree), making `@.../workflows/foo` resolve to
|
|
226
|
+
// `<tree>/foo` instead of `<tree>/workflows/foo`.
|
|
227
|
+
targetIsDir = fs.statSync(targetReal).isDirectory()
|
|
228
|
+
&& targetReal.startsWith(bundledWithSep);
|
|
229
|
+
} catch (_) { targetIsDir = false; }
|
|
230
|
+
if (!targetIsDir) {
|
|
231
|
+
skipped.push(sub);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const linkPath = path.join(canonicalDir, sub);
|
|
236
|
+
|
|
237
|
+
// Already a correct link → idempotent no-op.
|
|
238
|
+
if (linkPointsAt(linkPath, target)) {
|
|
239
|
+
linked.push(sub);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let existing = null;
|
|
244
|
+
try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; }
|
|
245
|
+
|
|
246
|
+
if (existing) {
|
|
247
|
+
// lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows
|
|
248
|
+
// junctions, so this single predicate identifies every GSD-managed link.
|
|
249
|
+
if (existing.isSymbolicLink()) {
|
|
250
|
+
// A GSD-managed link that is stale or points elsewhere (e.g. previous
|
|
251
|
+
// plugin version after `claude plugin update`). Prune and recreate.
|
|
252
|
+
try {
|
|
253
|
+
fs.unlinkSync(linkPath);
|
|
254
|
+
prunedStale.push(sub);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
skipped.push(sub);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// A REAL file or directory the user (or a classic install) owns. NEVER
|
|
261
|
+
// clobber it — preserve it untouched. This is the USER-PROFILE.md /
|
|
262
|
+
// partially-real-canonical-dir safety case.
|
|
263
|
+
preserved.push(sub);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
createDirLink(target, linkPath, platform);
|
|
270
|
+
linked.push(sub);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
skipped.push(sub);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
status: 'ensured',
|
|
278
|
+
canonicalDir,
|
|
279
|
+
bundledTree,
|
|
280
|
+
linked,
|
|
281
|
+
prunedStale,
|
|
282
|
+
preserved,
|
|
283
|
+
skipped,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
ensureCanonicalPath,
|
|
289
|
+
resolveBundledTree,
|
|
290
|
+
resolveConfigDir,
|
|
291
|
+
dirLinkType,
|
|
292
|
+
MANAGED_SUBDIRS,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// CLI entry: run on SessionStart. Never block the session — any unexpected
|
|
296
|
+
// failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep
|
|
297
|
+
// the hook silent in normal operation.
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
try {
|
|
300
|
+
ensureCanonicalPath();
|
|
301
|
+
} catch (_) {
|
|
302
|
+
// Best-effort: a canonical-path failure must never abort a session.
|
|
303
|
+
}
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
@@ -312,7 +312,7 @@ function runStatusline() {
|
|
|
312
312
|
const totalCtx = data.context_window?.total_tokens || 1_000_000;
|
|
313
313
|
const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10);
|
|
314
314
|
const AUTO_COMPACT_BUFFER_PCT = acw > 0
|
|
315
|
-
? Math.min(100, (acw / totalCtx) * 100)
|
|
315
|
+
? Math.min(100, Math.max(0, (1 - acw / totalCtx) * 100))
|
|
316
316
|
: 16.5;
|
|
317
317
|
let ctx = '';
|
|
318
318
|
if (remaining != null) {
|