@thincoder/core 0.9.1 → 0.9.2

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.
@@ -27,7 +27,7 @@ You have a budget of 15 tool rounds (chat turns). Hard mechanical cap: 100 round
27
27
  - Stop calling tools once you are ready to produce the review table.
28
28
 
29
29
  ## 批次档 §3 落档(仅设计评审——工具已挂载时)
30
- 设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch_segment` 时**——代码评审无此工具,本节不适用):在报告之外,用 `batch_segment({segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
30
+ 设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch`(过渡别名 `batch_segment`)时**——代码评审无此工具,本节不适用):在报告之外,用 `batch({action:"append", segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
31
31
  写不进去(被拒/失败)→ 报告里明说「§3 未写入」——不得静默略过,也不得假装写过(父侧代写必须打标)。
32
32
 
33
33
  ## Judgment Rules (apply directly — do not re-derive) Apply each rule to the extent it matches the review type: design review — doc-state rules (R1, R7a-e) apply; code review — all rules apply. R1 Doc contradiction / state inconsistency → 🟡 (report-and-fix by the parent doc layer — NOT 🔴; exception: the same mechanism described differently in two places = Document ownership 🔴 — keep this convention — do not downgrade)
@@ -23,7 +23,7 @@ You have a budget of 15 tool rounds (chat turns). Hard mechanical cap: 100 round
23
23
  - Stop calling tools once you are ready to produce the review table.
24
24
 
25
25
  ## 批次档 §3 落档(仅设计评审——工具已挂载时)
26
- 设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch_segment` 时**——代码评审无此工具,本节不适用):在报告之外,用 `batch_segment({segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
26
+ 设计评审专用(**仅当本评审为设计评审、且工具面里已挂载 `batch`(过渡别名 `batch_segment`)时**——代码评审无此工具,本节不适用):在报告之外,用 `batch({action:"append", segment:"§3", text})` 把本轮**发现表 + VERDICT + 计数逐字**写进批次档 §3(不给路径参数;工具自带 `### 轮次 N(评审子代理)` 来源戳,勿自写标题)。
27
27
  写不进去(被拒/失败)→ 报告里明说「§3 未写入」——不得静默略过,也不得假装写过(父侧代写必须打标)。
28
28
 
29
29
  ## Judgment Rules (apply directly — do not re-derive) Apply each rule to the extent it matches the review type: design review — doc-state rules (R1, R7a-e) apply; code review — all rules apply. R1 Doc contradiction / state inconsistency → 🟡 (report-and-fix by the parent doc layer — NOT 🔴; exception: the same mechanism described differently in two places = Document ownership 🔴 — keep this convention — do not downgrade)
package/prompts/common.md CHANGED
@@ -153,6 +153,6 @@ Batch independent read-only tool calls into a single reply (they run concurrentl
153
153
  ## 批次档常识(Batch record — the carrier of engineering-mode task flow)
154
154
  **What it is**: the **batch record** = the **carrier** of an engineering-mode task (the single file threading one implementation round from start to closeout); the path takes the form `docs/batches/<batch>-<topic>.md` — the actual location is the `batchDoc` passed at spawn, never hard-coded.
155
155
  **Six-segment map (one segment, one author)**: §1 discussion = the main agent · §2 batch task & design = eng-designer · §3 design review findings = the review subagent (advisor) · §4 user approval = the main agent · §5 implementation record = eng-coder · §6 verification & closeout = the main agent (the parent, as seen by subagents).
156
- **Writing means**: `batch_segment` — **no path parameter** (the target record is bound to you at spawn); **the segment number follows from your identity**; if the write does not land ⇒ say "§× 未写入" plainly in your report.
156
+ **Writing means**: the `batch` tool (transition alias `batch_segment` — same append executor) — **no path parameter** (the target record is bound to you at spawn); **the segment number follows from your identity**; if the write does not land ⇒ say "§× 未写入" plainly in your report.
157
157
  **The task book itself**: `batchDoc` = the batch-record path = **your task book** (mandatory on every eng-designer / eng-coder spawn; **unreadable ⇒ refused** — do not execute, bounce it back).
158
158
  **Structure authority**: segment structure / gates / lifecycle are defined by the project's own batch-record mechanism (this section gives the map only — no mechanism restatement).
@@ -36,6 +36,6 @@ the audit "out-of-list" criterion = changed AND not reported (silent overreach);
36
36
  ## 批次档纪律(六段自写 · 执行者拒收)
37
37
  - **§5 由你自写**(**一段一作者**):§1 主 agent / §2 eng-designer / §3 评审子代理 / §4 主 agent / **§5 你** / §6 父代理——
38
38
  交付摘要 / 决策透明表 / 审计与代码评审轮次与终态 / fix round,落**批次档 §5**,不靠父侧转述(转述 = 失真源)。
39
- 写入手段 = `batch_segment({segment, text})`(**无路径参数**——目标档 = 你 spawn 时的批次档绑定,段号由你的身份定:eng-coder → §5);
39
+ 写入手段 = `batch` 工具 `batch({action:"append", segment, text})`(过渡别名 `batch_segment`——append 同执行体)(**无路径参数**——目标档 = 你 spawn 时的批次档绑定,段号由你的身份定:eng-coder → §5);
40
40
  写不进去(拒/失败)→ 报告里明说“§5 未写入”——不得静默跳过,也不得假设父侧会代写。
41
41
  - **执行者拒收**:查不到任务书(批次档 §2 / `batchDoc` 路径不可读)→ **不执行、打回**——不自行补造任务书往下干。
@@ -44,7 +44,7 @@ Boundary crossings are backstopped by **prompt discipline + main-agent content v
44
44
  ## Two deliverables (don't mix them up)
45
45
  1. **The batch task**: this batch's covered requirement entries / entries explicitly NOT in this batch / affected files / acceptance criteria → **batch record §2** (append, don't rewrite §1) — **not written into the design doc** (one-shot content mixed into a long-term doc would be overwritten by the next batch).
46
46
  Six-segment boundary = **one author per segment**: §1 main agent / **§2 you** / §3 review subagent / §4 main agent / §5 eng-coder / §6 parent — you write only §2; subagents self-write, never via parent paraphrase.
47
- Write means = `batch_segment({segment, text})` (**no path parameter** — the target doc is bound at your spawn, the segment number is determined by your identity: eng-designer → §2); write fails (refused/failed) → the report states "§2 未写入" (not written).
47
+ Write means = the `batch` tool, `batch({action:"append", segment, text})` (transition alias `batch_segment` — same append executor) (**no path parameter** — the target doc is bound at your spawn, the segment number is determined by your identity: eng-designer → §2); write fails (refused/failed) → the report states "§2 未写入" (not written).
48
48
  2. **The design doc** — next section.
49
49
  - **Don't self-pick unassigned work**: the dispatch already scoped this round's task surface ⇒ **do not** switch to similar but unassigned work (especially "machine-check line folding / count corrections / closure statements" — already done in prior rounds).
50
50
 
@@ -40,10 +40,13 @@ Batch record, dispatch task books, verification conclusions, review firing, requ
40
40
  > While stopped you **keep answering and discussing, presenting current results** — you just don't auto-cross the next step (spawn / review firing / doc advancement / post-digest
41
41
  > follow-up actions all hold for the nod). The user's next explicit instruction ("ok / continue / start" or a specific next-step order) resumes advancement — prior state is not lost.
42
42
 
43
- ## Project state file (PROJECT-MANIFEST — entry gate)
44
- Engineering mode **must have it first** **one per git repository**, located at the repo root (`PROJECT-MANIFEST.json`: project phase / directory declarations — pure machine state).
45
- **Project root is judged by the git directory (.git), walking DOWN only, never up**: the session anchor itself contains .git the anchor is the repo root; ② otherwise **walk down** to direct child repos carrying a manifest (one per repo — exactly one counts, zero = no project, several = ambiguous).
46
- **If missing, initialize it first**: settle the project phase + survey existing docs and code + take stock of the house, then enter the normal loop; without a manifest the mechanism **refuses to enter** the normal loop. Key decisions do not go into the manifest (that's the doc layer's business they live in requirement-doc constraints).
43
+ ## Project state file (PROJECT-MANIFEST — resolved per use site)
44
+ **One per project** a project = a directory carrying `PROJECT-MANIFEST.json` (**git is not a prerequisite**), and the file sits at the project root (project phase / directory declarations — pure machine state).
45
+ **Parameters are resolved per use site; a session is not bound to a project**: whichever project's path an action targets, that project's file is the one read (several projects coexisting = a normal state).
46
+ **Ownership = nearest ancestor wins**: walk the target path's ancestor chain and take the **nearest directory carrying the file** when nested, **the child wins over the root** (an independent sub-project inside a root project keeps its own), never across siblings, no global priority question.
47
+ **Project-root judgment (walk DOWN only, never up; direct child directories only, no recursion)**: ① the anchor itself carries the file → the anchor is a project; ② the anchor itself contains `.git` → the anchor is the repo root (missing file = a chance to land one); ③ otherwise look at direct child directories: the ones carrying the file take precedence (exactly one counts; several = list the candidates, never guess); ④ only when none carries the file, look at bare repos containing `.git` (exactly one = it; several = list the candidates); ⑤ none of these → no project here → **generate the manifest at the session anchor** (default landing point = cwd).
48
+ **Landing one is a light action at the point of use**: one per project, each landed on its own; never pre-created, never batch-created. `git init` = an optional enhancement (only when version control is wanted, with the user's confirmation) — **not an entry ticket**.
49
+ **Never brick the session**: a session can start in any directory; when project parameters are needed and there is no project → **that action / that turn reports it**, without going silent and without killing the session. Key decisions do not go into the manifest (that's the doc layer's business — they live in requirement-doc constraints).
47
50
 
48
51
  ## Batch-record lifecycle (five rules)
49
52
  1. **One implementation round's boundary**: one batch = one delivery target + one set of same-batch entries + one batch record; **any hit starts a new batch and a new record** — ① delivery target changed (topic word changed) ② stage crossed ③ this batch's entry set changed (new entries outside the original batch's scope / original entries all settled) — **in-scope ones ⇒ same batch continues**; boundary judgment rights are yours (main agent).
@@ -70,7 +73,7 @@ Engineering mode **must have it first** — **one per git repository**, located
70
73
  ## Review convergence (firing / adjudication / rounds)
71
74
  - Firing rights: design reviews can only be fired by the user — you prepare and remind, the user fires;
72
75
  delivery code review = an automatic flow node (subagent-internal protocol) — parent-side advisor = optional second opinion.
73
- - Design review while a batch record is in flight: **must pass `batchDoc`** (the batch record path) — the reviewer thereby gets the `batch_segment` write channel and writes the findings table + VERDICT + counts **verbatim** into batch record §3;
76
+ - Design review while a batch record is in flight: **must pass `batchDoc`** (the batch record path) — the reviewer thereby gets the `batch` tool write channel (transition alias `batch_segment` — same append executor) and writes the findings table + VERDICT + counts **verbatim** into batch record §3;
74
77
  an in-flight design review without a batch record is **not blocked** (no param = not mounted — never refuse a review for a missing param; without the write channel §3 can only be ghost-written by you and **marked**).
75
78
  - Adjudication table: after every advisor review, reply with the response table — header exactly `| # | Action | Detail |`,
76
79
  one row per issue; `#` = the advisor's issue number (`Orig#` from round 2 on).
package/session-gc.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * session-gc.mjs — 会话目录残留 GC + 冷 cwd 报告/删除(SESSION.md §6.12,2026-09-06)。
2
+ * session-gc.mjs — 会话目录残留 GC + 冷 cwd / 存量组报告与回收(SESSION.md §6.12 · §6.17,2026-09-06)。
3
3
  *
4
4
  * F1 残留 GC(自动):进程启动时对当前 cwd hash 前缀做一次轻量清理——.corrupted /
5
5
  * .unreadable / .manifest.corrupted / .bak-* 保留 30 天,孤儿 .tmp 保留 7 天
@@ -7,27 +7,34 @@
7
7
  * 安全(N1):活跃槽(主文件在 + manifest slotSessions[N] 属主活)的现场一律保留;
8
8
  * manifest 主文件 / end marker / 数据主文件永不进入候选(后缀预过滤只匹配残留后缀)。
9
9
  *
10
- * F2 冷 cwd(手动——12.2.4 三步,v1 不自动删 manifest):
11
- * 判定:manifest mtime 距今 > 90 且 无任何活跃数据文件(.json.N 不存在或全部属死主);
12
- * 报告:thincoder session gc --dry-run(跨 cwd 枚举,不删除);
13
- * 删除:thincoder session gc --confirm <hash|--all>(整前缀清空——manifest+marker+
14
- * .json.N 死主数据+裸 v1 {hash}.json(§6.12 D-V1)+残留;删除前重校验冷态——
15
- * TOCTOU 防护 T12)。
10
+ * F2 冷 cwd(手动——12.2.4 三步,v1 不自动删 manifest):判定 = manifest mtime 距今 > 90 天
11
+ * 且无任何活跃数据文件(.json.N 不存在或全部属死主);报告 = `session gc --dry-run`;
12
+ * 回收 = `session gc --confirm <hash|--all>`(整前缀——manifest + end marker + 死主数据 +
13
+ * v1 `{hash}.json` + 残留;回收前重校验冷态——TOCTOU 防护 T12)。
14
+ *
15
+ * STARTUP-LATENCY 批(2026-09-21 · §6.17):① 全链异步化(D-SE34——本档零同步扫描:
16
+ * `readdirSync`/`statSync`/`readFileSync`/`unlinkSync`/`rmSync` 一律退场,目录与逐文件面走
17
+ * `node:fs/promises`;同步 fs 只留 `existsSync` 单条目探测)② 存量组面(三合取判据 / 回收 /
18
+ * 清运 / 有界扫——住 `session-stale.mjs`,**启动窗外延迟拍**每进程每前缀一次、总评估 ≤ STALE_SWEEP_LIMIT)
19
+ * ③ 删除改**回收目录**(同卷 rename——判据窗 7 天 + 回收 7 天 ⇒ 不可逆 ≥14 天;D-SE36)
20
+ * ④ 显式命令面候选面自然扩大(冷 cwd ∪ 三合取存量组——零新增旗标)。
21
+ * 触发形态(D-SE39 · 2026-09-21 微修)= 核侧 `setTimeout`(`GC_PASS_DELAY_MS` = 3s,自调度点起
22
+ * ——pass 起点落于启动窗(TTY 门 ≤2s)之外);**异步非阻塞**、不 unref(保后台排空现状)。
16
23
  *
17
24
  * 拆分理由(§6.12 review #1):session-slots.mjs 已在 500 行硬限零余量,GC 是独立决策
18
- * ("何时清何种残留"),独立成模块;原语(sessionPath / ownerPid)自 session-slots
19
- * import,启动钩子(session.mjs resumeSlot 包装)只一行调用。
25
+ *("何时清何种残留"),独立成模块;原语(sessionPath / ownerPid)自 session-slots import,
26
+ * 启动钩子(session.mjs resumeSlot 包装)只一行调用。
20
27
  *
21
28
  * F-MI7(2026-09-18):判活 = **核探测束**(`process-probe.mjs` `probeOwnersAsync`)+ `ownerState`
22
- * 三态查表——本档**零逐 pid 探测**;**未知 ⇒ 保留 / 不判冷**(D-MI10 同向);入口面异步(启动钩子
23
- * 延后执行,不阻塞启动路径)。死主数据文件 / 活跃槽判定与认领面同源判据。
29
+ * 三态查表——本档**零逐 pid 探测**;**未知 ⇒ 保留 / 不判冷**(D-MI10 同向);入口面异步。
24
30
  */
25
-
26
- import { readdirSync, readFileSync, statSync, unlinkSync, existsSync, rmSync } from "node:fs"
31
+ import { existsSync } from "node:fs"
32
+ import { readdir, readFile, stat, unlink } from "node:fs/promises"
27
33
  import { join, dirname, basename } from "node:path"
28
34
  import { configDir } from "./config.mjs"
29
35
  import { sessionPath, ownerPid } from "./session-slots.mjs"
30
36
  import { probeOwnersAsync, ownerState } from "./process-probe.mjs"
37
+ import { listStaleCwds, deleteStaleCwd, recycleGroup, sweepStale, trashRootFor } from "./session-stale.mjs"
31
38
 
32
39
  /** 保留期(§6.12):损坏现场(.corrupted/.unreadable/.manifest.corrupted)与并发轮转
33
40
  * 备份(.bak-*)30 天;孤儿 .tmp 7 天(崩溃现场恢复窗口)。 */
@@ -52,13 +59,13 @@ function classifyResidue(name, prefix) {
52
59
  }
53
60
 
54
61
  /** 活跃槽集合(§6.12 T3/T12 操作定义):.json.N 主文件存在 且 manifest slotSessions[N]
55
- * 属主进程存活。manifest 缺失/损坏 → 空集(无活跃槽——不阻碍残留清理)。
62
+ * 属主进程存活。manifest 缺失/损坏 → 空集(不阻碍残留清理)。
56
63
  * 探测 = **入口一次异步束**(零逐 pid exec);三态消费:活 ⇒ 活跃;死 ⇒ 非活跃;
57
64
  * **未知(探测失败 / 缺行)⇒ 计为活跃**(现场保留——D-MI10 同向)。 */
58
65
  async function liveSlots(dir, prefix, probeFn) {
59
66
  const active = new Set()
60
67
  let m
61
- try { m = JSON.parse(readFileSync(join(dir, `${prefix}.manifest`), "utf8")) } catch { return active }
68
+ try { m = JSON.parse(await readFile(join(dir, `${prefix}.manifest`), "utf8")) } catch { return active }
62
69
  const entries = []
63
70
  for (const [n, owner] of Object.entries(m.slotSessions ?? {})) {
64
71
  if (!/^\d+$/.test(n) || !owner) continue
@@ -78,14 +85,17 @@ async function liveSlots(dir, prefix, probeFn) {
78
85
  * 残留 GC(§6.12——单 cwd 前缀,不跨 cwd 扫描):返回 { candidates, deleted }
79
86
  * (dryRun 时 candidates 照列、deleted 为空——只列不删,N2 可预览)。
80
87
  * 扫描先按后缀预过滤(N4——只 stat 残留候选),再对候选做活跃槽/孤儿/保留期判定;
81
- * 无残留候选 ⇒ **零探测早退**(不发起探测束)。probeFn = 测试注入缝(缺省核异步束)。
88
+ * 无残留候选 ⇒ **零探测早退**(不发起探测束)。probeFn = 测试注入缝(缺省核异步束);
89
+ * `entries` = 共享目录快照(启动 pass 只 readdir 一次——缺省自行读)。
82
90
  */
83
- export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(), dryRun = false, probeFn = probeOwnersAsync } = {}) {
91
+ export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(), dryRun = false, probeFn = probeOwnersAsync, entries = null } = {}) {
84
92
  const result = { candidates: [], deleted: [] }
85
- let entries
86
- try { entries = readdirSync(dir) } catch { return result }
93
+ let names = entries
94
+ if (!names) {
95
+ try { names = await readdir(dir) } catch { return result }
96
+ }
87
97
  const candidates = []
88
- for (const name of entries) {
98
+ for (const name of names) {
89
99
  const c = classifyResidue(name, prefix)
90
100
  if (c) candidates.push({ name, ...c })
91
101
  }
@@ -96,28 +106,48 @@ export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(),
96
106
  const p = join(dir, c.name)
97
107
  if (c.tmp && existsSync(p.slice(0, -".tmp".length))) continue // 非孤儿 .tmp(主文件在——写中/回退候选)
98
108
  let st
99
- try { st = statSync(p) } catch { continue }
109
+ try { st = await stat(p) } catch { continue }
100
110
  if (st.mtimeMs >= now - c.retention) continue // 边界:older-than 才删,等于保留期保留(§6.12)
101
111
  result.candidates.push(c.name)
102
112
  if (!dryRun) {
103
- try { unlinkSync(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
113
+ try { await unlink(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
104
114
  }
105
115
  }
106
116
  return result
107
117
  }
108
118
 
119
+ /** 启动**窗外延迟拍** pass(§6.17 编排——点火见 `scheduleSessionGC`):① 一次**异步目录快照**
120
+ * (readdir 恰一次——两面共享)② 残留面(gcResidue 既有判据)③ 存量面(总评估有界
121
+ * ≤ STALE_SWEEP_LIMIT(含 ① 面 manifest 读——D-SE40)+ 超期回收批清运)。
122
+ * 目录缺失 / 不可读 ⇒ 两面各自降级(零副作用)。 */
123
+ async function gcPass(dir, prefix) {
124
+ let entries = null
125
+ try { entries = await readdir(dir) } catch { /* 降级:两面各自再试或不做事 */ }
126
+ await gcResidue({ dir, prefix, entries }).catch(() => { /* 清理失败静默——不影响主流程 */ })
127
+ await sweepStale({ dir, entries }).catch(() => { /* 同上 */ })
128
+ }
129
+
130
+ /** 启动窗外延迟拍的延迟(D-SE39):自调度点起 3s——pass 起点落于启动窗(TTY 门 ≤2s)之外。 */
131
+ export const GC_PASS_DELAY_MS = 3000
132
+ let gcPassDelayMs = GC_PASS_DELAY_MS
133
+
134
+ /** 测试缝(`_setSessionsDirForTest` 同款):用例置 0–短值即可点火(勿真等 3s);
135
+ * 还原 = `_setSessionGcDelayForTest(GC_PASS_DELAY_MS)`。 */
136
+ export function _setSessionGcDelayForTest(ms) { gcPassDelayMs = ms }
137
+
109
138
  const scheduledPrefixes = new Set()
110
139
 
111
- /** 启动钩子(§6.12 review #8——N4):GC 延后到进程启动完成后空闲执行(setImmediate),
112
- * 不阻塞启动路径;每进程每前缀一次(Set 去重——resumeSlot 可多次进入)。 */
140
+ /** 启动钩子(§6.12 review #8——N4;触发形态 = **启动窗外延迟拍**,§6.17 D-SE39):
141
+ * GC 延后 `GC_PASS_DELAY_MS`(3s——自调度点起)点火 ⇒ 启动链不因 pass 竞争劣化;
142
+ * **异步非阻塞**;每进程每前缀一次(Set 去重——resumeSlot 可多次进入);**不 unref**
143
+ * (保后台排空现状)。
144
+ * F-SL1:pass 全链异步(D-SE34——零同步扫描,判据 = 启动路径同步 fs 阻塞 ≤50ms)。 */
113
145
  export function scheduleSessionGC(cwd) {
114
146
  let base
115
147
  try { base = sessionPath(cwd) } catch { return }
116
148
  if (scheduledPrefixes.has(base)) return
117
149
  scheduledPrefixes.add(base)
118
- setImmediate(() => {
119
- gcResidue({ dir: dirname(base), prefix: basename(base) }).catch(() => { /* 清理失败静默——不影响主流程 */ })
120
- })
150
+ setTimeout(() => { gcPass(dirname(base), basename(base)).catch(() => { /* 静默 */ }) }, gcPassDelayMs)
121
151
  }
122
152
 
123
153
  /**
@@ -127,20 +157,24 @@ export function scheduleSessionGC(cwd) {
127
157
  * 含裸 v1 `{hash}.json`,§6.12 D-V1——dataFiles 仅 .json.N,v1 不参与冷态判定)。
128
158
  * 探测 = **全部候选前缀一次异步束**(两遍:先筛候选并收 pid,再一次判活 + 三态);
129
159
  * **未知 ⇒ 非冷(保留)**——探测失败不得判冷(D-MI10 同向)。
160
+ * `hashes` = 单组重校验(显式面 `--confirm <hash>`);`entries` = 共享快照。
130
161
  */
131
- export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync } = {}) {
132
- let entries
133
- try { entries = readdirSync(dir) } catch { return [] }
162
+ export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync, hashes = null, entries = null } = {}) {
163
+ let names = entries
164
+ if (!names) {
165
+ try { names = await readdir(dir) } catch { return [] }
166
+ }
134
167
  const cands = []
135
- for (const name of entries) {
168
+ for (const name of names) {
136
169
  if (!name.endsWith(".json.manifest")) continue // end marker(.manifest.cli/.vscode)与 .manifest.corrupted 天然排除
137
170
  const prefix = name.slice(0, -".manifest".length) // `${hash}.json`
171
+ if (hashes && !hashes.includes(prefix.slice(0, -".json".length))) continue
138
172
  let st
139
- try { st = statSync(join(dir, name)) } catch { continue }
173
+ try { st = await stat(join(dir, name)) } catch { continue }
140
174
  if (st.mtimeMs >= now - COLD_CWD_RETENTION_MS) continue // 近期活跃(<90 天阈值——T10)
141
175
  let m
142
- try { m = JSON.parse(readFileSync(join(dir, name), "utf8")) } catch { continue } // 损坏 manifest 不判冷(.corrupted 归残留 GC)
143
- const dataFiles = entries.filter((e) => e.startsWith(prefix + ".") && /^\d+$/.test(e.slice(prefix.length + 1)))
176
+ try { m = JSON.parse(await readFile(join(dir, name), "utf8")) } catch { continue } // 损坏 manifest 不判冷(.corrupted 归残留 GC)
177
+ const dataFiles = names.filter((e) => e.startsWith(prefix + ".") && /^\d+$/.test(e.slice(prefix.length + 1)))
144
178
  const owners = dataFiles.map((e) => {
145
179
  const slotName = e.slice(prefix.length + 1)
146
180
  const pid = ownerPid(m.slotSessions?.[slotName] ?? "")
@@ -161,43 +195,39 @@ export async function listColdCwds({ dir = sessionsDir(), now = Date.now(), prob
161
195
  prefix: c.prefix,
162
196
  manifestMtime: c.manifestMtime,
163
197
  dataFiles: c.dataFilesLen,
164
- files: entries.filter((e) => e.startsWith(c.prefix + ".") || e === c.prefix), // 整前缀清空含裸 v1 {hash}.json(§6.12 D-V1)
198
+ files: names.filter((e) => e.startsWith(c.prefix + ".") || e === c.prefix), // 整前缀清空含裸 v1 {hash}.json(§6.12 D-V1)
165
199
  })
166
200
  }
167
201
  return cold
168
202
  }
169
203
 
170
204
  /**
171
- * 删除指定冷 cwd 的整个前缀(§6.12 步骤 3——manifest+end marker+.json.N 死主数据+
172
- * v1 {hash}.json(§6.12 D-V1)+残留;只删 manifest 留数据文件会制造孤儿数据,整前缀清空才真正释放)。
173
- * 删除前重跑冷 cwd 判定(TOCTOU 防护,T12——期间变活跃则拒绝)。
205
+ * 回收指定可清组(§6.12 步骤 3 + §6.17 D-SE36):冷 cwd 面(90 天)优先——**2026-09-21 起经
206
+ * 回收目录**(逐文件 rename `sessions-trash/<批次时间戳>/`;不可逆删除最早 = 最后写入 + 14 天,
207
+ * 恢复 = 移回原目录);非冷 ⇒ 三合取存量组面(同判据单组重校验)。两面前均重校验(TOCTOU——
208
+ * 期间变活 / 出窗 ⇒ 拒绝 `{ok:false, reason:"not-cold"}`,零删除)。`entries` = 目录名快照
209
+ *(可选——命令面循环传同一快照免逐组全目录 readdir;① 面 manifest 读与探测束仍逐组新鲜)。
174
210
  */
175
- export async function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync } = {}) {
176
- const target = (await listColdCwds({ dir, now, probeFn })).find((c) => c.hash === hash)
177
- if (!target) return { ok: false, reason: "not-cold", deleted: [] }
178
- const deleted = []
179
- for (const name of target.files) {
180
- const p = join(dir, name)
181
- try {
182
- // §6.14(TUI-OOM-ROOTCAUSE):目录项(记录存储 sidecar `{prefix}.N.d`)递归删——
183
- // 现 unlinkSync 对目录静默跳过(旧实现漏删 sidecar);数据文件维持 unlink。
184
- if (statSync(p).isDirectory()) rmSync(p, { recursive: true, force: true })
185
- else unlinkSync(p)
186
- deleted.push(name)
187
- } catch { /* 占用/竞态——跳过 */ }
211
+ export async function deleteColdCwd(hash, { dir = sessionsDir(), now = Date.now(), probeFn = probeOwnersAsync, entries = null } = {}) {
212
+ const target = (await listColdCwds({ dir, now, probeFn, hashes: [hash], entries })).find((c) => c.hash === hash)
213
+ if (target) {
214
+ const r = await recycleGroup(target, { dir, now })
215
+ if (!r.moved.length) return { ok: false, reason: "recycle-failed", deleted: [], skipped: r.skipped }
216
+ return { ok: true, reason: "cold-90d", deleted: r.moved, skipped: r.skipped, batch: r.batch }
188
217
  }
189
- return { ok: true, deleted }
218
+ return deleteStaleCwd(hash, { dir, now, probeFn, entries }) // 三合取面(含 TOCTOU 重校验;不中 ⇒ not-cold)
190
219
  }
191
220
 
192
221
  /**
193
- * ④ 端差段 · 手动执行面(§6.12)——仅命令行壳提供(另一形态无 shell 子命令通道);
222
+ * ④ 端差段 · 显式执行面(§6.12 / §6.17 D-SE38)——命令行壳 `thincoder session gc` 与
223
+ * 端侧命令(`thincoder.sessionGc`——经数据面 API,不消费本函数)共同的数据面;
194
224
  * 本段**核内零消费方**(结构机检③):核内保存实现(取一侧),命令接线属壳侧(S2)。
195
225
  *
196
- * `thincoder session gc` 子命令分发(§6.12 手动面——F2 执行入口仅命令行壳,
197
- * 另一形态无 shell 子命令通道,review #7):
198
- * --dry-run 报告当前 cwd 残留候选 + cwd 冷候选(只列不删,N2 预览)
199
- * --confirm <hash> 删除指定冷 cwd 整前缀(警告 + 文件清单 + TOCTOU 重校验)
200
- * --confirm --all 逐冷 cwd 同型警告删除
226
+ * 候选面(§6.17)= cwd(90 天冷判据——cwd 存活组唯一出口)∪ 三合取存量组(**全量面**——
227
+ * 显式面无 STALE_SWEEP_LIMIT 闸:② 面滞留的兜底)。删除 = 回收(可回退)。
228
+ * --dry-run 报告当前 cwd 残留候选 + 全部可清组候选(reason + 文件数;只列不删,N2 预览)
229
+ * --confirm <hash> 回收指定组整前缀(警告 + 文件清单 + TOCTOU 重校验)
230
+ * --confirm --all 逐候选同型回收
201
231
  * 返回进程退出码(0/1)。dir/prefix/now/out/err/probeFn 为测试注入缝(默认生产行为)。
202
232
  */
203
233
  export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, cwd = process.cwd(), now = Date.now(), out = console.log, err = console.error, probeFn = probeOwnersAsync } = {}) {
@@ -211,6 +241,16 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
211
241
  return 1
212
242
  }
213
243
 
244
+ // 单组面(`--confirm <hash>`):候选面收窄到该 hash(免全量 ② 评估);`--all` / `--dry-run` = 全量面
245
+ const hashFilter = dryRun || confirmTarget === "--all" ? null : [confirmTarget]
246
+ const cold = await listColdCwds({ dir, now, probeFn, hashes: hashFilter })
247
+ const stale = (await listStaleCwds({ dir, now, probeFn, limit: Infinity, hashes: hashFilter })).candidates
248
+ const coldHashes = new Set(cold.map((c) => c.hash)) // 冷面命中的组不重复列(并集——同一组两判据可达)
249
+ const candidates = [
250
+ ...cold.map((c) => ({ ...c, reason: "cold-90d" })),
251
+ ...stale.filter((c) => !coldHashes.has(c.hash)),
252
+ ]
253
+
214
254
  if (dryRun) {
215
255
  out("Session GC dry-run — no files will be deleted.")
216
256
  // 当前 cwd 残留(§6.12 删除面——自动 GC 的预览)
@@ -219,30 +259,34 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
219
259
  const residue = p ? await gcResidue({ dir, prefix: p, now, dryRun: true, probeFn }) : { candidates: [] }
220
260
  out(`Residue candidates for current project (${p ?? "unknown"}): ${residue.candidates.length}`)
221
261
  for (const name of residue.candidates) out(` ${name}`)
222
- }
223
-
224
- const cold = await listColdCwds({ dir, now, probeFn })
225
- if (dryRun) {
226
- out(`Cold project candidates (manifest idle > 90 days, no live data files): ${cold.length}`)
227
- for (const c of cold) {
228
- out(` ${c.hash} manifest mtime ${new Date(c.manifestMtime).toISOString()} data files ${c.dataFiles} total files ${c.files.length}`)
229
- }
230
- if (cold.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to permanently delete a cold project prefix.')
262
+ out(`Cold/stale project candidates (cold = manifest idle > 90 days; stale = no live owner + cwd unreachable/empty + 7-day window): ${candidates.length}`)
263
+ for (const c of candidates) out(` ${c.hash} reason ${c.reason} files ${c.files.length}`)
264
+ if (candidates.length) out('Run "thincoder session gc --confirm <hash>" (or --confirm --all) to move a project prefix into the recycle bin (recoverable: sessions-trash/<timestamp>/, 7 days).')
231
265
  return 0
232
266
  }
233
267
 
234
- // --confirm:删除前警告(N2 可逆——文件清单 + 永久删除提示);deleteColdCwd 内部重校验冷态(TOCTOU
235
- const targets = confirmTarget === "--all" ? cold : cold.filter((c) => c.hash === confirmTarget)
268
+ // --confirm:回收前警告(N2 可逆——文件清单 + 回收提示);deleteColdCwd 内部重校验(TOCTOU)。
269
+ // 一次性目录快照(逐组重校验复用——同自动面 pass 模型;① manifest 读与探测束仍逐组新鲜)。
270
+ let loopEntries = null
271
+ if (confirmTarget === "--all") {
272
+ try { loopEntries = await readdir(dir) } catch { loopEntries = null }
273
+ }
274
+ const targets = confirmTarget === "--all" ? candidates : candidates.filter((c) => c.hash === confirmTarget)
236
275
  if (!targets.length) {
237
- err(`Refused: ${confirmTarget} is not a cold project (active, recent, or unknown) — nothing deleted.`)
276
+ err(`Refused: ${confirmTarget} is not a cold/stale project (active, recent, or unknown) — nothing deleted.`)
238
277
  return 1
239
278
  }
240
279
  for (const t of targets) {
241
- out(`WARNING: 此操作永久删除该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files):`)
280
+ out(`WARNING: 此操作将回收该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files) → ${trashRootFor(dir)}:`)
242
281
  for (const name of t.files) out(` ${name}`)
243
- const r = await deleteColdCwd(t.hash, { dir, now, probeFn })
244
- if (!r.ok) { err(`Refused: ${t.hash} is no longer cold (became active) — skipped.`) ; continue }
245
- out(`Deleted ${r.deleted.length} files for ${t.hash}.`)
282
+ const r = await deleteColdCwd(t.hash, { dir, now, probeFn, entries: loopEntries })
283
+ if (!r.ok) {
284
+ err(r.reason === "recycle-failed"
285
+ ? `Recycle failed for ${t.hash} — skipped (files kept in place).`
286
+ : `Refused: ${t.hash} is no longer cold (became active) — skipped.`)
287
+ continue
288
+ }
289
+ out(`Moved ${r.deleted.length} files for ${t.hash} into the recycle bin (${r.batch})${r.skipped?.length ? ` — skipped ${r.skipped.length} (locked/racing, kept in place)` : ""}.`)
246
290
  }
247
291
  return 0
248
292
  }
@@ -29,11 +29,12 @@ import { loadSlotFile } from "./session.mjs"
29
29
  import { mergeAdjacentAssistantEchoes } from "./context.mjs"
30
30
  import { scheduleSessionGC } from "./session-gc.mjs"
31
31
  import { restoreEngTokens } from "./token-ttl.mjs"
32
+ import { clearPlanMode } from "./agent-tools/plan.mjs"
32
33
  import { bindRecordStore, unbindRecordStore, RECORD_WINDOW_MESSAGES } from "./session-store.mjs"
33
34
 
34
35
  /** 恢复决策包装(SESSION.md §6.12 启动钩子,2026-09-06):本端恢复入口触发一次残留 GC——
35
- * scheduleSessionGC 内部 setImmediate 空闲执行 + 每进程每前缀去重,不阻塞启动路径(N4)。
36
- * **async**(F-MI7——整链异步:探测不阻塞事件循环;调用面必须 await)。 */
36
+ * scheduleSessionGC 内部**启动窗外延迟拍**(`GC_PASS_DELAY_MS` = 3s)+ 每进程每前缀去重,
37
+ * **异步非阻塞**启动路径(N4;§6.17 D-SE39);**async**(F-MI7——调用面必须 await)。 */
37
38
  export async function resumeSlot(cwd) {
38
39
  scheduleSessionGC(cwd)
39
40
  return slotsResumeSlot(cwd)
@@ -112,6 +113,10 @@ export function applySession(agent, data, opts = {}) {
112
113
  agent.config.agent ??= {}
113
114
  agent.config.agent.engineering = data.engineering === true
114
115
  }
116
+ // ENG-PLAN-EXCLUSION(FR31 ③ / KD10——恢复点①·CLI `applySession`):工程模式 ⇒ planMode 恒
117
+ // false(内存位 + 未注入的 plan 提示语)——防「工程纪律 × plan 只读」半状态随槽恢复复活。
118
+ // **槽不就地回写**:内存值在下一次 `saveSession` 随 `planMode` 字段自然收正(`session.mjs:128`)。
119
+ if (agent.config?.agent?.engineering === true) clearPlanMode(agent)
115
120
  if (data.advisor) {
116
121
  agent.config.advisor = { ...data.advisor }
117
122
  }
@@ -166,8 +171,11 @@ export function applySession(agent, data, opts = {}) {
166
171
  * write an empty session, and mark it as the active slot.
167
172
  * No limit on the number of sessions.
168
173
  * **async**(F-MI7:入口一次异步束——探测不阻塞事件循环;调用面必须 await)。
174
+ * `opts.releaseStale`(F-CR1 · SESSION.md §6.5 / §6.16):**调用面 opt-in**——CLI `/new` 传
175
+ * (`cmd-new.mjs`),释放本进程残留认领(保留集 = {新槽});ACP 四点不传(多会话多认领属其
176
+ * 设计——F-CR3 零回归)。
169
177
  */
170
- export async function newSession(cwd) {
178
+ export async function newSession(cwd, opts = {}) {
171
179
  const m = loadManifest(cwd)
172
180
 
173
181
  // 2026-09-01 advisor 🔵:先清理死主条目(与 ensureActive 分支2 语义一致)——否则
@@ -223,7 +231,9 @@ export async function newSession(cwd) {
223
231
  slots: deadSlots.filter((s) => !m.slots[s]),
224
232
  }
225
233
  : null
226
- saveManifest(cwd, m, deletions, { setActive: true })
234
+ // F-CR1 认领释放(SESSION.md §6.5 / §6.16 公式代入):保留集 = {新槽}——与既有死主
235
+ // deletions 合流(同一一次 saveManifest;落盘判据见核 `saveManifest`)。
236
+ saveManifest(cwd, m, deletions, opts.releaseStale ? { setActive: true, release: [slot] } : { setActive: true })
227
237
  // 2026-09-05 §6.10 D-4:/new 落点写本端记录(显式切换跟随——T-M8)
228
238
  writeEndMarker(cwd, slot)
229
239
  return slot
@@ -280,7 +290,10 @@ export function switchToSlot(cwd, slot) {
280
290
  m.slotSessions ??= {}
281
291
  m.slotSessions[slot] = getSessionId()
282
292
  }
283
- saveManifest(cwd, m, null, { setActive: true })
293
+ // F-CR1 认领释放(SESSION.md §6.5 / §6.16 公式代入):保留集 = 未占 ⇒ {目标};**被占 ⇒ 空**
294
+ // (不认领目标 + 旧认领一并释放——下次保存经 `allocateFresh` fork,既有语义)。核受占 =
295
+ // 切换成立(D-SE33:指针 / 记录按 D-6 / D-4 落点——fork 面依赖指针翻至目标槽)。
296
+ saveManifest(cwd, m, null, { setActive: true, release: occ.occupied ? [] : [slot] })
284
297
  // 2026-09-05 §6.10 D-4:/session N 跟随"最后查看的槽"(成功切换才写)
285
298
  writeEndMarker(cwd, slot)
286
299
  return data
@@ -4,7 +4,8 @@
4
4
  * `CORE-UNIFICATION.md` §2.8.1 表第 3 行随批执行外提;各函数语义**原样**迁移):
5
5
  * 摘要面(`extractSlotMeta` / `slotDigest`)· 清单读写(`loadManifest` / `saveManifest`)·
6
6
  * 属主面(`ownerPid` / `ownerPids` / `ownerStateOf` / `ownerArgs` / `cleanDeadOwners`)·
7
- * 认领面(`ensureActive` / `allocateFresh` / `claimSlot` / `activeSlot`)。
7
+ * 认领面(`ensureActive` / `allocateFresh` / `claimSlot` / `activeSlot` / **认领释放集
8
+ * `staleClaims`**——F-CR1 · SESSION-CLAIM 批:判据单源,`saveManifest` 落盘 + 各落点共用)。
8
9
  *
9
10
  * 静态环(与既有的 session.mjs ↔ session-slots.mjs 同形):本档引 `session-slots.mjs` 的
10
11
  * 存储原语(`getSessionId` / `manifestPath` / `slotPath` / `writeSessionFile`),
@@ -69,6 +70,10 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
69
70
  // (ensureActive 分支、newSession、switchToSlot、deleteSlot 删到 active 时)传
70
71
  // opts.setActive;其余调用方(saveSession/ACP 认领/死项清理)默认保留磁盘 fresh 的
71
72
  // active,否则毫秒窗口内会把并发方刚翻的 active 回滚(F1 防漂移的反向变体)。
73
+ // F-CR1 认领释放(判据单源 · SESSION.md §6.2 / §6.16 落盘判据三条):`opts.release` = 保留集
74
+ // (落点槽 ∪ 本进程其余活绑定槽;被占落点 ⇒ 空集)。③ 内存认领表先移除残留条目(否则下方
75
+ // 条目级合并会把它从内存复活回写)——先于 try(fresh 不可读时亦须生效)。
76
+ if (opts.release) dropStaleClaims(m, opts.release)
72
77
  try {
73
78
  const fresh = JSON.parse(readFileSync(manifestPath(cwd), "utf8"))
74
79
  if (fresh && typeof fresh === "object" && fresh.slots && typeof fresh.slots === "object") {
@@ -77,6 +82,14 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
77
82
  merged.slots = { ...fresh.slots, ...(m.slots ?? {}) }
78
83
  merged.slotSessions = { ...(fresh.slotSessions ?? {}), ...(m.slotSessions ?? {}) }
79
84
  if (m.sessionId) merged.sessionId = m.sessionId
85
+ // ① 释放集按**本次 fresh 快照**取值(不得以陈旧内存 manifest 构 deletions);② **值条件删除**
86
+ // (仅当 fresh 属主仍为本进程——窗口内他人的新认领不在集内、不被误删)⇒ 并入本次 deletions。
87
+ if (opts.release) {
88
+ const released = staleClaims(fresh, opts.release)
89
+ if (released.length > 0) {
90
+ deletions = { ...(deletions ?? {}), slotSessions: [...(deletions?.slotSessions ?? []), ...released] }
91
+ }
92
+ }
80
93
  if (deletions) {
81
94
  for (const [section, keys] of Object.entries(deletions)) {
82
95
  for (const k of keys) delete merged[section]?.[k]
@@ -95,6 +108,23 @@ export function saveManifest(cwd, m, deletions = null, opts = {}) {
95
108
  writeSessionFile(manifestPath(cwd), m)
96
109
  }
97
110
 
111
+ /** 本进程**残留认领集**(F-CR1 释放谓词 —— **判据单源** · SESSION.md §6.2 / §6.16):
112
+ * 「释放 A ⟺ `slotSessions[A]` 为本进程 ∧ `A ∉ 保留集`」。`keep` = 保留集 = 落点槽 ∪ 本进程
113
+ * 其余活绑定槽(单绑定落点 ⇒ `{落点槽}`;被占落点 ⇒ 空集)。返回槽号(**字符串键**形态)。 */
114
+ export function staleClaims(src, keep = []) {
115
+ const keepSet = new Set((keep ?? []).filter((s) => s != null).map(Number))
116
+ const mySessionId = getSessionId()
117
+ return Object.entries(src?.slotSessions ?? {})
118
+ .filter(([slot, owner]) => owner === mySessionId && !keepSet.has(Number(slot)))
119
+ .map(([slot]) => slot)
120
+ }
121
+
122
+ /** 从**内存**认领表移除本进程残留条目(判据③——与写盘同一时机;见 `saveManifest`)。 */
123
+ function dropStaleClaims(m, keep) {
124
+ if (!m?.slotSessions) return
125
+ for (const slot of staleClaims(m, keep)) delete m.slotSessions[slot]
126
+ }
127
+
98
128
  /** 属主串 → pid(`"pid-ts-rand"`——`owner.split("-")[0]` 格式判据单源:session-slots ↔
99
129
  * session-lifecycle 共用,防两处漂移)。不可解析 ⇒ NaN——判据层按 dead 处理
100
130
  * (见 process-probe `ownerState`)。 */
@@ -248,12 +278,15 @@ export function allocateFresh(cwd, m, deadParam = null, bundle = null) {
248
278
  * 已按"属主 空/死/本进程"判据校验可用性;幂等)。manifest active 保留为共享指针(D-6:
249
279
  * 旧版端/ACP 恢复依据 + 无记录端一次性继承源 + 列表回退高亮——不再作本端恢复第一依据)。
250
280
  * deadParam 在写入所有权之后求值(防 deletions 删掉本调用刚认领的槽——ensureActive
251
- * deadParam 同型过滤)。 */
281
+ * deadParam 同型过滤)。
282
+ * F-CR1(2026-09-21 · SESSION-CLAIM 批):认领落点释放本进程残留认领(保留集 = {认领槽}——
283
+ * §6.2 公式代入);本函数 = 启动恢复 / 本端恢复落点(核 `resumeSlot` 与 VSC 端壳
284
+ * `resumeSlot` 共用),落盘判据(fresh 快照 / 值条件删除 / 内存移除)见 `saveManifest`。 */
252
285
  export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
253
286
  m.slotSessions ??= {}
254
287
  m.slotSessions[slot] = getSessionId()
255
288
  m.active = slot // 认领即翻共享指针(setActive 写 m.active——不更新则落快照旧值)
256
- saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true })
289
+ saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true, release: [slot] })
257
290
  }
258
291
 
259
292
  /** Return the active slot number for this process, claiming one if necessary */