ghostrail 0.17.1 → 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.
Files changed (119) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/claude-code.d.ts +18 -1
  3. package/dist/agent/claude-code.d.ts.map +1 -1
  4. package/dist/agent/claude-code.js +36 -4
  5. package/dist/agent/claude-code.js.map +1 -1
  6. package/dist/board/page.d.ts.map +1 -1
  7. package/dist/board/page.js +44 -10
  8. package/dist/board/page.js.map +1 -1
  9. package/dist/cli/commands.d.ts +12 -7
  10. package/dist/cli/commands.d.ts.map +1 -1
  11. package/dist/cli/commands.js +261 -43
  12. package/dist/cli/commands.js.map +1 -1
  13. package/dist/commands/help.d.ts.map +1 -1
  14. package/dist/commands/help.js +5 -3
  15. package/dist/commands/help.js.map +1 -1
  16. package/dist/config/load.d.ts.map +1 -1
  17. package/dist/config/load.js +20 -3
  18. package/dist/config/load.js.map +1 -1
  19. package/dist/config/schema.d.ts +57 -0
  20. package/dist/config/schema.d.ts.map +1 -1
  21. package/dist/config/schema.js +19 -0
  22. package/dist/config/schema.js.map +1 -1
  23. package/dist/dashboard/index.d.ts +3 -2
  24. package/dist/dashboard/index.d.ts.map +1 -1
  25. package/dist/dashboard/index.js +3 -2
  26. package/dist/dashboard/index.js.map +1 -1
  27. package/dist/dashboard/page.d.ts.map +1 -1
  28. package/dist/dashboard/page.js +64 -10
  29. package/dist/dashboard/page.js.map +1 -1
  30. package/dist/dashboard/review-page.d.ts +14 -0
  31. package/dist/dashboard/review-page.d.ts.map +1 -0
  32. package/dist/dashboard/review-page.js +256 -0
  33. package/dist/dashboard/review-page.js.map +1 -0
  34. package/dist/dashboard/routes.d.ts +18 -0
  35. package/dist/dashboard/routes.d.ts.map +1 -1
  36. package/dist/dashboard/routes.js +24 -0
  37. package/dist/dashboard/routes.js.map +1 -1
  38. package/dist/dashboard/server.d.ts +19 -0
  39. package/dist/dashboard/server.d.ts.map +1 -1
  40. package/dist/dashboard/server.js +71 -1
  41. package/dist/dashboard/server.js.map +1 -1
  42. package/dist/doctor/checks.d.ts +21 -0
  43. package/dist/doctor/checks.d.ts.map +1 -1
  44. package/dist/doctor/checks.js +44 -0
  45. package/dist/doctor/checks.js.map +1 -1
  46. package/dist/doctor/probes.d.ts +15 -0
  47. package/dist/doctor/probes.d.ts.map +1 -1
  48. package/dist/doctor/probes.js +33 -0
  49. package/dist/doctor/probes.js.map +1 -1
  50. package/dist/doctor/run.d.ts +2 -1
  51. package/dist/doctor/run.d.ts.map +1 -1
  52. package/dist/doctor/run.js +25 -2
  53. package/dist/doctor/run.js.map +1 -1
  54. package/dist/factory/build.d.ts +6 -0
  55. package/dist/factory/build.d.ts.map +1 -1
  56. package/dist/factory/build.js +17 -4
  57. package/dist/factory/build.js.map +1 -1
  58. package/dist/global/tempdir.d.ts +19 -0
  59. package/dist/global/tempdir.d.ts.map +1 -0
  60. package/dist/global/tempdir.js +47 -0
  61. package/dist/global/tempdir.js.map +1 -0
  62. package/dist/init/greenroom.d.ts +18 -0
  63. package/dist/init/greenroom.d.ts.map +1 -0
  64. package/dist/init/greenroom.js +24 -0
  65. package/dist/init/greenroom.js.map +1 -0
  66. package/dist/loop/engine.d.ts +10 -1
  67. package/dist/loop/engine.d.ts.map +1 -1
  68. package/dist/loop/engine.js +116 -18
  69. package/dist/loop/engine.js.map +1 -1
  70. package/dist/loop/index.d.ts +1 -0
  71. package/dist/loop/index.d.ts.map +1 -1
  72. package/dist/loop/index.js +1 -0
  73. package/dist/loop/index.js.map +1 -1
  74. package/dist/loop/ports.d.ts +7 -0
  75. package/dist/loop/ports.d.ts.map +1 -1
  76. package/dist/loop/record.d.ts +6 -1
  77. package/dist/loop/record.d.ts.map +1 -1
  78. package/dist/loop/retry.d.ts +50 -0
  79. package/dist/loop/retry.d.ts.map +1 -0
  80. package/dist/loop/retry.js +77 -0
  81. package/dist/loop/retry.js.map +1 -0
  82. package/dist/loop/schedule.d.ts +33 -0
  83. package/dist/loop/schedule.d.ts.map +1 -0
  84. package/dist/loop/schedule.js +28 -0
  85. package/dist/loop/schedule.js.map +1 -0
  86. package/dist/respond/authors.d.ts +38 -0
  87. package/dist/respond/authors.d.ts.map +1 -0
  88. package/dist/respond/authors.js +50 -0
  89. package/dist/respond/authors.js.map +1 -0
  90. package/dist/respond/merge.d.ts.map +1 -1
  91. package/dist/respond/merge.js +47 -0
  92. package/dist/respond/merge.js.map +1 -1
  93. package/dist/respond/respond.d.ts +10 -0
  94. package/dist/respond/respond.d.ts.map +1 -1
  95. package/dist/respond/respond.js +62 -5
  96. package/dist/respond/respond.js.map +1 -1
  97. package/dist/respond/run-id.d.ts +17 -0
  98. package/dist/respond/run-id.d.ts.map +1 -0
  99. package/dist/respond/run-id.js +23 -0
  100. package/dist/respond/run-id.js.map +1 -0
  101. package/dist/respond/run-row.d.ts +24 -0
  102. package/dist/respond/run-row.d.ts.map +1 -0
  103. package/dist/respond/run-row.js +28 -0
  104. package/dist/respond/run-row.js.map +1 -0
  105. package/dist/store/events.d.ts +25 -0
  106. package/dist/store/events.d.ts.map +1 -1
  107. package/dist/store/events.js +30 -3
  108. package/dist/store/events.js.map +1 -1
  109. package/dist/store/index.d.ts +1 -1
  110. package/dist/store/index.d.ts.map +1 -1
  111. package/dist/store/index.js +1 -1
  112. package/dist/store/index.js.map +1 -1
  113. package/dist/triage/engine.d.ts +14 -0
  114. package/dist/triage/engine.d.ts.map +1 -1
  115. package/dist/triage/engine.js +73 -3
  116. package/dist/triage/engine.js.map +1 -1
  117. package/package.json +1 -1
  118. package/templates/code/ghostrail.toml +4 -0
  119. package/templates/content/ghostrail.toml +4 -0
@@ -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 = isAbsolute(flags.config) ? flags.config : join(flags.repo, flags.config);
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
- return { config: parsed.value, globalStore };
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 deps = buildLoopDeps(config, {
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
- return { config, deps, secrets, stateDir: state.dir };
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
- /** `ghostrail watch`: run ticks on an interval until interrupted (no cron needed). */
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
- streams.err(`watching every ${watch.intervalLabel}; press Ctrl-C to stop\n`);
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: makeLogger(streams, setup.secrets),
316
+ logger,
215
317
  isEnabled: async () => (await readControl(controlDir)).enabled,
216
318
  ...(watch.maxTicks === undefined ? {} : { maxTicks: watch.maxTicks }),
217
319
  tick: async () => {
218
- const result = await runTick(setup.deps, setup.config);
219
- streams.out(`${redactSecrets(JSON.stringify(result, null, 2), setup.secrets)}\n`);
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
- const selfLogins = (env.GHOSTRAIL_SELF_LOGINS ?? "")
282
- .split(",")
283
- .map((login) => login.trim())
284
- .filter((login) => login.length > 0);
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 pathExists(join(path, "ghostrail.toml"))) ? "ok" : "no config";
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 raw = await readIfPresent(join(repo, "ghostrail.toml"));
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 only; `respond` and `triage` still need their own schedule\n");
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 raw = await readIfPresent(join(repo, "ghostrail.toml"));
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 raw = await readIfPresent(join(repo, "ghostrail.toml"));
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
- * `ghostrail init <template> [--repo <path>] [--force|--diff|--update]`: scaffold
1329
- * `ghostrail.toml` and `prompts/` from a quickstart template. Safe for a fresh OR
1330
- * an existing repo: existing files are left untouched (skipped) unless `--force`
1331
- * is given. `--diff` reports drift instead of writing anything, and `--update`
1332
- * takes the template changes this repo never edited while naming what a
1333
- * customized file is missing.
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(repo);
1417
- for (const rel of await scaffoldFiles(srcDir)) {
1623
+ const existing = await readManifest(configRoot);
1624
+ for (const rel of rels) {
1418
1625
  const key = manifestKey(rel);
1419
- const to = join(repo, rel);
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(repo, mergeManifest(existing, template, VERSION, baselines, regionBaselines));
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 ${repo}: wrote ${written.length} file(s)`);
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
- await registerGhostrail(repo, env, streams);
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
  }