agentic-flow-kit 1.1.8 → 1.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-flow-kit",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "AI-Native 闭环工作流 + wiki 知识层脚手架——flow-kit init 一条命令为新项目装好 .agents 工作流引擎(阶段命令/角色契约/门禁钩子/kb 检索/看板/规则面预算)+ workflow 文档闭环 + wiki 知识库,装完自包含、零运行时依赖",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/doctor.mjs CHANGED
@@ -5,7 +5,7 @@ import { execSync, spawnSync } from 'node:child_process';
5
5
  import net from 'node:net';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { listTree, scriptTrusted } from './render.mjs';
8
- import { isOwned, HOSTS, pickStackVars } from './profiles.mjs';
8
+ import { isOwned, HOSTS, pickStackVars, srcTemplatePath, templateDriftOf, TEMPLATE_DRIFT_EXCLUDE } from './profiles.mjs';
9
9
 
10
10
  // 宿主目录映射(与 src/profiles.mjs#HOSTS 同源:装副本目录名前缀带点)
11
11
  const HOST_DIR = Object.fromEntries(Object.entries(HOSTS).map(([k, v]) => [k, v.dir]));
@@ -257,6 +257,19 @@ export function doctor(args, pkgRoot) {
257
257
  }
258
258
  }
259
259
 
260
+ // 6.9 模板下发感知(2026-10-06 template-downstream,只读 advisory——与 sync 出账同判据
261
+ // profiles.templateDriftOf 单源;doctor 不写 kit.json,两次 sync 之间持续回显,sync 后自然消隐)。
262
+ // 语义:源仓模板已演进且盘面未跟随 → WARN 提醒(owned 归项目所有,不自动覆盖);首次引入 WARN。
263
+ // 旧装户 owned[] 无 srcSha256 → no-anchor 静默跳过(无判定依据不产出告警),下次 sync 写锚后生效。
264
+ const tplRes = checkTemplateDrift(target, pkgRoot);
265
+ if (tplRes.skipped) {
266
+ if (tplRes.note) add('PASS', `模板感知跳过(${tplRes.note})`);
267
+ } else if (tplRes.stale.length === 0) {
268
+ add('PASS', `模板感知 ${tplRes.total} 份无陈旧分歧(源仓模板与上次同步锚一致或盘面已跟随)`);
269
+ } else {
270
+ add('WARN', `模板感知:源仓 ${tplRes.stale.length} 份模板自上次同步后有演进且盘面未跟随:${tplRes.stale.join('、')}——如需跟随从包源 templates/ 拷贝;有意定制可忽略(跑 flow-kit sync 刷新锚后本项静默)`);
271
+ }
272
+
260
273
  // 7. check-loop——2026-09-26 check-loop-node 起 node 实现直跑(sh 版曾需先探 sh 可用性:Windows
261
274
  // PowerShell 常无 sh,ENOENT 曾被吞进 hard-block 分支报成空原因假警报,incident 2026-09-24-doctor-sh-enoent;
262
275
  // 迁移后无 sh 依赖,探针退役。check-loop.sh 为兼容 shim,pre-push 钩子路径照常)
@@ -351,6 +364,42 @@ export function checkOwnedDrift(target) {
351
364
  return { drift, gone, total: owned.length, skipped: false };
352
365
  }
353
366
 
367
+ // 模板下发感知(2026-10-06 template-downstream):只读判据——owned 模板条目三方 sha 比对,
368
+ // 判定单源 profiles.templateDriftOf(与 sync 出账同源,防两处字面量漂移)。恒不写 kit.json。
369
+ // 返回 { skipped, note?, total, stale[] }:skipped=true 时 note 说明原因(无 kit / 无 owned /
370
+ // 无锚——旧装户静默不追溯);total=参与比对的条目数;stale=stale-drift 的 rel 清单。
371
+ export function checkTemplateDrift(target, pkgRoot) {
372
+ const kitPath = path.join(target, '.agents', 'kit.json');
373
+ if (!fs.existsSync(kitPath)) return { skipped: true, note: 'kit.json 不存在', total: 0, stale: [] };
374
+ let kit;
375
+ try {
376
+ kit = JSON.parse(fs.readFileSync(kitPath, 'utf8'));
377
+ } catch (e) {
378
+ return { skipped: true, note: `kit.json 解析失败:${e.message}`, total: 0, stale: [] };
379
+ }
380
+ if (!pkgRoot) return { skipped: true, note: '无包源根', total: 0, stale: [] };
381
+ const owned = Array.isArray(kit.owned) ? kit.owned : [];
382
+ if (owned.length === 0) return { skipped: true, note: 'kit.json 无 owned 条目', total: 0, stale: [] };
383
+ const shaText = (p) => createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
384
+ const stale = [];
385
+ let total = 0;
386
+ let anchored = 0;
387
+ for (const f of owned) {
388
+ if (TEMPLATE_DRIFT_EXCLUDE.has(f.rel)) continue;
389
+ const src = srcTemplatePath(pkgRoot, f.rel);
390
+ if (!src) continue;
391
+ total++;
392
+ if (typeof f.srcSha256 !== 'string' || !f.srcSha256) continue; // no-anchor:旧装户静默跳过
393
+ anchored++;
394
+ const srcCur = shaText(src);
395
+ const diskPath = path.join(target, f.rel);
396
+ const disk = fs.existsSync(diskPath) ? shaText(diskPath) : null;
397
+ if (templateDriftOf({ disk, srcRecord: f.srcSha256, srcCur }) === 'stale-drift') stale.push(f.rel);
398
+ }
399
+ if (anchored === 0) return { skipped: true, note: 'owned 模板条目均无 srcSha256 锚(旧装户,下次 sync 写锚后生效)', total: 0, stale: [] };
400
+ return { skipped: false, total, stale };
401
+ }
402
+
354
403
  // 跨宿主薄适配正文段漂移校验(独立 export 供 doctor 主流程 + 单元测试共用;2026-09-25 cross-host-sync)
355
404
  // 装户侧视角:.agents/{commands,roles}/*.md 是权威源;4 宿主目录(zcode→.zcode、omp→.omp、
356
405
  // opencode→.opencode、trae→.trae)下 {agents,commands}/*.md 是薄适配。按正文段 sha 比对
@@ -25,6 +25,24 @@ const out = `${r.stdout || ''}${r.stderr || ''}`;
25
25
  check('fresh init exit 0', r.status === 0, `exit=${r.status}\n${out.split('\n').slice(-30).join('\n')}`);
26
26
  const kit = JSON.parse(fs.readFileSync(path.join(dir, '.agents', 'kit.json'), 'utf8'));
27
27
  check('新装 audit 为 false 且 policyVersion 为 2', kit.audit === false && kit.policyVersion === 2, JSON.stringify({ audit: kit.audit, policyVersion: kit.policyVersion }));
28
+ // 模板下发感知初始锚(2026-10-06 template-downstream):有包源模板对应的 owned 条目带 srcSha256=
29
+ // 包源模板原文 LF 归一 sha;生成器目标(INDEX.md 类)排除出感知面无该键
30
+ {
31
+ const { createHash } = await import('node:crypto');
32
+ const tplAnchor = (rel) => kit.owned.find((f) => f.rel === rel);
33
+ const shaOf = (buf) => createHash('sha256').update(buf).digest('hex');
34
+ const agents = tplAnchor('AGENTS.md');
35
+ const srcRaw = fs.readFileSync(path.join(ROOT, 'templates', 'AGENTS.md'), 'utf8').replace(/\r\n/g, '\n');
36
+ check('新装 owned 模板条目带 srcSha256 且等于包源原文 sha(AGENTS.md)',
37
+ agents && typeof agents.srcSha256 === 'string' && agents.srcSha256 === shaOf(Buffer.from(srcRaw, 'utf8')),
38
+ JSON.stringify(agents || null));
39
+ check('新装生成器目标无锚(wiki/INDEX.md 排除出感知面)',
40
+ tplAnchor('wiki/INDEX.md') !== undefined && tplAnchor('wiki/INDEX.md').srcSha256 === undefined);
41
+ const cfg = tplAnchor('.agents/hooks/commit-check.config.json');
42
+ check('新装无包源模板对应条目无锚(生成类配置不参与感知)', cfg !== undefined && cfg.srcSha256 === undefined);
43
+ const rb = tplAnchor('.agents/rule-budgets.txt');
44
+ check('新装 _agents 前缀翻译参与感知(rule-budgets 带锚)', rb !== undefined && typeof rb.srcSha256 === 'string');
45
+ }
28
46
  check('claude 命令与角色薄适配落盘',
29
47
  fs.existsSync(path.join(dir, '.claude', 'commands', 'wf-plan.md'))
30
48
  && fs.existsSync(path.join(dir, '.claude', 'agents', 'implementer.md')));
package/src/init.mjs CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import { execSync, spawnSync } from 'node:child_process';
7
7
  import { renderTree, renderContent, sha256, scriptTrusted, listTree } from './render.mjs';
8
- import { HOSTS, STACKS, STACK_ALIASES, settingsJson, commitCheckConfig, pickStackVars, isOwned } from './profiles.mjs';
8
+ import { HOSTS, STACKS, STACK_ALIASES, settingsJson, commitCheckConfig, pickStackVars, isOwned, srcTemplatePath, TEMPLATE_DRIFT_EXCLUDE } from './profiles.mjs';
9
9
  import { doctor } from './doctor.mjs';
10
10
 
11
11
  function fail(msg) {
@@ -292,7 +292,7 @@ export async function init(args, pkgRoot) {
292
292
  // owned 的生成器目标按生成后盘面重记(P1-2);记账一律 LF 归一 sha(P1-4——render 写盘已归一,
293
293
  // 生成器重写目标与用户合并文件两侧同口径,跨 checkout 字节稳定)
294
294
  const shaText = (p) => sha256(Buffer.from(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
295
- const GEN_TARGETS = new Set(['workflow/INDEX.md', 'wiki/INDEX.md', 'wiki/知识沉淀总览.html']); // 新增生成器目标须同步本清单
295
+ const GEN_TARGETS = TEMPLATE_DRIFT_EXCLUDE; // 单源挪 profiles(2026-10-06 template-downstream):模板感知排除面与生成器重记面同集
296
296
  const managed = [
297
297
  ...t.written.filter((f) => !isOwned(f.rel)),
298
298
  // 宿主文件渲染时相对宿主根,入台账须还原为项目根相对路径
@@ -311,6 +311,14 @@ export async function init(args, pkgRoot) {
311
311
  owned.push({ rel, sha256: shaText(path.join(target, rel)) });
312
312
  }
313
313
  if (agentsMergedSha) owned.push({ rel: 'AGENTS.md', sha256: agentsMergedSha });
314
+ // 模板下发感知初始锚(2026-10-06 template-downstream):有包源模板对应的 owned 条目记
315
+ // srcSha256=包源模板原始文件 LF 归一 sha——锚记「见过的源」而非「期望盘面」(装户渲染/定制与锚正交);
316
+ // GEN_TARGETS 是生成器重写目标,不期望被跟随,排除出感知面(防「拉取了也被重写」的单周期无谓出账)
317
+ const ownedFinal = owned.map((f) => {
318
+ if (GEN_TARGETS.has(f.rel)) return f;
319
+ const src = srcTemplatePath(pkgRoot, f.rel);
320
+ return src ? { ...f, srcSha256: shaText(src) } : f;
321
+ });
314
322
  const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
315
323
  fs.mkdirSync(path.dirname(kitPath), { recursive: true });
316
324
  fs.writeFileSync(kitPath, `${JSON.stringify({
@@ -322,9 +330,9 @@ export async function init(args, pkgRoot) {
322
330
  createdAt: new Date().toISOString(),
323
331
  options: { hosts, stack: opt.stack, boardPort: String(opt.boardPort) },
324
332
  managed,
325
- owned,
333
+ owned: ownedFinal,
326
334
  }, null, 2)}\n`);
327
- console.log(` kit.json 台账:managed ${managed.length} 份 | owned ${owned.length} 份(版本 ${pkg.version})`);
335
+ console.log(` kit.json 台账:managed ${managed.length} 份 | owned ${ownedFinal.length} 份(版本 ${pkg.version})`);
328
336
 
329
337
  // 8) 自检(kit.json 已按生成后盘面记账——doctor §6.6 应全绿,fresh init 以 exit 0 收场)
330
338
  // 输出语义(p2-batch1 P2-4):doctor 打印在异步端口回调里、晚于本横幅——脚本化消费方按退出码判定
package/src/profiles.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  // 宿主注册表 + 技术栈 profile + init 生成的 owned 基线配置
2
+ // fs/path 仅供 srcTemplatePath(模板下发感知)——显式 import,不依赖 Node 22+ 的全局 fs(engines >=18)
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
2
5
  // stack 决定门禁配置三处:commit-check 的条件构建(builds)与质量检测(checks,秒级确定性
3
6
  // 检查——lint/类型/vet;测试不放提交门,关单在 test.md 阶段门)、settings.json 的自检验
4
7
  // 命令权限(allow)、AGENTS.md「项目适配区」命令预填;不改工作流流程本身。
@@ -112,6 +115,37 @@ export function isOwned(rel) {
112
115
  || rel === '.agents/hooks/local-pre-commit';
113
116
  }
114
117
 
118
+ // ---- 模板下发感知(2026-10-06 template-downstream)----
119
+ // 装户 owned 模板无下发通道(init 一次性复制),源仓演进静默陈旧——S18/S20 装户事故根因。
120
+ // 感知层 = 三方 sha 比对(一律 LF 归一,与 ownedSha 自愈同口径):
121
+ // disk=装户盘面 / srcRecord=kit.json owned[].srcSha256(上次 sync/init 见过的包源 sha)/ srcCur=本次包源。
122
+
123
+ // 生成器目标(init「按生成后盘面重记」的清单,原 init 内 GEN_TARGETS 挪此单源):生成器拥有该文件,
124
+ // 装户不期望跟随包源模板——模板感知排除出比对面(防「拉取了也被生成器重写」的无谓出账)。新增生成器目标须同步本清单。
125
+ export const TEMPLATE_DRIFT_EXCLUDE = new Set(['workflow/INDEX.md', 'wiki/INDEX.md', 'wiki/知识沉淀总览.html']);
126
+
127
+ // 映射规则单源:renderTree 的「srcRoot/templates ↔ targetRoot/项目根」关系 + 点目录前缀翻译
128
+ // (render.mjs:`_agents` → `.agents`)——装户 rel 以 .agents/ 开头时探 templates/_agents/<余径>。
129
+ // 复核 P2-1(2026-10-06 template-downstream 独立复核):漏翻译会让 rule-budgets.txt /
130
+ // metric-claims.txt / local-pre-commit 等 5 条有包源起步模板的 owned 件永不参与感知。
131
+ export function srcTemplatePath(pkgRoot, rel) {
132
+ const tplRel = rel.startsWith('.agents/') ? `_agents/${rel.slice('.agents/'.length)}` : rel;
133
+ const p = path.join(pkgRoot, 'templates', tplRel);
134
+ return fs.existsSync(p) ? p : null;
135
+ }
136
+
137
+ // 三态判定纯函数(sync 出账与 doctor 回显共用——防两处字面量漂移,N3 教训):
138
+ // 'stale-drift' 源已演进且盘面未跟随 → 唯一出账形态(advisory)
139
+ // 'custom-synced' 盘面 ≠ 源但源 == 上次锚 → 装户定制跟源,owned 语义正常态,永不告警
140
+ // 'synced' 盘面 == 当前源 → 静默(手工拉取后天然落此态)
141
+ // 'no-anchor' 台账缺 srcSha256(旧装户)→ 静默跳过,下次 sync 写锚后生效,不追溯
142
+ export function templateDriftOf({ disk, srcRecord, srcCur }) {
143
+ if (typeof srcRecord !== 'string' || !srcRecord) return 'no-anchor';
144
+ if (disk === srcCur) return 'synced';
145
+ if (srcCur !== srcRecord) return 'stale-drift';
146
+ return 'custom-synced';
147
+ }
148
+
115
149
  export function settingsJson(stackKey) {
116
150
  const allow = [...new Set([
117
151
  'node', 'git status', 'git diff', 'git add', 'git commit', 'git log', 'git push',
package/src/sync.mjs CHANGED
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import os from 'node:os';
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { renderTree, sha256, scriptTrusted } from './render.mjs';
15
- import { HOSTS, pickStackVars, isOwned } from './profiles.mjs';
15
+ import { HOSTS, pickStackVars, isOwned, srcTemplatePath, templateDriftOf, TEMPLATE_DRIFT_EXCLUDE } from './profiles.mjs';
16
16
  import { doctor } from './doctor.mjs';
17
17
 
18
18
  function fail(msg) {
@@ -166,6 +166,28 @@ export function sync(args, pkgRoot) {
166
166
  }
167
167
  if (ownedRefreshed) console.log(` owned 台账哈希按盘面刷新 ${ownedRefreshed} 份(owned 归项目所有,仅记账不约束)`);
168
168
 
169
+ // 模板下发感知(2026-10-06 template-downstream,advisory 恒不 hard-block):owned 模板无下发通道,
170
+ // 源仓演进静默陈旧(S18/S20 装户事故根因)——三方 sha 判定见 profiles.templateDriftOf 单源。
171
+ // 锚「见过即刷新」:本次 sync 见到的包源 sha 记入 srcSha256(含旧账无锚首跑写锚——写锚不出账,
172
+ // 不追溯);唯一出账形态 = 源已演进且盘面未跟随。GEN_TARGETS 生成器目标不期望被跟随,排除。
173
+ const driftList = [];
174
+ if (Array.isArray(kit.owned)) {
175
+ kit.owned = kit.owned.map((f) => {
176
+ if (TEMPLATE_DRIFT_EXCLUDE.has(f.rel)) return f;
177
+ const src = srcTemplatePath(pkgRoot, f.rel);
178
+ if (!src) return f; // 无包源模板对应(项目配置类)不参与
179
+ const srcCur = ownedSha(src);
180
+ const diskPath = path.join(target, f.rel);
181
+ const disk = fs.existsSync(diskPath) ? ownedSha(diskPath) : null;
182
+ if (templateDriftOf({ disk, srcRecord: f.srcSha256, srcCur }) === 'stale-drift') driftList.push(f.rel);
183
+ return srcCur === f.srcSha256 ? f : { ...f, srcSha256: srcCur };
184
+ });
185
+ }
186
+ if (driftList.length) {
187
+ console.log(` ⚠️ 模板感知(advisory):源仓 ${driftList.length} 份模板自上次同步后有演进且盘面未跟随:${driftList.join('、')}`);
188
+ console.log(' owned 归项目所有,不自动覆盖;如需跟随:从包源 templates/ 拷贝对应文件;有意定制可忽略(下次 sync 起静默)');
189
+ }
190
+
169
191
  // 台账重写:版本对齐当前包;removed 出册;skipped 保持包侧基线持续报告
170
192
  kit.managed = managedNew.sort((a, b) => a.rel.localeCompare(b.rel));
171
193
  kit.version = pkg.version;
package/src/sync.test.mjs CHANGED
@@ -296,5 +296,75 @@ else if (cmd === 'add-gate') addGate(rest, root);
296
296
  check('S14③ 盘上无 → 新增安装(既有行为不回归)', (r3.stdout + r3.stderr).includes('新增安装') && R(path.join(t3, '.agents/commands/new.txt')) === 'new in v2\n');
297
297
  }
298
298
 
299
+ // ============ 场景 15:模板下发感知三态(2026-10-06 template-downstream)============
300
+ // fixture 包源 templates/AGENTS.md = 'owned skeleton v2\n';盘面定制 'owned CUSTOM\n'(≠ 包源)
301
+ {
302
+ const sha = (s) => sha256(Buffer.from(s, 'utf8'));
303
+ const SRC_TPL = 'owned skeleton v2\n';
304
+ const kitOf = (t) => JSON.parse(R(path.join(t, '.agents', 'kit.json')));
305
+ const mkStale = (fx, t, owned) => W(path.join(t, '.agents', 'kit.json'), `${JSON.stringify({
306
+ kit: 'agentic-flow-kit', version: '1.0.0', createdAt: '2026-09-23T00:00:00Z',
307
+ options: { hosts: ['zcode'], stack: 'none', boardPort: '777' },
308
+ managed: [{ rel: '.agents/scripts/keep.txt', sha256: sha('keep v2\n') }], owned,
309
+ }, null, 2)}\n`);
310
+
311
+ // ① 源演进未拉取 → advisory 出账(唯一出账形态)+ 锚刷新 + 再跑静默(单周期出账)
312
+ const fx1 = mkFixture(), t1 = mkTarget(fx1);
313
+ W(path.join(t1, 'AGENTS.md'), 'owned CUSTOM\n');
314
+ mkStale(fx1, t1, [{ rel: 'AGENTS.md', sha256: sha('owned CUSTOM\n'), srcSha256: sha('owned skeleton v1\n') }]);
315
+ let r1 = runCmd('sync', fx1, t1);
316
+ const out1 = r1.stdout + r1.stderr;
317
+ // 断言锚定出账特征串「有演进且盘面未跟随」——doctor §6.9 的 PASS 回显也含「模板感知」四字,不可作判据
318
+ check('S15① 源演进未拉取 → 出账「模板感知」含文件名', out1.includes('有演进且盘面未跟随') && out1.includes('AGENTS.md'), out1.slice(0, 300));
319
+ check('S15① 锚刷新为当前包源 sha', kitOf(t1).owned[0].srcSha256 === sha(SRC_TPL));
320
+ r1 = runCmd('sync', fx1, t1);
321
+ check('S15① 锚已刷 → 再跑静默(单周期出账)', !(r1.stdout + r1.stderr).includes('有演进且盘面未跟随'));
322
+
323
+ // ② 定制跟源(srcSha256 == 当前包源)→ custom-synced 静默
324
+ const fx2 = mkFixture(), t2 = mkTarget(fx2);
325
+ W(path.join(t2, 'AGENTS.md'), 'owned CUSTOM\n');
326
+ mkStale(fx2, t2, [{ rel: 'AGENTS.md', sha256: sha('owned CUSTOM\n'), srcSha256: sha(SRC_TPL) }]);
327
+ const r2 = runCmd('sync', fx2, t2);
328
+ check('S15② 定制跟源 → 静默(owned 语义正常态永不告警)', !(r2.stdout + r2.stderr).includes('有演进且盘面未跟随'));
329
+
330
+ // ③ 旧 schema 无锚(mkTarget 原生)→ 静默写锚不追溯
331
+ const fx3b = mkFixture(), t3b = mkTarget(fx3b);
332
+ const r3b = runCmd('sync', fx3b, t3b);
333
+ check('S15③ 旧 schema 无锚 → 静默(写锚不出账,不追溯)', !(r3b.stdout + r3b.stderr).includes('有演进且盘面未跟随'));
334
+ check('S15③ 无锚首跑写入锚(下次 sync 起生效)', kitOf(t3b).owned.find((f) => f.rel === 'AGENTS.md').srcSha256 === sha(SRC_TPL));
335
+
336
+ // ④ 手工拉取(盘面 == 包源)→ synced 静默(可消退出账的路径)
337
+ const fx4 = mkFixture(), t4 = mkTarget(fx4);
338
+ mkStale(fx4, t4, [{ rel: 'AGENTS.md', sha256: sha(SRC_TPL), srcSha256: sha('owned skeleton v1\n') }]);
339
+ const r4 = runCmd('sync', fx4, t4);
340
+ check('S15④ 盘面已跟随(手工拉取)→ 静默', !(r4.stdout + r4.stderr).includes('有演进且盘面未跟随'));
341
+
342
+ // ⑤ doctor 只读回显:同判据出 stale 清单、且运行后 kit.json 字节不变;旧账无锚 skipped
343
+ // (no-anchor 须用未跑过 sync 的独立 fixture——sync 会写锚)
344
+ const { checkTemplateDrift } = await import(pathToFileURL(path.join(SRC_ROOT, 'doctor.mjs')).href);
345
+ const fx5 = mkFixture(), t5 = mkTarget(fx5);
346
+ W(path.join(t5, 'AGENTS.md'), 'owned CUSTOM\n');
347
+ mkStale(fx5, t5, [{ rel: 'AGENTS.md', sha256: sha('owned CUSTOM\n'), srcSha256: sha('owned skeleton v1\n') }]);
348
+ const before = R(path.join(t5, '.agents', 'kit.json'));
349
+ const resStale = checkTemplateDrift(t5, fx5);
350
+ check('S15⑤ doctor 判据同源:stale 出清单', resStale.skipped === false && resStale.stale.includes('AGENTS.md'), JSON.stringify(resStale));
351
+ check('S15⑤ doctor 只读:kit.json 字节不变', R(path.join(t5, '.agents', 'kit.json')) === before);
352
+ const fx6 = mkFixture(), t6 = mkTarget(fx6);
353
+ const resNoAnchor = checkTemplateDrift(t6, fx6);
354
+ check('S15⑤ doctor 旧账无锚 → skipped 静默', resNoAnchor.skipped === true, JSON.stringify(resNoAnchor));
355
+
356
+ // ⑥ 点目录前缀翻译(复核 P2-1):owned rel `.agents/x` ↔ 包源 `templates/_agents/x`——
357
+ // 有包源起步模板的 .agents/ owned 件同样参与感知(rule-budgets.txt 等 5 类)
358
+ const fx7 = mkFixture(), t7 = mkTarget(fx7);
359
+ W(path.join(fx7, 'templates/_agents/rule-budgets.txt'), 'budgets v2\n');
360
+ W(path.join(t7, '.agents/rule-budgets.txt'), 'budgets CUSTOM\n');
361
+ mkStale(fx7, t7, [
362
+ { rel: 'AGENTS.md', sha256: sha('owned skeleton v2\n'), srcSha256: sha(SRC_TPL) },
363
+ { rel: '.agents/rule-budgets.txt', sha256: sha('budgets CUSTOM\n'), srcSha256: sha('budgets v1\n') },
364
+ ]);
365
+ const r7 = runCmd('sync', fx7, t7);
366
+ check('S15⑥ .agents/ 前缀翻译 → _agents 模板参与感知出账', (r7.stdout + r7.stderr).includes('有演进且盘面未跟随') && (r7.stdout + r7.stderr).includes('.agents/rule-budgets.txt'));
367
+ }
368
+
299
369
  console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
300
370
  process.exit(failCount ? 1 : 0);
@@ -65,6 +65,7 @@ const PAIRS = [
65
65
  { doctor: '6.6', cl: null, note: 'owned 漂移——装户体检独有' },
66
66
  { doctor: '6.7', cl: null, note: '跨宿主薄适配——装户体检独有' },
67
67
  { doctor: '6.8', cl: null, note: 'workflows 编排脚本 lint——装户体检独有' },
68
+ { doctor: '6.9', cl: null, note: '模板下发感知(2026-10-06 template-downstream)——装户体检独有(只读回显 sync 出账同源判据)' },
68
69
  { doctor: '8', cl: null, note: '看板端口——信息级独有' },
69
70
  // 经 §7 覆盖:doctor §7 整体运行 check-loop(#2/#11 已直接配对,不重复登记)
70
71
  { doctor: '7', cl: '1', note: '经 §7 整体运行覆盖' },