feat(assistant): 助理主體收攏 start、status、stop 三個操作

What:
- 新增 assistant 技能,提供啟動、查現況、停止三個操作。
- 刪掉原本獨立的 status 技能,行為清單與 README 的技能目錄一起改。
- 三份 manifest 的版本一起提升,對 jsc-hooks 的下限提到含心跳腳本的那一版。

Why:
- 助理的生命週期是一件事,拆在兩支技能裡,靠描述自動觸發的 CLI 等於擲骰子挑一支。準則也明寫技能目標不得重複。
- 刪除的代價這時候最低:這個 domain 才剛落地,status 是它唯一一支技能,除了自己的文件沒有別的東西指向它。晚一步等各處都引用了再收攏,成本差很多。

How:
- 心跳的判定一律交給 jsc-hooks 的心跳腳本,三個操作都讀它的回報,不自己解析心跳檔。判定有兩份就會漂移,狀態與訊息就會對不上。
- 那支腳本的每一個結束碼都在技能裡有明確處置,包含「腳本自己沒跑起來」那一種——那時候既不能說助理在跑,也不能說助理停了。
- 時間戳壞掉一律當成不新鮮,絕不退回判成新鮮。
- 異常結束不需要額外的清理機制:心跳是時間戳,過了門檻自動失效。反過來說,stop 以外的任何路徑都不該寫心跳,否則就是留一個假心跳。
- stop 清心跳是它的職責,不算助理界線裡「不刪狀態檔」那一條。技能內文把這個例外寫明白,免得日後照界線把 stop 砍掉。
- stop 的收尾同時講兩件事:心跳清掉之後閘門會擋下技能呼叫,以及閘門目前還沒接線所以這一刻擋不到誰。前者是設計後果,停助理的人一定要知道;後者不講就是說一件還沒成真的事。
- 排程這一輪不做,start 只寫第一次心跳,並講明心跳不會自動更新。

Who:
助理落地的第二塊:心跳有了,接著要有人寫它、讀它、清它。
This commit is contained in:
2026-09-01 14:19:24 +08:00
parent 4b0e4d796c
commit fbf3e5a605
7 changed files with 116 additions and 70 deletions
+101
View File
@@ -0,0 +1,101 @@
---
name: assistant
description: Start, inspect, or stop the background assistant, with jsc-hooks/hooks/heartbeat.sh owning the single freshness verdict. start writes the first heartbeat and says no scheduler keeps it alive yet; status turns heartbeat.sh report plus the task book under $JSC_HOME/assistant/tasks/ into one read-only table; stop clears the heartbeat and states what a cleared heartbeat means for the jsc skill gate. A heartbeat that cannot be written or cleared (exit 5) is reported as a failure, never as success. Use when someone starts the assistant, stops it, or asks whether it is running and what is queued; not for environment health checks (jsc-cli:doctor), not for skill usage counts (jsc-log:stats).
---
# assistant — start, status, 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 three operations that touch that evidence: `start` writes it, `status` reads it, `stop` clears it.
`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. This skill adds no `tools/` script of its own: the heartbeat logic already lives in that script, and the task book is one directory listing.
## 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`; stopping, halting or shutting it down is `stop`. When the request names none of the three, or names more than one, ask through the `jsc-ask:ask` decision tree with those three as the options, each stating its effect — `start` writes a heartbeat, `status` changes nothing, `stop` deletes the heartbeat. Never guess, and never run a second operation the caller did not ask for. Completion condition: exactly one of `start`, `status`, `stop` is chosen and named in the report.
## 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/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` 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 assistant ran and has stopped | 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 — the assistant has never been started | 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 |
## Boundaries
The six limits in `AGENTS.md`「助理的界線」 hold for all three operations. Two of them need saying out loud here:
- **This skill never judges a gate.** It maintains the heartbeat and prints what the heartbeat says. Whether a stale heartbeat blocks a skill call is decided by a hook, synchronously and offline; nothing in this skill blocks or waves through anything.
- **`stop` clearing the heartbeat is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the assistant is alive", so a `stop` that leaves it behind leaves a lie behind. Clearing it is the whole job of `stop`, and it is the only deletion any operation here performs: `stop` touches nothing under `tasks/`, no worktree and no wiki page. Do not "restore" this limit later by taking the `clear` call out of `stop`.
## Crash exit needs no cleanup
An assistant that is killed, crashes, or dies with the machine writes no farewell. It does not need to. The heartbeat is a timestamp, not a lock: the last one written stays on disk, ages past the TTL on its own, and every reader from then on sees 過期. No shutdown handler, no cleanup hook and no pid check is involved, so there is nothing left that can fail to run.
That property holds only while nothing fakes a heartbeat. **`write` is called by `start` and by the scheduler that keeps a running assistant alive — nowhere else.** `status` never writes one, `stop` never writes one, and no other skill writes one. A heartbeat written by anything that is not a live assistant says a dead assistant is alive, and the reader has no way to tell the difference.
## start
Scheduling is not wired in this round. `start` writes the first heartbeat and reports; it installs no timer, no cron entry and no daemon. Until the scheduler lands, the heartbeat is never refreshed on its own and expires once the TTL passes.
1. **Write the first heartbeat.** Run `jsc-hooks/hooks/heartbeat.sh write`. On exit 5 the assistant cannot start: without a heartbeat its own gate reads it as not running, so report the failure, quote the script's stderr line and the heartbeat path, name the likely causes (a full disk, a permission problem on `$JSC_HOME/assistant/`, or something other than a regular file sitting at the heartbeat path), and stop — do not run step 2, and do not report a started assistant. On exit 2 or 6, follow that code's row in the exit-code table and stop. Completion condition: `write` exited 0, or the failure report naming the code and the path has been printed and no start was claimed.
2. **Confirm what was written.** Run `jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful `write` means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported.
3. **Report the start.** Print the heartbeat path, the local time of `ts`, the TTL in seconds, and `pid`, `cli`, `session` as hints. Then print this literally, with `{ttl}` replaced by the TTL just read:
> 助理已啟動,第一次心跳寫好了。排程還沒接線,心跳不會自動更新;過了 {ttl} 秒心跳就會過期,屆時請再跑一次 start。
Completion condition: the report carries the path, the local heartbeat time, the TTL and the three hint fields, and the notice above appears with the real TTL substituted.
## 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-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. **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 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 and the row count equals the task count from step 2.
4. **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.
5. **Finish successfully.** `助理未運行`, an absent `tasks/` directory and an empty `tasks/` directory 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-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 the assistant was already stopped; say so and still run step 2, because `clear` on a missing file is a success and 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. **Clear the heartbeat.** Run `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 the assistant is running 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 3 — 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.
3. **Report the stop and what it means for the gate.** Print the previous state and heartbeat time from step 1, 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 second clause is the part that keeps the notice honest while the gate is still unwired. When the gate is wired, that clause is what gets rewritten — not the first one. Completion condition: the notice appears with both clauses, and the previous state and heartbeat time are printed above it.