
Swain Session
- 108 installs
- 2 repo stars
- Updated July 24, 2026
- cristoslc/swain
Start, resume, or manage Swain coding sessions so context, tasks, and agent state persist across Claude Code interactions in one repo.
About
swain-session from cristoslc/swain manages Swain coding sessions in Claude Code. It starts and resumes work with persisted context and task state so multi-step agent builds stay coherent instead of resetting context on every prompt or losing track of in-progress changes.
- Opens and resumes Swain coding sessions
- Preserves agent context across interactions
- Coordinates task state inside a repo
- Improves continuity for long agent builds
Swain Session by the numbers
- 108 all-time installs (skills.sh)
- Ranked #4,116 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/swain --skill swain-sessionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 108 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 24, 2026 |
| Repository | cristoslc/swain ↗ |
What it does
Start, resume, or manage Swain coding sessions so context, tasks, and agent state persist across Claude Code interactions in one repo.
Files
<!-- swain-model-hint: haiku, effort: low -->
Session
Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation.
Auto-run behavior
This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked:
1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it
When invoked manually, the user can change preferences or bookmark context.
Session purpose text
When the operator launches with free text (e.g., swain new bug about timestamps), the launcher exports SWAIN_PURPOSE and — for runtimes that accept an initial prompt — also passes it inline as /swain-session Session purpose: new bug about timestamps.
The launcher is responsible for choosing the checkout that will own that bookmark:
- If the operator starts from the main checkout, the launcher opens a new worktree first and only then passes the session purpose.
- If the operator starts inside a linked worktree that already has a bookmark, the launcher should steer them to resume/finish that worktree or open a different worktree before reusing the purpose text.
The greeting script (swain-session-greeting.sh) reads $SWAIN_PURPOSE and writes the bookmark deterministically (SPEC-297). The greeting JSON exposes the captured text as the purpose field.
When the greeting JSON's purpose field is non-null:
- Display it to the operator:
**Session purpose:** <text>.
Do not re-parse the initial prompt or call swain-bookmark.sh yourself — the greeting already did both. The inline prompt text is for display context only; the env var is the source of truth.
Preflight
Before any step, run the preflight script to gather all session state in a single pass. This replaces the old subprocess chain (greeting → bootstrap → tab-name) with one read-only script.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
PREFLIGHT_SCRIPT="$(find "$REPO_ROOT" -path '*/swain-session/scripts/swain-session-preflight.sh' -print -quit 2>/dev/null)"
PREFLIGHT_JSON=$( bash "$PREFLIGHT_SCRIPT" --repo-root "$REPO_ROOT" 2>/dev/null )
echo "$PREFLIGHT_JSON"Store PREFLIGHT_JSON for use in all steps below. Every decision references a field from this JSON — do not run additional check commands unless performing a mutation.
Step 1 — Fast Greeting (SPEC-194)
Run the greeting script. It calls the preflight internally and applies lightweight mutations (tab naming, lock cleanup, .agents dir creation). It does not invoke specgraph, GitHub API, or the full status dashboard.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-greeting.sh" --jsonThe greeting emits structured JSON:
{
"greeting": true,
"branch": "trunk",
"dirty": false,
"isolated": false,
"bookmark": "Left off implementing the bootstrap script",
"focus": "VISION-001",
"tab": "project @ branch",
"warnings": []
}The preflight JSON also includes previous session state under prev_session, which eliminates the need for a separate swain-session-state.sh resume call:
{
"prev_session": {
"exists": true,
"status": "closed",
"session_id": "session-20260404-215204-9576",
"focus_lane": "INITIATIVE-002",
"phase": "closed",
"start_time": "2026-04-05T01:52:04Z",
"end_time": "2026-04-06T04:10:09Z",
"decisions_made": 0,
"walkaway": "Reviewed and fixed SPIKE-058"
}
}After receiving the greeting JSON:
1. Present the greeting to the operator — branch, dirty state, bookmark (if any), focus lane (if any), and warnings.
2. If prev_session.exists is true, display the previous session context (from the preflight JSON) so the operator can decide whether to continue or start fresh.
3. If isolated is false and the operator has not started work yet, do not create a worktree now — worktree creation is handled by bin/swain pre-launch (SPEC-245). If a worktree name is needed for reference, generate it:
bash "$REPO_ROOT/.agents/bin/swain-worktree-name.sh" "context"Then re-run the greeting with --path to refresh tab name and context:
bash "$REPO_ROOT/.agents/bin/swain-session-greeting.sh" --path "$(pwd)" --json3. If bookmark is not null, display it:
Resuming session — Last time: {bookmark}
4. The session is now ready for work. The full status dashboard is available on-demand (see Status Dashboard).
If `$TMUX` is NOT set (detected by absence of tab in the JSON), check whether tmux is installed:
- tmux not installed: Offer to install it (
brew install tmux). - tmux installed but not in a session: Show:
[note] Not in a tmux session — session tab and pane features unavailable
The operator can say "exit worktree" or "back to main" at any time — this ends the session. bin/swain handles worktree cleanup after the runtime exits (SPEC-245).
Worktree / branch changes (agent-agnostic)
When an agent enters a worktree or switches branches, re-run the bootstrap with --path to update the tab name:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-bootstrap.sh" --path "$NEW_WORKDIR" --skip-worktree --autoThis is agent-agnostic — works in Claude Code, opencode, gemini cli, codex, copilot, or any agent that reads AGENTS.md and can run bash commands.
Session.json schema
{
"lastBranch": "trunk",
"lastContext": "Working on swain-session skill",
"preferences": {
"verbosity": "concise"
},
"bookmark": {
"note": "Left off implementing the bootstrap script",
"files": ["SKILL.md"],
"timestamp": "2026-03-10T14:32:00Z"
}
}Migration: If .agents/session.json does not exist but the old global location (~/.claude/projects/<project-path-slug>/memory/session.json) does, the bootstrap script copies it automatically.
README Reconciliation Checkpoint (SPEC-209)
After the greeting and before work begins, compare README.md against the artifact tree. This runs once per session, at focus lane selection time.
Trigger
When a focus lane is set (either restored from a previous session or newly selected), and README.md exists:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
[ -f "$REPO_ROOT/README.md" ] && echo "has_readme" || echo "no_readme"If no README exists, skip reconciliation silently — swain-doctor will flag the missing README.
Process
1. Read README.md and extract claims — any statement about what the project does, who it's for, how it works, what it supports, or what behavior it exhibits. Read the entire README as prose; no markers or section conventions required. 2. Compare claims against current Active Visions, Designs, Journeys, and Persona artifacts. Look for:
- Stale promises — README claims a feature or behavior that an artifact explicitly dropped or superseded.
- Missing coverage — an artifact describes a capability the README doesn't mention.
- Contradictions — README and artifact disagree on behavior, audience, or scope.
3. For each mismatch, surface a specific question to the operator:
"README says '{claim}' but {artifact-id} {describes the conflict}. Which is right?"
Reconciliation direction
Bidirectional. Drift does not assume artifacts are right:
- A new Vision may mean the README needs updating.
- The README may be right and the Vision needs reshaping.
- A promise may have been intentionally dropped and needs removing from both.
Deferral tracking
The operator can defer any mismatch. Deferrals are tracked in .agents/session.json under a readme_deferrals key:
{
"readme_deferrals": [
{
"claim": "real-time sync",
"conflict_artifact": "VISION-003",
"deferred_at": "2026-03-31T14:00:00Z"
}
]
}Deferred items are raised again at the next session start. When the operator resolves a deferral (updates README or artifact), remove it from the list.
Silent pass
If no drift is detected, the reconciliation check passes silently — no output to the operator.
Session Lifecycle (SPEC-119)
swain-session owns a bounded session lifecycle: start → work → close → resume. Session state is tracked in .agents/session-state.json via the swain-session-state.sh script.
Session start
After bootstrap completes and the worktree is ready, initialize the session lifecycle:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-state.sh" init --focus "<FOCUS-ID>" --session-roadmap "$(pwd)/SESSION-ROADMAP.md" --repo-root "$REPO_ROOT"This: 1. Creates .agents/session-state.json with focus lane, decision budget (default 5), and start time 2. Generates SESSION-ROADMAP.md via chart.sh session --focus <ID>
The focus lane defaults to the previous session's lane (from bootstrap JSON session.focus). Confirm with the operator or accept their redirect.
Custom decision budget: --budget 7
During work — recording decisions
When the operator or agent makes a decision (approves a spec, chooses an approach, sets direction), record it:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-state.sh" record-decision --note "Approved SPEC-119 implementation approach"Session close
When the operator says "done", "wrap up", "close session", or the decision budget is reached, execute this close sequence. Critical: swain-retro must run while the session is still active so it can read session state. Do not close the session before running retro.
Step 1 — Generate session digest and progress logs
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-digest.sh" --session-id "$(jq -r .session_id "$REPO_ROOT/.agents/session-state.json")" --output "$REPO_ROOT/.agents/session-log.jsonl"
bash "$REPO_ROOT/.agents/bin/swain-progress-log.sh" --digest "$REPO_ROOT/.agents/session-log.jsonl"This appends a JSONL digest entry and updates each touched EPIC/Initiative's progress.md and ## Progress section.
Step 2 — Run retro (session still active)
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SWAIN_RETRO_SKILL="$REPO_ROOT/.claude/skills/swain-retro/SKILL.md"
Skill("$SWAIN_RETRO_SKILL", "Session close — session is closing. Run /swain-retro to capture session learnings before the session state is cleared.")Important: Retro reads session.json and session-state.json while they are still populated. Do not call session-state.sh close before this step.
Step 3 — Close the session
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-state.sh" close --walkaway "Completed SPEC-119 tests and state management" --session-roadmap "$(pwd)/SESSION-ROADMAP.md"This sets session phase to closed with end time and appends the walk-away signal to SESSION-ROADMAP.md.
Step 4 — Run session teardown
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SWAIN_TEARDOWN_SKILL="$REPO_ROOT/.claude/skills/swain-teardown/SKILL.md"
Skill("$SWAIN_TEARDOWN_SKILL", "Session teardown — --session-chain flag passed from swain-session close handler.")This runs orphan worktree checks, git dirty-state check, ticket sync prompt, and writes a handoff summary. The --session-chain flag tells teardown to skip the redundant session-active check since the handler already confirmed session state.
Step 5 — Commit SESSION-ROADMAP.md
Finally, commit SESSION-ROADMAP.md to git.
Session resume
On the next session start, read prev_session from the preflight JSON (see Preflight). This includes the previous session's focus lane, walkaway note, decision count, and staleness status — no separate script call needed.
If you need to call session-state.sh directly (e.g., from a script that doesn't have the preflight JSON):
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-session-state.sh" resumeDisplay the previous session context so the operator can decide whether to continue or start fresh.
Session state schema
{
"session_id": "session-20260328-220634-4ad1",
"focus_lane": "INITIATIVE-019",
"phase": "active",
"start_time": "2026-03-28T22:06:34Z",
"last_activity_time": "2026-03-28T22:06:34Z",
"end_time": null,
"decision_budget": 5,
"decisions_made": 0,
"decisions": [],
"walkaway": null
}Manual invocation commands
When invoked explicitly by the user, support these operations:
Set tab name
User says something like "set tab name to X" or "rename tab":
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-tab-name.sh" "Custom Name"Bookmark context
User says "remember where I am" or "bookmark this":
- Infer what they're working on from conversation context, or use the note they provided — do not prompt the user
- Write to session.json
bookmarkfield with note, relevant files, and timestamp - If a bookmark already exists, overwrite it silently without asking for confirmation —
swain-bookmark.shhandles atomic writes
Clear bookmark
User says "clear bookmark" or "fresh start":
- Remove the
bookmarkfield from session.json
Show session info
User says "session info" or "what's my session":
- Display current tab name, branch, preferences, bookmark status
- If the bookmark note contains an artifact ID (e.g.,
SPEC-052,EPIC-018), show the Vision ancestry breadcrumb for strategic context. Runbash "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents/bin/chart.sh" scope <ID> 2>/dev/null | head -5to get the parent chain. Display as:Context: Swain > Operator Situational Awareness > Vision-Rooted Chart Hierarchy
Set preference
User says "set preference X to Y":
- Update
preferencesin session.json
Post-operation bookmark (auto-update protocol)
Other swain skills update the session bookmark after operations. Read references/bookmark-protocol.md for the protocol, invocation patterns, and examples.
Focus Lane
The operator can set a focus lane to scope recommendations within a single vision or initiative. This is a steering mechanism — it doesn't hide other work, but frames recommendations around the operator's current focus.
Setting focus: When the operator says "focus on security" or "I'm working on VISION-001", resolve the name to an artifact ID and invoke the focus script.
Name-to-ID resolution: If the operator uses a name instead of an ID (e.g., "security" instead of "VISION-001"), search Vision and Initiative artifact titles for the best match using swain chart:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/chart.sh" --ids --flat 2>/dev/null | grep -i "<name>"If exactly one match, use it. If multiple matches, ask the operator to clarify. If no match, tell the operator no Vision or Initiative matches that name and offer to create one.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-focus.sh" set <RESOLVED-ID>Clearing focus:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-focus.sh" clearChecking focus:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-focus.sh"Display the focus artifact as a context line by calling artifact-context.sh on the focus ID. Fall back to the bare ID if the utility is unavailable.
Focus lane is stored in .agents/session.json under the focus_lane key. It persists across status checks within a session. The status dashboard reads it to filter recommendations and show peripheral awareness for non-focus visions.
Status Dashboard (SPEC-122)
swain-session now owns the project status dashboard. When the operator says "status", "what's next", "dashboard", "overview", "where are we", "what should I work on", or "show me priorities", run the status script:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
STATUS_SCRIPT="$REPO_ROOT/.agents/bin/swain-status.sh"
[ -f "$STATUS_SCRIPT" ] && bash "$STATUS_SCRIPT" --refresh || echo "status dashboard script not found"For compact mode (MOTD): bash "$STATUS_SCRIPT" --compact
After the script runs, present a structured agent summary following references/agent-summary-template.md.
Cache
Status writes to .agents/status-cache.json with 120-second TTL. Use --refresh to bypass, --json for raw output.
Recommendation
Read .priority.recommendations[0] from the JSON cache. When a focus lane is set, recommendations scope to that vision/initiative.
Context-rich display
When presenting artifacts to the operator (recommendations, focus lane, decisions needed), use the artifact-context utility instead of bare IDs:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
CONTEXT=$(bash "$REPO_ROOT/.agents/bin/artifact-context.sh" <ARTIFACT-ID> 2>/dev/null)If the utility is available and returns output, use the context line. If unavailable or empty, fall back to <ID> — <title> (current behavior).
Display format: title ID — scope. progress.
Mode Inference
1. Both specs in review AND strategic decisions pending → ask operator 2. Specs awaiting review → detail mode 3. Focus lane + pending decisions → vision mode 4. Nothing actionable → vision mode (master plan mirror)
Decisions Needed (roadmap integration)
Uses chart.sh roadmap --json for Eisenhower classification. Show top 5 items from "Do First" and "Schedule" quadrants that need operator decisions.
Settings
This skill reads from swain.settings.json (project root) and ~/.config/swain/settings.json (user override). User settings take precedence.
Relevant settings:
terminal.tabNameFormat— format string for tab names. Supports{project}and{branch}placeholders. Default:{project} @ {branch}
Error handling
- If jq is not available, warn the user and skip JSON operations. Tab naming still works without jq.
- If git is not available, use the directory name as the project name and skip branch detection.
- Never fail hard — session management is a convenience, not a gate.
Agent Summary Template
After running the status script, present a structured summary using these tables. The script's terminal output goes to the terminal with OSC 8 hyperlinks; this summary is what the user actually reads for decision-making.
Do NOT just dump bullet lists. Use tables so the user can scan and compare.
Lead with decisions and actions — answer "what's waiting on me?" before showing anything else. Reference data (Epic Progress, Spikes, Blocked) comes after.
Section 1: Session Context
Read .session.bookmark from the JSON cache. If the bookmark exists and has a non-null note, show it as a one-line orientation header before the recommendation:
Resuming: {note}
If the bookmark has files, list them on the next line as clickable paths.
This tells the operator where they left off. It's context, not a recommendation — the recommendation section follows and may suggest continuing that work or pivoting elsewhere.
Omit this section entirely if no bookmark exists or the note is null/empty.
Section 2: Recommendation
Read .priority.recommendations[0] from the JSON cache. Write exactly two sentences:
- Action: One sentence naming the action (e.g., "Activate EPIC-017.")
- Why: One sentence naming the score, vision context, and unblock count (e.g., "Security is weighted high with 3 pending decisions — activating this unblocks EPIC-023. Note: your last 2 weeks of work has been in design tooling.")
Include attention drift context if any drift is detected for the recommendation's vision (check .priority.drift).
Omit this section entirely if no ready items exist.
Drift prompt: If .priority.drift is non-empty, include a drift prompt after the recommendation: "Your attention has drifted from [Vision Name] (weight: [W]) — [N] days since last activity. Is that intentional, or should we course-correct?"
This is not a recommendation to change — it's a mirror. The operator decides.
Section 3: Peripheral Awareness
If a focus lane is set (.session.focus_lane is non-null) and there are decisions in other visions, summarize: "Meanwhile: [Vision Name] has N pending decisions (weight: W)"
One line per non-focus vision with pending decisions from .priority.decision_debt. Omit this section entirely if no focus lane is set.
Section 4: Decisions Needed
Artifacts requiring human judgment, sorted by unblock_count descending so highest-leverage decisions appear first. Includes:
- Proposed/Draft specs needing review
- Proposed ADRs needing acceptance
- Proposed spikes needing activation
- For the full type/phase classification (including VISION, JOURNEY, EPIC in Proposed state, PERSONA, DESIGN), see the
is_decisiondefinition in SKILL.md.
| Artifact | What's Needed | Unblocks |
|----------|--------------|----------|
| **TYPE-NNN**: Title | review and approve / review and decide / activate | SPEC-NNN, ... or — |Rules:
- Sort by unblock count descending (highest first)
- "What's Needed" = the human action required (approve, decide, accept, activate)
- Unblocks = downstream artifact IDs waiting on this decision
- Only show this section if there are items to list
- For EPICs without a parent chain to an Initiative (check
.priority.decision_debt— if the EPIC appears in_unaligned), append "(no initiative — assign first)" to the What's Needed column
Section 5: Work Ready to Start
Agent-delegatable, implementation-ready items from .artifacts.ready[] that are NOT decision-type artifacts (i.e., not Proposed specs, ADRs, or spikes).
| Artifact | Purpose | Unblocks |
|----------|---------|----------|
| SPEC-NNN: Title | Truncated description (~60 chars) | DEP-NNN, ... or — |Rules:
- Purpose = first ~60 chars of the artifact's description
- Unblocks = downstream artifact IDs that become unblocked once this is done
- Omit this section if there are no implementation-ready items
Section 6: Epic Progress
One table with all active epics and their child specs in a tree. Use └ to indent children under their parent epic.
| Artifact | Purpose | Readiness |
|----------|---------|-----------|
| **EPIC-NNN**: Title | Truncated description (~60 chars) | Needs decomposition / N/M specs resolved / Blocked on X |
| └ SPEC-NNN: Title | Truncated description | Proposed — review and approve / Ready — implementation ready / Blocked on Y |
| └ SPEC-NNN: Title | Truncated description | Status — next action |
| **EPIC-NNN**: Title | Truncated description | ... |Rules:
- Bold the epic ID and title
- Include ALL child specs under each epic, indented with
└ - Purpose = first ~60 chars of the artifact's description
- Readiness = current status + what needs to happen next
- Epics with no children: readiness = "Needs decomposition into specs"
- Epics/specs that are blocked: note what they're blocked on
Section 7: Research (Spikes)
Table of all unresolved spikes.
| Spike | Question | Status | Unblocks |
|-------|----------|--------|----------|
| SPIKE-NNN | Core research question from description | Proposed / Active | SPEC-NNN, ... or — |Rules:
- Question = the spike's core question (its description), truncated to ~80 chars
- Unblocks = downstream artifacts waiting on this spike
- Sort: Active first, then by unblock count descending, then by ID
Section 8: Blocked Items
Only if there are blocked items not already shown in the epic tree.
- **TYPE-NNN**: Title — blocked on: DEP-NNN (with note if the blocker is actionable)Rules:
- Group items that share a common blocker under a single entry. State whether
the blocker is actionable now.
Section 9: Tasks & Issues
Brief summary of in-progress tk tasks. Omit if empty.
Section 10: Open GitHub Issues
Table of open GitHub issues. These are external signals — bugs, feature requests, or process gaps reported outside the artifact system.
| Issue | Title | Labels |
|-------|-------|--------|
| #NNN | Issue title (~60 chars) | bug, enhancement, ... or — |Rules:
- Show all open issues from the status data (up to 10)
- If the user has assigned issues, show those first with a bold Assigned prefix
- Labels help the user triage — include them if present,
—if none - If an issue is linked to an artifact (visible in the Linked Issues section), note the artifact ID in parentheses after the title
- Omit this section if there are no open issues
Section 11: Cross-Reference Gaps
Table of artifacts with frontmatter/body cross-reference discrepancies. Only show artifacts with at least one discrepancy. Merge body-not-in-frontmatter and missing-reciprocal into one row per artifact. Omit this section entirely when there are no discrepancies.
| Artifact | Undeclared Body References | Missing Reciprocal | Action |
|----------|--------------------------|-------------------|--------|
| EPIC-005 | SPIKE-007, SPIKE-008 | — | Classify as depends-on or linked-artifacts |
| SPIKE-007 | — | EPIC-005 | Add EPIC-005 to linked-artifacts |Rules:
- Only show artifacts with at least one discrepancy
- Merge body-not-in-frontmatter and missing-reciprocal into one row per artifact
- Omit the entire section when there are no discrepancies
- The agent should suggest concrete frontmatter edits based on context (e.g., which field to add the reference to)
- When xref gaps exist, include in suggestions: "There are N cross-reference gaps — want me to review and fix the frontmatter declarations?"
Full Example
> **Resuming:** Fixed xref gaps across 79 artifact files — 0 missing reciprocals remain
## Recommendation
**Action:** Approve SPEC-009.
**Why:** Approving it unblocks SPEC-010 and EPIC-007 — highest downstream leverage of all actionable items.
## Decisions Needed
| Artifact | What's Needed | Unblocks |
|----------|--------------|----------|
| **SPEC-009**: Normalize Artifact Frontmatter Relationships | review and approve | SPEC-010, EPIC-007 |
| **SPIKE-012**: Which artifact types are decision-only? | activate | SPEC-010 |
## Work Ready to Start
| Artifact | Purpose | Unblocks |
|----------|---------|----------|
| SPEC-011: Skill Context Footprint Audit | Audit context size across all active skills | EPIC-006 |
## Epic Progress
| Artifact | Purpose | Readiness |
|----------|---------|-----------|
| **EPIC-005**: Isolated Claude Code Environment | One-command workflow for isolated, ephemeral Claude Code | Needs decomposition into specs |
| **EPIC-006**: Skill Context Footprint Reduction | Reduce disproportionate context consumption by swain skills | 0/1 specs resolved (1 remaining) |
| └ SPEC-010: Decision-Only Artifacts Bug | Misclassifies decision-only artifacts as implementable | Proposed — blocked on SPIKE-012 |
| **EPIC-007**: Model Routing & Reasoning Effort | Route skills to appropriate models and effort levels | Blocked on EPIC-006 |
## Research
| Spike | Question | Status | Unblocks |
|-------|----------|--------|----------|
| SPIKE-006 | What task tracking backend should swain-do use? | Active | — |
| SPIKE-012 | Which artifact types are decision-only across their lifecycle? | Proposed | SPEC-010 |
| SPIKE-010 | Which skills consume the most context and where's the waste? | Proposed | — |
| SPIKE-011 | What strategies can reduce skill content loaded into context? | Proposed | — |
| SPIKE-013 | How do agent runtimes expose model selection and effort controls? | Proposed | — |
| SPIKE-014 | Which skill operations belong to which cognitive load tier? | Proposed | — |
## Blocked Items
- **EPIC-007**: Model Routing & Reasoning Effort — blocked on: EPIC-006 (actionable: yes, EPIC-006 has ready specs)
## Tasks & Issues
No tasks in progress.
## Open GitHub Issues
| Issue | Title | Labels |
|-------|-------|--------|
| #36 | MOTD: show uncommitted file count, explore clickable commit | enhancement |
| #29 | Decision-only artifacts shown as implementable in status | bug |
| #28 | VISION-to-VISION deps should not block status | bug |
| #27 | swain-search: normalize YouTube transcripts to markdown | enhancement |
| #26 | Spike conclusions not surfaced in final pass | enhancement |Post-operation bookmark (auto-update protocol)
Other swain skills update the session bookmark after completing operations. This gives the developer a "where I left off" marker without requiring manual bookmarking.
When to update
A skill should update the bookmark when it completes a state-changing operation — artifact transitions, task updates, commits, releases, or status checks.
How to update
Use scripts/swain-bookmark.sh (relative to this skill's directory):
# Find the script
BOOKMARK_SCRIPT="$(find . .claude .agents -path '*/swain-session/scripts/swain-bookmark.sh' -print -quit 2>/dev/null)"
# Basic note
bash "$BOOKMARK_SCRIPT" "Transitioned SPEC-001 to Approved"
# Note with files
bash "$BOOKMARK_SCRIPT" "Implemented auth middleware" --files src/auth.ts src/auth.test.ts
# Clear bookmark
bash "$BOOKMARK_SCRIPT" --clearThe script handles session.json discovery, atomic writes, and graceful degradation (no jq = silent no-op).
Session Check Preamble
State-changing skills should check for an active session before proceeding. This is a soft gate — the operator can always dismiss it.
Integration
Add this to the top of state-changing skill SKILL.md files (after frontmatter, before the first section):
<!-- session-check -->
Before proceeding, verify an active session exists:
\`\`\`bash
bash "$(find "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -path '*/swain-session/scripts/swain-session-check.sh' -print -quit 2>/dev/null)" 2>/dev/null
\`\`\`
If the output JSON has `status` other than `"active"`, inform the operator: "No active session — start one with `/swain-session`?" and proceed if they dismiss.Skip list
These skills are read-only and skip the session check:
- swain-help
- swain-search (discover mode)
- swain-session (manages sessions itself)
- swain-doctor
Performance
The check script reads one JSON file and does a timestamp comparison. Target: < 100ms.
Status Format Reference
OSC 8 Hyperlinks
Terminal hyperlinks use the OSC 8 escape sequence, supported by iTerm2, Kitty, WezTerm, and other modern terminals.
Format:
\e]8;;URL\e\\DISPLAY_TEXT\e]8;;\e\\Link types used
| Type | URL scheme | Example |
|---|---|---|
| File path | file:///absolute/path | Opens in system default app |
| GitHub issue | https://github.com/owner/repo/issues/N | Opens in browser |
| GitHub PR | https://github.com/owner/repo/pull/N | Opens in browser |
Fallback
If the terminal doesn't support OSC 8, the display text is shown as plain text — links degrade gracefully.
Full output layout
# project — Status
**Resuming:** bookmark note here
Files: file1.md, file2.md
## Pipeline
Branch: **trunk** (clean)
Last commit: `abc123` feat(auth): add token rotation (2 hours ago)
## Active Epics
### EPIC-003: Architecture Discovery and Scaling [Active]
Progress: **4/7** specs resolved
- [x] SPEC-015: Discovery Service [Implemented]
- [x] SPEC-016: Load Balancer [Implemented]
- [x] SPEC-017: Health Checks [Implemented]
- [x] SPEC-018: Metrics Pipeline [Implemented]
- [ ] SPEC-019: Auto-scaling Rules [Draft]
- [ ] SPEC-020: Capacity Planning [Draft]
- [ ] SPEC-021: Failover Strategy [Draft]
## Actionable Now
- SPEC-019: Auto-scaling Rules [Draft] docs/specs/SPEC-019.md
- SPEC-020: Capacity Planning [Draft] docs/specs/SPEC-020.md
## Blocked
- SPEC-021: Failover Strategy [Draft] <- waiting on: SPEC-019
## Tasks
**In progress:**
- #42 Implement auto-scaling threshold config
**Recently completed:**
- #40 Add health check endpoints
- #39 Configure load balancer rules
12 total tracked issues.
## GitHub Issues
**Assigned to you:**
- #15 Investigate memory leak in discovery service
- #12 Update deployment docs
---
Artifacts: 30 total, 23 resolved, 4 ready, 1 blocked
Updated: 2026-03-10T22:30:00ZCompact output layout (for MOTD)
trunk (clean)
epic: EPIC-003 4/7
task: #42 Implement auto-scaling threshold
ready: 4 actionable
issues: 2 assignedThe compact format is designed for a 40-character-wide MOTD box. Each line maps to one data source. The MOTD script reads these lines positionally.
Cache JSON schema
{
"timestamp": "ISO-8601",
"repo": "/absolute/path",
"project": "project-name",
"git": {
"branch": "trunk",
"dirty": false,
"changedFiles": 0,
"lastCommit": { "hash": "abc123", "message": "...", "age": "2 hours ago" },
"recentCommits": [...]
},
"artifacts": {
"ready": [{ "id": "SPEC-019", "status": "Draft", "title": "...", "type": "SPEC", "file": "docs/..." }],
"blocked": [{ "id": "SPEC-021", "status": "Draft", "title": "...", "waiting": ["SPEC-019"] }],
"epics": {
"EPIC-003": {
"id": "EPIC-003",
"title": "...",
"status": "Active",
"progress": { "done": 4, "total": 7 },
"children": [...]
}
},
"counts": { "total": 30, "resolved": 23, "ready": 4, "blocked": 1 }
},
"tasks": {
"inProgress": [{ "id": "#42", "title": "..." }],
"recentlyCompleted": [{ "id": "#40", "title": "..." }],
"total": 12,
"available": true
},
"issues": {
"open": [{ "number": 15, "title": "...", "labels": [...], "assignees": [...] }],
"assigned": [{ "number": 15, "title": "..." }],
"available": true
},
"session": {
"bookmark": { "note": "...", "files": [...] },
"lastBranch": "main",
"lastContext": "..."
}
}#!/usr/bin/env bash
set -euo pipefail
# swain-bookmark.sh — Unified bookmark management for swain
#
# Manages two kinds of bookmarks in session.json:
# - Context bookmarks: free-text notes about what the operator is working on
# - Worktree bookmarks: structured records of worktrees created during sessions
#
# Usage (context):
# swain-bookmark.sh "note text"
# swain-bookmark.sh "note text" --files file1.md file2.md
# swain-bookmark.sh --clear
#
# Usage (worktree):
# swain-bookmark.sh worktree add <path> <branch>
# swain-bookmark.sh worktree remove <path>
# swain-bookmark.sh worktree list
# swain-bookmark.sh worktree prune
#
# Requires: jq (for worktree subcommands)
REPO_ROOT="${SWAIN_REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
SESSION_FILE="${SWAIN_SESSION_FILE:-$REPO_ROOT/.agents/session.json}"
# --- Locate / migrate session.json ---
if [[ ! -f "$SESSION_FILE" ]]; then
_OLD_SLUG=$(echo "$REPO_ROOT" | tr '/' '-')
_OLD_FILE="$HOME/.claude/projects/${_OLD_SLUG}/memory/session.json"
if [[ -f "$_OLD_FILE" ]]; then
mkdir -p "$(dirname "$SESSION_FILE")"
cp "$_OLD_FILE" "$SESSION_FILE"
fi
fi
if [[ ! -f "$SESSION_FILE" ]]; then
mkdir -p "$(dirname "$SESSION_FILE")"
echo '{}' > "$SESSION_FILE"
fi
# ============================================================
# Worktree subcommands
# ============================================================
worktree_cmd() {
local subcmd="${1:-}"
case "$subcmd" in
add) worktree_add "$2" "$3" ;;
remove) worktree_remove "$2" ;;
list) worktree_list ;;
prune) worktree_prune ;;
*) echo "Usage: swain-bookmark.sh worktree <add|remove|list|prune>" >&2; exit 1 ;;
esac
}
worktree_add() {
local wt_path="${1:-}"
local wt_branch="${2:-}"
if [[ -z "$wt_path" ]] || [[ -z "$wt_branch" ]]; then
echo "Error: worktree add requires <path> and <branch>" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree operations" >&2
exit 1
fi
# Skip trunk
if [[ "$wt_path" == "$REPO_ROOT" ]]; then
echo "Skipping trunk — trunk is never bookmarked."
return 0
fi
# Get session_id
local session_id
session_id=$(jq -r '.session_id // empty' "$REPO_ROOT/.agents/session-state.json" 2>/dev/null || echo "")
if [[ -z "$session_id" ]]; then
session_id="no-session"
fi
local timestamp
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Build new worktree entry as JSON
local new_entry
new_entry=$(jq -n \
--arg path "$wt_path" \
--arg branch "$wt_branch" \
--arg session_id "$session_id" \
--arg last_active "$timestamp" \
'{
path: $path,
branch: $branch,
session_id: $session_id,
last_active: $last_active
}')
# Remove any existing entry for this path, then append new one
# Worktrees is an array; we filter out the matching path and append
local tmp
tmp="$(mktemp)"
jq --argjson new_entry "$new_entry" \
'.worktrees = (
[ (.worktrees // [])[] | select(.path != $new_entry.path) ] +
[$new_entry]
)' \
"$SESSION_FILE" > "$tmp" && mv "$tmp" "$SESSION_FILE"
echo "Worktree bookmark added: $wt_path ($wt_branch)"
}
worktree_remove() {
local wt_path="${1:-}"
if [[ -z "$wt_path" ]]; then
echo "Error: worktree remove requires <path>" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree operations" >&2
exit 1
fi
local tmp
tmp="$(mktemp)"
jq --arg path "$wt_path" \
'.worktrees = [ (.worktrees // [])[] | select(.path != $path) ]' \
"$SESSION_FILE" > "$tmp" && mv "$tmp" "$SESSION_FILE"
echo "Worktree bookmark removed: $wt_path"
}
worktree_list() {
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree operations" >&2
exit 1
fi
local worktrees
worktrees=$(jq -r '.worktrees | if type == "array" then . else [] end | .[] | "\(.path)|\(.branch)|\(.session_id)|\(.last_active)"' "$SESSION_FILE" 2>/dev/null || echo "")
if [[ -z "$worktrees" ]]; then
echo "No worktree bookmarks found."
else
while IFS='|' read -r path branch session_id last_active; do
echo "$path|$branch|$session_id|$last_active"
done <<< "$worktrees"
fi
}
worktree_prune() {
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for worktree operations" >&2
exit 1
fi
local tmp
tmp="$(mktemp)"
local removed=0
# Read all paths and check which directories still exist
local paths
IFS=$'\n' read -r -d '' -a paths < <(jq -r '.worktrees | if type == "array" then .[].path else [] end | select(. != "")' "$SESSION_FILE" 2>/dev/null | sort -u) || true
if [[ ${#paths[@]} -eq 0 ]]; then
echo "No worktree bookmarks to prune."
return 0
fi
for wt_path in "${paths[@]}"; do
if [[ ! -d "$wt_path" ]]; then
jq --arg path "$wt_path" \
'.worktrees = [ (.worktrees // [])[] | select(.path != $path) ]' \
"$SESSION_FILE" > "$tmp" && mv "$tmp" "$SESSION_FILE"
echo "Pruned stale worktree bookmark: $wt_path"
((removed++)) || true
fi
done
echo "Pruned $removed stale worktree bookmark(s)."
}
# ============================================================
# Context bookmark (existing behavior)
# ============================================================
if [[ "${1:-}" == "worktree" ]]; then
shift
worktree_cmd "${1:-}" "${2:-}" "${3:-}"
exit $?
fi
# --- Context bookmark: requires jq for note operations ---
if ! command -v jq &>/dev/null; then
exit 0
fi
CLEAR=0
NOTE=""
FILES=()
PARSING_FILES=0
for arg in "$@"; do
if [[ "$arg" == "--clear" ]]; then
CLEAR=1
elif [[ "$arg" == "--files" ]]; then
PARSING_FILES=1
elif [[ "$PARSING_FILES" -eq 1 ]]; then
FILES+=("$arg")
elif [[ -z "$NOTE" ]]; then
NOTE="$arg"
fi
done
if [[ "$CLEAR" -eq 1 ]]; then
jq 'del(.bookmark)' "$SESSION_FILE" > "$SESSION_FILE.tmp" \
&& mv "$SESSION_FILE.tmp" "$SESSION_FILE"
elif [[ -n "$NOTE" ]]; then
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ "${#FILES[@]}" -gt 0 ]]; then
FILES_JSON=$(printf '%s\n' "${FILES[@]}" | jq -R . | jq -s . 2>/dev/null || echo '[]')
jq --arg note "$NOTE" --arg ts "$TIMESTAMP" --argjson files "$FILES_JSON" \
'.bookmark = {note: $note, files: $files, timestamp: $ts}' \
"$SESSION_FILE" > "$SESSION_FILE.tmp" \
&& mv "$SESSION_FILE.tmp" "$SESSION_FILE"
else
jq --arg note "$NOTE" --arg ts "$TIMESTAMP" \
'.bookmark = {note: $note, timestamp: $ts}' \
"$SESSION_FILE" > "$SESSION_FILE.tmp" \
&& mv "$SESSION_FILE.tmp" "$SESSION_FILE"
fi
else
echo "Usage: swain-bookmark.sh \"note text\" [--files file1 file2 ...]" >&2
echo " swain-bookmark.sh --clear" >&2
echo " swain-bookmark.sh worktree <add|remove|list|prune> [args]" >&2
exit 1
fi
#!/usr/bin/env bash
set -euo pipefail
# Set or clear the focus lane in session.json
# Usage: swain-focus.sh set VISION-001
# swain-focus.sh clear
# swain-focus.sh (show current)
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "Error: not inside a git repository" >&2
exit 1
}
SESSION_FILE="$REPO_ROOT/.agents/session.json"
ACTION="${1:-}"
FOCUS_ID="${2:-}"
if [[ ! -f "$SESSION_FILE" ]]; then
echo '{}' > "$SESSION_FILE"
fi
case "$ACTION" in
set)
if [[ -z "$FOCUS_ID" ]]; then
echo "Usage: swain-focus.sh set <VISION-ID or INITIATIVE-ID>" >&2
exit 1
fi
jq --arg focus "$FOCUS_ID" '.focus_lane = $focus' "$SESSION_FILE" > "${SESSION_FILE}.tmp" \
&& mv "${SESSION_FILE}.tmp" "$SESSION_FILE"
echo "Focus lane set to: $FOCUS_ID"
;;
clear)
jq 'del(.focus_lane)' "$SESSION_FILE" > "${SESSION_FILE}.tmp" \
&& mv "${SESSION_FILE}.tmp" "$SESSION_FILE"
echo "Focus lane cleared"
;;
*)
# Show current focus
CURRENT=$(jq -r '.focus_lane // "none"' "$SESSION_FILE" 2>/dev/null || echo "none")
echo "Current focus: $CURRENT"
;;
esac
#!/usr/bin/env bash
# swain-preflight-timing.sh — SPIKE-001: Detailed preflight timing breakdown
set +e
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$REPO_ROOT"
# Portable path resolution — works whether installed at skills/ or .agents/skills/
_src="${BASH_SOURCE[0]}"
while [[ -L "$_src" ]]; do
_dir="$(cd "$(dirname "$_src")" && pwd)"
_src="$(readlink "$_src")"
[[ "$_src" != /* ]] && _src="$_dir/$_src"
done
_TIMING_SCRIPT_DIR="$(cd "$(dirname "$_src")" && pwd)"
_TIMING_SKILLS_ROOT="$(dirname "$(dirname "$_TIMING_SCRIPT_DIR")")"
if command -v gdate &>/dev/null; then
_ts() { gdate +%s%3N; }
else
_ts() { python3 -c "import time; print(int(time.time()*1000))"; }
fi
time_check() {
local name="$1"
shift
local start=$(_ts)
eval "$@" >/dev/null 2>&1
local end=$(_ts)
printf " %-45s %6d ms\n" "$name" "$((end - start))"
}
echo "=== Preflight Timing Breakdown ==="
time_check "governance_files_exist" '[[ -f AGENTS.md ]] || [[ -f CLAUDE.md ]]'
time_check "governance_markers" 'grep -q "swain governance" AGENTS.md CLAUDE.md 2>/dev/null'
time_check "governance_freshness_hash" '
GOV_FILE=$(grep -l "swain governance" AGENTS.md CLAUDE.md 2>/dev/null | head -1)
awk "/<!-- swain governance/{f=1;next}/<!-- end swain governance/{f=0}f" "$GOV_FILE" | shasum -a 256
awk "/<!-- swain governance/{f=1;next}/<!-- end swain governance/{f=0}f" "'"$_TIMING_SKILLS_ROOT"'/swain-doctor/references/AGENTS.content.md" | shasum -a 256
'
time_check "agents_dir_check" '[[ -d .agents ]]'
time_check "tickets_dir_check" 'for f in .tickets/*.md; do [[ -f "$f" ]] && head -1 "$f" | grep -q "^---$"; break; done'
time_check "beads_dir_check" '[[ -d .beads ]]'
time_check "evidence_pool_check" '[[ -d docs/evidence-pools ]]'
time_check "stale_locks" 'find .tickets/.locks -type d -mmin +60 2>/dev/null | wc -l'
time_check "old_phase_dirs" 'find docs/*/Draft docs/*/Planned docs/*/Review 2>/dev/null | head -1'
time_check "commit_signing_check" 'git config --local commit.gpgsign'
time_check "script_permissions" "find '$_TIMING_SKILLS_ROOT' -type f \( -path '*/scripts/*.sh' -o -path '*/scripts/*.py' \) ! -perm -u+x 2>/dev/null"
time_check "ssh_readiness" "bash '$_TIMING_SKILLS_ROOT/swain-doctor/scripts/ssh-readiness.sh' --check 2>/dev/null"
time_check "skill_gitignore_hygiene" '
_origin_url="$(git remote get-url origin 2>/dev/null || true)"
for _base in .claude/skills .agents/skills; do
[ -d "$_base" ] || continue
for _skill_path in "$_base"/swain "$_base"/swain-*/; do
[[ -d "$_skill_path" ]] && git check-ignore -q "$_skill_path" 2>/dev/null
done
done
'
time_check "superpowers_detection" '
for skill in brainstorming writing-plans test-driven-development verification-before-completion subagent-driven-development executing-plans; do
ls .agents/skills/$skill/SKILL.md .claude/skills/$skill/SKILL.md 2>/dev/null | head -1
done
'
time_check "scanner_availability" "python3 '$_TIMING_SKILLS_ROOT/swain-security-check/scripts/scanner_availability.py' 2>/dev/null"
time_check "mmdc_check" 'command -v mmdc'
time_check "doctor_security_check" "python3 '$_TIMING_SKILLS_ROOT/swain-security-check/scripts/doctor_security_check.py' 2>/dev/null"
time_check "skill_change_discipline" "bash '$_TIMING_SKILLS_ROOT/swain-doctor/scripts/check-skill-changes.sh' 2>/dev/null"
time_check "agents_bin_symlink_repair" "
for skill_scripts_dir in '$_TIMING_SKILLS_ROOT'/*/scripts; do
[[ -d \"\$skill_scripts_dir\" ]] || continue
for script in \"\$skill_scripts_dir\"/*; do
[[ -f \"\$script\" && -x \"\$script\" ]] || continue
done
done
"
time_check "trunk_release_detection" 'bash .agents/bin/swain-trunk.sh 2>/dev/null && git ls-remote --heads origin trunk 2>/dev/null'
time_check "initiative_migration_check" 'find docs/epic -name "*.md" -not -name "README.md" -not -name "list-*.md" 2>/dev/null | while read f; do grep -q "parent-initiative:" "$f" 2>/dev/null; done'
#!/usr/bin/env bash
# swain-progress-log.sh — Append progress entries and synthesize progress sections
#
# Modes:
# --artifact-id <ID> --entry <text> Append a dated entry to progress.md
# --artifact-id <ID> --synthesize Regenerate ## Progress section from progress.md
# --digest <path-to-jsonl-entry> Process a session digest line
#
# SPEC-200: Progress Log and Synthesis
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
# ─── Argument parsing ───
ARTIFACT_ID=""
ENTRY=""
SYNTHESIZE=false
DIGEST_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--artifact-id) ARTIFACT_ID="$2"; shift 2 ;;
--entry) ENTRY="$2"; shift 2 ;;
--synthesize) SYNTHESIZE=true; shift ;;
--digest) DIGEST_PATH="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# ─── Helpers ───
resolve_artifact_dir() {
local id="$1"
# Find the directory containing this artifact under docs/
local dir
dir=$(find "$REPO_ROOT/docs" -type d -name "*${id}*" 2>/dev/null | head -1)
if [[ -z "$dir" ]]; then
echo "ERROR: Could not find artifact directory for $id" >&2
return 1
fi
echo "$dir"
}
resolve_artifact_file() {
local dir="$1"
local id="$2"
# Find the .md file matching the artifact ID in the directory
local file
file=$(find "$dir" -maxdepth 1 -name "*${id}*.md" ! -name "progress.md" 2>/dev/null | head -1)
if [[ -z "$file" ]]; then
echo "ERROR: Could not find artifact file for $id in $dir" >&2
return 1
fi
echo "$file"
}
append_entry() {
local artifact_dir="$1"
local entry_text="$2"
local progress_file="$artifact_dir/progress.md"
local today
today=$(date +%Y-%m-%d)
if [[ ! -f "$progress_file" ]]; then
echo "# Progress Log" > "$progress_file"
echo "" >> "$progress_file"
fi
{
echo "## $today"
echo ""
echo "$entry_text"
echo ""
} >> "$progress_file"
echo "Appended entry to $progress_file"
}
synthesize_progress() {
local artifact_dir="$1"
local artifact_id="$2"
local progress_file="$artifact_dir/progress.md"
local artifact_file
artifact_file=$(resolve_artifact_file "$artifact_dir" "$artifact_id")
if [[ ! -f "$progress_file" ]]; then
echo "No progress.md found in $artifact_dir — nothing to synthesize" >&2
return 0
fi
# Use Python for reliable text manipulation
uv run python3 -c "
import sys, re
progress_path = sys.argv[1]
artifact_path = sys.argv[2]
# Read progress.md and extract recent entries (last 2-3)
with open(progress_path) as f:
content = f.read()
# Split into dated sections
sections = re.split(r'^## (\d{4}-\d{2}-\d{2})', content, flags=re.MULTILINE)
# sections[0] is header, then pairs of (date, body)
entries = []
for i in range(1, len(sections) - 1, 2):
date = sections[i]
body = sections[i + 1].strip()
entries.append((date, body))
# Take last 2-3 entries for synthesis
recent = entries[-3:] if len(entries) > 3 else entries
synthesis_lines = []
for date, body in recent:
# Take first line of each entry as the synthesis line
first_line = body.split('\n')[0].strip()
if first_line:
synthesis_lines.append(f'**{date}:** {first_line}')
synthesis = '\n\n'.join(synthesis_lines) if synthesis_lines else '_No progress entries yet._'
# Read artifact file
with open(artifact_path) as f:
artifact_content = f.read()
# Find ## Progress section and replace its content
# Section runs from '## Progress' to the next '## ' heading or end of file
progress_pattern = re.compile(
r'(## Progress\n).*?(?=\n## [^\n]|\Z)',
re.DOTALL
)
progress_section = f'## Progress\n\n{synthesis}\n'
if progress_pattern.search(artifact_content):
new_content = progress_pattern.sub(progress_section, artifact_content)
else:
# Insert after ## Desired Outcomes or ## Goal / Objective
insert_patterns = [
r'(## Desired Outcomes\n.*?)(?=\n## )',
r'(## Goal / Objective\n.*?)(?=\n## )',
]
inserted = False
for pat in insert_patterns:
match = re.search(pat, artifact_content, re.DOTALL)
if match:
insert_pos = match.end()
new_content = artifact_content[:insert_pos] + '\n\n' + progress_section + '\n' + artifact_content[insert_pos:]
inserted = True
break
if not inserted:
# Fallback: append before ## Lifecycle or at end
lifecycle_match = re.search(r'\n## Lifecycle', artifact_content)
if lifecycle_match:
pos = lifecycle_match.start()
new_content = artifact_content[:pos] + '\n' + progress_section + '\n' + artifact_content[pos:]
else:
new_content = artifact_content + '\n\n' + progress_section
with open(artifact_path, 'w') as f:
f.write(new_content)
print(f'Synthesized progress into {artifact_path}')
" "$progress_file" "$artifact_file"
}
# ─── Digest mode ───
process_digest() {
local digest_path="$1"
if [[ ! -f "$digest_path" ]]; then
echo "ERROR: Digest file not found: $digest_path" >&2
exit 1
fi
uv run python3 -c "
import json, sys, subprocess, os
digest_path = sys.argv[1]
script = sys.argv[2]
with open(digest_path) as f:
entry = json.loads(f.read().strip())
artifacts = entry.get('artifacts_touched', [])
session_summary = entry.get('session_summary', 'Session work recorded.')
for artifact in artifacts:
artifact_id = artifact.get('id', '') if isinstance(artifact, dict) else str(artifact)
summary = artifact.get('summary', session_summary) if isinstance(artifact, dict) else session_summary
if not artifact_id:
continue
# Only update EPICs and Initiatives (container artifacts that track progress)
if not any(artifact_id.startswith(p) for p in ['EPIC-', 'INITIATIVE-']):
continue
result = subprocess.run(
['bash', script, '--artifact-id', artifact_id, '--entry', summary],
capture_output=True, text=True
)
if result.returncode != 0:
print(f'Warning: failed to append entry for {artifact_id}: {result.stderr}', file=sys.stderr)
else:
print(result.stdout, end='')
# Synthesize
result = subprocess.run(
['bash', script, '--artifact-id', artifact_id, '--synthesize'],
capture_output=True, text=True
)
if result.returncode != 0:
print(f'Warning: failed to synthesize for {artifact_id}: {result.stderr}', file=sys.stderr)
else:
print(result.stdout, end='')
" "$digest_path" "${BASH_SOURCE[0]}"
}
# ─── Main dispatch ───
if [[ -n "$DIGEST_PATH" ]]; then
process_digest "$DIGEST_PATH"
elif [[ -n "$ARTIFACT_ID" ]]; then
ARTIFACT_DIR=$(resolve_artifact_dir "$ARTIFACT_ID")
if [[ -n "$ENTRY" ]]; then
append_entry "$ARTIFACT_DIR" "$ENTRY"
fi
if [[ "$SYNTHESIZE" == true ]]; then
synthesize_progress "$ARTIFACT_DIR" "$ARTIFACT_ID"
fi
if [[ -z "$ENTRY" && "$SYNTHESIZE" == false ]]; then
echo "ERROR: --artifact-id requires --entry and/or --synthesize" >&2
exit 1
fi
else
echo "ERROR: Must provide --artifact-id or --digest" >&2
exit 1
fi
#!/usr/bin/env bash
# swain-session-archive.sh — Archive session.json for retro reconstruction
# SPEC-248 | EPIC-056
set -uo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
ARCHIVE_DIR="${SWAIN_ARCHIVE_DIR:-$REPO_ROOT/.agents/session-archive}"
_ensure_dir() {
mkdir -p "$ARCHIVE_DIR"
}
cmd_save() {
local worktree_path="$1"
local session_file="$worktree_path/.agents/session.json"
if [ ! -f "$session_file" ]; then
echo "No session.json in $worktree_path" >&2
return 0 # graceful, not an error
fi
_ensure_dir
# Generate session ID from branch name + timestamp
local branch
branch="$(git -C "$worktree_path" branch --show-current 2>/dev/null || echo "unknown")"
local timestamp
timestamp="$(date +%Y%m%dT%H%M%S)"
local session_id="${branch//\//-}-${timestamp}"
cp "$session_file" "$ARCHIVE_DIR/${session_id}.json"
echo "Archived: $session_id"
}
cmd_get() {
local session_id="$1"
local archive_file="$ARCHIVE_DIR/${session_id}.json"
local archive_gz="$ARCHIVE_DIR/${session_id}.json.gz"
if [ -f "$archive_file" ]; then
cat "$archive_file"
return 0
elif [ -f "$archive_gz" ]; then
gzip -dc "$archive_gz"
return 0
fi
echo "Not found: $session_id" >&2
return 1
}
cmd_find() {
local artifact_id="$1"
_ensure_dir
local found=false
for f in "$ARCHIVE_DIR"/*.json "$ARCHIVE_DIR"/*.json.gz; do
[ -f "$f" ] || continue
local content
if [[ "$f" == *.gz ]]; then
content="$(gzip -dc "$f")"
else
content="$(cat "$f")"
fi
if echo "$content" | grep -q "$artifact_id"; then
local name
name="$(basename "$f")"
echo "$name: $(echo "$content" | grep -o "\"note\":[^,}]*" | head -1)"
found=true
fi
done
if [ "$found" = false ]; then
return 0 # empty output, no matches
fi
}
cmd_compress() {
_ensure_dir
local now_epoch
now_epoch="$(date +%s)"
local seven_days=$((7 * 86400))
for f in "$ARCHIVE_DIR"/*.json; do
[ -f "$f" ] || continue
# Skip if already has a .gz companion
[ -f "${f}.gz" ] && continue
local file_epoch
file_epoch="$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo "$now_epoch")"
local age=$((now_epoch - file_epoch))
if [ "$age" -gt "$seven_days" ]; then
gzip "$f"
echo "Compressed: $(basename "$f")"
fi
done
}
# --- Main dispatch ---
cmd="${1:-help}"
shift || true
case "$cmd" in
save) cmd_save "$@" ;;
get) cmd_get "$@" ;;
find) cmd_find "$@" ;;
compress) cmd_compress ;;
help)
echo "Usage: swain-session-archive.sh <command> [args]"
echo ""
echo "Commands:"
echo " save <worktree-path> Archive session.json from worktree"
echo " get <session-id> Retrieve archived session"
echo " find <artifact-id> Find sessions touching an artifact"
echo " compress Gzip archives older than 7 days"
;;
*)
echo "Unknown command: $cmd" >&2
exit 1
;;
esac
#!/usr/bin/env bash
set +e # Never fail hard — session bootstrap is a convenience, not a gate
# swain-session-bootstrap.sh — Consolidated session startup
#
# Replaces multi-step agent orchestration (tab naming + worktree detection +
# session.json loading) with a single script call that emits structured JSON.
#
# Usage:
# swain-session-bootstrap.sh --auto # full bootstrap
# swain-session-bootstrap.sh --path DIR --auto # resolve from DIR
# swain-session-bootstrap.sh --skip-worktree --auto # omit worktree check
#
# Output: JSON to stdout with keys: tab, worktree, session, warnings
# See SPEC-172 for the full contract.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TAB_NAME_SCRIPT="$SCRIPT_DIR/swain-tab-name.sh"
BOOKMARK_SCRIPT="$SCRIPT_DIR/swain-bookmark.sh"
# ─── Argument parsing ───
SWAIN_BOOTSTRAP_PATH=""
SKIP_WORKTREE=0
AUTO=0
WARNINGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--path)
SWAIN_BOOTSTRAP_PATH="$2"
shift 2
;;
--skip-worktree)
SKIP_WORKTREE=1
shift
;;
--auto)
AUTO=1
shift
;;
--help|-h)
echo "Usage: swain-session-bootstrap.sh [--path DIR] [--skip-worktree] --auto"
echo ""
echo " --path DIR Resolve git context from DIR (default: auto-detect)"
echo " --skip-worktree Omit worktree isolation detection"
echo " --auto Run in non-interactive mode"
exit 0
;;
*)
shift
;;
esac
done
# ─── Resolve repo root ───
if [[ -n "$SWAIN_BOOTSTRAP_PATH" ]]; then
REPO_ROOT="$(git -C "$SWAIN_BOOTSTRAP_PATH" rev-parse --show-toplevel 2>/dev/null || echo "$SWAIN_BOOTSTRAP_PATH")"
else
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
# ─── Step 1: Tab naming (tmux only) ───
TAB_RESULT=""
if [[ -n "${TMUX:-}" ]]; then
if [[ -f "$TAB_NAME_SCRIPT" ]]; then
TAB_ARGS=()
[[ -n "$SWAIN_BOOTSTRAP_PATH" ]] && TAB_ARGS+=(--path "$SWAIN_BOOTSTRAP_PATH")
TAB_ARGS+=(--auto)
TAB_RESULT=$(bash "$TAB_NAME_SCRIPT" "${TAB_ARGS[@]}" 2>/dev/null)
else
WARNINGS+=("tab-name script not found at $TAB_NAME_SCRIPT")
fi
fi
# ─── Step 2: Worktree detection ───
WT_ISOLATED="false"
WT_PATH=""
WT_BRANCH=""
DETECT_PATH="${SWAIN_BOOTSTRAP_PATH:-$REPO_ROOT}"
if [[ "$SKIP_WORKTREE" -eq 0 ]]; then
GIT_COMMON=$(git -C "$DETECT_PATH" rev-parse --git-common-dir 2>/dev/null)
GIT_DIR=$(git -C "$DETECT_PATH" rev-parse --git-dir 2>/dev/null)
if [[ -n "$GIT_COMMON" && -n "$GIT_DIR" && "$GIT_COMMON" != "$GIT_DIR" ]]; then
WT_ISOLATED="true"
WT_PATH="$DETECT_PATH"
WT_BRANCH=$(git -C "$DETECT_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
else
WT_ISOLATED="false"
WT_BRANCH=$(git -C "$DETECT_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
fi
fi
# ─── Step 3: Session.json loading ───
SESSION_FILE="$REPO_ROOT/.agents/session.json"
SESSION_FOCUS=""
SESSION_BOOKMARK=""
SESSION_LAST_BRANCH=""
if [[ -f "$SESSION_FILE" ]] && command -v jq &>/dev/null; then
SESSION_FOCUS=$(jq -r '.focus_lane // empty' "$SESSION_FILE" 2>/dev/null)
SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' "$SESSION_FILE" 2>/dev/null)
SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' "$SESSION_FILE" 2>/dev/null)
# Update lastBranch to current
CURRENT_BRANCH=$(git -C "$DETECT_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [[ -n "$CURRENT_BRANCH" ]]; then
jq --arg branch "$CURRENT_BRANCH" '.lastBranch = $branch' \
"$SESSION_FILE" > "${SESSION_FILE}.tmp" 2>/dev/null \
&& mv "${SESSION_FILE}.tmp" "$SESSION_FILE" 2>/dev/null
fi
elif [[ ! -f "$SESSION_FILE" ]]; then
# Check for old global location and migrate
_OLD_SLUG=$(echo "$REPO_ROOT" | tr '/' '-')
_OLD_FILE="$HOME/.claude/projects/${_OLD_SLUG}/memory/session.json"
if [[ -f "$_OLD_FILE" ]]; then
mkdir -p "$(dirname "$SESSION_FILE")" 2>/dev/null
cp "$_OLD_FILE" "$SESSION_FILE" 2>/dev/null
WARNINGS+=("migrated session.json from old global location")
# Re-read after migration
if command -v jq &>/dev/null; then
SESSION_FOCUS=$(jq -r '.focus_lane // empty' "$SESSION_FILE" 2>/dev/null)
SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' "$SESSION_FILE" 2>/dev/null)
SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' "$SESSION_FILE" 2>/dev/null)
fi
fi
fi
# ─── Build JSON output ───
build_fallback_json() {
# Minimal JSON construction without jq
local out='{"worktree":{"isolated":'
out+="$WT_ISOLATED"
out+='},"session":{},"warnings":['
local first=1
for w in "${WARNINGS[@]}"; do
[[ $first -eq 0 ]] && out+=","
# Escape quotes in warning text
out+="\"${w//\"/\\\"}\""
first=0
done
out+=']}'
echo "$out"
}
# Use jq if available and functional, fall back to manual construction
OUTPUT=""
if command -v jq &>/dev/null && jq -n '{}' &>/dev/null; then
# Build warnings array
WARNINGS_JSON="[]"
for w in "${WARNINGS[@]}"; do
WARNINGS_JSON=$(echo "$WARNINGS_JSON" | jq --arg w "$w" '. + [$w]')
done
OUTPUT=$(jq -n \
--arg tab "$TAB_RESULT" \
--arg wt_isolated "$WT_ISOLATED" \
--arg wt_path "$WT_PATH" \
--arg wt_branch "$WT_BRANCH" \
--arg s_focus "$SESSION_FOCUS" \
--arg s_bookmark "$SESSION_BOOKMARK" \
--arg s_last_branch "$SESSION_LAST_BRANCH" \
--argjson warnings "$WARNINGS_JSON" \
'{
worktree: {
isolated: ($wt_isolated == "true"),
path: (if $wt_path == "" then null else $wt_path end),
branch: (if $wt_branch == "" then null else $wt_branch end)
},
session: {
focus: (if $s_focus == "" then null else $s_focus end),
bookmark: (if $s_bookmark == "" then null else $s_bookmark end),
lastBranch: (if $s_last_branch == "" then null else $s_last_branch end)
},
warnings: $warnings
}
| if $tab != "" then .tab = $tab else . end' 2>/dev/null)
fi
# If jq failed or wasn't available, use the fallback
if [[ -z "$OUTPUT" ]]; then
WARNINGS+=("jq not available — session fields may be incomplete")
OUTPUT=$(build_fallback_json)
fi
echo "$OUTPUT"
#!/usr/bin/env bash
# swain-session-check.sh — Lightweight session detection for skill preambles
# SPEC-121: Session Detection Hooks Across All Skills
#
# Reads .agents/session-state.json and emits a JSON result:
# {"status": "active|stale|closed|none", "focus_lane": "...", "session_id": "..."}
#
# Exit codes:
# 0 — session is active
# 1 — session is stale, closed, or missing (skill should prompt)
#
# Options:
# --state-file <path> Override state file location
# --threshold <seconds> Staleness threshold (default: 3600 = 1 hour)
set -uo pipefail
STATE_FILE="${SWAIN_SESSION_STATE:-.agents/session-state.json}"
THRESHOLD=3600
while [ $# -gt 0 ]; do
case "$1" in
--state-file) STATE_FILE="$2"; shift 2 ;;
--threshold) THRESHOLD="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ ! -f "$STATE_FILE" ]; then
echo '{"status": "none", "focus_lane": null, "session_id": null}'
exit 1
fi
python3 -c "
import json, sys
from datetime import datetime, timezone
with open('$STATE_FILE') as f:
state = json.load(f)
phase = state.get('phase', 'unknown')
focus = state.get('focus_lane')
sid = state.get('session_id')
activity = state.get('last_activity_time') or state.get('start_time', '')
result = {'focus_lane': focus, 'session_id': sid}
if phase == 'closed':
result['status'] = 'closed'
json.dump(result, sys.stdout)
sys.exit(1)
elif phase == 'active':
# Check staleness
try:
activity_dt = datetime.fromisoformat(activity.replace('Z', '+00:00'))
age = (datetime.now(timezone.utc) - activity_dt).total_seconds()
if age > $THRESHOLD:
result['status'] = 'stale'
json.dump(result, sys.stdout)
sys.exit(1)
else:
result['status'] = 'active'
json.dump(result, sys.stdout)
sys.exit(0)
except (ValueError, TypeError):
result['status'] = 'stale'
json.dump(result, sys.stdout)
sys.exit(1)
else:
result['status'] = 'none'
json.dump(result, sys.stdout)
sys.exit(1)
"
#!/usr/bin/env bash
# swain-session-digest.sh — Generate a structured JSONL digest of a session
# Part of SPEC-199: Session Digest Auto-Generation
#
# Usage:
# swain-session-digest.sh --session-id <ID> --start-time <ISO8601> [--focus <ARTIFACT-ID>] [--repo-root <PATH>] [--output <PATH>]
#
# Exit codes:
# 0 — digest written successfully
# 1 — error (missing required args, git not available, etc.)
set -euo pipefail
SESSION_ID=""
START_TIME=""
FOCUS=""
REPO_ROOT=""
OUTPUT_FILE=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--session-id)
SESSION_ID="$2"
shift 2
;;
--start-time)
START_TIME="$2"
shift 2
;;
--focus)
FOCUS="$2"
shift 2
;;
--repo-root)
REPO_ROOT="$2"
shift 2
;;
--output)
OUTPUT_FILE="$2"
shift 2
;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done
# Validate required args
if [[ -z "$SESSION_ID" ]]; then
echo "Error: --session-id is required" >&2
exit 1
fi
if [[ -z "$START_TIME" ]]; then
echo "Error: --start-time is required" >&2
exit 1
fi
# Defaults
if [[ -z "$REPO_ROOT" ]]; then
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "Error: not in a git repository and --repo-root not specified" >&2
exit 1
}
fi
if [[ -z "$OUTPUT_FILE" ]]; then
OUTPUT_FILE="$REPO_ROOT/.agents/session-log.jsonl"
fi
# Ensure output directory exists
mkdir -p "$(dirname "$OUTPUT_FILE")"
# --- Evidence gathering ---
# 1. Git commits since start-time
GIT_LOG=$(git -C "$REPO_ROOT" log --after="$START_TIME" --oneline --no-decorate 2>/dev/null || echo "")
# 2. Ticket completions — scan .tickets/ for closed tickets updated after start-time
TICKETS_DIR="$REPO_ROOT/.tickets"
CLOSED_TICKETS=""
if [[ -d "$TICKETS_DIR" ]]; then
for ticket_file in "$TICKETS_DIR"/*.md; do
[[ -f "$ticket_file" ]] || continue
# Check if status is closed
status=$(sed -n '/^---$/,/^---$/{ /^status:/{ s/^status: *//; p; q; } }' "$ticket_file" 2>/dev/null || echo "")
if [[ "$status" == "closed" ]]; then
# Check if file was modified after start-time (use file mtime as proxy)
# Extract tags for spec references
tags=$(sed -n '/^---$/,/^---$/{ /^tags:/{ s/^tags: *\[//; s/\].*//; p; q; } }' "$ticket_file" 2>/dev/null || echo "")
CLOSED_TICKETS="${CLOSED_TICKETS}${tags}"$'\n'
fi
done
fi
# 3. Pass everything to Python for JSON construction and output
export SESSION_ID START_TIME FOCUS GIT_LOG CLOSED_TICKETS OUTPUT_FILE REPO_ROOT
uv run python3 -c "
import json
import sys
import os
from datetime import datetime, timezone
session_id = os.environ['SESSION_ID']
start_time = os.environ['START_TIME']
focus = os.environ.get('FOCUS', '')
git_log = os.environ.get('GIT_LOG', '')
output_file = os.environ['OUTPUT_FILE']
# Parse git log lines
commits_lines = [line.strip() for line in git_log.strip().split('\n') if line.strip()]
commit_count = len(commits_lines)
# Extract artifact references and actions from commit messages
# Commit format: <hash> <prefix>(<scope>): <message> OR <hash> <prefix>: <message>
artifact_pattern_ids = set()
artifacts_touched = []
seen_ids = set()
import re
# Map conventional-commit prefixes to actions
prefix_map = {
'feat': 'implemented',
'fix': 'fixed',
'docs': 'documented',
'close': 'completed',
'test': 'tested',
'research': 'researched',
'refactor': 'refactored',
'chore': 'maintained',
'ci': 'maintained',
'style': 'maintained',
'perf': 'optimized',
}
# Pattern for artifact IDs
artifact_re = re.compile(r'(SPEC|EPIC|INITIATIVE|ADR|SPIKE|VISION|PERSONA|RUNBOOK|DESIGN|JOURNEY)-(\d+)')
# Pattern for conventional commit prefix
prefix_re = re.compile(r'^[a-f0-9]+ (\w+)(?:\([^)]*\))?[!]?:\s*(.*)')
for line in commits_lines:
# Find artifact IDs in this commit line
ids_in_line = artifact_re.findall(line)
if not ids_in_line:
continue
# Parse the commit prefix
prefix_match = prefix_re.match(line)
action = 'touched'
summary = line
if prefix_match:
prefix = prefix_match.group(1).lower()
action = prefix_map.get(prefix, 'touched')
summary = prefix_match.group(2).strip()
for artifact_type, artifact_num in ids_in_line:
artifact_id = f'{artifact_type}-{artifact_num}'
if artifact_id in seen_ids:
continue
seen_ids.add(artifact_id)
# Try to read the artifact title from disk
title = ''
repo_root = os.environ['REPO_ROOT']
# Map artifact type to directory
type_dir_map = {
'SPEC': 'spec', 'EPIC': 'epic', 'INITIATIVE': 'initiative',
'ADR': 'adr', 'SPIKE': 'spike', 'VISION': 'vision',
'PERSONA': 'persona', 'RUNBOOK': 'runbook', 'DESIGN': 'design',
'JOURNEY': 'journey',
}
type_dir = type_dir_map.get(artifact_type, artifact_type.lower())
# Search common locations
for subdir in ['Active', 'Complete', 'Proposed', 'InProgress', 'NeedsManualTest', 'Ready', 'Adopted', 'Retired', 'Superseded', 'Abandoned', 'Draft', '']:
candidate = os.path.join(repo_root, 'docs', type_dir, subdir)
if not os.path.isdir(candidate):
continue
for fname in os.listdir(candidate):
if artifact_id not in fname:
continue
fpath = os.path.join(candidate, fname)
# Handle subdirectory layout: (SPEC-194)-Title/(SPEC-194)-Title.md
if os.path.isdir(fpath):
for inner in os.listdir(fpath):
if artifact_id in inner and inner.endswith('.md'):
fpath = os.path.join(fpath, inner)
break
else:
continue
elif not fname.endswith('.md'):
continue
try:
with open(fpath) as f:
in_frontmatter = False
for fline in f:
fline = fline.rstrip()
if fline == '---':
if not in_frontmatter:
in_frontmatter = True
continue
else:
break
if in_frontmatter and fline.startswith('title:'):
title = fline[len('title:'):].strip().strip('\"').strip(\"'\")
break
except (IOError, OSError):
pass
break
if title:
break
artifacts_touched.append({
'id': artifact_id,
'title': title,
'action': action,
'summary': summary,
})
# Count tasks closed (from ticket tags referencing specs)
closed_tickets_raw = os.environ.get('CLOSED_TICKETS', '')
tasks_closed = len([line for line in closed_tickets_raw.strip().split('\n') if line.strip()])
# Build session summary
if artifacts_touched:
summaries = [a['summary'] for a in artifacts_touched]
session_summary = '; '.join(summaries[:5])
if len(summaries) > 5:
session_summary += f' (and {len(summaries) - 5} more)'
elif commit_count > 0:
session_summary = f'{commit_count} commits with no artifact references.'
else:
session_summary = 'Empty session — no commits recorded.'
# Build the digest entry
entry = {
'session_id': session_id,
'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'focus_lane': focus if focus else None,
'artifacts_touched': artifacts_touched,
'commits': commit_count,
'tasks_closed': tasks_closed,
'session_summary': session_summary,
}
# Append to output file
with open(output_file, 'a') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
" <<< "" || {
echo "Error: Python JSON construction failed" >&2
exit 1
}
echo "Digest written to $OUTPUT_FILE" >&2
exit 0
#!/usr/bin/env bash
# swain-session-greeting.sh — SPEC-194: Fast-path session greeting
#
# Produces immediate session context without expensive operations.
# Calls the preflight script for all read-only state, then applies
# lightweight mutations (tab naming, lock cleanup, .agents dir).
# Does NOT invoke specgraph, GitHub API, or the full status dashboard.
#
# Usage:
# swain-session-greeting.sh # human-readable output
# swain-session-greeting.sh --json # structured JSON
# swain-session-greeting.sh --path DIR # resolve from DIR
#
# Output (human-readable):
# Branch, dirty state, bookmark, focus lane, warnings
#
# Output (JSON):
# { greeting: true, branch, dirty, bookmark, focus, purpose, warnings[] }
#
# Session purpose (SPEC-297):
# If $SWAIN_PURPOSE is set and the session has no existing bookmark,
# the purpose text is written to the bookmark deterministically and
# surfaced as the `purpose` JSON field. Agent skills consume this
# field; they no longer parse the initial prompt themselves.
set +e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFLIGHT_SCRIPT="$SCRIPT_DIR/swain-session-preflight.sh"
TAB_NAME_SCRIPT="$SCRIPT_DIR/swain-tab-name.sh"
JSON_MODE=0
EXTRA_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--json) JSON_MODE=1; shift ;;
--path) EXTRA_PATH="$2"; shift 2 ;;
*) shift ;;
esac
done
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
# ─── Step 1: Run preflight (single script, no subprocess chain) ───
PREFLIGHT_ARGS=(--repo-root "$REPO_ROOT")
[[ -n "$EXTRA_PATH" ]] && PREFLIGHT_ARGS+=(--path "$EXTRA_PATH")
PREFLIGHT_JSON=""
if [[ -f "$PREFLIGHT_SCRIPT" ]]; then
PREFLIGHT_JSON=$(bash "$PREFLIGHT_SCRIPT" "${PREFLIGHT_ARGS[@]}" 2>/dev/null)
fi
# Parse preflight output
if command -v jq &>/dev/null && [[ -n "$PREFLIGHT_JSON" ]]; then
BRANCH=$(echo "$PREFLIGHT_JSON" | jq -r '.git.branch // "unknown"' 2>/dev/null)
DIRTY=$(echo "$PREFLIGHT_JSON" | jq -r 'if .git.dirty then "true" else "false" end' 2>/dev/null)
ISOLATED=$(echo "$PREFLIGHT_JSON" | jq -r 'if .git.worktree.isolated then "true" else "false" end' 2>/dev/null)
BOOKMARK=$(echo "$PREFLIGHT_JSON" | jq -r '.session.bookmark // empty' 2>/dev/null)
FOCUS=$(echo "$PREFLIGHT_JSON" | jq -r '.session.focus // empty' 2>/dev/null)
TAB_NAME=$(echo "$PREFLIGHT_JSON" | jq -r '.tmux.tab_name // empty' 2>/dev/null)
# Collect preflight warnings (structured keys like "stale_tk_locks:3")
PREFLIGHT_WARNINGS=$(echo "$PREFLIGHT_JSON" | jq -r '.warnings[]? // empty' 2>/dev/null)
else
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
DIRTY="false"
[[ -n "$(git status --porcelain 2>/dev/null | head -1)" ]] && DIRTY="true"
ISOLATED="false"
BOOKMARK=""
FOCUS=""
TAB_NAME=""
PREFLIGHT_WARNINGS=""
fi
# ─── Step 2: Apply mutations ───
WARNINGS=()
# Tab naming (tmux only) — uses the precomputed name, avoids full tab-name.sh resolution
if [[ -n "${TMUX:-}" && -n "$TAB_NAME" && -f "$TAB_NAME_SCRIPT" ]]; then
TAB_ARGS=("$TAB_NAME")
[[ -n "$EXTRA_PATH" ]] && TAB_ARGS=(--path "$EXTRA_PATH" --auto)
TAB=$(bash "$TAB_NAME_SCRIPT" "${TAB_ARGS[@]}" 2>/dev/null)
else
TAB="$TAB_NAME"
fi
# Clean stale tk locks
for w in $PREFLIGHT_WARNINGS; do
case "$w" in
stale_tk_locks:*)
count="${w#stale_tk_locks:}"
find "$REPO_ROOT/.tickets/.locks" -type d -mmin +60 -exec rm -rf {} + 2>/dev/null
WARNINGS+=("cleaned $count stale tk lock(s)")
;;
stale_git_index_lock)
WARNINGS+=("stale git index.lock detected — may need manual removal")
;;
missing_agents_dir)
mkdir -p "$REPO_ROOT/.agents"
WARNINGS+=("created missing .agents/ directory")
;;
*)
WARNINGS+=("$w")
;;
esac
done
# Update lastBranch in session.json
SESSION_FILE="$REPO_ROOT/.agents/session.json"
if [[ -f "$SESSION_FILE" ]] && command -v jq &>/dev/null; then
jq --arg branch "$BRANCH" '.lastBranch = $branch' \
"$SESSION_FILE" > "${SESSION_FILE}.tmp" 2>/dev/null \
&& mv "${SESSION_FILE}.tmp" "$SESSION_FILE" 2>/dev/null
fi
# SPEC-297: Session purpose capture.
# If SWAIN_PURPOSE is set and no bookmark exists yet, write it and re-read.
PURPOSE="${SWAIN_PURPOSE:-}"
if [[ -n "$PURPOSE" && -z "$BOOKMARK" ]]; then
BOOKMARK_SCRIPT="$SCRIPT_DIR/swain-bookmark.sh"
if [[ -f "$BOOKMARK_SCRIPT" ]]; then
SWAIN_REPO_ROOT="$REPO_ROOT" bash "$BOOKMARK_SCRIPT" "$PURPOSE" >/dev/null 2>&1
BOOKMARK="$PURPOSE"
fi
fi
# ─── Step 3: Output ───
if [[ "$JSON_MODE" -eq 1 ]]; then
WARNINGS_JSON="[]"
if command -v jq &>/dev/null; then
for w in "${WARNINGS[@]}"; do
WARNINGS_JSON=$(echo "$WARNINGS_JSON" | jq --arg w "$w" '. + [$w]')
done
fi
if command -v jq &>/dev/null; then
jq -n \
--arg branch "$BRANCH" \
--arg dirty "$DIRTY" \
--arg bookmark "$BOOKMARK" \
--arg focus "$FOCUS" \
--arg isolated "$ISOLATED" \
--arg tab "$TAB" \
--arg purpose "$PURPOSE" \
--argjson warnings "$WARNINGS_JSON" \
'{
greeting: true,
branch: $branch,
dirty: ($dirty == "true"),
isolated: ($isolated == "true"),
bookmark: (if $bookmark == "" then null else $bookmark end),
focus: (if $focus == "" then null else $focus end),
purpose: (if $purpose == "" then null else $purpose end),
tab: (if $tab == "" then null else $tab end),
warnings: $warnings
}'
else
echo "{\"greeting\":true,\"branch\":\"$BRANCH\",\"dirty\":$DIRTY}"
fi
else
state="clean"
[[ "$DIRTY" == "true" ]] && state="dirty"
isolation=""
[[ "$ISOLATED" == "true" ]] && isolation=" (worktree)"
echo "Branch: $BRANCH${isolation} [$state]"
if [[ -n "$PURPOSE" ]]; then
echo "Purpose: $PURPOSE"
fi
if [[ -n "$BOOKMARK" ]]; then
echo "Bookmark: $BOOKMARK"
fi
if [[ -n "$FOCUS" ]]; then
echo "Focus: $FOCUS"
fi
for w in "${WARNINGS[@]}"; do
echo "Warning: $w"
done
fi
#!/usr/bin/env bash
# swain-session-preflight.sh — read-only session state scanner
#
# Consolidates all session startup reads into a single script.
# Replaces the subprocess chain: greeting → bootstrap → tab-name.
# This script NEVER mutates state (no tab renames, no file writes,
# no lock cleanup). The caller applies mutations using the JSON output.
#
# Usage: bash swain-session-preflight.sh [--repo-root /path] [--path /dir]
#
# JSON schema (all keys present, some may be null on error):
#
# git.repo_root string — resolved repository root
# git.branch string — current branch name
# git.dirty bool — uncommitted changes present
# git.worktree.isolated bool — running inside a linked worktree
# git.worktree.path string — worktree path (null if not isolated)
#
# tmux.active bool — running inside a tmux session
# tmux.tab_format string — tab name format from settings
# tmux.tab_name string — computed tab name (not applied)
#
# session.focus string — focus lane from session.json
# session.bookmark string — bookmark note from session.json
# session.last_branch string — last branch from session.json
#
# prev_session.exists bool — session-state.json found
# prev_session.status string — "active" | "stale" | "closed" | "none"
# prev_session.session_id string — session identifier
# prev_session.focus_lane string — focus lane from state
# prev_session.phase string — lifecycle phase
# prev_session.start_time string — ISO timestamp
# prev_session.end_time string — ISO timestamp (null if active)
# prev_session.decisions_made int — count
# prev_session.walkaway string — walk-away note (null if none)
#
# warnings array — preflight warnings (read-only observations)
#
# Exit: always 0 (partial results on individual check failures)
set -euo pipefail
REPO_ROOT=""
DETECT_PATH=""
while [ $# -gt 0 ]; do
case "$1" in
--repo-root) REPO_ROOT="$2"; shift 2 ;;
--path) DETECT_PATH="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$REPO_ROOT" ]; then
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
if [ -z "$DETECT_PATH" ]; then
DETECT_PATH="$REPO_ROOT"
fi
# --- Collector variables ---
WARNINGS_RAW=""
add_warning() {
WARNINGS_RAW="${WARNINGS_RAW}${1}
"
}
# --- Git state ---
check_git() {
GIT_BRANCH=$(git -C "$DETECT_PATH" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
GIT_DIRTY=false
if [ -n "$(git -C "$DETECT_PATH" status --porcelain 2>/dev/null | head -1)" ]; then
GIT_DIRTY=true
fi
GIT_WT_ISOLATED=false
GIT_WT_PATH=""
GIT_COMMON=$(git -C "$DETECT_PATH" rev-parse --git-common-dir 2>/dev/null || true)
GIT_DIR=$(git -C "$DETECT_PATH" rev-parse --git-dir 2>/dev/null || true)
if [ -n "$GIT_COMMON" ] && [ -n "$GIT_DIR" ] && [ "$GIT_COMMON" != "$GIT_DIR" ]; then
GIT_WT_ISOLATED=true
GIT_WT_PATH="$DETECT_PATH"
fi
}
# --- Tmux state (read-only — no renames) ---
check_tmux() {
TMUX_ACTIVE=false
TMUX_TAB_FORMAT=""
TMUX_TAB_NAME=""
if [ -n "${TMUX:-}" ]; then
TMUX_ACTIVE=true
fi
# Read tab format from settings (project, then user)
SETTINGS_PROJECT="$REPO_ROOT/swain.settings.json"
SETTINGS_USER="${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json"
TMUX_TAB_FORMAT='{project} @ {branch}'
if [ -f "$SETTINGS_USER" ] && command -v jq &>/dev/null; then
val=$(jq -r '.terminal.tabNameFormat // empty' "$SETTINGS_USER" 2>/dev/null || true)
[ -n "$val" ] && TMUX_TAB_FORMAT="$val"
fi
if [ -f "$SETTINGS_PROJECT" ] && command -v jq &>/dev/null; then
val=$(jq -r '.terminal.tabNameFormat // empty' "$SETTINGS_PROJECT" 2>/dev/null || true)
[ -n "$val" ] && TMUX_TAB_FORMAT="$val"
fi
# Compute tab name from git context (same logic as tab-name.sh auto_title)
local common_dir repo_root project
common_dir=$(git -C "$DETECT_PATH" rev-parse --git-common-dir 2>/dev/null || true)
if [ -n "$common_dir" ]; then
repo_root=$(cd "$DETECT_PATH" && cd "$common_dir/.." && pwd 2>/dev/null || true)
fi
project=$(basename "${repo_root:-unknown}")
TMUX_TAB_NAME="${TMUX_TAB_FORMAT//\{project\}/$project}"
TMUX_TAB_NAME="${TMUX_TAB_NAME//\{branch\}/$GIT_BRANCH}"
}
# --- Session.json (bookmark, focus, last branch) ---
check_session_json() {
SESSION_FOCUS=""
SESSION_BOOKMARK=""
SESSION_LAST_BRANCH=""
local session_file="$REPO_ROOT/.agents/session.json"
if [ -f "$session_file" ] && command -v jq &>/dev/null; then
SESSION_FOCUS=$(jq -r '.focus_lane // empty' "$session_file" 2>/dev/null || true)
SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' "$session_file" 2>/dev/null || true)
SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' "$session_file" 2>/dev/null || true)
fi
}
# --- Session state (previous session resume context) ---
check_session_state() {
PREV_EXISTS=false
PREV_STATUS="none"
PREV_SESSION_ID=""
PREV_FOCUS_LANE=""
PREV_PHASE=""
PREV_START_TIME=""
PREV_END_TIME=""
PREV_DECISIONS=0
PREV_WALKAWAY=""
local state_file="${SWAIN_SESSION_STATE:-$REPO_ROOT/.agents/session-state.json}"
if [ -f "$state_file" ]; then
PREV_EXISTS=true
# Extract all fields in one python3 call
eval "$(python3 -c "
import json, sys
from datetime import datetime, timezone
with open('$state_file') as f:
state = json.load(f)
phase = state.get('phase', 'unknown')
focus = state.get('focus_lane') or ''
sid = state.get('session_id') or ''
activity = state.get('last_activity_time') or state.get('start_time', '')
start = state.get('start_time', '')
end = state.get('end_time') or ''
decisions = state.get('decisions_made', 0)
walkaway = state.get('walkaway') or ''
status = 'none'
if phase == 'closed':
status = 'closed'
elif phase == 'active':
try:
activity_dt = datetime.fromisoformat(activity.replace('Z', '+00:00'))
age = (datetime.now(timezone.utc) - activity_dt).total_seconds()
status = 'stale' if age > 3600 else 'active'
except (ValueError, TypeError):
status = 'stale'
# Shell-safe quoting via repr
def q(s):
return s.replace(\"'\", \"'\\\"'\\\"'\")
print(f\"PREV_STATUS='{q(status)}'\")
print(f\"PREV_SESSION_ID='{q(sid)}'\")
print(f\"PREV_FOCUS_LANE='{q(focus)}'\")
print(f\"PREV_PHASE='{q(phase)}'\")
print(f\"PREV_START_TIME='{q(start)}'\")
print(f\"PREV_END_TIME='{q(end)}'\")
print(f\"PREV_DECISIONS={decisions}\")
print(f\"PREV_WALKAWAY='{q(walkaway)}'\")
" 2>/dev/null)" || true
fi
}
# --- Preflight warnings (read-only observations) ---
check_warnings() {
# Stale tk locks (report only — don't clean)
if [ -d "$REPO_ROOT/.tickets/.locks" ]; then
stale_count=$(find "$REPO_ROOT/.tickets/.locks" -type d -mmin +60 2>/dev/null | wc -l | tr -d ' ')
if [ "$stale_count" -gt 0 ]; then
add_warning "stale_tk_locks:$stale_count"
fi
fi
# Stale git index.lock
if [ -f "$REPO_ROOT/.git/index.lock" ]; then
add_warning "stale_git_index_lock"
fi
# Missing .agents directory
if [ ! -d "$REPO_ROOT/.agents" ]; then
add_warning "missing_agents_dir"
fi
}
# --- Run all checks ---
check_git || true
check_tmux || true
check_session_json || true
check_session_state || true
check_warnings || true
# --- Emit JSON via python3 ---
python3 -c "
import json, sys
def to_bool(v):
return v.lower() == 'true'
def to_int(v):
try: return int(v)
except: return 0
def to_str_or_null(v):
return v if v else None
def to_list(raw):
return [x for x in raw.strip().split('\n') if x] if raw.strip() else []
data = {
'git': {
'repo_root': sys.argv[1],
'branch': sys.argv[2] or 'unknown',
'dirty': to_bool(sys.argv[3]),
'worktree': {
'isolated': to_bool(sys.argv[4]),
'path': to_str_or_null(sys.argv[5]),
},
},
'tmux': {
'active': to_bool(sys.argv[6]),
'tab_format': sys.argv[7],
'tab_name': sys.argv[8],
},
'session': {
'focus': to_str_or_null(sys.argv[9]),
'bookmark': to_str_or_null(sys.argv[10]),
'last_branch': to_str_or_null(sys.argv[11]),
},
'prev_session': {
'exists': to_bool(sys.argv[12]),
'status': sys.argv[13],
'session_id': to_str_or_null(sys.argv[14]),
'focus_lane': to_str_or_null(sys.argv[15]),
'phase': to_str_or_null(sys.argv[16]),
'start_time': to_str_or_null(sys.argv[17]),
'end_time': to_str_or_null(sys.argv[18]),
'decisions_made': to_int(sys.argv[19]),
'walkaway': to_str_or_null(sys.argv[20]),
},
'warnings': to_list(sys.argv[21]),
}
json.dump(data, sys.stdout, indent=2)
print()
" \
"$REPO_ROOT" "$GIT_BRANCH" "$GIT_DIRTY" \
"$GIT_WT_ISOLATED" "$GIT_WT_PATH" \
"$TMUX_ACTIVE" "$TMUX_TAB_FORMAT" "$TMUX_TAB_NAME" \
"$SESSION_FOCUS" "$SESSION_BOOKMARK" "$SESSION_LAST_BRANCH" \
"$PREV_EXISTS" "$PREV_STATUS" "$PREV_SESSION_ID" "$PREV_FOCUS_LANE" \
"$PREV_PHASE" "$PREV_START_TIME" "$PREV_END_TIME" "$PREV_DECISIONS" \
"$PREV_WALKAWAY" \
"$WARNINGS_RAW"
#!/usr/bin/env bash
# swain-session-state.sh — Session lifecycle state management
# SPEC-119: Session Lifecycle in swain-session
#
# Commands:
# init Create a new session state
# record-decision Record a decision made during the session
# close Close the session with a walk-away signal
# resume Read previous session state and emit resume context
# show Display current session state
#
# All commands accept --state-file <path> to override the default location.
set -uo pipefail
# Defaults
STATE_FILE="${SWAIN_SESSION_STATE:-.agents/session-state.json}"
SESSION_ROADMAP=""
REPO_ROOT=""
FOCUS=""
BUDGET=5
WALKAWAY=""
NOTE=""
usage() {
echo "usage: swain-session-state.sh <command> [options]"
echo ""
echo "Commands: init, record-decision, close, resume, show"
echo ""
echo "Common options:"
echo " --state-file <path> Override state file location"
echo ""
echo "init options:"
echo " --focus <ID> Focus lane (vision/initiative ID)"
echo " --budget <N> Decision budget (default: 5)"
echo " --session-roadmap <path> Path for SESSION-ROADMAP.md"
echo " --repo-root <path> Repository root for chart.sh"
echo ""
echo "record-decision options:"
echo " --note <text> Decision description"
echo ""
echo "close options:"
echo " --walkaway <text> Walk-away signal text"
echo " --session-roadmap <path> Path to SESSION-ROADMAP.md to finalize"
}
# Parse command
COMMAND="${1:-}"
shift 2>/dev/null || true
if [ -z "$COMMAND" ]; then
usage
exit 1
fi
# Parse options
while [ $# -gt 0 ]; do
case "$1" in
--state-file) STATE_FILE="$2"; shift 2 ;;
--focus) FOCUS="$2"; shift 2 ;;
--budget) BUDGET="$2"; shift 2 ;;
--walkaway) WALKAWAY="$2"; shift 2 ;;
--note) NOTE="$2"; shift 2 ;;
--session-roadmap) SESSION_ROADMAP="$2"; shift 2 ;;
--repo-root) REPO_ROOT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# Generate a short session ID (timestamp + random suffix)
generate_session_id() {
local ts
ts=$(date +%Y%m%d-%H%M%S)
local suffix
suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 4)
echo "session-${ts}-${suffix}"
}
# ISO 8601 timestamp
now_iso() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
cmd_init() {
local session_id
session_id=$(generate_session_id)
local start_time
start_time=$(now_iso)
local last_activity_time
last_activity_time="$start_time"
# Ensure directory exists
mkdir -p "$(dirname "$STATE_FILE")"
# Write initial state
python3 -c "
import json
state = {
'session_id': '$session_id',
'focus_lane': '$FOCUS',
'phase': 'active',
'start_time': '$start_time',
'last_activity_time': '$last_activity_time',
'end_time': None,
'decision_budget': $BUDGET,
'decisions_made': 0,
'decisions': [],
'walkaway': None
}
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
"
# Generate SESSION-ROADMAP.md if path provided and chart.sh available
if [ -n "$SESSION_ROADMAP" ]; then
local repo="${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
local chart
chart=$(find "$repo" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)
if [ -n "$chart" ] && [ -n "$FOCUS" ]; then
bash "$chart" session --focus "$FOCUS" 2>/dev/null
# chart.sh writes to SESSION-ROADMAP.md in repo root; move if needed
local default_roadmap="$repo/SESSION-ROADMAP.md"
if [ "$SESSION_ROADMAP" != "$default_roadmap" ] && [ -f "$default_roadmap" ]; then
cp "$default_roadmap" "$SESSION_ROADMAP"
fi
fi
fi
echo "$session_id"
}
cmd_record_decision() {
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No active session. Run 'init' first." >&2
exit 1
fi
python3 -c "
import json
from datetime import datetime, timezone
with open('$STATE_FILE') as f:
state = json.load(f)
current_time = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
state['decisions_made'] = state.get('decisions_made', 0) + 1
state['decisions'].append({
'note': '''$NOTE''',
'timestamp': current_time
})
state['last_activity_time'] = current_time
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
"
}
cmd_close() {
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No active session. Run 'init' first." >&2
exit 1
fi
local end_time
end_time=$(now_iso)
python3 -c "
import json
with open('$STATE_FILE') as f:
state = json.load(f)
state['phase'] = 'closed'
state['end_time'] = '$end_time'
state['walkaway'] = '''$WALKAWAY'''
with open('$STATE_FILE', 'w') as f:
json.dump(state, f, indent=2)
"
# Append walk-away signal to SESSION-ROADMAP.md if provided
if [ -n "$SESSION_ROADMAP" ] && [ -f "$SESSION_ROADMAP" ]; then
cat >> "$SESSION_ROADMAP" <<EOF
## Walk-Away Signal
> $WALKAWAY
*Session closed: $end_time*
EOF
fi
}
cmd_resume() {
if [ ! -f "$STATE_FILE" ]; then
echo "No previous session found."
exit 0
fi
python3 -c "
import json
with open('$STATE_FILE') as f:
state = json.load(f)
focus = state.get('focus_lane', 'none')
walkaway = state.get('walkaway', 'none')
decisions = state.get('decisions_made', 0)
phase = state.get('phase', 'unknown')
start = state.get('start_time', 'unknown')
end = state.get('end_time', 'unknown')
session_id = state.get('session_id', 'unknown')
print(f'Previous session: {session_id}')
print(f'Focus: {focus}')
print(f'Phase: {phase}')
print(f'Started: {start}')
if end and end != 'None':
print(f'Ended: {end}')
print(f'Decisions made: {decisions}')
if walkaway and walkaway != 'None':
print(f'Walk-away: {walkaway}')
"
}
cmd_show() {
if [ ! -f "$STATE_FILE" ]; then
echo "No active session."
exit 0
fi
python3 -c "
import json
with open('$STATE_FILE') as f:
print(json.dumps(json.load(f), indent=2))
"
}
case "$COMMAND" in
init) cmd_init ;;
record-decision) cmd_record_decision ;;
close) cmd_close ;;
resume) cmd_resume ;;
show) cmd_show ;;
*) echo "Unknown command: $COMMAND" >&2; usage; exit 1 ;;
esac
#!/usr/bin/env bash
# swain-startup-timing.sh — SPIKE-001: Instrument session startup time
#
# Measures wall time for each phase of the session startup chain.
# Does NOT modify any existing scripts — wraps them with timing.
#
# Usage:
# swain-startup-timing.sh [--include-status] [--runs N] [--json]
#
# Output: timing breakdown by phase (human-readable or JSON)
set +e
# Portable path resolution — resolves through symlinks
_src="${BASH_SOURCE[0]}"
while [[ -L "$_src" ]]; do
_dir="$(cd "$(dirname "$_src")" && pwd)"
_src="$(readlink "$_src")"
[[ "$_src" != /* ]] && _src="$_dir/$_src"
done
SCRIPT_DIR="$(cd "$(dirname "$_src")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
INCLUDE_STATUS=0
RUNS=1
JSON_OUTPUT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--include-status) INCLUDE_STATUS=1; shift ;;
--runs) RUNS="$2"; shift 2 ;;
--json) JSON_OUTPUT=1; shift ;;
*) shift ;;
esac
done
# Portable millisecond timer (macOS date doesn't support %N)
if command -v gdate &>/dev/null; then
_ts() { gdate +%s%3N; }
elif date +%s%N &>/dev/null 2>&1; then
_ts() { echo $(( $(date +%s%N) / 1000000 )); }
else
# Fallback: second-level precision (macOS without coreutils)
_ts() { python3 -c "import time; print(int(time.time()*1000))"; }
fi
declare -a PHASE_NAMES
declare -a PHASE_DURATIONS
time_phase() {
local name="$1"
shift
local start=$(_ts)
"$@" >/dev/null 2>&1
local end=$(_ts)
local dur=$((end - start))
PHASE_NAMES+=("$name")
PHASE_DURATIONS+=("$dur")
}
run_single() {
PHASE_NAMES=()
PHASE_DURATIONS=()
local total_start=$(_ts)
# Phase 1: .swain/init.json marker check (what the shell launcher would do)
time_phase "init_marker_check" test -f "$REPO_ROOT/.swain/init.json"
# Phase 2: Preflight
time_phase "preflight" bash "$(dirname "$(dirname "$SCRIPT_DIR")")/swain-doctor/scripts/swain-preflight.sh"
# Phase 3: Bootstrap (tab naming + worktree detect + session.json)
time_phase "bootstrap_full" bash "$SCRIPT_DIR/swain-session-bootstrap.sh" --auto
# Phase 3a: Bootstrap sub-phases (individual measurement)
# Tab naming only
if [[ -n "${TMUX:-}" ]] && [[ -f "$SCRIPT_DIR/swain-tab-name.sh" ]]; then
time_phase "tab_naming" bash "$SCRIPT_DIR/swain-tab-name.sh" --auto
else
PHASE_NAMES+=("tab_naming")
PHASE_DURATIONS+=("0")
fi
# Worktree detection only
time_phase "worktree_detect" git rev-parse --git-common-dir
# Session.json read only
time_phase "session_json_read" jq -r '.focus_lane // empty' "$REPO_ROOT/.agents/session.json"
# Phase 4: Status dashboard (optional — this is the expensive one)
if [[ "$INCLUDE_STATUS" -eq 1 ]] && [[ -f "$SCRIPT_DIR/swain-status.sh" ]]; then
time_phase "status_dashboard" bash "$SCRIPT_DIR/swain-status.sh" --json --refresh
fi
local total_end=$(_ts)
local total=$((total_end - total_start))
PHASE_NAMES+=("total_measured")
PHASE_DURATIONS+=("$total")
}
# ─── Execution ───
ALL_RESULTS=()
for ((i=1; i<=RUNS; i++)); do
run_single
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
# Build JSON for this run
run_json="{"
for ((j=0; j<${#PHASE_NAMES[@]}; j++)); do
[[ $j -gt 0 ]] && run_json+=","
run_json+="\"${PHASE_NAMES[$j]}\":${PHASE_DURATIONS[$j]}"
done
run_json+="}"
ALL_RESULTS+=("$run_json")
else
echo "=== Run $i/$RUNS ==="
for ((j=0; j<${#PHASE_NAMES[@]}; j++)); do
printf " %-25s %6d ms\n" "${PHASE_NAMES[$j]}" "${PHASE_DURATIONS[$j]}"
done
echo ""
fi
done
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
echo -n '{"runs":['
for ((i=0; i<${#ALL_RESULTS[@]}; i++)); do
[[ $i -gt 0 ]] && echo -n ","
echo -n "${ALL_RESULTS[$i]}"
done
echo -n '],"note":"Times are script execution only. LLM inference and tool-call overhead are not measured here — they dominate total wall time but cannot be measured from within scripts."}'
fi
#!/usr/bin/env bash
set +e # Never fail hard — session naming is a convenience, not a gate
# swain-tab-name.sh — Set terminal tab/window/session title
#
# Usage:
# swain-tab-name.sh --auto # project @ branch (from settings)
# swain-tab-name.sh --path DIR --auto # resolve git context from DIR
# swain-tab-name.sh --reset # restore defaults, remove hooks
# swain-tab-name.sh "Custom Title" # set a custom title
#
# See SPEC-056 and DESIGN-001 for the full interaction model.
# Allow socket override for testing or targeting a specific tmux server
TMUX_ARGS=""
if [[ -n "${SWAIN_TMUX_SOCKET:-}" ]]; then
TMUX_ARGS="-S $SWAIN_TMUX_SOCKET"
# Ensure TMUX-presence checks pass
TMUX="${TMUX:-$SWAIN_TMUX_SOCKET,0,0}"
fi
SETTINGS_PROJECT="${SWAIN_SETTINGS:-$(git rev-parse --show-toplevel 2>/dev/null)/swain.settings.json}"
SETTINGS_USER="${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json"
# Read a setting with fallback: user settings override project settings
read_setting() {
local key="$1"
local default="$2"
local val=""
if [[ -f "$SETTINGS_USER" ]]; then
val=$(jq -r "$key // empty" "$SETTINGS_USER" 2>/dev/null)
fi
if [[ -z "$val" && -f "$SETTINGS_PROJECT" ]]; then
val=$(jq -r "$key // empty" "$SETTINGS_PROJECT" 2>/dev/null)
fi
echo "${val:-$default}"
}
set_title() {
local title="$1"
local session_name="${2:-}"
if [[ -n "$TMUX" ]]; then
# Resolve the calling pane's session, not the "current client's" session.
# Priority:
# 1. $TMUX_PANE — tmux sets this for every process spawned in a pane;
# authoritative for "which pane is invoking me" regardless of focus.
# 2. SWAIN_HOOK_SESSION — expanded by tmux at hook fire time (hook path).
# 3. display-message fallback — only safe for direct interactive use with
# a single attached client; resolves to the most-recently-focused
# client otherwise, which can target the wrong session (gh#116).
local target_session=""
if [[ -n "${TMUX_PANE:-}" ]]; then
target_session=$(tmux $TMUX_ARGS display-message -p -t "$TMUX_PANE" '#{session_name}' 2>/dev/null)
fi
if [[ -z "$target_session" ]]; then
target_session="${SWAIN_HOOK_SESSION:-}"
fi
if [[ -z "$target_session" ]]; then
target_session=$(tmux $TMUX_ARGS display-message -p '#{session_name}' 2>/dev/null)
fi
# Rename the tmux window tab — target the hook's session, not the "current" one
tmux $TMUX_ARGS set-window-option ${target_session:+-t "$target_session"} automatic-rename off 2>/dev/null || true
tmux $TMUX_ARGS rename-window ${target_session:+-t "$target_session"} "$title" 2>/dev/null || true
# Rename the tmux session
if [[ -n "$session_name" ]]; then
tmux $TMUX_ARGS rename-session ${target_session:+-t "$target_session"} "$session_name" 2>/dev/null || true
fi
# Disable global set-titles — it broadcasts the focused client's window name
# to ALL client terminals, causing inactive iTerm tabs to show the wrong name.
# See SPEC-138.
tmux $TMUX_ARGS set-option -g set-titles off 2>/dev/null || true
# Instead, send OSC title escapes directly to THIS session's client terminal.
local client_tty
if [[ -n "$target_session" ]]; then
client_tty=$(tmux $TMUX_ARGS list-clients -t "$target_session" -F '#{client_tty}' 2>/dev/null | head -1)
fi
if [[ -n "${client_tty:-}" && -w "$client_tty" ]]; then
printf '\033]1;%s\007' "$title" > "$client_tty" 2>/dev/null || true
printf '\033]0;%s\007' "$title" > "$client_tty" 2>/dev/null || true
fi
elif [[ -t 1 ]]; then
if [[ "$TERM_PROGRAM" == "iTerm.app" ]]; then
printf '\033]1;%s\007' "$title"
fi
printf '\033]0;%s\007' "$title"
fi
}
install_hook() {
# Install a per-window pane-focus-in hook so titles update on pane switch.
# Per-window (set-hook -w) avoids interfering with other tmux sessions.
# Idempotent — re-running replaces the previous hook.
#
# IMPORTANT: Pass hook context via env vars. tmux expands #{...} format strings
# at hook fire time, giving the script the correct session/pane context. Without
# this, the script's tmux commands resolve to the "current client" (whichever
# session last had input), not the session where the hook fired. See SPEC-138.
if [[ -z "$TMUX" ]]; then
return
fi
local self
self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
tmux $TMUX_ARGS set-hook -w pane-focus-in "run-shell 'SWAIN_HOOK_SESSION=#{q:session_name} SWAIN_HOOK_PANE_PATH=#{q:pane_current_path} SWAIN_HOOK_PANE_ID=#{q:pane_id} bash \"$self\" --auto'" 2>/dev/null || true
}
reset_title() {
# Restore default behavior: remove hook, clear @swain_path, re-enable auto-rename
if [[ -n "$TMUX" ]]; then
tmux $TMUX_ARGS set-window-option automatic-rename on 2>/dev/null || true
tmux $TMUX_ARGS set-option -g set-titles off 2>/dev/null || true
tmux $TMUX_ARGS set-hook -uw pane-focus-in 2>/dev/null || true
tmux $TMUX_ARGS set-option -pu @swain_path 2>/dev/null || true
tmux $TMUX_ARGS set-option -pu @swain_path_explicit 2>/dev/null || true
# Reset the outer terminal title via this session's client only.
# Resolve target session from $TMUX_PANE (authoritative for calling pane)
# before falling back to display-message. See gh#116.
local session_name_resolved client_tty
if [[ -n "${TMUX_PANE:-}" ]]; then
session_name_resolved=$(tmux $TMUX_ARGS display-message -p -t "$TMUX_PANE" '#{session_name}' 2>/dev/null)
fi
if [[ -z "${session_name_resolved:-}" ]]; then
session_name_resolved="${SWAIN_HOOK_SESSION:-}"
fi
if [[ -z "$session_name_resolved" ]]; then
session_name_resolved=$(tmux $TMUX_ARGS display-message -p '#{session_name}' 2>/dev/null)
fi
if [[ -n "$session_name_resolved" ]]; then
client_tty=$(tmux $TMUX_ARGS list-clients -t "$session_name_resolved" -F '#{client_tty}' 2>/dev/null | head -1)
fi
if [[ -n "${client_tty:-}" && -w "$client_tty" ]]; then
printf '\033]0;%s\007' "${SHELL##*/}" > "$client_tty" 2>/dev/null || true
fi
fi
printf '\033]0;%s\007' "${SHELL##*/}"
}
resolve_path() {
# Resolution priority:
# 1. --path arg (SWAIN_TAB_PATH) — explicit call-time override
# 2. @swain_path_explicit=1 on pane — agent explicitly set a worktree path
# 3. SWAIN_HOOK_PANE_PATH — provided by hook context (run-shell can't use pwd)
# 4. pwd — normal interactive use; wins over stale @swain_path
# 5. #{pane_current_path} — fallback when pwd is not in a git repo
local path="$SWAIN_TAB_PATH"
# Use @swain_path only when it was explicitly set via --path (agent/worktree use case)
# Target the correct pane via SWAIN_HOOK_PANE_ID when available.
if [[ -z "$path" && -n "$TMUX" ]]; then
local pane_target="${SWAIN_HOOK_PANE_ID:+-t $SWAIN_HOOK_PANE_ID}"
local explicit
explicit=$(tmux $TMUX_ARGS show-options ${pane_target} -pqv @swain_path_explicit 2>/dev/null)
if [[ "$explicit" == "1" ]]; then
path=$(tmux $TMUX_ARGS show-options ${pane_target} -pqv @swain_path 2>/dev/null)
fi
fi
# In hook context, use the pane path tmux expanded at fire time (pwd is wrong
# inside run-shell — it's the tmux server's cwd, not the pane's).
if [[ -z "$path" && -n "${SWAIN_HOOK_PANE_PATH:-}" ]]; then
path="$SWAIN_HOOK_PANE_PATH"
fi
# Use pwd (only reliable in direct invocations, not hooks)
if [[ -z "$path" ]]; then
path="$(pwd)"
fi
# Fallback to tmux pane path if pwd isn't in a git repo
if [[ -z "$(git -C "$path" rev-parse --git-common-dir 2>/dev/null)" && -n "$TMUX" ]]; then
path="${SWAIN_HOOK_PANE_PATH:-$(tmux $TMUX_ARGS display-message -p '#{pane_current_path}' 2>/dev/null)}"
path="${path:-$(pwd)}"
fi
echo "$path"
}
auto_title() {
local project branch fmt title pane_path
pane_path=$(resolve_path)
# Use --git-common-dir to resolve the main repo root (not the worktree root)
local common_dir repo_root
common_dir=$(git -C "$pane_path" rev-parse --git-common-dir 2>/dev/null) || true
if [[ -n "$common_dir" ]]; then
repo_root=$(cd "$pane_path" && cd "$common_dir/.." && pwd 2>/dev/null) || true
fi
project=$(basename "${repo_root:-unknown}")
branch=$(git -C "$pane_path" rev-parse --abbrev-ref HEAD 2>/dev/null) || true
branch="${branch:-no-branch}"
fmt=$(read_setting '.terminal.tabNameFormat' '{project} @ {branch}')
title="${fmt//\{project\}/$project}"
title="${title//\{branch\}/$branch}"
set_title "$title" "$title"
# Store the resolved path as @swain_path on this pane.
# Only mark it as explicit when --path was given in this invocation (agent/worktree case).
# Without --path, we intentionally do NOT set @swain_path_explicit so that future
# --auto calls will prefer pwd over the stored value (prevents stale override on cd).
if [[ -n "$TMUX" ]]; then
local pane_target="${SWAIN_HOOK_PANE_ID:+-t $SWAIN_HOOK_PANE_ID}"
tmux $TMUX_ARGS set-option ${pane_target} -p @swain_path "$pane_path" 2>/dev/null || true
if [[ -n "$SWAIN_TAB_PATH" ]]; then
tmux $TMUX_ARGS set-option ${pane_target} -p @swain_path_explicit 1 2>/dev/null || true
fi
fi
echo "$title"
}
# ─── Argument parsing ───
SWAIN_TAB_PATH=""
args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--path)
SWAIN_TAB_PATH="$2"
shift 2
;;
*)
args+=("$1")
shift
;;
esac
done
case "${args[0]:-}" in
--auto)
auto_title
install_hook
;;
--reset)
reset_title
echo "(reset)"
;;
--help|-h)
echo "Usage: swain-tab-name.sh [--path DIR] [TITLE | --auto | --reset]"
echo ""
echo " --path DIR Resolve git context from DIR (for agents in worktrees)"
echo " TITLE Set a custom tab/window title"
echo " --auto Generate title from git project + branch (uses settings)"
echo " --reset Restore default terminal title"
exit 0
;;
"")
auto_title
;;
*)
set_title "${args[0]}" "${args[0]}"
echo "${args[0]}"
;;
esac
#!/usr/bin/env bash
# Generates artifact-aware worktree names (SPEC-251, ADR-025)
#
# Naming rules by track:
# Implementable (SPEC, SPIKE): <id>-<title-slug>
# Container (EPIC, INITIATIVE): <purpose-slug>-<YYYYMMDD>-<id>-<title-slug>
# Standing (VISION, ADR, etc.): <id>-<title-slug>
# No artifact: session-<YYYYMMDD>-<HHMMSS>-<random>
#
# Usage: swain-worktree-name.sh [purpose-text]
set -uo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
PURPOSE="${1:-}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
SUFFIX="$(head -c 2 /dev/urandom | od -An -tx1 | tr -d ' \n')"
# --- Helpers ---
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-50
}
# Track classification (ADR-025)
artifact_track() {
local type="$1"
case "$(echo "$type" | tr '[:upper:]' '[:lower:]')" in
spec|spike) echo "implementable" ;;
epic|initiative) echo "container" ;;
vision|journey|persona|adr|runbook|design|train) echo "standing" ;;
*) echo "unknown" ;;
esac
}
# Find artifact file and extract title from frontmatter
artifact_title() {
local id="$1"
local type num
type="$(echo "$id" | sed 's/-[0-9]*//' | tr '[:upper:]' '[:lower:]')"
num="$(echo "$id" | grep -oE '[0-9]+')"
local padded
padded="$(printf '%03d' "$num" 2>/dev/null || echo "$num")"
# Search for the artifact file
local artifact_file
artifact_file="$(find "$REPO_ROOT/docs" -name "*${id^^}*" -name "*.md" 2>/dev/null | head -1)"
# Try case-insensitive if not found
if [ -z "$artifact_file" ]; then
artifact_file="$(find "$REPO_ROOT/docs" -iname "*$(echo "$id" | tr '[:lower:]' '[:upper:]')*" -name "*.md" 2>/dev/null | head -1)"
fi
if [ -z "$artifact_file" ]; then
return 1
fi
# Extract title from frontmatter
local title
title="$(grep -m1 '^title:' "$artifact_file" | sed 's/^title:[[:space:]]*//' | sed 's/^"//;s/"$//')"
if [ -n "$title" ]; then
echo "$title"
return 0
fi
return 1
}
# --- Extract artifact ID ---
# Regex: TYPE-NNN (case insensitive)
ARTIFACT_ID=""
if [ -n "$PURPOSE" ]; then
# Extract all artifact IDs
ALL_IDS="$(echo "$PURPOSE" | grep -ioE '(SPEC|EPIC|SPIKE|VISION|INITIATIVE|ADR|DESIGN|JOURNEY|PERSONA|RUNBOOK|TRAIN)-[0-9]+' || true)"
if [ -n "$ALL_IDS" ]; then
# Prefer container type if present (EPIC, INITIATIVE), else take first
CONTAINER_ID="$(echo "$ALL_IDS" | grep -iE '^(EPIC|INITIATIVE)-' | head -1)"
if [ -n "$CONTAINER_ID" ]; then
ARTIFACT_ID="$CONTAINER_ID"
else
ARTIFACT_ID="$(echo "$ALL_IDS" | head -1)"
fi
# Normalize to uppercase type, lowercase for slug
ARTIFACT_ID="$(echo "$ARTIFACT_ID" | tr '[:lower:]' '[:upper:]')"
fi
fi
# --- Generate name ---
if [ -z "$ARTIFACT_ID" ]; then
# Fallback: session-YYYYMMDD-HHMMSS-XXXX
if [ -n "$PURPOSE" ]; then
PURPOSE_SLUG="$(slugify "$PURPOSE")"
# Use purpose slug if meaningful, else session
if [ -n "$PURPOSE_SLUG" ] && [ "${#PURPOSE_SLUG}" -gt 3 ]; then
printf 'session-%s-%s\n' "$TIMESTAMP" "$SUFFIX"
else
printf 'session-%s-%s\n' "$TIMESTAMP" "$SUFFIX"
fi
else
printf 'session-%s-%s\n' "$TIMESTAMP" "$SUFFIX"
fi
exit 0
fi
# Extract type and number
ART_TYPE="$(echo "$ARTIFACT_ID" | sed 's/-[0-9]*//')"
ART_NUM="$(echo "$ARTIFACT_ID" | grep -oE '[0-9]+')"
ART_ID_LOWER="$(echo "$ARTIFACT_ID" | tr '[:upper:]' '[:lower:]')"
TRACK="$(artifact_track "$ART_TYPE")"
# Try to get title from frontmatter
TITLE=""
if TITLE_RAW="$(artifact_title "$ARTIFACT_ID")"; then
TITLE="$(slugify "$TITLE_RAW")"
fi
case "$TRACK" in
implementable)
# Pattern: <id>-<title-slug>
if [ -n "$TITLE" ]; then
printf '%s-%s\n' "$ART_ID_LOWER" "$TITLE"
else
printf '%s\n' "$ART_ID_LOWER"
fi
;;
container)
# Pattern: <purpose-slug>-<YYYYMMDD>-<id>-<title-slug>
# Extract purpose words (remove the artifact ID from purpose text)
PURPOSE_CLEAN="$(echo "$PURPOSE" | sed -E "s/$ARTIFACT_ID//i" | xargs)"
PURPOSE_SLUG="$(slugify "$PURPOSE_CLEAN")"
DATE_ONLY="$(date +%Y%m%d)"
if [ -n "$PURPOSE_SLUG" ] && [ "${#PURPOSE_SLUG}" -gt 2 ]; then
if [ -n "$TITLE" ]; then
printf '%s-%s-%s-%s\n' "$PURPOSE_SLUG" "$DATE_ONLY" "$ART_ID_LOWER" "$TITLE"
else
printf '%s-%s-%s\n' "$PURPOSE_SLUG" "$DATE_ONLY" "$ART_ID_LOWER"
fi
else
if [ -n "$TITLE" ]; then
printf '%s-%s-%s\n' "$DATE_ONLY" "$ART_ID_LOWER" "$TITLE"
else
printf '%s-%s\n' "$DATE_ONLY" "$ART_ID_LOWER"
fi
fi
;;
standing)
# Pattern: <id>-<title-slug>
if [ -n "$TITLE" ]; then
printf '%s-%s\n' "$ART_ID_LOWER" "$TITLE"
else
printf '%s\n' "$ART_ID_LOWER"
fi
;;
*)
# Unknown type — use ID + fallback
printf '%s-%s-%s\n' "$ART_ID_LOWER" "$TIMESTAMP" "$SUFFIX"
;;
esac
#!/usr/bin/env bash
# test-session-greeting.sh — SPEC-194: Test the fast-path session greeting
#
# Tests swain-session-greeting.sh output for completeness and performance.
#
# Usage: bash test-session-greeting.sh [--verbose]
set -euo pipefail
VERBOSE=0
[[ "${1:-}" == "--verbose" ]] && VERBOSE=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GREETING_SCRIPT="$SCRIPT_DIR/swain-session-greeting.sh"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
PASS=0
FAIL=0
TOTAL=0
assert_contains() {
local test_name="$1" expected="$2" actual="$3"
TOTAL=$((TOTAL + 1))
if echo "$actual" | grep -q "$expected"; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: $test_name"
else
FAIL=$((FAIL + 1))
echo " FAIL: $test_name (expected to contain: '$expected')"
[[ $VERBOSE -eq 1 ]] && echo " Got: $(echo "$actual" | head -5)"
fi
}
assert_not_contains() {
local test_name="$1" unexpected="$2" actual="$3"
TOTAL=$((TOTAL + 1))
if ! echo "$actual" | grep -q "$unexpected"; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: $test_name"
else
FAIL=$((FAIL + 1))
echo " FAIL: $test_name (should NOT contain: '$unexpected')"
fi
}
assert_eq() {
local test_name="$1" expected="$2" actual="$3"
TOTAL=$((TOTAL + 1))
if [[ "$expected" == "$actual" ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: $test_name"
else
FAIL=$((FAIL + 1))
echo " FAIL: $test_name (expected: '$expected', got: '$actual')"
fi
}
# ─── Setup ───
echo "=== SPEC-194: Session greeting tests ==="
# Check greeting script exists
TOTAL=$((TOTAL + 1))
if [[ -x "$GREETING_SCRIPT" ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: greeting script exists and is executable"
else
FAIL=$((FAIL + 1))
echo " FAIL: greeting script not found or not executable at $GREETING_SCRIPT"
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
exit 1
fi
# ─── Test 0b: .agents/bin/ symlinks for all swain-session scripts (SPEC-206) ───
echo "Test 0b: Symlinks in .agents/bin/ for swain-session scripts"
OPERATOR_SCRIPTS="swain swain-box"
for script in "$SCRIPT_DIR"/*; do
[[ -f "$script" && -x "$script" ]] || continue
sname="$(basename "$script")"
[[ "$sname" == test-* || "$sname" == test_* ]] && continue
echo " $OPERATOR_SCRIPTS " | grep -q " $sname " && continue
SYMLINK_PATH="$REPO_ROOT/.agents/bin/$sname"
TOTAL=$((TOTAL + 1))
if [[ -L "$SYMLINK_PATH" && -e "$SYMLINK_PATH" ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: .agents/bin/$sname symlink resolves"
else
FAIL=$((FAIL + 1))
if [[ -L "$SYMLINK_PATH" ]]; then
echo " FAIL: .agents/bin/$sname symlink is broken"
else
echo " FAIL: .agents/bin/$sname symlink missing"
fi
fi
done
# ─── Test 1: Greeting includes branch info ───
echo "Test 1: Branch info in output"
output=$(bash "$GREETING_SCRIPT" 2>/dev/null)
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
assert_contains "greeting contains branch name" "$branch" "$output"
# ─── Test 2: JSON output mode ───
echo "Test 2: JSON output mode"
json_output=$(bash "$GREETING_SCRIPT" --json 2>/dev/null)
assert_contains "json has branch key" '"branch"' "$json_output"
assert_contains "json has greeting key" '"greeting"' "$json_output"
# ─── Test 3: Bookmark shown if present ───
echo "Test 3: Bookmark in output (if session.json has one)"
if [[ -f "$REPO_ROOT/.agents/session.json" ]]; then
bookmark=$(jq -r '.bookmark.note // empty' "$REPO_ROOT/.agents/session.json" 2>/dev/null)
if [[ -n "$bookmark" ]]; then
assert_contains "greeting contains bookmark" "Bookmark" "$output"
else
TOTAL=$((TOTAL + 1))
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: no bookmark set (skip)"
fi
else
TOTAL=$((TOTAL + 1))
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: no session.json (skip)"
fi
# ─── Test 4: Focus lane shown if present ───
echo "Test 4: Focus lane in output (if set)"
if [[ -f "$REPO_ROOT/.agents/session.json" ]]; then
focus=$(jq -r '.focus_lane // empty' "$REPO_ROOT/.agents/session.json" 2>/dev/null)
if [[ -n "$focus" ]]; then
assert_contains "greeting contains focus lane" "$focus" "$output"
else
TOTAL=$((TOTAL + 1))
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: no focus lane set (skip)"
fi
else
TOTAL=$((TOTAL + 1))
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: no session.json (skip)"
fi
# ─── Test 5: Does NOT include specgraph or GitHub data ───
echo "Test 5: No specgraph/GitHub data in greeting"
assert_not_contains "no specgraph output" "specgraph" "$output"
assert_not_contains "no GitHub issues" "github.com" "$output"
# ─── Test 6: Performance — greeting completes in <2000ms ───
echo "Test 6: Performance (<2000ms)"
start_ms=$(python3 -c "import time; print(int(time.time()*1000))")
bash "$GREETING_SCRIPT" >/dev/null 2>&1
end_ms=$(python3 -c "import time; print(int(time.time()*1000))")
elapsed=$((end_ms - start_ms))
TOTAL=$((TOTAL + 1))
if [[ $elapsed -lt 2000 ]]; then
PASS=$((PASS + 1))
[[ $VERBOSE -eq 1 ]] && echo " PASS: performance (${elapsed}ms)"
else
FAIL=$((FAIL + 1))
echo " FAIL: performance (${elapsed}ms, expected <2000ms)"
fi
# ─── Test 7: Dirty/clean state shown ───
echo "Test 7: Working tree state in output"
json_output=$(bash "$GREETING_SCRIPT" --json 2>/dev/null)
assert_contains "json has dirty key" '"dirty"' "$json_output"
# ─── Summary ───
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
#!/usr/bin/env bash
# test-session-close-integration.sh — SPEC-205
# Verify that swain-session SKILL.md session close section invokes
# the digest and progress-log scripts.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_FILE="$SCRIPT_DIR/../SKILL.md"
PASS=0
FAIL=0
assert() {
local label="$1" exit_code="$2"
if [ "$exit_code" -eq 0 ]; then
echo " PASS: $label"
PASS=$((PASS + 1))
else
echo " FAIL: $label"
FAIL=$((FAIL + 1))
fi
}
# Extract the session close section (between "### Session close" and the next "### ")
CLOSE_SECTION=$(sed -n '/^### Session close$/,/^### /p' "$SKILL_FILE")
echo "=== T1: Session close section references digest script"
echo "$CLOSE_SECTION" | grep -q "swain-session-digest" && T1=0 || T1=1
assert "T1a: mentions swain-session-digest" "$T1"
echo "=== T2: Session close section references progress-log script"
echo "$CLOSE_SECTION" | grep -q "swain-progress-log" && T2=0 || T2=1
assert "T2a: mentions swain-progress-log" "$T2"
echo "=== T3: Digest runs before progress-log"
DIGEST_LINE=$(echo "$CLOSE_SECTION" | grep -n "swain-session-digest" | head -1 | cut -d: -f1 || true)
PROGRESS_LINE=$(echo "$CLOSE_SECTION" | grep -n "swain-progress-log" | head -1 | cut -d: -f1 || true)
if [ -n "$DIGEST_LINE" ] && [ -n "$PROGRESS_LINE" ] && [ "$DIGEST_LINE" -lt "$PROGRESS_LINE" ]; then
assert "T3a: digest appears before progress-log" "0"
else
assert "T3a: digest appears before progress-log" "1"
fi
echo "=== T4: Progress-log uses --digest flag"
echo "$CLOSE_SECTION" | grep -q "progress-log.*--digest\|progress-log.sh.*--digest" && T4=0 || T4=1
assert "T4a: progress-log invoked with --digest" "$T4"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
#!/usr/bin/env bash
# test-session-digest.sh — tests for swain-session-digest.sh (SPEC-199)
# Verifies session digest generation and JSONL output
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
SCRIPT="$REPO_ROOT/.agents/bin/swain-session-digest.sh"
PASS=0
FAIL=0
TOTAL=0
assert() {
local desc="$1"
local result="$2"
TOTAL=$((TOTAL + 1))
if [[ "$result" == "0" ]]; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
echo " FAIL: $desc"
fi
}
# --- Test 1: Script exists and is executable ---
echo "Test 1: swain-session-digest.sh exists and is executable"
assert "script exists" "$([ -f "$SCRIPT" ] && echo 0 || echo 1)"
assert "script is executable" "$([ -x "$SCRIPT" ] && echo 0 || echo 1)"
# --- Test 2: Missing required args exits with code 1 ---
echo "Test 2: missing required args exits with code 1"
result=$(bash "$SCRIPT" 2>/dev/null && echo 0 || echo $?)
assert "no args exits non-zero" "$([ "$result" != "0" ] && echo 0 || echo 1)"
result=$(bash "$SCRIPT" --session-id test-123 2>/dev/null && echo 0 || echo $?)
assert "missing --start-time exits non-zero" "$([ "$result" != "0" ] && echo 0 || echo 1)"
result=$(bash "$SCRIPT" --start-time 2026-01-01T00:00:00Z 2>/dev/null && echo 0 || echo $?)
assert "missing --session-id exits non-zero" "$([ "$result" != "0" ] && echo 0 || echo 1)"
# --- Test 3: Produces valid JSONL with required args ---
echo "Test 3: produces valid JSONL with --session-id and --start-time"
TMPDIR_TEST=$(mktemp -d)
trap 'rm -rf "$TMPDIR_TEST"' EXIT
# Use a recent timestamp to get some commits from the real repo
ONE_HOUR_AGO=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)
OUTPUT_FILE="$TMPDIR_TEST/session-log.jsonl"
if bash "$SCRIPT" \
--session-id "test-session-001" \
--start-time "$ONE_HOUR_AGO" \
--repo-root "$REPO_ROOT" \
--output "$OUTPUT_FILE" 2>/dev/null; then
assert "script exits 0 with valid args" "0"
else
assert "script exits 0 with valid args" "1"
fi
# Check output file exists and has content
assert "output file exists" "$([ -f "$OUTPUT_FILE" ] && echo 0 || echo 1)"
if [ -f "$OUTPUT_FILE" ]; then
LINE=$(head -1 "$OUTPUT_FILE")
# Validate it's valid JSON using python
echo "$LINE" | uv run python3 -c "import sys, json; json.loads(sys.stdin.read())" 2>/dev/null
assert "output is valid JSON" "$?"
else
assert "output is valid JSON" "1"
fi
# --- Test 4: JSONL contains required fields ---
echo "Test 4: JSONL contains required fields"
if [ -f "$OUTPUT_FILE" ]; then
LINE=$(head -1 "$OUTPUT_FILE")
for field in session_id timestamp artifacts_touched commits tasks_closed session_summary; do
echo "$LINE" | uv run python3 -c "import sys, json; d=json.loads(sys.stdin.read()); assert '$field' in d" 2>/dev/null
assert "contains field: $field" "$?"
done
# Verify session_id matches what we passed
sid=$(echo "$LINE" | uv run python3 -c "import sys, json; print(json.loads(sys.stdin.read())['session_id'])" 2>/dev/null)
assert "session_id matches input" "$([ "$sid" = "test-session-001" ] && echo 0 || echo 1)"
else
for field in session_id timestamp artifacts_touched commits tasks_closed session_summary; do
assert "contains field: $field" "1"
done
assert "session_id matches input" "1"
fi
# --- Test 5: With --focus, focus_lane is populated ---
echo "Test 5: --focus populates focus_lane"
OUTPUT_FILE2="$TMPDIR_TEST/session-log2.jsonl"
if bash "$SCRIPT" \
--session-id "test-session-002" \
--start-time "$ONE_HOUR_AGO" \
--focus "INITIATIVE-019" \
--repo-root "$REPO_ROOT" \
--output "$OUTPUT_FILE2" 2>/dev/null; then
LINE=$(head -1 "$OUTPUT_FILE2")
focus=$(echo "$LINE" | uv run python3 -c "import sys, json; print(json.loads(sys.stdin.read())['focus_lane'])" 2>/dev/null)
assert "focus_lane is INITIATIVE-019" "$([ "$focus" = "INITIATIVE-019" ] && echo 0 || echo 1)"
else
assert "focus_lane is INITIATIVE-019" "1"
fi
# --- Test 6: Without --focus, focus_lane is null ---
echo "Test 6: without --focus, focus_lane is null"
if [ -f "$OUTPUT_FILE" ]; then
LINE=$(head -1 "$OUTPUT_FILE")
focus=$(echo "$LINE" | uv run python3 -c "import sys, json; print(json.loads(sys.stdin.read()).get('focus_lane'))" 2>/dev/null)
assert "focus_lane is None" "$([ "$focus" = "None" ] && echo 0 || echo 1)"
else
assert "focus_lane is None" "1"
fi
# --- Test 7: Output is appended, not overwritten ---
echo "Test 7: output is appended, not overwritten"
OUTPUT_FILE3="$TMPDIR_TEST/session-log3.jsonl"
bash "$SCRIPT" \
--session-id "test-session-003a" \
--start-time "$ONE_HOUR_AGO" \
--repo-root "$REPO_ROOT" \
--output "$OUTPUT_FILE3" 2>/dev/null || true
bash "$SCRIPT" \
--session-id "test-session-003b" \
--start-time "$ONE_HOUR_AGO" \
--repo-root "$REPO_ROOT" \
--output "$OUTPUT_FILE3" 2>/dev/null || true
if [ -f "$OUTPUT_FILE3" ]; then
line_count=$(wc -l < "$OUTPUT_FILE3" | tr -d ' ')
assert "file has 2 lines after 2 runs" "$([ "$line_count" = "2" ] && echo 0 || echo 1)"
else
assert "file has 2 lines after 2 runs" "1"
fi
# --- Test 8: Handles empty sessions gracefully ---
echo "Test 8: handles empty sessions (far future start-time)"
OUTPUT_FILE4="$TMPDIR_TEST/session-log4.jsonl"
if bash "$SCRIPT" \
--session-id "test-session-004" \
--start-time "2099-01-01T00:00:00Z" \
--repo-root "$REPO_ROOT" \
--output "$OUTPUT_FILE4" 2>/dev/null; then
assert "exits 0 for empty session" "0"
LINE=$(head -1 "$OUTPUT_FILE4")
commits=$(echo "$LINE" | uv run python3 -c "import sys, json; print(json.loads(sys.stdin.read())['commits'])" 2>/dev/null)
assert "commits is 0 for empty session" "$([ "$commits" = "0" ] && echo 0 || echo 1)"
else
assert "exits 0 for empty session" "1"
assert "commits is 0 for empty session" "1"
fi
# --- Summary ---
echo ""
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi