ghostrail 0.20.1 → 0.22.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 (118) hide show
  1. package/dist/board/page.d.ts.map +1 -1
  2. package/dist/board/page.js +76 -14
  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 +56 -1
  9. package/dist/cli/commands.d.ts.map +1 -1
  10. package/dist/cli/commands.js +203 -20
  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 +10 -0
  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.map +1 -1
  25. package/dist/dashboard/page.js +226 -9
  26. package/dist/dashboard/page.js.map +1 -1
  27. package/dist/dashboard/review-page.d.ts +5 -0
  28. package/dist/dashboard/review-page.d.ts.map +1 -1
  29. package/dist/dashboard/review-page.js +23 -3
  30. package/dist/dashboard/review-page.js.map +1 -1
  31. package/dist/dashboard/routes.d.ts +17 -0
  32. package/dist/dashboard/routes.d.ts.map +1 -1
  33. package/dist/dashboard/routes.js +25 -0
  34. package/dist/dashboard/routes.js.map +1 -1
  35. package/dist/dashboard/server.d.ts +29 -0
  36. package/dist/dashboard/server.d.ts.map +1 -1
  37. package/dist/dashboard/server.js +91 -2
  38. package/dist/dashboard/server.js.map +1 -1
  39. package/dist/doctor/checks.d.ts +1 -0
  40. package/dist/doctor/checks.d.ts.map +1 -1
  41. package/dist/doctor/checks.js +1 -1
  42. package/dist/doctor/checks.js.map +1 -1
  43. package/dist/doctor/probes.d.ts +19 -3
  44. package/dist/doctor/probes.d.ts.map +1 -1
  45. package/dist/doctor/probes.js +22 -27
  46. package/dist/doctor/probes.js.map +1 -1
  47. package/dist/doctor/run.d.ts.map +1 -1
  48. package/dist/doctor/run.js +17 -3
  49. package/dist/doctor/run.js.map +1 -1
  50. package/dist/factory/build.d.ts +6 -0
  51. package/dist/factory/build.d.ts.map +1 -1
  52. package/dist/factory/build.js +26 -0
  53. package/dist/factory/build.js.map +1 -1
  54. package/dist/loop/next-tick.d.ts +50 -0
  55. package/dist/loop/next-tick.d.ts.map +1 -0
  56. package/dist/loop/next-tick.js +87 -0
  57. package/dist/loop/next-tick.js.map +1 -0
  58. package/dist/loop/schedule.d.ts +11 -2
  59. package/dist/loop/schedule.d.ts.map +1 -1
  60. package/dist/loop/schedule.js +3 -1
  61. package/dist/loop/schedule.js.map +1 -1
  62. package/dist/loop/tick-log.d.ts +13 -0
  63. package/dist/loop/tick-log.d.ts.map +1 -0
  64. package/dist/loop/tick-log.js +64 -0
  65. package/dist/loop/tick-log.js.map +1 -0
  66. package/dist/publish/gh.d.ts +43 -2
  67. package/dist/publish/gh.d.ts.map +1 -1
  68. package/dist/publish/gh.js +80 -2
  69. package/dist/publish/gh.js.map +1 -1
  70. package/dist/publish/githost.d.ts +18 -1
  71. package/dist/publish/githost.d.ts.map +1 -1
  72. package/dist/publish/githost.js +13 -0
  73. package/dist/publish/githost.js.map +1 -1
  74. package/dist/publish/index.d.ts +1 -1
  75. package/dist/publish/index.d.ts.map +1 -1
  76. package/dist/publish/index.js +1 -1
  77. package/dist/publish/index.js.map +1 -1
  78. package/dist/release/index.d.ts +2 -0
  79. package/dist/release/index.d.ts.map +1 -0
  80. package/dist/release/index.js +2 -0
  81. package/dist/release/index.js.map +1 -0
  82. package/dist/release/release.d.ts +67 -0
  83. package/dist/release/release.d.ts.map +1 -0
  84. package/dist/release/release.js +193 -0
  85. package/dist/release/release.js.map +1 -0
  86. package/dist/respond/ci-fix-queue.d.ts +40 -0
  87. package/dist/respond/ci-fix-queue.d.ts.map +1 -0
  88. package/dist/respond/ci-fix-queue.js +74 -0
  89. package/dist/respond/ci-fix-queue.js.map +1 -0
  90. package/dist/respond/index.d.ts +2 -1
  91. package/dist/respond/index.d.ts.map +1 -1
  92. package/dist/respond/index.js +2 -1
  93. package/dist/respond/index.js.map +1 -1
  94. package/dist/respond/merge.d.ts.map +1 -1
  95. package/dist/respond/merge.js +17 -1
  96. package/dist/respond/merge.js.map +1 -1
  97. package/dist/respond/respond.d.ts +33 -1
  98. package/dist/respond/respond.d.ts.map +1 -1
  99. package/dist/respond/respond.js +155 -9
  100. package/dist/respond/respond.js.map +1 -1
  101. package/dist/store/events.d.ts +30 -0
  102. package/dist/store/events.d.ts.map +1 -1
  103. package/dist/store/events.js +38 -0
  104. package/dist/store/events.js.map +1 -1
  105. package/dist/store/index.d.ts +1 -1
  106. package/dist/store/index.d.ts.map +1 -1
  107. package/dist/store/index.js +1 -1
  108. package/dist/store/index.js.map +1 -1
  109. package/dist/triage/engine.d.ts +6 -2
  110. package/dist/triage/engine.d.ts.map +1 -1
  111. package/dist/triage/engine.js +6 -1
  112. package/dist/triage/engine.js.map +1 -1
  113. package/package.json +1 -1
  114. package/skill/ghostrail/SKILL.md +15 -4
  115. package/templates/code/ghostrail.toml +3 -0
  116. package/templates/code/prompts/release.md +40 -0
  117. package/templates/code/prompts/respond.md +2 -1
  118. 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,10 +28,14 @@ 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 { clearTickStarted, markTickStarted, writeNextTickAt } from "../loop/next-tick.js";
31
32
  import { duePasses, } from "../loop/schedule.js";
33
+ import { appendLog } from "../loop/tick-log.js";
32
34
  import { watchLoop } from "../loop/watch.js";
33
35
  import { GhHost, parseRepoSlug } from "../publish/gh.js";
36
+ import { releaseNow, releaseTick, requestRelease } from "../release/release.js";
34
37
  import { resolveIgnoredAuthors } from "../respond/authors.js";
38
+ import { FileCiFixQueueStore } from "../respond/ci-fix-queue.js";
35
39
  import { respondTick } from "../respond/respond.js";
36
40
  import { GLOBAL_SECRETS_FILE, appendPlaceholders, applyOverrides, launchEditor as defaultLaunchEditor, loadEnvOverrides, missingSecrets, readSecretsFile, redactSecrets, renderFile, resolveEditor, secretValuesOf, writeSecretsFile, } from "../secrets/index.js";
37
41
  import { DASHBOARD_UNIT, renderDashboardUnit, servicePath, stopTimeoutSeconds, systemdManager, unitName, } from "../service/index.js";
@@ -68,6 +72,27 @@ function makeLogger(streams, secrets) {
68
72
  warn: (message) => streams.err(`${redactSecrets(message, secrets)}\n`),
69
73
  };
70
74
  }
75
+ /**
76
+ * `watch`'s logger: everything `makeLogger` does, plus a redacted copy of
77
+ * every line appended to this repo's rolling watch log (`src/loop/tick-log.ts`),
78
+ * so the dashboard has something to show for a daemon it has no live
79
+ * connection to. `ghostrail run` and a standalone respond/triage pass keep
80
+ * using the plain logger: only the watch loop is what the dashboard's log
81
+ * section is about.
82
+ */
83
+ function watchLogger(streams, secrets, dir) {
84
+ const base = makeLogger(streams, secrets);
85
+ return {
86
+ info: (message) => {
87
+ base.info(message);
88
+ void appendLog(dir, redactSecrets(message, secrets));
89
+ },
90
+ warn: (message) => {
91
+ base.warn(message);
92
+ void appendLog(dir, `WARN ${redactSecrets(message, secrets)}`);
93
+ },
94
+ };
95
+ }
71
96
  /**
72
97
  * The effective `ghostrail.toml` path for a repo: the repo's own file if it
73
98
  * has one, else wherever its `.ghostrail-config` pointer file names, else
@@ -164,8 +189,13 @@ async function resolveClaudeProfile(env) {
164
189
  * Shared setup for the run/watch loops: apply secrets, load the merged config,
165
190
  * require the Linear key, and assemble the loop deps. Prints errors and returns
166
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.
167
197
  */
168
- async function prepareLoop(flags, streams, env) {
198
+ export async function prepareLoop(flags, streams, env, loggerFor = (_dir, secrets) => makeLogger(streams, secrets)) {
169
199
  const secrets = await applySecrets(flags, env);
170
200
  const loaded = await loadConfig(flags, streams, env);
171
201
  if (loaded === undefined)
@@ -189,6 +219,9 @@ async function prepareLoop(flags, streams, env) {
189
219
  // of one repo running overlapping ticks: while the store lived inside each
190
220
  // checkout they took different locks and nothing serialized them.
191
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);
192
225
  const ctx = {
193
226
  repoPath: flags.repo,
194
227
  ...(work.path === undefined ? {} : { workRepoPath: work.path }),
@@ -204,10 +237,10 @@ async function prepareLoop(flags, streams, env) {
204
237
  ...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
205
238
  ...(promptSourceDir === undefined ? {} : { promptSourceDir }),
206
239
  ...(repoSlug === undefined ? {} : { repoSlug }),
207
- logger: makeLogger(streams, secrets),
240
+ logger,
208
241
  };
209
242
  const deps = buildLoopDeps(config, ctx);
210
- return { config, deps, secrets, stateDir: state.dir, ctx };
243
+ return { config, deps, secrets, stateDir: state.dir, ctx, logger };
211
244
  }
212
245
  /** `ghostrail run`: drain eligible work once. */
213
246
  export async function cmdRun(argv, streams, env = process.env, cwd = process.cwd()) {
@@ -270,6 +303,10 @@ function schedulablePasses(setup, streams) {
270
303
  streams.err(`not watching triage: ${error instanceof Error ? error.message : String(error)}\n`);
271
304
  }
272
305
  }
306
+ if (setup.config.release !== undefined) {
307
+ const { deps, config: releaseConfig } = buildReleaseDeps(setup.config, setup.ctx);
308
+ passes.release = () => releaseTick(deps, releaseConfig);
309
+ }
273
310
  return passes;
274
311
  }
275
312
  /** The intervals for the passes that are actually wired up. */
@@ -281,11 +318,14 @@ function intervalsOf(config, passes) {
281
318
  if (passes.triage !== undefined && config.triage?.everyMs !== undefined) {
282
319
  intervals.triage = config.triage.everyMs;
283
320
  }
321
+ if (passes.release !== undefined) {
322
+ intervals.release = true;
323
+ }
284
324
  return intervals;
285
325
  }
286
- /** The ", plus respond every 5m" tail of the watch banner. */
326
+ /** The ", plus respond and triage" tail of the watch banner. */
287
327
  function describeSchedule(passes) {
288
- const extra = ["respond", "triage"].filter((pass) => passes[pass] !== undefined);
328
+ const extra = ["respond", "triage", "release"].filter((pass) => passes[pass] !== undefined);
289
329
  return extra.length === 0 ? "" : `, plus ${extra.join(" and ")}`;
290
330
  }
291
331
  export async function cmdWatch(argv, streams, env = process.env, cwd = process.cwd()) {
@@ -295,14 +335,26 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
295
335
  streams.err('invalid --interval (use e.g. "30s", "5m", "1h")\n');
296
336
  return 1;
297
337
  }
298
- const setup = await prepareLoop(flags, streams, env);
338
+ // The persisting logger has to be the one the loop deps are built with, not a
339
+ // second one made afterwards: the engine narrates through `ctx.logger`, and a
340
+ // logger `watch` keeps to itself only ever sees pause, resume, and a thrown
341
+ // tick, which is why the log stayed empty (TJ-1682).
342
+ const setup = await prepareLoop(flags, streams, env, (dir, secrets) => watchLogger(streams, secrets, dir));
299
343
  if (setup === undefined)
300
344
  return 1;
301
345
  const controller = new AbortController();
302
346
  const onSignal = () => controller.abort();
303
347
  process.once("SIGINT", onSignal);
304
348
  process.once("SIGTERM", onSignal);
305
- const logger = makeLogger(streams, setup.secrets);
349
+ const logger = setup.logger;
350
+ // A process killed mid-tick leaves its marker behind; starting up is the one
351
+ // moment we can be certain no tick of ours is running (TJ-1683).
352
+ try {
353
+ await clearTickStarted(setup.stateDir);
354
+ }
355
+ catch {
356
+ // Display state only. Never worth refusing to start over.
357
+ }
306
358
  const passes = schedulablePasses(setup, streams);
307
359
  streams.err(`watching every ${watch.intervalLabel}${describeSchedule(passes)}; press Ctrl-C to stop\n`);
308
360
  try {
@@ -317,6 +369,15 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
317
369
  isEnabled: async () => (await readControl(controlDir)).enabled,
318
370
  ...(watch.maxTicks === undefined ? {} : { maxTicks: watch.maxTicks }),
319
371
  tick: async () => {
372
+ // Best-effort and for display only (specs/dashboard-log.spec.md): a
373
+ // tick that outlasts the interval is ordinary here, and without this
374
+ // the countdown reaches zero and has nothing left to say (TJ-1683).
375
+ try {
376
+ await markTickStarted(setup.stateDir, Date.now());
377
+ }
378
+ catch {
379
+ // the countdown is a convenience, not something the loop depends on
380
+ }
320
381
  const due = duePasses({
321
382
  now: Date.now(),
322
383
  intervals: intervalsOf(setup.config, passes),
@@ -328,7 +389,11 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
328
389
  continue;
329
390
  try {
330
391
  const result = await run();
331
- streams.out(`${pass}: ${redactSecrets(JSON.stringify(result, null, 2), setup.secrets)}\n`);
392
+ // Through the logger, not `streams.out`: this is the most useful
393
+ // line a tick produces, and writing it straight to stdout kept it
394
+ // out of the log the dashboard shows (TJ-1682). `watchLogger`
395
+ // redacts, so this no longer redacts a second time.
396
+ logger.info(`${pass}: ${JSON.stringify(result, null, 2)}`);
332
397
  }
333
398
  catch (error) {
334
399
  // One broken pass must not disable the other two, and must not
@@ -337,9 +402,19 @@ export async function cmdWatch(argv, streams, env = process.env, cwd = process.c
337
402
  // an agent-backed pass means spending money in a loop.
338
403
  logger.warn(`${pass} pass failed: ${error instanceof Error ? error.message : String(error)}`);
339
404
  }
340
- if (pass !== "fix")
405
+ // `release` has no interval to stamp (see PassIntervals), same as `fix`.
406
+ if (pass !== "fix" && pass !== "release")
341
407
  lastRunAt[pass] = Date.now();
342
408
  }
409
+ // Best-effort and for display only (specs/dashboard-log.spec.md): the
410
+ // dashboard's countdown reads this back, but nothing here depends on
411
+ // it, so a write failure must not touch the loop.
412
+ try {
413
+ await writeNextTickAt(setup.stateDir, Date.now() + watch.intervalMs);
414
+ }
415
+ catch {
416
+ // the countdown is a convenience, not something the loop depends on
417
+ }
343
418
  },
344
419
  });
345
420
  streams.err(`stopped after ${ticks} tick(s)\n`);
@@ -439,6 +514,43 @@ export async function cmdRespond(argv, streams, env = process.env, cwd = process
439
514
  streams.out(`${redactSecrets(JSON.stringify(results, null, 2), secrets)}\n`);
440
515
  return results.some((r) => r.state === "failed") ? 1 : 0;
441
516
  }
517
+ /** `ghostrail release`: cut one release now. What "release" means is entirely [release].prompt's business. */
518
+ export async function cmdRelease(argv, streams, env = process.env, cwd = process.cwd()) {
519
+ const flags = parseCommandFlags(argv, { config: "ghostrail.toml", repo: cwd });
520
+ const secrets = await applySecrets(flags, env);
521
+ const loaded = await loadConfig(flags, streams, env);
522
+ if (loaded === undefined)
523
+ return 1;
524
+ const { config, promptSourceDir } = loaded;
525
+ if (config.release === undefined) {
526
+ streams.err("no [release] section in the config; add one to use `ghostrail release`\n");
527
+ return 1;
528
+ }
529
+ const claudeProfileDir = await resolveClaudeProfile(env);
530
+ const releaseState = await prepareState(flags.repo, config, env, streams);
531
+ const releaseWork = await resolveWorkRepo({
532
+ repoPath: flags.repo,
533
+ stateDir: releaseState.dir,
534
+ config,
535
+ env,
536
+ });
537
+ if (releaseWork.note !== undefined)
538
+ streams.err(`${releaseWork.note}\n`);
539
+ // No LINEAR_API_KEY requirement: a release touches no tracker item.
540
+ const { deps, config: releaseConfig } = buildReleaseDeps(config, {
541
+ repoPath: flags.repo,
542
+ ...(releaseWork.path === undefined ? {} : { workRepoPath: releaseWork.path }),
543
+ stateDir: releaseState.dir,
544
+ linearApiKey: env.LINEAR_API_KEY ?? "",
545
+ lock: { acquire: async () => true, release: async () => { } },
546
+ ...(claudeProfileDir === undefined ? {} : { claudeProfileDir }),
547
+ ...(promptSourceDir === undefined ? {} : { promptSourceDir }),
548
+ logger: makeLogger(streams, secrets),
549
+ });
550
+ const result = await releaseNow(deps, releaseConfig);
551
+ streams.out(`${redactSecrets(JSON.stringify(result, null, 2), secrets)}\n`);
552
+ return result.state === "failed" ? 1 : 0;
553
+ }
442
554
  /** Parse `board` flags: `--repo`, `--port`, `--bind`, `--token`. */
443
555
  export function parseBoardFlags(argv, defaults) {
444
556
  const flags = { ...defaults };
@@ -514,6 +626,10 @@ export async function cmdBoard(argv, streams, env = process.env, cwd = process.c
514
626
  bind: flags.bind,
515
627
  gitHost,
516
628
  control: fileControl(store.dir),
629
+ // Same directory `buildRespondDeps` resolves for this repo and config
630
+ // (`stateDirFor`), so a "Fix CI" click here is what the next
631
+ // `ghostrail respond` tick actually reads.
632
+ ciFixQueue: new FileCiFixQueueStore(join(store.dir, "ci-fix-queue.json")),
517
633
  ...(flags.token === undefined ? {} : { token: flags.token }),
518
634
  ...(unauthenticated ? { allowNoToken: true } : {}),
519
635
  });
@@ -604,12 +720,23 @@ function dashboardDeps(env, manager) {
604
720
  // subprocess. A machine with no `gh` and no credentials still serves the
605
721
  // whole dashboard, with PR status reported as unavailable.
606
722
  prStatus: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).getPrStatus(prNumber),
723
+ // The same directory `buildRespondDeps` resolves for this repo and
724
+ // config, so a "Fix CI" click here is what that ghostrail's next
725
+ // `ghostrail respond` tick actually reads.
726
+ ciFixQueue: async (entry) => new FileCiFixQueueStore(join(await defaultStateDir(entry), "ci-fix-queue.json")),
607
727
  // Posts the same trigger comment a human typing "resolve conflicts and
608
728
  // merge" by hand would; the next respond pass does the actual work (see
609
729
  // specs/dashboard-review-mode.spec.md). A machine with no `gh` and no
610
730
  // credentials still serves the whole dashboard; the route just answers
611
731
  // 503 rather than failing to start.
612
732
  mergeTrigger: async (entry, prNumber) => new GhHost(repoGhRunner(entry.path), await slugFor(entry)).postComment(prNumber, MERGE_TRIGGER_COMMENT),
733
+ releaseEnabled: (path) => releaseEnabledFor(path),
734
+ // Queuing a release is a local file write plus a store event, not a `gh`
735
+ // call, so this never needs the "no credentials" fallback mergeTrigger has.
736
+ releaseTrigger: async (entry) => {
737
+ const dir = await defaultStateDir(entry);
738
+ return requestRelease(dir, new FileStore(dir));
739
+ },
613
740
  readRegistry: () => store.readRegistry(),
614
741
  writeRegistry: (entries) => store.writeRegistry(entries),
615
742
  health: ghostrailHealth,
@@ -696,6 +823,25 @@ export async function ghostrailHealth(path) {
696
823
  return "missing";
697
824
  return (await resolveConfigPath(path)) !== undefined ? "ok" : "no config";
698
825
  }
826
+ /**
827
+ * Whether a registered ghostrail has `[release]` configured — the dashboard's
828
+ * gate for showing the release button. False for anything that cannot be
829
+ * read or parsed, the same leniency `ghostrailHealth` already has: a config
830
+ * problem hides the button rather than erroring the whole listing.
831
+ */
832
+ export async function releaseEnabledFor(path) {
833
+ const configPath = await resolveConfigPath(path);
834
+ if (configPath === undefined)
835
+ return false;
836
+ try {
837
+ const toml = await readFile(configPath, "utf8");
838
+ const parsed = parseConfig(toml);
839
+ return parsed.ok && parsed.value.release !== undefined;
840
+ }
841
+ catch {
842
+ return false;
843
+ }
844
+ }
699
845
  /** `ghostrail ls`: the ghostrails this account knows about. */
700
846
  export async function cmdLs(_argv, streams, env = process.env) {
701
847
  const store = globalStoreFor(env);
@@ -1192,6 +1338,12 @@ async function ghostrailName(repo) {
1192
1338
  * as it is, because re-running `init --update` must not switch `watch` back on
1193
1339
  * for a ghostrail the operator deliberately turned off.
1194
1340
  *
1341
+ * `options.repoint` gates the one case that is an act, not an add: an entry
1342
+ * that already has an identity, pointing at a checkout other than this one.
1343
+ * Off by default, an existing entry is reported and left alone rather than
1344
+ * rewritten, so a second clone of a registered repo cannot silently steal the
1345
+ * entry that governs what `start` runs unattended (TJ-1652).
1346
+ *
1195
1347
  * Never fails the command. Scaffolding the repo is the job; registering it is a
1196
1348
  * convenience, and aborting a good scaffold over an unwritable config dir would
1197
1349
  * be the worse trade.
@@ -1241,11 +1393,11 @@ async function prepareState(repo, config, env, streams) {
1241
1393
  streams.err(`${note}\n`);
1242
1394
  return { dir: state.dir, repoId: state.repoId };
1243
1395
  }
1244
- async function registerGhostrail(repo, env, streams, forceRegister = false) {
1396
+ async function registerGhostrail(repo, env, streams, options = {}) {
1245
1397
  const store = globalStoreFor(env);
1246
1398
  const path = resolve(repo);
1247
1399
  try {
1248
- if (!forceRegister && (await isTempPath(path, env))) {
1400
+ if (options.forceRegister !== true && (await isTempPath(path, env))) {
1249
1401
  // `watch` defaults to true and, for a repo with no remote, `path` is the
1250
1402
  // identity: registering here would switch on a service unit pointed at a
1251
1403
  // directory that is going to vanish. Scaffolding still happened; only the
@@ -1261,12 +1413,27 @@ async function registerGhostrail(repo, env, streams, forceRegister = false) {
1261
1413
  const repoId = await resolveRepoId(path);
1262
1414
  const existing = await findForRepoResolving(entries, repoId, path, resolveRepoId);
1263
1415
  if (existing !== undefined) {
1416
+ if (existing.repo === repoId && existing.path === path)
1417
+ return;
1418
+ // An entry that already has an identity, at a *different* path than this
1419
+ // checkout, is a deliberate repoint, not a migration. `--update` promises
1420
+ // to write only what it can prove is safe everywhere else, and moving the
1421
+ // entry that governs what `start` runs unattended is a far bigger act
1422
+ // than anything else it does, so it never happens as a side effect of
1423
+ // asking for template changes. Report it and leave the registry alone,
1424
+ // the way a stale entry is already reported rather than pruned.
1425
+ if (existing.repo !== undefined && options.repoint !== true) {
1426
+ streams.out(`this repo already has the ghostrail "${existing.name}", registered at a different checkout
1427
+ registered: ${existing.path}
1428
+ this one: ${path}
1429
+ \`ghostrail ls\` lists it; re-run with --repoint to point it here instead
1430
+ `);
1431
+ return;
1432
+ }
1264
1433
  // Everything the entry already carries survives, `watch` above all:
1265
1434
  // re-registering must never switch a ghostrail back on that the operator
1266
1435
  // deliberately turned off.
1267
1436
  const adopted = { ...existing, repo: repoId, path };
1268
- if (existing.repo === repoId && existing.path === path)
1269
- return;
1270
1437
  // By the entry's *old* key: adopting an identity re-keys it, so an
1271
1438
  // ordinary upsert would append beside it rather than replace it.
1272
1439
  await store.writeRegistry(replaceEntry(entries, entryKey(existing), adopted));
@@ -1333,7 +1500,7 @@ async function promptPathsAt(configPath) {
1333
1500
  * the template gained, which is what every hand port consisted of before
1334
1501
  * regions existed, and the skill places them in the repo's own wording.
1335
1502
  */
1336
- async function applyTemplateUpdate(template, srcDir, repo, streams, env, register) {
1503
+ async function applyTemplateUpdate(template, srcDir, repo, streams, env, options) {
1337
1504
  const manifest = await readManifest(repo);
1338
1505
  const rels = await scaffoldFiles(srcDir);
1339
1506
  const templatePrompts = (await promptPathsAt(join(srcDir, "ghostrail.toml"))) ?? {};
@@ -1458,7 +1625,10 @@ async function applyTemplateUpdate(template, srcDir, repo, streams, env, registe
1458
1625
  if (Object.keys(written).length > 0 || Object.keys(writtenRegions).length > 0) {
1459
1626
  await writeManifest(repo, mergeManifest(manifest, template, VERSION, written, writtenRegions));
1460
1627
  }
1461
- await registerGhostrail(repo, env, streams, register);
1628
+ await registerGhostrail(repo, env, streams, {
1629
+ repoint: options.repoint,
1630
+ forceRegister: options.register,
1631
+ });
1462
1632
  streams.out("\n");
1463
1633
  if (took > 0)
1464
1634
  streams.out(`took ${took} file(s) you had not edited\n`);
@@ -1502,7 +1672,8 @@ async function removeLocalScaffold(repo, rels) {
1502
1672
  }
1503
1673
  }
1504
1674
  /**
1505
- * `ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register]`:
1675
+ * `ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom
1676
+ * [<path>]] [--register] [--repoint]`:
1506
1677
  * scaffold `ghostrail.toml` and `prompts/` from a quickstart template. Safe for
1507
1678
  * a fresh OR an existing repo: existing files are left untouched (skipped)
1508
1679
  * unless `--force` is given. `--diff` reports drift instead of writing
@@ -1514,6 +1685,11 @@ async function removeLocalScaffold(repo, rels) {
1514
1685
  *
1515
1686
  * Registration is skipped, not just the write, when the target looks like a
1516
1687
  * temp directory (TJ-1654); `--register` registers it anyway.
1688
+ *
1689
+ * `--repoint` opts into the one registry write that is an act rather than an
1690
+ * add: moving an already-registered ghostrail's entry to this checkout when it
1691
+ * is currently pointing somewhere else. Without it, that mismatch is reported
1692
+ * and the registry is left alone (TJ-1652).
1517
1693
  */
1518
1694
  export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.env) {
1519
1695
  let template;
@@ -1521,6 +1697,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1521
1697
  let force = false;
1522
1698
  let diff = false;
1523
1699
  let update = false;
1700
+ let repoint = false;
1524
1701
  let register = false;
1525
1702
  let greenroom = false;
1526
1703
  let greenroomPath;
@@ -1540,6 +1717,9 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1540
1717
  else if (arg === "--update") {
1541
1718
  update = true;
1542
1719
  }
1720
+ else if (arg === "--repoint") {
1721
+ repoint = true;
1722
+ }
1543
1723
  else if (arg === "--register") {
1544
1724
  register = true;
1545
1725
  }
@@ -1555,7 +1735,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1555
1735
  }
1556
1736
  }
1557
1737
  if (template === undefined || !TEMPLATES.includes(template)) {
1558
- streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register]\ntemplates: ${TEMPLATES.join(", ")}\n`);
1738
+ streams.err(`usage: ghostrail init <template> [--repo <path>] [--force|--diff|--update|--greenroom [<path>]] [--register] [--repoint]\ntemplates: ${TEMPLATES.join(", ")}\n`);
1559
1739
  return 1;
1560
1740
  }
1561
1741
  if (diff && force) {
@@ -1578,7 +1758,10 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1578
1758
  const srcDir = join(templatesDir(), template);
1579
1759
  if (update) {
1580
1760
  try {
1581
- return await applyTemplateUpdate(template, srcDir, repo, streams, env, register);
1761
+ return await applyTemplateUpdate(template, srcDir, repo, streams, env, {
1762
+ repoint,
1763
+ register,
1764
+ });
1582
1765
  }
1583
1766
  catch (error) {
1584
1767
  streams.err(`init --update failed: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -1669,7 +1852,7 @@ export async function cmdInit(argv, streams, cwd = process.cwd(), env = process.
1669
1852
  streams.out("this repo's config and prompts now live outside it; keep that pointer file out of\n" +
1670
1853
  "version control (it is host-specific), the same as .ghostrail.env\n");
1671
1854
  }
1672
- await registerGhostrail(repo, env, streams, register);
1855
+ await registerGhostrail(repo, env, streams, { repoint, forceRegister: register });
1673
1856
  streams.out(await initNextStepsHint(env, cwd));
1674
1857
  return 0;
1675
1858
  }