223 lines
9.5 KiB
Markdown
223 lines
9.5 KiB
Markdown
---
|
|
name: trekendsession
|
|
description: Mark the current session as complete and write session-state pointing at the next session. Helper for informal multi-session flows.
|
|
argument-hint: "<next-brief-path> <next-label> | --help"
|
|
disable-model-invocation: true
|
|
---
|
|
|
|
# Voyage End-Session Local v1.0
|
|
|
|
Tiny helper for **informal** multi-session flows (no formal plan with
|
|
Execution Strategy). Writes a `.session-state.local.json` pointing at the
|
|
next session so `/trekcontinue` can resume in a fresh Claude chat.
|
|
|
|
For formal flows (a plan produced by `/trekplan --project`),
|
|
`/trekexecute` Phase 8 already writes the state file — this helper
|
|
is unnecessary there. Use this command for ad-hoc release runs, manual
|
|
multi-session handovers, or any flow that does not run through
|
|
`/trekexecute`.
|
|
|
|
Pipeline position:
|
|
|
|
```
|
|
... session N work ...
|
|
/trekendsession <brief> "<next-label>" → writes state
|
|
... session boundary, fresh chat ...
|
|
/trekcontinue → reads state, starts session N+1
|
|
```
|
|
|
|
See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
|
|
|
|
## Phase 0 — `--help` handling
|
|
|
|
Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
|
|
(NOT eager-exec), exactly as written: the arguments reach the parser as the one
|
|
line inside the quoted heredoc, on stdin, never as shell words:
|
|
|
|
```bash
|
|
setopt nonomatch 2>/dev/null || true
|
|
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
|
|
$ARGUMENTS
|
|
VOYAGE_ARGS_EOF_7F3C9E
|
|
```
|
|
|
|
The `setopt nonomatch` line is no longer needed for the arguments (the quoted
|
|
heredoc expands no glob); it stays harmless.
|
|
|
|
It prints the parse result as JSON. On exit 1, print its `errors` and stop.
|
|
When it prints no JSON, say that the parser did not run and stop.
|
|
|
|
Print the usage block below and exit cleanly ONLY when one of these two
|
|
conditions equals exactly true (no substring search, no "contains" check):
|
|
|
|
- `flags['--help'] === true`, OR
|
|
- `positional[0] === '-h'` (single-dash short form — the parser keeps it as
|
|
positional because the schema declares no alias).
|
|
|
|
Then do NOT proceed to any further phase. In every other case — a slug or a
|
|
label that holds `-h` or `help` too — go on to Phase 1.
|
|
|
|
```
|
|
/trekendsession — Mark current session done; point at next session.
|
|
|
|
Usage:
|
|
/trekendsession <next-brief-path> <next-label>
|
|
/trekendsession --help
|
|
|
|
Both arguments are REQUIRED. No interactive prompt — headless-safe.
|
|
|
|
Writes <project-dir>/.session-state.local.json with:
|
|
schema_version 1
|
|
project <auto-resolved from cwd>
|
|
next_session_brief_path <next-brief-path argument>
|
|
next_session_label <next-label argument>
|
|
status in_progress
|
|
updated_at <now, ISO-8601>
|
|
|
|
Then validates via lib/validators/session-state-validator.mjs and prints
|
|
the same 3-line narration that /trekcontinue will show in the next session.
|
|
|
|
Example:
|
|
/trekendsession .claude/projects/2026-05-01-feature/brief.md "Session 2 of 3"
|
|
```
|
|
|
|
## Phase 1 — Resolve project directory
|
|
|
|
Resolve the nearest `.claude/projects/*/brief.md` from cwd (the current working
|
|
directory). Use `node -e` enumeration (NOT shell glob — harness-mode safety):
|
|
|
|
```bash
|
|
!`node --input-type=module -e "import {existsSync, readdirSync} from 'node:fs'; import {join} from 'node:path'; const root='.claude/projects'; if(!existsSync(root)) process.exit(0); readdirSync(root).filter(d=>existsSync(join(root,d,'brief.md'))).forEach(d=>process.stdout.write(join(root,d)+'\\n'));"`
|
|
```
|
|
|
|
Decision tree:
|
|
|
|
- **0 candidates:** print error to stderr — "no `.claude/projects/<dir>/brief.md`
|
|
found under cwd; cannot determine project directory" — and exit 1. Do NOT
|
|
fall back to a synthesized path.
|
|
- **1 candidate:** use it as `<project-dir>`. Continue.
|
|
- **>1 candidates:** print all paths and ask the operator to `cd` into the
|
|
intended project directory before retrying. Exit 1.
|
|
|
|
## Phase 2 — Required args check (headless-safe)
|
|
|
|
Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
|
|
and `<next-label>` is the parser's `tail` (the rest of the text as typed after
|
|
the brief path, one outer quote pair removed, so a label with spaces stays
|
|
whole). Both are required. If either is missing or empty:
|
|
|
|
```
|
|
Error: missing required args.
|
|
Usage: /trekendsession <next-brief-path> '<next-label>'
|
|
```
|
|
|
|
Print to stderr and exit 1. **No interactive prompt** — this keeps the helper
|
|
headless-safe (per brief NFR; addresses adversarial-review major #11). If you
|
|
want an interactive flow, use `/trekcontinue --help` to see the full pipeline.
|
|
|
|
## Phase 3 — Atomically write `.session-state.local.json` + sibling NEXT-SESSION-PROMPT.local.md
|
|
|
|
Write `{project_dir}/.session-state.local.json` with the schema-v1 object:
|
|
|
|
```json
|
|
{
|
|
"schema_version": 1,
|
|
"project": "{project_dir}",
|
|
"next_session_brief_path": "{arg 1}",
|
|
"next_session_label": "{arg 2}",
|
|
"status": "in_progress",
|
|
"updated_at": "{now, ISO-8601}"
|
|
}
|
|
```
|
|
|
|
Use the atomic-write util — write to `<path>.tmp`, then `rename` into place —
|
|
to avoid partial-state on crash. The util is ESM, so invoke via
|
|
`node --input-type=module -e` with an `import` statement (a CommonJS shim
|
|
would throw `ERR_REQUIRE_ESM` on Node 18+ since `atomic-write.mjs` is ESM).
|
|
|
|
This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the
|
|
project directory with YAML frontmatter (`produced_by: trekendsession`,
|
|
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
|
|
in a single ESM block so the writes succeed or fail together.
|
|
|
|
Run the block below via the Bash tool at runtime, exactly as written: put no
|
|
value from Phase 1 or Phase 2 into it. This is NOT an eager-exec block — the
|
|
files do not exist at command-load time. The block takes nothing on its shell
|
|
line: it re-reads the arguments as the one line inside the quoted heredoc, on
|
|
stdin, never as shell words (the heredoc is the last thing in the command),
|
|
parses them with the same `parseArgs` as Phase 0 (the brief path is
|
|
`positional[0]`, the label is `tail`), and re-derives the project directory by
|
|
Phase 1's enumeration: exactly one `.claude/projects/<dir>/brief.md` under the
|
|
cwd, else it prints why and exits 1. The import paths must stay absolute via
|
|
`${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's repo, not the plugin
|
|
root, so a cwd-relative import throws `ERR_MODULE_NOT_FOUND`:
|
|
|
|
```bash
|
|
node --input-type=module -e "
|
|
import path from 'node:path';
|
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
|
|
import { parseArgs } from '${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs';
|
|
let text = readFileSync(0, 'utf8');
|
|
if (text.endsWith('\\n')) text = text.slice(0, -1);
|
|
if (text.endsWith('\\r')) text = text.slice(0, -1);
|
|
const args = parseArgs(text, 'trekendsession');
|
|
const brief = args.positional[0] || '';
|
|
const label = args.tail;
|
|
if (brief === '' || label === '') { console.error('Error: missing required args.'); process.exit(1); }
|
|
const root = '.claude/projects';
|
|
const found = existsSync(root) ? readdirSync(root).filter((d) => existsSync(path.join(root, d, 'brief.md'))).map((d) => path.join(root, d)) : [];
|
|
if (found.length === 0) { console.error('no .claude/projects/ directory with a brief.md found under cwd; cannot determine project directory'); process.exit(1); }
|
|
if (found.length > 1) { console.error('more than one project directory holds a brief.md; cd into the intended one and retry:\\n' + found.join('\\n')); process.exit(1); }
|
|
const dir = found[0];
|
|
const now = new Date().toISOString();
|
|
const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now };
|
|
const stateFile = path.join(dir, '.session-state.local.json');
|
|
atomicWriteJson(stateFile, stateObj);
|
|
const promptFile = path.join(dir, 'NEXT-SESSION-PROMPT.local.md');
|
|
const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '\\nproject: ' + dir + '\\n---\\n\\n# ' + label + '\\n\\nResume via /trekcontinue.\\n';
|
|
writeFileSync(promptFile, promptBody);
|
|
console.log(stateFile);
|
|
console.log(promptFile);
|
|
" <<'VOYAGE_ARGS_EOF_7F3C9E'
|
|
$ARGUMENTS
|
|
VOYAGE_ARGS_EOF_7F3C9E
|
|
```
|
|
|
|
## Phase 4 — Validate + narrate
|
|
|
|
Validate the freshly-written state file via the Bash tool at runtime,
|
|
substituting the resolved `{project_dir}` (NOT eager-exec — the file does
|
|
not exist at command-load time):
|
|
|
|
```bash
|
|
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/session-state-validator.mjs --json {project_dir}/.session-state.local.json
|
|
```
|
|
|
|
If `valid: true`, print the success block matching `/trekcontinue` Phase 3
|
|
narration (SC-8 cross-project consistency — same template both sides):
|
|
|
|
```
|
|
Session state written: {project_dir}/.session-state.local.json
|
|
|
|
Project: {project_dir}
|
|
Next session: {next_label}
|
|
Brief: {next_brief_path}
|
|
|
|
In a fresh Claude session, run /trekcontinue to resume.
|
|
```
|
|
|
|
If `valid: false`, print the structured `errors[]` and exit 1. Investigate
|
|
before retrying — usually means a bad path or label argument.
|
|
|
|
## Hard rules
|
|
|
|
- **Required args, no defaults.** Never invent a brief path or session label.
|
|
If args are missing, fail loud.
|
|
- **Atomic write only.** Tmp + rename — no partial state files on disk.
|
|
- **Zero secrets.** Status, paths, labels — never API keys, never user content
|
|
beyond filenames.
|
|
- **NEVER auto-invoke this command.** It is operator-typed only at session-end
|
|
(the frontmatter field keeps the model from starting it; the operator types it).
|
|
- **Idempotent within a session.** Running twice with the same args
|
|
overwrites cleanly (atomic rename); does not double-advance.
|