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.
- package/README.md +20 -25
- package/assets/agents/ostacky.md +18 -3
- package/assets/commands/install-stack.md +2 -2
- package/assets/mcp/ostacky-controller/controller-core.js +206 -0
- package/assets/mcp/ostacky-controller/index.js +166 -164
- package/assets/mcp/ostacky-controller/package.json +1 -1
- package/assets/plugins/controller-core.ts +206 -0
- package/assets/plugins/ostacky-plugin.ts +12 -124
- package/assets/skills/brainstorming/SKILL.md +5 -3
- package/dist/cli.js +503 -720
- package/manifest.json +43 -43
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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);
|
|
@@ -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
|
+
}
|