Cursor 长任务续推:用阶段验收、Skill 与有界 hook 接着做

让下一轮先核对文件与测试,再决定继续哪个阶段。附完整 Python 标准库练习和默认关闭的三轮 stop hook。

1. 把“继续做”拆成可以重新核对的阶段

一个文档工具可能先确定输入格式,再实现检查逻辑,最后补齐命令行。Agent 第一轮停下来时,真正需要决定的是:已有结果是否成立,下一个阶段是否具备条件,以及是否值得继续消耗一轮。把这几个判断留在聊天里,换会话后就容易重复实现,也容易把一句“完成了”当成事实。

本文用一个 Markdown 本地链接检查器演示阶段续推。项目只有 Python 标准库文件,输入是随文给出的微型文档目录。每次显式调用 /advance-phase,检查当前工作,再推进一个阶段。读者可以在任何空目录复现离线验收;Cursor Skill、可选 hook 和云端交接分别说明,不需要先开通云端。

这里的 TASK.md、PROGRESS.json、checkpoint.py 和 CONTINUE.json 全部是本文自定义文件。Cursor 不会因为目录里出现这些名字,就自动提供任务队列、持久执行或完成保证。保存进度解决的是恢复后怎样判断下一步;执行仍取决于会话、工具权限和实际运行环境。

文档核对日期:2026-10-07。本文实测范围为离线 Python 项目、临时 Git 仓库和模拟 hook 输入;没有实际运行 Cursor CLI、模型、Skill、hook 宿主、Cloud Agent、Automation 或收费界面。

三个阶段怎样交接
图 1 三个阶段怎样交接

2. 先理解本地会话能保留什么

在已经安装并登录的 Cursor CLI 中,官方会话文档列出 agent ls、agent resume、agent --continue 和 agent --resume="chat-id-here"。它们帮助找回或恢复对话。恢复以后应先确认工作目录和分支,再核对进度文件;不能假设聊天记录里的文件仍在,也不能将会话恢复理解为故障进程的指令级续跑。

agent ls
agent resume
agent --continue
agent --resume="chat-id-here"

上面是使用说明,本文没有执行这些命令,也不包含安装步骤。CLI 斜杠命令参考仍记录 /resume;/quit 和 /exit 会退出 CLI。关闭程序后,本例 stop hook 没有进程可以依附,无法按时唤醒它。该参考页还把 /goal 标为逐步推出,因此本文不把它当作读者一定拥有的基础能力。

任务本身的恢复入口保持简单:先读取 TASK.md、SPEC.md、PROGRESS.json,再检查 git status --short、git diff 与验收结果。即使换了一段全新的对话,这组资料也应足以解释支持范围、已完成声明和下一步工作。

3. 练习目标与三阶段验收

输入支持的是一小段明确的 Markdown 子集:同一行内的 [文字](相对路径),路径没有空白或括号。检查器略过图片、外部 URL 和纯锚点;相对目标去掉查询与片段后解码,要求落在扫描根目录内且是普通文件。不存在的文件报告 missing,越界或符号链接目标报告 unsafe。

为了让练习可控,这里不实现完整 CommonMark,不验证标题锚点,不解析引用式链接,也不剔除代码围栏里的文本。需要这些能力时,应先更新需求和独立验收,再另开任务;不能把教学用正则直接用于整个文档站的完整语法承诺。

scope 阶段确认样例和规格;engine 阶段检查 scan(root) 的路径与分类行为;cli 阶段检查 JSON 和退出码。acceptance.py 是单独的验收文件,开发阶段不得改它的预期来“修复失败”。正式团队可把验收文件的改动交给另一位审阅者,本文的本地脚本没有能力约束恶意修改者。

随文提供完整参考实现,先复制后运行即可复现。把它用于 Cursor 练习时,第一阶段读规格,第二阶段审查已有实现并验证,第三阶段核对入口行为;若想从零实现,可另建一个练习副本,仅把 check_links.py 留空,再让 Agent 按同一规格完成。不要删掉实际项目代码,也不要改动验收文件。

4. 从空目录建立可运行项目

新建一个仅用于练习的空目录,按下一节各个文件标题保存源码。要求 Python 3.10+ 和已有 Git;所有命令都在这个目录执行。文中的源码和输入全部展开,不依赖附件才能理解或复现。

mkdir cursor-phase-lab
cd cursor-phase-lab
# 按下文路径保存所有文件后:
git init
python3 acceptance.py scope
python3 acceptance.py engine
python3 acceptance.py cli
python3 checkpoint.py complete scope
python3 checkpoint.py complete engine
python3 checkpoint.py complete cli
python3 checkpoint.py verify

引用代码是完整实现,因此三个验收都会通过。scope 运行 1 个测试,engine 累计运行 5 个,cli 累计运行 6 个。最后应显示 verified claims; pending:。Git 尚未提交时看到未跟踪文件是正常的;本例记录这个事实,并不会把“干净工作区”当作完成条件。它不自动执行 git add、commit 或 push。

先运行验收,再运行 complete。complete 会重新验收,而不是相信前一条终端输出。重复 complete 同一阶段会重验并返回 already verified,保持进度文件内容不变。损坏的 JSON、跳过前置阶段或非空 blocker 都会导致退出码 2;不会自动重置成一份看似正常的状态。

5. 项目文件:TASK.md

# Markdown 本地链接检查器:三个阶段

只操作这个练习目录。Python 3.10+ 与 Git;不联网,不安装依赖。
TASK.md、PROGRESS.json 是本例约定,不是 Cursor 内置协议。
阶段 1 scope:阅读 SPEC.md、fixtures 与 acceptance.py,运行 scope 验收。
阶段 2 engine:实现 check_links.py 的 scan(root),运行 engine 验收。
阶段 3 cli:实现 CLI,运行全部验收;输出文件清单、Git 状态和剩余限制。
每次 /advance-phase 只推进一个尚未验收的阶段,然后交回结果。
先重验所有已声明完成的阶段。不能因为 status=done 跳过文件、Git 和测试核对。
不得改 SPEC.md、acceptance.py 或测试预期来通过测试;不得清除 blocker。
遇到范围不明、坏状态、权限问题或验收失败,报告并停止;不要自行联网找依赖。
禁止提交、推送、发布、装软件、读目录外文件或更改账户设置。

6. 项目文件:SPEC.md

# 验收范围
只扫描指定目录中的 *.md 普通文件,按文件名排序。
支持一行内形如 [文字](相对路径) 的简单链接;路径不得包含空白或括号。
忽略图片、http/https/mailto 等带 scheme 的链接、// URL 和纯 #锚点。
相对路径去掉 query/fragment 并 URL 解码,目标必须是扫描根目录内的普通文件。
不读取根目录之外的链接目标;symlink Markdown 输入跳过,symlink 目标拒绝。
丢失目标记 missing,越界或含 symlink 目标记 unsafe。行号从 1 开始。
这不是完整 CommonMark 解析器;不支持引用式链接、围栏代码排除和锚点存在性验证。
CLI 输出 JSON 数组;无问题 exit 0,有问题 exit 1,目录/读取错误 exit 2。

7. 项目文件:fixtures/ok/README.md

[Guide](guide.md#start) [Web](https://example.com) [Mail](mailto:a@example.com) [Anchor](#top) ![Image](missing.png)

8. 项目文件:fixtures/ok/guide.md

# Start

9. 项目文件:fixtures/bad/README.md

[Missing](gone.md)
[Escape](../outside.md)

10. 项目文件:check_links.py

import argparse
import json
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit

LINK = re.compile(r"(?<!!)\[[^\]\n]*\]\(([^\s()]+)\)")

def scan(root):
    root = Path(root).resolve(strict=True)
    if not root.is_dir():
        raise ValueError("root must be a directory")
    issues = []
    for source in sorted(root.rglob("*.md")):
        if source.is_symlink() or not source.is_file():
            continue
        # Reject symlink ancestors before reading an input file.
        relative = source.relative_to(root)
        if any((root / Path(*relative.parts[:i])).is_symlink()
               for i in range(1, len(relative.parts))):
            continue
        for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
            for match in LINK.finditer(line):
                href = match.group(1)
                url = urlsplit(href)
                if url.scheme or url.netloc or not url.path:
                    continue
                relative_target = Path(unquote(url.path))
                target = source.parent
                kind = None
                # Inspect each component before '..' can erase a symlink.
                # Never probe beyond the scan root.
                if relative_target.is_absolute():
                    kind = "unsafe"
                else:
                    for part in relative_target.parts:
                        if part == "..":
                            if target == root:
                                kind = "unsafe"
                                break
                            # parent is lexical; a missing/file component cannot be traversed.
                            if not target.is_dir():
                                kind = "missing"
                                break
                            target = target.parent
                        elif part != ".":
                            target = target / part
                            if target.is_symlink():
                                kind = "unsafe"
                                break
                if kind is None:
                    if target.is_file():
                        continue
                    kind = "missing"
                issues.append({"file":relative.as_posix(), "line":number,
                               "href":href, "kind":kind})
    return issues

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("root")
    args = parser.parse_args()
    try:
        issues = scan(args.root)
    except (OSError, ValueError) as exc:
        print(str(exc), file=sys.stderr)
        return 2
    print(json.dumps(issues, ensure_ascii=False, indent=2))
    return int(bool(issues))

if __name__ == "__main__":
    raise SystemExit(main())

路径检查还要防止另一种误判:missing/../guide.md 和 guide.md/../guide.md 不能因为最后的 guide.md 存在就算有效。前者缺少中间目录,后者把普通文件当作目录;先折叠 .. 会掩盖问题。Python 文档说明 parent 是词法操作,因此这里在上移前先确认当前组件是目录,失败记为 missing。真正存在的 dir/../guide.md 仍可通过,已有符号链接与越界拒绝逻辑保持不变。下节新增一个测试方法,覆盖四个反例与三个有效对照;URL 解码后的 %2e%2e 也走同一检查。它仍是无目录竞争的教学扫描器,不提供恶意并发修改下的安全保证,也不是完整文件系统路径解析器。

11. 项目文件:acceptance.py

# Independent acceptance contract. Do not weaken to make a phase pass.
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parent
class Scope(unittest.TestCase):
    def test_contract(self):
        self.assertIn("CommonMark", (ROOT / "SPEC.md").read_text())
        self.assertEqual((ROOT / "fixtures/ok/guide.md").read_text(), "# Start\n")
        self.assertEqual((ROOT / "fixtures/bad/README.md").read_text(),
                         "[Missing](gone.md)\n[Escape](../outside.md)\n")
class Engine(unittest.TestCase):
    def test_samples(self):
        from check_links import scan
        self.assertEqual(scan(ROOT / "fixtures/ok"), [])
        self.assertEqual(scan(ROOT / "fixtures/bad"), [
            {"file":"README.md","line":1,"href":"gone.md","kind":"missing"},
            {"file":"README.md","line":2,"href":"../outside.md","kind":"unsafe"}])
    def test_encoded_and_symlink(self):
        from check_links import scan
        with tempfile.TemporaryDirectory() as d:
            p=Path(d); (p/'a b.md').write_text('')
            (p/'read.md').write_text('[ok](a%20b.md?q=1#x) [x](%2e%2e/out.md)')
            self.assertEqual([x['kind'] for x in scan(p)], ['unsafe'])
            (p/'link.md').symlink_to(p/'a b.md')
            (p/'read.md').write_text('[x](link.md)')
            self.assertEqual([x['kind'] for x in scan(p)], ['unsafe'])
    def test_parent_requires_existing_directory(self):
        from check_links import scan
        with tempfile.TemporaryDirectory() as d:
            p = Path(d)
            (p / 'guide.md').write_text('', encoding='utf-8')
            (p / 'dir/sub').mkdir(parents=True)
            cases = [
                ('missing/../guide.md', 'missing'),
                ('guide.md/../guide.md', 'missing'),
                ('missing/%2e%2e/guide.md', 'missing'),
                ('guide.md/child/../../guide.md', 'missing'),
                ('dir/../guide.md', None),
                ('dir/%2e%2e/guide.md', None),
                ('dir/sub/../../guide.md', None),
            ]
            for href, kind in cases:
                with self.subTest(href=href):
                    (p / 'README.md').write_text('[link](' + href + ')\n', encoding='utf-8')
                    expected = [] if kind is None else [
                        {'file': 'README.md', 'line': 1, 'href': href, 'kind': kind}]
                    self.assertEqual(scan(p), expected)
    def test_symlink_before_parent_component(self):
        from check_links import scan
        with tempfile.TemporaryDirectory() as d:
            base=Path(d); root=base/'root'; root.mkdir()
            outside=base/'outside'/'sub'; outside.mkdir(parents=True)
            (root/'present.md').write_text('')
            (root/'link').symlink_to(outside, target_is_directory=True)
            (root/'README.md').write_text('[bad](link/../present.md)')
            self.assertEqual(scan(root), [
                {"file":"README.md","line":1,"href":"link/../present.md","kind":"unsafe"}])
class Cli(unittest.TestCase):
    def test_exit_codes(self):
        for name, code in [('ok',0),('bad',1),('absent',2)]:
            r=subprocess.run([sys.executable, str(ROOT/'check_links.py'),
                              str(ROOT/'fixtures'/name)], capture_output=True,text=True)
            self.assertEqual(r.returncode,code,r.stderr)
            if code<2: self.assertIsInstance(json.loads(r.stdout),list)
            else: self.assertTrue(r.stderr.strip())

def suite(phase):
    classes={'scope':[Scope], 'engine':[Scope,Engine], 'cli':[Scope,Engine,Cli]}[phase]
    result=unittest.TestSuite()
    for cls in classes: result.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(cls))
    return result
if __name__=='__main__':
    phase=sys.argv[1] if len(sys.argv)>1 else 'cli'
    raise SystemExit(not unittest.TextTestRunner(verbosity=2).run(suite(phase)).wasSuccessful())

12. 项目文件:checkpoint.py

import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

ROOT=Path(__file__).resolve().parent
PHASES=['scope','engine','cli']
PATH=ROOT/'PROGRESS.json'

def load():
    value=json.loads(PATH.read_text(encoding='utf-8'))
    if not isinstance(value,dict) or set(value)!={'version','phases','blocker','evidence'}:
        raise ValueError('invalid progress fields')
    if type(value['version']) is not int or value['version']!=1:
        raise ValueError('unsupported version')
    phases=value['phases']
    if not isinstance(phases,dict) or set(phases)!=set(PHASES):
        raise ValueError('invalid phases')
    states=[phases[x] for x in PHASES]
    if any(x not in ('todo','done') for x in states): raise ValueError('invalid state')
    if states != sorted(states, key=lambda x: x=='todo'): raise ValueError('non-prefix done')
    if not isinstance(value['blocker'],str) or not isinstance(value['evidence'],dict):
        raise ValueError('invalid blocker/evidence')
    return value

def save(value):
    # Single writer only. Atomic replacement is not a database transaction.
    name=None
    try:
        with tempfile.NamedTemporaryFile(mode='w',encoding='utf-8',dir=ROOT,
                                         prefix='.progress-',delete=False) as f:
            name=f.name
            json.dump(value,f,ensure_ascii=False,indent=2); f.write('\n')
            f.flush(); os.fsync(f.fileno())
        os.replace(name,PATH)
    finally:
        if name and os.path.exists(name): os.unlink(name)

def verify(phase):
    git=subprocess.run(['git','rev-parse','--show-toplevel'],cwd=ROOT,
                       capture_output=True,text=True,check=True).stdout.strip()
    if Path(git).resolve()!=ROOT: raise ValueError('exercise needs its own git root')
    r=subprocess.run([sys.executable,'acceptance.py',phase],cwd=ROOT,
                     capture_output=True,text=True)
    if r.returncode: raise ValueError(r.stdout+r.stderr)
    files=['TASK.md','SPEC.md','acceptance.py','check_links.py',
           'fixtures/ok/README.md','fixtures/ok/guide.md','fixtures/bad/README.md']
    hashes={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in files}
    status=subprocess.run(['git','status','--short'],cwd=ROOT,capture_output=True,
                          text=True,check=True).stdout
    return {'sha256':hashes,'git_status':status,'tests':r.stderr.strip()}

def main():
    value=load()
    if value['blocker']: raise ValueError('blocked: '+value['blocker'])
    command=sys.argv[1]
    # Every invocation rechecks actual evidence for all claimed done phases.
    for phase in PHASES:
        if value['phases'][phase]=='done': verify(phase)
    if command=='verify':
        print('verified claims; pending: '+','.join(p for p in PHASES if value['phases'][p]=='todo'))
        return
    if command!='complete' or len(sys.argv)!=3 or sys.argv[2] not in PHASES:
        raise ValueError('usage: checkpoint.py verify | complete scope/engine/cli')
    phase=sys.argv[2]
    if value['phases'][phase]=='done':
        print('already verified: '+phase); return
    if next(p for p in PHASES if value['phases'][p]=='todo')!=phase:
        raise ValueError('phase order violation')
    value['evidence'][phase]=verify(phase)
    value['phases'][phase]='done'
    save(value)
    print('completed: '+phase)
if __name__=='__main__':
    try: main()
    except (OSError,ValueError,KeyError,IndexError,subprocess.SubprocessError) as exc:
        print(str(exc),file=sys.stderr); raise SystemExit(2)

13. 项目文件:PROGRESS.json

{
  "version": 1,
  "phases": {
    "scope": "todo",
    "engine": "todo",
    "cli": "todo"
  },
  "blocker": "",
  "evidence": {}
}

14. 项目文件:.gitignore

__pycache__/
.progress-*
CONTINUE.json
.cursor/hooks.json

15. 进度文件的可信范围

PROGRESS.json 保存的是完成声明和当时的证据快照。checkpoint.py 每次先重验所有 done 阶段,再决定是否允许推进下一个 todo;verify 输出 pending 为空之前仍然要读取源码并运行测试。快照里的哈希便于人工比较,不能替代当下的测试,也不是签名。Git 状态会暴露目录是否仍有未跟踪或修改文件,但脚本不会替读者判断每一处改动合理。

写入采用同目录临时文件、flush/fsync 与 os.replace,避免常见的半截 JSON。它只适合单个写入者;没有多进程锁、跨机器同步、数据库事务或断电后目录元数据持久性的完整保证。同一任务开两个并发会话同时写进度,仍然可能互相覆盖。这个练习应该串行执行。

有阻塞时,把原因写在 blocker 中并停止,例如“验收范围是否包括代码围栏尚待确认”。解除阻塞由人先作决定,再手动调整;hook 和 Skill 都不能自行清空。状态文件坏了也先保留现场,检查上一次可信文件和实际产物。不要让 Agent 通过重建一个全 done 文件来修复报错。

16. 补充:JSON 没写坏,为什么另一轮进度还是丢了?

前面的 save 已经采用同目录临时文件、flush/fsync 和 os.replace,不需要再把它“修成原子写入”。真正值得拆开验证的是两件事:保存到一半失败时,上一份文件是否还在;两轮各自修改进度时,后保存的一轮会不会覆盖前一轮的结果。

2023 年 9 月,一位开发者在 Python 社区问过:先用 json.dumps 生成完整字符串,再调用 Path.write_text,为什么磁盘满了仍会留下零字节文件?这是一个历史问题,不是近期趋势。先完成序列化不能防止后续写入失败;直接以 w 模式打开既有文件时,原内容已经被截断。下面不填满磁盘,而是用明确的 TypeError 观察写入边界,不能把两种故障说成同一次复现。

补充实验于 2026 年 10 月 13 日在 Linux、CPython 3.12.14 上运行。它只调用前文原样保留的 load/save,读写自己创建的临时目录。所有阶段始终为 todo;writer_a 和 writer_b 是合成标记,不是完成证据。没有启动 Cursor、模型、Git、hook 或真实任务。

临时文件序列化失败会保留旧目标;直接覆盖会留下无效 JSON;两个旧快照顺序保存则留下有效 JSON,但丢失 A 的标记。
补充图 字节完整性与修改是否被覆盖,需要分别验收。

17. 先让保存失败,再检查目标字节

程序先写入一份合法的初始状态,保存其原始字节。随后在 evidence 的末尾放入集合 {1, 2},让 json.dump 在序列化途中抛出 TypeError。使用原 save 时,错误发生在临时文件里,尚未执行 replace;目标字节与原来完全相同,finally 也清理了本次临时文件。

对照组使用同一份坏输入,直接打开目标文件再调用 json.dump。它也抛出 TypeError,但目标已经被截断并写入部分内容;本次得到非空、无法解析的 JSON。测试故意把这个错误写法交给“旧字节必须保留”的断言,断言必须拒绝它。异常类型相同,不代表留下的文件相同。

另一项检查在 os.replace 调用处注入 OSError,且不执行真正替换。测试确认调用时的临时文件与目标位于同一目录、临时文件已是完整的新 JSON;失败后旧目标仍不变,临时文件也被清理。这只覆盖明确注入的“替换前失败”,没有模拟某种磁盘故障,也没有验证硬件持久性。普通异常会执行 finally 的结果,不能推广成进程骤停后必然清理。

18. 两个旧快照,顺序保存也会丢更新

丢更新不必靠碰运气触发。先让 A 和 B 各自 load,拿到 evidence 为空的两份独立快照。A 加入 writer_a 并保存,读回确认 A 已在文件中;随后 B 向自己的旧快照加入 writer_b,再调用同一个 save。最后 load 正常通过,文件却只剩 writer_b。B 从未读到 A 的更新,完整替换不会自动合并两份对象。

A load → B load → A 添加标记并 save → B 添加标记并 save
最终 evidence:{"writer_b": "synthetic B"}

A load → A 添加标记并 save → B load → B 添加标记并 save
最终 evidence:{"writer_a": "synthetic A", "writer_b": "synthetic B"}

第二行是串行重读对照:让 A 的整轮读取、修改、保存结束后,B 才读取。两份标记于是都能保留。这里没有实现锁;两个序列都在同一个进程内按固定顺序执行,也不是两台机器或两个 Cursor 会话的并发实测。另一条负对照断言故意要求旧快照序列保留两个标记,它必须失败。

因此,本练习中的“单写入者”应覆盖整轮读取、重验、修改和保存,不能只让两个 save 排队。如果任务确实需要同时修改一份状态,应先设计并单独验收适用的锁或事务存储,再允许并行推进;不能仅凭最终 JSON 能解析就宣布没有冲突。下面七项结果也不承诺满盘、进程骤停、断电、Windows、网络文件系统、多文件事务、元数据或权限保留,以及恶意目录竞争的行为。

19. 完整复现:独立目录里的输入、脚本和测试

新建一个空目录,把前文“项目文件:checkpoint.py”的完整源码原样保存为 src/checkpoint.py,再按下面标题保存四个文件。也可以解压源码 ZIP,包内含相同文件、说明和原始 SVG。这个补充只需要已有 Python 3.10+ 标准库,不需要前文的 Git 练习项目;不要单独运行包内 checkpoint.py 的阶段入口。

python -B src/boundary_lab.py
python -B src/boundary_lab.py --json
python -B -m unittest discover -s tests -v

第一条应输出下列七项结果;第二条给出实际运行版本和具体观测;第三条应有 12 个测试通过。-B 避免写字节码缓存,实验状态仅存在于程序自己创建并清理的临时目录。脚本不接受业务目录参数。源码、正文提取副本和 ZIP 解压副本在本次离线验证中得到相同的固定输出,12 项测试均通过。

下载完整源码、输入、测试与原始 SVG

fixtures/PROGRESS.json

{
  "version": 1,
  "phases": {
    "scope": "todo",
    "engine": "todo",
    "cli": "todo"
  },
  "blocker": "",
  "evidence": {}
}

src/boundary_lab.py

"""Replay storage boundaries in an owned temporary directory, without Cursor."""
import argparse
import copy
import json
import platform
import tempfile
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch

import checkpoint

HERE = Path(__file__).resolve().parent.parent


def seed():
    return json.loads((HERE / 'fixtures/PROGRESS.json').read_text(encoding='utf-8'))


@contextmanager
def isolated():
    # This harness itself is single-threaded. Never point it at a real project.
    previous = checkpoint.ROOT, checkpoint.PATH
    with tempfile.TemporaryDirectory(prefix='checkpoint-boundary-') as directory:
        checkpoint.ROOT = Path(directory)
        checkpoint.PATH = checkpoint.ROOT / 'PROGRESS.json'
        try:
            yield checkpoint
        finally:
            checkpoint.ROOT, checkpoint.PATH = previous


def require(condition, label):
    if not condition:
        raise AssertionError(label)


def require_preserved(before, after):
    require(before == after, 'old destination bytes changed')


def require_both(value):
    require(value['evidence'] == {'writer_a': 'synthetic A',
                                   'writer_b': 'synthetic B'},
            'both intended updates must survive')


def bad_value():
    value = seed()
    # This set is deliberately placed late in the object; JSON cannot encode it.
    value['evidence']['bad'] = {1, 2}
    return value


def direct_overwrite(module, value):
    # Deliberately unsafe negative control, used only on our temporary fixture.
    with module.PATH.open('w', encoding='utf-8') as stream:
        json.dump(value, stream, ensure_ascii=False, indent=2)
        stream.write('\n')


def attempt_serialization(writer):
    with isolated() as module:
        module.save(seed())
        old = module.PATH.read_bytes()
        try:
            writer(module, bad_value())
        except TypeError:
            pass
        else:
            raise AssertionError('expected serialization TypeError')
        current = module.PATH.read_bytes()
        try:
            json.loads(current)
            parseable = True
        except json.JSONDecodeError:
            parseable = False
        return old, current, parseable, len(list(module.ROOT.glob('.progress-*')))


def snapshots(reload_after_a):
    with isolated() as module:
        module.save(seed())
        a = module.load()
        b = None if reload_after_a else module.load()
        a['evidence']['writer_a'] = 'synthetic A'
        module.save(a)
        after_a = module.load()
        if reload_after_a:
            b = module.load()
        b['evidence']['writer_b'] = 'synthetic B'
        module.save(b)
        # load checks both JSON syntax and the article's state schema.
        return after_a, module.load()


def run_cases():
    rows = []

    def record(case, observed):
        rows.append({'case': case, 'passed': True, 'observed': observed})

    with isolated() as module:
        module.save(seed())
        require(module.load() == seed(), 'normal round trip')
        record('round_trip', {'equals_seed': True})

    old, current, parseable, leftovers = attempt_serialization(
        lambda module, value: module.save(value))
    require_preserved(old, current)
    require(parseable, 'original document remains parseable')
    record('staged_serialization_preserves_old', {'old_bytes_equal': True})
    require(leftovers == 0, 'ordinary exception left a temporary file')
    record('staged_exception_cleans_temp', {'temporary_files': leftovers})

    with isolated() as module:
        module.save(seed())
        old = module.PATH.read_bytes()
        replacement = copy.deepcopy(seed())
        replacement['evidence']['writer_a'] = 'synthetic A'
        with patch.object(module.os, 'replace',
                          side_effect=OSError('injected before replacement')) as mocked:
            try:
                module.save(replacement)
            except OSError:
                pass
            else:
                raise AssertionError('expected injected OSError')
        require(mocked.call_count == 1, 'replace boundary was not reached once')
        require_preserved(old, module.PATH.read_bytes())
        require(not list(module.ROOT.glob('.progress-*')), 'replace failure left temp')
        record('replace_failure_preserves_old',
               {'old_bytes_equal': True, 'temporary_files': 0})

    old, current, parseable, _ = attempt_serialization(direct_overwrite)
    require(current != old and not parseable and len(current) > 0,
            'direct-overwrite control must leave invalid partial JSON')
    record('direct_overwrite_corrupts',
           {'old_bytes_equal': False, 'parseable_json': False,
            'nonempty_partial': True})

    after_a, final = snapshots(reload_after_a=False)
    require(after_a['evidence'] == {'writer_a': 'synthetic A'}, 'A must save first')
    require(final['evidence'] == {'writer_b': 'synthetic B'}, 'stale B overwrites A')
    record('stale_snapshots_lose_a', {'parseable_json': True,
                                    'evidence': final['evidence']})

    _, final = snapshots(reload_after_a=True)
    require_both(final)
    record('reload_after_a_keeps_both', {'evidence': final['evidence']})
    return rows


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--json', action='store_true', help='include runtime and observations')
    args = parser.parse_args()
    rows = run_cases()
    if args.json:
        print(json.dumps({'python': platform.python_version(),
                          'platform': platform.system(), 'cases': rows,
                          'passed': len(rows)}, ensure_ascii=False, indent=2))
    else:
        for row in rows:
            print('PASS ' + row['case'])
        print('TOTAL cases:', len(rows))


if __name__ == '__main__':
    main()

tests/test_boundary_lab.py

"""Independent assertions and deliberately wrong-writer/merge expectations."""
import json
import subprocess
import sys
import unittest
from pathlib import Path
from unittest.mock import patch

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'src'))
import boundary_lab as lab


class BoundaryTests(unittest.TestCase):
    def test_round_trip(self):
        with lab.isolated() as module:
            module.save(lab.seed())
            self.assertEqual(module.load(), lab.seed())

    def test_serialization_keeps_exact_destination(self):
        before, after, valid, _ = lab.attempt_serialization(lambda m, v: m.save(v))
        self.assertEqual(after, before)
        self.assertTrue(valid)

    def test_ordinary_exception_cleans_owned_temporary_file(self):
        *_, leftovers = lab.attempt_serialization(lambda m, v: m.save(v))
        self.assertEqual(leftovers, 0)

    def test_replace_failure_after_complete_same_directory_stage(self):
        with lab.isolated() as module:
            module.save(lab.seed())
            before = module.PATH.read_bytes()
            new = lab.seed()
            new['evidence']['writer_a'] = 'synthetic A'
            calls = []

            def reject(source, target):
                source, target = Path(source), Path(target)
                self.assertEqual(source.parent, target.parent)
                self.assertEqual(target, module.PATH)
                self.assertEqual(json.loads(source.read_bytes()), new)
                calls.append((source, target))
                raise OSError('synthetic rename refusal')

            with patch.object(module.os, 'replace', side_effect=reject):
                with self.assertRaises(OSError):
                    module.save(new)
            self.assertEqual(len(calls), 1)
            self.assertEqual(module.PATH.read_bytes(), before)
            self.assertEqual(list(module.ROOT.glob('.progress-*')), [])

    def test_direct_overwrite_leaves_nonempty_invalid_json(self):
        before, after, valid, _ = lab.attempt_serialization(lab.direct_overwrite)
        self.assertNotEqual(after, before)
        self.assertGreater(len(after), 0)
        self.assertFalse(valid)
        with self.assertRaises(json.JSONDecodeError):
            json.loads(after)

    def test_negative_control_rejects_direct_writer(self):
        before, after, _, _ = lab.attempt_serialization(lab.direct_overwrite)
        with self.assertRaisesRegex(AssertionError, 'old destination bytes changed'):
            lab.require_preserved(before, after)

    def test_stale_snapshot_is_valid_but_loses_a(self):
        after_a, final = lab.snapshots(reload_after_a=False)
        self.assertEqual(after_a['evidence'], {'writer_a': 'synthetic A'})
        self.assertEqual(final['evidence'], {'writer_b': 'synthetic B'})
        self.assertNotIn('writer_a', final['evidence'])
        self.assertEqual(final['phases'], lab.seed()['phases'])

    def test_negative_control_rejects_assumed_merge(self):
        _, final = lab.snapshots(reload_after_a=False)
        with self.assertRaisesRegex(AssertionError, 'both intended updates must survive'):
            lab.require_both(final)

    def test_loading_b_after_a_preserves_both(self):
        _, final = lab.snapshots(reload_after_a=True)
        self.assertEqual(final['evidence'],
                         {'writer_a': 'synthetic A', 'writer_b': 'synthetic B'})

    def test_lab_never_invokes_phase_verification_or_subprocess(self):
        with patch.object(lab.checkpoint, 'verify', side_effect=AssertionError('verify')):
            with patch.object(lab.checkpoint, 'main', side_effect=AssertionError('main')):
                with patch.object(lab.checkpoint.subprocess, 'run',
                                  side_effect=AssertionError('subprocess')):
                    self.assertEqual(len(lab.run_cases()), 7)

    def test_fixture_unchanged_and_paths_restored(self):
        fixture = ROOT / 'fixtures/PROGRESS.json'
        before = fixture.read_bytes()
        original = lab.checkpoint.ROOT, lab.checkpoint.PATH
        with lab.isolated() as module:
            temporary = module.ROOT
            module.save(lab.seed())
        self.assertFalse(temporary.exists())
        self.assertEqual((lab.checkpoint.ROOT, lab.checkpoint.PATH), original)
        self.assertEqual(fixture.read_bytes(), before)

    def test_cli_output_and_reject_external_target(self):
        command = [sys.executable, '-B', str(ROOT / 'src/boundary_lab.py')]
        result = subprocess.run(command, capture_output=True, text=True, timeout=15)
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stdout, (ROOT / 'expected-output.txt').read_text())
        self.assertEqual(result.stderr, '')
        rejected = subprocess.run(command + ['--target', str(ROOT)],
                                  capture_output=True, text=True, timeout=15)
        self.assertEqual(rejected.returncode, 2)
        self.assertEqual(rejected.stdout, '')


if __name__ == '__main__':
    unittest.main()

expected-output.txt

PASS round_trip
PASS staged_serialization_preserves_old
PASS staged_exception_cleans_temp
PASS replace_failure_preserves_old
PASS direct_overwrite_corrupts
PASS stale_snapshots_lose_a
PASS reload_after_a_keeps_both
TOTAL cases: 7

20. 用显式 Skill 固定每次续推的动作

Cursor Skills 文档支持项目目录 .cursor/skills/。下面的 name 和 description 用于描述这份 Skill;disable-model-invocation: true 让它只在用户显式输入 /advance-phase 时进入上下文。官方仍提供 /migrate-to-skills 迁移动态规则与斜杠命令;这不等于所有旧命令已经被删除。

将以下文件保存为 .cursor/skills/advance-phase/SKILL.md。打开这个练习目录,确认 Cursor 已发现 Skill,再在 Agent 聊天里输入 /advance-phase。每轮只做一个阶段。第一轮可以先观察它是否真的读了规格、查看 Git 并运行验收,再决定要不要继续。

---
name: advance-phase
description: 在 Markdown 链接练习中核对证据并推进一个待完成阶段。
disable-model-invocation: true
---

从项目根目录读取 TASK.md、SPEC.md、PROGRESS.json 与 acceptance.py。
运行 git status --short、git diff,并运行 python3 checkpoint.py verify。
如状态损坏、blocker 非空、已有声明验收失败,停止并说明;不可清除 blocker。
若全部 done,重跑 python3 acceptance.py cli,报告实际结果后停止。
否则按 scope、engine、cli 顺序只推进第一个 todo。
scope:确认输入、支持范围、验收约束;engine:实现或审查 scan;cli:实现或审查 CLI。
源码参考存在时先理解并测试,不为了制造改动重复重写。
只编辑该阶段必要文件,不改 TASK.md、SPEC.md、acceptance.py 或验收预期。
结束前运行 python3 checkpoint.py complete <阶段>;失败时保留现场并停止。
回复本阶段变更、验收命令与结果、Git 状态、下一阶段或阻塞。
每次只推进一个阶段,不提交、不推送、不联网、不安装依赖。

如果 Skill 没有出现,先检查文件夹、文件名和 frontmatter,再参照当前版本的 Skills 页面检查发现范围。本文只验证了这份文件的内容和离线命令,没有验证特定 Cursor 版本的菜单、自动发现或模型是否会严格遵守指令。指令本身也不能取代工具权限。

21. 启用自定义 hook 前,先看内置入口

截至 2026-10-07,官方 Skills 文档已列出 /loop、/automate 和 /autopilot。在 Agent 聊天中输入 / 并选择对应 Skill,再用自然语言说明任务。先确认当前客户端确实提供该入口;本文不据此保证所有 IDE 或 CLI 版本均可使用。

/loop 可按本地计划重复执行提示或 Skill,直到达到指定结果或用户停止;不指定固定间隔时,由 Agent 决定唤醒时机。选择 /loop 后,可以给出自然语言示例:每五分钟运行本地练习的 PYTHONDONTWRITEBYTECODE=1 python3 checkpoint.py verify,检查已完成阶段的验收结果;全部阶段完成或出现错误、阻塞时停止,只报告结果,不修改项目源码或进度记录,不自行修复问题。这是任务描述,不是命令参数语法。已核官方文档没有提供关机后继续执行的保证,也没有给出统一的最长运行时间或取消命令,使用前应确认实际运行与停止方式。

若需求是未来按时间或事件触发,/automate可从本地会话用自然语言配置 Automation,仍须核对触发器、工具、执行身份和用量。若已有 PR 需要处理反馈、冲突或失败检查,/autopilot提供云端子代理入口;该文档说明其从桌面应用的 Agents Window 运行,在独立云环境中把 PR 推进到可合并状态。可合并不等于会自动合并。

以上真实入口本次均未实测,仍需保留阶段验收并遵守工具权限。下方自定义 stop hook 用于演示可离线检查的停止条件和三轮上限,可按需选择。

22. 可选:给当前练习加一个有上限的 stop hook

先用手动 /advance-phase 跑通。只有理解 hook 会在会话结束一轮后提交新消息,并接受后续模型用量时,才考虑启用这一节。官方 Hooks 文档定义 stop 输入的 status、loop_count 和输出 followup_message;本例在配置与脚本中都限定最多三次自动续推。

stop hook 的有限续推条件
图 2 stop hook 的有限续推条件

交付项目默认只有 .cursor/hooks.example.json,不存在生效的 .cursor/hooks.json,也没有 CONTINUE.json。先审查配置和脚本;只在独立练习目录中,将示例配置复制为 .cursor/hooks.json。不要覆盖已经存在的 hooks.json,已有配置需要人工合并并重新检查。项目 hook 会执行本地命令,应当像审查脚本一样审查它。

{
  "version": 1,
  "hooks": {
    "stop": [
      {
        "command": "python3 .cursor/hooks/continue_phase.py",
        "loop_limit": 3
      }
    ]
  }
}

把下一段保存为 .cursor/hooks/continue_phase.py。脚本只返回固定消息或空对象,不修改进度。status 不是 completed、轮数异常、没有启用标记、会话 ID 不匹配、任务全完成、存在阻塞或读取损坏状态时,都返回 {}。

# Optional local gate. Never writes progress or removes a blocker.
import json
import sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT))
from checkpoint import load
MESSAGE='/advance-phase\n继续本次练习的下一个阶段;先核验文件、Git 与测试,遇到阻塞停止。'

def reply(event):
    if not isinstance(event,dict): return {}
    count=event.get('loop_count')
    if event.get('status')!='completed' or type(count) is not int or not 0<=count<3:
        return {}
    flag=json.loads((ROOT/'CONTINUE.json').read_text())
    if not isinstance(flag,dict) or flag.get('enabled') is not True: return {}
    # Bind this locally authorized exercise run to one conversation.
    cid=event.get('conversation_id')
    if not isinstance(cid,str) or not cid or flag.get('conversation_id')!=cid: return {}
    state=load()
    if state['blocker'] or all(x=='done' for x in state['phases'].values()): return {}
    return {'followup_message':MESSAGE}

if __name__=='__main__':
    try: result=reply(json.load(sys.stdin))
    except (OSError,ValueError,TypeError,KeyError): result={}
    print(json.dumps(result,ensure_ascii=False))

本次练习要明确启用时,由操作者创建 CONTINUE.json,并把 conversation_id 填成当前 hook 事件提供的真实会话标识。不要照抄下面的占位值。若无法确认这个标识,就保持关闭并手动调用 Skill。这个标记是本例的一次练习许可,不是 Cursor 官方 run 配置,也不能用于云端 API 的 runId。

{
  "enabled": true,
  "conversation_id": "替换为本次练习的真实会话标识"
}

练习结束或人工接管时,将 enabled 改为 false。同一会话稍后开始新任务,也要先关闭旧标记;conversation_id 对整个对话稳定,标记不会自行过期。循环上限限制本次对话的自动续推,不提供时间调度。取消或报错时脚本不会尝试恢复。完成三个阶段后自动续推终止,仍应由人检查最终文件和测试结果。

stop 只响应活跃会话的事件。它不会在你关掉应用后重新启动进程。还有企业、团队或用户级 hook 时,其他配置可能影响最终合并结果;因此本例的离线返回值测试不等于真实宿主已完成端到端验证。

23. 用模拟输入先验证停止条件

把下面完整脚本保存为练习目录 cursor-phase-lab/run_checks.py,与 checkpoint.py 同层。先保存前文全部项目文件,以及可选小节中的 .cursor/hooks/continue_phase.py;无需创建生效的 hooks.json,也无需启用真实 hook。缺少文件时,脚本以退出码 2 列出缺失路径。

在该目录运行 python3 run_checks.py。无论尚未执行 complete,还是已经按前文完成全部三阶段,都使用同一条命令。脚本只复制列明的教学输入到新建临时目录,给临时副本建立全 todo 状态与独立 Git 仓库,不复制原目录的 Git、真实 hook 配置、CONTINUE.json 或 PROGRESS.json。恢复初始状态的动作仅作用于这个测试副本,原练习的进度、阻塞和启用标记保持不变。

测试使用 conversation_id 为 offline-fixture 的模拟输入,检查 completed 可以续推、aborted/error、轮数上限、会话不匹配、阻塞、全部完成与损坏状态均停止。脚本不调用模型、不注册 Cursor hook,也不创建云端任务;测试目录退出后自动清理。

from pathlib import Path
import json, shutil, subprocess, sys, tempfile
ROOT=Path(__file__).resolve().parent
FILES=[
 'TASK.md','SPEC.md','check_links.py','acceptance.py','checkpoint.py',
 'fixtures/ok/README.md','fixtures/ok/guide.md','fixtures/bad/README.md',
 '.cursor/hooks/continue_phase.py',
]
# Copy only the published exercise inputs. Never copy .git, hooks.json,
# CONTINUE.json, or the reader's current progress into the test workspace.
missing=[name for name in FILES if not (ROOT/name).is_file()]
if missing:
 print('Missing exercise files beside run_checks.py: '+', '.join(missing), file=sys.stderr)
 print('Save the named article files, including the optional hook script; no hook registration is needed.', file=sys.stderr)
 raise SystemExit(2)
def fresh_progress(p):
 state={'version':1,'phases':{'scope':'todo','engine':'todo','cli':'todo'},
        'blocker':'','evidence':{}}
 (p/'PROGRESS.json').write_text(json.dumps(state)+'\n',encoding='utf-8')
checks=[]
def run(p,*args,data=None):
 return subprocess.run([sys.executable,*args],cwd=p,input=data,capture_output=True,text=True)
def ok(label,condition):
 if not condition: raise AssertionError(label)
 checks.append(label)
with tempfile.TemporaryDirectory() as d:
 p=Path(d)/'exercise'; p.mkdir()
 for name in FILES:
  destination=p/name; destination.parent.mkdir(parents=True,exist_ok=True)
  shutil.copyfile(ROOT/name,destination)
 fresh_progress(p)
 subprocess.run(['git','init','-q',str(p)],check=True)
 r=run(p,'acceptance.py','cli');ok('complete reference acceptance: 6 tests',r.returncode==0)
 for phase in ['scope','engine','cli']:
  r=run(p,'checkpoint.py','complete',phase);ok('complete '+phase,r.returncode==0)
 old=(p/'PROGRESS.json').read_bytes()
 ok('repeated completion verifies',run(p,'checkpoint.py','complete','cli').returncode==0)
 ok('idempotent state bytes',old==(p/'PROGRESS.json').read_bytes())
 (p/'check_links.py').write_text('raise RuntimeError("broken")')
 ok('done cannot hide broken code',run(p,'checkpoint.py','verify').returncode!=0)
 shutil.copyfile(ROOT/'check_links.py',p/'check_links.py')
 (p/'PROGRESS.json').write_text('{broken')
 ok('corrupt state rejected',run(p,'checkpoint.py','verify').returncode!=0)
 ok('corrupt state not reset',(p/'PROGRESS.json').read_text()=='{broken')
 fresh_progress(p)
 ok('phase jump rejected',run(p,'checkpoint.py','complete','cli').returncode!=0)
 event={'status':'completed','loop_count':0,'conversation_id':'offline-fixture'}
 def hook(e):
  r=run(p,'.cursor/hooks/continue_phase.py',data=json.dumps(e));
  ok('hook exits cleanly',r.returncode==0)
  return json.loads(r.stdout)
 ok('disabled by default',hook(event)=={})
 (p/'CONTINUE.json').write_text(json.dumps({'enabled':True,'conversation_id':'offline-fixture'}))
 ok('authorized completed continues',bool(hook(event).get('followup_message')))
 for status in ['aborted','error','unknown']:
  ok(status+' stops',hook(dict(event,status=status))=={})
 for count in [-1,3,4,True,'0',None]:
  ok('invalid/exhausted count '+repr(count),hook(dict(event,loop_count=count))=={})
 ok('different conversation stops',hook(dict(event,conversation_id='other'))=={})
 state=json.loads((p/'PROGRESS.json').read_text());state['blocker']='needs clarification'
 (p/'PROGRESS.json').write_text(json.dumps(state));before=(p/'PROGRESS.json').read_bytes()
 ok('blocker stops',hook(event)=={});ok('blocker retained',before==(p/'PROGRESS.json').read_bytes())
 state['blocker']='';state['phases']={k:'done' for k in state['phases']}
 (p/'PROGRESS.json').write_text(json.dumps(state));ok('all done stops',hook(event)=={})
 (p/'PROGRESS.json').write_text('{bad');ok('corrupt progress stops hook',hook(event)=={})
 r=run(p,'.cursor/hooks/continue_phase.py',data='{bad');ok('malformed event stops',r.stdout.strip()=='{}')
print('\n'.join('PASS '+x for x in checks))
print('TOTAL assertions:',len(checks))

回归脚本包含 42 条离线断言,首项还运行独立验收中的 6 个 unittest。保留阶段顺序、重复完成不改状态、已完成后代码损坏仍拒绝等原有检查。正常输出以 TOTAL assertions: 42 结束;hook exits cleanly 会重复出现,因为每种输入均检查退出码。这里测试的是本地 Python 项目与模拟 hook 输入,不等同真实 Cursor 宿主运行。

24. 什么时候把任务交给 Cloud Agent

根据下一步工作选择执行位置
图 3 根据下一步工作选择执行位置

如果任务需要在离开本地电脑后继续完成,可以评估 Cloud Agents。交接前应确定实际使用的仓库、起始分支或提交、环境与测试命令。整理成一份可在目标环境读取的任务说明,说明允许修改的范围、验收要求、输出位置和必须停下来询问的条件。

不要假定当前未提交工作会自动传到云端,也不要假定个人 Skill 已经同步。项目 Skill 只有在目标工作区实际拥有相应文件时才有用;个人 ~/.cursor/skills/ 的云同步有独立开关和团队控制。跨环境交接之前,让目标环境读回 TASK、SPEC、验收文件和当前代码版本,再开始下一阶段。

本例需要 Python 与 Git,没用网络和外部秘密。真实仓库可能需要依赖、构建变量或访问权限;这些前提不满足时应该报告阻塞。不要为了让云端继续而把整个本地目录、凭据或无关文件放进交接提示。本文不创建云端任务,也不提供带密钥的请求示例。

用自然语言交接可以这样写:

在已确认的仓库与分支上继续 Markdown 链接检查任务。
先读 TASK.md、SPEC.md、PROGRESS.json 与 acceptance.py。
确认工作目录、当前提交及 git status;重新运行所有已声明完成阶段的验收。
只推进第一个 todo 阶段,不修改规格或验收预期。
若文件缺失、环境不同、需要新权限或出现 blocker,停止并报告。
完成后给出改动、测试命令与结果、分支/提交和剩余限制。

25. 什么时候需要 Automations

固定每周检查文档、PR 打开时审查链接相关修改,这类工作有未来的触发条件,可以考虑 Cursor Automations。本地一个三阶段任务尚未结束,通常先完成当前会话或明确交给 Cloud Agent,不必为续推几轮创建周期任务。

2026 年 3 月 5 日发布说明介绍了按时间或事件启动云端 Agent 的方式。当前能力与设置仍应以 Automations 文档为准。设计时写清触发源、处理的仓库或输入范围、允许的工具、没有发现时的行为,以及具体输出对象。能读 PR 不应自动扩大成可批准、合并或任意发送消息。

用量与身份也要一起检查。官方 Automations 帮助说明每次触发创建 Cloud Agent run,并按所选模型 API 定价计费;团队中的 Run as 决定以谁的认证执行、用量归谁。当前主文档区分 Me 与 Service account;后者由团队管理员选择,使用专属服务账户与团队用量池。这里不提供固定价格或无限额度承诺。

创建前核对账户当前可见的预算或用量限制、模型、触发频率、仓库访问和外部工具权限。团队共享可见性与执行身份是不同设置,不要因为同事能看到任务就假定它会使用同事的权限。首次只选择范围小、输出容易检查的工作,运行后核对结果和实际用量。

26. 停止、取消与继续要对准对象

本地练习想停止后续轮次,先关闭 CONTINUE.json,再对正在执行的会话使用当前客户端提供的停止方式;关闭标记只影响后续 hook 判断,不会撤回已经执行的文件修改。CLI 的 /quit、/exit 用于退出程序,不能当作云任务取消接口。

Automation 的启用状态控制未来触发。需要结束已经开始的工作时,还要检查它创建的具体 run;不能把停用 Automation 视为在途 run 已被取消。界面和账户能力会变,本文不编造统一的“全部取消”按钮。

Cloud Agents API v1 文档目前标为 public beta。POST /v1/agents/{id}/runs/{runId}/cancel 取消一个活跃 run,CANCELLED 是终态,原 run 不能恢复。需要继续同一对话时,在同一 agent 上创建新 run。一个 agent 同时只能有一个活跃 run;已有 CREATING 或 RUNNING 时创建新 run 会返回 409 agent_busy。对不可取消的 run 请求取消可能返回 409 run_not_cancellable。

以上只用于解释生命周期边界,本文没有调用 API。收到冲突应先读回当前状态,确认是不是仍有工作进行;盲目重试创建可能增加噪声。取消也不会自动撤销已经发生的外部副作用,仓库变更和已发送信息需要另外检查。

27. 完成时保留一份人能复核的结果

一个可交付的结束回复应包括:这次推进了哪个阶段、改了哪些文件、运行了哪些验收命令和实际结果、当前 Git 状态,以及仍不支持的语法。读者应能用这些信息重新运行项目,判断是否需要扩大需求。进度文件中所有阶段为 done,只是结束检查的入口。

本地参考实现与 42 条断言已经离线验证。真实 Cursor 中的 Skill 发现、模型执行、stop hook 调用顺序和权限交互,以及云端环境、账户用量与 UI 都没有实测。若要用于真实仓库,先保留手动逐阶段推进,观察真实工具记录,再决定是否启用三轮以内的自动续推。

关闭 Cursor 后 stop hook 会继续吗?

本例依附活跃会话事件,没有后台唤醒功能。需要脱离本地继续时,应明确交给可用的 Cloud Agent 环境。

PROGRESS.json 是 Cursor 内置格式吗?

不是。这是本文的本地练习约定,完成声明还要用真实文件、Git 状态和测试重新核对。

取消云端 run 后可以 resume 原 run 吗?

当前 v1 API 把取消视为终态。继续同一对话应在同一 agent 上创建新 run。

是否默认启用自动续推?

没有。项目只提供示例配置;读者审阅并确认当前练习范围后,才自行启用。