dsh-tower 2.1.0-beta.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +8 -1
- package/README.en.md +8 -2
- package/README.md +8 -2
- package/adapters/default.js +2 -0
- package/app/archive.js +190 -0
- package/app/board.js +3 -1
- package/app/checkCompletion.js +15 -2
- package/app/gate.js +31 -13
- package/app/init.js +45 -3
- package/app/merge.js +6 -0
- package/app/mission.js +2 -0
- package/app/plan.js +12 -4
- package/app/read.js +59 -3
- package/app/review.js +2 -0
- package/app/teardown.js +22 -1
- package/app/tower.js +34 -6
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +3 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +4 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +2 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +6 -6
- package/docs/reference/tools.md +7 -6
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/config/schema.js +26 -3
- package/domain/contracts/outputs.js +27 -1
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/errors.js +2 -0
- package/domain/integration/gate.js +25 -11
- package/domain/integration/init.js +40 -4
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +58 -5
- package/domain/missions/lifecycle.js +2 -1
- package/domain/planning.js +6 -3
- package/domain/read/views.js +14 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/lib/archive.js +279 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/doctor.js +178 -0
- package/lib/leases.js +14 -5
- package/lib/legacy.js +19 -5
- package/lib/persistence.js +12 -5
- package/lib/stateFile.js +99 -0
- package/lib/store.js +15 -0
- package/lib/tests.js +6 -2
- package/lib/worktreeOwnership.js +18 -7
- package/package.json +1 -1
- package/scripts/pack-manifest.txt +9 -0
- package/tools/complete.js +1 -1
- package/tools/doctor.js +1 -1
- package/tools/init.js +1 -1
- package/tools/plan.js +1 -1
- package/tools/preflight.js +1 -0
- package/tools/status.js +14 -6
- package/tools/teardown.js +1 -1
- package/types/index.d.ts +34 -0
package/docs/reference/tools.md
CHANGED
|
@@ -11,7 +11,8 @@ The build exposes up to 29 tools: 19 core repository tools below (including `tow
|
|
|
11
11
|
`task_drop_reason`/`taskDropReason`, `clear_blockers`/`clearBlockers`,
|
|
12
12
|
`include_read`/`includeRead`, `dry_run`/`dryRun`, `timeoutMs`/`timeout_ms`,
|
|
13
13
|
`waitMs`/`wait_ms`, `mission_id`/`missionId` on findings, `agent_id`/`agentId` on interrupt,
|
|
14
|
-
`include_gate`/`includeGate
|
|
14
|
+
`include_gate`/`includeGate`, `include_archived`/`includeArchived` on status,
|
|
15
|
+
`retire_missing`/`retireMissing` on adopt. Outputs use
|
|
15
16
|
canonical camelCase names.
|
|
16
17
|
|
|
17
18
|
## Roles
|
|
@@ -31,16 +32,16 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
|
|
|
31
32
|
| Tool | Parameters | Purpose / notes |
|
|
32
33
|
|---|---|---|
|
|
33
34
|
| `tower_init` | `base?`, `autoGitInit?`, `snapshotDirty?`, `reanchor?` | Create `<git-common-dir>/dsh-tower/state.json`. `autoGitInit` runs `git init` + empty initial commit; `snapshotDirty` records a dangling snapshot commit of uncommitted tracked changes used as the mission baseline; `reanchor` changes base only when no non-terminal mission exists. Returns `dirty`, `snapshotCommit`, `reanchored`. |
|
|
34
|
-
| `tower_plan` | `missions[]` (`title
|
|
35
|
+
| `tower_plan` | `missions[]` (`title` — printable Unicode, NFC-normalized, ≤120 characters, `scope[]`, `tasks[]?`, `deps[]?`, `kind?`, `context?`, `references[]?` ≤20, `reviewPolicy?`, `reviewExemptionReason?`) | Create missions `M1..`; overlapping build scopes auto-add a dependency; rejects unknown deps and cycles. Mission ids stay ASCII (`M1`, `M2`); invisible characters and over-long titles are rejected with `invalid-argument`. `reviewPolicy:"none"` requires a non-empty reason; when the review gate is on, preflight also requires `gates.allowReviewExemption`. |
|
|
35
36
|
| `tower_mission` | `id` (alias `missionId`), `status?`, `task_done?`, `task_drop?`+`task_drop_reason?`, `blocker?`, `clear_blockers?`, `note?`, `scope?`, `pause_cause?`, `authorization?` | Update one mission. Worker-safe fields are task/blocker/note; `merged` cannot be set here. Paused missions record `pause_cause` (user/failure/resource/dependency); resuming a user-paused mission honors `mode.requireResumeAuthorization`. |
|
|
36
|
-
|
|
37
|
+
\|\ `tower_status`\ \|\ `missionId\?`,\ `offset\?`,\ `limit\?`,\ `includeGate\?`,\ `includeArchived\?`,\ `format\?`\ \|\ Dashboard\ by\ default\.\ Paged\ missions\ carry\ `reportBytes`\ only;\ `missionId`\ returns\ full\ detail\ \(tasks/notes/reviews/findings/completionReport\ body\),\ and\ an\ archived\ mission\ is\ loaded\ from\ its\ year\ file\.\ `offset`/`limit`\ page\ active\ missions\ \(default\ 20,\ max\ 100\),\ `includeGate`\ adds\ gate\ digests,\ `includeArchived`\ appends\ compact\ archived\ summaries\ \(flagged\ `archived:true`,\ no\ report\ body\)\ after\ the\ active\ page,\ and\ `format:"markdown"`\ returns\ the\ board\ \(active\ rows\ plus\ an\ archived\ count\)\.\ Includes\ `mode`\ with\ `lastChange`\ and\ recent\ `history`\ when\ the\ host\ mode\ hook\ is\ available\.\ `archive`\ reports\ `\{count,\ years,\ unreadable,\ message}`;\ a\ corrupt\ archive\ degrades\ to\ `unreadable:true`\ instead\ of\ failing\ the\ dashboard\.\ \|
|
|
37
38
|
| `tower_spawn` | `missionId` (alias `id`), `role` (`worker`/`reviewer`), `name?`, `instructions?`, `worktreeRoot?`, `wait?`, `waitMs?`, `takeover?`, `includeDirtyBase?` | Provision/reuse the worktree and start a subagent. Worker uses `tower-worker`; reviewer requires a `completed` mission and uses the read-only `tower-reviewer`. `takeover:true` replaces a live worker entry that is no longer running (increments ownerGeneration). Only one live worker per mission; `spawn.maxLiveWorkers` caps the repository. |
|
|
38
39
|
| `tower_roster` | — | Roster with live activity (`running`/`inactive`/`missing`/`unknown`) and `stale` flags from the subagent catalog. Read-only. |
|
|
39
40
|
| `tower_adopt` | `retireMissing?` | Reconcile roster entries with the durable subagent catalog. Provably gone agents are retired (default) or marked `missing` (`retireMissing:false`) so they can be inspected/taken over. |
|
|
40
41
|
| `tower_diff` | `missionId` (alias `id`), `maxBytes?` | Read-only merge-base diff: files, commits, capped patch. Safe for reviewers. |
|
|
41
42
|
| `tower_send` | `to`, `subject`, `body`, `scope?`, `interrupt?`, `consentRef?` | Durable message to a roster name, `tower`, or `all`; delivers live to a running agent. `interrupt:true` interrupts the running turn first (Kimi-style), then delivers; `consentRef` is recorded for audit. Rate-limited by `send.maxPerMinute`. |
|
|
42
43
|
| `tower_inbox` | `limit?`, `include_read?` | Read/ack messages addressed to the caller; unread messages block `tower_complete`. |
|
|
43
|
-
| `tower_complete` | `missionId` (alias `id`), `report`, `checks?` | Worker reports completion (mission becomes `completed`, ready for preflight and optional review). Requires current owner, no open tasks/blockers, ≥1 commit, no unread inbox. `checks` records `{name, command?, exitCode?, evidence?}` evidence; `gates.requireChecks` can demand it. When configured, test discovery runs automatically and adds a `tests` check; failure throws `[tower:tests-failed]`. |
|
|
44
|
+
| `tower_complete` | `missionId` (alias `id`), `report`, `checks?` | Worker reports completion (mission becomes `completed`, ready for preflight and optional review). Requires current owner, no open tasks/blockers, ≥1 commit, no unread inbox. `report` is capped at `gates.maxReportBytes` (default 32768 UTF-8 bytes): a larger report is rejected with `invalid-argument` and a `nextAction` to file the full text in the mission worktree, never truncated; the stored report records `reportBytes`. `checks` records `{name, command?, exitCode?, evidence?}` evidence; `gates.requireChecks` can demand it. When configured, test discovery runs automatically and adds a `tests` check; failure throws `[tower:tests-failed]`. |
|
|
44
45
|
| `tower_interrupt` | `to?` (roster name), `agentId?`, `reason?` | Interrupt one running roster agent via `subagents.interrupt`, record `state:"interrupted"`. Idempotent (not running → `interrupted:false`). |
|
|
45
46
|
| `tower_finding` | `type`, `title`, `summary`, `details`, `suggested_fix`, `severity?`, `missionId?`, `location?` | Record an out-of-scope issue; reviewers use this instead of editing. Finds start as `open`. |
|
|
46
47
|
| `tower_findings` | `id?`, `status?`, `note?`, filters (`type?`, `severity?`, `missionId?`, `open?`, `limit?`) | Lifecycle tool: list/read/transition. Statuses `open → triaged → fixed`/`wontfix`, reopen allowed. Only the tower or a worker may mutate; a reviewer can list/read. |
|
|
@@ -48,11 +49,11 @@ findings but not mutate them. Violations throw `[tower:not-authorized]` or
|
|
|
48
49
|
| `tower_preflight` | `missionId` (alias `id`) | Read-only gate: returns `{ok, blockers[], branchTip, baseTip, changedFiles, review}`. |
|
|
49
50
|
| `tower_merge` | `missionId` (alias `id`), `timeoutMs?` (alias `timeout_ms`) | Merge after a green gate. Records already-landed merges (`reconciled`), recovers a failed call whose commit exists (`recoveredFromFailure`), and separates `merge-hook-failed` from `merge-conflict`. |
|
|
50
51
|
| `tower_rebase` | `missionId` (alias `id`) | Rebase the branch onto the base in its worktree. Refuses while the worker runs or the worktree is dirty. Already-contained branches return `alreadyMerged` untouched. |
|
|
51
|
-
| `tower_teardown` | `force?`, `exclude?`, `dry_run?` (alias `dryRun`) | Remove eligible merged/abandoned worktrees and branches; running agents are always kept. |
|
|
52
|
+
| `tower_teardown` | `force?`, `exclude?`, `dry_run?` (alias `dryRun`) | Remove eligible merged/abandoned worktrees and branches; running agents are always kept. The final pass applies the `archive` policy: terminal missions whose worktree is gone are moved to `<git-common-dir>/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, and `archived` lists their ids. Nothing is deleted; `archive.afterDays:0` archives a just-torn-down mission immediately. |
|
|
52
53
|
| `tower_mode` | — | Report mode, last change, paused goal, denied tools, store path, plugin version, HMR availability, workspace-lock holder. |
|
|
53
54
|
| `tower_control` | `action` (`enter`/`exit`), `reason` | Model-initiated mode change; registered by default; disable with `mode.allowAgentControl:false`; reason is mandatory. |
|
|
54
55
|
| `tower_reload` | `repoRoot?` | Dev-only: ask DSH HMR to reconcile the profile and reload the Host plugin from disk. Registered while `dev.hotReload` is on; scheduled after the result (default 50 ms); fails with `reload-unavailable` without HMR. When `repoRoot` is supplied the caller must be the tower. |
|
|
55
|
-
| `tower_doctor` | — (takes `repoRoot`) | Read-only health contract: git, state schema, config, presets/subagents/HMR, workspace lock, roster, worktree realpath/ownership, test config, leases. Returns `{ok, checks[{id,status,detail}], environmentContract}`. Never writes. |
|
|
56
|
+
| `tower_doctor` | — (takes `repoRoot`) | Read-only health contract: git, state schema, config, presets/subagents/HMR, workspace lock, roster, worktree realpath/ownership, test config, leases and the terminal-mission archive. Returns `{ok, checks[{id,status,detail}], stateSchema, archive, environmentContract}`; `archive` reports `{missions, archivedMissions, stateBytes, archiveBytes, years, files, unreadable, policy{enabled,afterDays,maxMissions}}`. A missing archive directory is healthy; a corrupt file is a `warn` with its diagnostic. Never writes. |
|
|
56
57
|
| `tower_recover` | `action` (`status`/`clear-stale-lock`), `confirm?` | Recovery diagnostics plus an explicit stale state-lock repair. `clear-stale-lock` requires `confirm:"CLEAR_STALE_LOCK"` and only moves a lock whose PID is provably dead to a backup; it never resets state/worktrees. |
|
|
57
58
|
| `tower_test` | `missionId` (alias `id`) | Run the worktree's `package.json` `tower.testDiscovery` and record a receipt; `ok:false` on failure. Use `tower_complete` for automatic admission checks. |
|
|
58
59
|
| `tower_batch` | `action` (`create`/`status`/`close`/`abandon`), `missionIds?`, `exclusions?`, `requiredChecks?`, `receiptPaths?`, `batchId?`, `checks?`, `treeTip?`, `receiptHashes?`, `reason?` | Finite evidence batch. Create freezes members/checks/receipts; close verifies every required check and receipt hash; merged members are eligible; abandon retires a stale open batch without deleting missions. Membership is immutable. |
|
|
@@ -31,7 +31,19 @@ Host 插件在 DSH 启动时加载:**安装或更新后需要重启 DSH**。
|
|
|
31
31
|
|
|
32
32
|
## 迁移说明
|
|
33
33
|
|
|
34
|
-
- **
|
|
34
|
+
- **state schema v1 → v2(未发布)**:`state.json` 会自动迁移。升级后的第一次**写入**会保留
|
|
35
|
+
`state.json.v1.bak` 备份、`revision` 只 +1,并向 `activity.jsonl` 追加 `state.migrated {from,to}`;
|
|
36
|
+
只读工具仍可读取 v1 文件且不改写它。`tower_doctor` 报告磁盘上的版本与待执行的迁移。由更新插件
|
|
37
|
+
写入的文件会以 `[tower:state-version-unsupported]` 拒绝,绝不会被重写或降级;迁移抛错会报
|
|
38
|
+
`[tower:state-migration-failed]`,原文件保持字节不变。
|
|
39
|
+
- **state schema v2 → v3(未发布,#90)**:scope 匹配跟随检出目录所在文件系统的行为。迁移把
|
|
40
|
+
`caseInsensitive` 回填为 `null`(“尚未探测”);迁移不会读取文件系统,因此升级后的第一次
|
|
41
|
+
`tower_init` 会探测一次并记录 `true`/`false`。在此之前使用安全的 `case-sensitive` 比较,
|
|
42
|
+
`tower_doctor` 会把未知模式报告为 `warn`(`case-mode`)。在大小写不敏感的检出上,
|
|
43
|
+
`src/Api/**` 与 `src/api/**` 从此视为重叠,仅大小写不同的改动不再被 out-of-scope 门禁拒绝。
|
|
44
|
+
存储的 scope 文本永远不会被改写:看板仍显示操作者输入的原样。
|
|
45
|
+
- **2.0.4 → 2.1.0-beta.1**:不需要迁移 config;state 会按上述说明自动迁移(v1/v2 → v3)。
|
|
46
|
+
这是发布到 `next` dist-tag 的**预发布**,
|
|
35
47
|
`latest` 仍指向 2.0.4——请用 `dsh plugin --profile web add dsh-tower@next`(或精确版本号)安装。
|
|
36
48
|
运行时行为未变;本版本承载的是交付闸门(CI 中的 mount smoke、`npm run release:check`、证据约定
|
|
37
49
|
与追踪策略),而不是工具变化。本文档先于人工发布步骤合入:只有在[发布清单](release.md)执行
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| `branch-missing` | 还没有 worktree/分支:先 spawn worker。 |
|
|
18
18
|
| `stale-base` | 分支不包含基线 tip:执行 `tower_rebase`。 |
|
|
19
19
|
| `read-only-survey` | survey mission 修改了文件;survey mission 必须只读。 |
|
|
20
|
-
| `out-of-scope` | 改动文件超出 mission scope;有意为之就扩大 scope
|
|
20
|
+
| `out-of-scope` | 改动文件超出 mission scope;有意为之就扩大 scope,否则回退。在大小写敏感的检出上,仅大小写不同的路径仍会被拒绝,detail 会指出折叠大小写后本可匹配的 scope 条目。 |
|
|
21
21
|
| `base-dirty` | 主检出存在未提交改动,合并会覆盖它们。 |
|
|
22
22
|
| `deps-unmerged` | 依赖 mission 尚未 merged/abandoned。 |
|
|
23
23
|
| `no-review` | `review.requireReview:true` 且分支上没有审查:spawn reviewer(或关闭审查门禁)。 |
|
|
@@ -82,8 +82,12 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
82
82
|
| `gates.requireChecks` | `false` | 合并前要求至少一条 `tower_complete.checks` 证据(`no-check-evidence`)。 |
|
|
83
83
|
| `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。 |
|
|
84
84
|
| `gates.allowReviewExemption` | `false` | 在 `review.requireReview:true` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;全局审查关闭时无影响。 |
|
|
85
|
+
| `gates.maxReportBytes` | `32768`(32 KiB) | `tower_complete.report` 的 UTF-8 字节硬上限。超出时以 `invalid-argument` 拒绝并给出可执行的 `nextAction`(把全文写进 mission worktree 的文件并引用路径),绝不截断。已存在的超限报告仍可读取。 |
|
|
85
86
|
| `docs.board` | `true` | 变更后写入 `MISSIONS.md`。 |
|
|
86
87
|
| `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。 |
|
|
88
|
+
| `archive.enabled` | `true` | 在变更边界把 worktree 已移除的终态(`merged`/`abandoned`)任务移入 `<git-common-dir>/dsh-tower/archive/`。归档任务保留 tasks、reviews、findings 与完成报告;这是移动,不是删除。 |
|
|
89
|
+
| `archive.afterDays` | `30` | 年龄触发:终态任务超过这么多天未更新,会在下次变更时归档(`0` 表示立即归档)。 |
|
|
90
|
+
| `archive.maxMissions` | `100` | 数量触发:热状态中的终态任务超过此数时,从最旧的开始归档直到满足上限(`0` 表示归档所有符合条件的终态任务)。worktree 仍存在的任务永不归档。 |
|
|
87
91
|
| `tools.exposeOperational` | `true` | 注册 batch/resource/import/legacy/recover/test/doctor 工具;`false` 可缩小固定工具 schema 上下文。 |
|
|
88
92
|
| `leases.ttlMs` | `900000` | 默认咨询式租约寿命;过期不会自动释放。 |
|
|
89
93
|
| `dev.hotReload` | `true` | 注册 `tower_reload`;关闭时 `/tower reload` 也被禁用。无 HMR 服务时调用返回 `reload-unavailable`。 |
|
|
@@ -11,21 +11,54 @@
|
|
|
11
11
|
{ "id": "git", "status": "pass", "detail": "git version 2.55.0" },
|
|
12
12
|
{ "id": "state", "status": "pass", "detail": ".../state.json revision 7" }
|
|
13
13
|
],
|
|
14
|
+
"archive": {
|
|
15
|
+
"missions": 3,
|
|
16
|
+
"archivedMissions": 12,
|
|
17
|
+
"stateBytes": 48211,
|
|
18
|
+
"archiveBytes": 190244,
|
|
19
|
+
"years": [2026],
|
|
20
|
+
"files": 2,
|
|
21
|
+
"unreadable": [],
|
|
22
|
+
"policy": { "enabled": true, "afterDays": 30, "maxMissions": 100 }
|
|
23
|
+
},
|
|
14
24
|
"environmentContract": {
|
|
15
25
|
"platform": "win32",
|
|
16
26
|
"gitVersion": "git version 2.55.0",
|
|
17
27
|
"repoRoot": "D:/repo",
|
|
18
28
|
"commonDir": "D:/repo/.git",
|
|
19
|
-
"stateDir": "D:/repo/.git/dsh-tower"
|
|
29
|
+
"stateDir": "D:/repo/.git/dsh-tower",
|
|
30
|
+
"caseInsensitive": true,
|
|
31
|
+
"comparisonMode": "case-insensitive"
|
|
20
32
|
}
|
|
21
33
|
}
|
|
22
34
|
```
|
|
23
35
|
|
|
36
|
+
`archive` 报告终态任务归档:活跃 `missions`、`archivedMissions` 与 `stateBytes`,归档目录的
|
|
37
|
+
`archiveBytes`/`years`/`files`,每条 `unreadable` 诊断,以及配置的 `policy`(`enabled`、`afterDays`、
|
|
38
|
+
`maxMissions`)。归档目录不存在是健康的空归档(`archive` 检查为 `pass`);截断的 index/year 文件是
|
|
39
|
+
`warn` 并附带诊断,且归档会暂停而不是覆盖它——归档不可读期间没有数据会丢失,符合条件的 mission 仍留在
|
|
40
|
+
`state.json`。
|
|
41
|
+
|
|
24
42
|
检查覆盖:git 可用性、仓库身份、state schema/加载、仓库配置是否存在、worker/reviewer preset 名称、
|
|
25
43
|
subagents 与 HMR 服务、workspace 锁持有者/是否过期、实时 roster、worktree 是否存在/归属/是否脏、
|
|
26
|
-
test-discovery
|
|
44
|
+
test-discovery 策略、完成报告大小、归档健康度、旧版快照、batch 与租约。被 `tower_teardown` 正常清理的 mission worktree 会被
|
|
27
45
|
跳过(检查中会注明已移除数量),不会被误报为 missing。`warn` 是提示;`fail` 表示仓库在 spawn/merge 前需要
|
|
46
|
+
|
|
47
|
+
`caseInsensitive` 是 `tower_init` 在检出目录所在文件系统上一次性探测的结果;`null` 表示“尚未探测”,
|
|
48
|
+
此时 `case-mode` 检查为 `warn`。`comparisonMode` 是 scope 匹配实际使用的比较方式:
|
|
49
|
+
`case-insensitive` 会折叠双方大小写,`case-sensitive` 则保持逐字节精确。doctor 从不执行探测(探测会
|
|
50
|
+
创建临时文件,只读调用不能写入);在没有可用写权限的检出上运行 `tower_init` 会记录该模式,在此之前
|
|
51
|
+
使用安全的 `case-sensitive` 默认值。
|
|
52
|
+
|
|
53
|
+
检查覆盖:git 可用性、仓库身份、state schema/加载、已探测的大小写模式、仓库配置是否存在、
|
|
54
|
+
worker/reviewer preset 名称、subagents 与 HMR 服务、workspace 锁持有者/是否过期、实时 roster、
|
|
55
|
+
worktree 是否存在/归属/是否脏、test-discovery 策略、旧版快照、batch 与租约。被 `tower_teardown`
|
|
56
|
+
正常清理的 mission worktree 会被跳过(检查中会注明已移除数量),不会被误报为 missing。`warn` 是提示;`fail` 表示仓库在 spawn/merge 前需要
|
|
28
57
|
处理。state 加载失败会作为失败的检查报告而不是抛出,因此 doctor 在损坏的仓库上仍然有用。
|
|
29
58
|
|
|
59
|
+
`reports` 检查会指出仓库中最大的完成报告(`<bytes> B on <mission>`,对照 `gates.maxReportBytes` 上限),
|
|
60
|
+
同样的数据也以 `reports: { count, largest: { missionId, bytes } | null, capBytes }` 返回。上限生效前写入的
|
|
61
|
+
报告只会产生 `warn`,doctor 绝不会重写它们。
|
|
62
|
+
|
|
30
63
|
环境契约仅供参考;它不是身份或授权证明。`tower_init` 仍要求控制塔角色,`tower_spawn` 仍会检查
|
|
31
64
|
实时子代理目录。
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[English](../../reference/errors.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
|
|
6
|
-
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义
|
|
6
|
+
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 35 个工具错误码。
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
[tower:mission-not-found] unknown mission "M9"
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
| `stale-base` | 操作准备期间仓库状态已变化,需要重试。 | two-phase mutation 重校验 |
|
|
24
24
|
| `worktree-dirty` | mission worktree 有未提交改动,不能完成。 | `tower_complete` 前遗留 staged/modified/untracked 文件 |
|
|
25
25
|
| `state-locked` | 另一个 Tower 写入者持有 state 锁。 | 锁超时后的并发写 |
|
|
26
|
-
| `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json` |
|
|
26
|
+
| `state-corrupt` | Tower state 文件不可读或无效。 | 手工编辑过的 `state.json`;非法 JSON;没有可用的 `schemaVersion` |
|
|
27
|
+
| `state-version-unsupported` | state 文件的 schema 版本高于本构建支持的版本,或无法迁移。 | 打开由更新插件写入的仓库;未知的旧版本;本构建拒绝降级 |
|
|
28
|
+
| `state-migration-failed` | state 迁移抛出异常;原文件未被修改。 | 迁移步骤中的 bug;查看迁移前的 `state.json.v<N>.bak` |
|
|
27
29
|
| `config-invalid` | Tower 配置无效。 | 格式错误的仓库 `config.json`、违反 schema 的值 |
|
|
28
30
|
| `config-unknown-key` | Tower 配置存在未知顶层键。 | 例如把键拼成 `allowAgentToggle` |
|
|
29
31
|
| `git-failed` | git 命令因未归入其他分类的原因失败。 | 被拒绝的合并命令、非法 ref |
|
|
@@ -32,16 +32,16 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
32
32
|
| 工具 | 参数 | 用途 / 说明 |
|
|
33
33
|
|---|---|---|
|
|
34
34
|
| `tower_init` | `base?`、`autoGitInit?`、`snapshotDirty?`、`reanchor?` | 创建 `<git-common-dir>/dsh-tower/state.json`。`autoGitInit` 执行 `git init` 并创建空的初始提交;`snapshotDirty` 记录一个游离快照提交作为 mission 基线;`reanchor` 仅在没有非终态 mission 时修改基线。返回 `dirty`、`snapshotCommit`、`reanchored`。 |
|
|
35
|
-
| `tower_plan` | `missions[]`(`title
|
|
35
|
+
| `tower_plan` | `missions[]`(`title`——可打印 Unicode,NFC 规范化,≤120 字符、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。mission id 保持 ASCII(`M1`、`M2`);不可见字符与超长标题会以 `invalid-argument` 拒绝。`reviewPolicy:"none"` 需要非空理由;审查门禁开启时,preflight 还要求 `gates.allowReviewExemption`。 |
|
|
36
36
|
| `tower_mission` | `id`(别名 `missionId`)、`status?`、`task_done?`、`task_drop?`+`task_drop_reason?`、`blocker?`、`clear_blockers?`、`note?`、`scope?`、`pause_cause?`、`authorization?` | 更新单个 mission。worker 可安全使用的字段是 task/blocker/note;不能在这里设置 `merged`。暂停的 mission 记录 `pause_cause`(user/failure/resource/dependency);恢复用户暂停的 mission 会遵循 `mode.requireResumeAuthorization`。 |
|
|
37
|
-
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` |
|
|
37
|
+
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。分页 mission 只带 `reportBytes`;`missionId` 返回完整详情(任务/备注/审查/finding/完成报告正文)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。 |
|
|
38
38
|
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer`。`takeover:true` 替换已不再运行的 worker 条目(ownerGeneration 递增)。每个 mission 只能有一个活跃 worker;`spawn.maxLiveWorkers` 限制整个仓库。 |
|
|
39
39
|
| `tower_roster` | — | roster 及来自子代理目录的实时活动(`running`/`inactive`/`missing`/`unknown`)与 `stale` 标记。只读。 |
|
|
40
40
|
| `tower_adopt` | `retireMissing?` | 把 roster 条目与持久子代理目录对账。可证明已消失的 agent 会被标记为 retired(默认),或标记为 `missing`(`retireMissing:false`)以便检查/接管。 |
|
|
41
41
|
| `tower_diff` | `missionId`(别名 `id`)、`maxBytes?` | 只读 merge-base diff:文件、提交、有上限的 patch。供 reviewer 安全使用。 |
|
|
42
42
|
| `tower_send` | `to`、`subject`、`body`、`scope?`、`interrupt?`、`consentRef?` | 向 roster 名称、`tower` 或 `all` 发送持久消息;对运行中的 agent 会即时投递。`interrupt:true` 先中断运行中的回合(Kimi 风格)再投递;`consentRef` 记录用于审计。受 `send.maxPerMinute` 限速。 |
|
|
43
43
|
| `tower_inbox` | `limit?`、`include_read?` | 读取并确认发给调用者的消息;未读消息会阻止 `tower_complete`。 |
|
|
44
|
-
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
44
|
+
| `tower_complete` | `missionId`(别名 `id`)、`report`、`checks?` | worker 报告完成(mission 变为 `completed`,可进入 preflight 与可选审查)。要求当前 owner、无未完成任务/blocker、≥1 个提交、无未读消息。`report` 受 `gates.maxReportBytes`(默认 32768 UTF-8 字节)硬上限约束:超限会以 `invalid-argument` 拒绝并给出 `nextAction`(把全文写进 mission worktree 的文件),绝不截断;存储时会记录 `reportBytes`。`checks` 记录 `{name, command?, exitCode?, evidence?}` 证据;`gates.requireChecks` 可以强制要求。配置了测试发现时还会自动运行并添加 `tests` 检查;失败会抛出 `[tower:tests-failed]`。 |
|
|
45
45
|
| `tower_interrupt` | `to?`(roster 名称)、`agentId?`、`reason?` | 通过 `subagents.interrupt` 中断一个运行中的 roster agent,并记录 `state:"interrupted"`。幂等(未运行 → `interrupted:false`)。 |
|
|
46
46
|
| `tower_finding` | `type`、`title`、`summary`、`details`、`suggested_fix`、`severity?`、`missionId?`、`location?` | 记录 scope 之外的问题;reviewer 用它代替直接改文件。新 finding 初始为 `open`。 |
|
|
47
47
|
| `tower_findings` | `id?`、`status?`、`note?`、过滤(`type?`、`severity?`、`missionId?`、`open?`、`limit?`) | 生命周期工具:列出/读取/流转。状态为 `open → triaged → fixed`/`wontfix`,允许重新打开。只有控制塔或 worker 能修改;reviewer 只能列出/读取。 |
|
package/domain/config/schema.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// `allowAgentControl: true` via the legacy top-level key.
|
|
16
16
|
import Schema from '@deepseek-ai/schemastery'
|
|
17
17
|
import { TowerToolError } from '../errors.js'
|
|
18
|
+
import { DEFAULT_MAX_REPORT_BYTES } from '../missions/completion.js'
|
|
18
19
|
|
|
19
20
|
export const DEFAULT_DENY_TOOLS = [
|
|
20
21
|
'create_goal',
|
|
@@ -32,7 +33,7 @@ export const DEFAULT_DENY_TOOLS = [
|
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
const LEGACY_KEYS = ['section', 'allowAgentControl']
|
|
35
|
-
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'leases', 'dev', 'tools']
|
|
36
|
+
const GROUP_KEYS = ['init', 'mode', 'review', 'merge', 'git', 'worktrees', 'spawn', 'send', 'tests', 'gates', 'docs', 'archive', 'leases', 'dev', 'tools']
|
|
36
37
|
|
|
37
38
|
const SECTION_KEYS = {
|
|
38
39
|
init: ['autoGitInit', 'snapshotDirty', 'autoExclude'],
|
|
@@ -71,8 +72,9 @@ const SECTION_KEYS = {
|
|
|
71
72
|
],
|
|
72
73
|
send: ['maxPerMinute', 'interruptDefault'],
|
|
73
74
|
tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
|
|
74
|
-
gates: ['requireChecks', 'requireTests', 'allowReviewExemption'],
|
|
75
|
+
gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
|
|
75
76
|
docs: ['board', 'boardLocation'],
|
|
77
|
+
archive: ['enabled', 'afterDays', 'maxMissions'],
|
|
76
78
|
tools: ['exposeOperational', 'exposeBatch', 'exposeImport', 'exposeLeases', 'exposeTests', 'exposeRecovery', 'exposeDoctor'],
|
|
77
79
|
leases: ['ttlMs'],
|
|
78
80
|
dev: ['hotReload', 'reloadDelayMs', 'reloadRequiredIds'],
|
|
@@ -226,6 +228,11 @@ export const Config = Schema.object({
|
|
|
226
228
|
allowReviewExemption: Schema.boolean()
|
|
227
229
|
.default(false)
|
|
228
230
|
.description('Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers.'),
|
|
231
|
+
maxReportBytes: Schema.natural()
|
|
232
|
+
.min(1024)
|
|
233
|
+
.max(16 * 1024 * 1024)
|
|
234
|
+
.default(DEFAULT_MAX_REPORT_BYTES)
|
|
235
|
+
.description('Hard cap, in UTF-8 bytes, for tower_complete.report; a larger report is rejected with an actionable nextAction, never truncated.'),
|
|
229
236
|
})
|
|
230
237
|
.default({})
|
|
231
238
|
.description('Merge-gate strictness.'),
|
|
@@ -239,6 +246,21 @@ export const Config = Schema.object({
|
|
|
239
246
|
})
|
|
240
247
|
.default({})
|
|
241
248
|
.description('Human-readable artifacts.'),
|
|
249
|
+
archive: Schema.object({
|
|
250
|
+
enabled: Schema.boolean()
|
|
251
|
+
.default(true)
|
|
252
|
+
.description('Move terminal (merged/abandoned) missions whose worktree is gone into <git-common-dir>/dsh-tower/archive/ at the next mutation, so state.json stops carrying their full history.'),
|
|
253
|
+
afterDays: Schema.natural()
|
|
254
|
+
.min(0)
|
|
255
|
+
.default(30)
|
|
256
|
+
.description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately).'),
|
|
257
|
+
maxMissions: Schema.natural()
|
|
258
|
+
.min(0)
|
|
259
|
+
.default(100)
|
|
260
|
+
.description('Maximum terminal missions kept hot; once exceeded, the oldest eligible terminal missions are archived until the cap holds (0 archives every eligible terminal mission).'),
|
|
261
|
+
})
|
|
262
|
+
.default({})
|
|
263
|
+
.description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
|
|
242
264
|
tools: Schema.object({
|
|
243
265
|
exposeOperational: Schema.boolean()
|
|
244
266
|
.default(true)
|
|
@@ -316,7 +338,7 @@ export function assertKnownKeys(raw, label) {
|
|
|
316
338
|
if (unknown.length > 0) {
|
|
317
339
|
throw new TowerToolError(
|
|
318
340
|
'config-unknown-key',
|
|
319
|
-
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, leases?, dev? }`,
|
|
341
|
+
`${label} has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl?, init?, mode?, review?, merge?, git?, worktrees?, spawn?, send?, tests?, gates?, docs?, archive?, leases?, dev?, tools? }`,
|
|
320
342
|
)
|
|
321
343
|
}
|
|
322
344
|
for (const [group, allowed] of Object.entries(SECTION_KEYS)) {
|
|
@@ -397,6 +419,7 @@ export function resolveTowerConfig(rawConfig) {
|
|
|
397
419
|
tests: clone(parsed.tests ?? {}),
|
|
398
420
|
gates: clone(parsed.gates ?? {}),
|
|
399
421
|
docs: clone(parsed.docs ?? {}),
|
|
422
|
+
archive: clone(parsed.archive ?? {}),
|
|
400
423
|
tools: clone(parsed.tools ?? {}),
|
|
401
424
|
leases: clone(parsed.leases ?? {}),
|
|
402
425
|
dev: clone(parsed.dev ?? {}),
|
|
@@ -54,6 +54,7 @@ const SCHEMAS = {
|
|
|
54
54
|
base: S.str,
|
|
55
55
|
revision: S.int,
|
|
56
56
|
missions: S.int,
|
|
57
|
+
caseInsensitive: S.any,
|
|
57
58
|
},
|
|
58
59
|
['created', 'repoRoot', 'commonDir', 'statePath', 'base', 'revision', 'missions'],
|
|
59
60
|
),
|
|
@@ -238,7 +239,32 @@ const SCHEMAS = {
|
|
|
238
239
|
},
|
|
239
240
|
['ok', 'scheduled'],
|
|
240
241
|
),
|
|
241
|
-
doctor: S.obj(
|
|
242
|
+
doctor: S.obj(
|
|
243
|
+
{
|
|
244
|
+
ok: S.bool,
|
|
245
|
+
checks: S.arr(S.obj()),
|
|
246
|
+
stateSchema: S.obj({
|
|
247
|
+
currentVersion: S.any,
|
|
248
|
+
supportedVersion: S.int,
|
|
249
|
+
migrations: S.arr(S.obj({ from: S.int, to: S.int })),
|
|
250
|
+
status: S.str,
|
|
251
|
+
}),
|
|
252
|
+
reports: S.any,
|
|
253
|
+
archive: S.obj({
|
|
254
|
+
missions: S.any,
|
|
255
|
+
archivedMissions: S.int,
|
|
256
|
+
stateBytes: S.any,
|
|
257
|
+
archiveBytes: S.int,
|
|
258
|
+
dir: S.str,
|
|
259
|
+
years: S.arr(S.int),
|
|
260
|
+
files: S.int,
|
|
261
|
+
unreadable: S.arr(S.str),
|
|
262
|
+
policy: S.obj(),
|
|
263
|
+
}),
|
|
264
|
+
environmentContract: S.obj(),
|
|
265
|
+
},
|
|
266
|
+
['ok', 'checks'],
|
|
267
|
+
),
|
|
242
268
|
recover: S.obj(
|
|
243
269
|
{ ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
|
|
244
270
|
['ok', 'action'],
|
|
@@ -14,7 +14,7 @@ export const PlanInput = Type.Object({
|
|
|
14
14
|
repoRoot: Type.String({ description: 'Absolute path inside the target Git repository.' }),
|
|
15
15
|
missions: Type.Array(
|
|
16
16
|
Type.Object({
|
|
17
|
-
title: Type.String({ description: 'Printable
|
|
17
|
+
title: Type.String({ description: 'Printable Unicode mission title, NFC-normalized, at most 120 characters; used as the mission label.' }),
|
|
18
18
|
scope: Type.Optional(Type.Array(Type.String(), { description: 'Relative file globs this mission may touch; empty only for survey.' })),
|
|
19
19
|
tasks: Type.Optional(Type.Array(Type.String(), { description: 'Verifiable task items the worker ticks off.' })),
|
|
20
20
|
deps: Type.Optional(Type.Array(Type.String(), { description: 'Mission ids that must merge before this one.' })),
|
|
@@ -54,6 +54,7 @@ export const StatusInput = Type.Object({
|
|
|
54
54
|
offset: Type.Optional(Type.Integer({ minimum: 0, description: 'Mission list offset (default 0).' })),
|
|
55
55
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: 'Mission list page size (default 20).' })),
|
|
56
56
|
includeGate: Type.Optional(Type.Boolean({ description: 'Compute tower_preflight-style gate digests for missions on this page.' })),
|
|
57
|
+
includeArchived: Type.Optional(Type.Boolean({ description: 'Append archived terminal missions (compact summaries, marked archived:true) after the active page. The default dashboard and board list active missions only.' })),
|
|
57
58
|
format: Type.Optional(enumOf(STATUS_FORMATS, { description: 'markdown returns the MISSIONS.md board text.' })),
|
|
58
59
|
}, { additionalProperties: false })
|
|
59
60
|
|
package/domain/errors.js
CHANGED
|
@@ -28,6 +28,8 @@ export const ERROR_CODES = {
|
|
|
28
28
|
'stale-base': 'The repository state changed while the operation was being prepared; retry.',
|
|
29
29
|
'state-locked': 'Another Tower writer holds the state lock.',
|
|
30
30
|
'state-corrupt': 'The Tower state file is unreadable or invalid.',
|
|
31
|
+
'state-version-unsupported': 'The Tower state file uses a schema version this build cannot read or downgrade.',
|
|
32
|
+
'state-migration-failed': 'A Tower state migration failed; the original state file was not modified.',
|
|
31
33
|
'config-invalid': 'Tower configuration is invalid.',
|
|
32
34
|
'config-unknown-key': 'Tower configuration has an unknown top-level key.',
|
|
33
35
|
'git-failed': 'A git command failed.',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// facts the app already read (tips, changed files, checkout branch). The app
|
|
3
3
|
// performs every read and owns the result assembly.
|
|
4
4
|
// @ts-check
|
|
5
|
-
import { fileInScope } from '../scope.js'
|
|
5
|
+
import { caseInsensitiveScopeMatch, fileInScope } from '../scope.js'
|
|
6
6
|
|
|
7
7
|
/** @typedef {import('../../types/index.js').Blocker} Blocker */
|
|
8
8
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
@@ -76,16 +76,29 @@ export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
|
|
|
76
76
|
* @param {object} input
|
|
77
77
|
* @param {Mission} input.mission
|
|
78
78
|
* @param {string[]} input.changedFiles
|
|
79
|
+
* @param {boolean} [input.caseInsensitive] the checkout's detected comparison mode
|
|
79
80
|
* @returns {Blocker | null}
|
|
80
81
|
*/
|
|
81
|
-
export function outOfScopeBlocker({ mission, changedFiles }) {
|
|
82
|
-
const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
export function outOfScopeBlocker({ mission, changedFiles, caseInsensitive = false }) {
|
|
83
|
+
const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file, { caseInsensitive }))
|
|
84
|
+
if (outOfScope.length === 0) return null
|
|
85
|
+
const parts = [`changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
|
|
86
|
+
// On a case-sensitive checkout a case-only mismatch is a human error worth
|
|
87
|
+
// naming, but it stays a rejection: folding here would make src/Api and
|
|
88
|
+
// src/api the same directory on a filesystem where they are not (#90).
|
|
89
|
+
if (!caseInsensitive) {
|
|
90
|
+
const hints = outOfScope
|
|
91
|
+
.map((file) => {
|
|
92
|
+
const pattern = caseInsensitiveScopeMatch(mission.scope, file)
|
|
93
|
+
return pattern
|
|
94
|
+
? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
|
|
95
|
+
: null
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.slice(0, 3)
|
|
99
|
+
if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
|
|
100
|
+
}
|
|
101
|
+
return { code: 'out-of-scope', detail: parts.join('. ') }
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
/**
|
|
@@ -114,13 +127,14 @@ export function baseDirtyBlocker(wouldOverwrite) {
|
|
|
114
127
|
* @param {object} input
|
|
115
128
|
* @param {string[]} input.deps
|
|
116
129
|
* @param {TowerState} input.state
|
|
130
|
+
* @param {Map<string, { status: string }>} [input.archived] compact archived missions (#73)
|
|
117
131
|
* @returns {Blocker[]}
|
|
118
132
|
*/
|
|
119
|
-
export function dependencyBlockers({ deps = [], state }) {
|
|
133
|
+
export function dependencyBlockers({ deps = [], state, archived = new Map() }) {
|
|
120
134
|
/** @type {Blocker[]} */
|
|
121
135
|
const blockers = []
|
|
122
136
|
for (const depId of deps) {
|
|
123
|
-
const dep = state.missions.find((entry) => entry.id === depId)
|
|
137
|
+
const dep = state.missions.find((entry) => entry.id === depId) ?? archived.get(depId)
|
|
124
138
|
if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
|
|
125
139
|
blockers.push({ code: 'deps-unmerged', detail: `${depId} is ${dep ? dep.status : 'missing'}` })
|
|
126
140
|
}
|
|
@@ -116,6 +116,7 @@ export function existingInitResult({ state, statePath, dirty }) {
|
|
|
116
116
|
reanchored: false,
|
|
117
117
|
dirty,
|
|
118
118
|
snapshotCommit: state.snapshotCommit ?? null,
|
|
119
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -128,10 +129,21 @@ export function existingInitResult({ state, statePath, dirty }) {
|
|
|
128
129
|
* @param {string | null} [input.towerAgentId]
|
|
129
130
|
* @param {string | null} [input.snapshotCommit]
|
|
130
131
|
* @param {string | null} [input.snapshotRef]
|
|
132
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
131
133
|
* @param {number} input.now
|
|
132
134
|
* @returns {TowerState}
|
|
133
135
|
*/
|
|
134
|
-
export function initialState({
|
|
136
|
+
export function initialState({
|
|
137
|
+
repoRoot,
|
|
138
|
+
commonDir,
|
|
139
|
+
base,
|
|
140
|
+
dirty,
|
|
141
|
+
towerAgentId = null,
|
|
142
|
+
snapshotCommit = null,
|
|
143
|
+
snapshotRef = null,
|
|
144
|
+
caseInsensitive = null,
|
|
145
|
+
now,
|
|
146
|
+
}) {
|
|
135
147
|
return {
|
|
136
148
|
schemaVersion: SCHEMA_VERSION,
|
|
137
149
|
repoRoot,
|
|
@@ -141,6 +153,7 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
|
|
|
141
153
|
createdAt: now,
|
|
142
154
|
updatedAt: now,
|
|
143
155
|
dirtyBase: dirty,
|
|
156
|
+
caseInsensitive,
|
|
144
157
|
...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
|
|
145
158
|
...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
|
|
146
159
|
missions: [],
|
|
@@ -156,9 +169,19 @@ export function initialState({ repoRoot, commonDir, base, dirty, towerAgentId =
|
|
|
156
169
|
* @param {boolean} input.dirty
|
|
157
170
|
* @param {string | null} input.snapshotCommit
|
|
158
171
|
* @param {string | null} input.snapshotRef
|
|
172
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
159
173
|
* @returns {object}
|
|
160
174
|
*/
|
|
161
|
-
export function createdInitResult({
|
|
175
|
+
export function createdInitResult({
|
|
176
|
+
repoRoot,
|
|
177
|
+
commonDir,
|
|
178
|
+
statePath,
|
|
179
|
+
base,
|
|
180
|
+
dirty,
|
|
181
|
+
snapshotCommit,
|
|
182
|
+
snapshotRef,
|
|
183
|
+
caseInsensitive = null,
|
|
184
|
+
}) {
|
|
162
185
|
return {
|
|
163
186
|
created: true,
|
|
164
187
|
repoRoot,
|
|
@@ -171,6 +194,7 @@ export function createdInitResult({ repoRoot, commonDir, statePath, base, dirty,
|
|
|
171
194
|
dirty,
|
|
172
195
|
snapshotCommit,
|
|
173
196
|
snapshotRef,
|
|
197
|
+
caseInsensitive,
|
|
174
198
|
}
|
|
175
199
|
}
|
|
176
200
|
|
|
@@ -194,6 +218,7 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
|
|
|
194
218
|
reanchored,
|
|
195
219
|
dirty,
|
|
196
220
|
snapshotCommit: state.snapshotCommit ?? null,
|
|
221
|
+
caseInsensitive: state.caseInsensitive ?? null,
|
|
197
222
|
}
|
|
198
223
|
}
|
|
199
224
|
|
|
@@ -202,10 +227,21 @@ export function existingInitWriteResult({ state, statePath, dirty, reanchored })
|
|
|
202
227
|
* @param {string} input.base
|
|
203
228
|
* @param {string | null} [input.snapshotCommit]
|
|
204
229
|
* @param {boolean} input.dirty
|
|
230
|
+
* @param {boolean | null} [input.caseInsensitive]
|
|
205
231
|
* @returns {string}
|
|
206
232
|
*/
|
|
207
|
-
export function initActivityText({ base, snapshotCommit = null, dirty }) {
|
|
208
|
-
|
|
233
|
+
export function initActivityText({ base, snapshotCommit = null, dirty, caseInsensitive = null }) {
|
|
234
|
+
const mode = caseInsensitive === null ? '' : ` (${caseInsensitive ? 'case-insensitive' : 'case-sensitive'} filesystem)`
|
|
235
|
+
return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}${mode}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Activity line for a probe recorded on an existing state.
|
|
240
|
+
* @param {boolean} caseInsensitive
|
|
241
|
+
* @returns {string}
|
|
242
|
+
*/
|
|
243
|
+
export function caseProbeActivityText(caseInsensitive) {
|
|
244
|
+
return `case sensitivity detected: ${caseInsensitive ? 'case-insensitive' : 'case-sensitive'}`
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
/**
|
package/domain/mission.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/** @type {readonly MissionStatus[]} */
|
|
11
11
|
export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
|
|
12
|
-
export const SCHEMA_VERSION =
|
|
12
|
+
export const SCHEMA_VERSION = 3
|
|
13
13
|
/** @type {readonly MergeVerdict[]} */
|
|
14
14
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
15
|
export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
|
|
@@ -8,6 +8,57 @@ import { TowerToolError } from '../errors.js'
|
|
|
8
8
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
9
9
|
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Hard cap for `tower_complete.report`, in UTF-8 bytes (32 KiB). The value is
|
|
13
|
+
* the default of `gates.maxReportBytes`; a repository may lower or raise it.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_MAX_REPORT_BYTES = 32 * 1024
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* UTF-8 byte length, so multi-byte text is not silently favoured by a
|
|
19
|
+
* character-count bound.
|
|
20
|
+
* @param {string} value
|
|
21
|
+
* @returns {number}
|
|
22
|
+
*/
|
|
23
|
+
export function utf8ByteLength(value) {
|
|
24
|
+
return Buffer.byteLength(String(value), 'utf8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reject a report over the cap instead of truncating it: a silent cut loses
|
|
29
|
+
* evidence and cannot be recovered by the tower. The writer is told to keep the
|
|
30
|
+
* full text beside the branch and reference it.
|
|
31
|
+
* @param {string} report
|
|
32
|
+
* @param {number} [maxBytes]
|
|
33
|
+
* @returns {number} accepted UTF-8 byte length
|
|
34
|
+
*/
|
|
35
|
+
export function assertReportSize(report, maxBytes = DEFAULT_MAX_REPORT_BYTES) {
|
|
36
|
+
const bytes = utf8ByteLength(report)
|
|
37
|
+
if (bytes > maxBytes) {
|
|
38
|
+
throw new TowerToolError(
|
|
39
|
+
'invalid-argument',
|
|
40
|
+
`report is ${bytes} bytes (UTF-8), over the ${maxBytes}-byte cap; reports are rejected, not truncated — write the full text to a file in the mission worktree and reference the path in the report`,
|
|
41
|
+
{
|
|
42
|
+
nextAction: 'write the full text to a file in the mission worktree and reference the path in the report',
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return bytes
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Byte size of a stored completion report. Older documents have no
|
|
51
|
+
* `reportBytes`, so the body is measured on read; the field is never required.
|
|
52
|
+
* @param {any} completionReport
|
|
53
|
+
* @returns {number | null}
|
|
54
|
+
*/
|
|
55
|
+
export function completionReportBytes(completionReport) {
|
|
56
|
+
if (!isPlainObject(completionReport)) return null
|
|
57
|
+
const stored = completionReport.reportBytes
|
|
58
|
+
if (Number.isInteger(stored) && stored >= 0) return stored
|
|
59
|
+
return typeof completionReport.report === 'string' ? utf8ByteLength(completionReport.report) : null
|
|
60
|
+
}
|
|
61
|
+
|
|
11
62
|
/**
|
|
12
63
|
* @param {string} message
|
|
13
64
|
* @param {string} [code]
|
|
@@ -53,10 +104,10 @@ export function normalizeChecks(checks) {
|
|
|
53
104
|
/**
|
|
54
105
|
* Resolve and validate the reporting worker and the mission's completion
|
|
55
106
|
* preconditions that need no repository access.
|
|
56
|
-
* @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any }} params
|
|
107
|
+
* @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any, maxReportBytes?: number }} params
|
|
57
108
|
* @returns {RosterEntry}
|
|
58
109
|
*/
|
|
59
|
-
export function assertCompletionRequest({ mission, roster, input }) {
|
|
110
|
+
export function assertCompletionRequest({ mission, roster, input, maxReportBytes }) {
|
|
60
111
|
const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
|
|
61
112
|
if (!worker || worker.missionId !== mission.id) {
|
|
62
113
|
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
@@ -75,6 +126,7 @@ export function assertCompletionRequest({ mission, roster, input }) {
|
|
|
75
126
|
if (typeof input.report !== 'string' || input.report.trim().length === 0) {
|
|
76
127
|
fail('tower_complete requires a non-empty report')
|
|
77
128
|
}
|
|
129
|
+
assertReportSize(input.report.trim(), maxReportBytes)
|
|
78
130
|
const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
79
131
|
if (openTasks.length > 0) {
|
|
80
132
|
fail(
|
|
@@ -106,9 +158,10 @@ export function assertCompletionInbox({ mission, worker, unread }) {
|
|
|
106
158
|
|
|
107
159
|
/**
|
|
108
160
|
* Project the completion report stored on the mission.
|
|
109
|
-
* @param {{ report: string, worker: RosterEntry, checks: any[], now: number }} params
|
|
161
|
+
* @param {{ report: string, worker: RosterEntry, checks: any[], now: number, maxReportBytes?: number }} params
|
|
110
162
|
* @returns {any}
|
|
111
163
|
*/
|
|
112
|
-
export function completionReport({ report, worker, checks, now }) {
|
|
113
|
-
|
|
164
|
+
export function completionReport({ report, worker, checks, now, maxReportBytes }) {
|
|
165
|
+
const text = report.trim()
|
|
166
|
+
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
114
167
|
}
|