@awak-app/simy-cli 0.5.7 → 0.5.10

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.
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
8
- export const SQM_AGENT_SKILL_VERSION = "2.0.0";
8
+ export const SQM_AGENT_SKILL_VERSION = "3.0.0";
9
9
  export const SIMY_CODEX_PLUGIN_ID = "simy";
10
10
  export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
11
11
 
@@ -15,41 +15,34 @@ const BUNDLED_CODEX_PLUGIN_ROOT = fileURLToPath(
15
15
  const MANAGED_PLUGIN_AUTHOR = "Specialist Doctors Inc.";
16
16
  const MANAGED_MARKETPLACE_SOURCE = `./plugins/${SIMY_CODEX_PLUGIN_ID}`;
17
17
 
18
+ const LEGACY_SKILL_DIGEST = "c9ab46e855280196dbc93fd36ba849418e0c4e500dd6e4b7b03b2296e838df92";
19
+
18
20
  const SKILL_CONTENT = `---
19
21
  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.
22
+ description: Run SIMY Software Quality Monitor checks for PR work when the user explicitly invokes @simy, this skill, or asks to use SQM. Ordinary PR creation or git push alone does not activate this skill.
21
23
  ---
22
24
 
23
25
  # SIMY SQM PR check
24
26
 
25
- Before creating or proposing a pull request:
27
+ Apply this workflow only when the user has explicitly selected \`@simy\`, invoked this skill, or requested SQM for the current task. A quoted mention, repository organization, installed plugin, or ordinary request to create a PR is not opt-in. Honor a later request to stop using SQM.
28
+
29
+ Without opt-in, continue the requested work without running SQM or making it a prerequisite. You may briefly offer SQM if useful; do not delay the work waiting for an optional answer. Follow any independent repository checks required by the project.
30
+
31
+ When opted in, before creating or proposing a pull request:
26
32
 
27
33
  1. Run \`simy sqm min-check\` from the repository root.
28
34
  2. Run \`simy sqm check --proof proof.json --signed-evidence proof.json.signed.json\`.
29
35
  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.
36
+ 4. If either command fails, SQM is unavailable, or the proof is not passed, report the exact blocker. Do not claim SQM passed or silently bypass the requested check.
31
37
  5. Include the proof path and SQM outcome in the PR body when the check passes.
32
38
 
33
39
  Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
34
40
  `;
35
41
 
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
- }
42
+ // Cached hook registrations in running sessions must also stop blocking PRs.
43
+ const RETIRED_HOOK_SCRIPT = `#!/usr/bin/env node
44
+ // Global SQM gate retired; the skill applies only after explicit task opt-in.
45
+ process.exit(0);
53
46
  `;
54
47
 
55
48
  const TARGETS = {
@@ -65,8 +58,6 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
65
58
  const digest = sqmAgentSkillDigest();
66
59
  const codexPlugin = await inspectCodexPlugin({ home });
67
60
  const hookPath = managedHookPath(home);
68
- const installedHookDigest = await fileDigest(hookPath);
69
- const hookMatches = installedHookDigest === digestText(HOOK_SCRIPT);
70
61
  const platforms = {};
71
62
  for (const [platform, segments] of Object.entries(TARGETS)) {
72
63
  const target = path.join(home, ...segments);
@@ -77,14 +68,12 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
77
68
  platforms[platform] = {
78
69
  path: target,
79
70
  hook_path: platformHookConfigPath(platform, home),
80
- installed: skillMatches && hookMatches && hookConfigured && platformExtraMatches,
71
+ installed: skillMatches && !hookConfigured && platformExtraMatches,
81
72
  state:
82
73
  installedDigest === null ||
83
- !hookConfigured ||
84
- installedHookDigest === null ||
85
74
  (platform === "codex" && codexPlugin.state === "missing")
86
75
  ? "missing"
87
- : skillMatches && hookMatches && platformExtraMatches
76
+ : skillMatches && !hookConfigured && platformExtraMatches
88
77
  ? "installed"
89
78
  : "outdated",
90
79
  ...(platform === "codex" ? { plugin: codexPlugin } : {}),
@@ -94,7 +83,7 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
94
83
  skill_id: SQM_AGENT_SKILL_ID,
95
84
  version: SQM_AGENT_SKILL_VERSION,
96
85
  digest: `sha256:${digest}`,
97
- enforcement: "pre_tool_use",
86
+ enforcement: "explicit_opt_in",
98
87
  codex_plugin: codexPlugin,
99
88
  installed: Object.values(platforms).every((entry) => entry.installed),
100
89
  platforms,
@@ -106,12 +95,10 @@ export async function installSqmAgentSkill({ home = homedir(), platforms = Objec
106
95
  if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
107
96
  throw new Error("platforms must contain codex and/or claude");
108
97
  }
109
- const hookPath = managedHookPath(home);
110
98
  if (requested.includes("codex")) await installCodexPlugin({ home });
111
- await atomicText(hookPath, HOOK_SCRIPT, 0o700);
99
+ await retireLegacySqmGate({ home });
112
100
  for (const platform of requested) {
113
101
  await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
114
- await installManagedHook(platform, home, hookPath);
115
102
  }
116
103
  return inspectSqmAgentSkill({ home });
117
104
  }
@@ -252,60 +239,59 @@ function platformHookConfigPath(platform, home) {
252
239
  : path.join(home, ".claude", "settings.json");
253
240
  }
254
241
 
255
- function managedHookGroup(hookPath) {
256
- return {
257
- matcher: "Bash",
258
- hooks: [
259
- {
260
- type: "command",
261
- command: `node ${JSON.stringify(hookPath)}`,
262
- statusMessage: "Running SIMY SQM PR gate",
263
- },
264
- ],
265
- };
242
+ // Only migrate the exact shipped legacy skill. Custom user instructions are retained.
243
+ export async function retireLegacySqmGate({ home = homedir() } = {}) {
244
+ const hookPath = managedHookPath(home);
245
+ let changed = false;
246
+ for (const [platform, segments] of Object.entries(TARGETS)) {
247
+ const target = path.join(home, ...segments);
248
+ if (await fileDigest(target) === LEGACY_SKILL_DIGEST) {
249
+ await atomicText(target, SKILL_CONTENT, 0o600);
250
+ changed = true;
251
+ }
252
+ changed = await removeManagedHook(platform, home, hookPath) || changed;
253
+ }
254
+ const hookDigest = await fileDigest(hookPath);
255
+ if (hookDigest !== null && hookDigest !== digestText(RETIRED_HOOK_SCRIPT)) {
256
+ await atomicText(hookPath, RETIRED_HOOK_SCRIPT, 0o700);
257
+ changed = true;
258
+ }
259
+ return changed;
266
260
  }
267
261
 
268
- async function installManagedHook(platform, home, hookPath) {
262
+ async function removeManagedHook(platform, home, hookPath) {
269
263
  const target = platformHookConfigPath(platform, home);
270
- let config = {};
271
- try {
272
- config = JSON.parse(await readFile(target, "utf8"));
273
- } catch (error) {
274
- if (error?.code !== "ENOENT") {
275
- throw new Error(`${target} must contain valid JSON before SIMY can merge the SQM hook.`);
264
+ const config = await readJsonFile(target, { missing: null, invalid: "throw" });
265
+ if (!config?.hooks) return false;
266
+ let changed = false;
267
+ for (const [event, groups] of Object.entries(config.hooks)) {
268
+ if (!Array.isArray(groups)) continue;
269
+ const kept = [];
270
+ for (const group of groups) {
271
+ if (!Array.isArray(group?.hooks)) { kept.push(group); continue; }
272
+ const remaining = group.hooks.filter((hook) => !isManagedHook(hook, hookPath));
273
+ if (remaining.length === group.hooks.length) kept.push(group);
274
+ else {
275
+ changed = true;
276
+ if (remaining.length) kept.push({ ...group, hooks: remaining });
277
+ }
276
278
  }
279
+ if (kept.length) config.hooks[event] = kept;
280
+ else if (groups.length) delete config.hooks[event];
277
281
  }
278
- const hooks = config && typeof config === "object" && !Array.isArray(config)
279
- ? { ...(config.hooks || {}) }
280
- : {};
281
- const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
282
- hooks.PreToolUse = [
283
- ...existing.filter((group) => !groupContainsManagedHook(group)),
284
- managedHookGroup(hookPath),
285
- ];
286
- await atomicText(target, `${JSON.stringify({ ...config, hooks }, null, 2)}\n`, 0o600);
282
+ if (changed) await atomicText(target, `${JSON.stringify(config, null, 2)}\n`, 0o600);
283
+ return changed;
287
284
  }
288
285
 
289
286
  async function hasManagedHook(platform, home, hookPath) {
290
- try {
291
- const config = JSON.parse(await readFile(platformHookConfigPath(platform, home), "utf8"));
292
- return Array.isArray(config?.hooks?.PreToolUse) &&
293
- config.hooks.PreToolUse.some((group) => groupContainsManagedHook(group, hookPath));
294
- } catch (error) {
295
- if (error?.code === "ENOENT" || error instanceof SyntaxError) return false;
296
- throw error;
297
- }
287
+ const config = await readJsonFile(platformHookConfigPath(platform, home), { missing: null, invalid: "throw" });
288
+ return Object.values(config?.hooks || {}).some((groups) => Array.isArray(groups) &&
289
+ groups.some((group) => Array.isArray(group?.hooks) && group.hooks.some((hook) => isManagedHook(hook, hookPath))));
298
290
  }
299
291
 
300
- function groupContainsManagedHook(group, expectedPath = null) {
301
- if (!Array.isArray(group?.hooks)) return false;
302
- return group.hooks.some((hook) => {
303
- const command = typeof hook?.command === "string" ? hook.command : "";
304
- return expectedPath
305
- ? command === `node ${JSON.stringify(expectedPath)}`
306
- : command.includes("/.simy/integrations/sqm-agent/pre-pr-hook.mjs") ||
307
- command.includes("\\.simy\\integrations\\sqm-agent\\pre-pr-hook.mjs");
308
- });
292
+ function isManagedHook(hook, expectedPath) {
293
+ const command = typeof hook?.command === "string" ? hook.command : "";
294
+ return command === `node ${JSON.stringify(expectedPath)}`;
309
295
  }
310
296
 
311
297
  async function fileDigest(target) {
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
 
5
5
  import { isSessionValid, readSession } from "../session-store.js";
6
6
  import { resolveWebOrigin } from "../web-origin.js";
7
+ import { submitLocalEvidence } from "./local-evidence.js";
7
8
  import { uploadRunEvidence } from "./evidence-client.js";
8
9
  import { evaluateKnowledgeBundle } from "./checkers.js";
9
10
  import { runSqmCheck, runSqmMinCheck } from "./index.js";
@@ -15,6 +16,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
15
16
  printHelp();
16
17
  return 0;
17
18
  }
19
+ if (argv[0] === "submit-evidence") return await submitLocalEvidence(argv);
18
20
  if (argv[0] === "lint") return await runLintCommand(argv);
19
21
  if (argv[0] === "replay") return await runReplayCommand(argv);
20
22
  if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|lint|replay> [options]");
@@ -95,6 +97,7 @@ function printHelp() {
95
97
  console.log(`Usage:
96
98
  simy sqm min-check [options]
97
99
  simy sqm check [options]
100
+ simy sqm submit-evidence --file <delivery.json> [--host <origin>] [--dry-run]
98
101
  simy sqm lint --bundle <path>
99
102
  simy sqm replay --bundle <path> --dataset <path>
100
103
 
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isSessionValid, readSession } from "../session-store.js";
3
+ import { resolveWebOrigin } from "../web-origin.js";
4
+ import { webApiUrl } from "../web-api.js";
5
+
6
+ /** Submission is explicit; no transcript scanning or implicit file attachment. */
7
+ export async function submitLocalEvidence(argv, { fetchImpl = globalThis.fetch, readSessionImpl = readSession } = {}) {
8
+ const option = (name) => { const i = argv.indexOf(name); return i < 0 ? undefined : argv[i + 1]; };
9
+ const file = option("--file");
10
+ if (!file || file.startsWith("--")) throw new Error("Usage: simy sqm submit-evidence --file <delivery.json> [--host <origin>] [--dry-run]");
11
+ const raw = await readFile(file);
12
+ if (raw.length > 64 * 1024) throw new Error("Local delivery evidence exceeds 64 KiB; submit a bounded summary.");
13
+ const evidence = JSON.parse(raw.toString("utf8"));
14
+ if (!evidence || Array.isArray(evidence) || evidence.schema_version !== "1.0.0") throw new Error("Expected local delivery evidence schema_version 1.0.0");
15
+ if (argv.includes("--dry-run")) { console.log(JSON.stringify({ submitted: false, evidence }, null, 2)); return 0; }
16
+ const origin = resolveWebOrigin(option("--host"));
17
+ const session = await readSessionImpl(origin);
18
+ if (!isSessionValid(session, Date.now(), origin)) throw new Error(`Sign in first: simy --host ${origin}`);
19
+ const response = await fetchImpl(webApiUrl("sqm/local-evidence", { webOrigin: origin, apiBaseUrl: session.api_base_url }), {
20
+ method: "POST", headers: { authorization: `Bearer ${session.token}`, "content-type": "application/json" },
21
+ body: JSON.stringify(evidence), signal: AbortSignal.timeout(30_000),
22
+ });
23
+ const result = await response.json().catch(() => null);
24
+ if (!response.ok) throw new Error(`SQM submission failed (${response.status}): ${result?.error?.message || result?.error || "Unavailable"}`);
25
+ if (!result?.source_id || !Array.isArray(result.jobs) || !result.jobs.length) throw new Error("SQM returned no analysis job receipt");
26
+ console.log(JSON.stringify(result, null, 2));
27
+ return 0;
28
+ }
@@ -73,7 +73,7 @@ export async function confirmWorkflowInstallation({
73
73
  } = {}) {
74
74
  output.write(
75
75
  [
76
- "SIMY can install or update its Codex plugin marketplace, Codex/Claude SQM skills, and PR hooks.",
76
+ "SIMY can install or update its Codex plugin marketplace, Codex/Claude opt-in SQM skills.",
77
77
  "Existing unrelated plugins and hooks will be preserved.",
78
78
  "",
79
79
  ].join("\n"),
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
 
7
7
  import { resolveBackendExecutable } from "../backend-executable.js";
8
- import { inspectSqmAgentSkill, installSqmAgentSkill } from "./agent-skill.js";
8
+ import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from "./agent-skill.js";
9
9
 
10
10
  const execFileAsync = promisify(execFile);
11
11
  const CHECK_INTERVAL_MS = 60 * 60 * 1000;
@@ -47,11 +47,12 @@ export async function syncInstalledWorkflows({ home = homedir(),
47
47
  throw error;
48
48
  }
49
49
  try {
50
+ const migrated = await retireLegacySqmGate({ home });
50
51
  let status = await inspect({ home });
51
52
  const platforms = Object.entries(status.platforms)
52
53
  .filter(([, entry]) => entry.state === "outdated").map(([platform]) => platform);
53
54
  if (platforms.length) status = await install({ home, platforms });
54
- if (!status.platforms.codex.installed) return { outcome: platforms.length ? "updated" : "unchanged" };
55
+ if (!status.platforms.codex.installed) return { outcome: platforms.length || migrated ? "updated" : "unchanged" };
55
56
 
56
57
  const marketplace = JSON.parse(await readFile(status.codex_plugin.marketplace_path, "utf8"));
57
58
  if (typeof marketplace.name !== "string" || !/^[A-Za-z0-9_-]+$/.test(marketplace.name)) {