@namewta/speculo 1.0.19 → 1.0.20

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 (148) hide show
  1. package/README.md +8 -7
  2. package/THIRD-PARTY-NOTICES.md +33 -0
  3. package/dist/src/agent-files.js +2 -2
  4. package/dist/src/agent-files.js.map +1 -1
  5. package/dist/src/agent-skills.d.ts +81 -0
  6. package/dist/src/agent-skills.js +530 -0
  7. package/dist/src/agent-skills.js.map +1 -0
  8. package/dist/src/cli.js +11 -3
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/config.js +5 -3
  11. package/dist/src/config.js.map +1 -1
  12. package/dist/src/doctor.js +7 -2
  13. package/dist/src/doctor.js.map +1 -1
  14. package/dist/src/index.d.ts +8 -0
  15. package/dist/src/index.js +23 -3
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/manifest.d.ts +9 -0
  18. package/dist/src/manifest.js +45 -7
  19. package/dist/src/manifest.js.map +1 -1
  20. package/dist/src/refresh.js +40 -0
  21. package/dist/src/refresh.js.map +1 -1
  22. package/dist/src/structured.js +15 -4
  23. package/dist/src/structured.js.map +1 -1
  24. package/dist/src/transaction.d.ts +24 -3
  25. package/dist/src/transaction.js +191 -12
  26. package/dist/src/transaction.js.map +1 -1
  27. package/package.json +7 -4
  28. package/scripts/validate-skills.mjs +56 -0
  29. package/template/AGENTS.md +1 -1
  30. package/template/CLAUDE.md +1 -1
  31. package/template/canonical/canonical-specdev-goal-plan.md +156 -28
  32. package/template/canonical/canonical-specdev-grill-with-docs.md +153 -31
  33. package/template/canonical/canonical-specdev-spec.md +153 -27
  34. package/template/canonical/canonical-specdev-tickets.md +153 -27
  35. package/template/canonical/canonical-specdev-wayfinder.md +142 -27
  36. package/template/commands/retro.md +11 -9
  37. package/template/skills/archive-and-consolidate/SKILL.md +163 -7
  38. package/template/skills/archive-and-consolidate/references/cleanup-rules.md +1 -1
  39. package/template/skills/docs-sync/references/agents/claude-redirect.md +8 -8
  40. package/template/skills/docs-sync/references/agents/content-contract.md +1 -1
  41. package/template/skills/docs-sync/references/agents/manifest-discovery.md +7 -7
  42. package/template/skills/docs-sync/references/agents/role-classification.md +4 -4
  43. package/template/skills/docs-sync/references/agents-contract.md +1 -1
  44. package/template/skills/engineering-standards-builder/SKILL.md +149 -9
  45. package/template/skills/engineering-standards-builder/agents/openai.yaml +2 -0
  46. package/template/skills/engineering-standards-builder/manifest.txt +2 -1
  47. package/template/skills/engineering-standards-builder/references/rules/09-mechanical-guardrails.md +9 -0
  48. package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +4 -7
  49. package/template/skills/git-history-squash/SKILL.md +87 -11
  50. package/template/skills/git-history-squash/agents/openai.yaml +2 -0
  51. package/template/skills/optimize-codex-config/SKILL.md +71 -11
  52. package/template/skills/{speculo-retro/references/entry-procedure.md → retrospective/SKILL.md} +12 -8
  53. package/template/skills/{speculo-retro → retrospective}/references/friction-taxonomy.md +22 -13
  54. package/template/skills/{speculo-retro → retrospective}/references/issue-drafting-sop.md +10 -20
  55. package/template/skills/source-code-zip/SKILL.md +44 -8
  56. package/template/skills/source-code-zip/references/{entry-procedure.md → cli-reference.md} +0 -43
  57. package/template/skills/upstream-fork-sync/SKILL.md +71 -8
  58. package/template/skills/{writing-great-skills → writing-for-agents}/GLOSSARY.md +1 -1
  59. package/template/skills/writing-for-agents/SKILL.md +22 -0
  60. package/template/skills/writing-for-agents/agents/openai.yaml +2 -0
  61. package/template/skills/{writing-great-skills/references/entry-procedure.md → writing-for-agents/references/authoring-principles.md} +2 -9
  62. package/template/workflows/ops/common/tests/test_ops.mjs +5 -8
  63. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +71 -14
  64. package/template/workflows/ops/common/tests/test_ops_workspace.mjs +17 -8
  65. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +4 -4
  66. package/template/workflows/ops/common/tools/opslib/transport.mjs +3 -3
  67. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +3 -3
  68. package/template/workflows/specdev/C-code-review/C-code-review.md +1 -1
  69. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +6 -0
  70. package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +6 -6
  71. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  72. package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
  73. package/template/workflows/specdev/I-init-setup/config-template.json +5 -1
  74. package/template/workflows/specdev/INDEX.md +2 -2
  75. package/template/workflows/specdev/P-goal-plan/planning-modes.md +1 -1
  76. package/template/workflows/specdev/P-prototype/P-prototype.md +7 -3
  77. package/template/workflows/specdev/P-prototype/logic-protocol.md +11 -0
  78. package/template/workflows/specdev/P-prototype/logic-template.md +27 -0
  79. package/template/workflows/specdev/P-prototype/project-preview-protocol.md +9 -0
  80. package/template/workflows/specdev/R-retro/R-retro.md +26 -0
  81. package/template/workflows/specdev/R-retro/retro-template.md +27 -0
  82. package/template/workflows/specdev/R-review-architecture/references/selected-design.md +1 -1
  83. package/template/workflows/specdev/README.md +13 -2
  84. package/template/workflows/specdev/S-spec/S-spec.md +4 -0
  85. package/template/workflows/specdev/T-tickets/T-tickets.md +4 -0
  86. package/template/workflows/specdev/T-triage/T-triage.md +31 -25
  87. package/template/workflows/specdev/T-triage/capture-protocol.md +1 -1
  88. package/template/workflows/specdev/T-triage/ci-security-protocol.md +11 -0
  89. package/template/workflows/specdev/T-triage/intake-protocol.md +10 -4
  90. package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +13 -0
  91. package/template/workflows/specdev/T-triage/pr-record-template.md +39 -0
  92. package/template/workflows/specdev/T-triage/publish-protocol.md +1 -1
  93. package/template/workflows/specdev/T-triage/queue-protocol.md +7 -0
  94. package/template/workflows/specdev/T-triage/reconcile-protocol.md +1 -1
  95. package/template/workflows/specdev/T-triage/references/issue-pr-policy.md +23 -0
  96. package/template/workflows/specdev/T-triage/references/release-authentication.md +9 -0
  97. package/template/workflows/specdev/T-triage/references/release-notes-injection.md +21 -0
  98. package/template/workflows/specdev/T-triage/references/workflow-yaml-reference.md +37 -0
  99. package/template/workflows/specdev/T-triage/release-preflight.md +13 -0
  100. package/template/workflows/specdev/T-triage/release-protocol.md +12 -0
  101. package/template/workflows/specdev/T-triage/release-recovery.md +18 -0
  102. package/template/workflows/specdev/T-triage/remote-operations.md +25 -0
  103. package/template/workflows/specdev/T-triage/scripts/github-transport.mjs +208 -0
  104. package/template/workflows/specdev/T-triage/source-template.md +7 -0
  105. package/template/workflows/specdev/T-triage/triage-run-template.md +34 -0
  106. package/template/workflows/specdev/T-triage/triage-template.md +4 -1
  107. package/template/workflows/specdev/common/rules/artifact-contract.md +7 -0
  108. package/template/workflows/specdev/common/rules/change-completion.md +6 -0
  109. package/template/workflows/specdev/common/rules/workflow-routing.md +6 -0
  110. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
  111. package/template/workflows/specdev/common/schemas/config.schema.json +137 -26
  112. package/template/workflows/specdev/common/schemas/logic-prototype.schema.json +62 -0
  113. package/template/workflows/specdev/common/schemas/pull-request.schema.json +90 -0
  114. package/template/workflows/specdev/common/schemas/retrospective.schema.json +58 -0
  115. package/template/workflows/specdev/common/schemas/triage-run.schema.json +149 -0
  116. package/template/workflows/specdev/common/schemas/triage.schema.json +117 -15
  117. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +95 -0
  118. package/template/workflows/specdev/common/tools/validate-specdev.mjs +60 -19
  119. package/template/workflows/specdev/runtime-contract.json +4 -2
  120. package/template/skills/archive-and-consolidate/references/entry-procedure.md +0 -178
  121. package/template/skills/docs-sync/references/entry-procedure.md +0 -3
  122. package/template/skills/engineering-standards-builder/references/entry-procedure.md +0 -154
  123. package/template/skills/git-history-squash/references/entry-procedure.md +0 -94
  124. package/template/skills/github-npm-ops/SKILL.md +0 -19
  125. package/template/skills/github-npm-ops/references/ci-and-security-ops.md +0 -178
  126. package/template/skills/github-npm-ops/references/entry-procedure.md +0 -25
  127. package/template/skills/github-npm-ops/references/failure-recovery.md +0 -123
  128. package/template/skills/github-npm-ops/references/issue-pr-triage.md +0 -227
  129. package/template/skills/github-npm-ops/references/issue-transport.md +0 -26
  130. package/template/skills/github-npm-ops/references/package-json-checklist.md +0 -171
  131. package/template/skills/github-npm-ops/references/preflight-checklist.md +0 -39
  132. package/template/skills/github-npm-ops/references/publish-detection.md +0 -68
  133. package/template/skills/github-npm-ops/references/release-notes-injection.md +0 -238
  134. package/template/skills/github-npm-ops/references/release-pipeline.md +0 -108
  135. package/template/skills/github-npm-ops/references/setup-npm-token.md +0 -63
  136. package/template/skills/github-npm-ops/references/troubleshooting-playbook.md +0 -293
  137. package/template/skills/github-npm-ops/references/version-bump-flow.md +0 -217
  138. package/template/skills/github-npm-ops/references/workflow-yaml-reference.md +0 -268
  139. package/template/skills/github-npm-ops/scripts/issue-transport.mjs +0 -227
  140. package/template/skills/optimize-codex-config/references/entry-procedure.md +0 -78
  141. package/template/skills/speculo-retro/SKILL.md +0 -19
  142. package/template/skills/upstream-fork-sync/references/entry-procedure.md +0 -78
  143. package/template/skills/writing-great-skills/SKILL.md +0 -23
  144. /package/template/skills/{writing-great-skills → writing-for-agents}/references/document-contract.md +0 -0
  145. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/adr-format.md +0 -0
  146. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/context-format.md +0 -0
  147. /package/template/workflows/specdev/{I-implement → common/rules}/design-it-twice.md +0 -0
  148. /package/template/workflows/specdev/{G-grill-with-docs/domain-modeling-rules.md → common/rules/domain-modeling.md} +0 -0
@@ -3,16 +3,52 @@ name: source-code-zip
3
3
  description: Create a requested source ZIP using the bundled Node script and its ignore rules; do not install dependencies.
4
4
  ---
5
5
 
6
- # source-code-zip
6
+ # Source Code ZIP
7
7
 
8
- This file is the routing entry. Read [`references/entry-procedure.md`](references/entry-procedure.md) only after this skill is selected. Read a named reference there only for the active branch.
8
+ 先确认 node 可用,只有 node 不存在时才回退 nodejs。使用 scripts/zip_source_code.js;无第三方依赖,不安装 npm/Python/zip 工具。
9
9
 
10
- ## Scope
10
+ ## 标准工作流
11
11
 
12
- - Trigger: Create a requested source ZIP using the bundled Node script and its ignore rules; do not install dependencies.
13
- - Output and write owner remain those declared by the entry procedure and the owning command/workflow.
14
- - Do not infer missing scope, credentials, target, or authorization.
12
+ 1. 确认用户提供的是目录路径,不是单个文件;路径含空格时必须加引号。
13
+ 2. 先确认 `node --version` 能正常执行。
14
+ 3. 默认先运行一次 `--dry-run`;目录较大、规则刚修改或可能包含敏感信息时,同时添加 `--verbose`。
15
+ 4. 检查预览结果,确认 `.env`、YAML、依赖目录、构建产物、归档文件和密钥没有被纳入。
16
+ 5. 正式创建 ZIP。
17
+ 6. 报告 ZIP 的绝对路径、纳入文件数、输入总大小和生成结果。
18
+ 7. 除非用户明确理解风险并提出要求,不得使用 `--no-default-ignore`。
15
19
 
16
- ## Stop
20
+ 推荐预览:
17
21
 
18
- Stop before side effects when the required input, owner, reference, confirmation, schema, or recovery evidence is missing; report the exact blocker and preserve any dry-run evidence.
22
+ ```bash
23
+ node scripts/zip_source_code.js "/path/to/project" --dry-run
24
+ ```
25
+
26
+ 查看每个项目被排除的原因:
27
+
28
+ ```bash
29
+ node scripts/zip_source_code.js "/path/to/project" \
30
+ --dry-run \
31
+ --verbose
32
+ ```
33
+
34
+ 正式创建:
35
+
36
+ ```bash
37
+ node scripts/zip_source_code.js "/path/to/project"
38
+ ```
39
+
40
+ 默认输出在源目录旁边:
41
+
42
+ ```text
43
+ <目录名>.code.zip
44
+ ```
45
+
46
+ 例如:
47
+
48
+ ```text
49
+ 源目录:/work/my-app
50
+ 输出: /work/my-app.code.zip
51
+ ```
52
+
53
+
54
+ 需要自定义过滤、额外文件、输出位置、覆盖、压缩或排障时读取 [参数与行为参考](references/cli-reference.md)。执行前核对目标目录和忽略策略,交付 ZIP 路径、清单/统计及未打包项。
@@ -74,49 +74,6 @@ chmod +x scripts/zip_source_code.js
74
74
  ./scripts/zip_source_code.js "/path/to/project"
75
75
  ```
76
76
 
77
- ## 标准工作流
78
-
79
- 1. 确认用户提供的是目录路径,不是单个文件;路径含空格时必须加引号。
80
- 2. 先确认 `node --version` 能正常执行。
81
- 3. 默认先运行一次 `--dry-run`;目录较大、规则刚修改或可能包含敏感信息时,同时添加 `--verbose`。
82
- 4. 检查预览结果,确认 `.env`、YAML、依赖目录、构建产物、归档文件和密钥没有被纳入。
83
- 5. 正式创建 ZIP。
84
- 6. 报告 ZIP 的绝对路径、纳入文件数、输入总大小和生成结果。
85
- 7. 除非用户明确理解风险并提出要求,不得使用 `--no-default-ignore`。
86
-
87
- 推荐预览:
88
-
89
- ```bash
90
- node scripts/zip_source_code.js "/path/to/project" --dry-run
91
- ```
92
-
93
- 查看每个项目被排除的原因:
94
-
95
- ```bash
96
- node scripts/zip_source_code.js "/path/to/project" \
97
- --dry-run \
98
- --verbose
99
- ```
100
-
101
- 正式创建:
102
-
103
- ```bash
104
- node scripts/zip_source_code.js "/path/to/project"
105
- ```
106
-
107
- 默认输出在源目录旁边:
108
-
109
- ```text
110
- <目录名>.code.zip
111
- ```
112
-
113
- 例如:
114
-
115
- ```text
116
- 源目录:/work/my-app
117
- 输出: /work/my-app.code.zip
118
- ```
119
-
120
77
  ## 默认过滤行为
121
78
 
122
79
  脚本采用两层过滤:
@@ -4,16 +4,79 @@ description: Assess a requested fork/upstream checkpoint and produce reproducibl
4
4
  metadata: {"speculo-id": "upstream-fork-sync", "speculo-kind": "skill"}
5
5
  ---
6
6
 
7
- # upstream-fork-sync
7
+ # Upstream Fork Sync
8
8
 
9
- This file is the routing entry. Read [`references/entry-procedure.md`](references/entry-procedure.md) only after this skill is selected. Read a named reference there only for the active branch.
9
+ 以**检查点**为主导词。每个仓库始终分开记录:本次观测到的 upstream tip、可选本地 mirror tip,以及已经由产品分支历史证明完成集成的 upstream checkpoint。Fetch 或 mirror 变化不能推进集成 checkpoint。
10
10
 
11
- ## Scope
11
+ ## 1. 解析上下文
12
12
 
13
- - Trigger: Assess a requested fork/upstream checkpoint and produce reproducible diff, conflict, and customization-risk evidence.
14
- - Output and write owner remain those declared by the entry procedure and the owning command/workflow.
15
- - Do not infer missing scope, credentials, target, or authorization.
13
+ 1. 从当前目录向上寻找并读取 `<Path>{roots.state}/workspace.json</Path>`,验证 `path_base` 和所有 roots,再读取 `<Path>{roots.config}</Path>`;无法唯一确定项目根时停止。
14
+ 2. 读取项目 `AGENTS.md` 及其作用域内规则。项目存在工程规范时读取适用入口。
15
+ 3. 将本 Skill 的唯一持久化根解析为 `<Path>{roots.state}/skills/upstream-fork-sync/</Path>`。独立运行只写该根;不得创建 workflow change 或 command 报告。
16
+ 4. 读取 [Repository contract](references/repository-contract.md),确定仓库、refs、网络刷新和副作用边界。
16
17
 
17
- ## Stop
18
+ 完成标准:项目根、Skill state 根、目标仓库集合和本次是否允许 fetch 都只有一个解释。
18
19
 
19
- Stop before side effects when the required input, owner, reference, confirmation, schema, or recovery evidence is missing; report the exact blocker and preserve any dry-run evidence.
20
+ ## 2. 建立稳定配置
21
+
22
+ 读取 `<Path>{roots.state}/skills/upstream-fork-sync/repository-map.json</Path>`。不存在时,只读发现 Git 仓库、remotes、默认产品分支和 upstream tracking refs,按 [Repository contract](references/repository-contract.md) 创建 schema v1 配置;无法从 Git 事实唯一判断的字段一次性询问用户。配置已存在时验证,不静默改写。
23
+
24
+ 读取 `<Path>{roots.state}/skills/upstream-fork-sync/customization-map.md</Path>`。不存在时创建最小稳定清单,记录 fork 必须长期保留的产品不变量和审查热点;无法从项目文档或代码证明的业务约束标记为待确认。该文件不接收运行 SHA、dirty paths、冲突或本次结论。
25
+
26
+ 完成标准:每个 repository id 唯一,所有项目路径和 refs 可解析,稳定配置与动态运行事实分离。
27
+
28
+ ## 3. 创建评估 change
29
+
30
+ 读取 [State schema](references/state-schema.md) 和 [Report contract](references/report-contract.md),从用户主题生成小写 ASCII kebab-case topic,然后运行:
31
+
32
+ ```bash
33
+ node <Path>{roots.skills}/upstream-fork-sync/scripts/upstream-sync.mjs</Path> assess \
34
+ --root . \
35
+ --state-root <Path>{roots.state}/skills/upstream-fork-sync</Path> \
36
+ --repository-map <Path>{roots.state}/skills/upstream-fork-sync/repository-map.json</Path> \
37
+ --topic <topic>
38
+ ```
39
+
40
+ - 默认离线使用本地 refs;只有用户明确要求刷新网络 refs 时添加 `--fetch`。
41
+ - 只需预览完整冻结快照时添加 `--dry-run`,此分支不写 state 或 change。
42
+ - 正常运行创建 `<Path>{roots.state}/skills/upstream-fork-sync/{date}-{topic}[-NN]/</Path>`,已有目录永不覆盖。
43
+
44
+ 完成标准:脚本退出 0,change 的 state 与两份报告完整发布,根 `state.json` 只在 change 发布成功后更新。
45
+
46
+ ## 4. 深化风险结论
47
+
48
+ 逐仓库检查报告中的 Git 确认冲突、自动合并重叠和定制风险路径。对每条高风险路径使用报告冻结的 SHA 执行精确 `git diff <base>..<target> -- <path>`,再与 `customization-map.md` 的稳定不变量映射。
49
+
50
+ 只把路径级证据支持的语义结论补入本 change 的报告。保持 Git 冲突、自动合并重叠、定制合同风险和 dirty-worktree 重叠为四个类别;零文本冲突不得表述为集成安全。
51
+
52
+ 完成标准:所有冲突和定制热点都有结论或明确未验证项,报告中的命令可以从冻结 SHA 复现。
53
+
54
+ ## 5. 返回后续选择
55
+
56
+ 返回 change 路径、仓库 checkpoint、观测 upstream SHA、风险摘要和未验证项。列出适合继续处理的已安装 Work,但不自动创建 workflow change、不调用 Work、不 merge、不 commit、不 push;后续路线由用户选择。
57
+
58
+ 完成标准:本次 Skill 在评估 change 完整后停止,用户能用冻结产物自行选择下一 Work。
59
+
60
+ ## 6. 记录外部集成
61
+
62
+ 只有其他 Work 已完成集成且用户要求更新 checkpoint 时进入此分支。读取目标 change state,并运行:
63
+
64
+ ```bash
65
+ node <Path>{roots.skills}/upstream-fork-sync/scripts/upstream-sync.mjs</Path> record-integration \
66
+ --root . \
67
+ --state-root <Path>{roots.state}/skills/upstream-fork-sync</Path> \
68
+ --repository-map <Path>{roots.state}/skills/upstream-fork-sync/repository-map.json</Path> \
69
+ --change <change> \
70
+ --repository <repository-id> \
71
+ --merge-commit <full-sha> \
72
+ --upstream-sha <full-sha> \
73
+ --verification '<command>: exit 0'
74
+ ```
75
+
76
+ 该入口只在 merge commit 可从产品 ref 到达、目标 upstream SHA 是其精确非第一父节点且匹配 change 冻结目标时更新状态;它不执行集成。
77
+
78
+ 完成标准:change state 和根 checkpoint 都已原子重读,实际 Git 历史与记录一致。
79
+
80
+ ## 停止条件
81
+
82
+ 目标 ref 缺失、保存的 checkpoint 不在产品历史、checkpoint 不在当前 upstream 历史、存在多个 merge-base、配置越界或状态 schema 非法时,停止且不移动 refs、不发布部分 change、不推进 checkpoint。返回准确 repository id、路径、refs 与 SHAs。
@@ -1,6 +1,6 @@
1
1
  # 术语表 — 构建优秀技能
2
2
 
3
- 关于什么造就优秀技能的领域模型。技能的存在是为了从随机系统中驯服确定性;根本美德是 **Predictability**(可预测性),以下每个术语都是调节它的一根杠杆。这是 [`writing-great-skills`](SKILL.md) 的公开参考。
3
+ 关于什么造就优秀技能的领域模型。技能的存在是为了从随机系统中驯服确定性;根本美德是 **Predictability**(可预测性),以下每个术语都是调节它的一根杠杆。这是 [`writing-for-agents`](SKILL.md) 的公开参考。
4
4
 
5
5
  术语按轴分组:**Invocation**(调用,技能如何被触达)、**Information Hierarchy**(信息层级,其内容如何排列)、**Steering**(引导,Agent 的运行时行为如何被塑造)和 **Pruning**(修剪,如何保持精炼)。每个**失败模式**都紧邻治愈它的杠杆,标记为 _失败模式_。
6
6
 
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: writing-for-agents
3
+ description: Review or author agent instructions, Skills, AGENTS, commands and workflow contracts when explicitly requested.
4
+ disable-model-invocation: true
5
+ metadata: {"speculo-id": "writing-for-agents", "speculo-kind": "skill", "speculo-invocation": "user-only"}
6
+ ---
7
+
8
+ # Writing for Agents
9
+
10
+ 对明确要求的文档编写/重构使用本能力;不因仓库存在指令文件而自动审计。先读 [保真合同](references/document-contract.md),确定真实源、调用方、owner、输出与恢复边界。
11
+
12
+ 1. 从当前任务入口追踪相关调用和生成器,列出现存能力、数量、工具、权限、验证及失败语义。不要默认整读仓库。
13
+ 2. 明确触发分支和近邻职责;入口保留每次必读步骤,只有分支规则、示例、flags 与模板下沉。需要审查结构与冗余时读 [编写原则](references/authoring-principles.md),术语按需查 [GLOSSARY](GLOSSARY.md)。
14
+ 3. 按单一规则 owner 合并重复;删除无效/过期内容,保留不可丢失的证据、研究、许可与必要元数据。新增或改变的行为单列。
15
+ 4. 修订调用方与生成器,测试正常、近邻不触发、缺失输入、失败与恢复;语法通过不代表实际宿主行为。
16
+ 5. 交付实际差异、全源字符统计、验证和未验证项。没有实际用量证据不声称 token 或额度节省。
17
+
18
+ ## 宿主适配
19
+
20
+ Claude 的显式调用使用 disable-model-invocation;Codex 使用 agents/openai.yaml 中 policy.allow_implicit_invocation=false。metadata 仅描述策略,不授予权限。Speculo 的分发路径不等于宿主原生发现目录,安装/启用需由对应宿主方式完成;不要复制到系统或插件缓存。
21
+
22
+ AGENTS 放项目独有事实与作用域约束,工具可检验的规则交机械检查;CLAUDE 需要加载同层手册时使用 @AGENTS.md,保护用户现有内容。
@@ -0,0 +1,2 @@
1
+ policy:
2
+ allow_implicit_invocation: false
@@ -1,4 +1,4 @@
1
- # Entry procedure
1
+ # 编写原则
2
2
 
3
3
  一个 skill 的存在是为了从随机系统中提取确定性。**可预测性** —— 即 agent 每次运行时采取相同的*流程*,而非产生相同的输出 —— 是根本美德;以下每个杠杆都为此服务。
4
4
 
@@ -6,14 +6,7 @@
6
6
 
7
7
  ## 调用方式
8
8
 
9
- 两种选择,付出不同的代价:
10
-
11
- - **模型调用** skill 保留 **description**,因此 agent 可以自主触发它,*并且*其他 skills 也可以触达它(你也仍然可以手动输入其名称)。它会产生**上下文负载** —— description 每轮都留在窗口里。实现机制:省略 `disable-model-invocation`,并编写一个面向模型的 description,包含丰富的触发短语("当用户想要……、提及……时使用")。
12
- - **用户调用** skill 将 description 从 agent 的触达范围中移除:只有你手动输入其名称才能调用它 —— 其他 skill 也无法触达它。零上下文负载,但它消耗**认知负载**:*你*是必须记住它存在的索引。实现机制:设置 `disable-model-invocation: true`;`description` 变为面向人类的 —— 一句摘要,去掉触发列表。
13
-
14
- 只有当 agent 必须自行触达该 skill,或其他 skill 必须触达它时,才选择模型调用。如果它只需手动触发,则设为用户调用,不付出任何上下文负载。
15
-
16
- 当用户调用 skills 多到你记不住时,积累的认知负载可以通过一个**路由 skill** 来解决:一个用户调用的 skill,列出其他 skills 以及何时使用它们。
9
+ 触发策略按宿主适配。模型调用时描述覆盖实际分支,显式调用时给出用途;宿主规则与元数据不能混淆。入口中的适配合同是唯一规则源,不宣称零上下文负载。
17
10
 
18
11
  ## 编写 description
19
12
 
@@ -175,16 +175,13 @@ test("lock is not autobroken", () => {
175
175
  });
176
176
  rmSync(d, { recursive: true, force: true });
177
177
  });
178
- test("symlink rejected", () => {
178
+ test("symlink rejected", (t) => {
179
179
  const d = mkdtempSync(join(tmpdir(), "ops-link-"));
180
+ t.after(() => rmSync(d, { recursive: true, force: true }));
180
181
  mkdirSync(join(d, "real"));
181
- try { symlinkSync(join(d, "real"), join(d, "link"), "dir"); }
182
- catch (e) {
183
- if (process.platform === "win32") { rmSync(d, { recursive: true, force: true }); return; }
184
- throw e;
185
- }
182
+ symlinkSync(join(d, "real"), join(d, "link"), process.platform === "win32" ? "junction" : "dir");
186
183
  assert.throws(() => atomicWrite(join(d, "link", "a.txt"), "x"), OpsError);
187
- rmSync(d, { recursive: true, force: true });
184
+ assert.equal(existsSync(join(d, "real", "a.txt")), false);
188
185
  });
189
186
  test("file permissions", () => {
190
187
  const d = mkdtempSync(join(tmpdir(), "ops-perm-"));
@@ -994,7 +991,7 @@ test("new controllers keep secrets out of default documentation", () => runFx((f
994
991
  }
995
992
  assert.match(readFileSync(join(local,"OPERATIONS.md"),"utf8"), /secret_ref:.*app-auth@1/);
996
993
  assert.ok(readFileSync(join(remote,"env","app.env"),"utf8").includes(PASSWORD));
997
- assert.equal(plan.operations.find((o) => o.path?.endsWith("env/app.env")).mode, 0o600);
994
+ assert.equal(plan.operations.find((o) => o.path === join(remote, "env", "app.env")).mode, 0o600);
998
995
  if (process.platform !== "win32") assert.equal(statSync(join(remote,"env","app.env")).mode & 0o777, 0o600);
999
996
  assert.equal(readJson(join(local,"docs-receipt.json")).status, "both-sides-verified");
1000
997
  }));
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { dirname, join } from "node:path";
6
+ import { dirname, join, posix } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import test from "node:test";
9
9
  import { createHash } from "node:crypto";
@@ -18,6 +18,7 @@ import * as model from "../tools/opslib/model.mjs";
18
18
  const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "../tools");
19
19
  const scriptPath = join(toolsDir, "bootstrap-volta.sh");
20
20
  const ACK = "I-APPROVE-THIS-BOOTSTRAP";
21
+ const linuxExecution = { skip: process.platform !== "linux" && "Requires Linux /bin/sh and a Linux Node executable; SSH protocol contracts run on every platform" };
21
22
 
22
23
  function sha256(p) {
23
24
  return createHash("sha256").update(readFileSync(p)).digest("hex");
@@ -58,13 +59,14 @@ function pinFor(archives, arch = "linux-x64") {
58
59
  return { ...base, archives: { ...base.archives, [arch]: slot } };
59
60
  }
60
61
 
61
- function mockTransport({ probeJson = null } = {}) {
62
+ function mockTransport({ probeJson = null, agentJson = null, applyJson = null } = {}) {
62
63
  const prev = transport.transportHooks.spawnSync;
63
64
  const log = [];
64
65
  transport.transportHooks.spawnSync = (cmd, args, kw) => {
65
66
  log.push({ cmd, args: [...args], encoding: kw.encoding, inputIsBuffer: Buffer.isBuffer(kw.input) });
66
67
  const last = args[args.length - 1];
67
68
  if (cmd === "scp") {
69
+ if (applyJson) return { status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
68
70
  const local = args[args.length - 2];
69
71
  const spec = args[args.length - 1];
70
72
  const remotePath = spec.slice(spec.indexOf(":") + 1);
@@ -80,11 +82,13 @@ function mockTransport({ probeJson = null } = {}) {
80
82
  return { status: 0, stdout: Buffer.from(JSON.stringify(probeJson) + "\n"), stderr: Buffer.alloc(0) };
81
83
  }
82
84
  if (String(last).includes("--input-type=module")) {
85
+ if (agentJson) return { status: 0, stdout: Buffer.from(JSON.stringify({ ok: true, result: agentJson }) + "\n"), stderr: Buffer.alloc(0) };
83
86
  const parts = splitRemote(last);
84
87
  return spawnSync(parts[0], parts.slice(1), {
85
88
  input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
86
89
  });
87
90
  }
91
+ if (applyJson) return { status: 0, stdout: Buffer.from(String(last).includes("--apply") ? JSON.stringify(applyJson) + "\n" : ""), stderr: Buffer.alloc(0) };
88
92
  const parts = splitRemote(last);
89
93
  return spawnSync(parts[0], parts.slice(1), {
90
94
  input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
@@ -182,7 +186,20 @@ test("scp uses the same host-key contract", () => {
182
186
  } finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
183
187
  });
184
188
 
185
- test("bootstrap-volta apply installs wrapper and does not touch profile", () => {
189
+ test("scp rejects controller paths and unsafe Linux paths before spawning", (t) => {
190
+ const d = mkdtempSync(join(tmpdir(), "ops-scp-path-"));
191
+ t.after(() => rmSync(d, { recursive: true, force: true }));
192
+ const kh = join(d, "known_hosts"), local = join(d, "archive.tar.gz");
193
+ writeFileSync(kh, "fixture pinned key\n"); writeFileSync(local, "reviewed archive");
194
+ const prev = transport.transportHooks.spawnSync;
195
+ t.after(() => { transport.transportHooks.spawnSync = prev; });
196
+ transport.transportHooks.spawnSync = () => { assert.fail("invalid paths must not reach scp"); };
197
+ for (const path of ["C:\\server\\archive.tar.gz", "C:/server/archive.tar.gz", "\\\\server\\share\\archive.tar.gz", "/srv/ops\\archive.tar.gz", "relative.tar.gz", "/srv/../archive.tar.gz", "/srv/space name.tar.gz", "/srv/nul\0.tar.gz"]) {
198
+ assert.throws(() => transport.posixSend({ hostname: "host.invalid", username: "ops", known_hosts: kh }, local, path), /remote scp path/);
199
+ }
200
+ });
201
+
202
+ test("bootstrap-volta apply installs wrapper and does not touch profile", linuxExecution, () => {
186
203
  const d = mkdtempSync(join(tmpdir(), "ops-vol-"));
187
204
  const home = join(d, "home");
188
205
  mkdirSync(home);
@@ -205,28 +222,33 @@ test("bootstrap-volta apply installs wrapper and does not touch profile", () =>
205
222
  } finally { rmSync(d, { recursive: true, force: true }); }
206
223
  });
207
224
 
208
- test("bootstrap-volta apply without ack blocked", () => {
225
+ test("bootstrap-volta apply without ack blocked", linuxExecution, () => {
209
226
  const d = mkdtempSync(join(tmpdir(), "ops-ack-"));
210
227
  const archives = makeArchives(d);
211
228
  const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", "nope"], { encoding: "utf8" });
212
- assert.notEqual(p.status, 0);
229
+ assert.equal(p.error, undefined);
230
+ assert.equal(p.status, 2);
231
+ assert.match(p.stderr, /acknowledgement/);
213
232
  rmSync(d, { recursive: true, force: true });
214
233
  });
215
234
 
216
- test("bootstrap-volta sha mismatch blocked", () => {
235
+ test("bootstrap-volta sha mismatch blocked", linuxExecution, () => {
217
236
  const d = mkdtempSync(join(tmpdir(), "ops-sha-"));
218
237
  const archives = makeArchives(d);
219
238
  const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, "0".repeat(64), archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", ACK], { encoding: "utf8" });
220
- assert.notEqual(p.status, 0);
239
+ assert.equal(p.error, undefined);
240
+ assert.equal(p.status, 2);
241
+ assert.match(p.stderr, /installer changed after review/);
221
242
  assert.ok(!existsSync(join(d, "volta", "bin", "ops-node")));
222
243
  rmSync(d, { recursive: true, force: true });
223
244
  });
224
245
 
225
- function cliFixture() {
246
+ function cliFixture({ executeLinux = false } = {}) {
226
247
  const tmp = mkdtempSync(join(tmpdir(), "ops-bootcli-"));
227
248
  const state = join(tmp, "controller");
228
- const target = join(tmp, "server");
229
- mkdirSync(target, { recursive: true });
249
+ // A simulated Linux SSH target is not a path on the controller filesystem.
250
+ const target = executeLinux ? join(tmp, "server") : "/srv/speculo-bootstrap-test";
251
+ if (executeLinux) mkdirSync(target, { recursive: true });
230
252
  const kh = join(tmp, "known_hosts");
231
253
  writeFileSync(kh, "fixture pinned key\n");
232
254
  const prevProbe = cliHooks.probeLocal;
@@ -251,6 +273,39 @@ const missingProbe = {
251
273
  node_path: null, node_version: null, node_usable: false, profile_has_volta: false,
252
274
  };
253
275
 
276
+ test("Linux SSH bootstrap and enrollment preserve remote paths on every controller platform", () => {
277
+ const fx = cliFixture();
278
+ const connectionNode = posix.join(fx.target, "_host/toolchains/ops/volta/bin/ops-node");
279
+ const mock = mockTransport({ probeJson: missingProbe, applyJson: { status: "installed", connection_node: connectionNode, profile_unchanged: true } });
280
+ try {
281
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
282
+ const conn = writeConn(fx.tmp, host);
283
+ const r = captureMain(["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
284
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha, "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha]);
285
+ assert.equal(r.code, 0, r.stderr);
286
+ const boot = JSON.parse(r.stdout), pins = pinFor(fx.archives).archives["linux-x64"];
287
+ const paths = [pins.volta.filename, pins.node.filename].map(name => posix.join(fx.target, "_host/installers", name));
288
+ assert.deepEqual(mock.log.filter(e => e.cmd === "scp").map(e => e.args.at(-1)), paths.map(p => "ops@host.invalid:" + p));
289
+ const applyArgs = splitRemote(mock.log.find(e => e.cmd === "ssh" && e.args.at(-1).includes("--apply")).args.at(-1));
290
+ assert.deepEqual(applyArgs.slice(applyArgs.indexOf("--apply") + 1), [paths[0], fx.archives.voltaSha, paths[1], fx.archives.nodeSha, posix.dirname(posix.dirname(connectionNode)), fx.archives.nodeVersion, ACK]);
291
+ assert.equal(JSON.parse(readFileSync(boot.receipt, "utf8")).connection_node, connectionNode);
292
+ assert.deepEqual(Object.keys(model.load(fx.state).hosts), []);
293
+ mock.restore();
294
+ host.connection.node = connectionNode;
295
+ const enrollFile = join(fx.tmp, "register.json"); writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
296
+ const inventory = { identity: "a".repeat(64), platform: "linux", tools: { node: { path: connectionNode, version: "v24.21.0" } } };
297
+ const enrolledMock = mockTransport({ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: connectionNode }, node_path: connectionNode, node_usable: true }, agentJson: inventory });
298
+ try {
299
+ const enrolled = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
300
+ assert.equal(enrolled.code, 0, enrolled.stderr);
301
+ assert.equal(model.load(fx.state).hosts[host.host_id].connection.node, connectionNode);
302
+ const snapshot = JSON.parse(enrolled.stdout).hosts[0].snapshot;
303
+ assert.deepEqual(JSON.parse(readFileSync(snapshot, "utf8")), inventory);
304
+ assert.equal(enrolledMock.log.filter(e => e.args.at(-1).includes("--input-type=module")).length, 2);
305
+ } finally { enrolledMock.restore(); }
306
+ } finally { mock.restore(); fx.cleanup(); }
307
+ });
308
+
254
309
  test("discover probe without node returns node-missing", () => {
255
310
  const fx = cliFixture();
256
311
  const mock = mockTransport({ probeJson: missingProbe });
@@ -313,6 +368,7 @@ test("bootstrap-node sha mismatch blocked", () => {
313
368
  "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
314
369
  ]);
315
370
  assert.equal(r.code, 2);
371
+ assert.match(r.stderr, /sha256 does not match the reviewed pin/i);
316
372
  assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
317
373
  } finally { mock.restore(); fx.cleanup(); }
318
374
  });
@@ -362,8 +418,8 @@ test("bootstrap-node skips when node exists", () => {
362
418
  } finally { mock.restore(); fx.cleanup(); }
363
419
  });
364
420
 
365
- test("bootstrap-node apply then enroll writes inventory and status", () => {
366
- const fx = cliFixture();
421
+ test("bootstrap-node apply then enroll writes inventory and status", linuxExecution, () => {
422
+ const fx = cliFixture({ executeLinux: true });
367
423
  const home = join(fx.tmp, "home");
368
424
  mkdirSync(home);
369
425
  writeFileSync(join(home, ".bashrc"), "keep-me\n");
@@ -435,10 +491,11 @@ test("enroll without node blocked", () => {
435
491
  test("probe --connection-file still does not imply register", () => {
436
492
  const fx = cliFixture();
437
493
  const mock = mockTransport({
438
- probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: process.execPath }, node_path: process.execPath, node_usable: true },
494
+ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: "/usr/bin/node" }, node_path: "/usr/bin/node", node_usable: true },
495
+ agentJson: { identity: "a".repeat(64), platform: "linux" },
439
496
  });
440
497
  try {
441
- const host = sshHost({ root: fx.target, kh: fx.kh, node: process.execPath, identity: "discover" });
498
+ const host = sshHost({ root: fx.target, kh: fx.kh, identity: "discover" });
442
499
  const conn = writeConn(fx.tmp, host);
443
500
  const r = captureMain(["probe", "--connection-file", conn]);
444
501
  assert.equal(r.code, 0, r.stderr);
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, symlinkSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { dirname, join, resolve } from "node:path";
6
+ import { dirname, join, resolve, posix } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { digest, emptyStatus, now, writeJson } from "../tools/opslib/core.mjs";
@@ -18,7 +18,7 @@ function fixture(t) {
18
18
  t.after(() => rmSync(root, { recursive: true, force: true }));
19
19
  const state = join(root, "state"); mkdirSync(state);
20
20
  const s = emptyStatus(); s.controller = { controller_id: "controller-a", state_root: state, created_at: now() };
21
- for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
21
+ for (const id of ["node-a", "node-b"]) s.hosts[id] = { host_id: id, display_name: id, platform: process.platform === "win32" ? "windows" : "linux", transport: "local", root: join(root, id), identity: "a".repeat(64), connection: {} };
22
22
  for (const id of ["app-a", "app-b"]) s.projects[id] = { project_id: id, display_name: id, kind: "app", service_type: null, source: { type: "local", location: join(root, "source"), revision: "v1" } };
23
23
  writeJson(join(state, "status.json"), s);
24
24
  const save = () => writeJson(join(state, "status.json"), s);
@@ -32,7 +32,13 @@ function request(id = "task-one") {
32
32
  function inventory(h) {
33
33
  return { identity: h.identity, platform: "linux", tools: { node: { version: "v22.22.3" } },
34
34
  snapshots: { [h.root]: { kind: "directory" } }, diagnostics: { disks: { [h.root]: { free: 4 * 1024 ** 3 } }, memory: { MemAvailable: 1024 ** 3 } },
35
- docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
35
+ docker_control: { status: "observed", endpoint: "unix:///var/run/docker.sock", data_root: posix.join(h.root, "_runtime/docker"), compose_version: "2.30.0" } };
36
+ }
37
+ function linuxServer(f) {
38
+ return Object.assign(f.s.hosts["node-a"], { platform: "linux", root: "/srv/speculo-test/node-a" });
39
+ }
40
+ function directoryLink(target, path) {
41
+ symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
36
42
  }
37
43
  function adapterFor(f, options = {}) {
38
44
  const calls = { compile: 0, approve: 0, apply: 0 };
@@ -76,7 +82,7 @@ test("explicit layer routing distinguishes controller, access, server, project a
76
82
  test("workspace rejects guessed roots, traversal, symlinks, duplicate JSON keys and orphan deployments", (t) => {
77
83
  const f = fixture(t);
78
84
  for (const path of [".", "/", f.state + "/../state"]) assert.throws(() => statePath(path));
79
- const link = join(f.root, "linked"); symlinkSync(f.state, link, "dir"); assert.throws(() => statePath(link), /symlink/);
85
+ const link = join(f.root, "linked"); directoryLink(f.state, link); assert.throws(() => statePath(link), /symlink/);
80
86
  writeFileSync(join(f.state, "status.json"), '{"schema_version":3,"schema_version":3}'); assert.throws(() => readRegistry(f.state), /duplicate JSON/);
81
87
  f.s.deployments.dangling = { deployment_id: "dangling", host_id: "missing", project_id: "app-a" }; f.save(); assert.throws(() => readRegistry(f.state), /orphan/);
82
88
  });
@@ -90,7 +96,7 @@ test("CLI help works without network, server dependencies or state", () => {
90
96
  const r = spawnSync(process.execPath, [path, "workspace-help"], { encoding: "utf8" }); assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /server-check/);
91
97
  });
92
98
  test("Linux checklist distinguishes missing, failed, profile-ready and stale evidence", (t) => {
93
- const f = fixture(t), h = f.s.hosts["node-a"], inv = inventory(h), at = new Date("2026-01-01T00:00:00Z");
99
+ const f = fixture(t), h = linuxServer(f), inv = inventory(h), at = new Date("2026-01-01T00:00:00Z");
94
100
  assert.equal(readiness(assessServer(h), at.toISOString(), { at }), "unknown");
95
101
  assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at }), "ready");
96
102
  assert.equal(readiness(assessServer(h, { profile: "compose", inventory: inv }), at.toISOString(), { at: new Date("2026-01-03T00:00:00Z") }), "stale");
@@ -100,7 +106,7 @@ test("Linux checklist distinguishes missing, failed, profile-ready and stale evi
100
106
  }
101
107
  });
102
108
  test("server check uses pinned public-key-only SSH and records restricted evidence", async (t) => {
103
- const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture verified key");
109
+ const f = fixture(t), h = linuxServer(f), kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture verified key");
104
110
  Object.assign(h, { transport: "ssh", connection: { hostname: "example.invalid", username: "deploy", known_hosts: kh } }); f.save();
105
111
  let spawned = 0, called = 0;
106
112
  const a = { sshArgv: () => ["ssh", "-o", "StrictHostKeyChecking=yes"], spawn: (_cmd, args) => { spawned++; assert.ok(args.includes("PasswordAuthentication=no")); assert.ok(args.includes("KbdInteractiveAuthentication=no")); return { status: 0 }; }, call: () => { called++; return inventory(h); } };
@@ -111,7 +117,7 @@ test("server check uses pinned public-key-only SSH and records restricted eviden
111
117
  writeFileSync(kh, "different key"); assert.notEqual(check.host_digest, targetDigest(h));
112
118
  });
113
119
  test("failed SSH login never triggers Node probe and is not reported ready", async (t) => {
114
- const f = fixture(t), h = f.s.hosts["node-a"], kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture"); Object.assign(h, { transport: "ssh", connection: { hostname: "invalid", username: "deploy", known_hosts: kh } }); f.save();
120
+ const f = fixture(t), h = linuxServer(f), kh = join(f.root, "known_hosts"); writeFileSync(kh, "fixture"); Object.assign(h, { transport: "ssh", connection: { hostname: "invalid", username: "deploy", known_hosts: kh } }); f.save();
115
121
  const r = await checkServer(f.state, "node-a", {}, { sshArgv: () => ["ssh"], spawn: () => ({ status: 255 }), call: () => { throw new Error("must not probe"); } });
116
122
  assert.equal(r.status, "blocked"); assert.equal(r.checks.find(c => c.id === "machine-identity").status, "unknown");
117
123
  });
@@ -210,7 +216,10 @@ test("fleet writes immutable generations, verifies readback and retains one cons
210
216
  const before = readFileSync(first.markdown, "utf8"); writeFileSync(first.html, "tampered"); assert.throws(() => generateFleet(f.state, { at }), /readback mismatch/); assert.equal(readFileSync(first.markdown, "utf8"), before);
211
217
  });
212
218
  test("fleet refuses symlink outputs and catalog locks without overwriting old entry", (t) => {
213
- const f = fixture(t), outside = join(f.root, "outside.txt"); writeFileSync(outside, "untouched"); symlinkSync(outside, join(f.state, "FLEET.md"));
219
+ const f = fixture(t), outsideDir = join(f.root, "outside"), outside = join(outsideDir, "sentinel.txt");
220
+ mkdirSync(outsideDir); writeFileSync(outside, "untouched");
221
+ if (process.platform === "win32") directoryLink(outsideDir, join(f.state, "FLEET.md"));
222
+ else symlinkSync(outside, join(f.state, "FLEET.md"));
214
223
  assert.throws(() => generateFleet(f.state), /symlink/); assert.equal(readFileSync(outside, "utf8"), "untouched");
215
224
  rmSync(join(f.state, "FLEET.md")); mkdirSync(join(f.state, ".locks/catalog"), { recursive: true }); assert.throws(() => generateFleet(f.state), /catalog lock/);
216
225
  });
@@ -1,7 +1,7 @@
1
1
  /** Node-less Linux SSH bootstrap: pinned Volta/Node via POSIX + scp, then enroll. */
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { dirname, basename, isAbsolute, join } from "node:path";
4
+ import { dirname, basename, join, posix as posixPath } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, rootPath, targetJoin, writeJson } from "./core.mjs";
7
7
  import { register, validateHost } from "./model.mjs";
@@ -142,8 +142,8 @@ export function bootstrapNode(args) {
142
142
  const installers = installerDir(hostRoot);
143
143
  const voltaHome = managedVoltaHome(hostRoot, args.account);
144
144
  posixCall(endpoint, "#!/bin/sh\nset -eu\nmkdir -p -- \"$1\" \"$2\"\n", { args: [installers, voltaHome] });
145
- const remoteVolta = join(installers, volta.filename);
146
- const remoteNode = join(installers, node.filename);
145
+ const remoteVolta = posixPath.join(installers, volta.filename);
146
+ const remoteNode = posixPath.join(installers, node.filename);
147
147
  posixSend(endpoint, volta.file, remoteVolta);
148
148
  posixSend(endpoint, node.file, remoteNode);
149
149
  const applied = posixCall(endpoint, SCRIPT, {
@@ -181,7 +181,7 @@ export function enroll(state, request) {
181
181
  if (posix.tools?.node === "missing" || posix.node_usable === false) throw new NodeMissing(posix);
182
182
  const discovery = host.identity === "discover" || host.identity == null;
183
183
  if (discovery) host.identity = "0".repeat(64);
184
- if (!isAbsolute(host.connection.node)) {
184
+ if (!posixPath.isAbsolute(host.connection.node) || host.connection.node.includes("\\")) {
185
185
  throw new OpsError("connection.node must be the absolute path returned by bootstrap-node");
186
186
  }
187
187
  validateHost(host);
@@ -1,7 +1,7 @@
1
1
  /** Strict host-key SSH and subprocess local transport. No passwords in argv. */
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { readFileSync } from "node:fs";
4
- import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { dirname, posix, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { canonical, digest, noSymlinks, OpsError, redact, UnknownResult } from "./core.mjs";
7
7
  import { fingerprint } from "./agent.mjs";
@@ -107,8 +107,8 @@ export function posixSend(endpoint, localFile, remotePath, { timeout = 180 } = {
107
107
  throw new OpsError("POSIX bootstrap is Linux-only; PowerShell targets still require an existing Node");
108
108
  }
109
109
  noSymlinks(localFile, { allowMissing: false });
110
- if (typeof remotePath !== "string" || !isAbsolute(remotePath) || remotePath.split("/").includes("..") || /[\s\n\r]/.test(remotePath)) {
111
- throw new OpsError("remote scp path must be absolute without spaces or ..");
110
+ if (typeof remotePath !== "string" || !posix.isAbsolute(remotePath) || remotePath.split("/").includes("..") || /[\s\\\0]/.test(remotePath)) {
111
+ throw new OpsError("remote scp path must be POSIX absolute without whitespace, backslashes, NUL or ..");
112
112
  }
113
113
  const argv = sshArgv(endpoint, { scp: true });
114
114
  argv.push("--", localFile, `${endpoint.username}@${endpoint.hostname}:${remotePath}`);
@@ -38,9 +38,9 @@
38
38
  change 内三份文档复用 grill 的既有格式,避免重复发明:
39
39
 
40
40
  - 设计日志:`<Path>{roots.workflows}/specdev/G-grill-with-docs/log-format.md</Path>`;
41
- - 领域上下文:`<Path>{roots.workflows}/specdev/G-grill-with-docs/context-format.md</Path>`;
42
- - 架构决策:`<Path>{roots.workflows}/specdev/G-grill-with-docs/adr-format.md</Path>`;
43
- - 领域建模规则:`<Path>{roots.workflows}/specdev/G-grill-with-docs/domain-modeling-rules.md</Path>`。
41
+ - 领域上下文:`<Path>{roots.workflows}/specdev/common/rules/context-format.md</Path>`;
42
+ - 架构决策:`<Path>{roots.workflows}/specdev/common/rules/adr-format.md</Path>`;
43
+ - 领域建模规则:`<Path>{roots.workflows}/specdev/common/rules/domain-modeling.md</Path>`。
44
44
 
45
45
  同步顺序固定:先 LOG,再 CONTEXT,最后 ADR。CONTEXT 只描述当前真相,历史轨迹留在 LOG;未确认的解读不得写成已接受 ADR;与现有永久 ADR 冲突时建立 supersedes 链,不重写历史。高影响条目带来源标识(`USER-DECISION`、`CODE:`、`RESEARCH:`、`ADR-###`)。
46
46