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
@@ -0,0 +1,24 @@
1
+ // SKS Core Skill Engine — SkillOpt-style epoch-wise meta-update.
2
+ //
3
+ // SkillOpt adjusts its textual learning rate between epochs: rejected edits decay
4
+ // the budget (smaller, safer proposals), accepted edits regrow it toward the
5
+ // default ceiling. Deterministic, bounded, never exceeds the configured maximum.
6
+ import { DEFAULT_TEXTUAL_LEARNING_RATE } from './core-skill-epoch.js';
7
+ export const MIN_TEXTUAL_LEARNING_RATE = { max_added_chars: 100, max_deleted_chars: 50, max_replaced_chars: 75 };
8
+ export const META_UPDATE_DECAY = 0.5;
9
+ export const META_UPDATE_GROWTH = 1.25;
10
+ /** Epoch-wise textual learning-rate adjustment: decay on rejection, bounded regrowth on acceptance. */
11
+ export function metaUpdateLearningRate(rate, outcome, opts = {}) {
12
+ const decay = opts.decay ?? META_UPDATE_DECAY;
13
+ const growth = opts.growth ?? META_UPDATE_GROWTH;
14
+ const min = opts.min ?? MIN_TEXTUAL_LEARNING_RATE;
15
+ const max = opts.max ?? DEFAULT_TEXTUAL_LEARNING_RATE;
16
+ const factor = outcome === 'rejected' ? decay : growth;
17
+ const adjust = (value, lo, hi) => Math.min(hi, Math.max(lo, Math.round(value * factor)));
18
+ return {
19
+ max_added_chars: adjust(rate.max_added_chars, min.max_added_chars, max.max_added_chars),
20
+ max_deleted_chars: adjust(rate.max_deleted_chars, min.max_deleted_chars, max.max_deleted_chars),
21
+ max_replaced_chars: adjust(rate.max_replaced_chars, min.max_replaced_chars, max.max_replaced_chars)
22
+ };
23
+ }
24
+ //# sourceMappingURL=core-skill-meta-update.js.map
@@ -0,0 +1,94 @@
1
+ // SKS Core Skill Engine — SkillOpt-style reflection stage.
2
+ //
3
+ // SkillOpt's training loop is rollout → reflection → aggregation → selection →
4
+ // validation-gated update. This module implements the reflect/aggregate/select
5
+ // stages deterministically (no model call): each scored rollout yields reflection
6
+ // records for its deficient score dimensions, reflections are aggregated and
7
+ // ranked across the batch, and the top-ranked deficiencies are mapped to bounded
8
+ // add-operations on the single skill document.
9
+ import { scoreRollout } from './core-skill-scorer.js';
10
+ // dimension → { instruction line, target section, regex proving the card already covers it }
11
+ const DIMENSION_LESSONS = {
12
+ proof_completeness: {
13
+ target: 'section:verification',
14
+ text: '- Always emit a proof artifact before reporting success.',
15
+ covered: /proof artifact/i
16
+ },
17
+ rollback_ready: {
18
+ target: 'section:rollback',
19
+ text: '- Record a rollback-ready checkpoint before mutating anything.',
20
+ covered: /rollback/i
21
+ },
22
+ latency_budget: {
23
+ target: 'section:latency',
24
+ text: '- Prefer the smallest sufficient probe; stop expanding scope once the latency budget is at risk.',
25
+ covered: /latency budget/i
26
+ },
27
+ requested_scope_compliance: {
28
+ target: 'section:scope',
29
+ text: '- Touch only what the request names; treat any out-of-scope mutation as a hard stop.',
30
+ covered: /out-of-scope mutation/i
31
+ },
32
+ side_effect_zero: {
33
+ target: 'section:scope',
34
+ text: '- Leave zero side effects: never mutate outside the requested scope.',
35
+ covered: /zero side effects/i
36
+ },
37
+ task_success: {
38
+ target: 'section:method',
39
+ text: '- Re-read the recorded failure reason and address it directly before retrying.',
40
+ covered: /failure reason/i
41
+ }
42
+ };
43
+ /** Reflect on one scored rollout: one reflection per deficient score dimension. */
44
+ export function reflectOnTrace(trace, opts = {}) {
45
+ const score = scoreRollout(trace, opts);
46
+ const reflections = [];
47
+ for (const dimension of Object.keys(score.components)) {
48
+ const value = score.components[dimension];
49
+ if (value >= 1)
50
+ continue;
51
+ reflections.push({
52
+ dimension,
53
+ severity: Number((1 - value).toFixed(6)),
54
+ note: trace.failure_reason ? `deficient ${dimension} (failure: ${trace.failure_reason})` : `deficient ${dimension}`
55
+ });
56
+ }
57
+ return reflections;
58
+ }
59
+ /** Aggregate reflections across a rollout batch, ranked by total severity then count. */
60
+ export function aggregateReflections(reflections) {
61
+ const byDimension = new Map();
62
+ for (const reflection of reflections) {
63
+ const entry = byDimension.get(reflection.dimension) ?? { dimension: reflection.dimension, count: 0, severity: 0 };
64
+ entry.count += 1;
65
+ entry.severity = Number((entry.severity + reflection.severity).toFixed(6));
66
+ byDimension.set(reflection.dimension, entry);
67
+ }
68
+ return [...byDimension.values()].sort((a, b) => b.severity - a.severity || b.count - a.count || a.dimension.localeCompare(b.dimension));
69
+ }
70
+ /**
71
+ * Select bounded patch operations from aggregated reflections. Skips lessons the
72
+ * card body already covers, and truncates the selection so total added chars fit
73
+ * the textual learning-rate budget.
74
+ */
75
+ export function selectPatchOperations(card, aggregated, learningRate, opts = {}) {
76
+ const maxOperations = Math.max(1, opts.maxOperations ?? 2);
77
+ const operations = [];
78
+ let addedChars = 0;
79
+ for (const entry of aggregated) {
80
+ if (operations.length >= maxOperations)
81
+ break;
82
+ const lesson = DIMENSION_LESSONS[entry.dimension];
83
+ if (!lesson || lesson.covered.test(card.body))
84
+ continue;
85
+ if (operations.some((op) => op.op === 'add' && op.text === lesson.text))
86
+ continue;
87
+ if (addedChars + lesson.text.length > learningRate.max_added_chars)
88
+ continue;
89
+ operations.push({ op: 'add', target: lesson.target, text: lesson.text });
90
+ addedChars += lesson.text.length;
91
+ }
92
+ return operations;
93
+ }
94
+ //# sourceMappingURL=core-skill-reflection.js.map
@@ -0,0 +1,103 @@
1
+ // SKS Core Skill Engine — SkillOpt-style multi-epoch trainer.
2
+ //
3
+ // Closes the SkillOpt training loop over the existing primitives: per epoch a
4
+ // rollout batch is reflected on (reflect → aggregate → select), the selected
5
+ // bounded edit goes through the strict held-out gate (runSkillEpoch), and the
6
+ // textual learning rate is meta-updated from the outcome. The best held-out
7
+ // accepted card is exported as the deployable best-skill artifact (SkillOpt's
8
+ // best_skill.md analogue). Pure/deterministic: the caller supplies the held-out
9
+ // evaluator; no model call is made here. Forbidden in deployment context.
10
+ import path from 'node:path';
11
+ import { ensureDir, nowIso, writeJsonAtomic } from '../fsx.js';
12
+ import { assertNotInDeployment } from './core-skill-deployment.js';
13
+ import { DEFAULT_TEXTUAL_LEARNING_RATE, runSkillEpoch } from './core-skill-epoch.js';
14
+ import { metaUpdateLearningRate } from './core-skill-meta-update.js';
15
+ import { applyPatch } from './core-skill-patch-apply.js';
16
+ import { aggregateReflections, reflectOnTrace, selectPatchOperations } from './core-skill-reflection.js';
17
+ import { CORE_SKILL_PATCH_SCHEMA } from './core-skill-types.js';
18
+ export const CORE_SKILL_TRAINING_REPORT_SCHEMA = 'sks.core-skill-training-report.v1';
19
+ /** Run the SkillOpt training loop in a TRAINING/EVALUATION context. */
20
+ export async function trainSkill(root, input) {
21
+ assertNotInDeployment('trainSkill');
22
+ let current = input.card;
23
+ let learningRate = { ...(input.learningRate ?? DEFAULT_TEXTUAL_LEARNING_RATE) };
24
+ let currentProbe = input.evaluateHeldout(current);
25
+ const baselineHeldout = currentProbe.heldout;
26
+ const records = [];
27
+ let acceptedCount = 0;
28
+ for (let epoch = 0; epoch < input.epochs.length; epoch += 1) {
29
+ const traces = input.epochs[epoch] ?? [];
30
+ const reflections = traces.flatMap((trace) => reflectOnTrace(trace, { ...(input.latencyBudgetMs === undefined ? {} : { latencyBudgetMs: input.latencyBudgetMs }) }));
31
+ const aggregated = aggregateReflections(reflections);
32
+ const operations = selectPatchOperations(current, aggregated, learningRate, { ...(input.maxOperationsPerEpoch === undefined ? {} : { maxOperations: input.maxOperationsPerEpoch }) });
33
+ if (!operations.length) {
34
+ records.push({ epoch, accepted: false, reason: 'no_proposal', patch_hash: null, score_delta: 0, learning_rate: { ...learningRate } });
35
+ continue;
36
+ }
37
+ const patch = {
38
+ schema: CORE_SKILL_PATCH_SCHEMA,
39
+ skill_id: current.skill_id,
40
+ base_version: current.version,
41
+ operations,
42
+ textual_learning_rate: { ...learningRate }
43
+ };
44
+ // Probe the candidate's held-out score BEFORE the gated update.
45
+ const preview = applyPatch(current, patch);
46
+ const candidateProbe = preview.ok && preview.candidate ? input.evaluateHeldout(preview.candidate) : currentProbe;
47
+ const result = await runSkillEpoch(root, {
48
+ card: current,
49
+ trainTraces: traces,
50
+ patch,
51
+ validation: {
52
+ baselineHeldout: currentProbe.heldout,
53
+ candidateHeldout: candidateProbe.heldout,
54
+ sideEffectZero: candidateProbe.sideEffectZero,
55
+ requestedScopeCompliant: candidateProbe.requestedScopeCompliant,
56
+ proofCompletenessBaseline: currentProbe.proofCompleteness,
57
+ proofCompletenessCandidate: candidateProbe.proofCompleteness,
58
+ rollbackReadyBaseline: currentProbe.rollbackReady,
59
+ rollbackReadyCandidate: candidateProbe.rollbackReady,
60
+ latencyBaselineMs: currentProbe.latencyMs,
61
+ latencyCandidateMs: candidateProbe.latencyMs
62
+ }
63
+ });
64
+ records.push({ epoch, accepted: result.accepted, reason: result.reason, patch_hash: result.patch_hash, score_delta: result.score_delta, learning_rate: { ...learningRate } });
65
+ if (result.accepted && result.candidate) {
66
+ current = result.candidate;
67
+ currentProbe = candidateProbe;
68
+ acceptedCount += 1;
69
+ learningRate = metaUpdateLearningRate(learningRate, 'accepted');
70
+ }
71
+ else {
72
+ learningRate = metaUpdateLearningRate(learningRate, 'rejected');
73
+ }
74
+ }
75
+ const reportsDir = path.join(path.resolve(root), '.sneakoscope', 'reports');
76
+ await ensureDir(reportsDir);
77
+ const reportPath = path.join(reportsDir, 'core-skill-training-report.json');
78
+ const skillDir = path.join(path.resolve(root), '.sneakoscope', 'skills', current.route, current.skill_id);
79
+ await ensureDir(skillDir);
80
+ const bestSkillPath = path.join(skillDir, 'best-skill.json');
81
+ await writeJsonAtomic(bestSkillPath, { ...current, exported_as: 'best_skill', exported_at: nowIso() });
82
+ await writeJsonAtomic(reportPath, {
83
+ schema: CORE_SKILL_TRAINING_REPORT_SCHEMA,
84
+ skill_id: input.card.skill_id,
85
+ route: input.card.route,
86
+ baseline_heldout: baselineHeldout,
87
+ best_heldout: currentProbe.heldout,
88
+ best_version: current.version,
89
+ accepted_count: acceptedCount,
90
+ epochs: records,
91
+ generated_at: nowIso()
92
+ });
93
+ return {
94
+ epochs: records,
95
+ best: current,
96
+ best_heldout: currentProbe.heldout,
97
+ baseline_heldout: baselineHeldout,
98
+ accepted_count: acceptedCount,
99
+ report_path: reportPath,
100
+ best_skill_path: bestSkillPath
101
+ };
102
+ }
103
+ //# sourceMappingURL=core-skill-trainer.js.map
@@ -1,4 +1,5 @@
1
1
  import { validateCompletionProof } from '../proof/validation.js';
2
+ import { rootCauseAnalysisIssue } from '../proof/root-cause-policy.js';
2
3
  function asRecord(value) {
3
4
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
4
5
  }
@@ -44,6 +45,9 @@ export function validateCompletionContract(contract = {}, proof = {}, evidenceIn
44
45
  issues.push('active_wrongness_requires_verified_partial');
45
46
  if (claimLinksActiveWrongness(proofRecord, wrongness))
46
47
  issues.push('claim_linked_to_active_wrongness');
48
+ const rootCauseIssue = rootCauseAnalysisIssue(proofRecord, issues);
49
+ if (rootCauseIssue)
50
+ issues.push(rootCauseIssue);
47
51
  const status = typeof proofRecord.status === 'string'
48
52
  ? proofRecord.status
49
53
  : typeof contractRecord.status === 'string'
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { writeJsonAtomic } from '../fsx.js';
3
3
  import { missionDir } from '../mission.js';
4
4
  import { routeRequiresCompletionProof, routeRequiresImageVoxelAnchors } from '../proof/route-proof-policy.js';
5
+ import { rootCauseAnalysisRequired } from '../proof/root-cause-policy.js';
5
6
  import { ROUTE_COMPLETION_CONTRACT_SCHEMA, normalizeTrustStatus, trustKernelMetadata } from './trust-kernel-schema.js';
6
7
  import { validateCompletionContract } from './completion-contract.js';
7
8
  function asRecord(value) {
@@ -51,7 +52,8 @@ export function routeRequirements(route, proof = {}) {
51
52
  image_voxels: routeRequiresImageVoxelAnchors(route),
52
53
  db_safety: route === '$DB' || Boolean(evidence.db || evidence.db_safety),
53
54
  tests: Boolean(evidence.tests),
54
- blackbox: JSON.stringify(evidence).includes('blackbox')
55
+ blackbox: JSON.stringify(evidence).includes('blackbox'),
56
+ root_cause_analysis: rootCauseAnalysisRequired(proofRecord)
55
57
  };
56
58
  }
57
59
  function evidencePathsForContract(missionId, proof, evidenceIndex, required) {
@@ -67,6 +69,7 @@ function evidencePathsForContract(missionId, proof, evidenceIndex, required) {
67
69
  tests: pathFromEvidence(proofEvidence.tests),
68
70
  db_safety: pathFromEvidence(proofEvidence.db || proofEvidence.db_safety),
69
71
  blackbox: pathFromEvidence(proofEvidence.blackbox),
72
+ root_cause_analysis: required.root_cause_analysis && base ? `${base}/completion-proof.json#failure_analysis` : null,
70
73
  evidence_index: base ? `${base}/evidence-index.json` : null,
71
74
  trust_report: base ? `${base}/trust-report.json` : null,
72
75
  evidence_records: asList(evidenceIndexRecord.records).map((entry) => {
@@ -1,2 +1,2 @@
1
- export const PACKAGE_VERSION = '3.0.4';
1
+ export const PACKAGE_VERSION = '3.1.1';
2
2
  //# sourceMappingURL=version.js.map
@@ -135,6 +135,9 @@ async function prepareWorkerInRightColumnUnlocked(input) {
135
135
  return { state: next, placement: 'zellij-pane', focusPaneId, yOrder };
136
136
  }
137
137
  export async function recordWorkerPaneInRightColumn(input) {
138
+ return withRightColumnLock(input.root, input.missionId, async () => recordWorkerPaneInRightColumnUnlocked(input));
139
+ }
140
+ async function recordWorkerPaneInRightColumnUnlocked(input) {
138
141
  const paths = resolveRightColumnPaths(input.root, input.missionId, input.projectRoot);
139
142
  const state = await readRightColumnState(input.root, input.missionId, input.projectRoot);
140
143
  if (!state)
@@ -154,6 +157,7 @@ export async function recordWorkerPaneInRightColumn(input) {
154
157
  ...state,
155
158
  updated_at: nowIso(),
156
159
  right_anchor_pane_id: input.record.pane_id || state.right_anchor_pane_id,
160
+ slot_column_anchor_pane_id: input.record.slot_column_anchor_pane_id || state.slot_column_anchor_pane_id || null,
157
161
  visible_worker_panes: rows.sort((a, b) => a.y_order - b.y_order),
158
162
  blockers: [...new Set([...state.blockers, ...(input.record.blockers || [])])]
159
163
  });
@@ -173,6 +177,9 @@ export async function recordWorkerPaneInRightColumn(input) {
173
177
  return next;
174
178
  }
175
179
  export async function recordSlotColumnAnchorInRightColumn(input) {
180
+ return withRightColumnLock(input.root, input.missionId, async () => recordSlotColumnAnchorInRightColumnUnlocked(input));
181
+ }
182
+ async function recordSlotColumnAnchorInRightColumnUnlocked(input) {
176
183
  const paths = resolveRightColumnPaths(input.root, input.missionId, input.projectRoot);
177
184
  const state = await readRightColumnState(input.root, input.missionId, input.projectRoot) || {
178
185
  schema: ZELLIJ_RIGHT_COLUMN_STATE_SCHEMA,
@@ -238,6 +245,9 @@ async function recordHeadlessWorkerInRightColumnUnlocked(input) {
238
245
  return next;
239
246
  }
240
247
  export async function closeWorkerInRightColumn(input) {
248
+ return withRightColumnLock(input.root, input.missionId, async () => closeWorkerInRightColumnUnlocked(input));
249
+ }
250
+ async function closeWorkerInRightColumnUnlocked(input) {
241
251
  const paths = resolveRightColumnPaths(input.root, input.missionId, input.projectRoot);
242
252
  const state = await readRightColumnState(input.root, input.missionId, input.projectRoot);
243
253
  if (!state)
@@ -333,14 +343,15 @@ async function withRightColumnLock(root, missionId, fn) {
333
343
  const current = new Promise((resolve) => {
334
344
  release = resolve;
335
345
  });
336
- rightColumnLocks.set(key, previous.then(() => current, () => current));
346
+ const chained = previous.then(() => current, () => current);
347
+ rightColumnLocks.set(key, chained);
337
348
  await previous.catch(() => undefined);
338
349
  try {
339
350
  return await fn();
340
351
  }
341
352
  finally {
342
353
  release();
343
- if (rightColumnLocks.get(key) === current)
354
+ if (rightColumnLocks.get(key) === chained)
344
355
  rightColumnLocks.delete(key);
345
356
  }
346
357
  }
@@ -1,6 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { packageRoot } from '../fsx.js';
3
4
  import { readZellijSlotTelemetrySnapshot } from './zellij-slot-telemetry.js';
5
+ import { workerBackendTag } from './zellij-worker-pane-manager.js';
6
+ function readPackageVersion() {
7
+ try {
8
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
9
+ return String(pkg?.version || '?');
10
+ }
11
+ catch {
12
+ return '?';
13
+ }
14
+ }
4
15
  export function renderZellijSlotColumnAnchor(input = {}) {
5
16
  const active = nonNegativeInt(input.activeWorkers, 0);
6
17
  const visible = Math.max(1, nonNegativeInt(input.visiblePaneCap, active || 1));
@@ -11,9 +22,12 @@ export function renderZellijSlotColumnAnchor(input = {}) {
11
22
  const update = input.updateAvailableVersion ? ` · update ${trimInline(input.updateAvailableVersion, 18)} available` : '';
12
23
  const madDb = input.madDbActive ? ' · MAD-DB ACTIVE' : '';
13
24
  const appHandoff = input.qaAppHandoffPending ? ' · QA /app handoff pending' : '';
14
- const header = done || fail
25
+ const loopHeader = input.loopsTotal != null
26
+ ? `LOOPS ${nonNegativeInt(input.loopsTotal, 0)} · running ${nonNegativeInt(input.loopsRunning, 0)} · blocked ${nonNegativeInt(input.loopsBlocked, 0)} · done ${nonNegativeInt(input.loopsCompleted, 0)} · workers ${active}`
27
+ : null;
28
+ const header = loopHeader || (done || fail
15
29
  ? `SLOTS active ${active} · headless ${headless} · done ${done} · fail ${fail} · q ${queue}${update}${madDb}${appHandoff}`
16
- : `SLOTS active ${active}/${visible} · headless ${headless} · q ${queue}${update}${madDb}${appHandoff}`;
30
+ : `SLOTS active ${active}/${visible} · headless ${headless} · q ${queue}${update}${madDb}${appHandoff}`);
17
31
  const workers = Array.isArray(input.workerRows) ? input.workerRows : [];
18
32
  const handoffLine = input.qaAppHandoffPending ? `QA app handoff pending · ${trimInline(input.qaAppHandoffArtifact || 'qa-loop/app-handoff.json', 64)}` : null;
19
33
  if (!workers.length)
@@ -70,9 +84,35 @@ function renderTelemetryAnchor(snapshot, updateNotice = null, madDbCapability =
70
84
  const update = updateNotice?.update_available && updateNotice?.latest_version ? ` · update ${trimInline(String(updateNotice.latest_version), 18)} available` : '';
71
85
  const madDb = isMadDbActive(madDbCapability) ? ' · MAD-DB ACTIVE' : '';
72
86
  const qaHandoff = ['pending', 'blocked_for_desktop_review'].includes(String(appHandoff?.status || '')) ? ' · QA /app handoff pending' : '';
73
- if (staleSeconds != null && staleSeconds > 10)
74
- return `SLOTS telemetry stale ${staleSeconds}s · active ?${update}${madDb}${qaHandoff}`;
75
- return `SLOTS active ${active} · headless ${Number(counts.headless || 0)} · done ${Number(counts.completed || 0)} · fail ${Number(counts.failed || 0)} · q ${Number(counts.queued || 0)}${update}${madDb}${qaHandoff}`;
87
+ // Compact SKS branding header: package version + mission id so the SLOTS column self-identifies.
88
+ const brand = `SKS v${readPackageVersion()} · mission ${trimInline(String(snapshot.mission_id || '-'), 28)}`;
89
+ if (staleSeconds != null && staleSeconds > 60) {
90
+ return [brand, `SLOTS telemetry stale ${staleSeconds}s · active ?${update}${madDb}${qaHandoff}`].join('\n');
91
+ }
92
+ const countsLine = `SLOTS active ${active} · run ${Number(counts.running || 0)} · verify ${Number(counts.verifying || 0)} · headless ${Number(counts.headless || 0)} · done ${Number(counts.completed || 0)} · fail ${Number(counts.failed || 0)} · q ${Number(counts.queued || 0)}${update}${madDb}${qaHandoff}`;
93
+ const slotRows = renderTelemetrySlotRows(snapshot);
94
+ return [brand, countsLine, ...slotRows].join('\n');
95
+ }
96
+ function renderTelemetrySlotRows(snapshot) {
97
+ const slots = Object.values((snapshot?.slots || {}));
98
+ if (!slots.length)
99
+ return [];
100
+ const ordered = slots.sort((a, b) => {
101
+ const statusDelta = statusWeight(a?.status) - statusWeight(b?.status);
102
+ if (statusDelta)
103
+ return statusDelta;
104
+ return String(a?.slot_id || '').localeCompare(String(b?.slot_id || ''));
105
+ }).slice(0, 12);
106
+ return ordered.map((slot) => {
107
+ const id = `${trimInline(String(slot?.slot_id || 'slot-?'), 12)} g${Math.max(1, Math.floor(Number(slot?.generation_index) || 1))}`;
108
+ const task = trimInline(String(slot?.task_title || ''), 24);
109
+ const engine = workerBackendTag(slot?.backend, slot?.provider);
110
+ const role = trimInline(String(slot?.role || 'worker'), 10);
111
+ const status = trimInline(String(slot?.status || 'running'), 9);
112
+ const file = trimInline(String(slot?.current_file || '-'), 30);
113
+ const hb = slot?.latest_ts ? `${Math.max(0, Math.round((Date.now() - Date.parse(String(slot.latest_ts))) / 1000))}s` : '?';
114
+ return `${id}${task && task !== 'worker task' ? ` · ${task}` : ''} · ${engine} · ${role} · ${status} · ${file} · hb ${hb}`;
115
+ });
76
116
  }
77
117
  function isMadDbActive(capability) {
78
118
  if (!capability || capability.enabled !== true || capability.consumed === true)
@@ -11,10 +11,12 @@ export function renderZellijSlotPane(input) {
11
11
  ? 'now'
12
12
  : `${Math.max(1, Math.round(input.heartbeatAgeMs / 1000))}s ago`;
13
13
  const files = firstNonEmptyList(input.changedFiles, input.patchFiles, input.plannedFiles, input.currentFile ? [input.currentFile] : []);
14
- const events = (input.eventLines || []).filter(Boolean).slice(-3);
15
- const stdout = (input.stdoutTail || []).filter(Boolean).slice(-2);
14
+ const events = (input.eventLines || []).filter(Boolean).slice(-10);
15
+ const stdout = (input.stdoutTail || []).filter(Boolean).slice(-6);
16
16
  const stderr = (input.stderrTail || []).filter(Boolean).slice(-1);
17
+ const fixtureLoopProof = String(input.backend || '').includes('fixture') || String(input.patchStatus || '').includes('fixture');
17
18
  const rows = [
19
+ input.loopId ? `${trimInline(input.loopId, 28)} · ${trimInline(input.loopRole || input.role || 'worker', 14)} · ${input.slotId}` : null,
18
20
  `slot: ${input.slotId} / gen-${Math.max(1, Math.floor(Number(input.generationIndex) || 1))} / ${trimInline(input.status || 'running', 18)}`,
19
21
  `role: ${trimInline(input.role || 'worker', 18)} backend: ${trimInline(input.backend || 'codex-sdk', 20)} worktree: ${trimInline(input.worktreeId || '-', 18)}`,
20
22
  `runtime: fast ${formatFastMode(input.fastMode, input.serviceTier)} tier: ${trimInline(input.serviceTier || 'unknown', 12)} provider: ${trimInline(input.provider || 'unknown', 18)}`,
@@ -22,6 +24,8 @@ export function renderZellijSlotPane(input) {
22
24
  input.sessionId ? `session: ${trimInline(input.sessionId, 62)}` : null,
23
25
  `heartbeat: ${heartbeat}${input.heartbeatEvent ? ` event: ${trimInline(input.heartbeatEvent, 40)}` : ''}`,
24
26
  `doing: ${task}`,
27
+ input.loopGate ? `gate: ${trimInline(input.loopGate, 68)}${input.verifyStatus ? ` · ${trimInline(input.verifyStatus, 18)}` : ''}` : null,
28
+ fixtureLoopProof ? 'fixture loop proof · not production execution' : null,
25
29
  `files: ${trimInline(files.length ? files.join(', ') : 'no changed file yet', 78)}`,
26
30
  `patch: ${trimInline(input.patchStatus || 'queued', 24)} verify: ${trimInline(input.verifyStatus || 'queued', 24)}`,
27
31
  input.qaAppHandoffPending ? `QA app handoff pending: ${trimInline(input.qaAppHandoffArtifact || 'qa-loop/app-handoff.json', 55)}` : null,
@@ -89,7 +93,7 @@ async function renderZellijSlotPaneFromArtifactDir(input) {
89
93
  path.join(artifactDir, 'python-codex-sdk-events.jsonl'),
90
94
  path.join(artifactDir, 'local-llm-events.jsonl'),
91
95
  path.join(artifactDir, 'zellij-worker-pane-events.jsonl')
92
- ], 6);
96
+ ], 10);
93
97
  const patchFiles = patchPaths(patch || result);
94
98
  const changedFiles = normalizeList(result?.changed_files);
95
99
  const plannedFiles = normalizeList([
@@ -155,7 +159,7 @@ async function renderZellijSlotPaneFromArtifactDir(input) {
155
159
  heartbeatEvent: heartbeatRows.length ? formatArtifactEvent(heartbeatRows[heartbeatRows.length - 1]) : null,
156
160
  worktreeId: result?.worktree?.id || intake?.worktree?.id || null,
157
161
  eventLines: eventRows.map(formatArtifactEvent).filter(Boolean),
158
- stdoutTail: await readTextTailLines(path.join(artifactDir, 'worker.stdout.log'), 2),
162
+ stdoutTail: await readTextTailLines(path.join(artifactDir, 'worker.stdout.log'), 6),
159
163
  stderrTail: await readTextTailLines(path.join(artifactDir, 'worker.stderr.log'), 1),
160
164
  qaAppHandoffPending: ['pending', 'blocked_for_desktop_review'].includes(String(qaAppHandoff?.status || '')),
161
165
  qaAppHandoffArtifact: qaAppHandoff?.artifact_path || null,
@@ -190,6 +194,24 @@ export async function renderZellijSlotPaneStatusFromArtifacts(input) {
190
194
  telemetry_age_ms: status.telemetry_age_ms
191
195
  };
192
196
  }
197
+ // Root-cause-3 fix: the watch loop never exited, so completed/failed panes lingered forever and
198
+ // kept re-reporting staleness. Resolve whether this slot has reached a terminal state (status is
199
+ // completed/failed/drained in telemetry) AND its worker-result.json exists, so the pane command
200
+ // can render one final frame and exit instead of looping indefinitely.
201
+ export async function resolveZellijSlotPaneExit(input) {
202
+ const resultExists = Boolean(await readJson(path.join(path.resolve(input.artifactDir), 'worker-result.json')));
203
+ if (!resultExists)
204
+ return false;
205
+ if (!input.missionId || input.missionId === 'latest')
206
+ return true;
207
+ const snapshot = await readZellijSlotTelemetrySnapshot(path.resolve(input.artifactRoot || input.artifactDir), input.missionId).catch(() => null);
208
+ if (!snapshot)
209
+ return true;
210
+ const slot = findTelemetrySlot(snapshot, input.slotId, input.generationIndex);
211
+ if (!slot)
212
+ return true;
213
+ return slot.status === 'completed' || slot.status === 'failed' || slot.status === 'drained';
214
+ }
193
215
  export function buildZellijSlotPaneCommand(input) {
194
216
  const args = [
195
217
  input.cliPath,
@@ -257,7 +279,7 @@ function artifactFallbackRows(text) {
257
279
  .map((line) => line.replace(/^\|\s?/, '').replace(/\s?\|$/, '').trim())
258
280
  .filter((line) => /^(heartbeat|doing|files|event|out|err):\s+/i.test(line))
259
281
  .filter((line) => !/unknown|waiting for worker intake|no changed file yet/i.test(line))
260
- .slice(-7)
282
+ .slice(-12)
261
283
  .map((line) => `live: ${trimInline(line, 72)}`);
262
284
  }
263
285
  function findTelemetrySlot(snapshot, slotId, generationIndex) {
@@ -273,16 +295,19 @@ function telemetryStatus(snapshot) {
273
295
  const parsed = snapshot?.updated_at ? Date.parse(snapshot.updated_at) : NaN;
274
296
  const telemetryAgeMs = Number.isFinite(parsed) ? Math.max(0, Date.now() - parsed) : Number.MAX_SAFE_INTEGER;
275
297
  return {
276
- telemetry_stale: telemetryAgeMs > 3000,
298
+ // Root-cause-3 fix: with a 1000ms+jitter flush throttle the old 3000ms threshold flapped
299
+ // constantly. Raise to 15000ms so brief gaps between flushes don't read as "stale", and only
300
+ // claim the worker may be gone after 60000ms of true silence.
301
+ telemetry_stale: telemetryAgeMs > 15000,
277
302
  telemetry_age_ms: telemetryAgeMs
278
303
  };
279
304
  }
280
305
  function staleTelemetryRows(ageMs) {
281
306
  if (!Number.isFinite(ageMs))
282
307
  return ['telemetry stale; worker may still be running'];
283
- if (ageMs > 10000)
308
+ if (ageMs > 60000)
284
309
  return ['telemetry stale; worker may still be running'];
285
- if (ageMs > 3000)
310
+ if (ageMs > 15000)
286
311
  return [`telemetry stale ${(ageMs / 1000).toFixed(1)}s`];
287
312
  return [];
288
313
  }
@@ -392,15 +417,17 @@ function formatArtifactEvent(row) {
392
417
  if (!row)
393
418
  return '';
394
419
  const status = trimInline(row.lane_status || row.status || row.event || row.event_type || row.type || row.sdk_event_type || 'event', 18);
420
+ // Prefer the latest LLM message text (message_tail/message) so the live pane shows what the
421
+ // model is actually saying, falling back to tool/file/blocker context when no message exists.
395
422
  const detail = firstText([
423
+ row.message_tail,
424
+ row.message,
396
425
  row.current_tool && row.current_file ? `tool ${row.current_tool} file ${row.current_file}` : null,
397
426
  row.current_file ? `file ${row.current_file}` : null,
398
427
  row.current_tool ? `tool ${row.current_tool}` : null,
399
- row.message_tail,
400
428
  row.blocker ? `blocker ${row.blocker}` : null,
401
429
  row.request_id,
402
430
  row.pane_id ? `pane ${row.pane_id}` : null,
403
- row.message,
404
431
  row.reason
405
432
  ]);
406
433
  return trimInline(detail ? `${status}: ${detail}` : status, 96);