# INFRA-F96 生命周期闸扫描面扩展 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把 `audit:plan-lifecycle` 的可达性判据从 `docs/superpowers/plans/` 一个目录扩到四个指令型一次性产物目录，并把判据从「文件名必须带日期前缀」解绑成「可达性为主、日期仅作 grace 参数」。

**Architecture:** 三处改动都在 `scripts/audit-plan-lifecycle.mjs` 内：① S1 的形态函数 `parseDatePrefix` 换成 `parseNameDate`，返回 `{ iso, claimsDate }` 两个比特，让「无日期」成为合法形态而非红；② 引用扫描从单遍加到两遍，第二遍用 `<父目录最后一段>/<文件名>` 的路径贪婪正则覆盖无日期文件；③ `MANAGED_DIRS` 由 1 行扩到 4 行，`computeInboundRefs` 的 key 从 basename 换成 `dir/name` 以消除跨目录同名碰撞。判据本身（S2 可达性）一字不改。

**Tech Stack:** Node ESM（`.mjs`，无依赖）· vitest（`tests/audit-plan-lifecycle.test.ts`）· husky pre-commit（L4 条件触发）+ `prepublishOnly`（L5）。

**设计真源：** [`docs/superpowers/specs/2026-08-05-infra-f96-lifecycle-scan-face-extension-design.md`](../specs/2026-08-05-infra-f96-lifecycle-scan-face-extension-design.md)。前身 spec（判据为什么是可达性）= [`2026-08-03-infra-f96-plan-spec-lifecycle-design.md`](../specs/2026-08-03-infra-f96-plan-spec-lifecycle-design.md) §2。

## Global Constraints

- **`GRACE_DAYS = 1` 不改**（spec §4.1 从既有合规样本推导；单测 `grace = 1 天（改这个值等于改判据）` 钉住）。
- **`EXEMPTIONS` 保持 `[]`** —— 空是终态不是待办，⛔ 不准为了让闸变绿往里加行。
- **不重命名任何文件**；不动 `docs/superpowers/specs/`（无限期保留）；不动 `audit:rule-inventory` 的 S4/S6 硬编码扫描面。
- **不扩到 `_spikes/` · `_reports/` · `retrospection/`**（记录型，spec §2）。
- **每条新判据先造故障看它变红，再采信「零违例」**；`pnpm run audit:plan-lifecycle` 的自印输出是唯一实测口径，⛔ 不抄 spec 里的数字。
- **commit 用 `git commit -F <msgfile> -- <路径逐条内联>`，提交后紧跟 `git reset -- <路径>`**；push 落地只信 `git ls-remote`（GitHub 会瞬时假报 `remote rejected`）。
- 本轮**不碰** `.css` / `.vue`（无需 `VISUAL_COMMIT_APPROVED`）。若发现必须碰，停下问 owner。

## File Structure

| 文件 | 动作 | 职责 |
|---|---|---|
| `scripts/audit-plan-lifecycle.mjs` | Modify | 闸本体：形态函数、两遍引用扫描、受管目录表、S1–S5 判定、自印报告 |
| `tests/audit-plan-lifecycle.test.ts` | Modify | 纯函数单测 + 真仓库回归钉；新增第二遍扫描的两个 bug 源钉子 |
| `.husky/pre-commit` | Modify | L4 触发面：加 `_prompts/` `_handoffs/` 与两个新归档落点 |
| `docs/_archive/_prompts/2026-07-23-*.prompt.md`（2 份） | Delete（`git mv`） | 归档到 `docs/_archive/_prompts/` |
| `docs/superpowers/plans/2026-07-23-repo-slim-execution-brief.md` | Delete（`git mv`） | 第二轮收敛后归档 |

---

### Task 1: S1 解绑日期 —— `parseDatePrefix` → `parseNameDate`

**Files:**
- Modify: `scripts/audit-plan-lifecycle.mjs`（`parseDatePrefix` 定义处 + `evaluate` 内 S1 分支）
- Test: `tests/audit-plan-lifecycle.test.ts`

**Interfaces:**
- Consumes: 无（本 plan 第一个 task）
- Produces: `export function parseNameDate(fileName: string): { iso: string | null, claimsDate: boolean }`
  - `iso` = 解析成功的 `YYYY-MM-DD`，否则 `null`
  - `claimsDate` = 文件名里**出现过** `\d{4}-\d{2}-\d{2}` 形状的串（不管是不是真日期）
  - S1 红的充要条件 = `claimsDate === true && iso === null`
  - `parseDatePrefix` **删除**（不留 back-compat 别名，避免两个名字各说一套）

- [ ] **Step 1: 写失败单测**

在 `tests/audit-plan-lifecycle.test.ts` 里，把 `describe('parseDatePrefix — S1 的形态判据', …)` 整段替换为：

```ts
describe('parseNameDate — S1 的形态判据（日期已解绑，只验真伪）', () => {
  it('日期前缀 → 解析出日期', () => {
    expect(parseNameDate('2026-08-03-infra-f96-lifecycle.md')).toEqual({
      iso: '2026-08-03',
      claimsDate: true,
    })
  })

  it('日期在中间也解析得出（_plans/ 的 pickup 就是这形态）', () => {
    expect(parseNameDate('next-session-pickup-2026-08-03-doc-volume.md')).toEqual({
      iso: '2026-08-03',
      claimsDate: true,
    })
  })

  it('无日期 → 合法形态，不是红（claimsDate=false）', () => {
    expect(parseNameDate('upstream-gate-codex.prompt.md')).toEqual({
      iso: null,
      claimsDate: false,
    })
    expect(parseNameDate('README.md')).toEqual({ iso: null, claimsDate: false })
  })

  it('形似日期但不是真日期 → claimsDate=true 且 iso=null（S1 据此判红）', () => {
    expect(parseNameDate('2026-13-45-foo.md')).toEqual({ iso: null, claimsDate: true })
    expect(parseNameDate('2026-02-30-foo.md')).toEqual({ iso: null, claimsDate: true })
  })

  it('只有日期没有 slug → 仍判红（保留旧 S1 的这半条，别静默丢掉）', () => {
    expect(parseNameDate('2026-08-03.md')).toEqual({ iso: null, claimsDate: true })
  })

  it('多个日期取第一个', () => {
    expect(parseNameDate('2026-01-02-vs-2026-03-04-compare.md').iso).toBe('2026-01-02')
  })
})
```

同时把 import 里的 `parseDatePrefix` 改成 `parseNameDate`。

- [ ] **Step 2: 跑测试确认它失败**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts -t 'parseNameDate'`
Expected: FAIL —— `parseNameDate is not a function`（或 import 报 undefined）

- [ ] **Step 3: 实现 `parseNameDate`**

在 `scripts/audit-plan-lifecycle.mjs` 里，把整个 `parseDatePrefix` 函数替换为：

```js
/**
 * 从文件名里取日期。日期**不再要求在开头** —— `_plans/` 的 pickup 是
 * `next-session-pickup-YYYY-MM-DD-slug.md` 形态，日期在中间。
 *
 * 返回两个比特而不是一个 iso：
 *   claimsDate=false → 无日期，**合法形态**（常青模板 / README），代价是不享 grace（S2 恒要求可达）
 *   claimsDate=true & iso=null → 名字里声称有日期但不是真日期 → S1 红（形态错 fail closed）
 *
 * ⚠️ `2026-08-03.md`（只有日期没 slug）刻意仍判红 —— 那是旧 S1 就有的检查，
 *    本次解绑日期时**不静默丢掉它**。
 */
export function parseNameDate(fileName) {
  const m = /(\d{4})-(\d{2})-(\d{2})/.exec(fileName)
  if (!m) return { iso: null, claimsDate: false }
  const [, y, mo, d] = m
  const iso = `${y}-${mo}-${d}`
  // 只有日期、没有 slug（`2026-08-03.md`）→ 不是合法产物名
  if (fileName === `${iso}.md`) return { iso: null, claimsDate: true }
  const t = Date.parse(`${iso}T00:00:00Z`)
  if (Number.isNaN(t)) return { iso: null, claimsDate: true }
  // 拒绝 2026-13-45 这种能被正则吃下但不是真日期的
  const back = new Date(t).toISOString().slice(0, 10)
  return back === iso ? { iso, claimsDate: true } : { iso: null, claimsDate: true }
}
```

- [ ] **Step 4: 跑测试确认它通过**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts -t 'parseNameDate'`
Expected: PASS（6 个 it 全绿）

- [ ] **Step 5: 改 `evaluate` 的 S1 分支**

在 `scripts/audit-plan-lifecycle.mjs` 的 `evaluate` 里，把

```js
      const iso = parseDatePrefix(f.name)
      // S1 — 文件名形态
      if (!iso) {
        findings.push({
          code: 'S1',
          msg: `${dir}/${f.name} —— 文件名不符 \`YYYY-MM-DD-<slug>.md\`，无法判定到期时间（形态错 fail closed）。`,
        })
        continue
      }
      const age = daysBetween(iso, today)
      const inbound = refs.get(f.name) ?? []
      const withinGrace = age <= graceDays
      report.push({ dir, name: f.name, bytes: f.bytes, age, refs: inbound.length, withinGrace })
```

替换为

```js
      const { iso, claimsDate } = parseNameDate(f.name)
      // S1 — 只验「声称有日期」的真伪；无日期是合法形态
      if (claimsDate && !iso) {
        findings.push({
          code: 'S1',
          msg:
            `${dir}/${f.name} —— 文件名里的日期不是真日期（或只有日期没有 slug），` +
            `无法判定到期时间（形态错 fail closed）。`,
        })
        continue
      }
      // 无日期 → 不享 grace（age=Infinity），必须始终可达。这是收紧不是放松。
      const age = iso ? daysBetween(iso, today) : Number.POSITIVE_INFINITY
      const inbound = refs.get(`${dir}/${f.name}`) ?? []
      const withinGrace = age <= graceDays
      report.push({
        dir,
        name: f.name,
        bytes: f.bytes,
        age: iso ? age : null,
        refs: inbound.length,
        withinGrace,
      })
```

⚠️ 注意 `refs` 的 key 从 `f.name` 变成了 `` `${dir}/${f.name}` `` —— Task 2 会把 `computeInboundRefs` 与测试 helper `managedOf` 一起改到这个 key。**本 task 结束时整测会红在这一点上，这是预期的**，Task 2 收口。

- [ ] **Step 6: 更新 S1/S2 相关的既有单测到新语义**

`describe('evaluate S1 — 文件名形态 fail closed', …)` 两个 it 改为：

```ts
describe('evaluate S1 — 只验日期真伪，无日期是合法形态', () => {
  it('must-fire：名字里声称有日期但不是真日期 → S1 红', () => {
    const r = evaluate({ managed: managedOf([{ name: '2026-13-45-foo.md' }]), today: '2026-08-05' })
    expect(codes(r)).toEqual(['S1'])
  })

  it('S1 命中的文件不再走 S2（一个文件只报一条根因）', () => {
    const r = evaluate({ managed: managedOf([{ name: '2026-13-45-foo.md' }]), today: '2026-08-05' })
    expect(r.findings).toHaveLength(1)
  })

  it('must-not-fire：无日期但有引用 → 绿（常青模板不该被形态误伤）', () => {
    const r = evaluate({
      managed: managedOf(
        [{ name: 'upstream-gate-codex.prompt.md' }],
        { 'docs/superpowers/plans/upstream-gate-codex.prompt.md': ['docs/internal/backlog.md'] },
      ),
      today: '2026-08-05',
    })
    expect(r.findings).toHaveLength(0)
  })

  it('must-fire：无日期且零引用 → S2 红（无 grace 可躲）', () => {
    const r = evaluate({
      managed: managedOf([{ name: 'upstream-gate-codex.prompt.md' }]),
      today: '2026-08-05',
    })
    expect(codes(r)).toEqual(['S2'])
  })
})
```

并把 helper `managedOf` 的 refs 构造改成新 key（`refs` 入参此后一律用 `` `${dir}/${name}` `` 作键）：

```ts
function managedOf(
  files: { name: string; bytes?: number }[],
  refs: Record<string, string[]> = {},
  over: Record<string, unknown> = {},
) {
  const dir = (over.dir as string) ?? 'docs/superpowers/plans'
  return [
    {
      label: 'test',
      dir,
      archiveDir: 'docs/_archive/superpowers/plans',
      files: files.map((f) => ({ name: f.name, bytes: f.bytes ?? 100 })),
      archivedCount: 0,
      dirExists: true,
      archiveExists: true,
      refs: new Map(files.map((f) => [`${dir}/${f.name}`, refs[`${dir}/${f.name}`] ?? []])),
      ...over,
    },
  ] as Parameters<typeof evaluate>[0]['managed']
}
```

⚠️ **既有 `describe('evaluate S2 — 可达性')` 那 5 个 it 现在按裸名传 refs**（如 `{ 'a.md': ['x'] }`），换 key 后会全部变成「零引用」而误红。逐个改成 `` { 'docs/superpowers/plans/a.md': ['x'] } ``。改完跑 `pnpm vitest run tests/audit-plan-lifecycle.test.ts -t 'evaluate S2'` 确认 5 个全绿 —— **不要**因为它们变红就以为是实现错了。

- [ ] **Step 7: 跑整测**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts`
Expected: PASS（S1/S2 全部新语义绿；`真仓库回归钉` 里的 `MANAGED_DIRS` 相关仍绿，因为本 task 没动目录表）

- [ ] **Step 8: Commit**

```bash
printf '%s\n' 'refactor(f96): S1 解绑日期 —— parseNameDate 返回 {iso, claimsDate}' '' \
  '无日期文件从「形态错判红」改为「合法形态但不享 grace」，必须始终可达（收紧不是放松）；' \
  'S1 收窄为只验「声称有日期」的真伪。日期在中间的 pickup 形态现在也能解析。' \
  '刻意保留旧 S1 的「只有日期没 slug 也判红」那半条，不静默丢掉。' > /tmp/f96-t1.txt
git commit -F /tmp/f96-t1.txt -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
git reset -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
```

---

### Task 2: 引用扫描加第二遍（无日期文件按 `<父目录最后一段>/<文件名>` 匹配）

**Files:**
- Modify: `scripts/audit-plan-lifecycle.mjs`（`computeInboundRefs` + 新增 `undatedMentionRe`）
- Test: `tests/audit-plan-lifecycle.test.ts`

**Interfaces:**
- Consumes: Task 1 的 `parseNameDate`
- Produces:
  - `export function undatedMentionRe(dir: string, fileName: string): RegExp` —— 为一个无日期受管文件造路径贪婪正则
  - `computeInboundRefs(managedFiles: { dir: string, name: string }[], repoRoot?: string): Map<string, string[]>`
    —— **key 从 basename 改成 `` `${dir}/${name}` ``**（消除跨目录同名碰撞，如两个 `README.md`）

- [ ] **Step 1: 写失败单测（含两个 bug 源钉子）**

在 `tests/audit-plan-lifecycle.test.ts` 末尾追加：

```ts
describe('undatedMentionRe — 第二遍：无日期文件的引用匹配', () => {
  it('带父目录最后一段的引用算数', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'upstream-gate-codex.prompt.md')
    re.lastIndex = 0
    expect(re.test('见 docs/internal/_prompts/upstream-gate-codex.prompt.md')).toBe(true)
  })

  it('相对写法也算数（所以只取最后一段，不是完整仓库路径）', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'upstream-gate-codex.prompt.md')
    re.lastIndex = 0
    expect(re.test('见 ../_prompts/upstream-gate-codex.prompt.md')).toBe(true)
  })

  it('裸名不算数 —— README.md 裸名全仓 109 命中，会让闸恒绿', () => {
    const re = undatedMentionRe('docs/internal/_plans', 'README.md')
    re.lastIndex = 0
    expect(re.test('见 README.md 里的说明')).toBe(false)
    re.lastIndex = 0
    expect(re.test('见 docs/internal/_plans/README.md')).toBe(true)
  })

  it('bug 源①：归档路径字面包含 live 路径 → 必须被 isProvenanceMention 剔掉', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'foo.prompt.md')
    re.lastIndex = 0
    const m = re.exec('见 docs/_archive/_prompts/foo.prompt.md')
    expect(m).not.toBeNull()
    // 正则会命中，但整段匹配含 _archive/ → 判为溯源，不算活指针
    expect(isProvenanceMention(m[0])).toBe(true)
  })
})

describe('computeInboundRefs — key 是 dir/name，不是 basename', () => {
  it('跨目录同名不互相顶替（两个 README.md 各算各的）', () => {
    const refs = computeInboundRefs([
      { dir: 'docs/internal/_plans', name: 'README.md' },
      { dir: 'docs/internal/_demos', name: 'README.md' },
    ])
    expect(refs.has('docs/internal/_plans/README.md')).toBe(true)
    expect(refs.has('docs/internal/_demos/README.md')).toBe(true)
    // 真仓库里 _plans/README.md 被指过（≥1），这条同时是真仓库钉
    expect(refs.get('docs/internal/_plans/README.md').length).toBeGreaterThan(0)
  })

  it('bug 源②：文件自己提到自己的路径不算引用', () => {
    const refs = computeInboundRefs([
      { dir: 'docs/internal/_plans', name: 'README.md' },
    ])
    expect(refs.get('docs/internal/_plans/README.md')).not.toContain(
      'docs/internal/_plans/README.md',
    )
  })
})
```

import 里加 `undatedMentionRe`。

- [ ] **Step 2: 跑测试确认它失败**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts -t 'undatedMentionRe'`
Expected: FAIL —— `undatedMentionRe is not a function`

- [ ] **Step 3: 实现第二遍**

在 `scripts/audit-plan-lifecycle.mjs` 里，`isProvenanceMention` 之后加：

```js
/** 正则元字符转义（文件名里有 `.`，不转义会把它当通配） */
function escapeRe(s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/**
 * 第二遍：无日期文件的引用匹配器。
 *
 * 判据 = `<父目录最后一段>/<文件名>` 字面出现，前面允许接任意路径字符
 * （与 PLAN_MENTION_RE 同形，好让 isProvenanceMention 能看见 `_archive/` 前缀）。
 *
 * ⛔ 为什么不用裸 basename：实测 `README.md` 裸名全仓 109 命中、带 `_plans/` 前缀只有 7 —— 
 *    裸名会让闸对通用名恒绿、判据失效。
 * ⚠️ 为什么只取最后一段而不是完整仓库路径：实际引用里既有 `docs/internal/_prompts/foo.md`
 *    也有相对写法 `../_prompts/foo.md`，用完整路径会漏掉后者。
 */
export function undatedMentionRe(dir, fileName) {
  const lastSeg = dir.split('/').filter(Boolean).pop()
  return new RegExp(`[A-Za-z0-9._/-]*${escapeRe(`${lastSeg}/${fileName}`)}`, 'g')
}
```

再把 `computeInboundRefs` 整体替换为：

```js
/**
 * 核心：算每份受管文件的入站引用。
 * @param {{dir: string, name: string}[]} managedFiles
 * @returns Map<`${dir}/${name}`, string[]>  值 = 引用它的文件相对路径
 *
 * key 用 dir/name 而不是 basename —— 受管目录扩到 4 个后，跨目录同名（两个 `README.md`）
 * 会互相顶替，basename 键是个静默的假绿源。
 */
export function computeInboundRefs(managedFiles, repoRoot = REPO_ROOT) {
  const result = new Map(managedFiles.map((f) => [`${f.dir}/${f.name}`, []]))
  if (managedFiles.length === 0) return result

  // 第一遍走单遍正则的快路径：带日期的文件按 basename 归位
  const datedByName = new Map()
  const undated = []
  for (const f of managedFiles) {
    if (parseNameDate(f.name).iso) {
      // 同名带日期文件跨目录碰撞概率极低，但仍按 key 列表存，命中时全部记上
      const list = datedByName.get(f.name) ?? []
      list.push(`${f.dir}/${f.name}`)
      datedByName.set(f.name, list)
    } else {
      undated.push({ ...f, re: undatedMentionRe(f.dir, f.name) })
    }
  }

  const files = collectRefFiles(repoRoot)
  for (const rel of files) {
    if (isArchivePath(rel)) continue // 已归档文档不算数
    let text
    try {
      text = readFileSync(join(repoRoot, rel), 'utf8')
    } catch {
      continue
    }

    // —— 第一遍：带日期的名字，单遍正则
    const self = basename(rel)
    PLAN_MENTION_RE.lastIndex = 0
    let m
    const seen = new Set()
    while ((m = PLAN_MENTION_RE.exec(text))) {
      if (isProvenanceMention(m[0])) continue // 溯源引用不算活指针
      const hit = basename(m[0])
      if (hit === self) continue // 排除自身
      const keys = datedByName.get(hit)
      if (!keys || seen.has(hit)) continue
      seen.add(hit)
      for (const k of keys) result.get(k).push(rel)
    }

    // —— 第二遍：无日期的名字，逐条路径贪婪匹配
    for (const u of undated) {
      const key = `${u.dir}/${u.name}`
      if (rel === key) continue // bug 源②：排除自身
      u.re.lastIndex = 0
      let um
      while ((um = u.re.exec(text))) {
        if (isProvenanceMention(um[0])) continue // bug 源①：归档路径字面含 live 路径
        result.get(key).push(rel)
        break
      }
    }
  }
  return result
}
```

- [ ] **Step 4: 跑测试确认它通过**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts`
Expected: PASS（含 Task 1 遗留的 key 不一致，此时应已收口）

- [ ] **Step 5: 实测 pre-commit 成本没劣化**

Run: `time pnpm run audit:plan-lifecycle`
Expected: 与 F96 头注释记的 **2.3s** 同量级（≤3.5s）。若明显劣化，停下：第二遍应改成一次性构造的多模式匹配，别硬上。

- [ ] **Step 6: Commit**

```bash
printf '%s\n' 'feat(f96): 引用扫描加第二遍 —— 无日期文件按 <父目录最后一段>/<文件名> 匹配' '' \
  '单遍正则只认名字里带日期的 token，无日期文件（常青模板 / README）恒 0 ref。' \
  '第二遍用路径贪婪正则补上，并复用 isProvenanceMention 挡住两个 bug 源：' \
  '① 归档路径 _archive/_prompts/foo.md 字面包含 live 路径 _prompts/foo.md（会永久钉住活文件）' \
  '② 文件自己提到自己的路径' '' \
  'computeInboundRefs 的 key 从 basename 换成 dir/name —— 受管目录扩到 4 个后' \
  '跨目录同名（两个 README.md）会互相顶替，是个静默假绿源。' > /tmp/f96-t2.txt
git commit -F /tmp/f96-t2.txt -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
git reset -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
```

---

### Task 3: 受管目录表扩到 4 行 + S3 微调 + hook 触发面

**Files:**
- Modify: `scripts/audit-plan-lifecycle.mjs`（`MANAGED_DIRS`、S3 分支、main 的目录收集与自印、文件头注释）
- Modify: `.husky/pre-commit`（第 491 行的触发正则）
- Test: `tests/audit-plan-lifecycle.test.ts`

**Interfaces:**
- Consumes: Task 2 的 `computeInboundRefs(managedFiles)`
- Produces: `MANAGED_DIRS` 4 行；S3 的归档落点判据新增入参 `hasExpiring: boolean`

- [ ] **Step 1: 写失败单测**

追加：

```ts
describe('evaluate S3 — 归档落点缺失只在真有文件到期时判红', () => {
  it('有文件到期 + 落点不存在 → S3 红', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-01-01-old.md' }], {}, { archiveExists: false }),
      today: '2026-08-05',
    })
    expect(codes(r)).toContain('S3')
  })

  it('无文件到期 + 落点不存在 → 不红（不为用不上的目录先塞空 .gitkeep）', () => {
    const r = evaluate({
      managed: managedOf(
        [{ name: '2026-08-05-fresh.md' }],
        {},
        { archiveExists: false },
      ),
      today: '2026-08-05',
    })
    expect(r.findings).toHaveLength(0)
  })
})

describe('真仓库回归钉 — 扩展后的受管目录表', () => {
  it('四个受管目录都真实存在', () => {
    expect(MANAGED_DIRS.map((m) => m.dir).sort()).toEqual([
      'docs/internal/_handoffs',
      'docs/internal/_plans',
      'docs/internal/_prompts',
      'docs/superpowers/plans',
    ])
    for (const m of MANAGED_DIRS) {
      expect(existsSync(resolve(REPO_ROOT, m.dir)), m.dir).toBe(true)
    }
  })

  it('记录型目录刻意不在扫描面内（靠 grep 发现不靠链接）', () => {
    const dirs = MANAGED_DIRS.map((m) => m.dir)
    for (const d of [
      'docs/internal/_spikes',
      'docs/internal/_reports',
      'docs/internal/retrospection',
      'docs/superpowers/specs',
    ]) {
      expect(dirs).not.toContain(d)
    }
  })
})
```

- [ ] **Step 2: 跑测试确认它失败**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts -t '扩展后的受管目录表'`
Expected: FAIL —— `MANAGED_DIRS` 只有 1 行

- [ ] **Step 3: 扩目录表**

把 `MANAGED_DIRS` 替换为：

```js
/**
 * 受管目录表 —— 收**指令型**一次性产物，不收记录型。
 *
 * 判据是「发不发指令」：一份已完成的 plan / prompt / pickup 留在 live tree 里会继续提供
 * 指令（实例见文件头）；而 `_spikes/` `_reports/` `retrospection/` 是记录，读它是 opt-in，
 * 且教训档天然靠 grep 命中而非链接可达 —— 套可达性判据会逼人补假指针。
 * 理由全文见 specs/2026-08-05-infra-f96-lifecycle-scan-face-extension-design.md §2。
 */
export const MANAGED_DIRS = [
  {
    dir: 'docs/superpowers/plans',
    archiveDir: 'docs/_archive/superpowers/plans',
    label: 'superpowers plans',
  },
  { dir: 'docs/internal/_prompts', archiveDir: 'docs/_archive/_prompts', label: 'prompts' },
  { dir: 'docs/internal/_plans', archiveDir: 'docs/_archive/_plans', label: 'session pickups' },
  { dir: 'docs/internal/_handoffs', archiveDir: 'docs/_archive/_handoffs', label: 'handoffs' },
]
```

- [ ] **Step 4: 改 S3 的落点判据**

在 `evaluate` 里，把无条件的

```js
    if (!archiveExists) {
      findings.push({
        code: 'S3',
        msg: `归档落点缺失：${archiveDir} 不存在 —— 闸会点名要求 git mv 到一个不存在的地方。`,
      })
    }
```

**从目录循环的开头挪到该目录逐文件判完之后**，并加条件（`expiring` = 本轮真被 S2 点名的份数）：

```js
    // S3（续）— 归档落点只在真有文件要归档时才必须存在。
    // 不为一个还用不上的目录先塞空 .gitkeep。
    if (!archiveExists && expiring > 0) {
      findings.push({
        code: 'S3',
        msg:
          `归档落点缺失：${archiveDir} 不存在，而本轮有 ${expiring} 份文件要归档过去 —— ` +
          `闸不能点名要求 git mv 到一个不存在的地方。`,
      })
    }
```

其中 `expiring` 在逐文件循环前置 `let expiring = 0`，在每次 push S2 finding 时 `expiring += 1`。

- [ ] **Step 5: 改 `run()` 的引用收集（⚠️ 顺带修一个 4× 性能回归）+ 自印**

⚠️ **现状是个必须一起修的坑**：`run()` 里 `computeInboundRefs` 在 `MANAGED_DIRS.map(…)` **内部**被调用 —— 即**每个受管目录各全仓扫一遍**。目录从 1 扩到 4，全仓扫描就从 1 次变 4 次（2.3s → ~9s，且这成本每次 pre-commit 都付）。必须把它提到 map 外面调一次。

把 `run()` 替换为：

```js
export function run({ repoRoot = REPO_ROOT, today = todayIso() } = {}) {
  const collected = MANAGED_DIRS.map((cfg) => {
    const absDir = join(repoRoot, cfg.dir)
    const absArchive = join(repoRoot, cfg.archiveDir)
    const files = countMd(absDir) ?? []
    const archived = countMd(absArchive)
    return {
      ...cfg,
      files,
      archivedCount: archived ? archived.length : 0,
      dirExists: existsSync(absDir),
      archiveExists: existsSync(absArchive),
    }
  })

  // ⚠️ 全仓扫描只跑一次 —— 放进上面的 map 里就是每个受管目录各扫一遍（4 目录 = 4×）。
  const refs = computeInboundRefs(
    collected.flatMap((m) => m.files.map((f) => ({ dir: m.dir, name: f.name }))),
    repoRoot,
  )
  const managed = collected.map((m) => ({ ...m, refs }))

  return { ...evaluate({ managed, today }), managed }
}
```

（`evaluate` 里已按 `` `${dir}/${f.name}` `` 取，四个目录共用同一张表即可。）

`main()` 里**只替换**开头的标题行 + 逐目录行（`入站引用` / `豁免` 两行与 FAIL 分支原样保留），并把 `if (!m.summary) continue` 的 guard 去掉 —— 那个 guard 会让 S3 判红的目录从自印里消失，正是最该被看见的那个：

```js
  console.log('audit:plan-lifecycle（INFRA-F96 一次性产物保留规则）')
  console.log('  扫描面 = 指令型四目录；记录型（_spikes / _reports / retrospection / specs）刻意不收')
  for (const m of managed) {
    const liveBytes = m.files.reduce((a, f) => a + f.bytes, 0)
    console.log(
      `  ${m.label.padEnd(20)} live ${String(m.files.length).padStart(3)} 份 / ` +
        `${(liveBytes / 1024).toFixed(0)} KB   已归档 ${m.archivedCount} 份` +
        (m.dirExists ? '' : '   ⚠️ 目录不存在'),
    )
  }
```

原本那句 `⛔ specs/ 刻意不在扫描面内 …… 见 specs/2026-08-03-infra-f96-*-design.md §3` **保留**，但把「指向哪份 spec」改成同时指两份（旧的论证 specs/ 不归档，新的论证四目录取舍）。

- [ ] **Step 6: 跑测试 + 跑闸看真实点名**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts && pnpm run audit:plan-lifecycle`
Expected: 单测 PASS；闸 **FAIL**，点名 `docs/internal/_prompts/` 下两份零引用死件（`2026-07-23-history-archive-prune.prompt.md` / `2026-07-23-ds-merge-phase3-component-cards.prompt.md`），并因 `docs/_archive/_prompts` 不存在而附 S3。⚠️ **这是预期的红**，Task 4 收口 —— 不要为了让它变绿去加豁免。

- [ ] **Step 7: 扩 pre-commit 触发面**

`.husky/pre-commit` 第 491 行的正则改为（新增三段，`_plans/` 原本就在）：

```sh
if git diff --cached --name-only --diff-filter=AMD | grep -qE '(^docs/superpowers/plans/|^docs/_archive/superpowers/plans/|^docs/internal/_prompts/|^docs/_archive/_prompts/|^docs/internal/_handoffs/|^docs/_archive/_handoffs/|^docs/_archive/_plans/|^scripts/audit-plan-lifecycle\.mjs$|^docs/STATUS\.md$|^docs/internal/backlog\.md$|^docs/internal/_plans/)'; then
```

同时把该段上方的注释里「治的病 = `docs/superpowers/plans/` 建立至今零归档」更新为四目录口径，并指向新 spec。

- [ ] **Step 8: 实测 hook 真的会触发**

```bash
touch docs/internal/_handoffs/__probe.md && git add docs/internal/_handoffs/__probe.md
git commit -m 'probe' --dry-run 2>&1 | grep -c '实施计划保留规则 gate'
git reset -- docs/internal/_handoffs/__probe.md && rm docs/internal/_handoffs/__probe.md
```
Expected: 计数 ≥1（hook 段被评估到）。⚠️ `--dry-run` 不跑 hook 的话，改用真 commit 到临时分支再 `git reset --hard`；**不要**用「我觉得正则对」代替实测。

- [ ] **Step 9: Commit**

```bash
printf '%s\n' 'feat(f96): 受管目录扩到四个指令型目录 + S3 落点判据条件化' '' \
  '_prompts / _plans / _handoffs 纳入可达性判据（记录型 _spikes / _reports / retrospection' \
  '/ specs 刻意不收 —— 靠 grep 发现不靠链接，套可达性会逼人补假指针）。' \
  'S3 的归档落点缺失改为只在本轮真有文件到期时判红，不为用不上的目录塞空 .gitkeep。' \
  'pre-commit 触发面同步扩容并实测触发。' '' \
  '本 commit 后闸会点名 _prompts/ 两份零引用死件 —— 预期的红，下一个 commit 收口。' > /tmp/f96-t3.txt
git commit -F /tmp/f96-t3.txt -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts .husky/pre-commit
git reset -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts .husky/pre-commit
```

> ⚠️ 本 commit 会被自己刚扩的 pre-commit 拦（闸红）。**用 `--no-verify` 是错的**。正确做法 = 把 Task 4 的归档并进同一个 commit，或先做 Task 4 再一起提。执行时按后者：**Task 3 Step 9 与 Task 4 Step 4 合并为一次 commit**。

---

### Task 4: 抢救扫描 + 归档两份死 prompt（真 `git mv` 建出落点）

**Files:**
- Delete（`git mv`）: `docs/_archive/_prompts/2026-07-23-history-archive-prune.prompt.md`
- Delete（`git mv`）: `docs/_archive/_prompts/2026-07-23-ds-merge-phase3-component-cards.prompt.md`
- Create: `docs/_archive/_prompts/`（由上面两次 `git mv` 建出，不塞 `.gitkeep`）

**Interfaces:**
- Consumes: Task 3 的四行 `MANAGED_DIRS`
- Produces: `docs/_archive/_prompts/` 落点存在，闸对 `_prompts/` 转绿

- [ ] **Step 1: 抢救扫描（⛔ 归档前必做，判据不是「看起来重不重要」）**

对两份文件各做一遍：通读全文，逐条列出它里面的 **live 约束**（对今天仍然成立的规则、数值、禁令、路径），然后对每条 `grep` 仓库别处有没有第二家。

```bash
# 逐份通读
cat docs/_archive/_prompts/2026-07-23-history-archive-prune.prompt.md
cat docs/_archive/_prompts/2026-07-23-ds-merge-phase3-component-cards.prompt.md
```

判据：**这条 live 约束在仓库别处有没有第二家**。
- 有第二家 → 直接归档。
- 没有第二家 → **先把它就地搬到唯一真源**（脚本头注释 / 对应 backlog entry / `.design-sync/NOTES.md` 等），搬完再归档。
- 搬迁时若在别处写下「抢救自 `docs/_archive/_prompts/<name>`」，**必须写归档后的路径**（含 `_archive/`）—— `isProvenanceMention` 据此判它是溯源而非活指针，否则会自锁（认真做抢救反而归档不掉）。

把逐条结论写进本 task 的 commit message。

- [ ] **Step 2: 归档**

```bash
mkdir -p docs/_archive/_prompts
git mv docs/_archive/_prompts/2026-07-23-history-archive-prune.prompt.md docs/_archive/_prompts/
git mv docs/_archive/_prompts/2026-07-23-ds-merge-phase3-component-cards.prompt.md docs/_archive/_prompts/
```

- [ ] **Step 3: 跑闸看第一轮转绿 + 第二轮点名**

Run: `pnpm run audit:plan-lifecycle`
Expected: `_prompts/` 转绿；**`docs/superpowers/plans/2026-07-23-repo-slim-execution-brief.md` 被点名**（它唯一的引用来自刚归档的那份 prompt，现在掉到 0 ref）。这是 spec §7 预告的**迭代收敛**，是正确行为。

- [ ] **Step 4: 对 repo-slim brief 同样做抢救扫描 + 归档**

```bash
cat docs/superpowers/plans/2026-07-23-repo-slim-execution-brief.md   # 抢救扫描
git mv docs/superpowers/plans/2026-07-23-repo-slim-execution-brief.md docs/_archive/superpowers/plans/
pnpm run audit:plan-lifecycle
```
Expected: 闸 **PASS**，自印四个目录各自的 live/archived 份数。⚠️ 若又点名出新的孤儿，继续同样流程（收敛是迭代的），**不要**为了收工加豁免。

抢救扫描要点（已知）：这份 brief 的 §5 PENDING 写着「等 Owner 选定历史存档策略」，而实测该策略**已执行**（`docs/_archive/` 101 份即是）；§6「明确 KEEP」清单里的 `figma-data/raw/` load-bearing 结论若在仓库别处无第二家，搬进 `docs/PROJECT_MAP.md` 相应段落。

- [ ] **Step 5: 与 Task 3 Step 9 合并为一次 commit**

```bash
printf '%s\n' 'feat(f96): 受管目录扩到四个指令型目录 + 归档三份零引用一次性产物' '' \
  '_prompts / _plans / _handoffs 纳入可达性判据（记录型 _spikes / _reports / retrospection' \
  '/ specs 刻意不收 —— 靠 grep 发现不靠链接，套可达性会逼人补假指针）。' \
  'S3 的归档落点缺失改为只在本轮真有文件到期时判红，不为用不上的目录塞空 .gitkeep。' \
  'run() 里的全仓引用扫描从「每目录各扫一遍」提到外层只扫一次（4 目录本会 4x）。' \
  'pre-commit 触发面同步扩容并实测触发。' '' \
  '抢救扫描结论（逐条）：' \
  '  - <约束 A> → 第二家在 <路径>，直接归档' \
  '  - <约束 B> → 无第二家，已搬到 <路径> 后归档' '' \
  '归档触发的迭代收敛：history-archive-prune.prompt 归档后，它唯一指向的' \
  'repo-slim-execution-brief（已 100% 执行完）掉到 0 ref 被闸点名，一并归档。' > /tmp/f96-t34.txt
git commit -F /tmp/f96-t34.txt -- \
  scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts .husky/pre-commit \
  docs/internal/_prompts docs/_archive/_prompts docs/superpowers/plans docs/_archive/superpowers/plans
git reset -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts .husky/pre-commit \
  docs/internal/_prompts docs/_archive/_prompts docs/superpowers/plans docs/_archive/superpowers/plans
```

⚠️ commit message 里的 `<约束 A/B>` 是**占位，必须换成 Step 1/4 的真实结论**再提交。

---

### Task 5: 12 条故障注入验收 + 头注释同步

**Files:**
- Modify: `scripts/audit-plan-lifecycle.mjs`（文件头注释：病因、扫描面、两个刻意设计选择、GRACE 推导来源）
- Test: `tests/audit-plan-lifecycle.test.ts`（补真仓库钉）

**Interfaces:**
- Consumes: Task 1–4 全部
- Produces: 无新导出；产出 = 12 条实测证据

- [ ] **Step 1: 逐条造故障，记录实测输出**

对每条：造故障 → 跑 `pnpm run audit:plan-lifecycle` → 记下它是否红、红在哪条 code → **`git checkout --` 还原**。

| # | 故障注入 | 期望 |
|---|---|---|
| 1 | 在 `_prompts/` 放一份无日期、零引用的 `.md` | S2 红 |
| 2 | 给它加一条**只在 `docs/_archive/**` 里**的引用 | 仍 S2 红 |
| 3 | 改成只被**裸名**引用（无 `_prompts/` 前缀） | S2 红 |
| 4 | 改成被 `_prompts/<名>` 引用 | 绿 |
| 5 | 放 `2026-13-45-foo.md` | S1 红 |
| 6 | 放 `next-session-pickup-2026-01-01-x.md`（date-infix、到期、零引用）到 `_plans/` | S2 红 |
| 7 | 临时把某受管目录清空（`git stash` 后跑） | S3 红 |
| 8 | 有文件到期 + 临时 `mv` 走归档落点 | S3 红 |
| 9 | 无文件到期 + 归档落点不存在（`_handoffs/`） | **不红** |
| 10 | 往 `EXEMPTIONS` 加一条已不再命中的行 | S4 红 |
| 11 | 传未知参数 `--bogus-flag` | exit 2（**写计划时实测基线 = exit 0 静默放行**，见 Step 2） |
| 12 | 闸真的挂上了 | pre-commit 实测触发（Task 3 Step 8 已做）+ `pnpm run prepublishOnly` 链里出现该条 |

⚠️ 第 2、3 条是本次新增机制**自己**的终审 —— 把刚建的东西放进终审范围，不只测被它管的对象。
⚠️ 每条都要**先看到红**再采信，不接受「我改完跑了一次全绿所以没问题」。

- [ ] **Step 2: 补 argv fail-closed（已实测确认当前是静默放行）**

**实测基线（2026-08-05，写计划时已跑）**：`node scripts/audit-plan-lifecycle.mjs --bogus-flag` 与 `… ""` 都**正常跑完并 exit 0** —— 闸完全忽略 argv。它本来就不接受任何参数，但静默吞掉会掩盖调用方的错（有人写 `pnpm run audit:plan-lifecycle -- --fix` 会以为真有 fix 模式且「通过了」）。

在 `main()` 开头加：

```js
function main() {
  // 本闸不接受任何参数。静默吞掉未知 argv 会让调用方的笔误表现成「通过」。
  const extra = process.argv.slice(2).filter((a) => a !== '')
  if (extra.length) {
    console.error(
      `✗ audit:plan-lifecycle：本闸不接受参数，收到 ${JSON.stringify(extra)}。\n` +
        `  （若你想传 --fix / --dry-run：没有这些模式，修法是按 FAIL 文案 git mv 或补指针。）`,
    )
    process.exit(2)
  }
  const { findings, report, managed } = run()
```

⚠️ 用 **exit 2** 而不是 1，与「判据违例」区分开。空串 `""` 刻意过滤掉 —— `pnpm run x -- ` 会透传一个空参数，那不是调用方的错。

补单测：

```ts
import { execFileSync } from 'node:child_process'

describe('argv fail closed —— 不静默吞掉未知参数', () => {
  const script = resolve(REPO_ROOT, 'scripts/audit-plan-lifecycle.mjs')

  it('未知参数 → exit 2，不是 exit 0', () => {
    let code = 0
    try {
      execFileSync('node', [script, '--bogus-flag'], { stdio: 'pipe' })
    } catch (e) {
      code = e.status
    }
    expect(code).toBe(2)
  })

  it('pnpm 透传的空串不算参数 → 正常判定', () => {
    let code = 0
    try {
      execFileSync('node', [script, ''], { stdio: 'pipe' })
    } catch (e) {
      code = e.status
    }
    expect(code).toBe(0) // 本轮落地后真仓库应当是绿的
  })
})
```

- [ ] **Step 3: 补真仓库回归钉**

```ts
it('真仓库：四个受管目录跑完 run() 零 finding（本轮落地后的常态）', () => {
  // 直接用 run()，别在测试里重算一遍判据 —— 重算等于把判据写两份，会各说一套
  const { findings } = run()
  expect(findings).toEqual([])
})

it('真仓库：无日期文件确实被第二遍看见（否则第二遍等于没写）', () => {
  const undated = MANAGED_DIRS.flatMap((m) =>
    readdirSync(resolve(REPO_ROOT, m.dir))
      .filter((n) => n.endsWith('.md') && !parseNameDate(n).iso)
      .map((name) => ({ dir: m.dir, name })),
  )
  expect(undated.length).toBeGreaterThan(0) // 分母非零：否则下面是空过
  const refs = computeInboundRefs(undated)
  for (const { dir, name } of undated) {
    expect((refs.get(`${dir}/${name}`) ?? []).length, `${dir}/${name}`).toBeGreaterThan(0)
  }
})
```

⚠️ 第二个 it 的 `expect(undated.length).toBeGreaterThan(0)` 是**分母非零钉** —— 没有它，哪天无日期文件被清空，这条测试会变成空过还显示绿。
⚠️ import 里需补 `run` 与 `parseNameDate`（`readdirSync` / `resolve` / `MANAGED_DIRS` / `computeInboundRefs` 已在）。

- [ ] **Step 4: 同步闸的文件头注释**

把头注释从「`docs/superpowers/plans/` 建立至今零删除零归档，28 份 / 1.0 MB」改成四目录口径，并补：
- 病因不变（已完成的东西留在 live tree 会继续提供指令），新增本轮那个活病例（死 prompt 钉住已执行完的 repo-slim brief）；
- 新的**三个**刻意设计选择：① 日期取自文件名、不取 git（原有，保留）② 无日期文件不享 grace，是收紧 ③ 第二遍用 `<父目录最后一段>/<文件名>` 而非裸名，并说明两个 bug 源；
- 指向新 spec 路径。

同时改 FAIL 分支末尾那句 `判据与理由见 docs/superpowers/README.md + …2026-08-03-infra-f96-plan-spec-lifecycle-design.md` —— 补上本轮的新 spec，否则被点名的人读到的是只讲 `plans/` 的旧文档。

- [ ] **Step 5: 跑全链**

Run: `pnpm vitest run tests/audit-plan-lifecycle.test.ts && pnpm run audit:plan-lifecycle && pnpm run audit:stale-anchors && pnpm run audit:doc-shape`
Expected: 全 PASS。⚠️ `audit:stale-anchors` 必跑 —— Task 4 的 `git mv` 会让指向旧路径的链接变死链。

- [ ] **Step 6: Commit + push**

```bash
printf '%s\n' 'test(f96): 12 条故障注入实测 + 头注释同步四目录口径' '' \
  '逐条实测结果（先看到红再采信）：' \
  '  <把 Step 1 表格的真实结论逐条写进来，不要写"全部通过">' > /tmp/f96-t5.txt
git commit -F /tmp/f96-t5.txt -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
git reset -- scripts/audit-plan-lifecycle.mjs tests/audit-plan-lifecycle.test.ts
git push origin master && git push github master
# 落地只信 ls-remote（GitHub 会瞬时假报 remote rejected）
for r in origin github; do echo "$r: $(git ls-remote "$r" master | awk '{print $1}')"; done
git rev-parse HEAD
```

---

## 收尾（不属于任何 task，但本轮必须做）

- [ ] `docs/STATUS.md` §三 13 与 backlog `INFRA-F95` 的待做③ 改标（另有安排，见本轮 #3）。
- [ ] 本 plan 文件自己受 F96 闸管 —— **落地当天必须被 STATUS / backlog 指向一次**，否则 grace 1 天后它自己会被点名。
