@thincoder/core 0.9.1 → 0.9.3

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/advisor/loop.mjs +2 -2
  5. package/advisor/run.mjs +1 -1
  6. package/agent/completion.mjs +3 -1
  7. package/agent/family-tools.mjs +24 -11
  8. package/agent/helpers.mjs +11 -2
  9. package/agent/run-stages.mjs +27 -5
  10. package/agent/setup-reminders.mjs +67 -11
  11. package/agent/setup.mjs +6 -0
  12. package/agent/write-gate.mjs +5 -5
  13. package/agent-tools/advisor-async.mjs +4 -4
  14. package/agent-tools/advisor.mjs +3 -3
  15. package/agent-tools/async-discard.mjs +1 -1
  16. package/agent-tools/audit-block.mjs +106 -0
  17. package/agent-tools/batch-lifecycle.mjs +301 -0
  18. package/agent-tools/batch-segment.mjs +16 -263
  19. package/agent-tools/batch-skeleton.mjs +156 -0
  20. package/agent-tools/batch.mjs +410 -0
  21. package/agent-tools/context.mjs +174 -0
  22. package/agent-tools/eng.mjs +4 -0
  23. package/agent-tools/goal.mjs +7 -0
  24. package/agent-tools/parent-channel.mjs +18 -1
  25. package/agent-tools/plan.mjs +39 -5
  26. package/agent-tools/read-history.mjs +122 -24
  27. package/agent-tools/settings.mjs +4 -2
  28. package/agent-tools/subagent-async.mjs +3 -3
  29. package/agent-tools/subagent-spawn.mjs +29 -101
  30. package/agent-tools/task.mjs +11 -0
  31. package/agent-tools.mjs +9 -2
  32. package/agent.mjs +10 -4
  33. package/config.mjs +1 -1
  34. package/context.mjs +66 -121
  35. package/fts-text.mjs +41 -0
  36. package/generate-title.mjs +6 -6
  37. package/i18n.mjs +4 -4
  38. package/ledger-cmd.mjs +30 -7
  39. package/ledger-db.mjs +22 -2
  40. package/ledger-executors.mjs +103 -0
  41. package/ledger-surface.mjs +15 -8
  42. package/ledger.mjs +15 -4
  43. package/manifest.mjs +172 -60
  44. package/memory/core.mjs +4 -18
  45. package/memory/schema.mjs +4 -11
  46. package/package.json +22 -1
  47. package/prompts/advisor-design.md +1 -1
  48. package/prompts/advisor-round2.md +1 -1
  49. package/prompts/advisor-round3.md +1 -1
  50. package/prompts/common.md +3 -3
  51. package/prompts/discipline-engineering.md +17 -2
  52. package/prompts/persona-eng-coder.md +1 -1
  53. package/prompts/persona-eng-designer.md +1 -1
  54. package/prompts/persona-engineering.md +9 -6
  55. package/session-gc.mjs +129 -74
  56. package/session-index-build.mjs +298 -0
  57. package/session-index-cmd.mjs +61 -0
  58. package/session-index-pass.mjs +95 -0
  59. package/session-index-query.mjs +102 -0
  60. package/session-index.mjs +285 -0
  61. package/session-lifecycle.mjs +18 -5
  62. package/session-slots-manifest.mjs +55 -3
  63. package/session-stale.mjs +247 -0
  64. package/token-window.mjs +188 -0
  65. package/tools/bash.mjs +4 -15
  66. package/tools/execute.mjs +5 -13
  67. package/tools/git-checkpoint.mjs +1 -1
  68. package/tools/git-ext.mjs +23 -20
  69. package/tools/git-run.mjs +141 -0
  70. package/tools/git.mjs +56 -45
  71. package/tools/index.mjs +3 -1
  72. package/tools/process-tree.mjs +20 -0
  73. package/tools/shared.mjs +8 -4
  74. package/traces/trace-cleanup.mjs +109 -0
  75. package/traces/trace-store.mjs +32 -36
@@ -3,7 +3,7 @@
3
3
  ## 🔴 Iron laws (top — highest-frequency hard constraints; violating them means rework)
4
4
  1. **Every dev task walks the four steps, no skipping**: Requirements → Design → Development → Testing. Three steps write docs (requirements/design/test) — jumping straight to code is wrong nine times out of ten.
5
5
  2. **Hit a wrong structure — fix it, don't defer it**: when a change collides with a wrong code-structure/state-ownership, fix it on the spot; never stack minimal patches to mask the symptom; a wrong structure touched by the current change must be fixed now.
6
- 3. **Work is tracked by task lists**: after requirements are confirmed, build task entries one per requirement (`task` session-level + persistent entries in requirement docs / ledger); no entry = the requirement hasn't landed.
6
+ 3. **Work is tracked by the batch record + the ledger**: after requirements are confirmed, build task entries one per requirement (entries land in batch record §2 + ledger rows); no entry = the requirement hasn't landed. (The `task` tool is mechanically disabled in engineering mode — the tracking authority is the batch record + the ledger.)
7
7
  4. **Zero discretion**: task size is not yours to judge — in this mode EVERY user request walks the full mandatory process, regardless of size.
8
8
  "The task is too small / just a quick fix" is never a reason to skip or compress steps; no change is exempt from landing in a design doc. If you find yourself weighing "does the process apply?", the answer is always the full process — the user already did the size judgment the moment they picked engineering mode.
9
9
 
@@ -32,7 +32,7 @@ Judge the **change face** before acting — different faces, different authoriza
32
32
  - **Acceptance** — each acceptance criterion machine-checkable;
33
33
  - **Dependencies** — upstream/downstream dependencies.
34
34
 
35
- Requirements done-criterion: all five elements present, concrete enough to design from (user confirmed, or answers no longer change the requirements). After confirmation, build task entries one per requirement — the task list is the marker that requirements were accepted.
35
+ Requirements done-criterion: all five elements present, concrete enough to design from (user confirmed, or answers no longer change the requirements). After confirmation, build task entries one per requirement — **the batch record §2 entry table + ledger rows** are the marker that requirements were accepted.
36
36
  2. **Design** — the approach, architecture, how to implement, landed in a design doc: problem statement, approach & rationale, full affected-file list, verifiable acceptance criteria (each pointing back to a user story). Design settles before you start.
37
37
  - Design = a check on requirements — wherever the design can't be written, the requirements weren't clear (ask back, don't invent).
38
38
  - **Requirement-gap stop chain**: exploration finds requirements that don't hold up / conflict with implementation / unclear ownership → **stop and bounce back to the main agent**; never pick one interpretation yourself and keep writing.
@@ -92,6 +92,21 @@ The report must contain: what changed / why, the paths of files touched, how you
92
92
  8. **Out-of-repo changes = stop and report**: when this round genuinely needs to touch out-of-repo files, **stop and report** (what / why),
93
93
  and the main agent handles it **in a separate round** — never write outside this repo in this round.
94
94
 
95
+ ### Multi-implementation-face discipline
96
+
97
+ When one mechanism lands on several implementation faces (multiple ends / languages / platforms / same-source mirror docs):
98
+
99
+ 1. **Each face implements independently, semantics from one source** — each face's own text is authoritative on that face; no byte-identical requirement, no cross-face sync dependency; consistency is guarded by the shared-source design + each face's own semantic anchors.
100
+ 2. **No cross-face rewrite from a face's artifacts (alignment goes through the shared design)** — never rewrite another face from any one face's actual artifacts; **this clause describes implementation form only, and is not grounds for keeping a difference** (cross-face difference disposition = clause 6).
101
+ 3. **Differences are reported as found** — a defect in the shared-source design discovered while landing ⇒ stop and report (design-doc fix + re-review); never deviate silently.
102
+ 4. **Face-specific sections stay on their own face** — a content section unique to one implementation face stays there, not merged into another face's layout; **a face-specific section is non-mechanism content; this clause is not grounds for keeping a mechanism-face difference** (mechanism-face handling = clause 6).
103
+ 5. **Verification duty for many-faces-one-mechanism design sets** — when one mechanism is designed across several faces, each face's design is written as its own document;
104
+ **the main agent MUST verify the pieces agree** (four axes = same rulings / same criteria / same-shaped boundaries / differences explicitly registered; a silent difference = drift);
105
+ the check runs once every face's design is on disk, inside the pre-review self-check; report its conclusion plus the difference table together with the "design ready for review" message.
106
+ 6. **Cross-face difference disposition (default and exception)** — **default = eliminate**: a mechanism-face difference ⇒ collapse to one authoritative implementation / align every face to one criterion;
107
+ **keeping one requires all three — structural asymmetry + evidence + an explicit ruling** (structural asymmetry = exists on one side only / depends on a host capability that side alone has); **this discipline is not grounds for keeping a difference**;
108
+ the difference register records **ruled keeps only** — it is not a fallback for undecided differences.
109
+
95
110
  ### Rules & exceptions (precedent is not grounds for exception)
96
111
  1. **The only grounds for an exception is a judgment line**: "it was always like this / already landed in this form / other batches' precedent / existing inventory" is never grounds to deviate from a rule —
97
112
  an exception can only be granted by a **machine-checkable judgment line**; no judgment line found → **follow the rule, or stop and report** — never pass on precedent.
@@ -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).
@@ -62,7 +65,7 @@ Engineering mode **must have it first** — **one per git repository**, located
62
65
  - **Parent does not ghost-write**: you do NOT write the design doc — anything needing change goes **via a fix round** to eng-designer; three exception categories: ① your own write domain (batch record §1/§4/§6 · ledger · requirement docs) ② purely mechanical form corrections (line folding / pointer form / counts) ③ **small edits** (single-line / table-level · no new semantics · verifiable one by one).
63
66
  **All three must be marked** ("parent direct execution" + revertable). **Judgment lines**: you content-writing on a dispatched surface without a fix round ⇒ violation; mechanical form correction unmarked ⇒ violation.
64
67
  - **Close three states (no "promises")**: each round's close allows **only three states** — ① **Do** (the action **was fired THIS round**: tool call / edit landed — the report only describes **what happened this round**); ② **Wait** (real dependency: waiting for the user's nod / a subagent's return — **must state what you're waiting for**); ③ **Stop** (anomaly / pending judgment — **state the stop point**). **The fourth state "promise" is forbidden**: writing "right away / next stroke / immediately / I will / up next" + an action WITHOUT firing that action in the same round ⇒ **treated as "not done"** — that wording must not be used: either do it in the same round, or rewrite it as "Wait".
65
- - **Debts go on the list**: undispatched / unfinished items of your own ⇒ **immediately written into the task list** (or batch record §6 unresolved) — debts **must be visible**, never living only in report prose waiting for the user to chase.
68
+ - **Debts go on the list**: undispatched / unfinished items of your own ⇒ **immediately written into batch record §6 unresolved (or a ledger row)** — debts **must be visible**, never living only in report prose waiting for the user to chase.
66
69
  - **Drain first (auto mode)**: while subagents are in flight, **clear your own queue in parallel** (verification / closure / settlement / mechanical corrections) — "waiting" **only holds for real dependencies**; parking doable work on "waiting" ⇒ violation.
67
70
  - **Implementation-round role routing (judge the change face first)**: before dispatching an implementation round, **judge the change face first** — **product-code face** (source/test dirs — per project declaration) → **eng-coder**; **doc face** (`docs/**` requirement/design docs) → **eng-designer**; **engineering-tools face** (`scripts/**` · CI) → **parent direct edit** (no spawn). Judgment lines: dispatching the doc face to eng-coder = violation (sole author of design/requirement docs is eng-designer); a dispatch that reflex-maps "design passed → implementation" to eng-coder without judging the face = violation.
68
71
  - **Round field**: dispatches **must carry「round」** — **initial round** = blank start, breadth exploration allowed; **fix round** = target pinned (finding-number list), **point fixes only** (number → change → read back), **no full exploration** (small fixes back to minute-level). **Dispatches pin coordinates (file:line), forbid "sweep everything X"** — never let a subagent explore what you already know.
@@ -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,45 +195,47 @@ 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 为测试注入缝(默认生产行为)。
232
+ *
233
+ * #178(hygiene-sweep 批):显式面进度 / 预估——**只增显示行,零触判据与删除集**(口径 = 台账
234
+ * #178 真机实测 ≈6ms/候选(6,887 候选 ≈41.6s),仅用于预估显示)。
202
235
  */
236
+ const GC_MS_PER_CANDIDATE = 6
237
+ const fmtGcEstimate = (n) => `~${Math.max(1, Math.round((n * GC_MS_PER_CANDIDATE) / 1000))}s`
238
+ const GC_PROGRESS_MIN = 2 // 候选数 ≥ 本值 ⇒ 出预估 / 逐组进度行(单组面输出逐字零变)
203
239
  export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, cwd = process.cwd(), now = Date.now(), out = console.log, err = console.error, probeFn = probeOwnersAsync } = {}) {
204
240
  const dryRun = args.includes("--dry-run")
205
241
  const confirmIdx = args.indexOf("--confirm")
@@ -211,6 +247,16 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
211
247
  return 1
212
248
  }
213
249
 
250
+ // 单组面(`--confirm <hash>`):候选面收窄到该 hash(免全量 ② 评估);`--all` / `--dry-run` = 全量面
251
+ const hashFilter = dryRun || confirmTarget === "--all" ? null : [confirmTarget]
252
+ const cold = await listColdCwds({ dir, now, probeFn, hashes: hashFilter })
253
+ const stale = (await listStaleCwds({ dir, now, probeFn, limit: Infinity, hashes: hashFilter })).candidates
254
+ const coldHashes = new Set(cold.map((c) => c.hash)) // 冷面命中的组不重复列(并集——同一组两判据可达)
255
+ const candidates = [
256
+ ...cold.map((c) => ({ ...c, reason: "cold-90d" })),
257
+ ...stale.filter((c) => !coldHashes.has(c.hash)),
258
+ ]
259
+
214
260
  if (dryRun) {
215
261
  out("Session GC dry-run — no files will be deleted.")
216
262
  // 当前 cwd 残留(§6.12 删除面——自动 GC 的预览)
@@ -219,30 +265,39 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
219
265
  const residue = p ? await gcResidue({ dir, prefix: p, now, dryRun: true, probeFn }) : { candidates: [] }
220
266
  out(`Residue candidates for current project (${p ?? "unknown"}): ${residue.candidates.length}`)
221
267
  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.')
268
+ out(`Cold/stale project candidates (cold = manifest idle > 90 days; stale = no live owner + cwd unreachable/empty + 7-day window): ${candidates.length}`)
269
+ for (const c of candidates) out(` ${c.hash} reason ${c.reason} files ${c.files.length}`)
270
+ if (candidates.length >= GC_PROGRESS_MIN) out(`Estimate: ${fmtGcEstimate(candidates.length)} to scan ${candidates.length} candidates (measured ≈${GC_MS_PER_CANDIDATE}ms/candidate).`)
271
+ 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
272
  return 0
232
273
  }
233
274
 
234
- // --confirm:删除前警告(N2 可逆——文件清单 + 永久删除提示);deleteColdCwd 内部重校验冷态(TOCTOU
235
- const targets = confirmTarget === "--all" ? cold : cold.filter((c) => c.hash === confirmTarget)
275
+ // --confirm:回收前警告(N2 可逆——文件清单 + 回收提示);deleteColdCwd 内部重校验(TOCTOU)。
276
+ // 一次性目录快照(逐组重校验复用——同自动面 pass 模型;① manifest 读与探测束仍逐组新鲜)。
277
+ let loopEntries = null
278
+ if (confirmTarget === "--all") {
279
+ try { loopEntries = await readdir(dir) } catch { loopEntries = null }
280
+ }
281
+ const targets = confirmTarget === "--all" ? candidates : candidates.filter((c) => c.hash === confirmTarget)
236
282
  if (!targets.length) {
237
- err(`Refused: ${confirmTarget} is not a cold project (active, recent, or unknown) — nothing deleted.`)
283
+ err(`Refused: ${confirmTarget} is not a cold/stale project (active, recent, or unknown) — nothing deleted.`)
238
284
  return 1
239
285
  }
286
+ let i = 0
287
+ if (targets.length >= GC_PROGRESS_MIN) out(`${targets.length} groups to recycle — estimate ${fmtGcEstimate(targets.length)} (progress per group below).`)
240
288
  for (const t of targets) {
241
- out(`WARNING: 此操作永久删除该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files):`)
289
+ i++
290
+ if (targets.length >= GC_PROGRESS_MIN) out(`[${i}/${targets.length}] ${t.hash} — ${t.files.length} files`)
291
+ out(`WARNING: 此操作将回收该 cwd 的全部会话历史 (hash ${t.hash}, ${t.files.length} files) → ${trashRootFor(dir)}:`)
242
292
  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}.`)
293
+ const r = await deleteColdCwd(t.hash, { dir, now, probeFn, entries: loopEntries })
294
+ if (!r.ok) {
295
+ err(r.reason === "recycle-failed"
296
+ ? `Recycle failed for ${t.hash} — skipped (files kept in place).`
297
+ : `Refused: ${t.hash} is no longer cold (became active) — skipped.`)
298
+ continue
299
+ }
300
+ 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
301
  }
247
302
  return 0
248
303
  }