Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .agents/skills/lavish-decisions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,20 @@ Use the firstmate-owned `lavish-axi` file protocol documented in `tools/lavish/R
Use nonempty unique lowercase-slug keys.
4. Choose a durable `$FM_HOME`-relative destination below `data/`.
This is where intake commits the validated answer before writing its receipt.
5. Run `lavish-axi create` with a stable decision id, title, Markdown request, question JSON, and destination.
6. Run `lavish show <id>` and `lavish inbox` to verify the exact durable request.
7. Surface only the title and command:
5. Run `lavish-axi create` with a stable decision id, title, Markdown request, question JSON, and destination, and retain its emitted `Run:` line.
6. From firstmate's environment, run `lavish show <id>` and `lavish inbox` to verify the exact durable request.
7. Surface only the title and the exact `Run:` line emitted by `lavish-axi create`:

```text
Decision waiting: <short title>
Run: lavish answer <decision-id>
<exact Run: line emitted by lavish-axi create>
```

The surfaced command is for the captain's shell, not firstmate's environment.
It must retain the emitted `--home` argument and resolved absolute home path even when firstmate has `FM_HOME` exported.
Never shorten the command or reconstruct it from a placeholder.
Always carrying the explicit home is slightly noisier than asking the captain to export `FM_HOME`, but it makes every decision independently runnable and avoids a hidden setup dependency.

Do not edit `request.md` or `manifest.toon` after surfacing the decision.
Their digest and ordered question set are the immutable contract.

Expand Down
15 changes: 10 additions & 5 deletions tools/lavish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ An unanswered request remains answerable until the files are deliberately remove

## Human commands

The captain-facing command contract is owned by the [`lavish-decisions` skill](../../.agents/skills/lavish-decisions/SKILL.md).
`lavish-axi create` follows that contract by printing an answer command with the resolved absolute home path.
For direct human use, pass the same explicit home to every command, replacing this example path with the fleet home's resolved absolute path:

```sh
lavish inbox
lavish show <decision-id>
lavish answer <decision-id>
lavish inbox --home '/Users/example/firstmate-home'
lavish show <decision-id> --home '/Users/example/firstmate-home'
lavish answer <decision-id> --home '/Users/example/firstmate-home'
```

`lavish answer` renders the complete request, collects one numbered choice for every ordered question, accepts an optional note, shows the whole batch, and requires one explicit confirmation.
Expand Down Expand Up @@ -62,8 +66,9 @@ Intake validates every unreceipted answer, writes the declared destination first
An existing matching destination or receipt is an idempotent success.
A conflicting destination fails closed.

All commands use `FM_HOME`.
Tests and recovery tools may pass `--home <path>` explicitly.
All commands require either `FM_HOME` or an explicit `--home <path>` and never guess a fleet home.
Firstmate's internal commands use `FM_HOME`; captain-facing commands carry the resolved absolute `--home` path.
Tests and recovery tools may also pass `--home <path>` explicitly.
The firstmate bootstrap install command also records the checkout's narrow wake adapter with `lavish-axi configure-wake`; this local pointer is not inherited into other homes.

## Protocol
Expand Down
6 changes: 5 additions & 1 deletion tools/lavish/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ function resolveHome(options) {
return resolve(home);
}

function shellQuote(value) {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}

function printRequest(decision) {
process.stdout.write(`${decision.requestText.trimEnd()}\n\n`);
process.stdout.write(`Decision: ${decision.manifest.title} (${decision.id})\n`);
Expand Down Expand Up @@ -342,7 +346,7 @@ async function createCommand(options) {
});
process.stdout.write(
`${result.created ? 'Created' : 'Already exists'}: ${result.decision.id}\n`
+ `Run: lavish answer ${result.decision.id}\n`,
+ `Run: lavish answer ${result.decision.id} --home ${shellQuote(home)}\n`,
);
}

Expand Down
38 changes: 36 additions & 2 deletions tools/lavish/test/lavish.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,14 @@ function runCli(args, {
function runExecutable(executable, args, {
env = {},
input = '',
unsetEnv = [],
} = {}) {
return new Promise((resolveRun, rejectRun) => {
const childEnv = { ...process.env, ...env };
for (const key of unsetEnv) delete childEnv[key];
const child = spawn(executable, args, {
detached: true,
env: { ...process.env, ...env },
env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
Expand Down Expand Up @@ -182,6 +185,7 @@ async function createRequest(fx, {
id = 'release-choice',
destination = 'data/replies/release-choice.toon',
createdAt = undefined,
returnResult = false,
} = {}) {
const args = [
'create',
Expand All @@ -199,7 +203,7 @@ async function createRequest(fx, {
if (createdAt !== undefined) args.push('--created-at', createdAt);
const result = await runCli(args, { home: fx.home });
assert.equal(result.code, 0, result.stderr);
return id;
return returnResult ? { id, result } : id;
}

async function answer(fx, id, {
Expand Down Expand Up @@ -235,6 +239,36 @@ test('a seven-day-old request remains answerable with no firstmate process', asy
assert.equal(inbox.stdout, 'No pending Lavish decisions.\n');
});

test('the surfaced captain command works with FM_HOME unset', async () => {
const fx = await fixture('captain shell');
const { id, result: created } = await createRequest(fx, { returnResult: true });
const runLine = created.stdout
.split('\n')
.find((line) => line.startsWith('Run: '));
assert.ok(runLine, `create did not surface a Run line: ${created.stdout}`);
const surfacedCommand = runLine.slice('Run: '.length);
assert.equal(
surfacedCommand,
`lavish answer ${id} --home '${fx.home}'`,
);

const fakeBin = join(fx.root, 'captain-bin');
await mkdir(fakeBin);
await symlink(CLI, join(fakeBin, 'lavish'));
const answered = await runExecutable('/bin/sh', ['-c', surfacedCommand], {
env: { PATH: `${fakeBin}:${process.env.PATH}` },
unsetEnv: ['FM_HOME'],
input: '1\nRun from the captain shell\ny\n',
});
assert.equal(answered.code, 0, answered.stderr);
assert.doesNotMatch(answered.stderr, /FM_HOME is required/);
assert.match(answered.stdout, /Answer saved.*wake queued/);
assert.equal(
await exists(join(fx.home, 'data/decisions', id, 'answer.toon')),
true,
);
});

test('interruption before rename cannot expose a partial answer', async () => {
const fx = await fixture('interrupted');
const id = await createRequest(fx);
Expand Down