ghostrail 0.7.5 → 0.9.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 (59) hide show
  1. package/dist/agent/result.d.ts +2 -1
  2. package/dist/agent/result.d.ts.map +1 -1
  3. package/dist/agent/result.js +7 -1
  4. package/dist/agent/result.js.map +1 -1
  5. package/dist/cli/commands.d.ts +26 -3
  6. package/dist/cli/commands.d.ts.map +1 -1
  7. package/dist/cli/commands.js +426 -7
  8. package/dist/cli/commands.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +9 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/help.d.ts.map +1 -1
  13. package/dist/commands/help.js +39 -1
  14. package/dist/commands/help.js.map +1 -1
  15. package/dist/global/index.d.ts +1 -0
  16. package/dist/global/index.d.ts.map +1 -1
  17. package/dist/global/index.js +1 -0
  18. package/dist/global/index.js.map +1 -1
  19. package/dist/global/registry.d.ts +63 -0
  20. package/dist/global/registry.d.ts.map +1 -0
  21. package/dist/global/registry.js +120 -0
  22. package/dist/global/registry.js.map +1 -0
  23. package/dist/global/store.d.ts +10 -0
  24. package/dist/global/store.d.ts.map +1 -1
  25. package/dist/global/store.js +25 -1
  26. package/dist/global/store.js.map +1 -1
  27. package/dist/init/drift.d.ts +68 -0
  28. package/dist/init/drift.d.ts.map +1 -0
  29. package/dist/init/drift.js +103 -0
  30. package/dist/init/drift.js.map +1 -0
  31. package/dist/loop/engine.d.ts.map +1 -1
  32. package/dist/loop/engine.js +11 -4
  33. package/dist/loop/engine.js.map +1 -1
  34. package/dist/loop/ports.d.ts +19 -1
  35. package/dist/loop/ports.d.ts.map +1 -1
  36. package/dist/publish/githost.d.ts +9 -3
  37. package/dist/publish/githost.d.ts.map +1 -1
  38. package/dist/publish/githost.js +18 -4
  39. package/dist/publish/githost.js.map +1 -1
  40. package/dist/publish/github-publisher.d.ts +2 -2
  41. package/dist/publish/github-publisher.d.ts.map +1 -1
  42. package/dist/publish/github-publisher.js +3 -3
  43. package/dist/publish/github-publisher.js.map +1 -1
  44. package/dist/service/index.d.ts +3 -0
  45. package/dist/service/index.d.ts.map +1 -0
  46. package/dist/service/index.js +3 -0
  47. package/dist/service/index.js.map +1 -0
  48. package/dist/service/manager.d.ts +69 -0
  49. package/dist/service/manager.d.ts.map +1 -0
  50. package/dist/service/manager.js +107 -0
  51. package/dist/service/manager.js.map +1 -0
  52. package/dist/service/unit.d.ts +60 -0
  53. package/dist/service/unit.d.ts.map +1 -0
  54. package/dist/service/unit.js +111 -0
  55. package/dist/service/unit.js.map +1 -0
  56. package/package.json +1 -1
  57. package/skill/ghostrail/SKILL.md +62 -16
  58. package/templates/code-local/prompts/resolve-issue.md +15 -1
  59. package/templates/content-loop/prompts/draft.md +1 -1
@@ -0,0 +1,107 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import process from "node:process";
5
+ import { spawnCollect } from "../backend/proc.js";
6
+ import { parseUnitStates, renderSystemdUnit, unitName } from "./unit.js";
7
+ /** Which manager this platform offers, whether or not it is supported. */
8
+ export async function detectManager() {
9
+ if (process.platform === "darwin") {
10
+ const launchctl = await spawnCollect(["launchctl", "help"], { timeoutMs: 5_000 });
11
+ // launchctl exits nonzero for `help`, so presence is what is being tested.
12
+ return launchctl.exitCode === 127 ? "none" : "launchd";
13
+ }
14
+ const systemctl = await spawnCollect(["systemctl", "--user", "--version"], {
15
+ timeoutMs: 5_000,
16
+ });
17
+ return systemctl.exitCode === 0 ? "systemd" : "none";
18
+ }
19
+ /** Where systemd looks for this user's units. */
20
+ export function systemdUnitDir(env = process.env) {
21
+ const base = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
22
+ return join(base, "systemd", "user");
23
+ }
24
+ /**
25
+ * Write a unit unless it is already right, or has been edited.
26
+ *
27
+ * An edited unit is never overwritten. Same instinct as `init --update`: a
28
+ * customization is deliberate until proven otherwise, and silently reverting
29
+ * one is the kind of thing that makes a tool untrustworthy.
30
+ */
31
+ export async function installUnit(spec, env = process.env) {
32
+ const unit = unitName(spec.name, spec.path);
33
+ const dir = systemdUnitDir(env);
34
+ const file = join(dir, unit);
35
+ const wanted = renderSystemdUnit(spec);
36
+ let current;
37
+ try {
38
+ current = await readFile(file, "utf8");
39
+ }
40
+ catch {
41
+ current = undefined;
42
+ }
43
+ if (current === wanted)
44
+ return { outcome: "unchanged", unit, file };
45
+ if (current !== undefined)
46
+ return { outcome: "hand-edited", unit, file };
47
+ await mkdir(dir, { recursive: true });
48
+ await writeFile(file, wanted, "utf8");
49
+ return { outcome: "written", unit, file };
50
+ }
51
+ /** Run a systemctl user subcommand, returning its exit code and output. */
52
+ async function systemctl(args) {
53
+ const result = await spawnCollect(["systemctl", "--user", ...args], { timeoutMs: 30_000 });
54
+ return { code: result.exitCode, out: `${result.stdout}${result.stderr}` };
55
+ }
56
+ /** Reload the manager so a newly written unit is visible. */
57
+ export async function reload() {
58
+ await systemctl(["daemon-reload"]);
59
+ }
60
+ /** Enable and start a unit. Returns an error message, or undefined on success. */
61
+ export async function enableAndStart(unit) {
62
+ const result = await systemctl(["enable", "--now", unit]);
63
+ return result.code === 0 ? undefined : result.out.trim();
64
+ }
65
+ /**
66
+ * Stop and disable a unit.
67
+ *
68
+ * This can genuinely take minutes: an in-flight tick is not interrupted, so the
69
+ * manager waits out the current agent run. Callers should say so before this
70
+ * starts rather than letting it look hung.
71
+ */
72
+ export async function disableAndStop(unit) {
73
+ const result = await systemctl(["disable", "--now", unit]);
74
+ if (result.code === 0)
75
+ return undefined;
76
+ // A unit that was never installed is not a failure to stop. The goal is that
77
+ // it is not running, and it is not. Reporting this as an error made `stop` on
78
+ // a never-started ghostrail look broken.
79
+ if (/does not exist|not loaded|no such file/i.test(result.out))
80
+ return undefined;
81
+ return result.out.trim();
82
+ }
83
+ /** Remove a unit file. Absent is success: the goal is that it is not there. */
84
+ export async function removeUnit(unit, env = process.env) {
85
+ await rm(join(systemdUnitDir(env), unit), { force: true });
86
+ }
87
+ /** Active state per unit, for the units asked about. */
88
+ export async function unitStates(units) {
89
+ if (units.length === 0)
90
+ return new Map();
91
+ const result = await systemctl(["show", "--property=Id", "--property=ActiveState", ...units]);
92
+ return parseUnitStates(result.out);
93
+ }
94
+ /** The real one: systemd through `systemctl --user`. */
95
+ export function systemdManager(env = process.env) {
96
+ return {
97
+ kind: detectManager,
98
+ install: (spec) => installUnit(spec, env),
99
+ enable: async (unit) => {
100
+ await reload();
101
+ return enableAndStart(unit);
102
+ },
103
+ disable: disableAndStop,
104
+ states: unitStates,
105
+ };
106
+ }
107
+ //# sourceMappingURL=manager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manager.js","sourceRoot":"","sources":["../../src/service/manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Rendering and naming for the user service units that keep a ghostrail
3
+ * running. Pure: nothing here touches the disk or shells out, so the awkward
4
+ * parts (unit naming, the stop timeout) are testable without a service manager.
5
+ */
6
+ /** Everything a unit file needs. */
7
+ export interface UnitSpec {
8
+ /** The ghostrail's display name, for the unit description. */
9
+ name: string;
10
+ /** Absolute path to the repo. Becomes the working directory. */
11
+ path: string;
12
+ /** Absolute path to the ghostrail binary. */
13
+ exec: string;
14
+ /** Poll interval, as a duration string like `5m`. */
15
+ interval: string;
16
+ /** How long the manager waits for a clean stop, in seconds. */
17
+ stopTimeoutSec: number;
18
+ }
19
+ /**
20
+ * The unit file name for a ghostrail: `ghostrail-<slug>-<hash>.service`.
21
+ *
22
+ * The hash of the path is not decoration. `name` is display-only and duplicates
23
+ * are entirely legal: two repos scaffolded from the same template are both
24
+ * called `code-local`. Keying the unit on the name alone would give them one
25
+ * unit between them, so starting the second would silently hijack the first.
26
+ * The identity is the path, so the path is what disambiguates.
27
+ */
28
+ export declare function unitName(name: string, path: string): string;
29
+ /**
30
+ * How long the service manager must wait for a clean stop.
31
+ *
32
+ * `watchLoop` checks its abort signal *before* each tick and each sleep, so a
33
+ * SIGTERM arriving mid-tick does not interrupt the running agent: the tick
34
+ * finishes first. A manager that gives up sooner SIGKILLs a live agent run,
35
+ * orphaning its container and leaving the tracker issue claimed by a bot that no
36
+ * longer exists.
37
+ *
38
+ * The headroom is there because the agent timeout bounds the agent, not the
39
+ * tick: provisioning, the gate, and publishing all happen outside it.
40
+ */
41
+ export declare function stopTimeoutSeconds(timeoutMs: number): number;
42
+ /**
43
+ * A systemd user unit. Deterministic, so an unchanged unit can be recognized
44
+ * and an edited one left alone.
45
+ *
46
+ * Paths are written as-is: a unit file is not shell, and a path containing a
47
+ * space is legal in `WorkingDirectory=`.
48
+ */
49
+ export declare function renderSystemdUnit(spec: UnitSpec): string;
50
+ /**
51
+ * Parse `systemctl --user show --property=Id --property=ActiveState` for
52
+ * several units into unit name -> active state.
53
+ *
54
+ * Tolerates what the real command emits: records separated by blank lines,
55
+ * properties in any order, extra properties, and a unit that does not exist
56
+ * (reported as `inactive` rather than as a failure). A record with no `Id` is
57
+ * skipped rather than fatal.
58
+ */
59
+ export declare function parseUnitStates(stdout: string): Map<string, string>;
60
+ //# sourceMappingURL=unit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unit.d.ts","sourceRoot":"","sources":["../../src/service/unit.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,oCAAoC;AACpC,MAAM,WAAW,QAAQ;IACvB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAC;IACjB,+DAA+D;IAC/D,cAAc,EAAE,MAAM,CAAC;CACxB;AAWD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAG3D;AAQD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQ5D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAoBxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBnE"}
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Lowercase, non-alphanumeric runs collapsed to `-`, trimmed. */
3
+ function slug(name) {
4
+ const out = name
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "");
8
+ return out.length > 0 ? out : "ghostrail";
9
+ }
10
+ /**
11
+ * The unit file name for a ghostrail: `ghostrail-<slug>-<hash>.service`.
12
+ *
13
+ * The hash of the path is not decoration. `name` is display-only and duplicates
14
+ * are entirely legal: two repos scaffolded from the same template are both
15
+ * called `code-local`. Keying the unit on the name alone would give them one
16
+ * unit between them, so starting the second would silently hijack the first.
17
+ * The identity is the path, so the path is what disambiguates.
18
+ */
19
+ export function unitName(name, path) {
20
+ const hash = createHash("sha256").update(path, "utf8").digest("hex").slice(0, 8);
21
+ return `ghostrail-${slug(name)}-${hash}.service`;
22
+ }
23
+ /** Headroom over the agent timeout, in seconds. */
24
+ const STOP_HEADROOM_SEC = 300;
25
+ /** Never wait less than this for a stop, however short the configured timeout. */
26
+ const MIN_STOP_SEC = 600;
27
+ /**
28
+ * How long the service manager must wait for a clean stop.
29
+ *
30
+ * `watchLoop` checks its abort signal *before* each tick and each sleep, so a
31
+ * SIGTERM arriving mid-tick does not interrupt the running agent: the tick
32
+ * finishes first. A manager that gives up sooner SIGKILLs a live agent run,
33
+ * orphaning its container and leaving the tracker issue claimed by a bot that no
34
+ * longer exists.
35
+ *
36
+ * The headroom is there because the agent timeout bounds the agent, not the
37
+ * tick: provisioning, the gate, and publishing all happen outside it.
38
+ */
39
+ export function stopTimeoutSeconds(timeoutMs) {
40
+ // Total by construction. The caller reads this from a validated config, so a
41
+ // negative or non-finite value should be unreachable, but this number is the
42
+ // one thing standing between a stop and a SIGKILL through a live agent run.
43
+ // Rendering `TimeoutStopSec=NaN` would make systemd reject the whole unit.
44
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
45
+ return MIN_STOP_SEC;
46
+ const seconds = Math.ceil(timeoutMs / 1000) + STOP_HEADROOM_SEC;
47
+ return Math.max(seconds, MIN_STOP_SEC);
48
+ }
49
+ /**
50
+ * A systemd user unit. Deterministic, so an unchanged unit can be recognized
51
+ * and an edited one left alone.
52
+ *
53
+ * Paths are written as-is: a unit file is not shell, and a path containing a
54
+ * space is legal in `WorkingDirectory=`.
55
+ */
56
+ export function renderSystemdUnit(spec) {
57
+ return `${[
58
+ "[Unit]",
59
+ `Description=ghostrail factory (${spec.name})`,
60
+ "Documentation=https://github.com/timothyjordan/ghostrail",
61
+ "",
62
+ "[Service]",
63
+ "Type=simple",
64
+ `WorkingDirectory=${spec.path}`,
65
+ // Absolute: a bare `ghostrail` is not on a service manager's PATH, and on
66
+ // an nvm install the real path is version-specific.
67
+ `ExecStart=${spec.exec} watch --interval ${spec.interval}`,
68
+ "Restart=on-failure",
69
+ "RestartSec=30",
70
+ // Long enough for an in-flight tick to finish. See stopTimeoutSeconds.
71
+ `TimeoutStopSec=${spec.stopTimeoutSec}`,
72
+ "",
73
+ "[Install]",
74
+ "WantedBy=default.target",
75
+ ].join("\n")}\n`;
76
+ }
77
+ /**
78
+ * Parse `systemctl --user show --property=Id --property=ActiveState` for
79
+ * several units into unit name -> active state.
80
+ *
81
+ * Tolerates what the real command emits: records separated by blank lines,
82
+ * properties in any order, extra properties, and a unit that does not exist
83
+ * (reported as `inactive` rather than as a failure). A record with no `Id` is
84
+ * skipped rather than fatal.
85
+ */
86
+ export function parseUnitStates(stdout) {
87
+ const states = new Map();
88
+ for (const record of stdout.split(/\n\s*\n/)) {
89
+ let id;
90
+ let active;
91
+ for (const line of record.split("\n")) {
92
+ const eq = line.indexOf("=");
93
+ if (eq === -1)
94
+ continue;
95
+ const key = line.slice(0, eq).trim();
96
+ const value = line.slice(eq + 1).trim();
97
+ if (key === "Id")
98
+ id = value;
99
+ else if (key === "ActiveState")
100
+ active = value;
101
+ }
102
+ // A record carrying an Id but no ActiveState becomes "unknown" rather than
103
+ // being dropped: the unit exists, and saying so beats pretending it does
104
+ // not. A repeated Id keeps the last record, matching how systemd's own
105
+ // later output would supersede earlier output.
106
+ if (id !== undefined && id.length > 0)
107
+ states.set(id, active ?? "unknown");
108
+ }
109
+ return states;
110
+ }
111
+ //# sourceMappingURL=unit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unit.js","sourceRoot":"","sources":["../../src/service/unit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAsBzC,kEAAkE;AAClE,SAAS,IAAI,CAAC,IAAY;IACxB,MAAM,GAAG,GAAG,IAAI;SACb,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC3B,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC;AAC5C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,IAAY;IACjD,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACjF,OAAO,aAAa,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,UAAU,CAAC;AACnD,CAAC;AAED,mDAAmD;AACnD,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAE9B,kFAAkF;AAClF,MAAM,YAAY,GAAG,GAAG,CAAC;AAEzB;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAAiB;IAClD,6EAA6E;IAC7E,6EAA6E;IAC7E,4EAA4E;IAC5E,2EAA2E;IAC3E,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,GAAG,CAAC;QAAE,OAAO,YAAY,CAAC;IACtE,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,iBAAiB,CAAC;IAChE,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAc;IAC9C,OAAO,GAAG;QACR,QAAQ;QACR,kCAAkC,IAAI,CAAC,IAAI,GAAG;QAC9C,0DAA0D;QAC1D,EAAE;QACF,WAAW;QACX,aAAa;QACb,oBAAoB,IAAI,CAAC,IAAI,EAAE;QAC/B,0EAA0E;QAC1E,oDAAoD;QACpD,aAAa,IAAI,CAAC,IAAI,qBAAqB,IAAI,CAAC,QAAQ,EAAE;QAC1D,oBAAoB;QACpB,eAAe;QACf,uEAAuE;QACvE,kBAAkB,IAAI,CAAC,cAAc,EAAE;QACvC,EAAE;QACF,WAAW;QACX,yBAAyB;KAC1B,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AACnB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,MAAc;IAC5C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;QAC7C,IAAI,EAAsB,CAAC;QAC3B,IAAI,MAA0B,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACtC,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,EAAE,KAAK,CAAC,CAAC;gBAAE,SAAS;YACxB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;YACrC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YACxC,IAAI,GAAG,KAAK,IAAI;gBAAE,EAAE,GAAG,KAAK,CAAC;iBACxB,IAAI,GAAG,KAAK,aAAa;gBAAE,MAAM,GAAG,KAAK,CAAC;QACjD,CAAC;QACD,2EAA2E;QAC3E,yEAAyE;QACzE,uEAAuE;QACvE,+CAA+C;QAC/C,IAAI,EAAE,KAAK,SAAS,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,IAAI,SAAS,CAAC,CAAC;IAC7E,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghostrail",
3
- "version": "0.7.5",
3
+ "version": "0.9.0",
4
4
  "description": "Open source software factory for coding agents. Config-driven loops, pluggable isolation backends, human merge gate.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: ghostrail
3
- description: Drive ghostrail (the software factory) from inside this repo. Use when the user types /ghostrail, or asks to set up, customize, check, or run a ghostrail factory — scaffolding ghostrail.toml and prompts, tailoring the scaffolded prompts and gate commands to this codebase, diagnosing a broken setup, or kicking off a run/respond/triage tick.
3
+ description: Drive ghostrail (the software factory) from inside this repo. Use when the user types /ghostrail, or asks to set up, customize, check, or run a ghostrail factory — scaffolding ghostrail.toml and prompts, tailoring the scaffolded prompts and gate commands to this codebase, carrying a later template improvement into prompts you have customized, diagnosing a broken setup, or kicking off a run/respond/triage tick.
4
4
  user-invocable: true
5
- argument-hint: "[customize | init | doctor | status | run | respond | triage]"
5
+ argument-hint: "[customize | init | update | doctor | status | run | respond | triage]"
6
6
  license: Apache-2.0
7
7
  metadata:
8
- version: 0.2.0
8
+ version: 0.3.0
9
9
  allowed-tools:
10
10
  - Read
11
11
  - Write
@@ -87,25 +87,71 @@ Scaffold the factory into this repo, then hand off to **customize**.
87
87
 
88
88
  ### Already initialized? Check for template drift
89
89
 
90
- If the repo already has `ghostrail.toml` and `prompts/`, do not re-run a bare
91
- `init` and report "0 files written" as if nothing were wrong. Run:
90
+ If the repo already has `ghostrail.toml` and prompts, do not re-run a bare
91
+ `init` and report "0 files written" as if nothing were wrong. See **update**
92
+ below.
93
+
94
+ Never suggest `--force` as the way to take a template update: it overwrites
95
+ every file, destroying the gate commands, tracker filters, and prompt edits that
96
+ **customize** wrote.
97
+
98
+ ---
99
+
100
+ ## update
101
+
102
+ Carry a template improvement into a repo that has customized its prompts. Run:
103
+
104
+ ```
105
+ <GR> init <template> --update
106
+ ```
107
+
108
+ It writes only files this repo never edited (proved by the baseline in
109
+ `ghostrail.template.json`), and it never touches a customized file. For those it
110
+ prints **signals**: what the template gained that the repo's copy lacks.
92
111
 
93
112
  ```
94
- <GR> init <template> --diff
113
+ ghostrail/prompts/resolve-issue.md
114
+ customized, and the template gained:
115
+ placeholder {{description}}
116
+ result field "testPlan"
95
117
  ```
96
118
 
97
- It is read-only. It reports each scaffolded file as up to date, locally
98
- customized, safe to take, a conflict, or missing, using the baseline recorded in
99
- `ghostrail.template.json`. Summarize the rows that are not up to date, show the
100
- `diff` command it printed, and offer to merge template changes in by hand.
119
+ Placing those is your job, and it is not a text merge. A customized prompt can
120
+ be a near-total rewrite that still needs the same one-line addition, so port the
121
+ **intent**, not the template's wording:
122
+
123
+ 1. Read the template's copy of the file to see how it uses the signal. The path
124
+ is printed by `<GR> init <template> --diff`.
125
+ 2. Read the repo's copy and find where the same idea belongs *in its structure*.
126
+ 3. Add it in the repo's own voice. A placeholder goes where that content is
127
+ wanted (`{{description}}` under the issue's ID/title/link block). A result
128
+ field goes on the matching line of the result-document list, with a
129
+ description written to match the surrounding entries.
130
+ 4. Never reformat, reorder, or "tidy" the rest of the file. The customization is
131
+ deliberate. Your diff should be as small as the signal count implies.
132
+ 5. Re-run `<GR> init <template> --update` and confirm it now reports
133
+ `customized, nothing to take`.
134
+
135
+ Verify a placeholder actually renders before anyone spends a run on it:
136
+
137
+ ```
138
+ node -e 'import("URL_TO_PROMPT_JS").then(({renderPrompt})=>{
139
+ const t=require("fs").readFileSync("PATH/TO/PROMPT.md","utf8");
140
+ const o=renderPrompt(t,{id:"TJ-1",title:"t",url:"u",description:"BODY"});
141
+ console.log(o.match(/\{\{[a-z]+\}\}/g) ?? "no unrendered placeholders");
142
+ })'
143
+ ```
101
144
 
102
- Never suggest `--force` as the way to take a template update: it overwrites every
103
- file, destroying the gate commands, tracker filters, and prompt edits that
104
- **customize** wrote. Merging by hand is the correct path.
145
+ Two things to tell the user plainly:
105
146
 
106
- If it reports no recorded baseline, the repo predates provenance. Re-running a
107
- bare `<GR> init` once adopts a baseline for every file that still matches the
108
- template exactly, which is safe and improves later diffs.
147
+ - **The installed template is the last published release.** A repo can be
148
+ current with it and still behind `main`. The `--update` header names the
149
+ version it compared against; repeat that in your summary.
150
+ - **A repo with no baseline gets signals but no writes.** Without a recorded
151
+ baseline nothing can be *shown* to be untouched, so `--update` will not
152
+ overwrite anything. Re-running a bare `<GR> init` once adopts a baseline for
153
+ every file that still matches the template exactly, which is safe and makes
154
+ later updates able to write.
109
155
 
110
156
  ---
111
157
 
@@ -26,10 +26,24 @@ Match the surrounding code: its style, patterns, and test conventions.
26
26
  ## Reporting your result (required, do this last)
27
27
  Write a JSON file at `.ghostrail/result.json` with exactly one of:
28
28
 
29
- - `{"status":"done","summary":"<what you changed>","type":"<feat|fix|docs|refactor|chore|...>"}`
29
+ - `{"status":"done","summary":"<what changed and why>","testPlan":"<how to check it by hand>","type":"<feat|fix|docs|refactor|chore|...>"}`
30
30
  - `{"status":"blocked","questions":"<a real product/design decision you need>"}`
31
31
  - `{"status":"failed","error":"<why>"}`
32
32
  - `{"status":"noop","reason":"<why nothing needed changing>"}`
33
33
 
34
34
  `type` is the conventional-commit type for the change; it sets the commit and PR
35
35
  title. If you omit it, the factory's configured default is used.
36
+
37
+ `summary` and `testPlan` are the pull request a human reads, so write them for
38
+ that reader rather than as a log of what you did:
39
+
40
+ - **`summary`**: one or two sentences on what changed and why, then markdown
41
+ bullets for the specifics. Do not write a single long paragraph, and do not
42
+ add headings; the factory supplies its own.
43
+ - **`testPlan`**: the steps someone runs to check this by hand. Concrete
44
+ commands, what to look at, and what they should see. The gate has already run
45
+ lint, typecheck, and the unit suite, so do not just repeat those: give the
46
+ commands that exercise what you actually changed. If the change is not
47
+ observable by hand (a refactor, a type fix), say what to run instead and what
48
+ a green result proves.
49
+
@@ -25,7 +25,7 @@ strategy). Follow them closely: they define how this work should read.
25
25
  ## Reporting your result (required, do this last)
26
26
  Write `.ghostrail/result.json` with exactly one of:
27
27
 
28
- - `{"status":"done","summary":"<one-paragraph summary of the draft>"}`
28
+ - `{"status":"done","summary":"<what you drafted and why>","testPlan":"<how to review it>"}`
29
29
  - `{"status":"blocked","questions":"<what you need decided>"}`
30
30
  - `{"status":"failed","error":"<why>"}`
31
31
  - `{"status":"noop","reason":"<why nothing needed changing>"}`