ostacky 0.8.2 → 0.8.4

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.
@@ -28,30 +28,91 @@ import {
28
28
  import { dirname, basename, join, resolve, relative } from 'node:path';
29
29
  import { writeFile as writeFileAsync, rename as renameAsync, mkdir as mkdirAsync } from 'node:fs/promises';
30
30
  import { SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, isSensitive, extractPathsFromBash } from './security.js';
31
+ import {
32
+ STATES,
33
+ TRANSITIONS,
34
+ TERMINAL_STATES,
35
+ DEFAULT_STATE,
36
+ MAX_TASKS,
37
+ MAX_TASKS_DEFAULT,
38
+ MAX_TASKS_CAP,
39
+ MAX_SNAPSHOT_JSON_LENGTH,
40
+ MAX_STATE_FILE_SIZE,
41
+ DEGRADED_THRESHOLD,
42
+ getMaxTasks,
43
+ } from './controller-core.js';
44
+
45
+ // --- Audit JSONL (D5) — single source: src/audit-jsonl.ts ---
46
+ function getAuditPath(projectRoot) {
47
+ return join(projectRoot, '.opencode', 'ostacky-audit.jsonl');
48
+ }
49
+ function appendAuditJsonl(projectRoot, entry) {
50
+ if (projectRoot === '/tmp' || projectRoot === '/') return;
51
+ try {
52
+ const p = join(projectRoot, '.opencode', 'ostacky-audit.jsonl');
53
+ const dir = dirname(p);
54
+ try {
55
+ mkdirSync(dir, { recursive: true });
56
+ } catch {}
57
+ try {
58
+ const prev = existsSync(p) ? readFileSync(p, 'utf-8') : '';
59
+ writeFileSync(p, prev + JSON.stringify(entry) + '\n', 'utf-8');
60
+ // Enforce OSTACKY_AUDIT_RETENTION
61
+ try {
62
+ const retention = (() => {
63
+ const raw = process.env.OSTACKY_AUDIT_RETENTION;
64
+ if (raw == null || raw === '') return 500;
65
+ const n = parseInt(raw, 10);
66
+ if (Number.isNaN(n) || n <= 0) return 500;
67
+ if (n > 2000) return 2000;
68
+ return n;
69
+ })();
70
+ const raw2 = readFileSync(p, 'utf-8');
71
+ const entries = raw2.split('\n').filter(Boolean);
72
+ if (entries.length > retention) {
73
+ const keep = entries.slice(-retention);
74
+ writeFileSync(p, keep.join('\n') + '\n', 'utf-8');
75
+ }
76
+ } catch {}
77
+ try {
78
+ const s = statSync(p);
79
+ if (s.size > 500 * 1024) {
80
+ const raw = readFileSync(p, 'utf-8');
81
+ const entries = raw
82
+ .split('\n')
83
+ .filter(Boolean)
84
+ .map((l) => JSON.parse(l));
85
+ const keep = entries.slice(-500);
86
+ writeFileSync(p, keep.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
87
+ }
88
+ } catch {}
89
+ } catch {}
90
+ } catch {}
91
+ }
92
+ function readAuditJsonl(projectRoot, opts = {}) {
93
+ try {
94
+ const p = join(projectRoot, '.opencode', 'ostacky-audit.jsonl');
95
+ if (!existsSync(p)) return [];
96
+ const raw = readFileSync(p, 'utf-8');
97
+ let entries = raw
98
+ .split('\n')
99
+ .filter(Boolean)
100
+ .map((l) => JSON.parse(l));
101
+ if (opts.phase) entries = entries.filter((e) => e.phase === opts.phase);
102
+ if (opts.since) entries = entries.filter((e) => e.ts >= opts.since);
103
+ const limit = opts.limit ?? 20;
104
+ const offset = opts.offset ?? 0;
105
+ const start = Math.max(0, entries.length - limit - offset);
106
+ const end = entries.length - offset;
107
+ return entries.slice(start, end).reverse();
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
31
112
 
32
113
  // T1: non-blocking wait — replaces busy-wait spins that froze the event loop
33
114
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
34
115
 
35
- // --- Constants (Fase 5.5 — headroom generoso) ---
36
- const MAX_TASKS = 100;
37
- const MAX_TASKS_DEFAULT = 100;
38
- const MAX_TASKS_CAP = 500;
39
- const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
40
- const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
41
- const DEGRADED_THRESHOLD = 3; // consecutive failures before auto-degraded mode
42
-
43
- function getMaxTasks() {
44
- const raw = process.env.OSTACKY_MAX_TASKS;
45
- if (raw == null || raw === '') return MAX_TASKS_DEFAULT;
46
- const n = parseInt(raw, 10);
47
- if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
48
- if (n > MAX_TASKS_CAP) {
49
- log('warn:max_tasks_capped', { requested: n, capped: MAX_TASKS_CAP });
50
- return MAX_TASKS_CAP;
51
- }
52
- return n;
53
- }
54
-
55
116
  function getProjectRoot(statePath) {
56
117
  if (!statePath) return resolve(process.cwd());
57
118
  return dirname(dirname(resolve(statePath)));
@@ -104,64 +165,7 @@ const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
104
165
 
105
166
  // D1: source-of-truth — src/security.ts (via ./security.js) — isSensitive, SENSITIVE_DEFAULT, BASH_SENSITIVE_RE, extractPathsFromBash imported above
106
167
 
107
- // --- Transition table ---
108
- const TRANSITIONS = {
109
- INTERPRETATION_PENDING: [
110
- { via: 'request_clarification', to: 'CLARIFICATION_PENDING' },
111
- { via: 'proceed_to_discovery', to: 'DISCOVERY' },
112
- { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
113
- { via: 'block', to: 'BLOCKED' },
114
- ],
115
- CLARIFICATION_PENDING: [
116
- { via: 'record_clarification', to: 'DISCOVERY' },
117
- { via: 'block', to: 'BLOCKED' },
118
- { via: 'abandon', to: 'BLOCKED' },
119
- ],
120
- DISCOVERY: [
121
- { via: 'record_discovery', to: 'ROUTE_DECISION_PENDING' },
122
- { via: 'block', to: 'BLOCKED' },
123
- { via: 'abandon', to: 'BLOCKED' },
124
- ],
125
- ROUTE_DECISION_PENDING: [
126
- { via: 'consume_route_decision', to: 'SPECIFICATION', choice: 'SPEC' },
127
- { via: 'consume_route_decision', to: 'EXECUTION_ANALYSIS', choice: 'DIRECT' },
128
- { via: 'block', to: 'BLOCKED' },
129
- { via: 'abandon', to: 'BLOCKED' },
130
- ],
131
- SPECIFICATION: [
132
- { via: 'spec_complete', to: 'EXECUTION_ANALYSIS' },
133
- { via: 'block', to: 'BLOCKED' },
134
- { via: 'abandon', to: 'BLOCKED' },
135
- ],
136
- EXECUTION_ANALYSIS: [
137
- { via: 'record_execution_analysis', to: 'EXECUTION_DECISION_PENDING' },
138
- { via: 'block', to: 'BLOCKED' },
139
- { via: 'abandon', to: 'BLOCKED' },
140
- ],
141
- EXECUTION_DECISION_PENDING: [
142
- { via: 'consume_execution_decision', to: 'EXECUTING_INLINE', mode: 'INLINE' },
143
- { via: 'consume_execution_decision', to: 'EXECUTING_SUBAGENTS', mode: 'SUBAGENT_DRIVEN' },
144
- { via: 'block', to: 'BLOCKED' },
145
- { via: 'abandon', to: 'BLOCKED' },
146
- ],
147
- EXECUTING_INLINE: [
148
- { via: 'implementation_complete', to: 'SYNC' },
149
- { via: 'block', to: 'BLOCKED' },
150
- ],
151
- EXECUTING_SUBAGENTS: [
152
- { via: 'implementation_complete', to: 'SYNC' },
153
- { via: 'block', to: 'BLOCKED' },
154
- ],
155
- BLOCKED: [
156
- { via: 'replan', to: 'INTERPRETATION_PENDING' },
157
- { via: 'abandon', to: 'DONE' },
158
- ],
159
- SYNC: [
160
- { via: 'sync_complete', to: 'DONE' },
161
- { via: 'block', to: 'BLOCKED' },
162
- ],
163
- DONE: [],
164
- };
168
+ // TRANSITIONS imported from controller-core.js (D1 single source)
165
169
 
166
170
  // --- O4: Pre-computed transition cache (O(1) lookup) ---
167
171
  const ALLOWED_TRANSITIONS = Object.freeze(
@@ -321,88 +325,6 @@ function fastFingerprint(filePath) {
321
325
  }
322
326
  }
323
327
 
324
- const STATES = Object.freeze({
325
- INTERPRETATION_PENDING: 'INTERPRETATION_PENDING',
326
- CLARIFICATION_PENDING: 'CLARIFICATION_PENDING',
327
- DISCOVERY: 'DISCOVERY',
328
- ROUTE_DECISION_PENDING: 'ROUTE_DECISION_PENDING',
329
- SPECIFICATION: 'SPECIFICATION',
330
- EXECUTION_ANALYSIS: 'EXECUTION_ANALYSIS',
331
- EXECUTION_DECISION_PENDING: 'EXECUTION_DECISION_PENDING',
332
- EXECUTING_INLINE: 'EXECUTING_INLINE',
333
- EXECUTING_SUBAGENTS: 'EXECUTING_SUBAGENTS',
334
- SYNC: 'SYNC',
335
- DONE: 'DONE',
336
- BLOCKED: 'BLOCKED',
337
- });
338
-
339
- // States where start_request should reset (not resume) when force=false
340
- const TERMINAL_STATES = Object.freeze([
341
- STATES.INTERPRETATION_PENDING,
342
- STATES.CLARIFICATION_PENDING,
343
- STATES.BLOCKED,
344
- STATES.DONE,
345
- ]);
346
-
347
- const DEFAULT_STATE = Object.freeze({
348
- state: STATES.INTERPRETATION_PENDING,
349
- revision: 0,
350
- requestId: null,
351
- changeId: null,
352
- routeDecisionId: null,
353
- routeChoice: null,
354
- level: null,
355
- executionDecisionId: null,
356
- executionMode: null,
357
- snapshots: { codegraph: null, execution: null },
358
- tasks: {},
359
- fileFingerprints: {},
360
- error: null,
361
- lastHandoff: null, // B2: { ts, summary, nextSteps, pendingTasks } | null
362
- expectedTasks: null, // C2: array of taskIds expected for this run (set via record_execution_analysis or set_expected_tasks)
363
- expectedTaskCount: null, // C2: count fallback when IDs not available
364
- auditSeq: 0, // C1: persistent seq for audit IDs
365
- degraded: false, // D2: persisted degraded flag for restart observability
366
- schemaVersion: 1, // D3: schema version for migrations
367
- stateOversizedCount: 0, // 2.3
368
- codegraphBypassCount: 0, // 6.3 / 3.1
369
- degradedEditsCount: 0, // 8.5
370
- cacheHitCount: 0, // 5.4 hardening-v2
371
- cacheMissCount: 0,
372
- tokenSavingEstimate: 0,
373
- discoveryCacheHitCount: 0, // mejora-acciones-controller F2
374
- redundantCallCount: 0,
375
- cacheMissWithoutPutCount: 0,
376
- stateCheckCount: 0,
377
- toolCallCount: 0,
378
- lastProposal: null, // 8.1
379
- allowedFiles: {}, // 9.2
380
- deniedFiles: {}, // 9.2
381
- sensitivePatterns: [
382
- '**/.env*',
383
- '**/.secrets/**',
384
- '**/*.pem',
385
- '**/*.key',
386
- '**/.aws/**',
387
- '**/.ssh/**',
388
- '**/credentials.json',
389
- '**/.npmrc',
390
- ], // 9.1
391
- sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 }, // 9.3
392
- staleContentAttempts: 0, // 10.4
393
- completeWithoutValidateCount: 0, // 10.5
394
- toolTimeoutCount: 0, // 11.1
395
- lastToolDurationMs: 0, // 11.4
396
- stateDurationMs: 0, // 11.4
397
- subagentFailedCount: 0, // 10.6
398
- lastValidated: null, // 10.5 {filePath, hash, ts}
399
- pendingFileAccess: {}, // 9.2
400
- // Heartbeat monitoring for external watchdog (30s stale threshold)
401
- lastHeartbeat: 0, // epoch ms, updated on each successful tool completion
402
- watchdogEnabled: true, // when false, external watchdog should not restart based on heartbeat
403
- ts: Date.now(), // for uptime
404
- });
405
-
406
328
  class OstackyController {
407
329
  #statePath;
408
330
  #state;
@@ -623,6 +545,17 @@ class OstackyController {
623
545
  lastHeartbeat: this.#state.lastHeartbeat,
624
546
  watchdogEnabled: this.#state.watchdogEnabled,
625
547
  });
548
+ // D9+D2: fix double-serialization and [REDACTED] pattern
549
+ if (typeof this.#state.snapshots?.codegraph === 'string') {
550
+ try {
551
+ this.#state.snapshots.codegraph = JSON.parse(this.#state.snapshots.codegraph);
552
+ } catch {}
553
+ }
554
+ if (Array.isArray(this.#state.sensitivePatterns) && this.#state.sensitivePatterns.includes('[REDACTED]')) {
555
+ this.#state.sensitivePatterns = [...SENSITIVE_DEFAULT];
556
+ log('warn:patterns_restored', {});
557
+ }
558
+ if (this.#state.schemaVersion === 1) this.#state.schemaVersion = 2;
626
559
  this.#degraded = !!this.#state.degraded;
627
560
  this.#loaded = true;
628
561
  return;
@@ -734,6 +667,9 @@ class OstackyController {
734
667
  const redactRecursively = (obj) => {
735
668
  if (!obj || typeof obj !== 'object') return;
736
669
  for (const k of Object.keys(obj)) {
670
+ if (k === 'sensitivePatterns') {
671
+ continue;
672
+ }
737
673
  if (k === 'tokenSavingEstimate') {
738
674
  if (typeof obj[k] === 'object') redactRecursively(obj[k]);
739
675
  continue;
@@ -938,11 +874,23 @@ class OstackyController {
938
874
  e.reasoning = e.reasoning.replace(/(apiKey|secret|token|password)\s*[:=]\s*\S+/gi, '$1=[REDACTED]');
939
875
  }
940
876
  }
877
+ // D5: also append to jsonl (single source) — keep tail in state for perf
878
+ try {
879
+ const projectRoot = this.#statePath ? getProjectRoot(this.#statePath) : null;
880
+ if (projectRoot) {
881
+ for (const e of this.#auditBuffer) appendAuditJsonl(projectRoot, e);
882
+ }
883
+ } catch {}
941
884
  this.#state.audit.push(...this.#auditBuffer);
942
885
  const retention = getAuditRetentionSafe();
943
886
  if (this.#state.audit.length > retention) {
944
887
  this.#state.audit = this.#state.audit.slice(-retention);
945
888
  }
889
+ // Keep only tail in state for size <5KB (full in jsonl)
890
+ if (this.#state.audit.length > 20) {
891
+ this.#state.auditTail = this.#state.audit.slice(-20);
892
+ // keep full for backward compat but will be trimmed on persist if oversized
893
+ }
946
894
  this.#auditBuffer = [];
947
895
  // O1: Skip persist for trivial Level 0, but WARN always persists (forcePersist)
948
896
  if (
@@ -1316,7 +1264,33 @@ class OstackyController {
1316
1264
  this.#load();
1317
1265
  const to = this.#isAllowedTransition(this.#state.state, 'spec_complete');
1318
1266
  if (!to) return this.#makeError(`Cannot complete spec from state ${this.#state.state}`, 'spec_complete');
1267
+ // D10: check spec_not_in_sync before transition
1268
+ let specNotInSync = false;
1269
+ let specHashDisk = null;
1270
+ let specHashHandoff = this.#state.lastHandoff?.specSnapshot?.specHash || null;
1271
+ try {
1272
+ if (this.#state.changeId && specHashHandoff) {
1273
+ const hasRecentAudit = [...(this.#state.audit || []), ...this.#auditBuffer].some(
1274
+ (e) => e.phase === 'SPECIFICATION' && e.ts > (this.#state.lastHandoff?.ts || 0)
1275
+ );
1276
+ if (hasRecentAudit) specNotInSync = true;
1277
+ }
1278
+ } catch {}
1319
1279
  await this.#transition(to);
1280
+ if (specNotInSync) {
1281
+ const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
1282
+ log('warn:spec_not_in_sync', { auditId, specHashDisk, specHashHandoff });
1283
+ await this.#audit('WARN', 'spec_not_in_sync', `spec_not_in_sync ${specHashDisk} vs ${specHashHandoff}`);
1284
+ this.#state.specNotInSync = true;
1285
+ await this.#persist();
1286
+ return {
1287
+ state: this.#state.state,
1288
+ revision: this.#state.revision,
1289
+ warning: 'spec_not_in_sync',
1290
+ auditId,
1291
+ specNotInSync: true,
1292
+ };
1293
+ }
1320
1294
  await this.#audit('EXECUTION_ANALYSIS', 'spec_complete');
1321
1295
  return { state: this.#state.state, revision: this.#state.revision };
1322
1296
  }
@@ -1702,6 +1676,21 @@ class OstackyController {
1702
1676
 
1703
1677
  async getAudit({ limit = 20, offset = 0, phase, since } = {}) {
1704
1678
  this.#load();
1679
+ // D5: try jsonl first (single source), fallback to state.audit
1680
+ try {
1681
+ const projectRoot = this.#statePath ? getProjectRoot(this.#statePath) : null;
1682
+ if (projectRoot) {
1683
+ const j = readAuditJsonl(projectRoot, { limit, offset, phase, since });
1684
+ if (j.length > 0)
1685
+ return j.map((e) => ({
1686
+ id: e.id,
1687
+ ts: e.ts,
1688
+ phase: e.phase,
1689
+ decision: e.decision,
1690
+ reasoning: e.reasoning ? String(e.reasoning).slice(0, 300) : undefined,
1691
+ }));
1692
+ }
1693
+ } catch {}
1705
1694
  let all = this.#state.audit || [];
1706
1695
  if (phase) all = all.filter((e) => e.phase === phase);
1707
1696
  if (since) all = all.filter((e) => e.ts >= since);
@@ -2140,13 +2129,26 @@ class OstackyController {
2140
2129
  return { error: 'fingerprint required: file exists but fileHash is null' };
2141
2130
  }
2142
2131
  }
2143
- // 10.5: ligadura validate complete WARN si no hubo validate previo
2132
+ // 7.1: hard gate INLINE (new files eximidos) vs WARN SUBAGENTS
2133
+ const isInline = this.#state.executionMode === 'INLINE' || this.#state.state === 'EXECUTING_INLINE';
2134
+ const isNewFile = !!(
2135
+ filePath &&
2136
+ !this.#state.fileFingerprints?.[filePath] &&
2137
+ !Object.values(this.#state.tasks || {}).some((t) => t.filePath === filePath)
2138
+ );
2144
2139
  if (!this.#state.lastValidated || (filePath && this.#state.lastValidated.filePath !== filePath)) {
2140
+ if (isInline && !isNewFile) {
2141
+ return {
2142
+ error: 'validate required',
2143
+ outcome: 'CONFLICT',
2144
+ reason: `complete_task without prior validate_edit for ${filePath || taskId} — hard gate INLINE (new files eximidos)`,
2145
+ };
2146
+ }
2145
2147
  this.#state.completeWithoutValidateCount = (this.#state.completeWithoutValidateCount || 0) + 1;
2146
2148
  await this.#audit(
2147
2149
  'WARN',
2148
2150
  'complete_without_validate',
2149
- `complete_task without prior validate_edit for ${filePath || taskId}`
2151
+ `complete_task without prior validate_edit for ${filePath || taskId}${isNewFile ? ' (new file, WARN not BLOCK)' : ''}`
2150
2152
  );
2151
2153
  } else {
2152
2154
  this.#state.lastValidated = null;
@@ -2371,7 +2373,7 @@ function safeHandler(fn, options = {}) {
2371
2373
 
2372
2374
  const server = new McpServer({
2373
2375
  name: 'ostacky-controller',
2374
- version: '0.8.2',
2376
+ version: '0.8.4',
2375
2377
  });
2376
2378
 
2377
2379
  server.registerTool(
@@ -2971,7 +2973,7 @@ function setupGracefulShutdown(ctrl) {
2971
2973
  }
2972
2974
 
2973
2975
  async function main() {
2974
- log('Starting ostacky-controller MCP v0.8.2...');
2976
+ log('Starting ostacky-controller MCP v0.8.4...');
2975
2977
  log('State path:', { path: statePath });
2976
2978
  // Clean up stale tmp/lock files from previous runs
2977
2979
  cleanupTmpFiles(statePath);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostacky-controller",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * controller-core — Single source of truth para TRANSITIONS, STATES, DEFAULT_STATE y helpers.
3
+ * Extraído de assets/mcp/ostacky-controller/index.js y assets/plugins/ostacky-plugin.ts
4
+ * para eliminar duplicación (D1). Ambos importan de acá.
5
+ */
6
+
7
+ import { SENSITIVE_DEFAULT } from "./security.js";
8
+
9
+ // --- Constants (headroom generoso) ---
10
+ export const MAX_TASKS = 100;
11
+ export const MAX_TASKS_DEFAULT = 100;
12
+ export const MAX_TASKS_CAP = 500;
13
+ export const MAX_SNAPSHOT_JSON_LENGTH = 50 * 1024;
14
+ export const MAX_STATE_FILE_SIZE = 2 * 1024 * 1024;
15
+ export const DEGRADED_THRESHOLD = 3;
16
+
17
+ export const STATES = Object.freeze({
18
+ INTERPRETATION_PENDING: "INTERPRETATION_PENDING",
19
+ CLARIFICATION_PENDING: "CLARIFICATION_PENDING",
20
+ DISCOVERY: "DISCOVERY",
21
+ ROUTE_DECISION_PENDING: "ROUTE_DECISION_PENDING",
22
+ SPECIFICATION: "SPECIFICATION",
23
+ EXECUTION_ANALYSIS: "EXECUTION_ANALYSIS",
24
+ EXECUTION_DECISION_PENDING: "EXECUTION_DECISION_PENDING",
25
+ EXECUTING_INLINE: "EXECUTING_INLINE",
26
+ EXECUTING_SUBAGENTS: "EXECUTING_SUBAGENTS",
27
+ SYNC: "SYNC",
28
+ DONE: "DONE",
29
+ BLOCKED: "BLOCKED",
30
+ } as const);
31
+
32
+ export const TRANSITIONS: Record<string, Array<{ via: string; to: string; choice?: string; mode?: string }>> = {
33
+ INTERPRETATION_PENDING: [
34
+ { via: "request_clarification", to: "CLARIFICATION_PENDING" },
35
+ { via: "proceed_to_discovery", to: "DISCOVERY" },
36
+ { via: "record_discovery", to: "ROUTE_DECISION_PENDING" },
37
+ { via: "block", to: "BLOCKED" },
38
+ ],
39
+ CLARIFICATION_PENDING: [
40
+ { via: "record_clarification", to: "DISCOVERY" },
41
+ { via: "block", to: "BLOCKED" },
42
+ { via: "abandon", to: "BLOCKED" },
43
+ ],
44
+ DISCOVERY: [
45
+ { via: "record_discovery", to: "ROUTE_DECISION_PENDING" },
46
+ { via: "block", to: "BLOCKED" },
47
+ { via: "abandon", to: "BLOCKED" },
48
+ ],
49
+ ROUTE_DECISION_PENDING: [
50
+ { via: "consume_route_decision", to: "SPECIFICATION", choice: "SPEC" },
51
+ { via: "consume_route_decision", to: "EXECUTION_ANALYSIS", choice: "DIRECT" },
52
+ { via: "block", to: "BLOCKED" },
53
+ { via: "abandon", to: "BLOCKED" },
54
+ ],
55
+ SPECIFICATION: [
56
+ { via: "spec_complete", to: "EXECUTION_ANALYSIS" },
57
+ { via: "block", to: "BLOCKED" },
58
+ { via: "abandon", to: "BLOCKED" },
59
+ ],
60
+ EXECUTION_ANALYSIS: [
61
+ { via: "record_execution_analysis", to: "EXECUTION_DECISION_PENDING" },
62
+ { via: "block", to: "BLOCKED" },
63
+ { via: "abandon", to: "BLOCKED" },
64
+ ],
65
+ EXECUTION_DECISION_PENDING: [
66
+ { via: "consume_execution_decision", to: "EXECUTING_INLINE", mode: "INLINE" },
67
+ { via: "consume_execution_decision", to: "EXECUTING_SUBAGENTS", mode: "SUBAGENT_DRIVEN" },
68
+ { via: "block", to: "BLOCKED" },
69
+ { via: "abandon", to: "BLOCKED" },
70
+ ],
71
+ EXECUTING_INLINE: [
72
+ { via: "implementation_complete", to: "SYNC" },
73
+ { via: "block", to: "BLOCKED" },
74
+ ],
75
+ EXECUTING_SUBAGENTS: [
76
+ { via: "implementation_complete", to: "SYNC" },
77
+ { via: "block", to: "BLOCKED" },
78
+ ],
79
+ BLOCKED: [
80
+ { via: "replan", to: "INTERPRETATION_PENDING" },
81
+ { via: "abandon", to: "DONE" },
82
+ ],
83
+ SYNC: [
84
+ { via: "sync_complete", to: "DONE" },
85
+ { via: "block", to: "BLOCKED" },
86
+ ],
87
+ DONE: [],
88
+ };
89
+
90
+ export const TERMINAL_STATES = Object.freeze([
91
+ STATES.INTERPRETATION_PENDING,
92
+ STATES.CLARIFICATION_PENDING,
93
+ STATES.BLOCKED,
94
+ STATES.DONE,
95
+ ]);
96
+
97
+ export const DEFAULT_STATE: any = {
98
+ state: STATES.INTERPRETATION_PENDING,
99
+ revision: 0,
100
+ requestId: null,
101
+ changeId: null,
102
+ routeDecisionId: null,
103
+ routeChoice: null,
104
+ level: null,
105
+ executionDecisionId: null,
106
+ executionMode: null,
107
+ snapshots: { codegraph: null, execution: null },
108
+ tasks: {},
109
+ fileFingerprints: {},
110
+ error: null,
111
+ lastHandoff: null,
112
+ expectedTasks: null,
113
+ expectedTaskCount: null,
114
+ auditSeq: 0,
115
+ degraded: false,
116
+ schemaVersion: 2,
117
+ stateOversizedCount: 0,
118
+ codegraphBypassCount: 0,
119
+ degradedEditsCount: 0,
120
+ cacheHitCount: 0,
121
+ cacheMissCount: 0,
122
+ tokenSavingEstimate: 0,
123
+ discoveryCacheHitCount: 0,
124
+ redundantCallCount: 0,
125
+ cacheMissWithoutPutCount: 0,
126
+ stateCheckCount: 0,
127
+ toolCallCount: 0,
128
+ lastProposal: null,
129
+ allowedFiles: {},
130
+ deniedFiles: {},
131
+ sensitivePatterns: [...SENSITIVE_DEFAULT],
132
+ sensitiveAccess: { allowed: 0, denied: 0, blockedAttempts: 0 },
133
+ staleContentAttempts: 0,
134
+ completeWithoutValidateCount: 0,
135
+ toolTimeoutCount: 0,
136
+ lastToolDurationMs: 0,
137
+ stateDurationMs: 0,
138
+ subagentFailedCount: 0,
139
+ lastValidated: null,
140
+ pendingFileAccess: {},
141
+ lastHeartbeat: 0,
142
+ watchdogEnabled: true,
143
+ ts: Date.now(),
144
+ specNotInSync: false,
145
+ };
146
+
147
+ export function safeJsonStringify(obj: any, pretty = false): string {
148
+ const seen = new WeakSet();
149
+ try {
150
+ return JSON.stringify(
151
+ obj,
152
+ (key, value) => {
153
+ if (typeof value === "object" && value !== null) {
154
+ if (seen.has(value)) return "[Circular]";
155
+ seen.add(value);
156
+ }
157
+ return value;
158
+ },
159
+ pretty ? 2 : undefined
160
+ );
161
+ } catch (e: any) {
162
+ return `[Unstringifiable: ${e.message}]`;
163
+ }
164
+ }
165
+
166
+ const SENSITIVE_REDACT_RE = /(apiKey|secret|token|password|api_key)/i;
167
+
168
+ export function redactForLog(data: any): any {
169
+ if (!data || typeof data !== "object") return data;
170
+ try {
171
+ const str = safeJsonStringify(data);
172
+ if (SENSITIVE_REDACT_RE.test(str)) {
173
+ const copy = JSON.parse(str);
174
+ const redactRecursively = (obj: any) => {
175
+ if (!obj || typeof obj !== "object") return;
176
+ for (const k of Object.keys(obj)) {
177
+ if (SENSITIVE_REDACT_RE.test(k)) obj[k] = "[REDACTED]";
178
+ else if (typeof obj[k] === "object") redactRecursively(obj[k]);
179
+ }
180
+ };
181
+ redactRecursively(copy);
182
+ return copy;
183
+ }
184
+ return data;
185
+ } catch {
186
+ return data;
187
+ }
188
+ }
189
+
190
+ export function getMaxTasks(): number {
191
+ const raw = process.env.OSTACKY_MAX_TASKS;
192
+ if (raw == null || raw === "") return MAX_TASKS_DEFAULT;
193
+ const n = parseInt(raw, 10);
194
+ if (Number.isNaN(n) || n <= 0) return MAX_TASKS_DEFAULT;
195
+ if (n > MAX_TASKS_CAP) return MAX_TASKS_CAP;
196
+ return n;
197
+ }
198
+
199
+ export function getAuditRetention(): number {
200
+ const raw = process.env.OSTACKY_AUDIT_RETENTION;
201
+ if (raw == null || raw === "") return 500;
202
+ const n = parseInt(raw, 10);
203
+ if (Number.isNaN(n) || n <= 0) return 500;
204
+ if (n > 2000) return 2000;
205
+ return n;
206
+ }