# 支柱④ 上游理解-分析 Gate — 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:** 让任何人 × 任何工具用 TVU DS 产出产品设计（code/mockup）之前，强制走上游理解-分析并产出一份结构化 `upstream-gate.<feature>.md`，其确定层由 zero-dep validator 机校、理解/质量层由 owner PR review 兜底，且挂在受控落点（chokepoint）的 PR/CI 上不可静默跳过。

**Architecture:** 四层 —— ① `upstream-gate.schema.json` 定义 artifact 字段 + 层归属；② `validate-upstream-gate.mjs`（zero-dep ESM，复用 `audit-stale-anchors.mjs` 的 slug/锚点纯函数）只机校确定层，输出 PASS/FAIL/UNVERIFIED/SKIP + degradation note；③ 薄适配器（CC skill = L3、bundle Project-Instructions 段 = L1）把生产者导进产出 artifact；④ chokepoint PR/CI（DS repo Gitea `pr-checks.yml` + 消费 repo scaffold 下发的 CI）跑 validator + owner review。artifact 不存在时 validator no-op PASS（chokepoint-bound，非 PR 普适）。

**Tech Stack:** Node ≥20（全局 `fetch` + `AbortController`）· ESM `.mjs` · vitest（`tests/*.test.ts`）· JSON Schema 2020-12（契约文档，validator 运行时不依赖 ajv）· Gitea Actions + GitHub Actions YAML · Claude Code skill（SKILL.md）。

## Global Constraints

以下是 spec 定的项目级护栏，**每个 task 隐含包含**（值逐字来自 spec + 实证）：

- **validator 必须 zero-dep**：仅 `node:` 内建 + 全局 `fetch`；唯一允许的相对 import 是 `./audit-stale-anchors.mjs`（同在 `scripts/`，随 npm 包 `files:["scripts"]` 下发）。禁止 import `ajv`（它是 devDependency，不随包发给消费方）。必须通过 `pnpm audit:scripts-stdlib`。
- **对齐现有 audit 风格**：`#!/usr/bin/env node` shebang · 中文注释 · PASS → `console.log` + `process.exit(0)` · FAIL → `console.error` + `process.exit(1)` · 每类输出 coverage 计数 · `if (import.meta.url === \`file://${process.argv[1]}\`) main()` 守卫（import 无副作用，供 vitest 直接 import 纯函数）。
- **chokepoint-bound enforcement（不可推翻）**：机械权威 = 受控落点的 PR/CI，不是普适 PR。artifact 不存在 → validator no-op PASS。裸 claude.ai 无落点 → 无法机械 gate，也不假装能（诚实边界）。
- **双层防假闸（不可推翻）**：validator **只机校确定层**（schema 完整 / sources_read 锚点 / competitive URL / data_feasibility 字段）；**绝不机校** understanding/persona_ia/mvp_scope 的质量（判断题，假装机校 = 假闸），这些留 owner PR review。
- **优雅降级不强制 fork #4（不可推翻）**：product-context 可选；存在 → data_feasibility 确定性检激活；缺失 → 该检 SKIP + degradation_note，**不 FAIL**。URL 超时/网络不可达 → UNVERIFIED（不脑补成 PASS，也不武断判死），不使进程 FAIL。
- **git-remote 探测（不可推翻）**：消费者已有 remote **永远优先**；仅在缺 remote 时**建议**默认 Gitea `<host>/ux-team/<项目名>.git` 且用户可覆盖；无 git repo 才 `git init`。**绝不强制覆盖**消费者已有 remote。
- **Gitea 边界**：Gitea (`.gitea/pr-checks.yml`) 只是 DS 自己团队仓的 CI；消费产品 CI = 消费产品自己的 CI（模板是 GitHub Actions）。二者互不依赖。
- **纪律**：owner 直推 master + push 双 remote（github + gitea）· **精确 `git add`（并行 session 共享工作树，禁 `git add -A`/`git add .`）**· 改代码后除 `pnpm test` 还要在收尾跑 `pnpm build` 确认无破坏 · executor 完成一个 task STOP 报告，不自 commit（由 plan owner 复审 + 用户 ack 后才 commit）。

---

## File Structure

**新建：**
- `scripts/upstream-gate.schema.json` — artifact 字段契约 + 层归属（`x-gate-layer` 扩展关键字）。validator 读其 `required`/`properties.type` 做浅结构校验；完整 schema 供文档 + 未来 ajv。
- `scripts/validate-upstream-gate.mjs` — zero-dep validator。纯函数（parseFrontmatter / validateStructure / checkSourceAnchor / validateCompetitiveItems / checkUrl / checkDataFeasibility / buildDegradationNote / findArtifacts）+ `main()`。
- `tests/upstream-gate.test.ts` — validator 纯函数单测（对齐 `tests/stale-anchors.test.ts` 范式）。
- `scripts/__fixtures__/upstream-gate/pass.md` — 通过样例（DoD #2）。
- `scripts/__fixtures__/upstream-gate/missing-field.md` — 缺必填字段（DoD #4）。
- `scripts/__fixtures__/upstream-gate/dead-anchor-and-url.md` — 死锚点 + 死 URL（DoD #4）。
- `scripts/__fixtures__/upstream-gate/no-context-ok.md` — 缺 context 正确降级（DoD #2）。
- `scripts/__fixtures__/upstream-gate/_product-context.fixture.md` — 人读参考（**非确切名** `product-context.md`，故不会被 `findProductContext` 的全-repo walk 当真 context 命中，从而不破坏 Task 6 的「no-context 降级」端到端测试）。
- `templates/consumer-product/docs/specs/_upstream-gate.template.md` — 消费方 artifact 模板（与 `_feature-design-record.template.md` 同簇）。
- `templates/consumer-product/docs/specs/_product-context.template.md` — 消费方 product-context 模板（含 `available_data_fields` front-matter）。
- `templates/upstream-gate-workflow.yml` — 消费方 GitHub Actions CI 模板（与 `templates/audit-workflow.yml` 平级同风格）。
- `scripts/scaffold-git-remote.mjs` — git-remote 探测（zero-dep，可单测的纯函数 `decideRemoteAction` + `main()`）。
- `skills/upstream-gate/SKILL.md` — L3 CC 适配器（引导生产者走上游流程 → 产出 artifact → 本地跑 validator）。
- `docs/internal/upstream-gate-l1-instructions.md` — L1 Project-Instructions 段源文件（bundle 注入用）。

**修改：**
- `package.json` — 加 `audit:upstream-gate` script。
- `.gitea/workflows/pr-checks.yml` — 加跑 validator 的 step。
- `.gitea/PULL_REQUEST_TEMPLATE.md` — 加 owner 理解/persona/MVP 人工核 checklist。
- `templates/consumer-product/SETUP_GUIDE.md` — 加 Step 2.9（upstream-gate CI 接线 + git-remote 探测）。
- `scripts/export-claude-design-bundle.mjs` — 注入 schema + L1 instructions 到 bundle。
- `tests/scaffold-git-remote.test.ts` — `decideRemoteAction` 单测（新建，归 Task 9）。

---

## Task 1: schema 文件 + validator 骨架（front-matter 解析 + 结构浅校验）

**Files:**
- Create: `scripts/upstream-gate.schema.json`
- Create: `scripts/validate-upstream-gate.mjs`
- Create: `tests/upstream-gate.test.ts`

**Interfaces:**
- Produces:
  - `export function parseFrontmatter(text: string): { data: Record<string, any>, body: string }` — 解析 md front-matter；机校复杂字段用 JSON-inline value（`[...]` / `{...}`），标量为 string/null。无 front-matter 抛 `Error`。
  - `export function loadSchema(): { required: string[], properties: Record<string,{type:string}> }` — 读同目录 `upstream-gate.schema.json`。
  - `export function validateStructure(data, schema): Array<{ field: string, ok: boolean, detail: string }>` — 逐 required 字段检存在 + 基础类型（`string`/`array`/`object`）。

- [ ] **Step 1: 写 schema 文件 `scripts/upstream-gate.schema.json`**

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "upstream-gate.schema.json",
  "title": "Upstream Understanding-Analysis Gate Artifact",
  "description": "支柱④ 上游理解-分析 gate 产出物字段契约。x-gate-layer 标注每字段由机校（deterministic）还是 owner PR review（human）验收。validator 运行时只读 required + properties.type 做浅校验（zero-dep，不依赖 ajv）。",
  "type": "object",
  "required": [
    "feature",
    "understanding",
    "sources_read",
    "competitive",
    "persona_ia",
    "data_feasibility",
    "mvp_scope"
  ],
  "additionalProperties": true,
  "properties": {
    "feature": {
      "type": "string",
      "x-gate-layer": "deterministic",
      "description": "本次产品设计任务标识（kebab slug）。artifact 文件名应为 upstream-gate.<feature>.md。"
    },
    "understanding": {
      "type": "string",
      "x-gate-layer": "human",
      "description": "需求复述（front-matter 放摘要，正文放完整复述）。owner PR review 确认复述是否正确。"
    },
    "sources_read": {
      "type": "array",
      "items": { "type": "string" },
      "x-gate-layer": "deterministic",
      "description": "读过的真源，每条 'path#anchor' 或 'path'。validator 校锚点存在（复用 stale-anchors 机制）。"
    },
    "competitive": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["vendor", "url", "finding"],
        "properties": {
          "vendor": { "type": "string" },
          "url": { "type": "string" },
          "finding": { "type": "string" }
        }
      },
      "x-gate-layer": "deterministic+human",
      "description": "竞品实时检索结果。validator 校 URL 合法 + 域名合理 + HTTP 可达；owner 核相关性。"
    },
    "persona_ia": {
      "type": "string",
      "x-gate-layer": "human",
      "description": "persona + 信息架构（front-matter 摘要，正文详述）。owner PR review。"
    },
    "data_feasibility": {
      "type": "object",
      "required": ["fields"],
      "properties": {
        "fields": { "type": "array", "items": { "type": "string" } }
      },
      "x-gate-layer": "deterministic",
      "description": "数据可行性：本设计消费的数据字段。有 product-context 时 validator 校 fields ⊆ context.available_data_fields；缺 context 则 SKIP + degradation_note，不 FAIL。"
    },
    "mvp_scope": {
      "type": "string",
      "x-gate-layer": "human",
      "description": "MVP 范围界定（front-matter 摘要，正文详述）。owner PR review。"
    },
    "degradation_note": {
      "type": "string",
      "x-gate-layer": "machine-written",
      "description": "哪些检因缺 context/工具降级。validator 运行时生成到 stdout；artifact 里此字段可选（不回写文件）。"
    }
  }
}
```

- [ ] **Step 2: 写失败测试 `tests/upstream-gate.test.ts`（parseFrontmatter + validateStructure）**

```typescript
// tests/upstream-gate.test.ts
// 支柱④ upstream-gate validator 纯函数单测（zero-dep validator，对齐 stale-anchors 范式）。
import { describe, it, expect } from 'vitest'
import {
  parseFrontmatter,
  loadSchema,
  validateStructure,
} from '../scripts/validate-upstream-gate.mjs'

describe('parseFrontmatter', () => {
  it('解析标量 + JSON-inline 复杂字段', () => {
    const text = [
      '---',
      'feature: live-dashboard',
      'sources_read: ["docs/PROJECT_GOAL.md#一句话目标", "docs/STATUS.md"]',
      'competitive: [{"vendor":"LiveU","url":"https://liveu.tv","finding":"has X"}]',
      'data_feasibility: {"fields": ["bitrate", "latency"]}',
      'understanding: 用户要一个实时看板',
      '---',
      '正文段落',
    ].join('\n')
    const { data, body } = parseFrontmatter(text)
    expect(data.feature).toBe('live-dashboard')
    expect(data.sources_read).toEqual(['docs/PROJECT_GOAL.md#一句话目标', 'docs/STATUS.md'])
    expect(data.competitive[0].vendor).toBe('LiveU')
    expect(data.data_feasibility.fields).toEqual(['bitrate', 'latency'])
    expect(data.understanding).toBe('用户要一个实时看板')
    expect(body.trim()).toBe('正文段落')
  })

  it('支持复杂字段跨多行（续行拼接后 JSON.parse）', () => {
    const text = [
      '---',
      'competitive: [',
      '  {"vendor":"Dejero","url":"https://dejero.com","finding":"y"}',
      ']',
      'feature: x',
      '---',
    ].join('\n')
    const { data } = parseFrontmatter(text)
    expect(data.competitive[0].vendor).toBe('Dejero')
    expect(data.feature).toBe('x')
  })

  it('无 front-matter 抛错', () => {
    expect(() => parseFrontmatter('# 只有正文\n没有 front-matter')).toThrow()
  })
})

describe('validateStructure', () => {
  const schema = loadSchema()

  it('全部必填字段存在且类型正确 → 全 ok', () => {
    const data = {
      feature: 'x',
      understanding: 'y',
      sources_read: ['a.md'],
      competitive: [],
      persona_ia: 'p',
      data_feasibility: { fields: [] },
      mvp_scope: 'm',
    }
    const results = validateStructure(data, schema)
    expect(results.every((r) => r.ok)).toBe(true)
  })

  it('缺必填字段 → 该字段 ok=false', () => {
    const data = { feature: 'x' }
    const results = validateStructure(data, schema)
    const understanding = results.find((r) => r.field === 'understanding')
    expect(understanding?.ok).toBe(false)
  })

  it('类型不符（sources_read 应 array 却给 string）→ ok=false', () => {
    const data = {
      feature: 'x', understanding: 'y', sources_read: 'not-array',
      competitive: [], persona_ia: 'p', data_feasibility: { fields: [] }, mvp_scope: 'm',
    }
    const results = validateStructure(data, schema)
    expect(results.find((r) => r.field === 'sources_read')?.ok).toBe(false)
  })
})
```

- [ ] **Step 3: 运行测试验证失败**

Run: `pnpm test -- upstream-gate`
Expected: FAIL —— `Cannot find module '../scripts/validate-upstream-gate.mjs'` 或 `parseFrontmatter is not a function`。

- [ ] **Step 4: 写 `scripts/validate-upstream-gate.mjs` 骨架（imports + 3 纯函数）**

```javascript
#!/usr/bin/env node
// validate-upstream-gate.mjs
// 支柱④ 上游理解-分析 gate validator（INFRA-F55 支柱④）。
// 只机校「确定层」：schema 结构完整 / sources_read 锚点存在 / competitive URL 可达 /
// data_feasibility 字段对得上 product-context。understanding/persona_ia/mvp_scope 的质量
// 是判断题（假装机校 = 假闸）—— 留 owner PR review。
//
// chokepoint-bound：挂在受控落点（DS Gitea pr-checks / 消费 repo scaffold 下发 CI）。
// artifact 不存在 → no-op PASS（不是每个 PR 都是产品设计任务）。
//
// 降级：缺 product-context → data_feasibility SKIP + degradation_note，不 FAIL；
//       URL 超时/网络不可达 → UNVERIFIED（不脑补 PASS，不武断判死），不使进程 FAIL。
//
// ESM + zero-dep（仅 node: 内建 + 全局 fetch；受 audit:scripts-stdlib 约束）。
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, extname, join, relative } from 'node:path'
import { collectHeadingSlugs } from './audit-stale-anchors.mjs'

const __dirname = dirname(fileURLToPath(import.meta.url))
const SCHEMA_PATH = resolve(__dirname, 'upstream-gate.schema.json')

// ───────────────────────── 纯函数（vitest 直接 import） ─────────────────────────

// 解析 md front-matter。机校复杂字段用 JSON-inline value（[...] / {...}），标量为 string/null。
// 顶层 key 行 = /^([A-Za-z_][\w-]*):\s?(.*)$/；value 累积续行直到下个顶层 key，再尝试 JSON.parse。
export function parseFrontmatter(text) {
  if (!text.startsWith('---')) throw new Error('artifact 缺 front-matter（必须以 --- 开头）')
  const end = text.indexOf('\n---', 3)
  if (end === -1) throw new Error('artifact front-matter 未闭合（缺结束 ---）')
  const fmRaw = text.slice(text.indexOf('\n') + 1, end)
  const body = text.slice(end + 4)
  const lines = fmRaw.split('\n')
  const data = {}
  const KEY_RE = /^([A-Za-z_][\w-]*):\s?(.*)$/
  let curKey = null
  let curVal = []
  const flush = () => {
    if (curKey === null) return
    const raw = curVal.join('\n').trim()
    let val
    if (raw === '') {
      val = null
    } else if (/^[[{"]/.test(raw)) {
      try { val = JSON.parse(raw) } catch (e) { throw new Error(`front-matter 字段 ${curKey} JSON 解析失败：${e.message}`) }
    } else {
      val = raw.replace(/^['"]|['"]$/g, '')
    }
    data[curKey] = val
    curKey = null
    curVal = []
  }
  for (const line of lines) {
    const m = line.match(KEY_RE)
    // 只有「零缩进 + 不在 JSON 续行中」才算新 key；JSON 续行（缩进/未配平）并入当前 value。
    const inJsonContinuation = curKey !== null && /^[[{]/.test(curVal.join('\n').trim())
    if (m && !line.startsWith(' ') && !inJsonContinuation) {
      flush()
      curKey = m[1]
      curVal = [m[2]]
    } else if (curKey !== null) {
      curVal.push(line)
    }
  }
  flush()
  return { data, body }
}

// 读同目录 schema，返回 { required, properties }。
export function loadSchema() {
  const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'))
  return { required: schema.required || [], properties: schema.properties || {} }
}

// 逐 required 字段检存在 + 基础类型（string/array/object）。
export function validateStructure(data, schema) {
  const typeOk = (v, t) => {
    if (t === 'array') return Array.isArray(v)
    if (t === 'object') return v !== null && typeof v === 'object' && !Array.isArray(v)
    return typeof v === t
  }
  const results = []
  for (const field of schema.required) {
    const v = data[field]
    if (v === undefined || v === null) {
      results.push({ field, ok: false, detail: '必填字段缺失或为空' })
      continue
    }
    const t = schema.properties[field]?.type
    if (t && !typeOk(v, t)) {
      results.push({ field, ok: false, detail: `类型应为 ${t}，实为 ${Array.isArray(v) ? 'array' : typeof v}` })
      continue
    }
    results.push({ field, ok: true, detail: 'ok' })
  }
  return results
}
```

- [ ] **Step 5: 运行测试验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（parseFrontmatter 3 例 + validateStructure 3 例全绿）。

- [ ] **Step 6: 运行 zero-dep 约束闸**

Run: `pnpm audit:scripts-stdlib`
Expected: PASS —— validate-upstream-gate.mjs 只 import `node:` 内建 + 相对 `./audit-stale-anchors.mjs`，无第三方包。

- [ ] **Step 7: STOP —— 报告 diff + 两条命令 stdout，等 plan owner 复审。不自 commit。**

---

## Task 2: sources_read 锚点校验（复用 stale-anchors slug）

**Files:**
- Modify: `scripts/validate-upstream-gate.mjs`（加 `checkSourceAnchor`）
- Modify: `tests/upstream-gate.test.ts`（加 describe 块）

**Interfaces:**
- Consumes: `collectHeadingSlugs`（from `audit-stale-anchors.mjs`，已 export）。
- Produces: `export function checkSourceAnchor(ref: string, opts: { repoRoot: string, artifactDir: string }): { ref: string, ok: boolean, detail: string }` — ref = `"path#anchor"` 或 `"path"`；先解析文件（相对 artifactDir 优先，再相对 repoRoot），文件不存在 → ok=false；有 `#anchor` 且目标是 `.md` → 用 `collectHeadingSlugs` 查 slug 存在。

- [ ] **Step 1: 加失败测试**

```typescript
// 追加到 tests/upstream-gate.test.ts
import { checkSourceAnchor } from '../scripts/validate-upstream-gate.mjs'
import { resolve } from 'node:path'

describe('checkSourceAnchor', () => {
  const repoRoot = resolve(__dirname, '..')
  const opts = { repoRoot, artifactDir: repoRoot }

  it('存在的文件 + 存在的 heading slug → ok', () => {
    // docs/PROJECT_GOAL.md 有 "## 一句话目标" → slug 一句话目标
    const r = checkSourceAnchor('docs/PROJECT_GOAL.md#一句话目标', opts)
    expect(r.ok).toBe(true)
  })

  it('存在的文件 + 不存在的 slug → ok=false', () => {
    const r = checkSourceAnchor('docs/PROJECT_GOAL.md#这个标题不存在xyz', opts)
    expect(r.ok).toBe(false)
    expect(r.detail).toMatch(/slug/)
  })

  it('不存在的文件 → ok=false', () => {
    const r = checkSourceAnchor('docs/does-not-exist.md#x', opts)
    expect(r.ok).toBe(false)
    expect(r.detail).toMatch(/文件/)
  })

  it('无 anchor 的纯路径只校文件存在 → ok', () => {
    const r = checkSourceAnchor('docs/STATUS.md', opts)
    expect(r.ok).toBe(true)
  })
})
```

- [ ] **Step 2: 运行验证失败**

Run: `pnpm test -- upstream-gate`
Expected: FAIL —— `checkSourceAnchor is not a function`。

- [ ] **Step 3: 实现 `checkSourceAnchor`（加到 validate-upstream-gate.mjs 纯函数区）**

```javascript
// 解析 sources_read 单条 'path#anchor'。文件相对 artifactDir 优先、再相对 repoRoot。
// 有 #anchor 且目标 .md → 用 collectHeadingSlugs（GitHub slug 算法）查 slug 存在。
export function checkSourceAnchor(ref, { repoRoot, artifactDir }) {
  const hashIdx = ref.indexOf('#')
  const path = hashIdx === -1 ? ref : ref.slice(0, hashIdx)
  const frag = hashIdx === -1 ? '' : ref.slice(hashIdx + 1)
  const cands = [resolve(artifactDir, path), resolve(repoRoot, path)]
  const abs = cands.find((c) => existsSync(c))
  if (!abs) return { ref, ok: false, detail: `目标文件不存在（相对 artifact 或 repo 根均未找到）：${path}` }
  if (!frag) return { ref, ok: true, detail: 'ok（文件存在）' }
  if (extname(abs) !== '.md') return { ref, ok: true, detail: 'ok（非 .md 目标，不校 anchor）' }
  const slugs = collectHeadingSlugs(readFileSync(abs, 'utf8'))
  if (!slugs.has(frag)) return { ref, ok: false, detail: `heading slug 不存在（已删/改名/合并）：#${frag}` }
  return { ref, ok: true, detail: 'ok（文件 + anchor 存在）' }
}
```

- [ ] **Step 4: 运行验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（新增 4 例全绿）。

- [ ] **Step 5: STOP —— 报告，等复审。**

---

## Task 3: competitive URL 校验（timeout + UNVERIFIED，不脑补）

**Files:**
- Modify: `scripts/validate-upstream-gate.mjs`（加 `validateCompetitiveItems` + `checkUrl`）
- Modify: `tests/upstream-gate.test.ts`

**Interfaces:**
- Produces:
  - `export function validateCompetitiveItems(items: any[]): Array<{ index: number, ok: boolean, detail: string }>` — 每 item 必须有非空 `vendor`/`url`/`finding`（string）。
  - `export async function checkUrl(url: string, fetchImpl?: typeof fetch): Promise<{ url: string, verdict: 'PASS'|'FAIL'|'UNVERIFIED', detail: string }>` — URL 构造失败 / host 是占位（example.com/localhost/foo.bar）/ HTTP 4xx-5xx → FAIL；2xx-3xx → PASS；网络 error/abort → UNVERIFIED。默认 `fetchImpl = globalThis.fetch`，8s AbortController timeout。

- [ ] **Step 1: 加失败测试（fetch 注入，不依赖真实网络）**

```typescript
// 追加到 tests/upstream-gate.test.ts
import { validateCompetitiveItems, checkUrl } from '../scripts/validate-upstream-gate.mjs'

describe('validateCompetitiveItems', () => {
  it('齐全 → ok', () => {
    const r = validateCompetitiveItems([{ vendor: 'LiveU', url: 'https://liveu.tv', finding: 'x' }])
    expect(r[0].ok).toBe(true)
  })
  it('缺 finding → ok=false', () => {
    const r = validateCompetitiveItems([{ vendor: 'LiveU', url: 'https://liveu.tv' }])
    expect(r[0].ok).toBe(false)
  })
})

describe('checkUrl', () => {
  it('200 → PASS', async () => {
    const r = await checkUrl('https://liveu.tv', async () => ({ ok: true, status: 200 }) as any)
    expect(r.verdict).toBe('PASS')
  })
  it('404 → FAIL', async () => {
    const r = await checkUrl('https://liveu.tv/dead', async () => ({ ok: false, status: 404 }) as any)
    expect(r.verdict).toBe('FAIL')
  })
  it('网络异常/超时 → UNVERIFIED（不脑补 PASS）', async () => {
    const r = await checkUrl('https://liveu.tv', async () => { throw new Error('aborted') })
    expect(r.verdict).toBe('UNVERIFIED')
  })
  it('无效 URL → FAIL（不发请求）', async () => {
    let called = false
    const r = await checkUrl('not-a-url', async () => { called = true; return { ok: true, status: 200 } as any })
    expect(r.verdict).toBe('FAIL')
    expect(called).toBe(false)
  })
  it('占位域名 example.com → FAIL（不发请求）', async () => {
    const r = await checkUrl('https://example.com/x', async () => ({ ok: true, status: 200 }) as any)
    expect(r.verdict).toBe('FAIL')
  })
})
```

- [ ] **Step 2: 运行验证失败**

Run: `pnpm test -- upstream-gate`
Expected: FAIL —— `validateCompetitiveItems is not a function` / `checkUrl is not a function`。

- [ ] **Step 3: 实现（加到纯函数区）**

```javascript
// competitive[] 每 item 结构校验：vendor/url/finding 非空 string。
export function validateCompetitiveItems(items) {
  return items.map((it, index) => {
    const bad = ['vendor', 'url', 'finding'].filter(
      (k) => typeof it?.[k] !== 'string' || it[k].trim() === '',
    )
    return bad.length
      ? { index, ok: false, detail: `competitive[${index}] 缺/空字段：${bad.join(', ')}` }
      : { index, ok: true, detail: 'ok' }
  })
}

// 占位/示例域名（写了但不是真竞品来源）。
const PLACEHOLDER_HOSTS = new Set(['example.com', 'example.org', 'localhost', 'foo.bar', 'test.com'])

// URL 可达性：合法 http(s) + 非占位 host + HTTP 可达。
// 超时/网络错 → UNVERIFIED（不脑补 PASS，也不武断判死 —— 环境可能无网）。
export async function checkUrl(url, fetchImpl = globalThis.fetch) {
  let u
  try { u = new URL(url) } catch { return { url, verdict: 'FAIL', detail: 'URL 格式非法' } }
  if (!/^https?:$/.test(u.protocol)) return { url, verdict: 'FAIL', detail: `协议非 http(s)：${u.protocol}` }
  if (PLACEHOLDER_HOSTS.has(u.hostname)) return { url, verdict: 'FAIL', detail: `占位/示例域名：${u.hostname}` }
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 8000)
  try {
    let res
    try {
      res = await fetchImpl(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' })
      // 有些服务器对 HEAD 返 405/501 → 用 GET 复核
      if (res.status === 405 || res.status === 501) {
        res = await fetchImpl(url, { method: 'GET', signal: controller.signal, redirect: 'follow' })
      }
    } catch {
      res = await fetchImpl(url, { method: 'GET', signal: controller.signal, redirect: 'follow' })
    }
    if (res.status >= 200 && res.status < 400) return { url, verdict: 'PASS', detail: `HTTP ${res.status}` }
    return { url, verdict: 'FAIL', detail: `HTTP ${res.status}` }
  } catch (e) {
    return { url, verdict: 'UNVERIFIED', detail: `网络不可达/超时（不脑补）：${e.name || 'error'}` }
  } finally {
    clearTimeout(timer)
  }
}
```

- [ ] **Step 4: 运行验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（新增 7 例全绿）。

- [ ] **Step 5: STOP —— 报告，等复审。**

---

## Task 4: data_feasibility 校验 + degradation note（缺 context SKIP 不 FAIL）

**Files:**
- Modify: `scripts/validate-upstream-gate.mjs`（加 `findProductContext` + `checkDataFeasibility` + `buildDegradationNote`）
- Modify: `tests/upstream-gate.test.ts`
- Create: `scripts/__fixtures__/upstream-gate/_product-context.fixture.md`

**Interfaces:**
- Produces:
  - `export function checkDataFeasibility(fields: string[], contextFields: string[] | null): { verdict: 'PASS'|'FAIL'|'SKIP', missing: string[], detail: string }` — `contextFields === null`（无 product-context）→ SKIP；否则 `fields ⊆ contextFields` → PASS，缺集非空 → FAIL。
  - `export function findProductContext(repoRoot: string): { path: string, availableFields: string[] } | null` — 递归找确切名 `product-context.md`（node_modules/.git/dist 除外），读其 front-matter `available_data_fields`；找不到返回 null。**薄 IO 包装，不单测**（核心决策逻辑在 `checkDataFeasibility`，已单测；`parseFrontmatter` 已单测）。
  - `export function buildDegradationNote(entries: Array<{ check: string, status: string, reason: string }>): string`。

- [ ] **Step 1: 建 fixture（人读参考，非确切名）**

`scripts/__fixtures__/upstream-gate/_product-context.fixture.md`：

> **命名铁律（executor 必守）**：文件名**必须**是 `_product-context.fixture.md`，**绝不能**叫确切名 `product-context.md`。因为 `findProductContext` 递归全 repo 找确切名 `product-context.md` —— 若 repo 树里存在任何确切名文件，Task 6 的「缺 product-context → SKIP」端到端测试会被它命中而失败。本 fixture 仅供人读了解 product-context 长啥样。

```markdown
---
product: fixture-product
available_data_fields: ["bitrate", "latency", "packet_loss", "device_status"]
---

# Fixture Product Context（人读参考 · 非 validator 发现目标）

（validator 从确切名 product-context.md 的 front-matter available_data_fields 读可用字段；
本文件是 _*.fixture.md 命名，validator 不会发现它。）
```

- [ ] **Step 2: 加失败测试**

```typescript
// 追加到 tests/upstream-gate.test.ts
import { checkDataFeasibility, buildDegradationNote } from '../scripts/validate-upstream-gate.mjs'

describe('checkDataFeasibility', () => {
  it('无 product-context（null）→ SKIP，不 FAIL', () => {
    const r = checkDataFeasibility(['bitrate'], null)
    expect(r.verdict).toBe('SKIP')
  })
  it('字段全在 context → PASS', () => {
    const r = checkDataFeasibility(['bitrate', 'latency'], ['bitrate', 'latency', 'x'])
    expect(r.verdict).toBe('PASS')
  })
  it('字段有缺集 → FAIL + 列出缺集', () => {
    const r = checkDataFeasibility(['bitrate', 'ghost_field'], ['bitrate'])
    expect(r.verdict).toBe('FAIL')
    expect(r.missing).toEqual(['ghost_field'])
  })
})

describe('buildDegradationNote', () => {
  it('汇总 SKIP/UNVERIFIED 条目成文本', () => {
    const note = buildDegradationNote([
      { check: 'data_feasibility', status: 'SKIP', reason: '无 product-context' },
      { check: 'competitive[0].url', status: 'UNVERIFIED', reason: '超时' },
    ])
    expect(note).toMatch(/data_feasibility/)
    expect(note).toMatch(/SKIP/)
    expect(note).toMatch(/UNVERIFIED/)
  })
  it('无降级条目 → 空串', () => {
    expect(buildDegradationNote([])).toBe('')
  })
})
```

- [ ] **Step 3: 运行验证失败**

Run: `pnpm test -- upstream-gate`
Expected: FAIL —— 三个函数未定义。

- [ ] **Step 4: 实现（加到纯函数区；findProductContext 用 fs walk）**

```javascript
// data_feasibility：contextFields === null（无 product-context）→ SKIP，不 FAIL（优雅降级）。
export function checkDataFeasibility(fields, contextFields) {
  if (contextFields === null) {
    return { verdict: 'SKIP', missing: [], detail: '无 product-context —— 该检降级（不阻断）' }
  }
  const set = new Set(contextFields)
  const missing = fields.filter((f) => !set.has(f))
  return missing.length
    ? { verdict: 'FAIL', missing, detail: `字段不在 product-context.available_data_fields：${missing.join(', ')}` }
    : { verdict: 'PASS', missing: [], detail: `全部 ${fields.length} 字段可行` }
}

// degradation note：把 SKIP/UNVERIFIED 条目汇成人可读文本（validator 输出到 stdout，PR 呈现）。
export function buildDegradationNote(entries) {
  if (!entries.length) return ''
  const lines = entries.map((e) => `  - [${e.status}] ${e.check} —— ${e.reason}`)
  return `降级说明（degradation_note，机写；这些检因缺 context/工具降级，非失败）：\n${lines.join('\n')}`
}

// 递归找消费/DS repo 里的 product-context.md（排除 node_modules / _*.template.md）。
function walkMd(dir, acc) {
  for (const name of readdirSync(dir)) {
    if (name === 'node_modules' || name === '.git' || name === 'dist') continue
    const abs = join(dir, name)
    let st
    try { st = statSync(abs) } catch { continue }
    if (st.isDirectory()) walkMd(abs, acc)
    else if (name === 'product-context.md') acc.push(abs)
  }
  return acc
}

export function findProductContext(repoRoot) {
  const hits = walkMd(repoRoot, [])
  if (!hits.length) return null
  const abs = hits[0]
  try {
    const { data } = parseFrontmatter(readFileSync(abs, 'utf8'))
    const fields = Array.isArray(data.available_data_fields) ? data.available_data_fields : []
    return { path: abs, availableFields: fields }
  } catch {
    return { path: abs, availableFields: [] }
  }
}
```

- [ ] **Step 5: 运行验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（新增 5 例全绿）。

- [ ] **Step 6: STOP —— 报告，等复审。**

---

## Task 5: main() 装配 + CLI + glob artifact + 退出码 + package.json script

**Files:**
- Modify: `scripts/validate-upstream-gate.mjs`（加 `findArtifacts` + `runGate` + `main`）
- Modify: `package.json`（加 `audit:upstream-gate` script）
- Modify: `tests/upstream-gate.test.ts`（加 `runGate` 集成测试，用 fixtures —— fixtures 在 Task 6 建，这里先测 findArtifacts + no-op）

**Interfaces:**
- Consumes: 全部前述纯函数。
- Produces:
  - `export function findArtifacts(root: string): string[]` — 递归找 `upstream-gate.*.md`（排除 `_*.template.md` / node_modules / .git / dist）。
  - `export async function runGate(artifactPath: string, opts: { repoRoot: string, fetchImpl?, network?: boolean }): Promise<{ artifact: string, verdict: 'PASS'|'FAIL', findings: any[], degradationNote: string }>` — 跑全部确定层检，聚合 verdict（任一 FAIL → FAIL；UNVERIFIED/SKIP 不 FAIL）。

- [ ] **Step 1: 加失败测试（findArtifacts + no-op）**

```typescript
// 追加到 tests/upstream-gate.test.ts
import { findArtifacts } from '../scripts/validate-upstream-gate.mjs'

describe('findArtifacts', () => {
  it('模板文件 _upstream-gate.template.md 不算 artifact', () => {
    // templates/consumer-product/docs/specs/_upstream-gate.template.md 存在但应被排除
    const repoRoot = resolve(__dirname, '..')
    const hits = findArtifacts(repoRoot)
    expect(hits.some((h) => h.includes('_upstream-gate.template.md'))).toBe(false)
  })
})
```

- [ ] **Step 2: 运行验证失败**

Run: `pnpm test -- upstream-gate`
Expected: FAIL —— `findArtifacts is not a function`。

- [ ] **Step 3: 实现 findArtifacts + runGate + main（加到 validate-upstream-gate.mjs）**

```javascript
// ───────────────────────── 装配（main + 可测的 runGate/findArtifacts） ─────────────────────────

// 递归找 upstream-gate.<feature>.md（排除模板 _*.template.md）。
function walkArtifacts(dir, acc) {
  for (const name of readdirSync(dir)) {
    if (name === 'node_modules' || name === '.git' || name === 'dist') continue
    const abs = join(dir, name)
    let st
    try { st = statSync(abs) } catch { continue }
    if (st.isDirectory()) walkArtifacts(abs, acc)
    else if (/^upstream-gate\..+\.md$/.test(name) && !name.startsWith('_')) acc.push(abs)
  }
  return acc
}
export function findArtifacts(root) {
  return walkArtifacts(root, [])
}

// 跑单个 artifact 的全部确定层检，聚合 verdict。
export async function runGate(artifactPath, { repoRoot, fetchImpl, network = true }) {
  const findings = []
  const degraded = []
  const text = readFileSync(artifactPath, 'utf8')
  const { data } = parseFrontmatter(text)
  const schema = loadSchema()

  // 1. 结构
  const structure = validateStructure(data, schema)
  for (const r of structure) if (!r.ok) findings.push({ check: `schema.${r.field}`, verdict: 'FAIL', detail: r.detail })

  // 结构挂了就不深入（字段类型不可信）。
  const structureOk = structure.every((r) => r.ok)

  if (structureOk) {
    const artifactDir = dirname(artifactPath)

    // 2. sources_read 锚点
    for (const ref of data.sources_read) {
      const r = checkSourceAnchor(ref, { repoRoot, artifactDir })
      if (!r.ok) findings.push({ check: `sources_read: ${ref}`, verdict: 'FAIL', detail: r.detail })
    }

    // 3. competitive 结构 + URL
    for (const r of validateCompetitiveItems(data.competitive)) {
      if (!r.ok) findings.push({ check: `competitive[${r.index}]`, verdict: 'FAIL', detail: r.detail })
    }
    if (network) {
      for (let i = 0; i < data.competitive.length; i++) {
        const it = data.competitive[i]
        if (typeof it?.url !== 'string') continue
        const r = await checkUrl(it.url, fetchImpl)
        if (r.verdict === 'FAIL') findings.push({ check: `competitive[${i}].url`, verdict: 'FAIL', detail: r.detail })
        else if (r.verdict === 'UNVERIFIED') degraded.push({ check: `competitive[${i}].url`, status: 'UNVERIFIED', reason: r.detail })
      }
    } else {
      degraded.push({ check: 'competitive[].url', status: 'UNVERIFIED', reason: '--no-network：跳过 URL 检查' })
    }

    // 4. data_feasibility
    const ctx = findProductContext(repoRoot)
    const df = checkDataFeasibility(data.data_feasibility.fields, ctx ? ctx.availableFields : null)
    if (df.verdict === 'FAIL') findings.push({ check: 'data_feasibility', verdict: 'FAIL', detail: df.detail })
    else if (df.verdict === 'SKIP') degraded.push({ check: 'data_feasibility', status: 'SKIP', reason: df.detail })
  }

  const verdict = findings.some((f) => f.verdict === 'FAIL') ? 'FAIL' : 'PASS'
  return { artifact: artifactPath, verdict, findings, degradationNote: buildDegradationNote(degraded) }
}

async function main() {
  const argPath = process.argv.find((a) => a.startsWith('--artifact='))
  const repoArg = process.argv.find((a) => a.startsWith('--repo-root='))
  const network = !process.argv.includes('--no-network')
  const repoRoot = repoArg ? resolve(repoArg.slice('--repo-root='.length)) : process.cwd()

  const artifacts = argPath
    ? [resolve(argPath.slice('--artifact='.length))]
    : findArtifacts(repoRoot)

  if (!artifacts.length) {
    console.log('validate-upstream-gate OK — 未发现 upstream-gate.<feature>.md（非产品设计 PR，no-op PASS）')
    process.exit(0)
  }

  let anyFail = false
  for (const a of artifacts) {
    const rel = relative(repoRoot, a)
    let result
    try {
      result = await runGate(a, { repoRoot, network })
    } catch (e) {
      console.error(`✗ ${rel} —— 解析失败：${e.message}`)
      anyFail = true
      continue
    }
    console.log(`\n── ${rel} ──`)
    if (result.verdict === 'PASS') {
      console.log(`  ✓ 确定层 PASS（${result.findings.length ? '' : '无 FAIL'}）`)
    } else {
      anyFail = true
      console.error(`  ✗ 确定层 FAIL：`)
      for (const f of result.findings) console.error(`      [${f.check}] ${f.detail}`)
    }
    if (result.degradationNote) console.log(`  ${result.degradationNote.replace(/\n/g, '\n  ')}`)
    console.log(`  → 人工层（understanding / persona_ia / mvp_scope + competitive 相关性）由 owner PR review 验收。`)
  }

  if (anyFail) {
    console.error('\nvalidate-upstream-gate FAIL — 修复确定层 finding 后重跑（人工层另由 owner review）')
    process.exit(1)
  }
  console.log('\nvalidate-upstream-gate OK — 确定层全过（人工层留 owner PR review）')
  process.exit(0)
}

if (import.meta.url === `file://${process.argv[1]}`) main()
```

- [ ] **Step 4: 加 package.json script**

在 `package.json` scripts 段 `"audit:stale-anchors"` 一行之后加：

```json
    "audit:upstream-gate": "node scripts/validate-upstream-gate.mjs",
```

- [ ] **Step 5: 运行验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（findArtifacts 例通过）。

- [ ] **Step 6: 手动跑 no-op（当前 repo 无真 artifact）**

Run: `pnpm audit:upstream-gate`
Expected: stdout `validate-upstream-gate OK — 未发现 upstream-gate.<feature>.md（非产品设计 PR，no-op PASS）`，exit 0。

- [ ] **Step 7: 再验 zero-dep 闸**

Run: `pnpm audit:scripts-stdlib`
Expected: PASS。

- [ ] **Step 8: STOP —— 报告，等复审。**

---

## Task 6: fixtures + 端到端伪造测试（DoD #2 / #4）

**Files:**
- Create: `scripts/__fixtures__/upstream-gate/pass.md`
- Create: `scripts/__fixtures__/upstream-gate/missing-field.md`
- Create: `scripts/__fixtures__/upstream-gate/dead-anchor-and-url.md`
- Create: `scripts/__fixtures__/upstream-gate/no-context-ok.md`
- Modify: `tests/upstream-gate.test.ts`（加 runGate 端到端，注入假 fetch）

**Interfaces:**
- Consumes: `runGate`（Task 5）+ Task 4 的 `product-context.md` fixture。

- [ ] **Step 1: 建 4 个 fixture artifact**

`scripts/__fixtures__/upstream-gate/pass.md`（sources 指向真实 repo 文件 + anchor；competitive URL 交给假 fetch；data fields ⊆ 同目录 product-context）：

```markdown
---
feature: fixture-pass
understanding: 用户要一个设备状态实时看板
sources_read: ["docs/PROJECT_GOAL.md#一句话目标", "docs/STATUS.md"]
competitive: [{"vendor":"LiveU","url":"https://liveu.tv","finding":"有 device health widget"}]
persona_ia: 现场导播 persona；IA = 顶层设备列表 + 详情抽屉
data_feasibility: {"fields": ["bitrate", "latency"]}
mvp_scope: 只做设备列表 + 状态灯，详情抽屉留 v2
---

# Upstream Gate — fixture-pass

## 需求复述（owner review）
用户要在现场导播场景下实时看多台编码器的健康状态……（完整复述正文，owner 核）
```

`scripts/__fixtures__/upstream-gate/missing-field.md`（缺 `mvp_scope` + `persona_ia`）：

```markdown
---
feature: fixture-missing
understanding: x
sources_read: ["docs/STATUS.md"]
competitive: [{"vendor":"LiveU","url":"https://liveu.tv","finding":"y"}]
data_feasibility: {"fields": []}
---

# 缺 persona_ia 和 mvp_scope
```

`scripts/__fixtures__/upstream-gate/dead-anchor-and-url.md`（死锚点 + 死 URL）：

```markdown
---
feature: fixture-dead
understanding: x
sources_read: ["docs/PROJECT_GOAL.md#这个标题根本不存在zzz"]
competitive: [{"vendor":"Ghost","url":"https://example.com/fake","finding":"z"}]
persona_ia: p
data_feasibility: {"fields": []}
mvp_scope: m
---

# 死锚点 + 占位 URL
```

`scripts/__fixtures__/upstream-gate/no-context-ok.md`（结构齐全，data fields 无 context 时应 SKIP 不 FAIL —— 注意此 fixture 放在**没有** product-context.md 的临时目录测，见测试）：

```markdown
---
feature: fixture-no-context
understanding: x
sources_read: ["docs/STATUS.md"]
competitive: [{"vendor":"LiveU","url":"https://liveu.tv","finding":"y"}]
persona_ia: p
data_feasibility: {"fields": ["some_field"]}
mvp_scope: m
---

# 无 context 降级
```

- [ ] **Step 2: 加端到端测试（注入假 fetch，repoRoot 用真实 repo）**

```typescript
// 追加到 tests/upstream-gate.test.ts
import { runGate } from '../scripts/validate-upstream-gate.mjs'

describe('runGate 端到端（DoD #2/#4）', () => {
  const repoRoot = resolve(__dirname, '..')
  const fx = resolve(repoRoot, 'scripts/__fixtures__/upstream-gate')
  const fakeFetchOk = async () => ({ ok: true, status: 200 }) as any

  it('pass fixture → 确定层 PASS', async () => {
    const r = await runGate(resolve(fx, 'pass.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('PASS')
  })

  it('缺必填字段 → FAIL（DoD #4）', async () => {
    const r = await runGate(resolve(fx, 'missing-field.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('FAIL')
    expect(r.findings.some((f) => f.check.includes('mvp_scope'))).toBe(true)
    expect(r.findings.some((f) => f.check.includes('persona_ia'))).toBe(true)
  })

  it('死锚点 + 占位 URL → FAIL（DoD #4）', async () => {
    const r = await runGate(resolve(fx, 'dead-anchor-and-url.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('FAIL')
    expect(r.findings.some((f) => f.check.startsWith('sources_read'))).toBe(true)
    expect(r.findings.some((f) => f.check.includes('competitive[0].url'))).toBe(true)
  })

  it('缺 product-context 时 data_feasibility SKIP 不 FAIL（DoD #2 降级）', async () => {
    // no-context-ok.md 的 data field 'some_field' 不在真 repo 任何 product-context；
    // 真 repo 根跑 findProductContext 应返回 null（repo 内无 product-context.md）→ SKIP。
    const r = await runGate(resolve(fx, 'no-context-ok.md'), { repoRoot, fetchImpl: fakeFetchOk })
    expect(r.verdict).toBe('PASS')
    expect(r.degradationNote).toMatch(/SKIP/)
  })
})
```

> **注意（executor 必读）**：`no-context-ok` 测试依赖「真 repo 根没有确切名 `product-context.md`」。Task 4 的 fixture 已命名 `_product-context.fixture.md`（非确切名，`findProductContext` 只匹配确切名 `product-context.md`，故不命中）。**建本 task 4 个 fixture 时，同样绝不要出现确切名 `product-context.md`**，否则本条测试会失败。

- [ ] **Step 3: 运行验证通过**

Run: `pnpm test -- upstream-gate`
Expected: PASS（全部 fixture 端到端例 + 前 5 task 例全绿）。

- [ ] **Step 4: 手动跑 validator 对 pass fixture（真实网络，验 --artifact + --no-network）**

Run: `pnpm audit:upstream-gate -- --artifact=scripts/__fixtures__/upstream-gate/pass.md --no-network`
Expected: 确定层 PASS（`--no-network` 下 competitive URL 记 UNVERIFIED 降级，不 FAIL），exit 0。

- [ ] **Step 5: STOP —— 报告全套测试 stdout，等复审。**

---

## Task 7: DS repo Gitea CI 挂载 + PR 模板 owner review checklist

**Files:**
- Modify: `.gitea/workflows/pr-checks.yml`
- Modify: `.gitea/PULL_REQUEST_TEMPLATE.md`

**Interfaces:**
- Consumes: `pnpm audit:upstream-gate`（Task 5）。

- [ ] **Step 1: 在 `.gitea/workflows/pr-checks.yml` 的 `Design system audits` step 之后加一个 step**

在文件末尾（`Design system audits (prepublishOnly gates)` step 之后）追加：

```yaml
      - name: Upstream understanding-analysis gate (支柱④)
        # chokepoint-bound：artifact 存在才校，无则 no-op PASS。纯 node 校验，Gitea runner 无 root 亦可跑。
        run: pnpm audit:upstream-gate
```

- [ ] **Step 2: 在 `.gitea/PULL_REQUEST_TEMPLATE.md` 的「检查清单」段后加 owner 人工核段**

在 `## 检查清单` 段之后、`## 备注` 之前插入：

```markdown
## 上游理解-分析 Gate（仅产品设计 PR — 有 `upstream-gate.<feature>.md` 时）

> 确定层由 `pnpm audit:upstream-gate` 机校（PR CI 跑）；**以下人工层由 owner review 验收，不可自 ack 伪造**：

- [ ] **understanding**：需求复述正确、抓住真实意图（不是复述表面字面）
- [ ] **persona_ia**：persona 真实、IA 合理、覆盖主要使用场景
- [ ] **mvp_scope**：MVP 切分合理，没把非核心塞进首版
- [ ] **competitive 相关性**：竞品发现是真·同类产品的真·相关能力（URL 可达由机校，相关性人核）
- [ ] degradation_note（若有）已知悉：哪些确定层检因缺 product-context / 网络降级
```

- [ ] **Step 3: 验证 YAML 合法**

Run: `node -e "const {readFileSync}=require('node:fs'); const t=readFileSync('.gitea/workflows/pr-checks.yml','utf8'); if(!t.includes('audit:upstream-gate')) process.exit(1); console.log('gitea workflow step OK')"`
Expected: stdout `gitea workflow step OK`。

- [ ] **Step 4: STOP —— 报告 diff，等复审。**

---

## Task 8: 消费 scaffold 模板（artifact + product-context + 消费方 CI workflow）

**Files:**
- Create: `templates/consumer-product/docs/specs/_upstream-gate.template.md`
- Create: `templates/consumer-product/docs/specs/_product-context.template.md`
- Create: `templates/upstream-gate-workflow.yml`

**Interfaces:**
- Consumes: schema 字段（Task 1）。
- Produces: 供 SETUP_GUIDE Step 2.9（Task 9）复制的模板文件。

- [ ] **Step 1: 建 artifact 模板 `templates/consumer-product/docs/specs/_upstream-gate.template.md`**

```markdown
---
# 支柱④ 上游理解-分析 gate 产出物。产品设计（code/mockup）动手前填。
# 命名：docs/specs/upstream-gate.<feature>.md（<feature> = kebab 任务标识）。
# 确定层字段（sources_read / competitive / data_feasibility）由 pnpm audit:upstream-gate 机校；
# 人工层（understanding / persona_ia / mvp_scope）由 owner PR review 验收。
# 复杂字段用 JSON-inline（[...] / {...}）—— validator 靠 JSON.parse 读。
feature: <kebab-slug>
understanding: <一句话需求复述摘要；完整复述写正文>
sources_read: ["docs/product-context.md", "docs/specs/<相关 design-record>.md#<锚>"]
competitive: [{"vendor": "<竞品名>", "url": "https://<真实竞品页>", "finding": "<该竞品在此场景做了什么>"}]
persona_ia: <persona + IA 摘要；详述写正文>
data_feasibility: {"fields": ["<本设计要用的数据字段>"]}
mvp_scope: <MVP 范围摘要；详述写正文>
---

# Upstream Gate — <Feature Name>

## 1. 需求复述（owner review：复述是否抓住真实意图）
<完整复述用户/PRD 的需求，你理解的问题、约束、成功标准。>

## 2. 竞品分析（实时检索，附来源；owner review：相关性）
<对每个 competitive 条目展开：截图/引用 + 我们要借鉴/规避什么。禁脑补，URL 必真。>

## 3. Persona / IA（owner review）
<目标 persona、使用场景、信息架构草图。>

## 4. 数据可行性
<data_feasibility.fields 每个字段的来源（API/字段名）；缺 product-context 时此段自评 + 标注降级。>

## 5. MVP 范围（owner review）
<首版做什么、不做什么、为什么。>
```

- [ ] **Step 2: 建 product-context 模板 `templates/consumer-product/docs/specs/_product-context.template.md`**

```markdown
---
# 消费产品「活的」上下文（可选但强烈建议）。填好后复制/重命名为 docs/product-context.md
# （确切文件名 product-context.md —— validator 靠此名发现它）。
# 有它 → upstream-gate 的 data_feasibility 确定性检激活（强 gate）；缺它 → 该检降级为 SKIP。
product: <产品名>
# 本产品后端/API 真实可用的数据字段（upstream-gate.data_feasibility.fields 会对照此清单）。
available_data_fields: ["<field-1>", "<field-2>"]
---

# <产品名> Product Context

## 产品定位 / roadmap 摘要
<这个产品是什么、当前阶段、近期方向。>

## 可用数据字段说明
<available_data_fields 每个字段的语义、来源 API、单位/取值范围。让上游分析判断数据可行性。>

## 已知约束
<平台/权限/性能/合规等约束。>
```

- [ ] **Step 3: 建消费方 CI workflow 模板 `templates/upstream-gate-workflow.yml`（对齐 `templates/audit-workflow.yml` 风格）**

```yaml
# Consumer-side TVU upstream-gate check (GitHub Actions template)
#
# ⚠️ THIS IS A TEMPLATE — copy to your consumer product repo's
# `.github/workflows/upstream-gate.yml`. (Gitea consumers: 用等价 .gitea/workflows/ 语法。)
#
# What this does:
#   - On every PR + push, runs the TVU upstream understanding-analysis gate validator.
#   - chokepoint-bound: only checks when a `docs/specs/upstream-gate.<feature>.md` exists
#     in the diff/repo; otherwise no-op PASS (not every PR is a product-design task).
#   - Deterministic layer only (schema / sources_read anchors / competitive URLs /
#     data_feasibility vs product-context). Human layer (understanding/persona/MVP) =
#     owner PR review.
#
# Prerequisites in the consumer repo:
#   1. `@nancyzeng0210/tvu-design-system` is a dependency.
#   2. Product-design artifacts live at `docs/specs/upstream-gate.<feature>.md`.
#   3. (Optional, stronger gate) `docs/product-context.md` with `available_data_fields`.

name: Upstream Gate

on:
  pull_request:
    branches: [main, master]
  push:
    branches: [main, master]

jobs:
  upstream-gate:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 10

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run upstream-gate validator
        # 从 node_modules 里跑随包发的 validator，仓库根为 repo-root。
        run: node node_modules/@nancyzeng0210/tvu-design-system/scripts/validate-upstream-gate.mjs --repo-root=.
```

- [ ] **Step 4: 验证模板文件语法**

Run: `node -e "const {readFileSync}=require('node:fs'); for (const f of ['templates/consumer-product/docs/specs/_upstream-gate.template.md','templates/consumer-product/docs/specs/_product-context.template.md','templates/upstream-gate-workflow.yml']) { readFileSync(f,'utf8'); } console.log('templates readable OK')"`
Expected: stdout `templates readable OK`。

- [ ] **Step 5: 验证 artifact 模板不被 validator 当真 artifact（模板带 `_` 前缀）**

Run: `pnpm audit:upstream-gate`
Expected: 仍是 no-op PASS（`_upstream-gate.template.md` 被 findArtifacts 排除；fixtures 在 `scripts/__fixtures__/` 也不在默认扫描——注意：findArtifacts 从 repoRoot=cwd 递归会扫到 fixtures！见下）。

> **executor 注意**：`findArtifacts` 递归全 repo 会命中 `scripts/__fixtures__/upstream-gate/pass.md` 等（不带 `_` 前缀、匹配 `upstream-gate.*.md`？实际文件名是 `pass.md` 不匹配 `^upstream-gate\.`）。fixture 命名 `pass.md`/`missing-field.md` **不匹配** `^upstream-gate\..+\.md$`，故不会被扫到。✓ 无需额外排除。若担心，可确认：`pnpm audit:upstream-gate` 输出应为 no-op PASS。

- [ ] **Step 6: STOP —— 报告，等复审。**

---

## Task 9: git-remote 探测脚本 + SETUP_GUIDE Step 2.9 接线

**Files:**
- Create: `scripts/scaffold-git-remote.mjs`
- Create: `tests/scaffold-git-remote.test.ts`
- Modify: `templates/consumer-product/SETUP_GUIDE.md`

**Interfaces:**
- Produces: `export function decideRemoteAction(state: { hasRepo: boolean, hasRemote: boolean, existingRemote?: string }, opts: { projectName: string, giteaHost: string }): { action: 'use-existing'|'suggest-default'|'init-then-suggest', message: string, suggestedRemote?: string }` — 纯决策函数（不执行 git，只决定动作 + 消息）。

- [ ] **Step 1: 写失败测试 `tests/scaffold-git-remote.test.ts`**

```typescript
// tests/scaffold-git-remote.test.ts
import { describe, it, expect } from 'vitest'
import { decideRemoteAction } from '../scripts/scaffold-git-remote.mjs'

const opts = { projectName: 'my-product', giteaHost: 'product-demo.tvustream.com:3001' }

describe('decideRemoteAction', () => {
  it('已有 remote → use-existing，绝不覆盖', () => {
    const r = decideRemoteAction({ hasRepo: true, hasRemote: true, existingRemote: 'git@github.com:me/x.git' }, opts)
    expect(r.action).toBe('use-existing')
    expect(r.message).toMatch(/git@github.com:me\/x\.git/)
    expect(r.suggestedRemote).toBeUndefined()
  })

  it('有 repo 无 remote → suggest-default（Gitea + 项目名，用户可覆盖）', () => {
    const r = decideRemoteAction({ hasRepo: true, hasRemote: false }, opts)
    expect(r.action).toBe('suggest-default')
    expect(r.suggestedRemote).toBe('product-demo.tvustream.com:3001/ux-team/my-product.git')
    expect(r.message).toMatch(/可覆盖|确认/)
  })

  it('无 repo → init-then-suggest', () => {
    const r = decideRemoteAction({ hasRepo: false, hasRemote: false }, opts)
    expect(r.action).toBe('init-then-suggest')
    expect(r.suggestedRemote).toBe('product-demo.tvustream.com:3001/ux-team/my-product.git')
  })
})
```

- [ ] **Step 2: 运行验证失败**

Run: `pnpm test -- scaffold-git-remote`
Expected: FAIL —— 模块不存在。

- [ ] **Step 3: 写 `scripts/scaffold-git-remote.mjs`**

```javascript
#!/usr/bin/env node
// scaffold-git-remote.mjs
// 支柱④ 落点自举：消费产品 scaffold 时探测 git remote，让 greenfield 从第一天就有 chokepoint。
// 护栏（spec §6.2，不可违背）：消费者已有 remote 永远优先；仅缺 remote 时「建议」默认 Gitea 且用户可覆盖；
// 无 repo 才 git init。绝不强制覆盖已有 remote。
//
// 决策纯函数 decideRemoteAction 可单测；main() 只做探测 + 打印建议，不擅自改 git（对齐 SETUP_GUIDE
// 「不自动改配置、只报告」doctrine）。真正执行 remote 设置由用户确认后手动/下一轮做。
//
// ESM + zero-dep（仅 node: 内建）。
import { execSync } from 'node:child_process'
import { basename } from 'node:path'

const DEFAULT_GITEA_HOST = 'product-demo.tvustream.com:3001'

// 纯决策：给定 git 状态 + 选项，返回动作 + 人可读消息（不执行 git）。
export function decideRemoteAction(state, { projectName, giteaHost }) {
  const suggestedRemote = `${giteaHost}/ux-team/${projectName}.git`
  if (state.hasRepo && state.hasRemote) {
    return {
      action: 'use-existing',
      message: `已检测到 git remote：${state.existingRemote}。直接用它作为 upstream-gate CI 的落点，不改动。`,
    }
  }
  if (state.hasRepo && !state.hasRemote) {
    return {
      action: 'suggest-default',
      suggestedRemote,
      message: `当前 repo 无 remote。建议（可覆盖 / 需你确认）设为默认 Gitea：\n    git remote add origin ${suggestedRemote}\n（若你的产品有别的 git 托管，用你自己的地址，别用这个默认。）`,
    }
  }
  return {
    action: 'init-then-suggest',
    suggestedRemote,
    message: `当前目录不是 git repo。建议：\n    git init\n    git remote add origin ${suggestedRemote}\n（remote 地址可覆盖 —— 有自己的托管就用自己的。）`,
  }
}

function probeGitState(cwd) {
  const run = (cmd) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
  let hasRepo = false
  try { run('git rev-parse --is-inside-work-tree'); hasRepo = true } catch { hasRepo = false }
  let hasRemote = false
  let existingRemote
  if (hasRepo) {
    try { existingRemote = run('git remote get-url origin'); hasRemote = !!existingRemote } catch { hasRemote = false }
  }
  return { hasRepo, hasRemote, existingRemote }
}

function main() {
  const cwd = process.cwd()
  const projectName = basename(cwd)
  const state = probeGitState(cwd)
  const decision = decideRemoteAction(state, { projectName, giteaHost: DEFAULT_GITEA_HOST })
  console.log(`upstream-gate 落点探测（${decision.action}）：`)
  console.log(`  ${decision.message.replace(/\n/g, '\n  ')}`)
  console.log('\n  ⚠ 本脚本只建议不动手（对齐 SETUP_GUIDE doctrine）——由你确认后设置。')
}

if (import.meta.url === `file://${process.argv[1]}`) main()
```

- [ ] **Step 4: 运行验证通过**

Run: `pnpm test -- scaffold-git-remote`
Expected: PASS（3 例全绿）。

- [ ] **Step 5: 在 `templates/consumer-product/SETUP_GUIDE.md` 加 Step 2.9**

在 `## Step 2.6. TVU 规范强制接线` 段之后、`## Step 3.` 之前插入：

````markdown
## Step 2.9. upstream-gate 接线（支柱④ 上游理解-分析 gate）

> **变体 A + B 均执行**（design-only 也产 mockup，同样要走上游 gate）。

**为什么**：产品设计（code/mockup）动手前强制走上游理解-分析并产出 `docs/specs/upstream-gate.<feature>.md`，其确定层由 validator 机校、理解层由 owner PR review 兜底。gate 的机械 reach = 消费 repo 是否成为 chokepoint（有 git remote + CI）。

### (a) ship 模板（idempotent，已存在不覆盖）

```bash
mkdir -p docs/specs
[ ! -f docs/specs/_upstream-gate.template.md ] && cp "$TEMPLATE/docs/specs/_upstream-gate.template.md" docs/specs/_upstream-gate.template.md
[ ! -f docs/specs/_product-context.template.md ] && cp "$TEMPLATE/docs/specs/_product-context.template.md" docs/specs/_product-context.template.md
# CI workflow 模板（复制到消费 repo 的 .github/workflows/，Gitea consumer 改等价语法）
mkdir -p .github/workflows
[ ! -f .github/workflows/upstream-gate.yml ] && cp "$TVU/templates/upstream-gate-workflow.yml" .github/workflows/upstream-gate.yml
```

### (b) git-remote 探测（落点自举 —— 消费者已有 remote 永远优先）

```bash
# 只探测 + 建议，绝不强制覆盖已有 remote（护栏 spec §6.2）。
node "$TVU/scripts/scaffold-git-remote.mjs"
```

按脚本输出：
- **已有 remote** → 什么都不用做，upstream-gate CI 直接挂在该 repo。
- **有 repo 无 remote** → 脚本建议默认 Gitea 地址；**用户确认或换成自己的托管**后手动 `git remote add origin <地址>`。
- **无 repo** → 脚本建议 `git init` + remote；用户确认后手动执行。

### (c) 报告

```
✅ upstream-gate 接线（支柱④）
   - docs/specs/_upstream-gate.template.md: ✅ 已 ship / 已存在
   - docs/specs/_product-context.template.md: ✅ 已 ship / 已存在（填好复制成 docs/product-context.md 激活强 gate）
   - .github/workflows/upstream-gate.yml: ✅ 已 ship / 已存在
   - git remote 落点: <use-existing 用现有 / suggest-default 待用户确认 / init-then-suggest 待 git init>
```
````

同步更新 SETUP_GUIDE 顶部「后续 step 适用矩阵」表 + Step 5 报告段：把 upstream-gate 列进两处（变体 A + B 均 ✅）。

- [ ] **Step 6: 验证 zero-dep + 模板可读**

Run: `pnpm audit:scripts-stdlib && node -e "require('node:fs').readFileSync('templates/consumer-product/SETUP_GUIDE.md','utf8').includes('Step 2.9') || process.exit(1); console.log('SETUP_GUIDE Step 2.9 OK')"`
Expected: scripts-stdlib PASS + stdout `SETUP_GUIDE Step 2.9 OK`。

- [ ] **Step 7: STOP —— 报告，等复审。**

---

## Task 10: L3 CC skill（skills/upstream-gate）

**Files:**
- Create: `skills/upstream-gate/SKILL.md`

**Interfaces:**
- Consumes: schema 字段（Task 1）+ `design-discovery`/`design-walkthrough` deliverable（既有 skill）+ `pnpm audit:upstream-gate`（Task 5）。

- [ ] **Step 1: 写 `skills/upstream-gate/SKILL.md`**

```markdown
---
name: upstream-gate
description: 支柱④ 上游理解-分析 gate（L3 Claude Code 适配器）。产品设计（code/mockup）动手前强制走：需求复述+理解确认 → 读真源 → 竞品实时检索 → persona/IA → 数据可行性 → MVP scope → 产出 docs/specs/upstream-gate.<feature>.md + 本地跑 validator。触发：任何"做产品设计 / 画 mockup / 写产品页面"任务的最前置。
---

# 支柱④ 上游理解-分析 Gate（L3 适配器）

> 这是**产出 code/mockup 之前**的强制前置。产出物 = `docs/specs/upstream-gate.<feature>.md`（schema = `scripts/upstream-gate.schema.json`）。确定层由 `pnpm audit:upstream-gate` 机校、理解/质量层由 owner PR review 验收。**不可静默跳过。**

## 何时触发

任何"做产品设计 / 画 mockup / 写产品页面 / 加新 feature"的任务，**在动手产出前**先走本 gate。已有 artifact 且需求没变则复用。

## 流程（产出 artifact 的 7 步）

1. **需求复述 + 理解确认**：把用户/PRD 的需求用自己的话复述，列出你理解的问题/约束/成功标准。**当场向用户确认复述是否正确**（human 在场时这就是理解层的实时确认）。→ 填 `understanding`（front-matter 摘要 + 正文完整复述）。
2. **读真源**：读消费产品的 `docs/product-context.md`（若有）+ 相关 design-record / PRD / roadmap。→ 填 `sources_read: ["path#anchor"]`（validator 会校锚点存在）。
3. **竞品实时检索**：用 WebSearch **实时检索**同类产品（TVU 域已验证可查：LiveU / Dejero / Grabyo / Teradek / QuickLink / Cingularity），带真实 URL + 引用。**禁脑补** URL。→ 填 `competitive: [{vendor,url,finding}]`。
4. **persona / IA**：目标 persona + 信息架构。可调用既有 `design-discovery` skill 的 6 deliverable（personas/scenarios/pain/stories/market/IA）产出，喂进本字段。→ 填 `persona_ia`。
5. **数据可行性**：本设计要用的数据字段是否真实可得（对照 `docs/product-context.md` 的 `available_data_fields`）。→ 填 `data_feasibility.fields`。
6. **MVP scope**：首版做什么/不做什么。→ 填 `mvp_scope`。
7. **产出 + 自校 + user 校验**：写 `docs/specs/upstream-gate.<feature>.md`（用 `docs/specs/_upstream-gate.template.md` 模板）→ 本地跑 `pnpm audit:upstream-gate --artifact=docs/specs/upstream-gate.<feature>.md`（或 consumer 里 `node node_modules/@nancyzeng0210/tvu-design-system/scripts/validate-upstream-gate.mjs --repo-root=.`）→ 确定层 PASS 后把 artifact 与 code/mockup 同 PR 提交，理解/质量层由 owner review。

## 与既有 skill 的关系

- `design-discovery`（6 deliverable）+ `design-walkthrough`（6-section 走查）**不废** —— 它们是本 gate 步骤 4/6 的**内容来源**，产出喂进 artifact 字段。本 gate 的增量 = 把这些从"AI 自判可跳过的 skill"提升为 **artifact + chokepoint PR 强制**。

## 护栏

- validator 只机校确定层（schema/锚点/URL/数据字段）；**理解/persona/MVP 质量绝不自 ack**，留 owner PR review。
- 竞品 URL 必真、必实时检索（[[feedback_verify-resource-availability-before-workaround]]）；工具超时/无返回时 **STOP + 标 UNVERIFIED，禁脑补**（[[feedback_no-fabrication-on-uncertain-tool-output]]）。
- 缺 `docs/product-context.md` 不阻断（data_feasibility 降 SKIP），但会在 PR degradation_note 标出。
```

- [ ] **Step 2: 验证 skill lint 通过**

Run: `pnpm lint-skills`
Expected: PASS（frontmatter name+description 合法、内部链接无孤儿）。

> **executor 注意**：若 `lint-skills` 对 `[[feedback_*]]` memory 链接报错（那是 Claude Code memory slug，非仓库文件），改为普通文字描述去掉 `[[]]` 语法，或确认 lint-skills 是否忽略 `[[]]`。先跑看结果再定。

- [ ] **Step 3: STOP —— 报告，等复审。**

---

## Task 11: claude-design-bundle 注入 schema + L1 Project-Instructions 段

**Files:**
- Create: `docs/internal/upstream-gate-l1-instructions.md`
- Modify: `scripts/export-claude-design-bundle.mjs`

**Interfaces:**
- Consumes: `scripts/upstream-gate.schema.json`（Task 1）。
- Produces: bundle 内 `reference/14-upstream-gate.md` + `reference/upstream-gate.schema.json`。

- [ ] **Step 1: 写 L1 instructions 源 `docs/internal/upstream-gate-l1-instructions.md`**

```markdown
# 支柱④ 上游理解-分析 Gate — L1 Project-Instructions（claude.ai / 其它工具）

> 你（AI）在用 TVU 设计系统产出**产品设计（mockup / code）之前**，必须先走上游理解-分析并产出一份 `upstream-gate.<feature>.md`（字段见同包 `reference/upstream-gate.schema.json`）。**不可跳过。**

## 为什么（L1 边界诚实说明）

claude.ai 等无 git/PR 的环境**没有机械闸**。本 gate 在 L1 靠两条：
1. **会话内人类实时理解确认**：你复述需求 → **当场请人类确认**（human 在场，这就是理解层验收）。
2. 若产出被 commit 进采纳了 gate 的 repo（有 CI），那里的 validator + owner review 才补上确定层机校。
产出永不落任何 repo 的纯 informal 使用 → 无机械 gate，也**不假装有**。

## 产出物字段（照 schema 填）

- `feature`：任务 kebab 标识。
- `understanding`：需求复述（**请人类确认**）。
- `sources_read`：读过的真源清单。
- `competitive`：竞品实时检索 `[{vendor,url,finding}]` —— **真实 URL、真实检索，禁脑补**。
- `persona_ia`：persona + 信息架构。
- `data_feasibility.fields`：要用的数据字段（对照产品 context 是否可得）。
- `mvp_scope`：首版范围。

## 7 步流程

需求复述+人类确认 → 读真源 → 竞品实时检索(带 URL) → persona/IA → 数据可行性 → MVP scope → 产出 artifact + 请人类校验。

## 护栏

- 理解/persona/MVP 质量由**人类**验收（你不自判通过）。
- 竞品必实时检索、URL 必真；拿不到就标注不确定，**不脑补**。
- 缺产品 context 不阻断，但要标注哪些判断降级了。
```

- [ ] **Step 2: 在 `export-claude-design-bundle.mjs` 的 `REFERENCE_DOCS` 数组末尾加一条**

```javascript
  ['docs/internal/icon-naming-convention.md', '13-icon-naming-convention.md'], // 图标命名原则
  ['docs/internal/upstream-gate-l1-instructions.md', '14-upstream-gate.md'], // 支柱④ 上游 gate L1 引导
]
```

- [ ] **Step 3: 在 `main()` 里 `for (const [src, name] of REFERENCE_DOCS)` 之后加一句注入 schema**

在 `for (const [src, name] of REFERENCE_DOCS) copyInto(src, \`reference/${name}\`, false)` 这一行**之后**加：

```javascript
  // 支柱④ upstream-gate schema（契约文档 —— AI 读结构，bundle 端不执行 validator）
  copyInto('scripts/upstream-gate.schema.json', 'reference/upstream-gate.schema.json', false)
```

- [ ] **Step 4: 跑导出验证注入生效**

Run: `pnpm export:claude-design-bundle --no-zip --out=/tmp/tvu-bundle-test`
Expected: stdout 无 error；`ls /tmp/tvu-bundle-test/reference/ | grep -E '14-upstream-gate|upstream-gate.schema'` 应列出两文件。

- [ ] **Step 5: 验证 bundle 导出脚本仍 zero-dep + 现有测试无破坏**

Run: `pnpm audit:scripts-stdlib && pnpm test -- upstream-gate`
Expected: 均 PASS。

- [ ] **Step 6: STOP —— 报告，等复审。**

---

## 收尾（全 task 完成后 · plan owner + 用户 ack 后执行）

- [ ] **全套回归**：`pnpm test`（全量，不只 upstream-gate）+ `pnpm audit:scripts-stdlib` + `pnpm audit:upstream-gate` + `pnpm audit:stale-anchors`（新 skill/文档的锚点）+ `pnpm build`（纪律②：改代码后除 vitest 还要 build 确认无破坏）。
- [ ] **精确 git add**（并行 session 共享工作树，禁 `git add -A`）：逐一 add 本 plan 新建/修改的文件。
- [ ] **双 remote push**（owner 直推 master）：github + gitea。
- [ ] **更新 STATUS.md**：Last updated + 支柱④ shipped 摘要（旧摘要 prepend 进 STATUS-CHANGELOG）；backlog INFRA-F55 支柱④ 标 shipped。
- [ ] **验证 DoD**（spec §9）逐条对实物核：validator 随包发（`files:["scripts"]` 已含）+ 挂 chokepoint CI（Gitea pr-checks step + 消费 workflow 模板）+ 样例通过/降级正确 + L3+L1 适配器落地 + 伪造测试 FAIL + PR 模板 owner checklist。

---

## Self-Review（写完本 plan 后的自审记录）

**1. Spec 覆盖**（逐 spec 节点对 task）：
- §3 artifact 字段 → Task 1 schema + 模板 Task 8。✓
- §4 validator 确定层（schema/sources_read/URL timeout 不脑补/data_feasibility SKIP 不 FAIL）→ Task 1-5。✓
- §5 降级阶梯 L3/L1 → Task 10（L3 skill）+ Task 11（L1 bundle）；L2 Codex 明确留后（spec §9 DoD #3「L2 可随后」）。✓
- §6.1 骑 scaffold 下发（validator 进 scripts/ 随包 · CI 片段进 templates · artifact+context 模板进 templates · setup 接线）→ Task 8 + 9。✓
- §6.2 git-remote 探测（已有优先/建议默认/init）→ Task 9。✓
- §6.3 product-context 可选优雅降级 → Task 4 + 8。✓
- §7 复用 design-discovery/design-walkthrough → Task 10 明确不废、作内容来源。✓
- §9 DoD 全条 → 收尾段逐条核。✓
- §10 bundle 带 schema + Project-Instructions → Task 11。✓

**2. Placeholder 扫描**：无 TBD / "add error handling" / "similar to Task N"；每 code step 给完整实现。competitive URL 缓存策略（spec §10 末条）**明确不做**（YAGNI，spec 列为"后续"非本 plan DoD）——在此标注为已知未覆盖项，非遗漏。

**3. 类型一致性**：`checkUrl` verdict 枚举 `PASS|FAIL|UNVERIFIED` 全 task 一致；`checkDataFeasibility` verdict `PASS|FAIL|SKIP`；`runGate` 返回 `verdict: PASS|FAIL`（UNVERIFIED/SKIP 归入 degradationNote 不进 top verdict）；`decideRemoteAction` action `use-existing|suggest-default|init-then-suggest` test 与实现一致；`parseFrontmatter` 返回 `{data,body}` 全引用一致。✓

**已知风险 / executor 须留意**：
- fixture 命名铁律：product-context fixture 必须叫 `_product-context.fixture.md`，绝不用确切名 `product-context.md`（否则被 `findProductContext` 全-repo walk 命中，破坏 Task 6 no-context 降级测试）。已在 Task 4 Step 1 + Task 6 注明。
- Task 10 Step 2：`[[memory-slug]]` 可能触发 lint-skills → 先跑看结果，必要时去 `[[]]`。
- 全局 `fetch` 是否被 `audit:scripts-stdlib` 判违规：它是全局非 import，预期放行；Task 1 Step 6 首次验证，若 FAIL 则改用 `node:https`（fallback 方案，executor 遇到再处理）。
