# npm-cli-github-issue-tower-defence-management

[![Test](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/actions/workflows/test.yml/badge.svg)](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/actions/workflows/test.yml)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
[![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)

Welcome to npm-cli-github-issue-tower-defence-management :tada:

## Usage 🛠️

Here's how you can use github-issue-tower-defence-management:

```
Usage: github-issue-tower-defence-management [command] [options]

CLI tool for GitHub Issue Tower Defence Management

Commands:
  schedule [options]                    Handle scheduled events (trigger: issue or schedule) (default)
  startDaemon [options]                 Start daemon to prepare GitHub issues
  notifyFinishedIssuePreparation [options]  Notify that issue preparation is finished
  selectLlmLaunchFlags [options]        Print the --effort and --autocompact launch flags for a worker session, derived from the defaultLlmEffortLevel and defaultLlmAutocompactMode fields of the config file at --configFilePath. Writes "--effort <value>" using defaultLlmEffortLevel, falling back to "xhigh" when defaultLlmEffortLevel is unset or empty. Writes "--autocompact <value>" after it on the same stdout line only when defaultLlmAutocompactMode is set to a non-empty value. The launcher decides no value itself and only forwards these flags to claude-agent, the same split as the selectLiveSessionOauthToken command.
  checkIssueReviewReadiness [options]   Check whether an issue is review-ready (read-only; does not change Status or post any comment)
  serveWeb [options]                    Start the local TDPM web server (console tabs, dashboard, and in-tmux session list)
  serveConsole [options]                Deprecated alias for serveWeb. Use serveWeb instead.
  selectOauthToken [options]            Print one rate-limit-aware Claude Code OAuth token to stdout (pipeable; read-only)
  selectLiveSessionOauthToken [options] Print one Claude Code OAuth token chosen for a new live interactive session (soonest 7d reset among tokens under their concurrent session limit; read-only)
  countInTmuxByHumanSessionsPerToken [options]  Print, per OAuth token, the count of live "In Tmux by human" interactive sessions using that token (read-only)
  killTmuxSession [options]             Cleanly kill a tmux session by running tmux kill-session and stopping its cl-*.scope systemd --user unit
  attachOrCreate [options]              Attach to an existing registered tmux session for the issue URL, or create a new one
  ownerCallFileAppend [options]         Append one owner call to the per-session owner call file (writes nothing to stdout)
  ownerCallFileDelete [options]         Delete the per-session owner call file (writes nothing to stdout; an already absent file is not an error)
  archive-unresumable-session [options]  Move aside the conversation record of a session that ended because its prompt exceeded the model context limit, so the next launch starts a fresh conversation
  select-resumable-session [options]    Print the claude session id a dispatched worker resumes, or nothing when it starts a new claude session, copying the transcript file from another worktree when needed
  check-issue-silent-dispatch-allowed [options]  Check whether an issue's body allows a dispatch cycle to end with no agent comment (read-only)
  workerRequestTextRead [options]       Print the request text a worker session receives for an issue, "Take ownership of <issue url>" (read-only)
  help [command]                        display help for command

Options for schedule:
  -t, --trigger <type>          Trigger type: issue or schedule
  -c, --config <path>           Path to config YAML file
  -v, --verbose                 Verbose output
  -i, --issue <url>             GitHub Issue URL
  --inTmuxProjectOrder <names>  Comma-separated project names, in display order, for the in-tmux-by-human session list. When omitted, falls back to the inTmuxProjectOrder value in the config file.

Options for startDaemon:
  --configFilePath <path>                          Path to config file for tower defence management (required)
  --projectUrl <url>                               GitHub project URL
  --manager <login>                                GitHub login of the manager; only Awaiting Workspace issues assigned to this login are picked up (required)
  --defaultAgentName <name>                        Default agent name
  --defaultLlmModelName <name>                     Default LLM model name
  --defaultLlmAgentName <name>                     Default LLM agent name
  --maximumPreparingIssuesCount <count>            Maximum number of issues in preparation status (default: 6 per available Claude OAuth token, otherwise 6)
  --utilizationPercentageThreshold <percent>       5-hour utilization hard threshold; tokens at or above this percentage are excluded from rotation. Per-token concurrency also tapers from 6 slots down to 1 as either the 5h or 7d utilization rises from 80% toward 100%, taking the more restrictive of the two (default: 90)
  --allowedIssueAuthors <authors>                  Comma-separated list of allowed issue authors
  --preparationProcessCheckCommand <template>      Shell command template with {URL} placeholder to check if a preparation process is alive
  --fleetConfigFilePath <path>                     Path to the fleet-wide YAML config file holding the preparationWorker mapping (normalConcurrentLimit); falls back to the TDPM_FLEET_CONFIG environment variable, and to the built-in values when neither is set

Options for notifyFinishedIssuePreparation:
  --configFilePath <path>                          Path to config file for tower defence management (required)
  --issueUrl <url>                                 GitHub issue URL (required)
  --projectUrl <url>                               GitHub project URL
  --thresholdForAutoReject <count>                 Threshold for auto-escalation after consecutive rejections (default: 3)
  --thresholdForDispatchLoop <count>               Threshold for auto-escalation after one agent is dispatched this many times since the last human comment, whether it names itself or two agents name each other in turn (default: 6)
  --workflowBlockerResolvedWebhookUrl <url>        Webhook URL to notify when a workflow blocker issue status changes
  --missingAgentName <name>                        Agent definition name that was not found; triggers task issue creation (assigned to the manager from config) and blocks the item until that issue is closed
  --sessionErrorLine <line>                        Exact error line from the session log to include in the task issue body
  --deferPreparation                               Defer the item via the Reactivation Trigger fields (sets nextActionDate to tomorrow) and return it to Awaiting Workspace without creating any issue, recording the value of --sessionErrorLine as the stop reason to the console log; use for transient upstream failures that should retry the next day
  --moveToFailedPreparation                        Move the item to Failed Preparation status after reaching the consecutive failure threshold; takes precedence over --deferPreparation when both are supplied; records --sessionErrorLine in the comment
  --fleetConfigFilePath <path>                     Path to the fleet-wide YAML config file holding the workflowIssueReporter mapping; falls back to the TDPM_FLEET_CONFIG environment variable. When set and the file contains a workflowIssueReporter section, a silent-redispatch escalation automatically creates (or comments on) an issue in the configured repository instead of only logging locally.
  --dispatchStartedAt <timestamp>                  ISO-8601 UTC timestamp (for example 2026-01-31T09:00:00Z) at which the item entered Preparation for the session that just ended. An agent report posted before this time is not counted as that session's report, so a session that posts nothing is recorded as NO_REPORT_FROM_AGENT_BOT and counts toward the consecutive-no-report threshold (thresholdForAutoReject) that moves the item to Failed Preparation. When omitted, the latest agent report on the issue is used whatever its age.
  --sessionLogFilePath <path>                      Path to the log of the worker session that just ended. The command reads it once, before the notification, and sets --rateLimitRejected for a usage-limit ending, or for a rejected rate-limit event when the session ended neither with terminal_reason blocking_limit nor on an HTTP 429 session-limit error, --promptTooLongOnResume for a resumed session that ended with terminal_reason blocking_limit before any tool call, and --moveToFailedPreparation with its own --sessionErrorLine on the third consecutive ending with the same non-completed terminal_reason. The consecutive count is kept per issue under the TDPM cache directory (worker-session-failure-streaks) and is reset by a completed ending or a log without a terminal_reason. The result is combined with the explicit flags; usage-limit and rate-limit classification is skipped when a non-empty --missingAgentName is given, which also keeps the count from moving the item to Failed Preparation.
  --sessionWasResumed                              The worker session that just ended resumed a previous conversation; used with --sessionLogFilePath to decide --promptTooLongOnResume
  The notification is attempted up to 3 times, waiting 30 seconds and then 60 seconds after a failed attempt; the session log is classified only once. A GitHub API rate limit ends the command without an error and without a retry, and the third failure ends the command with that error.

Options for selectLlmLaunchFlags:
  --configFilePath <path>                          Path to config file for tower defence management (required)

Options for checkIssueReviewReadiness:
  --configFilePath <path>                          Path to config file for tower defence management (required)
  --issueUrl <url>                                 GitHub issue URL (required)
  --projectUrl <url>                               GitHub project URL

Options for serveWeb (and its deprecated alias serveConsole):
  --configFilePath <path>                          Path to config file for tower defence management (required)
  --port <number>                                  Port for the web HTTP server (default: 9980)
  --consoleDataOutputDir <path>                    Directory where console data files are written and served from
  --inTmuxDataDir <path>                           Directory containing the flat in-tmux-by-human static JSON files served at /in-tmux-by-human/*.json
  --dashboardDir <path>                            Directory containing the static dashboard HTML fragment tdpm.txt served at /tdpm.txt when compose mode is not active (default: the jsonpub directory)
  --dashboardDataDir <path>                        Directory containing the dashboard data files (projects/<projectName>.json, machine-status.json, token-status.json); when set and at least one listed project has its own data file present the server composes the /tdpm.txt fragment live from whatever data is available, showing -- for any project without a file, otherwise it falls back to serving the static tdpm.txt from --dashboardDir (unset when not configured)
  --dashboardProjectNames <names>                  Comma-separated project names, in display order, for the dashboard project grid; the display label of each project is its first 2 characters, which must be unique across the listed names
  --fleetConfigFilePath <path>                     Path to the fleet-wide YAML config file; falls back to the TDPM_FLEET_CONFIG environment variable. When the file contains a top-level `workflowImprovementIssueUrl` string, the console tab bar displays a link that opens that URL in a new browser tab. When the file contains a `workflowIssueReporter` section with `owner` and `repo` fields, the console tab bar displays a + link for creating fleet-level tasks; the link opens `https://github.com/{owner}/{repo}/issues/new?assignees={owner}` in a new browser tab, pre-assigning the owner, and, when `projectUrl` starts with a GitHub org (`https://github.com/orgs/{org}/projects/{n}`) or user (`https://github.com/users/{user}/projects/{n}`) project URL (optionally followed by a `/views/N` path suffix or query parameters such as `?fullscreen=true`), also appends `?projects={owner}/{n}` so the new issue is automatically added to that project.

Options for selectOauthToken:
  --tokenListJsonPath <path>                       Path to the JSON array of { name, token, selectionWeight? } records; selectionWeight is an optional positive number (default 1) that biases how often a token is chosen among eligible candidates (falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable)
  --cacheDir <path>                                Directory holding per-token rate-limit cache files (falls back to the TDPM_RATELIMIT_CACHE_DIR environment variable, then to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit)

Options for selectLiveSessionOauthToken:
  --tokenListJsonPath <path>                       Path to the JSON array of { name, token, selectionWeight? } records; selectionWeight is an optional positive number (default 1) that scales this token concurrent live session limit; a smaller weight allows fewer simultaneous sessions and never bypasses eligibility filtering or starves a sole eligible token. Falls back to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable.
  --cacheDir <path>                                Directory holding per-token rate-limit cache files (falls back to the TDPM_RATELIMIT_CACHE_DIR environment variable, then to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit)
  --fleetConfigFilePath <path>                     Path to the fleet-wide YAML config file holding the liveSessionOauthTokenSelection mapping (maxConcurrentSessionCount, fullSpeedFiveHourFreeRatio, minFiveHourFreeRatio, minSevenDayFreeRatio, fiveHourShareConsumedPerSessionHour); falls back to the TDPM_FLEET_CONFIG environment variable, and to the built-in values when neither is set

Options for countInTmuxByHumanSessionsPerToken:
  --configFilePath <path>                          Path to config file for tower defence management (required)
  --projectUrl <url>                               GitHub project URL
  --tokenListJsonPath <path>                       Path to the JSON array of { name, token } records (falls back to the claudeCodeOauthTokenListJsonPath config value, then to the CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH environment variable)

Options for killTmuxSession:
  --session <name>                                 Name of the tmux session to kill
  --self                                            Terminate the current session by stopping its own cl-*.scope systemd user unit, derived from /proc/self/cgroup

Options for attachOrCreate:
  --issueUrl <url>                                 GitHub issue URL to attach to or create a session for (required)

Options for ownerCallFileAppend:
  --session <name>                                 tmux session name that raised the call (required)
  --calledAt <timestamp>                           Time the call was raised, as UTC ISO-8601 with second precision and a trailing Z (required)
  --body-file <path>                               Path to the file holding the call body; the body is read from a file because it is multi-line and can be long (required)
  --inTmuxDataDir <path>                           Directory the owner call files are written under, the same directory serveWeb serves them from

Options for ownerCallFileDelete:
  --session <name>                                 tmux session name that raised the call (required)
  --inTmuxDataDir <path>                           Directory the owner call files are written under, the same directory serveWeb serves them from

Options for archive-unresumable-session:
  --log-file <path>                                Path to the finished run stream-json log (required)
  --session-dir <path>                             Directory holding conversation records named <session-id>.jsonl (required)
  --archive-dir <path>                             Destination directory the conversation record is moved into; created when it does not exist (required)

Options for select-resumable-session:
  --session-name <name>                            Name the worker claude session is started with (claude --name); a transcript file is a candidate only when its first line contains "customTitle":"<name>" (required)
  --session-dir <path>                             Session directory holding the transcript files (<session id>.jsonl) of the current working directory of the worker; claude --resume <session id> finds transcript files only in this directory (required)
  --archive-root <path>                            Directory archive-unresumable-session moves transcript files into; a transcript file is archived when <archive-root>/<session id>.jsonl or <archive-root>/<directory>/<session id>.jsonl exists (required)
  --other-session-dir <path>                       Session directory of another worktree of the same repository, searched when --session-dir yields no resumable transcript file; repeatable, and of transcript files with the same modification time the one in the directory given first wins

Options for check-issue-silent-dispatch-allowed:
  --issue-url <url>                                GitHub issue URL (required)

Options for workerRequestTextRead:
  --issueUrl <url>                                 GitHub issue URL the worker session takes ownership of (required)
```

The `serveWeb` sub-command starts a local HTTP server that serves the TDPM web surface — the console tabs, the dashboard, and the in-tmux-by-human session list. `serveConsole` remains available as a deprecated alias that maps to the same handler, so existing invokers keep working during rollout; new usage should prefer `serveWeb`. One running instance serves every project: the user opens a per-project URL path `/projects/{pjcode}` (or `/projects/{pjcode}/{workflow-blocker|prs|triage|unread|failed-preparation|todo-by-human}`) and the bundled UI reads the `pjcode` from its own URL path and loads that project's list data. The server serves the bundled single-page-application `index.html` at `/`, `/index.html`, and every `/projects/{pjcode}` and `/projects/{pjcode}/{tab}` app route. Every response is sent with `Cache-Control: no-store`. Any request path containing a segment that begins with a dot (for example `/.git` or `/.env`) is rejected with HTTP 404. The UI bootstrap assets (HTML and JS) are served without authentication; served `*.json` files and `/api/*` paths require an access token supplied either as the `k` query parameter (`?k=<token>`) or the `X-PV-Token` request header. The access token is read from the `consoleAccessToken` config value and never appears on the command line. When the built UI bundle directory (`ui-dist`) is absent the server still starts and serves a minimal placeholder index for `/`, `/index.html`, and the per-project app routes.

The dashboard fragment is served unauthenticated at `GET /tdpm.txt` as `text/html`. Compose mode is opt-in: when `--dashboardDataDir` is set and at least one project named in `--dashboardProjectNames` has its own `projects/<projectName>.json` readable, the server composes the fragment live at request time from whatever of `machine-status.json`, `token-status.json`, and the per-project `projects/<projectName>.json` files are actually present — a project with no readable data file renders `--` in its row independently of the others, and a missing `machine-status.json` or `token-status.json` no longer blocks composition. Each present `projects/<projectName>.json` is keyed by the full project name (the same key the scheduled run writes; that project's actionable status counts), `machine-status.json` holds mem%, cpu%, disk%, loadavg, cycle-minutes, and `token-status.json` holds per-token rate-limit utilization, reset countdown, status color, and prep/hum counts. It renders the fixed-width fragment — the host-metrics line (`{memDot}M{mem}% {cpuDot}C{cpu}% {diskDot}D{disk}% cy{cycle} {loadDot}LA {load1} {load5} {load15}` for the no-disks case; each dot is 🔴 at or above the danger threshold — ≥90% for mem/cpu/disk, ≥10 for load1m — or 🟡 at or above the warning threshold — ≥80% for mem/cpu/disk, ≥5 for load1m — and absent when the metric is normal; the per-disk case still emits multiple lines), the project grid (a totals row summing each column across projects with data, then the header, then one row per name in `--dashboardProjectNames` order, labelled with the project's two-character display code derived from its name and prefixed with its severity dot), a blank separator line, then one row per token sorted by soonest seven-day reset — wrapped in `<tt>…</tt><br>` with spaces encoded as `&nbsp;`. Otherwise — when `--dashboardDataDir` is unset, or it is set but none of the listed projects has any data file present — the server falls back to serving the static `tdpm.txt` byte-for-byte from `--dashboardDir`, exactly as before compose mode existed. The route returns HTTP 404 only when neither source is available (compose mode inactive and no static `tdpm.txt` present). Only `GET` is accepted on this path.

Behind the token gate the server exposes three groups of routes:

- Data delivery (GET): `GET /projects/{pjcode}/{workflow-blocker|prs|triage|unread|failed-preparation|todo-by-human}/list.json` and the matching `detail/<key>.json` files are read from `{consoleDataOutputDir}/{pjcode}/{tab}/`, and `GET /projects/{pjcode}/in-tmux-by-human/*` is read from `{consoleDataOutputDir}/{pjcode}/in-tmux-by-human/`. Each served list has the `.done` cross-tab exclusion applied. The schedule cycle writes the full tab files; `notifyFinishedIssuePreparation` additionally patches the affected tab files immediately after each status transition.
- Flat in-tmux-by-human static files (GET): `GET /in-tmux-by-human/<file>.json` serves the flat static JSON files (for example `index.v4.json` and `{project}.v4.json`, plus the v3 files for backward compatibility) byte-for-byte from `--inTmuxDataDir`. Only flat file names directly under that directory are served; any other nested path and any path traversal attempt are rejected with HTTP 404. This route is distinct from the per-project `/projects/{pjcode}/in-tmux-by-human/*` data-delivery route above. These files are still gated by the same access token, so requests carry `?k=<token>`. The token-keyed file generation is performed by an external watcher, not by this server.
- Per-session owner call files (GET and DELETE): `GET /in-tmux-by-human/call-to-user/{pjcode or NA}/{session}.yaml` serves the owner call file of exactly one session byte-for-byte from `--inTmuxDataDir`, as `text/yaml`, and `DELETE` on the same path removes it and answers HTTP 204 whether or not the file was still there, so a client that deletes twice is not in error. Only this one-directory-deep shape is accepted; `DELETE` is refused with HTTP 404 on every other path under the prefix, including the flat JSON files. Both methods sit behind the same access token as every other served file. There is no route that returns several sessions at once: a client derives one path from one session name and fetches that.
- Read APIs (GET, backed by the server-side `GH_TOKEN`): `GET /api/itembody`, `GET /api/comments`, `GET /api/prfiles`, `GET /api/prcommits`, `GET /api/relatedprs`, and `GET /api/issuetitle`. Each takes a `url` query parameter. `GET /api/issuetitle` returns `{ state, merged, isPullRequest, title }`, where `title` comes from the pull request summary for pull request URLs and from the issue for issue URLs; it is served through an in-process cache: a merged result is cached permanently and every other result is re-fetched after 300 seconds. The console UI uses this endpoint both for the opened item header and to decorate full PR/issue URL links inside rendered markdown with their open/closed/merged state icon and title.
- Operation APIs (POST, JSON body): `POST /api/review` (`approve`, `request_changes`, `close`), `POST /api/triage` (`set_status`, `set_story`, `close`, `close_not_planned`, `snooze_1hour`, `snooze_3hours`, `snooze_6hours`, `snooze_1day`, `snooze_2days`, `snooze_3days`, `snooze_5days`, `snooze_1week`, `snooze_1month`), and `POST /api/intmux` (`set_intmux`). These routes are multi-project: every request body carries the `pjcode` of the project the UI is currently viewing (taken from the UI's own `/projects/{pjcode}` URL path), the server resolves that `pjcode` to its GitHub Project URL through the `pjcode → projectUrl` mapping described below, loads that project's status and story options (lazily, cached per `pjcode`), and applies the operation against the resolved project. A request with no `pjcode`, or a `pjcode` that has no configured project URL, is rejected with HTTP 400. Each confirmed operation records the affected `projectItemId` into the `.done` exclusion under the resolved `pjcode` so it disappears from every tab's served list for that project only. One running `serveWeb` instance therefore serves both reads and writes for every configured project.
- Comment APIs (POST, JSON body, backed by the server-side `GH_TOKEN`): `POST /api/comment` posts a top-level comment on the issue or pull request named by the request body's `url`. `POST /api/reviewcomment` posts a line-anchored inline review comment on a pull request diff; its request body carries `url` (the pull request URL), `path` (the changed file path), `line` (a positive integer diff line number), `side` (`RIGHT` for added or context lines on the new side, `LEFT` for removed lines on the old side), and `body`. The server resolves owner, repo, and pull request number from `url`, fetches the pull request head commit sha, and calls GitHub `POST /repos/{owner}/{repo}/pulls/{number}/comments` with `commit_id` set to that sha. When GitHub rejects the request, the response is HTTP 502 with the GitHub error message in the `error` field of the body so the UI can show the real reason.

The `.done` exclusion is persisted per tab in a `.done.json` file alongside each tab's `list.json` under `consoleDataOutputDir`. The file is never directly servable because the dot-segment block rejects any path containing it. It is an optimistic-hide store scoped to a single regeneration cycle: each confirmed operation records the affected `projectItemId` so the item disappears immediately, and the schedule cycle resets every tab's `.done.json` back to `{ "projectItemIds": [] }` right after it regenerates that project's `list.json` files. This bounds the store to at most one cycle of recorded ids, so it can never accumulate and over-hide items that legitimately re-enter a visible status.

Multi-project operation routing is configured through an optional `consoleProjects` mapping in the config file. It maps each `pjcode` to that project's GitHub Project URL so the operation APIs can resolve the correct project per request. The configured `projectName` is always added to this mapping automatically (pointing at the configured `projectUrl`), so a single-project deployment needs no `consoleProjects` entry at all. When the same `serveWeb` instance serves write operations for several projects, list each additional project explicitly:

```yaml
consoleAccessToken: '<console access token>'
projectUrl: 'https://github.com/orgs/my-org/projects/1'
projectName: 'my-project'
consoleProjects:
  my-project: 'https://github.com/orgs/my-org/projects/1'
  other-project: 'https://github.com/orgs/other-org/projects/2'
```

Each project's status and story options are loaded lazily the first time a `pjcode` is used and then cached for the life of the process, so the additional projects add no startup cost.

When the console serves multiple projects whose owners require distinct GitHub tokens, set `consoleGithubTokens` to an inline map of pjcode to token. When a request arrives for a repository owner, `serveWeb` finds the `pjcode` whose project URL owner matches and uses the corresponding token from the map. When `consoleGithubTokens` is absent or `null`, or when no pjcode maps to the owner, the fleet-wide `GH_TOKEN` is used as the fallback for that owner. When a pjcode is found in `consoleProjects` but is absent or blank in `consoleGithubTokens`, an error is thrown rather than falling back silently:

```yaml
consoleAccessToken: '<console access token>'
projectUrl: 'https://github.com/orgs/my-org/projects/1'
projectName: 'my-project'
consoleProjects:
  my-project: 'https://github.com/orgs/my-org/projects/1'
  other-project: 'https://github.com/orgs/other-org/projects/2'
consoleGithubTokens:
  my-project: '<fine-grained-token-for-my-org>'
  other-project: '<fine-grained-token-for-other-org>'
```

In the example above, `my-project`'s token is used for `my-org` and `other-project`'s token is used for `other-org`. The fleet-wide `GH_TOKEN` is still required for the `startDaemon` preparation cycle; only the console HTTP server routes go through the per-project token map.

The story-gate sub-commands `checkStoryGate`, `setIssueStoryIfUnset`, and `startSpecificationTask` accept an optional `--configFilePath <path>` option that names the same config file. Each GitHub request uses the token that the owner of its issue URL selects through `consoleProjects` and `consoleGithubTokens`. That token comes from the `pjcode` whose project URL owner matches the owner, then that `pjcode`'s `consoleGithubTokens` entry. The `GH_TOKEN` value is used when `--configFilePath` is omitted or when no `pjcode` maps to the owner. `GH_TOKEN` is always required, because it is the fallback. When a matched `pjcode` has an absent or blank token, the command exits with code 2. A missing or malformed file exits with code 1. When GitHub rate limits a request, the command exits with code 3. Its last stderr line is the JSON object `{"rateLimited":true,"resetAt":"<ISO 8601 UTC>"}`, preceded by the usual failure message on the first stderr line. The rate limit covers the primary rate limit, a secondary rate limit sent as HTTP 403 or HTTP 429, and a GraphQL `RATE_LIMITED` error. `resetAt` is the `x-ratelimit-reset` header read as epoch seconds, else the `retry-after` header added to the time the command received the response, and is `null` when neither header is present. For a GraphQL `RATE_LIMITED` error, the same header rules apply to the HTTP response that carries the error. A caller waits until `resetAt` before it runs the command again. Other GitHub request failures exit with code 2.

The `checkIssueReviewReadiness` sub-command lets an agent self-check whether an issue is currently review-ready. It does NOT change the issue Status field and does NOT post any comment. It writes a single JSON line to stdout of the shape `{ "reviewReady": boolean, "rejections": [{ "type": string, "detail": string }] }` and exits 0 on a successful evaluation regardless of readiness; a non-zero exit indicates an operational error (auth failure, network error). The rejection types include: `ISSUE_NOT_FOUND`, `NO_REPORT_FROM_AGENT_BOT`, `PULL_REQUEST_NOT_FOUND`, `PULL_REQUEST_IS_DRAFT`, `PULL_REQUEST_CONFLICTED`, `ANY_CI_JOB_FAILED_OR_IN_PROGRESS`, `REQUIRED_CI_JOB_NEVER_STARTED`, `ANY_REVIEW_COMMENT_NOT_RESOLVED`, and `MULTIPLE_PULL_REQUESTS_FOUND`. The `--projectUrl` option is optional; when omitted the command still runs using only the issue URL.

The `selectOauthToken` sub-command reads the same per-token rate-limit cache that the `startDaemon` proxy writes (see "Claude OAuth Token Rotation" below) and prints exactly one token string to stdout so a caller can choose an appropriate token before launching Claude Code. It is read-only: it never starts the proxy, never mutates any cache file, and never writes the token anywhere. Selection runs in two stages. First, a candidate filter keeps tokens whose 5-hour window is at least 60% free (5-hour utilization at most 0.40) AND whose 7-day window is at least 14% free (7-day utilization at most 0.86), where "% free" is `1 - utilization`. A token with no cache file, or whose window reset epoch has already passed, is treated as fully free (utilization 0) for these checks. The filter additionally excludes any token carrying a non-expired reactive `seven_day_fable` rejection marker — set when a `fable`-model request on that token was rejected with HTTP 429 (see "Claude OAuth Token Rotation" below) — because these interactive sessions run on the `fable` model; a token without the marker is treated as `fable`-usable, and the marker is ignored once its stored reset epoch has passed. Second, among the surviving candidates it selects the single token whose 7-day window reset epoch is nearest in the future (soonest reset), so weekly quota that would otherwise reset unused is consumed first; a candidate with no active 7-day window is treated as having the farthest reset (now + 7 days) and therefore sorts last. Each token entry may carry an optional `selectionWeight` (a positive number, default `1`); when the eligible candidates carry differing weights the selection among them becomes weighted-random by that weight, so a token with a smaller weight is chosen proportionally less often, while a token that is the only eligible candidate is always chosen regardless of its weight. When every eligible candidate shares the same weight (the default), the deterministic soonest-reset selection above is used unchanged. The selected token string is written to stdout (pipeable) and the per-candidate decision trace is written to stderr. When no token passes the filter, nothing is written to stdout and the command exits non-zero with an explanatory message on stderr. The token-list path comes from `--tokenListJsonPath` or the `CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH` environment variable; the cache directory comes from `--cacheDir` or the `TDPM_RATELIMIT_CACHE_DIR` environment variable, defaulting to `${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit`.

The `selectLiveSessionOauthToken` sub-command picks a token for a new live interactive Claude Code session a human is about to start, so that concurrent interactive sessions spread across distinct tokens instead of stacking onto the same one. It loads the token list and reads the same per-token rate-limit cache as `selectOauthToken`, and it is read-only in exactly the same way: it never starts the proxy, never mutates any cache file, and never writes the token anywhere. It applies a base rate-limit eligibility filter (5-hour window at least 25% free AND 7-day window at least 1% free, with a missing cache file or an expired window treated as fully free, and any token carrying a non-expired reactive `seven_day_fable` rejection marker excluded), and additionally excludes any token whose 5-hour window is less than `minFiveHourFreeRatio` (default 60%) free or whose 7-day window is less than `minSevenDayFreeRatio` (default 14%) free; these stricter thresholds prevent the `cl` script from starting a new interactive session on a token that is nearly exhausted. It additionally measures current live occupancy per token by scanning running Claude Code processes on the local Linux host: for each process under `/proc` it reads the NUL-separated `/proc/<pid>/environ` and, when the process is a Claude Code process, takes its `CLAUDE_CODE_OAUTH_TOKEN` and a session key: `CLAUDE_CONFIG_DIR` when set, else `CLAUDE_CODE_SESSION_ID` when set, else the process id. A token's occupancy is the number of distinct session keys bound to it, so child processes that inherit one config dir or session id count once, and a non-interactive `claude -p` process that carries neither still counts as one session. Processes without `CLAUDE_CODE_OAUTH_TOKEN` (for example API-key sessions) are ignored, and a process whose environ cannot be read is skipped. Among the eligible tokens the selection is deterministic and reset-first. Each eligible token is given a concurrent session limit of `maxConcurrentSessionCount` (default `10`), scaled by its optional `selectionWeight` (a positive number, default `1`). That limit is held at its full value while the free share of the token's 5-hour window is at or above `fullSpeedFiveHourFreeRatio` (default `0.5`), and is tapered linearly in proportion to the free share below that point. It is further capped at the number of sessions the free 5-hour share can carry until that window resets: the free share divided by the hours left until the reset (5 hours when the reset is unknown or has passed), never more than one fifth of a window per hour, divided by `fiveHourShareConsumedPerSessionHour` (default `0.05`, the share of a 5-hour window one session uses in an hour) and rounded down. The limit never drops under `1`. For example, a token with 68% of its 5-hour window free and 4.6 hours until the reset gets a limit of 2, and a fresh window gets a limit of 4, so sessions stop piling onto a token whose window would run out before it resets. The 5-hour window is the only window that lowers the limit: it is the one that runs out within a single working stretch, and a session that hits it mid-flight has to be restarted, which costs more than it saves. The 7-day window never lowers the limit, so a weekly allowance that is about to expire is drained at full speed rather than discarded unused; the 7-day window is instead what orders the candidates. The token whose 7-day window resets soonest among those still under their limit is selected, so allowance that is about to expire is consumed before the reset discards it; ties are broken by the fewer live sessions. All five tuning numbers (`maxConcurrentSessionCount`, `fullSpeedFiveHourFreeRatio`, `minFiveHourFreeRatio`, `minSevenDayFreeRatio`, `fiveHourShareConsumedPerSessionHour`) are read from the fleet-wide config file named by `--fleetConfigFilePath` or the `TDPM_FLEET_CONFIG` environment variable, under a `liveSessionOauthTokenSelection` mapping; a key the file omits keeps its built-in value, while an unreadable file or an out-of-range value is reported as an error rather than silently ignored. A token at its limit is skipped in favour of the next soonest-resetting token that still has room, and when every eligible token is at its limit the one least over its limit after one more session (live sessions plus one, divided by the limit) is returned rather than none, so an overflow spreads across tokens. A token that is the only eligible candidate is always chosen regardless of its weight or occupancy. When no token passes the primary live session filter (5h >= 60%, 7d >= 14%), a fallback stage runs: it keeps any non-excluded token (not subscription-disabled, not unified-rejected, not fable-rejected) whose 7-day window is at least 3% free, then selects the one with the highest 5-hour free ratio among those (ties broken by fewer live sessions), so a session can still start when all tokens are near the strict threshold. The selected token string is written to stdout (pipeable) and the per-candidate decision trace is written to stderr; when no token passes both the primary filter and the fallback, nothing is written to stdout and the command exits non-zero with an explanatory message on stderr. The token-list path and cache directory are resolved exactly as for `selectOauthToken`. Because occupancy is read from `/proc/<pid>/environ`, this sub-command is Linux-specific.

The `startDaemon` command supports a `preparationWorker` mapping in the fleet-wide config file named by `--fleetConfigFilePath` or the `TDPM_FLEET_CONFIG` environment variable. This section controls per-token concurrency for the preparation worker pool. Supported keys:

- `normalConcurrentLimit` (positive integer, default `6`): the maximum number of concurrent preparation workers each Claude OAuth token may run at full-speed utilization. When either the 5-hour or 7-day utilization of a token rises above 80%, its per-token concurrency tapers linearly from `normalConcurrentLimit` down to 1.
- `maxConcurrentWorkers` (positive integer, default `40`): the host-wide ceiling on the total number of concurrently running preparation workers across all tokens. Once this many workers are already running, no new worker is spawned in that daemon cycle regardless of per-token limits.
- `graphqlRateLimitFloor` (non-negative integer, default `500`): the minimum number of GitHub GraphQL API requests that must remain in the current rate-limit window before a daemon cycle spawns any workers. When the remaining count falls at or below this value the entire spawn step is skipped for that cycle.

A key the file omits keeps its built-in value; an unreadable file or an out-of-range value is reported as an error rather than silently ignored. Example:

```yaml
preparationWorker:
  normalConcurrentLimit: 8
  maxConcurrentWorkers: 40
  graphqlRateLimitFloor: 500
```

The `schedule` command (trigger `schedule`) supports a `startPreparation` mapping in the fleet-wide config file named by the `TDPM_FLEET_CONFIG` environment variable. Supported keys:

- `maximumPreparingIssuesCount` (integer of at least 1, default `80`): the maximum number of issues in preparation status, used when neither the project README nor the project config sets `maximumPreparingIssuesCount`.
- `llmModelNameByAgentName` (mapping of agent name to model name, default unset): the model used for an issue whose agent name is a key of the mapping and that has no `llm-model:` label. The file is read again on every schedule run, so a change takes effect on the next run without restarting the daemon.
  - The `startPreparation.llmModelNameByAgentName` value must be a mapping.
  - Each entry's value must be a non-empty string. An entry whose value is null is ignored.
  - The model of a work session is chosen in this order: the `llm-model:` label, then `llmModelNameByAgentName`, then the token-routed model, then the agent definition `model:` line, then `defaultLlmModelName`.

A key the file omits keeps its built-in value; a `maximumPreparingIssuesCount` value that is not an integer of at least 1 is reported as an error. Example:

```yaml
startPreparation:
  maximumPreparingIssuesCount: 80
```

Example with the map:

```yaml
startPreparation:
  maximumPreparingIssuesCount: 80
  llmModelNameByAgentName:
    triager: 'claude-haiku-5-5'
    developer: 'gpt-5.6-luna'
```

The `notifyFinishedIssuePreparation` and `revertOrphanedPreparation` commands support a `workflowIssueReporter` mapping in the fleet-wide config file named by `--fleetConfigFilePath` or the `TDPM_FLEET_CONFIG` environment variable. When a silent-redispatch escalation fires (an agent is dispatched and returns no report three consecutive times, indicating a TDPM process-level problem rather than a task-specific one), TDPM automatically creates a tracking issue in the configured repository or, when an open issue with the same title already exists, adds a comment to it instead of opening a duplicate. Supported keys:

- `owner` (string, required): GitHub owner (organisation or user) of the repository where the workflow issue is created.
- `repo` (string, required): repository name under that owner.
- `projectUrl` (string, optional): URL of a GitHub ProjectV2 to which the newly created issue is added. When the project contains a story option whose name includes "workflow blocker" (case-insensitive), the issue's Story field is set to that option automatically. When this URL starts with a GitHub org (`https://github.com/orgs/{org}/projects/{n}`) or user (`https://github.com/users/{user}/projects/{n}`) project URL (optionally followed by a `/views/N` path suffix or query parameters such as `?fullscreen=true`), the fleet task creation console link also appends `?projects={org}/{n}` or `?projects={user}/{n}` (in addition to `assignees={owner}`) so that any new issue created from that link is automatically added to the project.

Example:

```yaml
workflowIssueReporter:
  owner: my-org
  repo: workflow-issues
  projectUrl: https://github.com/orgs/my-org/projects/5
```

The `startDaemon`, `notifyFinishedIssuePreparation`, `checkIssueReviewReadiness`, and `schedule` (trigger=schedule) commands support a top-level `errorReportingRepository` string in the fleet-wide config file named by `--fleetConfigFilePath` or the `TDPM_FLEET_CONFIG` environment variable. When set, it takes precedence over the per-project `errorReportingRepository` config key and routes all TDPM-level error issues to that single repository: (1) any unhandled error that escapes the CLI process creates a new GitHub issue there, or adds a comment to an existing open issue with the same title; (2) "Register missing agent definition" tasks and "Unregistered agent in workflow configuration" blocker issues are created there instead of the product repository. The originating TDPM project name is included in the blocker issue body (as `- TDPM project: <name>`) when a `projectName` is configured in the per-project config file, so the source project is identifiable even when all errors route to a central repository. Requires `GH_TOKEN`.

Example:

```yaml
errorReportingRepository: my-org/secretary
```

The `countInTmuxByHumanSessionsPerToken` sub-command reports, per Claude Code OAuth token, how many live interactive sessions running under that token belong to an issue that is currently in the GitHub Project Status `In Tmux by human`. It is read-only: it never starts the proxy, never mutates any cache file, and never writes any token anywhere. It enumerates live interactive sessions by scanning running processes on the local Linux host: for each process under `/proc` it reads the NUL-separated `/proc/<pid>/cmdline` and selects only processes launched for an interactive session, identified by a `--name <issue-url>` argument whose value is an HTTP or HTTPS URL; `Take ownership` background spawns (which have no `--name` argument) are excluded. For each selected process it reads `/proc/<pid>/environ` and takes its `CLAUDE_CODE_OAUTH_TOKEN` and `CLAUDE_CODE_SESSION_ID`; a process missing either is skipped, and a process whose files cannot be read is skipped. It then loads the project's issues with their Status (reusing the same on-disk issue cache the daemon uses), keeps only the sessions whose `--name` issue URL maps to an open issue in Status `In Tmux by human`, and counts the distinct `CLAUDE_CODE_SESSION_ID` values per token so child processes that inherit one session id count once. It writes one tab-separated line per token to stdout in the form `<tokenName>\t<count>` (the token name comes from the token list; the raw token value is never printed), and writes the per-run decision trace to stderr. The token-list path comes from `--tokenListJsonPath`, the `claudeCodeOauthTokenListJsonPath` config value, or the `CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH` environment variable. Because occupancy is read from `/proc/<pid>/cmdline` and `/proc/<pid>/environ`, this sub-command is Linux-specific.

The `killTmuxSession` sub-command cleanly kills a tmux session by running `tmux kill-session` together with stopping that session's per-session `cl-*.scope` systemd `--user` unit, consolidating logic that would otherwise need to be hand-written by callers. It takes exactly one of two mutually exclusive options. `--session <name>` kills another named session: it first stops the session's `cl-*.scope` unit (wrapping the `systemctl --user stop` call with `systemctl --user reset-failed` both before and after), then runs `tmux kill-session -t "=<name>"` using the exact-name `=` prefix so a session name that is itself a prefix of another session's name is never matched by mistake. `--self` terminates the current session from inside it: because a live session cannot run `tmux kill-session` on itself without being killed before its own scope-stop step completes, this mode derives the caller's own `cl-*.scope` unit name from `/proc/self/cgroup` and stops only that scope, without calling `tmux kill-session` at all. Neither option may be combined with the other, and providing neither is an error.

The `ownerCallFileAppend` and `ownerCallFileDelete` sub-commands maintain one temporary file per tmux session holding the owner calls that session raised and the owner has not answered yet. Both take the tmux session name alone and derive the file location from a single rule — `call-to-user/{pjcode or NA}/{session key}.yaml` under `--inTmuxDataDir`, where the session key is the session-name derivation the in-tmux sub-commands already apply to an issue url (every `.` and `:` replaced by an underscore) followed by the replacement of every `/` by an underscore. That derivation gives the same key for a raw issue url and for the tmux session name of it, so the writing side, which knows the session name, and the reading side, which knows the issue url, reach the same file and can never drift apart; it is the same rule the `serveWeb` route above resolves. Neither sub-command takes a project code: `ownerCallFileAppend` resolves it from the in-tmux-by-human data in `--inTmuxDataDir`, which holds one `{pjcode}.v4.json` per project listing that project's sessions, and the project whose session list holds the session gives the code. `NA` is the project code of a session no project lists, such as a long-running session that is not tied to one project. `ownerCallFileAppend` appends one YAML document per call, separated by YAML's own `---` document delimiter, creating the file when it does not exist so the oldest call stays first; each document repeats `sessionName` so a reader can reject a file that does not belong to the session it opened, carries `calledAt` as UTC ISO-8601 with second precision and a trailing `Z`, and carries `body` as a literal block with the explicit indentation indicator `2` so a body whose first line begins with a space is still valid YAML. The body is passed as `--body-file` rather than as an argument because a call body is multi-line and can be long. `ownerCallFileDelete` removes the file of that session under whichever project directory holds it, so a session that moved between projects after its call was appended leaves nothing behind, and exits successfully when the file is already absent, so a reset that runs twice is not an error. Neither sub-command writes anything to stdout on success. The scheduled run additionally deletes the file of every session whose issue is closed.

The `archive-unresumable-session` sub-command moves aside the stored conversation record of a session that ended because its prompt exceeded the model context limit, so the next launch for that task starts a fresh conversation instead of resuming a record that can never load again. Without it, a launcher that resumes the same stored conversation on every attempt keeps terminating on the same condition and the task never starts. It reads the finished run's stream-json log named by `--log-file` line by line, parsing each line that is a JSON object and ignoring every line that is not. A record matches when all three of `type` equals `result`, `is_error` is `true`, and `result` equals `Prompt is too long` hold; the session whose record is archived is the `session_id` of that matching record. A session that merely hit a usage limit ends with `terminal_reason` `api_error`, `api_error_status` `429`, and a different `result`, so it does not match and nothing is moved. When no record matches, the sub-command writes `no-op` to stdout, changes nothing, and exits 0. When a record matches, it creates `--archive-dir` if it does not exist, moves `<--session-dir>/<session-id>.jsonl` to `<--archive-dir>/<session-id>.jsonl`, writes `archived <destination path>` to stdout, and exits 0. The conversation record is moved, never deleted, so the conversation stays recoverable. When a record matches but that conversation record file is absent, the sub-command writes a diagnostic naming the expected source path to stderr and exits 1. It only changes where the record is stored; it does not change how a session is chosen for a task.

The `select-resumable-session` sub-command decides whether a dispatched worker resumes an earlier `claude` session of the same task and agent, and which session id it passes to `claude --resume <session id>`, so the worker launcher only gathers directory paths and passes the answer to `claude`. A transcript file is the JSON Lines file `<session id>.jsonl` in which `claude` stores one session: a regular file in a session directory whose name ends in `.jsonl` and does not start with `.`; a missing session directory holds no transcript files. A transcript file is a candidate only when its first line contains `"customTitle":"<--session-name>"`, closing quote included, so a title that only starts with the name is not a candidate. A candidate is not resumable when `<--archive-root>/<session id>.jsonl` or `<--archive-root>/<directory>/<session id>.jsonl` exists, or when its last main-chain assistant entry is an API error whose text is `Prompt is too long`. A main-chain assistant entry is a JSON line with `type` `assistant` and `isSidechain` not `true`; its error text is the `text` values of its `message.content` items joined together when `isApiErrorMessage` is `true`, and empty otherwise; lines that are not JSON objects are ignored. In `--session-dir` the resumable candidate with the newest modification time is selected, and of candidates with the same modification time the one whose file name comes first in ascending order wins. When `--session-dir` yields no selection, the transcript files of all `--other-session-dir` directories are ranked together by modification time: of candidates with the same modification time the one in the directory given first wins, and within one directory the file name that comes first in ascending order wins. A directory equal to `--session-dir` is left out, and a transcript file whose file name also exists in `--session-dir` is skipped. The selected transcript file must have an entry time, the first JSON object line with a string `timestamp` that parses as a date; without one the sub-command writes `Session resumption: <session id> has no entry time to compare with the definitions; starting a new session` to stderr, copies nothing, and prints nothing. A transcript file selected from another session directory is copied into `--session-dir`, which is created with mode `700` when absent: the copy keeps the source's mode and its access and modification times, is written as `<session id>.jsonl.copying`, and is renamed to `<session id>.jsonl`, and the source stays where it is. After the transcript file is copied, its session id directory `<session id>/` next to the source transcript file, which holds the subagent transcripts and tool results of that session, is copied recursively, keeping its times, when it exists and `<--session-dir>/<session id>` does not. The sub-command then writes `Session resumption: copied <source transcript file> into <--session-dir> (original kept)` to stderr, preceded by `Session resumption: warning: could not copy session directory <source session id directory> into <--session-dir>` when only the session id directory copy failed, in which case the session is still resumed. When the transcript file copy fails, or `<--session-dir>/<session id>.jsonl` already exists, it writes `Session resumption: could not copy <source transcript file> into <--session-dir>; starting a new session` to stderr and prints nothing. stdout carries the session id to resume followed by a newline, or nothing when the worker starts a new `claude` session, and the exit code is 0 in every one of these cases; a missing required option exits non-zero.

The `check-issue-silent-dispatch-allowed` sub-command reports whether the issue named by `--issue-url` declares, in its own body, that a dispatch cycle ending with no agent comment is expected and must not be treated as a missing report. This lets `notifyFinishedIssuePreparation` skip the `NO_REPORT_FROM_AGENT_BOT` rejection for a task whose body already instructs the assigned agent to stay silent during some or all dispatch cycles, a design used for certain recurring, owner-directed tasks. It parses `owner`, `repo`, and the issue number from `--issue-url`, fetches that issue's body from the GitHub REST API using the `GH_TOKEN` environment variable, and evaluates it against the exact literal marker `<!-- TDPM_SILENT_DISPATCH_ALLOWED -->`: the marker must appear as a contiguous substring of the body, so the bare token without its `<!-- -->` wrapper does not count. It writes nothing to stdout. It exits 0 when the marker is present, 1 when it is absent, and 2 with a diagnostic on stderr when `--issue-url` cannot be parsed or the GitHub API call fails.

The `workerRequestTextRead` sub-command prints the request text a worker session receives for the issue named by `--issueUrl`, exactly `Take ownership of <issue url>` followed by one newline, to stdout, writes nothing to stderr, and exits 0. The worker launcher calls it and passes the printed text to the worker session unchanged, so the text is defined in one place in this CLI, the same CLI that parses it back out of running worker processes to find which issues already have a running worker. It reads nothing and changes nothing. When `--issueUrl` is missing, it prints nothing to stdout, prints an error naming `--issueUrl` to stderr, and exits non-zero.

## Example 📖

Here's a quick example to illustrate its usage:

```
npx github-issue-tower-defence-management schedule -t schedule -c ./config.yml
```

```
npx github-issue-tower-defence-management schedule -t issue -c ./config.yml -i https://github.com/HiromiShikata/test-repository/issues/1
```

```
npx github-issue-tower-defence-management startDaemon --configFilePath ./preparator-config.yml
```

```
npx github-issue-tower-defence-management notifyFinishedIssuePreparation --configFilePath ./preparator-config.yml --issueUrl https://github.com/HiromiShikata/test-repository/issues/1
```

```
npx github-issue-tower-defence-management checkIssueReviewReadiness --configFilePath ./preparator-config.yml --issueUrl https://github.com/HiromiShikata/test-repository/issues/1
```

```
npx github-issue-tower-defence-management serveWeb --configFilePath ./preparator-config.yml --port 9980
```

```
TOKEN=$(npx github-issue-tower-defence-management selectOauthToken --tokenListJsonPath ./claudeCodeOauthTokenList.json)
```

```
TOKEN=$(npx github-issue-tower-defence-management selectLiveSessionOauthToken --tokenListJsonPath ./claudeCodeOauthTokenList.json)
```

```
npx github-issue-tower-defence-management countInTmuxByHumanSessionsPerToken --configFilePath ./preparator-config.yml --tokenListJsonPath ./claudeCodeOauthTokenList.json
```

```
REQUEST_TEXT=$(npx github-issue-tower-defence-management workerRequestTextRead --issueUrl https://github.com/HiromiShikata/test-repository/issues/1)
```

## Config

### Schedule Command Config

The `config.yaml` for the `schedule` command must match the input type of `HandleScheduledEventUseCase.run()`. Below is the structure:

Workflow status names (`Unread`, `Awaiting Workspace`, `Awaiting Owner`, `Preparation`, `Failed Preparation`, `Todo by human`, `In Tmux by human`, `In Tmux by agent`, `Done`, `Icebox`) are fixed code constants and cannot be overridden from CLI options, config files, or project README. The `schedule` command automatically creates any missing required statuses on the target project on each run via `SetupTowerDefenceProjectUseCase`. Projects with the legacy `Todo` and `In Tmux` status names are automatically migrated to `Todo by human` and `In Tmux by human` respectively by reusing the existing option IDs so that task associations are preserved. The legacy `PC Todo` status is removed from the required list and excluded from the project status list on the next setup run. The legacy `Awaiting Task Breakdown` status is removed from the required list; any project items in that status are automatically moved to `Todo by human` and the status option is removed on the next setup run. The `Awaiting Owner` status is a required workflow status.

The two tmux-related statuses have distinct meanings. `In Tmux by human` means a task being handled in a live tmux session together with the human owner, who attends the session and converses with it; the owner does look at these tasks. `In Tmux by agent` means a task managed by an agent in tmux that the human owner does not look at. A task launched into a live, owner-attended session therefore belongs to `In Tmux by human`, and setting such a task to `In Tmux by agent` would remove it from the owner's view.

```yaml
disabled: boolean # When true, skip all processing and return null
org: string # Organization name
projectUrl: string # URL of the target project
manager: string # GitHub account name of the manager
workingReport:
  repo: string # Repository name
  members: # Array of member's GitHub account names
    - string
    - string
  warningThresholdHour?: number # Optional: Warning threshold in hours
  spreadsheetUrl: string # URL of the Google Spreadsheet
  reportIssueTemplate?: string # Optional: Template for issue reports
  reportIssueLabels: # Array of issue labels
    - string
    - string
startPreparation?: # Optional: Enable automatic issue preparation workflow
  defaultAgentName: string # Default agent name to assign for preparation
  configFilePath: string # Path to config file passed to the aw command
  defaultLlmModelName?: string | null # Optional: Default LLM model name (overridable via llm-model: label and llmModelNameByAgentName)
  defaultLlmAgentName?: string | null # Optional: Default LLM agent name (overridable via llm-agent: label)
  maximumPreparingIssuesCount: number | null # Max concurrent preparing issues. When token rotation is active, effective concurrency is also capped at 6 per available token. When null, the default is 6 per available token, or 6 without token rotation
  utilizationPercentageThreshold?: number # Optional: 5-hour utilization hard threshold (percentage, default 90). Tokens at or above this value are excluded from rotation
  allowedIssueAuthors?: string[] | null # Optional: Only start preparation for issues from these authors (null or omitted = no authors allowed; set an explicit list)
  preparationProcessCheckCommand?: string # Optional: Shell command template with {URL} placeholder to check if a preparation process is alive. When set, orphaned Preparation issues (process exits non-zero, or stale aw log) are evaluated for completion: if work is done they advance to Awaiting Owner; otherwise they fall back to Awaiting Workspace.
  awaitingOwnerStatus?: string | null # Optional: Project status name for issues awaiting owner review. When set with preparationProcessCheckCommand, orphaned issues with no rejections advance to this status instead of awaitingWorkspaceStatus
  autoAdvanceQualityCheckEnabled?: boolean # Optional: When true, issues in the Awaiting Owner status are automatically advanced to Done on each scheduled cycle. Default false (issues remain in Awaiting Owner for human review). Use awaitingOwnerStatus to configure the status name when it differs from the default
  autoRevertReopenedDoneEnabled?: boolean # Optional: When true, issues in the Done status that were reopened on GitHub (stateReason = REOPENED) are automatically moved back to Awaiting Workspace on each scheduled cycle. Default false. Requires startPreparation to be configured.
  codexHomeCandidates?: string[] | null # Optional: Ordered list of CODEX_HOME directory paths. Each launched Codex job cycles through the list; absent or empty keeps current behavior
  awLogDirectoryPath?: string # Optional: Directory path where aw log files named {org}_{repo}_{number}_* are written. Used with awLogStaleThresholdMinutes to detect zombie-wrapper orphans, and by startPreparation when an issue has more than one open same-repository PR: the PR whose head branch is checked out in the working directory recorded on the `Current directory:` line of the issue's newest {org}_{repo}_{number}_{YYYYMMDD}_{HHMMSS}.log is adopted and every other PR is closed. When this is unset, or that branch is the head branch of no open PR, the PR created first is adopted
  awLogStaleThresholdMinutes?: number # Optional: Minutes since last aw log mtime after which a Preparation issue is considered orphaned even when pgrep still returns 0 (outer wrapper alive but inner claude dead). Requires awLogDirectoryPath
  agentDefinitionsDirectoryPath?: string # Optional: Directory path containing per-agent definition markdown files named {agentName}.md, each with a YAML frontmatter `model:` field. Used as a model source when neither an `llm-model:` label, an `llmModelNameByAgentName` entry, nor live-session token routing resolves a model. Defaults to ~/.claude/agents when unset
  labelsAsLlmAgentName?: string[] | null # Optional: List of issue labels that are themselves agent names. When an issue carries any label that is included in this list, that label name is used as the agent name. Selection precedence is: (1) explicit `llm-agent:` label, (2) labelsAsLlmAgentName entry match, (3) `category:` label, (4) defaultLlmAgentName, (5) defaultAgentName
developerAgentNames?: string[] # Optional: The agent names treated as "developer" agents for PR link, CI, and conflict checks in both the notifyFinishedIssuePreparation and scheduled revert flows. When omitted or empty, no issue is treated as a developer agent issue (no silent fallback to `developer`). Set this when your project uses one or more agent names for coding tasks and you want those agents' issues to undergo PR/CI/conflict validation. Readable from the project README config section; readme value takes precedence over the config file value. Declared at the top level (sibling of startPreparation), not inside it. Also accepts the legacy single-string key `developerAgentName` for backward compatibility (wrapped as a one-element array).
labelsNotRequiringPullRequest?: string[] | null # Optional: List of issue labels whose issues are exempt from the pull-request part of the review-readiness check, without that label also being used as an agent name. An issue carrying any of these labels is never rejected for a missing, draft, conflicted, or failing pull request, and its agent is still resolved by the normal precedence (explicit `llm-agent:` label, labelsAsLlmAgentName entry match, `category:` label, defaultLlmAgentName, defaultAgentName). Every label in labelsAsLlmAgentName is exempt as well, so an existing config that relied on that key keeps its behaviour. Use this key for a label such as `story` whose issues produce child issues rather than a pull request and for which no agent of the same name is installed. Declared at the top level (sibling of startPreparation), not inside it.
agentDesignationLabelsToKeep?: string[] | null # Optional: List of agent label names for which the label is NOT removed after the Agent project field is set. When an issue carries a label matching an agent name in this list, the Agent field is updated normally but the label is kept on the issue (the normal behavior removes it). Use this for a label such as `story` that serves a dual purpose and must remain visible on the issue after routing. Declared at the top level (sibling of startPreparation), not inside it.
changeTargetPathAliases?: Record<string, string> | null # Optional: Map of short alias keys to full repository-root-relative directory paths, for use with `change-target:<alias>` labels. Allows deeply nested paths that exceed GitHub's 50-character label limit to be referenced via a short alias. Example: `{ "adapters": "src/domain/usecases/adapter-interfaces" }` — a label `change-target:adapters` then matches files under `src/domain/usecases/adapter-interfaces/`. Keys not matching any `change-target:` label value are ignored; values with leading or trailing slashes are normalized automatically. Declared at the top level (sibling of startPreparation), not inside it.
claudeCodeOauthTokenListJsonPath?: string # Optional: Path to a JSON file listing long-term Claude Code OAuth tokens to rotate across (see "Claude OAuth Token Rotation" below). Declared at the top level (sibling of startPreparation), not inside it.
autoAssignManagerAuthors?: string | string[] | null # Optional: Restrict which authors' open, unassigned issues and pull requests are auto-assigned to the manager. Accepts a comma-separated string or an array of GitHub author logins (bot logins carry the `[bot]` suffix, for example `renovate[bot]`). When set to a non-empty list, only items whose author is in the list are auto-assigned; when omitted and allowedIssueAuthors is set, allowedIssueAuthors is used as the author filter; when both are omitted, null, or empty, every open unassigned item is auto-assigned. Author matching is exact equality. Declared at the top level (sibling of startPreparation), not inside it.
queryToAddProjectEnabled?: boolean # Optional: Enable the queryToAddProject search (default false). The search runs only when this is true, so setting queryToAddProject alone changes nothing. Declared at the top level (sibling of startPreparation), not inside it.
queryToAddProject?: string | null # Optional: GitHub search query string, used only when queryToAddProjectEnabled is true. Each schedule cycle, issues and pull requests matching this query that are not already on the project board and whose author satisfies the effective author filter (autoAssignManagerAuthors, falling back to allowedIssueAuthors) are added to the project and assigned to the manager. Items whose author does not satisfy the filter are skipped entirely and not added to the board. Omit or set to null to skip this search. Declared at the top level (sibling of startPreparation), not inside it.
dailySecurityScan?: # Optional: Run a daily OSV-Scanner security scan across locally cloned repositories of the org. Declared at the top level (sibling of startPreparation). When the findings of one repository cannot be written to GitHub — for example because that repository is archived and therefore read-only, or because the rendered findings exceed what the issues API accepts — the failure is logged with the repository name and the cause, that repository is skipped, and the remaining repositories are still scanned and the CISA KEV report still runs.
  scanBaseDirectory: string # Base directory searched (4 levels deep) for cloned repositories to scan
  targetHourUtc: number # UTC hour (0-23) at which the scan runs once per day; the scan runs only when a target date matches this hour at minute zero
  enableKevNvdReport?: boolean # Optional: When true, also report CISA KEV catalog additions that have not been reported yet. A watermark recording the newest reported dateAdded, plus the CVE identifiers already reported on that date, is persisted at $XDG_CACHE_HOME/tdpm/kev-report-watermark.json (falling back to ~/.cache when XDG_CACHE_HOME is unset) and advanced over every addition the run considered, whether or not that addition matched a package the scan actually found and therefore produced a report issue. Only an addition whose CVE identifier matches a package the scan found at an installed version is written into the report issue. On the first run, when no watermark is stored yet, the additions dated on or after the day before the run are considered. Known limitations: (1) if the watermark write fails after the report issue was created, the failure is logged with the state file path and the cause, the run continues so that unrelated scheduled work is not aborted, and the same additions are considered again on the next run, so the guarantee is at-least-once at that boundary rather than exactly-once; (2) an addition whose dateAdded is strictly earlier than the stored watermark date, whether back-dated or published very late, is never selected again, so the protection covers late additions on the watermark date itself and not arbitrary lateness. When the stored watermark file exists but cannot be read or is malformed (including a stored date that is not a real calendar date in YYYY-MM-DD form), the KEV report is skipped for that run, an error naming the file and the cause is logged, the file is left untouched for inspection, and the rest of the scheduled work continues
  kevReportRepo?: string # Optional: Repository name (within org) where the CISA KEV additions issue is created. Required for KEV reporting
```

Example:

```yaml
disabled: false
org: 'my-org'
projectUrl: 'https://github.com/orgs/my-org/projects/1'
manager: 'HiromiShikata'
workingReport:
  repo: 'work-report'
  members:
    - 'HiromiShikata'
    - 'octokit'
  warningThresholdHour: 40
  spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/xxx'
  reportIssueTemplate: |
    ## Working Time Report

    ### Summary
    Period: {period}
    Team: {team}

    ### Details
    {details}
  reportIssueLabels:
    - 'report'
    - 'working-time'
  slack:
    userToken: 'xoxp-xxx'
startPreparation:
  defaultAgentName: 'aw'
  configFilePath: '/path/to/agent-config.yml'
  defaultLlmModelName: 'claude-opus-4-5'
  maximumPreparingIssuesCount: 3
  utilizationPercentageThreshold: 90
  preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"'
  awaitingOwnerStatus: 'Awaiting Owner'
  autoAdvanceQualityCheckEnabled: false # set to true to auto-advance to Done without human review
  autoRevertReopenedDoneEnabled: false # set to true to auto-revert reopened Done issues to Awaiting Workspace
  awLogDirectoryPath: '/home/user/logs-aw'
  awLogStaleThresholdMinutes: 15
  agentDefinitionsDirectoryPath: '/home/user/agent-definitions'
  labelsAsLlmAgentName:
    - story
    - story:body-condition
dailySecurityScan:
  scanBaseDirectory: '/home/user/repos'
  targetHourUtc: 0
  enableKevNvdReport: true
  kevReportRepo: 'security-reports'
```

### startDaemon and notifyFinishedIssuePreparation Commands Config

The config YAML for `startDaemon` and `notifyFinishedIssuePreparation` commands:

```yaml
projectUrl: string # URL of the GitHub project
projectName: string # Project name (used for cache directory path)
defaultAgentName: string # Default agent name for issue preparation
defaultLlmModelName?: string # Optional: Default LLM model name
defaultLlmAgentName?: string # Optional: Default LLM agent name
maximumPreparingIssuesCount?: number # Optional: Max concurrent preparing issues. When token rotation is active, effective concurrency is also capped at 6 per available token. Omitted defaults to 6 per available token, or 6 without token rotation
utilizationPercentageThreshold?: number # Optional: 5-hour utilization hard threshold (percentage, default 90). Tokens at or above this value are excluded from rotation
allowedIssueAuthors?: string # Optional: Comma-separated list of allowed issue authors
autoAssignManagerAuthors?: string # Optional: Comma-separated list of author logins whose open, unassigned issues and pull requests are auto-assigned to the manager. When set to a non-empty list, only items whose author is in the list are auto-assigned; when omitted and allowedIssueAuthors is set, allowedIssueAuthors is used as the fallback filter; when both are omitted or empty, every open unassigned item is auto-assigned. Author matching is exact equality; bot logins carry the `[bot]` suffix (for example `renovate[bot]`)
queryToAddProjectEnabled?: boolean # Optional: Enable the queryToAddProject search (default false). The search runs only when this is true, so setting queryToAddProject alone changes nothing
queryToAddProject?: string # Optional: GitHub search query string, used only when queryToAddProjectEnabled is true. Each schedule cycle, issues and pull requests matching this query that are not already on the project board and whose author satisfies the effective author filter (autoAssignManagerAuthors, falling back to allowedIssueAuthors) are added to the project and assigned to the manager. Items whose author does not satisfy the filter are skipped and not added to the board. Omit to skip this search.
thresholdForAutoReject?: number # Optional: Consecutive rejections before escalation (default: 3). Applies to orphaned-preparation, Auto Status Check reject, and conflict-revert paths alike
thresholdForDispatchLoop?: number # Optional: How many times one agent may be dispatched on a single issue since the last human comment before the issue is moved to Failed Preparation instead of being dispatched again (default: 6). This bounds dispatch loops in which the agents do report each round, both when an agent names itself as the next step and when two agents name each other in turn. Any comment that is neither an agent report nor a comment this tool posts itself counts as human input and restarts the count. Applies to orphaned-preparation, Auto Status Check reject, and conflict-revert paths alike
workflowBlockerResolvedWebhookUrl?: string # Optional: Webhook URL. Supports {URL} and {MESSAGE} placeholders
preparationProcessCheckCommand?: string # Optional: Shell command template with {URL} placeholder to check if a preparation process is alive. Orphaned Preparation issues (process exits non-zero, or stale aw log) are evaluated for completion: if work is done they advance to Awaiting Owner; otherwise an `Auto Status Check: REJECTED` comment is posted and the issue falls back to Awaiting Workspace, except that once `thresholdForAutoReject` cumulative orphan-time rejections accumulate within the recent comment window (and no earlier escalation marker is present) the issue is transitioned to Failed Preparation instead.
codexHomeCandidates?: string[] # Optional: Ordered list of CODEX_HOME directory paths for Codex profile cycling. Absent or empty keeps current behavior
claudeCodeOauthTokenListJsonPath?: string # Optional: Path to a JSON file listing long-term Claude Code OAuth tokens to rotate across (see "Claude OAuth Token Rotation" below)
awLogDirectoryPath?: string # Optional: Directory path where aw log files named {org}_{repo}_{number}_* are written. Used with awLogStaleThresholdMinutes to detect zombie-wrapper orphans, and by startPreparation when an issue has more than one open same-repository PR: the PR whose head branch is checked out in the working directory recorded on the `Current directory:` line of the issue's newest {org}_{repo}_{number}_{YYYYMMDD}_{HHMMSS}.log is adopted and every other PR is closed. When this is unset, or that branch is the head branch of no open PR, the PR created first is adopted
awLogStaleThresholdMinutes?: number # Optional: Minutes since last aw log mtime after which a Preparation issue is considered orphaned even when pgrep still returns 0. Requires awLogDirectoryPath
agentDefinitionsDirectoryPath?: string # Optional: Directory path containing per-agent definition markdown files named {agentName}.md, each with a YAML frontmatter `model:` field. Used as a model source when neither an llm-model: label nor live-session token routing resolves a model. Defaults to ~/.claude/agents when unset
developerAgentNames?: string[] # Optional: The agent names treated as "developer" agents for PR link, CI, and conflict checks. When omitted or empty, no issue is treated as a developer agent issue (no silent fallback to `developer`). Set this when your project uses one or more agent names for coding tasks. Readable from the project README config section; readme value takes precedence over the config file value. Also accepts the legacy single-string key `developerAgentName` for backward compatibility (wrapped as a one-element array).
labelsAsLlmAgentName?: string[] # Optional: List of issue labels that are themselves agent names. When an issue carries any label that is included in this list, that label name is used as the agent name. Selection precedence is: (1) explicit `llm-agent:` label, (2) labelsAsLlmAgentName entry match, (3) `category:` label, (4) defaultLlmAgentName, (5) defaultAgentName
consoleAccessToken?: string # Optional: Access token for the Console HTTP server. When set, `startDaemon` TCP-probes port 9980 before the first preparation cycle and, if nothing responds, spawns `serveWeb` as a detached background process on that port. SIGTERM and SIGINT sent to the daemon are forwarded to the console server child before the daemon exits. When unset, no console server is started automatically.
consoleDataOutputDir?: string # Optional: Base output directory for the per-project Console list.json files. When set, the schedule cycle writes the full tab files and notifyFinishedIssuePreparation immediately patches the affected tab files after each status transition (no additional GitHub fetch). When unset, Console list generation and patching are skipped
consoleGithubTokens?: Record<string, string> # Optional: Inline map of pjcode to GitHub token. When set together with consoleProjects, serveWeb resolves a per-project GH token by matching the repository owner against each project URL. When a matching pjcode is found but its token is absent or blank in the map, an error is thrown. Falls back to the default token when consoleGithubTokens is unset or when no pjcode maps to the owner. The story-gate sub-commands use the same map when run with `--configFilePath`.
consoleGithubAppPrivateKeyPaths?: Record<string, string[]> # Optional: Map of pjcode to an array of filesystem paths of GitHub App private key PEM files. Each value is a list of key FILE PATHS only; the private key contents are never written into the config file. Each path follows the same convention as githubAppPrivateKeyPaths: a PEM file with a JSON file of the same name (with -private-key.pem replaced by .json) containing a client_id field. It is used only by the Console HTTP server, and only for read-only GET access: when a request targets a repository whose owner matches the owner of the project URL registered for that pjcode in consoleProjects, issue, pull request, search and comment reads are made with a short-lived read-only installation access token minted from these keys, in the listed order, instead of the token from GH_TOKEN or consoleGithubTokens. Write operations and the Projects V2 operations always use the token resolved for that owner from GH_TOKEN or consoleGithubTokens. Installation access tokens expire after 1 hour: a token is re-minted from the same key paths once it is 50 minutes old, and once more when GitHub answers a read with HTTP 401 Bad credentials, after which the read is retried a single time. When a re-mint fails or the retried read is still refused (HTTP 401, 403 or 404), the read falls back to the token from GH_TOKEN or consoleGithubTokens. A pjcode with an empty array, or without any installation for the target account, keeps using that token for reads. When unset, the Console reads with the token from GH_TOKEN or consoleGithubTokens only.
consoleGithubTokenFileDir?: string # Deprecated: Previously used to read per-project tokens from files named tdpm-github-token-{pjcode}.txt. No longer used; set consoleGithubTokens instead.
githubAppPrivateKeyPaths?: string[] # Optional: Array of filesystem paths to GitHub App private key PEM files. At runtime, TDPM mints a short-lived read-only installation access token from each key path in order and uses them for read-only GitHub API calls (REST reads via RestIssueRepository and RestProjectRepository, and non-mutation GraphQL queries via GraphqlProjectRepository). The Console HTTP server applies these keys only to the repositories it builds with GH_TOKEN itself; the per-project read-only GitHub App access of the Console is configured by consoleGithubAppPrivateKeyPaths instead. Each path must point to a PEM file, and the corresponding JSON config file (same directory, same name with -private-key.pem replaced by .json) must contain a client_id field. Paths are tried in order; paths whose App has no installation for the target account are skipped. Write operations always use the manager token from GH_TOKEN. When unset or empty, the manager token is used for all calls.
workflowBlockerStoryName?: string # Optional: Story field name that the Console "workflow-blocker" tab matches (case-insensitive). Every non-closed issue with this story is listed regardless of status or reactivation-trigger fields. When unset, the workflow-blocker list is always empty
defaultTaskNameWithOwner?: string # Optional: Default `owner/repo` used when the Console "Create task" dialog opens. When set, this value overrides the heuristic that picks the nameWithOwner of the first issue on the board. When unset, the heuristic applies.
inTmuxDataOutputDir?: string # Optional: Base output directory for the in-tmux-by-human per-project and index JSON files written each schedule cycle. When unset, in-tmux-by-human generation is skipped
inTmuxConsoleBaseUrl?: string # Optional: Console base URL used to build the tdpmConsoleUrl in the v3/v4 in-tmux-by-human files (for example https://console.example.com). When unset, the v3 and v4 files are skipped
inTmuxConsoleToken?: string # Optional: Token embedded in the ?k= query string of the v4 in-tmux-by-human files. When unset, the v4 per-project file and index.v4.json are skipped
inTmuxProjectOrder?: string[] # Optional: Ordered list of project codes used to build the in-tmux-by-human index files. When unset or empty, the index files are skipped
newIssueRepo?: string # Optional: Repository name used only for the repo segment of the v4 in-tmux-by-human newIssueUrl. When unset, the newIssueUrl repo segment falls back to workingReport.repo, preserving the existing behavior. Only the newIssueUrl is affected; workingReport.repo and every other use of it are unchanged
inTmuxLauncherCommand?: string # Optional: Launcher command that starts an interactive session for an `In Tmux by human` issue. Each schedule cycle, any open assigned `In Tmux by human` issue without a live session is restarted by running this command with the issue URL as its argument inside a new detached tmux session named after the issue URL. When unset, session restarting is skipped
tokenExhaustionHandoverEnabled?: boolean # Optional: When true, the daemon detects Claude sessions whose OAuth token is near/at the rate limit (five-hour free < 0.10, seven-day free < 0.05 for tmux leaders, blocked/rejected, blockedUntilEpoch in the future, or a rejected weekly hard-cap whose resetsAt is still in the future) and, only when a DIFFERENT token is confirmed not-stale-not-exhausted, performs the handover immediately in the same cycle it is detected: a checkpoint instruction is sent to tmux leaders via `tmux send-keys` (impl `-p` subagents receive SIGTERM instead) and the session is killed right away, with no advance-notice message and no pre-action wait. Issue-URL leaders are relaunched by the preparation daemon; bare-name resident leaders are relaunched by this step via `cl <name>` on a fresher token; impl subagents are left for the daemon to re-dispatch. Requires `claudeCodeOauthTokenListJsonPath`. When false, detection and logging still run but no send-keys, kill, or relaunch is performed (dry-run). Default false
tokenExhaustionHandoverMessage?: string # Optional: Checkpoint instruction sent to an exhausted issue-URL leader via `tmux send-keys`. When unset, a built-in default that tells the session to checkpoint to its task issue and then self-kill is used
tokenExhaustionHandoverBareNameLeaderMessage?: string # Optional: Checkpoint instruction sent to an exhausted bare-name resident leader (a leader started with a bare `--name` and no task issue) via `tmux send-keys`. When unset, a built-in default that tells the leader to checkpoint into each in-flight subagent's own issue and NOT self-kill (this step relaunches it) is used
tokenRateLimitSnapshotBaseDir?: string # Optional: Directory path for the per-token rate-limit snapshot JSON files read by the token exhaustion handover step. When unset, defaults to ${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit
tokenExhaustionHandoverActionCooldownSeconds?: number # Optional: Minimum number of seconds that must elapse since the last kill/relaunch action taken on a given session before that same session may be actioned again on a later cycle. Does not delay or gate the first action taken on a session. Default 180
tokenExhaustionHandoverStateFilePath?: string # Optional: File path for the durable handover grace/debounce state (which sessions were signaled and when), written atomically so grace tracking survives daemon restarts. When unset, defaults to ${XDG_CACHE_HOME:-~/.cache}/tdpm/token-exhaustion-handover-state-tdpm-native.json (a TDPM-native filename kept distinct from any standalone monitor's state file so the two never share state)
silentNotificationEnabled?: boolean # Optional: Master switch for the silent live session self-check notification step. The step is a no-op unless this is `true`, the fleet-wide config file named by `--fleetConfigFilePath` (or `TDPM_FLEET_CONFIG`) contains a top-level `silentNotificationEnabled: true`, or the `TDPM_SILENT_NOTIFICATION_ENABLED` environment variable equals the string `true`. Priority order: this per-project config key (highest), fleet config (middle), env var (lowest). Default false
outputDegenerationResetEnabled?: boolean # Optional: Master switch for the output-degeneration reset step. When true, the daemon inspects every live interactive Claude Code session named after a github.com issue or pull-request URL and, when the session's most recent assistant turn is a collapsed single-token repeat loop (intra-turn) or the same isolated short trailing token recurs across the majority of recent turns (cross-turn), sends a checkpoint warning to the session via `tmux send-keys`, waits the grace period, and kills the tmux session so it is relaunched fresh. When false, detection and logging still run but no warning or kill is performed (dry-run). Default false
outputDegenerationWarningMessage?: string # Optional: Checkpoint warning sent to a degenerated session via `tmux send-keys` before it is reset. When unset, a built-in default that tells the session to checkpoint its resume state to its task issue is used
outputDegenerationGraceSeconds?: number # Optional: Seconds to wait after sending the degeneration warning before killing the tmux session, giving the session time to write its checkpoint. Default 5
outputDegenerationCooldownSeconds?: number # Optional: Seconds a session that was just reset is excluded from re-detection, so it is not repeatedly reset before its fresh relaunch completes. Default 300
outputDegenerationCooldownStateFilePath?: string # Optional: Path to the JSON state file that persists the per-session last-reset time used for the cooldown. When unset, defaults to ${XDG_CACHE_HOME:-~/.cache}/tdpm/output-degeneration-cooldown.json
changeTargetPathAliases?: # Optional: Map of short alias keys to full repository-root-relative directory paths. Allows `change-target:<alias>` labels to reference deeply nested paths that exceed GitHub's 50-character label limit. When a `change-target:` label's value matches a key in this map, it is expanded to the corresponding full path before confinement checking. Values with leading or trailing slashes are normalized automatically. Example below
  adapter-interfaces: src/domain/usecases/adapter-interfaces
errorReportingRepository?: string # Optional: GitHub repository in `owner/repo` format for TDPM-level issue routing. When set: (1) any unhandled error that escapes the CLI process creates a new GitHub issue here (title: `CLI error: {errorName}: {first 80 chars of message}`), or adds a comment to an existing open issue with the same title; (2) "Register missing agent definition" tasks (triggered when an agent definition file is absent) and "Unregistered agent in workflow configuration" blocker issues are created here instead of the product repository. Requires GH_TOKEN. Can also be set via the TDPM_ERROR_REPORT_REPOSITORY environment variable without a config file.
```

Workflow status names (`Unread`, `Awaiting Workspace`, `Awaiting Owner`, `Preparation`, `Failed Preparation`, `Todo by human`, `In Tmux by human`, `In Tmux by agent`, `Done`, `Icebox`) are hardcoded constants and are not accepted via this config, the CLI, or the project README. To ensure they exist on the target project, run the `schedule` command — it invokes `SetupTowerDefenceProjectUseCase` automatically. Projects with the legacy `Todo` and `In Tmux` status names are automatically migrated; `PC Todo` and `Awaiting Task Breakdown` are removed on the next setup run (items in `Awaiting Task Breakdown` are moved to `Todo by human` before the option is deleted). `Awaiting Owner` is a required workflow status created automatically when absent.

Example:

```yaml
projectUrl: 'https://github.com/orgs/my-org/projects/1'
projectName: 'my-project'
defaultAgentName: 'aw'
defaultLlmModelName: 'claude-opus-4-5'
maximumPreparingIssuesCount: 3
utilizationPercentageThreshold: 90
thresholdForAutoReject: 3
thresholdForDispatchLoop: 6
workflowBlockerResolvedWebhookUrl: 'https://example.com/webhook?url={URL}&msg={MESSAGE}'
preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"'
awLogDirectoryPath: '/home/user/logs-aw'
awLogStaleThresholdMinutes: 15
agentDefinitionsDirectoryPath: '/home/user/agent-definitions'
codexHomeCandidates:
  - .codex-dev1
  - .codex-dev2
  - .codex-main
```

#### README-based Config Overrides

For `startDaemon`, `schedule`, and `notifyFinishedIssuePreparation`, the GitHub project README can override config file values. Add a `<details><summary>config</summary>...</details>` section with YAML content to the project README. This takes highest priority over both the config file and CLI arguments. Any top-level YAML key inside the `config` block that is not a recognized override (for example a typo such as `maximumPreparingIssueCount` missing the trailing `s`) is ignored and logged via `console.warn` together with the project URL, so misspelled keys do not silently disable an intended override.

### Claude OAuth Token Rotation

When `claudeCodeOauthTokenListJsonPath` is set, `startDaemon` distributes preparation jobs across multiple long-term Claude Code OAuth tokens to keep each token's 5-hour rate-limit window from saturating. The mechanism has three parts:

1. Token list file (referenced by `claudeCodeOauthTokenListJsonPath`): a JSON array of `{ name, token, selectionWeight? }` records, for example:

   ```json
   [
     { "name": "personal-1", "token": "sk-ant-..." },
     { "name": "personal-2", "token": "sk-ant-...", "selectionWeight": 0.8 }
   ]
   ```

   The `token` value is a long-lived (~1 year) Anthropic OAuth access token (`sk-ant-oat01-...`). The `name` field is optional; when absent, TDPM assigns a positional name (`token-1`, `token-2`, …) based on the entry's index in the array. The `selectionWeight` field is optional and defaults to `1` when absent; it is a positive number that makes a token be used less than the others — a token with a smaller weight (for example `0.8`) is chosen proportionally less often by `selectOauthToken`, and is allowed proportionally fewer simultaneous live sessions by `selectLiveSessionOauthToken`. The weight only reorders preference among tokens that already pass every eligibility, rate-limit, and exhaustion check; it never bypasses that filtering and never starves a low-weight token, which is still chosen whenever it is the only eligible candidate. Entries without a string `token` are skipped. Missing files or malformed JSON yield no tokens and disable rotation for that run (the daemon then falls back to whatever `CLAUDE_CODE_OAUTH_TOKEN` is already in the environment).

2. Local reverse proxy (`127.0.0.1:8787`): on each `startDaemon` run, the daemon TCP-probes the port. If nothing responds, it spawns a detached child running `bin/adapter/proxy/proxyEntry.js`. The proxy forwards every request to `api.anthropic.com`, observes the `anthropic-ratelimit-unified-*` response headers, and writes them to a per-token cache file at `${XDG_CACHE_HOME:-~/.cache}/tdpm/ratelimit/<sha256-of-token>.json`. The cache file stores the latest 5-hour and 7-day utilization, reset epochs, and the unified, 5-hour, and 7-day statuses (used to detect `blocked` and `rejected` state per window). The proxy also records model-specific weekly limit events from the streamed response body's `rate_limit` events (objects carrying `rateLimitType`, `status`, and `resetsAt`) in the token's `modelWeeklyLimits` cache; selection reads only the `seven_day` entry from that cache to determine whether a token's weekly window is exhausted. The `fable` model's weekly limit is reported by neither the response headers nor the streamed body events, so it is tracked reactively and in binary form. Because Anthropic exposes no per-model rate-limit data, a 429 on a `fable` request cannot be split into "fable's own weekly limit" versus "the token's overall weekly limit"; the proxy therefore records the marker only when the 429 is a genuine seven-day (weekly) rejection, which is the only case in which the token cannot serve `fable` regardless of model. Concretely: when a request whose JSON body selects a `fable` model (a `model` value containing `fable`, such as `claude-fable-5`) receives an HTTP 429 whose response carries `anthropic-ratelimit-unified-7d-status: rejected`, the proxy records a rejected `seven_day_fable` entry in that token's `modelWeeklyLimits`. A model-agnostic 429 that is not a weekly rejection — a five-hour-window rejection (`anthropic-ratelimit-unified-5h-status: rejected` while the seven-day window is not rejected) or a transient 429 carrying no rate-limit rejection headers at all — does NOT set the marker, so a token whose weekly `fable` quota is intact is not wrongly excluded from `fable` selection. The marker's `resetsAt` is taken from the `anthropic-ratelimit-unified-7d-reset` header when that reset is present, and otherwise falls back to the same cooldown rule as the headerless 429 case below (`now + Retry-After` seconds when the `Retry-After` header is present, clamped to a maximum of 600 seconds, otherwise a fixed default of 90 seconds). No remaining-quota or utilization percentage is computed for `fable`; the marker is a plain "this token's fable is currently exhausted" flag that clears once its `resetsAt` has passed. When a response is an HTTP 429 that carries no `anthropic-ratelimit-*` headers at all (for example a `rate_limit_error` with `x-should-retry: true`), the proxy records a short-lived per-token cooldown as a `blockedUntilEpoch` field on that token's cache file without discarding the last-good snapshot: the cooldown end is `now + Retry-After` seconds when the `Retry-After` header is present (clamped to a maximum of 600 seconds), otherwise a fixed default of 90 seconds. A later response that does carry `anthropic-ratelimit-*` headers overwrites the cache and clears the cooldown.

3. Selection: before spawning each `aw` job, the daemon reads every token's cache file. A cached observation is treated as expired once its reset epoch has passed: when the current time is past the 5-hour reset, that token's 5-hour utilization is treated as `0` and any 5-hour-window rejection is cleared; likewise, when the 7-day reset has passed, the 7-day utilization is treated as `0` and any 7-day-window rejection is cleared; a `modelWeeklyLimits` entry's rejection is cleared once that entry's `resetsAt` has passed. This stale-reset expiry prevents a token that has actually recovered from being locked out of rotation forever (an excluded token receives no new requests, so the proxy never re-observes it). After expiry normalization, a token is excluded for any of these reasons: its status is `blocked`, its (non-expired) 5-hour-window rejection is still active, its `blockedUntilEpoch` cooldown is still in the future, its 5-hour utilization meets or exceeds the configured threshold, or its `seven_day` entry in `modelWeeklyLimits` is rejected (and has not yet expired). The `seven_day` entry is the only weekly-limit check applied during selection; per-model keys such as `seven_day_sonnet` or `seven_day_opus` are not examined. A cooled-down token becomes selectable again automatically once its `blockedUntilEpoch` has passed. The remaining eligible tokens are sorted by time until the 7-day reset deadline ascending (soonest 7-day reset first, so quota that would otherwise expire unused is consumed first), with 5-hour utilization ascending as the tiebreaker. The 7-day reset deadline for a given spawn is read from the `seven_day` entry in `modelWeeklyLimits`; when no `seven_day` entry exists but the cache snapshot recorded a non-expired top-level `anthropic-ratelimit-unified-7d-reset` deadline, that header value is bridged into a synthesized `seven_day` entry whose `rejected` flag matches the (non-expired) `anthropic-ratelimit-unified-7d-status` rejection. When neither source supplies a deadline, the token is treated as having an infinite deadline and sorts last. Each token's per-run concurrent preparation slot count is determined by both its 5-hour and 7-day utilization, taking the more restrictive (minimum) of the two so concurrency ramps down as either window fills: for each window, at or below 80% the token receives the full six concurrent slots; above 80%, the slot count is reduced proportionally — specifically `ceil(6 × (1 − util) / 0.2)` — with a minimum of 1 slot at any utilization level, so near-exhausted tokens still participate rather than being cut off entirely. The final slot count is the minimum of the 5-hour-derived and 7-day-derived counts, multiplied by the token's `selectionWeight` and rounded down, with a minimum of 1 slot at any weight. A token configured with `selectionWeight: 0.8` therefore receives four concurrent preparation slots where an unweighted token receives six, so a token the operator wants held in reserve is given proportionally less preparation concurrency instead of the full amount. Before each `aw` spawn, among the tokens that still have an available slot (slot count minus the in-flight count minus the count already spawned this run, greater than zero), the token whose 7-day reset is soonest is selected; in-flight slot count from prior runs no longer routes new work away from a soon-reset token, so a soon-reset token's slots are filled before a later-reset token receives any, and remaining slot count is used only as the tiebreaker when two candidates share the same 7-day reset. The in-flight count is read from `/proc`: a process is counted against a token only when its environment exports that token as `CLAUDE_CODE_OAUTH_TOKEN`, its command line contains the preparation worker marker `Take ownership`, and no ancestor process already counted for the same token. An interactive Claude Code session started by a human with the same token in its environment is therefore not counted, because it is not a preparation worker; counting such sessions made a token look saturated and pushed preparation work onto the tokens the operator intended to hold in reserve. The effective preparation limit for a run is the lesser of the sum of all eligible tokens' slot counts and the explicit `maximumPreparingIssuesCount` (which defaults to 6). When a token list is configured but no eligible token remains after filtering, preparation is skipped for that run. The selected token and the proxy URL (`http://127.0.0.1:8787`) are passed to the child `aw` process as `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_BASE_URL` environment variables. The child inherits the parent process's environment, so no further wiring is required inside `aw`.

When `claudeCodeOauthTokenListJsonPath` is unset, no proxy is started and `aw` runs with whatever `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_BASE_URL` are already in the environment.

### Console Server Auto-start

When `consoleAccessToken` is set, `startDaemon` automatically starts the console server before the first preparation cycle. The mechanism mirrors the rate-limit proxy: at the start of each `startDaemon` run, the daemon TCP-probes port 9980 (`DEFAULT_WEB_PORT`). If nothing responds, it spawns a detached child process running `serveWeb` on that port, passing the same `--configFilePath` and `--port` arguments. The spawned process is detached from the daemon so it persists across daemon runs. SIGTERM and SIGINT sent to the daemon are forwarded to the console server child process, after which the daemon exits.

When `consoleAccessToken` is unset, no console server is started automatically; `serveWeb` must be started manually if needed.

### Slack User Token

#### scope

- channels:read
- groups:read
- mpim:read
- im:read
- chat:write
- identify
- usergroups:read
- users:read
- files:write
- files:read

## Project ID Cache

The mapping from `owner/projectNumber` to the immutable GitHub Project node ID that `GraphqlProjectRepository.fetchProjectId` resolves is cached on disk so it is shared across processes, and so is the reverse mapping from that node ID back to the owner, owner type and project number. Because every TDPM invocation is a fresh process, an in-memory cache alone would re-resolve these mappings on every loop and every worker start. The cache reuses the same `${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/cache/{projectName}/` directory convention as the issue cache and writes each entry atomically (to a `.tmp` file then renamed) so concurrent processes never corrupt or read a partial file.

```
${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/cache/{projectName}/projectId-{owner}:{projectNumber}/{timestamp}.json
${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/cache/{projectName}/projectLocation-{projectId}/{timestamp}.json
```

- The `projectId` mapping (`owner/projectNumber` to project node ID) is permanently static, so it is reused with no time-to-live.
- The `projectLocation` mapping (project node ID to `{owner, ownerType, projectNumber}`) is the same mapping read the other way round and is equally static, so it too carries no time-to-live. It exists because the Projects V2 REST routes are addressed by owner and project number while the code passes the project node ID around. It is written whenever either direction becomes known: when `fetchProjectId` resolves a project URL, and when a cold start GraphQL read of the project returns the project URL.
- Only these immutable mappings are cached. Project metadata that can change — the project's fields and single-select status and story options resolved by `getProject` — is intentionally not cached on disk and is fetched fresh on every call, so newly created status and story options are never missed. With the location known, `getProject` and `listFieldNames` read that fresh metadata from the Projects V2 REST endpoints (`GET /{ownerType}/{owner}/projectsV2/{projectNumber}` and `.../fields`), which spend the core rate-limit budget instead of GraphQL points; the GraphQL project query remains as the cold start path for the first read in a workspace.
- A malformed or absent cache file falls back to a live GraphQL fetch and never crashes, and a read or write failure of either cache is logged rather than swallowed, because a silently failing write would send every later process back to the GraphQL path with no signal.
- A recorded location can still go stale if the owner is renamed. When the REST read returns 404 the entry is discarded, the project is re-read over GraphQL, and the corrected location is written back, so a rename recovers by itself on the next read.

## Story Issue List Cache

The scheduled sweep finds the issue of each story by listing the repository issues that carry the `story` label (`GET /repos/{owner}/{repo}/issues` with `labels`, `state`, `per_page=100` and `page`) instead of the `search/issues` endpoint, whose rate-limit bucket is shared with the agents. Each page of a list is requested with its saved `ETag` as `If-None-Match`, so an unchanged page answers `304` and its saved items are reused. The `ETag` and parsed items of every page are kept on disk so they are shared across processes:

```
${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/issue-summary-list/{owner},{repositoryName},{label},{state}.json
```

## Per-Project Situation Snapshot

After each schedule cycle, TDPM writes a per-project situation snapshot to:

```
${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/cache/{projectName}/situation-{projectId}.json
```

This file is written atomically (written to a `.tmp` file then renamed) so external readers never see a partial write.

### JSON Shape

```json
{
  "capturedAt": "2025-01-01T00:00:00.000Z",
  "config": {
    "maximumPreparingIssuesCount": 6,
    "utilizationPercentageThreshold": 90,
    "thresholdForAutoReject": 3
  },
  "status": {
    "awaitingOwnerImmediatelyActionable": 2,
    "preparation": 4,
    "awaitingWorkspaceImmediatelyActionable": 3,
    "awaitingWorkspaceBlockedByDependency": 1,
    "failedPreparation": 2
  },
  "processes": {
    "runningPreparation": 4
  },
  "system": {
    "memory": {
      "usedPercent": 45.2,
      "usedGib": 7.25,
      "totalGib": 16.0
    },
    "swap": {
      "usedPercent": 25.0,
      "usedGib": 1.0,
      "totalGib": 4.0
    }
  }
}
```

### Field Descriptions

- `capturedAt`: ISO 8601 timestamp when the snapshot was captured.
- `config.maximumPreparingIssuesCount`: Resolved maximum number of issues allowed in preparation status (`null` if unconfigured).
- `config.utilizationPercentageThreshold`: Resolved 5-hour utilization hard threshold (percentage). Tokens at or above this value are excluded from rotation.
- `config.thresholdForAutoReject`: Resolved consecutive rejection count before auto-escalation.
- `status.awaitingOwnerImmediatelyActionable`: Count of issues in the awaiting owner status with no dependency URL, next action date, or next action hour set.
- `status.preparation`: Count of issues currently in preparation status.
- `status.awaitingWorkspaceImmediatelyActionable`: Count of issues in awaiting workspace status with no dependency URL, next action date, or next action hour set.
- `status.awaitingWorkspaceBlockedByDependency`: Count of issues in awaiting workspace status that have a dependency URL set.
- `status.failedPreparation`: Count of issues currently in failed preparation status.
- `processes.runningPreparation`: Count of spawned preparation processes confirmed running via `preparationProcessCheckCommand`. `null` if `preparationProcessCheckCommand` is not configured.
- `system.memory.usedPercent`: Host memory usage as a percentage (MemTotal - MemAvailable / MemTotal × 100).
- `system.memory.usedGib` / `system.memory.totalGib`: Used and total host memory in GiB.
- `system.swap.usedPercent`: Host swap usage as a percentage.
- `system.swap.usedGib` / `system.swap.totalGib`: Used and total host swap in GiB.

System metrics are read from `/proc/meminfo` at snapshot write time.

## Per-Project Console Lists

When `consoleDataOutputDir` is configured, each schedule cycle writes the full per-tab Console list files from the in-memory project and issue data already loaded for the cycle (no additional GitHub API calls). Additionally, `notifyFinishedIssuePreparation` immediately patches the relevant tab files on disk when it transitions an issue's status, so the console reflects the new status without waiting for the next scheduled cycle.

```
{consoleDataOutputDir}/{projectName}/{tab}/list.json
```

for `tab` in `workflow-blocker`, `prs`, `triage`, `unread`, `failed-preparation`, and `todo-by-human`. Each file is written atomically (written to a `.tmp` file then renamed) so external readers never see a partial write. When `consoleDataOutputDir` is unset the generation is skipped, and any error during generation is logged and swallowed so the schedule cycle is never affected.

### Item Selection

Most tabs apply a common actionable filter to the project's issues: the issue is open, is assigned to the project manager, has no depended issue URLs, and has neither a next action date nor a next action hour set. Each tab then applies its own selector:

- `prs`: status equals `Awaiting Owner` (case-insensitive)
- `unread`: status equals `Unread` (case-insensitive)
- `failed-preparation`: status equals `Failed Preparation` (exact case)
- `todo-by-human`: status equals `Todo by human` or the legacy value `Todo` (exact case)
- `triage`: every open issue assigned to the console owner whose story field is empty or whose story name contains `no story` (case-insensitive), the two being the same operational state because the `NO STORY` option is what this tool assigns to an issue that has none, whatever status it carries, whatever it depends on and whatever next action date or hour it holds, because the `NO STORY` story option is assigned by this tool rather than chosen by a person, so the triage decision stays open however far the status has advanced and however the issue is blocked or scheduled. Pull requests are excluded, because a pull request's story is written by this tool from its task issue. This tab is the one tab built from the full issue list rather than from the shared actionable set, so `In Tmux by agent` is listed here while every other tab drops it

The `workflow-blocker` tab is the one exception to the common actionable filter: it lists every issue whose story name equals the configured `workflowBlockerStoryName` (case-insensitive) and whose issue state is not closed, regardless of its status, next action date, next action hour, or depended issue URLs. When `workflowBlockerStoryName` is unset, the `workflow-blocker` list is always empty.

### JSON Shape

The `workflow-blocker`, `prs`, `unread`, `failed-preparation`, and `todo-by-human` tabs share this shape:

```json
{
  "pjcode": "my-project",
  "generatedAt": "2026-06-14T07:22:33Z",
  "statusOptions": [
    { "id": "...", "name": "Awaiting Workspace", "color": "BLUE" }
  ],
  "storyOrder": ["Story Alpha", "Story Beta"],
  "storyColors": { "Story Alpha": { "color": "BLUE" } },
  "items": [
    {
      "number": 1,
      "title": "Example",
      "url": "https://github.com/owner/repo/issues/1",
      "repo": "owner/repo",
      "nameWithOwner": "owner/repo",
      "projectItemId": "...",
      "itemId": "...",
      "isPr": false,
      "story": "Story Alpha",
      "labels": ["bug"],
      "createdAt": "2026-06-13T08:18:45.000Z"
    }
  ]
}
```

The `triage` tab omits `statusOptions`, adds `storyOptions` (all story field options), and uses plain color string values in `storyColors` (for example `"Story Alpha": "BLUE"`).

### Field Descriptions

- `pjcode`: The configured project name.
- `generatedAt`: UTC timestamp (no milliseconds) when the lists were generated. Item `createdAt` values keep milliseconds.
- `statusOptions`: Project status field options offered as routing buttons. The current-status option and `Done` are excluded; `failed-preparation` additionally excludes `Preparation`, `Icebox`, `Unread`, `In Tmux by human`, and `In Tmux by agent`.
- `storyOptions` (triage tab only): All story field options.
- `storyOrder`: Story field option names in field order (empty array when the project has no story field).
- `storyColors`: Map from story name to its color. Object value (`{ "color": ... }`) for `workflow-blocker`/`prs`/`unread`/`failed-preparation`/`todo-by-human`; plain string value for `triage`.
- `items`: Selected issues, stable-sorted by their story's position in `storyOrder` (unknown stories sorted last). No item carries a `body` field.

## In-Tmux-by-Human Data

When `inTmuxDataOutputDir` is configured, each schedule cycle also writes the in-tmux-by-human data files for the current project, generated from the same in-memory project and issue data already loaded for the cycle (no additional GitHub API calls). Each file is written atomically (written to a `.tmp` file then renamed) so external readers never see a partial write. When `inTmuxDataOutputDir` is unset the generation is skipped, and any error during generation is logged and swallowed so the schedule cycle is never affected.

### Item Selection

An issue is selected when its status equals `In Tmux by human` (exact match), it is open, and its assignees include the project manager. Selected issues are grouped by their story value (a null story maps to the empty string). Groups are ordered by the project story option display order; a group whose story is not among the story options is placed at the tail, ordered by the story string. Within a group, issues keep their input order.

### Files

For the current project code `{pjcode}` (the configured `projectName`):

```
{inTmuxDataOutputDir}/{pjcode}.json       # v1: [{ story, urls: string[] }]
{inTmuxDataOutputDir}/{pjcode}.v2.json    # v2: [{ story, urls: [{ url, title }] }]
{inTmuxDataOutputDir}/{pjcode}.v3.json    # v3: { version, overviewUrl, tdpmConsoleUrl, groups: [{ story, urls: [{ url, title }] }] }
{inTmuxDataOutputDir}/{pjcode}.v4.json    # v4: { version, overviewUrl, tdpmConsoleUrl, newIssueUrl, groups: [{ story, sessions: [{ name, description }] }] }
{inTmuxDataOutputDir}/{pjcode}.v5.json    # v5: v4 plus, on every session, unansweredCalls: [{ calledAt, body }]
```

and the cross-project index files:

```
{inTmuxDataOutputDir}/index.json          # { projects: string[] }
{inTmuxDataOutputDir}/index.v2.json       # { version: 2, projects: string[] }
{inTmuxDataOutputDir}/index.v3.json       # { version: 3, projects: string[] }
{inTmuxDataOutputDir}/index.v4.json       # { version: 4, projects: [{ name, path }] }
{inTmuxDataOutputDir}/index.v5.json       # { version: 5, projects: [{ name, path }] }
```

The index files list every project in `inTmuxProjectOrder` whose `{name}.json` already exists in the output directory, so successive per-project schedule cycles incrementally build the same shared index. In `index.v4.json` and `index.v5.json`, each `path` is `/{basename of inTmuxDataOutputDir}/{name}.v4.json?k={token}` and `/{basename of inTmuxDataOutputDir}/{name}.v5.json?k={token}` respectively.

### Unanswered Owner Calls (v5)

Every session of the v5 document carries `unansweredCalls`, the owner calls that session has raised and the owner has not answered, oldest first. Each entry has `calledAt`, the ISO 8601 UTC time at which the call was raised, and `body`, the text of the message that raised it. `calledAt` is the identity of a call: two calls carrying the same text are distinguished by their time. A session with nothing outstanding carries an empty array.

The calls are read from the owner call files written by the daemon when the session raises an owner call; without those files every session carries an empty array. A session is keyed by its tmux session name, derived from the issue URL exactly as the in-tmux session reconciler derives it.

### Field Descriptions

- `overviewUrl`: The GitHub Project board URL, taken from the project `url`.
- `tdpmConsoleUrl`: `{inTmuxConsoleBaseUrl}/projects/{pjcode}`. The v4 variant appends `?k={token}`. The v3 and v4 files are skipped when `inTmuxConsoleBaseUrl` is unset.
- `newIssueUrl` (v4 only): `https://github.com/{org}/{newIssueRepo ?? workingReport.repo}/issues/new?assignees={manager}`, derived from existing config values. The repo segment uses the optional `newIssueRepo` config value when set, otherwise it falls back to `workingReport.repo`; `newIssueRepo` overrides only this url and nothing else.
- `groups`: Story groups. The v3 `groups` carry a `urls` array using the v2 `{ url, title }` entry shape. The v4 `groups` use tmux terminology: each group carries a `sessions` array and each session is `{ name, description }` where `name` is the GitHub issue URL and `description` is the issue title.
- Token handling: The v4 per-project file and `index.v4.json` are skipped when `inTmuxConsoleToken` is unset. The token value is never written to source code, tests, or documentation; it is supplied through configuration only.

### Session Restart Reconciler

When `inTmuxLauncherCommand` is configured, each schedule cycle also reconciles the interactive sessions for `In Tmux by human` issues so that those sessions keep running. The same issues selected for the data files above (status `In Tmux by human`, open, assigned to the project manager) are checked for liveness, and any whose session is missing is restarted.

A session is considered live for an issue only when both of the following hold: a tmux session named after the issue URL exists, and a running process advertises the issue URL on its command line. The tmux session name is the issue URL with every non-alphanumeric character replaced by an underscore.

For each `In Tmux by human` issue with no live session, the reconciler launches a new detached tmux session named after the issue URL that runs `{inTmuxLauncherCommand} {issueUrl}`. Process and tmux inspection and session launching are performed through injectable ports, so the reconciliation logic is unit-testable without touching the host. When `inTmuxLauncherCommand` is unset the reconciler is skipped, and any error during reconciliation is logged and swallowed so the schedule cycle is never affected.

## Silent Live Session Notification

Each schedule cycle, when `silentNotificationEnabled` is true, this step runs and logs that the notification step is active but sends no notifications. When `silentNotificationEnabled` is not set, the step logs that it is skipped. Any error during the step is logged and swallowed so the schedule cycle is never affected.

`TDPM_SILENT_NOTIFICATION_ENABLED` (the string `true` enables the step) is the environment variable equivalent. The fleet-wide config file's top-level `silentNotificationEnabled` is the intermediate source between the per-project config key and this env var.

Each schedule cycle, when `outputDegenerationResetEnabled` is true, also inspects every live interactive Claude Code session named after a github.com issue or pull-request URL and resets a session whose output has collapsed into a repeat loop. A session is detected as degenerated when either of two independent conditions holds. The intra-turn condition looks at the session's most recent assistant turn and fires on any of three signatures: the longest consecutive run of an identical short token (at most 32 characters) is at least 5 and dominates at least 0.8 of the turn's tokens; or, after fenced code blocks, table rows, and list items are removed, an absolute consecutive run reaches at least 15 identical short tokens; or the duplicated 4-gram density of the filtered turn is at least 0.3 over at least 40 tokens. The cross-turn condition looks at the last 10 assistant turns and fires when the same isolated short purely-alphabetic trailing token recurs as the appended token in at least 4 of them. These thresholds are ported unchanged from the empirically-validated standalone monitor: healthy sessions never exceed 2 to 3 consecutive repeats of a single token in one turn and end turns with varied content, so both conditions have measured zero false positives. When a session is detected, a checkpoint warning is sent via `tmux send-keys`, the grace period is awaited, and the tmux session is killed so it is relaunched fresh; a per-session cooldown (default 300 seconds) prevents re-resetting the same session before its fresh relaunch completes. When `outputDegenerationResetEnabled` is not true the detection still runs and logs what it would do, but no warning or kill is performed, and any error during the step is logged and swallowed so the schedule cycle is never affected. Each config key above has a matching environment variable read when the config key is unset: `TDPM_OUTPUT_DEGENERATION_RESET_ENABLED` (the string `true` enables the step), `TDPM_OUTPUT_DEGENERATION_WARNING_MESSAGE`, `TDPM_OUTPUT_DEGENERATION_GRACE_SECONDS`, `TDPM_OUTPUT_DEGENERATION_COOLDOWN_SECONDS`, and `TDPM_OUTPUT_DEGENERATION_COOLDOWN_STATE_FILE_PATH`.

## Token Rotation Order File

After each schedule cycle where Claude OAuth token rotation is active, TDPM writes the computed rotation order to:

```
${XDG_CACHE_HOME:-~/.cache}/tdpm/rotation-order.json
```

This file is written atomically (written to a `.tmp` file then renamed) so external readers never see a partial write.

### JSON Shape

```json
[
  {
    "name": "personal-1",
    "fiveHourUtilization": 0.12,
    "blocked": false,
    "rejected": false,
    "thresholdExcluded": false
  },
  {
    "name": "personal-2",
    "fiveHourUtilization": 0.95,
    "blocked": false,
    "rejected": false,
    "thresholdExcluded": true
  }
]
```

The array is ordered with selected (eligible) tokens first, sorted by ascending `fiveHourUtilization`, followed by excluded tokens. Raw token values are never written to this file; each entry uses the `name` field from the token list JSON as its identifier.

### Field Descriptions

- `name`: Non-secret identifier for the token entry, taken from the `name` field in the token list JSON.
- `fiveHourUtilization`: Current 5-hour utilization ratio (0.0–1.0) for this token.
- `blocked`: `true` if the token is marked as blocked.
- `rejected`: `true` if the token's 5-hour window is rejected, which excludes the token from rotation.
- `thresholdExcluded`: `true` if the token is eligible (not blocked, not 5-hour-rejected, not in cooldown, and not weekly-limited for the requested model) but excluded because its 5-hour utilization is at or above the configured threshold, giving it zero available preparation slots.

## Cadence and Cache Contract

The `schedule` command runs two distinct processing loops within a single invocation:

### Fast path (runs every cycle)

The following use cases execute on every `schedule` trigger (cadence is determined by the caller's cron/daemon, typically ~3 minutes):

- `OwnerRepliedIssueRevertUseCase` — on every cycle, returns each open `Awaiting Owner` issue (pull requests excluded) to `Awaiting Workspace` when the owner replied after the newest agent comment: a comment from an `allowedIssueAuthors` author that is neither an agent comment nor a comment this tool posts itself (such as `Auto Status Check:`) was created after the creation of the newest agent comment, an edit to that agent comment never moving that point, an agent comment being an `allowedIssueAuthors` comment that starts with `From: :robot: ` or carries a fenced JSON object. The Status write is skipped when the item's live Status is no longer `Awaiting Owner`, and nothing is returned when `allowedIssueAuthors` is null, omitted or empty.
- `RevertNotReadyReviewQueueIssueUseCase` — handles two review-queue statuses in a single pass:
  - Awaiting Owner items: reverts items whose linked PR is conflicting, draft, missing, multiplied, has failing or never-started CI checks, or has unresolved review comments back to awaiting-workspace. For items that have no rejections it also runs the `change-target:<path>` auto-approve check: when the item carries one or more `change-target:` labels and every changed file of the ready PR is confined to the labeled paths, the PR is auto-approved.
  - Unread pull requests: evaluates each open Unread PR for review-readiness using the same criteria above; if the PR is not yet ready, the status is moved to awaiting-workspace and the item's Story is set to "workflow management".
  - Items with an `llm-agent` label are skipped in both loops.
- `RevertOrphanedPreparationUseCase` — reverts orphaned preparation issues back to awaiting-workspace
- `ClearDependedIssueURLUseCase` (items with a closed depended issue, on cycles that do not run the slow sweep) — runs before `StartPreparationUseCase` on the issue list the cycle already fetched and does not fetch issues again. For every open item whose Depended Issue URL lists a closed issue, it removes the closed and Icebox URLs from the field, so a task whose blocker closed is dispatched in the same cycle. It calls GitHub only for an item it changes, with the same field write, comment read and notification comment the slow sweep would make for that item. Items whose depended issues are only open, in Icebox, absent from the issue list or circular are left to the slow sweep. A failure is logged with the failing item URLs and the cycle continues.
- `StartPreparationUseCase` — starts preparation for issues ready to be worked on
- `NotifyFinishedIssuePreparationUseCase` — checks preparation-status issues and advances them. When the issue carries one or more `change-target:<path>` labels and all auto status checks pass, the related pull request's full file list is fetched (paginated, no 100-file truncation) and the PR is auto-approved before transitioning to Awaiting Owner if every changed file is under at least one of the labeled paths. Matching is boundary-safe (`change-target:foo` matches `foo/bar.ts` but not `foobar/baz.ts`); multiple `change-target:` labels are OR-combined. When no `change-target:` label is present, behavior is unchanged. When the issue carries one or more `change-target-must:<path>` labels, the PR is also rejected with a `CHANGE_TARGET_MUST_PATH_NOT_CHANGED` status when no changed file falls under a required path. The rejection submits a "request changes" review containing an inline comment on the first changed file (or a PR-level comment when the PR has no changed files) that names the required directory. `change-target-must:` paths are also treated as allowed paths for the confinement check, behaving identically to `change-target:` in addition to requiring at least one change under the path. When the last fenced `json` block that parses as JSON in the last trusted agent report includes a non-empty `nextStepAgent` string property, the use case sets the issue's project Agent field to that value (adding the value as an Agent field option, and the Agent field itself, when the project does not have them yet) and transitions the issue to Awaiting Workspace, so the agent named in the Agent field picks up the issue on the next dispatch.
  - `closeIssueAs` in the last fenced `json` block that parses as JSON in the last trusted agent report, the same block `nextStepAgent`, `workflowError` and `needOwnerConfirmationOrApproval` are read from, asks the tool to close the task issue, so the agent does not close it itself. The allowed values are `"completed"` and `"not_planned"`, the GitHub REST `state_reason` values behind the "Close as completed" and "Close as not planned" buttons. An absent field or `null` requests no close. The evidence for closing stays in the comment body and the JSON carries only the value, for example `{"closeIssueAs": "completed"}`.
  - Precedence: when the same block also sets a non-empty string `nextStepAgent`, a non-empty `workflowError` or `needOwnerConfirmationOrApproval: true`, the report is routed exactly as without `closeIssueAs` and the issue is not closed.
  - A valid value is evaluated as soon as the last agent report is located, before the unregistered `nextStepAgent` check, the CI-failing pull request hand-over and every Failed Preparation escalation (no report, REJECTED count, dispatch loop), so none of them prevents the close. When the issue is open and no open pull request closes it, the tool closes it with that state reason, then sets Status to `Done` and posts no comment. When the project's Status field has no `Done` option, the issue is still closed, Status is left unchanged and an error naming the project and the issue is logged. An issue whose Depended Issue URL or Reactivation Trigger is still pending moves to Awaiting Workspace as before and is not closed.
  - Open pull request rule: when an open pull request closes the issue on merge, the tool does not close the issue, logs a warning naming the issue and every such pull request, and routes the report exactly as without `closeIssueAs`.
  - Already-closed rule: when the issue is already closed, the tool does not close it again and sets Status to `Done`.
  - Invalid-value rule: any other value, such as `"not planned"`, `""`, `false` or `1`, is checked after the `needOwnerConfirmationOrApproval` branch, so the existing escalations still apply first. The tool then does not close the issue, posts `Invalid closeIssueAs value {received value as JSON}: allowed values are "completed" and "not_planned". The issue was not closed.` and sets Status to Awaiting Workspace.
  - `RevertOrphanedPreparationUseCase` applies the same precedence, open pull request, already-closed and missing `Done` option rules to an orphaned Preparation issue whose last trusted agent report carries a valid `closeIssueAs`, right after locating that report and before its unregistered agent check, its dispatch loop escalation and its `waitingForOwner` check, then moves on to the next issue. `waitingForOwner` is not a precedence signal. It reads the comments of an already-closed orphaned issue so the already-closed rule can apply; when that read fails, which is logged as an error, or no valid `closeIssueAs` applies, the closed issue keeps its existing routing to Awaiting Owner, else `Done`, else Awaiting Workspace.
- `SetWorkflowManagementIssueToStoryUseCase`
- `SetNoStoryIssueToStoryUseCase`
- `UpdateIssueStatusByLabelUseCase`

### Slow path (runs at most once per 600 seconds)

The following use cases run only when at least 600 seconds have elapsed since the last slow sweep:

- `CreateNewStoryByLabelUseCase`
- `ChangeStatusByStoryColorUseCase`
- `ActionAnnouncementUseCase`
- `ClearPastNextActionDateHourUseCase`
- `ClearDependedIssueURLUseCase` — removes closed and Icebox depended issue URLs, and absent and circular ones when the issue list came from a full fetch; on a slow sweep cycle it runs instead of the fast path removal
- `CreateEstimationIssueUseCase`
- `ConvertCheckboxToIssueInStoryIssueUseCase`
- `AssignNoAssigneeIssueToManagerUseCase`

The timestamp of the last slow sweep is stored in the `HandleScheduledEvent` Google Spreadsheet sheet (column E, row 2), under the header `LastSlowSweepDateTime`.

### Issue cache contract

On each access the repository chooses between a full fetch (when the cache is absent or stale) and a two-phase, time-precise incremental fetch (when the cache is fresh). The incremental path separates a lightweight day-scan phase from a targeted detail-fetch phase, keeping per-cycle GitHub API usage proportional to the number of items updated since the last fetch rather than proportional to total project size.

The issue repository keeps a single always-latest JSON file per project at `${XDG_CACHE_HOME:-$HOME/.cache}/tdpm/cache/{projectName}/allIssues-{projectId}/latest.json` and refreshes it on access. That base directory is resolved from the user's cache home rather than from the process working directory, so a worker session started inside its own worktree reads the same cache the daemon and every other session write, instead of finding an empty directory and paginating the whole project board again. Each refresh writes the file atomically (to a `.tmp` file then renamed) and records `lastFetchedAt`, `lastFullFetchAt`, the cached `project`, and the `issues` array in the same file.

- When the cache file is missing or its `lastFullFetchAt` is at least one hour old, the repository fetches every project item, replaces the whole issue list, refreshes the cached `project`, and sets both `lastFullFetchAt` and `lastFetchedAt` to now (`cacheUsed` is `false`).
- Otherwise the repository performs a two-phase, time-precise incremental fetch, reusing the cached `project` and setting `lastFetchedAt` to now while leaving `lastFullFetchAt` untouched (`cacheUsed` is `true`). Phase 1 (light day scan): it queries `items(query: "updated:>=<YYYY-MM-DD>")` for the UTC day of the previous `lastFetchedAt` with no previous-day overlap, requesting only each `ProjectV2Item` `id` and `updatedAt` plus the content `url` and `number` (no `fieldValues`), paginating all pages. Because the GitHub ProjectV2 `updated:` filter is day-granular, the results are then filtered client-side to only items whose `ProjectV2Item.updatedAt` is greater than or equal to a cutoff equal to the previous `lastFetchedAt` timestamp minus a small clock-skew buffer (5 minutes), giving time precision while absorbing realistic clock skew between the daemon host and GitHub; the same cutoff date drives the phase-1 query, so the UTC-midnight boundary is handled by construction. The re-included buffer window is idempotent because upserts are keyed by issue URL. Phase 2 (detail fetch): only when that filtered set is non-empty, it fetches the full item detail for those items by node id via `nodes(ids: [...])` in batches of up to 100, converts each to an issue, and upserts them into the cached list keyed by issue URL; when the filtered set is empty phase 2 is skipped entirely.
- Within a single process the refresh runs at most once per project; every subsequent `getAllIssues` call for the same project returns the memoized result, so the many in-cycle reads do not each call GitHub.
- Clearing or rewriting the Depended Issue URL field through the repository replaces the matching issue in the memoized issue list and writes the new value into the cache file, so every later read in the same process (`getAllIssues`, `getStoryObjectMap`, `getAllOpened`, `getIssueByUrl`) sees the written value without another GitHub call. Issue objects handed out before the write keep the value they had.
- The two-phase incremental fetch (shipped in v1.123.0) is live on the default branch and available via `npm install npm-cli-github-issue-tower-defence-management@latest`.
- `ConvertCheckboxToIssueInStoryIssueUseCase` always fetches the latest story-issue body directly via `getIssueByUrl`, ensuring story bodies are never stale when converting checkboxes to issues.
- Because the memoized list can be older than a value another client wrote a few seconds ago, the use cases that pick an item from that list and write its Story, Status or Agent first read that single item live through `get` (one item query, no list pagination) immediately before the write. The write is skipped, with a warning naming the changed field and both values, when a field that made the item eligible no longer holds its listed value or the item is no longer on the project; the next cycle evaluates the item again from fresh data. This applies to `SetNoStoryIssueToStoryUseCase` and `SetWorkflowManagementIssueToStoryUseCase` (Story), the default Agent write of `AgentDesignationLabelAdoptUseCase`, and the Status writes of `IssueNoStatusUpdateUseCase`, `ChangeStatusByStoryColorUseCase`, `QualityCheckAdvanceUseCase`, `ReopenedDoneIssueRevertUseCase`, `RevertNotReadyReviewQueueIssueUseCase`, the open-item recovery of `SetupTowerDefenceProjectUseCase`, and the Todo by human Status write for an author-not-allowed issue in `StartPreparationUseCase`. `StartPreparationUseCase` re-reads each spawn candidate live through `get` rather than from the on-disk cache before moving it to Preparation. The same live re-check also gates `NonPreparationWorkerScopeStopUseCase`'s worker-scope stop decision, which is not a Story/Status/Agent field write but an equally irreversible action: immediately before stopping a running worker scope whose snapshot `status` is not Preparation, it re-reads that issue live through `get` and skips the stop when the live `status` is now Preparation, still stops it when the live read finds the item removed from the project or its `status` still not Preparation, and isolates a live-read failure for one running scope so it does not stop that scope without aborting evaluation of the other running scopes in the same cycle.

## Migration Guide

### v1.x → v2.0.0

**Breaking change: `allowedIssueAuthors: null` is now fail-closed**

In v1.x, passing `null` (or omitting) `allowedIssueAuthors` caused `notifyFinishedIssuePreparation` and `checkIssueReviewReadiness` to trust every comment author. In v2.0.0, `null` or an omitted value means no author is trusted — agent reports from any author are rejected.

To preserve the v1.x behavior of processing agent reports, set `allowedIssueAuthors` to an explicit list of trusted GitHub usernames in your config file:

```yaml
startPreparation:
  allowedIssueAuthors:
    - HiromiShikata
```

**Breaking change: unknown `nextStepAgent` values are now rejected**

In v1.x, a `nextStepAgent` value not present in the configured `agents` list was silently accepted and added as a new agent field option. In v2.0.0, when `agents` is configured as a non-empty list, any `nextStepAgent` value not in that list causes the issue to transition to `Failed Preparation` with an error comment.

## Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md)

## License

MIT
