@autopilot-harness/cli 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/LICENSE +21 -0
- package/README.md +55 -0
- package/assets/autopilot-harness-hook.mjs +193 -0
- package/assets/vendor/migrations/001_initial.sql +62 -0
- package/assets/vendor/migrations/002_pending_followup.sql +4 -0
- package/assets/vendor/migrations/003_reviewing_item.sql +1 -0
- package/assets/vendor/runtime.mjs +5524 -0
- package/dist/assets/autopilot-harness-hook.mjs +193 -0
- package/dist/assets/vendor/migrations/001_initial.sql +62 -0
- package/dist/assets/vendor/migrations/002_pending_followup.sql +4 -0
- package/dist/assets/vendor/migrations/003_reviewing_item.sql +1 -0
- package/dist/assets/vendor/runtime.mjs +5524 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +300 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/init/config-merge.d.ts +27 -0
- package/dist/init/config-merge.d.ts.map +1 -0
- package/dist/init/config-merge.js +115 -0
- package/dist/init/config-merge.js.map +1 -0
- package/dist/init/default-config.d.ts +17 -0
- package/dist/init/default-config.d.ts.map +1 -0
- package/dist/init/default-config.js +101 -0
- package/dist/init/default-config.js.map +1 -0
- package/dist/init/hooks-merge.d.ts +25 -0
- package/dist/init/hooks-merge.d.ts.map +1 -0
- package/dist/init/hooks-merge.js +134 -0
- package/dist/init/hooks-merge.js.map +1 -0
- package/dist/init/install.d.ts +31 -0
- package/dist/init/install.d.ts.map +1 -0
- package/dist/init/install.js +829 -0
- package/dist/init/install.js.map +1 -0
- package/dist/init/platforms.d.ts +60 -0
- package/dist/init/platforms.d.ts.map +1 -0
- package/dist/init/platforms.js +275 -0
- package/dist/init/platforms.js.map +1 -0
- package/dist/init/tui.d.ts +65 -0
- package/dist/init/tui.d.ts.map +1 -0
- package/dist/init/tui.js +549 -0
- package/dist/init/tui.js.map +1 -0
- package/dist/init/types.d.ts +75 -0
- package/dist/init/types.d.ts.map +1 -0
- package/dist/init/types.js +9 -0
- package/dist/init/types.js.map +1 -0
- package/dist/init/wizard-copy.d.ts +74 -0
- package/dist/init/wizard-copy.d.ts.map +1 -0
- package/dist/init/wizard-copy.js +173 -0
- package/dist/init/wizard-copy.js.map +1 -0
- package/dist/init/wizard-helpers.d.ts +103 -0
- package/dist/init/wizard-helpers.d.ts.map +1 -0
- package/dist/init/wizard-helpers.js +724 -0
- package/dist/init/wizard-helpers.js.map +1 -0
- package/dist/locale-set.d.ts +24 -0
- package/dist/locale-set.d.ts.map +1 -0
- package/dist/locale-set.js +286 -0
- package/dist/locale-set.js.map +1 -0
- package/dist/names.d.ts +5 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +5 -0
- package/dist/names.js.map +1 -0
- package/dist/project-fs.d.ts +48 -0
- package/dist/project-fs.d.ts.map +1 -0
- package/dist/project-fs.js +210 -0
- package/dist/project-fs.js.map +1 -0
- package/dist/read-untrusted-file.d.ts +41 -0
- package/dist/read-untrusted-file.d.ts.map +1 -0
- package/dist/read-untrusted-file.js +362 -0
- package/dist/read-untrusted-file.js.map +1 -0
- package/dist/session.d.ts +38 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +242 -0
- package/dist/session.js.map +1 -0
- package/dist/status-doctor.d.ts +22 -0
- package/dist/status-doctor.d.ts.map +1 -0
- package/dist/status-doctor.js +712 -0
- package/dist/status-doctor.js.map +1 -0
- package/dist/uninstall.d.ts +31 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/dist/uninstall.js +333 -0
- package/dist/uninstall.js.map +1 -0
- package/dist/upgrade.d.ts +29 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +290 -0
- package/dist/upgrade.js.map +1 -0
- package/dist/vendor-entry.d.ts +6 -0
- package/dist/vendor-entry.d.ts.map +1 -0
- package/dist/vendor-entry.js +16 -0
- package/dist/vendor-entry.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getLatestSchemaVersion, sanitizeSessionDisplayText, StateStore, } from "@autopilot-harness/core";
|
|
5
|
+
import { parseDocument } from "yaml";
|
|
6
|
+
import { formatPlatformsDisplay, parsePlatformBindingsFromConfig, } from "./init/platforms.js";
|
|
7
|
+
import { autopilotStopHasUnlimitedLoop, summarizeAutopilotHooks, validateHooksShape, } from "./init/hooks-merge.js";
|
|
8
|
+
import { PACKAGE_VERSION } from "./init/types.js";
|
|
9
|
+
import { assertNotSymlink, assertRealpathInside, normalizePlansDir } from "./init/wizard-helpers.js";
|
|
10
|
+
import { MAX_UNTRUSTED_TEXT_BYTES, readUntrustedUtf8File, } from "./read-untrusted-file.js";
|
|
11
|
+
import { formatSessionDisplayName, shortSessionId } from "./session.js";
|
|
12
|
+
const VALID_PHASES = new Set([
|
|
13
|
+
"idle",
|
|
14
|
+
"planning",
|
|
15
|
+
"executing",
|
|
16
|
+
"done",
|
|
17
|
+
]);
|
|
18
|
+
const VALID_PAUSED_REASONS = new Set([
|
|
19
|
+
"stuck",
|
|
20
|
+
"repeated_errors",
|
|
21
|
+
"human_gate",
|
|
22
|
+
]);
|
|
23
|
+
const DEFAULT_STALE_HOURS = 72;
|
|
24
|
+
/** Cap absurd values so hours→ms math stays finite. */
|
|
25
|
+
const MAX_STALE_HOURS = 24 * 365 * 100;
|
|
26
|
+
const SKILL_NAMES = [
|
|
27
|
+
"autopilot-on",
|
|
28
|
+
"autopilot-run",
|
|
29
|
+
"autopilot-off",
|
|
30
|
+
"autopilot-resume",
|
|
31
|
+
"autopilot-replan",
|
|
32
|
+
];
|
|
33
|
+
const YAML_TO_JS_OPTS = { maxAliasCount: 64 };
|
|
34
|
+
/** Refuse absurd configs (DoS / accidental paste) — same cap as locale-set. */
|
|
35
|
+
const MAX_CONFIG_BYTES = MAX_UNTRUSTED_TEXT_BYTES;
|
|
36
|
+
/** In-flight marker — never auto-purge. */
|
|
37
|
+
function isProtectedFromPrune(row) {
|
|
38
|
+
// armed executor (incl. corrupt phase); paused gate (human_gate/stuck/errors);
|
|
39
|
+
// pending_action mid-flow (run pick / replan).
|
|
40
|
+
if (row.armed === 1)
|
|
41
|
+
return true;
|
|
42
|
+
if (row.paused === 1)
|
|
43
|
+
return true;
|
|
44
|
+
const pending = row.pending_action;
|
|
45
|
+
return typeof pending === "string" && pending.length > 0;
|
|
46
|
+
}
|
|
47
|
+
function coercePositiveHours(raw) {
|
|
48
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
|
49
|
+
return Math.min(raw, MAX_STALE_HOURS);
|
|
50
|
+
}
|
|
51
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
52
|
+
const n = Number(raw.trim());
|
|
53
|
+
if (Number.isFinite(n) && n > 0)
|
|
54
|
+
return Math.min(n, MAX_STALE_HOURS);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse session.stale_after_hours.
|
|
60
|
+
* - missing → default 72
|
|
61
|
+
* - 0 / "0" / "0.0" → disable stale detection (never stale)
|
|
62
|
+
* - positive number/numeric string → capped hours
|
|
63
|
+
* - other → default + invalid flag
|
|
64
|
+
*/
|
|
65
|
+
function parseStaleAfterHours(raw) {
|
|
66
|
+
if (raw === undefined || raw === null) {
|
|
67
|
+
return { hours: DEFAULT_STALE_HOURS, invalid: false };
|
|
68
|
+
}
|
|
69
|
+
if (typeof raw === "number" && raw === 0) {
|
|
70
|
+
return { hours: 0, invalid: false };
|
|
71
|
+
}
|
|
72
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
73
|
+
const n = Number(raw.trim());
|
|
74
|
+
if (n === 0)
|
|
75
|
+
return { hours: 0, invalid: false };
|
|
76
|
+
}
|
|
77
|
+
const coerced = coercePositiveHours(raw);
|
|
78
|
+
if (coerced != null)
|
|
79
|
+
return { hours: coerced, invalid: false };
|
|
80
|
+
return { hours: DEFAULT_STALE_HOURS, invalid: true };
|
|
81
|
+
}
|
|
82
|
+
function safeDisplayToken(value, fallback = "?") {
|
|
83
|
+
const cleaned = sanitizeSessionDisplayText(value);
|
|
84
|
+
return cleaned || fallback;
|
|
85
|
+
}
|
|
86
|
+
/** pin.json is tiny (version string); refuse absurd blobs before parse. */
|
|
87
|
+
const MAX_PIN_BYTES = 64_000;
|
|
88
|
+
/** True when user-level Cursor hooks still run a legacy global self-review script. */
|
|
89
|
+
export function hasGlobalSelfReviewHooks(homeDir) {
|
|
90
|
+
// homeDir inject được (tests) — chỉ chấp nhận absolute để chặn path lung tung.
|
|
91
|
+
if (typeof homeDir !== "string" || !homeDir || !path.isAbsolute(homeDir)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const hooksPath = path.join(homeDir, ".cursor", "hooks.json");
|
|
95
|
+
try {
|
|
96
|
+
const raw = readUntrustedUtf8File(hooksPath, MAX_CONFIG_BYTES, "~/.cursor/hooks.json");
|
|
97
|
+
return (raw.includes("run-global-self-review") ||
|
|
98
|
+
raw.includes("self-review-on-stop.py"));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function readPinVersion(projectRoot) {
|
|
105
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const pinPath = path.join(path.resolve(projectRoot.trim()), ".autopilot", "pin.json");
|
|
109
|
+
try {
|
|
110
|
+
const raw = readUntrustedUtf8File(pinPath, MAX_PIN_BYTES, ".autopilot/pin.json");
|
|
111
|
+
const pin = JSON.parse(raw);
|
|
112
|
+
return typeof pin["autopilot-harness"] === "string"
|
|
113
|
+
? pin["autopilot-harness"]
|
|
114
|
+
: null;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Read config.yml with symlink refuse + size cap (untrusted project file). */
|
|
121
|
+
function readProjectConfigYaml(configPath) {
|
|
122
|
+
return readUntrustedUtf8File(configPath, MAX_CONFIG_BYTES, ".autopilot/config.yml");
|
|
123
|
+
}
|
|
124
|
+
function isPlainObject(value) {
|
|
125
|
+
return (!!value &&
|
|
126
|
+
typeof value === "object" &&
|
|
127
|
+
!Array.isArray(value) &&
|
|
128
|
+
Object.prototype.toString.call(value) === "[object Object]");
|
|
129
|
+
}
|
|
130
|
+
function parseConfigObject(configYaml) {
|
|
131
|
+
try {
|
|
132
|
+
const doc = parseDocument(configYaml);
|
|
133
|
+
if (doc.errors.length > 0)
|
|
134
|
+
return null;
|
|
135
|
+
const parsed = doc.toJS(YAML_TO_JS_OPTS);
|
|
136
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function readStatusConfig(configYaml) {
|
|
143
|
+
const parsed = parseConfigObject(configYaml);
|
|
144
|
+
if (!parsed) {
|
|
145
|
+
return {
|
|
146
|
+
configOk: false,
|
|
147
|
+
platform: "?",
|
|
148
|
+
platforms: [],
|
|
149
|
+
locale: "?",
|
|
150
|
+
preferredName: "Autopilot",
|
|
151
|
+
plansDir: "plans",
|
|
152
|
+
plansDirError: null,
|
|
153
|
+
staleAfterHours: DEFAULT_STALE_HOURS,
|
|
154
|
+
staleHoursInvalid: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const cli = isPlainObject(parsed.cli) ? parsed.cli : {};
|
|
158
|
+
const artifacts = isPlainObject(parsed.artifacts) ? parsed.artifacts : {};
|
|
159
|
+
const session = isPlainObject(parsed.session) ? parsed.session : {};
|
|
160
|
+
let plansRaw = "plans";
|
|
161
|
+
let plansDirTypeError = null;
|
|
162
|
+
if (Object.prototype.hasOwnProperty.call(artifacts, "plans_dir") &&
|
|
163
|
+
artifacts.plans_dir !== undefined &&
|
|
164
|
+
artifacts.plans_dir !== null) {
|
|
165
|
+
if (typeof artifacts.plans_dir === "string") {
|
|
166
|
+
plansRaw = artifacts.plans_dir;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
plansDirTypeError = "artifacts.plans_dir must be a string";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const plansNorm = normalizePlansDir(plansRaw);
|
|
173
|
+
const staleParsed = parseStaleAfterHours(session.stale_after_hours);
|
|
174
|
+
const preferredRaw = typeof cli.preferred_name === "string" && cli.preferred_name.trim()
|
|
175
|
+
? sanitizeSessionDisplayText(cli.preferred_name)
|
|
176
|
+
: "Autopilot";
|
|
177
|
+
const platforms = parsePlatformBindingsFromConfig(parsed);
|
|
178
|
+
return {
|
|
179
|
+
configOk: true,
|
|
180
|
+
platform: safeDisplayToken(formatPlatformsDisplay(platforms)),
|
|
181
|
+
platforms,
|
|
182
|
+
locale: typeof parsed.locale === "string"
|
|
183
|
+
? safeDisplayToken(parsed.locale)
|
|
184
|
+
: "?",
|
|
185
|
+
preferredName: preferredRaw || "Autopilot",
|
|
186
|
+
plansDir: plansNorm.ok ? plansNorm.value : "plans",
|
|
187
|
+
plansDirError: plansDirTypeError
|
|
188
|
+
? plansDirTypeError
|
|
189
|
+
: plansNorm.ok
|
|
190
|
+
? null
|
|
191
|
+
: plansNorm.error,
|
|
192
|
+
staleAfterHours: staleParsed.hours,
|
|
193
|
+
staleHoursInvalid: staleParsed.invalid,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Read `session.stale_after_hours` (0 = disabled). Missing file → default 72; bad/invalid config → 0. */
|
|
197
|
+
export function readStaleAfterHours(projectRoot) {
|
|
198
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const root = path.resolve(projectRoot.trim());
|
|
202
|
+
const configPath = path.join(root, ".autopilot", "config.yml");
|
|
203
|
+
try {
|
|
204
|
+
const cfg = readStatusConfig(readProjectConfigYaml(configPath));
|
|
205
|
+
if (!cfg.configOk || cfg.staleHoursInvalid)
|
|
206
|
+
return 0;
|
|
207
|
+
return cfg.staleAfterHours;
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const code = err?.code;
|
|
211
|
+
if (code === "ENOENT")
|
|
212
|
+
return DEFAULT_STALE_HOURS;
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function formatPhase(row) {
|
|
217
|
+
const phase = safeDisplayToken(String(row.phase), "?");
|
|
218
|
+
if (row.paused === 1)
|
|
219
|
+
return `${phase} (paused)`;
|
|
220
|
+
return phase;
|
|
221
|
+
}
|
|
222
|
+
function isStaleSession(row, staleAfterHours, nowMs) {
|
|
223
|
+
if (!(staleAfterHours > 0))
|
|
224
|
+
return false;
|
|
225
|
+
const t = Date.parse(row.last_active_at);
|
|
226
|
+
if (Number.isNaN(t))
|
|
227
|
+
return false;
|
|
228
|
+
return nowMs - t > staleAfterHours * 3600 * 1000;
|
|
229
|
+
}
|
|
230
|
+
function openStateStore(projectRoot) {
|
|
231
|
+
const dbPath = path.join(projectRoot, ".autopilot", "state.db");
|
|
232
|
+
try {
|
|
233
|
+
const st = fs.lstatSync(dbPath);
|
|
234
|
+
if (st.isSymbolicLink()) {
|
|
235
|
+
return { ok: false, error: "symlink" };
|
|
236
|
+
}
|
|
237
|
+
if (!st.isFile()) {
|
|
238
|
+
return { ok: false, error: "not a regular file" };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const code = err?.code;
|
|
243
|
+
if (code === "ENOENT") {
|
|
244
|
+
return { ok: false, error: "missing" };
|
|
245
|
+
}
|
|
246
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
247
|
+
return { ok: false, error: safeDisplayToken(msg, "error") };
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
assertNotSymlink(path.join(projectRoot, ".autopilot"), ".autopilot/");
|
|
251
|
+
assertNotSymlink(dbPath, ".autopilot/state.db");
|
|
252
|
+
return { ok: true, store: new StateStore(projectRoot) };
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
256
|
+
return { ok: false, error: safeDisplayToken(msg, "error") };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
export function formatStatus(projectRoot) {
|
|
260
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
261
|
+
return "Autopilot status: projectRoot must be a non-empty string";
|
|
262
|
+
}
|
|
263
|
+
const root = path.resolve(projectRoot.trim());
|
|
264
|
+
const configPath = path.join(root, ".autopilot", "config.yml");
|
|
265
|
+
let cfg;
|
|
266
|
+
try {
|
|
267
|
+
cfg = readStatusConfig(readProjectConfigYaml(configPath));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
const code = err?.code;
|
|
271
|
+
if (code === "ENOENT") {
|
|
272
|
+
return "Autopilot status: not initialized (no .autopilot/config.yml)";
|
|
273
|
+
}
|
|
274
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
275
|
+
return `Autopilot status: cannot read config.yml (${safeDisplayToken(msg, "error")})`;
|
|
276
|
+
}
|
|
277
|
+
const pin = safeDisplayToken(readPinVersion(root) ?? "unknown");
|
|
278
|
+
const lines = [
|
|
279
|
+
`${cfg.preferredName} status`,
|
|
280
|
+
` project: ${root}`,
|
|
281
|
+
` pin: autopilot-harness@${pin}`,
|
|
282
|
+
` platforms: ${cfg.platform}`,
|
|
283
|
+
` locale: ${cfg.locale}`,
|
|
284
|
+
cfg.plansDirError
|
|
285
|
+
? ` plans: invalid (${cfg.plansDirError})`
|
|
286
|
+
: ` plans: ${cfg.plansDir}`,
|
|
287
|
+
];
|
|
288
|
+
if (!cfg.configOk) {
|
|
289
|
+
lines.push(" config: invalid YAML (showing defaults)");
|
|
290
|
+
}
|
|
291
|
+
const opened = openStateStore(root);
|
|
292
|
+
if (!opened.ok) {
|
|
293
|
+
if (opened.error === "missing") {
|
|
294
|
+
lines.push(" state: no state.db yet");
|
|
295
|
+
lines.push(" sessions: 0");
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
lines.push(` state: cannot open (${opened.error})`);
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n");
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const rows = opened.store.listSessions();
|
|
304
|
+
lines.push(` state: state.db ok (schema ${safeDisplayToken(String(opened.store.getSchemaVersion()))})`);
|
|
305
|
+
lines.push(` sessions: ${rows.length}`);
|
|
306
|
+
if (rows.length > 0) {
|
|
307
|
+
const latest = rows[0];
|
|
308
|
+
lines.push(` latest: ${shortSessionId(latest.conversation_id)} ${formatSessionDisplayName(latest)}`);
|
|
309
|
+
lines.push(` phase: ${formatPhase(latest)}${latest.armed === 1 ? " · armed" : ""}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
314
|
+
lines.push(` state: error (${safeDisplayToken(msg, "error")})`);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
opened.store.close();
|
|
318
|
+
}
|
|
319
|
+
return lines.join("\n");
|
|
320
|
+
}
|
|
321
|
+
export function runDoctor(projectRoot, opts = {}) {
|
|
322
|
+
if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
lines: ["FAIL projectRoot must be a non-empty string"],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const root = path.resolve(projectRoot.trim());
|
|
329
|
+
const lines = [];
|
|
330
|
+
let ok = true;
|
|
331
|
+
let pruned;
|
|
332
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
333
|
+
const packageVersion = opts.packageVersion ?? PACKAGE_VERSION;
|
|
334
|
+
const configPath = path.join(root, ".autopilot", "config.yml");
|
|
335
|
+
let cfg;
|
|
336
|
+
try {
|
|
337
|
+
cfg = readStatusConfig(readProjectConfigYaml(configPath));
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
const code = err?.code;
|
|
341
|
+
if (code === "ENOENT") {
|
|
342
|
+
lines.push("FAIL .autopilot/config.yml missing — run init");
|
|
343
|
+
return { ok: false, lines };
|
|
344
|
+
}
|
|
345
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
346
|
+
lines.push(`FAIL .autopilot/config.yml unreadable (${safeDisplayToken(msg, "error")})`);
|
|
347
|
+
return { ok: false, lines };
|
|
348
|
+
}
|
|
349
|
+
if (!cfg.configOk) {
|
|
350
|
+
lines.push("FAIL config.yml unreadable or invalid YAML");
|
|
351
|
+
ok = false;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
lines.push("OK config.yml");
|
|
355
|
+
}
|
|
356
|
+
const ignorePath = path.join(root, ".autopilotignore");
|
|
357
|
+
try {
|
|
358
|
+
assertNotSymlink(ignorePath, ".autopilotignore");
|
|
359
|
+
const st = fs.lstatSync(ignorePath);
|
|
360
|
+
if (!st.isFile()) {
|
|
361
|
+
lines.push("WARN .autopilotignore is not a regular file — using built-in defaults");
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
lines.push("OK .autopilotignore");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
const code = err?.code;
|
|
369
|
+
if (code === "ENOENT") {
|
|
370
|
+
lines.push("WARN .autopilotignore missing — using built-in defaults; run upgrade to add");
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
+
lines.push(`WARN .autopilotignore unreadable (${safeDisplayToken(msg, "error")}) — using built-in defaults`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (cfg.staleHoursInvalid) {
|
|
378
|
+
// Fail closed: do not claim we "use default" while --prune-stale refuses.
|
|
379
|
+
lines.push("FAIL session.stale_after_hours invalid — fix or remove the key");
|
|
380
|
+
ok = false;
|
|
381
|
+
}
|
|
382
|
+
const pin = readPinVersion(root);
|
|
383
|
+
if (!pin) {
|
|
384
|
+
lines.push("FAIL pin.json missing or invalid");
|
|
385
|
+
ok = false;
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
const pinShown = safeDisplayToken(pin);
|
|
389
|
+
lines.push(`OK pin.json → ${pinShown}`);
|
|
390
|
+
if (pin !== packageVersion) {
|
|
391
|
+
lines.push(`WARN pin ${pinShown} ≠ package ${safeDisplayToken(packageVersion)} — consider upgrade`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const binDir = path.join(root, ".autopilot", "bin");
|
|
395
|
+
const hook = path.join(binDir, "autopilot-harness-hook.mjs");
|
|
396
|
+
let binTrusted = true;
|
|
397
|
+
try {
|
|
398
|
+
assertNotSymlink(binDir, ".autopilot/bin/");
|
|
399
|
+
try {
|
|
400
|
+
const binSt = fs.lstatSync(binDir);
|
|
401
|
+
if (binSt.isDirectory() && !binSt.isSymbolicLink()) {
|
|
402
|
+
assertRealpathInside(root, binDir, ".autopilot/bin/");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
const code = err?.code;
|
|
407
|
+
if (code !== "ENOENT")
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
binTrusted = false;
|
|
413
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
414
|
+
lines.push(`FAIL hook bin — ${safeDisplayToken(msg, "unreadable")}`);
|
|
415
|
+
ok = false;
|
|
416
|
+
}
|
|
417
|
+
if (!binTrusted) {
|
|
418
|
+
// bin path already FAILed above
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
try {
|
|
422
|
+
// lstat/assert — existsSync misses dangling hook symlinks.
|
|
423
|
+
assertNotSymlink(hook, ".autopilot/bin/autopilot-harness-hook.mjs");
|
|
424
|
+
const st = fs.lstatSync(hook);
|
|
425
|
+
if (!st.isFile()) {
|
|
426
|
+
lines.push("FAIL hook binary is not a regular file");
|
|
427
|
+
ok = false;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
lines.push("OK autopilot-harness-hook.mjs");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
const code = err?.code;
|
|
435
|
+
if (code === "ENOENT") {
|
|
436
|
+
lines.push("FAIL hook binary missing");
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
440
|
+
lines.push(`FAIL hook binary — ${safeDisplayToken(msg, "unreadable")}`);
|
|
441
|
+
}
|
|
442
|
+
ok = false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const vendorDir = path.join(binDir, "vendor");
|
|
446
|
+
const vendorRuntime = path.join(vendorDir, "runtime.mjs");
|
|
447
|
+
const vendorMigDir = path.join(vendorDir, "migrations");
|
|
448
|
+
const vendorMig = path.join(vendorMigDir, "001_initial.sql");
|
|
449
|
+
try {
|
|
450
|
+
assertNotSymlink(vendorDir, ".autopilot/bin/vendor/");
|
|
451
|
+
assertNotSymlink(vendorRuntime, ".autopilot/bin/vendor/runtime.mjs");
|
|
452
|
+
assertNotSymlink(vendorMigDir, ".autopilot/bin/vendor/migrations/");
|
|
453
|
+
assertNotSymlink(vendorMig, ".autopilot/bin/vendor/migrations/001_initial.sql");
|
|
454
|
+
try {
|
|
455
|
+
const vendSt = fs.lstatSync(vendorDir);
|
|
456
|
+
if (vendSt.isDirectory() && !vendSt.isSymbolicLink()) {
|
|
457
|
+
assertRealpathInside(root, vendorDir, ".autopilot/bin/vendor/");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
const code = err?.code;
|
|
462
|
+
if (code !== "ENOENT")
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
// lstat isFile: existsSync follows pointing symlinks and lies on dangling.
|
|
466
|
+
let vendorPresent = false;
|
|
467
|
+
try {
|
|
468
|
+
const rt = fs.lstatSync(vendorRuntime);
|
|
469
|
+
const mig = fs.lstatSync(vendorMig);
|
|
470
|
+
if (rt.isSymbolicLink() || mig.isSymbolicLink()) {
|
|
471
|
+
throw new Error("hook vendor path is a symlink; refusing to open");
|
|
472
|
+
}
|
|
473
|
+
vendorPresent = rt.isFile() && mig.isFile();
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
const code = err?.code;
|
|
477
|
+
if (code !== "ENOENT")
|
|
478
|
+
throw err;
|
|
479
|
+
vendorPresent = false;
|
|
480
|
+
}
|
|
481
|
+
if (!vendorPresent) {
|
|
482
|
+
lines.push("FAIL hook vendor runtime missing — run upgrade (or init --force)");
|
|
483
|
+
ok = false;
|
|
484
|
+
}
|
|
485
|
+
else if (binTrusted) {
|
|
486
|
+
lines.push("OK hook vendor runtime");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
491
|
+
lines.push(`FAIL hook vendor — ${safeDisplayToken(msg, "unreadable")}`);
|
|
492
|
+
ok = false;
|
|
493
|
+
}
|
|
494
|
+
const hooksPath = path.join(root, ".cursor", "hooks.json");
|
|
495
|
+
try {
|
|
496
|
+
// Avoid existsSync: dangling symlinks look missing but must FAIL as unreadable.
|
|
497
|
+
const raw = readUntrustedUtf8File(hooksPath, MAX_CONFIG_BYTES, ".cursor/hooks.json");
|
|
498
|
+
const parsed = JSON.parse(raw);
|
|
499
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
500
|
+
lines.push("FAIL hooks.json is not a JSON object");
|
|
501
|
+
ok = false;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const hooks = parsed;
|
|
505
|
+
const shapeError = validateHooksShape(hooks.hooks ? hooks : { version: 1, hooks: {} });
|
|
506
|
+
if (shapeError) {
|
|
507
|
+
lines.push(`FAIL ${safeDisplayToken(shapeError, "invalid hooks.json")}`);
|
|
508
|
+
ok = false;
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
const { missingEvents, duplicates } = summarizeAutopilotHooks(hooks);
|
|
512
|
+
if (missingEvents.length > 0) {
|
|
513
|
+
lines.push(`FAIL hooks.json missing Autopilot for: ${missingEvents.join(", ")} — run init --force`);
|
|
514
|
+
ok = false;
|
|
515
|
+
}
|
|
516
|
+
if (duplicates > 0) {
|
|
517
|
+
lines.push(`WARN hooks.json has ${duplicates} duplicate Autopilot entr(y/ies)`);
|
|
518
|
+
}
|
|
519
|
+
// Warn even when other events are missing/duplicated — stop can still
|
|
520
|
+
// be present without loop_limit:null and get Cursor-capped mid-chain.
|
|
521
|
+
if (!missingEvents.includes("stop") &&
|
|
522
|
+
!autopilotStopHasUnlimitedLoop(hooks)) {
|
|
523
|
+
lines.push("WARN Autopilot stop missing loop_limit:null — Cursor defaults to 5 and may skip mid review chain; run upgrade");
|
|
524
|
+
}
|
|
525
|
+
if (missingEvents.length === 0 &&
|
|
526
|
+
duplicates === 0 &&
|
|
527
|
+
autopilotStopHasUnlimitedLoop(hooks)) {
|
|
528
|
+
lines.push("OK hooks.json Autopilot entries");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
const code = err?.code;
|
|
535
|
+
if (code === "ENOENT") {
|
|
536
|
+
lines.push("FAIL .cursor/hooks.json missing");
|
|
537
|
+
ok = false;
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
541
|
+
lines.push(`FAIL hooks.json unreadable (${safeDisplayToken(msg, "error")})`);
|
|
542
|
+
ok = false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
546
|
+
if (hasGlobalSelfReviewHooks(homeDir)) {
|
|
547
|
+
lines.push("WARN ~/.cursor global self-review hooks detected — may double-inject with Autopilot; disable run-global-self-review or rely on Autopilot alone");
|
|
548
|
+
}
|
|
549
|
+
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
|
550
|
+
if (nodeMajor < 22) {
|
|
551
|
+
lines.push(`WARN Node ${process.versions.node} — recommend >=22 (node:sqlite)`);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
lines.push(`OK Node ${process.versions.node}`);
|
|
555
|
+
}
|
|
556
|
+
const plansRoot = path.join(root, cfg.plansDir);
|
|
557
|
+
if (cfg.plansDirError) {
|
|
558
|
+
lines.push(`FAIL artifacts.plans_dir invalid: ${cfg.plansDirError}`);
|
|
559
|
+
ok = false;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
try {
|
|
563
|
+
const st = fs.lstatSync(plansRoot);
|
|
564
|
+
if (st.isSymbolicLink()) {
|
|
565
|
+
lines.push(`FAIL plans path is a symlink (${cfg.plansDir})`);
|
|
566
|
+
ok = false;
|
|
567
|
+
}
|
|
568
|
+
else if (!st.isDirectory()) {
|
|
569
|
+
lines.push(`FAIL plans path is not a directory (${cfg.plansDir})`);
|
|
570
|
+
ok = false;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
assertRealpathInside(root, plansRoot, `plans (${cfg.plansDir})`);
|
|
574
|
+
lines.push(`OK plans (${cfg.plansDir}/)`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const code = err?.code;
|
|
579
|
+
if (code === "ENOENT") {
|
|
580
|
+
lines.push(`WARN plans dir missing (${cfg.plansDir}/) — run init or mkdir`);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
584
|
+
lines.push(`FAIL plans path unreadable (${cfg.plansDir}): ${safeDisplayToken(msg, "error")}`);
|
|
585
|
+
ok = false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
let missingSkills = 0;
|
|
590
|
+
for (const name of SKILL_NAMES) {
|
|
591
|
+
const skillPath = path.join(root, ".cursor", "skills", name, "SKILL.md");
|
|
592
|
+
try {
|
|
593
|
+
const st = fs.lstatSync(skillPath);
|
|
594
|
+
// existsSync follows pointing symlinks / lies on dangling — require a real file.
|
|
595
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
596
|
+
missingSkills += 1;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// Skill dir itself may be a symlink escape; realpath must stay in-project.
|
|
600
|
+
assertRealpathInside(root, skillPath, `.cursor/skills/${name}/SKILL.md`);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
missingSkills += 1;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (missingSkills > 0) {
|
|
607
|
+
lines.push(`WARN ${missingSkills} skill(s) missing under .cursor/skills/ — run upgrade`);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
lines.push("OK skills (5)");
|
|
611
|
+
}
|
|
612
|
+
const opened = openStateStore(root);
|
|
613
|
+
if (!opened.ok) {
|
|
614
|
+
if (opened.error === "missing") {
|
|
615
|
+
lines.push("OK state.db (not created yet)");
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
lines.push(`FAIL state.db — ${opened.error}`);
|
|
619
|
+
ok = false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
const store = opened.store;
|
|
624
|
+
try {
|
|
625
|
+
const version = store.getSchemaVersion();
|
|
626
|
+
const latest = getLatestSchemaVersion();
|
|
627
|
+
const schemaOk = version === latest;
|
|
628
|
+
if (!schemaOk) {
|
|
629
|
+
lines.push(`FAIL schema_version=${safeDisplayToken(String(version))} (package expects ${safeDisplayToken(String(latest))}) — run upgrade`);
|
|
630
|
+
ok = false;
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
lines.push(`OK state.db schema_version=${safeDisplayToken(String(version))}`);
|
|
634
|
+
}
|
|
635
|
+
const rows = store.listSessions();
|
|
636
|
+
const orphans = [];
|
|
637
|
+
for (const row of rows) {
|
|
638
|
+
if (!VALID_PHASES.has(row.phase)) {
|
|
639
|
+
orphans.push(`${shortSessionId(row.conversation_id)} unknown phase "${safeDisplayToken(String(row.phase), "?")}"`);
|
|
640
|
+
}
|
|
641
|
+
if (row.paused_reason != null &&
|
|
642
|
+
row.paused_reason !== "" &&
|
|
643
|
+
!VALID_PAUSED_REASONS.has(row.paused_reason)) {
|
|
644
|
+
orphans.push(`${shortSessionId(row.conversation_id)} unknown paused_reason "${safeDisplayToken(String(row.paused_reason), "?")}"`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (orphans.length > 0) {
|
|
648
|
+
ok = false;
|
|
649
|
+
for (const msg of orphans.slice(0, 5)) {
|
|
650
|
+
lines.push(`FAIL orphan state: ${msg} — reset-review or purge`);
|
|
651
|
+
}
|
|
652
|
+
if (orphans.length > 5) {
|
|
653
|
+
lines.push(`FAIL …and ${orphans.length - 5} more orphan row(s)`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const stale = rows.filter((r) => isStaleSession(r, cfg.staleAfterHours, nowMs));
|
|
657
|
+
// Broken config / unknown phase must not drive destructive prune.
|
|
658
|
+
if (cfg.configOk && !cfg.staleHoursInvalid && stale.length > 0) {
|
|
659
|
+
if (opts.pruneStale) {
|
|
660
|
+
if (orphans.length > 0) {
|
|
661
|
+
lines.push(`FAIL refusing --prune-stale until orphan state is resolved (${stale.length} stale left)`);
|
|
662
|
+
ok = false;
|
|
663
|
+
}
|
|
664
|
+
else if (!schemaOk) {
|
|
665
|
+
lines.push(`FAIL refusing --prune-stale until schema is migrated (${stale.length} stale left)`);
|
|
666
|
+
ok = false;
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
const protectedRows = stale.filter(isProtectedFromPrune);
|
|
670
|
+
const candidates = stale.filter((r) => !isProtectedFromPrune(r));
|
|
671
|
+
if (protectedRows.length > 0) {
|
|
672
|
+
lines.push(`WARN skipped ${protectedRows.length} in-flight session(s) (armed/paused/pending_action)`);
|
|
673
|
+
}
|
|
674
|
+
if (candidates.length === 0) {
|
|
675
|
+
lines.push(`WARN ${stale.length} stale session(s) protected — none pruned`);
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
let count = 0;
|
|
679
|
+
for (const row of candidates) {
|
|
680
|
+
// Re-check stale/protection inside purge txn (list→delete TOCTOU).
|
|
681
|
+
if (store.purgeSession(row.conversation_id, (fresh) => !isProtectedFromPrune(fresh) &&
|
|
682
|
+
isStaleSession(fresh, cfg.staleAfterHours, nowMs))) {
|
|
683
|
+
count += 1;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (count > 0) {
|
|
687
|
+
pruned = count;
|
|
688
|
+
lines.push(`OK pruned ${count} stale session(s) (>${cfg.staleAfterHours}h)`);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
lines.push(`WARN no eligible stale sessions pruned (in-flight, changed, or already gone)`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
lines.push(`WARN ${stale.length} stale session(s) (>${cfg.staleAfterHours}h) — session purge <id> or doctor --prune-stale`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
703
|
+
lines.push(`FAIL state.db — ${safeDisplayToken(msg, "error")}`);
|
|
704
|
+
ok = false;
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
store.close();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return pruned === undefined ? { ok, lines } : { ok, lines, pruned };
|
|
711
|
+
}
|
|
712
|
+
//# sourceMappingURL=status-doctor.js.map
|