portawhip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // Step 1 proof: one declarative recipe, dispatched to whichever backend
3
+ // already solves that capability type well. This file owns NO install
4
+ // logic and no host list — it only shells out to add-mcp / mise / asm
5
+ // per entry.type, targeting whatever detectHosts() finds on THIS machine.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import spawnSync from "cross-spawn";
10
+ import { detectHosts } from "./hosts.mjs";
11
+ import { mergeRawEntries } from "../core/registry/registry.mjs";
12
+ import { readActiveSelection, resolveRecipePaths } from "../core/state/bundle-state.mjs";
13
+
14
+ function run(cmd, args) {
15
+ console.log(`\n$ ${cmd} ${args.join(" ")}`);
16
+ const result = spawnSync.sync(cmd, args, { stdio: "inherit" });
17
+ return result.status === 0;
18
+ }
19
+
20
+ function slugify(label) {
21
+ return label.trim().toLowerCase().replace(/\s+/g, "-");
22
+ }
23
+
24
+ function loadMcp(entry, hosts) {
25
+ if (hosts.mcpHosts.length === 0) {
26
+ console.log(`skip ${entry.id}: no MCP-capable agent detected on this machine`);
27
+ return true;
28
+ }
29
+ // add-mcp fails the WHOLE batch if any target lacks transport support for
30
+ // this entry (e.g. Claude Desktop only takes stdio, not remote/http). It
31
+ // names the incompatible host in its own error text, so read that instead
32
+ // of hardcoding a per-host transport capability matrix ourselves.
33
+ let candidates = [...hosts.mcpHosts];
34
+ for (let attempt = 0; attempt < candidates.length; attempt += 1) {
35
+ const args = ["--yes", "add-mcp", entry.source, "-y"];
36
+ if (entry.scope !== "project") args.push("-g");
37
+ if (entry.name) args.push("-n", entry.name);
38
+ // add-mcp can silently upgrade project-scoped entries to global if any
39
+ // target host requires it (observed: VS Code / Copilot CLI force this).
40
+ // A relative path only resolves correctly from this repo's own cwd, so
41
+ // any local file argument must be made absolute regardless of scope.
42
+ for (const a of entry.args ?? []) {
43
+ args.push("--args", existsSync(a) ? resolve(a) : a);
44
+ }
45
+ for (const host of candidates) args.push("-a", host);
46
+ console.log(`\n$ npx ${args.join(" ")}`);
47
+ const result = spawnSync.sync("npx", args, { encoding: "utf8" });
48
+ const text = (result.stdout || "") + (result.stderr || "");
49
+ console.log(text);
50
+ if (result.status === 0) return true;
51
+
52
+ const match = text.match(/don't support .*transport:\s*([^\n]+)/i);
53
+ if (!match) return false; // some other failure — don't loop blindly
54
+ const excluded = new Set(match[1].split(",").map((s) => slugify(s)));
55
+ const next = candidates.filter((id) => !excluded.has(id));
56
+ if (next.length === candidates.length || next.length === 0) return false;
57
+ console.log(`retrying without incompatible host(s): ${[...excluded].join(", ")}`);
58
+ candidates = next;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function loadCli(entry, hosts, scope) {
64
+ // mise itself is the cross-OS layer here; no per-agent targeting needed.
65
+ // scope:"project" pins the tool in THIS project's own .mise.toml (created/
66
+ // updated in the cwd mise is run from) instead of the user's global mise
67
+ // config — for a bundle role that's only relevant to one project, not
68
+ // every project on the machine.
69
+ const args = scope === "project" ? ["use", entry.source] : ["use", "-g", entry.source];
70
+ return run("mise", args);
71
+ }
72
+
73
+ function loadSkill(entry, hosts) {
74
+ if (hosts.skillHosts.length === 0) {
75
+ console.log(`skip ${entry.id}: no skill-capable agent detected on this machine`);
76
+ return true;
77
+ }
78
+ let ok = true;
79
+ for (const provider of hosts.skillHosts) {
80
+ const args = ["--yes", "agent-skill-manager", "install", entry.source];
81
+ if (entry.path) args.push("--path", entry.path);
82
+ args.push("-p", provider, "--yes");
83
+ ok = run("npx", args) && ok;
84
+ }
85
+ return ok;
86
+ }
87
+
88
+ const DISPATCH = { mcp: loadMcp, cli: loadCli, skill: loadSkill };
89
+
90
+ // Extracted so scripts/bundles.mjs's `select` can install right after
91
+ // recording a bundle choice, without duplicating this dispatch logic.
92
+ // scope only affects type:cli entries (mise -g vs project .mise.toml);
93
+ // mcp/skill handlers ignore the extra argument.
94
+ export function installEntries(entries, hosts, scope = "global") {
95
+ const results = [];
96
+ for (const entry of entries) {
97
+ if (entry.install === false) {
98
+ console.log(`skip ${entry.id}: route-only entry`);
99
+ results.push({ id: entry.id, ok: true, skipped: true });
100
+ continue;
101
+ }
102
+ const handler = DISPATCH[entry.type];
103
+ if (!handler) {
104
+ console.error(`unknown type "${entry.type}" for ${entry.id}`);
105
+ results.push({ id: entry.id, ok: false });
106
+ continue;
107
+ }
108
+ const ok = handler(entry, hosts, scope);
109
+ results.push({ id: entry.id, ok });
110
+ }
111
+ return results;
112
+ }
113
+
114
+ async function main() {
115
+ // An explicit path argument bypasses the opt-in bundle layer entirely
116
+ // (existing single-recipe usage, unchanged). Otherwise install whatever
117
+ // bundles were selected via scripts/bundles.mjs select, composed in front
118
+ // of this project's own recipe.yaml — defaults to just recipe.yaml when
119
+ // nothing has been selected (today's exact behavior).
120
+ const explicitRecipe = process.argv[2];
121
+ const recipePaths = explicitRecipe
122
+ ? [explicitRecipe]
123
+ : resolveRecipePaths(resolve("."), readActiveSelection(resolve(".")));
124
+ const entries = mergeRawEntries(recipePaths);
125
+ const hosts = await detectHosts();
126
+
127
+ console.log(`detected MCP hosts: ${hosts.mcpHosts.join(", ") || "(none)"}`);
128
+ console.log(`detected skill hosts: ${hosts.skillHosts.join(", ") || "(none)"}`);
129
+
130
+ const results = installEntries(entries, hosts);
131
+
132
+ console.log("\n== load summary ==");
133
+ for (const r of results) console.log(`${r.ok ? "OK " : "FAIL"} ${r.id}`);
134
+ process.exitCode = results.every((r) => r.ok) ? 0 : 1;
135
+ }
136
+
137
+ import { pathToFileURL } from "node:url";
138
+
139
+ // Guard main() behind an entry-point check (matches link-hooks/link-connectors/
140
+ // generate) so importing this module never triggers a real install as a side
141
+ // effect of module load.
142
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
143
+ if (isMain) main();
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+
5
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
6
+
7
+ test("npm package exposes public npx and MCP entry points", () => {
8
+ assert.equal(packageJson.private, false);
9
+ assert.equal(packageJson.publishConfig?.access, "public");
10
+ assert.equal(packageJson.bin?.portawhip, "scripts/tui.mjs");
11
+ assert.equal(packageJson.bin?.["portawhip-router"], "core/router/router-cli.mjs");
12
+ assert.equal(packageJson.bin?.["harness-router"], "server/mcp-server.mjs");
13
+ });
14
+
15
+ test("npm package declares a supported runtime and a release-safe file allowlist", () => {
16
+ assert.match(packageJson.engines?.node ?? "", />=20/);
17
+ assert.ok(packageJson.files?.includes("core/"));
18
+ assert.ok(packageJson.files?.includes("scripts/"));
19
+ assert.ok(packageJson.files?.includes("server/"));
20
+ assert.ok(packageJson.files?.includes("recipe.yaml"));
21
+ assert.ok(!packageJson.files?.some((entry) => entry.startsWith(".hp-state")));
22
+ });
23
+
24
+ test("test script uses Node's cross-platform recursive discovery", () => {
25
+ assert.equal(packageJson.scripts?.test, "node --test");
26
+ });
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { collectSurfaceInventory } from "../core/surface/surface-inventory.mjs";
4
+
5
+ const inventory = await collectSurfaceInventory();
6
+ console.log(JSON.stringify(inventory, null, 2));
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import spawnSync from "cross-spawn";
6
+ import { syncSurfaces } from "./sync-surfaces.mjs";
7
+
8
+ function localAgents(root) {
9
+ const suffix = process.platform === "win32" ? ".cmd" : "";
10
+ const candidate = join(root, "node_modules", ".bin", `agents${suffix}`);
11
+ return existsSync(candidate) ? candidate : "agents";
12
+ }
13
+
14
+ export function shouldSyncAfterAgents(args) {
15
+ const [command, subcommand] = args.filter((arg) => !arg.startsWith("-"));
16
+ if (command === "connect" || command === "disconnect") return true;
17
+ if (command === "mcp" && ["add", "import", "remove"].includes(subcommand)) return true;
18
+ return false;
19
+ }
20
+
21
+ function parseScope(args) {
22
+ const index = args.indexOf("--scope");
23
+ if (index === -1) return "project";
24
+ return args[index + 1] === "project" ? "project" : "global";
25
+ }
26
+
27
+ async function main() {
28
+ const root = resolve(".");
29
+ const args = process.argv.slice(2);
30
+ const result = spawnSync.sync(localAgents(root), args, { cwd: root, stdio: "inherit" });
31
+ if (result.status !== 0) {
32
+ process.exitCode = result.status ?? 1;
33
+ return;
34
+ }
35
+ if (!shouldSyncAfterAgents(args)) return;
36
+
37
+ console.log("\n== surface auto-sync after agents change ==");
38
+ const syncResult = await syncSurfaces({ root, scope: parseScope(args) });
39
+ const ok = syncResult.lanes.every((lane) => lane.ok);
40
+ for (const lane of syncResult.lanes) {
41
+ const count = typeof lane.count === "number" ? ` (${lane.count} item(s))` : "";
42
+ console.log(`${lane.ok ? "OK " : "FAIL"} ${lane.lane}: ${lane.backend} ${lane.action}${count}`);
43
+ }
44
+ process.exitCode = ok ? 0 : 1;
45
+ }
46
+
47
+ import { pathToFileURL } from "node:url";
48
+
49
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
50
+ if (isMain) {
51
+ main().catch((error) => {
52
+ console.error(error.message);
53
+ process.exitCode = 1;
54
+ });
55
+ }
@@ -0,0 +1,18 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { shouldSyncAfterAgents } from "./agents-surface.mjs";
4
+
5
+ test("agents-surface: auto-syncs after source-changing agents commands", () => {
6
+ assert.equal(shouldSyncAfterAgents(["connect", "--llm", "codex"]), true);
7
+ assert.equal(shouldSyncAfterAgents(["disconnect", "--llm", "codex"]), true);
8
+ assert.equal(shouldSyncAfterAgents(["mcp", "add", "harness-router"]), true);
9
+ assert.equal(shouldSyncAfterAgents(["mcp", "import", "--file", "servers.json"]), true);
10
+ assert.equal(shouldSyncAfterAgents(["mcp", "remove", "harness-router"]), true);
11
+ });
12
+
13
+ test("agents-surface: read-only agents commands do not trigger full sync", () => {
14
+ assert.equal(shouldSyncAfterAgents(["status", "--verbose"]), false);
15
+ assert.equal(shouldSyncAfterAgents(["mcp", "list"]), false);
16
+ assert.equal(shouldSyncAfterAgents(["mcp", "test", "harness-router"]), false);
17
+ assert.equal(shouldSyncAfterAgents(["sync", "--check"]), false);
18
+ });
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // Auto-sync worker (Phase S1c, decision D — reconciled) — the background
3
+ // half of "import once, use everywhere".
4
+ //
5
+ // Split of responsibilities (locked with owner 2026-07-10):
6
+ // IMPORT (discovered -> canonical) is MANUAL: the user runs
7
+ // `npm run import` and chooses what to canonicalize (auto-enriched there).
8
+ // FAN-OUT (canonical -> all hosts) is AUTO: this worker, fired
9
+ // fire-and-forget from the session-start hook, keeps every host in sync
10
+ // with whatever is already canonical (recipe.yaml + recipes/imported.yaml
11
+ // + selected bundles).
12
+ //
13
+ // It deliberately does NOT discover-and-import new capabilities on its own —
14
+ // that is what made an earlier auto-import design pour hundreds of entries
15
+ // across every host unbidden. This worker only propagates deliberate,
16
+ // already-canonical entries, so there is no surprise fan-out.
17
+ //
18
+ // Throttled + locked + logged + fully fail-open. Silence (hosts already in
19
+ // sync) is the normal steady state.
20
+
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
24
+ import spawnSync from "cross-spawn";
25
+ import { loadConfig } from "../../core/state/config.mjs";
26
+
27
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
28
+ const STATE_DIR = join(ROOT, ".hp-state");
29
+ const STATE_PATH = join(STATE_DIR, "auto-sync.state.json");
30
+ const LOCK_PATH = join(STATE_DIR, "auto-sync.lock");
31
+ const LOG_PATH = join(STATE_DIR, "auto-sync.log");
32
+ const LOCK_STALE_MS = 15 * 60 * 1000; // a lock older than this is presumed dead
33
+
34
+ // Pure: has enough time passed since the last run? (throttle)
35
+ export function shouldRun(state, now, throttleMs) {
36
+ if (!throttleMs || throttleMs <= 0) return true;
37
+ const last = Number(state?.lastRunAt ?? 0);
38
+ return now - last >= throttleMs;
39
+ }
40
+
41
+ // Pure: is an existing lock stale (safe to steal)?
42
+ export function lockIsStale(lockMtimeMs, now, staleMs = LOCK_STALE_MS) {
43
+ return now - lockMtimeMs >= staleMs;
44
+ }
45
+
46
+ function readState() {
47
+ if (!existsSync(STATE_PATH)) return {};
48
+ try {
49
+ return JSON.parse(readFileSync(STATE_PATH, "utf8"));
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ function writeState(state) {
56
+ mkdirSync(STATE_DIR, { recursive: true });
57
+ writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
58
+ }
59
+
60
+ function log(line) {
61
+ try {
62
+ mkdirSync(STATE_DIR, { recursive: true });
63
+ appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`);
64
+ } catch {
65
+ // logging is best-effort; never throw out of the worker
66
+ }
67
+ }
68
+
69
+ function acquireLock(now = Date.now()) {
70
+ mkdirSync(STATE_DIR, { recursive: true });
71
+ if (existsSync(LOCK_PATH)) {
72
+ let mtime = 0;
73
+ try {
74
+ mtime = Number(readFileSync(LOCK_PATH, "utf8")) || 0;
75
+ } catch {
76
+ mtime = 0;
77
+ }
78
+ if (!lockIsStale(mtime, now)) return false;
79
+ }
80
+ writeFileSync(LOCK_PATH, String(now));
81
+ return true;
82
+ }
83
+
84
+ function releaseLock() {
85
+ try {
86
+ rmSync(LOCK_PATH, { force: true });
87
+ } catch {
88
+ // ignore
89
+ }
90
+ }
91
+
92
+ // Fan out canonical -> all hosts via the existing sync-surfaces lane (mise/
93
+ // asm/agents-dotdir). Idempotent; only touches what is already canonical.
94
+ function fanOut() {
95
+ const r = spawnSync.sync(process.execPath, [join(ROOT, "scripts", "sync-surfaces.mjs"), "sync"], {
96
+ cwd: ROOT,
97
+ encoding: "utf8",
98
+ });
99
+ return r.status === 0;
100
+ }
101
+
102
+ export async function runAutoSync({ now = Date.now(), config = null, fanOutImpl = fanOut } = {}) {
103
+ const cfg = config ?? loadConfig(join(ROOT, "router.config.yaml"));
104
+ const auto = cfg.autoSync ?? {};
105
+ if (auto.enabled === false) return { skipped: "disabled" };
106
+
107
+ const throttleMs = (Number(auto.throttleMinutes ?? 60) || 0) * 60 * 1000;
108
+ const state = readState();
109
+ if (!shouldRun(state, now, throttleMs)) return { skipped: "throttled" };
110
+ if (!acquireLock(now)) return { skipped: "locked" };
111
+
112
+ try {
113
+ const ok = fanOutImpl();
114
+ log(`fan-out ${ok ? "ok" : "FAILED"}`);
115
+ writeState({ ...state, lastRunAt: now, lastResult: ok ? "synced" : "sync-failed" });
116
+ return { synced: ok };
117
+ } catch (error) {
118
+ log(`ERROR ${error.message}`);
119
+ return { error: error.message };
120
+ } finally {
121
+ releaseLock();
122
+ }
123
+ }
124
+
125
+ async function main() {
126
+ const result = await runAutoSync();
127
+ console.log(`auto-sync: ${JSON.stringify(result)}`);
128
+ }
129
+
130
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
131
+ if (isMain) {
132
+ main().catch((error) => {
133
+ log(`FATAL ${error.message}`);
134
+ });
135
+ }
@@ -0,0 +1,41 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { shouldRun, lockIsStale, runAutoSync } from "./auto-sync.mjs";
4
+ import { loadConfig } from "../../core/state/config.mjs";
5
+ import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ test("shouldRun: throttles until enough time has passed", () => {
10
+ const throttle = 60 * 60 * 1000;
11
+ assert.equal(shouldRun({ lastRunAt: 1_000_000 }, 1_000_000 + throttle - 1, throttle), false);
12
+ assert.equal(shouldRun({ lastRunAt: 1_000_000 }, 1_000_000 + throttle, throttle), true);
13
+ assert.equal(shouldRun({}, Date.now(), throttle), true);
14
+ assert.equal(shouldRun({ lastRunAt: Date.now() }, Date.now(), 0), true);
15
+ });
16
+
17
+ test("lockIsStale: only steals a lock older than the stale window", () => {
18
+ const now = 10_000_000;
19
+ const stale = 15 * 60 * 1000;
20
+ assert.equal(lockIsStale(now - stale + 1, now, stale), false);
21
+ assert.equal(lockIsStale(now - stale, now, stale), true);
22
+ });
23
+
24
+ test("runAutoSync: disabled config skips without touching fan-out", async () => {
25
+ let called = false;
26
+ const res = await runAutoSync({ config: { autoSync: { enabled: false } }, fanOutImpl: () => (called = true) });
27
+ assert.deepEqual(res, { skipped: "disabled" });
28
+ assert.equal(called, false);
29
+ });
30
+
31
+ // The enabled path acquires a real lock + writes real state under ROOT/
32
+ // .hp-state, so it is verified live (docs/archive/phaseS1c-verify.md) rather than in
33
+ // a unit test that would pollute/contend on that shared state.
34
+
35
+ test("loadConfig: autoSync defaults on with a 60m throttle; overrides read", () => {
36
+ assert.deepEqual(loadConfig("does-not-exist.yaml").autoSync, { enabled: true, throttleMinutes: 60 });
37
+ const dir = mkdtempSync(join(tmpdir(), "autosync-cfg-"));
38
+ const path = join(dir, "router.config.yaml");
39
+ writeFileSync(path, "autoSync:\n enabled: false\n throttleMinutes: 5\n");
40
+ assert.deepEqual(loadConfig(path).autoSync, { enabled: false, throttleMinutes: 5 });
41
+ });