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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,24 +420,26 @@ Everything thirdshift logs goes under `~/.thirdshift/logs/`, or the `logs.dir` s
```
~/.thirdshift/logs/<owner>/<repo>/
├── activity.log the Activity log
├── sessions/ Session logs
└── commands/
├── architect/ <stamp>.log
├── pickup/ <n>-<stamp>.log
└── issue/ <n>-<stamp>.log
├── architect/ <stamp>.log and Session logs
├── pickup/ <n>-<stamp>.log and Session logs
├── secure/ <stamp>.log and Session logs
└── issue/ <n>-<stamp>.log and Session logs
```

The folder already names the repository, so no file name repeats it. A **Session log** is one session's full transcript, as Claude Code's `stream-json` output:

```
~/.thirdshift/logs/<owner>/<repo>/sessions/<n>-<stamp>-implement.jsonl
~/.thirdshift/logs/<owner>/<repo>/sessions/<n>-<stamp>-repair-<i>.jsonl
~/.thirdshift/logs/<owner>/<repo>/sessions/architect-<stamp>-architecture-review.jsonl
~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>-implement.jsonl
~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>-repair-<i>.jsonl
~/.thirdshift/logs/<owner>/<repo>/commands/architect/architect-<stamp>-architecture-review.jsonl
```

Session logs share the owning command's folder: a Pickup run's dispatched Run logs in `commands/pickup/`, an Architect run's in `commands/architect/`, and a Security run's in `commands/secure/`. Tickets and Base fixes inherit that folder too.

A Resume is logged as its session's kind plus `-resume`, e.g. `implement-resume.jsonl`.

A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift <Issue URL>`, a Run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' Runs, a Run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the [Origin match](#what-a-run-does), and `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to <path>`. A Command log that can't be written, as when its folder can't be created or the disk is full, is one `warning:` line on stderr, and the command carries on with the same outcome, stdout and exit code.
A **Command log** is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: `thirdshift <Issue URL>`, a Run or a Spec run, in `issue/`, `thirdshift pickup` in `pickup/`, named for the issue it took, and `thirdshift architect` in `architect/`. A Spec run's covers its Tickets' Runs, a Run's covers its [Base fix](#base-fix), and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So `ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head` lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the [Origin match](#what-a-run-does), and `setup`, `email-test`, `update`, `version` and `help` never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it [passed over](#why-an-issue-was-passed-over) before it took one, are written first, and once it is created a progress line says `logging this command to <path>`. A Command log that can't be written produces one `warning:` line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session log, the Run fails before starting that session.

The stamp is the local time the command started, with its UTC offset, as in `20261003T120000-0400`, in the machine's time zone, or `TZ`'s if set, so it agrees with `date` and `ls -l`. A command's Command log and all of its Session logs, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of its most recent Session log, the place to start looking, then that of its Command log.

Expand All @@ -450,7 +452,7 @@ The **Activity log**, `activity.log`, is a short running record of what the fact
2026-10-03 10:00:01 Pickup run skipped: no Ready issue on acme/widgets
```

Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one `warning:` line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Logs written before this layout, under `sessions/` and `commands/` at the root of the logs, are not moved.
Each line is appended whole, so passes that run at once on one repository never garble it. One that can't be written is one `warning:` line on stderr, and the command carries on as for a Command log. thirdshift never rotates it: collapsed skips keep it small. Existing logs, including Session logs in `<owner>/<repo>/sessions/` and logs from before the per-repository layout, are not moved.

With `quiet_skips = true` in the `[activity]` section of the [User config](#user-config), a skipped Pass prints nothing on stdout or stderr, its dated first line included, and leaves only its Activity log line. A pass that does work, or fails, prints as ever, so a scheduler's log file catches only what went wrong. Without it, a skipped pass prints as it always has, for a pass you type by hand.

Expand Down Expand Up @@ -536,7 +538,7 @@ thirdshift: 03:12:40 CI red on test, which also fails on main at 362b9ca; fix ma
thirdshift: 03:12:40 Base check: test: https://github.com/acme/widgets/actions/runs/1/job/2
thirdshift: 03:12:40 Retry with: thirdshift https://github.com/acme/widgets/issues/7 base-fix
thirdshift: 03:12:40 Or set: base.fix = true in ~/.thirdshift/config.toml, to allow a Base fix for every Run on this machine
thirdshift: 03:12:40 session log: ~/.thirdshift/logs/acme/widgets/sessions/7-….jsonl
thirdshift: 03:12:40 session log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….jsonl
thirdshift: 03:12:40 command log: ~/.thirdshift/logs/acme/widgets/commands/issue/7-….log
```

Expand Down Expand Up @@ -572,7 +574,7 @@ thirdshift --parallel 1 https://github.com/acme/widgets/issues/20 # one at a tim
When nothing is left to run and any Ticket is not done, the Spec run is a **Failed spec run**: it leaves the Spec PR a draft, its checklist showing what's missing, prints its URL on stdout (if any Ticket has landed, so there is one), exits `1`, and lists on stderr each Ticket that landed, with its pull request, and each one not done, with why:

```
thirdshift: 03:12:40 #21 failed: claude exited 1 (session log: ~/.thirdshift/logs/acme/widgets/sessions/21-….jsonl)
thirdshift: 03:12:40 #21 failed: claude exited 1 (session log: ~/.thirdshift/logs/acme/widgets/commands/issue/21-….jsonl)
thirdshift: 03:12:40 #22 blocked by #21
thirdshift: 03:12:40 #23 landed with https://github.com/acme/widgets/pull/1
thirdshift: 03:12:40 #24 unready: labelled needs-info
Expand Down Expand Up @@ -770,7 +772,7 @@ A Security run checks its gates in order: another **Pass** on the repository run

The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when `launch.pull` is set. Its Session prompt runs `thirdshift-security-audit` in full audit mode with the `quick` profile, auditing the whole repository except vendored and third-party code. It names a `SECURITY.md` or conventional threat-model document when present, reads compatible earlier runs and ends `incomplete` instead of asking for input.

Each audit keeps a new output directory under `<logs.dir>/<owner>/<repo>/audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs are in `commands/secure/`; Session logs are named `secure-<stamp>-security-audit.jsonl`; the Activity log records the start and ending as for other Passes.
Each audit keeps a new output directory under `<logs.dir>/<owner>/<repo>/audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in `commands/secure/`; Session logs are named `secure-<stamp>-security-audit.jsonl`; the Activity log records the start and ending as for other Passes.

A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the `other` ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled `security-finding` and `needs-triage`. It is never replaced by a public finding issue.

Expand Down
1 change: 1 addition & 0 deletions src/asks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ mod tests {
/// fix, its sessions on Claude's `sonnet`.
fn given(kind: Kind, base_fix: BaseFixAsk) -> Given {
Given {
command: crate::logs::CommandKind::Issue,
security: crate::security::Options::default(),
kind,
stamp: "20261003T120000-0400".to_string(),
Expand Down
28 changes: 25 additions & 3 deletions src/child_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ pub struct Given {
/// The start stamp of the command that started it, which its Session
/// logs take.
pub stamp: String,
/// The owning command, whose folder its Session logs inherit.
pub command: logs::CommandKind,
/// What it is asked about a Base fix.
pub base_fix: BaseFixAsk,
pub security: Options,
Expand All @@ -91,6 +93,9 @@ const BASE_FIX_INTO: &str = "--base-fix-into";
/// The hidden argument followed by the command's start stamp.
const STAMP: &str = "--stamp";

/// The hidden argument followed by the owning command's log folder.
const LOGS_COMMAND: &str = "--logs-command";

/// The hidden argument that asks a child Run [`BaseFixAsk::Allow`].
const ALLOW_BASE_FIX: &str = "--allow-base-fix";
const SECURITY_REVIEW: &str = "--run-security-review";
Expand Down Expand Up @@ -123,6 +128,8 @@ impl Given {
self.kind.base(),
STAMP,
&self.stamp,
LOGS_COMMAND,
self.command.folder(),
];
match &self.base_fix {
BaseFixAsk::Allow => args.push(ALLOW_BASE_FIX),
Expand Down Expand Up @@ -153,6 +160,7 @@ impl Given {
pub struct Reader {
kind: Option<Kind>,
stamp: Option<String>,
command: Option<logs::CommandKind>,
base_fix: Option<BaseFixAsk>,
security: Options,
harness: Option<Harness>,
Expand Down Expand Up @@ -199,6 +207,13 @@ impl Reader {
not_yet_given(&self.stamp, arg)?;
self.stamp = Some(value("stamp")?);
}
LOGS_COMMAND => {
not_yet_given(&self.command, arg)?;
let name = value("logs command")?;
self.command = Some(logs::CommandKind::named(&name).with_context(|| {
format!("{arg} must name issue, pickup, architect or secure, not {name}")
})?);
}
ALLOW_BASE_FIX | OFFER_BASE_FIX => {
if let Some(given) = &self.base_fix
&& matches!(given, BaseFixAsk::Allow) != (arg == ALLOW_BASE_FIX)
Expand Down Expand Up @@ -239,7 +254,8 @@ impl Reader {
/// arguments a kind.
pub fn finish(self) -> Result<Option<Given>> {
let Some(kind) = self.kind else {
if self.stamp.is_some()
if self.command.is_some()
|| self.stamp.is_some()
|| self.base_fix.is_some()
|| self.security.review
|| self.security.fix
Expand All @@ -249,7 +265,7 @@ impl Reader {
{
bail!(
"only a child Run is given {STAMP}, {ALLOW_BASE_FIX}, {OFFER_BASE_FIX}, \
{SESSIONS_HARNESS}, {SESSIONS_MODEL} or {SESSIONS_EFFORT}"
{SESSIONS_HARNESS}, {SESSIONS_MODEL}, {SESSIONS_EFFORT} or {LOGS_COMMAND}"
);
}
return Ok(None);
Expand All @@ -261,6 +277,9 @@ impl Reader {
Ok(Some(Given {
kind,
stamp,
command: self
.command
.with_context(|| format!("missing {LOGS_COMMAND}"))?,
base_fix: self.base_fix.unwrap_or(BaseFixAsk::Forbid),
security: self.security,
harness: Choice {
Expand Down Expand Up @@ -330,6 +349,7 @@ pub fn start(
let given = Given {
kind,
stamp: logs::stamp(),
command: logs::command_kind(),
base_fix,
security,
harness: harness.clone(),
Expand Down Expand Up @@ -708,6 +728,7 @@ mod tests {
let given = Given {
kind: ticket(),
stamp: STAMPED.to_string(),
command: logs::CommandKind::Issue,
base_fix: BaseFixAsk::Forbid,
security: Options::default(),
harness: Choice::default(),
Expand Down Expand Up @@ -766,6 +787,7 @@ mod tests {
let given = Given {
kind: kind.clone(),
stamp: STAMPED.to_string(),
command: logs::CommandKind::Issue,
base_fix,
security: Options::default(),
harness: harness.clone(),
Expand All @@ -792,7 +814,7 @@ mod tests {
/// What a Run given a hidden argument only a child Run is given, without
/// a kind, is rejected with.
const ONLY_A_CHILD_RUN: &str = "only a child Run is given --stamp, --allow-base-fix, \
--offer-base-fix, --sessions-harness, --sessions-model or --sessions-effort";
--offer-base-fix, --sessions-harness, --sessions-model, --sessions-effort or --logs-command";

#[test]
fn hidden_arguments_that_are_repeated_have_no_value_or_lack_a_kind_or_stamp_are_rejected() {
Expand Down
1 change: 1 addition & 0 deletions src/child_run/execution_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ impl Fixture {
spec_branch: "issue-237".to_string(),
},
stamp: "20261003T120000-0400".to_string(),
command: logs::CommandKind::Issue,
base_fix: BaseFixAsk::Forbid,
security: crate::security::Options::default(),
harness: Choice::default(),
Expand Down
58 changes: 52 additions & 6 deletions src/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,44 @@ use activity::Kind;
use effects::OnMachine;
use recording::Record;

/// The command typed, which owns its Session logs and all child Runs' logs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandKind {
Issue,
Architect,
Pickup,
Secure,
}

impl CommandKind {
pub fn folder(self) -> &'static str {
match self {
Self::Issue => "issue",
Self::Architect => "architect",
Self::Pickup => "pickup",
Self::Secure => "secure",
}
}

pub fn named(name: &str) -> Option<Self> {
match name {
"issue" => Some(Self::Issue),
"architect" => Some(Self::Architect),
"pickup" => Some(Self::Pickup),
"secure" => Some(Self::Secure),
_ => None,
}
}
}

/// How a command begins.
pub enum Begin<'a> {
/// `thirdshift <Issue URL>`, a Run or a Spec run on this issue.
Run(&'a IssueUrl),
/// A child Run, a Ticket's Run or a Base fix, with the stamp the command
/// that started it gave it. It keeps no Command log and writes no
/// Activity log line: the command that started it does.
ChildRun(&'a str),
ChildRun(&'a str, CommandKind),
/// `thirdshift architect`, a pass.
ArchitectRun,
/// `thirdshift pickup`, a pass.
Expand Down Expand Up @@ -144,6 +174,20 @@ pub fn root(repo: &Repo) -> PathBuf {
record().root(repo)
}

/// The command whose folder holds this process's Session logs.
pub fn command_kind() -> CommandKind {
record().command_kind()
}

/// Where this command and all its child Runs keep Session logs on `repo`.
pub fn session_dir(repo: &Repo) -> PathBuf {
let record = record();
record
.root(repo)
.join("commands")
.join(record.command_kind().folder())
}

/// The command's start stamp, as in `20261003T120000-0400`, which names its
/// Command log and its Session logs.
pub fn stamp() -> String {
Expand Down Expand Up @@ -207,13 +251,15 @@ impl Work<'_> {
/// `thirdshift architect` in `architect/`.
fn command_log(self, root: &Path, stamp: &str) -> PathBuf {
let (folder, prefix) = match self {
Work::Run(issue) | Work::SpecRun(issue) => ("issue", format!("{}-", issue.number)),
Work::PickupRun(issue) => ("pickup", format!("{}-", issue.number)),
Work::ArchitectRun(_) => ("architect", String::new()),
Work::SecurityRun(_) => ("secure", String::new()),
Work::Run(issue) | Work::SpecRun(issue) => {
(CommandKind::Issue, format!("{}-", issue.number))
}
Work::PickupRun(issue) => (CommandKind::Pickup, format!("{}-", issue.number)),
Work::ArchitectRun(_) => (CommandKind::Architect, String::new()),
Work::SecurityRun(_) => (CommandKind::Secure, String::new()),
};
root.join("commands")
.join(folder)
.join(folder.folder())
.join(format!("{prefix}{stamp}.log"))
}
}
Expand Down
Loading
Loading