sneakoscope 3.1.0 → 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 (60) 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/commands/zellij-slot-column-anchor.js +3 -1
  8. package/dist/commands/zellij-slot-pane.js +19 -2
  9. package/dist/core/agents/agent-janitor.js +10 -1
  10. package/dist/core/agents/agent-orchestrator.js +1 -0
  11. package/dist/core/agents/agent-runner-ollama.js +11 -4
  12. package/dist/core/agents/native-cli-session-swarm.js +69 -9
  13. package/dist/core/codex-control/codex-task-runner.js +9 -0
  14. package/dist/core/commands/loop-command.js +54 -13
  15. package/dist/core/commands/naruto-command.js +26 -17
  16. package/dist/core/commands/team-command.js +1 -0
  17. package/dist/core/fsx.js +1 -1
  18. package/dist/core/locks/file-lock.js +88 -0
  19. package/dist/core/loops/loop-artifacts.js +33 -2
  20. package/dist/core/loops/loop-checkpoint.js +22 -0
  21. package/dist/core/loops/loop-finalizer.js +33 -7
  22. package/dist/core/loops/loop-gate-registry.js +96 -0
  23. package/dist/core/loops/loop-gate-runner.js +165 -17
  24. package/dist/core/loops/loop-gpt-final-arbiter.js +61 -0
  25. package/dist/core/loops/loop-integration-merge.js +75 -0
  26. package/dist/core/loops/loop-lease.js +35 -20
  27. package/dist/core/loops/loop-planner.js +36 -5
  28. package/dist/core/loops/loop-runtime-control.js +25 -0
  29. package/dist/core/loops/loop-runtime.js +248 -93
  30. package/dist/core/loops/loop-scheduler.js +12 -3
  31. package/dist/core/loops/loop-worker-prompts.js +43 -0
  32. package/dist/core/loops/loop-worker-runtime.js +275 -0
  33. package/dist/core/loops/loop-worktree-runtime.js +92 -0
  34. package/dist/core/naruto/naruto-finalizer.js +7 -2
  35. package/dist/core/naruto/naruto-loop-mesh.js +7 -1
  36. package/dist/core/proof/proof-schema.js +6 -0
  37. package/dist/core/proof/proof-writer.js +5 -2
  38. package/dist/core/proof/root-cause-policy.js +70 -0
  39. package/dist/core/proof/route-adapter.js +18 -1
  40. package/dist/core/proof/route-proof-gate.js +4 -0
  41. package/dist/core/release/release-gate-batch-runner.js +56 -10
  42. package/dist/core/release/release-gate-cache-v2.js +18 -3
  43. package/dist/core/release/release-gate-dag.js +65 -17
  44. package/dist/core/release/release-gate-node.js +2 -1
  45. package/dist/core/release/release-gate-resource-governor.js +27 -6
  46. package/dist/core/skills/core-skill-meta-update.js +24 -0
  47. package/dist/core/skills/core-skill-reflection.js +94 -0
  48. package/dist/core/skills/core-skill-trainer.js +103 -0
  49. package/dist/core/trust-kernel/completion-contract.js +4 -0
  50. package/dist/core/trust-kernel/route-contract.js +4 -1
  51. package/dist/core/version.js +1 -1
  52. package/dist/core/zellij/zellij-right-column-manager.js +13 -2
  53. package/dist/core/zellij/zellij-slot-column-anchor.js +40 -3
  54. package/dist/core/zellij/zellij-slot-pane-renderer.js +36 -11
  55. package/dist/core/zellij/zellij-slot-telemetry.js +96 -44
  56. package/dist/core/zellij/zellij-worker-pane-manager.js +42 -4
  57. package/dist/scripts/loop-directive-check-lib.js +225 -2
  58. package/dist/scripts/loop-worker-fixture-child.js +53 -0
  59. package/dist/scripts/naruto-real-local-gpt-final-smoke.js +10 -1
  60. package/package.json +5 -2
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.1.0** 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.1.0"
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.1.0"
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.1.0"),
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.1.0",
5
- "source_digest": "0eed1c5f95e3d3e67d263680e28b27bfd1dabe232ff7265720a57d7d377252e8",
6
- "source_file_count": 2392,
7
- "built_at_source_time": 1781256416238
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.1.0';
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') {
@@ -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);
@@ -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
@@ -1,12 +1,15 @@
1
1
  import path from 'node:path';
2
2
  import { printJson } from '../../cli/output.js';
3
3
  import { createMission, findLatestMission, loadMission, setCurrent } from '../mission.js';
4
- import { readJson, sksRoot, writeJsonAtomic } from '../fsx.js';
5
- import { loopGraphProofPath, loopPlanPath, loopRoot } from '../loops/loop-artifacts.js';
4
+ import { readJson, sksRoot } from '../fsx.js';
5
+ import { loopLatestCheckpointPath, loopPlanPath, loopProofPath, loopRoot } from '../loops/loop-artifacts.js';
6
+ import { finalizeLoopGraph } from '../loops/loop-finalizer.js';
6
7
  import { readLoopGraphProof } from '../loops/loop-observability.js';
7
8
  import { planLoopsFromRequest } from '../loops/loop-planner.js';
8
9
  import { renderLoopProofSummary } from '../loops/loop-proof-summary.js';
9
- import { runLoopPlan } from '../loops/loop-runtime.js';
10
+ import { runLoopNode, runLoopPlan } from '../loops/loop-runtime.js';
11
+ import { scheduleLoopGraph } from '../loops/loop-scheduler.js';
12
+ import { writeLoopKillRequest } from '../loops/loop-runtime-control.js';
10
13
  import { flag, promptOf, readFlagValue } from './command-utils.js';
11
14
  export async function loopCommand(subcommand = 'help', args = []) {
12
15
  const action = subcommand || 'help';
@@ -21,7 +24,7 @@ export async function loopCommand(subcommand = 'help', args = []) {
21
24
  if (action === 'kill')
22
25
  return loopKill(args);
23
26
  if (action === 'resume')
24
- return loopRun(args);
27
+ return loopResume(args);
25
28
  if (action === 'graph')
26
29
  return loopGraph(args);
27
30
  console.log(`SKS Loop
@@ -32,7 +35,7 @@ Usage:
32
35
  sks loop status latest [--json]
33
36
  sks loop proof latest [--json]
34
37
  sks loop kill <loop-id|all>
35
- sks loop resume latest
38
+ sks loop resume latest [--rerun-completed]
36
39
  sks loop graph latest
37
40
  `);
38
41
  }
@@ -78,12 +81,21 @@ async function loopStatus(args) {
78
81
  const plan = await readJson(loopPlanPath(root, missionId), null);
79
82
  const proof = await readLoopGraphProof(root, missionId);
80
83
  const states = await Promise.all((plan?.graph.nodes || []).map((node) => readJson(path.join(loopRoot(root, missionId), node.loop_id, 'loop-state.json'), null)));
81
- const result = { schema: 'sks.loop-status-command.v1', mission_id: missionId, plan_ok: Boolean(plan && plan.blockers.length === 0), graph: proof, states };
84
+ const proofs = await Promise.all((plan?.graph.nodes || []).map((node) => readJson(loopProofPath(root, missionId, node.loop_id), null)));
85
+ const checkpoints = await Promise.all((plan?.graph.nodes || []).map((node) => readJson(loopLatestCheckpointPath(root, missionId, node.loop_id), null)));
86
+ const result = { schema: 'sks.loop-status-command.v1', mission_id: missionId, plan_ok: Boolean(plan && plan.blockers.length === 0), graph: proof, states, proofs, checkpoints };
82
87
  if (flag(args, '--json'))
83
88
  return printJson(result);
84
89
  console.log(`Loop status: ${missionId}`);
85
90
  for (const state of states.filter(Boolean)) {
86
- console.log(` ${String(state.loop_id).padEnd(18)} ${String(state.status).padEnd(10)} iter ${state.iteration} owner ${(Array.isArray(state.acting_on?.files) ? state.acting_on.files.join(', ') : '-')}`);
91
+ const loopId = String(state.loop_id);
92
+ const nodeProof = proofs.find((row) => row?.loop_id === loopId);
93
+ const checkpoint = checkpoints.find((row) => row?.loop_id === loopId);
94
+ const backend = nodeProof?.maker_result?.backend || 'blocked';
95
+ const gates = nodeProof?.gate_result ? `${nodeProof.gate_result.passed_gates.length}/${nodeProof.gate_result.selected_gates.length}` : '-';
96
+ const worktree = nodeProof?.worktree?.id || state.acting_on?.worktree_id || '-';
97
+ const resumable = checkpoint?.resumable ? `resumable:${checkpoint.phase}` : 'resumable:-';
98
+ console.log(` ${loopId.padEnd(18)} ${String(state.status).padEnd(10)} backend ${String(backend).padEnd(24)} gates ${gates.padEnd(5)} worktree ${String(worktree).padEnd(18)} ${resumable}`);
87
99
  }
88
100
  }
89
101
  async function loopProof(args) {
@@ -112,14 +124,43 @@ async function loopKill(args) {
112
124
  const target = args[0];
113
125
  if (!missionId || !target)
114
126
  throw new Error('Usage: sks loop kill <loop-id|all>');
115
- await writeJsonAtomic(path.join(loopRoot(root, missionId), 'kill-request.json'), {
116
- schema: 'sks.loop-kill-request.v1',
117
- mission_id: missionId,
118
- target,
119
- requested_at: new Date().toISOString()
120
- });
127
+ await writeLoopKillRequest(root, missionId, target);
121
128
  console.log(`Loop kill requested: ${target}`);
122
129
  }
130
+ async function loopResume(args) {
131
+ const root = await sksRoot();
132
+ const missionId = await resolveLoopMission(root, args[0]);
133
+ if (!missionId)
134
+ throw new Error('Usage: sks loop resume <mission-id|latest> [--rerun-completed]');
135
+ const plan = await readJson(loopPlanPath(root, missionId));
136
+ const rerunCompleted = flag(args, '--rerun-completed');
137
+ const existingProofs = await Promise.all(plan.graph.nodes.map((node) => readJson(loopProofPath(root, missionId, node.loop_id), null)));
138
+ const completed = new Set(existingProofs.filter((proof) => proof !== null && proof.status === 'completed').map((proof) => proof.loop_id));
139
+ const runnable = rerunCompleted ? plan.graph.nodes : plan.graph.nodes.filter((node) => !completed.has(node.loop_id));
140
+ const schedule = scheduleLoopGraph(runnable, normalizeParallelism(readFlagValue(args, '--parallelism', 'balanced')));
141
+ const started = Date.now();
142
+ const resumedProofs = [];
143
+ for (const batch of schedule.batches) {
144
+ const batchProofs = await Promise.all(batch.map((node) => runLoopNode({ root, plan, node })));
145
+ resumedProofs.push(...batchProofs);
146
+ }
147
+ const mergedProofs = [
148
+ ...existingProofs.filter((proof) => proof !== null && proof.status === 'completed' && !rerunCompleted),
149
+ ...resumedProofs
150
+ ];
151
+ const graphProof = await finalizeLoopGraph({
152
+ root,
153
+ plan,
154
+ proofs: mergedProofs,
155
+ maxActiveLoops: schedule.max_active_loops,
156
+ maxActiveWorkers: Math.max(1, mergedProofs.reduce((sum, proof) => sum + proof.maker_result.worker_count + proof.checker_result.worker_count, 0)),
157
+ wallMs: Math.max(1, Date.now() - started)
158
+ });
159
+ await setCurrent(root, { mission_id: missionId, mode: 'LOOP', route: 'Loop', route_command: '$Loop', phase: graphProof.ok ? 'LOOP_COMPLETED' : 'LOOP_BLOCKED', stop_gate: 'loop-graph-proof.json' });
160
+ if (flag(args, '--json'))
161
+ return printJson({ schema: 'sks.loop-resume-command.v1', ok: graphProof.ok, mission_id: missionId, resumed_loops: resumedProofs.map((proof) => proof.loop_id), skipped_completed: [...completed], graph_proof: graphProof });
162
+ console.log(renderLoopProofSummary(graphProof));
163
+ }
123
164
  async function resolveLoopMission(root, arg) {
124
165
  if (arg && arg !== 'latest')
125
166
  return arg;
@@ -385,6 +385,9 @@ async function narutoRun(parsed) {
385
385
  serviceTier: 'fast',
386
386
  noFast: false,
387
387
  writeMode: writeCapable ? parsed.writeMode || 'parallel' : 'off',
388
+ applyPatches: parsed.applyPatches,
389
+ dryRunPatches: parsed.dryRunPatches,
390
+ maxWriteAgents: parsed.maxWriteAgents,
388
391
  gitWorktreePolicy: worktreePolicy,
389
392
  narutoWorkGraph: workGraph,
390
393
  narutoAllocationPolicy: allocationPolicy,
@@ -418,18 +421,6 @@ async function narutoRun(parsed) {
418
421
  blockers: [...(result.proof?.blockers || []), ...(parallelRuntimeOk ? [] : ['naruto_parallel_runtime_proof_below_gate'])],
419
422
  updated_at: nowIso()
420
423
  });
421
- await setCurrent(root, {
422
- mission_id: mission.id,
423
- route: 'Naruto',
424
- route_command: '$Naruto',
425
- mode: 'NARUTO',
426
- phase: result.ok === true ? 'NARUTO_COMPLETE_OR_REVIEW' : 'NARUTO_BLOCKED',
427
- native_sessions_verified: nativeProofOk,
428
- subagents_verified: nativeProofOk,
429
- naruto_gate_file: 'naruto-gate.json',
430
- stop_gate: 'naruto-gate.json',
431
- prompt: parsed.prompt
432
- });
433
424
  const clones = result.roster?.agent_count ?? roster.agent_count;
434
425
  const localWorkerSummary = summarizeNarutoLocalWorkerResult(localWorker, result);
435
426
  // Finalizer policy: when local LLM workers contributed patches, the GPT
@@ -437,16 +428,29 @@ async function narutoRun(parsed) {
437
428
  const finalizer = evaluateNarutoFinalizer({
438
429
  localParticipated: Number(localWorkerSummary?.selected_worker_count || 0) > 0,
439
430
  gptFinalStatus: result.proof?.gpt_final_status || null,
440
- applyPatches: writeCapable
431
+ applyPatches: parsed.applyPatches
441
432
  });
442
433
  await writeJsonAtomic(path.join(mission.dir, 'naruto-finalizer.json'), {
443
434
  ...finalizer,
444
435
  generated_at: nowIso(),
445
436
  mission_id: mission.id
446
437
  });
438
+ const summaryOk = result.ok === true && (parsed.applyPatches === true ? finalizer.ok === true : finalizer.run_ok === true);
439
+ await setCurrent(root, {
440
+ mission_id: mission.id,
441
+ route: 'Naruto',
442
+ route_command: '$Naruto',
443
+ mode: 'NARUTO',
444
+ phase: summaryOk ? 'NARUTO_COMPLETE_OR_REVIEW' : 'NARUTO_BLOCKED',
445
+ native_sessions_verified: nativeProofOk,
446
+ subagents_verified: nativeProofOk,
447
+ naruto_gate_file: 'naruto-gate.json',
448
+ stop_gate: 'naruto-gate.json',
449
+ prompt: parsed.prompt
450
+ });
447
451
  const summary = {
448
452
  schema: NARUTO_RESULT_SCHEMA,
449
- ok: result.ok === true,
453
+ ok: summaryOk,
450
454
  mode: 'NARUTO',
451
455
  jutsu: 'kage_bunshin_no_jutsu',
452
456
  mission_id: result.mission_id,
@@ -502,6 +506,7 @@ async function narutoRun(parsed) {
502
506
  headless_workers: parallelRuntime.headless_workers,
503
507
  passed: parallelRuntime.passed
504
508
  } : null,
509
+ parallel_write_policy: result.parallel_write_policy || null,
505
510
  local_worker: localWorkerSummary,
506
511
  finalizer,
507
512
  proof: result.proof?.status || 'missing',
@@ -538,6 +543,7 @@ function compactNarutoRunResult(result) {
538
543
  mission_id: result?.mission_id || null,
539
544
  route: result?.route || NARUTO_ROUTE,
540
545
  backend: result?.backend || null,
546
+ parallel_write_policy: result?.parallel_write_policy || null,
541
547
  target_active_slots: result?.target_active_slots ?? null,
542
548
  proof: result?.proof ? {
543
549
  ok: result.proof.ok === true,
@@ -754,7 +760,7 @@ async function narutoHelp(parsed) {
754
760
  mode: 'NARUTO',
755
761
  description: 'Shadow Clone Swarm: fan out up to ' + MAX_NARUTO_AGENT_COUNT + ' parallel clone sessions.',
756
762
  usage: [
757
- 'sks naruto run "<task>" [--clones N] [--backend codex-sdk|fake|ollama] [--local-model|--no-ollama] [--work-items N] [--real] [--readonly] [--json]',
763
+ 'sks naruto run "<task>" [--clones N] [--backend codex-sdk|fake|ollama] [--local-model|--no-ollama] [--work-items N] [--write-mode parallel|serial|off] [--apply-patches] [--dry-run-patches] [--real] [--readonly] [--json]',
758
764
  'sks naruto status [--mission <id>] [--json]',
759
765
  'sks naruto proof latest [--messages 20] [--json]'
760
766
  ],
@@ -788,6 +794,9 @@ function parseNarutoArgs(args = []) {
788
794
  const readonly = hasFlag(args, '--readonly') || hasFlag(args, '--read-only');
789
795
  const writeModeRaw = String(readOption(args, '--write-mode', hasFlag(args, '--parallel-write') ? 'parallel' : '') || '');
790
796
  const writeMode = (['proof-safe', 'parallel', 'serial', 'off'].includes(writeModeRaw) ? writeModeRaw : null);
797
+ const applyPatches = hasFlag(args, '--apply-patches');
798
+ const dryRunPatches = hasFlag(args, '--dry-run-patches') || hasFlag(args, '--dry-run-patch');
799
+ const maxWriteAgents = Math.max(0, Math.floor(Number(readOption(args, '--max-write-agents', '0')) || 0));
791
800
  const positionalMission = action === 'dashboard' || action === 'workers' || action === 'status' || action === 'proof'
792
801
  ? positionalArgs(rest, new Set()).find((arg) => /^latest$|^M-/.test(arg))
793
802
  : null;
@@ -799,9 +808,9 @@ function parseNarutoArgs(args = []) {
799
808
  const smoke = hasFlag(args, '--smoke');
800
809
  const parallelism = normalizeParallelism(readOption(args, '--parallelism', 'extreme'));
801
810
  const messages = normalizeMessages(readOption(args, '--messages', '8'));
802
- const valueFlags = new Set(['--clones', '--agents', '--work-items', '--concurrency', '--target-active-slots', '--backend', '--write-mode', '--mission', '--mission-id', '--ollama-model', '--local-model-model', '--ollama-base-url', '--local-model-base-url', '--parallelism', '--messages']);
811
+ const valueFlags = new Set(['--clones', '--agents', '--work-items', '--concurrency', '--target-active-slots', '--backend', '--write-mode', '--max-write-agents', '--mission', '--mission-id', '--ollama-model', '--local-model-model', '--ollama-base-url', '--local-model-base-url', '--parallelism', '--messages']);
803
812
  const prompt = positionalArgs(rest, valueFlags).join(' ').trim() || 'Naruto shadow clone swarm run';
804
- return { action, prompt, clones, workItems, concurrency, backend, backendExplicit, mock, real, readonly, ollamaEnabled: useOllama && !noOllama, noOllama, ollamaModel, ollamaBaseUrl, writeMode, json, missionId, noOpenZellij, attach, smoke, parallelism, messages };
813
+ return { action, prompt, clones, workItems, concurrency, backend, backendExplicit, mock, real, readonly, ollamaEnabled: useOllama && !noOllama, noOllama, ollamaModel, ollamaBaseUrl, writeMode, applyPatches, dryRunPatches, maxWriteAgents, json, missionId, noOpenZellij, attach, smoke, parallelism, messages };
805
814
  }
806
815
  function normalizeParallelism(value) {
807
816
  const text = String(value || 'extreme').toLowerCase();
@@ -25,6 +25,7 @@ async function redirectTeamCreateToNaruto(args = []) {
25
25
  redirected_to: 'sks naruto run',
26
26
  route_command: '$Naruto',
27
27
  deprecated_route: '$Team',
28
+ parallel_write_policy: result?.parallel_write_policy || result?.run?.parallel_write_policy || null,
28
29
  created_at: nowIso(),
29
30
  args: list
30
31
  });
package/dist/core/fsx.js CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- export const PACKAGE_VERSION = '3.1.0';
8
+ export const PACKAGE_VERSION = '3.1.1';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
  export function nowIso() {
@@ -0,0 +1,88 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureDir, nowIso, randomId, writeJsonAtomic } from '../fsx.js';
4
+ import { guardedRm, guardContextForRoute } from '../safety/mutation-guard.js';
5
+ import { CONFIRMATION_REQUIRED, REQUESTED_SCOPE_CONTRACT_SCHEMA } from '../safety/requested-scope-contract.js';
6
+ export async function withFileLock(input, fn) {
7
+ const lockPath = path.resolve(input.lockPath);
8
+ const timeoutMs = Math.max(1, input.timeoutMs);
9
+ const staleMs = Math.max(1, input.staleMs);
10
+ const started = Date.now();
11
+ const owner = `${process.pid}-${randomId(8)}`;
12
+ await ensureDir(path.dirname(lockPath));
13
+ while (true) {
14
+ try {
15
+ await fsp.mkdir(lockPath);
16
+ await writeJsonAtomic(path.join(lockPath, 'owner.json'), {
17
+ schema: 'sks.file-lock-owner.v1',
18
+ owner,
19
+ pid: process.pid,
20
+ acquired_at: nowIso(),
21
+ stale_ms: staleMs
22
+ });
23
+ break;
24
+ }
25
+ catch (err) {
26
+ const code = errorCode(err);
27
+ if (code !== 'EEXIST')
28
+ throw err;
29
+ await recoverStaleLock(lockPath, staleMs);
30
+ if (Date.now() - started > timeoutMs) {
31
+ throw new Error(`file_lock_timeout:${lockPath}`);
32
+ }
33
+ await sleep(jitterDelay());
34
+ }
35
+ }
36
+ try {
37
+ return await fn();
38
+ }
39
+ finally {
40
+ await removeLockDir(lockPath).catch(() => undefined);
41
+ }
42
+ }
43
+ async function recoverStaleLock(lockPath, staleMs) {
44
+ try {
45
+ const stat = await fsp.stat(lockPath);
46
+ if (Date.now() - stat.mtimeMs > staleMs) {
47
+ await removeLockDir(lockPath);
48
+ }
49
+ }
50
+ catch { }
51
+ }
52
+ async function removeLockDir(lockPath) {
53
+ await guardedRm(guardContextForRoute(process.cwd(), lockScopeContract(lockPath), 'remove SKS file lock directory'), lockPath, {
54
+ recursive: true,
55
+ force: true
56
+ });
57
+ }
58
+ function lockScopeContract(lockPath) {
59
+ const resolved = path.resolve(lockPath);
60
+ return {
61
+ schema: REQUESTED_SCOPE_CONTRACT_SCHEMA,
62
+ route: 'internal:file-lock',
63
+ user_request: 'Remove only the current SKS file-lock directory.',
64
+ allowed_mutations: {
65
+ project_files: true,
66
+ global_codex_config: false,
67
+ codex_app_process: false,
68
+ codex_lb_auth: false,
69
+ package_install: false,
70
+ zellij_install: false,
71
+ network: false,
72
+ skill_snapshot_promotion: false
73
+ },
74
+ allowed_paths: [resolved, `${resolved}/**`],
75
+ forbidden_paths: ['~/.codex/config.toml', '/Applications/**'],
76
+ requires_explicit_confirmation: [...CONFIRMATION_REQUIRED]
77
+ };
78
+ }
79
+ function jitterDelay() {
80
+ return 15 + Math.floor(Math.random() * 45);
81
+ }
82
+ function sleep(ms) {
83
+ return new Promise((resolve) => setTimeout(resolve, ms));
84
+ }
85
+ function errorCode(err) {
86
+ return err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
87
+ }
88
+ //# sourceMappingURL=file-lock.js.map