@awak-app/simy-cli 0.1.3 → 0.1.5

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.
@@ -2,14 +2,67 @@ import { spawn } from "node:child_process";
2
2
 
3
3
  const MACOS_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex";
4
4
 
5
- export async function resolveBackendExecutable(
5
+ export const BACKEND_VERSION_POLICIES = Object.freeze({
6
+ codex: Object.freeze({
7
+ label: "Codex",
8
+ minimumVersion: "0.144.0",
9
+ updateCommand: "npm install -g @openai/codex@latest",
10
+ }),
11
+ claude: Object.freeze({
12
+ label: "Claude Code",
13
+ minimumVersion: "2.1.200",
14
+ updateCommand: "claude update",
15
+ }),
16
+ });
17
+
18
+ export async function resolveBackendExecutable(backend, options = {}) {
19
+ const inspection = await inspectBackendExecutable(backend, options);
20
+ return inspection.available ? inspection.executable : null;
21
+ }
22
+
23
+ export async function inspectBackendExecutable(
6
24
  backend,
7
25
  { spawnImpl = spawn, candidates = backendCandidates(backend), timeoutMs = 1_500 } = {},
8
26
  ) {
27
+ const policy = versionPolicy(backend);
9
28
  for (const candidate of [...new Set(candidates.filter(Boolean))]) {
10
- if (await executableAvailable(candidate, { spawnImpl, timeoutMs })) return candidate;
29
+ const probe = await probeExecutable(candidate, { spawnImpl, timeoutMs });
30
+ if (!probe.available) continue;
31
+ const installedVersion = parseBackendVersion(probe.output);
32
+ if (!installedVersion) {
33
+ return {
34
+ backend,
35
+ executable: candidate,
36
+ available: true,
37
+ compatible: false,
38
+ status: "unknown_version",
39
+ installed_version: null,
40
+ minimum_version: policy.minimumVersion,
41
+ update_command: policy.updateCommand,
42
+ };
43
+ }
44
+ const compatible = compareVersions(installedVersion, policy.minimumVersion) >= 0;
45
+ return {
46
+ backend,
47
+ executable: candidate,
48
+ available: true,
49
+ compatible,
50
+ status: compatible ? "compatible" : "outdated",
51
+ installed_version: installedVersion,
52
+ minimum_version: policy.minimumVersion,
53
+ update_command: policy.updateCommand,
54
+ };
11
55
  }
12
- return null;
56
+ return {
57
+ backend,
58
+ executable: null,
59
+ available: false,
60
+ compatible: false,
61
+ status: "missing",
62
+ installed_version: null,
63
+ minimum_version: policy.minimumVersion,
64
+ update_command: policy.updateCommand,
65
+ };
13
66
  }
14
67
 
15
68
  export function backendCandidates(backend) {
@@ -23,16 +76,49 @@ export function backendCandidates(backend) {
23
76
  ];
24
77
  }
25
78
 
26
- function executableAvailable(command, { spawnImpl, timeoutMs }) {
79
+ export function parseBackendVersion(output) {
80
+ const match = String(output || "").match(/(?:^|\s|v)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$|\))/);
81
+ return match?.[1] ?? null;
82
+ }
83
+
84
+ export function compareVersions(left, right) {
85
+ const leftParts = numericVersionParts(left);
86
+ const rightParts = numericVersionParts(right);
87
+ for (let index = 0; index < 3; index += 1) {
88
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
89
+ }
90
+ return 0;
91
+ }
92
+
93
+ function versionPolicy(backend) {
94
+ return backend === "claude" ? BACKEND_VERSION_POLICIES.claude : BACKEND_VERSION_POLICIES.codex;
95
+ }
96
+
97
+ function numericVersionParts(value) {
98
+ const match = String(value || "").match(/^(\d+)\.(\d+)\.(\d+)/);
99
+ if (!match) return [0, 0, 0];
100
+ return match.slice(1).map(Number);
101
+ }
102
+
103
+ function probeExecutable(command, { spawnImpl, timeoutMs }) {
27
104
  return new Promise((resolve) => {
28
105
  let settled = false;
29
106
  let timeout;
30
- const child = spawnImpl(command, ["--version"], { stdio: "ignore" });
107
+ let output = "";
108
+ const child = spawnImpl(command, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
109
+ child.stdout?.setEncoding?.("utf8");
110
+ child.stderr?.setEncoding?.("utf8");
111
+ child.stdout?.on?.("data", (chunk) => {
112
+ output += chunk;
113
+ });
114
+ child.stderr?.on?.("data", (chunk) => {
115
+ output += chunk;
116
+ });
31
117
  const finish = (available) => {
32
118
  if (settled) return;
33
119
  settled = true;
34
120
  if (timeout) clearTimeout(timeout);
35
- resolve(available);
121
+ resolve({ available, output: output.trim() });
36
122
  };
37
123
  timeout = setTimeout(() => {
38
124
  child.kill?.("SIGTERM");
@@ -19,11 +19,12 @@ const TERMINAL_STATES = new Set([
19
19
  ]);
20
20
  const NEW_TASK = "__new_task__";
21
21
 
22
- export function CodingLoopConsole({ agent, onQuit = () => {} }) {
22
+ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
23
23
  const { exit } = useApp();
24
24
  const { stdout } = useStdout();
25
25
  const [terminal, setTerminal] = useState(() => terminalSize(stdout));
26
26
  const [runs, setRuns] = useState(() => agent.registry.list());
27
+ const [cliUpdate, setCliUpdate] = useState(() => agent.updates?.snapshot?.() ?? null);
27
28
  const [selectedId, setSelectedId] = useState(() => runs[0]?.id ?? NEW_TASK);
28
29
  const [inputMode, setInputMode] = useState(() => runs.length === 0);
29
30
  const [input, setInput] = useState("");
@@ -51,6 +52,13 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
51
52
  return () => agent.registry.off("change", update);
52
53
  }, [agent.registry]);
53
54
 
55
+ useEffect(() => {
56
+ if (!agent.updates?.on) return undefined;
57
+ const update = (nextState) => setCliUpdate(nextState);
58
+ agent.updates.on("change", update);
59
+ return () => agent.updates.off("change", update);
60
+ }, [agent.updates]);
61
+
54
62
  useEffect(() => {
55
63
  const resize = () => setTerminal(terminalSize(stdout));
56
64
  stdout.on("resize", resize);
@@ -68,7 +76,8 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
68
76
  1 +
69
77
  (waitingForHuman ? 1 : 0) +
70
78
  (!selectedRun ? 1 : 0) +
71
- (notice || busy ? 1 : 0);
79
+ (notice || busy ? 1 : 0) +
80
+ (visibleUpdateNotice(cliUpdate) ? 1 : 0);
72
81
  const mainHeight = Math.max(8, terminal.rows - 5 - actionRows);
73
82
  const detailInnerWidth = Math.max(20, terminal.columns - 4);
74
83
  const detailRows = useMemo(
@@ -138,7 +147,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
138
147
  }
139
148
  if (!selectedRun) {
140
149
  if (command.type !== "guidance") {
141
- setNotice("That command requires a selected coding run.");
150
+ setNotice("That command requires a selected Agentic Loop run.");
142
151
  return;
143
152
  }
144
153
  setBusy(true);
@@ -175,12 +184,12 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
175
184
  setNotice("Guidance queued for the next executor handoff.");
176
185
  } else {
177
186
  await agent.controls.continue(selectedRun, command.message);
178
- setNotice("Human guidance accepted. The coding loop is continuing.");
187
+ setNotice("Human guidance accepted. The agentic loop is continuing.");
179
188
  }
180
189
  break;
181
190
  case "decision":
182
191
  await agent.controls.applyDecision(selectedRun, command);
183
- setNotice("Human decision recorded. The coding loop is continuing.");
192
+ setNotice("Human decision recorded. The agentic loop is continuing.");
184
193
  break;
185
194
  case "recheck":
186
195
  await agent.controls.recheck(selectedRun);
@@ -259,7 +268,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
259
268
  await agent.controls.selectRepository(selectedRun, match);
260
269
  setDetailOpen(true);
261
270
  setDetailScrollFromEnd(0);
262
- setNotice(`Authorized ${match.repository}. The same coding run is continuing.`);
271
+ setNotice(`Authorized ${match.repository}. The same Agentic Loop run is continuing.`);
263
272
  } else if (selectedRun) {
264
273
  setNotice(
265
274
  `${selectedRun.request.repository} was not found under ${result.root}. Run /scan <path> for another authorized root.`,
@@ -290,7 +299,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
290
299
  setRepositoryPicker(null);
291
300
  setDetailOpen(true);
292
301
  setDetailScrollFromEnd(0);
293
- setNotice(`Authorized ${selected.repository}. The same coding run is continuing.`);
302
+ setNotice(`Authorized ${selected.repository}. The same Agentic Loop run is continuing.`);
294
303
  } else {
295
304
  const verified = await agent.controls.selectRepository(null, selected);
296
305
  setDraft((current) => ({
@@ -461,7 +470,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
461
470
  if (confirmStop) void execute("/stop");
462
471
  else {
463
472
  setConfirmStop(true);
464
- setNotice("Press x again to stop the selected coding run.");
473
+ setNotice("Press x again to stop the selected Agentic Loop run.");
465
474
  }
466
475
  return;
467
476
  }
@@ -529,11 +538,14 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
529
538
  draft,
530
539
  repositoryMissing: isRepositoryMissing(selectedRun),
531
540
  modalOpen: Boolean(scanPrompt || repositoryPicker),
541
+ cliUpdate,
532
542
  }),
533
543
  ],
534
544
  });
535
545
  }
536
546
 
547
+ export const CodingLoopConsole = AgenticLoopConsole;
548
+
537
549
  function Header({ agent, runCount }) {
538
550
  return jsxs(Box, {
539
551
  borderStyle: "single",
@@ -542,6 +554,12 @@ function Header({ agent, runCount }) {
542
554
  children: [
543
555
  jsx(Text, { bold: true, color: "cyan", children: "SIMY" }),
544
556
  jsx(Text, { bold: true, children: " Coding Chat" }),
557
+ agent.updates?.snapshot?.().current_version
558
+ ? jsx(Text, {
559
+ dimColor: true,
560
+ children: ` v${agent.updates.snapshot().current_version}`,
561
+ })
562
+ : null,
545
563
  jsx(Text, { dimColor: true, children: ` localhost:${agent.port}` }),
546
564
  jsx(Spacer, {}),
547
565
  jsx(Text, { color: runCount > 0 ? "green" : "yellow", children: `${runCount} runs` }),
@@ -778,6 +796,7 @@ function ActionBar({
778
796
  draft,
779
797
  repositoryMissing,
780
798
  modalOpen,
799
+ cliUpdate,
781
800
  }) {
782
801
  const waiting = run && (run.controlState === "waiting_human" || run.status === "waiting_human");
783
802
  return jsxs(Box, {
@@ -823,10 +842,29 @@ function ActionBar({
823
842
  : busy
824
843
  ? jsx(Text, { color: "cyan", children: "Working..." })
825
844
  : null,
845
+ visibleUpdateNotice(cliUpdate)
846
+ ? jsx(Text, {
847
+ color: cliUpdate.state === "failed" ? "red" : "yellow",
848
+ children: visibleUpdateNotice(cliUpdate),
849
+ })
850
+ : null,
826
851
  ],
827
852
  });
828
853
  }
829
854
 
855
+ function visibleUpdateNotice(update) {
856
+ if (
857
+ !update ||
858
+ !["update_available", "waiting_for_idle", "updating", "restarting", "failed"].includes(
859
+ update.state,
860
+ )
861
+ ) {
862
+ return "";
863
+ }
864
+ const command = update.action?.command ? ` Run: ${update.action.command}` : "";
865
+ return `${update.message || "SIMY CLI update status changed."}${command}`;
866
+ }
867
+
830
868
  function initialDraft(agent) {
831
869
  const backends = agent.capabilities?.backends ?? {};
832
870
  return {
@@ -1,11 +1,11 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
 
4
- import { CodingLoopConsole } from "./app.js";
4
+ import { AgenticLoopConsole } from "./app.js";
5
5
 
6
- export async function runCodingLoopConsole(agent, options = {}) {
6
+ export async function runAgenticLoopConsole(agent, options = {}) {
7
7
  const instance = render(
8
- jsx(CodingLoopConsole, {
8
+ jsx(AgenticLoopConsole, {
9
9
  agent,
10
10
  onQuit: options.onQuit,
11
11
  }),
@@ -21,5 +21,6 @@ export async function runCodingLoopConsole(agent, options = {}) {
21
21
  await instance.waitUntilExit();
22
22
  }
23
23
 
24
- export { CodingLoopConsole } from "./app.js";
24
+ export const runCodingLoopConsole = runAgenticLoopConsole;
25
+ export { AgenticLoopConsole, CodingLoopConsole } from "./app.js";
25
26
  export { consoleHelpLines, parseConsoleCommand } from "./commands.js";
@@ -0,0 +1,137 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import {
4
+ BACKEND_VERSION_POLICIES,
5
+ inspectBackendExecutable,
6
+ } from "./backend-executable.js";
7
+
8
+ export const DESKTOP_EXECUTION_TARGET = "desktop";
9
+ export const DESKTOP_EXECUTION_LABEL = "Your Desktop";
10
+
11
+ export function normalizeDesktopExecutionTarget(value) {
12
+ const target = String(value ?? "").trim().toLowerCase();
13
+ if (!target || target === DESKTOP_EXECUTION_TARGET) return DESKTOP_EXECUTION_TARGET;
14
+ const error = new Error(
15
+ `SIMY CLI runs only on ${DESKTOP_EXECUTION_LABEL}; execution_target must be desktop.`,
16
+ );
17
+ error.code = "unsupported_execution_target";
18
+ error.executionTarget = target;
19
+ throw error;
20
+ }
21
+
22
+ export async function inspectDesktopExecutor(backend, options = {}) {
23
+ const provider = normalizeBackend(backend);
24
+ const inspection = await inspectBackendExecutable(provider, options);
25
+ return {
26
+ execution_target: DESKTOP_EXECUTION_TARGET,
27
+ execution_label: DESKTOP_EXECUTION_LABEL,
28
+ provider_label: BACKEND_VERSION_POLICIES[provider].label,
29
+ ...inspection,
30
+ };
31
+ }
32
+
33
+ export function desktopExecutorCapability(inspection) {
34
+ return {
35
+ execution_target: DESKTOP_EXECUTION_TARGET,
36
+ execution_label: DESKTOP_EXECUTION_LABEL,
37
+ backend: inspection.backend,
38
+ provider_label: inspection.provider_label,
39
+ status: inspection.status,
40
+ available: inspection.available,
41
+ compatible: inspection.compatible,
42
+ installed_version: inspection.installed_version,
43
+ minimum_version: inspection.minimum_version,
44
+ update_command: inspection.update_command,
45
+ };
46
+ }
47
+
48
+ export async function resolveDesktopExecutorCommand({
49
+ backend,
50
+ instruction,
51
+ repositoryPath,
52
+ environment = process.env,
53
+ spawnImpl = spawn,
54
+ inspectionOptions = {},
55
+ } = {}) {
56
+ const provider = normalizeBackend(backend);
57
+ const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
58
+ const override =
59
+ provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
60
+ if (override) {
61
+ return {
62
+ ...shellCommand(override, repositoryPath, instruction, spawnImpl),
63
+ backend: provider,
64
+ execution_target: target,
65
+ verification: "explicit_command_override",
66
+ };
67
+ }
68
+
69
+ const inspection = await inspectDesktopExecutor(provider, {
70
+ ...inspectionOptions,
71
+ spawnImpl: inspectionOptions.spawnImpl ?? spawnImpl,
72
+ });
73
+ if (!inspection.compatible || !inspection.executable) {
74
+ throw desktopExecutorUnavailableError(inspection);
75
+ }
76
+ return {
77
+ bin: inspection.executable,
78
+ args:
79
+ provider === "claude"
80
+ ? claudeBackendArgs(instruction)
81
+ : ["exec", "--json", instruction],
82
+ env: {},
83
+ spawn: spawnImpl,
84
+ backend: provider,
85
+ execution_target: target,
86
+ verification: "compatible_version",
87
+ installed_version: inspection.installed_version,
88
+ };
89
+ }
90
+
91
+ export function claudeBackendArgs(instruction) {
92
+ return [
93
+ "-p",
94
+ instruction,
95
+ "--output-format",
96
+ "stream-json",
97
+ "--verbose",
98
+ // The Agentic Loop is scoped to a verified checkout and explicitly approved
99
+ // by the user. Print mode cannot display permission prompts, so edits would
100
+ // otherwise be silently unavailable to the desktop executor.
101
+ "--dangerously-skip-permissions",
102
+ ];
103
+ }
104
+
105
+ function normalizeBackend(backend) {
106
+ if (backend === "codex" || backend === "claude") return backend;
107
+ throw new Error("Desktop executor backend must be codex or claude.");
108
+ }
109
+
110
+ function desktopExecutorUnavailableError(inspection) {
111
+ const provider = inspection.provider_label;
112
+ let message;
113
+ if (inspection.status === "outdated") {
114
+ message = `${provider} ${inspection.installed_version} is too old for ${DESKTOP_EXECUTION_LABEL}. Update to ${inspection.minimum_version} or newer: ${inspection.update_command}`;
115
+ } else if (inspection.status === "unknown_version") {
116
+ message = `${provider} is installed on ${DESKTOP_EXECUTION_LABEL}, but its version could not be verified. Update it and try again: ${inspection.update_command}`;
117
+ } else {
118
+ message = `${provider} is not installed on ${DESKTOP_EXECUTION_LABEL}. Install or update it before starting this run: ${inspection.update_command}`;
119
+ }
120
+ const error = new Error(message);
121
+ error.code = `desktop_executor_${inspection.status}`;
122
+ error.inspection = desktopExecutorCapability(inspection);
123
+ return error;
124
+ }
125
+
126
+ function shellCommand(command, cwd, instruction, spawnImpl) {
127
+ return {
128
+ bin: process.platform === "win32" ? "cmd.exe" : "sh",
129
+ args: process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command],
130
+ cwd,
131
+ env: {
132
+ SIMY_AGENTIC_LOOP_REQUIREMENT: instruction,
133
+ SIMY_CODING_LOOP_REQUIREMENT: instruction,
134
+ },
135
+ spawn: spawnImpl,
136
+ };
137
+ }
package/src/index.js CHANGED
@@ -4,6 +4,13 @@ import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { startAgent } from "./agent.js";
7
+ import {
8
+ autoUpdateHelpText,
9
+ createCliAutoUpdater,
10
+ readCliPackageInfo,
11
+ restartDaemonProcess,
12
+ writeUpdateHandoffFromEnvironment,
13
+ } from "./auto-update.js";
7
14
  import { openAuthorizationUrl } from "./browser.js";
8
15
  import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
9
16
 
@@ -18,10 +25,13 @@ if (args.has("--help") || args.has("-h")) {
18
25
 
19
26
  Options:
20
27
  --daemon, --deamon Run detached in the background
21
- --no-tui Disable the interactive Coding Loop console
28
+ --no-tui Disable the interactive Agentic Loop console
22
29
  --no-open Do not open the browser when authorization is required
23
30
  --port <port> Bind a specific localhost port
24
31
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
32
+ --no-auto-update Disable the hourly CLI update check
33
+
34
+ ${autoUpdateHelpText()}
25
35
  `);
26
36
  process.exit(0);
27
37
  }
@@ -29,6 +39,7 @@ Options:
29
39
  const daemon = args.has("--daemon") || args.has("--deamon");
30
40
  const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
31
41
  const port = readPort(argv);
42
+ const packageInfo = await readCliPackageInfo();
32
43
  let webOrigin;
33
44
  try {
34
45
  webOrigin = resolveWebOrigin(readOption(argv, "--host"));
@@ -48,14 +59,39 @@ if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
48
59
  process.exit(0);
49
60
  }
50
61
 
51
- const agent = await startAgent({ requestedPort: port, daemon, webOrigin, quiet: interactive });
62
+ const updateManager = await createCliAutoUpdater({
63
+ daemon,
64
+ interactive,
65
+ requestedPort: port,
66
+ disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
67
+ dependencies: { packageInfo },
68
+ });
69
+ const agent = await startAgent({
70
+ requestedPort: port,
71
+ daemon,
72
+ webOrigin,
73
+ quiet: interactive,
74
+ updateManager,
75
+ });
76
+ await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
77
+ updateManager.start({
78
+ registry: agent.registry,
79
+ restartDaemon: async (targetVersion) => {
80
+ await restartDaemonProcess({
81
+ entryPath: fileURLToPath(import.meta.url),
82
+ argv: process.argv.slice(2),
83
+ targetVersion,
84
+ });
85
+ await closeServer(agent.server);
86
+ },
87
+ });
52
88
  if (agent.loginUrl && !args.has("--no-open")) {
53
89
  await openAuthorizationUrl(agent.loginUrl);
54
90
  }
55
91
  if (interactive) {
56
- const { runCodingLoopConsole } = await import("./console/index.js");
92
+ const { runAgenticLoopConsole } = await import("./console/index.js");
57
93
  try {
58
- await runCodingLoopConsole(agent);
94
+ await runAgenticLoopConsole(agent);
59
95
  } finally {
60
96
  await closeServer(agent.server);
61
97
  }
@@ -7,7 +7,7 @@ export const MAX_ATTACHMENT_COUNT = 5;
7
7
  export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
8
8
  export const MAX_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024;
9
9
  const RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
10
- const SAFE_RUN_ID = /^coding_loop_[A-Za-z0-9_-]+$/;
10
+ const SAFE_RUN_ID = /^(?:agentic_loop|coding_loop)_[A-Za-z0-9_-]+$/;
11
11
  const ALLOWED_EXACT_MIME_TYPES = new Set([
12
12
  "application/json",
13
13
  "application/octet-stream",
@@ -32,7 +32,7 @@ export function runsRoot(override) {
32
32
  }
33
33
 
34
34
  export async function stageRunAttachments({ runId, attachments, manifest, root }) {
35
- if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid coding loop run_id");
35
+ if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid agentic loop run_id");
36
36
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
37
37
  if (attachments.length > MAX_ATTACHMENT_COUNT) {
38
38
  throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
@@ -1,4 +1,4 @@
1
- import { stat } from "node:fs/promises";
1
+ import { open, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import {
@@ -7,8 +7,9 @@ import {
7
7
  hasAllowedPrefix,
8
8
  stringArray,
9
9
  } from "./shared.js";
10
+ import { evaluateRetryProblemSolving } from "./problem-solving.js";
10
11
 
11
- export async function auditAttempt(charter, attempt) {
12
+ export async function auditAttempt(charter, attempt, { previousAttempt = null } = {}) {
12
13
  const checks = [];
13
14
  const addCheck = (id, label, passed, detail, severity = "blocker", repairability = "auto") => {
14
15
  checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: [] });
@@ -112,6 +113,12 @@ export async function auditAttempt(charter, attempt) {
112
113
  : "No committed local evidence artifacts reported.",
113
114
  );
114
115
 
116
+ const retryProblemSolving = evaluateRetryProblemSolving(attempt, previousAttempt);
117
+ for (const check of retryProblemSolving.checks) {
118
+ addCheck(check.id, check.label, check.passed, check.detail);
119
+ }
120
+ attempt.retry_problem_solving = retryProblemSolving;
121
+
115
122
  const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
116
123
  const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
117
124
  if (evidenceRequired || attempt.ui_evidence_path) {
@@ -126,6 +133,16 @@ export async function auditAttempt(charter, attempt) {
126
133
  evidenceArtifact?.summary || "Local evidence path missing.",
127
134
  );
128
135
  }
136
+ if (charter.evidence_policy?.browser_required) {
137
+ addCheck(
138
+ "browser_visual_evidence_present",
139
+ "Browser screenshot or video evidence is present",
140
+ Boolean(evidenceArtifact?.visual_file_count > 0),
141
+ evidenceArtifact?.visual_file_count > 0
142
+ ? `${evidenceArtifact.visual_file_count} screenshot or video artifact(s) found.`
143
+ : "UI and browser-extension work requires a real screenshot or video; a path, text file, or empty directory is not sufficient.",
144
+ );
145
+ }
129
146
 
130
147
  const findings = checks
131
148
  .filter((check) => !check.passed)
@@ -301,17 +318,94 @@ async function inspectEvidence(charter, evidencePath) {
301
318
  const underRoot = Boolean(
302
319
  root && (resolved === root || resolved.startsWith(`${root}${path.sep}`)),
303
320
  );
321
+ const directoryEvidence = fileStat?.isDirectory()
322
+ ? await scanEvidenceDirectory(resolved)
323
+ : null;
324
+ const directKind = fileStat?.isFile() ? evidenceKind(resolved) : "unknown";
325
+ const visualFileCount = fileStat?.isFile()
326
+ ? (await isVisualEvidenceFile(resolved))
327
+ ? 1
328
+ : 0
329
+ : directoryEvidence?.visualFileCount ?? 0;
330
+ const sizeBytes = fileStat?.isDirectory()
331
+ ? directoryEvidence?.sizeBytes ?? 0
332
+ : fileStat?.size ?? 0;
304
333
  return {
305
334
  path: resolved,
306
- kind: fileStat?.isDirectory() ? "directory" : evidenceKind(resolved),
335
+ kind: fileStat?.isDirectory() ? "directory" : directKind,
307
336
  exists: Boolean(fileStat),
308
337
  under_required_root: underRoot,
309
- size_bytes: fileStat?.size ?? 0,
310
- file_count: fileStat?.isFile() ? 1 : 0,
338
+ size_bytes: sizeBytes,
339
+ file_count: fileStat?.isFile() ? 1 : directoryEvidence?.fileCount ?? 0,
340
+ visual_file_count: visualFileCount,
311
341
  git_tracked: false,
312
342
  summary:
313
- fileStat && underRoot && fileStat.size > 0
314
- ? "Evidence exists under the required local root."
343
+ fileStat && underRoot && sizeBytes > 0
344
+ ? `Evidence exists under the required local root; ${visualFileCount} visual artifact(s) found.`
315
345
  : "Evidence is missing, empty, or outside the required local root.",
316
346
  };
317
347
  }
348
+
349
+ async function scanEvidenceDirectory(directory) {
350
+ const pending = [directory];
351
+ let fileCount = 0;
352
+ let visualFileCount = 0;
353
+ let sizeBytes = 0;
354
+ while (pending.length > 0 && fileCount < 500) {
355
+ const current = pending.shift();
356
+ if (!current) break;
357
+ let entries;
358
+ try {
359
+ entries = await readdir(current, { withFileTypes: true });
360
+ } catch {
361
+ continue;
362
+ }
363
+ for (const entry of entries) {
364
+ if (entry.isSymbolicLink()) continue;
365
+ const candidate = path.join(current, entry.name);
366
+ if (entry.isDirectory()) {
367
+ pending.push(candidate);
368
+ continue;
369
+ }
370
+ if (!entry.isFile()) continue;
371
+ fileCount += 1;
372
+ if (await isVisualEvidenceFile(candidate)) visualFileCount += 1;
373
+ try {
374
+ sizeBytes += (await stat(candidate)).size;
375
+ } catch {
376
+ // A file disappearing during inspection is simply not counted toward its size.
377
+ }
378
+ if (fileCount >= 500) break;
379
+ }
380
+ }
381
+ return { fileCount, visualFileCount, sizeBytes };
382
+ }
383
+
384
+ async function isVisualEvidenceFile(filePath) {
385
+ if (!["image", "video"].includes(evidenceKind(filePath))) return false;
386
+ const header = Buffer.alloc(16);
387
+ let handle;
388
+ try {
389
+ handle = await open(filePath, "r");
390
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
391
+ if (bytesRead === 0) return false;
392
+ } catch {
393
+ return false;
394
+ } finally {
395
+ try {
396
+ await handle?.close();
397
+ } catch {
398
+ // The evidence file may disappear while it is being inspected.
399
+ }
400
+ }
401
+ const ascii = header.toString("ascii");
402
+ return (
403
+ header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
404
+ header.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
405
+ ascii.startsWith("GIF87a") ||
406
+ ascii.startsWith("GIF89a") ||
407
+ (ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
408
+ ascii.slice(4, 8) === "ftyp" ||
409
+ header.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))
410
+ );
411
+ }