@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +975 -196
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +503 -87
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/host-integration.cjs +33 -8
- package/gsd-core/bin/lib/init.cjs +60 -53
- package/gsd-core/bin/lib/install-engine.cjs +93 -22
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/mcp-server.cjs +18 -7
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +7 -37
- package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
- package/gsd-core/bin/lib/smart-entry.cjs +4 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +12 -8
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -3
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -4
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-statusline.js +164 -14
- package/hooks/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +10 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +3 -0
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/validate-registry.cjs +117 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- package/vscode/package.json +96 -0
|
@@ -223,7 +223,7 @@ Agent(
|
|
|
223
223
|
## 8. Handle Checker Return
|
|
224
224
|
|
|
225
225
|
**If `## UI-SPEC VERIFIED`:**
|
|
226
|
-
Display dimension results. Proceed to step
|
|
226
|
+
Display dimension results. Proceed to step 9.5.
|
|
227
227
|
|
|
228
228
|
**If `## ISSUES FOUND`:**
|
|
229
229
|
Display blocking issues. Proceed to step 9.
|
|
@@ -264,6 +264,151 @@ Options:
|
|
|
264
264
|
|
|
265
265
|
Use AskUserQuestion for the choice.
|
|
266
266
|
|
|
267
|
+
**On "Force approve":** proceed to step 9.5 (the UI-consideration probe still runs on the accepted UI-SPEC, so state coverage is recorded even when quality FLAGs were accepted), then step 10. **On "Edit manually" / "Abandon":** exit without running the probe.
|
|
268
|
+
|
|
269
|
+
## 9.5. UI-Consideration Probe (post-verification)
|
|
270
|
+
|
|
271
|
+
Run AFTER the checker approves the UI-SPEC (VERIFIED, or force-approved at step 9) — never inline
|
|
272
|
+
during authoring, so a revision-loop researcher rewrite (step 9) cannot clobber the section and the
|
|
273
|
+
`## UI Considerations` block is committed with the FINAL UI-SPEC. This is the visual analog of
|
|
274
|
+
spec-phase Step 5.5's edge probe, retargeted to the UI element/state axis. Reference:
|
|
275
|
+
@~/.claude/gsd-core/references/ui-consideration-probe.md.
|
|
276
|
+
|
|
277
|
+
**Skip conditions:** if `--auto` and the UI-SPEC already carries a resolved `## UI Considerations`
|
|
278
|
+
section (re-run), the write-back is idempotent (it REPLACES that section, never appends). If the
|
|
279
|
+
runtime is non-Claude and the probe engine cannot be resolved, the shim FAILS LOUD (below) — it
|
|
280
|
+
never silently no-ops (a silent skip would drop the whole state-coverage axis).
|
|
281
|
+
|
|
282
|
+
**Runtime coverage compute — resolve and invoke ui-consideration-probe.cjs:**
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# Resolve the compiled ui-consideration-probe.cjs against the GSD install dir via RUNTIME_DIR
|
|
286
|
+
# (#448) — NOT the consuming project's git root — falling back to git toplevel / $HOME/.claude.
|
|
287
|
+
# Mirrors spec-phase.md Step 5.5's edge-probe resolution idiom verbatim (same candidate paths).
|
|
288
|
+
_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
289
|
+
UI_PROBE_JS=$(for _c in \
|
|
290
|
+
"$_GSD_RT/gsd-core/bin/lib/ui-consideration-probe.cjs" \
|
|
291
|
+
"$_GSD_RT/bin/lib/ui-consideration-probe.cjs" \
|
|
292
|
+
"$_GSD_RT/.claude/bin/lib/ui-consideration-probe.cjs" \
|
|
293
|
+
"$HOME/.claude/gsd-core/bin/lib/ui-consideration-probe.cjs" \
|
|
294
|
+
"$HOME/.claude/bin/lib/ui-consideration-probe.cjs"; do
|
|
295
|
+
[ -f "$_c" ] && { echo "$_c"; break; }
|
|
296
|
+
done)
|
|
297
|
+
|
|
298
|
+
# Graceful degradation — never a silent skip. Build ONLY when $_GSD_RT is a verified GSD source
|
|
299
|
+
# checkout (has tsconfig.build.json + src/ui-consideration-probe.cts), pinned with --prefix so we
|
|
300
|
+
# never trigger the CONSUMING project's own build during a ui-phase. Real installs ship the
|
|
301
|
+
# compiled .cjs via prepublishOnly, so this path only matters in a GSD dev checkout.
|
|
302
|
+
if [ -z "$UI_PROBE_JS" ]; then
|
|
303
|
+
if [ -f "$_GSD_RT/tsconfig.build.json" ] && [ -f "$_GSD_RT/src/ui-consideration-probe.cts" ]; then
|
|
304
|
+
npm --prefix "$_GSD_RT" run build:lib 2>/dev/null || true
|
|
305
|
+
UI_PROBE_JS=$(for _c in \
|
|
306
|
+
"$_GSD_RT/gsd-core/bin/lib/ui-consideration-probe.cjs" \
|
|
307
|
+
"$_GSD_RT/bin/lib/ui-consideration-probe.cjs" \
|
|
308
|
+
"$_GSD_RT/.claude/bin/lib/ui-consideration-probe.cjs" \
|
|
309
|
+
"$HOME/.claude/gsd-core/bin/lib/ui-consideration-probe.cjs" \
|
|
310
|
+
"$HOME/.claude/bin/lib/ui-consideration-probe.cjs"; do
|
|
311
|
+
[ -f "$_c" ] && { echo "$_c"; break; }
|
|
312
|
+
done)
|
|
313
|
+
fi
|
|
314
|
+
if [ -z "$UI_PROBE_JS" ]; then
|
|
315
|
+
echo "ERROR: ui-consideration-probe.cjs not found — reinstall GSD or run \`npm run build:lib\` in your GSD checkout." >&2
|
|
316
|
+
exit 1
|
|
317
|
+
fi
|
|
318
|
+
fi
|
|
319
|
+
|
|
320
|
+
# Element extraction (MANUAL BY DESIGN — not an oversight): the agent reads the researcher-authored
|
|
321
|
+
# UI-SPEC prose (the described surfaces — the Design System / Copywriting rows and any element the
|
|
322
|
+
# researcher named) and writes ONE object per UI element/surface: {"id","text"} where text is the
|
|
323
|
+
# prose describing it. This mirrors spec-phase Step 5.5's edge-probe REQS_JSON step VERBATIM — a
|
|
324
|
+
# hand-populated heredoc guarded by the fail-loud <replace:> check below — the established, shipped
|
|
325
|
+
# pattern for feeding a probe from a prose spec. It is NOT mechanized on purpose: a UI-SPEC has no
|
|
326
|
+
# single machine-parseable "elements" column — surfaces are distributed across design-token tables
|
|
327
|
+
# (Design System / Typography / Color), the Copywriting section, and prose the researcher names, so a
|
|
328
|
+
# regex/table parse would fail-OPEN (miss a prose-named surface, or feed a design-token row as a bogus
|
|
329
|
+
# element). The agent-authored heredoc + fail-loud guard is the conservative choice, identical to the
|
|
330
|
+
# requirement-side edge-probe path (RR-04). If a future UI-SPEC gains a canonical element table,
|
|
331
|
+
# revisit to parse it. Populate the heredoc from the UI-SPEC; the guard below fails loud on a
|
|
332
|
+
# forgotten substitution (never a no-op).
|
|
333
|
+
ELEMENTS_JSON=$(mktemp "${TMPDIR:-/tmp}/ui-probe-elements-XXXXXX") && mv "$ELEMENTS_JSON" "${ELEMENTS_JSON}.json" && ELEMENTS_JSON="${ELEMENTS_JSON}.json" || exit 1
|
|
334
|
+
cat > "$ELEMENTS_JSON" <<'JSON'
|
|
335
|
+
[
|
|
336
|
+
{ "id": "E1", "text": "<replace: element/surface description from the UI-SPEC prose>" }
|
|
337
|
+
]
|
|
338
|
+
JSON
|
|
339
|
+
if ! node -e 'const a=require(process.argv[1]);if(!Array.isArray(a)||a.length===0)process.exit(1);if(a.some(e=>typeof e.text!=="string"||!e.text.trim()||e.text.includes("<replace:")))process.exit(1)' "$ELEMENTS_JSON" 2>/dev/null; then
|
|
340
|
+
rm -f "$ELEMENTS_JSON"
|
|
341
|
+
echo "ERROR: ui-probe elements JSON is empty/invalid or still holds the <replace: …> placeholder — populate \$ELEMENTS_JSON from the UI-SPEC's described surfaces before this step runs." >&2
|
|
342
|
+
exit 1
|
|
343
|
+
fi
|
|
344
|
+
# Invoke the compiled engine and CAPTURE its report. FATAL-INVOKE GUARD: use `if ! COVERAGE=$(…)`,
|
|
345
|
+
# NEVER a bare `COVERAGE=$(node …)` — a bare capture swallows the engine's exit 2 (invalid shape /
|
|
346
|
+
# bad input) and falls through to prose re-derivation: fail-OPEN at the exact boundary the engine
|
|
347
|
+
# validation protects.
|
|
348
|
+
if ! COVERAGE=$(node "$UI_PROBE_JS" "$ELEMENTS_JSON"); then
|
|
349
|
+
rm -f "$ELEMENTS_JSON"
|
|
350
|
+
echo "ERROR: ui-consideration-probe engine failed (invalid shapes or bad input) — fix the element(s) and re-run; never proceed with empty coverage." >&2
|
|
351
|
+
exit 1
|
|
352
|
+
fi
|
|
353
|
+
rm -f "$ELEMENTS_JSON"
|
|
354
|
+
# Malformed-report guard: exit 0 but garbage. The report must parse as { items[], coverage{} }.
|
|
355
|
+
if ! printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let r;try{r=JSON.parse(s)}catch{process.exit(1)}if(!r||!Array.isArray(r.items)||typeof r.coverage!=="object"||r.coverage===null)process.exit(1)})'; then
|
|
356
|
+
echo "ERROR: ui-consideration-probe produced an unparseable or malformed coverage report — refusing to proceed with the resolution loop." >&2
|
|
357
|
+
exit 1
|
|
358
|
+
fi
|
|
359
|
+
# Zero-applicable guard: a report where NO category applied across ANY element is far more likely a
|
|
360
|
+
# classification miss (or malformed elements) than a genuinely state-free UI. Surface it loudly.
|
|
361
|
+
APPLICABLE=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.applicable}catch{n=0}process.stdout.write(String(n))})')
|
|
362
|
+
if [ "$APPLICABLE" = "0" ]; then
|
|
363
|
+
echo "WARNING: ui-consideration-probe proposed ZERO applicable categories across all elements — likely a classification miss or malformed elements, not a genuinely state-free UI. Do NOT silently write an empty UI Considerations section." >&2
|
|
364
|
+
fi
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
If `$APPLICABLE` is `0`, do NOT proceed silently: ask via AskUserQuestion ("The UI probe found no
|
|
368
|
+
applicable state considerations — is this genuinely a state-free surface, or should we revisit the
|
|
369
|
+
element descriptions?"). Only write an empty section after explicit confirmation.
|
|
370
|
+
|
|
371
|
+
**Propose-then-confirm (the partial-cue mitigation — load-bearing).** For each element, the engine
|
|
372
|
+
reports the DETECTED element kinds (`classifyElement` over the built `.cjs`). The prose classifier
|
|
373
|
+
is heuristic and LOSSY: a surface that is genuinely both a form and a list, but whose prose trips
|
|
374
|
+
only the form cue, under-covers — and because SOMETHING classified, no `unclassified` signal fires.
|
|
375
|
+
So SURFACE the detected kinds to the user (AskUserQuestion) and ask whether any real element kind
|
|
376
|
+
was missed. If the user ADDs a kind, re-run that element with an authored `elements` override
|
|
377
|
+
(the union of detected + added) so the missed categories are raised. A single tripped cue is a
|
|
378
|
+
SIGNAL, not proof the element is only that kind — the confirm step, not the heuristic, is what makes
|
|
379
|
+
coverage sound.
|
|
380
|
+
|
|
381
|
+
**Resolution loop** (mirror spec-phase 5.5): resolve each applicable consideration via
|
|
382
|
+
AskUserQuestion — **Specify** (→ `covered`, write a concrete truth) / **Dismiss (reason required)** /
|
|
383
|
+
**Backstop** (a held-out/visual UI-state test) / **Defer** (→ `unresolved`). An `unclassified` row is
|
|
384
|
+
a manual-review nudge, not a hard block. Text mode (`workflow.text_mode` / `--text`) → numbered lists.
|
|
385
|
+
|
|
386
|
+
**Kind-confirmation under `--auto`.** The propose-then-confirm step above is an AskUserQuestion, so
|
|
387
|
+
under `--auto` it follows the spec-phase 5.5 convention (replace AskUserQuestion with Claude's
|
|
388
|
+
recommended choice): Claude re-reads each element's prose and authors the `elements` override (the
|
|
389
|
+
union of the detected kinds + any kind it identifies as missed) instead of prompting — so `--auto`
|
|
390
|
+
recall rests on Claude's kind-identification, not the heuristic cue-match alone. This matters because
|
|
391
|
+
`autoResolve` (below) is a RESOLUTION floor only: it resolves the *detected* categories and cannot
|
|
392
|
+
recover a kind that was never surfaced, so recall is fixed HERE, at kind-confirmation, before
|
|
393
|
+
resolution runs.
|
|
394
|
+
|
|
395
|
+
**`--auto` mode (two layers).** The adapter's `autoResolve` is the CODE floor: every applicable
|
|
396
|
+
consideration auto-`backstop`s (carrying the taxonomy question as its resolution) and an
|
|
397
|
+
`unclassified` candidate stays `unresolved` — it NEVER auto-`dismiss`es and never auto-backstops an
|
|
398
|
+
unclassified item (#1110). On top of that floor the workflow MAY upgrade an item to `covered` when a
|
|
399
|
+
defensible acceptance criterion can be written (the same judgment spec-phase 5.5 applies in prose).
|
|
400
|
+
An auto `--auto` run therefore leaves un-upgraded backstops as `backstop`: at verify time each one
|
|
401
|
+
with no wired evidence routes to `insufficient_spec → human_needed` — never a silent pass (#1154).
|
|
402
|
+
That surfacing is the intended honest-verifier behavior, not over-flagging.
|
|
403
|
+
|
|
404
|
+
**Write-back.** Populate a `## UI Considerations` section in the UI-SPEC from the resolved
|
|
405
|
+
considerations, in the format the shipped plan-phase `## UI Considerations` lift rule reads:
|
|
406
|
+
`covered` → a truth string; `backstop` → a flat scalar `{ statement, verification: backstop }`;
|
|
407
|
+
`unresolved` → an explicit `⚠ unresolved — planner must treat as assumption` row. Empty-state and
|
|
408
|
+
error-state COPY stays in `## Copywriting Contract` — the considerations section covers shape-rooted
|
|
409
|
+
STATE coverage and REFERENCES those rows rather than restating the copy (de-dup). IDEMPOTENT: if a
|
|
410
|
+
`## UI Considerations` section already exists, REPLACE it — never append a duplicate.
|
|
411
|
+
|
|
267
412
|
## 10. Present Final Status
|
|
268
413
|
|
|
269
414
|
Display:
|
|
@@ -122,11 +122,11 @@ Handle return:
|
|
|
122
122
|
|
|
123
123
|
**State B (create):**
|
|
124
124
|
1. Read template from `~/.claude/gsd-core/templates/VALIDATION.md`
|
|
125
|
-
2. Fill: frontmatter, Test Infrastructure, Per-Task Map, Manual-Only, Sign-Off
|
|
125
|
+
2. Fill: frontmatter (**set `status: validated`**), Test Infrastructure, Per-Task Map, Manual-Only, Sign-Off
|
|
126
126
|
3. Write to `${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md`
|
|
127
127
|
|
|
128
128
|
**State A (update):**
|
|
129
|
-
1. Update Per-Task Map statuses, add escalated to Manual-Only, update frontmatter
|
|
129
|
+
1. Update Per-Task Map statuses, add escalated to Manual-Only, update frontmatter (**set `status: validated`**)
|
|
130
130
|
2. Append audit trail:
|
|
131
131
|
|
|
132
132
|
```markdown
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
|
+
// Namespace (not destructured) so tests can inject spawn failures by
|
|
10
|
+
// monkeypatching childProcess.execFileSync.
|
|
11
|
+
const childProcess = require('child_process');
|
|
9
12
|
const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
|
|
10
13
|
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
|
|
11
14
|
|
|
@@ -286,6 +289,129 @@ function formatGsdState(s) {
|
|
|
286
289
|
return parts.join(' · ');
|
|
287
290
|
}
|
|
288
291
|
|
|
292
|
+
// --- Context token count (opt-in) ---------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Format a token count compactly: 156342 → '156k', 1234567 → '1.2M'.
|
|
296
|
+
*/
|
|
297
|
+
function formatTokens(tokens) {
|
|
298
|
+
// Promote to the M branch when k-rounding would reach 1000 (999,500-999,999
|
|
299
|
+
// must render "1.0M", never "1000k").
|
|
300
|
+
if (tokens >= 1000000 || Math.round(tokens / 1000) >= 1000) {
|
|
301
|
+
return (tokens / 1000000).toFixed(1) + 'M';
|
|
302
|
+
}
|
|
303
|
+
if (tokens >= 1000) return Math.round(tokens / 1000) + 'k';
|
|
304
|
+
return String(tokens);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Pure function: build the token-count suffix for the context meter from the
|
|
309
|
+
* hook input's context_window.current_usage block. Sums input, cache-creation,
|
|
310
|
+
* cache-read, and output tokens (the same total Claude Code's /context shows).
|
|
311
|
+
* Returns ' (156k)' or '' when usage is absent/empty.
|
|
312
|
+
*/
|
|
313
|
+
function contextTokenSuffix(currentUsage) {
|
|
314
|
+
if (!currentUsage || typeof currentUsage !== 'object') return '';
|
|
315
|
+
const total = (Number(currentUsage.input_tokens) || 0) +
|
|
316
|
+
(Number(currentUsage.cache_creation_input_tokens) || 0) +
|
|
317
|
+
(Number(currentUsage.cache_read_input_tokens) || 0) +
|
|
318
|
+
(Number(currentUsage.output_tokens) || 0);
|
|
319
|
+
return total > 0 ? ` (${formatTokens(total)})` : '';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Model name --------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Collapse the verbose " (… context)" model-name suffix Claude Code sends for
|
|
326
|
+
* long-context sessions (e.g. "Sonnet 4.5 (1M context)") to a compact badge
|
|
327
|
+
* (" (1M)"). The signal is preserved; the width isn't. Tolerant by design
|
|
328
|
+
* (issue #2160 approval condition): any trailing parenthesized token ending
|
|
329
|
+
* in "context" is collapsed — a future "(500K context)" becomes "(500K)"
|
|
330
|
+
* rather than silently no-opping. The token's own casing is preserved.
|
|
331
|
+
* Any other display name passes through unchanged.
|
|
332
|
+
*/
|
|
333
|
+
function compactModelName(name) {
|
|
334
|
+
if (typeof name !== 'string') return name;
|
|
335
|
+
return name.replace(/\s*\(([^)]+?)\s+(?:context|ctx)\)$/i, ' ($1)');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- Git segment (opt-in) ------------------------------------------------------
|
|
339
|
+
//
|
|
340
|
+
// Opt-in via `statusline.show_git: true` in .planning/config.json. Renders the
|
|
341
|
+
// current branch plus compact work-state markers after the directory segment:
|
|
342
|
+
// " │ main+2~1?3↑1" (staged / unstaged / untracked / ahead / behind)
|
|
343
|
+
// " │ main✓" (clean, in sync)
|
|
344
|
+
// One `git status --porcelain=v2 --branch` spawn per render — no shell, args
|
|
345
|
+
// are a fixed array, and the workspace dir is passed via -C. Fails silently
|
|
346
|
+
// (segment absent) outside a repo, without git, or on timeout.
|
|
347
|
+
|
|
348
|
+
const GIT_STATUS_TIMEOUT_MS = 1500;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Run `git status --porcelain=v2 --branch` in dir.
|
|
352
|
+
* Returns raw stdout, or null when git is missing, dir isn't a repo, or the
|
|
353
|
+
* call times out. Never throws.
|
|
354
|
+
*/
|
|
355
|
+
function readGitStatus(dir) {
|
|
356
|
+
try {
|
|
357
|
+
// 8 MiB maxBuffer (default 1 MiB) headroom for repos with very many changed
|
|
358
|
+
// or untracked files; overflow still degrades safely to segment-absent via
|
|
359
|
+
// the catch below.
|
|
360
|
+
return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
|
|
361
|
+
{ encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
362
|
+
} catch (e) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Pure function: parse `git status --porcelain=v2 --branch` output.
|
|
369
|
+
*
|
|
370
|
+
* Returns { branch, ahead, behind, staged, unstaged, untracked } or null when
|
|
371
|
+
* the text carries no branch header (not a repo / unparseable). Detached HEAD
|
|
372
|
+
* reports branch "(detached)" — porcelain v2's literal spelling, shown as-is.
|
|
373
|
+
* Unmerged (conflict) entries count as unstaged: they're pending work either way.
|
|
374
|
+
*/
|
|
375
|
+
function parseGitStatus(text) {
|
|
376
|
+
if (typeof text !== 'string') return null;
|
|
377
|
+
const info = { branch: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 };
|
|
378
|
+
for (const line of text.split('\n')) {
|
|
379
|
+
if (line.startsWith('# branch.head ')) {
|
|
380
|
+
info.branch = line.slice('# branch.head '.length).trim() || null;
|
|
381
|
+
} else if (line.startsWith('# branch.ab ')) {
|
|
382
|
+
const m = line.match(/\+(\d+) -(\d+)/);
|
|
383
|
+
if (m) { info.ahead = parseInt(m[1], 10); info.behind = parseInt(m[2], 10); }
|
|
384
|
+
} else if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
385
|
+
// Changed / renamed entries: XY pair at cols 2-3, '.' = unmodified side
|
|
386
|
+
const xy = line.slice(2, 4);
|
|
387
|
+
if (xy[0] !== '.') info.staged++;
|
|
388
|
+
if (xy[1] !== '.') info.unstaged++;
|
|
389
|
+
} else if (line.startsWith('u ')) {
|
|
390
|
+
info.unstaged++;
|
|
391
|
+
} else if (line.startsWith('? ')) {
|
|
392
|
+
info.untracked++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return info.branch ? info : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Pure function: format parsed git info into the statusline segment, divider
|
|
400
|
+
* included (mirrors lastCmdSuffix). Branch is dimmed to match the directory
|
|
401
|
+
* segment; markers keep their own colors. Returns '' when info is absent.
|
|
402
|
+
*/
|
|
403
|
+
function buildGitSegment(info) {
|
|
404
|
+
if (!info || !info.branch) return '';
|
|
405
|
+
const markers = [];
|
|
406
|
+
if (info.staged) markers.push(`\x1b[32m+${info.staged}\x1b[0m`);
|
|
407
|
+
if (info.unstaged) markers.push(`\x1b[33m~${info.unstaged}\x1b[0m`);
|
|
408
|
+
if (info.untracked) markers.push(`\x1b[31m?${info.untracked}\x1b[0m`);
|
|
409
|
+
if (info.ahead) markers.push(`\x1b[32m↑${info.ahead}\x1b[0m`);
|
|
410
|
+
if (info.behind) markers.push(`\x1b[31m↓${info.behind}\x1b[0m`);
|
|
411
|
+
const state = markers.length ? markers.join('') : '\x1b[32m✓\x1b[0m';
|
|
412
|
+
return ` │ \x1b[2m${info.branch}\x1b[0m${state}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
289
415
|
// --- stdin ------------------------------------------------------------------
|
|
290
416
|
|
|
291
417
|
function runStatusline() {
|
|
@@ -299,11 +425,16 @@ function runStatusline() {
|
|
|
299
425
|
clearTimeout(stdinTimeout);
|
|
300
426
|
try {
|
|
301
427
|
const data = JSON.parse(input);
|
|
302
|
-
const model = data.model?.display_name || 'Claude';
|
|
428
|
+
const model = compactModelName(data.model?.display_name || 'Claude');
|
|
303
429
|
const dir = data.workspace?.current_dir || process.cwd();
|
|
304
430
|
const session = data.session_id || '';
|
|
305
431
|
const remaining = data.context_window?.remaining_percentage;
|
|
306
432
|
|
|
433
|
+
// Read .planning config once — used by the context meter (token suffix)
|
|
434
|
+
// and the last-command/position block below. Fail-soft to {}.
|
|
435
|
+
let cfg = {};
|
|
436
|
+
try { cfg = readGsdConfig(dir); } catch (e) {}
|
|
437
|
+
|
|
307
438
|
// Context window display (shows USED percentage scaled to usable context)
|
|
308
439
|
// Claude Code reserves a buffer for autocompact. By default this is ~16.5%
|
|
309
440
|
// of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
@@ -349,15 +480,21 @@ function runStatusline() {
|
|
|
349
480
|
const filled = Math.floor(used / 10);
|
|
350
481
|
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
351
482
|
|
|
483
|
+
// Opt-in absolute token count after the percentage (statusline.show_context_tokens)
|
|
484
|
+
let tokenSuffix = '';
|
|
485
|
+
if (getConfigValue(cfg, 'statusline.show_context_tokens') === true) {
|
|
486
|
+
tokenSuffix = contextTokenSuffix(data.context_window?.current_usage);
|
|
487
|
+
}
|
|
488
|
+
|
|
352
489
|
// Color based on usable context thresholds
|
|
353
490
|
if (used < 50) {
|
|
354
|
-
ctx = ` \x1b[32m${bar} ${used}
|
|
491
|
+
ctx = ` \x1b[32m${bar} ${used}%${tokenSuffix}\x1b[0m`;
|
|
355
492
|
} else if (used < 65) {
|
|
356
|
-
ctx = ` \x1b[33m${bar} ${used}
|
|
493
|
+
ctx = ` \x1b[33m${bar} ${used}%${tokenSuffix}\x1b[0m`;
|
|
357
494
|
} else if (used < 80) {
|
|
358
|
-
ctx = ` \x1b[38;5;208m${bar} ${used}
|
|
495
|
+
ctx = ` \x1b[38;5;208m${bar} ${used}%${tokenSuffix}\x1b[0m`;
|
|
359
496
|
} else {
|
|
360
|
-
ctx = ` \x1b[5;31m💀 ${bar} ${used}
|
|
497
|
+
ctx = ` \x1b[5;31m💀 ${bar} ${used}%${tokenSuffix}\x1b[0m`;
|
|
361
498
|
}
|
|
362
499
|
}
|
|
363
500
|
|
|
@@ -421,8 +558,8 @@ function runStatusline() {
|
|
|
421
558
|
// Failure here must never break the statusline — wrap the entire lookup.
|
|
422
559
|
let lastCmdSuffix = '';
|
|
423
560
|
let position = 'end';
|
|
561
|
+
let gitSuffix = '';
|
|
424
562
|
try {
|
|
425
|
-
const cfg = readGsdConfig(dir);
|
|
426
563
|
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
|
|
427
564
|
const transcriptPath = data.transcript_path;
|
|
428
565
|
const lastCmd = readLastSlashCommand(transcriptPath);
|
|
@@ -432,8 +569,11 @@ function runStatusline() {
|
|
|
432
569
|
}
|
|
433
570
|
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
|
|
434
571
|
if (cfgPos != null) position = cfgPos;
|
|
572
|
+
if (getConfigValue(cfg, 'statusline.show_git') === true) {
|
|
573
|
+
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
|
|
574
|
+
}
|
|
435
575
|
} catch (e) {
|
|
436
|
-
// Never break the statusline on config/transcript errors
|
|
576
|
+
// Never break the statusline on config/transcript/git errors
|
|
437
577
|
}
|
|
438
578
|
|
|
439
579
|
// Output
|
|
@@ -444,7 +584,7 @@ function runStatusline() {
|
|
|
444
584
|
? `\x1b[2m${gsdStateStr}\x1b[0m`
|
|
445
585
|
: null;
|
|
446
586
|
|
|
447
|
-
process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position }));
|
|
587
|
+
process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, gitSuffix, position }));
|
|
448
588
|
} catch (e) {
|
|
449
589
|
// Silent fail - don't break statusline on parse errors
|
|
450
590
|
}
|
|
@@ -463,6 +603,7 @@ function runStatusline() {
|
|
|
463
603
|
* @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent
|
|
464
604
|
* @param {string} opts.dirname - project directory basename (dim styling applied here)
|
|
465
605
|
* @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' │ last: /foo'
|
|
606
|
+
* @param {string} [opts.gitSuffix=''] - git branch/status segment, e.g. ' │ main✓' (after dirname)
|
|
466
607
|
* @param {'end'|'front'} [opts.position='end']
|
|
467
608
|
* - 'end' (default): ctx appended after dirname — preserved byte-for-byte
|
|
468
609
|
* - 'front': ctx immediately after model name so the meter stays visible in narrow terminals
|
|
@@ -478,6 +619,7 @@ function composeStatusline({
|
|
|
478
619
|
middle = null,
|
|
479
620
|
dirname,
|
|
480
621
|
lastCmdSuffix = '',
|
|
622
|
+
gitSuffix = '',
|
|
481
623
|
position = 'end',
|
|
482
624
|
} = {}) {
|
|
483
625
|
const modelSeg = `\x1b[2m${model}\x1b[0m`;
|
|
@@ -486,12 +628,12 @@ function composeStatusline({
|
|
|
486
628
|
const pos = position === 'front' ? 'front' : 'end';
|
|
487
629
|
|
|
488
630
|
if (pos === 'front') {
|
|
489
|
-
if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${lastCmdSuffix}`;
|
|
490
|
-
return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${lastCmdSuffix}`;
|
|
631
|
+
if (middle) return `${gsdUpdate}${modelSeg}${ctx} │ ${middle} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
|
|
632
|
+
return `${gsdUpdate}${modelSeg}${ctx} │ ${dirSeg}${gitSuffix}${lastCmdSuffix}`;
|
|
491
633
|
}
|
|
492
634
|
// 'end' — preserved byte-for-byte relative to original inline templates
|
|
493
|
-
if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${ctx}${lastCmdSuffix}`;
|
|
494
|
-
return `${gsdUpdate}${modelSeg} │ ${dirSeg}${ctx}${lastCmdSuffix}`;
|
|
635
|
+
if (middle) return `${gsdUpdate}${modelSeg} │ ${middle} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
|
|
636
|
+
return `${gsdUpdate}${modelSeg} │ ${dirSeg}${gitSuffix}${ctx}${lastCmdSuffix}`;
|
|
495
637
|
}
|
|
496
638
|
|
|
497
639
|
function isInstalledAheadOfLatest(installed, latest) {
|
|
@@ -531,6 +673,10 @@ module.exports = {
|
|
|
531
673
|
composeStatusline,
|
|
532
674
|
isInstalledAheadOfLatest,
|
|
533
675
|
evaluateUpdateCache,
|
|
676
|
+
formatTokens,
|
|
677
|
+
contextTokenSuffix,
|
|
678
|
+
compactModelName,
|
|
679
|
+
readGitStatus, parseGitStatus, buildGitSegment,
|
|
534
680
|
};
|
|
535
681
|
|
|
536
682
|
/**
|
|
@@ -538,12 +684,13 @@ module.exports = {
|
|
|
538
684
|
* testing without feeding stdin. Returns the rendered string.
|
|
539
685
|
*/
|
|
540
686
|
function renderStatusline(data) {
|
|
541
|
-
const model = data.model?.display_name || 'Claude';
|
|
687
|
+
const model = compactModelName(data.model?.display_name || 'Claude');
|
|
542
688
|
const dir = data.workspace?.current_dir || process.cwd();
|
|
543
689
|
const dirname = path.basename(dir);
|
|
544
690
|
|
|
545
691
|
let lastCmdSuffix = '';
|
|
546
692
|
let position = 'end';
|
|
693
|
+
let gitSuffix = '';
|
|
547
694
|
try {
|
|
548
695
|
const cfg = readGsdConfig(dir);
|
|
549
696
|
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
|
|
@@ -554,11 +701,14 @@ function renderStatusline(data) {
|
|
|
554
701
|
}
|
|
555
702
|
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
|
|
556
703
|
if (cfgPos != null) position = cfgPos;
|
|
704
|
+
if (getConfigValue(cfg, 'statusline.show_git') === true) {
|
|
705
|
+
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
|
|
706
|
+
}
|
|
557
707
|
} catch (e) { /* swallow */ }
|
|
558
708
|
|
|
559
709
|
const gsdStateStr = formatGsdState(readGsdState(dir) || {});
|
|
560
710
|
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
|
|
561
|
-
return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position });
|
|
711
|
+
return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
|
|
562
712
|
}
|
|
563
713
|
|
|
564
714
|
module.exports.renderStatusline = renderStatusline;
|