@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
package/hooks/gsd-statusline.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// gsd-windsurf-pre-command.js — Windsurf/Cascade pre_run_command hook (ADR-1239 / #2100)
|
|
4
|
+
//
|
|
5
|
+
// Cascade (Windsurf's agent) invokes this script before each shell-command
|
|
6
|
+
// tool call executes, via the workspace/global hooks.json hook bus.
|
|
7
|
+
//
|
|
8
|
+
// Input schema (Cascade pre_run_command envelope, JSON on stdin):
|
|
9
|
+
// { agent_action_name: 'pre_run_command', trajectory_id, execution_id,
|
|
10
|
+
// timestamp, model_name,
|
|
11
|
+
// tool_info: { command_line } }
|
|
12
|
+
//
|
|
13
|
+
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
|
|
14
|
+
// - exit 0 -> allow the command to run (no stdout contract)
|
|
15
|
+
// - exit 2 -> BLOCK the command; the printed stderr text is the reason
|
|
16
|
+
// shown to the agent/user
|
|
17
|
+
//
|
|
18
|
+
// Behaviour: blocks a small, CONSERVATIVE, well-scoped, BEST-EFFORT deny-list
|
|
19
|
+
// of obviously destructive commands. This is intentionally not exhaustive —
|
|
20
|
+
// a broad deny-list would false-positive on legitimate agent/tooling work,
|
|
21
|
+
// and Cascade honors exit 2 unconditionally, so a false positive blocks the
|
|
22
|
+
// user's real work. When in doubt, this script allows:
|
|
23
|
+
// - a fork-bomb pattern
|
|
24
|
+
// - `rm -rf` (or equivalent combined/long flags), including through common
|
|
25
|
+
// prefixed forms (`sudo rm -rf /`, `/bin/rm -rf /`, `env FOO=1 rm -rf /`),
|
|
26
|
+
// targeting the filesystem root, the user's home directory, or a Windows
|
|
27
|
+
// drive root/profile root
|
|
28
|
+
// - `git push` with a force flag (`-f`/`--force`/`--force-with-lease`) or a
|
|
29
|
+
// `+`-prefixed refspec, explicitly targeting a protected branch
|
|
30
|
+
// (main / master / next) as the push destination — not merely mentioning
|
|
31
|
+
// that name elsewhere in a longer branch name or a trailing comment
|
|
32
|
+
// Everything else — including force-pushes to feature branches and `rm -rf`
|
|
33
|
+
// against ordinary project subdirectories — is intentionally left alone.
|
|
34
|
+
// Fails OPEN on any error, timeout, or unrecognized shape — a hook bug must
|
|
35
|
+
// never wedge Cascade.
|
|
36
|
+
//
|
|
37
|
+
// Classification is TOKENIZE-based (split into shell segments, then
|
|
38
|
+
// whitespace-split tokens), not a single mega-regex over the raw string —
|
|
39
|
+
// this keeps every check linear in input length. `command_line` longer than
|
|
40
|
+
// MAX_COMMAND_LENGTH is allowed outright before any pattern matching runs:
|
|
41
|
+
// no realistic destructive command is anywhere near that long, so the cap
|
|
42
|
+
// both fails open on pathological input and bounds the worst-case cost of
|
|
43
|
+
// every classifier below (defense-in-depth against regex-based DoS).
|
|
44
|
+
//
|
|
45
|
+
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
|
|
46
|
+
// https://docs.devin.ai/desktop/cascade/hooks
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
// No realistic destructive command comes anywhere close to this length.
|
|
51
|
+
const MAX_COMMAND_LENGTH = 4096;
|
|
52
|
+
|
|
53
|
+
// Classic bash fork bomb: `:(){ :|:& };:`
|
|
54
|
+
const FORK_BOMB_RE = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
55
|
+
|
|
56
|
+
// Command-prefix wrappers to look through when locating the "real" command at
|
|
57
|
+
// the head of a segment: `sudo rm -rf /`, `/bin/rm -rf /` (basename strip),
|
|
58
|
+
// `env FOO=1 rm -rf /` (env's leading VAR=val args are skipped too).
|
|
59
|
+
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
|
|
60
|
+
|
|
61
|
+
// Bare filesystem-root-class tokens for `rm`'s target. Ordinary paths like
|
|
62
|
+
// `/tmp/foo` or `/home/user/project` never match this set.
|
|
63
|
+
const ROOT_SENTINELS = new Set(['/', '/*', '~', '~/', '$HOME', '${HOME}']);
|
|
64
|
+
|
|
65
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master', 'next']);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tokenizing helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
// Split a command line into shell segments on `;`, `&&`, `||`, `|`, newline —
|
|
72
|
+
// each segment is classified independently.
|
|
73
|
+
function splitSegments(cmd) {
|
|
74
|
+
return cmd.split(/\|\||&&|[;\n|]/);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A `#` starts a bash comment when it's the first character of a "word"
|
|
78
|
+
// (preceded by whitespace, or at the very start of the segment). Strip it
|
|
79
|
+
// before classifying, so a comment mentioning a protected branch name never
|
|
80
|
+
// counts as a real command argument.
|
|
81
|
+
function stripBashComment(segment) {
|
|
82
|
+
const m = segment.match(/(^|\s)#/);
|
|
83
|
+
if (!m) return segment;
|
|
84
|
+
const idx = m.index + m[1].length;
|
|
85
|
+
return segment.slice(0, idx).replace(/\s+$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tokenize(segment) {
|
|
89
|
+
return segment.split(/\s+/).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strip any directory path from a token: `/bin/rm` -> `rm`.
|
|
93
|
+
function basename(tok) {
|
|
94
|
+
const parts = tok.split(/[\\/]/);
|
|
95
|
+
return parts[parts.length - 1] || tok;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find the index of the "real" command token in a token list, skipping past
|
|
99
|
+
// known command-prefix wrappers (and, for `env`, its leading VAR=val args).
|
|
100
|
+
function indexOfCommandAfterPrefixes(tokens) {
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < tokens.length) {
|
|
103
|
+
const base = basename(tokens[i]).toLowerCase();
|
|
104
|
+
if (!CMD_PREFIXES.has(base)) return i;
|
|
105
|
+
const wasEnv = base === 'env';
|
|
106
|
+
i++;
|
|
107
|
+
if (wasEnv) {
|
|
108
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return i;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// True if `tokens` contains a flag matching either the exact long form, or a
|
|
115
|
+
// combined/short `-xyz` cluster containing `shortChar` (e.g. `-rf`, `-fr`,
|
|
116
|
+
// `-r`). A single `[a-zA-Z]+` quantifier with no nested ambiguity — linear,
|
|
117
|
+
// no catastrophic backtracking regardless of token length.
|
|
118
|
+
function hasFlag(tokens, shortChar, longFlag) {
|
|
119
|
+
return tokens.some((t) => {
|
|
120
|
+
if (t === longFlag) return true;
|
|
121
|
+
if (t.length > 1 && t[0] === '-' && t[1] !== '-' && /^[a-zA-Z]+$/.test(t.slice(1))) {
|
|
122
|
+
return t.slice(1).toLowerCase().includes(shortChar);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isRootSentinel(tok) {
|
|
129
|
+
if (ROOT_SENTINELS.has(tok)) return true;
|
|
130
|
+
// Bare Windows drive root: `C:\` or `C:/`.
|
|
131
|
+
if (/^[A-Za-z]:[\\/]$/.test(tok)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Classifiers (each operates on one already comment-stripped segment)
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// `rm` (any flag order/spelling, optionally through `sudo`/`env FOO=1`/an
|
|
140
|
+
// absolute path/etc.) with BOTH a recursive flag and a force flag, targeting
|
|
141
|
+
// a bare filesystem-root-class token.
|
|
142
|
+
function isDestructiveRmRf(segment) {
|
|
143
|
+
const tokens = tokenize(segment);
|
|
144
|
+
const cmdIdx = indexOfCommandAfterPrefixes(tokens);
|
|
145
|
+
if (cmdIdx >= tokens.length) return null;
|
|
146
|
+
if (basename(tokens[cmdIdx]) !== 'rm') return null;
|
|
147
|
+
const args = tokens.slice(cmdIdx + 1);
|
|
148
|
+
const hasRecursive = hasFlag(args, 'r', '--recursive');
|
|
149
|
+
const hasForce = hasFlag(args, 'f', '--force');
|
|
150
|
+
if (!hasRecursive || !hasForce) return null;
|
|
151
|
+
const rootTok = args.find(isRootSentinel);
|
|
152
|
+
if (rootTok) return `rm -rf targeting the filesystem root or home directory ('${rootTok}')`;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isWindowsRootSentinel(tok) {
|
|
157
|
+
if (/^[A-Za-z]:\\?$/.test(tok)) return true;
|
|
158
|
+
if (/^\$env:userprofile\\?$/i.test(tok)) return true;
|
|
159
|
+
if (/^~\\?$/.test(tok)) return true;
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isWindowsDriveRoot(tok) {
|
|
164
|
+
return /^[A-Za-z]:\\?$/.test(tok);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Windows equivalents: `Remove-Item -Recurse -Force <drive-root|profile-root>`
|
|
168
|
+
// and `rd /s /q <drive-root>` / `rmdir /s /q <drive-root>`.
|
|
169
|
+
function isDestructiveWindowsRmRf(segment) {
|
|
170
|
+
const tokens = tokenize(segment);
|
|
171
|
+
if (tokens.length === 0) return null;
|
|
172
|
+
const first = basename(tokens[0]).toLowerCase();
|
|
173
|
+
const rest = tokens.slice(1);
|
|
174
|
+
if (first === 'remove-item') {
|
|
175
|
+
const hasRecurse = rest.some((t) => t.toLowerCase() === '-recurse');
|
|
176
|
+
const hasForce = rest.some((t) => t.toLowerCase() === '-force');
|
|
177
|
+
if (hasRecurse && hasForce && rest.some(isWindowsRootSentinel)) {
|
|
178
|
+
return 'Remove-Item -Recurse -Force targeting a drive root or user-profile root';
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (first === 'rd' || first === 'rmdir') {
|
|
183
|
+
const hasS = rest.some((t) => t.toLowerCase() === '/s');
|
|
184
|
+
const hasQ = rest.some((t) => t.toLowerCase() === '/q');
|
|
185
|
+
if (hasS && hasQ) {
|
|
186
|
+
const rootTok = rest.find(isWindowsDriveRoot);
|
|
187
|
+
if (rootTok) return `rd /s /q targeting drive root '${rootTok}'`;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isForceToken(tok) {
|
|
195
|
+
if (tok === '--force' || tok === '-f') return true;
|
|
196
|
+
if (/^--force-with-lease(=.*)?$/i.test(tok)) return true;
|
|
197
|
+
if (tok.startsWith('+')) return true;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Resolve the branch a push-argument token targets, honoring `+<dst>` and
|
|
202
|
+
// `<src>:<dst>` refspec forms and an optional `refs/heads/` prefix. Returns
|
|
203
|
+
// the lower-cased protected branch name, or null. Whole-token comparison
|
|
204
|
+
// only — `feature/main-fix` never matches `main`.
|
|
205
|
+
function protectedTargetFromToken(tok) {
|
|
206
|
+
let t = tok;
|
|
207
|
+
if (t.startsWith('+')) t = t.slice(1);
|
|
208
|
+
const colonIdx = t.lastIndexOf(':');
|
|
209
|
+
const candidate = colonIdx !== -1 ? t.slice(colonIdx + 1) : t;
|
|
210
|
+
const stripped = candidate.replace(/^refs\/heads\//i, '');
|
|
211
|
+
const lower = stripped.toLowerCase();
|
|
212
|
+
return PROTECTED_BRANCHES.has(lower) ? lower : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `git push` with a force flag/refspec AND an explicit protected-branch push
|
|
216
|
+
// target (main / master / next — see scripts/setup-branch-protection.sh).
|
|
217
|
+
function isProtectedBranchForcePush(segment) {
|
|
218
|
+
const tokens = tokenize(segment);
|
|
219
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
220
|
+
if (tokens[i].toLowerCase() === 'git' && tokens[i + 1].toLowerCase() === 'push') {
|
|
221
|
+
const rest = tokens.slice(i + 2);
|
|
222
|
+
if (!rest.some(isForceToken)) return null;
|
|
223
|
+
for (const tok of rest) {
|
|
224
|
+
const target = protectedTargetFromToken(tok);
|
|
225
|
+
if (target) return `git push --force targeting protected branch '${target}'`;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function destructiveReason(cmd) {
|
|
234
|
+
if (FORK_BOMB_RE.test(cmd)) return 'fork-bomb pattern';
|
|
235
|
+
for (const rawSegment of splitSegments(cmd)) {
|
|
236
|
+
const segment = stripBashComment(rawSegment).trim();
|
|
237
|
+
if (!segment) continue;
|
|
238
|
+
const reason = isDestructiveRmRf(segment)
|
|
239
|
+
|| isDestructiveWindowsRmRf(segment)
|
|
240
|
+
|| isProtectedBranchForcePush(segment);
|
|
241
|
+
if (reason) return reason;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function block(reason) {
|
|
247
|
+
process.stderr.write(`GSD windsurf pre_run_command guard: ${reason}\n`);
|
|
248
|
+
process.exit(2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function allow() {
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let input = '';
|
|
256
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
|
|
257
|
+
process.stdin.setEncoding('utf8');
|
|
258
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
259
|
+
process.stdin.on('end', () => {
|
|
260
|
+
clearTimeout(stdinTimeout);
|
|
261
|
+
try {
|
|
262
|
+
const data = JSON.parse(input || '{}');
|
|
263
|
+
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
|
|
264
|
+
const commandLine = typeof toolInfo.command_line === 'string' ? toolInfo.command_line : '';
|
|
265
|
+
if (!commandLine) { allow(); return; }
|
|
266
|
+
if (commandLine.length > MAX_COMMAND_LENGTH) { allow(); return; }
|
|
267
|
+
|
|
268
|
+
const reason = destructiveReason(commandLine);
|
|
269
|
+
if (reason) { block(reason); return; }
|
|
270
|
+
allow();
|
|
271
|
+
} catch {
|
|
272
|
+
// Silent fail-open — never block a valid tool call due to a hook bug.
|
|
273
|
+
allow();
|
|
274
|
+
}
|
|
275
|
+
});
|