--- name: assistant description: 'Start, inspect, patrol or stop the background assistant: jsc-hooks/hooks/heartbeat.sh owns the freshness verdict, tools/schedule.sh the system scheduler, tools/patrol.sh one round. The heartbeat is written by a completed round and by nothing else, so the schedule carries the patrol entry only, its period from the heartbeat TTL; start runs one round then installs that entry - absolute CLI path, environment snapshot, unattended write confirmation, which cron lacks - status prints heartbeat, schedule and task book read-only, stop removes the entry before clearing the heartbeat. One round reads five independent sources - skill and chain usage, version gaps and the restart gate, SDLC stage and work-package locks, the heartbeat''s own report, and the status event stream that jsc-hooks/tools/report-status.sh drains and rotates, whose starts with no matching end are the only evidence an earlier skill run aborted - then rewrites wiki MONITOR_{HASH} through jsc-gitea:wiki as three fixed blocks - basic data untouched, the latest round replaced whole, a 24-row summary table - and upserts its MONITOR_CONTENTS entry through jsc-gitea/tools/wiki-contents.sh, which reads the separate CONTENTS wiki repo and keeps one H2 block per machine - the heading is the monitor page''s own name, the fields are bullets under it, and one of them links that page by its absolute wiki-url. Every link on either page is written as [text](URL) and is verified by jsc-gitea/tools/link-check.sh before that page is written, so a dead link stops the write instead of landing on the page. A round that cannot record its result writes no heartbeat; one that starts while the previous holds the lock stands down. Use when someone starts, patrols or stops the assistant, or asks whether it runs and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats).' --- # assistant — start, status, patrol, stop The background assistant runs where nobody is watching it. Its heartbeat is the only evidence that it is alive, so this skill is the single entry point for the four operations that touch that evidence: `patrol` writes it, `status` reads it, `stop` clears it, and `start` bootstraps the whole loop. `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` owns every heartbeat operation, including the freshness verdict. Never read, parse, write or delete `$JSC_HOME/assistant/heartbeat` directly — one verdict, one source. `$JSC_HOME/current/jsc-assist/tools/schedule.sh` owns every system-scheduler operation: installing an entry, removing it, and reading which entries exist. Never call `crontab` or `schtasks` from this skill, and never edit a crontab by hand. `$JSC_HOME/current/jsc-assist/tools/patrol.sh` owns one patrol round: taking the round lock, reading the five sources, composing the monitor page's blocks, and — after the page carries this round — writing the heartbeat. Never re-read a source this skill already handed to that script, and never compose a block by hand; the script prints the file paths. All three flows have fixed inputs and outputs, so all three live in scripts. The task book is the only thing this skill reads for itself, and that is one directory listing. ## Tool paths Every tool below is addressed through `$JSC_HOME/current/{plugin}`, and `$JSC_HOME` falls back to `~/.jsc` exactly as everywhere else in this skill: | What it does | Path to run | | --- | --- | | one patrol round | `$JSC_HOME/current/jsc-assist/tools/patrol.sh` | | the system scheduler | `$JSC_HOME/current/jsc-assist/tools/schedule.sh` | | the heartbeat | `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh` | | the status event stream | `$JSC_HOME/current/jsc-hooks/tools/report-status.sh` | | the wiki, through `jsc-gitea:wiki` | `$JSC_HOME/current/jsc-gitea/tools/gitea.sh` | | the `MONITOR_CONTENTS` entry | `$JSC_HOME/current/jsc-gitea/tools/wiki-contents.sh` | | the link check every write depends on | `$JSC_HOME/current/jsc-gitea/tools/link-check.sh` | **A `Skill(...)` rule permits invoking that skill and nothing more.** Every Bash call inside it is still checked on its own, so `jsc-gitea:wiki` reaching the wiki depends on `gitea.sh` carrying its own rule, the directory entry depends on `wiki-contents.sh` carrying one too, and both writes depend on `link-check.sh` carrying one — without them the round is refused locally, before any request leaves the machine, and the page never gets written. **Never build a tool path out of the base directory the CLI hands you in the skill prompt.** That directory points into the plugin cache and carries a version segment, and the permission gate allows exactly the seven paths above and nothing else. A cache path is therefore refused silently: the round stops on a permission prompt nobody can answer, records nothing, writes no heartbeat, and the refusal looks exactly like a broken tool. Read the paths off this table every time — not off the prompt, not off a previous transcript, not off `crontab -l`. Both scripts check this for themselves: run from anywhere outside `$JSC_HOME/current`, they print a `[WARN]` line on stderr naming the path they were started from and the path they should have been started from, and then carry on. That line means this round is on the wrong path — quote it, fix the path, and do not treat the round's success as proof that the path was fine. `current` is a set of version-free links that `jsc-cli:deploy` maintains, so an upgrade moves the cache and leaves these paths alone. When one of them is missing, report the missing link and say `jsc-cli:deploy` has to run; never fall back to a cache path to get the round through, and never create the link here. ## Two rules bind every link this skill writes Both pages this round writes carry links, and both rules below hold for every one of them — the monitor page and the directory entry alike. **Rule A — a link is always written as `[{text}]({URL})`.** The wiki's own `[[page]]` and `[[text|page]]` forms are not used here at all, and neither is the split between "same repo" and "cross repo" writing. The URL comes from `$JSC_HOME/current/jsc-gitea/tools/gitea.sh wiki-url {repo} {page}`; never assemble a path by hand. `[[...]]` resolves only inside the wiki it sits in: the monitor page and the directory page live in two different repos, so a `[[MONITOR_{HASH}]]` written into the directory entry renders as an ordinary-looking link that goes nowhere, and nothing reports it. **Rule B — a link is verified before it is written, never after.** Collect every link that is about to go into the page, hand the whole set to `$JSC_HOME/current/jsc-gitea/tools/link-check.sh`, and write only on exit 0. The script prints one `{OK|DEAD|SKIP}{URL}{note}` line per URL and checks Gitea URLs through the API, never through the web status code — a private repo answers 404 to a logged-out web request, so a status-code check condemns live pages. | Exit | Meaning | Do | | --- | --- | --- | | 0 | every link resolves | write the page | | 1 | at least one link is dead | write nothing, report the `DEAD` lines verbatim, and take the step's own abort row | | 2 | usage error — no URL was given | a defect in the call: pass the URLs and run it once more | | 3 | a Gitea URL is in the list but `GITEA_HOST` is unset | report the variable and stop; never skip the check to get the write through | | 7 | Gitea authentication failed (401, 403) | stop and report it as a token problem, never as dead links — an expired token makes live private pages look missing | A round with no link to write skips the call and says so; a round that cannot verify writes nothing. ## Pick the operation Run exactly one operation per invocation. Take it from the request: starting, launching or waking the assistant is `start`; asking whether it runs, what it is doing, or what is queued is `status`; running one round, patrolling, or a scheduled wake-up is `patrol`; stopping, halting or shutting it down is `stop`. When the request names none of the four, or names more than one, ask through the `jsc-ask:ask` decision tree with those four as the options, each stating its effect — `start` runs one round and installs the scheduled entry that keeps running rounds, `status` changes nothing, `patrol` runs one round and writes one heartbeat, `stop` removes that entry and deletes the heartbeat. **The one exception: a `patrol` invocation never asks anything at all** (see 界線 1 below). Never guess, and never run a second operation the caller did not ask for. Completion condition: exactly one of `start`, `status`, `patrol`, `stop` is chosen and named in the report. ## Every operation ends with one status event The last thing any of the four operations does, after its report is printed, is write its own end event: `$JSC_HOME/current/jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {exit} "{one line}"` The `start` half is already on record — a hook writes it when this skill loads — so this call is what tells the difference between an operation that finished and one that stopped half way. **Skipping it makes this skill's own run look aborted**, and the next patrol round reports it as such, on the page this skill writes. Pick the status from what actually happened: | status | When | | --- | --- | | `ok` | the operation reached its own completion condition | | `blocked` | the round stood down because another round holds the lock, or `install heartbeat` was refused — nothing was done and nothing is wrong | | `failed` | a step returned a code that stopped the operation: `collect` exit 5 or 6, a monitor-page write that could not be made, `remove` non-zero in `stop` | | `degraded` | the operation finished with a known gap: the directory entry was left unwritten, or the schedule entry was installed but the cron service is stopped | | `aborted` | the operation stopped because a precondition did not hold, such as an empty `hash=` or a missing `current` link | The call never changes the outcome: it returns 0 even when it cannot write, and a non-zero from it is reported as a defect in the reporting chain, never as a failure of the operation that just succeeded. Completion condition for all four operations: exactly one `skill-end` was written, and its status matches the outcome that was reported. ## Data sources | Path | Read by | Format | | --- | --- | --- | | `$JSC_HOME/assistant/heartbeat` | `heartbeat.sh` only, never this skill | `key=value` lines: `ts`, `pid`, `cli`, `session` | | `$JSC_HOME/assistant/schedule.log` | nobody here — the scheduled entry appends to it | free text; point the operator at it when a scheduled round misbehaves | | `$JSC_HOME/assistant/tasks/{id}` | this skill, read-only | `key=value` lines, one task per file: `id`, `kind` (`check` / `todo`), `title`, `action`, `trigger`, `recur`, `repo`, `due`, `state` (`pending` / `done` / `paused`), `last_run`, `next_run`, `fail_count`, `origin` (`user` / `assistant`) | | `$JSC_HOME/assistant/patrol.lock/` | `patrol.sh` only | the round lock, a directory. `info` holds `round`, `pid`, `started` | | `$JSC_HOME/assistant/patrol/` | `patrol.sh` only | one round's scratch files, including `latest.md`, `summary.md`, `summary-row.md`, `newpage.md` and `contents-entry.md` | | `$JSC_HOME/assistant/usage-prev.tsv` | `patrol.sh` only | last recorded round's cumulative usage counts, so the next round can print a real per-round delta | | `$JSC_HOME/usage/events.jsonl` | `report-status.sh` only, never this skill and never `patrol.sh` by hand | one JSON object per line: `ts`, `cli`, `session`, `kind`, `name`, `phase`, `status`, `exit`, optional `ms` and `detail` | | `$JSC_HOME/assistant/events-open.tsv` | `patrol.sh` only | the starts still waiting for a matching end, carried from round to round: `session`, `name`, `kind`, first-seen epoch, the event's own `ts` | `$JSC_HOME` defaults to `~/.jsc`. `heartbeat.sh report` prints the resolved heartbeat path in its `file=` field, so take the assistant directory from there rather than rebuilding it. **The verdict is time-based only.** A heartbeat counts as fresh when the file exists and its `ts` is less than the TTL behind now (300 seconds by default, `JSC_ASSISTANT_HEARTBEAT_TTL` overrides it). `pid` liveness is never tested: five CLIs and container processes cannot see each other's pids, so a live-looking pid proves nothing and a missing one proves nothing either. Report `pid` as a hint for whoever has to find a blocking process, and give it no weight in the verdict. ## heartbeat.sh exit codes Every call in every operation below is judged by this table. Report the code you got, then take the row's action — never retry a code silently, and never downgrade a failure into a success. | Code | Meaning | What to do | | --- | --- | --- | | 0 | `write` wrote the heartbeat, `clear` finished and the file is gone, `report` printed its line, `check` says fresh | Carry on with the operation's next step. For `report`, the state still has to be read out of the printed `state=` field | | 1 | `check`: the heartbeat exists but is at or past the TTL — the last patrol round finished more than one TTL ago | Report `助理未運行`, name the age in seconds, and say the assistant has to be started again. `report` returns this state as `state=stale` with exit 0 | | 2 | The script did not run at all — it failed to load its `lib.sh` | Report that the heartbeat state is unknown, name the script path and the code, and stop the operation. Never claim the assistant is running, and never claim it is stopped | | 3 | `check`: no heartbeat file — no patrol round has ever finished | Report `助理未運行` and say to run `start`. `report` returns this state as `state=absent` with exit 0. In `stop` this state cannot appear, because `clear` treats a missing file as success | | 4 | `check`: the heartbeat exists but its `ts` is missing, empty or not a number — the file is damaged, the assistant is not merely stopped | Treat it as not fresh; falling back to fresh is forbidden. Report the file as damaged, say the state cannot be read from it, and tell the operator to run `stop` and then `start` to rebuild it. `report` returns this state as `state=invalid` with exit 0 | | 5 | Filesystem failure — `write` could not write the file, or `clear` could not delete it and the file is still there | Serious. Report it loudly with the stderr text and the path, and follow the operation's own step for this code. Never report the operation as done | | 6 | Usage error — an unknown subcommand, or none at all | This is a defect in the call, not a state of the assistant. Report the exact command line that was run, correct it to one of `write`, `check`, `report`, `clear`, and run it once more. Report a second exit 6 as a defect in this skill and stop | ## The scheduler Nothing in a background assistant runs on its own. The system scheduler is what makes it periodic, and `$JSC_HOME/current/jsc-assist/tools/schedule.sh` is the only thing here that touches it. One job exists, written as exactly one entry carrying the fixed marker `# jsc-assist:assistant patrol`: | Job | Period | Runs | Installed by `start` | | --- | --- | --- | --- | | `patrol` | derived from the heartbeat TTL (`*/2 * * * *` at the default TTL of 300 seconds) | one patrol round through the caller's CLI | yes, always | | `heartbeat` | — | nothing. This job existed in the previous version and is no longer installable | no — `install heartbeat` exits 6 | **The heartbeat job is gone on purpose.** It used to call `heartbeat.sh write` every minute, which made a fresh heartbeat prove only that cron was alive. Anything that writes a heartbeat outside a finished patrol round brings that back, so `install heartbeat` is refused, and `install patrol` deletes any leftover `heartbeat` entry from an older install and reports `legacy_removed=1`. Say that number in the report — a surviving legacy entry silently undoes this whole design. **The period is derived, never guessed.** The heartbeat now moves once per patrol round, so the round period has to fit inside the freshness threshold. `schedule.sh` reads the machine's effective threshold from `heartbeat.sh report`'s `ttl=` field and picks the largest whole-hour-dividing minute count `P` with `2 × P × 60 < ttl`: one missed round still reads fresh, two missed rounds read stale. At the default 300 seconds that is every 2 minutes; raise `JSC_ASSISTANT_HEARTBEAT_TTL` to 1800 and it becomes every 12 minutes. Report both numbers (`ttl=`, `period=`) so the operator can see the trade-off and change it in one place. `--period` overrides the calculation and is checked against the same inequality; a period that does not fit exits 6 rather than installing a schedule that keeps the heartbeat permanently stale. The mechanism follows the platform: `crontab` on Linux, WSL and macOS, `schtasks` on Windows. macOS keeps `crontab` — a `launchd` user who wants a plist writes it themselves; this skill does not generate one. Four properties of that script matter enough to state here, because a report that ignores any of them is wrong: - **It only ever touches its own entries.** Install filters out its own old entries by marker and appends the new one; it never rewrites a crontab it failed to read, and it counts everybody else's lines before and after to prove none went missing. Remove takes out its own markers only. Say this in the report — the operator is entitled to know their own cron entries survived. - **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up". - **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project. - **The entry runs with no human present.** The command is installed with `` preamble, and then one H2 block per machine — the heading is that machine's monitor page name, and the fields are one `- {name}:{value}` bullet each underneath. The script owns the read-match-write of one block, so never read this page and rebuild it by hand, never write it through `jsc-gitea:wiki`, and never rebuild it the way step 3 rebuilds the content page — every other block here belongs to a machine that is not this one, and one careless whole-page write deletes their records. **Finish the block first.** `contents_file` holds this machine's whole block — `## MONITOR_{HASH}`, a blank line, then the bullets — and its 監控頁 bullet already carries the rule A shape `[{page name}]({URL})` with the placeholder `{監控頁絕對網址}` standing in for the URL, because the absolute URL cannot be known until step 3 has actually put the page. Run `$JSC_HOME/current/jsc-gitea/tools/gitea.sh wiki-url {the MONITOR repo step 3 resolved} MONITOR_{HASH}`, replace the placeholder with what it prints, and write the finished block to a file. Exit 4 there means step 3's write has not landed — go back to step 3 rather than writing a block. Exit 5 means the page carries no `html_url`: report it and never assemble a URL by hand. Exit 7 or 8: report the code and take the abort row below. **Any other non-zero exit takes the same abort row**, a missing argument included — a URL that never arrived would otherwise leave that bullet holding the raw placeholder, and the block would still be written. **Then verify that URL before the block goes anywhere.** Run `$JSC_HOME/current/jsc-gitea/tools/link-check.sh {the URL just substituted}` and read the exit code by the rule B table above. Exit 0 is the only result that permits the upsert. On exit 1 the directory would gain a block pointing at a page that is not there: report the `DEAD` line verbatim, write no block, and treat the directory entry as not updated — the round's own result is already on `MONITOR_{HASH}`, so carry on to step 5 and write the heartbeat, exactly as exit 3 from the upsert does, and put the dead link into the 待人處理 rows. Exits 2, 3 and 7 are reported the same way and the block is left unwritten. Never write the block first and check afterwards: the directory is what other people read to find this machine, and a dead link there sends every one of them to a page that does not exist. Then run, with the template as the fifth argument every time: `$JSC_HOME/current/jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {block file} $JSC_HOME/current/jsc-assist/templates/monitor-contents.md` **The key is the H2 heading — the page name `MONITOR_{HASH}`**, taken from `collect`'s `page=` line verbatim, with no link, no brackets and no URL around it. The script compares the heading text, so the 監控頁 bullet cannot be the key: it holds `GITEA_HOST` and the wiki's encoding of the page name, so a changed host, a `JSC_WIKI_REPO_MONITOR` pointed at another repo, or a different URL encoding changes that text and stops it matching. This page is written once every round, so from the moment matching breaks every round appends one more block for this same machine and the old block is never updated again. The page name depends on `{host}/{user}` alone, which none of those three touch. That bullet's link stays in the block for people to click, and never for matching. A key typed by hand matches nothing either, and appends the same duplicate block. **The `1` is the third argument, `key-col`, and it only matters while an old page is still a table.** A directory page written in the previous format holds a markdown table, and the script converts the whole page to blocks before it upserts; `key-col` tells it which column of that table held the identity, counting from 1. Column 1 of this page's old table was the monitor-page link, whose cell is `[MONITOR_{HASH}]({URL})`, and the conversion takes the text out of it as the H2 heading. Once the page is in the block format the argument is ignored — pass `1` regardless, and never a column number worked out from the current page. | Exit | Do | | --- | --- | | 0 | The block is in place. The script prints `updated` or `added` plus the page it wrote — carry that word into the report, and carry on to step 5 | | 1 | The page content could not be built, or the write failed. Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. A page with no matching block is not this code: an unmatched key is an append | | 2 | An argument was rejected and nothing was written. A template path that does not exist lands here too, and means the plugin installation is incomplete. Correct the call and run it once more; report a second exit 2 as a defect in this skill, then abort and stop | | 3 | No `CONTENTS` wiki repo is configured. **This one does not stop the round.** Carry on to step 5 and write the heartbeat: the round's result is already on `MONITOR_{HASH}`, and that is exactly what a heartbeat stands for. Report the directory entry as not updated, name `JSC_WIKI_REPO_CONTENTS` and `JSC_WIKI_REPO` as the two variables to set, and add that to the 待人處理 rows. Never abort a recorded round over the directory page — a missing directory block loses one index entry, an aborted round loses the whole round, and the patrol cannot ask anybody for the missing setting | | 4 | The page is absent and no template reached the script. The call above always passes the template as its fifth argument, so this code cannot come out of it — getting it means that argument was dropped, so restore it and run the call once more. A template path that does not exist is rejected as exit 2, never as 4 | | 7 | The token is invalid or lacks permission, so the other machines' blocks are unknown. The script wrote nothing, which is what keeps those blocks alive. Abort, report the key problem, and stop | | 8 | Some other API failure. Abort, report the status, and stop | Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran. 5. **Write the heartbeat.** Run `$JSC_HOME/current/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. 6. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL. **The event numbers get their own line, and the unpaired starts get their own list.** Print `events_total=` and `events_bad=` as this round's event count and its non-`ok` count, then every non-`ok` event with its `kind`, `name`, `status`, `exit` and `detail`, then — separately, never folded into the same list — every start with no matching end, by `name` and `session`. A non-zero `events_unpaired=` is the round's most important finding: each row is a skill run that started and never reached its closing step. Say `events_rotated=` too when it is `rotated` or `failed`. When `item=D-11` failed, say the source could not be read rather than reporting zero events — zero read events and zero existing events look identical in a report and mean opposite things. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on. ## status Read-only throughout. This operation creates, modifies and deletes nothing under `$JSC_HOME`, and it never calls `write` or `clear`. 1. **Read the heartbeat through the script.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh report` and split the line on spaces, taking `file=` last so a path containing spaces stays intact. Map `state=` to the verdict: `fresh` → `新鮮`, `stale` → `過期`, `invalid` → `心跳檔損壞`, `absent` → `不存在`. Print `助理未運行` for `stale`, `invalid` and `absent`. Never re-derive the verdict from `ts` yourself, and never treat `invalid` as fresh. On exit 2 or 6, follow that code's row, record the heartbeat state as unknown, and carry on to step 2 — the task book is still worth printing. Completion condition: the heartbeat state holds one of `新鮮`, `過期`, `心跳檔損壞`, `不存在` or unknown, and `ts`, `age`, `ttl`, `pid`, `cli`, `session` and `file` are recorded as read or as empty. 2. **Read the task book.** Take the assistant directory from the `file=` path of step 1, list the regular files directly under its `tasks/` subdirectory, and parse each one as `key=value` lines. Branch on the outcome. | Outcome | Do | | --- | --- | | Directory absent | Report zero entries. This is a normal result, not an error | | Directory present, no files | Report zero entries | | A file cannot be read, or holds no recognisable key | Keep it as one row, put the file name in the title column, name the read or parse error in that row, and carry on with the remaining files | | A key is missing from a readable file | Print `-` in that column | Completion condition: every file under `tasks/` produced exactly one row, or zero entries was reported. 3. **Read the schedule.** Run `$JSC_HOME/current/jsc-assist/tools/schedule.sh status`. It writes nothing. Record `mechanism=`, `service=`, `ttl=`, `period=` and the `installed=` value of both jobs. A `heartbeat` job reported as installed is a leftover from an older version: say so, and say `start` or `schedule.sh install patrol` removes it. On exit 2, 3 or 6 nothing was read: record the schedule state as unknown with its code and carry on — the heartbeat and the task book still print. Completion condition: both jobs have an installed state, or the schedule state is recorded as unknown with its code. 4. **Print the status table.** Lead with the heartbeat block — verdict, last heartbeat time rendered from `ts` in local time, age in seconds, TTL, `cli`, `session`, `pid`, and the task count. Follow it with the schedule block — mechanism, service state, derived period, and one line per job saying installed or not. Then one row per task carrying `state`, `title`, `next_run` and `fail_count`, in the order the files were listed. Completion condition: the heartbeat block holds all eight values, the schedule block holds both jobs and the period, and the row count equals the task count from step 2. 5. **Say what the two blocks together mean.** Four combinations get an explicit sentence, because each one reads as something it is not: | Heartbeat | Schedule | Say | | --- | --- | --- | | 新鮮 | patrol installed, service running | 上一輪巡檢跑完了,結果也記上監控頁了,排程還在跑。那一輪各項有沒有全過,要看監控頁的本輪判定 | | 新鮮 | not installed, or service stopped | 上一輪巡檢跑完了,但沒有排程在叫下一輪,過了 TTL 心跳就會過期 | | 過期 or 不存在 | patrol installed, service running | 排程裝著卻沒有新的心跳,巡檢自己跑失敗了,去看 `$JSC_HOME/assistant/schedule.log` 與監控頁的最新一輪 | | any | `heartbeat` job installed | 舊版的心跳排程還留著,它會蓋掉「心跳等於巡檢跑完」這件事。請跑一次 `start`,或 `schedule.sh install patrol` 把它清掉 | Completion condition: every matching sentence is printed, or none of the four combinations applied. 6. **Flag the repeatedly failing tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Completion condition: every row with `fail_count` above 0 carries the marker and its number matches the file. 7. **Finish successfully.** `助理未運行`, an absent `tasks/` directory, an empty `tasks/` directory and an uninstalled schedule are normal results — never exit non-zero for any of them. Reserve a failure report for a condition none of the tables above covers, and state which path and which error produced it. Completion condition: the report is printed and nothing under `$JSC_HOME` has been created, modified or deleted. ## stop 1. **Record what is being stopped.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh report` first and keep its `state=`, `ts=`, `pid=`, `cli=` and `file=` fields for the closing report — after the clear they are gone for good. `state=absent` means no round has finished; say so and still run steps 2 and 3, because a scheduled entry can outlive its heartbeat and `clear` on a missing file is a success, so running both leaves the outcome unambiguous. On exit 2 or 6, follow that code's row, record the previous state as unknown, and carry on to step 2. Completion condition: the previous state and its fields are recorded, or the previous state is recorded as unknown with its code. 2. **Remove the schedule first.** Run `$JSC_HOME/current/jsc-assist/tools/schedule.sh remove all` — both job names, so the patrol entry and any leftover heartbeat entry from an older install both go. This comes before the clear and never after: clear first and the next scheduled round writes a fresh heartbeat over the stopped assistant, and every reader from then on is told a dead assistant is alive. Judge the result by the schedule.sh exit-code table, and keep `removed=` and `others_kept=` for the report. On any non-zero code the schedule is still installed: report the code, say plainly that rounds will keep running and the assistant therefore cannot be stopped, name the manual fix (`crontab -l` to look, then remove the line carrying `# jsc-assist:assistant` by hand), and skip steps 3 and 4 — clearing a heartbeat that the next round rewrites only hides the problem. Completion condition: `remove` exited 0 with its counts recorded, or the failure report has been printed and no stop was claimed. 3. **Clear the heartbeat.** Run `$JSC_HOME/current/jsc-hooks/hooks/heartbeat.sh clear`. On exit 5 the file is still there: report the failure with the script's stderr line and the path, say plainly that every reader still sees a heartbeat claiming a round just finished and that the assistant is therefore not reliably stopped, name the manual fix (delete that path by hand, then run `status` to confirm `助理未運行`), and skip step 4 — the closing notice must not be printed after a failed clear. On exit 2 or 6, follow that code's row and stop the same way. Completion condition: `clear` exited 0, or the failure report naming the code, the path and the manual fix has been printed and no stop was claimed. 4. **Report the stop and what it means for the gate.** Print the previous state and heartbeat time from step 1 and the entries removed in step 2, then this literally: > 助理已停止,排程移除了,心跳也清掉了,其他人的排程一筆都沒動。靠心跳判定的 jsc 技能閘門一讀到沒有心跳就會擋下技能呼叫;閘門目前還沒接線,所以這一刻誰都擋不到。要再工作就先跑一次 start。 Say it exactly this way. The blocking is the designed consequence of a cleared heartbeat, and whoever stops the assistant has to know it is coming; the clause about the gate being unwired is the part that keeps the notice honest while that is still true. When the gate is wired, that clause is what gets rewritten — not the rest. Completion condition: the notice appears with all three clauses, and the previous state, the heartbeat time and the removal counts are printed above it. ## Round lock and a round that will not stop leaving one behind A patrol round holds `$JSC_HOME/assistant/patrol.lock` from `collect` to `finish` or `abort`, which spans the wiki writes — the slow part. Two consequences bind every branch above: - **Every path out of a started round ends in `finish` or `abort`.** Steps 2, 3 and 4 of `patrol` each name their abort. A round that stops without either leaves the lock standing until it ages out, which stands the next rounds down for up to one TTL. There is no third option. - **`stop` does not remove the lock.** It is not a state file that records work, but it is also not this skill's to delete while a round may still be using it; it ages out on its own within one TTL. If an operator reports that every round stands down, tell them the holder and age from the `lock=busy` line and let them decide — 界線 5 keeps destructive cleanup with the human.