gsd-pi 2.37.1 → 2.38.0-dev.96dc7fb
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 +1 -1
- package/dist/cli.js +9 -0
- package/dist/extension-discovery.d.ts +5 -3
- package/dist/extension-discovery.js +14 -9
- package/dist/onboarding.js +1 -0
- package/dist/resources/extensions/browser-tools/package.json +3 -1
- package/dist/resources/extensions/cmux/index.js +55 -1
- package/dist/resources/extensions/context7/package.json +1 -1
- package/dist/resources/extensions/google-search/package.json +3 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +67 -1
- package/dist/resources/extensions/gsd/auto-loop.js +7 -1
- package/dist/resources/extensions/gsd/auto-post-unit.js +14 -0
- package/dist/resources/extensions/gsd/auto-prompts.js +91 -2
- package/dist/resources/extensions/gsd/auto-recovery.js +37 -1
- package/dist/resources/extensions/gsd/auto-start.js +6 -1
- package/dist/resources/extensions/gsd/auto-worktree-sync.js +11 -4
- package/dist/resources/extensions/gsd/captures.js +9 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +16 -3
- package/dist/resources/extensions/gsd/commands.js +20 -1
- package/dist/resources/extensions/gsd/doctor-checks.js +82 -0
- package/dist/resources/extensions/gsd/doctor-environment.js +78 -0
- package/dist/resources/extensions/gsd/doctor-format.js +15 -0
- package/dist/resources/extensions/gsd/doctor-providers.js +35 -1
- package/dist/resources/extensions/gsd/doctor.js +184 -11
- package/dist/resources/extensions/gsd/files.js +41 -0
- package/dist/resources/extensions/gsd/observability-validator.js +24 -0
- package/dist/resources/extensions/gsd/package.json +1 -1
- package/dist/resources/extensions/gsd/preferences-types.js +2 -1
- package/dist/resources/extensions/gsd/preferences-validation.js +42 -0
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +2 -1
- package/dist/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
- package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
- package/dist/resources/extensions/gsd/templates/task-plan.md +11 -3
- package/dist/resources/extensions/gsd/worktree.js +35 -16
- package/dist/resources/extensions/subagent/index.js +12 -3
- package/dist/resources/extensions/universal-config/package.json +1 -1
- package/dist/welcome-screen.d.ts +12 -0
- package/dist/welcome-screen.js +53 -0
- package/package.json +2 -1
- package/packages/pi-ai/dist/env-api-keys.js +13 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +172 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +172 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
- package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
- package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
- package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +47 -764
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
- package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +2 -2
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/package.json +1 -0
- package/packages/pi-ai/src/env-api-keys.ts +14 -0
- package/packages/pi-ai/src/models.generated.ts +172 -0
- package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
- package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
- package/packages/pi-ai/src/providers/anthropic.ts +76 -868
- package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
- package/packages/pi-ai/src/types.ts +2 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/package-manager.js +8 -4
- package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/packages/pi-coding-agent/src/core/package-manager.ts +8 -4
- package/pkg/package.json +1 -1
- package/src/resources/extensions/cmux/index.ts +57 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +93 -0
- package/src/resources/extensions/gsd/auto-loop.ts +13 -1
- package/src/resources/extensions/gsd/auto-post-unit.ts +14 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +125 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +42 -0
- package/src/resources/extensions/gsd/auto-start.ts +7 -1
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +12 -3
- package/src/resources/extensions/gsd/captures.ts +10 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +17 -2
- package/src/resources/extensions/gsd/commands.ts +21 -1
- package/src/resources/extensions/gsd/doctor-checks.ts +75 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +82 -1
- package/src/resources/extensions/gsd/doctor-format.ts +20 -0
- package/src/resources/extensions/gsd/doctor-providers.ts +38 -1
- package/src/resources/extensions/gsd/doctor-types.ts +16 -1
- package/src/resources/extensions/gsd/doctor.ts +177 -13
- package/src/resources/extensions/gsd/files.ts +45 -0
- package/src/resources/extensions/gsd/observability-validator.ts +27 -0
- package/src/resources/extensions/gsd/preferences-types.ts +5 -1
- package/src/resources/extensions/gsd/preferences-validation.ts +41 -0
- package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -1
- package/src/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
- package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
- package/src/resources/extensions/gsd/templates/task-plan.md +11 -3
- package/src/resources/extensions/gsd/tests/cmux.test.ts +93 -0
- package/src/resources/extensions/gsd/tests/doctor-enhancements.test.ts +266 -0
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +108 -3
- package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
- package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +511 -0
- package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
- package/src/resources/extensions/gsd/tests/worktree.test.ts +47 -0
- package/src/resources/extensions/gsd/types.ts +43 -0
- package/src/resources/extensions/gsd/worktree.ts +35 -15
- package/src/resources/extensions/subagent/index.ts +12 -3
|
@@ -657,6 +657,81 @@ export async function checkRuntimeHealth(
|
|
|
657
657
|
} catch {
|
|
658
658
|
// Non-fatal — external state check failed
|
|
659
659
|
}
|
|
660
|
+
|
|
661
|
+
// ── Metrics ledger integrity ───────────────────────────────────────────
|
|
662
|
+
try {
|
|
663
|
+
const metricsPath = join(root, "metrics.json");
|
|
664
|
+
if (existsSync(metricsPath)) {
|
|
665
|
+
try {
|
|
666
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
667
|
+
const ledger = JSON.parse(raw);
|
|
668
|
+
if (ledger.version !== 1 || !Array.isArray(ledger.units)) {
|
|
669
|
+
issues.push({
|
|
670
|
+
severity: "warning",
|
|
671
|
+
code: "metrics_ledger_corrupt",
|
|
672
|
+
scope: "project",
|
|
673
|
+
unitId: "project",
|
|
674
|
+
message: "metrics.json has an unexpected structure (version !== 1 or units is not an array) — metrics data may be unreliable",
|
|
675
|
+
file: ".gsd/metrics.json",
|
|
676
|
+
fixable: false,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
issues.push({
|
|
681
|
+
severity: "warning",
|
|
682
|
+
code: "metrics_ledger_corrupt",
|
|
683
|
+
scope: "project",
|
|
684
|
+
unitId: "project",
|
|
685
|
+
message: "metrics.json is not valid JSON — metrics data may be corrupt",
|
|
686
|
+
file: ".gsd/metrics.json",
|
|
687
|
+
fixable: false,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
// Non-fatal — metrics check failed
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ── Large planning file detection ──────────────────────────────────────
|
|
696
|
+
// Files over 100KB can cause LLM context pressure. Report the worst offenders.
|
|
697
|
+
try {
|
|
698
|
+
const MAX_FILE_BYTES = 100 * 1024; // 100KB
|
|
699
|
+
const milestonesPath = milestonesDir(basePath);
|
|
700
|
+
if (existsSync(milestonesPath)) {
|
|
701
|
+
const largeFiles: Array<{ path: string; sizeKB: number }> = [];
|
|
702
|
+
function scanForLargeFiles(dir: string, depth = 0): void {
|
|
703
|
+
if (depth > 6) return;
|
|
704
|
+
try {
|
|
705
|
+
for (const entry of readdirSync(dir)) {
|
|
706
|
+
const full = join(dir, entry);
|
|
707
|
+
try {
|
|
708
|
+
const s = statSync(full);
|
|
709
|
+
if (s.isDirectory()) { scanForLargeFiles(full, depth + 1); continue; }
|
|
710
|
+
if (entry.endsWith(".md") && s.size > MAX_FILE_BYTES) {
|
|
711
|
+
largeFiles.push({ path: full.replace(basePath + "/", ""), sizeKB: Math.round(s.size / 1024) });
|
|
712
|
+
}
|
|
713
|
+
} catch { /* skip entry */ }
|
|
714
|
+
}
|
|
715
|
+
} catch { /* skip dir */ }
|
|
716
|
+
}
|
|
717
|
+
scanForLargeFiles(milestonesPath);
|
|
718
|
+
if (largeFiles.length > 0) {
|
|
719
|
+
largeFiles.sort((a, b) => b.sizeKB - a.sizeKB);
|
|
720
|
+
const worst = largeFiles[0]!;
|
|
721
|
+
issues.push({
|
|
722
|
+
severity: "warning",
|
|
723
|
+
code: "large_planning_file",
|
|
724
|
+
scope: "project",
|
|
725
|
+
unitId: "project",
|
|
726
|
+
message: `${largeFiles.length} planning file(s) exceed 100KB — largest: ${worst.path} (${worst.sizeKB}KB). Large files cause LLM context pressure.`,
|
|
727
|
+
file: worst.path,
|
|
728
|
+
fixable: false,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch {
|
|
733
|
+
// Non-fatal — large file scan failed
|
|
734
|
+
}
|
|
660
735
|
}
|
|
661
736
|
|
|
662
737
|
/**
|
|
@@ -407,6 +407,63 @@ function checkGitRemote(basePath: string): EnvironmentCheckResult | null {
|
|
|
407
407
|
return { name: "git_remote", status: "ok", message: "Git remote reachable" };
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Check if the project build passes (opt-in slow check, use --build flag).
|
|
412
|
+
* Runs npm run build and reports failure as env_build.
|
|
413
|
+
*/
|
|
414
|
+
function checkBuildHealth(basePath: string): EnvironmentCheckResult | null {
|
|
415
|
+
const pkgPath = join(basePath, "package.json");
|
|
416
|
+
if (!existsSync(pkgPath)) return null;
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
420
|
+
const buildScript = pkg.scripts?.build;
|
|
421
|
+
if (!buildScript) return null;
|
|
422
|
+
|
|
423
|
+
const result = tryExec("npm run build 2>&1", basePath);
|
|
424
|
+
if (result === null) {
|
|
425
|
+
return {
|
|
426
|
+
name: "build",
|
|
427
|
+
status: "error",
|
|
428
|
+
message: "Build failed — npm run build exited non-zero",
|
|
429
|
+
detail: "Fix build errors before dispatching work",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return { name: "build", status: "ok", message: "Build passes" };
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check if tests pass (opt-in slow check, use --test flag).
|
|
440
|
+
* Runs npm test and reports failures as env_test.
|
|
441
|
+
*/
|
|
442
|
+
function checkTestHealth(basePath: string): EnvironmentCheckResult | null {
|
|
443
|
+
const pkgPath = join(basePath, "package.json");
|
|
444
|
+
if (!existsSync(pkgPath)) return null;
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
448
|
+
const testScript = pkg.scripts?.test;
|
|
449
|
+
// Skip if no test script or the default placeholder
|
|
450
|
+
if (!testScript || testScript.includes("no test specified")) return null;
|
|
451
|
+
|
|
452
|
+
const result = tryExec("npm test 2>&1", basePath);
|
|
453
|
+
if (result === null) {
|
|
454
|
+
return {
|
|
455
|
+
name: "test",
|
|
456
|
+
status: "warning",
|
|
457
|
+
message: "Tests failing — npm test exited non-zero",
|
|
458
|
+
detail: "Fix failing tests before shipping",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return { name: "test", status: "ok", message: "Tests pass" };
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
410
467
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
411
468
|
|
|
412
469
|
/**
|
|
@@ -454,6 +511,26 @@ export function runFullEnvironmentChecks(basePath: string): EnvironmentCheckResu
|
|
|
454
511
|
return results;
|
|
455
512
|
}
|
|
456
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Run slow opt-in checks (build and/or test).
|
|
516
|
+
* These are never run on the pre-dispatch gate — only on explicit /gsd doctor --build/--test.
|
|
517
|
+
*/
|
|
518
|
+
export function runSlowEnvironmentChecks(
|
|
519
|
+
basePath: string,
|
|
520
|
+
options?: { includeBuild?: boolean; includeTests?: boolean },
|
|
521
|
+
): EnvironmentCheckResult[] {
|
|
522
|
+
const results: EnvironmentCheckResult[] = [];
|
|
523
|
+
if (options?.includeBuild) {
|
|
524
|
+
const buildCheck = checkBuildHealth(basePath);
|
|
525
|
+
if (buildCheck) results.push(buildCheck);
|
|
526
|
+
}
|
|
527
|
+
if (options?.includeTests) {
|
|
528
|
+
const testCheck = checkTestHealth(basePath);
|
|
529
|
+
if (testCheck) results.push(testCheck);
|
|
530
|
+
}
|
|
531
|
+
return results;
|
|
532
|
+
}
|
|
533
|
+
|
|
457
534
|
/**
|
|
458
535
|
* Convert environment check results to DoctorIssue format for the doctor pipeline.
|
|
459
536
|
*/
|
|
@@ -477,12 +554,16 @@ export function environmentResultsToDoctorIssues(results: EnvironmentCheckResult
|
|
|
477
554
|
export async function checkEnvironmentHealth(
|
|
478
555
|
basePath: string,
|
|
479
556
|
issues: DoctorIssue[],
|
|
480
|
-
options?: { includeRemote?: boolean },
|
|
557
|
+
options?: { includeRemote?: boolean; includeBuild?: boolean; includeTests?: boolean },
|
|
481
558
|
): Promise<void> {
|
|
482
559
|
const results = options?.includeRemote
|
|
483
560
|
? runFullEnvironmentChecks(basePath)
|
|
484
561
|
: runEnvironmentChecks(basePath);
|
|
485
562
|
|
|
563
|
+
if (options?.includeBuild || options?.includeTests) {
|
|
564
|
+
results.push(...runSlowEnvironmentChecks(basePath, options));
|
|
565
|
+
}
|
|
566
|
+
|
|
486
567
|
issues.push(...environmentResultsToDoctorIssues(results));
|
|
487
568
|
}
|
|
488
569
|
|
|
@@ -76,3 +76,23 @@ export function formatDoctorIssuesForPrompt(issues: DoctorIssue[]): string {
|
|
|
76
76
|
return `- [${prefix}] ${issue.unitId} | ${issue.code} | ${issue.message}${issue.file ? ` | file: ${issue.file}` : ""} | fixable: ${issue.fixable ? "yes" : "no"}`;
|
|
77
77
|
}).join("\n");
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Serialize a doctor report to JSON — suitable for CI/tooling integration.
|
|
82
|
+
* Usage: /gsd doctor --json
|
|
83
|
+
*/
|
|
84
|
+
export function formatDoctorReportJson(report: DoctorReport): string {
|
|
85
|
+
return JSON.stringify(
|
|
86
|
+
{
|
|
87
|
+
ok: report.ok,
|
|
88
|
+
basePath: report.basePath,
|
|
89
|
+
generatedAt: new Date().toISOString(),
|
|
90
|
+
summary: summarizeDoctorIssues(report.issues),
|
|
91
|
+
issues: report.issues,
|
|
92
|
+
fixesApplied: report.fixesApplied,
|
|
93
|
+
...(report.timing ? { timing: report.timing } : {}),
|
|
94
|
+
},
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { AuthStorage } from "@gsd/pi-coding-agent";
|
|
17
|
+
import { getEnvApiKey } from "@gsd/pi-ai";
|
|
17
18
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
18
19
|
import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
|
|
19
20
|
|
|
@@ -56,6 +57,7 @@ function modelToProviderId(model: string): string | null {
|
|
|
56
57
|
google: "google",
|
|
57
58
|
anthropic: "anthropic",
|
|
58
59
|
openai: "openai",
|
|
60
|
+
"github-copilot": "github-copilot",
|
|
59
61
|
};
|
|
60
62
|
if (prefixMap[prefix]) return prefixMap[prefix];
|
|
61
63
|
}
|
|
@@ -139,7 +141,15 @@ function resolveKey(providerId: string): KeyLookup {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
// Check environment variable
|
|
144
|
+
// Check environment variable using the authoritative env var resolution
|
|
145
|
+
// (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY,
|
|
146
|
+
// COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.)
|
|
147
|
+
if (getEnvApiKey(providerId)) {
|
|
148
|
+
return { found: true, source: "env", backedOff: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey
|
|
152
|
+
// (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7)
|
|
143
153
|
if (info?.envVar && process.env[info.envVar]) {
|
|
144
154
|
return { found: true, source: "env", backedOff: false };
|
|
145
155
|
}
|
|
@@ -149,6 +159,16 @@ function resolveKey(providerId: string): KeyLookup {
|
|
|
149
159
|
|
|
150
160
|
// ── Individual check groups ────────────────────────────────────────────────────
|
|
151
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Providers that can serve models normally associated with another provider.
|
|
164
|
+
* Key = the provider whose models can be served, Value = alternative providers to check.
|
|
165
|
+
* e.g. GitHub Copilot subscriptions can access Claude and GPT models.
|
|
166
|
+
*/
|
|
167
|
+
const PROVIDER_ROUTES: Record<string, string[]> = {
|
|
168
|
+
anthropic: ["github-copilot"],
|
|
169
|
+
openai: ["github-copilot"],
|
|
170
|
+
};
|
|
171
|
+
|
|
152
172
|
function checkLlmProviders(): ProviderCheckResult[] {
|
|
153
173
|
const required = collectConfiguredModelProviders();
|
|
154
174
|
const results: ProviderCheckResult[] = [];
|
|
@@ -159,6 +179,23 @@ function checkLlmProviders(): ProviderCheckResult[] {
|
|
|
159
179
|
const lookup = resolveKey(providerId);
|
|
160
180
|
|
|
161
181
|
if (!lookup.found) {
|
|
182
|
+
// Check if a cross-provider can serve this provider's models
|
|
183
|
+
const routes = PROVIDER_ROUTES[providerId];
|
|
184
|
+
const routeProvider = routes?.find(routeId => resolveKey(routeId).found);
|
|
185
|
+
if (routeProvider) {
|
|
186
|
+
const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider);
|
|
187
|
+
const routeLabel = routeInfo?.label ?? routeProvider;
|
|
188
|
+
results.push({
|
|
189
|
+
name: providerId,
|
|
190
|
+
label,
|
|
191
|
+
category: "llm",
|
|
192
|
+
status: "ok",
|
|
193
|
+
message: `${label} — available via ${routeLabel}`,
|
|
194
|
+
required: true,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
162
199
|
const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
|
|
163
200
|
results.push({
|
|
164
201
|
name: providerId,
|
|
@@ -53,7 +53,20 @@ export type DoctorIssueCode =
|
|
|
53
53
|
| "stranded_lock_directory"
|
|
54
54
|
// Git / worktree integrity checks
|
|
55
55
|
| "integration_branch_missing"
|
|
56
|
-
| "worktree_directory_orphaned"
|
|
56
|
+
| "worktree_directory_orphaned"
|
|
57
|
+
// GSD state structural checks
|
|
58
|
+
| "circular_slice_dependency"
|
|
59
|
+
| "orphaned_slice_directory"
|
|
60
|
+
| "duplicate_task_id"
|
|
61
|
+
| "task_file_not_in_plan"
|
|
62
|
+
| "stale_replan_file"
|
|
63
|
+
| "future_timestamp"
|
|
64
|
+
// Runtime data integrity
|
|
65
|
+
| "metrics_ledger_corrupt"
|
|
66
|
+
| "large_planning_file"
|
|
67
|
+
// Slow environment checks (opt-in via --build / --test flags)
|
|
68
|
+
| "env_build"
|
|
69
|
+
| "env_test";
|
|
57
70
|
|
|
58
71
|
/**
|
|
59
72
|
* Issue codes that represent expected completion-transition states.
|
|
@@ -83,6 +96,8 @@ export interface DoctorReport {
|
|
|
83
96
|
basePath: string;
|
|
84
97
|
issues: DoctorIssue[];
|
|
85
98
|
fixesApplied: string[];
|
|
99
|
+
/** Per-domain check durations in milliseconds. Present on explicit /gsd doctor runs. */
|
|
100
|
+
timing?: { git: number; runtime: number; environment: number; gsdState: number };
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
export interface DoctorSummary {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { loadFile, parsePlan, parseRoadmap, parseSummary, saveFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
|
|
5
|
-
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile } from "./paths.js";
|
|
5
|
+
import { resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTasksDir, milestonesDir, gsdRoot, relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relGsdRootFile, resolveGsdRootFile, relMilestonePath } from "./paths.js";
|
|
6
6
|
import { deriveState, isMilestoneComplete } from "./state.js";
|
|
7
7
|
import { invalidateAllCaches } from "./cache.js";
|
|
8
8
|
import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.js";
|
|
9
9
|
|
|
10
|
-
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
|
|
10
|
+
import type { DoctorIssue, DoctorIssueCode, DoctorReport } from "./doctor-types.js";
|
|
11
11
|
import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
|
|
12
|
+
import type { RoadmapSliceEntry } from "./types.js";
|
|
12
13
|
import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
|
|
13
14
|
import { checkEnvironmentHealth } from "./doctor-environment.js";
|
|
14
15
|
import { runProviderChecks } from "./doctor-providers.js";
|
|
@@ -17,7 +18,7 @@ import { runProviderChecks } from "./doctor-providers.js";
|
|
|
17
18
|
// All public types and functions from extracted modules are re-exported here
|
|
18
19
|
// so that existing imports from "./doctor.js" continue to work unchanged.
|
|
19
20
|
export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
|
|
20
|
-
export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
|
|
21
|
+
export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt, formatDoctorReportJson } from "./doctor-format.js";
|
|
21
22
|
export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
|
|
22
23
|
export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
|
|
23
24
|
|
|
@@ -350,10 +351,60 @@ export async function selectDoctorScope(basePath: string, requestedScope?: strin
|
|
|
350
351
|
return state.registry[0]?.id;
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
|
|
354
|
+
// ── Helper: circular dependency detection ──────────────────────────────────
|
|
355
|
+
function detectCircularDependencies(slices: RoadmapSliceEntry[]): string[][] {
|
|
356
|
+
const known = new Set(slices.map(s => s.id));
|
|
357
|
+
const adj = new Map<string, string[]>();
|
|
358
|
+
for (const s of slices) adj.set(s.id, s.depends.filter(d => known.has(d)));
|
|
359
|
+
const state = new Map<string, "unvisited" | "visiting" | "done">();
|
|
360
|
+
for (const s of slices) state.set(s.id, "unvisited");
|
|
361
|
+
const cycles: string[][] = [];
|
|
362
|
+
function dfs(id: string, path: string[]): void {
|
|
363
|
+
const st = state.get(id);
|
|
364
|
+
if (st === "done") return;
|
|
365
|
+
if (st === "visiting") { cycles.push([...path.slice(path.indexOf(id)), id]); return; }
|
|
366
|
+
state.set(id, "visiting");
|
|
367
|
+
for (const dep of adj.get(id) ?? []) dfs(dep, [...path, id]);
|
|
368
|
+
state.set(id, "done");
|
|
369
|
+
}
|
|
370
|
+
for (const s of slices) if (state.get(s.id) === "unvisited") dfs(s.id, []);
|
|
371
|
+
return cycles;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Helper: doctor run history ──────────────────────────────────────────────
|
|
375
|
+
interface DoctorHistoryEntry { ts: string; ok: boolean; errors: number; warnings: number; fixes: number; codes: string[] }
|
|
376
|
+
|
|
377
|
+
async function appendDoctorHistory(basePath: string, report: DoctorReport): Promise<void> {
|
|
378
|
+
try {
|
|
379
|
+
const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
|
|
380
|
+
const entry = JSON.stringify({
|
|
381
|
+
ts: new Date().toISOString(),
|
|
382
|
+
ok: report.ok,
|
|
383
|
+
errors: report.issues.filter(i => i.severity === "error").length,
|
|
384
|
+
warnings: report.issues.filter(i => i.severity === "warning").length,
|
|
385
|
+
fixes: report.fixesApplied.length,
|
|
386
|
+
codes: [...new Set(report.issues.map(i => i.code))],
|
|
387
|
+
} satisfies DoctorHistoryEntry);
|
|
388
|
+
const existing = existsSync(historyPath) ? readFileSync(historyPath, "utf-8") : "";
|
|
389
|
+
await saveFile(historyPath, existing + entry + "\n");
|
|
390
|
+
} catch { /* non-fatal */ }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Read the last N doctor history entries. Returns most-recent-first. */
|
|
394
|
+
export async function readDoctorHistory(basePath: string, lastN = 50): Promise<DoctorHistoryEntry[]> {
|
|
395
|
+
try {
|
|
396
|
+
const historyPath = join(gsdRoot(basePath), "doctor-history.jsonl");
|
|
397
|
+
if (!existsSync(historyPath)) return [];
|
|
398
|
+
const lines = readFileSync(historyPath, "utf-8").split("\n").filter(l => l.trim());
|
|
399
|
+
return lines.slice(-lastN).reverse().map(l => JSON.parse(l) as DoctorHistoryEntry);
|
|
400
|
+
} catch { return []; }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; dryRun?: boolean; scope?: string; fixLevel?: "task" | "all"; isolationMode?: "none" | "worktree" | "branch"; includeBuild?: boolean; includeTests?: boolean }): Promise<DoctorReport> {
|
|
354
404
|
const issues: DoctorIssue[] = [];
|
|
355
405
|
const fixesApplied: string[] = [];
|
|
356
406
|
const fix = options?.fix === true;
|
|
407
|
+
const dryRun = options?.dryRun === true;
|
|
357
408
|
const fixLevel = options?.fixLevel ?? "all";
|
|
358
409
|
|
|
359
410
|
// Issue codes that represent completion state transitions — creating summary
|
|
@@ -364,11 +415,18 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
364
415
|
|
|
365
416
|
/** Whether a given issue code should be auto-fixed at the current fixLevel. */
|
|
366
417
|
const shouldFix = (code: DoctorIssueCode): boolean => {
|
|
367
|
-
if (!fix) return false;
|
|
418
|
+
if (!fix || dryRun) return false;
|
|
368
419
|
if (fixLevel === "task" && COMPLETION_TRANSITION_CODES.has(code)) return false;
|
|
369
420
|
return true;
|
|
370
421
|
};
|
|
371
422
|
|
|
423
|
+
/** Log a dry-run "would fix" entry when fix=true but dryRun=true. */
|
|
424
|
+
const dryRunCanFix = (code: DoctorIssueCode, message: string): void => {
|
|
425
|
+
if (dryRun && fix && !(fixLevel === "task" && COMPLETION_TRANSITION_CODES.has(code))) {
|
|
426
|
+
fixesApplied.push(`[dry-run] would fix: ${message}`);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
372
430
|
const prefs = loadEffectiveGSDPreferences();
|
|
373
431
|
if (prefs) {
|
|
374
432
|
const prefIssues = validatePreferenceShape(prefs.preferences);
|
|
@@ -385,21 +443,33 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
385
443
|
}
|
|
386
444
|
}
|
|
387
445
|
|
|
388
|
-
// Git health checks
|
|
446
|
+
// Git health checks — timed
|
|
447
|
+
const t0git = Date.now();
|
|
389
448
|
const isolationMode: "none" | "worktree" | "branch" = options?.isolationMode ??
|
|
390
449
|
(prefs?.preferences?.git?.isolation === "none" ? "none" :
|
|
391
450
|
prefs?.preferences?.git?.isolation === "branch" ? "branch" : "worktree");
|
|
392
451
|
await checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode);
|
|
452
|
+
const gitMs = Date.now() - t0git;
|
|
393
453
|
|
|
394
|
-
// Runtime health checks
|
|
454
|
+
// Runtime health checks — timed
|
|
455
|
+
const t0runtime = Date.now();
|
|
395
456
|
await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
|
|
457
|
+
const runtimeMs = Date.now() - t0runtime;
|
|
396
458
|
|
|
397
|
-
// Environment health checks
|
|
398
|
-
|
|
459
|
+
// Environment health checks — timed
|
|
460
|
+
const t0env = Date.now();
|
|
461
|
+
await checkEnvironmentHealth(basePath, issues, {
|
|
462
|
+
includeRemote: !options?.scope,
|
|
463
|
+
includeBuild: options?.includeBuild,
|
|
464
|
+
includeTests: options?.includeTests,
|
|
465
|
+
});
|
|
466
|
+
const envMs = Date.now() - t0env;
|
|
399
467
|
|
|
400
468
|
const milestonesPath = milestonesDir(basePath);
|
|
401
469
|
if (!existsSync(milestonesPath)) {
|
|
402
|
-
|
|
470
|
+
const report: DoctorReport = { ok: issues.every(i => i.severity !== "error"), basePath, issues, fixesApplied, timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: 0 } };
|
|
471
|
+
await appendDoctorHistory(basePath, report);
|
|
472
|
+
return report;
|
|
403
473
|
}
|
|
404
474
|
|
|
405
475
|
const requirementsPath = resolveGsdRootFile(basePath, "REQUIREMENTS");
|
|
@@ -465,6 +535,43 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
465
535
|
if (!roadmapContent) continue;
|
|
466
536
|
const roadmap = parseRoadmap(roadmapContent);
|
|
467
537
|
|
|
538
|
+
// ── Circular dependency detection ──────────────────────────────────────
|
|
539
|
+
for (const cycle of detectCircularDependencies(roadmap.slices)) {
|
|
540
|
+
issues.push({
|
|
541
|
+
severity: "error",
|
|
542
|
+
code: "circular_slice_dependency",
|
|
543
|
+
scope: "milestone",
|
|
544
|
+
unitId: milestoneId,
|
|
545
|
+
message: `Circular dependency detected: ${cycle.join(" → ")}`,
|
|
546
|
+
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
547
|
+
fixable: false,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Orphaned slice directories ─────────────────────────────────────────
|
|
552
|
+
try {
|
|
553
|
+
const slicesDir = join(milestonePath, "slices");
|
|
554
|
+
if (existsSync(slicesDir)) {
|
|
555
|
+
const knownSliceIds = new Set(roadmap.slices.map(s => s.id));
|
|
556
|
+
for (const entry of readdirSync(slicesDir)) {
|
|
557
|
+
try {
|
|
558
|
+
if (!lstatSync(join(slicesDir, entry)).isDirectory()) continue;
|
|
559
|
+
} catch { continue; }
|
|
560
|
+
if (!knownSliceIds.has(entry)) {
|
|
561
|
+
issues.push({
|
|
562
|
+
severity: "warning",
|
|
563
|
+
code: "orphaned_slice_directory",
|
|
564
|
+
scope: "milestone",
|
|
565
|
+
unitId: milestoneId,
|
|
566
|
+
message: `Directory "${entry}" exists in ${milestoneId}/slices/ but is not referenced in the roadmap`,
|
|
567
|
+
file: `${relMilestonePath(basePath, milestoneId)}/slices/${entry}`,
|
|
568
|
+
fixable: false,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} catch { /* non-fatal */ }
|
|
574
|
+
|
|
468
575
|
for (const slice of roadmap.slices) {
|
|
469
576
|
const unitId = `${milestoneId}/${slice.id}`;
|
|
470
577
|
if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue;
|
|
@@ -539,6 +646,33 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
539
646
|
continue;
|
|
540
647
|
}
|
|
541
648
|
|
|
649
|
+
// ── Duplicate task IDs ───────────────────────────────────────────────
|
|
650
|
+
const taskIdCounts = new Map<string, number>();
|
|
651
|
+
for (const task of plan.tasks) taskIdCounts.set(task.id, (taskIdCounts.get(task.id) ?? 0) + 1);
|
|
652
|
+
for (const [taskId, count] of taskIdCounts) {
|
|
653
|
+
if (count > 1) {
|
|
654
|
+
issues.push({ severity: "error", code: "duplicate_task_id", scope: "slice", unitId,
|
|
655
|
+
message: `Task ID "${taskId}" appears ${count} times in ${slice.id}-PLAN.md — duplicate IDs cause dispatch failures`,
|
|
656
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "PLAN"), fixable: false });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ── Task files on disk not in plan ────────────────────────────────────
|
|
661
|
+
try {
|
|
662
|
+
if (tasksDir) {
|
|
663
|
+
const planTaskIds = new Set(plan.tasks.map(t => t.id));
|
|
664
|
+
for (const f of readdirSync(tasksDir)) {
|
|
665
|
+
if (!f.endsWith("-SUMMARY.md")) continue;
|
|
666
|
+
const diskTaskId = f.replace(/-SUMMARY\.md$/, "");
|
|
667
|
+
if (!planTaskIds.has(diskTaskId)) {
|
|
668
|
+
issues.push({ severity: "info", code: "task_file_not_in_plan", scope: "slice", unitId,
|
|
669
|
+
message: `Task summary "${f}" exists on disk but "${diskTaskId}" is not in ${slice.id}-PLAN.md`,
|
|
670
|
+
file: relTaskFile(basePath, milestoneId, slice.id, diskTaskId, "SUMMARY"), fixable: false });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch { /* non-fatal */ }
|
|
675
|
+
|
|
542
676
|
let allTasksDone = plan.tasks.length > 0;
|
|
543
677
|
for (const task of plan.tasks) {
|
|
544
678
|
const taskUnitId = `${unitId}/${task.id}`;
|
|
@@ -555,6 +689,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
555
689
|
file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"),
|
|
556
690
|
fixable: true,
|
|
557
691
|
});
|
|
692
|
+
dryRunCanFix("task_done_missing_summary", `create stub summary for ${taskUnitId}`);
|
|
558
693
|
if (shouldFix("task_done_missing_summary")) {
|
|
559
694
|
const stubPath = join(
|
|
560
695
|
basePath, ".gsd", "milestones", milestoneId, "slices", slice.id, "tasks",
|
|
@@ -618,6 +753,22 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
618
753
|
}
|
|
619
754
|
}
|
|
620
755
|
|
|
756
|
+
// ── Future timestamp check ─────────────────────────────────────
|
|
757
|
+
if (task.done && hasSummary && summaryPath) {
|
|
758
|
+
try {
|
|
759
|
+
const rawSummary = await loadFile(summaryPath);
|
|
760
|
+
const m = rawSummary?.match(/^completed_at:\s*(.+)$/m);
|
|
761
|
+
if (m) {
|
|
762
|
+
const ts = new Date(m[1].trim());
|
|
763
|
+
if (!isNaN(ts.getTime()) && ts.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
|
|
764
|
+
issues.push({ severity: "warning", code: "future_timestamp", scope: "task", unitId: taskUnitId,
|
|
765
|
+
message: `Task ${task.id} has completed_at "${m[1].trim()}" which is more than 24h in the future`,
|
|
766
|
+
file: relTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY"), fixable: false });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} catch { /* non-fatal */ }
|
|
770
|
+
}
|
|
771
|
+
|
|
621
772
|
allTasksDone = allTasksDone && task.done;
|
|
622
773
|
}
|
|
623
774
|
|
|
@@ -646,6 +797,13 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
646
797
|
}
|
|
647
798
|
}
|
|
648
799
|
|
|
800
|
+
// ── Stale REPLAN: exists but all tasks done ────────────────────────
|
|
801
|
+
if (replanPath && allTasksDone) {
|
|
802
|
+
issues.push({ severity: "info", code: "stale_replan_file", scope: "slice", unitId,
|
|
803
|
+
message: `${slice.id} has a REPLAN.md but all tasks are done — REPLAN.md may be stale`,
|
|
804
|
+
file: relSliceFile(basePath, milestoneId, slice.id, "REPLAN"), fixable: false });
|
|
805
|
+
}
|
|
806
|
+
|
|
649
807
|
const sliceSummaryPath = resolveSliceFile(basePath, milestoneId, slice.id, "SUMMARY");
|
|
650
808
|
const sliceUatPath = join(slicePath, `${slice.id}-UAT.md`);
|
|
651
809
|
const hasSliceSummary = !!(sliceSummaryPath && await loadFile(sliceSummaryPath));
|
|
@@ -661,6 +819,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
661
819
|
file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"),
|
|
662
820
|
fixable: true,
|
|
663
821
|
});
|
|
822
|
+
dryRunCanFix("all_tasks_done_missing_slice_summary", `create placeholder summary for ${unitId}`);
|
|
664
823
|
if (shouldFix("all_tasks_done_missing_slice_summary")) await ensureSliceSummaryStub(basePath, milestoneId, slice.id, fixesApplied);
|
|
665
824
|
}
|
|
666
825
|
|
|
@@ -674,6 +833,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
674
833
|
file: `${relSlicePath(basePath, milestoneId, slice.id)}/${slice.id}-UAT.md`,
|
|
675
834
|
fixable: true,
|
|
676
835
|
});
|
|
836
|
+
dryRunCanFix("all_tasks_done_missing_slice_uat", `create placeholder UAT for ${unitId}`);
|
|
677
837
|
if (shouldFix("all_tasks_done_missing_slice_uat")) await ensureSliceUatStub(basePath, milestoneId, slice.id, fixesApplied);
|
|
678
838
|
}
|
|
679
839
|
|
|
@@ -687,6 +847,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
687
847
|
file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
|
|
688
848
|
fixable: true,
|
|
689
849
|
});
|
|
850
|
+
dryRunCanFix("all_tasks_done_roadmap_not_checked", `mark ${slice.id} done in roadmap`);
|
|
690
851
|
if (shouldFix("all_tasks_done_roadmap_not_checked") && (hasSliceSummary || issues.some(issue => issue.code === "all_tasks_done_missing_slice_summary" && issue.unitId === unitId))) {
|
|
691
852
|
await markSliceDoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
|
|
692
853
|
}
|
|
@@ -744,14 +905,17 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
|
|
|
744
905
|
}
|
|
745
906
|
}
|
|
746
907
|
|
|
747
|
-
if (fix && fixesApplied.length > 0) {
|
|
908
|
+
if (fix && !dryRun && fixesApplied.length > 0) {
|
|
748
909
|
await updateStateFile(basePath, fixesApplied);
|
|
749
910
|
}
|
|
750
911
|
|
|
751
|
-
|
|
912
|
+
const report: DoctorReport = {
|
|
752
913
|
ok: issues.every(issue => issue.severity !== "error"),
|
|
753
914
|
basePath,
|
|
754
915
|
issues,
|
|
755
916
|
fixesApplied,
|
|
917
|
+
timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: Math.max(0, Date.now() - t0env - envMs) },
|
|
756
918
|
};
|
|
919
|
+
await appendDoctorHistory(basePath, report);
|
|
920
|
+
return report;
|
|
757
921
|
}
|