--- name: maintain description: 'SDLC maintenance stage. Gate on capability tags enforced in code by sdlc-gate (maintenance requires no specific tag, but the actual model id must be determinable from the transcript), read projects still inside their maintenance window from MAINTAIN_CONTENTS, which sits in the separate CONTENTS wiki repo, then run one sub agent per project: switch to develop or master, propose at least five maintenance actions, commit to a new branch, push, and PR. Write a jsc-log:worklog entry per finished project and update the last-maintained timestamp afterward through jsc-gitea/tools/wiki-contents.sh, then close with tools/stage-report.sh - model tag verdict, worklog link, every wiki link written. Use for periodic upkeep of delivered projects inside their maintenance window; not for projects still mid-implementation or not yet registered in MAINTAIN_CONTENTS.' --- # maintain Goal: run routine maintenance for every project in the maintenance contents page. **`MAINTAIN_CONTENTS` lives in the CONTENTS wiki repo**, the one `jsc-gitea/tools/gitea.sh wiki-repo CONTENTS` resolves: `JSC_WIKI_REPO_CONTENTS` first, `JSC_WIKI_REPO` second, exit 3 when neither is set; it **never** falls back to `JSC_WIKI_REPO_MAINTAIN`. `MAINTAIN` is the one page type with no content page, so this directory page is the whole record — read it and write it there, and nowhere else. Every link it carries is written as `[{text}]({url})`, and the `{url}` is the absolute URL from `gitea.sh wiki-url {that type's repo} {page}` — one link syntax, whichever wiki the page sits in. The syntax and the check that runs before every write: "Every link is checked before it reaches a page" below. Branch on `wiki-url`'s exit code every time — this is the same branching `jsc-log:worklog` runs over the same call: | Exit | What this stage does | | --- | --- | | 0 | use the URL it printed, verbatim | | 4 | that page is not on the wiki — leave the cell with the literal 「無」 and say so, or, when the page was supposed to have been written by this run, go back and write it before coming here again | | 5 | the page exists but carries no `html_url` — stop and report it, and never assemble the URL by hand; a hand-built path is not the one Gitea serves | | 7 | the key is invalid or lacks permission (HTTP 401/403) — stop and report the key problem. Never read this as exit 4 and never fill 「無」: the page is alive, and 「無」 records a live page as one that does not exist | | 8 | any other API failure — stop and report that status, and do not retry the same call unchanged | Never let an empty string stand in for the URL: a cell that is empty names a page nobody can open, and the next run rewrites that row as if it were correct. All wiki reads and writes go through `jsc-gitea:wiki`. **A failed wiki read or write stops this stage**: report which page and which operation failed, never carry on against a page you could not read, and never report a page as saved when the write failed. Steps 5 and 6 still run after such a stop. ## Steps 1. **Model gate and stage lock** — run `jsc-cli/tools/model-tags.sh sync`, then `jsc-hooks/hooks/sdlc-gate.sh lock maintain`. This stage requires no specific capability tag; the gate passes as long as the script can determine the actual model id. Rules: `references/model-gate.md`. Completion condition: the script exited 0, and you have reported the stage, the required tag, the actual model id it read from the transcript, and the verdict. 2. Read `MAINTAIN_CONTENTS` via `jsc-gitea:wiki`, out of the CONTENTS wiki repo (`gitea.sh wiki-repo CONTENTS`, never the MAINTAIN one), and filter projects **still inside their maintenance window**: start date ≤ today, and (end date is NULL or ≥ today). Completion condition: you have listed every in-window project with its `{owner}/{repo}` and window dates, or reported that none is in window and stopped. 3. **Align every project in one batch first, then run one sub agent per project.** Completion condition: every project listed in step 2 has its sub agent finished, and each one ends in either a PR link or a recorded skip reason. 1. **Batch prefetch, run by the main agent before any sub agent starts.** For every in-window project from step 2, run `git fetch --prune origin`, then put it on its maintenance branch and align it with `origin/{branch}`. **The projects are independent — run this batch concurrently**, and hand each sub agent the branch name and the aligned commit sha instead of letting it fetch again. Which branch that is, the remote-is-the-basis rule, the diverged case and the never-pull-never-reset rule all live in `references/branch.md`; never guess the branch name. From sub-step 3.2 onward the flow is one project at a time, sequential, so that 3.5's work log rule holds. Completion condition: every project's HEAD points at the same commit as `origin/{branch}`, or its gap is reported and that project is skipped and left out of the sub agent runs. 2. **From here on, one project at a time, and each project's maintenance MUST run as a sub agent.** Propose **at least five** maintenance methods, then let the user pick per `jsc-ask:ask` rules — every option states its impact scope (which files it touches, whether it can break the build, how much review it costs). Candidates: - dependency updates (reuse `jsc-pkg:pkg-update`) - security vulnerability scan and patching - dead code and stale comment cleanup - test coverage reinforcement - docs and README synchronization - build warning elimination Completion condition: the user has picked the methods to apply, and every picked method is either applied or reported with the reason it could not be. 3. **A code comment states why the code is written this way; it never states where the work is tracked.** Issue numbers, commit hashes, branch names, people's names and `@` mentions stay out of every code comment this project's maintenance touches — including the comments the cleanup method rewrites. Full list and the allowed exceptions: `jsc-review/references/comment-scope.md`. Two passes already cover the diff, so **run no separate manual sweep of your own**: `jsc-hooks/hooks/comment-scope.sh` compares each file after it is written and prints a warning — fix the flagged line at once, then carry on — and `jsc-git:commit` sweeps the whole working tree again in step 3.4, before anything is committed. **Coverage is not the same on every CLI**: only claude gets the per-file warning as the file is written. On codex, kiro, copilot and antigravity the hook fires late — at the end of the turn on codex, at the next prompt submit on kiro, at the end of the session on copilot and antigravity — so the pre-commit sweep in step 3.4 is the only pass on all four that lands in time to keep a flagged comment out of the commit. Completion condition: every warning the hook printed is fixed, and the step 3.4 sweep reported no remaining comment line carrying an issue number, a commit hash, a branch name, a person's name or an `@` mention. 4. Commit the changes to a new branch per `jsc-git:commit`, push, then open a PR per `jsc-git:pr` back to the branch of step 3.1, passing it explicitly as the base. Completion condition: the PR exists, and you have reported it with the table format in `jsc-meta/references/pr-report.md`. 5. **One project's maintenance is one finished task — call `jsc-log:worklog` right after its PR is open.** A task is one of three things: one work package, one round of PR-comment fixes, or one standalone fix commit; this stage produces the third kind, one per project. Never let the stage end and then write a single catch-up entry, and never let a second project start before the first one's entry is saved — by then the elapsed time, the token counts and the difficulties are gone. Every entry appends to the same `LOG_{HASH}` page. Content parked earlier by `tools/stage-report.sh --pending-file` is merged into that same write and cleared only once the write succeeds; parked content is not a written log. Completion condition: this project's entry is saved on `LOG_{HASH}` before the next project's sub agent starts. 6. Update the project's last-maintained field (the zh-TW column 「前次維護時間」) in `MAINTAIN_CONTENTS` to today with `jsc-gitea/tools/wiki-contents.sh upsert MAINTAIN 1 {owner}/{repo} {row file} templates/maintain-contents.md`, never by hand-editing the page. Rebuild that project's row from the one the page already holds, change only the 「前次維護時間」 cell, and keep every other cell byte-for-byte as it was; the key is column 1, the repository name, written exactly as the row file writes it. **A row that carries a link goes through `jsc-gitea/tools/link-check.sh` before the upsert, and is upserted only on exit 0** — see "Every link is checked before it reaches a page" below. Branch on the upsert's exit code per "Contents pages are appended, never overwritten" below. Completion condition: the script exited 0, every link in the rebuilt row was cleared by a `link-check.sh` run that exited 0, `MAINTAIN_CONTENTS` shows today's date in 「前次維護時間」 for that project, and every other project's row is byte-for-byte unchanged. 4. The main agent reports the summary: maintenance methods applied per project, PR table rows, and failure reasons. The report and all generated wiki content, commits, and PR descriptions stay Traditional Chinese per the STE100 rule. Completion condition: the summary names every project read in step 2, each with its applied methods and either a PR table row or the reason it was skipped. 5. **Stage report — the last thing this stage does, including when no project was in window, and when a wiki read or write failed.** Run `tools/stage-report.sh maintain` with one `--page TYPE:{page}` per wiki page this run wrote — that is `--page CONTENTS:MAINTAIN_CONTENTS`, under the `CONTENTS` type, because the script resolves each page's repo from the TYPE you pass and `MAINTAIN:` would resolve the wrong repo and print no URL — plus `--worklog` and `--worklog-heading` pointing at the entries step 3.5 wrote. `--pending-file {file} --log-hash {HASH}` is the fallback for a stage that stopped before any project finished: it holds the content for the next `jsc-log:worklog` run, and held content is not a written log. Rules and exit codes: `references/stage-report.md`. Exit 1 is a warning, never a block. Completion condition: the script's output is reported to the user verbatim, and every wiki page this run wrote appears in it. 6. **Write this run's `skill-end` status event — the very last thing this stage does, right after step 5, on every path including when no project was in window.** Run `jsc-hooks/tools/report-status.sh skill-end jsc-sdlc:maintain {status} {exit} [detail]`, naming the script the way this stage already names `jsc-hooks/hooks/sdlc-gate.sh` in step 1. The matching `skill-start` event is written by jsc-hooks on its own, so this step owes only the `end`: a hook fires on the skill tool call and this stage's work happens in the model turns after it, so **no hook can see how this run ended**. A `start` with no `end` is what an aborted run looks like in the record, and a stage that often ends with nothing to do needs that difference recorded, not guessed. `{status}` is one of five words, never a sixth: | Status | When `maintain` reports it | | --- | --- | | `ok` | Every step's completion condition is met: the gate passed, every in-window project ran its sub agent and ended in a PR, each finished project has its work log entry, `MAINTAIN_CONTENTS` shows today in 「前次維護時間」 for every one of them, and `tools/stage-report.sh` exited 0 | | `blocked` | A check that lives in code stopped the run before any maintenance: `sdlc-gate.sh lock maintain` exited non-zero because the script could not determine the actual model id from the transcript, which is the one thing this stage's gate asks for; or step 3.1 found every in-window project out of step with `origin/{branch}`, so all of them were skipped and not one maintenance action ran. Nothing was maintained, so this is **never `failed`** — both are the guard working | | `failed` | Maintenance ran and then a write did not land: `wiki-contents.sh` returned 1, 7 or 8 over `MAINTAIN_CONTENTS`, `wiki-url` returned 5, 7 or 8, or `link-check.sh` returned 1 so the row was never written. `MAINTAIN` has no content page, so a row that never lands loses the whole wiki record of this run — that is why it is `failed` and not `degraded` | | `degraded` | Some projects came through and some did not: one project was skipped for a branch gap or a method that could not be applied while the others got their PR, or every project got its PR while `wiki-contents.sh` returned 3 so no 「前次維護時間」 was updated, or `tools/stage-report.sh` exited 1 (no work log, or a link in its list does not answer) | | `aborted` | The user stopped the run, or the run stopped itself because its premise did not hold — step 2 found no project inside its maintenance window, so there was nothing to maintain | `{exit}` is the exit code of the script whose verdict decided the status — the gate's code for `blocked`, the failing script's code for `failed` and `degraded` — and `0` when nothing exited non-zero, `ok` and `aborted` included. `[detail]` is optional and Traditional Chinese per the STE100 rule: one line, no line break, naming what decided the status (for example 「無專案在維護期內」 or 「兩個專案與遠端有落差已略過」). The script truncates it at 200 characters, so put the short reason there and nothing else. **A failure in this step never changes this stage's verdict.** The script is not found (jsc-hooks is not installed on this machine, or this CLI's layout puts it somewhere else) → skip the event quietly and carry on; nothing is reported to the user and no step is re-run. The three recording sub-commands are built to exit 0 even when the write fails, so a non-zero code here means only that the call itself was malformed (exit 2, a usage error) — fix the arguments once and, either way, never turn a stage that opened its PRs into a failed one because the record of it failed. Completion condition: one `skill-end` event has been written for this run, or the script could not be found and that skip is the reason no event exists. ## Every link is checked before it reaches a page **One syntax.** Every link this stage writes — in the `MAINTAIN_CONTENTS` row, in a PR description, in the stage report — is written as `[{text}]({url})`. The `{url}` is the absolute URL `gitea.sh wiki-url {repo} {page}` printed, used verbatim: never assemble a wiki path by hand, and never write a link as `[[頁名]]` or `[[顯示文字|頁名]]`. That form resolves only inside the wiki it sits in, and it fails without an error — the reader sees plain text or a dead link, so a wrong link is neither noticed nor fixable. **Checked before it is written.** Collect every link the row is about to carry, hand them all to `jsc-gitea/tools/link-check.sh` in one run — `link-check.sh {網址}...`, or the same URLs on stdin, one per line — and write only when that run exits 0. It prints one `{OK|DEAD|SKIP}{網址}{說明}` line per URL. The check goes through the API, never a web status code: a private repository's web URL answers 404 to a request carrying no key, so a status-code check marks live pages dead. | Exit | What this stage does | | --- | --- | | 0 | every link answers — upsert the row | | 1 | at least one link is dead — **write nothing**, and report the `DEAD` lines to the user | | 2 | usage error: not one URL was passed — pass the links and run it again | | 3 | the list holds a Gitea URL but `GITEA_HOST` is unset — report it as a setting to fix and run it again, and never skip the check instead | | 7 | Gitea authentication failed (401/403) — stop and report the key problem. Never read this as exit 1: an expired key makes live pages look absent, and `MAINTAIN_CONTENTS` is the whole record of this stage, so a row rewritten on that reading loses links nothing else holds | Completion condition: every row this stage wrote was cleared by a `link-check.sh` run that exited 0, and every non-zero code was branched on as this table says. ## Contents pages are appended, never overwritten `MAINTAIN_CONTENTS` is a shared directory in the CONTENTS wiki repo: every row on it belongs to somebody's project, and this run reads none of those rows from anywhere else. So step 3.6 is an upsert of one row — never a whole-page overwrite, and a project this run did not maintain keeps its row untouched. `jsc-gitea/tools/wiki-contents.sh upsert` is the one way this stage does it: it resolves the CONTENTS repo, reads the whole page, replaces the row whose key column matches and appends when none matches, then writes the page back. Branch on its exit code: | Exit | What this step does | | --- | --- | | 0 | the row is in place — carry the `updated` or `added` word it printed into the stage report | | 1 | the write failed, or the page holds no markdown table — report it as a failed write and go to step 5 as a failure | | 2 | an argument was rejected (unknown type, key column, missing row file) — fix the argument and run it again; nothing was written | | 3 | no CONTENTS wiki repo is configured — stop and report `JSC_WIKI_REPO_CONTENTS` and `JSC_WIKI_REPO` as the two variables to set. `MAINTAIN` has no content page, so nothing of this stage's record survives elsewhere: the PR is open but the maintenance date is unrecorded, and it is reported that way | | 4 | the directory page is absent and no template was passed. **Step 3.6 always passes `templates/maintain-contents.md`, so this code does not come out of this skill's call** — a wrong template path is rejected as 2, not as 4. Seeing it anyway means the template file is not where the plugin puts it: confirm the plugin installation is complete and run it again, and never answer it by dropping the template argument. An absent page also means step 2 had no project to maintain | | 7 | the key is invalid or lacks permission — stop and report the key problem; the script wrote nothing, which is what keeps every other project's row alive | | 8 | any other API failure — stop and report that status, and do not retry the same call unchanged | Why 7 and 8 abort: both mean the old content is unknown, not that the page is missing. Reading either as "not there yet" would write a fresh template over a live directory, and every other project's maintenance window is gone — the write carries no merge and no backup. Content pages, which belong to one subject each and live in their own type's repo, are the opposite case and may be rewritten whole. The distinction is the page, not the write. Completion condition: the `MAINTAIN_CONTENTS` write names the `wiki-contents.sh` exit code it branched on, and the directory page was created on no code other than 4.