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 +3 -1
- package/dist/resources/extensions/gsd/auto-loop.js +7 -2
- package/dist/resources/extensions/gsd/auto-model-selection.js +15 -3
- package/dist/resources/extensions/gsd/commands-rate.js +31 -0
- package/dist/resources/extensions/gsd/commands.js +43 -1
- package/dist/resources/extensions/gsd/guided-flow.js +7 -1
- package/dist/resources/extensions/gsd/index.js +16 -32
- package/dist/resources/extensions/gsd/milestone-ids.js +3 -2
- package/dist/resources/extensions/gsd/preferences.js +12 -0
- package/dist/resources/extensions/gsd/session-lock.js +27 -0
- package/dist/resources/extensions/gsd/state.js +2 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-loop.ts +11 -1
- package/src/resources/extensions/gsd/auto-model-selection.ts +23 -2
- package/src/resources/extensions/gsd/commands-rate.ts +55 -0
- package/src/resources/extensions/gsd/commands.ts +43 -1
- package/src/resources/extensions/gsd/guided-flow.ts +7 -1
- package/src/resources/extensions/gsd/index.ts +20 -32
- package/src/resources/extensions/gsd/milestone-ids.ts +3 -2
- package/src/resources/extensions/gsd/preferences.ts +14 -1
- package/src/resources/extensions/gsd/session-lock.ts +30 -0
- package/src/resources/extensions/gsd/state.ts +2 -1
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +26 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +5 -0
package/README.md
CHANGED
|
@@ -455,7 +455,9 @@ auto_report: true
|
|
|
455
455
|
|
|
456
456
|
### Agent Instructions
|
|
457
457
|
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
//
|
|
593
|
-
|
|
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}${
|
|
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] :
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
686
|
-
|
|
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}${
|
|
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] :
|
|
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
|
|
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);
|