sneakoscope 0.7.64 → 0.7.65

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.7.64",
4
+ "version": "0.7.65",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
package/src/cli/main.mjs CHANGED
@@ -2010,10 +2010,26 @@ async function selftest() {
2010
2010
  };
2011
2011
  for (let i = 0; i < 5; i++) {
2012
2012
  const stop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'continuing implementation without visible questions' });
2013
- if (stop?.gate === 'clarification' || /ambiguity|clarification|question/i.test(String(stop?.reason || ''))) throw new Error('selftest failed: stale clarification gate still hard-paused without visible questions');
2013
+ if (stop?.decision !== 'block' || stop?.gate !== 'clarification' || !/paused|answers|pipeline answer/i.test(String(stop?.reason || ''))) throw new Error('selftest failed: clarification not paused');
2014
2014
  }
2015
- const visibleQuestionStop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'Required questions still pending:\n1. GOAL_PRECISE: What should be changed?\n\nReply by slot id; I will seal the contract with sks pipeline answer latest --stdin.' });
2016
- if (visibleQuestionStop?.gate === 'clarification' || /ambiguity|clarification/i.test(String(visibleQuestionStop?.reason || ''))) throw new Error('selftest failed: visible stale clarification wording still blocked stop');
2015
+ if (await exists(path.join(clarificationMission.dir, 'hard-blocker.json'))) throw new Error('selftest failed: clarification wrote hard-blocker');
2016
+ const visibleQuestionStop = await evaluateStop(tmp, clarificationState, { last_assistant_message: 'Required questions\n1. GOAL_PRECISE\nsks pipeline answer latest --stdin' });
2017
+ if (visibleQuestionStop?.continue !== true) throw new Error('selftest failed: visible clarification did not wait');
2018
+ const cg = await projectGateStatus(tmp, clarificationState);
2019
+ if (!cg.blockers.includes('clarification-gate:explicit_user_answers') || !cg.blockers.includes('clarification-gate:pipeline_answer')) throw new Error('selftest failed: missing clarification blockers');
2020
+ await setCurrent(tmp, clarificationState);
2021
+ const hookPath = path.join(packageRoot(), 'bin', 'sks.mjs');
2022
+ const blockedPre = await runProcess(process.execPath, [hookPath, 'hook', 'pre-tool'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, tool_name: 'Bash', tool_input: { command: 'npm run selftest' } }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2023
+ if (blockedPre.code !== 0) throw new Error(`selftest failed: pre-tool exit ${blockedPre.code}: ${blockedPre.stderr}`);
2024
+ const bp = JSON.parse(blockedPre.stdout || '{}');
2025
+ if (bp.decision !== 'block' || !String(bp.reason || '').includes('waiting for explicit user answers')) throw new Error('selftest failed: pre-tool not blocked');
2026
+ const deniedPermission = await runProcess(process.execPath, [hookPath, 'hook', 'permission-request'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, command: 'npm run selftest', action: 'Run command' }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2027
+ if (deniedPermission.code !== 0) throw new Error(`selftest failed: permission exit ${deniedPermission.code}: ${deniedPermission.stderr}`);
2028
+ const dp = JSON.parse(deniedPermission.stdout || '{}');
2029
+ if (dp.hookSpecificOutput?.decision?.behavior !== 'deny' || !String(dp.hookSpecificOutput?.decision?.message || '').includes('waiting for explicit user answers')) throw new Error('selftest failed: permission not denied');
2030
+ const answerTool = await runProcess(process.execPath, [hookPath, 'hook', 'pre-tool'], { cwd: tmp, input: JSON.stringify({ cwd: tmp, tool_name: 'Bash', tool_input: { command: `sks pipeline answer ${clarificationMission.id} --stdin` } }), timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
2031
+ if (answerTool.code !== 0) throw new Error(`selftest failed: answer hook exit ${answerTool.code}: ${answerTool.stderr}`);
2032
+ if (JSON.parse(answerTool.stdout || '{}').decision === 'block') throw new Error('selftest failed: answer command blocked');
2017
2033
  await setCurrent(tmp, loopState);
2018
2034
  const dfixPromptHook = await runProcess(process.execPath, [path.join(packageRoot(), 'bin', 'sks.mjs'), 'hook', 'user-prompt-submit'], {
2019
2035
  cwd: tmp,
@@ -3275,7 +3291,9 @@ async function selftest() {
3275
3291
  await writeTextAtomic(fakeTmuxBin, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconst log = process.env.SKS_FAKE_TMUX_LOG;\nif (log) fs.appendFileSync(log, process.argv.slice(2).join(' ') + '\\n');\nconst cmd = process.argv[2];\nif (cmd === 'has-session') process.exit(0);\nif (cmd === 'kill-session') process.exit(0);\nif (cmd === 'kill-pane') process.exit(0);\nif (cmd === 'new-session') { console.log('%1'); process.exit(0); }\nif (cmd === 'split-window') { console.log(process.env.SKS_FAKE_TMUX_SPLIT_ID || '%2'); process.exit(0); }\nif (cmd === 'list-windows') { console.log('@1'); process.exit(0); }\nif (cmd === 'display-message') { console.log(process.env.SKS_FAKE_TMUX_DISPLAY || 'sks-existing-selftest\\t@1\\t%1'); process.exit(0); }\nif (cmd === 'list-panes') { console.log(process.env.SKS_FAKE_TMUX_LIST || ''); process.exit(0); }\nif (cmd === 'set-option' || cmd === 'select-layout' || cmd === 'resize-window' || cmd === 'set-window-option' || cmd === 'set-hook') process.exit(0);\nprocess.exit(0);\n`);
3276
3292
  await fsp.chmod(fakeTmuxBin, 0o755);
3277
3293
  const previousFakeTmuxLog = process.env.SKS_FAKE_TMUX_LOG;
3294
+ const previousPath = process.env.PATH;
3278
3295
  process.env.SKS_FAKE_TMUX_LOG = fakeTmuxLog;
3296
+ process.env.PATH = `${fakeTmuxDir}${path.delimiter}${previousPath || ''}`;
3279
3297
  const recreatedTmux = await createTmuxSession({ root: tmp, session: 'sks-existing-selftest', tmux: { bin: fakeTmuxBin }, codex: { bin: process.execPath } }, [
3280
3298
  { cwd: tmp, command: 'pwd', role: 'overview' },
3281
3299
  { cwd: tmp, command: 'pwd', role: 'lane' }
@@ -3332,6 +3350,8 @@ async function selftest() {
3332
3350
  if (!madCockpit.created || madCockpit.mode !== 'mad_session' || madCockpit.opened?.panes?.length !== 1 || !madTmuxLogText.includes('new-session') || madTmuxLogText.includes('split-window')) throw new Error('selftest failed: MAD tmux launch should create one pane and leave split panes to Team lanes');
3333
3351
  if (previousFakeTmuxLog === undefined) delete process.env.SKS_FAKE_TMUX_LOG;
3334
3352
  else process.env.SKS_FAKE_TMUX_LOG = previousFakeTmuxLog;
3353
+ if (previousPath === undefined) delete process.env.PATH;
3354
+ else process.env.PATH = previousPath;
3335
3355
  const codexLaunchArgs = defaultCodexLaunchArgs({ SKS_CODEX_REASONING: 'low' }).join(' ');
3336
3356
  if (!codexLaunchArgs.includes('service_tier="fast"') || !codexLaunchArgs.includes('model_reasoning_effort="low"')) throw new Error('selftest failed: Codex tmux launch args do not force Fast service tier plus dynamic reasoning');
3337
3357
  await initTeamLive(teamId, teamDir, '역할 팀 테스트', { agentSessions: roleTeamPlan.agent_session_count, roleCounts: roleTeamPlan.role_counts, roster: roleTeamPlan.roster });
@@ -3422,7 +3442,7 @@ async function selftest() {
3422
3442
  if (!teamLive.includes('Context tracking SSOT: TriWiki')) throw new Error('selftest failed: team live transcript missing TriWiki context tracking');
3423
3443
  if (!(await readTeamTranscriptTail(teamDir, 1)).join('\n').includes('selftest mapped options')) throw new Error('selftest failed: team transcript tail missing event');
3424
3444
  const teamLane = await renderTeamAgentLane(teamDir, { missionId: teamId, agent: 'analysis_scout_1', lines: 4 });
3425
- if (!teamLane.includes('SKS Team Agent Lane') || !teamLane.includes('analysis_scout_1') || !teamLane.includes('selftest mapped repo slice')) throw new Error('selftest failed: team agent lane missing agent event context');
3445
+ if (!teamLane.includes('selftest mapped repo slice')) throw new Error('selftest failed: team agent lane missing event context');
3426
3446
  const teamLaneCli = await runProcess(process.execPath, [hookBin, 'team', 'lane', teamId, '--agent', 'analysis_scout_1', '--lines', '4'], { cwd: tmp, env: { SKS_DISABLE_UPDATE_CHECK: '1' }, timeoutMs: 15000, maxOutputBytes: 64 * 1024 });
3427
3447
  if (teamLaneCli.code !== 0 || !String(teamLaneCli.stdout || '').includes('SKS Team Agent Lane') || !String(teamLaneCli.stdout || '').includes('analysis_scout_1')) throw new Error('selftest failed: sks team lane CLI did not render an agent lane');
3428
3448
  await writeTextAtomic(path.join(teamDir, 'team-analysis.md'), '- claim: analysis scout mapped route registry | source: src/core/routes.mjs | risk: high | confidence: supported\n');
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fsp from 'node:fs/promises';
3
- import { readJson, writeJsonAtomic, writeTextAtomic, appendJsonlBounded, nowIso, exists, ensureDir, packageRoot, dirSize, formatBytes, PACKAGE_VERSION, sksRoot } from '../core/fsx.mjs';
3
+ import { readJson, writeJsonAtomic, writeTextAtomic, appendJsonlBounded, nowIso, exists, ensureDir, packageRoot, dirSize, formatBytes, PACKAGE_VERSION, sksRoot, readStdin } from '../core/fsx.mjs';
4
4
  import { initProject } from '../core/init.mjs';
5
5
  import { getCodexInfo, runCodexExec } from '../core/codex-adapter.mjs';
6
6
  import { createMission, loadMission, findLatestMission, missionDir, setCurrent, stateFile } from '../core/mission.mjs';
@@ -640,7 +640,7 @@ export async function dbCommand(sub, args = []) {
640
640
  return;
641
641
  }
642
642
  if (sub === 'scan-payload') {
643
- const raw = await fsp.readFile(0, 'utf8');
643
+ const raw = await readStdin();
644
644
  const payload = raw.trim() ? JSON.parse(raw) : {};
645
645
  const decision = await checkDbOperation(root, {}, payload, { duringNoQuestion: false });
646
646
  console.log(JSON.stringify(decision, null, 2));
@@ -204,7 +204,7 @@ function recursivelyCollectStrings(obj, out = [], depth = 0) {
204
204
  if (Array.isArray(obj)) { for (const x of obj) recursivelyCollectStrings(x, out, depth + 1); return out; }
205
205
  if (typeof obj === 'object') {
206
206
  for (const [k, v] of Object.entries(obj)) {
207
- if (/^(sql|query|statement|command|migration|body|input|text)$/i.test(k) || typeof v === 'object') recursivelyCollectStrings(v, out, depth + 1);
207
+ if (/^(sql|query|statement|command|migration|body|input|text|action|purpose|intent|description)$/i.test(k) || typeof v === 'object') recursivelyCollectStrings(v, out, depth + 1);
208
208
  }
209
209
  }
210
210
  return out;
@@ -216,6 +216,12 @@ function looksLikeSqlText(text = '') {
216
216
  || /;\s*(select|with|show|explain|describe|insert|update|delete|drop|truncate|alter|create|grant|revoke)\b/i.test(s);
217
217
  }
218
218
 
219
+ function hasReadOnlyDbInspectionIntent(text = '') {
220
+ const s = String(text || '').toLowerCase();
221
+ if (/\b(insert|update|delete|drop|truncate|alter|create|grant|revoke|write|mutate|migration|apply|push|reset|repair)\b|삭제|수정|변경|쓰기|삽입|생성|초기화|적용/i.test(s)) return false;
222
+ return /\b(read.?only|select|with|show|explain|describe|inspect|list|get|fetch|count|schema)\b|조회|확인|읽|보기|목록|스키마/i.test(s);
223
+ }
224
+
219
225
  export function classifyToolPayload(payload = {}) {
220
226
  const strings = recursivelyCollectStrings(payload).slice(0, 200);
221
227
  const toolName = [payload.tool_name, payload.toolName, payload.name, payload.tool?.name, payload.server, payload.mcp_tool, payload.tool, payload.type].filter(Boolean).join(' ').toLowerCase();
@@ -241,7 +247,14 @@ export function classifyToolPayload(payload = {}) {
241
247
  }
242
248
  if (toolReasons.includes('dangerous_supabase_management_tool')) level = 'destructive';
243
249
  if (toolReasons.includes('migration_apply_tool') && level !== 'destructive') level = 'write';
244
- if (toolReasons.includes('database_tool') && level === 'none') level = 'possible_db';
250
+ if (toolReasons.includes('database_tool') && level === 'none') {
251
+ if (hasReadOnlyDbInspectionIntent([toolName, ...strings].join(' '))) {
252
+ level = 'safe';
253
+ reasons.push('read_only_database_inspection_intent');
254
+ } else {
255
+ level = 'possible_db';
256
+ }
257
+ }
245
258
  return { level, toolName, toolReasons, reasons, sql: sqlClass, command: commandClass, stringsExamined: strings.length };
246
259
  }
247
260
 
package/src/core/fsx.mjs 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
 
8
- export const PACKAGE_VERSION = '0.7.64';
8
+ export const PACKAGE_VERSION = '0.7.65';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -181,14 +181,17 @@ async function hookUserPrompt(root, state, payload, noQuestion) {
181
181
  }
182
182
 
183
183
  function isClarificationAwaiting(state = {}) {
184
- return Boolean(state.clarification_required && String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS'))
185
- || ['QALOOP_CLARIFICATION_AWAITING_ANSWERS'].includes(String(state.phase || ''));
184
+ const phase = String(state.phase || '');
185
+ const stopGate = String(state.stop_gate || '');
186
+ const gateAwaiting = phase.includes('CLARIFICATION_AWAITING_ANSWERS') || stopGate === 'clarification-gate';
187
+ if (!gateAwaiting) return false;
188
+ if (!state?.mission_id) return false;
189
+ if (state.ambiguity_gate_required !== true || state.ambiguity_gate_passed === true) return false;
190
+ return Boolean(state.clarification_required || state.implementation_allowed === false);
186
191
  }
187
192
 
188
193
  function isBlockingClarificationAwaiting(state = {}) {
189
- if (!isClarificationAwaiting(state)) return false;
190
- return ['QALoop', 'PPT'].includes(String(state.route || ''))
191
- || ['QALOOP', 'PPT'].includes(String(state.mode || ''));
194
+ return isClarificationAwaiting(state);
192
195
  }
193
196
 
194
197
  function looksLikeClarificationCancel(prompt = '') {
@@ -316,7 +319,7 @@ function clarificationGateLocked(state = {}) {
316
319
  && state.implementation_allowed === false
317
320
  && state.ambiguity_gate_required === true
318
321
  && state.ambiguity_gate_passed !== true
319
- && isBlockingClarificationAwaiting(state)
322
+ && (String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS') || String(state.stop_gate || '') === 'clarification-gate')
320
323
  );
321
324
  }
322
325
 
@@ -509,8 +509,7 @@ export async function activeRouteContext(root, state) {
509
509
  return `SKS Honest Mode found unresolved gaps for ${state.route_command || state.route || state.mode}. Do not ask ambiguity questions again. Continue from the sealed decision-contract.json, inspect .sneakoscope/missions/${state.mission_id}/honest-loopback.json, fix gaps, rerun verification, refresh/validate TriWiki, then retry final Honest Mode.${reasoningNote}${planNote}`;
510
510
  }
511
511
  if (state.clarification_required && String(state.phase || '').includes('CLARIFICATION_AWAITING_ANSWERS')) {
512
- if (['QALoop', 'PPT'].includes(String(state.route || '')) || ['QALOOP', 'PPT'].includes(String(state.mode || ''))) return clarificationAwaitingAnswersContext(root, state);
513
- return `Previous ${state.route_command || state.route || state.mode || 'SKS'} clarification state is non-blocking. Do not reprint old question sheets; prepare the current prompt normally and replace stale route state when needed.`;
512
+ return clarificationAwaitingAnswersContext(root, state);
514
513
  }
515
514
  if (state.clarification_passed && String(state.phase || '').includes('CLARIFICATION_CONTRACT_SEALED')) {
516
515
  return `Route contract sealed for ${state.route_command || state.route || state.mode}. Use decision-contract.json and ${PIPELINE_PLAN_ARTIFACT} before executing the route. Before the next route phase, read relevant TriWiki context, hydrate low-trust claims from source, and refresh/validate TriWiki again after new findings or artifact changes. Next atomic action: continue the original route lifecycle with the inferred goal, constraints, non-goals, risk boundary, and test scope.${planNote}`;
@@ -993,7 +992,7 @@ async function clarificationAwaitingAnswersContext(root, state) {
993
992
  const id = state.mission_id;
994
993
  if (!id) return '';
995
994
  const planNote = await activePipelinePlanNote(root, state);
996
- return `Active SKS route ${state.route_command || state.route || state.mode} has stale prequestion state. Do not reprint old question sheets. Re-prepare the current prompt so the route auto-seals from prompt, TriWiki/current-code defaults, and conservative SKS policy, or seal the existing mission internally with "sks pipeline answer ${id} --stdin" using inferred answers.${planNote}`;
995
+ return `Active SKS route ${state.route_command || state.route || state.mode} is paused at its ambiguity gate and waiting for explicit user answers. Do not advance to implementation, tests, route materialization, or a new pipeline stage. If the user's reply is now available, seal it with "sks pipeline answer ${id} --stdin"; otherwise show only the missing slot ids from .sneakoscope/missions/${id}/questions.md and wait.${planNote}`;
997
996
  }
998
997
 
999
998
  function clarificationVisibleResponseContract(id) {
@@ -1031,9 +1030,9 @@ async function clarificationStopReason(root, state, kind) {
1031
1030
  const files = state?.mission_id ? `
1032
1031
  Answer schema: .sneakoscope/missions/${state.mission_id}/required-answers.schema.json` : '';
1033
1032
  const command = `sks pipeline answer ${id} --stdin`;
1034
- const title = `SKS ${routeName} has stale prequestion state.`;
1033
+ const title = `SKS ${routeName} is paused for explicit user answers.`;
1035
1034
  return `${title}
1036
- Do not reprint old question sheets. Seal internally with inferred answers using "${command}", or re-prepare the current prompt so the route auto-seals.${files}
1035
+ Do not continue to implementation or the next pipeline stage until the ambiguity gate is sealed. Ask only the missing slot ids if they have not already been shown, then wait for the user. When the user's reply is available, seal it with "${command}".${files}
1037
1036
 
1038
1037
  After the contract is sealed, continue the original ${routeName} route.`;
1039
1038
  }
@@ -1329,7 +1328,15 @@ export async function evaluateStop(root, state, payload, opts = {}) {
1329
1328
  }
1330
1329
 
1331
1330
  function clarificationGatePending(state = {}) {
1332
- return false;
1331
+ const phase = String(state.phase || '');
1332
+ return Boolean(state?.clarification_required && phase.includes('CLARIFICATION_AWAITING_ANSWERS'))
1333
+ || Boolean(
1334
+ state?.mission_id
1335
+ && state.implementation_allowed === false
1336
+ && state.ambiguity_gate_required === true
1337
+ && state.ambiguity_gate_passed !== true
1338
+ && (phase.includes('CLARIFICATION_AWAITING_ANSWERS') || state.stop_gate === 'clarification-gate')
1339
+ );
1333
1340
  }
1334
1341
 
1335
1342
  async function complianceBlock(root, state = {}, reason = '', detail = {}) {
@@ -6,6 +6,7 @@ export { MIN_TEAM_REVIEWER_LANES, MIN_TEAM_REVIEW_POLICY_TEXT, MIN_TEAM_REVIEW_S
6
6
 
7
7
  const MAX_LIVE_BYTES = 192 * 1024;
8
8
  const TEAM_RUNTIME_TASKS_ARTIFACT = 'team-runtime-tasks.json';
9
+ const TEAM_SESSION_CLEANUP_ARTIFACT = 'team-session-cleanup.json';
9
10
  const DEFAULT_AGENTS = ['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer'];
10
11
  export const DEFAULT_TEAM_ROLE_COUNTS = { user: 1, planner: 1, reviewer: MIN_TEAM_REVIEWER_LANES, executor: 3 };
11
12
  export const DEFAULT_MAX_TEAM_AGENT_SESSIONS = 6;
@@ -471,7 +472,19 @@ async function reconcileTeamTmuxFromEvent(dir, record = {}) {
471
472
  }
472
473
 
473
474
  export async function readTeamControl(dir) {
474
- return readJson(teamLogPaths(dir).control, defaultTeamControl(path.basename(dir)));
475
+ const control = await readJson(teamLogPaths(dir).control, defaultTeamControl(path.basename(dir)));
476
+ const cleanup = await readJson(path.join(dir, TEAM_SESSION_CLEANUP_ARTIFACT), null).catch(() => null);
477
+ if (!cleanup || (cleanup.passed !== true && cleanup.live_transcript_finalized !== true && cleanup.all_sessions_closed !== true)) return control;
478
+ return {
479
+ ...defaultTeamControl(path.basename(dir)),
480
+ ...control,
481
+ status: 'ended',
482
+ cleanup_requested: true,
483
+ cleanup_requested_at: cleanup.updated_at || cleanup.completed_at || cleanup.closed_at || control.cleanup_requested_at || 'artifact',
484
+ cleanup_requested_by: cleanup.agent || control.cleanup_requested_by || 'parent_orchestrator',
485
+ cleanup_reason: cleanup.reason || control.cleanup_reason || `${TEAM_SESSION_CLEANUP_ARTIFACT} passed.`,
486
+ final_message: cleanup.final_message || control.final_message || 'Team session ended. Lane follow loops stop and managed tmux Team panes should close.'
487
+ };
475
488
  }
476
489
 
477
490
  export async function requestTeamSessionCleanup(dir, opts = {}) {
@@ -532,26 +545,30 @@ export async function renderTeamAgentLane(dir, opts = {}) {
532
545
  const missionId = opts.missionId || dashboard?.mission_id || runtime?.mission_id || path.basename(dir);
533
546
  const status = dashboard?.agents?.[agent] || {};
534
547
  const runtimeTasks = Array.isArray(runtime?.tasks) ? runtime.tasks : Array.isArray(runtime) ? runtime : [];
535
- const assignedTasks = runtimeTasks.filter((task) => task?.worker === agent || task?.agent_hint === agent);
536
548
  const eventWindow = await readTeamTranscriptTail(dir, Math.max(lines * 8, 80));
537
549
  const parsedWindow = eventWindow.map(parseTranscriptLine).filter(Boolean);
538
- const agentEvents = parsedWindow.filter((event) => event?.agent === agent || eventAddressedTo(event, agent)).slice(-lines);
539
- const directMessages = parsedWindow.filter((event) => event?.type === 'message' && eventAddressedTo(event, agent)).slice(-lines);
540
- const globalTail = (await readTeamTranscriptTail(dir, lines)).map(parseTranscriptLine).filter(Boolean);
550
+ const aliases = teamLaneAliases(agent, parsedWindow, dashboard, runtimeTasks);
551
+ const aliasSet = new Set(aliases);
552
+ const statusAliases = aliases.length > 1 ? [...aliases.slice(1), aliases[0]] : aliases;
553
+ const laneStatus = statusAliases.map((id) => dashboard?.agents?.[id]).find((entry) => entry && entry.status && entry.status !== 'pending') || status;
554
+ const assignedTasks = runtimeTasks.filter((task) => aliasSet.has(task?.worker) || aliasSet.has(task?.agent_hint));
555
+ const agentEvents = parsedWindow.filter((event) => aliasSet.has(event?.agent) || aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
556
+ const directMessages = parsedWindow.filter((event) => event?.type === 'message' && aliases.some((id) => eventAddressedTo(event, id))).slice(-lines);
541
557
  const laneStyle = teamLaneTextStyle(agent);
542
558
  return [
543
559
  `# SKS Team Agent Lane`,
544
560
  '',
545
561
  `Mission: ${missionId}`,
546
562
  `Agent: ${agent}`,
563
+ aliases.length > 1 ? `Mirrored agents: ${aliases.slice(1).join(', ')}` : null,
547
564
  `Lane color: ${laneStyle.color_name}`,
548
565
  `Requested phase: ${phase || 'any'}`,
549
566
  teamCleanupRequested(control) ? `Cleanup: requested at ${control.cleanup_requested_at || 'unknown'}` : null,
550
567
  '',
551
568
  `## Agent Status`,
552
- `- status: ${status.status || 'pending'}`,
553
- `- phase: ${status.phase || 'unknown'}`,
554
- `- last_seen: ${status.last_seen || 'never'}`,
569
+ `- status: ${laneStatus.status || 'pending'}`,
570
+ `- phase: ${laneStatus.phase || 'unknown'}`,
571
+ `- last_seen: ${laneStatus.last_seen || 'never'}`,
555
572
  '',
556
573
  `## Assigned Runtime Tasks`,
557
574
  ...(runtime ? formatRuntimeTasks(assignedTasks) : ['- team-runtime-tasks.json not available yet.']),
@@ -561,9 +578,11 @@ export async function renderTeamAgentLane(dir, opts = {}) {
561
578
  '',
562
579
  `## Direct Messages`,
563
580
  ...(directMessages.length ? directMessages.map(formatTranscriptEvent) : ['- No direct or broadcast messages in the bounded tail.']),
564
- '',
565
- `## Fallback Global Tail`,
566
- ...(globalTail.length ? globalTail.map(formatTranscriptEvent) : ['- No transcript events yet.']),
581
+ opts.includeGlobalTail ? '' : null,
582
+ opts.includeGlobalTail ? `## Global Tail` : null,
583
+ ...(opts.includeGlobalTail
584
+ ? (await readTeamTranscriptTail(dir, lines)).map(parseTranscriptLine).filter(Boolean).map(formatTranscriptEvent)
585
+ : []),
567
586
  teamCleanupRequested(control) ? ['', renderTeamCleanupSummary(control)].join('\n') : null
568
587
  ].filter((line) => line !== null).join('\n');
569
588
  }
@@ -685,6 +704,32 @@ function eventAddressedTo(event = {}, agent = '') {
685
704
  return target === name || target === 'all' || target === '*' || target === 'broadcast';
686
705
  }
687
706
 
707
+ function teamLaneAliases(agent = '', events = [], dashboard = null, runtimeTasks = []) {
708
+ const primary = String(agent || '').trim();
709
+ if (!primary) return [];
710
+ const aliases = [primary];
711
+ const ordinal = numberedLaneOrdinal(primary);
712
+ if (!ordinal) return aliases;
713
+ const role = teamLaneTextStyle(primary).role;
714
+ const candidates = uniqueAgentIds([
715
+ ...Object.keys(dashboard?.agents || {}),
716
+ ...events.map((event) => event?.agent).filter(Boolean),
717
+ ...runtimeTasks.flatMap((task) => [task?.worker, task?.agent_hint]).filter(Boolean)
718
+ ])
719
+ .filter((id) => id !== primary)
720
+ .filter((id) => !DEFAULT_AGENTS.includes(id))
721
+ .filter((id) => teamLaneTextStyle(id).role === role)
722
+ .filter((id) => !numberedLaneOrdinal(id));
723
+ const concrete = candidates[ordinal - 1];
724
+ if (concrete) aliases.push(concrete);
725
+ return aliases;
726
+ }
727
+
728
+ function numberedLaneOrdinal(agent = '') {
729
+ const match = String(agent || '').match(/_(\d+)$/);
730
+ return match ? Number(match[1]) : 0;
731
+ }
732
+
688
733
  function teamLaneTextStyle(agentId = '') {
689
734
  const id = String(agentId || '').toLowerCase();
690
735
  if (!id || id === 'mission_overview' || id === 'overview') return { role: 'overview', color_name: 'Blue' };
@@ -119,6 +119,9 @@ const TERMINAL_TEAM_AGENT_STATUSES = new Set([
119
119
  'tmux_lane_closed'
120
120
  ]);
121
121
 
122
+ const LEGACY_TEAM_PANE_TITLE_RE = /^(?:overview: mission_overview|scout: analysis_scout|plan: (?:debate|consensus|planner|user)|exec: (?:executor|implementation|worker)|review: (?:reviewer|qa|validation)|safety:)/;
123
+ const GENERIC_TEAM_AGENT_IDS = new Set(['parent_orchestrator', 'analysis_scout', 'team_consensus', 'implementation_worker', 'db_safety_reviewer', 'qa_reviewer']);
124
+
122
125
  export function isTmuxShellSession(env = process.env) {
123
126
  return Boolean(String(env.TMUX || '').trim());
124
127
  }
@@ -395,6 +398,11 @@ function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts =
395
398
  const visible = teamViewAgentIds(plan).filter((id) => id && id !== 'mission_overview');
396
399
  const agents = dashboard?.agents && typeof dashboard.agents === 'object' ? dashboard.agents : null;
397
400
  if (!agents) return opts.plannedFallback ? visible : [];
401
+ const concrete = concreteDashboardAgentIds(agents, visible).filter((id) => {
402
+ const status = String(agents[id]?.status || '').trim().toLowerCase();
403
+ return status && status !== 'pending' && !isTerminalTeamAgentStatus(status);
404
+ });
405
+ if (concrete.length) return uniqueAgentIds(concrete);
398
406
  const active = [];
399
407
  for (const id of visible) {
400
408
  const entry = agents[id] || {};
@@ -407,6 +415,17 @@ function teamCockpitAgentIds(plan = {}, dashboard = null, control = null, opts =
407
415
  return uniqueAgentIds(active);
408
416
  }
409
417
 
418
+ function concreteDashboardAgentIds(agents = {}, planned = []) {
419
+ const plannedSet = new Set(planned);
420
+ const concrete = Object.keys(agents)
421
+ .filter((id) => id && !plannedSet.has(id))
422
+ .filter((id) => !GENERIC_TEAM_AGENT_IDS.has(id))
423
+ .filter((id) => !/_(?:\d+)$/.test(id));
424
+ if (!concrete.length) return [];
425
+ const plannedRoles = new Set(planned.map((id) => teamLaneStyle(id).role));
426
+ return concrete.filter((id) => plannedRoles.has(teamLaneStyle(id).role));
427
+ }
428
+
410
429
  function teamCockpitLanes(plan = {}, dashboard = null, control = null, opts = {}) {
411
430
  const agents = teamCockpitAgentIds(plan, dashboard, control, opts);
412
431
  if (!agents.length) return [];
@@ -441,6 +460,10 @@ function parseTmuxPaneLines(stdout = '') {
441
460
  }).filter((pane) => /^%\d+$/.test(pane.pane_id || ''));
442
461
  }
443
462
 
463
+ function isLegacyTeamPane(pane = {}) {
464
+ return LEGACY_TEAM_PANE_TITLE_RE.test(String(pane.title || '').trim());
465
+ }
466
+
444
467
  async function listTmuxWindowPanes(bin, windowId) {
445
468
  const format = ['#{pane_id}', '#{pane_title}', '#{pane_current_command}', '#{@sks_team_managed}', '#{@sks_mission_id}', '#{@sks_agent_id}', '#{@sks_lane_role}'].join('\t');
446
469
  const run = await tmuxRun(bin, ['list-panes', '-t', windowId, '-F', format], { timeoutMs: 5000, maxOutputBytes: 32 * 1024 });
@@ -784,7 +807,13 @@ export async function reconcileTmuxTeamCockpit({ root, missionId, plan = {}, pro
784
807
 
785
808
  export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFile = null, json = false, attach = false, args = [] } = {}) {
786
809
  const launch = await buildTmuxLaunchPlan({ root, session: `sks-team-${missionId}` });
787
- const visibleAgents = teamViewAgentIds(plan);
810
+ const missionDir = path.join(launch.root, '.sneakoscope', 'missions', missionId);
811
+ const dashboard = await readTeamDashboard(missionDir).catch(() => null);
812
+ const control = await readTeamControl(missionDir).catch(() => null);
813
+ const plannedAgents = teamViewAgentIds(plan);
814
+ const concreteAgents = concreteDashboardAgentIds(dashboard?.agents || {}, plannedAgents);
815
+ const cleanupRequested = teamCleanupRequested(control);
816
+ const visibleAgents = cleanupRequested ? [] : (json ? plannedAgents : (concreteAgents.length ? concreteAgents : plannedAgents));
788
817
  const commands = visibleAgents.map((agentId) => ({
789
818
  agent: agentId,
790
819
  command: teamAgentCommand(launch.root, missionId, agentId, teamLanePhase(agentId), promptFile),
@@ -792,7 +821,7 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
792
821
  title: teamLaneTitle(agentId)
793
822
  }));
794
823
  const overview = { agent: 'mission_overview', role: 'overview', command: teamOverviewCommand(launch.root, missionId), style: teamLaneStyle('mission_overview'), title: teamLaneTitle('mission_overview') };
795
- const lanes = [overview, ...commands.map((entry) => ({ ...entry, role: entry.style.role }))];
824
+ const lanes = cleanupRequested ? [] : [overview, ...commands.map((entry) => ({ ...entry, role: entry.style.role }))];
796
825
  const splitUi = {
797
826
  mode: 'single_window_split_panes',
798
827
  window: 'sks',
@@ -813,14 +842,14 @@ export async function launchTmuxTeamView({ root, missionId, plan = {}, promptFil
813
842
  agents: commands,
814
843
  lanes,
815
844
  split_ui: splitUi,
816
- cleanup_policy: 'mark-complete; tmux panes remain user controlled',
845
+ cleanup_policy: 'mark-complete; close SKS-managed Team panes; main Codex pane remains user controlled',
817
846
  blockers: launch.blockers,
818
847
  attach_command: launch.attach_command
819
848
  };
820
849
  if (json || !launch.ready) return result;
821
850
  const wantsSeparateSession = args.includes('--separate-session') || args.includes('--new-session') || args.includes('--legacy-team-session') || args.includes('--no-dynamic-team-tmux');
822
851
  if (!wantsSeparateSession) {
823
- const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, plannedFallback: true });
852
+ const cockpit = await reconcileTmuxTeamCockpit({ root: launch.root, missionId, plan, promptFile, dashboard, control, plannedFallback: true });
824
853
  result.dynamic_cockpit = cockpit;
825
854
  if (cockpit.ok) {
826
855
  result.created = true;
@@ -962,8 +991,25 @@ async function readTmuxTeamRecord(root, missionId) {
962
991
  export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSession = false } = {}) {
963
992
  const resolvedRoot = path.resolve(root || await sksRoot());
964
993
  const record = await readTmuxTeamRecord(resolvedRoot, missionId);
965
- if (!record?.session) return { ok: false, skipped: true, reason: 'no recorded tmux Team session', mission_id: missionId };
994
+ if (!record?.session) {
995
+ const legacy = await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, missionId, { closeSession }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
996
+ return {
997
+ ok: legacy.ok,
998
+ skipped: legacy.closed_lane_count === 0 && !legacy.killed_session,
999
+ reason: legacy.reason,
1000
+ mission_id: missionId,
1001
+ legacy_cleanup: legacy,
1002
+ requested_close_surfaces: legacy.requested_close_surfaces || 0,
1003
+ closed_surfaces: legacy.closed_lane_count || (legacy.killed_session ? 1 : 0)
1004
+ };
1005
+ }
966
1006
  const dynamicCleanup = await reconcileTmuxTeamCockpit({ root: resolvedRoot, missionId: record.mission_id || missionId, close: true }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'dynamic tmux cleanup failed' }));
1007
+ const recordedCleanup = dynamicCleanup?.ok
1008
+ ? null
1009
+ : await cleanupRecordedTmuxTeamPanes(resolvedRoot, record.mission_id || missionId, record).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'recorded tmux cleanup failed' }));
1010
+ const legacyCleanup = (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count)
1011
+ ? null
1012
+ : await cleanupLegacyTmuxTeamSurfaces(resolvedRoot, record.mission_id || missionId, { closeSession: false }).catch((err) => ({ ok: false, skipped: true, reason: err.message || 'legacy tmux cleanup failed' }));
967
1013
  let killed_session = false;
968
1014
  if ((closeSession || closeSession === true) && record.mode !== 'current_session_dynamic_panes') {
969
1015
  const tmuxBin = await findTmuxBin() || 'tmux';
@@ -979,13 +1025,110 @@ export async function cleanupTmuxTeamView({ root, missionId = 'latest', closeSes
979
1025
  close_session: Boolean(closeSession),
980
1026
  killed_session,
981
1027
  dynamic_cleanup: dynamicCleanup,
982
- requested_close_surfaces: closeSession ? 1 : (dynamicCleanup?.closed_lane_count || 0),
983
- closed_surfaces: killed_session ? 1 : (dynamicCleanup?.closed_lane_count || 0),
1028
+ recorded_cleanup: recordedCleanup,
1029
+ legacy_cleanup: legacyCleanup,
1030
+ requested_close_surfaces: closeSession ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.requested_close_surfaces || 0),
1031
+ closed_surfaces: killed_session ? 1 : (dynamicCleanup?.closed_lane_count || recordedCleanup?.closed_lane_count || legacyCleanup?.closed_lane_count || 0),
984
1032
  reason: dynamicCleanup?.ok
985
1033
  ? 'cleanup closed managed Team panes in the current SKS tmux session.'
1034
+ : recordedCleanup?.ok
1035
+ ? 'cleanup closed recorded managed Team panes by stored tmux pane ids.'
1036
+ : legacyCleanup?.ok
1037
+ ? legacyCleanup.reason
986
1038
  : closeSession
987
1039
  ? 'tmux kill-session requested for recorded Team session.'
988
- : 'cleanup marks the SKS tmux Team record complete; panes remain user-controlled.'
1040
+ : 'cleanup marks the SKS tmux Team record complete; no managed panes were reachable.'
1041
+ };
1042
+ }
1043
+
1044
+ async function cleanupLegacyTmuxTeamSurfaces(root, missionId, opts = {}) {
1045
+ const id = String(missionId || '').trim();
1046
+ const tmuxBin = await findTmuxBin() || 'tmux';
1047
+ const current = await currentTmuxTarget(tmuxBin).catch(() => ({ ok: false }));
1048
+ const closed = [];
1049
+ const failed = [];
1050
+ let killed_session = false;
1051
+ let session_kill_requested = false;
1052
+ const session = id && id !== 'latest' ? sanitizeTmuxSessionName(`sks-team-${id}`) : '';
1053
+ if (session && await hasTmuxSession(tmuxBin, session)) {
1054
+ if (current.ok && current.session === session) {
1055
+ const panes = await listTmuxWindowPanes(tmuxBin, current.window_id);
1056
+ if (panes.ok) {
1057
+ for (const pane of panes.panes.filter((entry) => entry.pane_id !== current.pane_id && isLegacyTeamPane(entry))) {
1058
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1059
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, title: pane.title });
1060
+ else failed.push({ pane_id: pane.pane_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1061
+ }
1062
+ }
1063
+ } else {
1064
+ session_kill_requested = true;
1065
+ const kill = await tmuxRun(tmuxBin, ['kill-session', '-t', session], { timeoutMs: 5000 });
1066
+ killed_session = kill.code === 0;
1067
+ if (!killed_session) failed.push({ session, stderr: kill.stderr || kill.stdout || 'tmux kill-session failed' });
1068
+ }
1069
+ }
1070
+ if (current.ok) {
1071
+ const panes = await listTmuxWindowPanes(tmuxBin, current.window_id);
1072
+ if (panes.ok) {
1073
+ for (const pane of panes.panes.filter((entry) => !entry.managed && entry.pane_id !== current.pane_id && isLegacyTeamPane(entry))) {
1074
+ if (closed.some((entry) => entry.pane_id === pane.pane_id)) continue;
1075
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1076
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, title: pane.title });
1077
+ else failed.push({ pane_id: pane.pane_id, title: pane.title, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1078
+ }
1079
+ if (closed.length) {
1080
+ await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, 'tiled'], { timeoutMs: 5000 }).catch(() => null);
1081
+ await tmuxRun(tmuxBin, ['select-layout', '-t', current.window_id, '-E'], { timeoutMs: 5000 }).catch(() => null);
1082
+ }
1083
+ }
1084
+ }
1085
+ return {
1086
+ ok: failed.length === 0,
1087
+ skipped: !killed_session && closed.length === 0,
1088
+ session: session || null,
1089
+ killed_session,
1090
+ closed_lane_count: closed.length,
1091
+ requested_close_surfaces: (session_kill_requested ? 1 : 0) + closed.length,
1092
+ closed,
1093
+ failed,
1094
+ reason: killed_session
1095
+ ? 'cleanup closed legacy Team tmux session by mission id.'
1096
+ : closed.length
1097
+ ? 'cleanup closed legacy Team panes by lane title.'
1098
+ : 'cleanup found no legacy Team panes for this mission.'
1099
+ };
1100
+ }
1101
+
1102
+ async function cleanupRecordedTmuxTeamPanes(root, missionId, record = {}) {
1103
+ const id = record.mission_id || missionId;
1104
+ const cockpitState = await readJson(tmuxCockpitStatePath(root), {}).catch(() => ({}));
1105
+ const cockpit = cockpitState?.missions?.[id] || {};
1106
+ const target = cockpit.window_id || record.window_id || cockpit.session || record.session;
1107
+ if (!target) return { ok: false, skipped: true, reason: 'no recorded tmux target', closed_lane_count: 0 };
1108
+ const tmuxBin = await findTmuxBin() || 'tmux';
1109
+ const paneList = await listTmuxWindowPanes(tmuxBin, target);
1110
+ if (!paneList.ok) return { ok: false, skipped: true, reason: paneList.stderr, closed_lane_count: 0 };
1111
+ const managed = paneList.panes.filter((pane) => pane.managed && pane.mission_id === id);
1112
+ const closed = [];
1113
+ const failed = [];
1114
+ for (const pane of managed) {
1115
+ const kill = await tmuxRun(tmuxBin, ['kill-pane', '-t', pane.pane_id], { timeoutMs: 5000 });
1116
+ if (kill.code === 0) closed.push({ pane_id: pane.pane_id, agent: pane.agent, role: pane.role });
1117
+ else failed.push({ pane_id: pane.pane_id, agent: pane.agent, stderr: kill.stderr || kill.stdout || 'tmux kill-pane failed' });
1118
+ }
1119
+ if (closed.length) {
1120
+ await tmuxRun(tmuxBin, ['select-layout', '-t', target, 'tiled'], { timeoutMs: 5000 }).catch(() => null);
1121
+ await tmuxRun(tmuxBin, ['select-layout', '-t', target, '-E'], { timeoutMs: 5000 }).catch(() => null);
1122
+ }
1123
+ return {
1124
+ ok: failed.length === 0,
1125
+ skipped: false,
1126
+ session: cockpit.session || record.session,
1127
+ window_id: cockpit.window_id || record.window_id || null,
1128
+ closed_lane_count: closed.length,
1129
+ closed,
1130
+ failed,
1131
+ reason: closed.length ? 'closed recorded managed panes' : 'no recorded managed panes found'
989
1132
  };
990
1133
  }
991
1134