ghostrail 0.21.0 → 0.23.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 (105) hide show
  1. package/dist/board/page.d.ts.map +1 -1
  2. package/dist/board/page.js +71 -13
  3. package/dist/board/page.js.map +1 -1
  4. package/dist/board/server.d.ts +10 -0
  5. package/dist/board/server.d.ts.map +1 -1
  6. package/dist/board/server.js +24 -0
  7. package/dist/board/server.js.map +1 -1
  8. package/dist/cli/commands.d.ts +74 -2
  9. package/dist/cli/commands.d.ts.map +1 -1
  10. package/dist/cli/commands.js +183 -18
  11. package/dist/cli/commands.js.map +1 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +3 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/commands/help.d.ts.map +1 -1
  16. package/dist/commands/help.js +11 -2
  17. package/dist/commands/help.js.map +1 -1
  18. package/dist/config/load.d.ts.map +1 -1
  19. package/dist/config/load.js +19 -0
  20. package/dist/config/load.js.map +1 -1
  21. package/dist/config/schema.d.ts +13 -0
  22. package/dist/config/schema.d.ts.map +1 -1
  23. package/dist/config/schema.js.map +1 -1
  24. package/dist/dashboard/page.d.ts +0 -14
  25. package/dist/dashboard/page.d.ts.map +1 -1
  26. package/dist/dashboard/page.js +107 -9
  27. package/dist/dashboard/page.js.map +1 -1
  28. package/dist/dashboard/routes.d.ts +4 -0
  29. package/dist/dashboard/routes.d.ts.map +1 -1
  30. package/dist/dashboard/routes.js +4 -0
  31. package/dist/dashboard/routes.js.map +1 -1
  32. package/dist/dashboard/server.d.ts +29 -0
  33. package/dist/dashboard/server.d.ts.map +1 -1
  34. package/dist/dashboard/server.js +75 -4
  35. package/dist/dashboard/server.js.map +1 -1
  36. package/dist/factory/build.d.ts +6 -0
  37. package/dist/factory/build.d.ts.map +1 -1
  38. package/dist/factory/build.js +23 -0
  39. package/dist/factory/build.js.map +1 -1
  40. package/dist/loop/branch.d.ts +8 -1
  41. package/dist/loop/branch.d.ts.map +1 -1
  42. package/dist/loop/branch.js +14 -2
  43. package/dist/loop/branch.js.map +1 -1
  44. package/dist/loop/engine.d.ts.map +1 -1
  45. package/dist/loop/engine.js +57 -5
  46. package/dist/loop/engine.js.map +1 -1
  47. package/dist/loop/next-tick.d.ts +43 -5
  48. package/dist/loop/next-tick.d.ts.map +1 -1
  49. package/dist/loop/next-tick.js +68 -18
  50. package/dist/loop/next-tick.js.map +1 -1
  51. package/dist/loop/ports.d.ts +4 -1
  52. package/dist/loop/ports.d.ts.map +1 -1
  53. package/dist/loop/recurrence.d.ts +43 -0
  54. package/dist/loop/recurrence.d.ts.map +1 -0
  55. package/dist/loop/recurrence.js +92 -0
  56. package/dist/loop/recurrence.js.map +1 -0
  57. package/dist/loop/schedule.d.ts +11 -2
  58. package/dist/loop/schedule.d.ts.map +1 -1
  59. package/dist/loop/schedule.js +3 -1
  60. package/dist/loop/schedule.js.map +1 -1
  61. package/dist/publish/gh.d.ts +43 -2
  62. package/dist/publish/gh.d.ts.map +1 -1
  63. package/dist/publish/gh.js +80 -2
  64. package/dist/publish/gh.js.map +1 -1
  65. package/dist/publish/githost.d.ts +18 -1
  66. package/dist/publish/githost.d.ts.map +1 -1
  67. package/dist/publish/githost.js +13 -0
  68. package/dist/publish/githost.js.map +1 -1
  69. package/dist/publish/index.d.ts +1 -1
  70. package/dist/publish/index.d.ts.map +1 -1
  71. package/dist/publish/index.js +1 -1
  72. package/dist/publish/index.js.map +1 -1
  73. package/dist/release/index.d.ts +2 -0
  74. package/dist/release/index.d.ts.map +1 -0
  75. package/dist/release/index.js +2 -0
  76. package/dist/release/index.js.map +1 -0
  77. package/dist/release/release.d.ts +67 -0
  78. package/dist/release/release.d.ts.map +1 -0
  79. package/dist/release/release.js +193 -0
  80. package/dist/release/release.js.map +1 -0
  81. package/dist/respond/ci-fix-queue.d.ts +40 -0
  82. package/dist/respond/ci-fix-queue.d.ts.map +1 -0
  83. package/dist/respond/ci-fix-queue.js +74 -0
  84. package/dist/respond/ci-fix-queue.js.map +1 -0
  85. package/dist/respond/index.d.ts +2 -1
  86. package/dist/respond/index.d.ts.map +1 -1
  87. package/dist/respond/index.js +2 -1
  88. package/dist/respond/index.js.map +1 -1
  89. package/dist/respond/merge.d.ts.map +1 -1
  90. package/dist/respond/merge.js +14 -1
  91. package/dist/respond/merge.js.map +1 -1
  92. package/dist/respond/respond.d.ts +26 -1
  93. package/dist/respond/respond.d.ts.map +1 -1
  94. package/dist/respond/respond.js +154 -9
  95. package/dist/respond/respond.js.map +1 -1
  96. package/dist/version.d.ts +6 -0
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +6 -0
  99. package/dist/version.js.map +1 -1
  100. package/package.json +1 -1
  101. package/skill/ghostrail/SKILL.md +15 -4
  102. package/templates/code/ghostrail.toml +3 -0
  103. package/templates/code/prompts/release.md +40 -0
  104. package/templates/code/prompts/respond.md +2 -1
  105. package/templates/content/prompts/respond.md +3 -1
@@ -12,7 +12,7 @@ import { parseDuration } from "../config/duration.js";
12
12
  import { parseConfig, parseMergedConfig } from "../config/load.js";
13
13
  import { MERGE_TRIGGER_COMMENT, createDashboardServer, defaultStateDir, } from "../dashboard/index.js";
14
14
  import { hasFailure, renderDoctorJson, renderDoctorText, runDoctor, } from "../doctor/index.js";
15
- import { buildLoopDeps, buildRespondDeps, buildTriageDeps, repoGhRunner, resolveRepoSlug, } from "../factory/build.js";
15
+ import { buildLoopDeps, buildReleaseDeps, 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
18
  import { canonicalRepoId, localRepoId, repoKey } from "../global/repo-id.js";
@@ -28,19 +28,21 @@ import { concurrencyAdvice } from "../loop/advice.js";
28
28
  import { fileControl } from "../loop/control.js";
29
29
  import { readControl } from "../loop/control.js";
30
30
  import { runTick } from "../loop/engine.js";
31
- import { writeNextTickAt } from "../loop/next-tick.js";
31
+ import { clearTickStarted, markTickStarted, writeNextTickAt } from "../loop/next-tick.js";
32
32
  import { duePasses, } from "../loop/schedule.js";
33
33
  import { appendLog } from "../loop/tick-log.js";
34
34
  import { watchLoop } from "../loop/watch.js";
35
35
  import { GhHost, parseRepoSlug } from "../publish/gh.js";
36
+ import { releaseNow, releaseTick, requestRelease } from "../release/release.js";
36
37
  import { resolveIgnoredAuthors } from "../respond/authors.js";
38
+ import { FileCiFixQueueStore } from "../respond/ci-fix-queue.js";
37
39
  import { respondTick } from "../respond/respond.js";
38
40
  import { GLOBAL_SECRETS_FILE, appendPlaceholders, applyOverrides, launchEditor as defaultLaunchEditor, loadEnvOverrides, missingSecrets, readSecretsFile, redactSecrets, renderFile, resolveEditor, secretValuesOf, writeSecretsFile, } from "../secrets/index.js";
39
41
  import { DASHBOARD_UNIT, renderDashboardUnit, servicePath, stopTimeoutSeconds, systemdManager, unitName, } from "../service/index.js";
40
42
  import { applySkillPlan, describePlan, pathCtx, planSkillInstall, uninstallSkill, } from "../skill/install.js";
41
43
  import { FileStore } from "../store/file-store.js";
42
44
  import { triageTick } from "../triage/engine.js";
43
- import { VERSION } from "../version.js";
45
+ import { STABILITY_NOTICE, VERSION } from "../version.js";
44
46
  import { askOnTerminal, parseInstallOffers } from "./prompt.js";
45
47
  import { describeStalledUpdate } from "./update-diagnosis.js";
46
48
  /**
@@ -187,8 +189,13 @@ async function resolveClaudeProfile(env) {
187
189
  * Shared setup for the run/watch loops: apply secrets, load the merged config,
188
190
  * require the Linear key, and assemble the loop deps. Prints errors and returns
189
191
  * undefined on failure.
192
+ *
193
+ * `loggerFor` is the seam TJ-1682 turned on: the logger the deps are built with
194
+ * is decided here, because only here is the state directory known, and a caller
195
+ * that builds its own afterwards cannot reach the engine's narration. Exported
196
+ * so that invariant can be tested directly rather than inferred from a tick.
190
197
  */
191
- async function prepareLoop(flags, streams, env) {
198
+ export async function prepareLoop(flags, streams, env, loggerFor = (_dir, secrets) => makeLogger(streams, secrets)) {
192
199
  const secrets = await applySecrets(flags, env);
193
200
  const loaded = await loadConfig(flags, streams, env);
194
201
  if (loaded === undefined)
@@ -212,6 +219,9 @@ async function prepareLoop(flags, streams, env) {
212
219
  // of one repo running overlapping ticks: while the store lived inside each
213
220
  // checkout they took different locks and nothing serialized them.
214
221
  const lock = new FileLock(join(state.dir, "lock"), config.guardrails.claimTtlMs);
222
+ // Keyed on the state dir because that is what a persisting logger needs and
223
+ // only `prepareLoop` knows it: the caller cannot build this itself (TJ-1682).
224
+ const logger = loggerFor(state.dir, secrets);
215
225
  const ctx = {
216
226
  repoPath: flags.repo,
217
227
  ...(work.path === undefined ? {} : { workRepoPath: work.path }),
@@ -227,10 +237,10 @@ async function prepareLoop(flags, streams, env) {
227
237
  ...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
228
238
  ...(promptSourceDir === undefined ? {} : { promptSourceDir }),
229
239
  ...(repoSlug === undefined ? {} : { repoSlug }),
230
- logger: makeLogger(streams, secrets),
240
+ logger,
231
241
  };
232
242
  const deps = buildLoopDeps(config, ctx);
233
- return { config, deps, secrets, stateDir: state.dir, ctx };
243
+ return { config, deps, secrets, stateDir: state.dir, ctx, logger };
234
244
  }
235
245
  /** `ghostrail run`: drain eligible work once. */
236
246
  export async function cmdRun(argv, streams, env = process.env, cwd = process.cwd()) {
@@ -243,12 +253,14 @@ export async function cmdRun(argv, streams, env = process.env, cwd = process.cwd
243
253
  return result.results.some((r) => r.state === "failed") ? 1 : 0;
244
254
  }
245
255
  /**
246
- * Parse `ghostrail watch` options: `--interval`/`-i <duration>` (default `60s`)
247
- * and an optional `--max-ticks <n>`. Returns undefined for a malformed interval.
256
+ * Parse `ghostrail watch` options: `--interval`/`-i <duration>` (default `60s`),
257
+ * an optional `--max-ticks <n>`, and `--self-update-restart`. Returns undefined
258
+ * for a malformed interval.
248
259
  */
249
260
  export function parseWatchFlags(argv) {
250
261
  let intervalLabel = "60s";
251
262
  let maxTicks;
263
+ let selfUpdateRestart = false;
252
264
  for (let i = 0; i < argv.length; i++) {
253
265
  const arg = argv[i];
254
266
  const next = argv[i + 1];
@@ -262,13 +274,51 @@ export function parseWatchFlags(argv) {
262
274
  maxTicks = n;
263
275
  i++;
264
276
  }
277
+ else if (arg === "--self-update-restart") {
278
+ selfUpdateRestart = true;
279
+ }
265
280
  }
266
281
  const intervalMs = parseDuration(intervalLabel);
267
282
  if (intervalMs === null)
268
283
  return undefined;
269
- return maxTicks === undefined
270
- ? { intervalMs, intervalLabel }
271
- : { intervalMs, intervalLabel, maxTicks };
284
+ return {
285
+ intervalMs,
286
+ intervalLabel,
287
+ ...(maxTicks === undefined ? {} : { maxTicks }),
288
+ ...(selfUpdateRestart ? { selfUpdateRestart: true } : {}),
289
+ };
290
+ }
291
+ /** Detached so the relaunch survives this process exiting right after. */
292
+ function defaultRestart(watchArgv) {
293
+ spawn("ghostrail", ["watch", ...watchArgv], { stdio: "inherit", detached: true }).unref();
294
+ }
295
+ /**
296
+ * `--self-update-restart`'s whole behavior: called between ticks (never
297
+ * mid-tick, since `watchLoop` awaits `tick()` to completion first). Checks
298
+ * whether a newer ghostrail is published; if not, this is a no-op — refusing
299
+ * to start an update *is* the "pause ticks" the feature asks for, since the
300
+ * caller simply lets the loop continue. If a newer version exists, updates the
301
+ * same way `ghostrail self-update` does and, only on success, relaunches a
302
+ * detached process with the exact argv `watch` was given. A failed update is
303
+ * reported and left running on the current version rather than restarted.
304
+ */
305
+ export async function maybeSelfUpdateAndRestart(watchArgv, streams, deps = {}) {
306
+ const current = deps.currentVersion ?? VERSION;
307
+ const latestVersion = deps.latestVersion ?? registryLatestVersion;
308
+ const selfUpdate = deps.selfUpdate ?? cmdSelfUpdate;
309
+ const restart = deps.restart ?? defaultRestart;
310
+ const latest = await latestVersion();
311
+ if (latest === undefined || latest === current)
312
+ return false;
313
+ streams.err(`self-update-restart: ${current} -> ${latest} available; updating\n`);
314
+ const code = await selfUpdate([], streams);
315
+ if (code !== 0) {
316
+ streams.err("self-update-restart: update failed; staying on the running version\n");
317
+ return false;
318
+ }
319
+ streams.err(`self-update-restart: restarting: ghostrail watch ${watchArgv.join(" ")}\n`);
320
+ restart(watchArgv);
321
+ return true;
272
322
  }
273
323
  /**
274
324
  * The passes this config can run under `watch`: the fix queue always, plus
@@ -293,6 +343,10 @@ function schedulablePasses(setup, streams) {
293
343
  streams.err(`not watching triage: ${error instanceof Error ? error.message : String(error)}\n`);
294
344
  }
295
345
  }
346
+ if (setup.config.release !== undefined) {
347
+ const { deps, config: releaseConfig } = buildReleaseDeps(setup.config, setup.ctx);
348
+ passes.release = () => releaseTick(deps, releaseConfig);
349
+ }
296
350
  return passes;
297
351
  }
298
352
  /** The intervals for the passes that are actually wired up. */
@@ -304,11 +358,14 @@ function intervalsOf(config, passes) {
304
358
  if (passes.triage !== undefined && config.triage?.everyMs !== undefined) {
305
359
  intervals.triage = config.triage.everyMs;
306
360
  }
361
+ if (passes.release !== undefined) {
362
+ intervals.release = true;
363
+ }
307
364
  return intervals;
308
365
  }
309
- /** The ", plus respond every 5m" tail of the watch banner. */
366
+ /** The ", plus respond and triage" tail of the watch banner. */
310
367
  function describeSchedule(passes) {
311
- const extra = ["respond", "triage"].filter((pass) => passes[pass] !== undefined);
368
+ const extra = ["respond", "triage", "release"].filter((pass) => passes[pass] !== undefined);
312
369
  return extra.length === 0 ? "" : `, plus ${extra.join(" and ")}`;
313
370
  }
314
371
  export async function cmdWatch(argv, streams, env = process.env, cwd = process.cwd()) {
@@ -318,16 +375,29 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
318
375
  streams.err('invalid --interval (use e.g. "30s", "5m", "1h")\n');
319
376
  return 1;
320
377
  }
321
- const setup = await prepareLoop(flags, streams, env);
378
+ // The persisting logger has to be the one the loop deps are built with, not a
379
+ // second one made afterwards: the engine narrates through `ctx.logger`, and a
380
+ // logger `watch` keeps to itself only ever sees pause, resume, and a thrown
381
+ // tick, which is why the log stayed empty (TJ-1682).
382
+ const setup = await prepareLoop(flags, streams, env, (dir, secrets) => watchLogger(streams, secrets, dir));
322
383
  if (setup === undefined)
323
384
  return 1;
324
385
  const controller = new AbortController();
325
386
  const onSignal = () => controller.abort();
326
387
  process.once("SIGINT", onSignal);
327
388
  process.once("SIGTERM", onSignal);
328
- const logger = watchLogger(streams, setup.secrets, setup.stateDir);
389
+ const logger = setup.logger;
390
+ // A process killed mid-tick leaves its marker behind; starting up is the one
391
+ // moment we can be certain no tick of ours is running (TJ-1683).
392
+ try {
393
+ await clearTickStarted(setup.stateDir);
394
+ }
395
+ catch {
396
+ // Display state only. Never worth refusing to start over.
397
+ }
329
398
  const passes = schedulablePasses(setup, streams);
330
399
  streams.err(`watching every ${watch.intervalLabel}${describeSchedule(passes)}; press Ctrl-C to stop\n`);
400
+ let restarting = false;
331
401
  try {
332
402
  const controlDir = setup.stateDir;
333
403
  // In memory, for the life of this process: a restart re-runs every pass
@@ -340,6 +410,15 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
340
410
  isEnabled: async () => (await readControl(controlDir)).enabled,
341
411
  ...(watch.maxTicks === undefined ? {} : { maxTicks: watch.maxTicks }),
342
412
  tick: async () => {
413
+ // Best-effort and for display only (specs/dashboard-log.spec.md): a
414
+ // tick that outlasts the interval is ordinary here, and without this
415
+ // the countdown reaches zero and has nothing left to say (TJ-1683).
416
+ try {
417
+ await markTickStarted(setup.stateDir, Date.now());
418
+ }
419
+ catch {
420
+ // the countdown is a convenience, not something the loop depends on
421
+ }
343
422
  const due = duePasses({
344
423
  now: Date.now(),
345
424
  intervals: intervalsOf(setup.config, passes),
@@ -351,7 +430,11 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
351
430
  continue;
352
431
  try {
353
432
  const result = await run();
354
- streams.out(`${pass}: ${redactSecrets(JSON.stringify(result, null, 2), setup.secrets)}\n`);
433
+ // Through the logger, not `streams.out`: this is the most useful
434
+ // line a tick produces, and writing it straight to stdout kept it
435
+ // out of the log the dashboard shows (TJ-1682). `watchLogger`
436
+ // redacts, so this no longer redacts a second time.
437
+ logger.info(`${pass}: ${JSON.stringify(result, null, 2)}`);
355
438
  }
356
439
  catch (error) {
357
440
  // One broken pass must not disable the other two, and must not
@@ -360,7 +443,8 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
360
443
  // an agent-backed pass means spending money in a loop.
361
444
  logger.warn(`${pass} pass failed: ${error instanceof Error ? error.message : String(error)}`);
362
445
  }
363
- if (pass !== "fix")
446
+ // `release` has no interval to stamp (see PassIntervals), same as `fix`.
447
+ if (pass !== "fix" && pass !== "release")
364
448
  lastRunAt[pass] = Date.now();
365
449
  }
366
450
  // Best-effort and for display only (specs/dashboard-log.spec.md): the
@@ -372,9 +456,17 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
372
456
  catch {
373
457
  // the countdown is a convenience, not something the loop depends on
374
458
  }
459
+ // Checked once per tick, never mid-tick: `watchLoop` awaits this whole
460
+ // callback before starting the next one, so this only ever runs
461
+ // between ticks (specs/self-update-restart.spec.md).
462
+ if (watch.selfUpdateRestart === true) {
463
+ restarting = await maybeSelfUpdateAndRestart(argv, streams);
464
+ if (restarting)
465
+ controller.abort();
466
+ }
375
467
  },
376
468
  });
377
- streams.err(`stopped after ${ticks} tick(s)\n`);
469
+ streams.err(restarting ? "stopped for self-update-restart\n" : `stopped after ${ticks} tick(s)\n`);
378
470
  return 0;
379
471
  }
380
472
  finally {
@@ -471,6 +563,43 @@ export async function cmdRespond(argv, streams, env = process.env, cwd = process
471
563
  streams.out(`${redactSecrets(JSON.stringify(results, null, 2), secrets)}\n`);
472
564
  return results.some((r) => r.state === "failed") ? 1 : 0;
473
565
  }
566
+ /** `ghostrail release`: cut one release now. What "release" means is entirely [release].prompt's business. */
567
+ export async function cmdRelease(argv, streams, env = process.env, cwd = process.cwd()) {
568
+ const flags = parseCommandFlags(argv, { config: "ghostrail.toml", repo: cwd });
569
+ const secrets = await applySecrets(flags, env);
570
+ const loaded = await loadConfig(flags, streams, env);
571
+ if (loaded === undefined)
572
+ return 1;
573
+ const { config, promptSourceDir } = loaded;
574
+ if (config.release === undefined) {
575
+ streams.err("no [release] section in the config; add one to use `ghostrail release`\n");
576
+ return 1;
577
+ }
578
+ const claudeProfileDir = await resolveClaudeProfile(env);
579
+ const releaseState = await prepareState(flags.repo, config, env, streams);
580
+ const releaseWork = await resolveWorkRepo({
581
+ repoPath: flags.repo,
582
+ stateDir: releaseState.dir,
583
+ config,
584
+ env,
585
+ });
586
+ if (releaseWork.note !== undefined)
587
+ streams.err(`${releaseWork.note}\n`);
588
+ // No LINEAR_API_KEY requirement: a release touches no tracker item.
589
+ const { deps, config: releaseConfig } = buildReleaseDeps(config, {
590
+ repoPath: flags.repo,
591
+ ...(releaseWork.path === undefined ? {} : { workRepoPath: releaseWork.path }),
592
+ stateDir: releaseState.dir,
593
+ linearApiKey: env.LINEAR_API_KEY ?? "",
594
+ lock: { acquire: async () => true, release: async () => { } },
595
+ ...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
596
+ ...(promptSourceDir === undefined ? {} : { promptSourceDir }),
597
+ logger: makeLogger(streams, secrets),
598
+ });
599
+ const result = await releaseNow(deps, releaseConfig);
600
+ streams.out(`${redactSecrets(JSON.stringify(result, null, 2), secrets)}\n`);
601
+ return result.state === "failed" ? 1 : 0;
602
+ }
474
603
  /** Parse `board` flags: `--repo`, `--port`, `--bind`, `--token`. */
475
604
  export function parseBoardFlags(argv, defaults) {
476
605
  const flags = { ...defaults };
@@ -546,6 +675,10 @@ export async function cmdBoard(argv, streams, env = process.env, cwd = process.c
546
675
  bind: flags.bind,
547
676
  gitHost,
548
677
  control: fileControl(store.dir),
678
+ // Same directory `buildRespondDeps` resolves for this repo and config
679
+ // (`stateDirFor`), so a "Fix CI" click here is what the next
680
+ // `ghostrail respond` tick actually reads.
681
+ ciFixQueue: new FileCiFixQueueStore(join(store.dir, "ci-fix-queue.json")),
549
682
  ...(flags.token === undefined ? {} : { token: flags.token }),
550
683
  ...(unauthenticated ? { allowNoToken: true } : {}),
551
684
  });
@@ -636,12 +769,23 @@ function dashboardDeps(env, manager) {
636
769
  // subprocess. A machine with no `gh` and no credentials still serves the
637
770
  // whole dashboard, with PR status reported as unavailable.
638
771
  prStatus: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).getPrStatus(prNumber),
772
+ // The same directory `buildRespondDeps` resolves for this repo and
773
+ // config, so a "Fix CI" click here is what that ghostrail's next
774
+ // `ghostrail respond` tick actually reads.
775
+ ciFixQueue: async (entry) => new FileCiFixQueueStore(join(await defaultStateDir(entry), "ci-fix-queue.json")),
639
776
  // Posts the same trigger comment a human typing "resolve conflicts and
640
777
  // merge" by hand would; the next respond pass does the actual work (see
641
778
  // specs/dashboard-review-mode.spec.md). A machine with no `gh` and no
642
779
  // credentials still serves the whole dashboard; the route just answers
643
780
  // 503 rather than failing to start.
644
781
  mergeTrigger: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).postComment(prNumber, MERGE_TRIGGER_COMMENT),
782
+ releaseEnabled: (path) => releaseEnabledFor(path),
783
+ // Queuing a release is a local file write plus a store event, not a `gh`
784
+ // call, so this never needs the "no credentials" fallback mergeTrigger has.
785
+ releaseTrigger: async (entry) => {
786
+ const dir = await defaultStateDir(entry);
787
+ return requestRelease(dir, new FileStore(dir));
788
+ },
645
789
  readRegistry: () => store.readRegistry(),
646
790
  writeRegistry: (entries) => store.writeRegistry(entries),
647
791
  health: ghostrailHealth,
@@ -728,6 +872,25 @@ export async function ghostrailHealth(path) {
728
872
  return "missing";
729
873
  return (await resolveConfigPath(path)) !== undefined ? "ok" : "no config";
730
874
  }
875
+ /**
876
+ * Whether a registered ghostrail has `[release]` configured — the dashboard's
877
+ * gate for showing the release button. False for anything that cannot be
878
+ * read or parsed, the same leniency `ghostrailHealth` already has: a config
879
+ * problem hides the button rather than erroring the whole listing.
880
+ */
881
+ export async function releaseEnabledFor(path) {
882
+ const configPath = await resolveConfigPath(path);
883
+ if (configPath === undefined)
884
+ return false;
885
+ try {
886
+ const toml = await readFile(configPath, "utf8");
887
+ const parsed = parseConfig(toml);
888
+ return parsed.ok && parsed.value.release !== undefined;
889
+ }
890
+ catch {
891
+ return false;
892
+ }
893
+ }
731
894
  /** `ghostrail ls`: the ghostrails this account knows about. */
732
895
  export async function cmdLs(_argv, streams, env = process.env) {
733
896
  const store = globalStoreFor(env);
@@ -1740,6 +1903,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1740
1903
  }
1741
1904
  await registerGhostrail(repo, env, streams, { repoint, forceRegister: register });
1742
1905
  streams.out(await initNextStepsHint(env, cwd));
1906
+ streams.out(`${STABILITY_NOTICE}\n`);
1743
1907
  return 0;
1744
1908
  }
1745
1909
  /**
@@ -1877,6 +2041,7 @@ async function npmInstallGlobalResult(version) {
1877
2041
  * install` gives you exactly x.y.z.
1878
2042
  */
1879
2043
  export async function cmdInstall(argv, streams, env = process.env, cwd = process.cwd(), deps = {}) {
2044
+ streams.out(`${STABILITY_NOTICE}\n`);
1880
2045
  streams.out(`installing ghostrail@${VERSION} globally...\n`);
1881
2046
  const code = await npmInstallGlobal(VERSION);
1882
2047
  if (code !== 0) {