gsd-pi 2.35.0-dev.30eec3f → 2.35.0-dev.34ce717

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/README.md CHANGED
@@ -455,7 +455,9 @@ auto_report: true
455
455
 
456
456
  ### Agent Instructions
457
457
 
458
- Create an `agent-instructions.md` file in your project root to inject persistent per-project behavioral guidance into every agent session. This file is loaded automatically and provides project-specific context the LLM should always have coding standards, architectural decisions, domain terminology, or workflow preferences.
458
+ Place an `AGENTS.md` file in any directory to provide persistent behavioral guidance for that scope. Pi core loads `AGENTS.md` automatically (with `CLAUDE.md` as a fallback) at both user and project levels. Use these files for coding standards, architectural decisions, domain terminology, or workflow preferences.
459
+
460
+ > **Note:** The legacy `agent-instructions.md` format (`~/.gsd/agent-instructions.md` and `.gsd/agent-instructions.md`) is deprecated and no longer loaded. Migrate any existing instructions to `AGENTS.md` or `CLAUDE.md`.
459
461
 
460
462
  ### Debug Mode
461
463
 
@@ -632,6 +632,11 @@ export async function autoLoop(ctx, pi, s, deps) {
632
632
  unitType,
633
633
  unitId,
634
634
  });
635
+ // Detect retry and capture previous tier for escalation
636
+ const isRetry = !!(s.currentUnit &&
637
+ s.currentUnit.type === unitType &&
638
+ s.currentUnit.id === unitId);
639
+ const previousTier = s.currentUnitRouting?.tier;
635
640
  // Closeout previous unit
636
641
  if (s.currentUnit) {
637
642
  await deps.closeoutUnit(ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id));
@@ -737,8 +742,8 @@ export async function autoLoop(ctx, pi, s, deps) {
737
742
  const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr);
738
743
  process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`);
739
744
  }
740
- // Select and apply model
741
- const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel);
745
+ // Select and apply model (with tier escalation on retry)
746
+ const modelResult = await deps.selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel, { isRetry, previousTier });
742
747
  s.currentUnitRouting =
743
748
  modelResult.routing;
744
749
  // Start unit supervision
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js";
7
7
  import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
8
- import { resolveModelForComplexity } from "./model-router.js";
8
+ import { resolveModelForComplexity, escalateTier } from "./model-router.js";
9
9
  import { getLedger, getProjectTotals } from "./metrics.js";
10
10
  import { unitPhaseLabel } from "./auto-dashboard.js";
11
11
  /**
@@ -15,7 +15,7 @@ import { unitPhaseLabel } from "./auto-dashboard.js";
15
15
  *
16
16
  * Returns routing metadata for metrics tracking.
17
17
  */
18
- export async function selectAndApplyModel(ctx, pi, unitType, unitId, basePath, prefs, verbose, autoModeStartModel) {
18
+ export async function selectAndApplyModel(ctx, pi, unitType, unitId, basePath, prefs, verbose, autoModeStartModel, retryContext) {
19
19
  const modelConfig = resolveModelWithFallbacksForUnit(unitType);
20
20
  let routing = null;
21
21
  if (modelConfig) {
@@ -37,8 +37,20 @@ export async function selectAndApplyModel(ctx, pi, unitType, unitId, basePath, p
37
37
  const isHook = unitType.startsWith("hook/");
38
38
  const shouldClassify = !isHook || routingConfig.hooks !== false;
39
39
  if (shouldClassify) {
40
- const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
40
+ let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
41
41
  const availableModelIds = availableModels.map(m => m.id);
42
+ // Escalate tier on retry when escalate_on_failure is enabled (default: true)
43
+ if (retryContext?.isRetry &&
44
+ retryContext.previousTier &&
45
+ routingConfig.escalate_on_failure !== false) {
46
+ const escalated = escalateTier(retryContext.previousTier);
47
+ if (escalated) {
48
+ classification = { ...classification, tier: escalated, reason: "escalated after failure" };
49
+ if (verbose) {
50
+ ctx.ui.notify(`Tier escalation: ${retryContext.previousTier} → ${escalated} (retry after failure)`, "info");
51
+ }
52
+ }
53
+ }
42
54
  const routingResult = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
43
55
  if (routingResult.wasDowngraded) {
44
56
  effectiveModelConfig = {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * /gsd rate — Submit feedback on the last unit's model tier assignment.
3
+ * Feeds into the adaptive routing history so future dispatches improve.
4
+ */
5
+ import { loadLedgerFromDisk } from "./metrics.js";
6
+ import { recordFeedback, initRoutingHistory } from "./routing-history.js";
7
+ const VALID_RATINGS = new Set(["over", "under", "ok"]);
8
+ export async function handleRate(args, ctx, basePath) {
9
+ const rating = args.trim().toLowerCase();
10
+ if (!rating || !VALID_RATINGS.has(rating)) {
11
+ ctx.ui.notify("Usage: /gsd rate <over|ok|under>\n" +
12
+ " over — model was overpowered for that task (encourage cheaper)\n" +
13
+ " ok — model was appropriate\n" +
14
+ " under — model was too weak (encourage stronger)", "info");
15
+ return;
16
+ }
17
+ const ledger = loadLedgerFromDisk(basePath);
18
+ if (!ledger || ledger.units.length === 0) {
19
+ ctx.ui.notify("No completed units found — nothing to rate.", "warning");
20
+ return;
21
+ }
22
+ const lastUnit = ledger.units[ledger.units.length - 1];
23
+ const tier = lastUnit.tier;
24
+ if (!tier) {
25
+ ctx.ui.notify("Last unit has no tier data (dynamic routing was not active). Rating skipped.", "warning");
26
+ return;
27
+ }
28
+ initRoutingHistory(basePath);
29
+ recordFeedback(lastUnit.type, lastUnit.id, tier, rating);
30
+ ctx.ui.notify(`Recorded "${rating}" for ${lastUnit.type}/${lastUnit.id} at tier ${tier}.`, "info");
31
+ }
@@ -36,6 +36,7 @@ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
36
36
  import { runEnvironmentChecks } from "./doctor-environment.js";
37
37
  import { handleLogs } from "./commands-logs.js";
38
38
  import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
39
+ import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
39
40
  /** Resolve the effective project root, accounting for worktree paths. */
40
41
  export function projectRoot() {
41
42
  const cwd = process.cwd();
@@ -54,6 +55,38 @@ export function projectRoot() {
54
55
  }
55
56
  return root;
56
57
  }
58
+ /**
59
+ * Check if another process holds the auto-mode session lock.
60
+ * Returns the lock data if a remote session is alive, null otherwise.
61
+ */
62
+ function getRemoteAutoSession(basePath) {
63
+ const lockData = readSessionLockData(basePath);
64
+ if (!lockData)
65
+ return null;
66
+ if (lockData.pid === process.pid)
67
+ return null;
68
+ if (!isSessionLockProcessAlive(lockData))
69
+ return null;
70
+ return { pid: lockData.pid };
71
+ }
72
+ /**
73
+ * Show a steering menu when auto-mode is running in another process.
74
+ * Returns true if a remote session was detected (caller should return early).
75
+ */
76
+ function notifyRemoteAutoActive(ctx, basePath) {
77
+ const remote = getRemoteAutoSession(basePath);
78
+ if (!remote)
79
+ return false;
80
+ ctx.ui.notify(`Auto-mode is running in another process (PID ${remote.pid}).\n` +
81
+ `Use these commands to interact with it:\n` +
82
+ ` /gsd status — check progress\n` +
83
+ ` /gsd discuss — discuss architecture decisions\n` +
84
+ ` /gsd queue — queue the next milestone\n` +
85
+ ` /gsd steer — apply an override to active work\n` +
86
+ ` /gsd capture — fire-and-forget thought\n` +
87
+ ` /gsd stop — stop auto-mode`, "warning");
88
+ return true;
89
+ }
57
90
  export function registerGSDCommand(pi) {
58
91
  pi.registerCommand("gsd", {
59
92
  description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|update",
@@ -74,6 +107,7 @@ export function registerGSDCommand(pi) {
74
107
  { cmd: "triage", desc: "Manually trigger triage of pending captures" },
75
108
  { cmd: "dispatch", desc: "Dispatch a specific phase directly" },
76
109
  { cmd: "history", desc: "View execution history" },
110
+ { cmd: "rate", desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing" },
77
111
  { cmd: "undo", desc: "Revert last completed unit" },
78
112
  { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" },
79
113
  { cmd: "export", desc: "Export milestone/slice results" },
@@ -459,6 +493,8 @@ export async function handleGSDCommand(args, ctx, pi) {
459
493
  await handleDryRun(ctx, projectRoot());
460
494
  return;
461
495
  }
496
+ if (notifyRemoteAutoActive(ctx, projectRoot()))
497
+ return;
462
498
  const verboseMode = trimmed.includes("--verbose");
463
499
  const debugMode = trimmed.includes("--debug");
464
500
  if (debugMode)
@@ -513,6 +549,11 @@ export async function handleGSDCommand(args, ctx, pi) {
513
549
  await handleUndo(trimmed.replace(/^undo\s*/, "").trim(), ctx, pi, projectRoot());
514
550
  return;
515
551
  }
552
+ if (trimmed === "rate" || trimmed.startsWith("rate ")) {
553
+ const { handleRate } = await import("./commands-rate.js");
554
+ await handleRate(trimmed.replace(/^rate\s*/, "").trim(), ctx, projectRoot());
555
+ return;
556
+ }
516
557
  if (trimmed.startsWith("skip ")) {
517
558
  await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, projectRoot());
518
559
  return;
@@ -809,7 +850,8 @@ Examples:
809
850
  return;
810
851
  }
811
852
  if (trimmed === "") {
812
- // Bare /gsd defaults to step mode
853
+ if (notifyRemoteAutoActive(ctx, projectRoot()))
854
+ return;
813
855
  await startAuto(ctx, pi, projectRoot(), false, { step: true });
814
856
  return;
815
857
  }
@@ -17,6 +17,7 @@ import { resolveExpectedArtifactPath } from "./auto.js";
17
17
  import { gsdRoot, milestonesDir, resolveMilestoneFile, resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile, relMilestoneFile, relSliceFile, } from "./paths.js";
18
18
  import { join } from "node:path";
19
19
  import { readFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
20
+ import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
20
21
  import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
21
22
  import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
22
23
  import { loadEffectiveGSDPreferences } from "./preferences.js";
@@ -426,7 +427,12 @@ export async function showDiscuss(ctx, pi, basePath) {
426
427
  // If all pending slices are discussed, notify and exit instead of looping
427
428
  const allDiscussed = pendingSlices.every(s => discussedMap.get(s.id));
428
429
  if (allDiscussed) {
429
- ctx.ui.notify(`All ${pendingSlices.length} slices discussed. Run /gsd to start planning.`, "info");
430
+ const lockData = readSessionLockData(basePath);
431
+ const remoteAutoRunning = lockData && lockData.pid !== process.pid && isSessionLockProcessAlive(lockData);
432
+ const nextStep = remoteAutoRunning
433
+ ? "Auto-mode is already running — use /gsd status to check progress."
434
+ : "Run /gsd to start planning.";
435
+ ctx.ui.notify(`All ${pendingSlices.length} slices discussed. ${nextStep}`, "info");
430
436
  return;
431
437
  }
432
438
  // Find the first undiscussed slice to recommend
@@ -46,33 +46,21 @@ import { pauseAutoForProviderError, classifyProviderError } from "./provider-err
46
46
  import { toPosixPath } from "../shared/mod.js";
47
47
  import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js";
48
48
  import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js";
49
- // ── Agent Instructions ────────────────────────────────────────────────────
50
- // Lightweight "always follow" files injected into every GSD agent session.
51
- // Global: ~/.gsd/agent-instructions.md Project: .gsd/agent-instructions.md
52
- // Both are loaded and concatenated (global first, project appends).
53
- function loadAgentInstructions() {
54
- const parts = [];
55
- const globalPath = join(homedir(), ".gsd", "agent-instructions.md");
56
- if (existsSync(globalPath)) {
57
- try {
58
- const content = readFileSync(globalPath, "utf-8").trim();
59
- if (content)
60
- parts.push(content);
61
- }
62
- catch { /* non-fatal — skip unreadable file */ }
63
- }
64
- const projectPath = join(process.cwd(), ".gsd", "agent-instructions.md");
65
- if (existsSync(projectPath)) {
66
- try {
67
- const content = readFileSync(projectPath, "utf-8").trim();
68
- if (content)
69
- parts.push(content);
49
+ // ── Agent Instructions (DEPRECATED) ──────────────────────────────────────
50
+ // agent-instructions.md is deprecated. Use AGENTS.md or CLAUDE.md instead.
51
+ // Pi core natively supports AGENTS.md (with CLAUDE.md fallback) per directory.
52
+ function warnDeprecatedAgentInstructions() {
53
+ const paths = [
54
+ join(homedir(), ".gsd", "agent-instructions.md"),
55
+ join(process.cwd(), ".gsd", "agent-instructions.md"),
56
+ ];
57
+ for (const p of paths) {
58
+ if (existsSync(p)) {
59
+ console.warn(`[GSD] DEPRECATED: ${p} is no longer loaded. ` +
60
+ `Migrate your instructions to AGENTS.md (or CLAUDE.md) in the same directory. ` +
61
+ `See https://github.com/gsd-build/GSD-2/issues/1492`);
70
62
  }
71
- catch { /* non-fatal — skip unreadable file */ }
72
63
  }
73
- if (parts.length === 0)
74
- return null;
75
- return parts.join("\n\n");
76
64
  }
77
65
  // ── Depth verification state ──────────────────────────────────────────────
78
66
  let depthVerificationDone = false;
@@ -589,12 +577,8 @@ export default function (pi) {
589
577
  newSkillsBlock = formatSkillsXml(newSkills);
590
578
  }
591
579
  }
592
- // Load agent instructions (global + project)
593
- let agentInstructionsBlock = "";
594
- const agentInstructions = loadAgentInstructions();
595
- if (agentInstructions) {
596
- agentInstructionsBlock = `\n\n## Agent Instructions\n\nThe following instructions were provided by the user and must be followed in every session:\n\n${agentInstructions}`;
597
- }
580
+ // Warn if deprecated agent-instructions.md files are still present
581
+ warnDeprecatedAgentInstructions();
598
582
  const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd());
599
583
  // Worktree context — override the static CWD in the system prompt
600
584
  let worktreeBlock = "";
@@ -637,7 +621,7 @@ export default function (pi) {
637
621
  "Write every .gsd artifact in the worktree path above, never in the main project tree.",
638
622
  ].join("\n");
639
623
  }
640
- const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${agentInstructionsBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
624
+ const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
641
625
  stopContextTimer({
642
626
  systemPromptSize: fullSystem.length,
643
627
  injectionSize: injection?.length ?? 0,
@@ -67,8 +67,9 @@ export function findMilestoneIds(basePath) {
67
67
  .filter((d) => d.isDirectory())
68
68
  .map((d) => {
69
69
  const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/);
70
- return match ? match[1] : d.name;
71
- });
70
+ return match ? match[1] : null;
71
+ })
72
+ .filter((id) => id !== null);
72
73
  // Apply custom queue order if available, else fall back to numeric sort
73
74
  const customOrder = loadQueueOrder(basePath);
74
75
  return sortByQueueOrder(ids, customOrder);
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { gsdRoot } from "./paths.js";
16
16
  import { parse as parseYaml } from "yaml";
17
17
  import { normalizeStringArray } from "../shared/mod.js";
18
+ import { resolveProfileDefaults as _resolveProfileDefaults } from "./preferences-models.js";
18
19
  import { MODE_DEFAULTS, } from "./preferences-types.js";
19
20
  import { validatePreferences } from "./preferences-validation.js";
20
21
  import { formatSkillRef } from "./preferences-skills.js";
@@ -79,6 +80,17 @@ export function loadEffectiveGSDPreferences() {
79
80
  ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}),
80
81
  };
81
82
  }
83
+ // Apply token-profile defaults as the lowest-priority layer so that
84
+ // `token_profile: budget` sets models and phase-skips automatically.
85
+ // Explicit user preferences always override profile defaults.
86
+ const profile = result.preferences.token_profile;
87
+ if (profile) {
88
+ const profileDefaults = _resolveProfileDefaults(profile);
89
+ result = {
90
+ ...result,
91
+ preferences: mergePreferences(profileDefaults, result.preferences),
92
+ };
93
+ }
82
94
  // Apply mode defaults as the lowest-priority layer
83
95
  if (result.preferences.mode) {
84
96
  result = {
@@ -231,6 +231,14 @@ export function acquireSessionLock(basePath) {
231
231
  stale: 1_800_000, // 30 minutes — match primary lock settings
232
232
  update: 10_000,
233
233
  onCompromised: () => {
234
+ // Same false-positive suppression as the primary lock (#1512).
235
+ // Without this, the retry path fires _lockCompromised unconditionally
236
+ // on benign mtime drift (laptop sleep, heavy LLM event loop stalls).
237
+ const elapsed = Date.now() - _lockAcquiredAt;
238
+ if (elapsed < 1_800_000) {
239
+ process.stderr.write(`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`);
240
+ return;
241
+ }
234
242
  _lockCompromised = true;
235
243
  _releaseFunction = null;
236
244
  },
@@ -315,6 +323,25 @@ export function updateSessionLock(basePath, unitType, unitId, completedUnits, se
315
323
  export function validateSessionLock(basePath) {
316
324
  // Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
317
325
  if (_lockCompromised) {
326
+ // Recovery gate (#1512): Before declaring the lock lost, check if the lock
327
+ // file still contains our PID. If it does, no other process took over — the
328
+ // onCompromised fired from benign mtime drift (laptop sleep, event loop stall
329
+ // beyond the stale window). Attempt re-acquisition instead of giving up.
330
+ const lp = lockPath(basePath);
331
+ const existing = readExistingLockData(lp);
332
+ if (existing && existing.pid === process.pid) {
333
+ // Lock file still ours — try to re-acquire the OS lock
334
+ try {
335
+ const result = acquireSessionLock(basePath);
336
+ if (result.acquired) {
337
+ process.stderr.write(`[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`);
338
+ return true;
339
+ }
340
+ }
341
+ catch {
342
+ // Re-acquisition failed — fall through to return false
343
+ }
344
+ }
318
345
  return false;
319
346
  }
320
347
  // If we have an OS-level lock, we're still the owner
@@ -33,11 +33,12 @@ export function isValidationTerminal(validationContent) {
33
33
  const verdict = match[1].match(/verdict:\s*(\S+)/);
34
34
  if (!verdict)
35
35
  return false;
36
+ const v = verdict[1] === 'passed' ? 'pass' : verdict[1];
36
37
  // 'pass' and 'needs-attention' are always terminal.
37
38
  // 'needs-remediation' is treated as terminal to prevent infinite loops
38
39
  // when no remediation slices exist in the roadmap (#832). The validation
39
40
  // report is preserved on disk for manual review.
40
- return verdict[1] === 'pass' || verdict[1] === 'needs-attention' || verdict[1] === 'needs-remediation';
41
+ return v === 'pass' || v === 'needs-attention' || v === 'needs-remediation';
41
42
  }
42
43
  const CACHE_TTL_MS = 100;
43
44
  let _stateCache = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.35.0-dev.30eec3f",
3
+ "version": "2.35.0-dev.34ce717",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -456,6 +456,7 @@ export interface LoopDeps {
456
456
  prefs: GSDPreferences | undefined,
457
457
  verbose: boolean,
458
458
  startModel: { provider: string; id: string } | null,
459
+ retryContext?: { isRetry: boolean; previousTier?: string },
459
460
  ) => Promise<{ routing: { tier: string; modelDowngraded: boolean } | null }>;
460
461
  startUnitSupervision: (sctx: {
461
462
  s: AutoSession;
@@ -1182,6 +1183,14 @@ export async function autoLoop(
1182
1183
  unitId,
1183
1184
  });
1184
1185
 
1186
+ // Detect retry and capture previous tier for escalation
1187
+ const isRetry = !!(
1188
+ s.currentUnit &&
1189
+ s.currentUnit.type === unitType &&
1190
+ s.currentUnit.id === unitId
1191
+ );
1192
+ const previousTier = s.currentUnitRouting?.tier;
1193
+
1185
1194
  // Closeout previous unit
1186
1195
  if (s.currentUnit) {
1187
1196
  await deps.closeoutUnit(
@@ -1335,7 +1344,7 @@ export async function autoLoop(
1335
1344
  );
1336
1345
  }
1337
1346
 
1338
- // Select and apply model
1347
+ // Select and apply model (with tier escalation on retry)
1339
1348
  const modelResult = await deps.selectAndApplyModel(
1340
1349
  ctx,
1341
1350
  pi,
@@ -1345,6 +1354,7 @@ export async function autoLoop(
1345
1354
  prefs,
1346
1355
  s.verbose,
1347
1356
  s.autoModeStartModel,
1357
+ { isRetry, previousTier },
1348
1358
  );
1349
1359
  s.currentUnitRouting =
1350
1360
  modelResult.routing as AutoSession["currentUnitRouting"];
@@ -7,8 +7,9 @@
7
7
  import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
8
8
  import type { GSDPreferences } from "./preferences.js";
9
9
  import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js";
10
+ import type { ComplexityTier } from "./complexity-classifier.js";
10
11
  import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
11
- import { resolveModelForComplexity } from "./model-router.js";
12
+ import { resolveModelForComplexity, escalateTier } from "./model-router.js";
12
13
  import { getLedger, getProjectTotals } from "./metrics.js";
13
14
  import { unitPhaseLabel } from "./auto-dashboard.js";
14
15
 
@@ -33,6 +34,7 @@ export async function selectAndApplyModel(
33
34
  prefs: GSDPreferences | undefined,
34
35
  verbose: boolean,
35
36
  autoModeStartModel: { provider: string; id: string } | null,
37
+ retryContext?: { isRetry: boolean; previousTier?: string },
36
38
  ): Promise<ModelSelectionResult> {
37
39
  const modelConfig = resolveModelWithFallbacksForUnit(unitType);
38
40
  let routing: { tier: string; modelDowngraded: boolean } | null = null;
@@ -60,8 +62,27 @@ export async function selectAndApplyModel(
60
62
  const shouldClassify = !isHook || routingConfig.hooks !== false;
61
63
 
62
64
  if (shouldClassify) {
63
- const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
65
+ let classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
64
66
  const availableModelIds = availableModels.map(m => m.id);
67
+
68
+ // Escalate tier on retry when escalate_on_failure is enabled (default: true)
69
+ if (
70
+ retryContext?.isRetry &&
71
+ retryContext.previousTier &&
72
+ routingConfig.escalate_on_failure !== false
73
+ ) {
74
+ const escalated = escalateTier(retryContext.previousTier as ComplexityTier);
75
+ if (escalated) {
76
+ classification = { ...classification, tier: escalated, reason: "escalated after failure" };
77
+ if (verbose) {
78
+ ctx.ui.notify(
79
+ `Tier escalation: ${retryContext.previousTier} → ${escalated} (retry after failure)`,
80
+ "info",
81
+ );
82
+ }
83
+ }
84
+ }
85
+
65
86
  const routingResult = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
66
87
 
67
88
  if (routingResult.wasDowngraded) {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * /gsd rate — Submit feedback on the last unit's model tier assignment.
3
+ * Feeds into the adaptive routing history so future dispatches improve.
4
+ */
5
+
6
+ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
7
+ import { loadLedgerFromDisk } from "./metrics.js";
8
+ import { recordFeedback, initRoutingHistory } from "./routing-history.js";
9
+ import type { ComplexityTier } from "./complexity-classifier.js";
10
+
11
+ const VALID_RATINGS = new Set(["over", "under", "ok"]);
12
+
13
+ export async function handleRate(
14
+ args: string,
15
+ ctx: ExtensionCommandContext,
16
+ basePath: string,
17
+ ): Promise<void> {
18
+ const rating = args.trim().toLowerCase();
19
+
20
+ if (!rating || !VALID_RATINGS.has(rating)) {
21
+ ctx.ui.notify(
22
+ "Usage: /gsd rate <over|ok|under>\n" +
23
+ " over — model was overpowered for that task (encourage cheaper)\n" +
24
+ " ok — model was appropriate\n" +
25
+ " under — model was too weak (encourage stronger)",
26
+ "info",
27
+ );
28
+ return;
29
+ }
30
+
31
+ const ledger = loadLedgerFromDisk(basePath);
32
+ if (!ledger || ledger.units.length === 0) {
33
+ ctx.ui.notify("No completed units found — nothing to rate.", "warning");
34
+ return;
35
+ }
36
+
37
+ const lastUnit = ledger.units[ledger.units.length - 1];
38
+ const tier = lastUnit.tier as ComplexityTier | undefined;
39
+
40
+ if (!tier) {
41
+ ctx.ui.notify(
42
+ "Last unit has no tier data (dynamic routing was not active). Rating skipped.",
43
+ "warning",
44
+ );
45
+ return;
46
+ }
47
+
48
+ initRoutingHistory(basePath);
49
+ recordFeedback(lastUnit.type, lastUnit.id, tier, rating as "over" | "under" | "ok");
50
+
51
+ ctx.ui.notify(
52
+ `Recorded "${rating}" for ${lastUnit.type}/${lastUnit.id} at tier ${tier}.`,
53
+ "info",
54
+ );
55
+ }
@@ -48,6 +48,7 @@ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
48
48
  import { runEnvironmentChecks } from "./doctor-environment.js";
49
49
  import { handleLogs } from "./commands-logs.js";
50
50
  import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
51
+ import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
51
52
 
52
53
 
53
54
  /** Resolve the effective project root, accounting for worktree paths. */
@@ -69,6 +70,39 @@ export function projectRoot(): string {
69
70
  return root;
70
71
  }
71
72
 
73
+ /**
74
+ * Check if another process holds the auto-mode session lock.
75
+ * Returns the lock data if a remote session is alive, null otherwise.
76
+ */
77
+ function getRemoteAutoSession(basePath: string): { pid: number } | null {
78
+ const lockData = readSessionLockData(basePath);
79
+ if (!lockData) return null;
80
+ if (lockData.pid === process.pid) return null;
81
+ if (!isSessionLockProcessAlive(lockData)) return null;
82
+ return { pid: lockData.pid };
83
+ }
84
+
85
+ /**
86
+ * Show a steering menu when auto-mode is running in another process.
87
+ * Returns true if a remote session was detected (caller should return early).
88
+ */
89
+ function notifyRemoteAutoActive(ctx: ExtensionCommandContext, basePath: string): boolean {
90
+ const remote = getRemoteAutoSession(basePath);
91
+ if (!remote) return false;
92
+ ctx.ui.notify(
93
+ `Auto-mode is running in another process (PID ${remote.pid}).\n` +
94
+ `Use these commands to interact with it:\n` +
95
+ ` /gsd status — check progress\n` +
96
+ ` /gsd discuss — discuss architecture decisions\n` +
97
+ ` /gsd queue — queue the next milestone\n` +
98
+ ` /gsd steer — apply an override to active work\n` +
99
+ ` /gsd capture — fire-and-forget thought\n` +
100
+ ` /gsd stop — stop auto-mode`,
101
+ "warning",
102
+ );
103
+ return true;
104
+ }
105
+
72
106
  export function registerGSDCommand(pi: ExtensionAPI): void {
73
107
  pi.registerCommand("gsd", {
74
108
  description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|update",
@@ -89,6 +123,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
89
123
  { cmd: "triage", desc: "Manually trigger triage of pending captures" },
90
124
  { cmd: "dispatch", desc: "Dispatch a specific phase directly" },
91
125
  { cmd: "history", desc: "View execution history" },
126
+ { cmd: "rate", desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing" },
92
127
  { cmd: "undo", desc: "Revert last completed unit" },
93
128
  { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" },
94
129
  { cmd: "export", desc: "Export milestone/slice results" },
@@ -511,6 +546,7 @@ export async function handleGSDCommand(
511
546
  await handleDryRun(ctx, projectRoot());
512
547
  return;
513
548
  }
549
+ if (notifyRemoteAutoActive(ctx, projectRoot())) return;
514
550
  const verboseMode = trimmed.includes("--verbose");
515
551
  const debugMode = trimmed.includes("--debug");
516
552
  if (debugMode) enableDebug(projectRoot());
@@ -566,6 +602,12 @@ export async function handleGSDCommand(
566
602
  return;
567
603
  }
568
604
 
605
+ if (trimmed === "rate" || trimmed.startsWith("rate ")) {
606
+ const { handleRate } = await import("./commands-rate.js");
607
+ await handleRate(trimmed.replace(/^rate\s*/, "").trim(), ctx, projectRoot());
608
+ return;
609
+ }
610
+
569
611
  if (trimmed.startsWith("skip ")) {
570
612
  await handleSkip(trimmed.replace(/^skip\s*/, "").trim(), ctx, projectRoot());
571
613
  return;
@@ -899,7 +941,7 @@ Examples:
899
941
  }
900
942
 
901
943
  if (trimmed === "") {
902
- // Bare /gsd defaults to step mode
944
+ if (notifyRemoteAutoActive(ctx, projectRoot())) return;
903
945
  await startAuto(ctx, pi, projectRoot(), false, { step: true });
904
946
  return;
905
947
  }
@@ -23,6 +23,7 @@ import {
23
23
  } from "./paths.js";
24
24
  import { join } from "node:path";
25
25
  import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
26
+ import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
26
27
  import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
27
28
  import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
28
29
  import { loadEffectiveGSDPreferences } from "./preferences.js";
@@ -516,8 +517,13 @@ export async function showDiscuss(
516
517
  // If all pending slices are discussed, notify and exit instead of looping
517
518
  const allDiscussed = pendingSlices.every(s => discussedMap.get(s.id));
518
519
  if (allDiscussed) {
520
+ const lockData = readSessionLockData(basePath);
521
+ const remoteAutoRunning = lockData && lockData.pid !== process.pid && isSessionLockProcessAlive(lockData);
522
+ const nextStep = remoteAutoRunning
523
+ ? "Auto-mode is already running — use /gsd status to check progress."
524
+ : "Run /gsd to start planning.";
519
525
  ctx.ui.notify(
520
- `All ${pendingSlices.length} slices discussed. Run /gsd to start planning.`,
526
+ `All ${pendingSlices.length} slices discussed. ${nextStep}`,
521
527
  "info",
522
528
  );
523
529
  return;
@@ -66,32 +66,24 @@ import { toPosixPath } from "../shared/mod.js";
66
66
  import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js";
67
67
  import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js";
68
68
 
69
- // ── Agent Instructions ────────────────────────────────────────────────────
70
- // Lightweight "always follow" files injected into every GSD agent session.
71
- // Global: ~/.gsd/agent-instructions.md Project: .gsd/agent-instructions.md
72
- // Both are loaded and concatenated (global first, project appends).
73
-
74
- function loadAgentInstructions(): string | null {
75
- const parts: string[] = [];
76
-
77
- const globalPath = join(homedir(), ".gsd", "agent-instructions.md");
78
- if (existsSync(globalPath)) {
79
- try {
80
- const content = readFileSync(globalPath, "utf-8").trim();
81
- if (content) parts.push(content);
82
- } catch { /* non-fatal skip unreadable file */ }
83
- }
84
-
85
- const projectPath = join(process.cwd(), ".gsd", "agent-instructions.md");
86
- if (existsSync(projectPath)) {
87
- try {
88
- const content = readFileSync(projectPath, "utf-8").trim();
89
- if (content) parts.push(content);
90
- } catch { /* non-fatal — skip unreadable file */ }
69
+ // ── Agent Instructions (DEPRECATED) ──────────────────────────────────────
70
+ // agent-instructions.md is deprecated. Use AGENTS.md or CLAUDE.md instead.
71
+ // Pi core natively supports AGENTS.md (with CLAUDE.md fallback) per directory.
72
+
73
+ function warnDeprecatedAgentInstructions(): void {
74
+ const paths = [
75
+ join(homedir(), ".gsd", "agent-instructions.md"),
76
+ join(process.cwd(), ".gsd", "agent-instructions.md"),
77
+ ];
78
+ for (const p of paths) {
79
+ if (existsSync(p)) {
80
+ console.warn(
81
+ `[GSD] DEPRECATED: ${p} is no longer loaded. ` +
82
+ `Migrate your instructions to AGENTS.md (or CLAUDE.md) in the same directory. ` +
83
+ `See https://github.com/gsd-build/GSD-2/issues/1492`,
84
+ );
85
+ }
91
86
  }
92
-
93
- if (parts.length === 0) return null;
94
- return parts.join("\n\n");
95
87
  }
96
88
 
97
89
  // ── Depth verification state ──────────────────────────────────────────────
@@ -682,12 +674,8 @@ export default function (pi: ExtensionAPI) {
682
674
  }
683
675
  }
684
676
 
685
- // Load agent instructions (global + project)
686
- let agentInstructionsBlock = "";
687
- const agentInstructions = loadAgentInstructions();
688
- if (agentInstructions) {
689
- agentInstructionsBlock = `\n\n## Agent Instructions\n\nThe following instructions were provided by the user and must be followed in every session:\n\n${agentInstructions}`;
690
- }
677
+ // Warn if deprecated agent-instructions.md files are still present
678
+ warnDeprecatedAgentInstructions();
691
679
 
692
680
  const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd());
693
681
 
@@ -732,7 +720,7 @@ export default function (pi: ExtensionAPI) {
732
720
  ].join("\n");
733
721
  }
734
722
 
735
- const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${agentInstructionsBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
723
+ const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`;
736
724
  stopContextTimer({
737
725
  systemPromptSize: fullSystem.length,
738
726
  injectionSize: injection?.length ?? 0,
@@ -80,8 +80,9 @@ export function findMilestoneIds(basePath: string): string[] {
80
80
  .filter((d) => d.isDirectory())
81
81
  .map((d) => {
82
82
  const match = d.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/);
83
- return match ? match[1] : d.name;
84
- });
83
+ return match ? match[1] : null;
84
+ })
85
+ .filter((id): id is string => id !== null);
85
86
 
86
87
  // Apply custom queue order if available, else fall back to numeric sort
87
88
  const customOrder = loadQueueOrder(basePath);
@@ -15,9 +15,10 @@ import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { gsdRoot } from "./paths.js";
17
17
  import { parse as parseYaml } from "yaml";
18
- import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js";
18
+ import type { PostUnitHookConfig, PreDispatchHookConfig, TokenProfile } from "./types.js";
19
19
  import type { DynamicRoutingConfig } from "./model-router.js";
20
20
  import { normalizeStringArray } from "../shared/mod.js";
21
+ import { resolveProfileDefaults as _resolveProfileDefaults } from "./preferences-models.js";
21
22
 
22
23
  import {
23
24
  MODE_DEFAULTS,
@@ -141,6 +142,18 @@ export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {
141
142
  };
142
143
  }
143
144
 
145
+ // Apply token-profile defaults as the lowest-priority layer so that
146
+ // `token_profile: budget` sets models and phase-skips automatically.
147
+ // Explicit user preferences always override profile defaults.
148
+ const profile = result.preferences.token_profile as TokenProfile | undefined;
149
+ if (profile) {
150
+ const profileDefaults = _resolveProfileDefaults(profile);
151
+ result = {
152
+ ...result,
153
+ preferences: mergePreferences(profileDefaults as GSDPreferences, result.preferences),
154
+ };
155
+ }
156
+
144
157
  // Apply mode defaults as the lowest-priority layer
145
158
  if (result.preferences.mode) {
146
159
  result = {
@@ -260,6 +260,16 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
260
260
  stale: 1_800_000, // 30 minutes — match primary lock settings
261
261
  update: 10_000,
262
262
  onCompromised: () => {
263
+ // Same false-positive suppression as the primary lock (#1512).
264
+ // Without this, the retry path fires _lockCompromised unconditionally
265
+ // on benign mtime drift (laptop sleep, heavy LLM event loop stalls).
266
+ const elapsed = Date.now() - _lockAcquiredAt;
267
+ if (elapsed < 1_800_000) {
268
+ process.stderr.write(
269
+ `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — event loop stall, continuing.\n`,
270
+ );
271
+ return;
272
+ }
263
273
  _lockCompromised = true;
264
274
  _releaseFunction = null;
265
275
  },
@@ -361,6 +371,26 @@ export function updateSessionLock(
361
371
  export function validateSessionLock(basePath: string): boolean {
362
372
  // Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
363
373
  if (_lockCompromised) {
374
+ // Recovery gate (#1512): Before declaring the lock lost, check if the lock
375
+ // file still contains our PID. If it does, no other process took over — the
376
+ // onCompromised fired from benign mtime drift (laptop sleep, event loop stall
377
+ // beyond the stale window). Attempt re-acquisition instead of giving up.
378
+ const lp = lockPath(basePath);
379
+ const existing = readExistingLockData(lp);
380
+ if (existing && existing.pid === process.pid) {
381
+ // Lock file still ours — try to re-acquire the OS lock
382
+ try {
383
+ const result = acquireSessionLock(basePath);
384
+ if (result.acquired) {
385
+ process.stderr.write(
386
+ `[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`,
387
+ );
388
+ return true;
389
+ }
390
+ } catch {
391
+ // Re-acquisition failed — fall through to return false
392
+ }
393
+ }
364
394
  return false;
365
395
  }
366
396
 
@@ -64,11 +64,12 @@ export function isValidationTerminal(validationContent: string): boolean {
64
64
  if (!match) return false;
65
65
  const verdict = match[1].match(/verdict:\s*(\S+)/);
66
66
  if (!verdict) return false;
67
+ const v = verdict[1] === 'passed' ? 'pass' : verdict[1];
67
68
  // 'pass' and 'needs-attention' are always terminal.
68
69
  // 'needs-remediation' is treated as terminal to prevent infinite loops
69
70
  // when no remediation slices exist in the roadmap (#832). The validation
70
71
  // report is preserved on disk for manual review.
71
- return verdict[1] === 'pass' || verdict[1] === 'needs-attention' || verdict[1] === 'needs-remediation';
72
+ return v === 'pass' || v === 'needs-attention' || v === 'needs-remediation';
72
73
  }
73
74
 
74
75
  // ─── State Derivation ──────────────────────────────────────────────────────
@@ -244,6 +244,32 @@ console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ===');
244
244
  }
245
245
  }
246
246
 
247
+ // ═══════════════════════════════════════════════════════════════════════════
248
+ // Test: non-milestone directories are filtered out (#1494)
249
+ // ═══════════════════════════════════════════════════════════════════════════
250
+
251
+ console.log('\n=== E2E: non-milestone directories filtered from findMilestoneIds (#1494) ===');
252
+ {
253
+ const base = createFixtureBase();
254
+ try {
255
+ writeContext(base, 'M001', '', 'First');
256
+ writeContext(base, 'M002', '', 'Second');
257
+ // Create a rogue non-milestone directory
258
+ mkdirSync(join(base, '.gsd', 'milestones', 'slices'), { recursive: true });
259
+ mkdirSync(join(base, '.gsd', 'milestones', 'temp-backup'), { recursive: true });
260
+
261
+ invalidateStateCache();
262
+ const ids = findMilestoneIds(base);
263
+ assertEq(ids.length, 2, 'only M001 and M002 returned');
264
+ assertTrue(!ids.includes('slices'), 'slices directory excluded');
265
+ assertTrue(!ids.includes('temp-backup'), 'temp-backup directory excluded');
266
+ assertTrue(ids.includes('M001'), 'M001 included');
267
+ assertTrue(ids.includes('M002'), 'M002 included');
268
+ } finally {
269
+ cleanup(base);
270
+ }
271
+ }
272
+
247
273
  // ═══════════════════════════════════════════════════════════════════════════
248
274
  // Test: depends_on inline array format removal
249
275
  // ═══════════════════════════════════════════════════════════════════════════
@@ -104,6 +104,11 @@ test("isValidationTerminal returns true for verdict: needs-remediation (#832)",
104
104
  assert.equal(isValidationTerminal(content), true);
105
105
  });
106
106
 
107
+ test("isValidationTerminal returns true for verdict: passed (#1429)", () => {
108
+ const content = "---\nverdict: passed\nremediation_round: 0\n---\n\n# Validation";
109
+ assert.equal(isValidationTerminal(content), true);
110
+ });
111
+
107
112
  test("isValidationTerminal returns false for missing frontmatter", () => {
108
113
  const content = "# Validation\nNo frontmatter here.";
109
114
  assert.equal(isValidationTerminal(content), false);