文件

1546 行
56 KiB
Python
可执行文件

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Add a new session to journal file and update index.md.
Usage:
python3 add_session.py --title "Title" --commit "hash" --summary "Summary" [--package cli]
python3 add_session.py --title "Title" --branch "feat/my-branch"
# Pipe detailed content via stdin (use --stdin to opt in):
cat << 'EOF' | python3 add_session.py --stdin --title "Title" --summary "Summary"
<session content here>
EOF
# Structured content (repeatable; a section with no bullets is omitted):
python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z"
# Commit evidence that cannot be resolved from the local object database
# (amended, not yet fetched) needs an explicit subject, one per OID:
python3 add_session.py --title "Title" --commit "abc1234" \
--commit-subject "abc1234=fix(cli): stop truncating the manifest"
Commit evidence:
Every --commit token is a bounded hex OID (7-40 chars). Each one is
resolved to its real subject from the local object database BEFORE
anything is written. An OID that cannot be resolved fails the command
before the journal or index is touched — no placeholder prose is ever
recorded. Pass "-" (the default) for a planning session with no commits.
Retry convergence:
The journal append, the index row and the optional auto-commit are one
resumable operation. Every entry carries a fingerprint marker derived from
its own inputs, so re-running an interrupted command repairs the pending
record instead of appending a second one, and a failed auto-commit exits
non-zero with the checkpoint to resume from. A record that is already
committed is never reused: an identical later request is a new session
unless an explicit --idempotency-key says otherwise.
Branch resolution order:
1. --branch CLI arg (explicit)
2. task.json branch field (from active task, if still exists)
3. git branch --show-current (auto-detect)
4. None (omitted gracefully)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from common.paths import (
DIR_TASKS,
DIR_WORKFLOW,
FILE_JOURNAL_PREFIX,
get_repo_root,
get_current_task,
get_developer,
get_workspace_dir,
)
from common.developer import ensure_developer
from common.git import branch_exists_locally, run_git
from common.io import write_text_atomic
from common.log import Colors, colored
from common.safe_commit import (
print_gitignore_warning,
safe_git_add,
safe_trellis_paths_to_add,
)
from common.tasks import load_task
from common.types import TaskInfo
from common.config import (
get_packages,
get_session_auto_commit,
get_session_commit_message,
get_max_journal_lines,
is_monorepo,
resolve_package,
validate_package,
)
# Bounded, argv-safe input shapes. Anything outside them is rejected during
# preflight, before a single byte is written.
COMMIT_TOKEN_RE = re.compile(r"^[0-9a-fA-F]{7,40}$")
IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
MAX_SUBJECT_LEN = 500
# Machine-readable record identity. An HTML comment renders as nothing, so the
# human evidence in the entry is unchanged by its presence.
MARKER_PREFIX = "<!-- trellis-session:"
# Bumped when the fingerprint inputs change. Untagged markers are v1, whose
# fingerprint mixed in the calendar date; see compute_record_fingerprint.
MARKER_VERSION = 2
LEGACY_MARKER_RE = re.compile(r"^<!-- trellis-session: fp=([0-9a-f]{16}) -->$")
ENTRY_DATE_RE = re.compile(r"^\*\*Date\*\*: (\d{4}-\d{2}-\d{2})\s*$")
SESSION_HEADING_RE = re.compile(r"^## Session (\d+):", re.MULTILINE)
# Recording states, in the order the operation walks them.
STATE_ABSENT = "absent"
STATE_JOURNAL_RECORDED = "journal-recorded"
STATE_INDEX_RECORDED = "index-recorded"
STATE_COMMITTED = "committed"
# Auto-commit outcomes.
COMMIT_DONE = "committed"
COMMIT_SKIPPED = "skipped"
COMMIT_BLOCKED = "blocked"
COMMIT_FAILED = "failed"
# Git probes against other refs are best-effort; never let one hang a session.
GIT_PROBE_TIMEOUT = 15.0
# Upper bound on the refs folded into session numbering. A repo with hundreds
# of stale branches must not turn session recording into a tree walk.
MAX_CONVERGENCE_REFS = 100
# =============================================================================
# Helper Functions
# =============================================================================
def get_latest_journal_info(dev_dir: Path) -> tuple[Path | None, int, int]:
"""Get latest journal file info.
Returns:
Tuple of (file_path, file_number, line_count).
"""
latest_file: Path | None = None
latest_num = -1
for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
if not f.is_file():
continue
match = re.search(r"(\d+)$", f.stem)
if match:
num = int(match.group(1))
if num > latest_num:
latest_num = num
latest_file = f
if latest_file:
lines = len(latest_file.read_text(encoding="utf-8").splitlines())
return latest_file, latest_num, lines
return None, 0, 0
def get_current_session(index_file: Path) -> int:
"""Get current session number from index.md."""
if not index_file.is_file():
return 0
content = index_file.read_text(encoding="utf-8")
return _max_session_in_index(content)
def _max_session_in_index(content: str) -> int:
"""Read the `Total Sessions` counter out of an index.md body."""
for line in content.splitlines():
if "Total Sessions" in line:
match = re.search(r":\s*(\d+)", line)
if match:
return int(match.group(1))
return 0
def _max_session_in_journal(content: str) -> int:
"""Highest `## Session N:` heading in a journal body (0 when none)."""
numbers = [int(m.group(1)) for m in SESSION_HEADING_RE.finditer(content)]
return max(numbers) if numbers else 0
def _extract_journal_num(filename: str) -> int:
"""Extract journal number from filename for sorting."""
match = re.search(r"(\d+)", filename)
return int(match.group(1)) if match else 0
def count_journal_files(dev_dir: Path, active_num: int) -> str:
"""Count journal files and return table rows."""
active_file = f"{FILE_JOURNAL_PREFIX}{active_num}.md"
result_lines = []
files = sorted(
[f for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md") if f.is_file()],
key=lambda f: _extract_journal_num(f.stem),
reverse=True
)
for f in files:
filename = f.name
lines = len(f.read_text(encoding="utf-8").splitlines())
status = "Active" if filename == active_file else "Archived"
result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
return "\n".join(result_lines)
def get_current_git_branch(repo_root: Path) -> str | None:
"""Return the current checkout branch, or None for detached/non-git states."""
rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
if rc != 0:
return None
detected = branch_out.strip()
return detected or None
def branch_ref_exists(repo_root: Path, branch: str) -> bool:
"""Return True when branch exists locally or as the local origin ref."""
for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"):
rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root)
if rc == 0:
return True
return False
def resolve_session_branch(
repo_root: Path,
cli_branch: str | None,
task_data: TaskInfo | None,
) -> str | None:
"""Resolve journal branch without trusting stale task.json branch fields."""
if cli_branch:
return cli_branch
current_branch = get_current_git_branch(repo_root)
raw_task_branch = task_data.raw.get("branch") if task_data else None
task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else ""
if not task_branch:
return current_branch
if branch_ref_exists(repo_root, task_branch):
return task_branch
if current_branch:
print(
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.",
file=sys.stderr,
)
return current_branch
print(
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.",
file=sys.stderr,
)
return None
def is_git_worktree(repo_root: Path) -> bool:
"""Return True when repo_root is a linked worktree (not the main working tree).
Standard test: `git rev-parse --git-dir` (per-worktree) differs from
`git rev-parse --git-common-dir` (shared across all worktrees) once both
are resolved to absolute paths. In the main working tree these are the
same directory.
"""
rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root)
rc_common, git_common_dir, _ = run_git(
["rev-parse", "--git-common-dir"], cwd=repo_root
)
if rc_dir != 0 or rc_common != 0:
return False
git_dir_path = (repo_root / git_dir.strip()).resolve()
git_common_dir_path = (repo_root / git_common_dir.strip()).resolve()
return git_dir_path != git_common_dir_path
def warn_if_parallel_worktree(repo_root: Path) -> None:
"""Non-blocking note: index.md conflicts across parallel worktrees/branches
are expected and safe. Only fires when running in a linked git worktree
(not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier).
"""
if not get_session_auto_commit(repo_root):
return
if not is_git_worktree(repo_root):
return
print(
colored(
"[NOTE] Running in a git worktree with session_auto_commit enabled: "
"journal-*.md files auto-merge via .gitattributes, but index.md "
"conflicts across parallel worktrees/branches are expected and safe "
"to resolve by picking either side (task state lives in task.json, "
"not index.md). See .trellis/spec/cli/backend/directory-structure.md "
'("Workspace Journal Merge Behavior").',
Colors.YELLOW,
),
file=sys.stderr,
)
def create_new_journal_file(
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
) -> Path | None:
"""Create a new journal file. Returns None when the write fails."""
prev_num = num - 1
new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
content = f"""# Journal - {developer} (Part {num})
> Continuation from `{FILE_JOURNAL_PREFIX}{prev_num}.md` (archived at ~{max_lines} lines)
> Started: {today}
---
"""
if not write_text_atomic(new_file, content):
return None
return new_file
# =============================================================================
# Commit evidence (R1) — accurate subjects or nothing
# =============================================================================
def parse_commit_tokens(commit: str) -> tuple[list[str], str | None]:
"""Split --commit into bounded hex OIDs. Returns (tokens, error)."""
raw = (commit or "").strip()
if not raw or raw == "-":
return [], None
tokens: list[str] = []
for part in raw.split(","):
token = part.strip()
if not token:
continue
if not COMMIT_TOKEN_RE.match(token):
return [], (
f"invalid --commit token '{token}': expected a 7-40 character hex "
"commit OID, or '-' for a planning session with no commits"
)
token = token.lower()
if token not in tokens:
tokens.append(token)
return tokens, None
def parse_subject_overrides(values: list[str] | None) -> tuple[dict[str, str], str | None]:
"""Parse `--commit-subject <oid>=<subject>` into a one-to-one mapping."""
overrides: dict[str, str] = {}
for raw in values or []:
if "=" not in raw:
return {}, (
f"invalid --commit-subject '{raw}': expected <oid>=<subject>"
)
oid_part, subject = raw.split("=", 1)
oid = oid_part.strip().lower()
subject = subject.strip()
if not COMMIT_TOKEN_RE.match(oid):
return {}, (
f"invalid --commit-subject key '{oid_part.strip()}': expected a "
"7-40 character hex commit OID"
)
if not subject:
return {}, f"invalid --commit-subject for '{oid}': subject is empty"
if len(subject) > MAX_SUBJECT_LEN:
return {}, (
f"invalid --commit-subject for '{oid}': subject exceeds "
f"{MAX_SUBJECT_LEN} characters"
)
if oid in overrides:
return {}, f"duplicate --commit-subject mapping for '{oid}'"
overrides[oid] = subject
return overrides, None
def resolve_commit_subject(repo_root: Path, oid: str) -> str | None:
"""Resolve one OID to its subject from the local object database.
Passed as argv (never interpolated into a shell) and peeled with
``^{commit}`` so a hex-looking ref name cannot resolve to a tree or tag.
Returns None when the object is missing, ambiguous, or has no subject.
"""
rc, out, _ = run_git(
["show", "-s", "--format=%s", f"{oid}^{{commit}}", "--"], cwd=repo_root
)
if rc != 0:
return None
for line in out.splitlines():
subject = line.strip()
if subject:
return subject[:MAX_SUBJECT_LEN]
return None
def build_commit_evidence(
repo_root: Path,
tokens: list[str],
overrides: dict[str, str],
) -> tuple[list[tuple[str, str]], str | None]:
"""Pair every commit OID with an accurate subject, or fail.
There is no placeholder path: an OID that neither resolves locally nor
carries an explicit mapping stops the command before any mutation.
"""
evidence: list[tuple[str, str]] = []
unresolved: list[str] = []
for oid in tokens:
if oid in overrides:
evidence.append((oid, overrides[oid]))
continue
subject = resolve_commit_subject(repo_root, oid)
if subject is None:
unresolved.append(oid)
continue
evidence.append((oid, subject))
if unresolved:
return [], (
"cannot resolve commit evidence for: "
+ ", ".join(unresolved)
+ ". Fetch the objects, correct the OID, or pass the exact subject "
"with --commit-subject <oid>=<subject>. Nothing was written."
)
unused = sorted(oid for oid in overrides if oid not in tokens)
if unused:
return [], (
"--commit-subject supplied for OIDs that are not in --commit: "
+ ", ".join(unused)
+ ". The mapping must be one-to-one. Nothing was written."
)
return evidence, None
def escape_markdown_cell(text: str) -> str:
"""Make a commit subject safe inside a Markdown table cell."""
collapsed = " ".join(text.split())
return collapsed.replace("\\", "\\\\").replace("|", "\\|")
# =============================================================================
# Record identity (R2)
# =============================================================================
def _normalize_text(value: str | None) -> str:
return " ".join((value or "").split())
def _fingerprint_payload(
developer: str,
title: str,
summary: str,
package: str | None,
branch: str | None,
evidence: list[tuple[str, str]],
changes: list[str] | None,
extra_content: str | None,
tests: list[str] | None,
next_steps: list[str] | None,
idempotency_key: str | None,
) -> dict:
"""Normalized semantic inputs of one record, shared by both schemes."""
return {
"developer": developer,
"title": _normalize_text(title),
"summary": _normalize_text(summary),
"package": package or "",
"branch": branch or "",
"commits": [[oid, _normalize_text(subject)] for oid, subject in evidence],
"changes": [_normalize_text(c) for c in changes or []],
"extra": _normalize_text(extra_content),
"tests": [_normalize_text(t) for t in tests or []],
"next_steps": [_normalize_text(n) for n in next_steps or []],
"idempotency_key": idempotency_key or "",
}
def _hash_payload(payload: dict) -> str:
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:16]
def compute_record_fingerprint(payload: dict) -> str:
"""Bounded fingerprint over the normalized semantic inputs of one record.
This is the retry key while the record is still pending in the worktree —
not a global dedupe key. Two genuinely separate sessions with identical
prose differ only if the caller says so, which is why an already-committed
match is never adopted (see classify_record).
Deliberately date-free (v2). v1 mixed in the calendar date, which made a
record unfindable by its own retry across a midnight rollover: the journal
and index were already written, the commit had failed, and the recomputed
fingerprint no longer matched the marker sitting in the journal, so the
retry appended a second entry for one session.
Dropping the date cannot over-collapse two same-day sessions, because the
date was equal for both of them anyway. Across days it only ever separated
records with byte-identical prose, commits, branch and package — and a
*committed* record of that shape is already refused for reuse by
cmd_add_session, which steps to the next `generation` of the payload
rather than reusing the committed marker. What is left
is exactly the question this key should answer: is there an uncommitted
record in this worktree matching these inputs?
"""
return _hash_payload(payload)
def compute_legacy_fingerprint(payload: dict, date: str) -> str:
"""The v1 fingerprint, for resolving markers written before the change.
Lookup only — nothing writes this scheme any more.
"""
return _hash_payload({**payload, "date": date})
def render_marker(fingerprint: str) -> str:
"""Machine-readable record marker; renders as nothing in Markdown."""
return f"{MARKER_PREFIX} v={MARKER_VERSION} fp={fingerprint} -->"
def render_legacy_marker(fingerprint: str) -> str:
"""The v1 marker form: no version tag. Read, never written."""
return f"{MARKER_PREFIX} fp={fingerprint} -->"
# =============================================================================
# Session numbering (collision-proof across concurrent branches)
# =============================================================================
def _repo_relative(repo_root: Path, path: Path) -> str | None:
try:
return path.resolve().relative_to(repo_root.resolve()).as_posix()
except (OSError, ValueError):
return None
def _default_branch_local(repo_root: Path) -> str | None:
"""Resolve the default branch without ever touching the network.
`resolve_default_branch()` falls back to `git remote show origin`, which
can block on a fetch. Session recording is a hot path, so it stops at the
local symbolic ref and the two conventional names.
"""
rc, out, _ = run_git(
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
cwd=repo_root,
timeout=GIT_PROBE_TIMEOUT,
)
if rc == 0 and out.strip():
return out.strip().rsplit("/", 1)[-1]
for candidate in ("main", "master"):
if branch_exists_locally(candidate, repo_root):
return candidate
return None
def _convergence_refs(repo_root: Path) -> list[str]:
"""Refs whose recorded workspace state must be folded into the counter.
Ordered by how much they matter, then capped: HEAD and the default branch
first (the merge target every branch eventually meets), then every other
local head — a parallel worktree's branch lives there and is the case that
actually collided — then remote-tracking refs.
"""
refs: list[str] = []
def add(ref: str) -> None:
if ref not in refs:
refs.append(ref)
rc, _, _ = run_git(["rev-parse", "--verify", "--quiet", "HEAD"], cwd=repo_root)
if rc == 0:
add("HEAD")
default = _default_branch_local(repo_root)
if default:
for ref in (f"refs/heads/{default}", f"refs/remotes/origin/{default}"):
rc, _, _ = run_git(
["show-ref", "--verify", "--quiet", ref], cwd=repo_root
)
if rc == 0:
add(ref)
for pattern in ("refs/heads/", "refs/remotes/"):
rc, out, _ = run_git(
["for-each-ref", "--format=%(refname)", pattern],
cwd=repo_root,
timeout=GIT_PROBE_TIMEOUT,
)
if rc != 0:
continue
for line in out.splitlines():
ref = line.strip()
# A symbolic ref like refs/remotes/origin/HEAD is a duplicate of
# the branch it points at and git grep rejects some of them.
if ref and not ref.endswith("/HEAD"):
add(ref)
return refs[:MAX_CONVERGENCE_REFS]
def _max_session_across_refs(repo_root: Path, refs: list[str], dev_rel: str) -> int:
"""Highest session number recorded under `dev_rel` in any of `refs`.
One `git grep` over every ref rather than an ls-tree + show per ref: the
number of branches in a real repo is unbounded, the per-ref cost is not.
Exit status 1 (no match) is a normal outcome, not an error.
"""
if not refs:
return 0
rc, out, _ = run_git(
[
"grep",
"--no-color",
"-h",
"-E",
"-e",
r"^## Session [0-9]+:",
"-e",
r"Total Sessions",
]
+ refs
+ ["--", dev_rel],
cwd=repo_root,
timeout=GIT_PROBE_TIMEOUT,
)
if rc > 1 or not out:
return 0
# `git grep` prefixes each hit with `<rev>:<path>:`, so the numbers are
# matched anywhere in the line rather than anchored.
numbers = [int(m.group(1)) for m in re.finditer(r"## Session (\d+):", out)]
numbers += [
int(m.group(1)) for m in re.finditer(r"Total Sessions[^\d]{0,8}(\d+)", out)
]
return max(numbers) if numbers else 0
def max_local_session(dev_dir: Path, index_file: Path) -> int:
"""Highest session number visible in the working tree."""
highest = get_current_session(index_file)
for f in dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
if not f.is_file():
continue
try:
content = f.read_text(encoding="utf-8")
except OSError:
continue
highest = max(highest, _max_session_in_journal(content))
return highest
def resolve_next_session(repo_root: Path, dev_dir: Path, index_file: Path) -> int:
"""Next session number, converged across the local tree and other branches.
Deriving the number from the working tree alone lets two branches that
each record before merging claim the same number (observed twice on
2026-08-06). Taking the union with every recorded ref — the default
branch, and the other local heads a parallel worktree records on — makes
the number monotonic across concurrent branches; `journal-*.md`
merge=union then keeps both entries when the branches meet.
"""
highest = max_local_session(dev_dir, index_file)
dev_rel = _repo_relative(repo_root, dev_dir)
if dev_rel:
highest = max(
highest,
_max_session_across_refs(
repo_root, _convergence_refs(repo_root), dev_rel
),
)
return highest + 1
# =============================================================================
# Pending-record classifier (R3/R4)
# =============================================================================
def find_marker_entries(dev_dir: Path, marker: str) -> list[tuple[Path, int | None]]:
"""Locate every journal entry carrying `marker`.
Returns (journal_file, session_number); the number is None when the marker
is not directly under a `## Session N:` heading, which is malformed
pending evidence and must never be guessed into completion.
"""
hits: list[tuple[Path, int | None]] = []
for f in sorted(dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
if not f.is_file():
continue
try:
lines = f.read_text(encoding="utf-8").splitlines()
except OSError:
continue
for i, line in enumerate(lines):
if line.strip() != marker:
continue
session_num: int | None = None
for j in range(i - 1, max(-1, i - 4), -1):
match = SESSION_HEADING_RE.match(lines[j])
if match:
session_num = int(match.group(1))
break
hits.append((f, session_num))
return hits
def content_at_head(repo_root: Path, path: Path) -> str | None:
"""File content as committed at HEAD, or None when it isn't there.
The `./` prefix makes the path cwd-relative. `HEAD:<path>` alone is
relative to the *git* toplevel, which is not always `repo_root` — the
Trellis root is the nearest directory holding `.trellis/`, which can sit
below the git root. Without it, every lookup in that layout fails and a
committed record looks pending.
"""
rel = _repo_relative(repo_root, path)
if not rel:
return None
rc, out, _ = run_git(["show", f"HEAD:./{rel}"], cwd=repo_root)
return out if rc == 0 else None
def index_has_session_row(index_file: Path, session_num: int) -> bool:
"""Whether the session-history block already holds this session's row."""
if not index_file.is_file():
return False
try:
lines = index_file.read_text(encoding="utf-8").splitlines()
except OSError:
return False
row_re = re.compile(r"^\|\s*%d\s*\|" % session_num)
in_history = False
for line in lines:
if "@@@auto:session-history" in line:
in_history = True
continue
if "@@@/auto:session-history" in line:
in_history = False
continue
if in_history and row_re.match(line):
return True
return False
def _entry_date_at(lines: list[str], marker_index: int) -> str | None:
"""The `**Date**:` value belonging to the entry whose marker is at `marker_index`.
`generate_session_content` renders it two lines below the marker. Scanning
a short window rather than a fixed offset keeps this working if blank-line
spacing around the header ever changes, and stops well before it could
reach the next entry.
"""
for line in lines[marker_index + 1:marker_index + 6]:
match = ENTRY_DATE_RE.match(line.strip())
if match:
return match.group(1)
return None
def resolve_effective_marker(
dev_dir: Path,
payload: dict,
marker: str,
) -> tuple[str, str | None]:
"""The marker this record actually carries, across both schemes.
Returns (marker, error). The v2 marker wins whenever an entry carries it,
which is every record written since the change and costs one scan.
Otherwise this looks for a pending record written under v1. Those markers
are only a hash, but the entry renders its own `**Date**:` line right
below, so the date that produced the v1 fingerprint is recoverable from
the entry itself: recompute with that date and compare against what is
actually written there. That is exact, and unlike a +/-1 day window it does
not quietly fail a retry resumed after a weekend.
Returning the v1 marker leaves the entry untouched — it is an in-flight
record about to be committed, and rewriting its marker mid-repair would
move it to a state the machine does not model.
"""
if find_marker_entries(dev_dir, marker):
return marker, None
matches: set[str] = set()
for journal in sorted(dev_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
if not journal.is_file():
continue
try:
lines = journal.read_text(encoding="utf-8").splitlines()
except OSError:
continue
for i, line in enumerate(lines):
legacy = LEGACY_MARKER_RE.match(line.strip())
if not legacy:
continue
date = _entry_date_at(lines, i)
if date is None:
continue
if compute_legacy_fingerprint(payload, date) == legacy.group(1):
matches.add(line.strip())
if len(matches) > 1:
return "", (
f"found {len(matches)} pending journal entries matching this record "
"under the pre-versioning marker scheme. Refusing to guess which one "
"to resume — remove the duplicate entry or pass --idempotency-key to "
"record a new session."
)
if matches:
return matches.pop(), None
return marker, None
def classify_record(
repo_root: Path,
dev_dir: Path,
index_file: Path,
marker: str,
) -> tuple[str, Path | None, int | None, str | None]:
"""Classify the current state of this exact record.
Returns (state, journal_file, session_num, error). Only a unique, exact,
still-uncommitted match is ever adopted; anything ambiguous, malformed or
partially written comes back as an error so the caller fails safely.
"""
hits = find_marker_entries(dev_dir, marker)
if not hits:
return STATE_ABSENT, None, None, None
if len(hits) > 1:
files = ", ".join(sorted({p.name for p, _ in hits}))
return "", None, None, (
f"found {len(hits)} journal entries carrying this record's marker "
f"({files}). Refusing to guess which one to resume — remove the "
"duplicate entry or pass --idempotency-key to record a new session."
)
journal_file, session_num = hits[0]
if session_num is None:
return "", None, None, (
f"{journal_file.name} carries this record's marker without a "
"'## Session N:' heading above it. The pending record is malformed; "
"repair it by hand before retrying."
)
head_content = content_at_head(repo_root, journal_file)
if head_content is not None and marker in head_content:
return STATE_COMMITTED, journal_file, session_num, None
if index_has_session_row(index_file, session_num):
return STATE_INDEX_RECORDED, journal_file, session_num, None
recorded_total = get_current_session(index_file)
if recorded_total > session_num:
return "", None, None, (
f"journal entry for session {session_num} has no index row, but "
f"index.md already counts {recorded_total} sessions. Repairing the "
"row would rewrite a later record — resolve index.md by hand."
)
return STATE_JOURNAL_RECORDED, journal_file, session_num, None
# =============================================================================
# Rendering
# =============================================================================
def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str:
"""Render a Markdown section as bullets, or "" when there is no content.
A section with zero provided values is omitted entirely from the
rendered entry rather than falling back to a placeholder string.
"""
if not items:
return ""
bullets = "\n".join(f"{bullet_prefix}{item}" for item in items)
return f"\n\n### {header}\n\n{bullets}"
def _render_main_changes(changes: list[str], extra_content: str | None) -> str:
"""Render the Main Changes section from --change bullets or freeform content."""
if changes:
return _render_bullet_section("Main Changes", changes)
if extra_content:
return f"\n\n### Main Changes\n\n{extra_content}"
return ""
def generate_session_content(
session_num: int,
title: str,
evidence: list[tuple[str, str]],
summary: str,
today: str,
marker: str,
package: str | None = None,
branch: str | None = None,
changes: list[str] | None = None,
extra_content: str | None = None,
tests: list[str] | None = None,
next_steps: list[str] | None = None,
) -> str:
"""Generate session content."""
if evidence:
commit_table = """| Hash | Message |
|------|---------|"""
for oid, subject in evidence:
commit_table += f"\n| `{oid}` | {escape_markdown_cell(subject)} |"
else:
commit_table = "(No commits - planning session)"
package_line = f"\n**Package**: {package}" if package else ""
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
main_changes_section = _render_main_changes(changes or [], extra_content)
testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ")
next_steps_section = _render_bullet_section("Next Steps", next_steps or [])
return f"""
## Session {session_num}: {title}
{marker}
**Date**: {today}
**Task**: {title}{package_line}{branch_line}
### Summary
{summary}{main_changes_section}
### Git Commits
{commit_table}{testing_section}
### Status
[OK] **Completed**{next_steps_section}
"""
def format_commit_display(evidence: list[tuple[str, str]]) -> str:
"""Index-table rendering of the commit OIDs."""
if not evidence:
return "-"
return ", ".join(f"`{oid}`" for oid, _ in evidence)
def update_index(
index_file: Path,
dev_dir: Path,
title: str,
evidence: list[tuple[str, str]],
new_session: int,
active_file: str,
today: str,
branch: str | None = None,
) -> bool:
"""Update index.md with new session info."""
commit_display = format_commit_display(evidence)
# Get file number from active_file name
match = re.search(r"(\d+)", active_file)
active_num = int(match.group(1)) if match else 0
files_table = count_journal_files(dev_dir, active_num)
print(f"Updating index.md for session {new_session}...")
print(f" Title: {title}")
print(f" Commit: {commit_display}")
print(f" Active File: {active_file}")
print()
content = index_file.read_text(encoding="utf-8")
if "@@@auto:current-status" not in content:
print("Error: Markers not found in index.md. Please ensure markers exist.", file=sys.stderr)
return False
# Process sections
lines = content.splitlines()
new_lines = []
in_current_status = False
in_active_documents = False
in_session_history = False
header_written = False
for line in lines:
if "@@@auto:current-status" in line:
new_lines.append(line)
in_current_status = True
new_lines.append(f"- **Active File**: `{active_file}`")
new_lines.append(f"- **Total Sessions**: {new_session}")
new_lines.append(f"- **Last Active**: {today}")
continue
if "@@@/auto:current-status" in line:
in_current_status = False
new_lines.append(line)
continue
if "@@@auto:active-documents" in line:
new_lines.append(line)
in_active_documents = True
new_lines.append("| File | Lines | Status |")
new_lines.append("|------|-------|--------|")
new_lines.append(files_table)
continue
if "@@@/auto:active-documents" in line:
in_active_documents = False
new_lines.append(line)
continue
if "@@@auto:session-history" in line:
new_lines.append(line)
in_session_history = True
header_written = False
continue
if "@@@/auto:session-history" in line:
in_session_history = False
new_lines.append(line)
continue
if in_current_status:
continue
if in_active_documents:
continue
if in_session_history:
# Migrate old 4/6-column headers to 5-column Branch-only history.
if re.match(
r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
line,
):
new_lines.append("| # | Date | Title | Commits | Branch |")
continue
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
new_lines.append("| # | Date | Title | Commits | Branch |")
continue
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
new_lines.append("| # | Date | Title | Commits | Branch |")
continue
if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
new_lines.append("|---|------|-------|---------|--------|")
new_lines.append(f"| {new_session} | {today} | {title} | {commit_display} | `{branch or '-'}` |")
header_written = True
continue
new_lines.append(line)
continue
new_lines.append(line)
if not write_text_atomic(index_file, "\n".join(new_lines)):
print(f"Error: failed to write {index_file}", file=sys.stderr)
return False
print("[OK] Updated index.md successfully!")
return True
# =============================================================================
# Main Function
# =============================================================================
def _auto_commit_workspace(repo_root: Path) -> str:
"""Stage Trellis-owned workspace + current-task paths and commit.
Path scope is restricted to specific products: the current developer's
journal files + index.md, and ONLY the current task directory (resolved
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
or iterate over all active task dirs (#303: parallel-window dirty task
dirs must not be bundled into the session auto-commit). If `.gitignore`
blocks the specific paths we warn + skip — never retry with ``-f``.
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
``false``, this function returns immediately without touching git
(journal/index files are still written to disk by the caller).
Returns one of ``COMMIT_DONE`` / ``COMMIT_SKIPPED`` / ``COMMIT_BLOCKED`` /
``COMMIT_FAILED``. ``COMMIT_BLOCKED`` is the gitignored-`.trellis/` case:
the user has told git to stay out of this tree, so it is a configured skip
like ``session_auto_commit: false``, not a failure to retry.
"""
if not get_session_auto_commit(repo_root):
print(
"[OK] session_auto_commit: false — skipping git stage/commit.",
file=sys.stderr,
)
return COMMIT_SKIPPED
# Not a git repository at all: like the gitignored case, this is an
# environment the user configured, not a transient git error — a retry
# can never succeed, so it must not be COMMIT_FAILED's exit-1 loop.
rc, _, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo_root)
if rc != 0:
print(
"[WARN] Not a git repository — journal/index written, "
"skipping auto-commit.",
file=sys.stderr,
)
return COMMIT_BLOCKED
commit_msg = get_session_commit_message(repo_root)
# Resolve the current task so staging is scoped to its dir only. The ref
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
current = get_current_task(repo_root)
if current:
task_name = Path(current).name
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
else:
# Current task unknown (0 or >=2 parallel sessions — exactly the
# parallel-window case #303 is about). Do NOT fall back to the wide
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
# dirs into the session commit. Stage only the developer's journal/
# index and skip every task dir.
paths = [
p
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
]
if not paths:
print("[OK] No workspace changes to commit.", file=sys.stderr)
return COMMIT_SKIPPED
success, _, err = safe_git_add(paths, repo_root)
if not success:
if err and "ignored by" in err.lower():
print_gitignore_warning(paths)
return COMMIT_BLOCKED
print(
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
file=sys.stderr,
)
return COMMIT_FAILED
# Check if there are staged changes for the paths we just staged.
rc, _, _ = run_git(
["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
)
if rc == 0:
print("[OK] No workspace changes to commit.", file=sys.stderr)
return COMMIT_SKIPPED
# Commit with an explicit pathspec: a bare `git commit` would sweep any
# unrelated entries the developer had staged before this script ran into
# the chore commit (#579). The pathspec keeps their staged work untouched.
rc, _, commit_err = run_git(
["commit", "-m", commit_msg, "--", *paths], cwd=repo_root
)
if rc == 0:
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
return COMMIT_DONE
print(
f"[WARN] Auto-commit failed: {commit_err.strip()}",
file=sys.stderr,
)
return COMMIT_FAILED
def _print_commit_checkpoint(session_num: int, journal_name: str) -> None:
"""Actionable checkpoint for a failed auto-commit (R5)."""
print("", file=sys.stderr)
print(
f"[BLOCKED] Checkpoint: session {session_num} is recorded in "
f"{journal_name} and index.md, but the auto-commit did not complete.",
file=sys.stderr,
)
print(
"[BLOCKED] The pending record was left exactly as written — nothing was "
"rolled back.",
file=sys.stderr,
)
print(
"[BLOCKED] Fix the git error above, then re-run the identical "
"add_session.py command: it resumes at the commit step and will not add "
"a second session.",
file=sys.stderr,
)
def add_session(
title: str,
commit: str = "-",
summary: str = "Session summary was not supplied.",
changes: list[str] | None = None,
extra_content: str | None = None,
tests: list[str] | None = None,
next_steps: list[str] | None = None,
auto_commit: bool = True,
package: str | None = None,
branch: str | None = None,
commit_subjects: list[str] | None = None,
idempotency_key: str | None = None,
) -> int:
"""Add a new session, resuming an interrupted one instead of duplicating it."""
repo_root = get_repo_root()
warn_if_parallel_worktree(repo_root)
ensure_developer(repo_root)
developer = get_developer(repo_root)
if not developer:
print("Error: Developer not initialized", file=sys.stderr)
return 1
dev_dir = get_workspace_dir(repo_root)
if not dev_dir:
print("Error: Workspace directory not found", file=sys.stderr)
return 1
# -------------------------------------------------------------------
# Preflight — no writes happen until every one of these succeeds.
# -------------------------------------------------------------------
if idempotency_key is not None and not IDEMPOTENCY_KEY_RE.match(idempotency_key):
print(
f"Error: invalid --idempotency-key '{idempotency_key}': expected 1-64 "
"characters from [A-Za-z0-9._-]",
file=sys.stderr,
)
return 1
tokens, token_error = parse_commit_tokens(commit)
if token_error:
print(f"Error: {token_error}", file=sys.stderr)
return 1
overrides, override_error = parse_subject_overrides(commit_subjects)
if override_error:
print(f"Error: {override_error}", file=sys.stderr)
return 1
evidence, evidence_error = build_commit_evidence(repo_root, tokens, overrides)
if evidence_error:
print(f"Error: {evidence_error}", file=sys.stderr)
return 1
max_lines = get_max_journal_lines(repo_root)
index_file = dev_dir / "index.md"
today = datetime.now().strftime("%Y-%m-%d")
payload = _fingerprint_payload(
developer, title, summary, package, branch, evidence,
changes, extra_content, tests, next_steps, idempotency_key,
)
marker = render_marker(compute_record_fingerprint(payload))
# `today` is no longer a fingerprint input; a record has to be findable by
# its own retry after a date rollover. It still dates the rendered entry.
marker, resolve_error = resolve_effective_marker(dev_dir, payload, marker)
if resolve_error:
print(f"Error: {resolve_error}", file=sys.stderr)
return 1
state, matched_file, matched_num, classify_error = classify_record(
repo_root, dev_dir, index_file, marker
)
if classify_error:
print(f"Error: {classify_error}", file=sys.stderr)
return 1
if state == STATE_COMMITTED and idempotency_key:
print(
f"[OK] Session {matched_num} with idempotency key "
f"'{idempotency_key}' is already recorded and committed in "
f"{matched_file.name if matched_file else 'the journal'}; "
"nothing to do.",
file=sys.stderr,
)
return 0
# A committed record is finished, so an identical later request is a
# legitimately new session. It must not reuse the committed marker: two
# entries carrying one marker make every later run ambiguous, and
# classify_record refuses to guess between them. Step to the next
# generation of this record instead. The marker stays a pure function of
# (payload, generation), so a retry of the new entry recomputes the same
# marker and still resumes; generation 0 omits the field entirely, leaving
# first-time markers byte-identical to what this scheme already writes.
generation = 0
while state == STATE_COMMITTED:
generation += 1
marker = render_marker(
compute_record_fingerprint({**payload, "generation": generation})
)
state, matched_file, matched_num, classify_error = classify_record(
repo_root, dev_dir, index_file, marker
)
if classify_error:
print(f"Error: {classify_error}", file=sys.stderr)
return 1
print("========================================", file=sys.stderr)
print("ADD SESSION", file=sys.stderr)
print("========================================", file=sys.stderr)
print("", file=sys.stderr)
# -------------------------------------------------------------------
# Absent → journal-recorded
# -------------------------------------------------------------------
target_file: Path | None
target_num: int
new_session: int
if state == STATE_ABSENT:
journal_file, current_num, current_lines = get_latest_journal_info(dev_dir)
new_session = resolve_next_session(repo_root, dev_dir, index_file)
session_content = generate_session_content(
new_session, title, evidence, summary, today, marker, package, branch,
changes=changes, extra_content=extra_content, tests=tests,
next_steps=next_steps,
)
content_lines = len(session_content.splitlines())
print(f"Session: {new_session}", file=sys.stderr)
print(f"Title: {title}", file=sys.stderr)
print(f"Commit: {format_commit_display(evidence)}", file=sys.stderr)
print("", file=sys.stderr)
print(f"Current journal file: {FILE_JOURNAL_PREFIX}{current_num}.md", file=sys.stderr)
print(f"Current lines: {current_lines}", file=sys.stderr)
print(f"New content lines: {content_lines}", file=sys.stderr)
print(f"Total after append: {current_lines + content_lines}", file=sys.stderr)
print("", file=sys.stderr)
target_file = journal_file
target_num = current_num
if current_lines + content_lines > max_lines:
target_num = current_num + 1
print(f"[!] Exceeds {max_lines} lines, creating {FILE_JOURNAL_PREFIX}{target_num}.md", file=sys.stderr)
target_file = create_new_journal_file(dev_dir, target_num, developer, today, max_lines)
if target_file is None:
print(
f"Error: failed to create {FILE_JOURNAL_PREFIX}{target_num}.md; "
"nothing was recorded.",
file=sys.stderr,
)
return 1
print(f"Created: {target_file}", file=sys.stderr)
if target_file is None:
print(
"Error: no journal file to append to. Run init_developer.py first.",
file=sys.stderr,
)
return 1
try:
existing = target_file.read_text(encoding="utf-8") if target_file.is_file() else ""
except OSError as exc:
print(f"Error: cannot read {target_file}: {exc}", file=sys.stderr)
return 1
if not write_text_atomic(target_file, existing + session_content):
print(
f"Error: failed to append the session to {target_file.name}; "
"the previous journal content is intact and nothing was recorded.",
file=sys.stderr,
)
return 1
print(f"[OK] Appended session to {target_file.name}", file=sys.stderr)
state = STATE_JOURNAL_RECORDED
else:
if matched_file is None or matched_num is None:
print(
f"Error: resumed state '{state}' without a matching journal entry.",
file=sys.stderr,
)
return 1
target_file = matched_file
new_session = matched_num
target_num = _extract_journal_num(target_file.stem)
print(
f"[RESUME] Session {new_session} is already in {target_file.name} and "
f"uncommitted; continuing from '{state}' instead of appending again.",
file=sys.stderr,
)
print("", file=sys.stderr)
print("", file=sys.stderr)
# -------------------------------------------------------------------
# Journal-recorded → index-recorded
# -------------------------------------------------------------------
active_file = f"{FILE_JOURNAL_PREFIX}{target_num}.md"
if state == STATE_JOURNAL_RECORDED:
if not update_index(
index_file,
dev_dir,
title,
evidence,
new_session,
active_file,
today,
branch,
):
print(
f"[BLOCKED] Checkpoint: session {new_session} is in "
f"{active_file} but index.md was not updated. Fix index.md, then "
"re-run the identical command to repair the row without adding a "
"second session.",
file=sys.stderr,
)
return 1
state = STATE_INDEX_RECORDED
else:
print(f"[OK] index.md already records session {new_session}.", file=sys.stderr)
print("", file=sys.stderr)
print("========================================", file=sys.stderr)
print(f"[OK] Session {new_session} added successfully!", file=sys.stderr)
print("========================================", file=sys.stderr)
print("", file=sys.stderr)
print("Files updated:", file=sys.stderr)
print(f" - {target_file.name if target_file else 'journal'}", file=sys.stderr)
print(" - index.md", file=sys.stderr)
# -------------------------------------------------------------------
# Index-recorded → committed
# -------------------------------------------------------------------
if not auto_commit:
return 0
print("", file=sys.stderr)
outcome = _auto_commit_workspace(repo_root)
if outcome == COMMIT_FAILED:
_print_commit_checkpoint(
new_session, target_file.name if target_file else "the journal"
)
return 1
if outcome == COMMIT_DONE and target_file is not None:
committed = content_at_head(repo_root, target_file)
if committed is None or marker not in committed:
print(
f"[WARN] Auto-commit reported success but {target_file.name} at "
"HEAD does not contain this session's record.",
file=sys.stderr,
)
_print_commit_checkpoint(new_session, target_file.name)
return 1
return 0
# =============================================================================
# Main Entry
# =============================================================================
def main() -> int:
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Add a new session to journal file and update index.md"
)
parser.add_argument("--title", required=True, help="Session title")
parser.add_argument(
"--commit",
default="-",
help=(
"Comma-separated commit OIDs (7-40 hex chars each). Each one is "
"resolved to its real subject before anything is written; an "
"unresolvable OID fails the command. Use '-' for a planning session."
),
)
parser.add_argument(
"--commit-subject",
action="append",
metavar="OID=SUBJECT",
help=(
"Explicit subject for a commit that cannot be resolved locally "
"(repeatable). The mapping must be one-to-one with --commit."
),
)
parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary")
parser.add_argument("--content-file", help="Path to file with detailed content")
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)")
parser.add_argument("--test", action="append", help="Testing bullet (repeatable)")
parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)")
parser.add_argument(
"--idempotency-key",
help=(
"Caller-supplied retry key ([A-Za-z0-9._-], 1-64 chars). Makes an "
"already-committed identical record a no-op instead of a new session."
),
)
parser.add_argument("--no-commit", action="store_true",
help="Skip auto-commit of workspace changes")
parser.add_argument("--stdin", action="store_true",
help="Read extra content from stdin (explicit opt-in)")
args = parser.parse_args()
extra_content: str | None = None
if args.content_file:
content_path = Path(args.content_file)
if content_path.is_file():
extra_content = content_path.read_text(encoding="utf-8")
elif args.stdin:
extra_content = sys.stdin.read()
# Load active task once — shared by package and branch resolution
repo_root = get_repo_root()
current = get_current_task(repo_root)
task_data = load_task(repo_root / current) if current else None
package = args.package
if package:
# CLI source: fail-fast in monorepo, ignore in single-repo
if not is_monorepo(repo_root):
print("Warning: --package ignored in single-repo project", file=sys.stderr)
package = None
elif not validate_package(package, repo_root):
packages = get_packages(repo_root)
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
print(f"Error: unknown package '{package}'. Available: {available}", file=sys.stderr)
return 1
else:
# Inferred: active task's task.json.package → default_package → None
task_package = task_data.package if task_data else None
package = resolve_package(task_package, repo_root)
branch = resolve_session_branch(repo_root, args.branch, task_data)
return add_session(
args.title, args.commit, args.summary,
changes=args.change, extra_content=extra_content, tests=args.test,
next_steps=args.next_step,
auto_commit=not args.no_commit,
package=package,
branch=branch,
commit_subjects=args.commit_subject,
idempotency_key=args.idempotency_key,
)
if __name__ == "__main__":
sys.exit(main())