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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@
|
|
|
4
4
|
|
|
5
5
|
## 未发布(Unreleased)
|
|
6
6
|
|
|
7
|
+
## 2.1.0(2026-10-23)
|
|
8
|
+
|
|
9
|
+
### 新增
|
|
10
|
+
|
|
11
|
+
- **#72 state 迁移**:`state.json` 的 `schemaVersion` 升到 3(v1→v2→v3 有序迁移链),并新增有序、纯函数式的迁移注册表
|
|
12
|
+
(`domain/state/migrations.js`)。旧 state 在持有 state 锁的加载中自动迁移:先写
|
|
13
|
+
`state.json.v<N>.bak` 备份,再原子替换、`revision` 只 +1、并向 activity 追加
|
|
14
|
+
`state.migrated {from,to}`;只读工具只在内存中迁移,不改写文件。由更新插件写入的 state 现在
|
|
15
|
+
以 `[tower:state-version-unsupported]` 拒绝(不再误报 `state-corrupt`),且绝不会被降级重写;
|
|
16
|
+
迁移抛错以 `[tower:state-migration-failed]` 报出,原文件保持字节不变。`tower_doctor` 新增
|
|
17
|
+
`stateSchema {currentVersion,supportedVersion,migrations,status}` 与 `state-schema` 检查。
|
|
18
|
+
- **#73 终态任务归档**:`merged`/`abandoned` 且 worktree 已移除的 mission 在达到可配置策略
|
|
19
|
+
(`archive.enabled`/`archive.afterDays`/`archive.maxMissions`)后,于下一次变更边界移入
|
|
20
|
+
`<git-common-dir>/dsh-tower/archive/index.json` 与 `archive/<year>.json`(各自带 `archiveVersion`
|
|
21
|
+
头)。归档保留完整 mission、tasks、reviews、findings 与完成报告,state.json 只保留活跃任务,
|
|
22
|
+
因此每次 mutation 不再重写全部历史;归档按 id 幂等,进程崩溃后可重试。`tower_status` 默认只列
|
|
23
|
+
活跃任务,`includeArchived:true` 在活跃页后追加 `archived:true` 的压缩摘要(不含报告正文),
|
|
24
|
+
`missionId` 详情会从 year 文件加载;归档损坏时降级为 `archive.unreadable` 诊断而不是抛错。
|
|
25
|
+
`tower_doctor` 新增 `archive {missions,archivedMissions,stateBytes,archiveBytes,years,files,
|
|
26
|
+
unreadable,policy}` 与 `archive` 检查。`tower_preflight`/`tower_merge` 对已归档 mission 保持原行为,
|
|
27
|
+
新 mission 的编号也会避开归档 id。
|
|
28
|
+
|
|
29
|
+
- **#88 Unicode mission 标题**:`validateTitle` 改为接受可打印 Unicode(NFC 归一化、120 码点上限),
|
|
30
|
+
显式拒绝 bidi override(U+202A–202E、U+2066–2069)与零宽字符(U+200B–200D、U+FEFF),控制/格式/
|
|
31
|
+
代理码点单独报错;mission id 仍为 ASCII。CJK/西里尔/日韩/emoji 标题可完整往返 `tower_plan` →
|
|
32
|
+
state → `tower_status` → `MISSIONS.md` → briefing,board 表格保持对齐。
|
|
33
|
+
### 变更
|
|
34
|
+
|
|
35
|
+
- **#89 completion report 上限**:`tower_complete.report` 新增 `gates.maxReportBytes`
|
|
36
|
+
(默认 32768,按 **UTF-8 字节**而非字符计)硬上限;超限以 `invalid-argument` 拒绝并给出
|
|
37
|
+
`nextAction`(把全文写进 mission worktree 的文件并引用路径),绝不截断。存储的完成报告新增
|
|
38
|
+
`reportBytes`;`tower_status` 的分页摘要只返回大小,完整正文只在 `missionId` 详情中返回;
|
|
39
|
+
`tower_doctor` 新增 `reports` 检查并报告仓库中最大的报告。旧 state 无需迁移:缺失
|
|
40
|
+
`reportBytes` 时在读取时按 UTF-8 字节推导。
|
|
41
|
+
|
|
42
|
+
- **#90 scope 大小写**:`tower_init` 在检出目录上做一次性大小写探测并记录 `caseInsensitive`
|
|
43
|
+
(探测失败记录 `null`,比较继续使用安全的 case-sensitive 默认值);`tower_doctor` 报告
|
|
44
|
+
`caseInsensitive` 与生效的 `comparisonMode`。在大小写不敏感的文件系统上,`fileInScope` 与
|
|
45
|
+
`scopesOverlap` 折叠双方,因此 `src/Api/**` 与 `src/api/**` 视为重叠(plan 自动串行、扩 scope
|
|
46
|
+
被拒绝),仅大小写不同的改动不再被 out-of-scope 门禁拒绝;在大小写敏感的检出上仍拒绝,但 blocker
|
|
47
|
+
detail 会指出折叠大小写后本可匹配的 scope 条目。存储的 scope 文本保持原样。state schema 升到 3,
|
|
48
|
+
v2 → v3 迁移回填 `caseInsensitive: null`。
|
|
49
|
+
|
|
50
|
+
- **#86 平台矩阵 CI**:`test` job 改为显式 include 矩阵——ubuntu Node 22/24 与 Windows Node 22
|
|
51
|
+
(`test (22)`/`test (24)`/`test (22, windows)`),required context 名称不变;`win32Lower`/`isInside`、
|
|
52
|
+
`shellSpec`、`renameWithRetry`、`unlinkWithRetry`、worktree `compareKey` 均改为可注入 seam,并在任意
|
|
53
|
+
OS 上断言两条分支。Windows leg 已加入 `main` 的 required checks。
|
|
54
|
+
|
|
55
|
+
- **#91 Windows 测试确定性**:新增 `tests/helpers/cleanup.mjs`(有界重试 + 隔离 rename + `os.tmpdir()`
|
|
56
|
+
守卫),37 个测试文件、106 处直接 `rmSync` 全部迁移;导入时把 Windows CI 的 8.3 短路径 TEMP
|
|
57
|
+
规范化,消除短/长路径比较失败;Windows 全量套件连续 5 次全绿。
|
|
7
58
|
## 2.1.0-beta.1(2026-10-22)
|
|
8
59
|
|
|
9
60
|
**预发布**:本版本发布到 npm 的 `next` dist-tag,`latest` 继续保持 `2.0.4`,直到出现稳定版本。
|
package/COMPATIBILITY.md
CHANGED
|
@@ -5,6 +5,7 @@ is shipped in the npm package; doctor reads it locally and never contacts a regi
|
|
|
5
5
|
|
|
6
6
|
| dsh version | tag / commit | verified on | what was run |
|
|
7
7
|
|---|---|---|---|
|
|
8
|
+
| 0.2.1-rc.2 | v2.1.0 | 2026-10-23 | mount smoke (29 tower tools, `/tower`, 3 skills, 2 presets, single bundle row), `npm test` (479), `npm run docs:check`, generated docs checks |
|
|
8
9
|
| 0.2.1-rc.2 | v2.1.0-beta.1 | 2026-10-22 | mount smoke (29 tower tools, `/tower`, 3 skills, 2 presets, single bundle row), `npm test` (413), `npm run docs:check`, generated docs checks |
|
|
9
10
|
| 0.2.1-rc.2 | 6ee19de | 2026-10-21 | `npm test` (229), `npm run docs:check`, offline `ui-bundle` tests |
|
|
10
11
|
| 0.2.1-rc.2 | 5b0accd | 2026-10-18 | `npm test` (219), `npm run docs:check` |
|
package/CONTRIBUTING.md
CHANGED
|
@@ -20,6 +20,13 @@ and every PR stays unmergeable. The setting and its verification command are in
|
|
|
20
20
|
[SECURITY.md](SECURITY.md#repository-protection-on-main). The `mount-fixtures` job runs the seeded
|
|
21
21
|
smoke failures for evidence but is deliberately **not** a required context.
|
|
22
22
|
|
|
23
|
+
The offline `test` matrix also has a `test (22, windows)` leg on `windows-latest` (Node 22). It runs the
|
|
24
|
+
offline suite and the offline gates, not the release/coverage steps, so the platform branches are
|
|
25
|
+
measured on a real Windows filesystem (see `tests/platform.test.mjs`). Its name is pinned through
|
|
26
|
+
`matrix.name` so the two required contexts above stay byte-identical. The Windows leg is **not** a
|
|
27
|
+
required context yet: #86 only asks that it exists and is green, and adding it to the protection setting
|
|
28
|
+
is a separate human step.
|
|
29
|
+
|
|
23
30
|
There are no mandatory approvals, no CODEOWNERS gate, and **merging your own PR is allowed**: there is
|
|
24
31
|
one maintainer, and a mandatory self-approval would be theatre. What replaces the second reviewer is a
|
|
25
32
|
written contract.
|
|
@@ -74,7 +81,7 @@ Requirements: Node.js 22+, Git. No package installation is needed for the offlin
|
|
|
74
81
|
|
|
75
82
|
## Pull request checklist
|
|
76
83
|
|
|
77
|
-
1. `npm test` passes locally (
|
|
84
|
+
1. `npm test` passes locally (479 tests today, including protocol end-to-end against a real temporary Git repository and mock spawn coverage) and `npm run docs:check` passes.
|
|
78
85
|
2. New behavior has a test. Bug fixes should add a test that fails before the fix.
|
|
79
86
|
3. No secrets, tokens, personal paths, session logs, or generated artifacts are added. `.gitignore` covers local acceptance output; do not force-add it.
|
|
80
87
|
4. Keep the public API and state-file formats compatible; if a change is breaking, call it out in `CHANGELOG.md` and bump the version.
|
package/README.en.md
CHANGED
|
@@ -18,6 +18,7 @@ The repository root **is** the DSH bundle: `package.json` declares `dsh.bundle.p
|
|
|
18
18
|
- **Missions with real isolation.** Every mission gets its own branch and Git worktree; workers never touch your checkout.
|
|
19
19
|
- **Review-gated merges.** When `review.requireReview:true`, a reviewer bound to the current branch tip must submit a `clean` / `merge` verdict before merge; the merge gate re-checks scope, staleness, task completion, review history, and worktree ownership before a `--no-ff` merge. Independent review is optional by default (the gate is off); use `reviewPolicy:"none"` + reason plus `gates.allowReviewExemption:true` for a single mission when the global gate is on.
|
|
20
20
|
- **Durable coordination.** Roster, inbox, findings, reviews, and mission state are persisted under `.git/dsh-tower/`, so a restart does not lose the plan.
|
|
21
|
+
- **Bounded state growth.** Terminal missions whose worktree is gone move to `.git/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, keeping their tasks, reviews, findings and completion report; mutations then rewrite only active work. `tower_status includeArchived` lists compact archived summaries, and `tower_doctor` reports archive size, count and policy.
|
|
21
22
|
- **Safe by default.** While Tower mode is active, `create_goal`, `update_goal(resume)`, `todo_write`, the Team task board, and `spawn_teammate` are refused; a paused goal is never resumed automatically.
|
|
22
23
|
- **Traceable finding lifecycle.** `tower_findings` lists, reads, and moves findings through `open → triaged → fixed` / `wontfix` (and can reopen); only the tower or a worker may change state, while reviewers can only read.
|
|
23
24
|
- **Repository-level config.** Any repository can override review/merge/worktree/spawn policy in `<git-common-dir>/dsh-tower/config.json`; tool arguments override the repository layer, so no profile edit is needed per repository.
|
|
@@ -207,7 +208,7 @@ Workers use the bundled `tower-worker` preset (writable worktree surface); revie
|
|
|
207
208
|
| `locale/`, `icon.svg` | localization and icon |
|
|
208
209
|
| `tests/` | pure-function, protocol end-to-end, and plugin-level tests; optional isolated Web-runtime scripts |
|
|
209
210
|
| `ui-bundle/` | optional Client widget (Tower tool cards, persistent Tower-mode chip, mode-aware denied rows, dock summary, transient overlay strip), not installed by default |
|
|
210
|
-
| `.github/workflows/test.yml` | CI running the offline suite on Node 22/24, the release and coverage checks, and the Node 22 mount smoke and seeded fixtures |
|
|
211
|
+
| `.github/workflows/test.yml` | CI running the offline suite on Node 22/24 (Ubuntu) and Node 22 (Windows), the release and coverage checks, and the Node 22 mount smoke and seeded fixtures |
|
|
211
212
|
|
|
212
213
|
## Development and testing
|
|
213
214
|
|
|
@@ -227,12 +228,17 @@ Every push and PR runs the same set of jobs; each job backs one checkable claim
|
|
|
227
228
|
|
|
228
229
|
| Job | Claim | Evidence |
|
|
229
230
|
|---|---|---|
|
|
230
|
-
| `test` (
|
|
231
|
+
| `test (22)`, `test (24)` (Ubuntu) | the offline suite, documentation consistency, generated references, the published file list, architecture, types, and domain purity all hold | `npm test`, `npm run docs:check`, the three `docs:check:generated*` checks, `pack:check`, `architecture:check`, `types:check`, `domain:check` |
|
|
232
|
+
| `test (22, windows)` (`windows-latest`, not required) | the same offline suite and offline gates hold on a real Windows filesystem, so path casing, the `pwsh` shell, and the rename/unlink retries are measured instead of assumed | `npm test` plus the same offline gates as Ubuntu (no release/coverage) |
|
|
231
233
|
| `test`'s `Coverage (Node 22)` step | coverage is actually measured in CI (reported only: no threshold, no badge) | `npm run test:coverage` |
|
|
232
234
|
| `test`'s `Release check` step (Node 22) | a tag is a claim: the version moved past the previous version tag, a pushed tag is `v<version>`, and the changelog carries release content | `npm run release:check` |
|
|
233
235
|
| `mount` (Node 22) | the packed tarball, added through the plugin CLI to a throwaway profile, mounts in the real loader and registers the surface the bundle claims | `npm run smoke:mount` |
|
|
234
236
|
| `mount-fixtures` (Node 22, not required) | the mount smoke still goes red for the right seeded reasons (a renamed tool id, a removed skills section), with one cached host install shared by all runs | `npm run smoke:fixtures -- --host-dir <cached host>` |
|
|
235
237
|
|
|
238
|
+
The `test` matrix pins each leg's context string through `matrix.name`: `test (22)`, `test (24)` and the new `test (22, windows)`. The first two are `main`'s required checks and keep their exact names; adding an `os` matrix key without an explicit name would rename them to `test (22, ubuntu-latest)`, and protected `main` would wait for a context that never reports. The Windows leg is Node 22 only and runs the offline suite and offline gates (no browser, no live suite, no release/coverage), in parallel with the existing jobs, so it does not extend the critical path. It is not a required context yet: making it green is the acceptance for #86, while adding it to the protection setting is a human step in #94.
|
|
239
|
+
|
|
240
|
+
`tests/platform.test.mjs` injects `platform`/`rename`/`unlink` so both the win32 and POSIX branches are asserted on any OS; the only case that needs a real Windows filesystem (renaming over an open destination) is counted as `# SKIP` on Linux (`tests 11`, `skipped 1`), never silently absent. There is no macOS runner: macOS follows the same POSIX branch as Linux, which is a #86 non-goal.
|
|
241
|
+
|
|
236
242
|
`mount` asserts the throwaway profile's **loaded surface**, never the text of `cordis.patch.yml`: 29 `tower_*` tools (27 `tools/*.js` modules plus `tower_mode` / `tower_control`), the 3 bundled skills, both the `tower-worker` and `tower-reviewer` presets, the `/tower` command, and exactly one top-level `tower-bundle` group row (the #66 invariant). It calls no model and opens no browser. Measured on the maintainer's Windows machine: about 100 seconds on a cold host (install included) and about 85 seconds with a cached host; the CI step allows 3 minutes (`timeout-minutes: 3`) inside the 5-minute `mount` job. A red run prints the resolved profile path, the host build, the loaded counts, and every unmet claim.
|
|
237
243
|
|
|
238
244
|
The host build is pinned in exactly one place, `scripts/mount-smoke/host.json`; a unit test keeps the newest `COMPATIBILITY.md` row equal to it, so a release never retypes it. `npm run smoke:fixtures` proves the smoke still fails for the right seeded reasons (a renamed tool id, a removed skills section). CI runs it as the non-required `mount-fixtures` job, where the control and both seeds share one cached host install through `--host-dir` (about 2 minutes warm here).
|
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Co
|
|
|
18
18
|
- **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
|
|
19
19
|
- **审查门禁合并。** 开启 `review.requireReview:true` 时,审查者针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。独立审查默认可选(门禁关闭);全局开启时,单个 mission 用 `reviewPolicy:"none"` + 理由并开启 `gates.allowReviewExemption:true` 做更窄豁免。
|
|
20
20
|
- **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
|
|
21
|
+
- **受控的状态增长。** worktree 已移除的终态任务在达到 `archive.afterDays`/`archive.maxMissions` 后移入 `.git/dsh-tower/archive/`,保留 tasks、reviews、findings 与完成报告;此后 mutation 只重写活跃工作。`tower_status includeArchived` 列出压缩后的归档摘要,`tower_doctor` 报告归档大小、数量与策略。
|
|
21
22
|
- **默认安全。** Tower 模式开启期间拒绝 `create_goal`、`update_goal(resume)`、`todo_write`、Team 任务板与 `spawn_teammate`;被暂停的 goal 不会被自动恢复。
|
|
22
23
|
- **可追溯的 finding 生命周期。** `tower_findings` 列表、读取并按 `open → triaged → fixed` / `wontfix` 流转,也可重新打开;只有 tower 或 worker 能改状态,reviewer 只能读。
|
|
23
24
|
- **仓库级配置。** 每个仓库可用 `<git-common-dir>/dsh-tower/config.json` 覆盖 review/merge/worktree/spawn 策略,工具参数再覆盖仓库配置;无需为不同仓库改 profile。
|
|
@@ -203,7 +204,7 @@ worker 与 reviewer 是使用内置 `tower-worker` preset 的普通 DSH 子代
|
|
|
203
204
|
| `locale/`、`icon.svg` | 本地化与图标 |
|
|
204
205
|
| `tests/` | 纯函数、协议 e2e、插件级测试;可选的真实 Web 运行时脚本 |
|
|
205
206
|
| `ui-bundle/` | 可选 Client 小挂件(Tower 工具卡片、Tower·on 状态 chip、模式感知的 disabled 行、dock 摘要、临时 overlay 状态条),默认不安装 |
|
|
206
|
-
| `.github/workflows/test.yml` | CI:Node 22/24
|
|
207
|
+
| `.github/workflows/test.yml` | CI:Ubuntu 上 Node 22/24 与 Windows 上 Node 22 的离线套件、release 与覆盖率检查、Node 22 挂载 smoke 与种子失败 fixtures |
|
|
207
208
|
|
|
208
209
|
## 开发与测试
|
|
209
210
|
|
|
@@ -221,12 +222,17 @@ npm test # node --test tests/*.test.mjs
|
|
|
221
222
|
|
|
222
223
|
| Job | 声明 | 证据 |
|
|
223
224
|
|---|---|---|
|
|
224
|
-
| `test
|
|
225
|
+
| `test (22)`、`test (24)`(Ubuntu) | 离线套件、文档一致性、生成文档、发布文件清单、架构、类型与 domain 纯度全部成立 | `npm test`、`npm run docs:check`、三个 `docs:check:generated*`、`pack:check`、`architecture:check`、`types:check`、`domain:check` |
|
|
226
|
+
| `test (22, windows)`(`windows-latest`,非必需检查) | 同一套离线套件与离线 gates 在真实 Windows 文件系统上成立;路径大小写、`pwsh` shell、rename/unlink 重试都不是只在 Linux 上被假设 | `npm test` + 与 Ubuntu 相同的离线 gates(不跑 release/coverage) |
|
|
225
227
|
| `test` 的 `Coverage (Node 22)` 步骤 | 覆盖率在 CI 中真实测量(只报告,不设阈值、不加徽章) | `npm run test:coverage` |
|
|
226
228
|
| `test` 的 `Release check`(Node 22)步骤 | tag 是一种声明:版本已越过上一个版本 tag、推送的 tag 是 `v<version>`、CHANGELOG 带有发布内容 | `npm run release:check` |
|
|
227
229
|
| `mount`(Node 22) | 打出的 tarball 经 plugin CLI 装进一次性 profile 后,真实 loader 挂载并注册了 bundle 声称的表面 | `npm run smoke:mount` |
|
|
228
230
|
| `mount-fixtures`(Node 22,非必需检查) | mount smoke 仍会因正确的种子失败而变红(改名一个工具 id、删除 skills 段),三次运行共用一次缓存的 host 安装 | `npm run smoke:fixtures -- --host-dir <缓存的 host>` |
|
|
229
231
|
|
|
232
|
+
`test` 的三条 leg 在 job 内用 `matrix.name` 固定各自的 context 字符串:`test (22)`、`test (24)` 与新增的 `test (22, windows)`。前两条是 `main` 的必需检查,名字逐字节不变;把 `os` 直接塞进 matrix 会把它们改名成 `test (22, ubuntu-latest)`,保护规则将永远等不到该 context,所以矩阵用显式 `include` + `name`。Windows leg 只跑 Node 22 和离线套件/离线 gates(无浏览器、无 live suite、无 release/coverage),与现有 job 并行,不延长关键路径。它目前不是必需 context:让它变绿是本 issue 的验收,把它加入保护设置是 #94 的人工步骤。
|
|
233
|
+
|
|
234
|
+
`tests/platform.test.mjs` 用注入的 `platform`/`rename`/`unlink` 在任意 OS 上同时断言 win32 与 POSIX 两条分支;唯一需要真实 Windows 的“rename 覆盖被打开的目标文件”用例在 Linux 上以 `# SKIP` 计入 TAP(`tests 11`、`skipped 1`),而不是静默消失。macOS 不单设 runner:它与 Linux 走同一条 POSIX 分支,这是 #86 的非目标。
|
|
235
|
+
|
|
230
236
|
`mount` 断言一次性 profile 的**已加载表面**,而不是 `cordis.patch.yml` 的文本:29 个 `tower_*` 工具(27 个 `tools/*.js` 模块 + `tower_mode` / `tower_control`)、3 个内置 skill、`tower-worker` / `tower-reviewer` 两个 preset、`/tower` 命令,以及唯一一个顶层 `tower-bundle` group 行(#66)。它不调用模型、不打开浏览器。在维护者的 Windows 机器上实测:host 冷装(含安装)约 100 秒,host 已缓存约 85 秒;CI 中该步骤上限为 3 分钟(`timeout-minutes: 3`),`mount` job 上限 5 分钟。红叉会打印解析后的 profile 路径、host build、已加载计数与每一条未满足的声明。
|
|
231
237
|
|
|
232
238
|
host build 只固定在一个地方:`scripts/mount-smoke/host.json`,`COMPATIBILITY.md` 最新一行由单元测试强制与它一致,发布时不再各写一遍。`npm run smoke:fixtures` 用两种种子失败(改名一个工具 id、删除 skills 段)证明 smoke 会以正确原因变红;CI 中它作为非必需的 `mount-fixtures` job 运行,control 与两个种子通过 `--host-dir` 共用一次缓存的 host 安装(本机热跑约 2 分钟)。
|
package/adapters/default.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Default composition root for Tower dependencies (#50).
|
|
2
2
|
import * as store from '../lib/store.js'
|
|
3
3
|
import * as vcs from '../lib/git.js'
|
|
4
|
+
import * as caseSensitivity from '../lib/caseSensitivity.js'
|
|
4
5
|
|
|
5
6
|
export const defaultDeps = {
|
|
6
7
|
store,
|
|
7
8
|
vcs,
|
|
9
|
+
caseSensitivity,
|
|
8
10
|
clock: { now: () => Date.now() },
|
|
9
11
|
ids: { uuid: () => crypto.randomUUID() },
|
|
10
12
|
}
|
package/app/archive.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Terminal-mission archival use case (#73).
|
|
2
|
+
//
|
|
3
|
+
// The policy sweep runs inside an existing state lock, immediately before the
|
|
4
|
+
// caller writes state.json: archive files are written first, then the state
|
|
5
|
+
// document is compacted, so a crash between the two leaves the mission in state
|
|
6
|
+
// and the archive entry is deduped by id on retry. Nothing is deleted: the full
|
|
7
|
+
// mission, its linked reviews and its findings are moved to archive/<year>.json.
|
|
8
|
+
import { appendJsonl } from '../lib/store.js'
|
|
9
|
+
import { readArchiveIndex, readArchivedEntry, writeArchiveEntries } from '../lib/archive.js'
|
|
10
|
+
import { hasLiveAgent } from '../domain/integration/liveness.js'
|
|
11
|
+
import { missionSummary } from '../domain/summary.js'
|
|
12
|
+
import {
|
|
13
|
+
archiveYearFor,
|
|
14
|
+
archivedEntry,
|
|
15
|
+
isClosedFinding,
|
|
16
|
+
linkedFindings,
|
|
17
|
+
linkedReviews,
|
|
18
|
+
selectForArchive,
|
|
19
|
+
} from '../domain/state/archive.js'
|
|
20
|
+
|
|
21
|
+
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
22
|
+
/** @typedef {import('../types/index.js').TowerState} TowerState */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read `archive/index.json` through the Tower's store. Never throws: the
|
|
26
|
+
* returned `ok:false` carries the diagnostic that status/doctor surface.
|
|
27
|
+
* @param {any} tower
|
|
28
|
+
* @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
|
|
29
|
+
*/
|
|
30
|
+
export function archiveIndex(tower) {
|
|
31
|
+
return readArchiveIndex(tower.deps.store, tower.paths)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load one archived mission with its linked reviews and findings.
|
|
36
|
+
* @param {any} tower
|
|
37
|
+
* @param {string} missionId
|
|
38
|
+
*/
|
|
39
|
+
export function archivedMission(tower, missionId) {
|
|
40
|
+
return readArchivedEntry(tower.deps.store, tower.paths, missionId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A compact archived mission is rendered through the same summary projection as
|
|
45
|
+
* an active one, plus `archived: true`. The completion report and task bodies
|
|
46
|
+
* are intentionally absent: they live in the year file and are loaded only by
|
|
47
|
+
* `tower_status missionId`.
|
|
48
|
+
* @param {Record<string, any>} entry
|
|
49
|
+
* @returns {any}
|
|
50
|
+
*/
|
|
51
|
+
export function archivedSummary(entry) {
|
|
52
|
+
const mission = /** @type {any} */ ({
|
|
53
|
+
id: entry.id,
|
|
54
|
+
title: entry.title,
|
|
55
|
+
kind: entry.kind ?? 'build',
|
|
56
|
+
status: entry.status,
|
|
57
|
+
scope: Array.isArray(entry.scope) ? entry.scope : [],
|
|
58
|
+
deps: Array.isArray(entry.deps) ? entry.deps : [],
|
|
59
|
+
owner: entry.owner ?? null,
|
|
60
|
+
worktree: null,
|
|
61
|
+
blockers: [],
|
|
62
|
+
tasks: [],
|
|
63
|
+
reviewPolicy: 'required-clean',
|
|
64
|
+
mergedCommit: entry.mergedCommit ?? null,
|
|
65
|
+
mergedAt: entry.mergedAt ?? null,
|
|
66
|
+
updatedAt: entry.updatedAt ?? null,
|
|
67
|
+
})
|
|
68
|
+
return {
|
|
69
|
+
...missionSummary(mission),
|
|
70
|
+
archived: true,
|
|
71
|
+
archivedAt: entry.archivedAt,
|
|
72
|
+
archivedYear: entry.archivedYear ?? null,
|
|
73
|
+
branch: entry.branch ?? null,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Status/board view of the archive index: a count plus a diagnostic, never a
|
|
79
|
+
* thrown error.
|
|
80
|
+
* @param {{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }} index
|
|
81
|
+
* @returns {Record<string, any>}
|
|
82
|
+
*/
|
|
83
|
+
export function archiveView(index) {
|
|
84
|
+
const years = index.ok
|
|
85
|
+
? [...new Set(index.missions.map((entry) => entry.archivedYear).filter((year) => Number.isInteger(year)))].sort(
|
|
86
|
+
(left, right) => left - right,
|
|
87
|
+
)
|
|
88
|
+
: []
|
|
89
|
+
return {
|
|
90
|
+
count: index.ok ? index.missions.length : 0,
|
|
91
|
+
years,
|
|
92
|
+
file: index.file,
|
|
93
|
+
present: index.exists === true,
|
|
94
|
+
unreadable: index.ok ? false : true,
|
|
95
|
+
...(index.ok ? {} : { message: index.error ?? 'tower archive is unreadable' }),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Move the eligible terminal missions out of `state` and into the archive.
|
|
101
|
+
* Mutates `state` in memory (missions, linked reviews and closed findings) and
|
|
102
|
+
* leaves the caller to validate/persist it under the lock it already holds.
|
|
103
|
+
*
|
|
104
|
+
* @param {any} tower
|
|
105
|
+
* @param {TowerState} state
|
|
106
|
+
* @param {{ now?: number, force?: boolean, activity?: Record<string, string | undefined> }} [options]
|
|
107
|
+
* @returns {Promise<{ archived: string[], written: boolean, files?: string[], error: string | null, year?: number }>}
|
|
108
|
+
*/
|
|
109
|
+
export async function sweepArchive(tower, state, options = {}) {
|
|
110
|
+
const policy = tower.config.archive ?? {}
|
|
111
|
+
if (policy.enabled === false) return { archived: [], written: false, error: null }
|
|
112
|
+
const now = options.now ?? Date.now()
|
|
113
|
+
const activity = options.activity ?? {}
|
|
114
|
+
|
|
115
|
+
// Open batches still need their members; a live agent's mission is never
|
|
116
|
+
// archived even if it is terminal (defence in depth; teardown keeps it too).
|
|
117
|
+
const protectedIds = new Set()
|
|
118
|
+
for (const batch of state.batches ?? []) {
|
|
119
|
+
const record = /** @type {Record<string, any>} */ (batch)
|
|
120
|
+
if (record.status !== 'open') continue
|
|
121
|
+
for (const missionId of Array.isArray(record.missionIds) ? record.missionIds : []) {
|
|
122
|
+
protectedIds.add(String(missionId))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const mission of state.missions) {
|
|
126
|
+
if (hasLiveAgent(state, mission.id, activity)) protectedIds.add(mission.id)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const selected = selectForArchive(state.missions, policy, {
|
|
130
|
+
now,
|
|
131
|
+
force: options.force === true,
|
|
132
|
+
protectedIds,
|
|
133
|
+
})
|
|
134
|
+
if (selected.length === 0) return { archived: [], written: false, error: null }
|
|
135
|
+
|
|
136
|
+
const archivedYear = archiveYearFor(now)
|
|
137
|
+
const entries = selected.map((mission) =>
|
|
138
|
+
archivedEntry(mission, {
|
|
139
|
+
archivedAt: now,
|
|
140
|
+
archivedYear,
|
|
141
|
+
reviews: linkedReviews(mission, state.reviews),
|
|
142
|
+
findings: linkedFindings(mission, state.findings),
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
let written
|
|
147
|
+
try {
|
|
148
|
+
written = await writeArchiveEntries(tower.deps.store, tower.paths, { entries, now })
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
|
|
151
|
+
}
|
|
152
|
+
if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
|
|
153
|
+
|
|
154
|
+
// The archive now holds the full record: compact state.json in the same lock.
|
|
155
|
+
const ids = new Set(selected.map((mission) => mission.id))
|
|
156
|
+
const branches = new Set(
|
|
157
|
+
selected
|
|
158
|
+
.map((mission) => mission.worktree?.branch)
|
|
159
|
+
.filter((branch) => typeof branch === 'string'),
|
|
160
|
+
)
|
|
161
|
+
state.missions = state.missions.filter((mission) => !ids.has(mission.id))
|
|
162
|
+
if (Array.isArray(state.reviews)) {
|
|
163
|
+
state.reviews = state.reviews.filter((review) => !ids.has(review.missionId) && !branches.has(review.branch))
|
|
164
|
+
}
|
|
165
|
+
// Open findings stay actionable in state.json; the archive keeps a snapshot.
|
|
166
|
+
if (Array.isArray(state.findings)) {
|
|
167
|
+
state.findings = state.findings.filter(
|
|
168
|
+
(finding) => !(finding.missionId != null && ids.has(finding.missionId) && isClosedFinding(finding)),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
state.revision += 1
|
|
172
|
+
state.updatedAt = now
|
|
173
|
+
|
|
174
|
+
for (const mission of selected) {
|
|
175
|
+
await appendJsonl(tower.paths.activity, {
|
|
176
|
+
at: now,
|
|
177
|
+
type: 'archive',
|
|
178
|
+
missionId: mission.id,
|
|
179
|
+
text: `archived to archive/${archivedYear}.json`,
|
|
180
|
+
}).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
archived: [...ids],
|
|
185
|
+
written: true,
|
|
186
|
+
files: written.files,
|
|
187
|
+
error: null,
|
|
188
|
+
year: archivedYear,
|
|
189
|
+
}
|
|
190
|
+
}
|
package/app/board.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { fsp } from '../adapters/store-fs.js'
|
|
4
4
|
import { renameWithRetry } from '../lib/store.js'
|
|
5
|
+
import { archiveView } from './archive.js'
|
|
5
6
|
|
|
6
7
|
export function boardFile(tower) {
|
|
7
8
|
return tower.config.docs?.boardLocation === 'repo'
|
|
@@ -13,10 +14,11 @@ export async function writeBoard(tower) {
|
|
|
13
14
|
if (tower.config.docs?.board === false) return null
|
|
14
15
|
try {
|
|
15
16
|
const state = await tower.load()
|
|
17
|
+
const index = await tower.archiveIndex()
|
|
16
18
|
const file = boardFile(tower)
|
|
17
19
|
await tower.ensureExcluded(file)
|
|
18
20
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
19
|
-
await fsp.writeFile(tmp, tower.boardMarkdown(state), 'utf8')
|
|
21
|
+
await fsp.writeFile(tmp, tower.boardMarkdown(state, archiveView(index)), 'utf8')
|
|
20
22
|
await renameWithRetry(tmp, file)
|
|
21
23
|
return file
|
|
22
24
|
} catch {
|
package/app/checkCompletion.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fullMission } from '../domain/summary.js'
|
|
|
10
10
|
import { validateState } from '../domain/state.js'
|
|
11
11
|
import { countCommits, isClean } from '../lib/git.js'
|
|
12
12
|
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
13
|
+
import { sweepArchive } from './archive.js'
|
|
13
14
|
|
|
14
15
|
export { normalizeChecks }
|
|
15
16
|
|
|
@@ -19,7 +20,12 @@ export async function checkCompletion(tower, input) {
|
|
|
19
20
|
if (!mission) {
|
|
20
21
|
throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
|
|
21
22
|
}
|
|
22
|
-
const worker = assertCompletionRequest({
|
|
23
|
+
const worker = assertCompletionRequest({
|
|
24
|
+
mission,
|
|
25
|
+
roster: state.roster,
|
|
26
|
+
input,
|
|
27
|
+
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
28
|
+
})
|
|
23
29
|
await tower.assertWorktree(mission.worktree)
|
|
24
30
|
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
25
31
|
if (commits < 1) {
|
|
@@ -58,8 +64,15 @@ export async function complete(tower, input) {
|
|
|
58
64
|
const checks = normalizeChecks(input.checks)
|
|
59
65
|
const now = Date.now()
|
|
60
66
|
mission.status = 'completed'
|
|
61
|
-
mission.completionReport = completionReport({
|
|
67
|
+
mission.completionReport = completionReport({
|
|
68
|
+
report: input.report,
|
|
69
|
+
worker,
|
|
70
|
+
checks,
|
|
71
|
+
now,
|
|
72
|
+
maxReportBytes: tower.config.gates?.maxReportBytes,
|
|
73
|
+
})
|
|
62
74
|
mission.updatedAt = now
|
|
75
|
+
await sweepArchive(tower, state, { now })
|
|
63
76
|
state.revision += 1
|
|
64
77
|
state.updatedAt = now
|
|
65
78
|
|
package/app/gate.js
CHANGED
|
@@ -20,8 +20,23 @@ import {
|
|
|
20
20
|
|
|
21
21
|
export async function evaluateGate(tower, input) {
|
|
22
22
|
const state = await tower.load()
|
|
23
|
-
|
|
23
|
+
let mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
24
|
+
let archivedEntry = null
|
|
25
|
+
if (!mission) {
|
|
26
|
+
// A completed mission may have been archived by policy; preflight and
|
|
27
|
+
// merge must behave the same for it as before the move (#73).
|
|
28
|
+
const archived = await tower.archivedMission(input.missionId)
|
|
29
|
+
if (archived.ok && archived.entry) {
|
|
30
|
+
archivedEntry = archived.entry
|
|
31
|
+
mission = archived.entry.mission
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
if (!mission) return missingMissionGateResult(input.missionId)
|
|
35
|
+
const archiveIndex = await tower.archiveIndex()
|
|
36
|
+
const archivedMissions = new Map(
|
|
37
|
+
(archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
|
|
38
|
+
)
|
|
39
|
+
const reviews = archivedEntry ? [...(state.reviews ?? []), ...(archivedEntry.reviews ?? [])] : state.reviews
|
|
25
40
|
const activity = input.activity ?? {}
|
|
26
41
|
const blockers = []
|
|
27
42
|
const push = (code, detail) => blockers.push({ code, detail })
|
|
@@ -57,7 +72,7 @@ export async function evaluateGate(tower, input) {
|
|
|
57
72
|
if (blocker) push(blocker.code, blocker.detail)
|
|
58
73
|
}
|
|
59
74
|
if (mission.kind !== 'survey') {
|
|
60
|
-
const blocker = outOfScopeBlocker({ mission, changedFiles })
|
|
75
|
+
const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
|
|
61
76
|
if (blocker) push(blocker.code, blocker.detail)
|
|
62
77
|
}
|
|
63
78
|
const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
|
|
@@ -71,12 +86,12 @@ export async function evaluateGate(tower, input) {
|
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state })) {
|
|
89
|
+
for (const blocker of dependencyBlockers({ deps: mission.deps ?? [], state, archived: archivedMissions })) {
|
|
75
90
|
push(blocker.code, blocker.detail)
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
const branch = worktree ? worktree.branch : null
|
|
79
|
-
const latest = latestReview(reviewsForBranch(
|
|
94
|
+
const latest = latestReview(reviewsForBranch(reviews, branch))
|
|
80
95
|
for (const blocker of mergeGateBlockers({
|
|
81
96
|
mission,
|
|
82
97
|
roster: state.roster ?? [],
|
|
@@ -94,15 +109,18 @@ export async function evaluateGate(tower, input) {
|
|
|
94
109
|
const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
|
|
95
110
|
if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
|
|
96
111
|
|
|
97
|
-
return
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
return {
|
|
113
|
+
...gateResult({
|
|
114
|
+
mission: fullMission(mission),
|
|
115
|
+
worktree,
|
|
116
|
+
branchTip,
|
|
117
|
+
baseTip,
|
|
118
|
+
changedFiles,
|
|
119
|
+
review: latest,
|
|
120
|
+
blockers,
|
|
121
|
+
}),
|
|
122
|
+
...(archivedEntry ? { archived: true } : {}),
|
|
123
|
+
}
|
|
106
124
|
}
|
|
107
125
|
|
|
108
126
|
/** Read-only mission diff for reviewers: branch changes against the merge base. */
|
package/app/init.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
// commit — one short state lock writes/claims/reanchors, revalidating the
|
|
8
8
|
// state revision captured by prepare.
|
|
9
9
|
import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
10
|
+
import { detectCaseSensitivity } from '../lib/caseSensitivity.js'
|
|
10
11
|
import { validateState } from '../domain/state.js'
|
|
11
12
|
import { TowerToolError } from '../domain/errors.js'
|
|
12
13
|
import {
|
|
13
14
|
SNAPSHOT_REF_PREFIX,
|
|
15
|
+
caseProbeActivityText,
|
|
14
16
|
claimCaller,
|
|
15
17
|
createdInitResult,
|
|
16
18
|
existingInitResult,
|
|
@@ -33,6 +35,19 @@ function fail(message, code = 'invalid-argument') {
|
|
|
33
35
|
throw new TowerToolError(code, message)
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Probe the checkout's own filesystem through the injected seam. The default
|
|
40
|
+
* composition uses the real probe; a composition that predates #90 keeps
|
|
41
|
+
* working through the fallback.
|
|
42
|
+
* @param {any} tower
|
|
43
|
+
* @returns {Promise<boolean | null>}
|
|
44
|
+
*/
|
|
45
|
+
async function detectCaseMode(tower) {
|
|
46
|
+
const detect = tower.deps?.caseSensitivity?.detect
|
|
47
|
+
if (typeof detect === 'function') return detect(tower.root)
|
|
48
|
+
return detectCaseSensitivity(tower.root)
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
/** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
|
|
37
52
|
async function prepareInit(tower, input) {
|
|
38
53
|
const dirty = !(await tower.isClean(tower.root))
|
|
@@ -60,6 +75,10 @@ async function prepareInit(tower, input) {
|
|
|
60
75
|
if (ownedByOther(owner, caller)) {
|
|
61
76
|
fail(`tower state is owned by another agent; ${caller} is not the tower`, 'not-authorized')
|
|
62
77
|
}
|
|
78
|
+
// "Detect once": a state that already carries a boolean is never re-probed,
|
|
79
|
+
// so a transient probe failure cannot silently flip the comparison mode.
|
|
80
|
+
const known = state.caseInsensitive === true || state.caseInsensitive === false
|
|
81
|
+
const caseInsensitive = known ? null : await detectCaseMode(tower)
|
|
63
82
|
let reanchorBase = null
|
|
64
83
|
if (input.reanchor === true) {
|
|
65
84
|
const refusal = reanchorBlockedMessage(state.missions)
|
|
@@ -68,10 +87,10 @@ async function prepareInit(tower, input) {
|
|
|
68
87
|
}
|
|
69
88
|
const claim = claimCaller(owner, caller) !== null
|
|
70
89
|
const reanchor = shouldReanchor({ reanchor: input.reanchor, reanchorBase, currentBase: state.base })
|
|
71
|
-
if (!claim && !reanchor) {
|
|
90
|
+
if (!claim && !reanchor && caseInsensitive === null) {
|
|
72
91
|
return { early: existingInitResult({ state, statePath: tower.paths.state, dirty }) }
|
|
73
92
|
}
|
|
74
|
-
return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty }
|
|
93
|
+
return { stateRevision: state.revision, claim, reanchor, base: reanchorBase, dirty, caseInsensitive }
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
return {
|
|
@@ -79,6 +98,7 @@ async function prepareInit(tower, input) {
|
|
|
79
98
|
base: await resolveBase(),
|
|
80
99
|
snapshot: shouldSnapshot({ snapshotDirty: input.snapshotDirty, dirty }),
|
|
81
100
|
dirty,
|
|
101
|
+
caseInsensitive: await detectCaseMode(tower),
|
|
82
102
|
}
|
|
83
103
|
}
|
|
84
104
|
|
|
@@ -154,6 +174,7 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
154
174
|
towerAgentId: input.towerAgentId ? String(input.towerAgentId) : null,
|
|
155
175
|
snapshotCommit: acted.snapshotCommit,
|
|
156
176
|
snapshotRef: acted.snapshotRef,
|
|
177
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
157
178
|
now,
|
|
158
179
|
})
|
|
159
180
|
validateState(state, tower.paths.state)
|
|
@@ -161,7 +182,12 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
161
182
|
await appendJsonl(tower.paths.activity, {
|
|
162
183
|
at: now,
|
|
163
184
|
type: 'init',
|
|
164
|
-
text: initActivityText({
|
|
185
|
+
text: initActivityText({
|
|
186
|
+
base: prepared.base,
|
|
187
|
+
snapshotCommit: acted.snapshotCommit,
|
|
188
|
+
dirty: prepared.dirty,
|
|
189
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
190
|
+
}),
|
|
165
191
|
})
|
|
166
192
|
return createdInitResult({
|
|
167
193
|
repoRoot: tower.root,
|
|
@@ -171,6 +197,7 @@ async function commitInitCreate(tower, prepared, acted, input) {
|
|
|
171
197
|
dirty: prepared.dirty,
|
|
172
198
|
snapshotCommit: acted.snapshotCommit,
|
|
173
199
|
snapshotRef: acted.snapshotRef,
|
|
200
|
+
caseInsensitive: prepared.caseInsensitive,
|
|
174
201
|
})
|
|
175
202
|
})
|
|
176
203
|
}
|
|
@@ -205,6 +232,21 @@ async function commitInitExisting(tower, prepared, input) {
|
|
|
205
232
|
})
|
|
206
233
|
}
|
|
207
234
|
let reanchored = false
|
|
235
|
+
// Record the one-time case-sensitivity probe (#90). An unknown result is
|
|
236
|
+
// not persisted, so a later init can retry it without bumping the revision
|
|
237
|
+
// on every call.
|
|
238
|
+
if (prepared.caseInsensitive !== null && state.caseInsensitive !== prepared.caseInsensitive) {
|
|
239
|
+
state.caseInsensitive = prepared.caseInsensitive
|
|
240
|
+
state.revision += 1
|
|
241
|
+
state.updatedAt = now
|
|
242
|
+
validateState(state, tower.paths.state)
|
|
243
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
244
|
+
await appendJsonl(tower.paths.activity, {
|
|
245
|
+
at: now,
|
|
246
|
+
type: 'init',
|
|
247
|
+
text: caseProbeActivityText(prepared.caseInsensitive),
|
|
248
|
+
})
|
|
249
|
+
}
|
|
208
250
|
if (prepared.reanchor && state.base !== prepared.base) {
|
|
209
251
|
state.base = prepared.base
|
|
210
252
|
state.baseChangedAt = now
|
package/app/merge.js
CHANGED
|
@@ -232,6 +232,12 @@ async function prepareFromState(tower, input, deps) {
|
|
|
232
232
|
const { mergeTimeoutMs, MERGE_MAX_BUFFER } = deps
|
|
233
233
|
let state = await tower.load()
|
|
234
234
|
let mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
235
|
+
if (!mission) {
|
|
236
|
+
// An archived terminal mission still answers `alreadyMerged` (or the same
|
|
237
|
+
// blockers) instead of turning into mission-missing after the move (#73).
|
|
238
|
+
const archived = await tower.archivedMission(input.missionId)
|
|
239
|
+
if (archived.ok && archived.entry) mission = archived.entry.mission
|
|
240
|
+
}
|
|
235
241
|
if (!mission) return { result: missionMissingResult(input.missionId) }
|
|
236
242
|
|
|
237
243
|
// Reconcile a stale merge intent: Git runs here, outside every state lock.
|