Schema:
spec.prompts/v0.1(file shape) · Layout: v0.2 (one file per branch) Status: draft — the contract is not yet stable. Review and argue here before code lands.
A .prompts file bundles the conversational sessions captured on a
git branch into one human-readable TOML artifact. Trunk has one such
file (prompts/main.prompts). Each non-trunk branch has its own draft
file under a slugged version of the branch name. spec prompts capture
appends new sessions into the current branch's file idempotently.
.prompts files are treated as build inputs, not telemetry. Editing
one changes what the compiler sees on the next run. That is the whole
point.
Prompts are a first-class class of source, not markdown in disguise.
.md is for specs. spec.yaml is for the compiler contract.
.prompts is for the English-language work that produced code.
The compiler still treats .prompts as build input, not telemetry. Separately, Spec Cloud may compute read-only aggregates for engineer profiles and bundle dashboards (contribution heatmaps, iteration depth, abandonment rate). Those metrics use only what capture already writes: [[sessions.turns]] timestamps and role, optional ended_at, optional [sessions.commit].commit_sha, and operator for attribution.
Canonical definitions (quiet window, grace days, what “finished for counting” means) are published here:
https://spec.lightreach.io/docs#activity-metrics
The central rule. The .prompts file for a branch lives at:
<bundle-root>/prompts/<branch-slug>.prompts
The slug is the branch name, lowercased, with anything outside
[a-z0-9._-] (including /) collapsed to a single -, leading and
trailing . / - stripped. So:
| Branch | File |
|---|---|
main |
prompts/main.prompts |
feature/billing-rewrite |
prompts/feature-billing-rewrite.prompts |
dependabot/npm/foo-1.2.3 |
prompts/dependabot-npm-foo-1.2.3.prompts |
The original branch name (with case / / / non-ASCII intact) is
preserved in [commit].branch and on every session's
[sessions.commit].branch, so the slug is just a stable filesystem
handle, not a reversible encoding.
spec prompts capture is append-only. New sessions go into the
current branch's file in started_at order; sessions whose id is
already present are dropped. Re-running capture is safe and produces
byte-identical output. The branch's prior captured files (under
prompts/captured/<timestamp>.prompts from the v0.1 era) are still
accepted by every parser; new captures land at the root of prompts/.
- One narrative per branch. A reviewer reading a draft branch sees the unmerged thinking in one file with a stable name, not a forensic crawl through a directory of timestamped envelopes.
- PR-shaped review by construction. The diff between a branch's prompts file and trunk's is exactly the unmerged delta — same shape every engineer already knows from GitHub.
- Cloud merges by appending. Approving a branch review promotes
every session in the branch's file into trunk's file with
merged_from/merged_at/approved_bystamped on each one. The trunk file is the canonical, accumulated record. The branch file stays on the branch under its own slug for history. - Append-only diffs. The trunk file only ever grows; merges never reorder existing sessions. Two reviews touching the same session id collapse cleanly (idempotent dedup).
- Feed-ready.
/prompts(the public feed) walks each bundle's trunk prompts file. The "Reviewed only" filter keys offmerged_fromto surface just the green-lit thinking.
my-bundle/
├── spec.yaml # exactly 1 · manifest
├── docs/
│ ├── product.md # specs — English-language source
│ └── auth.md
└── prompts/
├── main.prompts # trunk · accumulated narrative
├── feature-billing-rewrite.prompts # one .prompts per non-trunk branch
└── feature-auth.prompts
The branch-file convention is the primary path. The legacy
prompts/captured/ / prompts/curated/ / prompts/curated/_pending/
tiers from v0.1 are still accepted by every parser — bundles that
predate v0.2 keep working untouched — but new captures always go to
the per-branch file at the root. Cloud's branch-merge is the canonical
path from "my draft branch" to "on trunk".
.prompts files in v0.1 lived in two populations that deserved very
different review treatment, in two directories:
| Tier | Directory | Written by | Compiler treats as |
|---|---|---|---|
captured |
prompts/captured/ |
prompts capture (v0.1) |
advisory scrollback |
curated |
prompts/curated/ |
prompts review accept |
first-class intent |
pending |
prompts/curated/_pending/ |
prompts submit |
excluded entirely |
legacy |
prompts/ (root, no subdirectory) |
pre-tier capture | treated as curated |
In v0.2 the local submit / review / accept workflow is no longer
the canonical path — Cloud branch reviews are. The legacy CLI commands
still work for users without Cloud, but they target the older
prompts/captured/ directory and don't touch the per-branch files at
the root. The two coexist; neither blocks the other.
Three file classes. No overlap:
| Extension | Kind | Contains |
|---|---|---|
spec.yaml |
settings |
compiler manifest (exactly one) |
.md / .markdown |
md |
spec / intent prose |
.prompts |
prompts |
sessions that produced one commit |
.md under prompts/ is rejected. Prompts have their own
extension; the folder is pure convention.
.prompts is TOML. One file contains one or more sessions.
schema = "spec.prompts/v0.1"
# ── Commit metadata ────────────────────────────────────────────────
[commit]
branch = "main" # captured git branch
message = "Extract tax logic into its own module"
committed_at = 2026-04-21T11:58:03Z # UTC, Z-suffixed
author_name = "Alice Chen" # from `git config user.name`
author_email = "alice@example.com" # from `git config user.email`
author_username = "alicec" # optional · Cloud handle when linked
# ── One or more conversational sessions ────────────────────────────
[[sessions]]
id = "d1714569-2799-464b-9a0e-360aced5767c"
source = "claude_code" # claude_code | cursor | codex | compress | manual
model = "claude-sonnet-4-6"
started_at = 2026-04-21T11:47:12Z
ended_at = 2026-04-21T11:58:03Z
operator = "alice@example.com" # who drove the chat (may ≠ commit author)
title = "Extract tax logic"
summary = """
Pulled tax calculation out of billing.py into its own module, keeping
the public interface identical.
"""
lesson = "Grep call sites first; the refactor was trivial once the graph was in my head."
tags = ["refactor", "billing", "python"]
outcome = "shipped" # shipped | abandoned | exploratory | failed
visibility = "public" # public | private (default: public)
forked_from = "" # optional · session id of the ancestor
# Files the session's tool calls touched. Derived from tool-call args on
# first write; safe to hand-edit when a path slipped in by exploration.
paths_touched = [
"billing/billing.py",
"billing/tax.py",
]
[[sessions.turns]]
role = "user"
at = 2026-04-21T11:47:12Z
text = """
Refactor billing.py to extract the tax logic into its own module.
Keep the public interface identical.
"""
[[sessions.turns]]
role = "assistant"
at = 2026-04-21T11:47:35Z
model = "claude-sonnet-4-5"
summary = "Mapping tax call sites before extraction."
[[sessions.turns.tool_calls]]
name = "Grep"
args = { pattern = "calculate_tax", path = "billing/" }
[[sessions.turns.tool_calls]]
name = "Read"
args = { path = "billing/billing.py" }
[[sessions.turns.tool_calls]]
name = "Edit"
args = { path = "billing/billing.py",
old_head = "def calculate_tax(",
new_head = "from .tax import calculate_tax" }
# ── Append-only edit log for the file as a whole ───────────────────
# Every hand-edit must leave a record here. Removing an entry is a
# validator error.
[[edits]]
at = 2026-04-21T09:00:00Z
by = "alice@example.com"
sessions = ["d1714569-…"]
turns = [3, 7]
reason = "Redacted internal algorithm name in turn 3."| Field | Required | Notes |
|---|---|---|
branch |
yes | git branch at commit time |
message |
no | commit message |
committed_at |
no | UTC RFC-3339, Z suffix |
author_name |
yes | git config user.name |
author_email |
yes | git config user.email — never rendered publicly |
author_username |
no | Cloud handle; set when the email maps to a user |
Email lives in the file because it lives in every git commit anyway.
Public-facing UI (cards, feed, permalinks) only ever shows
author_username when present, else author_name, else anonymous.
| Field | Required | Notes |
|---|---|---|
id |
yes | unique UUID-ish string; primary key in Cloud and in permalinks |
source |
yes | claude_code | cursor | codex | compress | manual |
model |
no | captured model name (what the engineer actually ran) |
started_at |
no | UTC RFC-3339 |
ended_at |
no | UTC RFC-3339 |
operator |
no | who drove the chat (email); may differ from [commit].author_email |
title |
no | author-written headline; drives feed readability |
summary |
no | 1–3 sentence author-written prose |
lesson |
no | one-sentence "what a colleague should know" |
tags |
no | string array; free-form |
outcome |
no | shipped | abandoned | exploratory | failed |
visibility |
no | public (default) | private |
forked_from |
no | id of an ancestor session, if any |
paths_touched |
no | string array; populated from tool-call args.path on write |
verbose |
no | boolean; when true, assistant turns may carry text |
merged_from |
no | branch name a session was merged from (set by Cloud at merge time) |
merged_at |
no | UTC RFC-3339; when the session landed on trunk via review |
approved_by |
no | reviewer handle / email who clicked "Merge into trunk" |
The merged_from / merged_at / approved_by trio is the
review provenance signal. They're set ONLY by Cloud's branch-merge
endpoint when a reviewed branch's session is appended into trunk's
prompts file — never by spec prompts capture. Sessions in trunk's
file with these fields populated render with the green-dot
"reviewed" chip in the feed UI; sessions without them are
direct-to-trunk captures and stay un-flagged. They're idempotent:
re-running merge on a session that's already stamped leaves the
existing values alone, so the original review attribution is never
overwritten.
All author-written fields stay author-written — the Cloud backend
never synthesises title, summary, lesson, or outcome. The
review provenance fields are the one exception: they're written by
Cloud, not the author.
A v0.2 branch file accumulates many commits over time, so each
session can carry its own commit context. The fields mirror the
file-level [commit] block plus a commit_sha:
| Field | Notes |
|---|---|
branch |
branch name as known to git at capture time |
commit_sha |
commit the session was captured against |
committed_at |
UTC RFC-3339; commit timestamp |
message |
commit message subject (optional) |
author_name |
git config user.name at capture time |
author_email |
git config user.email at capture time |
author_username |
Cloud handle if known |
The block is omitted entirely when every field is None (typical on
files written before v0.2 introduced per-session attribution).
A session is an ordered array of turns. The compiler reads them in
array order; at is informational, not structural.
| role | Required fields | Optional fields |
|---|---|---|
user |
role, text |
at |
assistant |
role |
at, model, summary, tool_calls |
tool_result |
reserved — disallowed in v0.1 |
Assistant turns MUST NOT carry text unless the session's verbose is
true. Use summary for short descriptions; regenerate full text with
spec prompts simulate. Per-turn text (user, or assistant when
verbose = true) is capped (512 KiB in the reference implementation;
see MAX_TURN_TEXT_CHARS in spec_cli.prompts.schema).
Optional per-turn model (string, ≤128 chars in the reference validator)
records which LLM answered that assistant turn. User turns MUST NOT
include model; session-level sessions.model remains for backward
compatibility (typically first model seen in the thread).
Every [[turns.tool_calls]] entry:
name = "ToolName" # required; see allowlist below
args = { … } # required; per-tool summary fields
status = "ok" # optional; ok | error| Tool | Captured args fields |
|---|---|
Read |
path, optional offset, limit |
Glob |
pattern, optional target_directory |
Grep |
pattern, optional path, glob, type, output_mode |
Shell |
command (truncated ≤200 chars), optional cwd, exit |
Write |
path, bytes (size of content) |
Edit / StrReplace |
path, old_head, new_head (each ≤40 chars) |
Delete |
path |
WebFetch |
url |
WebSearch |
search_term |
Task |
subagent_type, description, optional model |
TodoWrite |
todos (full; already structured and small) |
Hard rules (from §Determinism + §Privacy):
- No payloads. File contents, stdout, diffs, grep results: never stored. The linkage to the repo is by path, not by content.
- Truncate aggressively. Every free-text field is capped. Overflow
is replaced with
…[truncated N chars]. - Secret scrub applies to
command,url,title,summary,lesson,tags, and any free-text field. Regex-based. - Unknown tools are dropped with a trailing comment. The validator
warns;
--strict-unknownescalates to an error.
[[edits]]
at = 2026-04-21T09:00:00Z
by = "alice@example.com"
sessions = ["d1714569-…"] # which session ids were edited
turns = [3, 7] # per-session turn indices affected
reason = "Human-readable explanation."Removing an entry is a hard validator error. Adding is free.
A .prompts file is valid iff:
- Parses as TOML.
schemais either absent or equalsspec.prompts/v0.1.- Exactly one
[commit]table. [commit].branch,author_name,author_emailare present and non-empty.[[sessions]]contains at least one session.- Each session has
id(non-empty, unique within the file) andsource∈{claude_code, cursor, codex, compress, manual}. - Each session has at least one
[[turns]]entry. - Every turn has a
rolein the allowed set;userhastext(per-turn cap, 512 KiB in the reference validator) and nomodel;assistanthas notextunlessverbose = true(same cap on storedtextwhen verbose). Assistantmodel, when present, is capped (128 chars in the reference validator). visibility∈{public, private}if present (defaultpublic).outcome∈ the enum if present.tool_calls[].nameis in the allowlist OR dropped with a comment.- Unknown keys anywhere are a hard error. Typos must fail loud.
The validator lives in spec_cli.prompts.schema and is reused by
the compiler.
spec prompts capture must produce byte-identical files when
run twice with identical inputs. Concretely:
- Field order is fixed (writer enforces).
- Timestamps are normalised to UTC +
Zsuffix + second precision. - Sessions within a file are sorted by
started_at(ascending),idas a tiebreaker. - Turns within a session are ordered by source-file arrival — never
sorted on
atalone, because sources that lack per-turn timestamps would become non-deterministic. - Array values (
tags,paths_touched) are emitted in source order and de-duplicated; the writer does not alphabetically sort, because human edits carry meaning in order. - Never emit machine-generated "helpful" comments. A
.promptsfile has exactly one header comment, fixed text.
Users SHOULD edit .prompts files. The rules:
- Rewrite
textto tighten, redact, or clarify a user prompt. The compiler treats the new text as authoritative. - Write
title,summary,lesson. These are author-written and drive the/promptsfeed. - Set
outcomeandvisibilitydeliberately. Defaultvisibilityispublic; flip toprivatefor sensitive sessions (the commit's file still needs to exist, but the feed skips that session). - Delete turns to prune exploratory noise.
- Insert new
[[sessions.turns]]blocks by hand. A hand-authored user turn is indistinguishable from a captured one — by design. - Add an
[[edits]]entry describing the change. Not enforced at write time, but the validator flags edits without a corresponding log entry (warn, not error).
Forbidden:
- Deleting
[[edits]]entries — the log is append-only. - Changing a session's
id— ids are primary keys for cross-file references (forks, compile lineage, permalinks). - Removing
[commit].author_email— required for git/Cloud identity linkage.
Public by default. Per-session override.
visibility = "public"(default) → session appears in/promptsand is linkable by permalink.visibility = "private"→ session is still a build input (the compiler reads it the same way) but the feed and any public API refuse to surface it.- A future bundle-level private flag (not yet modelled) will trump per-session visibility entirely.
The file itself is always committed to git. "Private" is a publish gate, not a storage hide.
A regex pass runs at write time on:
- Every tool-call
argsstring field (command,url, free text). - Every session's
title,summary,lesson,tags[*]. - Every turn's
text(user) andsummary(assistant).
Patterns scrubbed (non-exhaustive): sk-…, ghp_…, AKIA…,
Bearer …, eyJ… (JWT), arbitrary password=, token=, apikey=
assignments. Matches are replaced with [REDACTED]. Defense in depth
— users may still paste tokens anywhere.
Prompts are promoted through the review lifecycle using four CLI commands; every state change is expressed as ordinary filesystem moves so the audit log is the git history of the bundle.
capture submit review
┌──────────┐ prompts/ ┌──────────┐ prompts/ ┌──────────┐
│ agent │──────────▶ │ captured │────────────▶ │ _pending │
│ store │ └──────────┘ └────┬─────┘
└──────────┘ │
accept ───┴─── reject
│ │
▼ ▼
┌──────────┐ deleted
│ curated │ from worktree
└──────────┘
- Capture (
spec prompts capture) pulls new sessions from the local agent store (Claude Code today) and writes them intoprompts/captured/. Capture is idempotent across tiers — once a session id appears anywhere underprompts/, it won't be re-written. - Submit (
spec prompts submit <file>or--all-captured) moves a file intoprompts/curated/_pending/. The author commits the move and opens a PR; the pending file now appears in the PR's diff alongside whatever spec edits it accompanies. - Review (
spec prompts review) walks each pending file on the reviewer's checkout of the PR. For each file the reviewer either accepts it (the file moves toprompts/curated/) or rejects it (the file is deleted from the worktree). Review only mutates the filesystem; the reviewer stages, commits, and pushes the result, so the acceptance or rejection is captured in normal git history. - Check (
spec prompts check --ci) is a CI-friendly gate that exits non-zero wheneverprompts/curated/_pending/is non-empty. Wire it as a GitHub Actions required status check and enable branch protection: a PR is mergeable only when every pending prompt has been accepted or rejected. Because rejection deletes the file, a rejected prompt never lands on the default branch — the code that merges is exactly the code the reviewer approved.
# .github/workflows/spec-prompts.yml
name: spec prompts
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install spec CLI
# Do not `pip install spec-cli` — PyPI's "spec-cli" is a different
# project. Same install as https://spec.lightreach.io/install
run: |
curl -LsSf https://spec.lightreach.io/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- run: spec prompts check --ciIn GitHub branch protection, mark spec prompts / check as a
required status check for the default branch. Done — no merge with
pending prompts.
spec prompts capture Discover new sessions from local Claude Code,
Cursor, Codex, and Compress stores. Appends
snapshots to prompts/<branch>.prompts.
--since <iso> Only sessions started after this time.
--source claude_code|cursor|codex|compress|all
Restrict to one source. Default: all.
spec codex capture Show recent Codex Desktop chats for this bundle,
select one by number, and append it as one
`source = "codex"` session.
The normal `spec prompts capture --source all`
and `spec add .` flows also scan Codex Desktop,
Claude Code, and Cursor stores automatically.
--verbose Capture full assistant text. Marks the file
with `session.verbose = true` on each such
session. Off by default.
--dry-run Print counts, don't write.
spec prompts validate Run schema + determinism checks against every
.prompts file in the bundle. Exit 0 clean,
1 on any error.
[<path>...] Restrict to given paths.
--strict-unknown Treat unknown tool names as errors, not
warnings.
spec prompts simulate Re-run a session through the compiler in a
read-only sandbox, up to the specified turn,
and print (or record) the simulated response.
--session <id|path> Session id or containing file. Required.
--up-to-turn <n> Stop after turn n. Default: last turn.
--model <name> Override compiler.model.
--record POST the simulation record to Cloud under
the bundle's cloud.project (hashes + metadata
only).
--no-tools Refuse all tool calls — pure text reply.
--dry-run Show the plan, don't call the model.
- Discover candidate sessions from enabled sources (Claude Code, and in future Cursor).
- Drop any session already present in any existing
.promptsfile inprompts/(byid). Capture is idempotent. - Read git context:
git rev-parse --abbrev-ref HEAD,git config user.name,git config user.email. These seed[commit]. - Timestamp the output filename with
datetime.now(UTC). - Write
<bundle-root>/prompts/<timestamp>.promptswith a[commit]block plus one[[sessions]]block per discovered session. - Never overwrite an existing file; abort if the timestamp collides (second-precision collisions require two captures in the same second — extremely rare, and the fix is "wait a second and re-run").
Unchanged from previous spec. Simulations run in the compiler on
the user's machine, never in Cloud. Cloud only stores a --record
summary (hashes + metadata).
Hard invariants:
- Simulated responses NEVER land in committed
.promptsfiles. - Simulated responses NEVER trigger repo writes.
- Captured and simulated responses are distinct rows in Cloud with distinct UI treatments.
- Pre-commit hook vs explicit capture. A hook could auto-write
the
.promptsfile before every commit. For v0.1 capture is explicit; hook integration is a v0.2 comfort. - Maximum file size. Cap at N sessions / N KiB per
.prompts? A commit with 50 sessions is unusual; probably a sign the user should have split the commit. - Sidechain / sub-agent turns. Claude Code emits
isSidechain: truefor Task-launched sub-agents. Fold into the parent session under a[[turns.sidechains]]nested array, or represent sub-agents as separate sessions with aparent_session_id? Leaning nested for v0.1 to avoid file explosion. - File-level edits for redaction. Today edits record per-session
turn indices. If a user rewrites
[commit].author_name(unlikely but possible), there's no session to pin to. A"file"sentinel forsessionsin an[[edits]]entry is one shape; noted, not yet decided.