sneakoscope 3.0.4 → 3.1.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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +1 -1
  5. package/dist/.sks-build-stamp.json +4 -4
  6. package/dist/bin/sks.js +1 -1
  7. package/dist/cli/command-registry.js +1 -0
  8. package/dist/cli/context7-command.js +29 -5
  9. package/dist/cli/install-helpers.js +15 -7
  10. package/dist/commands/zellij-slot-column-anchor.js +3 -1
  11. package/dist/commands/zellij-slot-pane.js +19 -2
  12. package/dist/core/agents/agent-janitor.js +10 -1
  13. package/dist/core/agents/agent-orchestrator.js +1 -0
  14. package/dist/core/agents/agent-runner-ollama.js +11 -4
  15. package/dist/core/agents/native-cli-session-swarm.js +69 -9
  16. package/dist/core/agents/runtime-proof-summary.js +4 -0
  17. package/dist/core/codex-control/codex-task-runner.js +9 -0
  18. package/dist/core/commands/goal-command.js +19 -1
  19. package/dist/core/commands/loop-command.js +176 -0
  20. package/dist/core/commands/naruto-command.js +26 -17
  21. package/dist/core/commands/team-command.js +1 -0
  22. package/dist/core/fsx.js +1 -1
  23. package/dist/core/init.js +6 -1
  24. package/dist/core/locks/file-lock.js +88 -0
  25. package/dist/core/loops/goal-to-loop-compat.js +23 -0
  26. package/dist/core/loops/loop-artifacts.js +72 -0
  27. package/dist/core/loops/loop-checkpoint.js +22 -0
  28. package/dist/core/loops/loop-decomposer.js +56 -0
  29. package/dist/core/loops/loop-finalizer.js +54 -0
  30. package/dist/core/loops/loop-gate-ladder.js +16 -0
  31. package/dist/core/loops/loop-gate-registry.js +96 -0
  32. package/dist/core/loops/loop-gate-runner.js +177 -0
  33. package/dist/core/loops/loop-gate-selector.js +52 -0
  34. package/dist/core/loops/loop-gpt-final-arbiter.js +61 -0
  35. package/dist/core/loops/loop-integration-merge.js +75 -0
  36. package/dist/core/loops/loop-iteration-runner.js +2 -0
  37. package/dist/core/loops/loop-lease.js +91 -0
  38. package/dist/core/loops/loop-observability.js +19 -0
  39. package/dist/core/loops/loop-owner-inference.js +57 -0
  40. package/dist/core/loops/loop-owner-ledger.js +2 -0
  41. package/dist/core/loops/loop-planner.js +170 -0
  42. package/dist/core/loops/loop-proof-summary.js +10 -0
  43. package/dist/core/loops/loop-proof.js +2 -0
  44. package/dist/core/loops/loop-risk-classifier.js +42 -0
  45. package/dist/core/loops/loop-runtime-control.js +25 -0
  46. package/dist/core/loops/loop-runtime.js +314 -0
  47. package/dist/core/loops/loop-scheduler.js +69 -0
  48. package/dist/core/loops/loop-schema.js +63 -0
  49. package/dist/core/loops/loop-state.js +61 -0
  50. package/dist/core/loops/loop-worker-prompts.js +43 -0
  51. package/dist/core/loops/loop-worker-runtime.js +275 -0
  52. package/dist/core/loops/loop-worktree-runtime.js +92 -0
  53. package/dist/core/naruto/naruto-finalizer.js +7 -2
  54. package/dist/core/naruto/naruto-loop-mesh.js +39 -0
  55. package/dist/core/naruto/naruto-loop-worker-router.js +38 -0
  56. package/dist/core/pipeline-internals/runtime-core.js +82 -2
  57. package/dist/core/proof/proof-schema.js +6 -0
  58. package/dist/core/proof/proof-writer.js +5 -2
  59. package/dist/core/proof/root-cause-policy.js +70 -0
  60. package/dist/core/proof/route-adapter.js +18 -1
  61. package/dist/core/proof/route-proof-gate.js +4 -0
  62. package/dist/core/release/release-gate-batch-runner.js +56 -10
  63. package/dist/core/release/release-gate-cache-v2.js +18 -3
  64. package/dist/core/release/release-gate-dag.js +65 -17
  65. package/dist/core/release/release-gate-node.js +2 -1
  66. package/dist/core/release/release-gate-resource-governor.js +27 -6
  67. package/dist/core/skills/core-skill-meta-update.js +24 -0
  68. package/dist/core/skills/core-skill-reflection.js +94 -0
  69. package/dist/core/skills/core-skill-trainer.js +103 -0
  70. package/dist/core/trust-kernel/completion-contract.js +4 -0
  71. package/dist/core/trust-kernel/route-contract.js +4 -1
  72. package/dist/core/version.js +1 -1
  73. package/dist/core/zellij/zellij-right-column-manager.js +13 -2
  74. package/dist/core/zellij/zellij-slot-column-anchor.js +45 -5
  75. package/dist/core/zellij/zellij-slot-pane-renderer.js +37 -10
  76. package/dist/core/zellij/zellij-slot-telemetry.js +96 -44
  77. package/dist/core/zellij/zellij-worker-pane-manager.js +42 -4
  78. package/dist/scripts/loop-directive-check-lib.js +388 -0
  79. package/dist/scripts/loop-worker-fixture-child.js +53 -0
  80. package/dist/scripts/naruto-real-local-gpt-final-smoke.js +10 -1
  81. package/package.json +38 -3
  82. package/schemas/loops/loop-node.schema.json +21 -0
  83. package/schemas/loops/loop-plan.schema.json +21 -0
  84. package/schemas/loops/loop-proof.schema.json +20 -0
  85. package/schemas/loops/loop-state.schema.json +19 -0
package/README.md CHANGED
@@ -35,7 +35,7 @@ Set up this agent project with Sneakoscope Codex. Use [[mandarange/Sneakoscope-C
35
35
 
36
36
  ## 🚀 Current Release
37
37
 
38
- SKS **3.0.4** is Codex 0.139-aware while it bundles @openai/codex-sdk 0.138.0 at this release boundary. It detects and uses 0.139 features from the external Codex CLI when that CLI supports them, with release gates that include hermetic fixtures and actual real probes for code-mode web search, preserved `oneOf`/`allOf` tool schemas, doctor env redaction, plugin marketplace `source` and cache behavior, the `-P` profile alias, the multi-agent v2 `interrupt_agent` rename, image referenced-path routing, sandbox/proxy preservation, Zellij stacked/fallback pane proof, pane-lock openWorkerPane integration, release cache safety fixtures, runtime proof summaries, and release proof source-truth artifacts. See [docs/codex-0.139-compat.md](docs/codex-0.139-compat.md) and [docs/codex-0.139-real-probes.md](docs/codex-0.139-real-probes.md).
38
+ SKS **3.1.1** is Codex 0.139-aware while it bundles @openai/codex-sdk 0.138.0 at this release boundary. It detects and uses 0.139 features from the external Codex CLI when that CLI supports them, with release gates that include hermetic fixtures and actual real probes for code-mode web search, preserved `oneOf`/`allOf` tool schemas, doctor env redaction, plugin marketplace `source` and cache behavior, the `-P` profile alias, the multi-agent v2 `interrupt_agent` rename, image referenced-path routing, sandbox/proxy preservation, Zellij stacked/fallback pane proof, pane-lock openWorkerPane integration, release cache safety fixtures, runtime proof summaries, and release proof source-truth artifacts. See [docs/codex-0.139-compat.md](docs/codex-0.139-compat.md) and [docs/codex-0.139-real-probes.md](docs/codex-0.139-real-probes.md).
39
39
 
40
40
  SKS 3.0.0 was the parallel-runtime stabilization release. The whole live-swarm experience — what you actually *see* while 5, 20, or 100 workers run — was rebuilt and proven end-to-end.
41
41
 
@@ -76,7 +76,7 @@ dependencies = [
76
76
 
77
77
  [[package]]
78
78
  name = "sks-core"
79
- version = "3.0.4"
79
+ version = "3.1.1"
80
80
  dependencies = [
81
81
  "serde_json",
82
82
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "sks-core"
3
- version = "3.0.4"
3
+ version = "3.1.1"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -4,7 +4,7 @@ use std::io::{self, Read, Seek, SeekFrom};
4
4
  fn main() {
5
5
  let mut args = std::env::args().skip(1);
6
6
  match args.next().as_deref() {
7
- Some("--version") => println!("sks-rs 3.0.4"),
7
+ Some("--version") => println!("sks-rs 3.1.1"),
8
8
  Some("compact-info") => {
9
9
  let mut input = String::new();
10
10
  let _ = io::stdin().read_to_string(&mut input);
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schema": "sks.dist-build-stamp.v1",
3
3
  "package_name": "sneakoscope",
4
- "package_version": "3.0.4",
5
- "source_digest": "fbc7541cf9a5f0432c9ceb28c4478eca651010d2304fc499bbe27f2f62b41d4b",
6
- "source_file_count": 2328,
7
- "built_at_source_time": 1781240948838
4
+ "package_version": "3.1.1",
5
+ "source_digest": "ea95ac89533e71474a3f68a4dbf879d966eb64159ca24da208d6bb066fb7c573",
6
+ "source_file_count": 2410,
7
+ "built_at_source_time": 1781345242524
8
8
  }
package/dist/bin/sks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const FAST_PACKAGE_VERSION = '3.0.4';
2
+ const FAST_PACKAGE_VERSION = '3.1.1';
3
3
  const args = process.argv.slice(2);
4
4
  try {
5
5
  if (args[0] === '--agent' && args[1] === 'worker') {
@@ -123,6 +123,7 @@ export const COMMANDS = {
123
123
  agent: entry('beta', 'Run native multi-session agent missions', 'dist/core/commands/agent-command.js', argsCommand(() => import('../core/commands/agent-command.js'), 'agentCommand', 'dist/core/commands/agent-command.js')),
124
124
  'with-local-llm': entry('beta', 'Enable or inspect local Ollama worker backend', 'dist/core/commands/local-model-command.js', argsCommand(() => import('../core/commands/local-model-command.js'), 'localModelCommand', 'dist/core/commands/local-model-command.js')),
125
125
  naruto: entry('labs', 'Run $Naruto shadow-clone swarm (up to 100 parallel sessions)', 'dist/core/commands/naruto-command.js', argsCommand(() => import('../core/commands/naruto-command.js'), 'narutoCommand', 'dist/core/commands/naruto-command.js')),
126
+ loop: entry('labs', 'Dynamic Loop Runtime: plan/run/status/proof loop graphs.', 'dist/core/commands/loop-command.js', subcommand(() => import('../core/commands/loop-command.js'), 'loopCommand', 'dist/core/commands/loop-command.js', 'help')),
126
127
  'qa-loop': entry('beta', 'Run QA loop missions', 'dist/core/commands/qa-loop-command.js', subcommand(() => import('../core/commands/qa-loop-command.js'), 'qaLoopCommand', 'dist/core/commands/qa-loop-command.js')),
127
128
  research: entry('labs', 'Run research missions', 'dist/core/commands/research-command.js', subcommand(() => import('../core/commands/research-command.js'), 'researchCommand', 'dist/core/commands/research-command.js')),
128
129
  autoresearch: entry('labs', 'Alias for research/autoresearch route', 'dist/core/commands/autoresearch-command.js', subcommand(() => import('../core/commands/autoresearch-command.js'), 'autoresearchCommand', 'dist/core/commands/autoresearch-command.js', 'status')),
@@ -3,7 +3,7 @@ import { getCodexInfo } from '../core/codex-adapter.js';
3
3
  import { context7Docs, context7Resolve, context7Text, context7Tools } from '../core/context7-client.js';
4
4
  import { context7Evidence, recordContext7Evidence } from '../core/pipeline.js';
5
5
  import { stateFile } from '../core/mission.js';
6
- import { checkContext7, ensureProjectContext7Config } from './install-helpers.js';
6
+ import { checkContext7, context7GlobalMcpStatus, ensureProjectContext7Config } from './install-helpers.js';
7
7
  const flag = (args, name) => args.includes(name);
8
8
  export async function context7Command(sub = 'check', args = []) {
9
9
  const action = sub || 'check';
@@ -92,9 +92,24 @@ export async function context7Command(sub = 'check', args = []) {
92
92
  timeoutMs: readNumberOption(args, '--timeout-ms', 30000)
93
93
  });
94
94
  const state = { ...(await readJson(stateFile(root), {})), mission_id: missionId };
95
- await recordContext7Evidence(root, state, { tool_name: 'resolve-library-id', library: libraryNameOrId, library_id: result.library_id, source: result.resolve ? 'sks context7 evidence' : 'sks context7 evidence explicit-library-id' });
95
+ const evidenceQuery = readOption(args, '--query', readOption(args, '--topic', libraryNameOrId));
96
+ const evidenceTopic = readOption(args, '--topic', libraryNameOrId);
97
+ await recordContext7Evidence(root, state, {
98
+ tool_name: 'resolve-library-id',
99
+ library: libraryNameOrId,
100
+ library_id: result.library_id,
101
+ query: evidenceQuery,
102
+ source: result.resolve ? 'sks context7 evidence' : 'sks context7 evidence explicit-library-id'
103
+ });
96
104
  if (result.docs_tool) {
97
- await recordContext7Evidence(root, state, { tool_name: result.docs_tool, library_id: result.library_id, source: 'sks context7 evidence' });
105
+ await recordContext7Evidence(root, state, {
106
+ tool_name: result.docs_tool,
107
+ library_id: result.library_id,
108
+ query: evidenceQuery,
109
+ topic: evidenceTopic,
110
+ tokens: readNumberOption(args, '--tokens', 2000),
111
+ source: 'sks context7 evidence'
112
+ });
98
113
  }
99
114
  const evidence = await context7Evidence(root, state);
100
115
  const out = { ...result, mission_id: missionId, evidence };
@@ -126,12 +141,21 @@ export async function context7Command(sub = 'check', args = []) {
126
141
  const codex = await getCodexInfo();
127
142
  if (!codex.bin)
128
143
  throw new Error('Codex CLI missing. Install separately: npm i -g @openai/codex, or set SKS_CODEX_BIN.');
144
+ const env = { ...process.env };
145
+ env.CODEX_LB_API_KEY = '';
146
+ const existing = await context7GlobalMcpStatus(codex.bin, env);
147
+ if (existing.present) {
148
+ if (flag(args, '--json'))
149
+ return console.log(JSON.stringify({ changed: false, status: 'present', codex_mcp_list: existing }, null, 2));
150
+ console.log('Context7 global MCP already configured; existing entry preserved.');
151
+ return;
152
+ }
129
153
  const cmdArgs = transport === 'remote'
130
154
  ? ['mcp', 'add', 'context7', '--url', 'https://mcp.context7.com/mcp']
131
155
  : ['mcp', 'add', 'context7', '--', 'npx', '-y', '@upstash/context7-mcp@latest'];
132
- const result = await runProcess(codex.bin, cmdArgs, { timeoutMs: 30000, maxOutputBytes: 64 * 1024 });
156
+ const result = await runProcess(codex.bin, cmdArgs, { env, timeoutMs: 30000, maxOutputBytes: 64 * 1024 });
133
157
  if (flag(args, '--json'))
134
- return console.log(JSON.stringify({ command: `${codex.bin} ${cmdArgs.join(' ')}`, result }, null, 2));
158
+ return console.log(JSON.stringify({ changed: result.code === 0, status: result.code === 0 ? 'installed' : 'failed', command: `${codex.bin} ${cmdArgs.join(' ')}`, result }, null, 2));
135
159
  if (result.code !== 0)
136
160
  throw new Error(result.stderr || result.stdout || 'codex mcp add failed');
137
161
  console.log('Context7 global MCP configured.');
@@ -2019,14 +2019,26 @@ async function ensureGlobalContext7DuringInstall() {
2019
2019
  if (!codex.bin)
2020
2020
  return { status: 'codex_missing' };
2021
2021
  const env = withoutSecretEnv(['CODEX_LB_API_KEY']);
2022
- const list = await runProcess(codex.bin, ['mcp', 'list'], { env, timeoutMs: 8000, maxOutputBytes: 32 * 1024 }).catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
2023
- if (list.code === 0 && /context7/i.test(`${list.stdout}\n${list.stderr}`))
2022
+ const existing = await context7GlobalMcpStatus(codex.bin, env);
2023
+ if (existing.present)
2024
2024
  return { status: 'present' };
2025
2025
  const add = await runProcess(codex.bin, ['mcp', 'add', 'context7', '--', 'npx', '-y', '@upstash/context7-mcp@latest'], { env, timeoutMs: 30000, maxOutputBytes: 64 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
2026
2026
  if (add.code === 0)
2027
2027
  return { status: 'installed' };
2028
2028
  return { status: 'failed', error: `${add.stderr || add.stdout || 'codex mcp add failed'}`.trim() };
2029
2029
  }
2030
+ export async function context7GlobalMcpStatus(codexBin, env = process.env) {
2031
+ const list = await runProcess(codexBin, ['mcp', 'list'], { env, timeoutMs: 8000, maxOutputBytes: 32 * 1024 })
2032
+ .catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
2033
+ const output = `${list.stdout || ''}\n${list.stderr || ''}`;
2034
+ return {
2035
+ checked: true,
2036
+ ok: list.code === 0,
2037
+ present: list.code === 0 && /context7/i.test(output),
2038
+ stdout: list.stdout || '',
2039
+ stderr: list.stderr || ''
2040
+ };
2041
+ }
2030
2042
  function withoutSecretEnv(keys = []) {
2031
2043
  const env = { ...process.env };
2032
2044
  for (const key of keys)
@@ -2348,11 +2360,7 @@ export async function ensureProjectContext7Config(root, transport = 'local') {
2348
2360
  const block = context7ConfigToml(transport).trim();
2349
2361
  const existingBlock = /(^|\n)\[mcp_servers\.context7\]\n[\s\S]*?(?=\n\[[^\]]+\]|\s*$)/;
2350
2362
  if (existingBlock.test(current)) {
2351
- const next = current.replace(existingBlock, `$1${block}\n`);
2352
- if (next === current)
2353
- return false;
2354
- await writeTextAtomic(configPath, next.endsWith('\n') ? next : `${next}\n`);
2355
- return true;
2363
+ return false;
2356
2364
  }
2357
2365
  if (hasContext7ConfigText(current))
2358
2366
  return false;
@@ -7,7 +7,9 @@ export async function run(_command = 'zellij-slot-column-anchor', args = []) {
7
7
  const intervalMs = Math.max(250, Number(readOption(args, '--interval-ms', '1000') || 1000));
8
8
  for (;;) {
9
9
  const text = await renderZellijSlotColumnAnchorFromArtifacts({ artifactRoot, missionId, mode });
10
- process.stdout.write('\x1Bc' + text + '\n');
10
+ // Cursor-home + clear-to-end redraw; `\x1Bc` (RIS) resets the pane's
11
+ // scrollback/modes every tick and intermittently breaks scrolling.
12
+ process.stdout.write('\x1b[H' + text + '\n\x1b[0J');
11
13
  if (!watch)
12
14
  break;
13
15
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -1,4 +1,4 @@
1
- import { renderZellijSlotPaneFromArtifacts, renderZellijSlotPaneStatusFromArtifacts } from '../core/zellij/zellij-slot-pane-renderer.js';
1
+ import { renderZellijSlotPaneFromArtifacts, renderZellijSlotPaneStatusFromArtifacts, resolveZellijSlotPaneExit } from '../core/zellij/zellij-slot-pane-renderer.js';
2
2
  export async function run(_command = 'zellij-slot-pane', args = []) {
3
3
  const artifactDir = readOption(args, '--artifact-dir', process.cwd()) || process.cwd();
4
4
  const artifactRoot = readOption(args, '--artifact-root', artifactDir) || artifactDir;
@@ -18,9 +18,19 @@ export async function run(_command = 'zellij-slot-pane', args = []) {
18
18
  }
19
19
  for (;;) {
20
20
  const text = await renderZellijSlotPaneFromArtifacts({ artifactDir, artifactRoot, missionId, slotId, generationIndex, backend, role, mode });
21
- process.stdout.write('\x1Bc' + text + '\n');
21
+ process.stdout.write(redrawFrame(text));
22
22
  if (!watch)
23
23
  break;
24
+ // Root-cause-3 fix: exit the pane once the worker has reached a terminal state and written its
25
+ // result, so the pane closes (or shows the final exited frame) instead of looping forever and
26
+ // perpetually re-reporting telemetry staleness.
27
+ const shouldExit = await resolveZellijSlotPaneExit({ artifactDir, artifactRoot, missionId, slotId, generationIndex }).catch(() => false);
28
+ if (shouldExit) {
29
+ await new Promise((resolve) => setTimeout(resolve, 5000));
30
+ const finalText = await renderZellijSlotPaneFromArtifacts({ artifactDir, artifactRoot, missionId, slotId, generationIndex, backend, role, mode });
31
+ process.stdout.write(redrawFrame(finalText));
32
+ return;
33
+ }
24
34
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
25
35
  }
26
36
  }
@@ -28,6 +38,13 @@ function readOption(args, name, fallback) {
28
38
  const index = args.indexOf(name);
29
39
  return index >= 0 && args[index + 1] ? String(args[index + 1]) : fallback;
30
40
  }
41
+ // Redraw in place with cursor-home + clear-to-end instead of `\x1Bc` (RIS).
42
+ // RIS performs a full terminal reset every tick, which wipes the Zellij pane's
43
+ // scrollback and resets scroll position/modes — the cause of intermittent
44
+ // "scrolling stops working" while a watch pane is refreshing.
45
+ function redrawFrame(text) {
46
+ return '\x1b[H' + text + '\n\x1b[0J';
47
+ }
31
48
  function hasFlag(args, flag) {
32
49
  return args.includes(flag);
33
50
  }
@@ -164,7 +164,16 @@ async function listFiles(dir) {
164
164
  const out = [];
165
165
  if (!(await exists(dir)))
166
166
  return out;
167
- for (const entry of await fsp.readdir(dir, { withFileTypes: true })) {
167
+ let entries;
168
+ try {
169
+ entries = await fsp.readdir(dir, { withFileTypes: true });
170
+ }
171
+ catch (error) {
172
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR')
173
+ return out;
174
+ throw error;
175
+ }
176
+ for (const entry of entries) {
168
177
  const file = path.join(dir, entry.name);
169
178
  if (entry.isDirectory())
170
179
  out.push(...await listFiles(file));
@@ -477,6 +477,7 @@ export async function runNativeAgentOrchestrator(opts = {}) {
477
477
  visiblePanes: visualLaneCount,
478
478
  expectedWorkerRuntimeMs: targetActiveSlots >= 10 ? 8000 : targetActiveSlots >= 2 ? 2000 : 25,
479
479
  minActiveWorkers: Math.min(targetActiveSlots, desiredWorkItemCount),
480
+ ...(backend === 'codex-sdk' && opts.real === true ? { minSpeedupRatio: 3 } : {}),
480
481
  proofMode: opts.mock === true ? 'mock-process' : 'production',
481
482
  requireWorkerPids: opts.nativeCliSwarm !== false && targetActiveSlots >= 16
482
483
  });
@@ -132,19 +132,25 @@ export function classifyOllamaWorkerSlice(slice, input = {}) {
132
132
  input.agent?.persona_id,
133
133
  slice?.role,
134
134
  slice?.domain,
135
+ slice?.kind,
135
136
  slice?.title,
136
137
  slice?.description,
137
138
  ...(Array.isArray(slice?.target_paths) ? slice.target_paths : [])
138
139
  ].map((value) => String(value || '')).join('\n');
139
- const bannedRole = /(?:^|\b)(architect|verifier|safety|integrator|schema|release|ux|db)(?:\b|$)/i.test(String(input.agent?.role || slice?.role || ''));
140
+ const bannedRole = /(?:^|\b)(architect|verifier|checker|reviewer|researcher|safety|integrator|schema|release|ux|db)(?:\b|$)/i.test(String(input.agent?.role || slice?.role || ''));
140
141
  const collection = /\b(collect|gather|extract|inventory|list|scan|grep|tail|summarize|catalog)\b|수집|추출|목록|스캔|인벤토리/i.test(text);
141
142
  const coding = /\b(code|implement|patch|write|edit|fix|mechanical|simple)\b|코드|작성|수정|구현|패치|단순/i.test(text);
142
- const banned = /\b(strategy|strategize|planning|plan|architecture|architect|design|review|verify|verification|safety|risk|consensus|debate|orchestrate|policy|decide|decision|migration|database|schema)\b|전략|기획|설계|디자인|검증|리뷰|안전|위험|합의|토론|결정|마이그레이션|데이터베이스/i.test(text);
143
- const allowed = !bannedRole && !banned && (writePaths.length > 0 || collection || coding);
143
+ const banned = /\b(strategy|strategize|planning|plan|architecture|architect|design|review|verify|verification|audit|inspect|safety|risk|consensus|debate|orchestrate|policy|decide|decision|migration|database|schema)\b|전략|기획|설계|디자인|검증|검수|리뷰|감사|안전|위험|합의|토론|결정|마이그레이션|데이터베이스/i.test(text);
144
+ // Web research / external lookup must run on GPT, never on the local model:
145
+ // local LLMs hallucinate sources and cannot browse. This wins even over the
146
+ // collection allowlist (e.g. "web research and collect docs" stays on GPT).
147
+ const research = /\b(web|research|browse|browser|crawl|fetch docs|websearch|web search|search the web|investigate|context7)\b|웹|리서치|조사|웹서치|웹 ?검색|검색/i.test(text);
148
+ const allowed = !bannedRole && !banned && !research && (writePaths.length > 0 || collection || coding);
144
149
  const blockers = [
145
150
  ...(allowed ? [] : ['ollama_worker_task_not_simple_code_or_collection']),
146
151
  ...(bannedRole ? ['ollama_worker_role_blocked'] : []),
147
- ...(banned ? ['ollama_worker_strategy_planning_design_blocked'] : [])
152
+ ...(banned ? ['ollama_worker_strategy_planning_design_blocked'] : []),
153
+ ...(research ? ['ollama_worker_web_research_blocked'] : [])
148
154
  ];
149
155
  return {
150
156
  schema: OLLAMA_WORKER_POLICY_SCHEMA,
@@ -156,6 +162,7 @@ export function classifyOllamaWorkerSlice(slice, input = {}) {
156
162
  write_path_count: writePaths.length,
157
163
  collection_detected: collection,
158
164
  coding_detected: coding,
165
+ research_detected: research,
159
166
  blockers
160
167
  };
161
168
  }
@@ -379,6 +379,7 @@ class NativeCliSessionSwarmRecorder {
379
379
  serviceTier: this.input.fastModePolicy.service_tier,
380
380
  worktree: worktree ? { id: worktree.id, path: worktree.path, branch: worktree.branch } : null,
381
381
  backend: this.input.backend,
382
+ taskTitle: String(input.ctx.slice?.title || input.ctx.slice?.description || input.ctx.slice?.id || '') || null,
382
383
  uiMode,
383
384
  projectRoot: input.ctx.opts.projectRoot || this.input.projectRoot || input.ctx.opts.cwd,
384
385
  rightColumnMode: 'spawn-on-first-worker',
@@ -484,7 +485,23 @@ class NativeCliSessionSwarmRecorder {
484
485
  session_id: input.ctx.agent.session_id,
485
486
  worker_artifact_dir: input.workerDirRel
486
487
  });
487
- const parsed = await waitForWorkerResult(path.join(this.root, input.resultRel), Number(process.env.SKS_ZELLIJ_WORKER_RESULT_TIMEOUT_MS || 120000));
488
+ const parsed = await this.waitForWorkerResultWithActivity({
489
+ resultPath: path.join(this.root, input.resultRel),
490
+ activityPaths: [
491
+ path.join(this.root, input.heartbeatRel),
492
+ path.join(this.root, input.stdoutRel),
493
+ path.join(this.root, input.stderrRel),
494
+ path.join(this.root, input.workerDirRel, 'codex-sdk-events.jsonl'),
495
+ path.join(this.root, input.workerDirRel, 'python-codex-sdk-events.jsonl'),
496
+ path.join(this.root, input.workerDirRel, 'local-llm-events.jsonl')
497
+ ],
498
+ stdoutPath: path.join(this.root, input.stdoutRel),
499
+ ctx: input.ctx,
500
+ heartbeatRel: input.heartbeatRel,
501
+ resultRel: input.resultRel,
502
+ stdoutRel: input.stdoutRel,
503
+ stderrRel: input.stderrRel
504
+ });
488
505
  const compactExit = processRun ? await processRun.wait(parsed ? 10000 : 1000) : null;
489
506
  this.active.delete(activeToken);
490
507
  input.record.closed_at = nowIso();
@@ -593,6 +610,45 @@ class NativeCliSessionSwarmRecorder {
593
610
  artifacts: [...new Set([...(Array.isArray(parsed.artifacts) ? parsed.artifacts : []), input.stdoutRel, input.stderrRel, path.join(input.workerDirRel, 'zellij-worker-pane.json')])]
594
611
  });
595
612
  }
613
+ // Root-cause-2 fix: a fixed 2-minute wall-clock result timeout killed live codex-sdk
614
+ // workers (real runs exceed 2 min), marking false zellij_worker_result_timeout failures and
615
+ // freezing the UI while the worker kept running. Replace it with an activity-aware wait: keep
616
+ // waiting as long as ANY worker artifact (heartbeat, stdout/stderr, sdk event jsonl) was touched
617
+ // recently. Only give up after SKS_ZELLIJ_WORKER_INACTIVITY_TIMEOUT_MS of silence (default 5min)
618
+ // OR an absolute cap SKS_ZELLIJ_WORKER_RESULT_TIMEOUT_MS (default 1h; 0 = no cap). While waiting,
619
+ // emit a heartbeat telemetry event every ~10s so the SLOTS snapshot updated_at stays fresh from
620
+ // the orchestrator side too.
621
+ async waitForWorkerResultWithActivity(input) {
622
+ const inactivityTimeoutMs = Math.max(1000, Number(process.env.SKS_ZELLIJ_WORKER_INACTIVITY_TIMEOUT_MS || 300000));
623
+ const absoluteCapRaw = Number(process.env.SKS_ZELLIJ_WORKER_RESULT_TIMEOUT_MS ?? 3600000);
624
+ const absoluteCapMs = Number.isFinite(absoluteCapRaw) ? absoluteCapRaw : 3600000;
625
+ const start = Date.now();
626
+ let lastActivityMs = start;
627
+ let lastHeartbeatEmit = 0;
628
+ for (;;) {
629
+ const result = await readJson(input.resultPath, null).catch(() => null);
630
+ if (result)
631
+ return result;
632
+ const now = Date.now();
633
+ const newestActivity = await newestMtimeMs(input.activityPaths);
634
+ if (newestActivity != null && newestActivity > lastActivityMs)
635
+ lastActivityMs = newestActivity;
636
+ if (absoluteCapMs > 0 && now - start >= absoluteCapMs)
637
+ return null;
638
+ if (now - lastActivityMs >= inactivityTimeoutMs)
639
+ return null;
640
+ if (now - lastHeartbeatEmit >= 10000) {
641
+ lastHeartbeatEmit = now;
642
+ await this.telemetry(input.ctx, {
643
+ eventType: 'heartbeat',
644
+ status: 'running',
645
+ artifacts: [input.heartbeatRel, input.resultRel, input.stdoutRel, input.stderrRel],
646
+ logTail: await tailFile(input.stdoutPath, 600)
647
+ });
648
+ }
649
+ await new Promise((resolve) => setTimeout(resolve, 250));
650
+ }
651
+ }
596
652
  async finalize() {
597
653
  await this.persist();
598
654
  return this.summary();
@@ -777,15 +833,19 @@ function buildPaneWorkerHeader(input) {
777
833
  'status: running'
778
834
  ].join('\n');
779
835
  }
780
- async function waitForWorkerResult(file, timeoutMs) {
781
- const deadline = Date.now() + Math.max(1000, timeoutMs);
782
- while (Date.now() < deadline) {
783
- const result = await readJson(file, null).catch(() => null);
784
- if (result)
785
- return result;
786
- await new Promise((resolve) => setTimeout(resolve, 250));
836
+ async function newestMtimeMs(files) {
837
+ let newest = null;
838
+ for (const file of files) {
839
+ try {
840
+ const mtime = (await fs.promises.stat(file)).mtimeMs;
841
+ if (newest == null || mtime > newest)
842
+ newest = mtime;
843
+ }
844
+ catch {
845
+ // missing file: no activity signal from it
846
+ }
787
847
  }
788
- return null;
848
+ return newest;
789
849
  }
790
850
  async function waitForWorkerHeartbeat(file, timeoutMs) {
791
851
  const deadline = Date.now() + Math.max(1000, timeoutMs);
@@ -3,6 +3,7 @@ import { findLatestMission, missionDir } from '../mission.js';
3
3
  import { readJson, writeJsonAtomic } from '../fsx.js';
4
4
  import { readAgentMessageBus } from './agent-message-bus.js';
5
5
  import { buildZellijWorkerPaneSummary } from '../zellij/zellij-worker-pane-summary.js';
6
+ import { readLoopGraphProof, summarizeLoopGraphProof } from '../loops/loop-observability.js';
6
7
  export const RUNTIME_PROOF_SUMMARY_SCHEMA = 'sks.runtime-proof-summary.v1';
7
8
  export async function buildRuntimeProofSummary(root, missionIdInput = 'latest', opts = {}) {
8
9
  const missionId = missionIdInput === 'latest' ? await findLatestMission(root) : missionIdInput;
@@ -18,6 +19,7 @@ export async function buildRuntimeProofSummary(root, missionIdInput = 'latest',
18
19
  const messagesAll = await readAgentMessageBus(root, missionId, { max: 500 });
19
20
  const recentMessages = await readAgentMessageBus(root, missionId, { max: opts.maxMessages || 8 });
20
21
  const zellijSummary = await buildZellijWorkerPaneSummary(root, missionId).catch(() => null);
22
+ const loopSummary = summarizeLoopGraphProof(await readLoopGraphProof(root, missionId).catch(() => null));
21
23
  const failedMessages = messagesAll.filter((row) => row.event_type === 'worker_failed');
22
24
  const errorMessages = messagesAll.filter((row) => row.level === 'error');
23
25
  const telemetryAgeMs = telemetry?.updated_at ? Math.max(0, Date.now() - Date.parse(telemetry.updated_at)) : Number.MAX_SAFE_INTEGER;
@@ -73,6 +75,7 @@ export async function buildRuntimeProofSummary(root, missionIdInput = 'latest',
73
75
  pane_lock_held_p95_ms: Number(zellijSummary?.pane_lock_held_p95_ms || 0),
74
76
  duplicate_slot_anchor_count: Number(zellijSummary?.duplicate_slot_anchor_count || 0)
75
77
  },
78
+ loops: loopSummary,
76
79
  blockers
77
80
  };
78
81
  await writeJsonAtomic(path.join(agentsDir, 'runtime-proof-summary.json'), summary);
@@ -91,6 +94,7 @@ export function renderRuntimeProofSummary(summary) {
91
94
  `Stack fallback: ${summary.zellij.stacked_fallback_count}`,
92
95
  `Pane lock wait p95: ${summary.zellij.pane_lock_wait_p95_ms}ms`,
93
96
  `SLOTS anchors: ${summary.zellij.duplicate_slot_anchor_count}`,
97
+ `Loops: ${summary.loops.total} total / ${summary.loops.completed} done / ${summary.loops.blocked} blocked / ${summary.loops.speedup_ratio}x`,
94
98
  ...(summary.messages.recent.length ? [
95
99
  'Recent worker messages:',
96
100
  ...summary.messages.recent.map((row) => ` ${messageStatusLabel(row)} ${row.slot_id || row.worker_id}: ${row.message}`)
@@ -290,6 +290,15 @@ async function runLocalControlTask(root, task, schema, routerDecision) {
290
290
  ...(validation.ok ? [] : ['local_llm_structured_output_invalid', ...validation.issues.map((issue) => `schema:${issue}`)])
291
291
  ];
292
292
  const workerResult = normalizeWorkerResult(structuredOutput, task, finalBlockers, validation.ok, 'local-llm');
293
+ // Stamp the local-llm request id as backend proof on model-authored patch
294
+ // envelopes; without it agent-patch-schema rejects every local-llm patch
295
+ // with model_authored_backend_proof_missing (the model cannot know the id).
296
+ if (Array.isArray(workerResult.patch_envelopes)) {
297
+ workerResult.patch_envelopes = workerResult.patch_envelopes.map((envelope) => ({
298
+ ...envelope,
299
+ backend_ollama_request_id: envelope?.backend_ollama_request_id || adapterResult.requestId
300
+ }));
301
+ }
293
302
  const workerResultPath = path.join(root, 'local-llm-worker-result.json');
294
303
  await writeJsonAtomic(workerResultPath, workerResult);
295
304
  const patchEnvelopePath = Array.isArray(workerResult.patch_envelopes) && workerResult.patch_envelopes.length
@@ -4,6 +4,8 @@ import { initProject } from '../init.js';
4
4
  import { createMission, loadMission, setCurrent, stateFile } from '../mission.js';
5
5
  import { GOAL_BRIDGE_ARTIFACT, GOAL_WORKFLOW_ARTIFACT, updateGoalWorkflow, writeGoalWorkflow } from '../goal-workflow.js';
6
6
  import { flag, promptOf, resolveMissionId } from './command-utils.js';
7
+ import { compileGoalToLoopPlan } from '../loops/goal-to-loop-compat.js';
8
+ import { runLoopPlan } from '../loops/loop-runtime.js';
7
9
  export async function goalCommand(sub, args = []) {
8
10
  const known = new Set(['create', 'pause', 'resume', 'clear', 'status', 'help', '--help', '-h']);
9
11
  const action = known.has(sub) ? sub : 'create';
@@ -31,11 +33,27 @@ async function goalCreate(args) {
31
33
  const prompt = promptOf(args);
32
34
  if (!prompt)
33
35
  throw new Error('Missing goal task prompt.');
36
+ if (flag(args, '--legacy-goal-runtime') || process.env.SKS_LEGACY_GOAL_RUNTIME === '1')
37
+ return legacyGoalCreate(root, prompt, args);
38
+ const { id, dir, mission } = await createMission(root, { mode: 'goal', prompt });
39
+ const workflow = await writeGoalWorkflow(dir, mission, { action: 'create', prompt });
40
+ const plan = await compileGoalToLoopPlan({ root, missionId: id, goalText: prompt, legacyGoalOptions: { native_goal: workflow.native_goal } });
41
+ const result = await runLoopPlan({ root, plan, parallelism: 'balanced' });
42
+ await setCurrent(root, { mission_id: id, mode: 'GOAL', route: 'Goal', route_command: '$Goal', phase: result.ok ? 'GOAL_LOOP_COMPLETED' : 'GOAL_LOOP_BLOCKED', questions_allowed: true, implementation_allowed: true, native_goal: workflow.native_goal, stop_gate: 'loop-graph-proof.json' }, { replace: true });
43
+ if (flag(args, '--json'))
44
+ return console.log(JSON.stringify({ schema: 'sks.goal-create.v1', ok: result.ok, mission_id: id, workflow, loop_plan: plan, loop_result: result }, null, 2));
45
+ console.log(`Goal compiled to Loop Graph: ${id}`);
46
+ console.log('Use `sks loop status latest` to inspect.');
47
+ console.log(`Artifact: ${path.relative(root, path.join(dir, GOAL_WORKFLOW_ARTIFACT))}`);
48
+ console.log(`Bridge: ${path.relative(root, path.join(dir, GOAL_BRIDGE_ARTIFACT))}`);
49
+ console.log(`Native Codex control: ${workflow.native_goal.slash_command}`);
50
+ }
51
+ async function legacyGoalCreate(root, prompt, args) {
34
52
  const { id, dir, mission } = await createMission(root, { mode: 'goal', prompt });
35
53
  const workflow = await writeGoalWorkflow(dir, mission, { action: 'create', prompt });
36
54
  await setCurrent(root, { mission_id: id, mode: 'GOAL', route: 'Goal', route_command: '$Goal', phase: 'GOAL_READY', questions_allowed: true, implementation_allowed: true, native_goal: workflow.native_goal, stop_gate: 'none' }, { replace: true });
37
55
  if (flag(args, '--json'))
38
- return console.log(JSON.stringify({ schema: 'sks.goal-create.v1', ok: true, mission_id: id, workflow }, null, 2));
56
+ return console.log(JSON.stringify({ schema: 'sks.goal-create.v1', ok: true, mission_id: id, workflow, runtime: 'legacy-goal' }, null, 2));
39
57
  console.log(`Goal mission created: ${id}`);
40
58
  console.log(`Artifact: ${path.relative(root, path.join(dir, GOAL_WORKFLOW_ARTIFACT))}`);
41
59
  console.log(`Bridge: ${path.relative(root, path.join(dir, GOAL_BRIDGE_ARTIFACT))}`);