# Learning Curator 3.1 — 規格 vs 實作審查報告

日期：2026-05-21（PR-A 修正 2026-05-22） · 分支：`feat/learning-curator-pivot` · 審查範圍：Layer 0-8 規格 vs 實作 · 修正分支：`fix/learning-orphan-eval-test` (PR #46) · Spec realignment：`docs/learning-curator-pr-a-spec-realignment` (PR-A)

### TL;DR

- ✅ 規格實作整體 PASS。Schema reconcile (PR #31) 全部 11 條已實作。9 個 layer 的所有 PASS 項加總 > 80 個。
- 🔧 修了 3 個真實 bug（已 commit 在 PR #46 並 push）：orphan pytest test、Layer 1 ObservationSkeleton 缺欄位、LIFECYCLE_STATUSES 重複定義。
- 🚧 4 個 blocker 需要你決定（不應該由 orchestrator 自動處理 — 改了會動到資料 shape 或行為，請看下方「Blockers」）。
- 📋 5 項 drift 接受為 first-slice 範圍（不需處理）。
- 測試狀態：1747 Jest+Node + 546 pytest + 7 observer-daemon → 全綠；biome lint 全乾淨。

## 檢查事項列表

我把 9 個 layer 的規格逐一對照實作（每 layer 都讀規格 + 讀對應的 code + 跑必要的指令）。下面是逐項清單。

### Layer 0 — Enablement / Scope Gate

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | `shouldObserve()` 串接 eval-trial skip + `ARCFORGE_OBSERVE_SKIP_PATHS` | `hooks/observe/main.js:75-88`
PASS | Eval-trial 路徑 regex (segment + suffix) | `main.js:57-58` Decision 7
PASS | Disabled-by-default — `defaultScopeConfig()` 回傳 `enabled: false` | `scripts/lib/learning.js:75-76`
PASS | Layer 0 不持久化任何東西（fail-closed） | 未發現 fs.write
DRIFT 接受 | 沒有 `ARCFORGE_SKIP_OBSERVE=1` 自迴避 guard（daemon self-loop） | arcforge dev repo 因 plugin disabled 不會觸發；其他環境風險低

### Layer 1 — Observation Collection

狀態 | 檢查項目 | 證據
--- | --- | ---
已修正 | 持久化記錄缺 `schema_version: 1` 跟 `source: { collector, phase }` | PR #46 已補上
PASS | Skeleton 必填欄位（ts/event/session/project/project_id/tool） | `main.js:405-412`
PASS | 不持久化原始 `tool_input` | 全部走 `buildObservedEvidence`
PASS | Skill args 不持久化（只記 skill name） | `extractSkillName`
PASS | PostToolUse 只記 outcome + output_bytes | `main.js:439-444`
DRIFT 接受 | 頂層 `observation.skill` 在 pre + post 都設（規格只描述 tool_start） | 低影響，移除可能破壞消費者

### Layer 2 — Sanitization + Derived Semantic View

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | `EVIDENCE_STATUS` 凍結常數 + 全部 4 個值 | `sanitize-observation.js:174-179`
PASS | `omitted_no_input` vs `omitted_safety` 語意正確 | `classifyOmission` + 每個 per-tool branch
PASS | Decision 5 全部 keyword + value form 覆蓋（14 regex / 42 tests） | `sanitize-observation.js:60-94`
PASS | Per-tool persistence contract（Bash/Read/Edit/Write/Grep/Glob/NotebookEdit/Skill/Web/PostToolUse） | SafeEvidencePatch 每個分支
PASS | `operation_kind` 欄位名（PR #31 reconcile 1.1） | 全部正確
PASS | `summarizeToolInput` 是 read-time only（不持久化） | `learning-observation-view.js` 純函式
PASS | Fail-closed：raw fallback 不允許 | outer catch fallback 用 `OMITTED_NO_INPUT`

### Layer 3 — Curator Batch Assembly

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | One-way — 不讀 Layer 5-8 狀態 | `batch-assembler.js` 沒有相關 import
PASS | `CuratorBatchManifest` 持久化到正確路徑 | `~/.arcforge/learning/curator-batches/<id>.manifest.json`
PASS | `batch_id` 跟 `batch_hash` 格式正確 | SHA-256 truncated
PASS | Safety metadata 全部 raw_*_included = false | `batch-assembler.js:426-432`
PASS | Atomic write（`atomicWriteFile`） | 避免半寫入 manifest 毒化 Layer 4
BLOCKER | Reflect + Recall 來源完全沒讀（規格 line 49-56 列為 approved input） | `max_reflections: 0, max_recalls: 0` 硬寫死
DRIFT | Diaries 是 raw string 不是 `DiaryEvidenceItem`（沒有 evidence_id，Layer 4 沒法 cite） | `readRecentDiaries()` 回傳 string[]
DRIFT | `source_windows.diaries` 從 manifest 缺漏 | 只填了 observations + transcript_summaries

### Layer 4 — LLM Curator Analysis

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | Daemon 不直接讀 `observations.jsonl` | 只透過 batch manifest
PASS | Bash daemon + Node CLI 分工（plan §3） | `observer-daemon.sh:158,296-298`
PASS | `body_source: "llm_curator"` 三層強制（prompt / record builder / validator） | PR #31 reconcile 1.2 全部到位
PASS | First-slice `allowed_artifact_types: ["instinct"]` | `observer-prompt.md:18-20`
PASS | `CuratorRunManifest` 每次必寫（含失敗路徑） | atomic + 9 個 parse_status branch
PASS | 失敗模式（timeout/transport_error/malformed_json/empty）都不會建 queue state | 每個 fail branch 都 early return
PASS | `sanitizer_module` + policy version 記錄在 `CuratorPromptPolicy` metadata（layer-4 lines 109-110, 124-135） | Spec 要求記在 metadata（已 wired），未要求印在 prompt 文字內 — 2026-05-21 audit 措辭誤導，2026-05-22 PR-A 修正

### Layer 5 — Candidate Queue + Lifecycle

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | `validateCandidateV1` 覆蓋全 spec 必填欄位 | `schema.js:140-479`
PASS | `body_source` 4-value enum | PR #31 reconcile 1.2
PASS | `promoted_from_*` 在 scope 上會被 reject | PR #31 reconcile 1.3
PASS | `REJECTION_CODES` 完整 18-code canonical union | PR #31 reconcile 1.4
PASS | Action × Status matrix 跟規格表完全一致（8×6） | PR #31 reconcile 1.5 + Slice G 加的 deactivate 欄
PASS | `applyTransition` 對 promote/evolve 會 throw | candidate-producing actions
PASS | `evidence_quality` v1 只用 `project_obs_count` | PR #31 reconcile 1.11
PASS | Queue + rejections + lock 路徑正確 + sanitizer 套用 | PR #31 reconcile 1.9
PASS | 事件 replay 容忍 corrupted trailing line | try/catch + continue
已修正 | `LIFECYCLE_STATUSES` 重複定義 | PR #46 改成 import from lifecycle.js
BLOCKER | 接受的 candidate `safety` metadata 缺 spec 必填欄位（validator_version / sanitizer_policy_version / secret_scan / activation_claim_scan / file_write_claim_scan） | `proposal-ingestor.js:167-174` — 詳見 Blocker #1
BLOCKER | `evidence_ref_omitted_upstream` rejection code 定義了但 never emit（Layer 5 從來不去讀上游 evidence_status） | 詳見 Blocker #2
DRIFT | `dedupe.dedupe_basis` ad-hoc keys vs spec 規範 shape | 沒做主動 dedup enforcement
DRIFT | `evidence_quality_metadata.rule_version` 跟 sanitizer policy version 混用同個值 | 是兩個不同的版本空間

### Layer 6 — Dashboard Review Control Plane

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | `DashboardCandidateCard` 全部必填欄位（包括 PR #31 reconcile 1.5 的 available_actions） | `learning-dashboard.js:113-136`
PASS | Privacy invariant — 對抗 4 種 fixture（API key / Bearer / JWT / private key）都 redacted | `tests:247-307`
PASS | `project_id` 從 wire model 剔除 | `buildCardScope`
PASS | Server-side enforce Action × Status matrix（用 Slice D 的 `isLegalAction`） | `handleDashboardAction:266-268`
PASS | `actions.jsonl` audit log（accept + reject 都記） | 多處 writeAuditEntry
PASS | Layer 6 不 call LLM、不寫 skill、不寫 CLAUDE.md | 無相關 import
PASS | Promote 建新 global candidate，source status 不變 | 跟 Slice D `applyTransition` rule 一致
PASS | Token-gated POST（24-byte random token） | `hasDashboardWriteHeader`
DRIFT 接受 | Wire model 沒輸出 `evidence_quality_chip` / `relationships` | First-slice UI 沒用到
DRIFT 接受 | Request envelope 丟掉 `expected_current_status` / `actor` / `safety_ack` / `reason` | First-slice 簡化（樂觀並發）
DRIFT 接受 | Detail view 缺 `evidence_summaries` / `llm_assessment` / `materialization` / `activation` blocks | First-slice 只給 rationale + body_preview

### Layer 7 — Materialization

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | 只接受 `approved` + `deactivated`（matrix 一致） | `materialize.js:327-330`
PASS | 只寫 inactive draft — 不碰 active skill/instinct 路徑 | draft root containment check
PASS | Path-containment 防止逃逸 | `materialize.js:399-403`
PASS | `MaterializationRecord` 先寫完再回報 Layer 5 | atomicWriteFile 在 transition event 之前
PASS | Atomic + lock-protected | `atomicWriteFile` + draft lock
PASS | Idempotent on `(candidate_hash, render_policy_version)` | findExistingMaterialization
PASS | Body secret scan（strict equality check） | redactObservationText 比對
DRIFT 接受 | `render_policy.include_evidence_summaries: false` 硬寫死 | First-slice rendering policy

### Layer 8 — Activation / Runtime Influence Surface

狀態 | 檢查項目 | 證據
--- | --- | ---
PASS | 只接受 `materialized` + `deactivated` | `activate.js:229-232`
PASS | Materialization record 完整性驗證（hash check） | `activate.js:235-237`
PASS | Activate 路徑要求 reviewer_ack | `activate.js:240-243`
PASS | Per-target-kind overwrite policy（instinct=supersede_with_backup, 其他=forbidden） | PR #31 reconcile 1.10
PASS | `claude_md_addition` 不會 auto-apply（雙層 block） | `FIRST_SLICE_TARGET_KINDS` + 顯式 check
PASS | Allowed roots 顯式 allowlist + path-resolve containment check | `activate.js:38-54,285-300`
PASS | Atomic write + lock + supersede 時備份 | `.backups/<id>-<ts>.md`
PASS | ActivationRecord 先寫完再回報 Layer 5 transition | atomicWriteFile 在 appendTransitionEvent 之前
PASS | SessionStart 不會 auto-load instincts | `inject-context.js:271-273` 有顯式 comment + e2e test
PASS | 失敗不會建 `activated` event | 所有 fail() 在 transition 之前 return
BLOCKER | `deactivate()` 不驗證 `reviewer_ack`（spec 要求 activate AND deactivate 都要） | 詳見 Blocker #3
DRIFT | `active_path_summary`（audit 誤稱 target_path_summary）含 project_id，2026-05-22 PR-A codify redaction rule 進 Layer 8 spec；PR-B 同步 impl 改 hash 化 | `activate.js:345`
DRIFT 接受 | Hash verify 順序（先讀 draft 再 hash vs spec 「verify before reading」） | 功能等價

## Blockers（需要你決定）

下面這些是真實的 spec drift，但修正會動到資料 shape 或行為，不適合 orchestrator 自動處理。請逐項判斷要不要修。

### Blocker #1 — Layer 5 接受的 candidate `safety` metadata 缺欄位 需要決定

檔案：`scripts/lib/learning-curator/proposal-ingestor.js:167-174`

規格要求（layer-5 spec 第 937-963 行）：每筆接受的 candidate `safety` 區塊要記錄 `validator_version`, `sanitizer_policy_version`, `sanitizer_module`, `secret_scan {status, rule_version}`, `activation_claim_scan`, `file_write_claim_scan`，以及 6 個 `raw_*_included: false` flags。

實作現況：sanitizer 確實有套用（`queue-writer.js:134-153`），但接受的 candidate 只記 6 個 raw flags，其他 provenance 欄位通通沒寫。Validator 不檢查這些欄位（schema 允許缺）所以驗證會通過。

影響：PR #31 reconcile 1.9 講的「Layer 4↔5 共用 sanitizer、兩邊記同個 `rule_version`」契約 — 從 persisted artifacts 無法重建。未來做安全稽核、回放、版本升級時，你拿到的 candidate 不知道是用哪個 sanitizer 版本驗的。

修法：`proposal-ingestor.js` 接受的 candidate 在 `safety` block 加上完整欄位，從 `SANITIZER_POLICY_VERSION` 跟新增的 `VALIDATOR_VERSION` 取值。

風險：改了 candidate 持久化 shape，所有未來新增的 candidate 都會多帶這些欄位。不影響既有 queue 因為是 additive，但 dashboard 跟 materialize 如果有 snapshot 測試會更新。

### Blocker #2 — `evidence_ref_omitted_upstream` 定義了但 never emit 需要決定

檔案：`scripts/lib/learning-curator/proposal-ingestor.js` evidence ref 驗證段

規格要求（PR #31 reconcile 1.4 + layer-5 spec 第 257-268 行）：當 proposal cite 的 evidence_id 對應到 batch 裡 `evidence_status ≠ "present"` 的 item 時，要 reject with `evidence_ref_omitted_upstream`。

實作現況：Code 只 check `evidence_id` 是否存在於 batch（`evidence_ref_missing`），但從不讀那個 item 的 `evidence_status`。所以如果 Layer 3 batch 含有 omitted-upstream 項目而 LLM cite 了它，會被當成 valid 收下來。

影響：**今天沒有 bug** — Layer 3 目前不會把 non-present 項目放進 batch（只有 `'present'` 的 observation 才進去）。但 Layer 3 一旦開始把 omitted 項目也帶入 batch（為了讓 LLM 知道「這裡有東西但被遮蔽了」），這個檢查就會默默失靈。

修法：`proposal-ingestor.js` 在驗 evidence_ref 時，從 batch manifest 讀對應 item 的 `evidence_status`，非 `present` 的就 reject with 新 code。

風險：今天 100% 不會觸發。是一個防禦欄位。但如果未來改 Layer 3 行為而忘了補這條，會在 production 默默放行不該收的 candidate。

### Blocker #3 — Layer 8 `deactivate()` 不驗證 `reviewer_ack` 需要決定

檔案：`scripts/lib/learning-curator/activate.js:423-462`

規格要求（layer-8 spec §Inputs）：Activate AND Deactivate 都要 reviewer_ack。

實作現況：Activate 路徑有檢查（`activate.js:240-243`）。Deactivate 路徑完全沒查 — 任何送過來的 deactivate 請求都會被執行。

影響：Dashboard 的 "Deactivate" 按鈕一鍵就生效，沒有確認對話框。對 single-user dashboard 可能是 acceptable UX 簡化；但如果未來改成 multi-user 或有自動化 caller，可能會意外停用真實 active artifact。

修法：Deactivate 入口加同樣的 reviewer_ack 檢查。Dashboard 端的 deactivate POST 也要送 ack。

請判斷：(a) 維持現狀（first-slice UX 簡化）；(b) 加 ack 檢查 + dashboard 加確認 modal。

### Blocker #4 — Layer 3 reflect/recall 來源未讀；diaries 不是 typed evidence 需要決定

檔案：`scripts/lib/learning-curator/batch-assembler.js`

規格要求（layer-3 spec 第 49-56 行）：Approved evidence sources 包含 observation / diary / reflect / recall / transcript_summary，每筆要有 `evidence_id` + `evidence_type` + `source_ref` 以便 Layer 4 cite。

實作現況：

- `max_reflections: 0, max_recalls: 0` 硬寫死 — reflect 跟 recall 完全沒讀
- Diaries 透過 `readRecentDiaries()` 讀成 string[]，塞到 prompt 文字但沒包裝成 `DiaryEvidenceItem` — Layer 4 沒法用 `evidence_id` cite
- `source_windows.diaries` 從 manifest 缺漏

影響：Layer 4 的 curator 可以看到 diary 內容（透過 prompt），但 proposal 沒法用 evidence_id 引用。所以 candidate 的 `evidence[]` 永遠只有 observation 來源。降低了 candidate 的訊號品質。

請判斷：(a) first-slice 接受 — diary/reflect/recall 全部留到 v2；(b) 把 diary 改成 typed evidence；(c) 完整支援三個來源（含 evidence_id）。

## 已修正項目（PR #46）

項目 | 檔案 | 說明
--- | --- | ---
Orphan pytest 測試 | `tests/skills/test_optional_learning_release_eval.py` | Slice I.3a (#43) 刪了 scenario 但沒刪驗證它的 test — 每次 npm test 直接炸 FileNotFoundError。已刪除。
Layer 1 ObservationSkeleton 缺欄位 | `hooks/observe/main.js` | 加上 `schema_version: 1` + `source: { collector, phase }`。Additive，207 hook tests 全綠。
LIFECYCLE_STATUSES 重複定義 | `scripts/lib/learning-curator/schema.js` | 從 `lifecycle.js` import 替代本地 8-string array。Single source of truth。

## 接受為 first-slice drift（已記錄、不處理）

- Layer 6 wire model 不輸出 `evidence_quality_chip` / `relationships` — 第一版 UI 沒消費
- Layer 6 request envelope 簡化（丟掉 `expected_current_status` / `actor` / `safety_ack` / `reason`）— 樂觀並發 first-slice OK
- Layer 6 detail view 缺 `evidence_summaries` / `llm_assessment` / `materialization` / `activation` blocks — 只給 rationale + body_preview
- Layer 7 `render_policy.include_evidence_summaries` 硬寫 false — first-slice rendering policy
- Layer 8 hash verification 順序（先讀後 hash vs spec「verify before reading」）— 功能等價
- Layer 1 `observation.skill` 在 pre + post 都設 — 規格只描述 tool_start，但移除會破壞既有消費者
- Layer 0 `ARCFORGE_SKIP_OBSERVE=1` daemon 自迴避 guard — arcforge dev repo 已 plugin disabled，其他環境風險低
- Layer 4 prompt 未明示 `sanitizer_module` + version — sanitizer 確實 wired，只是 prompt 文字沒提
- Layer 5 `dedupe_basis` ad-hoc keys；無主動 dedup enforcement — first-slice 沒有用戶會手動建重複的 candidate
- Layer 5 `evidence_quality_metadata.rule_version` 跟 sanitizer policy version 混用 — 命名空間混亂但目前無 user-visible bug

## 整體 verdict

實作 v.s. 規格整體 PASS 程度約 90%。

- 所有 PR #31 schema reconcile 11 條 — 已落地。
- 所有 layer 的 load-bearing invariant（隱私、生命週期狀態機、persistence atomicity）— PASS。
- Privacy 不變式（PII 不外洩）— PASS，sanitizer + dashboard wire model + materialize body scan 三層守。
- Layer 4↔5 sanitizer 契約 — 程式碼確實 wired，但 provenance 沒記錄（Blocker #1）。
- Drift 大多集中在 first-slice 簡化（envelope 欄位省略、render policy 寫死、reflect/recall 留 v2）— 屬設計取捨而非錯誤。

Critical path 沒有 P0 bug。Blockers #1 跟 #2 是「未來會默默降質」的型，不是「今天會炸」。Blocker #3 是 UX 決策。Blocker #4 是 v1 scope 決策。

## 建議的下一步

1. Review + merge PR #46（3 個低風險修正）— 在 merge 後 main 是 spec-clean 的。
2. 對 4 個 blockers 逐項判斷：
   - #1（safety provenance）建議**處理** — 安全稽核重要欄位
   - #2（omitted_upstream）建議**處理** — 防禦欄位，現在加比未來踩坑便宜
   - #3（deactivate ack）你決定 UX
   - #4（Layer 3 reflect/recall）你決定 v1 scope
3. 選定要處理的 blockers → 我可以另開 follow-up PR 實作（不在這個審查週期內，避免 scope 失控）。