@namewta/speculo 1.0.19 → 1.0.21

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 (194) 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/D-project-deploy/D-project-deploy.md +1 -1
  63. package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
  64. package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
  65. package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
  66. package/template/workflows/ops/INDEX.md +2 -0
  67. package/template/workflows/ops/README.md +7 -3
  68. package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
  69. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
  70. package/template/workflows/ops/common/CAPABILITIES.md +2 -0
  71. package/template/workflows/ops/common/USAGE.md +5 -5
  72. package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
  73. package/template/workflows/ops/common/examples/README.md +1 -1
  74. package/template/workflows/ops/common/examples/register.example.json +7 -3
  75. package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
  76. package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
  77. package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
  78. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  79. package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
  80. package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
  81. package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
  82. package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
  83. package/template/workflows/ops/common/schemas/host.schema.json +19 -0
  84. package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
  85. package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
  86. package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
  87. package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
  88. package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
  89. package/template/workflows/ops/common/schemas/status.schema.json +19 -0
  90. package/template/workflows/ops/common/templates/FLEET.html +1 -1
  91. package/template/workflows/ops/common/templates/HOST-README.md +3 -1
  92. package/template/workflows/ops/common/tests/test_ops.mjs +8 -11
  93. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +101 -16
  94. package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
  95. package/template/workflows/ops/common/tests/test_ops_workspace.mjs +17 -8
  96. package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
  97. package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
  98. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +52 -26
  99. package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
  100. package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
  101. package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
  102. package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
  103. package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
  104. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
  105. package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
  106. package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
  107. package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
  108. package/template/workflows/ops/common/tools/opslib/transport.mjs +10 -6
  109. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
  110. package/template/workflows/ops/common/tools/server-discover.sh +65 -0
  111. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
  112. package/template/workflows/ops/manifest.json +12 -5
  113. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +3 -3
  114. package/template/workflows/specdev/C-code-review/C-code-review.md +1 -1
  115. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +6 -0
  116. package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +6 -6
  117. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  118. package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
  119. package/template/workflows/specdev/I-init-setup/config-template.json +5 -1
  120. package/template/workflows/specdev/INDEX.md +2 -2
  121. package/template/workflows/specdev/P-goal-plan/planning-modes.md +1 -1
  122. package/template/workflows/specdev/P-prototype/P-prototype.md +7 -3
  123. package/template/workflows/specdev/P-prototype/logic-protocol.md +11 -0
  124. package/template/workflows/specdev/P-prototype/logic-template.md +27 -0
  125. package/template/workflows/specdev/P-prototype/project-preview-protocol.md +9 -0
  126. package/template/workflows/specdev/R-retro/R-retro.md +26 -0
  127. package/template/workflows/specdev/R-retro/retro-template.md +27 -0
  128. package/template/workflows/specdev/R-review-architecture/references/selected-design.md +1 -1
  129. package/template/workflows/specdev/README.md +13 -2
  130. package/template/workflows/specdev/S-spec/S-spec.md +4 -0
  131. package/template/workflows/specdev/T-tickets/T-tickets.md +4 -0
  132. package/template/workflows/specdev/T-triage/T-triage.md +31 -25
  133. package/template/workflows/specdev/T-triage/capture-protocol.md +1 -1
  134. package/template/workflows/specdev/T-triage/ci-security-protocol.md +11 -0
  135. package/template/workflows/specdev/T-triage/intake-protocol.md +10 -4
  136. package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +13 -0
  137. package/template/workflows/specdev/T-triage/pr-record-template.md +39 -0
  138. package/template/workflows/specdev/T-triage/publish-protocol.md +1 -1
  139. package/template/workflows/specdev/T-triage/queue-protocol.md +7 -0
  140. package/template/workflows/specdev/T-triage/reconcile-protocol.md +1 -1
  141. package/template/workflows/specdev/T-triage/references/issue-pr-policy.md +23 -0
  142. package/template/workflows/specdev/T-triage/references/release-authentication.md +9 -0
  143. package/template/workflows/specdev/T-triage/references/release-notes-injection.md +21 -0
  144. package/template/workflows/specdev/T-triage/references/workflow-yaml-reference.md +37 -0
  145. package/template/workflows/specdev/T-triage/release-preflight.md +13 -0
  146. package/template/workflows/specdev/T-triage/release-protocol.md +12 -0
  147. package/template/workflows/specdev/T-triage/release-recovery.md +18 -0
  148. package/template/workflows/specdev/T-triage/remote-operations.md +25 -0
  149. package/template/workflows/specdev/T-triage/scripts/github-transport.mjs +208 -0
  150. package/template/workflows/specdev/T-triage/source-template.md +7 -0
  151. package/template/workflows/specdev/T-triage/triage-run-template.md +34 -0
  152. package/template/workflows/specdev/T-triage/triage-template.md +4 -1
  153. package/template/workflows/specdev/common/rules/artifact-contract.md +7 -0
  154. package/template/workflows/specdev/common/rules/change-completion.md +6 -0
  155. package/template/workflows/specdev/common/rules/workflow-routing.md +6 -0
  156. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
  157. package/template/workflows/specdev/common/schemas/config.schema.json +137 -26
  158. package/template/workflows/specdev/common/schemas/logic-prototype.schema.json +62 -0
  159. package/template/workflows/specdev/common/schemas/pull-request.schema.json +90 -0
  160. package/template/workflows/specdev/common/schemas/retrospective.schema.json +58 -0
  161. package/template/workflows/specdev/common/schemas/triage-run.schema.json +149 -0
  162. package/template/workflows/specdev/common/schemas/triage.schema.json +117 -15
  163. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +95 -0
  164. package/template/workflows/specdev/common/tools/validate-specdev.mjs +60 -19
  165. package/template/workflows/specdev/runtime-contract.json +4 -2
  166. package/template/skills/archive-and-consolidate/references/entry-procedure.md +0 -178
  167. package/template/skills/docs-sync/references/entry-procedure.md +0 -3
  168. package/template/skills/engineering-standards-builder/references/entry-procedure.md +0 -154
  169. package/template/skills/git-history-squash/references/entry-procedure.md +0 -94
  170. package/template/skills/github-npm-ops/SKILL.md +0 -19
  171. package/template/skills/github-npm-ops/references/ci-and-security-ops.md +0 -178
  172. package/template/skills/github-npm-ops/references/entry-procedure.md +0 -25
  173. package/template/skills/github-npm-ops/references/failure-recovery.md +0 -123
  174. package/template/skills/github-npm-ops/references/issue-pr-triage.md +0 -227
  175. package/template/skills/github-npm-ops/references/issue-transport.md +0 -26
  176. package/template/skills/github-npm-ops/references/package-json-checklist.md +0 -171
  177. package/template/skills/github-npm-ops/references/preflight-checklist.md +0 -39
  178. package/template/skills/github-npm-ops/references/publish-detection.md +0 -68
  179. package/template/skills/github-npm-ops/references/release-notes-injection.md +0 -238
  180. package/template/skills/github-npm-ops/references/release-pipeline.md +0 -108
  181. package/template/skills/github-npm-ops/references/setup-npm-token.md +0 -63
  182. package/template/skills/github-npm-ops/references/troubleshooting-playbook.md +0 -293
  183. package/template/skills/github-npm-ops/references/version-bump-flow.md +0 -217
  184. package/template/skills/github-npm-ops/references/workflow-yaml-reference.md +0 -268
  185. package/template/skills/github-npm-ops/scripts/issue-transport.mjs +0 -227
  186. package/template/skills/optimize-codex-config/references/entry-procedure.md +0 -78
  187. package/template/skills/speculo-retro/SKILL.md +0 -19
  188. package/template/skills/upstream-fork-sync/references/entry-procedure.md +0 -78
  189. package/template/skills/writing-great-skills/SKILL.md +0 -23
  190. /package/template/skills/{writing-great-skills → writing-for-agents}/references/document-contract.md +0 -0
  191. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/adr-format.md +0 -0
  192. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/context-format.md +0 -0
  193. /package/template/workflows/specdev/{I-implement → common/rules}/design-it-twice.md +0 -0
  194. /package/template/workflows/specdev/{G-grill-with-docs/domain-modeling-rules.md → common/rules/domain-modeling.md} +0 -0
@@ -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
  });
@@ -9,6 +9,7 @@ import { register, putCredential } from "./opslib/model.mjs";
9
9
  import { probeLocal } from "./opslib/transport.mjs";
10
10
  import { compilePlan } from "./opslib/planner.mjs";
11
11
  import { approval, apply } from "./opslib/execution.mjs";
12
+ import { discoverServer, confirmServerRoot } from "./opslib/onboarding.mjs";
12
13
 
13
14
  export function run(output) {
14
15
  output = resolve(output);
@@ -22,10 +23,13 @@ export function run(output) {
22
23
  const inventory = probeLocal();
23
24
  const platform = inventory.platform;
24
25
  if (!["linux", "darwin", "windows"].includes(platform)) throw new OpsError("unsupported platform");
26
+ const discovered = discoverServer(state, "demo-local", { platform, transport: "local", connection: {} });
27
+ const confirmed = confirmServerRoot(state, "demo-local", discovered.discovery.receipt_id, target, "demo-user", "Run this explicitly requested disposable demo only inside its new output/server directory.");
25
28
  register(state, {
26
29
  hosts: [{
27
30
  host_id: "demo-local", display_name: "Disposable local demo", platform, transport: "local",
28
31
  connection: {}, root: target, identity: inventory.identity,
32
+ root_confirmation: confirmed.root_confirmation,
29
33
  }],
30
34
  projects: [{
31
35
  project_id: "app-a", display_name: "Demo APP A", kind: "app", service_type: null,
@@ -775,10 +775,36 @@ export function benchmarkMirrors(req) {
775
775
  };
776
776
  }
777
777
 
778
+ export function onboardingObservation(root = null) {
779
+ const account = userInfo();
780
+ const result = { identity: fingerprint(), platform: process.platform === "win32" ? "windows" : process.platform,
781
+ username: account.username, uid: String(account.uid), home: account.homedir, node: process.execPath, node_version: process.version };
782
+ if (root) {
783
+ if (!isAbsolute(root) || root.split(/[\\/]/).includes("..")) throw new Failure("unsafe discovery root");
784
+ noLinks(root);
785
+ let parent = root;
786
+ while (!existsSync(parent)) parent = dirname(parent);
787
+ if (!statSync(parent).isDirectory()) throw new Failure("root ancestor is not a directory");
788
+ let writable = true;
789
+ try { accessSync(parent, fsConstants.W_OK | fsConstants.X_OK); } catch { writable = false; }
790
+ const present = existsSync(root);
791
+ for (const rel of [".ops-host.json", "_host", "_runtime", "docs", "knowledge", "README.md", "DEPLOYMENTS.md"]) noLinks(join(root, rel));
792
+ const marker = join(root, ".ops-host.json");
793
+ let owner = null;
794
+ if (existsSync(marker)) {
795
+ if (!statSync(marker).isFile() || statSync(marker).size > 16384) throw new Failure("invalid root owner marker");
796
+ owner = rj(marker);
797
+ }
798
+ result.root_check = { exists: present, writable, nonempty: present && readdirSync(root).length > 0, owner };
799
+ }
800
+ return result;
801
+ }
802
+
778
803
  export function main(req) {
779
804
  const identity = fingerprint();
780
805
  if (req.identity && identity !== req.identity) throw new Failure("target identity drift");
781
806
  const action = req.action;
807
+ if (action === "onboarding") return onboardingObservation(req.root);
782
808
  if (action === "probe") return inventory(req);
783
809
  if (action === "mirror-probe") return benchmarkMirrors(req);
784
810
  if (action === "snapshot") return { identity, paths: Object.fromEntries((req.paths || []).map((p) => [p, fileState(p)])), at: stamp() };
@@ -1,15 +1,16 @@
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
- import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, rootPath, targetJoin, writeJson } from "./core.mjs";
6
+ import { digest, exact, identifier, newId, noSymlinks, NodeMissing, now, OpsError, privateDir, readJson, rootPath, targetJoin, withLock, writeJson } from "./core.mjs";
7
7
  import { register, validateHost } from "./model.mjs";
8
8
  import { call, posixCall, posixSend, validateSshEndpoint } from "./transport.mjs";
9
+ import { confirmedBootstrapHost, validateRootConfirmation } from "./onboarding.mjs";
9
10
 
10
11
  const ACK = "I-APPROVE-THIS-BOOTSTRAP";
11
12
  const TOOLS = join(dirname(fileURLToPath(import.meta.url)), "..");
12
- const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8");
13
+ const SCRIPT = readFileSync(join(TOOLS, "bootstrap-volta.sh"), "utf8").replace(/\r\n?/g, "\n");
13
14
  const PIN_PATH = join(TOOLS, "..", "toolchains", "volta-linux.json");
14
15
 
15
16
  export function loadVoltaPin() {
@@ -47,7 +48,7 @@ function parseJsonLine(stdout, label) {
47
48
  export function posixProbe(endpoint, nodePath) {
48
49
  const args = ["--probe"];
49
50
  if (nodePath) args.push(String(nodePath));
50
- const r = posixCall(endpoint, SCRIPT, { args });
51
+ const r = posixCall(endpoint, SCRIPT, { args, sudo: false });
51
52
  return parseJsonLine(r.stdout, "posix probe");
52
53
  }
53
54
 
@@ -124,7 +125,7 @@ export function bootstrapNode(args) {
124
125
  if (args.ack !== ACK) throw new OpsError("explicit acknowledgement I-APPROVE-THIS-BOOTSTRAP is required");
125
126
  if (!args.account) throw new OpsError("--account is required");
126
127
  if (!args.host_root) throw new OpsError("--host-root is required");
127
- identifier(args.account, "toolchain account");
128
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,79}$/.test(args.account)) throw new OpsError("unsafe toolchain account");
128
129
  const hostRoot = rootPath(args.host_root, "linux");
129
130
  const nodeVersion = rejectUnpinned("node version", args.node_version || pin.node_version);
130
131
  if (nodeVersion !== pin.node_version) throw new OpsError("node version must match the reviewed pin " + pin.node_version);
@@ -139,29 +140,53 @@ export function bootstrapNode(args) {
139
140
  allowNetwork: Boolean(args.allow_network), cacheDir, label: "Node",
140
141
  });
141
142
 
143
+ // Only read operations precede confirmation. The remote write gate is shared with H/D.
144
+ const confirmed = confirmedBootstrapHost(args.state, host, args.host_id, hostRoot);
145
+ if (args.account !== endpoint.username) throw new OpsError("bootstrap account must be the observed SSH login account");
146
+ if (/[\s\\\0]/.test(hostRoot)) throw new OpsError("Node bootstrap scp requires a root without whitespace or backslashes; use an existing Node or a supported dedicated path");
147
+
142
148
  const installers = installerDir(hostRoot);
143
149
  const voltaHome = managedVoltaHome(hostRoot, args.account);
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);
147
- posixSend(endpoint, volta.file, remoteVolta);
148
- posixSend(endpoint, node.file, remoteNode);
149
- const applied = posixCall(endpoint, SCRIPT, {
150
- args: ["--apply", remoteVolta, volta.sha256, remoteNode, node.sha256, voltaHome, nodeVersion, ACK],
151
- timeout: 600,
150
+ return withLock(join(args.state, ".locks", "catalog"), { operation: "bootstrap-node", host_id: args.host_id }, () => {
151
+ validateRootConfirmation(args.state, confirmed, { fresh: true, freshLegacy: true, checkEndpoint: true });
152
+ const rootConfirmation = confirmed.root_confirmation ?? null;
153
+ const attempt = join(args.state, "hosts", args.host_id, "bootstrap", "attempt-" + digest({ identity: confirmed.identity, root: hostRoot }).slice(0, 24) + ".json");
154
+ const inputDigest = digest({ identity: confirmed.identity, root: hostRoot, account: args.account, node_version: nodeVersion, volta_sha256: volta.sha256, node_sha256: node.sha256 });
155
+ noSymlinks(attempt);
156
+ if (existsSync(attempt)) {
157
+ const previous = readJson(attempt);
158
+ if (args.recover_ack !== "I-VERIFIED-BOOTSTRAP-IS-STOPPED" || !args.recovery_statement?.trim()) throw new OpsError("bootstrap already started; inspect " + attempt + " and remote state, then explicitly supply --recover-ack I-VERIFIED-BOOTSTRAP-IS-STOPPED --recovery-statement with the actual recovery authorization; never replay blindly");
159
+ if (previous.input_digest !== inputDigest) throw new OpsError("bootstrap recovery inputs differ from the original attempt; preserve evidence and prepare a separate repair plan");
160
+ writeJson(join(dirname(attempt), newId("recovery") + ".json"), { previous_attempt: previous, ack: args.recover_ack, statement: args.recovery_statement, at: now() }, { exclusive: true });
161
+ }
162
+ writeJson(attempt, { status: "started", host_id: args.host_id, root: hostRoot, root_confirmation: rootConfirmation, input_digest: inputDigest, at: now() });
163
+ const owner = JSON.stringify({ host_id: args.host_id, identity: confirmed.identity });
164
+ // Match the existing agent-owned marker. Run as the login account so scp can write.
165
+ // Recheck links immediately before mkdir; never chown/chmod an existing parent.
166
+ posixCall(endpoint, "#!/bin/sh\nset -eu\numask 077\n[ ! -e \"$3/_host/execution.lock\" ] && [ ! -L \"$3/_host/execution.lock\" ] || { echo 'target execution lock exists; inspect before bootstrap' >&2; exit 2; }\nfor dest in \"$1\" \"$2\"; do p=$dest; while [ \"$p\" != / ]; do [ ! -L \"$p\" ] || exit 2; p=$(dirname -- \"$p\"); done; done\nmkdir -p -- \"$1\" \"$2\"\nif [ ! -e \"$3/.ops-host.json\" ]; then (set -C; printf '%s\\n' \"$4\" > \"$3/.ops-host.json\"); fi\n", { sudo: false, args: [installers, voltaHome, hostRoot, owner] });
167
+ const remoteVolta = posixPath.join(installers, volta.filename);
168
+ const remoteNode = posixPath.join(installers, node.filename);
169
+ posixSend(endpoint, volta.file, remoteVolta);
170
+ posixSend(endpoint, node.file, remoteNode);
171
+ const applied = posixCall(endpoint, SCRIPT, {
172
+ args: ["--apply", remoteVolta, volta.sha256, remoteNode, node.sha256, voltaHome, nodeVersion, ACK],
173
+ timeout: 600,
174
+ sudo: false,
175
+ });
176
+ const result = parseJsonLine(applied.stdout, "posix apply");
177
+ result.status = result.status || "installed";
178
+ result.posix_probe = posix;
179
+ result.arch = arch;
180
+
181
+ if (args.state && args.host_id) {
182
+ identifier(args.host_id);
183
+ const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
184
+ writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
185
+ writeJson(attempt, { status: "installed", host_id: args.host_id, root: hostRoot, root_confirmation: rootConfirmation, input_digest: inputDigest, receipt, at: now() });
186
+ result.receipt = receipt;
187
+ }
188
+ return result;
152
189
  });
153
- const result = parseJsonLine(applied.stdout, "posix apply");
154
- result.status = result.status || "installed";
155
- result.posix_probe = posix;
156
- result.arch = arch;
157
-
158
- if (args.state && args.host_id) {
159
- identifier(args.host_id);
160
- const receipt = join(args.state, "hosts", args.host_id, "bootstrap", newId("receipt") + ".json");
161
- writeJson(receipt, { ...result, host_id: args.host_id, at: now() });
162
- result.receipt = receipt;
163
- }
164
- return result;
165
190
  }
166
191
 
167
192
  export function enroll(state, request) {
@@ -181,13 +206,14 @@ export function enroll(state, request) {
181
206
  if (posix.tools?.node === "missing" || posix.node_usable === false) throw new NodeMissing(posix);
182
207
  const discovery = host.identity === "discover" || host.identity == null;
183
208
  if (discovery) host.identity = "0".repeat(64);
184
- if (!isAbsolute(host.connection.node)) {
209
+ if (!posixPath.isAbsolute(host.connection.node) || host.connection.node.includes("\\")) {
185
210
  throw new OpsError("connection.node must be the absolute path returned by bootstrap-node");
186
211
  }
187
212
  validateHost(host);
188
213
  const observed = call(host, { action: "probe", ...(discovery ? { identity: null } : {}) }, { timeout: 180 });
189
214
  host.identity = observed.identity;
190
215
  validateHost(host);
216
+ validateRootConfirmation(state, host, { checkEndpoint: true });
191
217
  register(state, { hosts: [host], projects: request.projects });
192
218
  const inventory = call(host, { action: "probe" }, { timeout: 180 });
193
219
  const snap = join(state, "hosts", host.host_id, "inventory", newId("snapshot") + ".json");
@@ -223,6 +223,7 @@ function parseArgs(argv) {
223
223
  grab("--connection-file"); grab("--host-id"); grab("--account"); grab("--host-root");
224
224
  grab("--volta-archive"); grab("--volta-sha256"); grab("--node-archive"); grab("--node-sha256");
225
225
  grab("--node-version"); grab("--ack");
226
+ grab("--recover-ack"); grab("--recovery-statement");
226
227
  out.probe = flag("--probe");
227
228
  out.apply = flag("--apply");
228
229
  out.allow_network = flag("--allow-network");
@@ -8,7 +8,7 @@ import { hostname } from "node:os";
8
8
  import { dirname, join, posix, win32 } from "node:path";
9
9
  import { spawnSync } from "node:child_process";
10
10
 
11
- export const VERSION = "2.2.0";
11
+ export const VERSION = "2.3.0";
12
12
  const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
13
  export const SECRET_RE = /\{\{credential:([a-z0-9-]+)@([1-9][0-9]*):([A-Za-z_][A-Za-z0-9_]*)\}\}/g;
14
14
 
@@ -285,6 +285,7 @@ export function hostReadme(status, hid, runId, at, { ledger = null, full = false
285
285
  const out = [
286
286
  `# 主机 ${host.display_name} / ${hid}\n`,
287
287
  `主机持久化根:${code(host.root)};更新:${at};运行:${runId}。\n`,
288
+ `连接账号:${code(host.connection.username ?? "显式本机账号")};根目录确认:${host.root_confirmation ? code(host.root_confirmation.receipt_id) : "既有登记(保留原根,不补造历史确认)"}。更换连接账号不改变该根。\n`,
288
289
  "APP 与公共服务同级。主机级入口(WireGuard/Nginx/探测)登记为 host_services,不是假的 APP 目录。通用规范见 docs/standards/DEPLOYMENT-STANDARD.md。\n",
289
290
  "## 服务一览\n",
290
291
  renderOverviewTable(rows, { includeCredentials }),
@@ -11,6 +11,7 @@ import { load, save, ledgerLoad, validate, validateStatus } from "./model.mjs";
11
11
  import { locatePlan, envFile, sliceDigest } from "./planner.mjs";
12
12
  import { call as transportCall, hostTransportDigest } from "./transport.mjs";
13
13
  import { deliveryBundle } from "./docs.mjs";
14
+ import { validateRootConfirmation } from "./onboarding.mjs";
14
15
 
15
16
  const COMMON = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
17
 
@@ -18,6 +19,7 @@ export function defaultEngineDigest() {
18
19
  const opslib = join(COMMON, "tools", "opslib");
19
20
  const schemas = join(COMMON, "schemas");
20
21
  const files = [
22
+ join(COMMON, "tools", "server-discover.sh"),
21
23
  ...readdirSync(opslib).filter((n) => n.endsWith(".mjs")).map((n) => join(opslib, n)),
22
24
  ...readdirSync(schemas).filter((n) => n.endsWith(".json")).map((n) => join(schemas, n)),
23
25
  ].sort();
@@ -245,6 +247,7 @@ function deliver(state, folder, plan, status, execution, ledger) {
245
247
  }
246
248
  const receipt = { schema_version: 1, run_id: plan.run_id, plan_digest: digest(plan), completed_at: now(), documents: bundle.acks, status: "both-sides-verified" };
247
249
  writeJson(join(folder, "docs-receipt.json"), receipt);
250
+ for (const hid of Object.keys(plan.hosts)) writeJson(join(state, "hosts", hid, "docs-receipt.json"), receipt);
248
251
  for (const did of eligible) {
249
252
  const dep = status.deployments[did];
250
253
  writeJson(join(state, "hosts", dep.host_id, "deployments", did, "docs-receipt.json"), receipt);
@@ -279,6 +282,7 @@ export function apply(state, run, { resume = false, docsOnly = false } = {}) {
279
282
  verifyJournal(join(folder, "journal.jsonl"));
280
283
  for (const [hid, h] of Object.entries(plan.hosts)) {
281
284
  if (hostTransportDigest(h) !== plan.transport_digests[hid]) throw new OpsError("SSH connection/known_hosts changed since approval");
285
+ validateRootConfirmation(state, h, { fresh: true });
282
286
  }
283
287
  for (const [ref, wanted] of Object.entries(plan.credential_versions)) {
284
288
  const [cid, v] = ref.split("@");
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { atomicWrite, digest, identifier, noSymlinks, OpsError, redact, withLock, writeJson } from "./core.mjs";
7
7
  import { catalogUnlocked, ids, readBoundedJson, readRegistry, statePath, targetDigest } from "./workspace.mjs";
8
+ import { rootSummary } from "./onboarding.mjs";
8
9
  import { latestCheck, readiness } from "./server_checks.mjs";
9
10
 
10
11
  const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "../../templates/FLEET.html");
@@ -44,7 +45,7 @@ function taskSummaries(state) {
44
45
  });
45
46
  }
46
47
 
47
- export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24 } = {}) {
48
+ export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(), staleHours = 24, onboarding = {} } = {}) {
48
49
  if (!Number.isFinite(at.getTime()) || !Number.isFinite(staleHours) || staleHours <= 0 || staleHours > 8760) throw new OpsError("invalid inventory time/staleness policy");
49
50
  const deployments = sorted(registry.deployments).map((d) => ({
50
51
  deployment_id: d.deployment_id, project_id: d.project_id, server_id: d.host_id,
@@ -68,7 +69,11 @@ export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(
68
69
  const own = byServer.get(h.host_id) || [];
69
70
  return { server_id: h.host_id, name: text(h.display_name), platform: text(h.platform), transport: h.transport,
70
71
  address: h.transport === "ssh" ? text(h.connection.hostname) : "local (explicit)", port: h.transport === "ssh" ? h.connection.port ?? 22 : null,
71
- root: text(h.root), readiness: checkStatus, profile: c?.profile ?? null, checked_at: time(c?.checked_at),
72
+ root: text(h.root), login_account: text(h.connection.username ?? "local"),
73
+ root_confirmation: onboarding[h.host_id]?.root ?? { status: h.root_confirmation ? "unverified" : "legacy-registered", login_home: null, confirmed_at: null },
74
+ initialization: { registered: true, directory: checkStatus !== "stale" && checkStatus !== "unknown" ? c?.checks?.find(v => v.id === "deployment-root")?.status ?? "unknown" : "unknown",
75
+ documents: onboarding[h.host_id]?.documents ?? "unknown", runtime: checkStatus === "ready" && c?.profile !== "base" ? c.profile : "unverified" },
76
+ readiness: checkStatus, profile: c?.profile ?? null, checked_at: time(c?.checked_at),
72
77
  checks: (c?.checks || []).map((v) => ({ id: v.id, status: v.status, required: v.required })),
73
78
  project_ids: [...new Set(own.filter((d) => d.status !== "retired").map((d) => d.project_id))].sort(),
74
79
  deployment_ids: own.map((d) => d.deployment_id),
@@ -96,7 +101,7 @@ export function projectFleet(registry, checks = {}, tasks = [], { at = new Date(
96
101
  export function renderMarkdown(data, html = "index.html", json = "inventory.json") {
97
102
  const table = (headers, rows) => `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n` + (rows.length ? rows.map((r) => `| ${r.map(md).join(" | ")} |`).join("\n") : `| ${headers.map((_, i) => i ? "—" : "暂无记录").join(" | ")} |`) + "\n";
98
103
  return `# OPS 服务器与部署清单\n\n[打开离线看板](${html}) · [结构化快照](${json})\n\n生成:${data.generated_at};资源修订:${data.source_revision};控制端:${data.controller_id}。\n\n这是离线记录,不是实时监控。ready 仅代表指定检查 profile 的必需项通过;completed 是历史执行结果。未测、过期、身份变化不视为正常。\n\n## 服务器 → 项目\n\n` +
99
- table(["服务器", "名称 / 地址", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
104
+ table(["服务器", "名称 / 地址", "账号 / 持久化根", "根确认 / 文档", "系统", "检查 / profile", "检查时间", "项目", "主机级服务"], data.servers.map((s) => [s.server_id, `${s.name} / ${s.address}`, `${s.login_account} / ${s.root}`, `${s.root_confirmation.status} / ${s.initialization.documents}`, s.platform, `${s.readiness} / ${s.profile ?? "未检查"}`, s.checked_at, s.project_ids.join(", "), s.host_services.map((v) => v.service_id).join(", ")])) +
100
105
  `\n## 项目 → 服务器\n\n` + table(["项目", "名称", "类别", "服务器", "部署实例"], data.projects.map((p) => [p.project_id, p.name, p.kind, p.server_ids.join(", "), p.deployment_ids.join(", ")])) +
101
106
  `\n## 部署实例\n\n` + table(["部署", "项目", "服务器", "环境", "记录状态", "计划版本", "验证版本", "根目录"], data.deployments.map((d) => [d.deployment_id, d.project_id, d.server_id, d.environment, d.status, d.desired_version, d.observed_version, d.root])) +
102
107
  `\n## 任务记录\n\n` + table(["任务", "范围", "状态", "服务器", "Run"], data.tasks.map((t) => [t.task_id, t.scope, t.status, t.server_ids.join(", "), t.run_ids.join(", ")])) +
@@ -123,7 +128,24 @@ export function generateFleet(state, { at = new Date(), staleHours = 24 } = {})
123
128
  catalogUnlocked(state);
124
129
  const registry = readRegistry(state);
125
130
  const checks = Object.fromEntries(sorted(registry.hosts).map((h) => [h.host_id, latestCheck(state, h)]));
126
- const data = projectFleet(registry, checks, taskSummaries(state), { at, staleHours });
131
+ const rootEvidence = Object.fromEntries(sorted(registry.hosts).map(h => {
132
+ const root = rootSummary(state, h);
133
+ const receiptPath = join(state, "hosts", h.host_id, "docs-receipt.json");
134
+ noSymlinks(receiptPath);
135
+ let documents = "unknown";
136
+ if (existsSync(receiptPath)) {
137
+ const receipt = readBoundedJson(receiptPath), release = registry.releases[receipt.run_id];
138
+ const localKey = "hosts/" + h.host_id + "/README.md";
139
+ const local = join(state, localKey); noSymlinks(local);
140
+ const remoteKey = "remote:" + h.host_id + ":" + (h.platform === "windows" ? h.root + "\\README.md" : h.root + "/README.md");
141
+ const unresolved = Object.values(registry.releases).some(r => r.host_ids.includes(h.host_id) && r.status !== "completed" && Date.parse(r.updated_at) >= Date.parse(release?.updated_at));
142
+ if (!unresolved && receipt.status === "both-sides-verified" && release?.status === "completed" && release.host_ids.includes(h.host_id)
143
+ && receipt.documents?.[remoteKey] && existsSync(local)
144
+ && receipt.documents?.["local:" + localKey]?.sha256 === digest(readFileSync(local))) documents = "both-sides-verified";
145
+ }
146
+ return [h.host_id, { root, documents }];
147
+ }));
148
+ const data = projectFleet(registry, checks, taskSummaries(state), { at, staleHours, onboarding: rootEvidence });
127
149
  const html = renderHtml(data), markdown = renderMarkdown(data);
128
150
  // Snapshot identity includes renderer bytes, so a template update cannot collide with an older view.
129
151
  const viewId = "view-" + digest({ data, html, markdown }).slice(0, 32);
@@ -2,9 +2,43 @@
2
2
  import { identifier, exact, newId, now, OpsError, targetJoin, within } from "./core.mjs";
3
3
  import { load } from "./model.mjs";
4
4
  import { call as transportCall } from "./transport.mjs";
5
+ import { validateRootConfirmation, discoverServer } from "./onboarding.mjs";
5
6
 
6
7
  export const hostRecipesHooks = { call: transportCall };
7
8
 
9
+ export function initializeHostSpec(state, hid) {
10
+ const status = load(state), host = status.hosts[hid];
11
+ if (!host) throw new OpsError("unknown server; complete S first");
12
+ validateRootConfirmation(state, host, { fresh: true });
13
+ const dirs = ["_host/cache", "_host/installers", "_host/toolchains", "_runtime", "docs/standards", "knowledge"];
14
+ const paths = dirs.map((p) => targetJoin(host, p));
15
+ const inventory = hostRecipesHooks.call(host, { action: "probe", paths, disk_roots: [host.root] }, { timeout: 180 });
16
+ if (inventory.identity !== host.identity) throw new OpsError("host initialization identity drift");
17
+ const actions = dirs.flatMap((path, i) => {
18
+ const observed = inventory.snapshots?.[paths[i]];
19
+ if (observed?.kind === "directory") return [];
20
+ if (observed?.kind !== "absent") throw new OpsError("initialization path conflict: " + paths[i]);
21
+ return [{ host_id: hid, kind: "mkdir", path, mode: 0o750, reason: "Prepare only the selected Host root; keep all existing data and account permissions" }];
22
+ });
23
+ return { schema_version: 1, worker: "H", operation: "prepare", hosts: [hid], host_actions: actions,
24
+ acknowledged_consumers: Object.values(status.deployments).filter((d) => d.host_id === hid && d.status !== "retired").map((d) => d.deployment_id).sort(),
25
+ reason: "Minimal Host initialization: managed directories and verified bilateral host documents; project runtimes are prepared only when needed",
26
+ rollback_note: "Preserve all existing directories and data. Inspect unknown runs; docs-sync repairs documents without reinstalling anything.", risk: "external-mutation" };
27
+ }
28
+
29
+ export function connectionSpec(state, hid, endpoint) {
30
+ const status = load(state), host = status.hosts[hid];
31
+ if (!host) throw new OpsError("unknown server; complete S first");
32
+ if (endpoint.platform !== host.platform || endpoint.transport !== host.transport) throw new OpsError("connection update cannot change platform or transport");
33
+ const discovery = discoverServer(state, hid, endpoint);
34
+ if (discovery.facts.identity !== host.identity) throw new OpsError("new connection is a different machine");
35
+ const updated = { ...host, connection: endpoint.connection };
36
+ validateRootConfirmation(state, updated, { fresh: true, freshLegacy: true });
37
+ return { schema_version: 1, worker: "H", operation: "maintain", hosts: [hid], resource_updates: { hosts: [updated] },
38
+ reason: "S verified connection update for the same machine; preserve Host identity, root and original root confirmation",
39
+ rollback_note: "Keep the old login channel available; use a new exact approved plan to restore it if needed. Never move the root to the new account home.", risk: "external-mutation" };
40
+ }
41
+
8
42
  function shlexQuote(s) {
9
43
  if (s === "") return "''";
10
44
  if (/[^\w@%+=:,./-]/.test(s)) return "'" + s.replaceAll("'", "'\"'\"'") + "'";
@@ -2,6 +2,7 @@
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { validateRootConfirmation, assertUniqueMachine } from "./onboarding.mjs";
5
6
  import { readFileSync } from "node:fs";
6
7
  import {
7
8
  OpsError, canonical, digest, emptyStatus, exact, identifier, now, readJson, relative,
@@ -261,6 +262,11 @@ export function register(state, request) {
261
262
  if (key in s[group] && digest(s[group][key]) !== digest(item)) {
262
263
  throw new OpsError(`registered identity is immutable through register: ${group}/${key}; use a reviewed migration`);
263
264
  }
265
+ if (group === "hosts" && !(key in s.hosts)) {
266
+ validateHost(item);
267
+ assertUniqueMachine(s, item);
268
+ validateRootConfirmation(state, item, { require: true, checkEndpoint: true });
269
+ }
264
270
  s[group][key] = item;
265
271
  }
266
272
  }