# Requirement Provenance Index — 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:** 让 AI/人免全局搜索就能双向定位「需求 ↔ Jira/Slack/Miro ↔ Figma 节点 ↔ design-record/handoff 文档」,并承载同一需求跨期逻辑变更。

**Architecture:** 三层——(A) design-record + handoff 两类文档带 v2 YAML frontmatter(真源);(B) 零依赖 Node 脚本扫描解析合并、派生 `REQUEST-INDEX.md` + `request-index.json`;(C) FIGMA_LINKS.md 起手约定 + 回流 tvu-design-system 消费产品标准。

**Tech Stack:** Node v24(内置 `node:test`、`node:fs`,无 npm 依赖);Markdown/JSON;YAML frontmatter(受控子集,脚本自解析)。

## Global Constraints

- **零 npm 依赖**:脚本只用 Node 内置模块;测试用 `node --test`。
- **TVU Pack 非 git repo** → 该项目内所有步骤**不做 git commit**(落盘即可)。仅 Task 5(tvu-design-system)是 git repo,按其纪律 commit + push gitea。
- **frontmatter schema v2 字段**(verbatim):`id`(slug 主键,`[a-z0-9-]+`)、`title`、`doc_type`(`design-record`|`handoff`)、`status`(`requirement`|`in-dev`|`QA`|`delivered`)、`target_release`(可选 str)、`last_updated`(`YYYY-MM-DD`)、`sources`(list of `{type,ref,url?}`,type ∈ `jira|slack|miro|bug|email|verbal|doc|other`,`ref` 必填)、`figma`(可选 `{file,nodes:[{id,label,url}]}`)。
- **同一需求的多篇文档共用同一 `id`**;索引按 `id` 归组。
- 索引产物**不手改**,只由脚本生成;确定性排序(`last_updated` 倒序,同日按 id)。
- 只覆盖 `docs/specs/`(design-record)+ `docs/handoffs/`;`decisions/`/`retrospects/`/`audits/`/batch-plan/`-spec.md` 之外的非 design-record 文件不纳入。

---

## File Structure

| 文件 | 职责 | 动作 |
|---|---|---|
| `docs/scripts/build-request-index.mjs` | frontmatter 解析 + 扫描 + 按 id 合并 + 出 md/json + 校验 | Create |
| `docs/scripts/build-request-index.test.mjs` | node:test 固定装置测试(解析器 + 合并 + 校验) | Create |
| `docs/scripts/_fixtures/` | 测试用样例 md | Create |
| `docs/specs/*.md`(design-record)| 加 v2 frontmatter 头 | Modify |
| `docs/handoffs/*.md` | 加 v2 frontmatter 头 | Modify |
| `docs/REQUEST-INDEX.md` / `docs/request-index.json` | 派生产物 | Generate |
| `docs/FIGMA_LINKS.md` | 顶部加起手约定段 | Modify |
| tvu-design-system 模板 + conventions | 真源回写(独立步) | Modify(另 repo) |

---

## Task 1: 索引生成器 + 测试

**Files:**
- Create: `docs/scripts/build-request-index.mjs`
- Test: `docs/scripts/build-request-index.test.mjs`
- Create: `docs/scripts/_fixtures/sample-spec.md`, `docs/scripts/_fixtures/sample-handoff.md`

**Interfaces:**
- Produces: `parseFrontmatter(text) → {data, errors[]}`、`mergeById(records) → requirement[]`、`renderMarkdown(reqs) → string`、`renderJson(reqs) → object[]`。CLI:`node docs/scripts/build-request-index.mjs [--check]`(`--check` 只校验不写)。

- [ ] **Step 1: 建两个 fixture**

`docs/scripts/_fixtures/sample-spec.md`:
```markdown
---
id: demo-feature
title: Demo Feature
doc_type: design-record
status: in-dev
target_release: "8.3"
last_updated: 2026-07-06
sources:
  - { type: jira, ref: "V4-9999", url: "https://x/browse/V4-9999" }
  - { type: slack, ref: "thread 123 @CHAN" }
figma:
  file: ABC123
  nodes:
    - { id: "1:2", label: "flow", url: "https://figma/x?node-id=1-2" }
---
# body
```
`docs/scripts/_fixtures/sample-handoff.md`:
```markdown
---
id: demo-feature
title: Demo Feature — handoff
doc_type: handoff
status: in-dev
last_updated: 2026-07-05
sources:
  - { type: jira, ref: "V4-9999" }
---
# body
```

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

`docs/scripts/build-request-index.test.mjs`:
```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseFrontmatter, mergeById } from './build-request-index.mjs';
import { readFileSync } from 'node:fs';

test('parseFrontmatter reads scalars, typed sources, figma nodes', () => {
  const txt = readFileSync(new URL('./_fixtures/sample-spec.md', import.meta.url), 'utf8');
  const { data, errors } = parseFrontmatter(txt);
  assert.equal(errors.length, 0);
  assert.equal(data.id, 'demo-feature');
  assert.equal(data.doc_type, 'design-record');
  assert.equal(data.target_release, '8.3');
  assert.equal(data.sources.length, 2);
  assert.equal(data.sources[0].type, 'jira');
  assert.equal(data.sources[0].ref, 'V4-9999');
  assert.equal(data.sources[1].url, undefined);
  assert.equal(data.figma.file, 'ABC123');
  assert.equal(data.figma.nodes[0].id, '1:2');
});

test('missing required field is reported, not thrown', () => {
  const { errors } = parseFrontmatter('---\ntitle: X\n---\n');
  assert.ok(errors.some(e => e.includes('id')));
});

test('mergeById groups spec + handoff under one requirement', () => {
  const spec = parseFrontmatter(readFileSync(new URL('./_fixtures/sample-spec.md', import.meta.url),'utf8')).data;
  const ho = parseFrontmatter(readFileSync(new URL('./_fixtures/sample-handoff.md', import.meta.url),'utf8')).data;
  spec._path = 'docs/specs/sample-spec.md';
  ho._path = 'docs/handoffs/sample-handoff.md';
  const reqs = mergeById([spec, ho]);
  assert.equal(reqs.length, 1);
  assert.equal(reqs[0].id, 'demo-feature');
  assert.equal(reqs[0].docs.length, 2);
  // merged requirement uses newest last_updated
  assert.equal(reqs[0].last_updated, '2026-07-06');
});
```

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

Run: `node --test docs/scripts/build-request-index.test.mjs`
Expected: FAIL(`Cannot find module` / `parseFrontmatter is not a function`)。

- [ ] **Step 4: 写实现**

`docs/scripts/build-request-index.mjs`:
```javascript
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const DOCS = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DIRS = [
  { dir: join(DOCS, 'specs'), expect: 'design-record' },
  { dir: join(DOCS, 'handoffs'), expect: 'handoff' },
];
const TYPES = ['jira','slack','miro','bug','email','verbal','doc','other'];
const STATUS = ['requirement','in-dev','QA','delivered'];
const REQUIRED = ['id','title','doc_type','status','last_updated','sources'];

// --- minimal frontmatter parser (controlled YAML subset) ---
// Each top-level line is `key: rest`. If `rest` is empty, following indented
// lines form a block value: `- {json}` items → array; `key: val` → object.
// Inline `{...}` / `[...]` / quoted / bare scalars are JSON-parsed when possible.
function coerce(v) {
  const s = v.trim();
  if (s === '') return '';
  if (/^[\[{]/.test(s) || /^".*"$/.test(s)) { try { return JSON.parse(s); } catch { /* fall through */ } }
  return s.replace(/^"(.*)"$/, '$1');
}
export function parseFrontmatter(text) {
  const errors = [];
  const m = text.match(/^---\n([\s\S]*?)\n---/);
  if (!m) { errors.push('no frontmatter block'); return { data: {}, errors }; }
  const lines = m[1].split('\n');
  const data = {};
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    if (!line.trim()) { i++; continue; }
    const top = line.match(/^([a-z_]+):\s?(.*)$/);
    if (!top) { i++; continue; }
    const key = top[1];
    const rest = top[2];
    if (rest.trim() !== '') { data[key] = coerce(rest); i++; continue; }
    // block value: gather indented lines
    const block = [];
    i++;
    while (i < lines.length && /^\s+\S/.test(lines[i])) { block.push(lines[i]); i++; }
    if (block.some(b => b.trim().startsWith('- '))) {
      data[key] = block.filter(b => b.trim().startsWith('- ')).map(b => coerce(b.trim().slice(2)));
    } else {
      const obj = {};
      let j = 0;
      while (j < block.length) {
        const bl = block[j];
        const kv = bl.trim().match(/^([a-z_]+):\s?(.*)$/);
        if (kv && kv[2].trim() !== '') { obj[kv[1]] = coerce(kv[2]); j++; }
        else if (kv) { // nested list under this key (e.g. figma.nodes)
          const sub = [];
          j++;
          const baseIndent = bl.match(/^\s*/)[0].length;
          while (j < block.length && block[j].match(/^\s*/)[0].length > baseIndent) { sub.push(block[j]); j++; }
          obj[kv[1]] = sub.filter(s => s.trim().startsWith('- ')).map(s => coerce(s.trim().slice(2)));
        } else { j++; }
      }
      data[key] = obj;
    }
  }
  // validate
  for (const r of REQUIRED) if (!(r in data)) errors.push(`missing required field: ${r}`);
  if (data.doc_type && !['design-record','handoff'].includes(data.doc_type)) errors.push(`bad doc_type: ${data.doc_type}`);
  if (data.status && !STATUS.includes(data.status)) errors.push(`bad status: ${data.status}`);
  if (data.id && !/^[a-z0-9-]+$/.test(data.id)) errors.push(`bad id slug: ${data.id}`);
  if (Array.isArray(data.sources)) {
    for (const s of data.sources) {
      if (!s || typeof s !== 'object') { errors.push('source not object'); continue; }
      if (!TYPES.includes(s.type)) errors.push(`bad source type: ${s.type}`);
      if (!s.ref) errors.push('source missing ref');
    }
  }
  return { data, errors };
}

export function mergeById(records) {
  const byId = new Map();
  for (const r of records) {
    if (!byId.has(r.id)) byId.set(r.id, {
      id: r.id, title: r.title, status: r.status,
      target_release: r.target_release || null,
      sources: [], figma: null, docs: [], last_updated: r.last_updated,
    });
    const req = byId.get(r.id);
    if (r.last_updated > req.last_updated) { req.last_updated = r.last_updated; req.status = r.status; req.title = r.title; }
    req.docs.push({ doc_type: r.doc_type, path: r._path, title: r.title, last_updated: r.last_updated });
    for (const s of (r.sources || [])) if (!req.sources.some(x => x.type===s.type && x.ref===s.ref)) req.sources.push(s);
    if (r.figma && !req.figma) req.figma = r.figma;
  }
  return [...byId.values()].sort((a,b) => b.last_updated.localeCompare(a.last_updated) || a.id.localeCompare(b.id));
}

function badge(s){ const l = s.type==='jira'?`JIRA ${s.ref}`:s.type==='slack'?'Slack':s.type==='miro'?'Miro':s.ref; return s.url?`[${l}](${s.url})`:l; }
export function renderMarkdown(reqs) {
  const rows = reqs.map(r => {
    const src = r.sources.map(badge).join(' · ') || '—';
    const fig = r.figma ? r.figma.nodes.map(n => `[${n.id}](${n.url})`).join(' · ') : '—';
    const docs = r.docs.map(d => `[${d.doc_type}](${d.path})`).join(' · ');
    return `| ${r.id} | ${r.title} | ${src} | ${fig} | ${docs} | ${r.status} | ${r.target_release||'—'} | ${r.last_updated} |`;
  });
  return `# Requirement Provenance Index\n\n> 派生文件,勿手改。改 design-record / handoff 后跑 \`node docs/scripts/build-request-index.mjs\` 重生成。\n\n| id | title | sources | figma | docs | status | target | updated |\n|---|---|---|---|---|---|---|---|\n${rows.join('\n')}\n`;
}

function main() {
  const check = process.argv.includes('--check');
  const records = []; const allErrors = [];
  for (const { dir, expect } of DIRS) {
    let files = [];
    try { files = readdirSync(dir).filter(f => f.endsWith('.md')); } catch { continue; }
    for (const f of files) {
      const p = join(dir, f);
      const text = readFileSync(p, 'utf8');
      if (!text.startsWith('---')) continue; // no frontmatter → skip (non-indexed file)
      const { data, errors } = parseFrontmatter(text);
      const rel = 'docs/' + p.slice(DOCS.length + 1);
      if (errors.length) { allErrors.push(`${rel}: ${errors.join('; ')}`); continue; }
      data._path = rel;
      records.push(data);
    }
  }
  if (allErrors.length) { console.error('Frontmatter errors:\n' + allErrors.map(e=>'  - '+e).join('\n')); process.exitCode = 1; if (check) return; }
  const reqs = mergeById(records);
  if (check) { console.log(`OK: ${records.length} docs → ${reqs.length} requirements`); return; }
  writeFileSync(join(DOCS, 'REQUEST-INDEX.md'), renderMarkdown(reqs));
  writeFileSync(join(DOCS, 'request-index.json'), JSON.stringify(reqs, null, 2) + '\n');
  console.log(`Wrote REQUEST-INDEX.md + request-index.json: ${records.length} docs → ${reqs.length} requirements`);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
```

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

Run: `node --test docs/scripts/build-request-index.test.mjs`
Expected: PASS(3 tests)。若解析 block 结构失败,对照 fixture 调 `parseFrontmatter` 的缩进/嵌套分支直到绿。

---

## Task 2: 回填 design-record + handoff frontmatter

**Files (Modify):** 下表每个文件顶部插入 v2 frontmatter(已有 frontmatter 的 `2026-07-03-preset-r-go-live-design-record.md` 跳过/补齐 `doc_type`)。

**Slug 归组表**(同 id 的文档会被索引合并):

| id (slug) | 文档 |
|---|---|
| `v4-1865-wifi-hotspot-mode` | specs/2026-05-29-v4-1865-wifi-hotspot-mode-design-record.md · handoffs/2026-05-29-v4-1865-mockup-handoff.md · handoffs/2026-06-01-v4-1865-mockup-v2-handoff.md · handoffs/2026-06-02-v4-1865-lcd-pickup.md |
| `v4-2312-channel-status-indicator` | specs/2026-06-02-v4-2312-channel-status-indicator-design-record.md · specs/2026-06-02-v4-2312-channel-status-indicator-spec.md · handoffs/2026-06-23-v4-2312-channel-4k-live-conflict-banner-copy-handoff.md |
| `v4-2318-internal-wifi-module-mode` | specs/2026-06-02-v4-2318-internal-wifi-module-mode-lcd-design-record.md · handoffs/2026-06-04-v4-2318-lcd-mockup-handoff.md |
| `v4-2285-2286-embedded-audio-routing` | specs/2026-06-04-v4-2285-2286-embedded-audio-routing-design-record.md · handoffs/2026-06-22-v4-2285-2286-embedded-audio-color-binding-handoff.md |
| `v4-1864-roaming-access` | specs/2026-06-09-v4-1864-2326-roaming-access-tech-design-record.md |
| `fb-10014-connection-priority` | specs/2026-06-15-fb-10014-connection-priority-design-record.md · handoffs/2026-06-15-fb-10014-connection-priority-pickup.md |
| `v4-2259-capture-output-matrix` | handoffs/2026-06-23-v4-2259-aspect-ratio-encoding-collapse-handoff.md · handoffs/2026-06-23-v4-2259-v3v4-dual-device-capture-output-matrix-handoff.md |
| `preset-r-go-live` | specs/2026-07-03-preset-r-go-live-design-record.md(已有,补 `doc_type: design-record`)|

**不纳入**:`specs/2026-06-03-rps-one-8.4-batch-plan.md`(批量计划,非单需求 design-record)——不加 frontmatter(脚本自动跳过无 `---` 开头文件)。

- [ ] **Step 1**: 逐个文件——`head -20` 读现有正文,抽 Jira key(文件名/正文)、Figma 链接/节点(正文)、Status、日期(文件名)、title。
- [ ] **Step 2**: 在文件最顶部(现有 `# 标题` 之前)插入 frontmatter 块,格式与 schema v2 一致(块式 YAML,sources 每项 `- { type:..., ref:..., url:... }`,url 无则省)。`doc_type` 按目录(specs=design-record,handoffs=handoff),`id` 用上表 slug。
- [ ] **Step 3**: 全部回填后跑校验:`node docs/scripts/build-request-index.mjs --check`
  Expected: `OK: N docs → 8 requirements`,**0 errors**。有 error 按提示修对应文件 frontmatter。

---

## Task 3: 生成索引并验证

- [ ] **Step 1**: 生成 `node docs/scripts/build-request-index.mjs`
  Expected: `Wrote REQUEST-INDEX.md + request-index.json: N docs → 8 requirements`。
- [ ] **Step 2**: 抽查 `docs/REQUEST-INDEX.md`——`preset-r-go-live` 行 sources 含 `JIRA V4-2309` + `Slack`,figma 含 8062:8070 / 8152:205;`v4-1865-wifi-hotspot-mode` docs 列含 1 个 design-record + 3 个 handoff。
- [ ] **Step 3**: 幂等验证——再跑一次,`git`-less diff 用 `node -e` 或再次运行后肉眼比对两产物无变化(排序稳定)。

---

## Task 4: FIGMA_LINKS.md 起手约定

**Files:** Modify `docs/FIGMA_LINKS.md`(顶部标题下)

- [ ] **Step 1**: 在首个 `---` 之前插入:
```markdown
## 起手先读索引(免全局搜索)

**定位需求先读 [`REQUEST-INDEX.md`](REQUEST-INDEX.md)**:需求 ↔ Jira/Slack/Miro ↔ Figma 节点 ↔ design-record/handoff 的映射总表(按需求归组)。给任一 Jira key / Figma 节点 / Slack 线索,在此一步反查,不必全局搜索。

**维护**:design-record 与 handoff 两类文档必须带 v2 frontmatter(见 `docs/superpowers/specs/2026-07-06-requirement-provenance-index-design.md`);改动任一后跑 `node docs/scripts/build-request-index.mjs` 重生成索引(勿手改 REQUEST-INDEX.md / request-index.json)。
```

---

## Task 5: 回流 tvu-design-system 消费产品标准(独立步,动前确认)

> ⚠️ 另一个 git repo(`~/Documents/AICoding/VS_Code/tvu-design-system`),maintainer scope。**执行前向用户确认**;完成按该 repo 纪律 commit + push gitea。

**Files (Modify, 另 repo):**
- `templates/consumer-product/docs/specs/_feature-design-record.template.md`:顶部加 v2 frontmatter 模板头(占位值 + 字段注释)。
- handoff 模板(若无则 Create `templates/consumer-product/docs/handoffs/_handoff.template.md`):同样加 frontmatter 头 `doc_type: handoff`。
- 复制 `build-request-index.mjs` 到 `templates/consumer-product/docs/scripts/`。
- `docs/internal/code-conventions.md` 或 `design-process.md`(真源):加一节「需求溯源索引」——两类文档必带 frontmatter + wrap-up 跑脚本 + 起手读索引。

- [ ] **Step 1**: 确认用户要现在做真源回写。
- [ ] **Step 2**: `git -C <ds> fetch && git -C <ds> status -sb`,确保干净、最新。
- [ ] **Step 3**: 落上述模板 + 脚本 + 约定文本(与 TVU Pack 侧一致)。
- [ ] **Step 4**: commit + push gitea(遵 consumer-product-conventions 收尾纪律)。

---

## Self-Review 记录

- **Spec 覆盖**:A(schema)→Task2+模板;B(脚本)→Task1;C(约定)→Task4+Task5;D(回填)→Task2+Task3。全覆盖。
- **占位符**:无 TBD;生成器代码完整;回填给了 slug 表 + 抽取步骤。
- **类型一致**:`parseFrontmatter`/`mergeById`/`renderMarkdown` 命名在 Task1 代码与测试间一致;字段名与 Global Constraints 一致。
