sneakoscope 6.1.2 → 6.2.0

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 (64) hide show
  1. package/README.md +11 -4
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/dist/cli/install-helpers.js +8 -3
  5. package/dist/config/skills-manifest.json +58 -58
  6. package/dist/core/agents/agent-effort-policy.js +28 -17
  7. package/dist/core/agents/agent-schema.js +1 -1
  8. package/dist/core/codex/codex-config-guard.js +178 -7
  9. package/dist/core/codex/codex-config-readability.js +21 -8
  10. package/dist/core/codex/codex-config-toml.js +14 -11
  11. package/dist/core/codex-app/mcp-manager.js +679 -0
  12. package/dist/core/codex-app/sks-menubar.js +405 -6
  13. package/dist/core/codex-control/codex-lb-launch-recovery.js +15 -0
  14. package/dist/core/codex-native/core-skill-manifest.js +1 -1
  15. package/dist/core/commands/mad-sks-command.js +44 -3
  16. package/dist/core/commands/menubar-command.js +94 -0
  17. package/dist/core/commands/naruto-command.js +3 -1
  18. package/dist/core/commands/wiki-command.js +11 -5
  19. package/dist/core/fsx.js +1 -0
  20. package/dist/core/hooks-runtime/code-pack-freshness-preflight.js +14 -8
  21. package/dist/core/hooks-runtime/naruto-decision-gate.js +184 -0
  22. package/dist/core/hooks-runtime/stop-repeat-guard.js +89 -0
  23. package/dist/core/hooks-runtime.js +36 -105
  24. package/dist/core/init/skills.js +4 -4
  25. package/dist/core/init.js +1 -1
  26. package/dist/core/managed-assets/managed-assets-manifest.js +178 -29
  27. package/dist/core/preflight/parallel-preflight-engine.js +16 -3
  28. package/dist/core/proof/route-adapter.js +1 -1
  29. package/dist/core/proof/selftest-proof-fixtures.js +3 -5
  30. package/dist/core/provider/model-router.js +15 -6
  31. package/dist/core/release/package-size-budget.js +4 -6
  32. package/dist/core/research/research-plan-markdown.js +123 -0
  33. package/dist/core/research/research-super-search.js +8 -4
  34. package/dist/core/research.js +4 -119
  35. package/dist/core/retention.js +70 -2
  36. package/dist/core/routes/dollar-manifest-lite.js +1 -1
  37. package/dist/core/routes.js +75 -22
  38. package/dist/core/subagents/agent-catalog.js +87 -15
  39. package/dist/core/subagents/model-policy.js +189 -48
  40. package/dist/core/subagents/naruto-help-contract.js +11 -4
  41. package/dist/core/subagents/official-subagent-preparation.js +55 -9
  42. package/dist/core/subagents/official-subagent-prompt.js +45 -23
  43. package/dist/core/subagents/thread-budget.js +1 -1
  44. package/dist/core/subagents/triwiki-attention.js +117 -14
  45. package/dist/core/triwiki/code-pack-head-freshness.js +291 -0
  46. package/dist/core/version.js +1 -1
  47. package/dist/core/zellij/zellij-launcher.js +12 -2
  48. package/dist/core/zellij/zellij-pane-proof.js +9 -1
  49. package/dist/core/zellij/zellij-update.js +14 -1
  50. package/dist/scripts/canonical-test-runner.js +7 -1
  51. package/dist/scripts/codex-lb-fast-mode-truth-check.js +11 -1
  52. package/dist/scripts/codex-lb-fast-ui-preservation-check.js +9 -2
  53. package/dist/scripts/codex-lb-gpt56-fast-profile-check.js +13 -2
  54. package/dist/scripts/codex-native-agent-role-content-check.js +13 -1
  55. package/dist/scripts/codex-sdk-backend-router-check.js +2 -0
  56. package/dist/scripts/lib/codex-sdk-gate-lib.js +4 -0
  57. package/dist/scripts/official-subagent-workflow-check.js +1 -1
  58. package/dist/scripts/packlist-performance-check.js +1 -18
  59. package/dist/scripts/python-codex-sdk-all-pipelines-check.js +8 -0
  60. package/dist/scripts/sks-menubar-install-check.js +6 -1
  61. package/dist/scripts/super-search-provider-interface-check.js +31 -18
  62. package/package.json +3 -1
  63. package/dist/scripts/codex-0139-feature-probes-check.js +0 -30
  64. package/dist/scripts/codex-0139-marketplace-source-check.js +0 -13
@@ -2,7 +2,10 @@ import path from 'node:path';
2
2
  import { ui as cliUi } from '../../cli/cli-theme.js';
3
3
  import { projectRoot } from '../fsx.js';
4
4
  import { inspectSksMenuBarStatus, installSksMenuBar, restartSksMenuBar, uninstallSksMenuBar } from '../codex-app/sks-menubar.js';
5
+ import { addCodexMcpServer, listCodexMcpServers, removeCodexMcpServer, setCodexMcpServerEnabled } from '../codex-app/mcp-manager.js';
5
6
  export async function menubarCommand(subcommand = 'status', args = []) {
7
+ if (String(subcommand || '').toLowerCase() === 'mcp')
8
+ return menubarMcpCommand(args);
6
9
  const action = normalizeAction(subcommand);
7
10
  const root = path.resolve(String(readOption(args, '--root', '') || await projectRoot()));
8
11
  const home = stringOption(args, '--home');
@@ -79,6 +82,38 @@ export async function menubarCommand(subcommand = 'status', args = []) {
79
82
  printUsage();
80
83
  process.exitCode = 2;
81
84
  }
85
+ async function menubarMcpCommand(args = []) {
86
+ const action = String(args[0] || 'list').toLowerCase();
87
+ const rest = args.slice(1);
88
+ const home = stringOption(rest, '--home');
89
+ const options = home ? { home } : {};
90
+ let result;
91
+ if (['list', 'status', 'refresh'].includes(action)) {
92
+ result = await listCodexMcpServers(options);
93
+ }
94
+ else if (action === 'add') {
95
+ const payload = flag(rest, '--stdin-json') ? await readStdinJson() : null;
96
+ result = await addCodexMcpServer(payload, options);
97
+ }
98
+ else if (action === 'remove') {
99
+ result = await removeCodexMcpServer(stringOption(rest, '--name') || positional(rest), options);
100
+ }
101
+ else if (action === 'enable' || action === 'disable') {
102
+ result = await setCodexMcpServerEnabled(stringOption(rest, '--name') || positional(rest), action === 'enable', options);
103
+ }
104
+ else {
105
+ printMcpUsage();
106
+ process.exitCode = 2;
107
+ return;
108
+ }
109
+ if (flag(rest, '--json') || flag(args, '--json'))
110
+ printJson(result);
111
+ else
112
+ printMcpResult(result);
113
+ if (result?.ok !== true)
114
+ process.exitCode = 1;
115
+ return result;
116
+ }
82
117
  function normalizeAction(value) {
83
118
  const text = String(value || 'status').toLowerCase();
84
119
  if (['status', 'inspect', 'doctor'].includes(text))
@@ -124,8 +159,67 @@ Usage:
124
159
  sks menubar install [--no-launch] [--json]
125
160
  sks menubar restart [--json]
126
161
  sks menubar uninstall [--json]
162
+ sks menubar mcp list [--json]
163
+ sks menubar mcp add --stdin-json [--json]
164
+ sks menubar mcp enable|disable|remove <name> [--json]
165
+ `);
166
+ }
167
+ function printMcpUsage() {
168
+ console.log(`SKS Menu Bar MCP Manager
169
+
170
+ Usage:
171
+ sks menubar mcp list [--json]
172
+ sks menubar mcp add --stdin-json [--json]
173
+ sks menubar mcp enable <name> [--json]
174
+ sks menubar mcp disable <name> [--json]
175
+ sks menubar mcp remove <name> [--json]
127
176
  `);
128
177
  }
178
+ function printMcpResult(result) {
179
+ if (result?.schema === 'sks.menubar-mcp-list.v1') {
180
+ console.log(`Codex MCP servers (${result.server_count || 0})`);
181
+ for (const server of result.servers || [])
182
+ console.log(`- ${server.enabled ? 'on ' : 'off'} ${server.name}: ${server.summary}`);
183
+ return;
184
+ }
185
+ console.log(`Codex MCP ${result?.action || 'mutation'}: ${result?.ok ? 'ok' : 'failed'}`);
186
+ for (const blocker of result?.blockers || [])
187
+ console.log(`blocker: ${blocker}`);
188
+ }
189
+ async function readStdinJson() {
190
+ if (process.stdin.isTTY)
191
+ return null;
192
+ const chunks = [];
193
+ let bytes = 0;
194
+ for await (const chunk of process.stdin) {
195
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
196
+ bytes += buffer.length;
197
+ if (bytes > 1024 * 1024)
198
+ throw new Error('menubar_mcp_stdin_payload_too_large');
199
+ chunks.push(buffer);
200
+ }
201
+ const text = Buffer.concat(chunks).toString('utf8').trim();
202
+ if (!text)
203
+ return null;
204
+ try {
205
+ return JSON.parse(text);
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ function positional(args) {
212
+ for (let index = 0; index < args.length; index += 1) {
213
+ const arg = String(args[index] || '');
214
+ if (arg === '--home' || arg === '--name') {
215
+ index += 1;
216
+ continue;
217
+ }
218
+ if (!arg.startsWith('--'))
219
+ return arg;
220
+ }
221
+ return null;
222
+ }
129
223
  function printJson(value) {
130
224
  console.log(JSON.stringify(value, null, 2));
131
225
  }
@@ -196,7 +196,8 @@ async function narutoRunTransaction(parsed, root, appSession, sessionKey) {
196
196
  parentSummary: effectiveParentSummary,
197
197
  blockers,
198
198
  appSession,
199
- sessionKey
199
+ sessionKey,
200
+ suggestedAgents: Array.isArray(completedPlan?.suggested_agents) ? completedPlan.suggested_agents : []
200
201
  });
201
202
  await writeJsonAtomic(path.join(dir, NARUTO_SUMMARY_FILENAME), summary);
202
203
  await writeNarutoGate(dir, { missionId: id, workflowRunId, evidence, passed, blockers });
@@ -364,6 +365,7 @@ async function narutoProof(parsed) {
364
365
  function narutoHelp(parsed) {
365
366
  const result = buildNarutoHelpResult();
366
367
  return emit(parsed, result, () => {
368
+ cliUi.ok('official subagent workflow help available');
367
369
  console.log('$Naruto — Codex official subagent workflow');
368
370
  for (const line of result.usage)
369
371
  console.log(` ${line}`);
@@ -22,6 +22,7 @@ import { recordImageWrongnessFromValidation } from '../triwiki-wrongness/image-w
22
22
  import { publishSharedMemory, rebuildSharedIndexes, sharedMemorySummary, validateSharedMemory } from '../git-hygiene/shared-memory-publish.js';
23
23
  import { scanCodebaseIndex } from '../triwiki/code-index-scanner.js';
24
24
  import { buildCodePack, validateCodePack, writeCodePackAtomic } from '../triwiki/code-pack.js';
25
+ import { inspectCodePackHeadFreshness } from '../triwiki/code-pack-head-freshness.js';
25
26
  import { sealTriWikiContextPack, validateTriWikiContextPackProvenance } from '../triwiki-provenance.js';
26
27
  import { flag, positionalArgs, readFlagValue, readOption, resolveMissionId } from './command-utils.js';
27
28
  export async function wikiCommand(sub, args = []) {
@@ -251,8 +252,10 @@ async function wikiRefreshCode(args = []) {
251
252
  console.log(`- ${issue}`);
252
253
  }
253
254
  /** Cheap freshness check: compares the code pack's recorded git HEAD sha (at
254
- * generation time) against the current HEAD. Any uncertainty (no pack, no git,
255
- * spawn failure) resolves to 'stale' rather than 'fresh', never overclaiming. */
255
+ * generation time) against the current HEAD. A commit containing only the two
256
+ * tracked code-pack metadata files is equivalent to the generating HEAD, which
257
+ * avoids an impossible self-referential commit hash. Any other uncertainty
258
+ * resolves to 'stale' rather than 'fresh', never overclaiming. */
256
259
  /** Active-wrongness counts per TriWiki module id (from wrongness records' module_ids),
257
260
  * so attention can hydrate frequently-wrong modules' code entries first. */
258
261
  async function buildWrongnessByModule(root) {
@@ -276,9 +279,12 @@ async function codePackFreshness(root) {
276
279
  const packSha = pack?.git_head_sha || null;
277
280
  if (!packSha)
278
281
  return { status: 'missing', git_head_sha: null, pack_sha: null };
279
- const head = await runProcess('git', ['rev-parse', 'HEAD'], { cwd: root, timeoutMs: 5000 }).catch(() => null);
280
- const currentSha = head && head.code === 0 ? head.stdout.trim() : null;
281
- return { status: currentSha && currentSha === packSha ? 'fresh' : 'stale', git_head_sha: currentSha, pack_sha: packSha };
282
+ const freshness = await inspectCodePackHeadFreshness(root, packSha, { timeoutMs: 5_000 });
283
+ return {
284
+ status: freshness.fresh ? 'fresh' : 'stale',
285
+ git_head_sha: freshness.current_sha,
286
+ pack_sha: freshness.pack_sha
287
+ };
282
288
  }
283
289
  function wikiRefreshFailureAnalysis(validationResult, gate = {}) {
284
290
  if (validationResult?.ok) {
package/dist/core/fsx.js CHANGED
@@ -11,6 +11,7 @@ import { PACKAGE_VERSION } from './version.js';
11
11
  export { PACKAGE_VERSION };
12
12
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
13
13
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
14
+ export const SKS_TEMP_LEASE_FILE = '.sks-temp-lease.json';
14
15
  const gunzipAsync = promisify(gunzipCallback);
15
16
  export function nowIso() {
16
17
  return new Date().toISOString();
@@ -1,17 +1,21 @@
1
1
  import path from 'node:path';
2
- import { exists, readJson, runProcess } from '../fsx.js';
2
+ import { exists, readJson } from '../fsx.js';
3
+ import { inspectCodePackHeadFreshness } from '../triwiki/code-pack-head-freshness.js';
3
4
  /** Bounded, non-blocking code-pack staleness nudge for the user-prompt-submit hook.
4
5
  *
5
6
  * Returns a one-line context note when a published code pack
6
7
  * (.sneakoscope/wiki/code-pack.json) exists but was generated against a different
7
8
  * git HEAD than the current one — i.e. the codebase moved and the LLM-facing code
8
- * summaries are now out of date. Returns null (silent) when there is no pack at all
9
+ * summaries are now out of date. A follow-up commit that changes only the two
10
+ * tracked code-pack metadata files remains fresh; otherwise committing the generated
11
+ * pack would make itself stale forever. Returns null (silent) when there is no pack at all
9
12
  * (repos that never opted into `sks wiki refresh --code` must not be nagged) or when
10
13
  * the check can't complete cheaply. It NEVER regenerates the pack and NEVER blocks:
11
- * the whole check is one JSON read plus one `git rev-parse HEAD`, wrapped in a hard
12
- * timeout so it cannot blow the hook latency budget. Any failure resolves to null. */
14
+ * the common path is one JSON read plus one bounded `git log` history check,
15
+ * wrapped in a hard timeout so it cannot blow the hook latency budget. Any
16
+ * failure resolves to null. */
13
17
  export async function codePackFreshnessNote(root, opts = {}) {
14
- const budgetMs = Math.max(1, opts.budgetMs ?? 250);
18
+ const budgetMs = Math.max(1, opts.budgetMs ?? 750);
15
19
  const gitTimeoutMs = Math.max(1, budgetMs - 50);
16
20
  return raceWithTimeout(computeNote(root, gitTimeoutMs), budgetMs).catch(() => null);
17
21
  }
@@ -25,9 +29,11 @@ async function computeNote(root, gitTimeoutMs) {
25
29
  // rather than nag with a comparison we can't actually make.
26
30
  if (!packSha)
27
31
  return null;
28
- const head = await runProcess('git', ['rev-parse', 'HEAD'], { cwd: root, timeoutMs: gitTimeoutMs }).catch(() => null);
29
- const currentSha = head && head.code === 0 ? String(head.stdout || '').trim() : null;
30
- if (!currentSha || currentSha === packSha)
32
+ const freshness = await inspectCodePackHeadFreshness(root, packSha, {
33
+ timeoutMs: gitTimeoutMs,
34
+ advisoryCache: true,
35
+ });
36
+ if (!freshness.conclusive || !freshness.current_sha || freshness.fresh)
31
37
  return null;
32
38
  return 'SKS note: the codebase code pack is stale (HEAD moved since it was built). Run `sks wiki refresh --code` to refresh source-cited code context.';
33
39
  }
@@ -0,0 +1,184 @@
1
+ import path from 'node:path';
2
+ import { appendJsonlBounded, nowIso, sha256 } from '../fsx.js';
3
+ import { codexHookEventName } from '../codex-compat/codex-hook-events.js';
4
+ import { narutoDecisionForRoute, routeByDollarCommand, routeById, routePrompt, stripVisibleDecisionAnswerBlocks } from '../routes.js';
5
+ import { classifyTaskProfile, isTaskProfile } from '../runtime/task-profile.js';
6
+ export const HOOK_NARUTO_DECISION_LOG = 'hook-naruto-decision-gate.jsonl';
7
+ const HOOK_NARUTO_DECISION_LOG_MAX_BYTES = 256 * 1024;
8
+ export function hookNarutoDecisionLogPath(root) {
9
+ return path.join(root, '.sneakoscope', 'state', HOOK_NARUTO_DECISION_LOG);
10
+ }
11
+ export async function evaluateHookNarutoDecisionGate(input) {
12
+ const decision = decideHookNaruto(input);
13
+ const prompt = decision.event === 'UserPromptSubmit'
14
+ ? stripVisibleDecisionAnswerBlocks(extractPrompt(input.payload))
15
+ : '';
16
+ const row = {
17
+ ts: nowIso(),
18
+ schema: decision.schema,
19
+ event: decision.event,
20
+ mode: decision.mode,
21
+ required: decision.required,
22
+ action: decision.action,
23
+ route_id: decision.route_id,
24
+ task_profile: decision.task_profile,
25
+ reason: decision.reason,
26
+ source: decision.source,
27
+ trivial: decision.trivial,
28
+ default_parallel: decision.default_parallel,
29
+ session_hash: shortHash(input.sessionKey),
30
+ turn_hash: shortHash(input.payload?.turn_id || input.payload?.turnId || ''),
31
+ prompt_hash: prompt ? shortHash(prompt) : null
32
+ };
33
+ let recorded = true;
34
+ try {
35
+ await appendJsonlBounded(hookNarutoDecisionLogPath(input.root), row, HOOK_NARUTO_DECISION_LOG_MAX_BYTES);
36
+ }
37
+ catch {
38
+ recorded = false;
39
+ }
40
+ return { ...decision, recorded };
41
+ }
42
+ export function decideHookNaruto(input) {
43
+ const event = codexHookEventName(input.name) || String(input.name || 'unknown');
44
+ const state = input.state || {};
45
+ if (event !== 'UserPromptSubmit') {
46
+ return decisionFromState(event, state);
47
+ }
48
+ const prompt = stripVisibleDecisionAnswerBlocks(extractPrompt(input.payload));
49
+ const profile = classifyTaskProfile(prompt);
50
+ if (input.parentLaunchMissionId) {
51
+ return decision({
52
+ event,
53
+ mode: 'generic_naruto',
54
+ required: true,
55
+ action: 'prepare_naruto',
56
+ routeId: 'Naruto',
57
+ profile,
58
+ reason: 'attached_parent_naruto_launch',
59
+ source: 'parent_launch',
60
+ trivial: false
61
+ });
62
+ }
63
+ if (input.noQuestion || shouldInheritActiveRoute(prompt, state)) {
64
+ return decisionFromState(event, state);
65
+ }
66
+ const route = routePrompt(prompt);
67
+ const routeDecision = narutoDecisionForRoute(route, prompt, profile);
68
+ return decision({
69
+ event,
70
+ mode: routeDecision.mode,
71
+ required: routeDecision.required,
72
+ action: routeDecision.mode === 'generic_naruto'
73
+ ? 'prepare_naruto'
74
+ : routeDecision.mode === 'route_owned'
75
+ ? 'route_owned'
76
+ : 'bypass',
77
+ routeId: routeDecision.route_id,
78
+ profile: routeDecision.task_profile,
79
+ reason: routeDecision.reason,
80
+ source: 'user_prompt',
81
+ trivial: routeDecision.trivial
82
+ });
83
+ }
84
+ export function looksLikeActiveContinuationPrompt(prompt = '') {
85
+ const text = stripVisibleDecisionAnswerBlocks(String(prompt || ''))
86
+ .trim()
87
+ .replace(/[.!?。!?…,:;]+$/u, '')
88
+ .trim();
89
+ if (!text)
90
+ return false;
91
+ return /^(?:(?:please\s+)?(?:keep\s+going|continue|resume|go\s+on|proceed|carry\s+on)(?:\s+please)?|계속(?:\s*진행)?(?:\s*해\s*줘|\s*해주세요|\s*해)?|이어\s*서(?:\s*해\s*줘|\s*해주세요|\s*진행해)?|이어서(?:\s*해\s*줘|\s*해주세요|\s*진행해)?|진행(?:\s*해\s*줘|\s*해주세요|\s*해)?|마저\s*해(?:\s*줘|\s*주세요)?|다음|next)$/i.test(text);
92
+ }
93
+ function decisionFromState(event, state) {
94
+ const route = routeFromState(state);
95
+ const routePolicy = narutoDecisionForRoute(route, '', 'passthrough');
96
+ if (state?.route_closed !== true && routePolicy.mode === 'route_owned') {
97
+ return decision({
98
+ event,
99
+ mode: 'route_owned',
100
+ required: false,
101
+ action: 'route_owned',
102
+ routeId: state?.route_command || state?.route || state?.mode || routePolicy.route_id,
103
+ profile: isTaskProfile(state?.task_profile) ? state.task_profile : routePolicy.task_profile,
104
+ reason: routePolicy.reason,
105
+ source: state?.mission_id ? 'active_route' : 'no_task_context',
106
+ trivial: false
107
+ });
108
+ }
109
+ const required = state?.route_closed === true
110
+ ? false
111
+ : state?.subagents_required === true
112
+ || (/^(?:NARUTO|TEAM)$/i.test(String(state?.mode || state?.route || '')) && state?.subagents_required !== false);
113
+ const profile = isTaskProfile(state?.task_profile)
114
+ ? state.task_profile
115
+ : required
116
+ ? 'bounded-work'
117
+ : 'passthrough';
118
+ return decision({
119
+ event,
120
+ mode: required ? 'generic_naruto' : 'none',
121
+ required,
122
+ action: required ? 'observe_required' : 'observe_bypass',
123
+ routeId: state?.route_command || state?.route || state?.mode || null,
124
+ profile,
125
+ reason: state?.route_closed === true
126
+ ? 'active_route_closed'
127
+ : required
128
+ ? 'active_route_requires_official_subagents'
129
+ : 'no_active_naruto_requirement',
130
+ source: state?.mission_id ? 'active_route' : 'no_task_context',
131
+ trivial: !required
132
+ });
133
+ }
134
+ function routeFromState(state) {
135
+ for (const candidate of [state?.route, state?.mode, state?.route_command]) {
136
+ const route = routeById(candidate) || routeByDollarCommand(candidate);
137
+ if (route)
138
+ return route;
139
+ }
140
+ return null;
141
+ }
142
+ function shouldInheritActiveRoute(prompt, state) {
143
+ if (!state?.mission_id || state?.route_closed === true)
144
+ return false;
145
+ if (looksLikeActiveContinuationPrompt(prompt))
146
+ return true;
147
+ const phase = String(state?.phase || '');
148
+ const clarificationAwaiting = phase.includes('CLARIFICATION_AWAITING_ANSWERS')
149
+ || String(state?.stop_gate || '') === 'clarification-gate';
150
+ return clarificationAwaiting
151
+ && state?.ambiguity_gate_required === true
152
+ && state?.ambiguity_gate_passed !== true;
153
+ }
154
+ function decision(input) {
155
+ return {
156
+ schema: 'sks.hook-naruto-decision.v1',
157
+ event: input.event,
158
+ mode: input.mode,
159
+ required: input.required,
160
+ action: input.action,
161
+ route_id: input.routeId,
162
+ task_profile: input.profile,
163
+ reason: input.reason,
164
+ source: input.source,
165
+ trivial: input.trivial,
166
+ default_parallel: input.required,
167
+ recorded: false
168
+ };
169
+ }
170
+ function extractPrompt(payload = {}) {
171
+ return String(payload.prompt
172
+ || payload.user_prompt
173
+ || payload.userPrompt
174
+ || payload.message
175
+ || payload.input?.prompt
176
+ || payload.input?.message
177
+ || payload.raw
178
+ || '');
179
+ }
180
+ function shortHash(value) {
181
+ const text = String(value || '').trim();
182
+ return text ? sha256(text).slice(0, 16) : null;
183
+ }
184
+ //# sourceMappingURL=naruto-decision-gate.js.map
@@ -0,0 +1,89 @@
1
+ import path from 'node:path';
2
+ import { nowIso, readJson, sha256, writeJsonAtomic } from '../fsx.js';
3
+ import { appendMissionStatus } from '../recallpulse.js';
4
+ import { conversationId } from './payload-signals.js';
5
+ const STOP_REPEAT_GUARD_ARTIFACT = 'stop-hook-repeat-guard.json';
6
+ const STOP_REPEAT_GUARD_WINDOW_MS = 10 * 60 * 1000;
7
+ const STOP_REPEAT_GUARD_MAX_ENTRIES = 25;
8
+ const DEFAULT_STOP_REPEAT_GUARD_LIMIT = 2;
9
+ export async function finalizationRepeatDecision(root, state = {}, payload = {}, reason = '', kind = 'finalization') {
10
+ const now = nowIso();
11
+ const guardPath = path.join(root, '.sneakoscope', 'state', STOP_REPEAT_GUARD_ARTIFACT);
12
+ const previous = await readJson(guardPath, {}).catch(() => ({}));
13
+ const limit = stopRepeatGuardLimit();
14
+ const entries = pruneStopRepeatEntries(previous.entries || {}, now);
15
+ const key = stopRepeatKey(state, payload, reason, kind);
16
+ const prior = entries[key] || {};
17
+ const repeatCount = stopRepeatInWindow(prior, now)
18
+ ? Number(prior.repeat_count || 0) + 1
19
+ : 1;
20
+ const record = {
21
+ schema_version: 1,
22
+ updated_at: now,
23
+ window_ms: STOP_REPEAT_GUARD_WINDOW_MS,
24
+ limit,
25
+ entries: {
26
+ ...entries,
27
+ [key]: {
28
+ kind,
29
+ route: state.route_command || state.route || state.mode || null,
30
+ mission_id: state.mission_id || null,
31
+ conversation_id: conversationId(payload),
32
+ first_seen: stopRepeatInWindow(prior, now) ? (prior.first_seen || now) : now,
33
+ last_seen: now,
34
+ repeat_count: repeatCount,
35
+ tripped: repeatCount >= limit,
36
+ reason
37
+ }
38
+ }
39
+ };
40
+ await writeJsonAtomic(guardPath, record).catch(() => null);
41
+ if (state.mission_id) {
42
+ await appendMissionStatus(root, state.mission_id, {
43
+ category: repeatCount >= limit ? 'warning' : 'blocker',
44
+ audience: ['user', 'route', 'final-summary'],
45
+ stage_id: 'before_final',
46
+ message: repeatCount >= limit
47
+ ? `Repeated ${kind} stop prompt was suppressed; route completion is still unclaimed until evidence passes.`
48
+ : reason,
49
+ dedupe_key: key,
50
+ evidence: [STOP_REPEAT_GUARD_ARTIFACT]
51
+ }).catch(() => null);
52
+ }
53
+ if (repeatCount < limit)
54
+ return null;
55
+ return {
56
+ continue: true,
57
+ systemMessage: `SKS stop hook repeat guard suppressed repeated ${kind} prompt after ${repeatCount} identical block(s). No completion success is claimed by the hook.`
58
+ };
59
+ }
60
+ function stopRepeatKey(state = {}, payload = {}, reason = '', kind = '') {
61
+ return sha256(JSON.stringify({
62
+ kind,
63
+ reason,
64
+ conversation_id: conversationId(payload),
65
+ mission_id: state.mission_id || null,
66
+ route: state.route_command || state.route || state.mode || null,
67
+ gate: state.stop_gate || null
68
+ })).slice(0, 24);
69
+ }
70
+ function stopRepeatGuardLimit() {
71
+ const raw = Number.parseInt(process.env.SKS_STOP_REPEAT_GUARD_LIMIT || '', 10);
72
+ if (!Number.isFinite(raw))
73
+ return DEFAULT_STOP_REPEAT_GUARD_LIMIT;
74
+ return Math.max(1, Math.min(20, raw));
75
+ }
76
+ function stopRepeatInWindow(entry = {}, now = nowIso()) {
77
+ const last = Date.parse(entry.last_seen || '');
78
+ const current = Date.parse(now);
79
+ if (!Number.isFinite(last) || !Number.isFinite(current))
80
+ return false;
81
+ return current - last <= STOP_REPEAT_GUARD_WINDOW_MS;
82
+ }
83
+ function pruneStopRepeatEntries(entries = {}, now = nowIso()) {
84
+ return Object.fromEntries(Object.entries(entries)
85
+ .filter(([, entry]) => stopRepeatInWindow(entry, now))
86
+ .sort((a, b) => Date.parse(b[1]?.last_seen || '') - Date.parse(a[1]?.last_seen || ''))
87
+ .slice(0, STOP_REPEAT_GUARD_MAX_ENTRIES));
88
+ }
89
+ //# sourceMappingURL=stop-repeat-guard.js.map