Skip to content

Commit 23c283e

Browse files
feat(paths): --user-dir / RALPH_USER_DIR + scout init --from-example (#51)
Closes #50. Lets scouts and results live outside the ralph-for-kiro checkout via a documented resolution order: 1. --user-dir <path> (highest priority) 2. $RALPH_USER_DIR 3. $XDG_CONFIG_HOME/ralph-for-kiro/ (honored only when present) 4. process.cwd() (legacy fallback — preserves existing crons) Implementation: - src/utils/paths.ts — new getUserDir() / resolveUserDir() / setUserDir() helpers; scoutsDir() / resultsDir() / watchManifestFile() getters replace the old SCOUTS_DIR / RESULTS_DIR / WATCH_MANIFEST_FILE constants. - src/index.ts — --user-dir is a global program option; a preSubcommand commander hook calls setUserDir() once before any subcommand handler runs, so every path helper reads a stable value for the duration of the process. - src/core/watch-runner.ts + src/commands/{watch,scout}.ts — all path call sites migrated to the new getters. - src/commands/scout.ts — new `--from-example <template>` option on `ralph scout init`. A SCOUT_EXAMPLES registry maps template keys to bundled manifest JSON + steering markdown (currently: hn-frontpage). `ralph scout init --from-example hn-frontpage my-hn-scout` copies both files into the new scout's tree; ensureScoutKiroTree preserves steering on first run so the template customization survives. - scouts/README.md — documents the resolution order, drops the incorrect "4am CST cron" reference (cron times run in the host's local TZ, not ours), and shows a generic cron example that pins $RALPH_USER_DIR explicitly. Tests: - tests/user-dir.test.ts — 6 new tests for the full resolution precedence, including XDG-when-absent falling through to cwd, and scoutsDir/resultsDir/watchManifestFile following the active user dir. - tests/scout-init.test.ts — 2 new tests for --from-example: happy path (manifest + steering land) and unknown-template rejection. Backwards compatibility: setting no flag / env / XDG dir results in the previous repo-relative behavior. Existing cron invocations keep working without modification. Verified with live CLI runs: `--user-dir`, `RALPH_USER_DIR`, and `scout init --from-example` all produce expected files; backwards-compat fallback correctly reports no scouts when cwd is empty. All gates green: typecheck, biome, 101/101 tests.
1 parent e81a218 commit 23c283e

8 files changed

Lines changed: 383 additions & 52 deletions

File tree

scouts/README.md

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,28 @@ committed to the ralph-for-kiro repo. Think of it the same way you think of
1717
rewrites each scout's `.kiro/{agents,steering,hooks,settings}` on
1818
every run, so any committed copy goes stale immediately.
1919

20+
## Where does `scouts/` live?
21+
22+
By default, `scouts/` sits alongside the ralph-for-kiro checkout. That works
23+
for a single-machine setup but means scout data is coupled to the repo clone.
24+
Override with either:
25+
26+
- `--user-dir <path>` CLI flag (e.g. `ralph --user-dir ~/ralph-data scout ls`)
27+
- `RALPH_USER_DIR=<path>` env var (useful in cron entries)
28+
- `$XDG_CONFIG_HOME/ralph-for-kiro/` (auto-detected if the directory exists)
29+
30+
Resolution order is flag → env → XDG → cwd. The cwd fallback keeps existing
31+
setups working without change.
32+
2033
## Making a new scout
2134

2235
Two ways:
2336

24-
**From an example template** (recommended for feed-shaped scouts):
37+
**From a built-in template** (recommended for feed-shaped scouts):
2538
```bash
26-
# Coming in the follow-up PR per GH issue #TBD —
27-
# `ralph scout init --from-example hn-frontpage`
28-
cp src/data/examples/hn-frontpage-manifest.json \
29-
scouts/my-hn-scout/manifest.json
30-
mkdir -p scouts/my-hn-scout/.kiro/steering
31-
cp src/data/examples/hn-frontpage-steering.md \
32-
scouts/my-hn-scout/.kiro/steering/watcher-context.md
39+
ralph scout init --from-example hn-frontpage my-hn-scout
3340
```
41+
Available templates: `hn-frontpage`.
3442

3543
**From scratch** (repo-watch scouts like ai-eval):
3644
```bash
@@ -50,8 +58,19 @@ ralph scout tail my-scout # watch an in-flight run
5058

5159
Output lands in `results/<scout-name>/pw-YYYYMMDD-HHmm/` (also gitignored).
5260

53-
## Heads-up on the 4am-CST cron
61+
## Running nightly via cron
5462

5563
If you set up a nightly cron, it will fail silently if `scouts/` is empty.
56-
Either seed from an example or have `ralph scout init` run once before the
57-
first cron invocation.
64+
Either seed a scout from a template or run `ralph scout init` once before
65+
the first cron invocation. Pin the user dir explicitly in the crontab entry
66+
if you want scouts to live outside the repo:
67+
68+
```cron
69+
0 9 * * * RALPH_USER_DIR=$HOME/ralph-data /path/to/ralph-for-kiro \
70+
scout --concurrency 3 --min-iterations 2 --max-iterations 4 \
71+
>> $HOME/ralph-data/cron.log 2>&1
72+
```
73+
74+
Remember that crontab times are in the host's local timezone — if your
75+
devbox runs UTC and you want a local-CST time, convert accordingly (and
76+
remember DST will shift the run by an hour twice a year).

src/commands/scout.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,28 @@ import {
1313
runWatch,
1414
type WatchRunEntry,
1515
} from "../core/watch-runner";
16-
import { RESULTS_DIR, SCOUTS_DIR } from "../utils/paths";
16+
import hnFrontpageManifest from "../data/examples/hn-frontpage-manifest.json";
17+
import hnFrontpageSteering from "../data/examples/hn-frontpage-steering.md" with {
18+
type: "text",
19+
};
20+
import { resultsDir, scoutsDir } from "../utils/paths";
21+
22+
/**
23+
* Registry of built-in scout templates shipped under src/data/examples/.
24+
* Each entry supplies a manifest object + a steering markdown string that
25+
* `ralph scout init --from-example <key>` copies into a new scout's tree.
26+
*/
27+
const SCOUT_EXAMPLES: Record<
28+
string,
29+
{ manifest: unknown; steering: string; description: string }
30+
> = {
31+
"hn-frontpage": {
32+
manifest: hnFrontpageManifest,
33+
steering: hnFrontpageSteering,
34+
description:
35+
"RSS-feed-driven trend scout for HN frontpage + best + show. Trend + surprise output, not repo discovery.",
36+
},
37+
};
1738

1839
/**
1940
* A scout definition derived from its directory and manifest.
@@ -48,18 +69,18 @@ interface ScoutRunOptions {
4869
*/
4970
async function discoverScouts(): Promise<ScoutInfo[]> {
5071
try {
51-
await readdir(SCOUTS_DIR);
72+
await readdir(scoutsDir());
5273
} catch {
5374
return [];
5475
}
5576

56-
const entries = await readdir(SCOUTS_DIR, { withFileTypes: true });
77+
const entries = await readdir(scoutsDir(), { withFileTypes: true });
5778
const scouts: ScoutInfo[] = [];
5879

5980
for (const entry of entries) {
6081
if (!entry.isDirectory()) continue;
6182

62-
const manifestPath = join(SCOUTS_DIR, entry.name, "manifest.json");
83+
const manifestPath = join(scoutsDir(), entry.name, "manifest.json");
6384
try {
6485
const manifest = await readManifest(manifestPath);
6586
scouts.push({
@@ -98,7 +119,7 @@ export async function scoutRunCommand(opts: ScoutRunOptions): Promise<void> {
98119
if (allScouts.length === 0) {
99120
log.error(
100121
pc.red(
101-
`No scouts found in ${SCOUTS_DIR}/\nRun 'ralph scout init <name>' to create one.`,
122+
`No scouts found in ${scoutsDir()}/\nRun 'ralph scout init <name>' to create one.`,
102123
),
103124
);
104125
return;
@@ -232,7 +253,7 @@ export async function scoutLsCommand(): Promise<void> {
232253

233254
if (scouts.length === 0) {
234255
log.message(
235-
`No scouts found in ${SCOUTS_DIR}/. Run 'ralph scout init <name>' to create one.`,
256+
`No scouts found in ${scoutsDir()}/. Run 'ralph scout init <name>' to create one.`,
236257
);
237258
return;
238259
}
@@ -335,13 +356,22 @@ export async function scoutResultsCommand(name?: string): Promise<void> {
335356
}
336357

337358
/**
338-
* Scaffolds a new scout with an empty manifest.
359+
* Scaffolds a new scout with an empty manifest — or, with --from-example,
360+
* by copying a built-in template (manifest + steering) into the new scout's
361+
* tree. Steering from a template goes to
362+
* `scouts/<name>/.kiro/steering/watcher-context.md`; ensureScoutKiroTree
363+
* preserves existing steering so the template customization survives.
339364
*/
340365
export async function scoutInitCommand(
341366
name: string,
342-
opts: { topics?: string; languages?: string; force?: boolean },
367+
opts: {
368+
topics?: string;
369+
languages?: string;
370+
force?: boolean;
371+
fromExample?: string;
372+
},
343373
): Promise<void> {
344-
const scoutDir = join(SCOUTS_DIR, name);
374+
const scoutDir = join(scoutsDir(), name);
345375
const manifestPath = join(scoutDir, "manifest.json");
346376

347377
// Check for existing
@@ -362,6 +392,38 @@ export async function scoutInitCommand(
362392

363393
await mkdir(scoutDir, { recursive: true });
364394

395+
if (opts.fromExample) {
396+
const example = SCOUT_EXAMPLES[opts.fromExample];
397+
if (!example) {
398+
const available = Object.keys(SCOUT_EXAMPLES).join(", ");
399+
log.error(
400+
pc.red(
401+
`Unknown example "${opts.fromExample}". Available: ${available || "(none)"}`,
402+
),
403+
);
404+
return;
405+
}
406+
407+
// Write the template manifest as-is.
408+
await Bun.write(
409+
manifestPath,
410+
`${JSON.stringify(example.manifest, null, "\t")}\n`,
411+
);
412+
413+
// Stamp the custom steering so ensureScoutKiroTree preserves it on
414+
// first run (it skips steering writes when the file already exists).
415+
const steeringDir = join(scoutDir, ".kiro", "steering");
416+
await mkdir(steeringDir, { recursive: true });
417+
await Bun.write(join(steeringDir, "watcher-context.md"), example.steering);
418+
419+
log.success(
420+
`${pc.green("Created")} scout ${pc.cyan(name)} from example ${pc.cyan(opts.fromExample)} at ${scoutDir}`,
421+
);
422+
log.message(pc.dim(` ${example.description}`));
423+
log.message(pc.dim(`\n Run: ralph scout run --name ${name}`));
424+
return;
425+
}
426+
365427
const topics = opts.topics
366428
? opts.topics.split(",").map((t) => t.trim())
367429
: [name];
@@ -399,7 +461,7 @@ export async function scoutInitCommand(
399461
export async function scoutStatusCommand(): Promise<void> {
400462
const scouts = await discoverScouts();
401463
if (scouts.length === 0) {
402-
log.message(`No scouts found in ${SCOUTS_DIR}/.`);
464+
log.message(`No scouts found in ${scoutsDir()}/.`);
403465
return;
404466
}
405467

@@ -477,7 +539,7 @@ export async function scoutTailCommand(
477539
return;
478540
}
479541

480-
const iterationsDir = join(RESULTS_DIR, name, latest.taskId, "iterations");
542+
const iterationsDir = join(resultsDir(), name, latest.taskId, "iterations");
481543
log.info(pc.bold(`Tailing ${pc.cyan(name)} — run ${latest.taskId}`));
482544
log.message(pc.dim(` ${iterationsDir} (polling every ${intervalMs}ms)`));
483545
console.log();

src/commands/watch.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import {
2020
KIRO_AGENTS_DIR,
2121
KIRO_SETTINGS_DIR,
2222
KIRO_STEERING_DIR,
23-
RESULTS_DIR,
24-
WATCH_MANIFEST_FILE,
23+
resultsDir,
24+
watchManifestFile,
2525
} from "../utils/paths";
2626

2727
/**
@@ -98,7 +98,7 @@ export async function watchInitCommand(opts: WatchInitOptions): Promise<void> {
9898
await mkdir(KIRO_AGENTS_DIR, { recursive: true });
9999
await mkdir(KIRO_STEERING_DIR, { recursive: true });
100100
await mkdir(KIRO_SETTINGS_DIR, { recursive: true });
101-
await mkdir(RESULTS_DIR, { recursive: true });
101+
await mkdir(resultsDir(), { recursive: true });
102102

103103
// Write agent config
104104
await Bun.write(agentPath, `${JSON.stringify(agentConfig, null, 2)}\n`);
@@ -132,16 +132,16 @@ export async function watchInitCommand(opts: WatchInitOptions): Promise<void> {
132132
}
133133

134134
// Copy manifest if it doesn't exist
135-
if (!(await Bun.file(WATCH_MANIFEST_FILE).exists())) {
135+
if (!(await Bun.file(watchManifestFile()).exists())) {
136136
const manifestSource = Bun.file(manifestSourcePath);
137137
if (await manifestSource.exists()) {
138138
const manifestContent = await manifestSource.text();
139-
await Bun.write(WATCH_MANIFEST_FILE, manifestContent);
140-
log.success(`${pc.green("Created")} ${WATCH_MANIFEST_FILE}`);
139+
await Bun.write(watchManifestFile(), manifestContent);
140+
log.success(`${pc.green("Created")} ${watchManifestFile()}`);
141141
log.message(pc.dim(" Edit this file to set your topics and languages"));
142142
}
143143
} else {
144-
log.message(pc.dim(` ${WATCH_MANIFEST_FILE} already exists, skipping`));
144+
log.message(pc.dim(` ${watchManifestFile()} already exists, skipping`));
145145
}
146146

147147
// Success message
@@ -221,7 +221,7 @@ export async function watchResultsCommand(taskId?: string): Promise<void> {
221221
}
222222

223223
// Show iteration files
224-
const iterationsDir = join(RESULTS_DIR, targetId, "iterations");
224+
const iterationsDir = join(resultsDir(), targetId, "iterations");
225225
try {
226226
const files = await readdir(iterationsDir);
227227
if (files.length > 0) {

src/core/watch-runner.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { LoopConfigSchema } from "../schemas/config";
1111
import { type WatchManifest, WatchManifestSchema } from "../schemas/manifest";
1212
import { type WatchStatus, WatchStatusSchema } from "../schemas/results";
1313
import {
14-
RESULTS_DIR,
15-
SCOUTS_DIR,
14+
resultsDir,
15+
scoutsDir,
1616
WATCH_AGENT_NAME,
17-
WATCH_MANIFEST_FILE,
17+
watchManifestFile,
1818
} from "../utils/paths";
1919
import { runLoop } from "./loop-runner";
2020
import { ensureScoutKiroTree } from "./scout-init";
@@ -52,7 +52,7 @@ export function generateTaskId(): string {
5252
export async function readManifest(
5353
manifestPath?: string | null,
5454
): Promise<WatchManifest> {
55-
const path = manifestPath ?? WATCH_MANIFEST_FILE;
55+
const path = manifestPath ?? watchManifestFile();
5656
const file = Bun.file(path);
5757

5858
if (!(await file.exists())) {
@@ -123,8 +123,8 @@ export async function runWatch(opts: WatchRunOptions): Promise<void> {
123123
// When running as a scout, namespace results under the scout name
124124
const taskId = generateTaskId();
125125
const resultsBase = opts.scoutName
126-
? join(RESULTS_DIR, opts.scoutName)
127-
: RESULTS_DIR;
126+
? join(resultsDir(), opts.scoutName)
127+
: resultsDir();
128128
const resultsPath = join(resultsBase, taskId);
129129
const iterationsPath = join(resultsPath, "iterations");
130130

@@ -165,7 +165,7 @@ export async function runWatch(opts: WatchRunOptions): Promise<void> {
165165
const absResultsPath = isAbsolute(resultsPath)
166166
? resultsPath
167167
: resolve(process.cwd(), resultsPath);
168-
const manifestFilePath = opts.manifestPath ?? WATCH_MANIFEST_FILE;
168+
const manifestFilePath = opts.manifestPath ?? watchManifestFile();
169169
const absManifestPath = isAbsolute(manifestFilePath)
170170
? manifestFilePath
171171
: resolve(process.cwd(), manifestFilePath);
@@ -182,7 +182,7 @@ export async function runWatch(opts: WatchRunOptions): Promise<void> {
182182
// repo root for backwards compatibility.
183183
let scoutCwd: string | null = null;
184184
if (opts.scoutName) {
185-
const scoutDir = resolve(process.cwd(), SCOUTS_DIR, opts.scoutName);
185+
const scoutDir = resolve(process.cwd(), scoutsDir(), opts.scoutName);
186186
await ensureScoutKiroTree(scoutDir);
187187
scoutCwd = scoutDir;
188188
}
@@ -229,7 +229,7 @@ export async function readStatus(
229229
taskId: string,
230230
scoutName?: string | null,
231231
): Promise<WatchStatus | null> {
232-
const base = scoutName ? join(RESULTS_DIR, scoutName) : RESULTS_DIR;
232+
const base = scoutName ? join(resultsDir(), scoutName) : resultsDir();
233233
const statusPath = join(base, taskId, "status.json");
234234
const file = Bun.file(statusPath);
235235

@@ -248,7 +248,7 @@ export async function readSummary(
248248
taskId: string,
249249
scoutName?: string | null,
250250
): Promise<string | null> {
251-
const base = scoutName ? join(RESULTS_DIR, scoutName) : RESULTS_DIR;
251+
const base = scoutName ? join(resultsDir(), scoutName) : resultsDir();
252252
const summaryPath = join(base, taskId, "summary.md");
253253
const file = Bun.file(summaryPath);
254254

@@ -272,7 +272,7 @@ export interface WatchRunEntry extends WatchStatus {
272272
export async function listRuns(
273273
scoutName?: string | null,
274274
): Promise<WatchRunEntry[]> {
275-
const base = scoutName ? join(RESULTS_DIR, scoutName) : RESULTS_DIR;
275+
const base = scoutName ? join(resultsDir(), scoutName) : resultsDir();
276276

277277
// Check if directory exists
278278
try {

src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,23 @@ import {
2222
watchResultsCommand,
2323
watchRunCommand,
2424
} from "./commands";
25+
import { setUserDir } from "./utils/paths";
2526
import { VERSION } from "./version";
2627

2728
const program = new Command()
2829
.name("ralph")
2930
.description("Ralph Wiggum iterative loop technique for Kiro CLI")
3031
.version(VERSION)
32+
.option(
33+
"--user-dir <path>",
34+
"Directory where scouts/ and results/ live (overrides $RALPH_USER_DIR and $XDG_CONFIG_HOME/ralph-for-kiro; falls back to cwd)",
35+
)
36+
.hook("preSubcommand", (thisCommand) => {
37+
// Resolve --user-dir once before any subcommand runs so every path
38+
// helper in src/utils/paths.ts reads a stable value.
39+
const opts = thisCommand.opts();
40+
setUserDir((opts["userDir"] as string | undefined) ?? null);
41+
})
3142
.addHelpText(
3243
"after",
3344
`
@@ -38,7 +49,13 @@ Common tasks:
3849
ralph scout tail ai-eval Follow a live scout run
3950
ralph loop "Build a CLI" -m 20 Run Ralph iteratively on a task
4051
41-
Data:
52+
User directory resolution (where scouts/ and results/ live):
53+
--user-dir <path> Flag (highest precedence)
54+
$RALPH_USER_DIR Env var
55+
$XDG_CONFIG_HOME/ralph-for-kiro/ XDG default (only if it exists)
56+
$(pwd) Legacy fallback
57+
58+
Data layout (under the resolved user directory):
4259
scouts/<name>/ Per-scout manifest + .kiro/ tree
4360
results/<scout>/pw-YYYYMMDD-HHmm/ Per-run artefacts (iterations/,
4461
summary.md, discovery.json,
@@ -211,6 +228,10 @@ scoutCmd
211228
.option("-t, --topics <topics>", "Comma-separated topics")
212229
.option("-l, --languages <langs>", "Comma-separated languages")
213230
.option("-f, --force", "Overwrite existing scout")
231+
.option(
232+
"--from-example <template>",
233+
"Scaffold from a built-in template (e.g. 'hn-frontpage'). Copies manifest + steering",
234+
)
214235
.action(scoutInitCommand);
215236

216237
scoutCmd

0 commit comments

Comments
 (0)