ghostrail 0.18.0 → 0.20.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/README.md +29 -0
- package/dist/agent/claude-code.d.ts +18 -1
- package/dist/agent/claude-code.d.ts.map +1 -1
- package/dist/agent/claude-code.js +36 -4
- package/dist/agent/claude-code.js.map +1 -1
- package/dist/board/page.js +1 -1
- package/dist/cli/commands.d.ts +12 -7
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +261 -43
- package/dist/cli/commands.js.map +1 -1
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +5 -3
- package/dist/commands/help.js.map +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +20 -3
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +57 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +19 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dashboard/index.d.ts +3 -2
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -2
- package/dist/dashboard/index.js.map +1 -1
- package/dist/dashboard/page.d.ts.map +1 -1
- package/dist/dashboard/page.js +39 -3
- package/dist/dashboard/page.js.map +1 -1
- package/dist/dashboard/review-page.d.ts +14 -0
- package/dist/dashboard/review-page.d.ts.map +1 -0
- package/dist/dashboard/review-page.js +256 -0
- package/dist/dashboard/review-page.js.map +1 -0
- package/dist/dashboard/routes.d.ts +18 -0
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +24 -0
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/dashboard/server.d.ts +19 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +71 -1
- package/dist/dashboard/server.js.map +1 -1
- package/dist/doctor/checks.d.ts +21 -0
- package/dist/doctor/checks.d.ts.map +1 -1
- package/dist/doctor/checks.js +44 -0
- package/dist/doctor/checks.js.map +1 -1
- package/dist/doctor/probes.d.ts +15 -0
- package/dist/doctor/probes.d.ts.map +1 -1
- package/dist/doctor/probes.js +33 -0
- package/dist/doctor/probes.js.map +1 -1
- package/dist/doctor/run.d.ts +2 -1
- package/dist/doctor/run.d.ts.map +1 -1
- package/dist/doctor/run.js +25 -2
- package/dist/doctor/run.js.map +1 -1
- package/dist/factory/build.d.ts +6 -0
- package/dist/factory/build.d.ts.map +1 -1
- package/dist/factory/build.js +17 -5
- package/dist/factory/build.js.map +1 -1
- package/dist/global/tempdir.d.ts +19 -0
- package/dist/global/tempdir.d.ts.map +1 -0
- package/dist/global/tempdir.js +47 -0
- package/dist/global/tempdir.js.map +1 -0
- package/dist/init/greenroom.d.ts +18 -0
- package/dist/init/greenroom.d.ts.map +1 -0
- package/dist/init/greenroom.js +24 -0
- package/dist/init/greenroom.js.map +1 -0
- package/dist/loop/engine.d.ts +9 -0
- package/dist/loop/engine.d.ts.map +1 -1
- package/dist/loop/engine.js +114 -7
- package/dist/loop/engine.js.map +1 -1
- package/dist/loop/index.d.ts +1 -0
- package/dist/loop/index.d.ts.map +1 -1
- package/dist/loop/index.js +1 -0
- package/dist/loop/index.js.map +1 -1
- package/dist/loop/ports.d.ts +7 -0
- package/dist/loop/ports.d.ts.map +1 -1
- package/dist/loop/record.d.ts +6 -1
- package/dist/loop/record.d.ts.map +1 -1
- package/dist/loop/retry.d.ts +50 -0
- package/dist/loop/retry.d.ts.map +1 -0
- package/dist/loop/retry.js +77 -0
- package/dist/loop/retry.js.map +1 -0
- package/dist/loop/schedule.d.ts +33 -0
- package/dist/loop/schedule.d.ts.map +1 -0
- package/dist/loop/schedule.js +28 -0
- package/dist/loop/schedule.js.map +1 -0
- package/dist/respond/authors.d.ts +38 -0
- package/dist/respond/authors.d.ts.map +1 -0
- package/dist/respond/authors.js +50 -0
- package/dist/respond/authors.js.map +1 -0
- package/dist/respond/merge.d.ts.map +1 -1
- package/dist/respond/merge.js +25 -19
- package/dist/respond/merge.js.map +1 -1
- package/dist/respond/respond.d.ts +7 -0
- package/dist/respond/respond.d.ts.map +1 -1
- package/dist/respond/respond.js +36 -16
- package/dist/respond/respond.js.map +1 -1
- package/dist/respond/run-id.d.ts +17 -0
- package/dist/respond/run-id.d.ts.map +1 -0
- package/dist/respond/run-id.js +23 -0
- package/dist/respond/run-id.js.map +1 -0
- package/dist/respond/run-row.d.ts +24 -0
- package/dist/respond/run-row.d.ts.map +1 -0
- package/dist/respond/run-row.js +28 -0
- package/dist/respond/run-row.js.map +1 -0
- package/dist/store/events.d.ts +12 -0
- package/dist/store/events.d.ts.map +1 -1
- package/dist/store/events.js +10 -3
- package/dist/store/events.js.map +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js.map +1 -1
- package/dist/triage/engine.d.ts +14 -0
- package/dist/triage/engine.d.ts.map +1 -1
- package/dist/triage/engine.js +73 -3
- package/dist/triage/engine.js.map +1 -1
- package/package.json +1 -1
- package/templates/code/ghostrail.toml +4 -0
- package/templates/content/ghostrail.toml +4 -0
package/dist/cli/commands.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
@@ -10,24 +10,28 @@ import { createBoardServer, isLoopback, normalizeToken } from "../board/server.j
|
|
|
10
10
|
import { DEFAULT_CLAUDE_PINS, buildProposal, writeProfile } from "../claude-profile/index.js";
|
|
11
11
|
import { parseDuration } from "../config/duration.js";
|
|
12
12
|
import { parseConfig, parseMergedConfig } from "../config/load.js";
|
|
13
|
-
import { createDashboardServer, defaultStateDir } from "../dashboard/index.js";
|
|
13
|
+
import { MERGE_TRIGGER_COMMENT, createDashboardServer, defaultStateDir, } from "../dashboard/index.js";
|
|
14
14
|
import { hasFailure, renderDoctorJson, renderDoctorText, runDoctor, } from "../doctor/index.js";
|
|
15
15
|
import { buildLoopDeps, buildRespondDeps, buildTriageDeps, repoGhRunner, resolveRepoSlug, } from "../factory/build.js";
|
|
16
16
|
import { describeMigration, migrateLegacyStore, resolveWorkRepo, stateDirFor, } from "../factory/state.js";
|
|
17
17
|
import { entryKey, findByPath, findForRepoResolving, replaceEntry, upsert, } from "../global/registry.js";
|
|
18
|
-
import { canonicalRepoId, localRepoId } from "../global/repo-id.js";
|
|
18
|
+
import { canonicalRepoId, localRepoId, repoKey } from "../global/repo-id.js";
|
|
19
19
|
import { GlobalStore, resolveGlobalDir } from "../global/store.js";
|
|
20
|
+
import { isTempPath } from "../global/tempdir.js";
|
|
20
21
|
import { DEFAULT_FACTORY_IMAGE, dockerBuildArgs, dockerDir, parseImageFlags, } from "../image/build.js";
|
|
21
22
|
import { extractPlaceholders, isClean, missingSignals, repoPathFor, } from "../init/drift.js";
|
|
23
|
+
import { GREENROOM_POINTER_FILE, parseGreenroomPointer, resolveGreenroomDir, } from "../init/greenroom.js";
|
|
22
24
|
import { mergeRegions, parseRegions, regionHashes } from "../init/regions.js";
|
|
23
|
-
import { classify, hashContent, mergeManifest, readManifest, writeManifest, } from "../init/template.js";
|
|
25
|
+
import { MANIFEST_FILE, classify, hashContent, mergeManifest, readManifest, writeManifest, } from "../init/template.js";
|
|
24
26
|
import { FileLock } from "../lock/file-lock.js";
|
|
25
27
|
import { concurrencyAdvice } from "../loop/advice.js";
|
|
26
28
|
import { fileControl } from "../loop/control.js";
|
|
27
29
|
import { readControl } from "../loop/control.js";
|
|
28
30
|
import { runTick } from "../loop/engine.js";
|
|
31
|
+
import { duePasses, } from "../loop/schedule.js";
|
|
29
32
|
import { watchLoop } from "../loop/watch.js";
|
|
30
33
|
import { GhHost, parseRepoSlug } from "../publish/gh.js";
|
|
34
|
+
import { resolveIgnoredAuthors } from "../respond/authors.js";
|
|
31
35
|
import { respondTick } from "../respond/respond.js";
|
|
32
36
|
import { GLOBAL_SECRETS_FILE, appendPlaceholders, applyOverrides, launchEditor as defaultLaunchEditor, loadEnvOverrides, missingSecrets, readSecretsFile, redactSecrets, renderFile, resolveEditor, secretValuesOf, writeSecretsFile, } from "../secrets/index.js";
|
|
33
37
|
import { DASHBOARD_UNIT, renderDashboardUnit, servicePath, stopTimeoutSeconds, systemdManager, unitName, } from "../service/index.js";
|
|
@@ -64,12 +68,49 @@ function makeLogger(streams, secrets) {
|
|
|
64
68
|
warn: (message) => streams.err(`${redactSecrets(message, secrets)}\n`),
|
|
65
69
|
};
|
|
66
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The effective `ghostrail.toml` path for a repo: the repo's own file if it
|
|
73
|
+
* has one, else wherever its `.ghostrail-config` pointer file names, else
|
|
74
|
+
* undefined. Used for status/metadata reads that only ever look at the
|
|
75
|
+
* repo's default config, never an explicit `--config`.
|
|
76
|
+
*/
|
|
77
|
+
async function resolveConfigPath(repo) {
|
|
78
|
+
const local = join(repo, "ghostrail.toml");
|
|
79
|
+
if (await pathExists(local))
|
|
80
|
+
return local;
|
|
81
|
+
const pointerRaw = await readIfPresent(join(repo, GREENROOM_POINTER_FILE));
|
|
82
|
+
if (pointerRaw === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
const pointer = parseGreenroomPointer(pointerRaw);
|
|
85
|
+
if (pointer === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const external = join(resolveGreenroomDir(repo, pointer), "ghostrail.toml");
|
|
88
|
+
return (await pathExists(external)) ? external : undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The config path `flags` name: an explicit `--config` wins outright, and a
|
|
92
|
+
* relative default that resolves inside the repo is used as-is (the common
|
|
93
|
+
* case). Only the untouched default (`ghostrail.toml`, absent from the repo)
|
|
94
|
+
* falls through to `resolveConfigPath`'s `.ghostrail-config` lookup, so an
|
|
95
|
+
* explicit but wrong `--config` still fails exactly as it did before.
|
|
96
|
+
*/
|
|
97
|
+
async function configPathForFlags(flags) {
|
|
98
|
+
if (isAbsolute(flags.config))
|
|
99
|
+
return flags.config;
|
|
100
|
+
const local = join(flags.repo, flags.config);
|
|
101
|
+
if (flags.config === "ghostrail.toml" && !(await pathExists(local))) {
|
|
102
|
+
const resolved = await resolveConfigPath(flags.repo);
|
|
103
|
+
if (resolved !== undefined)
|
|
104
|
+
return resolved;
|
|
105
|
+
}
|
|
106
|
+
return local;
|
|
107
|
+
}
|
|
67
108
|
/**
|
|
68
109
|
* Load the repo config layered over the global account settings, and resolve the
|
|
69
110
|
* global store. Prints errors and returns undefined on failure.
|
|
70
111
|
*/
|
|
71
112
|
async function loadConfig(flags, streams, env) {
|
|
72
|
-
const configPath =
|
|
113
|
+
const configPath = await configPathForFlags(flags);
|
|
73
114
|
let repoToml;
|
|
74
115
|
try {
|
|
75
116
|
repoToml = await readFile(configPath, "utf8");
|
|
@@ -87,7 +128,16 @@ async function loadConfig(flags, streams, env) {
|
|
|
87
128
|
}
|
|
88
129
|
return undefined;
|
|
89
130
|
}
|
|
90
|
-
|
|
131
|
+
// Only set when the config actually used lives outside the repo: the
|
|
132
|
+
// default (in-repo) case must read the agent's prompt from the provisioned
|
|
133
|
+
// workspace exactly as it always has.
|
|
134
|
+
const configDir = resolve(dirname(configPath));
|
|
135
|
+
const promptSourceDir = configDir === resolve(flags.repo) ? undefined : configDir;
|
|
136
|
+
return {
|
|
137
|
+
config: parsed.value,
|
|
138
|
+
globalStore,
|
|
139
|
+
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
140
|
+
};
|
|
91
141
|
}
|
|
92
142
|
/**
|
|
93
143
|
* Load the global + repo secret files and apply them over `env` in place, with
|
|
@@ -120,7 +170,7 @@ async function prepareLoop(flags, streams, env) {
|
|
|
120
170
|
const loaded = await loadConfig(flags, streams, env);
|
|
121
171
|
if (loaded === undefined)
|
|
122
172
|
return undefined;
|
|
123
|
-
const { config, globalStore } = loaded;
|
|
173
|
+
const { config, globalStore, promptSourceDir } = loaded;
|
|
124
174
|
const linearApiKey = env.LINEAR_API_KEY;
|
|
125
175
|
if (linearApiKey === undefined || linearApiKey.length === 0) {
|
|
126
176
|
streams.err("LINEAR_API_KEY is not set\n");
|
|
@@ -139,18 +189,25 @@ async function prepareLoop(flags, streams, env) {
|
|
|
139
189
|
// of one repo running overlapping ticks: while the store lived inside each
|
|
140
190
|
// checkout they took different locks and nothing serialized them.
|
|
141
191
|
const lock = new FileLock(join(state.dir, "lock"), config.guardrails.claimTtlMs);
|
|
142
|
-
const
|
|
192
|
+
const ctx = {
|
|
143
193
|
repoPath: flags.repo,
|
|
144
194
|
...(work.path === undefined ? {} : { workRepoPath: work.path }),
|
|
145
195
|
stateDir: state.dir,
|
|
146
196
|
linearApiKey,
|
|
147
197
|
lock,
|
|
148
198
|
globalStore,
|
|
199
|
+
selfLogins: resolveIgnoredAuthors({
|
|
200
|
+
sourceKind: config.source.kind,
|
|
201
|
+
...(config.respond === undefined ? {} : { configured: config.respond.ignoreAuthors }),
|
|
202
|
+
...(env.GHOSTRAIL_SELF_LOGINS === undefined ? {} : { env: env.GHOSTRAIL_SELF_LOGINS }),
|
|
203
|
+
}),
|
|
149
204
|
...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
|
|
205
|
+
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
150
206
|
...(repoSlug === undefined ? {} : { repoSlug }),
|
|
151
207
|
logger: makeLogger(streams, secrets),
|
|
152
|
-
}
|
|
153
|
-
|
|
208
|
+
};
|
|
209
|
+
const deps = buildLoopDeps(config, ctx);
|
|
210
|
+
return { config, deps, secrets, stateDir: state.dir, ctx };
|
|
154
211
|
}
|
|
155
212
|
/** `ghostrail run`: drain eligible work once. */
|
|
156
213
|
export async function cmdRun(argv, streams, env = process.env, cwd = process.cwd()) {
|
|
@@ -190,7 +247,47 @@ export function parseWatchFlags(argv) {
|
|
|
190
247
|
? { intervalMs, intervalLabel }
|
|
191
248
|
: { intervalMs, intervalLabel, maxTicks };
|
|
192
249
|
}
|
|
193
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* The passes this config can run under `watch`: the fix queue always, plus
|
|
252
|
+
* respond and triage when their sections are present. A section that fails to
|
|
253
|
+
* assemble (a triage source that isn't wired up yet, say) is reported once and
|
|
254
|
+
* left out, rather than throwing on every tick.
|
|
255
|
+
*/
|
|
256
|
+
function schedulablePasses(setup, streams) {
|
|
257
|
+
const passes = {
|
|
258
|
+
fix: () => runTick(setup.deps, setup.config),
|
|
259
|
+
};
|
|
260
|
+
if (setup.config.respond !== undefined) {
|
|
261
|
+
const { deps, config: respondConfig } = buildRespondDeps(setup.config, setup.ctx);
|
|
262
|
+
passes.respond = () => respondTick(deps, respondConfig);
|
|
263
|
+
}
|
|
264
|
+
if (setup.config.triage !== undefined) {
|
|
265
|
+
try {
|
|
266
|
+
const deps = buildTriageDeps(setup.config, setup.ctx);
|
|
267
|
+
passes.triage = () => triageTick(deps, setup.config);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
streams.err(`not watching triage: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return passes;
|
|
274
|
+
}
|
|
275
|
+
/** The intervals for the passes that are actually wired up. */
|
|
276
|
+
function intervalsOf(config, passes) {
|
|
277
|
+
const intervals = {};
|
|
278
|
+
if (passes.respond !== undefined && config.respond?.everyMs !== undefined) {
|
|
279
|
+
intervals.respond = config.respond.everyMs;
|
|
280
|
+
}
|
|
281
|
+
if (passes.triage !== undefined && config.triage?.everyMs !== undefined) {
|
|
282
|
+
intervals.triage = config.triage.everyMs;
|
|
283
|
+
}
|
|
284
|
+
return intervals;
|
|
285
|
+
}
|
|
286
|
+
/** The ", plus respond every 5m" tail of the watch banner. */
|
|
287
|
+
function describeSchedule(passes) {
|
|
288
|
+
const extra = ["respond", "triage"].filter((pass) => passes[pass] !== undefined);
|
|
289
|
+
return extra.length === 0 ? "" : `, plus ${extra.join(" and ")}`;
|
|
290
|
+
}
|
|
194
291
|
export async function cmdWatch(argv, streams, env = process.env, cwd = process.cwd()) {
|
|
195
292
|
const flags = parseCommandFlags(argv, { config: "ghostrail.toml", repo: cwd });
|
|
196
293
|
const watch = parseWatchFlags(argv);
|
|
@@ -205,18 +302,44 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
|
|
|
205
302
|
const onSignal = () => controller.abort();
|
|
206
303
|
process.once("SIGINT", onSignal);
|
|
207
304
|
process.once("SIGTERM", onSignal);
|
|
208
|
-
|
|
305
|
+
const logger = makeLogger(streams, setup.secrets);
|
|
306
|
+
const passes = schedulablePasses(setup, streams);
|
|
307
|
+
streams.err(`watching every ${watch.intervalLabel}${describeSchedule(passes)}; press Ctrl-C to stop\n`);
|
|
209
308
|
try {
|
|
210
309
|
const controlDir = setup.stateDir;
|
|
310
|
+
// In memory, for the life of this process: a restart re-runs every pass
|
|
311
|
+
// once, which is what someone restarting a daemon expects to happen.
|
|
312
|
+
const lastRunAt = {};
|
|
211
313
|
const ticks = await watchLoop({
|
|
212
314
|
intervalMs: watch.intervalMs,
|
|
213
315
|
signal: controller.signal,
|
|
214
|
-
logger
|
|
316
|
+
logger,
|
|
215
317
|
isEnabled: async () => (await readControl(controlDir)).enabled,
|
|
216
318
|
...(watch.maxTicks === undefined ? {} : { maxTicks: watch.maxTicks }),
|
|
217
319
|
tick: async () => {
|
|
218
|
-
const
|
|
219
|
-
|
|
320
|
+
const due = duePasses({
|
|
321
|
+
now: Date.now(),
|
|
322
|
+
intervals: intervalsOf(setup.config, passes),
|
|
323
|
+
lastRunAt,
|
|
324
|
+
});
|
|
325
|
+
for (const pass of due) {
|
|
326
|
+
const run = passes[pass];
|
|
327
|
+
if (run === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
try {
|
|
330
|
+
const result = await run();
|
|
331
|
+
streams.out(`${pass}: ${redactSecrets(JSON.stringify(result, null, 2), setup.secrets)}\n`);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
// One broken pass must not disable the other two, and must not
|
|
335
|
+
// kill the loop. It still stamps lastRunAt below: a pass that
|
|
336
|
+
// fails fast would otherwise retry on every base tick, which for
|
|
337
|
+
// an agent-backed pass means spending money in a loop.
|
|
338
|
+
logger.warn(`${pass} pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
339
|
+
}
|
|
340
|
+
if (pass !== "fix")
|
|
341
|
+
lastRunAt[pass] = Date.now();
|
|
342
|
+
}
|
|
220
343
|
},
|
|
221
344
|
});
|
|
222
345
|
streams.err(`stopped after ${ticks} tick(s)\n`);
|
|
@@ -234,7 +357,7 @@ export async function cmdTriage(argv, streams, env = process.env, cwd = process.
|
|
|
234
357
|
const loaded = await loadConfig(flags, streams, env);
|
|
235
358
|
if (loaded === undefined)
|
|
236
359
|
return 1;
|
|
237
|
-
const { config, globalStore } = loaded;
|
|
360
|
+
const { config, globalStore, promptSourceDir } = loaded;
|
|
238
361
|
if (config.triage === undefined) {
|
|
239
362
|
streams.err("no [triage] section in the config; add one to use `ghostrail triage`\n");
|
|
240
363
|
return 1;
|
|
@@ -263,6 +386,7 @@ export async function cmdTriage(argv, streams, env = process.env, cwd = process.
|
|
|
263
386
|
lock,
|
|
264
387
|
globalStore,
|
|
265
388
|
...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
|
|
389
|
+
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
266
390
|
logger: makeLogger(streams, secrets),
|
|
267
391
|
});
|
|
268
392
|
const result = await triageTick(deps, config);
|
|
@@ -276,12 +400,16 @@ export async function cmdRespond(argv, streams, env = process.env, cwd = process
|
|
|
276
400
|
const loaded = await loadConfig(flags, streams, env);
|
|
277
401
|
if (loaded === undefined)
|
|
278
402
|
return 1;
|
|
279
|
-
const { config } = loaded;
|
|
403
|
+
const { config, promptSourceDir } = loaded;
|
|
280
404
|
const linearApiKey = env.LINEAR_API_KEY ?? "";
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
405
|
+
// The union of the source kind's built-in bots, `[respond].ignore_authors`,
|
|
406
|
+
// and the environment variable. Without the built-ins, a Linear factory's
|
|
407
|
+
// linkback comment reads as human feedback on every open PR (TJ-1667).
|
|
408
|
+
const selfLogins = resolveIgnoredAuthors({
|
|
409
|
+
sourceKind: config.source.kind,
|
|
410
|
+
...(config.respond === undefined ? {} : { configured: config.respond.ignoreAuthors }),
|
|
411
|
+
...(env.GHOSTRAIL_SELF_LOGINS === undefined ? {} : { env: env.GHOSTRAIL_SELF_LOGINS }),
|
|
412
|
+
});
|
|
285
413
|
const claudeProfileDir = await resolveClaudeProfile(env);
|
|
286
414
|
const repoSlug = await resolveRepoSlug(config, flags.repo);
|
|
287
415
|
// Respond's watermarks live with the rest of the repo's state, so a pass run
|
|
@@ -303,6 +431,7 @@ export async function cmdRespond(argv, streams, env = process.env, cwd = process
|
|
|
303
431
|
lock: { acquire: async () => true, release: async () => { } },
|
|
304
432
|
selfLogins,
|
|
305
433
|
...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
|
|
434
|
+
...(promptSourceDir === undefined ? {} : { promptSourceDir }),
|
|
306
435
|
...(repoSlug === undefined ? {} : { repoSlug }),
|
|
307
436
|
logger: makeLogger(streams, secrets),
|
|
308
437
|
});
|
|
@@ -475,6 +604,12 @@ function dashboardDeps(env, manager) {
|
|
|
475
604
|
// subprocess. A machine with no `gh` and no credentials still serves the
|
|
476
605
|
// whole dashboard, with PR status reported as unavailable.
|
|
477
606
|
prStatus: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).getPrStatus(prNumber),
|
|
607
|
+
// Posts the same trigger comment a human typing "resolve conflicts and
|
|
608
|
+
// merge" by hand would; the next respond pass does the actual work (see
|
|
609
|
+
// specs/dashboard-review-mode.spec.md). A machine with no `gh` and no
|
|
610
|
+
// credentials still serves the whole dashboard; the route just answers
|
|
611
|
+
// 503 rather than failing to start.
|
|
612
|
+
mergeTrigger: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).postComment(prNumber, MERGE_TRIGGER_COMMENT),
|
|
478
613
|
readRegistry: () => store.readRegistry(),
|
|
479
614
|
writeRegistry: (entries) => store.writeRegistry(entries),
|
|
480
615
|
health: ghostrailHealth,
|
|
@@ -559,7 +694,7 @@ export async function cmdArtifacts(argv, streams, env = process.env) {
|
|
|
559
694
|
export async function ghostrailHealth(path) {
|
|
560
695
|
if (!(await pathExists(path)))
|
|
561
696
|
return "missing";
|
|
562
|
-
return (await
|
|
697
|
+
return (await resolveConfigPath(path)) !== undefined ? "ok" : "no config";
|
|
563
698
|
}
|
|
564
699
|
/** `ghostrail ls`: the ghostrails this account knows about. */
|
|
565
700
|
export async function cmdLs(_argv, streams, env = process.env) {
|
|
@@ -596,7 +731,8 @@ function ghostrailExecPath() {
|
|
|
596
731
|
}
|
|
597
732
|
/** A ghostrail's configured agent timeout, for the unit's stop timeout. */
|
|
598
733
|
async function agentTimeoutMs(repo) {
|
|
599
|
-
const
|
|
734
|
+
const configPath = await resolveConfigPath(repo);
|
|
735
|
+
const raw = configPath === undefined ? undefined : await readIfPresent(configPath);
|
|
600
736
|
if (raw !== undefined) {
|
|
601
737
|
const parsed = parseConfig(raw);
|
|
602
738
|
if (parsed.ok)
|
|
@@ -849,7 +985,7 @@ export async function cmdStart(argv, streams, env = process.env, ask = askOnTerm
|
|
|
849
985
|
}
|
|
850
986
|
}
|
|
851
987
|
}
|
|
852
|
-
streams.out("\n`watch` ticks the fix queue
|
|
988
|
+
streams.out("\n`watch` ticks the fix queue every interval, plus respond and triage on their own (`[respond].every`, `[triage].every`)\n");
|
|
853
989
|
return failed ? 1 : 0;
|
|
854
990
|
}
|
|
855
991
|
/** `ghostrail stop`: take ghostrails down without changing what should run. */
|
|
@@ -1041,7 +1177,8 @@ function globalStoreFor(env) {
|
|
|
1041
1177
|
}
|
|
1042
1178
|
/** A ghostrail's display name: `[factory].name`, else the directory. */
|
|
1043
1179
|
async function ghostrailName(repo) {
|
|
1044
|
-
const
|
|
1180
|
+
const configPath = await resolveConfigPath(repo);
|
|
1181
|
+
const raw = configPath === undefined ? undefined : await readIfPresent(configPath);
|
|
1045
1182
|
if (raw !== undefined) {
|
|
1046
1183
|
const parsed = parseConfig(raw);
|
|
1047
1184
|
if (parsed.ok && parsed.value.factory.name.length > 0)
|
|
@@ -1088,7 +1225,10 @@ async function resolveRepoId(path) {
|
|
|
1088
1225
|
* the checkout's origin.
|
|
1089
1226
|
*/
|
|
1090
1227
|
async function configAt(repo) {
|
|
1091
|
-
const
|
|
1228
|
+
const configPath = await resolveConfigPath(repo);
|
|
1229
|
+
if (configPath === undefined)
|
|
1230
|
+
return undefined;
|
|
1231
|
+
const raw = await readIfPresent(configPath);
|
|
1092
1232
|
if (raw === undefined)
|
|
1093
1233
|
return undefined;
|
|
1094
1234
|
const parsed = parseConfig(raw);
|
|
@@ -1101,10 +1241,18 @@ async function prepareState(repo, config, env, streams) {
|
|
|
1101
1241
|
streams.err(`${note}\n`);
|
|
1102
1242
|
return { dir: state.dir, repoId: state.repoId };
|
|
1103
1243
|
}
|
|
1104
|
-
async function registerGhostrail(repo, env, streams) {
|
|
1244
|
+
async function registerGhostrail(repo, env, streams, forceRegister = false) {
|
|
1105
1245
|
const store = globalStoreFor(env);
|
|
1106
1246
|
const path = resolve(repo);
|
|
1107
1247
|
try {
|
|
1248
|
+
if (!forceRegister && (await isTempPath(path, env))) {
|
|
1249
|
+
// `watch` defaults to true and, for a repo with no remote, `path` is the
|
|
1250
|
+
// identity: registering here would switch on a service unit pointed at a
|
|
1251
|
+
// directory that is going to vanish. Scaffolding still happened; only the
|
|
1252
|
+
// registry write is skipped, and said plainly rather than silently.
|
|
1253
|
+
streams.out(`not registered: ${path} looks like a temp directory\n run \`ghostrail init <template> --register\` to register it anyway\n`);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1108
1256
|
const entries = await store.readRegistry();
|
|
1109
1257
|
if (entries === undefined) {
|
|
1110
1258
|
streams.err(`note: ${store.registryPath()} could not be parsed, so this ghostrail was not registered\n`);
|
|
@@ -1185,7 +1333,7 @@ async function promptPathsAt(configPath) {
|
|
|
1185
1333
|
* the template gained, which is what every hand port consisted of before
|
|
1186
1334
|
* regions existed, and the skill places them in the repo's own wording.
|
|
1187
1335
|
*/
|
|
1188
|
-
async function applyTemplateUpdate(template, srcDir, repo, streams, env) {
|
|
1336
|
+
async function applyTemplateUpdate(template, srcDir, repo, streams, env, register) {
|
|
1189
1337
|
const manifest = await readManifest(repo);
|
|
1190
1338
|
const rels = await scaffoldFiles(srcDir);
|
|
1191
1339
|
const templatePrompts = (await promptPathsAt(join(srcDir, "ghostrail.toml"))) ?? {};
|
|
@@ -1310,7 +1458,7 @@ async function applyTemplateUpdate(template, srcDir, repo, streams, env) {
|
|
|
1310
1458
|
if (Object.keys(written).length > 0 || Object.keys(writtenRegions).length > 0) {
|
|
1311
1459
|
await writeManifest(repo, mergeManifest(manifest, template, VERSION, written, writtenRegions));
|
|
1312
1460
|
}
|
|
1313
|
-
await registerGhostrail(repo, env, streams);
|
|
1461
|
+
await registerGhostrail(repo, env, streams, register);
|
|
1314
1462
|
streams.out("\n");
|
|
1315
1463
|
if (took > 0)
|
|
1316
1464
|
streams.out(`took ${took} file(s) you had not edited\n`);
|
|
@@ -1325,12 +1473,47 @@ async function applyTemplateUpdate(template, srcDir, repo, streams, env) {
|
|
|
1325
1473
|
return 0;
|
|
1326
1474
|
}
|
|
1327
1475
|
/**
|
|
1328
|
-
* `
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1332
|
-
|
|
1333
|
-
|
|
1476
|
+
* Where `--greenroom` scaffolds when no path is given: `<global config
|
|
1477
|
+
* dir>/greenroom/<repo key>`, the same identity-derived key `stateDirFor`
|
|
1478
|
+
* already uses, so a bare `--greenroom` needs no path and a second run on a
|
|
1479
|
+
* machine that already knows this repo lands in the same place.
|
|
1480
|
+
*/
|
|
1481
|
+
async function defaultGreenroomDir(repo, env) {
|
|
1482
|
+
const globalDir = resolveGlobalDir({ GHOSTRAIL_HOME: env.GHOSTRAIL_HOME, XDG_CONFIG_HOME: env.XDG_CONFIG_HOME }, homedir());
|
|
1483
|
+
const repoId = await resolveRepoId(repo);
|
|
1484
|
+
return join(globalDir, "greenroom", repoKey(repoId));
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Remove a repo's own copy of a plain `init`'s scaffold, now superseded by a
|
|
1488
|
+
* greenroom directory: the scaffolded files themselves, the provenance
|
|
1489
|
+
* manifest, and a `prompts/` directory only if scaffolding left it empty
|
|
1490
|
+
* (something else living there is left alone).
|
|
1491
|
+
*/
|
|
1492
|
+
async function removeLocalScaffold(repo, rels) {
|
|
1493
|
+
for (const rel of rels) {
|
|
1494
|
+
await rm(join(repo, rel), { force: true });
|
|
1495
|
+
}
|
|
1496
|
+
await rm(join(repo, MANIFEST_FILE), { force: true });
|
|
1497
|
+
try {
|
|
1498
|
+
await rmdir(join(repo, "prompts"));
|
|
1499
|
+
}
|
|
1500
|
+
catch {
|
|
1501
|
+
// Non-empty, or never existed: leave it exactly as it is.
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* `ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register]`:
|
|
1506
|
+
* scaffold `ghostrail.toml` and `prompts/` from a quickstart template. Safe for
|
|
1507
|
+
* a fresh OR an existing repo: existing files are left untouched (skipped)
|
|
1508
|
+
* unless `--force` is given. `--diff` reports drift instead of writing
|
|
1509
|
+
* anything, `--update` takes the template changes this repo never edited while
|
|
1510
|
+
* naming what a customized file is missing, and `--greenroom` scaffolds into a
|
|
1511
|
+
* directory outside the repo instead of into it (see
|
|
1512
|
+
* specs/greenroom-compatibility.spec.md), converting an existing plain `init`
|
|
1513
|
+
* in place when the repo already has one.
|
|
1514
|
+
*
|
|
1515
|
+
* Registration is skipped, not just the write, when the target looks like a
|
|
1516
|
+
* temp directory (TJ-1654); `--register` registers it anyway.
|
|
1334
1517
|
*/
|
|
1335
1518
|
export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.env) {
|
|
1336
1519
|
let template;
|
|
@@ -1338,6 +1521,9 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1338
1521
|
let force = false;
|
|
1339
1522
|
let diff = false;
|
|
1340
1523
|
let update = false;
|
|
1524
|
+
let register = false;
|
|
1525
|
+
let greenroom = false;
|
|
1526
|
+
let greenroomPath;
|
|
1341
1527
|
for (let i = 0; i < argv.length; i++) {
|
|
1342
1528
|
const arg = argv[i];
|
|
1343
1529
|
const next = argv[i + 1];
|
|
@@ -1354,12 +1540,22 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1354
1540
|
else if (arg === "--update") {
|
|
1355
1541
|
update = true;
|
|
1356
1542
|
}
|
|
1543
|
+
else if (arg === "--register") {
|
|
1544
|
+
register = true;
|
|
1545
|
+
}
|
|
1546
|
+
else if (arg === "--greenroom") {
|
|
1547
|
+
greenroom = true;
|
|
1548
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
1549
|
+
greenroomPath = next;
|
|
1550
|
+
i++;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1357
1553
|
else if (arg !== undefined && !arg.startsWith("-")) {
|
|
1358
1554
|
template ??= arg;
|
|
1359
1555
|
}
|
|
1360
1556
|
}
|
|
1361
1557
|
if (template === undefined || !TEMPLATES.includes(template)) {
|
|
1362
|
-
streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update]\ntemplates: ${TEMPLATES.join(", ")}\n`);
|
|
1558
|
+
streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register]\ntemplates: ${TEMPLATES.join(", ")}\n`);
|
|
1363
1559
|
return 1;
|
|
1364
1560
|
}
|
|
1365
1561
|
if (diff && force) {
|
|
@@ -1372,10 +1568,17 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1372
1568
|
streams.err("init: --update cannot be combined with --force or --diff\n");
|
|
1373
1569
|
return 1;
|
|
1374
1570
|
}
|
|
1571
|
+
if (greenroom && (update || diff)) {
|
|
1572
|
+
// --update and --diff both compare against the template this CLI ships;
|
|
1573
|
+
// where the resulting files land is an orthogonal question, and combining
|
|
1574
|
+
// them is refused rather than guessing an order.
|
|
1575
|
+
streams.err("init: --greenroom cannot be combined with --update or --diff\n");
|
|
1576
|
+
return 1;
|
|
1577
|
+
}
|
|
1375
1578
|
const srcDir = join(templatesDir(), template);
|
|
1376
1579
|
if (update) {
|
|
1377
1580
|
try {
|
|
1378
|
-
return await applyTemplateUpdate(template, srcDir, repo, streams, env);
|
|
1581
|
+
return await applyTemplateUpdate(template, srcDir, repo, streams, env, register);
|
|
1379
1582
|
}
|
|
1380
1583
|
catch (error) {
|
|
1381
1584
|
streams.err(`init --update failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -1391,6 +1594,9 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1391
1594
|
return 1;
|
|
1392
1595
|
}
|
|
1393
1596
|
}
|
|
1597
|
+
const configRoot = greenroom
|
|
1598
|
+
? resolve(greenroomPath ?? (await defaultGreenroomDir(repo, env)))
|
|
1599
|
+
: repo;
|
|
1394
1600
|
const written = [];
|
|
1395
1601
|
const skipped = [];
|
|
1396
1602
|
// Baselines for the files this run actually wrote. A skipped file gets no
|
|
@@ -1412,11 +1618,12 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1412
1618
|
return;
|
|
1413
1619
|
regionBaselines[key] = regionHashes(parsed.regions);
|
|
1414
1620
|
};
|
|
1621
|
+
const rels = await scaffoldFiles(srcDir);
|
|
1415
1622
|
try {
|
|
1416
|
-
const existing = await readManifest(
|
|
1417
|
-
for (const rel of
|
|
1623
|
+
const existing = await readManifest(configRoot);
|
|
1624
|
+
for (const rel of rels) {
|
|
1418
1625
|
const key = manifestKey(rel);
|
|
1419
|
-
const to = join(
|
|
1626
|
+
const to = join(configRoot, rel);
|
|
1420
1627
|
const content = await readFile(join(srcDir, rel), "utf8");
|
|
1421
1628
|
const templateHash = hashContent(content);
|
|
1422
1629
|
if (!force && (await pathExists(to))) {
|
|
@@ -1440,18 +1647,29 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
|
|
|
1440
1647
|
baselines[key] = templateHash;
|
|
1441
1648
|
recordRegions(key, content);
|
|
1442
1649
|
}
|
|
1443
|
-
await writeManifest(
|
|
1650
|
+
await writeManifest(configRoot, mergeManifest(existing, template, VERSION, baselines, regionBaselines));
|
|
1444
1651
|
}
|
|
1445
1652
|
catch (error) {
|
|
1446
1653
|
streams.err(`init failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1447
1654
|
return 1;
|
|
1448
1655
|
}
|
|
1449
|
-
streams.out(`initialized ${template} in ${
|
|
1656
|
+
streams.out(`initialized ${template} in ${configRoot}: wrote ${written.length} file(s)`);
|
|
1450
1657
|
if (skipped.length > 0) {
|
|
1451
1658
|
streams.out(`, kept ${skipped.length} existing (${skipped.join(", ")}; use --force to overwrite)`);
|
|
1452
1659
|
}
|
|
1453
1660
|
streams.out("\n");
|
|
1454
|
-
|
|
1661
|
+
if (greenroom) {
|
|
1662
|
+
// A repo set up before switching to a greenroom (or by a plain `init`
|
|
1663
|
+
// moments ago) has its own copies now superseded by configRoot: this is
|
|
1664
|
+
// the "switch to it later" path, distinguished from "from the start" only
|
|
1665
|
+
// by whether there was anything here to remove.
|
|
1666
|
+
await removeLocalScaffold(repo, rels);
|
|
1667
|
+
await writeFile(join(repo, GREENROOM_POINTER_FILE), `${configRoot}\n`, "utf8");
|
|
1668
|
+
streams.out(`wrote ${join(repo, GREENROOM_POINTER_FILE)}, pointing at ${configRoot}\n`);
|
|
1669
|
+
streams.out("this repo's config and prompts now live outside it; keep that pointer file out of\n" +
|
|
1670
|
+
"version control (it is host-specific), the same as .ghostrail.env\n");
|
|
1671
|
+
}
|
|
1672
|
+
await registerGhostrail(repo, env, streams, register);
|
|
1455
1673
|
streams.out(await initNextStepsHint(env, cwd));
|
|
1456
1674
|
return 0;
|
|
1457
1675
|
}
|