agentic-flow-kit 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,7 @@ npx agentic-flow-kit add-gate dotnet-ca
54
54
  | 选项 | 说明 |
55
55
  |------|------|
56
56
  | `--stack` | `dotnet` / `node` / `python` / `go` / `none`(默认 none)——**门禁配置三处**:commit-check 条件编译检查(暂存对应扩展名时 commit 前自动构建)+ **质量检测**(`checks`:lint / 类型检查 / vet 等秒级确定性检查,存在对应配置文件(tsconfig / eslint / ruff / go.mod 等)才启用,没有则自动跳过;测试不放提交门——关单在 test.md 阶段门)、settings.json 自检验命令权限、AGENTS.md「项目适配区」命令预填 |
57
- | `--hosts` | `zcode` / `opencode` / `trae` / `omp` / `claude` / `cursor` / `codex` 逗号多选(默认 zcode)。zcode/omp 为本地配置,自动进 .gitignore。Claude Code 加载 `.claude/commands` 与 `.claude/agents`;Cursor 加载 `.cursor/commands`;Codex 读仓库根 `AGENTS.md` 与 `.codex/skills/`,`.codex/commands` 是同形薄转发 |
57
+ | `--hosts` | `zcode` / `opencode` / `trae` / `omp` / `claude` / `cursor` / `codex` 逗号多选(默认 zcode)。七个宿主一律入库(2026-10-05 起 zcode/omp 撤销 localOnly——换宿主不丢文件、CI 克隆面与本仓一致);zcode 的会话级液态草稿(`.zcode/drafts/`、`.zcode/plans/`)是本机态、不该入库——init 不再自动写这条规则,请自行加进 `.gitignore`(本仓实例见根 `.gitignore`)。Claude Code 加载 `.claude/commands` 与 `.claude/agents`;Cursor 加载 `.cursor/commands`;Codex 读仓库根 `AGENTS.md` 与 `.codex/skills/`,`.codex/commands` 是同形薄转发 |
58
58
  | `--board-port` | workflow 看板端口(默认 8933) |
59
59
  | `--dir` | 目标项目根(默认当前目录) |
60
60
  | `--force` | 覆盖已存在的同名文件(默认保守跳过) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-flow-kit",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "AI-Native 闭环工作流 + wiki 知识层脚手架——flow-kit init 一条命令为新项目装好 .agents 工作流引擎(阶段命令/角色契约/门禁钩子/kb 检索/看板/规则面预算)+ workflow 文档闭环 + wiki 知识库,装完自包含、零运行时依赖",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/add-host.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // flow-kit add-host <宿主>:init 后补装宿主适配层。
2
2
  // 渲染 modules/hosts/<h> → HOSTS[h].dir → managed 台账按 rel 去重补记 → options.hosts 补记
3
- // → localOnly 宿主(zcode/omp).gitignore 追加 → doctor
3
+ // → localOnly 宿主(2026-10-05 起暂无命中,见 profiles.mjs#HOSTS.localOnly).gitignore 追加 → doctor
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { renderTree } from './render.mjs';
@@ -51,7 +51,8 @@ export function addHost(args, pkgRoot) {
51
51
  if (!already) kit.options.hosts = [...(kit.options.hosts || []), host];
52
52
  fs.writeFileSync(kitPath, `${JSON.stringify(kit, null, 2)}\n`);
53
53
 
54
- // localOnly 宿主进 .gitignore(缺才补)
54
+ // localOnly 宿主进 .gitignore(缺才补)。2026-10-05 起七个宿主一律 localOnly:false,该分支暂无宿主命中;
55
+ // 保留是为了未来重开「本机专属宿主」档位时不必改结构——判定单源在 profiles.mjs#HOSTS.localOnly。
55
56
  if (HOSTS[host].localOnly) {
56
57
  const giPath = path.join(target, '.gitignore');
57
58
  const line = `${HOSTS[host].dir}/`;
package/src/cli.mjs CHANGED
@@ -46,7 +46,7 @@ init 选项(全部可选,均有默认值):
46
46
  条件编译检查(builds)与质量检测(checks:lint/类型/vet 等秒级确定性检查,
47
47
  有对应配置文件才启用)、settings.json 自检验命令权限(allow)、
48
48
  AGENTS.md「项目适配区」命令预填
49
- --hosts <宿主列表> 逗号分隔:zcode,opencode,trae,omp,claude,cursor,codex(默认 zcode;zcode/omp 为本地配置,自动进 .gitignore)
49
+ --hosts <宿主列表> 逗号分隔:zcode,opencode,trae,omp,claude,cursor,codex(默认 zcode;七个宿主一律入库,不自动进 .gitignore)
50
50
  --board-port <端口> workflow 看板端口(默认 8933)
51
51
  --dir <目录> 目标项目根(默认当前目录)
52
52
  --force 覆盖已存在的同名文件(默认保守跳过)
package/src/init.mjs CHANGED
@@ -242,7 +242,8 @@ export async function init(args, pkgRoot) {
242
242
  ownedGenerated.push({ rel, sha256: sha256(Buffer.from(content, 'utf8')) });
243
243
  }
244
244
 
245
- // 4) .gitignore 追加(缺哪条补哪条)
245
+ // 4) .gitignore 追加(缺哪条补哪条)。2026-10-05 起七个宿主一律 localOnly:false,
246
+ // 宿主目录不再自动进 .gitignore——实际只补 .agents/cache/ 这条运行时缓存。
246
247
  const giPath = path.join(target, '.gitignore');
247
248
  const giNeed = ['.agents/cache/', ...(hosts.filter((h) => HOSTS[h].localOnly).map((h) => `${HOSTS[h].dir}/`))];
248
249
  let giText = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
package/src/profiles.mjs CHANGED
@@ -6,10 +6,14 @@
6
6
  // pkg:scripts.build 表示 package.json 里该脚本为非空字符串。没有对应配置的项目自动跳过,
7
7
  // 不 fail-closed 误拦。none = 不配置(项目日后自填)。
8
8
  export const HOSTS = {
9
- zcode: { dir: '.zcode', localOnly: true },
9
+ // 2026-10-05 宿主本地化撤销:zcode / omp 原 localOnly:true(init 与 add-host 会把 .zcode/、.omp/
10
+ // 整目录写进装户 .gitignore)。改为 false 后七个宿主一律入库,换宿主不丢文件、CI 克隆面与本仓一致。
11
+ // 代价是 zcode 的会话级液态草稿(.zcode/drafts/、.zcode/plans/)改由装户自己加 .gitignore 规则;
12
+ // 本仓 .gitignore 净效果保留这两行草稿规则(实例见根 .gitignore),.zcode/agents/*.md 保持 tracked(2026-09-27 gate-coverage 独立复核确立)。
13
+ zcode: { dir: '.zcode', localOnly: false },
10
14
  opencode: { dir: '.opencode', localOnly: false, commandPrefix: 'wf-' },
11
15
  trae: { dir: '.trae', localOnly: false, commandPrefix: 'wf-' },
12
- omp: { dir: '.omp', localOnly: true },
16
+ omp: { dir: '.omp', localOnly: false },
13
17
  // 2026-09-29 店面:序号仍把 zcode 放第一(交互菜单 1–4 不变)。claude / cursor 的 commands 由宿主自动加载;
14
18
  // codex 自动读仓库根 AGENTS.md 与 .codex/skills/,commands 与 agents 是同形薄转发,供显式引用。
15
19
  claude: { dir: '.claude', localOnly: false, commandPrefix: 'wf-' },
package/src/sync.test.mjs CHANGED
@@ -18,6 +18,8 @@ function check(name, cond, detail = '') {
18
18
  }
19
19
  const W = (p, content) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); };
20
20
  const R = (p) => fs.readFileSync(p, 'utf8');
21
+ // 可选读:装户端在无 localOnly 宿主时不再创建 .gitignore,断言「不追加」须容忍文件不存在
22
+ const ROpt = (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '');
21
23
 
22
24
  // ---- fixture 包根(v2 形态:chg/host 变更、new 新增、gone 已删)----
23
25
  function mkFixture() {
@@ -162,7 +164,7 @@ else if (cmd === 'add-gate') addGate(rest, root);
162
164
  const kit = JSON.parse(R(path.join(t, '.agents/kit.json')));
163
165
  check('S7 managed 台账补记(项目根相对路径)', kit.managed.some((f) => f.rel === '.zcode/h.txt'), JSON.stringify(kit.managed));
164
166
  check('S7 options.hosts 补记', Array.isArray(kit.options.hosts) && kit.options.hosts.includes('zcode'));
165
- check('S7 localOnly 宿主进 .gitignore', R(path.join(t, '.gitignore')).includes('.zcode/'));
167
+ check('S7 宿主目录不再进 .gitignore(localOnly 已撤销,zcode 与其余宿主同策)', !ROpt(path.join(t, '.gitignore')).includes('.zcode/'), ROpt(path.join(t, '.gitignore')));
166
168
  const r2 = runCmd('add-host', fx, t, ['zcode']);
167
169
  check('S7 重复装未 --force → 报错退出', r2.status === 1 && (r2.stderr || '').includes('--force'), r2.stderr);
168
170
  }
@@ -26,7 +26,7 @@
26
26
  - `.githooks/`(经 `core.hooksPath` 挂载,本地 clone 后执行 `git config core.hooksPath .githooks`):pre-commit(闭环配对 / 泳道完整性 / wiki 台账 / 规则面预算 / 双源一致性 / managed 台账快检 / 敏感信息扫描与条件构建)、pre-push(闭环断档扫描)、commit-msg、post-commit、pre-merge-commit。
27
27
  - **`git commit` / `git merge` / `git push` 三处一律禁 `--no-verify`**——被拦说明产出不合规,按提示修完原路重试。
28
28
  - 项目专属门禁挂 `.agents/hooks/local-pre-commit`。装户五条硬规则(配对、验收、确认留痕、敏感信息、双源台账)与 `audit` 档见 `workflow/README.md`「硬规则」。
29
- - 提交遵循 Conventional Commits 中文(feat / fix / docs / style / refactor / perf);L1+ 入口文档 / spec / plan 随代码同一提交;确认(approved)后立即 `docs(*)` 单独提交留痕。
29
+ - 提交遵循 Conventional Commits 中文(feat / fix / docs / style / refactor / perf)。L1+ 三段式闭环链:`docs(*)` approved 留痕 → 代码 → 关单 `docs(*)`,相邻提交即同一闭环(2026-10-04 修订)。
30
30
  - **确认门(2026-09-27 起)**:intent/spec/plan 的 approved/done 与 incident 的 fixed/closed(两跳、无单跳)唯一入口 = `node .agents/scripts/confirm-doc.mjs <path>`,放弃态走显式 `--to superseded|cancelled`(合法前态见脚本提示;四终态同样内容绑定),两形态:① 用户终端亲手运行键入「可以」(TTY,AI 会话内被拒);② **对话委托代录**——用户在对话内明确确认后,AI 跑 `confirm-doc.mjs <path> --delegated "<用户原话>"` 逐件代录(默认一次一份,多份并录被拒),台账如实记 `source: chat-delegated` + 原话供对质,永不伪装 TTY 行(check-loop 15 指纹+台账对账拦截,与 source 无关);L0/L1 协作道可 `--batch` 多份一次代录(台账 `brief:true`,15 并录告警豁免;L2/L3 仍逐份)。
31
31
 
32
32
  ### 检索、看板与量化
@@ -54,6 +54,24 @@
54
54
  #q { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 3px; padding: 6px 10px; font: 12px/1.4 var(--mono); width: 200px; transition: border-color .15s ease-out; }
55
55
  #q::placeholder { color: var(--faint); }
56
56
  #q:focus { outline: none; border-color: var(--border-strong); }
57
+ .viewbtn { background: var(--card); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; padding: 6px 10px; font: 12px/1.4 var(--mono); cursor: pointer; transition: border-color .15s ease-out, color .15s ease-out, background .15s ease-out; }
58
+ .viewbtn:hover { border-color: var(--border-strong); color: var(--text); }
59
+ .viewbtn.on { background: var(--text); color: var(--bg); border-color: var(--text); }
60
+
61
+ /* 执行器视图:run 卡片列(2026-10-04 board-run-panel) */
62
+ #runs-view { display: flex; flex-direction: column; height: calc(100vh - 58px); overflow-y: auto; padding: 14px 22px 28px; gap: 10px; }
63
+ .run-card { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--border-strong); border-radius: 4px; padding: 12px 14px; cursor: pointer; transition: background .15s ease-out, border-color .15s ease-out, transform .15s ease-out, box-shadow .15s ease-out; }
64
+ .run-card:hover { background: var(--card-hover); border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--shadow-1); }
65
+ .run-card .row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 7px; }
66
+ .run-card .title { font-size: 13px; line-height: 1.6; }
67
+ .run-bar { font: 12px/1.6 var(--mono); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
68
+ .run-bar b { color: var(--text); font-weight: 600; }
69
+ .stopchip { font: 500 10px/1 var(--mono); border-radius: 2px; padding: 2px 5px; }
70
+ .stopchip.work-order { color: var(--intent); border: 1px solid var(--intent); }
71
+ .stopchip.await-confirm { color: var(--warn); border: 1px solid var(--warn); }
72
+ .stopchip.gate-fail { color: var(--bad); border: 1px solid var(--bad); }
73
+ .stopchip.done { color: var(--ok); border: 1px solid var(--ok); }
74
+ .stopchip.aborted { color: var(--muted); border: 1px solid var(--border-strong); }
57
75
 
58
76
  /* ---- 看板:去列盒子,发丝竖线分栏的编辑式构图 ---- */
59
77
  #board { display: flex; overflow-x: auto; height: calc(100vh - 58px); }
@@ -162,9 +180,11 @@
162
180
  <span class="chip" id="c-plans"></span><span class="chip" id="c-specs"></span>
163
181
  <span class="chip" id="c-alerts" style="display:none"></span>
164
182
  <input id="q" placeholder="过滤 标题 / slug / 文件…" spellcheck="false">
183
+ <button id="view-runs" class="viewbtn" title="pipeline-run 执行器事件流">执行器</button>
165
184
  <span id="sse"><i></i><span>连接断开</span></span>
166
185
  </header>
167
186
  <div id="board"></div>
187
+ <div id="runs-view" style="display:none"></div>
168
188
 
169
189
  <div id="drawer">
170
190
  <div id="d-head">
@@ -176,6 +196,8 @@
176
196
  <div id="d-tabs">
177
197
  <button data-tab="doc" class="on">文档</button>
178
198
  <button data-tab="hist">修改历史</button>
199
+ <button data-tab="timeline" class="run-tab" style="display:none">时间线</button>
200
+ <button data-tab="workorder" class="run-tab" style="display:none">工单</button>
179
201
  </div>
180
202
  <div id="d-body"></div>
181
203
  </div>
@@ -198,6 +220,10 @@ let TAB = 'doc';
198
220
  let retryTimer = null; // /api/board 失败后的重试定时器
199
221
  let ROOT = ''; // 仓库根(/api/board 返回,内链 vscode 协议用)
200
222
  let Q = ''; // 搜索过滤关键字
223
+ let RUNS = []; // 最近一次 /api/runs 的 run 卡片
224
+ let RUN_CURRENT = null; // 抽屉当前打开的 run
225
+ let VIEW = 'board'; // 当前视图:board | runs
226
+ let RUN_TAB = 'timeline'; // run 抽屉当前 tab:timeline | workorder
201
227
 
202
228
  const $ = (s) => document.querySelector(s);
203
229
  const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
@@ -343,7 +369,11 @@ function renderDrawer() {
343
369
  const related = CARDS.filter((c) => c.slug === CURRENT.slug && c.file !== CURRENT.file);
344
370
  $('#d-related').innerHTML = related.map((c) =>
345
371
  `<a data-file="${esc(c.file)}">${TYPE_LABEL[c.type]} · ${esc(c.date)}${c.status !== CURRENT.status ? ' · ' + esc(c.status) : ''}</a>`).join('');
346
- document.querySelectorAll('#d-tabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === TAB));
372
+ document.querySelectorAll('#d-tabs button').forEach((b) => {
373
+ const isRunTab = b.classList.contains('run-tab');
374
+ b.style.display = isRunTab ? 'none' : '';
375
+ b.classList.toggle('on', b.dataset.tab === TAB);
376
+ });
347
377
  if (TAB === 'doc') loadDoc(); else loadHist();
348
378
  }
349
379
 
@@ -383,6 +413,159 @@ async function loadHist() {
383
413
  : '<div class="none">暂无提交记录(文件尚未 commit;git 历史只包含已提交的修改)</div>';
384
414
  }
385
415
 
416
+ // ================= 执行器视图(2026-10-04 board-run-panel) =================
417
+ // 只读 pipeline-runs/ 事件流(机器事实),不读 orchestration-runs.jsonl(自报态)。
418
+ const STOP_LABEL = {
419
+ 'work-order': '工单', 'await-confirm': '等待确认', 'gate-fail': '门拒绝', done: '完成', aborted: '已放弃',
420
+ };
421
+ const EVENT_DESC = {
422
+ 'run-created': '创建 run', 'work-order': '下发工单', triage: '定级', gate: '门执行',
423
+ confirm: '确认', commit: '提交', stop: '停机', done: '完成', abort: '放弃',
424
+ };
425
+
426
+ async function refreshRuns() {
427
+ let data;
428
+ try {
429
+ const resp = await fetch('/api/runs');
430
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
431
+ data = await resp.json();
432
+ } catch (e) {
433
+ return false;
434
+ }
435
+ RUNS = data.cards || []; // 键名 cards 对齐 /api/runs 载荷(intent 2026-10-04-board-run-panel 验收#1)
436
+ const q = Q.trim().toLowerCase();
437
+ const list = RUNS.filter((r) => !q || (r.runId + ' ' + r.requirement + ' ' + r.stage).toLowerCase().includes(q));
438
+ const html = list.length
439
+ ? list.map((r) => {
440
+ const stop = STOP_LABEL[r.stopType] || esc(r.stopType || '');
441
+ const stopCls = r.stopType || '';
442
+ const bar = r.stageBar || '';
443
+ return `<div class="run-card" data-run="${esc(r.runId)}">` +
444
+ `<div class="row">` +
445
+ `<span class="type" style="background:var(--spec)">RUN</span>` +
446
+ `<span class="lv">${esc(r.triage?.level || '')}</span>` +
447
+ `<span class="stopchip ${esc(stopCls)}">${esc(stop)}</span>` +
448
+ `<span class="date">${esc(r.updatedAt?.slice(0, 16).replace('T', ' ') || '')}</span>` +
449
+ `</div>` +
450
+ `<div class="title">${esc(r.requirement || r.runId)}</div>` +
451
+ `<div class="run-bar">${esc(r.runId)} · <b>${esc(r.stage || '')}</b>${r.eventCount ? ` · ${r.eventCount} 事件` : ''}</div>` +
452
+ (bar ? `<div class="run-bar">${esc(bar)}</div>` : '') +
453
+ `</div>`;
454
+ }).join('')
455
+ : '<div class="empty">暂无 run</div>';
456
+ $('#runs-view').innerHTML = `<h2 style="font:600 11px/1 var(--mono);letter-spacing:.12em;color:var(--muted);padding:4px 0 10px;">执行器 · ${RUNS.length} runs</h2>` + html;
457
+ return true;
458
+ }
459
+
460
+ // —— run 抽屉:时间线 + 工单/确认详情 ——
461
+ function runEventHtml(run) {
462
+ const evs = run.events || [];
463
+ if (!evs.length) return '<div class="none">暂无事件</div>';
464
+ return `<div class="tl">${evs.map((e) => {
465
+ const t = EVENT_DESC[e.type] || e.type;
466
+ const sub = e.type === 'gate'
467
+ ? `${esc(e.cmd || '')} · exit ${e.exit}${e.ms != null ? ` · ${e.ms}ms` : ''}`
468
+ : e.type === 'commit'
469
+ ? `${esc(e.subject || '')} · ${esc(e.sha || '')}`
470
+ : e.type === 'work-order'
471
+ ? `${esc(e.kind || '')} 工单 #${e.id}`
472
+ : e.type === 'stop'
473
+ ? `${esc(e.stopType || '')}${e.stage ? ' · ' + esc(e.stage) : ''}${e.workOrderId != null ? ' · 工单 #' + e.workOrderId : ''}`
474
+ : e.type === 'confirm'
475
+ ? `${esc(e.doc || '')} → ${esc(e.to || '')}${e.source ? ' · ' + esc(e.source) : ''}`
476
+ : e.type === 'abort'
477
+ ? `→ ${esc(e.to || '')}`
478
+ : e.type === 'triage'
479
+ ? `${esc(e.kind || '')} · ${esc(e.level || '')} · ${esc(e.module || '')}`
480
+ : '';
481
+ return `<div class="tl-item"><div class="tl-sub">${esc(t)}</div>` +
482
+ (sub ? `<div class="tl-meta">${sub}</div>` : '') +
483
+ `<div class="tl-meta">${esc((e.t || '').slice(0, 19).replace('T', ' '))}</div></div>`;
484
+ }).join('')}</div>`;
485
+ }
486
+
487
+ function loadRunTimeline() {
488
+ if (!RUN_CURRENT) return;
489
+ $('#d-body').innerHTML = runEventHtml(RUN_CURRENT);
490
+ }
491
+
492
+ function loadRunWorkOrder() {
493
+ if (!RUN_CURRENT) return;
494
+ const w = RUN_CURRENT.workOrder;
495
+ if (w) {
496
+ $('#d-body').innerHTML =
497
+ `<div class="md"><h2>工单 #${w.id} · ${esc(w.kind)}</h2>` +
498
+ `<p><b>${esc(w.title || '')}</b></p>` +
499
+ `<p>目标:${esc(w.goal || '')}</p>` +
500
+ (w.instructions?.length ? `<ul>${w.instructions.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '') +
501
+ (w.context?.kbHits?.length ? `<p>查重命中:</p><ul>${w.context.kbHits.slice(0, 6).map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '') +
502
+ (w.context?.stderrTail ? `<pre><code>${esc(w.context.stderrTail)}</code></pre>` : '') +
503
+ (w.constraints?.length ? `<p>约束:</p><ul>${w.constraints.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '') +
504
+ (w.acceptance?.length ? `<p>验收:</p><ul>${w.acceptance.map((s) => `<li>${esc(s)}</li>`).join('')}</ul>` : '') +
505
+ `</div>`;
506
+ return;
507
+ }
508
+ // 停机在确认门:渲染确认门要点(intent 2026-10-04-board-run-panel 目标节「工单详情 + 确认门要点」)
509
+ const c = RUN_CURRENT.awaitConfirm;
510
+ if (c) {
511
+ $('#d-body').innerHTML =
512
+ `<div class="md"><h2>等待确认</h2>` +
513
+ `<p>文档:<code>${esc(c.doc || '')}</code></p>` +
514
+ `<p>确认门要点:${esc(c.points || '')}</p>` +
515
+ `<p>${c.ledger ? '将经 confirm-doc 代录入台账(对话内明确确认后逐份代录,台账记原话)。' : '对话级确认,记 run 事件不进台账。'}</p>` +
516
+ `</div>`;
517
+ return;
518
+ }
519
+ $('#d-body').innerHTML = '<div class="none">当前无工单(已停机在非工单点或已完成)</div>';
520
+ }
521
+
522
+ function renderRunDrawer() {
523
+ if (!RUN_CURRENT) return;
524
+ $('#d-badges').innerHTML =
525
+ `<span class="type" style="background:var(--spec)">RUN</span>` +
526
+ (RUN_CURRENT.triage?.level ? `<span class="lv">${esc(RUN_CURRENT.triage.level)}</span>` : '') +
527
+ ` <span class="chip">${esc(RUN_CURRENT.stopType || '')}</span>` +
528
+ ` <span class="chip">${esc(RUN_CURRENT.stage || '')}</span>`;
529
+ $('#d-title').textContent = RUN_CURRENT.requirement || RUN_CURRENT.runId;
530
+ $('#d-related').innerHTML = `<a class="runid">${esc(RUN_CURRENT.runId)}</a>`;
531
+ // run 抽屉:显示时间线/工单 tab,隐藏文档/历史 tab
532
+ document.querySelectorAll('#d-tabs button').forEach((b) => {
533
+ const isRunTab = b.classList.contains('run-tab');
534
+ b.style.display = isRunTab ? '' : 'none';
535
+ b.classList.toggle('on', b.dataset.tab === RUN_TAB);
536
+ });
537
+ if (RUN_TAB === 'timeline') loadRunTimeline(); else loadRunWorkOrder();
538
+ }
539
+
540
+ async function openRun(runId) {
541
+ RUN_CURRENT = null;
542
+ $('#drawer').classList.add('open');
543
+ $('#d-title').textContent = '加载中…';
544
+ $('#d-body').innerHTML = '<div class="none">加载中…</div>';
545
+ try {
546
+ const resp = await fetch(`/api/run?file=${encodeURIComponent(runId + '.json')}`);
547
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
548
+ const data = await resp.json();
549
+ RUN_CURRENT = data.run || null;
550
+ if (!RUN_CURRENT) throw new Error('run 为空');
551
+ RUN_TAB = 'timeline';
552
+ renderRunDrawer();
553
+ } catch (e) {
554
+ $('#d-title').textContent = '加载失败';
555
+ $('#d-body').innerHTML = `<div class="none">无法加载 run:${esc(e.message)}</div>`;
556
+ }
557
+ }
558
+
559
+ function switchView(v) {
560
+ VIEW = v;
561
+ const isRuns = v === 'runs';
562
+ $('#board').style.display = isRuns ? 'none' : '';
563
+ $('#runs-view').style.display = isRuns ? '' : 'none';
564
+ $('#view-runs').classList.toggle('on', isRuns);
565
+ $('#q').placeholder = isRuns ? '过滤 runId / 需求 / 阶段…' : '过滤 标题 / slug / 文件…';
566
+ if (isRuns) refreshRuns(); else refreshBoard();
567
+ }
568
+
386
569
  function openCard(card) { CURRENT = card; TAB = 'doc'; $('#drawer').classList.add('open'); renderDrawer(); }
387
570
 
388
571
  // —— toast 反馈 ——
@@ -445,20 +628,35 @@ document.addEventListener('click', (e) => {
445
628
  if (hash) navigator.clipboard.writeText(hash.dataset.h)
446
629
  .then(() => toast('已复制 ' + hash.dataset.h.slice(0, 9)))
447
630
  .catch(() => toast('复制失败'));
631
+ // 执行器 run 卡片 → 开抽屉(时间线)
632
+ const runCard = e.target.closest('.run-card');
633
+ if (runCard) {
634
+ const hit = RUNS.find((r) => r.runId === runCard.dataset.run);
635
+ if (hit) openRun(hit.runId);
636
+ return;
637
+ }
448
638
  });
449
639
  $('#d-close').onclick = () => { $('#drawer').classList.remove('open'); };
450
640
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') $('#drawer').classList.remove('open'); });
451
- document.querySelectorAll('#d-tabs button').forEach((b) => b.onclick = () => { TAB = b.dataset.tab; renderDrawer(); });
641
+ // 抽屉 tab:run 抽屉(时间线/工单)与文档抽屉(文档/历史)分开处理
642
+ document.querySelectorAll('#d-tabs button').forEach((b) => b.onclick = () => {
643
+ if (b.classList.contains('run-tab') && RUN_CURRENT) { RUN_TAB = b.dataset.tab; renderRunDrawer(); }
644
+ else if (!b.classList.contains('run-tab')) { TAB = b.dataset.tab; renderDrawer(); }
645
+ });
646
+ $('#view-runs').onclick = () => switchView(VIEW === 'runs' ? 'board' : 'runs');
452
647
 
453
648
  // SSE:服务端 fs.watch 防抖后推送 changed → 看板与打开中的抽屉自动刷新
454
649
  const sse = new EventSource('/api/events');
455
- sse.addEventListener('changed', () => { refreshBoard().then((ok) => { if (ok) renderDrawer(); }); });
650
+ sse.addEventListener('changed', () => {
651
+ refreshBoard().then((ok) => { if (ok) renderDrawer(); });
652
+ if (VIEW === 'runs') refreshRuns();
653
+ });
456
654
  sse.addEventListener('watchdead', () => { $('#sse').classList.remove('on'); $('#sse').classList.add('poll'); $('#sse span:last-child').textContent = '监听失效 · 60s 轮询'; });
457
655
  sse.onopen = () => { $('#sse').classList.add('on'); $('#sse').classList.remove('poll'); $('#sse span:last-child').textContent = '实时'; };
458
656
  sse.onerror = () => { $('#sse').classList.remove('on'); $('#sse span:last-child').textContent = '连接断开,自动重连中'; };
459
657
 
460
658
  // 60s 兜底轮询:SSE/watch 任何环节失效时数据仍保鲜(正常时多拉一次无害)
461
- setInterval(() => refreshBoard(), 60000);
659
+ setInterval(() => { refreshBoard(); if (VIEW === 'runs') refreshRuns(); }, 60000);
462
660
 
463
661
  // 搜索过滤:本地过滤标题/slug/文件,SSE 刷新时保持关键字
464
662
  // 击键去抖 400ms(复核 P2-2 rider):/api/board 服务端有 loop TTL 缓存,去抖进一步避免连发请求排队
@@ -466,7 +664,7 @@ let qTimer = null;
466
664
  $('#q').addEventListener('input', (e) => {
467
665
  Q = e.target.value;
468
666
  clearTimeout(qTimer);
469
- qTimer = setTimeout(refreshBoard, 400);
667
+ qTimer = setTimeout(() => { if (VIEW === 'runs') refreshRuns(); else refreshBoard(); }, 400);
470
668
  });
471
669
 
472
670
  // 首次入场:列错峰上浮一次(.boot 稍后摘除,SSE / 过滤触发的重渲染不再重播)
@@ -504,7 +504,7 @@ for (const inc of docFiles('incidents')) {
504
504
  }
505
505
  }
506
506
  const zc = `.zcode/agents/${role}.md`;
507
- if (fs.existsSync(path.join(ROOT, zc))) { // 本地配置不入 git:缺失不告警,存在时校验一致性
507
+ if (fs.existsSync(path.join(ROOT, zc))) { // .zcode/agents/ 现为入库件(2026-10-05 起宿主一律入库);缺失只说明宿主未装,不告警,存在时校验一致性
508
508
  if (!new RegExp(`^name:\\s*["']?${role}["']?\\s*$`, 'm').test(textOf(zc).join('\n'))) warnings.push(`- [WARN Adapter 无效] ZCode ${role} 的 name 与文件名不一致:${zc}`);
509
509
  if (!textOf(zc).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] ZCode ${role} 未引用公共角色:${rolePath}`);
510
510
  }
@@ -596,10 +596,10 @@ function verifyEvidenceTruth(evidenceStr, planBase, root) {
596
596
 
597
597
  const planLines = linesOf(planPath) || [];
598
598
  const declaredFiles = [];
599
- // 扫描「改动面」或「任务拆解」节
599
+ // 扫描「改动面」「任务拆解」或「改动方案」(L1 Quick-Plan 生成器 fill-plan 输出)节
600
600
  let inDeclaredSec = false;
601
601
  for (const line of planLines) {
602
- if (/^##\s+(改动面|任务拆解)/.test(line)) { inDeclaredSec = true; continue; }
602
+ if (/^##\s+(改动面|任务拆解|改动方案)/.test(line)) { inDeclaredSec = true; continue; }
603
603
  if (inDeclaredSec && /^##\s+/.test(line)) { inDeclaredSec = false; continue; }
604
604
  if (inDeclaredSec) {
605
605
  const fileMatch = line.match(/([a-zA-Z0-9._\/-]+\.[a-zA-Z0-9]+)/);
@@ -16,6 +16,9 @@ import { entryConfirmed, MARK_RE, laneOfEntry, laneOfDoc } from './stage-gates.m
16
16
 
17
17
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
18
18
  const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
19
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git'; // Windows spawn 不补 .exe 扩展名(gitInit/gitCommitAll/场景 43/44/47 共用)
20
+ // 注意:check-loop.mjs 无 isMain 主守卫,被 import 即全量执行门禁并 process.exit——本套件只能端到端 spawn,
21
+ // 不可从其 import 函数直测(2026-10-04 review-fix-batch 实证;可 import 化记 papercuts 待 L2 重构)
19
22
 
20
23
  let pass = 0;
21
24
  let fail = 0;
@@ -250,7 +253,7 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
250
253
  rmfix(T);
251
254
  }
252
255
 
253
- // ---- 场景 15:ZCode Adapter 缺失不告警(本地配置不入 git),存在且断线仍告警 ----
256
+ // ---- 场景 15:ZCode Adapter 缺失不告警(宿主未装),存在且断线仍告警 ----
254
257
  {
255
258
  const T = mkfix();
256
259
  w(T, 'workflow/intents/2026-09-12-o.md', INTENT('o', '状态: done\n级别: L1\n日期: 2026-09-12', '\n## 验收标准(可测试)\n- [x] 用例通过(证据:全绿)\n'));
@@ -564,6 +567,65 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
564
567
  r.status === 1 && outOf(r).includes('验收缺证据'), `exit=${r.status}\n${outOf(r)}`);
565
568
  rmfix(T);
566
569
  }
570
+ // evidenceFixture:场景 43/44/47 共用脚手架——git 仓 + L1 done 三件(plan「改动方案」节声明 declared)+
571
+ // intent 证据引用唯一内容提交,返回 { T, sha }。createDeclared=false 时声明【从未存在】的文件(唯一内容
572
+ // 提交必然不触及,判定确定——2026-10-04 实测原「二次提交区分触及/不触及」方案在 npm test 环境偶发失败
573
+ // (git 时序敏感),负例改 ghost 构造)
574
+ const evidenceFixture = (slug, declared, createDeclared) => {
575
+ const T = mkfix();
576
+ gitInit(T);
577
+ if (createDeclared) {
578
+ fs.mkdirSync(path.join(T, path.dirname(declared)), { recursive: true });
579
+ w(T, declared, '// fixture\n');
580
+ }
581
+ w(T, `workflow/plans/2026-10-04-${slug}.md`, PLAN(slug, '状态: done\n级别: L1', `\n## 改动方案\n- ${declared}:声明文件${createDeclared ? '' : '(实际不存在,证据必不触及)'}\n`));
582
+ w(T, `workflow/intents/2026-10-04-${slug}.md`, INTENT(slug, '状态: done\n级别: L1\n日期: 2026-10-04',
583
+ '\n## 验收标准(可测试)\n- [x] 用例通过(证据:commit PLACEHOLDER)\n'));
584
+ gitCommitAll(T, `feat: ${slug} fixture`);
585
+ const sha = spawnSync(GIT, ['rev-parse', 'HEAD'], { cwd: T, encoding: 'utf8' }).stdout.trim().slice(0, 7);
586
+ w(T, `workflow/intents/2026-10-04-${slug}.md`, INTENT(slug, '状态: done\n级别: L1\n日期: 2026-10-04',
587
+ `\n## 验收标准(可测试)\n- [x] 用例通过(证据:commit ${sha})\n`));
588
+ gitCommitAll(T, `docs: ${slug} evidence`);
589
+ return { T, sha };
590
+ };
591
+
592
+ // ---- 场景 43:检查 8 证据真相——L1 plan「改动方案」节(fill-plan 输出)+ 证据 SHA 触及声明文件 → 不报「证据无关」(2026-10-04 plan-section-name-evidence 回归)----
593
+ {
594
+ const { T } = evidenceFixture('ev3', 'src/foo.js', true);
595
+ const r = run(T);
596
+ // fixture 不构造确认记录 → done 必触「确认未对账」hard(exit 1 预期);核心断言是证据面不出现「证据无关」
597
+ check('L1 plan「改动方案」节 + 证据触及声明文件 → 不报证据无关',
598
+ !outOf(r).includes('证据无关'), `exit=${r.status}\n${outOf(r)}`);
599
+ rmfix(T);
600
+ }
601
+
602
+ // ---- 场景 44:检查 8 证据真相——L1 plan「改动方案」节 + 证据 SHA 不触及声明文件 → 仍报「证据无关」(负例,防过度豁免)----
603
+ {
604
+ const { T } = evidenceFixture('ev4', 'src/ghost.js', false);
605
+ const r = run(T);
606
+ check('L1 plan「改动方案」节 + 证据不触及声明文件 → 仍报证据无关(负例)',
607
+ outOf(r).includes('证据无关'), `exit=${r.status}\n${outOf(r)}`);
608
+ rmfix(T);
609
+ }
610
+
611
+ // ---- 场景 47:检查 8 证据真相——证据 SHA 触及声明文件 → 校验通过 exit 0(2026-10-04 review-fix-batch:
612
+ // 补 spec 2026-10-04-plan-section-name-evidence §49「校验通过(type=sha)」断言强度——场景 43 的 fixture
613
+ // 触「确认未对账」hard 只能间接断言无「证据无关」;本场景沿场景 5x 造法(锚前自报日期豁免存量面)走满
614
+ // type=sha 放行路径:证据为真实 SHA + 声明文件被提交触及 + 提交无执行器标记 → exit 0 只能经 type=sha 达成
615
+ // (text 需无 hex 串 / process 需 runId 标记 / forged·irrelevant 均 exit 1,三条岔路均被构造排除)----
616
+ {
617
+ const T = mkfix();
618
+ gitInit(T);
619
+ w(T, 'workflow/plans/2026-09-26-sh1.md', PLAN('sh1', '状态: done\n级别: L1\n日期: 2026-09-26\n模块: material', '\n## 改动方案\n\n- src/app.mjs:实现功能\n'));
620
+ fs.mkdirSync(path.join(T, 'src'), { recursive: true });
621
+ w(T, 'src/app.mjs', '// fixture\n');
622
+ gitCommitAll(T, 'feat: sh1 实现(无执行器标记)');
623
+ const sha = spawnSync(GIT, ['rev-parse', '--short', 'HEAD'], { cwd: T, encoding: 'utf8' }).stdout.trim();
624
+ w(T, 'workflow/intents/2026-09-26-sh1.md', INTENT('sh1', '状态: done\n级别: L1\n日期: 2026-09-26', `\n## 验收标准(可测试)\n- [x] 用例通过(证据:${sha})\n`));
625
+ expectOk('证据 SHA 触及声明文件(无执行器标记)→ type=sha 校验通过 exit 0', T);
626
+ rmfix(T);
627
+ }
628
+
567
629
  // ---- 场景 42:检查 4——fill-{intent,spec,plan}.mjs 花括号展开,三路皆存在 → 不报引用断档 ----
568
630
  {
569
631
  const T = mkfix();
@@ -577,7 +639,7 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
577
639
  r.status === 0 && !outOf(r).includes('引用断档'), `exit=${r.status}\n${outOf(r)}`);
578
640
  rmfix(T);
579
641
  }
580
- // ---- 场景 43:检查 4——花括号展开三路皆不存在 → 仍报引用断档(带完整展开式文案)----
642
+ // ---- 场景 45(原 43,2026-10-04 review-fix-batch 改号避让证据真相场景重号;旧号无文档按号引用):检查 4——花括号展开三路皆不存在 → 仍报引用断档(带完整展开式文案)----
581
643
  {
582
644
  const T = mkfix();
583
645
  w(T, 'AGENTS.md', '# AGENTS\n\n起草先跑 `node .agents/scripts/fill-{alpha,beta,gamma}.mjs` 拿结构化草稿。\n');
@@ -588,7 +650,7 @@ const mkConfirmedDoc = (root, rel, fmBody) => {
588
650
  r.status === 0 && /引用断档.*fill-\{alpha,beta,gamma\}\.mjs/.test(outOf(r)), `exit=${r.status}\n${outOf(r)}`);
589
651
  rmfix(T);
590
652
  }
591
- // ---- 场景 44:检查 2——<主题> 与 .md 同行 = 命名约定描述 → 不报模板未填 ----
653
+ // ---- 场景 46(原 44,同场景 45 缘由改号):检查 2——<主题> 与 .md 同行 = 命名约定描述 → 不报模板未填 ----
592
654
  {
593
655
  const T = mkfix();
594
656
  w(T, 'workflow/intents/2026-09-01-nm1.md', INTENT('nm1', '状态: done\n级别: L1\n日期: 2026-09-01',
@@ -231,7 +231,7 @@ function stopDone(ctx, run) {
231
231
  }
232
232
 
233
233
  // ── 输出(人类可读 + 机器标记) ────────────────────────────────────────
234
- function stageBar(run) {
234
+ export function stageBar(run) {
235
235
  const s = run.stage;
236
236
  const mk = (name, pos) => (pos === 'cur' ? '[●' + name + ']' : pos === 'past' ? '[✓' + name + ']' : '[…' + name + ']');
237
237
  const seq = run.triage?.kind === 'verify' ? [['Verify', s === 'verify' || s === 'done']]
@@ -996,4 +996,4 @@ function adoptRun(ctx, a) {
996
996
  console.log(' 提示:adopt 不推断断点位置——按文档现状人工续走(next 会按 stage 拒绝,请对照事件缺失情况手动接续或另起 run)');
997
997
  }
998
998
 
999
- export default { parseKV, validateDraftContent, closeoutComplete, placeholdersIn };
999
+ export default { parseKV, validateDraftContent, closeoutComplete, placeholdersIn, stageBar };
@@ -3,18 +3,23 @@
3
3
  // 用法:node .agents/scripts/workflow-board-server.mjs [--port 8933];多项目并行请用 ensure-board.mjs 拉起(自动上探可用端口)
4
4
  // 零依赖(node:http/node:fs/node:path/node:url/node:child_process);只读 workflow/ 与 git,仅绑 127.0.0.1。
5
5
  // API:GET /(看板页) /marked.min.js(vendor) /api/board /api/doc?file=<相对路径> /api/history?file=<相对路径> /api/events(SSE)
6
+ // /api/runs(pipeline-run 执行器 run 卡片列表) /api/run?file=<runId>.json(单 run 事件流)
6
7
  // 实时边界:文档内容与状态随落盘实时(fs.watch→SSE 推送);git 历史仅含已提交记录(git 语义)。
8
+ // 执行器面板(2026-10-04 board-run-panel):只读 .agents/cache/pipeline-runs/*.json(脚本亲写的机器事实),
9
+ // 不读 orchestration-runs.jsonl(宿主 AI 自报态,非验证态);纯只读观测层,不启动/推进/中止 run。
7
10
  import http from 'node:http';
8
11
  import fs from 'node:fs';
9
12
  import path from 'node:path';
10
13
  import { fileURLToPath } from 'node:url';
11
14
  import { execFile, spawnSync } from 'node:child_process';
12
15
  import { ENUMS } from './workflow-enums.mjs';
16
+ import { stageBar } from './pipeline-run.mjs';
13
17
 
14
18
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
15
19
  const WORKFLOW = path.join(ROOT, 'workflow');
16
20
  const BOARD_DIR = path.join(ROOT, '.agents', 'board');
17
21
  const DOC_TYPES = ['intents', 'incidents', 'plans', 'specs'];
22
+ const PIPELINE_RUNS = path.join(ROOT, '.agents', 'cache', 'pipeline-runs');
18
23
 
19
24
  const portArg = process.argv.indexOf('--port');
20
25
  const PORT = portArg > 0 ? Number(process.argv[portArg + 1]) || 8933 : 8933;
@@ -53,6 +58,58 @@ function parseAcceptance(text) {
53
58
  }
54
59
  export { parseAcceptance };
55
60
 
61
+ // ---- 执行器 run 文件解析(2026-10-04 board-run-panel)----
62
+ // 只读 pipeline-runs/ 下的 run JSON(脚本亲写的机器事实,含 gate 真实退出码)。坏 JSON / 缺字段容错返回 null,
63
+ // 由调用方跳过——run 文件写入原子化(临时文件+rename),读瞬间可能撞上 rename 前状态,不 500。
64
+ export function parseRunFile(text) {
65
+ let j;
66
+ try {
67
+ j = JSON.parse(text);
68
+ } catch {
69
+ return null;
70
+ }
71
+ if (!j || typeof j !== 'object' || typeof j.runId !== 'string' || !j.runId) return null;
72
+ return j;
73
+ }
74
+
75
+ // 单 run 卡片(/api/runs 载荷元素):runId / requirement / stage / stopType / updatedAt / triage / 事件数 / 阶段条
76
+ export function runCard(run) {
77
+ const events = Array.isArray(run.events) ? run.events : [];
78
+ return {
79
+ runId: run.runId,
80
+ requirement: run.requirement || '',
81
+ stage: run.stage || '',
82
+ stopType: run.stopType || '',
83
+ updatedAt: run.updatedAt || run.createdAt || '',
84
+ triage: run.triage || null,
85
+ eventCount: events.length,
86
+ stageBar: stageBar(run),
87
+ };
88
+ }
89
+
90
+ // 扫 pipeline-runs/ 全部 run → 卡片数组,按 updatedAt 倒序(无目录/空目录 → [])
91
+ export function scanRuns() {
92
+ if (!fs.existsSync(PIPELINE_RUNS) || !fs.statSync(PIPELINE_RUNS).isDirectory()) return [];
93
+ const cards = [];
94
+ for (const f of fs.readdirSync(PIPELINE_RUNS)) {
95
+ if (!f.endsWith('.json')) continue;
96
+ try {
97
+ const run = parseRunFile(fs.readFileSync(path.join(PIPELINE_RUNS, f), 'utf8'));
98
+ if (run) cards.push(runCard(run));
99
+ } catch {
100
+ // 读失败(瞬时占用等)跳过,等下次推送
101
+ }
102
+ }
103
+ return cards.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
104
+ }
105
+
106
+ // 路径白名单:resolve 后必须仍在 pipeline-runs/ 内且 .json 后缀(export 供测试直测白名单面)
107
+ export function safeRunPath(rel) {
108
+ const abs = path.resolve(PIPELINE_RUNS, rel);
109
+ if (!abs.startsWith(PIPELINE_RUNS + path.sep) || !abs.endsWith('.json')) return null;
110
+ return abs;
111
+ }
112
+
56
113
  // ---- 配对断裂检测:按完整文件名(含日期)聚合同族,异常卡附 alerts ----
57
114
  // 与 check-loop 检查 1 的 basename 同一口径。slug(去掉日期)只给界面分组,不参与配对。
58
115
  // 规则(看板为预警层、不阻断;枚举读单源 workflow-enums.txt):
@@ -196,16 +253,22 @@ let debounceTimer = null;
196
253
  function broadcast(event) {
197
254
  for (const res of sseClients) { if (!res.destroyed) res.write(`event: ${event}\ndata: {}\n\n`); }
198
255
  }
199
- try {
200
- const watcher = fs.watch(WORKFLOW, { recursive: true }, () => {
201
- clearTimeout(debounceTimer);
202
- debounceTimer = setTimeout(() => broadcast('changed'), 500);
203
- });
204
- // watch 中途失效(目录被动/杀软锁):显式告知前端降级轮询,避免假实时
205
- watcher.on('error', (e) => { console.error('fs.watch 失效,已通知前端降级轮询:', e.message); broadcast('watchdead'); });
206
- } catch (e) {
207
- console.error('fs.watch 初始化失败(前端将走 60s 兜底轮询):', e.message);
256
+ // 统一监听回调:workflow/ 文档 + pipeline-runs/ 执行器 run 文件任一变化 → 500ms 防抖广播 changed
257
+ function onWatchChange() {
258
+ clearTimeout(debounceTimer);
259
+ debounceTimer = setTimeout(() => broadcast('changed'), 500);
208
260
  }
261
+ function watchDir(dir) {
262
+ try {
263
+ const watcher = fs.watch(dir, { recursive: true }, onWatchChange);
264
+ watcher.on('error', (e) => { console.error(`fs.watch 失效(${dir}),已通知前端降级轮询:`, e.message); broadcast('watchdead'); });
265
+ } catch (e) {
266
+ // 同步失败(典型:目录不存在 ENOENT)与异步 error 同口径广播 watchdead——否则前端徽标滞留「实时」而推送已死
267
+ console.error(`fs.watch 初始化失败(${dir},前端将走 60s 兜底轮询):`, e.message);
268
+ broadcast('watchdead');
269
+ }
270
+ }
271
+ watchDir(WORKFLOW);
209
272
  // 25s 心跳:防半开连接;顺带清理已断开的 client
210
273
  setInterval(() => { for (const res of sseClients) { if (res.destroyed) sseClients.delete(res); else res.write(': ping\n\n'); } }, 25000).unref();
211
274
 
@@ -269,6 +332,21 @@ const server = http.createServer(async (req, res) => {
269
332
  }
270
333
  if (url.pathname === '/api/board') return sendJson(res, 200, await scanBoard());
271
334
 
335
+ // 执行器面板(2026-10-04 board-run-panel):run 卡片列表 + 单 run 事件流
336
+ if (url.pathname === '/api/runs') return sendJson(res, 200, { cards: scanRuns() }); // 键名 cards 对齐 intent 2026-10-04-board-run-panel 验收#1
337
+ if (url.pathname === '/api/run') {
338
+ const rel = url.searchParams.get('file') || '';
339
+ const abs = safeRunPath(rel);
340
+ if (!abs) return sendJson(res, 403, { error: '路径不在 pipeline-runs/ 白名单内' });
341
+ try {
342
+ return sendJson(res, 200, { run: parseRunFile(fs.readFileSync(abs, 'utf8')) });
343
+ } catch (e) {
344
+ if (e && e.code === 'ENOENT') return sendJson(res, 404, { error: 'run 文件不存在' });
345
+ console.error(`读 run 失败(${rel}):`, e.message); // 权限/占用等如实 500 留痕,不并成 404
346
+ return sendJson(res, 500, { error: `读 run 失败:${e.message}` });
347
+ }
348
+ }
349
+
272
350
  if (url.pathname === '/api/doc' || url.pathname === '/api/history') {
273
351
  const rel = url.searchParams.get('file') || '';
274
352
  const abs = safeDocPath(rel);
@@ -305,7 +383,11 @@ server.on('error', (e) => {
305
383
  // isMain 守卫(board-kb-p1):parseLoopHardBlocks 可被测试 import(import 不 listen)
306
384
  const isMain = process.argv[1] && process.argv[1].endsWith('workflow-board-server.mjs');
307
385
  if (isMain) {
386
+ // run 目录缺失(.agents/cache/ 被 gitignore,全新装机无此目录)时 fs.watch 同步抛 ENOENT 且无重试——
387
+ // 补建空目录启用监听(pipeline-run 首跑本会建;失败不阻启动,由 watchDir catch 兜底广播 watchdead)
388
+ try { fs.mkdirSync(PIPELINE_RUNS, { recursive: true }); } catch { /* 竞态/权限等交给 watchDir 降级 */ }
389
+ watchDir(PIPELINE_RUNS); // 执行器 run 文件监听仅在作为服务运行时启动(import 不监听,避免测试进程挂起)
308
390
  server.listen(PORT, '127.0.0.1', () => {
309
- console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
391
+ console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/ 与 pipeline-runs/,不写任何文件;run 目录缺失时补建空目录以启用监听)`);
310
392
  });
311
393
  }
@@ -12,8 +12,9 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { parseLoopHardBlocks, parseAcceptance, detectAlerts } from './workflow-board-server.mjs';
15
+ import { parseLoopHardBlocks, parseAcceptance, detectAlerts, parseRunFile, runCard, safeRunPath } from './workflow-board-server.mjs';
16
16
  import { computeFingerprint } from './confirm-doc.mjs';
17
+ import { stageBar } from './pipeline-run.mjs';
17
18
 
18
19
  let pass = 0;
19
20
  let fail = 0;
@@ -161,5 +162,41 @@ const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
161
162
  check('⑥未知状态仍报不在枚举内', has(bogus, '状态不在枚举内'), JSON.stringify(bogus.alerts));
162
163
  }
163
164
 
165
+ // ---- ⑦执行器面板纯函数(2026-10-04 board-run-panel)----
166
+ {
167
+ // 真实 run 文件形态(对齐 .agents/cache/pipeline-runs/20261002-1819-runtime-env-md-c2ju.json)
168
+ const real = {
169
+ runId: '20261002-1819-runtime-env-md-c2ju',
170
+ requirement: '在 runtime-env.md 补记 pipeline-run 运行态',
171
+ stage: 'done',
172
+ stopType: 'done',
173
+ triage: { kind: 'require', level: 'L0', module: 'pipeline', topic: 'runtime-note', decidedBy: 'ai' },
174
+ updatedAt: '2026-10-02T10:19:22.566Z',
175
+ events: [
176
+ { t: '2026-10-02T10:19:02.680Z', type: 'run-created', requirement: 'x' },
177
+ { t: '2026-10-02T10:19:02.682Z', type: 'work-order', id: 1, kind: 'triage' },
178
+ { t: '2026-10-02T10:19:19.512Z', type: 'gate', cmd: 'git status --porcelain', exit: 0, ms: 58 },
179
+ { t: '2026-10-02T10:19:22.566Z', type: 'commit', sha: '37b552a', subject: 'docs(pipeline): x' },
180
+ { t: '2026-10-02T10:19:22.566Z', type: 'done' },
181
+ ],
182
+ };
183
+ check('⑦parseRunFile:真实 run JSON 解析成功', parseRunFile(JSON.stringify(real))?.runId === real.runId);
184
+ check('⑦parseRunFile:坏 JSON → null', parseRunFile('{oops') === null);
185
+ check('⑦parseRunFile:非对象/缺 runId → null', parseRunFile('"str"') === null && parseRunFile('{}') === null && parseRunFile('{"a":1}') === null);
186
+ const card = runCard(real);
187
+ check('⑦runCard:字段映射齐全', card.runId === real.runId && card.stage === 'done' && card.stopType === 'done' && card.eventCount === 5 && card.requirement === real.requirement);
188
+ check('⑦runCard:updatedAt 取 updatedAt', card.updatedAt === real.updatedAt);
189
+ // 阶段条复用 pipeline-run stageBar(done → Plan/Design/Build 全 [✓],Test 为当前 [●])
190
+ check('⑦stageBar:done run 前三个阶段 [✓] 且 Test 为当前', card.stageBar.includes('[✓Plan]') && card.stageBar.includes('[✓Design]') && card.stageBar.includes('[✓Build]') && card.stageBar.includes('[●Test]'));
191
+ // 空事件容错
192
+ const empty = runCard({ runId: 'r1', requirement: '', stage: 'triage', stopType: 'work-order', events: null });
193
+ check('⑦runCard:events 非数组容错为 0', empty.eventCount === 0);
194
+ // safeRunPath 路径穿越白名单(2026-10-04 review-fix-batch:安全相关函数此前无测试;用例只用平台无关的正向斜杠相对路径)
195
+ check('⑦safeRunPath:合法 runId.json 通过', typeof safeRunPath('20261002-1819-x-abcd.json') === 'string' && safeRunPath('20261002-1819-x-abcd.json').endsWith('.json'));
196
+ check('⑦safeRunPath:.. 穿越拒绝', safeRunPath('../../package.json') === null && safeRunPath('a/../../package.json') === null);
197
+ check('⑦safeRunPath:根相对路径拒绝', safeRunPath('/etc/passwd') === null);
198
+ check('⑦safeRunPath:非 .json 拒绝', safeRunPath('20261002-1819-x-abcd.md') === null && safeRunPath('20261002-1819-x-abcd') === null);
199
+ }
200
+
164
201
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
165
202
  process.exit(fail ? 1 : 0);
@@ -6,7 +6,7 @@
6
6
  # PLAN — <与入口文档同名主题>
7
7
  <!-- 与 intents/ 或 incidents/ 下同名入口文档配对;L2/L3 必须先有 ../specs/ 同名 spec 确认通过;AI 起草、用户对话内确认后开工 -->
8
8
  <!-- frontmatter 受限子集(2026-09-13):状态∈draft/approved/done/superseded/cancelled(严格枚举,附注写备注键);级别∈L0/L1/L2/L3;正文不再写状态/级别行 -->
9
- <!-- L1 极简形态(Quick-Plan,与 fill-plan.mjs 单源一致):单/少文件微改动只填「改动方案」+「约束与风险」+「验证计划」三节——改动方案逐条列改哪个文件做什么(判据细节直接写进条目),验证计划一行静态门 + 按需 UI 实测;任务拆解/执行顺序仅 L1 多文件多步骤时保留 -->
9
+ <!-- L1 极简形态(Quick-Plan,与 fill-plan.mjs 单源一致):单/少文件微改动只填「改动方案」+「约束与风险」+「验证计划」三节(L1 主节标题模板正文用「改动面」,fill-plan 生成「改动方案」——check-loop 证据校验对 改动面/任务拆解/改动方案 三种节名均识别为声明文件来源)——改动方案逐条列改哪个文件做什么(判据细节直接写进条目),验证计划一行静态门 + 按需 UI 实测;任务拆解/执行顺序仅 L1 多文件多步骤时保留 -->
10
10
 
11
11
  对应入口:../intents/YYYY-MM-DD-<主题>.md 或 ../incidents/YYYY-MM-DD-<主题>.md(保留实际一项)
12
12
  对应 spec:../specs/YYYY-MM-DD-<主题>.md(L1 可省略)