peaks-loop 4.0.49 → 4.0.50

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.
Files changed (119) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/core/skill-command.js +53 -4
  7. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  8. package/dist/cli/commands/core/standards-command.js +74 -0
  9. package/dist/cli/commands/hooks-commands.js +55 -38
  10. package/dist/cli/commands/share-commands.js +37 -11
  11. package/dist/cli/commands/web-commands.js +8 -1
  12. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  13. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  14. package/dist/services/adapter/adapter.d.ts +30 -0
  15. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  16. package/dist/services/adapter/claude-adapter.js +12 -0
  17. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  18. package/dist/services/adapter/codex-adapter.js +12 -0
  19. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  20. package/dist/services/adapter/copilot-adapter.js +12 -0
  21. package/dist/services/audit/backing-detector.d.ts +25 -7
  22. package/dist/services/audit/backing-detector.js +33 -17
  23. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  24. package/dist/services/audit/enforcer-liveness.js +100 -0
  25. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  26. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  27. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  28. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  29. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  30. package/dist/services/audit/enforcers/lint-style.js +38 -2
  31. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  32. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  33. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  34. package/dist/services/audit/red-lines-service.js +51 -7
  35. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  36. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  37. package/dist/services/capability-audit-service/index.d.ts +3 -1
  38. package/dist/services/capability-audit-service/index.js +1 -0
  39. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  40. package/dist/services/capability-audit-service/runner.js +76 -15
  41. package/dist/services/capability-audit-service/types.d.ts +48 -0
  42. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  43. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  44. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  45. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  46. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  47. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  48. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  49. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  50. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  51. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  52. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  53. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  54. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  55. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  56. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  57. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  58. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  59. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  60. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  61. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  62. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  63. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  64. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  65. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  66. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  67. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  68. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  69. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  70. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  71. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  72. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  73. package/dist/services/capability-guard-runner/registry.js +140 -0
  74. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  75. package/dist/services/capability-guard-runner/runner.js +63 -6
  76. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  77. package/dist/services/code/auto-compact-modes.js +20 -4
  78. package/dist/services/code/post-compact-detector.js +20 -11
  79. package/dist/services/code/step-08-gate.js +21 -6
  80. package/dist/services/config/config-safety.js +11 -9
  81. package/dist/services/final-review/pre-post-diff.js +10 -2
  82. package/dist/services/observability/observability-service.d.ts +1 -1
  83. package/dist/services/scan/api-diff-types.js +20 -2
  84. package/dist/services/security/safe-settings-path.js +19 -1
  85. package/dist/services/skill/skill-search-service.d.ts +3 -3
  86. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  87. package/dist/services/standards/loop-engineering-lint.js +6 -0
  88. package/dist/services/web/daemon-registry.js +27 -2
  89. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  90. package/dist/services/workspace/claude-settings-template.js +105 -83
  91. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  92. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  93. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  94. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  95. package/dist/services/workspace/workspace-service.js +11 -1
  96. package/dist/shared/fs-utils.d.ts +26 -0
  97. package/dist/shared/fs-utils.js +35 -0
  98. package/package.json +9 -7
  99. package/scripts/copy-templates.mjs +0 -12
  100. package/scripts/install-skills.mjs +154 -53
  101. package/skills/bee/peaks-qa/SKILL.md +0 -1
  102. package/skills/bee/peaks-rd/SKILL.md +0 -1
  103. package/skills/peaks-code/SKILL.md +12 -10
  104. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  105. package/skills/peaks-code/references/runbook.md +3 -0
  106. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  107. package/skills/peaks-code/references/startup-sequence.md +2 -2
  108. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  109. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  110. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  111. package/dist/cli/commands/context-builder-commands.js +0 -85
  112. package/dist/services/hooks/write-gate.js +0 -111
  113. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  114. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  115. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  116. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  117. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  118. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  119. package/skills/peaks-code/references/command-migration.md +0 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.50 — 2026-09-16 (自审把 29% 从分母里排除 + 反漂移闸门报 15/15 而只跑了 1 条 + 一条"no exceptions"的红线没有任何执行者 + 五个测试套件从未运行过 + macOS 从未进 CI)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **"散文比例 0%" 是一个重新定义了自己分母的指标。** `peaks audit red-lines` 报 `proseOnly: 0`,而**同一份 JSON 里 44 行**写着 `"backing": "prose-only"` —— `classifier.ts` 把所有未匹配的 marker 标为 `informational: true`,比例计算再把它**排除出分母**。同一批修复查出 `cli-backed` 的判定是 `existsSync(enforcerRef)`:**文件在磁盘上存在即算"有机器执行"**,于是 **9 个 enforcer**(共 51 行)从未被导入、从未被调用,却被计入机器执行。诚实重算,同一棵树:**cli-backed 105 → 54,prose-only 0 → 98(64.5%)**。指标变难看,是因为它开始度量该度量的东西。另外两个同族缺陷:`lint-rd-handoff-coverage` 检查的是 **SKILL.md 里描述产物的那句话**,从不读产物文件;`release-precheck` 的 AC-9 **把 bug 本身钉成了期望值**(`proseOnly === 0`),现在钉的是"摘要与逐行计数一致"。
8
+
9
+ 2. **RL-10 指定的反漂移机制在自证,而它能自证是因为它只跑了一条。** `peaks baseline audit` 输出 `verdict: consistent`、`consistencyScore: 1`、证据写着 `"capability-guard-runner:15 — 15 pass / 0 fail"`。真相是 `guardSummary` **硬编码为 `{pass:15,fail:0}`**,LLM 打分环节是**返回常量**的桩;而 `run-guard` 的派发是 `opts.journey === 'J01' ? … : {status:'skipped'}` —— **不带参数时只跑 J01**,其余 14 条返回 `skipped` 且 **exit 0**,与"通过"在退出码上不可区分。15 个 guard contract 里 13 个是 `existsSync` + 常见词子串(J10 断言 `hooks-commands.ts` 含词 `hook`;J06 的注释书面承认把闸门改松以让它通过)。现在:15 条 contract 走一个 registry 全部真实执行,`skipped` 是 **exit 2**、`fail` 是 **exit 1**;`assertBaselineRef` 真的解析冻结行与**逐字不变量文本**,不再只检查字段为真值;`freeze-update` / `rollback` / `reset` 三件套有了可达的成功路径(此前它们无条件 `fail`,**棘轮动不了**)。**诚实否定**:J10 的 `HOOK_PERMISSION_DENIED` 在基线 JSON 里存在但从未实现 —— 没有把它编码成探针,因为那会造出一盏永久的红灯;J08 的不变量措辞夸大了四个 gate 实际塌缩成一个布尔。
10
+
11
+ 3. **发布闸门从"只拒一个字面量"改成白名单,于是它立刻挡住了发布。** `publish.yml` 原本只在 `verdict` **精确等于 `"drifted"`** 时失败 —— `partial` / `inconclusive` / 任何别的值都会放行。改成白名单(仅 `consistent` 放行)之后,上面那条伪造的 15/15 不再能放行任何东西,**发布被挡住了**。这是刻意的、也是被接受的:闸门不再是摆设,代价是它开始真的挡。解开它的不是放宽,而是接上一个**能失败的确定性独立检查器**(见第 4 条)。同一轮里 `describeMode()` 被发现把自己描述成 `0.85/0.95`,**漏掉真正生效的 0.80 档**;往下挖还查出一条真实分叉:`peaks skill presence`(每轮都调)在 **≥0.80** 触发 `auto-fire`,而 `peaks code context-now` —— SKILL.md 自己声明的 single source of truth —— **硬编码了它自己的 0.85**。只改描述,**没碰阈值和行为**。
12
+
13
+ 4. **独立打分器:独立性是信息通道的属性,不是基质的属性。** 桩打分器不能自证之后,需要的是一个**真正独立**的评估者。选择确定性检查器而非 LLM,有两个理由:其一,**LLM 喂原来那份 payload `{baselineJourneyId, guard}` 只是更贵的橡皮图章** —— 它会把守卫结果重述一遍;其二,也是决定性的,**`publish.yml` 是 secretless 的**(OIDC,`contents: read` / `id-token: write`,任何步骤都没有 token),LLM 打分器需要 key,加一个 key 等于**用一个无密钥的发布管线去换一个非确定性的外部依赖,还塞在闸门里**。检查器改为读**冻结行 + 武装它们的 contract registry + 守卫摘要**三者比对。`llmRunner` 接缝被**删除**,而不是留成一个带假调用者的死参数。裁决语义**收紧而非放宽**:`consistent` 现在要求 `guardFail === 0` **且**零 findings,`inconclusive` 只能从 `--scorer stub` 到达。**残留如实披露**:每次运行都报 `coverage`,本轮是 `invariantsArmed 15/45`、`forbiddenChangesUnverified 30` —— 所以 `consistent` 读作"在当前度量范围内一致",不可过度解读。注入验证(真实基线 + registry,三臂 + 干净对照):对照 `consistent`;把一个冻结的 `sourceFiles` 条目指向磁盘上不存在的路径 → **`drifted`**,`SOURCE_FILE_MISSING` 点名 J01。
14
+
15
+ 5. **多个"闸门"的共同形状:声称有执行者,执行者不在。** (a) `CLAUDE.md:34` 称顶层目录禁令由 `tests/unit/workspace/top-level-change-id-guard.test.ts`「**will fail the suite**」守护 —— **该文件不存在**,死于 `457b9a87`;四层防护的第 2 层缺失。(b) 红线文件称 RL-0..RL-10 由 `peaks standards lint --category loop-engineering` 强制 —— **该子命令根本没注册**,其实现函数 `lintLoopEngineeringGuidelines` **全仓零调用者**;现在动词已注册,RL-10 进了校验集,`EXPECTED_RED_LINE_IDS` 不再止于 RL-9。(c) `CLAUDE.md` 描述的 `--change-id` 流程与 `current-change` 绑定文件**两者都不存在**,且与 `.peaks/PROJECT.md` 明文矛盾 —— 以 PROJECT.md 为准修正。(d) 三份权威设计文档(含 `docs/adr/` **整个目录**)不存在,定位所依赖的结晶设计 spec **只活在记忆的散文里**。(e) `package.json` 的 `test:race` 点名 4 个文件、**只有 1 个存在**,靠 `passWithNoTests` **静默空跑退出 0**。(f) `Co-Authored-By` 红线称 "no exceptions",而 `.git/hooks` 里只有 `.sample`、无 husky/lefthook/commitlint、`tests/` 里只出现在一个 fixture 的散文中 —— 现在有了 vitest 守卫(选它而非 hook,因为 `.git/hooks` 不被 git 跟踪、clone 后不存在),失败路径是一条**永久用例**:建真仓库、提交违规信息、断言变红,配干净对照。两条排除规则是被真实历史逼出来的:**只有行首 trailer**,且**括号里的说明不是身份**(`Co-Authored-By: (omitted per CLAUDE.md red rule)` 历史上出现过两次,天真搜索**一出生就是红的**)。**残留如实披露**:它检出,不阻止。另新增一个**引用完整性守卫** —— 文档里被反引号括起的仓库路径若不存在则变红(注入验证过)。**而它自己有作用域缺口**,见第 8 条。
16
+
17
+ 6. **五个测试套件从未运行过。** 整个 `tests/e2e/`(5 个真实 CLI 关键路径测试 + 3 个 shell 脚本)被**两个 vitest 配置同时排除**,且**没有任何脚本或 CI 步骤选中它**。另有 7 个测试门控在一个**无处设置**的环境变量 `PEAKS_BUILD_AVAILABLE` 上 —— 以无人设置的环境变量为条件的 skip 就是穿着戏服的永久 skip。`stryker.vitest.config.mjs` 指向两个在 `f17aa377` 被删除的路径,**变异测试按当前配置跑不起来**;修好后它跑到变异阶段并诚实报 **0.00%**:4 个被变异的文件里**有 3 个没有任何测试导入**。两条不可能失败的断言被重写并注入验证(一条上一行刚构造出 `markerPath`、下一行断言它 `endsWith(...)`,注释自认目的是消除 lint 告警;另一条测试名声称 "preserves the original errorId",而被调函数**根本没有 errorId 参数**)。`vm-kvm` 的三个测试在 CI 的全部平台上永不执行,现在 skip 原因进了测试名。**一个测试套件复活后失败的代价被如实计入而非消除。**
18
+
19
+ 7. **消费者真机路径:升级不刷新生成物,而 macOS 从未进过 CI。** `npm i -g` 升级**不刷新**已生成的 `.claude/*.json` —— 唯一写入者是 `initWorkspace`,而 `ensureSession` 在项目已有绑定时**提前返回**,刷新路径不再触发;逃生舱 `peaks upgrade --apply-init` 存在,但**没有任何东西告知用户其配置已陈旧**。生成物也因此**没有版本戳**,落后与否不可判定;现在有了戳和检测器(伪装一个旧版本戳 → 检测器报 `stale: true, package-upgraded`)。管理段 `.gitignore` 是**写入一次、内容永不传播**且无漂移检测。`postinstall` **无条件在 `$HOME` 创建 10 个 IDE 目录** —— 包括用户根本没有的工具;hermes/openclaw 的 profile 不在探测表里,**永远探测不到**;`installProjectConfig` 是有定义、有导出、**零调用**的死代码。**macOS 此前不在任何 workflow 里**,于是每一类 Mac 专属缺陷**在构造上就不可见**(`findTranscriptJsonl` 的 ESM `require` 假绿 —— vitest 因 esbuild 注入的 CommonJS shim 而在完全破损的生产路径上 6/6 全绿)。矩阵现在含 `macos-latest`,并**明确承诺不抑制红色单元格**。另外 `peaks workflow init` **不解析会话绑定、静默把图写进 `unknown-sid`** —— 那个桶自 2026-09-01 起累积了**至少 4 个不同 caller id** 的产物,即**对谁都没成功过**;现在解析失败会明确报错而不是静默兜底。
20
+
21
+ 8. **一个"三个同名层"的适配器结构与一批从未被测过的路径。** 9 个 IDE 适配器只有 1 个有专属测试文件,且只覆盖 compact 路径;**4 个 sub-agent dispatcher**(claudeCode / trae / traeCn / cursor)在全仓测试中**零引用**,含它们产出的 `awaitByLlm` 字段 0 命中 —— 即**"扇出到非 claude-code IDE"这条路径从未被测过**。新增 5 个测试文件 / 114 个用例,6 次注入各配干净对照。两件本不该为真的事:`share-commands.ts` **告诉用户会出现 `awaitByLlm` 标记,而没有任何 adapter 产出它**;`src/services/adapter/{adapter,auto-adapter,claude,codex,copilot}` 是**第三个同名层且完全死掉**(零 importer,方法不收参数)—— 标注而非删除,因为诊断文档引用了那些路径,删除会制造本仓正在清理的那类悬空引用。**移交不吸收**:`traeCnSubAgentDispatcher` / `awaitByLlmFallback` / `registerClaudeCodeAwait` 全仓零引用、`peaks sub-agent await` 传 `recordPaths: []` 因而**永远报不出结果**、9 个 `supportsScope` 谓词全是同义反复 —— 全部报出,未修。同一轮查出**引用完整性守卫自身的作用域缺口**:`de0872b7`(2026-07-05)把 9 个技能移到 `skills/bee/` 却**没有更新指向它们的路径**,而守卫的语料(`CLAUDE.md` / `.peaks/PROJECT.md` / `README.md` / `.peaks/standards`)**不含 `skills/**`** —— 锚点正则接受 `skills/`,语料不含它。**未修,刻意**:扩围守卫会让套件变红,扩围与修复必须同一片落地;规模只作线索(临时扫描器给出 313 处引用 / 95 处缺失,但本仓规矩是用已有 AST 守卫而非另写正则扫描)。
22
+
23
+ 9. **第二个编辑闸门是个空操作,而它的弃用理由论证是对的。** `write-gate.js` 的 `decide()` **对每一条路径都弃权**,且这是刻意的、有完整论证的:退出码 2 才阻塞,其他非零码是**非阻塞错误** —— 动作照常执行,但每次编辑都在 transcript 里刷 `<hook> hook error`,所以旧的"静默 fall through 到闸门"其实是**最吵的那个结果**。弃权正确,**安装不该存在**:它往每个消费者的配置里放了一条 `node "C:/…/nvm/v24.14.0/…"` 指令,**钉死在安装时解析到的那一个 Node 版本目录上**,外加一个 `shell: powershell` 钉。删除的是**安装**而非那个决策,论证保留在 `.claude/HOOKS.md`;**没有**恢复那个 handler —— 那正是本轮的清理对象:一个零调用者却因为"有东西指着它"而活着的东西。删除顺带暴露了刷新路径里的一个真 bug:`templateContentMatches` **把超集判成 current**,于是**已退役的条目原样存活**;修完后在真 CLI 上验证 3 条 → 2 条。
24
+
25
+ 10. **两套编辑闸门装在同一 matcher 上而无人协调**(matcher 写法顺序都不同),且一个消费者项目的会话绑定解析、hook 装配与生成物刷新此前**没有任何一处**是端到端验证过的。本轮的每一次修复都配了**能失败的验证**:能红、能复现、红的理由精确到行号或文件名。**已知未闭合**:`src/` 下已无 `.js` 文件,因此 vendor-neutrality 守卫里那条"`.js` 扩展名盲区"断言被删除,该盲区现在由结构与散文覆盖、**不再由测试覆盖** —— 这是本轮唯一一处**把覆盖换成更弱而非更强**的地方,如实记录而非留给读者发现。
26
+
3
27
  ## 4.0.49 — 2026-09-15 (闸门验的是描述产物的话而不是产物本身 + id 轴三次布防后改走结构 + 两处吞异常与一个不是证据的 mtime + 套件自己把夹具写进了收集 glob)
4
28
 
5
29
  **Highlights**:
package/README-en.md CHANGED
@@ -140,7 +140,7 @@ Every lane opens with **one slash command**.
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.49 (2026-09-15) |
143
+ | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.50 (2026-09-16) |
144
144
  | **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test` · `peaks-slice-decompose`) |
145
145
  | **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
146
146
  | **Test suite** | 285+ cases · 4 packages (peaks-loop / peaks-loop-mut / peaks-loop-shared-channel / peaks-loop-shared) · **0 timeouts** · 14 BDD caller-binding edge cases |
package/README.md CHANGED
@@ -140,7 +140,7 @@ npm i -g peaks-loop
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.49(2026-09-15) |
143
+ | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.50(2026-09-16) |
144
144
  | **覆盖域** | 代码(`peaks-code`) · 内容(`peaks-content`) · 项目健康(`peaks-doctor`) · 批量修 issue(`peaks-issue-fix-orchestrator`) · 自定义 SOP(`peaks-sop`) · 通用原语(`peaks-solo` 分诊 / `peaks-resume` 续 / `peaks-status` 看 / `peaks-test` 测 / `peaks-slice-decompose` 切片) |
145
145
  | **沉淀池** | `~/.peaks/` 本地池 · 跑两次自动晋升成 bee · 跑翻车让你重定义 · bee 跟着你的口味长 |
146
146
  | **测试套件** | 1096 cases · 4 packages (peaks-loop 1015 / runtime 39 / mut 22 / shared-channel 20) · **CI 首次全绿**(ubuntu + windows) · 14 BDD caller-binding coverage |
@@ -455,6 +455,7 @@ function emptyProseRatioResult() {
455
455
  cliBacked: 0,
456
456
  partial: 0,
457
457
  proseOnly: 0,
458
+ discoveredProseOnly: 0,
458
459
  informational: 0,
459
460
  ratio: 0,
460
461
  target: 0.05,
@@ -1,7 +1,11 @@
1
1
  // src/cli/commands/baseline-commands.ts
2
- import { readFileSync } from 'node:fs';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
3
4
  import { historySnapshot, readBaselineFile, writeBaselineFile } from '../../services/capability-baseline/store.js';
4
5
  import { validateBaselineFile } from '../../services/capability-baseline/validator.js';
6
+ import { P0_JOURNEY_IDS } from '../../services/capability-baseline/types.js';
7
+ import { GUARD_CONTRACTS, getGuardContract, isJourneyId } from '../../services/capability-guard-runner/registry.js';
8
+ import { exitCodeForGuardSummary, runAllGuards } from '../../services/capability-guard-runner/runner.js';
5
9
  function fail(io, code, message, data = {}) {
6
10
  io.stdout(JSON.stringify({ ok: false, command: `baseline`, code, message, data, warnings: [], nextActions: [] }));
7
11
  process.exitCode = 1;
@@ -9,6 +13,23 @@ function fail(io, code, message, data = {}) {
9
13
  function ok(io, command, data, nextActions = []) {
10
14
  io.stdout(JSON.stringify({ ok: true, command, data, warnings: [], nextActions }));
11
15
  }
16
+ const CURRENT_DIR = (root) => join(root, 'openspec', 'baselines', 'current');
17
+ const HISTORY_DIR = (root, version) => join(root, 'openspec', 'baselines', 'history', version);
18
+ /** Whitelist of supported `--scorer` values for `peaks baseline audit`. */
19
+ const SCORER_MODES = ['live', 'stub'];
20
+ /**
21
+ * `live` is the default because it is the credential-free one. Defaulting to
22
+ * `stub` would leave the publish gate permanently inconclusive; defaulting to a
23
+ * scorer that needs a key would leave it unrunnable in CI.
24
+ */
25
+ const DEFAULT_SCORER_MODE = 'live';
26
+ function isScorerMode(v) {
27
+ return v !== undefined && SCORER_MODES.includes(v);
28
+ }
29
+ /** The guard `run-guard` / `audit` execute against. */
30
+ function guardContext(projectRoot) {
31
+ return { projectRoot, sessionId: 'cli', contract: {}, baselineInvariant: 'auto' };
32
+ }
12
33
  export function registerBaselineCommands(program, io) {
13
34
  const baseline = program.command('baseline').description('Manage the capability baseline (frozen product semantics for 15 P0 journeys).');
14
35
  baseline
@@ -69,16 +90,38 @@ export function registerBaselineCommands(program, io) {
69
90
  });
70
91
  baseline
71
92
  .command('run-guard')
72
- .description('Run a guard contract over the frozen baseline.')
73
- .option('--journey <id>', 'Run only one journey; default is all 15.')
93
+ .description('Run the guard contracts over the frozen baseline. Runs all 15 journeys unless --journey is given.')
94
+ .option('--journey <id>', `Run only one journey (${P0_JOURNEY_IDS.join('|')}); default is all 15.`)
74
95
  .option('--project <path>', 'Project root', '.')
75
96
  .option('--json', 'Emit JSON envelope')
76
97
  .action(async (opts) => {
77
98
  const projectRoot = opts.project ?? '.';
78
- const { runJ01Contract } = await import('../../services/capability-guard-runner/contracts/J01.js');
79
- const ctx = { projectRoot, sessionId: 'cli', contract: {}, baselineInvariant: 'auto' };
80
- const r = opts.journey ? await (opts.journey === 'J01' ? runJ01Contract(ctx) : Promise.resolve({ status: 'skipped' })) : await runJ01Contract(ctx);
81
- ok(io, 'baseline.run-guard', r);
99
+ let contracts;
100
+ if (opts.journey === undefined) {
101
+ contracts = GUARD_CONTRACTS;
102
+ }
103
+ else if (!isJourneyId(opts.journey)) {
104
+ fail(io, 'UNKNOWN_JOURNEY', `unknown journey "${opts.journey}"; expected one of ${P0_JOURNEY_IDS.join(', ')}`);
105
+ return;
106
+ }
107
+ else {
108
+ const contract = getGuardContract(opts.journey);
109
+ if (contract === undefined) {
110
+ fail(io, 'UNKNOWN_JOURNEY', `no guard contract is registered for ${opts.journey}`);
111
+ return;
112
+ }
113
+ contracts = [contract];
114
+ }
115
+ const summary = await runAllGuards(contracts, guardContext(projectRoot));
116
+ const data = summary;
117
+ const exitCode = exitCodeForGuardSummary(summary);
118
+ if (exitCode === 0) {
119
+ ok(io, 'baseline.run-guard', data);
120
+ return;
121
+ }
122
+ fail(io, exitCode === 1 ? 'GUARD_FAILED' : 'GUARD_SKIPPED', `${String(summary.fail)} failed, ${String(summary.skipped)} skipped of ${String(summary.total)} guard contracts`, data);
123
+ // `fail` sets 1; a skipped run is a distinct outcome from a failed one.
124
+ process.exitCode = exitCode;
82
125
  });
83
126
  baseline
84
127
  .command('diff')
@@ -96,51 +139,146 @@ export function registerBaselineCommands(program, io) {
96
139
  });
97
140
  baseline
98
141
  .command('audit')
99
- .description('Run the capability audit (independent-context scorer).')
142
+ .description('Run the capability audit (independent-context scorer). Exits non-zero unless the verdict is consistent.')
143
+ .option('--scorer <mode>', `Scorer to run: ${SCORER_MODES.join('|')}. 'live' (default) runs the deterministic independent checker, which needs no credentials; 'stub' performs no evaluation and can never be consistent.`, DEFAULT_SCORER_MODE)
100
144
  .option('--project <path>', 'Project root', '.')
101
145
  .option('--json', 'Emit JSON envelope')
102
146
  .action(async (opts) => {
103
147
  const projectRoot = opts.project ?? '.';
148
+ if (!isScorerMode(opts.scorer)) {
149
+ fail(io, 'UNKNOWN_SCORER', `unknown scorer "${String(opts.scorer)}"; expected one of ${SCORER_MODES.join(', ')}`);
150
+ return;
151
+ }
104
152
  const r = readBaselineFile(projectRoot);
105
153
  if (!r.ok) {
106
154
  fail(io, r.error.code, r.error.message);
107
155
  return;
108
156
  }
109
- const guardSummary = { pass: 15, fail: 0, skipped: 0, total: 15, results: [] };
110
- const stub = {
111
- call: async (_system, _user, _opts) => ({
112
- output: JSON.stringify({ verdict: 'consistent' }),
113
- tokens: { input: _system.length, output: _user.length }
114
- })
115
- };
157
+ // The guard summary is the REAL aggregate of the 15 contracts. It used to
158
+ // be a literal `{pass:15,fail:0,skipped:0,total:15}` that no run produced.
159
+ const guardSummary = await runAllGuards(GUARD_CONTRACTS, guardContext(projectRoot));
160
+ // `live` is the deterministic independent checker: a real
161
+ // separate-context evaluation that needs no credentials, which is why it
162
+ // is the only kind that can run inside the secretless OIDC publish gate.
163
+ // It is handed the frozen rows and the registry, not just the guard
164
+ // summary — a scorer that only sees the guard result is a restatement.
116
165
  const { runAudit } = await import('../../services/capability-audit-service/runner.js');
117
- const audit = await runAudit({ projectRoot, sessionId: 'cli', journeyId: 'J01', llmRunner: stub, guardSummary });
118
- ok(io, 'baseline.audit', audit);
166
+ const audit = await runAudit({
167
+ projectRoot,
168
+ sessionId: 'cli',
169
+ journeyId: 'J01',
170
+ scorerMode: opts.scorer,
171
+ baselineRows: r.file.rows,
172
+ contracts: GUARD_CONTRACTS,
173
+ guardSummary
174
+ });
175
+ const data = audit;
176
+ if (audit.verdict === 'consistent' && !audit.degraded) {
177
+ ok(io, 'baseline.audit', data);
178
+ return;
179
+ }
180
+ fail(io, 'AUDIT_NOT_CONSISTENT', `capability audit verdict is "${audit.verdict}"${audit.degraded ? ' (degraded: stub scorer)' : ''}`, data);
119
181
  });
120
182
  baseline
121
183
  .command('freeze-update')
122
- .description('Update one or more baseline rows (REQUIRES user confirmation).')
184
+ .description('Update one or more baseline rows. The LLM authors the JSON; --confirm records the user\'s approval.')
123
185
  .option('--from <path>', 'Path to the new baseline JSON input')
186
+ .option('--confirm', "Record the user's approval (collected via AskUserQuestion) for this ratchet change")
124
187
  .option('--project <path>', 'Project root', '.')
125
188
  .option('--json', 'Emit JSON envelope')
126
189
  .action((opts) => {
127
- fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'freeze-update requires the user to confirm via AskUserQuestion; LLM may not auto-run this command. The LLM must surface a multi-choice prompt to the user before retrying.');
190
+ const projectRoot = opts.project ?? '.';
191
+ if (opts.confirm !== true) {
192
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'freeze-update requires the user to approve the ratchet change via AskUserQuestion. The LLM must surface a multi-choice prompt, then re-run with --confirm.');
193
+ return;
194
+ }
195
+ if (!opts.from) {
196
+ fail(io, 'MISSING_ARG', '--from is required');
197
+ return;
198
+ }
199
+ const r = readBaselineFile(projectRoot);
200
+ if (!r.ok) {
201
+ fail(io, r.error.code, r.error.message);
202
+ return;
203
+ }
204
+ const file = JSON.parse(readFileSync(opts.from, 'utf8'));
205
+ const v = validateBaselineFile(file);
206
+ if (!v.ok) {
207
+ fail(io, v.error.code, v.error.message);
208
+ return;
209
+ }
210
+ const out = writeBaselineFile({ projectRoot, file });
211
+ historySnapshot({ projectRoot, version: file.version });
212
+ ok(io, 'baseline.freeze-update', {
213
+ path: out.path,
214
+ lockPath: out.lockPath,
215
+ fromVersion: r.file.version,
216
+ version: file.version
217
+ });
128
218
  });
129
219
  baseline
130
220
  .command('rollback')
131
- .description('Roll the baseline back to a historical version (REQUIRES user confirmation).')
221
+ .description('Roll the baseline back to a historical version. --confirm records the user\'s approval.')
132
222
  .option('--to <version>', 'Historical version to roll back to')
223
+ .option('--confirm', "Record the user's approval (collected via AskUserQuestion) for this rollback")
133
224
  .option('--project <path>', 'Project root', '.')
134
225
  .option('--json', 'Emit JSON envelope')
135
- .action(() => {
136
- fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'rollback requires the user to confirm via AskUserQuestion; LLM may not auto-run this command.');
226
+ .action((opts) => {
227
+ const projectRoot = opts.project ?? '.';
228
+ if (opts.confirm !== true) {
229
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'rollback requires the user to approve via AskUserQuestion. The LLM must surface a multi-choice prompt, then re-run with --confirm.');
230
+ return;
231
+ }
232
+ if (!opts.to) {
233
+ fail(io, 'MISSING_ARG', '--to is required');
234
+ return;
235
+ }
236
+ const source = HISTORY_DIR(projectRoot, opts.to);
237
+ const from = join(source, 'capability-baseline.json');
238
+ const fromLock = join(source, 'capability-baseline.lock');
239
+ if (!existsSync(from) || !existsSync(fromLock)) {
240
+ fail(io, 'BASELINE_HISTORY_GAP', `no frozen baseline recorded for version ${opts.to}`);
241
+ return;
242
+ }
243
+ const r = readBaselineFile(projectRoot);
244
+ if (!r.ok) {
245
+ fail(io, r.error.code, r.error.message);
246
+ return;
247
+ }
248
+ mkdirSync(CURRENT_DIR(projectRoot), { recursive: true });
249
+ copyFileSync(from, join(CURRENT_DIR(projectRoot), 'capability-baseline.json'));
250
+ copyFileSync(fromLock, join(CURRENT_DIR(projectRoot), 'capability-baseline.lock'));
251
+ // Re-read through the locked store so a tampered history entry cannot be
252
+ // installed: the copy is only reported successful if it verifies.
253
+ const after = readBaselineFile(projectRoot);
254
+ if (!after.ok) {
255
+ fail(io, after.error.code, after.error.message);
256
+ return;
257
+ }
258
+ ok(io, 'baseline.rollback', { fromVersion: r.file.version, toVersion: after.file.version, historyPath: source });
137
259
  });
138
260
  baseline
139
261
  .command('reset')
140
- .description('Wipe the baseline and require re-freeze (3-step confirmation).')
262
+ .description('Wipe the current baseline and require a re-freeze. --confirm records the user\'s approval.')
263
+ .option('--confirm', "Record the user's approval (collected via AskUserQuestion) for the wipe")
141
264
  .option('--project <path>', 'Project root', '.')
142
265
  .option('--json', 'Emit JSON envelope')
143
- .action(() => {
144
- fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'reset requires the user to confirm via AskUserQuestion AND a passphrase; LLM may not auto-run this command.');
266
+ .action((opts) => {
267
+ const projectRoot = opts.project ?? '.';
268
+ if (opts.confirm !== true) {
269
+ fail(io, 'HUMAN_NL_DECISION_REQUIRED', 'reset requires the user to approve via AskUserQuestion. The LLM must surface a multi-choice prompt, then re-run with --confirm.');
270
+ return;
271
+ }
272
+ const r = readBaselineFile(projectRoot);
273
+ // A missing baseline is already "wiped" — but an unreadable one (hash
274
+ // mismatch / unsigned lock) must not be silently discarded.
275
+ if (!r.ok && r.error.code !== 'BASELINE_NOT_FOUND') {
276
+ fail(io, r.error.code, r.error.message);
277
+ return;
278
+ }
279
+ const wipedVersion = r.ok ? r.file.version : null;
280
+ rmSync(join(CURRENT_DIR(projectRoot), 'capability-baseline.json'), { force: true });
281
+ rmSync(join(CURRENT_DIR(projectRoot), 'capability-baseline.lock'), { force: true });
282
+ ok(io, 'baseline.reset', { wipedVersion, nextAction: 're-freeze with `baseline freeze --from <path>`' });
145
283
  });
146
284
  }
@@ -15,6 +15,7 @@ import { resolveAutoCompactProfile } from '../../../services/mode/mode-status-se
15
15
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
16
16
  import { fail, ok } from 'peaks-loop-shared/result';
17
17
  import { stableRealPath } from '../../../shared/path-utils.js';
18
+ import { detectStaleGeneratedArtifacts } from '../../../services/workspace/generated-artifacts-stamp.js';
18
19
  /**
19
20
  * Canonicalize a user-supplied `--project <path>` value.
20
21
  *
@@ -94,6 +95,52 @@ function canonicalizeProjectOption(project) {
94
95
  return project;
95
96
  }
96
97
  }
98
+ /**
99
+ * D1 (2026-09-15) — generated-config staleness, carried on `skill presence`.
100
+ *
101
+ * WHY THIS CALL. `npm i -g peaks-loop@<newer>` upgrades the CLI and leaves the
102
+ * project's generated config exactly as the OLD release wrote it:
103
+ * `initWorkspace` is the only writer and `ensureSession` early-returns once a
104
+ * session is bound, so nothing re-runs the generator. `.claude/settings.local.json`
105
+ * is drift-checked — but only on an init that never comes. The user found the
106
+ * previous instance of this by deleting their `.claude/*.json` and restarting;
107
+ * nothing in the product told them to.
108
+ *
109
+ * `peaks skill presence` is the ONE peaks call every skill makes in every turn
110
+ * (CLAUDE.md mandates it at the start of every response), so it is the only
111
+ * channel guaranteed to carry a drift notice to the LLM that can act on it —
112
+ * the same reasoning as the loop-hygiene verdict attached one screen down. A
113
+ * rule that lives only in a SKILL.md body is compacted away; a warning that
114
+ * rides the per-turn tool output is not.
115
+ *
116
+ * The field is additive and present only when stale, so no existing consumer
117
+ * of the envelope changes shape. `null` projectRoot means the caller had no
118
+ * project to inspect — skipped, not assumed stale.
119
+ */
120
+ function generatedConfigNotice(projectRoot) {
121
+ if (projectRoot === undefined)
122
+ return { field: {}, warnings: [] };
123
+ const staleness = detectStaleGeneratedArtifacts(projectRoot);
124
+ if (!staleness.stale)
125
+ return { field: {}, warnings: [] };
126
+ const onDiskVersion = staleness.onDisk?.packageVersion ?? '(unstamped)';
127
+ return {
128
+ field: {
129
+ generatedArtifacts: {
130
+ stale: true,
131
+ reasons: staleness.reasons,
132
+ onDiskPackageVersion: staleness.onDisk?.packageVersion ?? null,
133
+ installedPackageVersion: staleness.expected.packageVersion
134
+ }
135
+ },
136
+ warnings: [
137
+ `Generated config at '${projectRoot}' was produced by peaks-loop ${onDiskVersion} ` +
138
+ `but ${staleness.expected.packageVersion} is installed (${staleness.reasons.join(', ')}). ` +
139
+ `Re-run \`peaks workspace init\` (or the idempotent \`peaks upgrade --apply-init\`) ` +
140
+ `to regenerate .claude/settings.local.json and the offline template copy.`
141
+ ]
142
+ };
143
+ }
97
144
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
98
145
  // Slice S0 (4.0.0-beta.5 peaks-solo dispatcher release):
99
146
  // `peaks skill search` is the CLI primitive that feeds the
@@ -228,9 +275,10 @@ export function registerSkillCommand(program, io) {
228
275
  .option('--check-stale', 'slice 002 (v2.15.0): also report whether the recorded outer session id still matches the current one. Default false (back-compat).')
229
276
  .option('--project <path>', 'project root (default: cwd)')).action((options) => {
230
277
  const projectOption = canonicalizeProjectOption(options.project);
278
+ const generatedConfig = generatedConfigNotice(projectOption ?? findProjectRoot(process.cwd()) ?? process.cwd());
231
279
  const presence = getSkillPresence(projectOption);
232
280
  if (presence === null) {
233
- printResult(io, ok('skill.presence', { active: false }), options.json);
281
+ printResult(io, ok('skill.presence', { active: false, ...generatedConfig.field }, generatedConfig.warnings), options.json);
234
282
  return;
235
283
  }
236
284
  // Loop-hygiene verdict: attached to every ACTIVE read, so the
@@ -251,11 +299,12 @@ export function registerSkillCommand(program, io) {
251
299
  staleReason: staleness.reason,
252
300
  currentOuterSessionId: staleness.currentOuterSessionId,
253
301
  recordedOuterSessionId: staleness.recordedOuterSessionId,
254
- ...(verdict.context !== null ? { context: verdict.context } : {})
255
- }, [], verdict.nextActions), options.json);
302
+ ...(verdict.context !== null ? { context: verdict.context } : {}),
303
+ ...generatedConfig.field
304
+ }, generatedConfig.warnings, verdict.nextActions), options.json);
256
305
  return;
257
306
  }
258
- printResult(io, ok('skill.presence', { active: true, ...presence, ...(verdict.context !== null ? { context: verdict.context } : {}) }, [], verdict.nextActions), options.json);
307
+ printResult(io, ok('skill.presence', { active: true, ...presence, ...(verdict.context !== null ? { context: verdict.context } : {}), ...generatedConfig.field }, generatedConfig.warnings, verdict.nextActions), options.json);
259
308
  });
260
309
  addJsonOption(skill
261
310
  .command('presence:set <name>')
@@ -1,3 +1,27 @@
1
1
  import type { Command } from 'commander';
2
2
  import { type ProgramIO } from '../../cli-helpers.js';
3
+ /** The only `--category` this subcommand implements today. */
4
+ export declare const LOOP_ENGINEERING_CATEGORY = "loop-engineering";
5
+ /** Project-relative location of the file the `loop-engineering` lint reads. */
6
+ export declare const LOOP_ENGINEERING_GUIDELINES_RELATIVE_PATH = ".peaks/standards/loop-engineering-guidelines.md";
7
+ export interface StandardsLintEnvelope {
8
+ ok: boolean;
9
+ code: string;
10
+ message: string;
11
+ data: {
12
+ category: string;
13
+ path: string;
14
+ redLineCount: number;
15
+ redLineIds: string[];
16
+ findings: string[];
17
+ };
18
+ nextActions: string[];
19
+ }
20
+ /**
21
+ * Pure helper: read the loop-engineering guideline file under `projectRoot`
22
+ * and lint it. Kept out of the Commander action so unit tests can drive it
23
+ * without spawning a child process (same shape as
24
+ * `runReadinessLint` in skill-loop-engineering-readiness-commands.ts).
25
+ */
26
+ export declare function runLoopEngineeringLint(projectRoot: string): StandardsLintEnvelope;
3
27
  export declare function registerStandardsCommand(program: Command, io: ProgramIO): void;
@@ -1,9 +1,58 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
1
3
  import { summarizeProjectStandardsInitResult, summarizeProjectStandardsUpdateResult } from '../../../services/standards/project-standards-service.js';
2
4
  import { executeProjectStandardsInitIdeAware, executeProjectStandardsUpdateIdeAware } from '../../../services/standards/ide-aware-standards-service.js';
3
5
  import { migrateStandards } from '../../../services/standards/migrate-service.js';
4
6
  import { migrateClaudeRules } from '../../../services/standards/migrate-claude-rules-service.js';
7
+ import { lintLoopEngineeringGuidelines, EXPECTED_RED_LINE_IDS } from '../../../services/standards/loop-engineering-lint.js';
5
8
  import { fail, ok } from 'peaks-loop-shared/result';
6
9
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
10
+ /** The only `--category` this subcommand implements today. */
11
+ export const LOOP_ENGINEERING_CATEGORY = 'loop-engineering';
12
+ /** Project-relative location of the file the `loop-engineering` lint reads. */
13
+ export const LOOP_ENGINEERING_GUIDELINES_RELATIVE_PATH = '.peaks/standards/loop-engineering-guidelines.md';
14
+ /**
15
+ * Pure helper: read the loop-engineering guideline file under `projectRoot`
16
+ * and lint it. Kept out of the Commander action so unit tests can drive it
17
+ * without spawning a child process (same shape as
18
+ * `runReadinessLint` in skill-loop-engineering-readiness-commands.ts).
19
+ */
20
+ export function runLoopEngineeringLint(projectRoot) {
21
+ const root = resolve(projectRoot);
22
+ const guidelinePath = join(root, LOOP_ENGINEERING_GUIDELINES_RELATIVE_PATH);
23
+ const data = { category: LOOP_ENGINEERING_CATEGORY, path: guidelinePath, redLineCount: 0, redLineIds: [], findings: [] };
24
+ if (!existsSync(guidelinePath)) {
25
+ return {
26
+ ok: false,
27
+ code: 'LINT_FILE_NOT_FOUND',
28
+ message: `no ${LOOP_ENGINEERING_GUIDELINES_RELATIVE_PATH} under ${root}`,
29
+ data,
30
+ nextActions: [`Pass --project pointing at a peaks-loop checkout (the lint reads its own guideline file).`],
31
+ };
32
+ }
33
+ const result = lintLoopEngineeringGuidelines(readFileSync(guidelinePath, 'utf-8'));
34
+ const redLineIds = result.redLines.map((rl) => rl.id);
35
+ const fullData = { ...data, redLineCount: redLineIds.length, redLineIds };
36
+ if (result.ok) {
37
+ return {
38
+ ok: true,
39
+ code: 'STANDARDS_LINT_OK',
40
+ message: `${redLineIds.length} red line(s) present with all 4 sections (expected: ${EXPECTED_RED_LINE_IDS.join(', ')})`,
41
+ data: fullData,
42
+ nextActions: ['Any new red line must use the 4-section form or this lint will reject it.'],
43
+ };
44
+ }
45
+ return {
46
+ ok: false,
47
+ code: 'STANDARDS_LINT_FAILED',
48
+ message: `guideline file failed the lint (${result.findings.length} finding(s))`,
49
+ data: { ...fullData, findings: result.findings },
50
+ nextActions: [
51
+ 'Address every finding in data.findings, then re-run the lint.',
52
+ 'Each red line must be a `## RL-N — <title>` heading with the 4 sections: Failure modes / Rewrite / Self-check / Out-of-scope.',
53
+ ],
54
+ };
55
+ }
7
56
  export function registerStandardsCommand(program, io) {
8
57
  const standards = program.command('standards').description('Manage project-local coding standards');
9
58
  addJsonOption(standards
@@ -92,4 +141,29 @@ export function registerStandardsCommand(program, io) {
92
141
  process.exitCode = 1;
93
142
  }
94
143
  });
144
+ addJsonOption(standards
145
+ .command('lint')
146
+ .description('Lint a guideline file for structural completeness (the loop-engineering red lines)')
147
+ .requiredOption('--category <category>', `guideline category to lint (${LOOP_ENGINEERING_CATEGORY})`)
148
+ .option('--project <path>', 'project root holding .peaks/standards/ (default: cwd)')).action((options) => {
149
+ if (options.category !== LOOP_ENGINEERING_CATEGORY) {
150
+ printResult(io, fail('standards.lint', 'UNKNOWN_LINT_CATEGORY', `only --category ${LOOP_ENGINEERING_CATEGORY} is implemented`, { category: options.category }, [`Pass --category ${LOOP_ENGINEERING_CATEGORY}.`]), options.json);
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+ try {
155
+ const envelope = runLoopEngineeringLint(options.project ?? process.cwd());
156
+ const response = envelope.ok
157
+ ? ok('standards.lint', envelope.data, [], envelope.nextActions)
158
+ : fail('standards.lint', envelope.code, envelope.message, envelope.data, envelope.nextActions);
159
+ printResult(io, response, options.json);
160
+ if (!envelope.ok) {
161
+ process.exitCode = 1;
162
+ }
163
+ }
164
+ catch (error) {
165
+ printResult(io, fail('standards.lint', 'STANDARDS_LINT_ERROR', getErrorMessage(error), { category: options.category }, ['Verify the guideline file is readable.']), options.json);
166
+ process.exitCode = 1;
167
+ }
168
+ });
95
169
  }