opencode-worktree-guardian 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/codex/.codex-plugin/plugin.json +31 -0
- package/codex/hooks/guardian-hook.ts +265 -0
- package/codex/hooks/hooks.json +30 -0
- package/codex/skills/worktree-guardian/SKILL.md +29 -0
- package/commands/delete-paths.md +12 -0
- package/commands/delete-worktree.md +16 -0
- package/commands/done.md +14 -0
- package/commands/finish-workflow.md +18 -0
- package/commands/finish.md +16 -0
- package/commands/hygiene.md +16 -0
- package/commands/preserve.md +14 -0
- package/commands/recover.md +14 -0
- package/commands/report.md +14 -0
- package/commands/start.md +14 -0
- package/commands/status.md +14 -0
- package/commands/unblock-finish.md +16 -0
- package/docs/adr/0001-guardian-safety-policy.md +192 -0
- package/docs/publishing.md +55 -0
- package/docs/release-checklist.md +84 -0
- package/package.json +72 -0
- package/scripts/readiness.ts +75 -0
- package/scripts/with-safe-node-temp.mjs +78 -0
- package/skills/worktree-guardian/SKILL.md +73 -0
- package/src/config.ts +87 -0
- package/src/delete-paths-apply.ts +77 -0
- package/src/delete-paths-preflight.ts +146 -0
- package/src/delete-paths.ts +1 -0
- package/src/delete-worktree-preflight.ts +25 -0
- package/src/delete-worktree-report.ts +70 -0
- package/src/delete-worktree-targets.ts +152 -0
- package/src/delete-worktree.ts +222 -0
- package/src/delete.ts +1 -0
- package/src/deletion-fingerprint.ts +59 -0
- package/src/done-primary-publish.ts +129 -0
- package/src/done-primary-snapshot.ts +79 -0
- package/src/done-reattach.ts +32 -0
- package/src/done-shared.ts +28 -0
- package/src/done.ts +80 -0
- package/src/filesystem-boundaries.ts +49 -0
- package/src/finish-dirty-files.ts +56 -0
- package/src/finish-report.ts +80 -0
- package/src/finish.ts +212 -0
- package/src/git.ts +288 -0
- package/src/guards/allowlists.ts +83 -0
- package/src/guards/classifier.ts +39 -0
- package/src/guards/destructive-classifier.ts +189 -0
- package/src/guards/git-invocation.ts +145 -0
- package/src/guards/guard-types.ts +36 -0
- package/src/guards/options.ts +15 -0
- package/src/guards/path-policy.ts +31 -0
- package/src/guards/protected-branch-policy.ts +88 -0
- package/src/guards/shell-parser.ts +126 -0
- package/src/guards/shell-prefix.ts +100 -0
- package/src/guards.ts +3 -0
- package/src/hygiene-apply.ts +230 -0
- package/src/hygiene-scan.ts +200 -0
- package/src/hygiene.ts +10 -0
- package/src/index.ts +18 -0
- package/src/lifecycle.ts +31 -0
- package/src/plugin/direct-file-routing.ts +68 -0
- package/src/plugin/event-log.ts +60 -0
- package/src/plugin/guard-context.ts +40 -0
- package/src/plugin/hook-context.ts +35 -0
- package/src/plugin/invisible-policy.ts +28 -0
- package/src/plugin/native-tool.ts +82 -0
- package/src/plugin/plan-token-cache.ts +66 -0
- package/src/plugin/readable-output-cleanup.ts +141 -0
- package/src/plugin/readable-output-status.ts +86 -0
- package/src/plugin/readable-output-values.ts +21 -0
- package/src/plugin/readable-output-workflow.ts +70 -0
- package/src/plugin/readable-output.ts +16 -0
- package/src/plugin/server.ts +257 -0
- package/src/plugin/session-routing.ts +74 -0
- package/src/plugin/slash-commands.ts +20 -0
- package/src/preserve.ts +32 -0
- package/src/recover.ts +195 -0
- package/src/report.ts +168 -0
- package/src/session/context.ts +41 -0
- package/src/session/last-safe-state.ts +27 -0
- package/src/session/worktree-binding.ts +161 -0
- package/src/start.ts +157 -0
- package/src/state.ts +197 -0
- package/src/tool-registry.ts +35 -0
- package/src/tools.ts +8 -0
- package/src/tui.ts +113 -0
- package/src/types.ts +339 -0
- package/src/unblock-finish.ts +298 -0
- package/src/workflow-candidates.ts +111 -0
- package/src/workflow.ts +84 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { guardianRecover, guardianStatus } from "./recover.ts";
|
|
2
|
+
import { getGuardianPaths, writeReportAtomic } from "./state.ts";
|
|
3
|
+
import type { MutableRecord } from "./types.ts";
|
|
4
|
+
import { isMutableRecord } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
type LooseRecord = MutableRecord;
|
|
7
|
+
|
|
8
|
+
const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
9
|
+
|
|
10
|
+
function escapeHtml(value: unknown) {
|
|
11
|
+
return String(value ?? "")
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """)
|
|
16
|
+
.replace(/'/g, "'")
|
|
17
|
+
.replace(/=/g, "=");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function recordValue(value: unknown): LooseRecord {
|
|
21
|
+
return isMutableRecord(value) ? value : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function arrayValue(value: unknown): unknown[] {
|
|
25
|
+
return Array.isArray(value) ? value : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function textValue(value: unknown, fallback = "-") {
|
|
29
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function numberValue(value: unknown, fallback = 0) {
|
|
33
|
+
const number = Number(value);
|
|
34
|
+
return Number.isFinite(number) ? number : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shortCommit(value: unknown) {
|
|
38
|
+
const text = textValue(value);
|
|
39
|
+
return text === "-" ? text : text.slice(0, 12);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function metric(label: string, value: number, tone = "") {
|
|
43
|
+
return `<article class="metric ${tone}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></article>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function emptyRow(columns: number, message: string) {
|
|
47
|
+
return `<tr><td colspan="${columns}" class="empty">${escapeHtml(message)}</td></tr>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sessionsTable(sessions: unknown[]) {
|
|
51
|
+
const rows = sessions.map((entry) => {
|
|
52
|
+
const session = recordValue(entry);
|
|
53
|
+
return `<tr><td>${escapeHtml(session.session_id ?? session.sessionId)}</td><td>${escapeHtml(session.status)}</td><td>${escapeHtml(session.branch)}</td><td>${escapeHtml(session.worktree_path ?? session.worktreePath)}</td><td>${escapeHtml(shortCommit(session.head_commit ?? session.headCommit))}</td></tr>`;
|
|
54
|
+
}).join("") || emptyRow(5, "No guardian sessions recorded.");
|
|
55
|
+
return `<table><thead><tr><th>Session</th><th>Status</th><th>Branch</th><th>Worktree</th><th>Head</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function worktreesTable(worktrees: unknown[]) {
|
|
59
|
+
const rows = worktrees.map((entry) => {
|
|
60
|
+
const worktree = recordValue(entry);
|
|
61
|
+
const markers = [worktree.detached === true ? "detached" : "", worktree.bare === true ? "bare" : ""].filter(Boolean).join(", ");
|
|
62
|
+
return `<tr><td>${escapeHtml(worktree.branch)}</td><td>${escapeHtml(worktree.path)}</td><td>${escapeHtml(shortCommit(worktree.head ?? worktree.head_commit ?? worktree.headCommit))}</td><td>${escapeHtml(markers || "clean")}</td></tr>`;
|
|
63
|
+
}).join("") || emptyRow(4, "No git worktrees found.");
|
|
64
|
+
return `<table><thead><tr><th>Branch</th><th>Path</th><th>Head</th><th>Markers</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function branchesTable(branches: unknown[]) {
|
|
68
|
+
const rows = branches.map((entry) => {
|
|
69
|
+
const branch = recordValue(entry);
|
|
70
|
+
return `<tr><td>${escapeHtml(branch.name ?? entry)}</td><td>${escapeHtml(shortCommit(branch.commit ?? branch.head))}</td></tr>`;
|
|
71
|
+
}).join("") || emptyRow(2, "Every listed branch has a worktree.");
|
|
72
|
+
return `<table><thead><tr><th>Branch Without Worktree</th><th>Commit</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function severityRank(value: unknown) {
|
|
76
|
+
return textValue(value) === "fail" ? 0 : textValue(value) === "warn" ? 1 : 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hygieneCountCard(label: string, value: number, tone = "") {
|
|
80
|
+
return `<article class="hygiene-count ${tone}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></article>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hygieneFindingsTable(findings: unknown[]) {
|
|
84
|
+
const rows = [...findings]
|
|
85
|
+
.sort((left, right) => {
|
|
86
|
+
const leftRecord = recordValue(left);
|
|
87
|
+
const rightRecord = recordValue(right);
|
|
88
|
+
return severityRank(leftRecord.severity) - severityRank(rightRecord.severity)
|
|
89
|
+
|| textValue(leftRecord.category).localeCompare(textValue(rightRecord.category))
|
|
90
|
+
|| textValue(leftRecord.path).localeCompare(textValue(rightRecord.path));
|
|
91
|
+
})
|
|
92
|
+
.slice(0, 12)
|
|
93
|
+
.map((entry) => {
|
|
94
|
+
const finding = recordValue(entry);
|
|
95
|
+
const severity = textValue(finding.severity);
|
|
96
|
+
const tone = severity === "fail" ? "bad" : severity === "warn" ? "warn" : "";
|
|
97
|
+
return `<tr><td><span class="status-pill ${tone}">${escapeHtml(severity)}</span></td><td>${escapeHtml(finding.category)}</td><td><code>${escapeHtml(finding.path)}</code></td><td>${escapeHtml(finding.reason)}</td></tr>`;
|
|
98
|
+
}).join("") || emptyRow(4, "No workspace hygiene findings recorded.");
|
|
99
|
+
return `<table><thead><tr><th>Severity</th><th>Category</th><th>Path</th><th>Reason</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hygieneSection(input: unknown) {
|
|
103
|
+
const hygiene = recordValue(input);
|
|
104
|
+
const summary = recordValue(hygiene.summary);
|
|
105
|
+
const bySeverity = recordValue(summary.bySeverity);
|
|
106
|
+
const byCategory = recordValue(summary.byCategory);
|
|
107
|
+
const findings = arrayValue(hygiene.findings);
|
|
108
|
+
const findingCount = numberValue(summary.findingCount, findings.length);
|
|
109
|
+
const failCount = numberValue(bySeverity.fail);
|
|
110
|
+
const warnCount = numberValue(bySeverity.warn);
|
|
111
|
+
const knownCleanableCount = numberValue(byCategory["known-cleanable"]);
|
|
112
|
+
const nestedGitCount = numberValue(byCategory["nested-git"]);
|
|
113
|
+
const suspiciousCount = numberValue(byCategory.suspicious);
|
|
114
|
+
const sectionTone = failCount > 0 ? "risk" : findingCount > 0 ? "warning" : "info";
|
|
115
|
+
const failureReason = hygiene.ok === false ? `<p class="hygiene-alert">Scan failed: ${escapeHtml(hygiene.reason)}</p>` : "";
|
|
116
|
+
|
|
117
|
+
return `<section class="card hygiene ${sectionTone}" aria-labelledby="workspace-hygiene-heading"><h2 id="workspace-hygiene-heading">Workspace Hygiene</h2><p class="section-note">Report-only scan of untracked and ignored workspace artifacts. No cleanup actions are performed here.</p>${failureReason}<div class="hygiene-metrics">${hygieneCountCard("Candidate Paths", numberValue(summary.candidateCount))}${hygieneCountCard("Findings", findingCount, findingCount > 0 ? "warn" : "good")}${hygieneCountCard("Fail", failCount, failCount > 0 ? "bad" : "good")}${hygieneCountCard("Warn", warnCount, warnCount > 0 ? "warn" : "good")}${hygieneCountCard("Known Cleanable", knownCleanableCount, knownCleanableCount > 0 ? "warn" : "good")}${hygieneCountCard("Nested Git", nestedGitCount, nestedGitCount > 0 ? "bad" : "good")}${hygieneCountCard("Suspicious", suspiciousCount, suspiciousCount > 0 ? "warn" : "good")}${hygieneCountCard("Exclusions", numberValue(summary.exclusionCount))}</div><h3>Top Findings By Severity And Category</h3>${hygieneFindingsTable(findings)}</section>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function listSection(title: string, entries: unknown[], tone: "risk" | "info") {
|
|
121
|
+
const items = entries.map((entry) => `<li><code>${escapeHtml(describeEntry(entry))}</code></li>`).join("") || `<li class="empty">${escapeHtml(tone === "risk" ? "No risks detected." : "Nothing to show.")}</li>`;
|
|
122
|
+
return `<section class="card ${tone}"><h2>${escapeHtml(title)}</h2><ul>${items}</ul></section>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function describeEntry(entry: unknown) {
|
|
126
|
+
const item = recordValue(entry);
|
|
127
|
+
return textValue(item.session_id ?? item.sessionId ?? item.branch ?? item.path ?? item.worktree_path ?? item.name ?? item.ref ?? item.command ?? entry, JSON.stringify(entry));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function commandSection(commands: unknown[]) {
|
|
131
|
+
const items = commands.map((command) => `<li><code>${escapeHtml(command)}</code></li>`).join("") || `<li class="empty">No recovery commands suggested.</li>`;
|
|
132
|
+
return `<section class="card command-bank"><h2>Recovery Commands</h2><p>Read-only suggestions. Review before running anything mutating.</p><ul>${items}</ul></section>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function styles() {
|
|
136
|
+
return `:root{--bg:#080b0d;--panel:#11181c;--panel-2:#172126;--line:#31444c;--text:#e4efe9;--muted:#92a49f;--good:#7ddf9a;--warn:#ffbe5c;--bad:#ff6b5e;--accent:#8fd6ff;--shadow:0 24px 80px rgba(0,0,0,.42);--radius:18px;--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-6:24px;--space-8:32px}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0%,#17303a 0,#080b0d 34%),linear-gradient(135deg,#080b0d,#121618);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025),rgba(255,255,255,.025) 1px,transparent 1px,transparent 6px)}header,main{width:min(1180px,calc(100% - 32px));margin:0 auto}header{padding:var(--space-8) 0 var(--space-6)}.eyebrow{color:var(--accent);letter-spacing:.18em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(32px,6vw,72px);line-height:.9;margin:var(--space-3) 0;font-weight:900;letter-spacing:-.08em}h2{margin:0 0 var(--space-4);font-size:18px;text-transform:uppercase;letter-spacing:.08em}.timestamp{color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--space-4);margin-bottom:var(--space-4)}.card,.metric{background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.015));border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}.card{grid-column:span 12;padding:var(--space-6);overflow:hidden}.half{grid-column:span 6}.third{grid-column:span 4}.metric{padding:var(--space-4);min-height:96px}.metric span{display:block;color:var(--muted);text-transform:uppercase;font-size:12px;letter-spacing:.1em}.metric strong{display:block;font-size:34px;line-height:1.1}.metric.good strong{color:var(--good)}.metric.warn strong{color:var(--warn)}.metric.bad strong{color:var(--bad)}table{width:100%;border-collapse:collapse;overflow:hidden}th,td{padding:var(--space-3);border-bottom:1px solid rgba(255,255,255,.08);text-align:left;vertical-align:top}th{color:var(--accent);font-size:12px;text-transform:uppercase;letter-spacing:.08em;background:rgba(143,214,255,.06)}td{color:var(--text);word-break:break-word}.empty{color:var(--muted)}ul{margin:0;padding-left:var(--space-6)}li{margin:var(--space-2) 0}code,pre{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:#d7ffea}code{background:rgba(125,223,154,.08);padding:2px 6px;border-radius:8px}pre{white-space:pre-wrap;word-break:break-word;margin:var(--space-4) 0 0;max-height:520px;overflow:auto}.risk{border-color:rgba(255,190,92,.45)}.command-bank{border-color:rgba(143,214,255,.35)}details summary{cursor:pointer;color:var(--accent);font-weight:700}.section-note{margin:0 0 var(--space-4);color:var(--muted)}.hygiene.warning{border-color:rgba(255,190,92,.45)}.hygiene.info{border-color:rgba(143,214,255,.35)}.hygiene h3{margin:var(--space-6) 0 var(--space-3);font-size:13px;color:var(--accent);text-transform:uppercase;letter-spacing:.12em}.hygiene-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-3);margin:var(--space-4) 0}.hygiene-count{border:1px solid rgba(255,255,255,.1);background:rgba(0,0,0,.18);border-radius:14px;padding:var(--space-3)}.hygiene-count span{display:block;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:.09em}.hygiene-count strong{display:block;font-size:24px;line-height:1.1}.hygiene-count.good strong,.status-pill.good{color:var(--good)}.hygiene-count.warn strong,.status-pill.warn{color:var(--warn)}.hygiene-count.bad strong,.status-pill.bad{color:var(--bad)}.status-pill{display:inline-block;text-transform:uppercase;font-size:12px;font-weight:800;letter-spacing:.08em}.hygiene-alert{color:var(--bad);font-weight:800}@media(max-width:800px){.half,.third{grid-column:span 12}.hygiene-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}header,main{width:min(100% - 20px,1180px)}th,td{padding:var(--space-2)}}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderGuardianReportHtml(input: { reportPath: string; generatedAt: string; status: LooseRecord; recover: LooseRecord }) {
|
|
140
|
+
const { reportPath, generatedAt, status, recover } = input;
|
|
141
|
+
const sessions = arrayValue(status.sessions);
|
|
142
|
+
const worktrees = arrayValue(status.worktrees);
|
|
143
|
+
const orphanedSessions = arrayValue(status.orphanedSessions);
|
|
144
|
+
const worktreesWithoutState = arrayValue(status.worktreesWithoutState);
|
|
145
|
+
const dirtyFiles = arrayValue(status.dirtyFiles);
|
|
146
|
+
const stashes = arrayValue(status.stashes);
|
|
147
|
+
const safetyRefs = arrayValue(status.safetyRefs);
|
|
148
|
+
const recoveryCandidates = arrayValue(recover.recoveryCandidates);
|
|
149
|
+
const hygiene = recordValue(status.hygiene);
|
|
150
|
+
const hygieneSummary = recordValue(hygiene.summary);
|
|
151
|
+
const hygieneBySeverity = recordValue(hygieneSummary.bySeverity);
|
|
152
|
+
const hygieneFindingCount = numberValue(hygieneSummary.findingCount, arrayValue(hygiene.findings).length);
|
|
153
|
+
const hygieneFailCount = numberValue(hygieneBySeverity.fail);
|
|
154
|
+
const riskCount = orphanedSessions.length + worktreesWithoutState.length + dirtyFiles.length + stashes.length + hygieneFindingCount;
|
|
155
|
+
const rawJson = JSON.stringify({ reportPath, generatedAt, status, recover }, null, 2);
|
|
156
|
+
|
|
157
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHtml(CSP)}"><title>Guardian Control Report</title><style>${styles()}</style></head><body><header><div class="eyebrow">OpenCode Worktree Guardian</div><h1>Control Report</h1><p class="timestamp">Generated ${escapeHtml(generatedAt)} for <code>${escapeHtml(status.repoRoot)}</code></p><p class="timestamp">Offline file: <code>${escapeHtml(reportPath)}</code></p></header><main><section class="grid" aria-label="Guardian summary metrics">${metric("Sessions", sessions.length, sessions.length > 0 ? "good" : "")}${metric("Worktrees", worktrees.length, worktrees.length > 0 ? "good" : "")}${metric("Risks", riskCount, riskCount > 0 ? "warn" : "good")}${metric("Safety Refs", safetyRefs.length, safetyRefs.length > 0 ? "warn" : "")}${metric("Recovery Candidates", recoveryCandidates.length, recoveryCandidates.length > 0 ? "warn" : "good")}${metric("Dirty Files", dirtyFiles.length, dirtyFiles.length > 0 ? "bad" : "good")}${metric("Hygiene Findings", hygieneFindingCount, hygieneFailCount > 0 ? "bad" : hygieneFindingCount > 0 ? "warn" : "good")}</section><section class="grid"><section class="card"><h2>Sessions</h2>${sessionsTable(sessions)}</section><section class="card"><h2>Worktrees</h2>${worktreesTable(worktrees)}</section><section class="card half"><h2>Branch Coverage</h2>${branchesTable(arrayValue(status.branchesWithoutWorktrees))}</section>${listSection("Orphaned Sessions", orphanedSessions, "risk")}${listSection("Worktrees Without State", worktreesWithoutState, "risk")}${listSection("Dirty Files", dirtyFiles, "risk")}${listSection("Stashes", stashes, "risk")}${hygieneSection(hygiene)}${listSection("Safety Refs", safetyRefs, "info")}${listSection("Recovery Candidates", recoveryCandidates, "info")}${commandSection(arrayValue(recover.suggestedCommands))}<section class="card"><h2>Raw Guardian JSON</h2><details><summary>Open raw status and recovery metadata</summary><pre>${escapeHtml(rawJson)}</pre></details></section></section></main></body></html>\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function guardianReportHtml(input: LooseRecord = {}) {
|
|
161
|
+
const status = await guardianStatus(input);
|
|
162
|
+
const recover = await guardianRecover(input);
|
|
163
|
+
const paths = await getGuardianPaths(String(status.repoRoot));
|
|
164
|
+
const generatedAt = new Date().toISOString();
|
|
165
|
+
const html = renderGuardianReportHtml({ reportPath: paths.reportPath, generatedAt, status, recover });
|
|
166
|
+
await writeReportAtomic(paths, html);
|
|
167
|
+
return { ok: true, reportPath: paths.reportPath, status, recover };
|
|
168
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { loadConfig, normalizeConfig } from "../config.ts";
|
|
2
|
+
import type { GuardianConfig, GuardianStateRecord, GuardianToolInput } from "../types.ts";
|
|
3
|
+
import { isRecordLike } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export async function configFromInput(input: GuardianToolInput, repoRoot: string): Promise<GuardianConfig> {
|
|
6
|
+
if (input.config === undefined || input.config === null) return (await loadConfig(repoRoot)).config;
|
|
7
|
+
if (!isRecordLike(input.config)) throw new Error("config must be an object");
|
|
8
|
+
return normalizeConfig(input.config);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stateFromInput(value: unknown): GuardianStateRecord | null {
|
|
12
|
+
if (!isRecordLike(value)) return null;
|
|
13
|
+
const sessions: GuardianStateRecord["sessions"] = {};
|
|
14
|
+
if (isRecordLike(value.sessions)) {
|
|
15
|
+
for (const [sessionId, session] of Object.entries(value.sessions)) {
|
|
16
|
+
if (isRecordLike(session)) sessions[sessionId] = session;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
...value,
|
|
21
|
+
sessions,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionIdFromInput(input: GuardianToolInput): string | null {
|
|
26
|
+
const rawSessionId = input.sessionId ?? input.sessionID;
|
|
27
|
+
return rawSessionId == null || rawSessionId === "" ? null : String(rawSessionId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type WorktreeCache = {
|
|
31
|
+
readonly get: (key: string) => unknown;
|
|
32
|
+
readonly set: (key: string, value: string) => unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function worktreeCache(value: unknown): WorktreeCache | null {
|
|
36
|
+
if (!(value instanceof Map)) return null;
|
|
37
|
+
return {
|
|
38
|
+
get: (key: string) => value.get(key),
|
|
39
|
+
set: (key: string, nextValue: string) => value.set(key, nextValue),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getCurrentBranch, getHeadCommit, getRepoRoot } from "../git.ts";
|
|
2
|
+
import { isTerminalSession } from "../lifecycle.ts";
|
|
3
|
+
import { getGuardianPaths, readState } from "../state.ts";
|
|
4
|
+
import type { GuardianToolInput, GuardianToolResult } from "../types.ts";
|
|
5
|
+
import { isRecordLike } from "../types.ts";
|
|
6
|
+
import { configFromInput, sessionIdFromInput } from "./context.ts";
|
|
7
|
+
import { protectedBranchReason, recordActiveSession } from "./worktree-binding.ts";
|
|
8
|
+
|
|
9
|
+
export async function recordLastSafeState(input: GuardianToolInput = {}): Promise<GuardianToolResult> {
|
|
10
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
|
|
11
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
12
|
+
const config = await configFromInput(input, repoRoot);
|
|
13
|
+
const sessionId = sessionIdFromInput(input);
|
|
14
|
+
if (!sessionId) return { ok: false, reason: "sessionId is required" };
|
|
15
|
+
const guardianPaths = await getGuardianPaths(repoRoot);
|
|
16
|
+
const state = await readState(guardianPaths, { repoRoot, config });
|
|
17
|
+
const rawExisting = state.sessions?.[sessionId];
|
|
18
|
+
const existing = isRecordLike(rawExisting) ? rawExisting : undefined;
|
|
19
|
+
if (existing && isTerminalSession(existing)) return { ok: true, status: "skipped", reason: `session ${sessionId} is terminal (${String(existing.status)})`, session: existing };
|
|
20
|
+
const worktreePath = await getRepoRoot(cwd);
|
|
21
|
+
const branch = await getCurrentBranch(worktreePath);
|
|
22
|
+
if (!branch) return { ok: false, reason: "detached HEAD" };
|
|
23
|
+
const unsafeReason = protectedBranchReason(config, branch);
|
|
24
|
+
if (unsafeReason) return { ok: false, reason: unsafeReason, branch, worktreePath };
|
|
25
|
+
await getHeadCommit(worktreePath);
|
|
26
|
+
return recordActiveSession({ repoRoot, config, sessionId, worktreePath, branch, eventType: "last_safe_state", tool: input.tool });
|
|
27
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { expandWorktreeRoot } from "../config.ts";
|
|
3
|
+
import { getCurrentBranch, getHeadCommit, getRepoRoot, listWorktrees } from "../git.ts";
|
|
4
|
+
import { isTerminalSession } from "../lifecycle.ts";
|
|
5
|
+
import { getGuardianPaths, readState, recordSession } from "../state.ts";
|
|
6
|
+
import type { GuardianConfig, GuardianToolInput, GuardianToolResult, MutableRecord, SessionWorktreeResult, WorktreeEntry } from "../types.ts";
|
|
7
|
+
import { isRecordLike } from "../types.ts";
|
|
8
|
+
import { configFromInput, sessionIdFromInput, stateFromInput, worktreeCache } from "./context.ts";
|
|
9
|
+
|
|
10
|
+
function isSameOrInside(candidate: string, root: string) {
|
|
11
|
+
const relative = path.relative(root, candidate);
|
|
12
|
+
return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function collectKnownWorktreePaths(input: GuardianToolInput = {}): Promise<string[]> {
|
|
16
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
|
|
17
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
18
|
+
const config = await configFromInput(input, repoRoot);
|
|
19
|
+
const paths = new Set<string>();
|
|
20
|
+
if (typeof input.currentWorktree === "string") paths.add(path.resolve(input.currentWorktree));
|
|
21
|
+
try {
|
|
22
|
+
const worktreeRoot = path.resolve(repoRoot, expandWorktreeRoot(config.worktreeRoot, repoRoot));
|
|
23
|
+
paths.add(worktreeRoot);
|
|
24
|
+
} catch {}
|
|
25
|
+
try {
|
|
26
|
+
for (const entry of await listWorktrees(repoRoot)) paths.add(path.resolve(entry.path));
|
|
27
|
+
} catch {}
|
|
28
|
+
try {
|
|
29
|
+
const guardianPaths = await getGuardianPaths(repoRoot);
|
|
30
|
+
const state = await readState(guardianPaths, { repoRoot, config });
|
|
31
|
+
const sessions = Object.values(state.sessions ?? {});
|
|
32
|
+
for (const session of sessions) {
|
|
33
|
+
if (isRecordLike(session) && typeof session.worktree_path === "string") paths.add(path.resolve(session.worktree_path));
|
|
34
|
+
}
|
|
35
|
+
} catch {}
|
|
36
|
+
return [...paths];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchesWorktree(expectedWorktree: string, actualPath: string) {
|
|
40
|
+
return isSameOrInside(path.resolve(actualPath), path.resolve(expectedWorktree));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function samePath(left: string, right: string) {
|
|
44
|
+
return path.resolve(left) === path.resolve(right);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function validateRecordedBinding(repoRoot: string, config: GuardianConfig, session: MutableRecord, actualWorktree: string) {
|
|
48
|
+
const expectedWorktree = session.worktree_path;
|
|
49
|
+
if (typeof expectedWorktree !== "string") return { ok: false, reason: "recorded session has no worktree path" };
|
|
50
|
+
if (!matchesWorktree(expectedWorktree, actualWorktree)) return { ok: false, reason: "session worktree path does not match actual worktree" };
|
|
51
|
+
const entries = await listWorktrees(repoRoot);
|
|
52
|
+
const matches = entries.filter((entry: WorktreeEntry) => samePath(entry.path, expectedWorktree));
|
|
53
|
+
if (matches.length !== 1) return { ok: false, reason: matches.length > 1 ? "recorded worktree path matches multiple git worktrees" : "recorded worktree is not checked out in git worktree list" };
|
|
54
|
+
const entry = matches[0];
|
|
55
|
+
if (!entry) return { ok: false, reason: "recorded worktree is not checked out in git worktree list" };
|
|
56
|
+
if (entry.detached || !entry.branch) return { ok: false, reason: "recorded worktree is detached" };
|
|
57
|
+
if (typeof session.branch === "string" && entry.branch !== session.branch) return { ok: false, reason: "recorded branch does not match checked-out worktree branch" };
|
|
58
|
+
if (Array.isArray(config.protectedBranches) && config.protectedBranches.includes(entry.branch)) return { ok: false, reason: "recorded worktree branch is protected" };
|
|
59
|
+
if (samePath(entry.path, repoRoot)) return { ok: false, reason: "recorded worktree is the primary repository worktree" };
|
|
60
|
+
return { ok: true, branch: entry.branch };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function validateOwnedSession(repoRoot: string, config: GuardianConfig, session: MutableRecord) {
|
|
64
|
+
if (typeof session.worktree_path !== "string") return { ok: false, reason: "recorded session has no worktree path" };
|
|
65
|
+
return validateRecordedBinding(repoRoot, config, session, session.worktree_path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function protectedBranchReason(config: GuardianConfig, branch: string | null) {
|
|
69
|
+
if (!branch) return "detached HEAD cannot be recorded by guardian";
|
|
70
|
+
return Array.isArray(config.protectedBranches) && config.protectedBranches.includes(branch)
|
|
71
|
+
? "protected branches cannot be recorded as Guardian-owned worktrees"
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function resolveSessionWorktree(input: GuardianToolInput = {}): Promise<SessionWorktreeResult> {
|
|
76
|
+
const sessionId = sessionIdFromInput(input);
|
|
77
|
+
if (!sessionId) return { ok: true, sessionId: null, expectedWorktree: null, actualWorktree: null, matches: true };
|
|
78
|
+
|
|
79
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
|
|
80
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
81
|
+
const actualWorktree = typeof input.actualWorktree === "string" ? input.actualWorktree : await getRepoRoot(cwd);
|
|
82
|
+
const cache = worktreeCache(input.cache);
|
|
83
|
+
const validateBinding = input.validateBinding === true;
|
|
84
|
+
const cachedWorktree = cache?.get(sessionId);
|
|
85
|
+
|
|
86
|
+
if (typeof cachedWorktree === "string" && !validateBinding) {
|
|
87
|
+
const matches = matchesWorktree(cachedWorktree, actualWorktree);
|
|
88
|
+
return {
|
|
89
|
+
ok: matches,
|
|
90
|
+
sessionId,
|
|
91
|
+
expectedWorktree: cachedWorktree,
|
|
92
|
+
actualWorktree,
|
|
93
|
+
matches,
|
|
94
|
+
source: "cache",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const config = await configFromInput(input, repoRoot);
|
|
99
|
+
const state = stateFromInput(input.state) ?? await readState(await getGuardianPaths(repoRoot), { repoRoot, config });
|
|
100
|
+
const rawSession = state.sessions?.[sessionId];
|
|
101
|
+
const session = isRecordLike(rawSession) ? rawSession : undefined;
|
|
102
|
+
if (session && isTerminalSession(session)) {
|
|
103
|
+
return { ok: true, sessionId, expectedWorktree: null, actualWorktree, matches: true, source: "terminal-state", terminal: true, status: typeof session.status === "string" ? session.status : undefined };
|
|
104
|
+
}
|
|
105
|
+
const expectedWorktree = typeof session?.worktree_path === "string" ? session.worktree_path : null;
|
|
106
|
+
if (!session || !expectedWorktree) {
|
|
107
|
+
return { ok: true, sessionId, expectedWorktree: null, actualWorktree, matches: true, source: "state" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
cache?.set(sessionId, expectedWorktree);
|
|
111
|
+
const matches = matchesWorktree(expectedWorktree, actualWorktree);
|
|
112
|
+
if (matches && validateBinding) {
|
|
113
|
+
const binding = await validateRecordedBinding(repoRoot, config, session, actualWorktree);
|
|
114
|
+
if (!binding.ok) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: binding.reason,
|
|
118
|
+
sessionId,
|
|
119
|
+
expectedWorktree,
|
|
120
|
+
actualWorktree,
|
|
121
|
+
matches: false,
|
|
122
|
+
source: "state",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: matches,
|
|
128
|
+
sessionId,
|
|
129
|
+
expectedWorktree,
|
|
130
|
+
actualWorktree,
|
|
131
|
+
matches,
|
|
132
|
+
source: "state",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function recordActiveSession(input: {
|
|
137
|
+
readonly repoRoot: string;
|
|
138
|
+
readonly config: GuardianConfig;
|
|
139
|
+
readonly sessionId: string;
|
|
140
|
+
readonly worktreePath: string;
|
|
141
|
+
readonly branch: string;
|
|
142
|
+
readonly eventType: string;
|
|
143
|
+
readonly tool?: unknown;
|
|
144
|
+
}): Promise<GuardianToolResult> {
|
|
145
|
+
const headCommit = await getHeadCommit(input.worktreePath);
|
|
146
|
+
const recordedState = await recordSession(input.repoRoot, input.config, {
|
|
147
|
+
session_id: input.sessionId,
|
|
148
|
+
status: "active",
|
|
149
|
+
branch: input.branch,
|
|
150
|
+
worktree_path: input.worktreePath,
|
|
151
|
+
base_ref: `${input.config.remote}/${input.config.baseBranch}`,
|
|
152
|
+
head_commit: headCommit,
|
|
153
|
+
}, { event: { type: input.eventType, session_id: input.sessionId, tool: input.tool } });
|
|
154
|
+
return { ok: true, session: recordedState.sessions[input.sessionId], stateVersion: recordedState.state_version };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function currentWorktreeBranch(cwd: string) {
|
|
158
|
+
const worktreePath = await getRepoRoot(cwd);
|
|
159
|
+
const branch = await getCurrentBranch(worktreePath);
|
|
160
|
+
return { worktreePath, branch };
|
|
161
|
+
}
|
package/src/start.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { expandWorktreeRoot } from "./config.ts";
|
|
4
|
+
import { getCurrentBranch, getHeadCommit, getRepoRoot, runGit } from "./git.ts";
|
|
5
|
+
import { isTerminalSession } from "./lifecycle.ts";
|
|
6
|
+
import { getGuardianPaths, readState, recordSession } from "./state.ts";
|
|
7
|
+
import type { GuardianSession, GuardianToolInput, GuardianToolResult, MutableRecord } from "./types.ts";
|
|
8
|
+
import { errorCode, isRecordLike } from "./types.ts";
|
|
9
|
+
import { configFromInput, sessionIdFromInput } from "./session/context.ts";
|
|
10
|
+
import { protectedBranchReason, validateOwnedSession } from "./session/worktree-binding.ts";
|
|
11
|
+
|
|
12
|
+
async function safeRealpath(candidate: string) {
|
|
13
|
+
try {
|
|
14
|
+
return await fs.realpath(candidate);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
17
|
+
return path.resolve(candidate);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSameOrInside(candidate: string, root: string) {
|
|
22
|
+
const relative = path.relative(root, candidate);
|
|
23
|
+
return relative === "" || Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type CreateSessionWorktreeResult =
|
|
27
|
+
| { readonly ok: true; readonly branch: string; readonly worktreePath: string }
|
|
28
|
+
| { readonly ok: false; readonly status: "blocked"; readonly reason: string; readonly branch: string };
|
|
29
|
+
|
|
30
|
+
type GuardianStartTypedSession = GuardianSession & {
|
|
31
|
+
readonly session_id: string;
|
|
32
|
+
readonly branch: string;
|
|
33
|
+
readonly worktree_path: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type GuardianStartResult = GuardianToolResult & {
|
|
37
|
+
readonly session: GuardianStartTypedSession;
|
|
38
|
+
readonly previous: MutableRecord & {
|
|
39
|
+
readonly branch: string;
|
|
40
|
+
readonly worktree_path: string;
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
};
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
readonly branch: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
async function resolveWorktreeTarget(repoRoot: string, config: Awaited<ReturnType<typeof configFromInput>>, requestedPath: string | undefined, branchName: string) {
|
|
48
|
+
const root = path.resolve(repoRoot, expandWorktreeRoot(config.worktreeRoot, repoRoot));
|
|
49
|
+
await fs.mkdir(root, { recursive: true });
|
|
50
|
+
const resolvedRoot = await safeRealpath(root);
|
|
51
|
+
const target = requestedPath ? path.resolve(repoRoot, requestedPath) : path.join(resolvedRoot, slug(branchName));
|
|
52
|
+
const parent = path.dirname(target);
|
|
53
|
+
await fs.mkdir(parent, { recursive: true });
|
|
54
|
+
const resolvedParent = await safeRealpath(parent);
|
|
55
|
+
const resolvedTarget = path.join(resolvedParent, path.basename(target));
|
|
56
|
+
if (!isSameOrInside(resolvedTarget, resolvedRoot)) {
|
|
57
|
+
throw new Error(`worktreePath must stay inside configured worktreeRoot: ${config.worktreeRoot}`);
|
|
58
|
+
}
|
|
59
|
+
return resolvedTarget;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function slug(value: unknown) {
|
|
63
|
+
return String(value ?? "work")
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
66
|
+
.replace(/^-+|-+$/g, "")
|
|
67
|
+
.slice(0, 48) || "work";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createSessionWorktree(repoRoot: string, config: Awaited<ReturnType<typeof configFromInput>>, input: GuardianToolInput, sessionId: string): Promise<CreateSessionWorktreeResult> {
|
|
71
|
+
const branchName = typeof input.branch === "string" ? input.branch : `${config.branchPrefix}${slug(input.taskName)}-${slug(sessionId).slice(0, 8)}`;
|
|
72
|
+
const unsafeReason = protectedBranchReason(config, branchName);
|
|
73
|
+
if (unsafeReason) return { ok: false, status: "blocked", reason: unsafeReason, branch: branchName };
|
|
74
|
+
const worktreePath = await resolveWorktreeTarget(repoRoot, config, typeof input.worktreePath === "string" ? input.worktreePath : undefined, branchName);
|
|
75
|
+
await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, `${config.remote}/${config.baseBranch}`]);
|
|
76
|
+
return { ok: true, branch: branchName, worktreePath };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function guardianStart(input?: GuardianToolInput): Promise<GuardianStartResult>;
|
|
80
|
+
export async function guardianStart(input: GuardianToolInput = {}): Promise<GuardianToolResult> {
|
|
81
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : typeof input.repoRoot === "string" ? input.repoRoot : process.cwd();
|
|
82
|
+
const repoRoot = typeof input.repoRoot === "string" ? input.repoRoot : await getRepoRoot(cwd);
|
|
83
|
+
const config = await configFromInput(input, repoRoot);
|
|
84
|
+
const sessionId = sessionIdFromInput(input);
|
|
85
|
+
if (!sessionId) throw new Error("sessionId is required");
|
|
86
|
+
const guardianPaths = await getGuardianPaths(repoRoot);
|
|
87
|
+
const state = await readState(guardianPaths, { repoRoot, config });
|
|
88
|
+
const rawExisting = state.sessions?.[sessionId];
|
|
89
|
+
const existing = isRecordLike(rawExisting) ? rawExisting : undefined;
|
|
90
|
+
if (existing && isTerminalSession(existing)) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
status: "blocked",
|
|
94
|
+
reason: `session ${sessionId} is terminal (${String(existing.status)}); start a new session instead of recreating a deleted or finished worktree`,
|
|
95
|
+
session: existing,
|
|
96
|
+
stateVersion: state.state_version,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (existing?.status === "active" && typeof existing.worktree_path === "string") {
|
|
100
|
+
const binding = await validateOwnedSession(repoRoot, config, existing);
|
|
101
|
+
if (binding.ok) return { ok: true, session: existing, stateVersion: state.state_version, existing: true };
|
|
102
|
+
if (input.createWorktree !== true) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
status: "blocked",
|
|
106
|
+
reason: `recorded session cannot be used: ${binding.reason}; rerun guardian_start with createWorktree=true`,
|
|
107
|
+
session: existing,
|
|
108
|
+
stateVersion: state.state_version,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const previous = { worktree_path: existing.worktree_path, branch: existing.branch, reason: binding.reason };
|
|
112
|
+
const created = await createSessionWorktree(repoRoot, config, input, sessionId);
|
|
113
|
+
if (!created.ok) return { ...created, session: existing, stateVersion: state.state_version, previous };
|
|
114
|
+
const headCommit = await getHeadCommit(created.worktreePath);
|
|
115
|
+
const repairedSession = {
|
|
116
|
+
...existing,
|
|
117
|
+
session_id: sessionId,
|
|
118
|
+
status: "active",
|
|
119
|
+
branch: created.branch,
|
|
120
|
+
worktree_path: created.worktreePath,
|
|
121
|
+
base_ref: `${config.remote}/${config.baseBranch}`,
|
|
122
|
+
head_commit: headCommit,
|
|
123
|
+
safety_refs: Array.isArray(existing.safety_refs) ? existing.safety_refs.filter((value): value is string => typeof value === "string") : [],
|
|
124
|
+
};
|
|
125
|
+
const repairedState = await recordSession(repoRoot, config, repairedSession, { event: { type: "guardian_start_repair", session_id: sessionId, reason: binding.reason } });
|
|
126
|
+
const recordedSession = repairedState.sessions[sessionId];
|
|
127
|
+
if (!recordedSession) throw new Error(`guardian_start repair failed to record session ${sessionId}`);
|
|
128
|
+
return { ok: true, session: recordedSession, stateVersion: repairedState.state_version, existing: false, repaired: true, previous };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let worktreePath = await getRepoRoot(cwd);
|
|
132
|
+
let branch = await getCurrentBranch(worktreePath);
|
|
133
|
+
|
|
134
|
+
if (input.createWorktree === true) {
|
|
135
|
+
const created = await createSessionWorktree(repoRoot, config, input, sessionId);
|
|
136
|
+
if (!created.ok) return { ...created, stateVersion: state.state_version };
|
|
137
|
+
worktreePath = created.worktreePath;
|
|
138
|
+
branch = created.branch;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!branch) throw new Error("Cannot start guardian session from detached HEAD");
|
|
142
|
+
const unsafeReason = input.createWorktree === true ? null : protectedBranchReason(config, branch);
|
|
143
|
+
if (unsafeReason) return { ok: false, status: "blocked", reason: unsafeReason, branch, worktreePath };
|
|
144
|
+
const headCommit = await getHeadCommit(worktreePath);
|
|
145
|
+
const session = {
|
|
146
|
+
session_id: sessionId,
|
|
147
|
+
status: "active",
|
|
148
|
+
branch,
|
|
149
|
+
worktree_path: worktreePath,
|
|
150
|
+
base_ref: `${config.remote}/${config.baseBranch}`,
|
|
151
|
+
head_commit: headCommit,
|
|
152
|
+
safety_refs: [],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const recordedState = await recordSession(repoRoot, config, session, { event: { type: "guardian_start", session_id: sessionId } });
|
|
156
|
+
return { ok: true, session: recordedState.sessions[sessionId], stateVersion: recordedState.state_version };
|
|
157
|
+
}
|