@awak-app/simy-cli 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { createInterface } from "node:readline/promises";
5
6
 
6
7
  import { startAgent } from "./agent.js";
7
8
  import {
@@ -9,6 +10,8 @@ import {
9
10
  createCliAutoUpdater,
10
11
  readCliPackageInfo,
11
12
  restartDaemonProcess,
13
+ runCliUpdateGate,
14
+ shouldRetryIncompatibleCli,
12
15
  writeUpdateHandoffFromEnvironment,
13
16
  } from "./auto-update.js";
14
17
  import { openAuthorizationUrl } from "./browser.js";
@@ -20,26 +23,75 @@ import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
20
23
 
21
24
  const argv = process.argv.slice(2);
22
25
  const args = new Set(argv);
26
+ const dispatchArgv = argv.filter((value) => value !== "--no-auto-update");
27
+ const entryPath = fileURLToPath(import.meta.url);
28
+ const packageInfo = await readCliPackageInfo();
29
+ const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
23
30
 
24
- if (argv[0] === "sqm") {
25
- const packageInfo = await readCliPackageInfo();
26
- try {
27
- const { runSqmCommand } = await import("./sqm/command.js");
28
- process.exitCode = await runSqmCommand(argv.slice(1), { cliVersion: packageInfo.version });
29
- } catch (error) {
30
- console.error(error instanceof Error ? error.message : String(error));
31
- process.exitCode = 2;
31
+ if (dispatchArgv[0] === "update") {
32
+ const checkOnly = args.has("--check");
33
+ const result = await runCliUpdateGate({
34
+ argv,
35
+ packageInfo,
36
+ mode: checkOnly ? "check" : "update",
37
+ disabled: updatesDisabled,
38
+ allowMajor: args.has("--yes"),
39
+ confirmMajor: confirmMajorUpdate,
40
+ });
41
+ process.exitCode = ["check_failed", "install_failed"].includes(result.outcome) ? 1 : 0;
42
+ } else {
43
+ const startupUpdate = await runCliUpdateGate({
44
+ argv,
45
+ packageInfo,
46
+ entryPath,
47
+ disabled: updatesDisabled,
48
+ });
49
+ if (startupUpdate.reexecuted) {
50
+ process.exitCode = startupUpdate.exitCode;
51
+ } else {
52
+ await runCommand();
32
53
  }
33
- process.exit();
34
54
  }
35
55
 
36
- if (args.has("--help") || args.has("-h")) {
37
- console.log(`Usage:
56
+ async function runCommand() {
57
+ if (dispatchArgv[0] === "sqm") {
58
+ try {
59
+ const { runSqmCommand } = await import("./sqm/command.js");
60
+ process.exitCode = await runSqmCommand(dispatchArgv.slice(1), { cliVersion: packageInfo.version });
61
+ } catch (error) {
62
+ if (shouldRetryIncompatibleCli(error)) {
63
+ const retried = await runCliUpdateGate({
64
+ argv,
65
+ packageInfo,
66
+ entryPath,
67
+ mode: "incompatible",
68
+ disabled: updatesDisabled,
69
+ allowMajor: args.has("--yes"),
70
+ confirmMajor: confirmMajorUpdate,
71
+ incompatibleRetry: true,
72
+ });
73
+ if (retried.reexecuted) {
74
+ process.exitCode = retried.exitCode;
75
+ return;
76
+ }
77
+ const required = error.minimumVersion ? ` (requires ${error.minimumVersion}+)` : "";
78
+ console.error(`SQM requires a newer SIMY CLI${required}, but automatic recovery did not install one.`);
79
+ }
80
+ console.error(error instanceof Error ? error.message : String(error));
81
+ process.exitCode = 2;
82
+ }
83
+ return;
84
+ }
85
+
86
+ if (args.has("--help") || args.has("-h")) {
87
+ console.log(`Usage:
38
88
  simy Start the local SIMY agent
39
89
  simy --daemon Start it in the background
40
90
  simy --no-tui Start the foreground HTTP agent without the interactive console
41
91
  simy sqm min-check Run the fast deterministic SQM diff preflight
42
92
  simy sqm check Run full scenarios, write proof.json, and sign the run
93
+ simy update --check Check the installed and latest stable CLI versions
94
+ simy update Install an eligible stable CLI update
43
95
 
44
96
  Options:
45
97
  --daemon, --deamon Run detached in the background
@@ -47,73 +99,92 @@ Options:
47
99
  --no-open Do not open the browser when authorization is required
48
100
  --port <port> Bind a specific localhost port
49
101
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
50
- --no-auto-update Disable the hourly CLI update check
102
+ --no-auto-update Disable update checks and automatic installation
51
103
 
52
104
  ${autoUpdateHelpText()}
53
105
  `);
54
- process.exit(0);
55
- }
106
+ return;
107
+ }
56
108
 
57
- const daemon = args.has("--daemon") || args.has("--deamon");
58
- const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
59
- const port = readPort(argv);
60
- const packageInfo = await readCliPackageInfo();
61
- let webOrigin;
62
- try {
63
- webOrigin = resolveWebOrigin(readOption(argv, "--host"));
64
- } catch (error) {
65
- console.error(error instanceof Error ? error.message : String(error));
66
- process.exit(2);
109
+ await runLocalAgent();
67
110
  }
68
111
 
69
- if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
70
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
71
- detached: true,
72
- stdio: "ignore",
73
- env: { ...process.env, SIMY_DAEMON_CHILD: "1" },
74
- });
75
- child.unref();
76
- console.log("SIMY local agent started in the background.");
77
- process.exit(0);
78
- }
112
+ async function runLocalAgent() {
113
+ const daemon = args.has("--daemon") || args.has("--deamon");
114
+ const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
115
+ const port = readPort(argv);
116
+ let webOrigin;
117
+ try {
118
+ webOrigin = resolveWebOrigin(readOption(argv, "--host"));
119
+ } catch (error) {
120
+ console.error(error instanceof Error ? error.message : String(error));
121
+ process.exitCode = 2;
122
+ return;
123
+ }
79
124
 
80
- const updateManager = await createCliAutoUpdater({
81
- daemon,
82
- interactive,
83
- requestedPort: port,
84
- disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
85
- dependencies: { packageInfo },
86
- });
87
- const agent = await startAgent({
88
- requestedPort: port,
89
- daemon,
90
- webOrigin,
91
- quiet: interactive,
92
- updateManager,
93
- });
94
- const shutdownAgent = createAgentShutdown(agent);
95
- installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
96
- await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
97
- updateManager.start({
98
- registry: agent.registry,
99
- restartDaemon: async (targetVersion) => {
100
- await restartDaemonProcess({
101
- entryPath: fileURLToPath(import.meta.url),
102
- argv: process.argv.slice(2),
103
- targetVersion,
125
+ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
126
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
127
+ detached: true,
128
+ stdio: "ignore",
129
+ env: { ...process.env, SIMY_DAEMON_CHILD: "1" },
104
130
  });
105
- await shutdownAgent();
106
- },
107
- });
108
- if (agent.loginUrl && !args.has("--no-open")) {
109
- await openAuthorizationUrl(agent.loginUrl);
131
+ child.unref();
132
+ console.log("SIMY local agent started in the background.");
133
+ return;
134
+ }
135
+
136
+ const updateManager = await createCliAutoUpdater({
137
+ daemon,
138
+ interactive,
139
+ requestedPort: port,
140
+ disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
141
+ dependencies: { packageInfo },
142
+ });
143
+ const agent = await startAgent({
144
+ requestedPort: port,
145
+ daemon,
146
+ webOrigin,
147
+ quiet: interactive,
148
+ updateManager,
149
+ });
150
+ const shutdownAgent = createAgentShutdown(agent);
151
+ installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
152
+ await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
153
+ updateManager.start({
154
+ registry: agent.registry,
155
+ restartDaemon: async (targetVersion) => {
156
+ await restartDaemonProcess({
157
+ entryPath: fileURLToPath(import.meta.url),
158
+ argv: process.argv.slice(2),
159
+ targetVersion,
160
+ });
161
+ await shutdownAgent();
162
+ },
163
+ });
164
+ if (agent.loginUrl && !args.has("--no-open")) {
165
+ await openAuthorizationUrl(agent.loginUrl);
166
+ }
167
+ if (interactive) {
168
+ const { runAgenticLoopConsole } = await import("./console/index.js");
169
+ try {
170
+ await runAgenticLoopConsole(agent);
171
+ } finally {
172
+ await shutdownAgent();
173
+ }
174
+ }
110
175
  }
111
- if (interactive) {
112
- const { runAgenticLoopConsole } = await import("./console/index.js");
176
+
177
+ async function confirmMajorUpdate({ currentVersion, latestVersion }) {
178
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
179
+ console.log(`Major update ${currentVersion} → ${latestVersion} requires confirmation. Re-run with \`simy update --yes\`.`);
180
+ return false;
181
+ }
182
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
113
183
  try {
114
- await runAgenticLoopConsole(agent);
184
+ const answer = await prompt.question(`Install major SIMY CLI update ${currentVersion} → ${latestVersion}? [y/N] `);
185
+ return /^(y|yes)$/i.test(answer.trim());
115
186
  } finally {
116
- await shutdownAgent();
187
+ prompt.close();
117
188
  }
118
189
  }
119
190
 
@@ -79,8 +79,14 @@ export function completionEvidenceChecks(charter, attempt) {
79
79
  if (contract.version !== COMPLETION_CONTRACT_VERSION) return [];
80
80
 
81
81
  const uncertainty = normalizeUncertaintyVerification(attempt?.uncertainty_verification);
82
- const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
83
82
  const rows = acceptanceCriterionRows(charter);
83
+ const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
84
+ const expectedCriterionIds = new Set(rows.map((row) => row.id));
85
+ const unexpectedCriterionIds = uniqueStrings(
86
+ criterionEvidence
87
+ .map((item) => item.criterion_id)
88
+ .filter((criterionId) => !expectedCriterionIds.has(criterionId)),
89
+ );
84
90
  const checks = [
85
91
  check(
86
92
  "top_uncertainty_recorded",
@@ -110,6 +116,14 @@ export function completionEvidenceChecks(charter, attempt) {
110
116
  ? "The executor marked the highest uncertainty resolved with observed evidence."
111
117
  : uncertainty.fallback || "The highest uncertainty remains unresolved.",
112
118
  ),
119
+ check(
120
+ "criterion_evidence_scope",
121
+ "Criterion evidence matches the charter acceptance criteria",
122
+ unexpectedCriterionIds.length === 0,
123
+ unexpectedCriterionIds.length === 0
124
+ ? "Every criterion evidence row references a chartered acceptance criterion."
125
+ : `Unexpected criterion evidence: ${unexpectedCriterionIds.join(", ")}.`,
126
+ ),
113
127
  ];
114
128
 
115
129
  for (const row of rows) {
@@ -0,0 +1,373 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
8
+ export const SQM_AGENT_SKILL_VERSION = "2.0.0";
9
+ export const SIMY_CODEX_PLUGIN_ID = "simy";
10
+ export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
11
+
12
+ const BUNDLED_CODEX_PLUGIN_ROOT = fileURLToPath(
13
+ new URL(`../codex-plugins/${SIMY_CODEX_PLUGIN_ID}/`, import.meta.url),
14
+ );
15
+ const MANAGED_PLUGIN_AUTHOR = "Specialist Doctors Inc.";
16
+ const MANAGED_MARKETPLACE_SOURCE = `./plugins/${SIMY_CODEX_PLUGIN_ID}`;
17
+
18
+ const SKILL_CONTENT = `---
19
+ name: ${SQM_AGENT_SKILL_ID}
20
+ description: Run SIMY Software Quality Monitor checks before creating or proposing a pull request. Use whenever preparing, opening, or updating a PR.
21
+ ---
22
+
23
+ # SIMY SQM PR check
24
+
25
+ Before creating or proposing a pull request:
26
+
27
+ 1. Run \`simy sqm min-check\` from the repository root.
28
+ 2. Run \`simy sqm check --proof proof.json --signed-evidence proof.json.signed.json\`.
29
+ 3. Read \`proof.json\` and require \`outcome.status\` to be \`passed\`.
30
+ 4. If either command fails, SQM is unavailable, or the proof is not passed, stop. Report the exact blocker and do not create the PR.
31
+ 5. Include the proof path and SQM outcome in the PR body when the check passes.
32
+
33
+ Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
34
+ `;
35
+
36
+ const HOOK_SCRIPT = `#!/usr/bin/env node
37
+ import { spawnSync } from "node:child_process";
38
+
39
+ let raw = "";
40
+ for await (const chunk of process.stdin) raw += chunk;
41
+ let payload = {};
42
+ try { payload = raw ? JSON.parse(raw) : {}; } catch { process.exit(0); }
43
+ const command = String(payload?.tool_input?.command || payload?.tool_input?.cmd || "");
44
+ if (!/(^|[;&|]\\s*)gh\\s+pr\\s+create(?:\\s|$)/.test(command)) process.exit(0);
45
+
46
+ for (const args of [["sqm", "min-check"], ["sqm", "check"]]) {
47
+ const result = spawnSync("simy", args, { cwd: process.cwd(), stdio: "inherit" });
48
+ if (result.error || result.status !== 0) {
49
+ console.error(result.error ? \`SIMY SQM PR gate failed: \${result.error.message}\` : \`SIMY SQM PR gate blocked the PR (exit \${result.status}).\`);
50
+ process.exit(2);
51
+ }
52
+ }
53
+ `;
54
+
55
+ const TARGETS = {
56
+ codex: [".agents", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
57
+ claude: [".claude", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
58
+ };
59
+
60
+ export function sqmAgentSkillDigest() {
61
+ return digestText(SKILL_CONTENT);
62
+ }
63
+
64
+ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
65
+ const digest = sqmAgentSkillDigest();
66
+ const codexPlugin = await inspectCodexPlugin({ home });
67
+ const hookPath = managedHookPath(home);
68
+ const installedHookDigest = await fileDigest(hookPath);
69
+ const hookMatches = installedHookDigest === digestText(HOOK_SCRIPT);
70
+ const platforms = {};
71
+ for (const [platform, segments] of Object.entries(TARGETS)) {
72
+ const target = path.join(home, ...segments);
73
+ const installedDigest = await fileDigest(target);
74
+ const skillMatches = installedDigest === digest;
75
+ const hookConfigured = await hasManagedHook(platform, home, hookPath);
76
+ const platformExtraMatches = platform !== "codex" || codexPlugin.installed;
77
+ platforms[platform] = {
78
+ path: target,
79
+ hook_path: platformHookConfigPath(platform, home),
80
+ installed: skillMatches && hookMatches && hookConfigured && platformExtraMatches,
81
+ state:
82
+ installedDigest === null ||
83
+ !hookConfigured ||
84
+ installedHookDigest === null ||
85
+ (platform === "codex" && codexPlugin.state === "missing")
86
+ ? "missing"
87
+ : skillMatches && hookMatches && platformExtraMatches
88
+ ? "installed"
89
+ : "outdated",
90
+ ...(platform === "codex" ? { plugin: codexPlugin } : {}),
91
+ };
92
+ }
93
+ return {
94
+ skill_id: SQM_AGENT_SKILL_ID,
95
+ version: SQM_AGENT_SKILL_VERSION,
96
+ digest: `sha256:${digest}`,
97
+ enforcement: "pre_tool_use",
98
+ codex_plugin: codexPlugin,
99
+ installed: Object.values(platforms).every((entry) => entry.installed),
100
+ platforms,
101
+ };
102
+ }
103
+
104
+ export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS) } = {}) {
105
+ const requested = [...new Set(platforms)];
106
+ if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
107
+ throw new Error("platforms must contain codex and/or claude");
108
+ }
109
+ const hookPath = managedHookPath(home);
110
+ if (requested.includes("codex")) await installCodexPlugin({ home });
111
+ await atomicText(hookPath, HOOK_SCRIPT, 0o700);
112
+ for (const platform of requested) {
113
+ await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
114
+ await installManagedHook(platform, home, hookPath);
115
+ }
116
+ return inspectSqmAgentSkill({ home });
117
+ }
118
+
119
+ export async function inspectCodexPlugin({ home = homedir() } = {}) {
120
+ const expectedDigest = await directoryDigest(BUNDLED_CODEX_PLUGIN_ROOT);
121
+ const target = codexPluginPath(home);
122
+ const installedDigest = await directoryDigest(target, { missing: null });
123
+ const marketplacePath = codexMarketplacePath(home);
124
+ const marketplace = await readJsonFile(marketplacePath, { missing: null, invalid: null });
125
+ const entry = Array.isArray(marketplace?.plugins)
126
+ ? marketplace.plugins.find((candidate) => candidate?.name === SIMY_CODEX_PLUGIN_ID)
127
+ : null;
128
+ const marketplaceMatches = isManagedMarketplaceEntry(entry);
129
+ const filesMatch = installedDigest === expectedDigest;
130
+ return {
131
+ plugin_id: SIMY_CODEX_PLUGIN_ID,
132
+ version: SIMY_CODEX_PLUGIN_VERSION,
133
+ path: target,
134
+ marketplace_path: marketplacePath,
135
+ digest: `sha256:${expectedDigest}`,
136
+ installed: filesMatch && marketplaceMatches,
137
+ state:
138
+ installedDigest === null || !entry
139
+ ? "missing"
140
+ : filesMatch && marketplaceMatches
141
+ ? "installed"
142
+ : "outdated",
143
+ };
144
+ }
145
+
146
+ async function installCodexPlugin({ home }) {
147
+ const target = codexPluginPath(home);
148
+ const marketplacePath = codexMarketplacePath(home);
149
+ const marketplace = await readJsonFile(marketplacePath, { missing: {}, invalid: "throw" });
150
+ if (!marketplace || typeof marketplace !== "object" || Array.isArray(marketplace)) {
151
+ throw new Error(`${marketplacePath} must contain a JSON object before SIMY can install its Codex plugin.`);
152
+ }
153
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
154
+ const collision = plugins.find((entry) => entry?.name === SIMY_CODEX_PLUGIN_ID);
155
+ if (collision && !isManagedMarketplaceEntry(collision)) {
156
+ throw new Error(`Codex marketplace already contains an unmanaged ${SIMY_CODEX_PLUGIN_ID} plugin entry.`);
157
+ }
158
+ await assertManagedPluginTarget(target);
159
+
160
+ const staged = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.tmp`);
161
+ const backup = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.bak`);
162
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
163
+ await rm(staged, { recursive: true, force: true });
164
+ await rm(backup, { recursive: true, force: true });
165
+ await cp(BUNDLED_CODEX_PLUGIN_ROOT, staged, { recursive: true, errorOnExist: true });
166
+ let backedUp = false;
167
+ try {
168
+ if (await pathExists(target)) {
169
+ await rename(target, backup);
170
+ backedUp = true;
171
+ }
172
+ await rename(staged, target);
173
+ if (backedUp) await rm(backup, { recursive: true, force: true });
174
+ } catch (error) {
175
+ await rm(staged, { recursive: true, force: true });
176
+ if (backedUp && !(await pathExists(target))) await rename(backup, target);
177
+ throw error;
178
+ }
179
+
180
+ const nextMarketplace = {
181
+ ...marketplace,
182
+ name: typeof marketplace.name === "string" && marketplace.name.trim()
183
+ ? marketplace.name
184
+ : "personal",
185
+ interface: {
186
+ displayName: "Personal",
187
+ ...(marketplace.interface && typeof marketplace.interface === "object"
188
+ ? marketplace.interface
189
+ : {}),
190
+ },
191
+ plugins: [
192
+ ...plugins.filter((entry) => entry?.name !== SIMY_CODEX_PLUGIN_ID),
193
+ managedMarketplaceEntry(),
194
+ ],
195
+ };
196
+ await atomicText(marketplacePath, `${JSON.stringify(nextMarketplace, null, 2)}\n`, 0o600);
197
+ }
198
+
199
+ function managedMarketplaceEntry() {
200
+ return {
201
+ name: SIMY_CODEX_PLUGIN_ID,
202
+ source: { source: "local", path: MANAGED_MARKETPLACE_SOURCE },
203
+ policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" },
204
+ category: "Developer Tools",
205
+ };
206
+ }
207
+
208
+ function isManagedMarketplaceEntry(entry) {
209
+ return entry?.name === SIMY_CODEX_PLUGIN_ID &&
210
+ entry?.source?.source === "local" &&
211
+ entry?.source?.path === MANAGED_MARKETPLACE_SOURCE &&
212
+ entry?.policy?.installation === "INSTALLED_BY_DEFAULT" &&
213
+ entry?.policy?.authentication === "ON_INSTALL";
214
+ }
215
+
216
+ async function assertManagedPluginTarget(target) {
217
+ if (!(await pathExists(target))) return;
218
+ const manifest = await readJsonFile(path.join(target, ".codex-plugin", "plugin.json"), {
219
+ missing: null,
220
+ invalid: null,
221
+ });
222
+ if (manifest?.name !== SIMY_CODEX_PLUGIN_ID || manifest?.author?.name !== MANAGED_PLUGIN_AUTHOR) {
223
+ throw new Error(`${target} already exists and is not a SIMY-managed Codex plugin.`);
224
+ }
225
+ }
226
+
227
+ function codexPluginPath(home) {
228
+ return path.join(home, "plugins", SIMY_CODEX_PLUGIN_ID);
229
+ }
230
+
231
+ function codexMarketplacePath(home) {
232
+ return path.join(home, ".agents", "plugins", "marketplace.json");
233
+ }
234
+
235
+ function managedHookPath(home) {
236
+ return path.join(home, ".simy", "integrations", "sqm-agent", "pre-pr-hook.mjs");
237
+ }
238
+
239
+ function platformHookConfigPath(platform, home) {
240
+ return platform === "codex"
241
+ ? path.join(home, ".codex", "hooks.json")
242
+ : path.join(home, ".claude", "settings.json");
243
+ }
244
+
245
+ function managedHookGroup(hookPath) {
246
+ return {
247
+ matcher: "Bash",
248
+ hooks: [
249
+ {
250
+ type: "command",
251
+ command: `node ${JSON.stringify(hookPath)}`,
252
+ statusMessage: "Running SIMY SQM PR gate",
253
+ },
254
+ ],
255
+ };
256
+ }
257
+
258
+ async function installManagedHook(platform, home, hookPath) {
259
+ const target = platformHookConfigPath(platform, home);
260
+ let config = {};
261
+ try {
262
+ config = JSON.parse(await readFile(target, "utf8"));
263
+ } catch (error) {
264
+ if (error?.code !== "ENOENT") {
265
+ throw new Error(`${target} must contain valid JSON before SIMY can merge the SQM hook.`);
266
+ }
267
+ }
268
+ const hooks = config && typeof config === "object" && !Array.isArray(config)
269
+ ? { ...(config.hooks || {}) }
270
+ : {};
271
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
272
+ hooks.PreToolUse = [
273
+ ...existing.filter((group) => !groupContainsManagedHook(group)),
274
+ managedHookGroup(hookPath),
275
+ ];
276
+ await atomicText(target, `${JSON.stringify({ ...config, hooks }, null, 2)}\n`, 0o600);
277
+ }
278
+
279
+ async function hasManagedHook(platform, home, hookPath) {
280
+ try {
281
+ const config = JSON.parse(await readFile(platformHookConfigPath(platform, home), "utf8"));
282
+ return Array.isArray(config?.hooks?.PreToolUse) &&
283
+ config.hooks.PreToolUse.some((group) => groupContainsManagedHook(group, hookPath));
284
+ } catch (error) {
285
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return false;
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ function groupContainsManagedHook(group, expectedPath = null) {
291
+ if (!Array.isArray(group?.hooks)) return false;
292
+ return group.hooks.some((hook) => {
293
+ const command = typeof hook?.command === "string" ? hook.command : "";
294
+ return expectedPath
295
+ ? command === `node ${JSON.stringify(expectedPath)}`
296
+ : command.includes("/.simy/integrations/sqm-agent/pre-pr-hook.mjs") ||
297
+ command.includes("\\.simy\\integrations\\sqm-agent\\pre-pr-hook.mjs");
298
+ });
299
+ }
300
+
301
+ async function fileDigest(target) {
302
+ try {
303
+ return digestText(await readFile(target, "utf8"));
304
+ } catch (error) {
305
+ if (error?.code === "ENOENT") return null;
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ async function directoryDigest(root, { missing = "throw" } = {}) {
311
+ let rootStat;
312
+ try {
313
+ rootStat = await lstat(root);
314
+ } catch (error) {
315
+ if (error?.code === "ENOENT" && missing !== "throw") return missing;
316
+ throw error;
317
+ }
318
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
319
+ throw new Error(`${root} must be a real directory.`);
320
+ }
321
+ const hash = createHash("sha256");
322
+ const visit = async (directory, prefix = "") => {
323
+ const entries = await readdir(directory, { withFileTypes: true });
324
+ entries.sort((left, right) => left.name.localeCompare(right.name));
325
+ for (const entry of entries) {
326
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
327
+ const target = path.join(directory, entry.name);
328
+ if (entry.isSymbolicLink()) throw new Error(`${target} must not be a symbolic link.`);
329
+ if (entry.isDirectory()) {
330
+ await visit(target, relative);
331
+ continue;
332
+ }
333
+ if (!entry.isFile()) throw new Error(`${target} must be a regular file.`);
334
+ hash.update(relative).update("\0").update(await readFile(target)).update("\0");
335
+ }
336
+ };
337
+ await visit(root);
338
+ return hash.digest("hex");
339
+ }
340
+
341
+ async function readJsonFile(target, { missing, invalid }) {
342
+ try {
343
+ return JSON.parse(await readFile(target, "utf8"));
344
+ } catch (error) {
345
+ if (error?.code === "ENOENT") return missing;
346
+ if (error instanceof SyntaxError) {
347
+ if (invalid !== "throw") return invalid;
348
+ throw new Error(`${target} must contain valid JSON before SIMY can update it.`);
349
+ }
350
+ throw error;
351
+ }
352
+ }
353
+
354
+ async function pathExists(target) {
355
+ try {
356
+ await lstat(target);
357
+ return true;
358
+ } catch (error) {
359
+ if (error?.code === "ENOENT") return false;
360
+ throw error;
361
+ }
362
+ }
363
+
364
+ function digestText(content) {
365
+ return createHash("sha256").update(content).digest("hex");
366
+ }
367
+
368
+ async function atomicText(target, content, mode) {
369
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
370
+ const temporary = `${target}.${process.pid}.tmp`;
371
+ await writeFile(temporary, content, { encoding: "utf8", mode });
372
+ await rename(temporary, target);
373
+ }