@magnusekdahl/parallix 1.0.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.
- package/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnAndTee } = require('../core/spawn-tee');
|
|
5
|
+
const { extractCodexTelemetry } = require('./codex-telemetry');
|
|
6
|
+
const sessions = require('../tools/sessions');
|
|
7
|
+
|
|
8
|
+
// Injectable I/O for tests. Production uses the real spawn-tee / export capture.
|
|
9
|
+
let _spawnAndTee = spawnAndTee;
|
|
10
|
+
let _sessions = sessions;
|
|
11
|
+
|
|
12
|
+
// Test hooks: override the launcher's I/O without touching the public signature.
|
|
13
|
+
function __setSpawnAndTeeForTest(fn) { _spawnAndTee = fn || spawnAndTee; }
|
|
14
|
+
function __setSessionsForTest(mod) { _sessions = mod || sessions; }
|
|
15
|
+
|
|
16
|
+
// Codex outputs "To continue this session, run codex resume <id>" at the end.
|
|
17
|
+
// Also captures the session ID from the "Interaction Summary" block.
|
|
18
|
+
const CODEX_SESSION_ID_RE = /codex\s+resume\s+([0-9a-f-]+)/i;
|
|
19
|
+
const CODEX_SESSION_ID_ALT_RE = /Session ID:\s*([0-9a-f-]+)/i;
|
|
20
|
+
|
|
21
|
+
function extractCodexSessionId(stdout) {
|
|
22
|
+
if (!stdout) return null;
|
|
23
|
+
const m = CODEX_SESSION_ID_RE.exec(stdout);
|
|
24
|
+
if (m) return m[1];
|
|
25
|
+
const m2 = CODEX_SESSION_ID_ALT_RE.exec(stdout);
|
|
26
|
+
if (m2) return m2[1];
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveCodexCommand() {
|
|
31
|
+
return 'codex';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasLiveTty() {
|
|
35
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildCodexDraftInvocation({ prompt, worktree, interactive = hasLiveTty(), env = {}, resume = false, sessionId = null, model = null }) {
|
|
39
|
+
if (resume) {
|
|
40
|
+
const args = ['exec', 'resume'];
|
|
41
|
+
if (sessionId) {
|
|
42
|
+
args.push(sessionId);
|
|
43
|
+
} else {
|
|
44
|
+
args.push('--last');
|
|
45
|
+
}
|
|
46
|
+
if (model) args.push('-m', model);
|
|
47
|
+
args.push(prompt);
|
|
48
|
+
return {
|
|
49
|
+
command: resolveCodexCommand(),
|
|
50
|
+
args,
|
|
51
|
+
options: {
|
|
52
|
+
stdio: 'inherit',
|
|
53
|
+
cwd: worktree,
|
|
54
|
+
env: { ...process.env, ...env, HOME: codexHomeRoot(worktree) }
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const modelArgs = model ? ['-m', model] : [];
|
|
60
|
+
const args = interactive
|
|
61
|
+
? ['--full-auto', ...modelArgs, '--cd', worktree, prompt]
|
|
62
|
+
: ['exec', '--sandbox', 'danger-full-access', ...modelArgs, '--cd', worktree, prompt];
|
|
63
|
+
|
|
64
|
+
const baseEnv = { ...process.env };
|
|
65
|
+
if (!interactive) {
|
|
66
|
+
baseEnv.HOME = codexHomeRoot(worktree);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
command: resolveCodexCommand(),
|
|
71
|
+
args,
|
|
72
|
+
options: {
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
cwd: worktree,
|
|
75
|
+
env: { ...baseEnv, ...env }
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function startCodexDraftAgent({ prompt, worktree, env = {}, resume = false, sessionId = null, model = null, teeOptions = {}, slug = null, role = null }) {
|
|
81
|
+
// The launcher always tees through spawnAndTee for limit-hit detection, which
|
|
82
|
+
// forces child stdio to ['inherit', 'pipe', 'pipe']. Codex's `--full-auto`
|
|
83
|
+
// interactive UI requires a TTY on stdout, so we always use the headless
|
|
84
|
+
// `exec` path here regardless of whether the parent has a TTY.
|
|
85
|
+
ensureCodexHome(worktree);
|
|
86
|
+
|
|
87
|
+
function isStaleSessionResult(result) {
|
|
88
|
+
if (!result) return false;
|
|
89
|
+
const stderr = result.stderr || '';
|
|
90
|
+
const stdout = result.stdout || '';
|
|
91
|
+
return (stderr.includes('Session not found') || stdout.includes('Session not found'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function processResult(result) {
|
|
95
|
+
if (result && result.stdout) {
|
|
96
|
+
result.sessionId = extractCodexSessionId(result.stdout);
|
|
97
|
+
}
|
|
98
|
+
if (result) {
|
|
99
|
+
// Real usage telemetry comes from the codex rollout JSONL written under the
|
|
100
|
+
// worktree-scoped codex-home, not the stdout stream (see codex-telemetry.js).
|
|
101
|
+
try {
|
|
102
|
+
const telemetry = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: Date.now() });
|
|
103
|
+
if (telemetry) {
|
|
104
|
+
result.telemetry = telemetry;
|
|
105
|
+
if (telemetry.model) result.model = telemetry.model;
|
|
106
|
+
if (telemetry.provider) result.provider = telemetry.provider;
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {
|
|
109
|
+
// Telemetry is best-effort; never let it break the launch result.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function staleSessionHandler(invocation) {
|
|
116
|
+
return _spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions })
|
|
117
|
+
.then(result => {
|
|
118
|
+
if (isStaleSessionResult(result) && worktree && resume) {
|
|
119
|
+
try {
|
|
120
|
+
_sessions.clearSession(worktree, slug, role);
|
|
121
|
+
} catch (_) { /* best-effort */ }
|
|
122
|
+
const freshInv = buildCodexDraftInvocation({ prompt, worktree, interactive: false, env, resume: false, sessionId: null, model });
|
|
123
|
+
return _spawnAndTee(freshInv.command, freshInv.args, { ...freshInv.options, ...teeOptions });
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
})
|
|
127
|
+
.then(processResult);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const startedMs = Date.now();
|
|
131
|
+
const invocation = buildCodexDraftInvocation({ prompt, worktree, interactive: false, env, resume, sessionId, model });
|
|
132
|
+
const resultPromise = staleSessionHandler(invocation);
|
|
133
|
+
|
|
134
|
+
return { invocation, resultPromise };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function codexHomeRoot(worktree) {
|
|
138
|
+
return path.join(worktree, '.workflow', 'codex-home');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function codexConfigPath(worktree) {
|
|
142
|
+
return path.join(codexHomeRoot(worktree), '.codex', 'config.toml');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function codexAuthPath(worktree) {
|
|
146
|
+
return path.join(codexHomeRoot(worktree), '.codex', 'auth.json');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function userCodexAuthPath() {
|
|
150
|
+
return path.join(os.homedir(), '.codex', 'auth.json');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tomlString(value) {
|
|
154
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Graphify's Codex skill requires multi_agent = true for spawn_agent subagent
|
|
158
|
+
// dispatch (skill-codex.md line 233). Without this, the copied Graphify skill
|
|
159
|
+
// cannot launch semantic extraction subagents and parallel graph building fails.
|
|
160
|
+
function headlessCodexConfig(worktree) {
|
|
161
|
+
const worktreeParent = path.dirname(path.resolve(worktree));
|
|
162
|
+
return [
|
|
163
|
+
'sandbox_mode = "danger-full-access"',
|
|
164
|
+
'',
|
|
165
|
+
'[features]',
|
|
166
|
+
'multi_agent = true',
|
|
167
|
+
'',
|
|
168
|
+
`[projects.${tomlString(worktreeParent)}]`,
|
|
169
|
+
'trust_level = "trusted"',
|
|
170
|
+
'approval_policy = "never"',
|
|
171
|
+
'',
|
|
172
|
+
`[projects.${tomlString(path.resolve(worktree))}]`,
|
|
173
|
+
'trust_level = "trusted"',
|
|
174
|
+
'approval_policy = "never"',
|
|
175
|
+
''
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureCodexHome(worktree) {
|
|
180
|
+
fs.mkdirSync(codexHomeRoot(worktree), { recursive: true });
|
|
181
|
+
fs.mkdirSync(path.dirname(codexConfigPath(worktree)), { recursive: true });
|
|
182
|
+
fs.writeFileSync(codexConfigPath(worktree), headlessCodexConfig(worktree), 'utf8');
|
|
183
|
+
|
|
184
|
+
const sourceAuthPath = userCodexAuthPath();
|
|
185
|
+
if (fs.existsSync(sourceAuthPath)) {
|
|
186
|
+
fs.copyFileSync(sourceAuthPath, codexAuthPath(worktree));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Seed the globally-installed Graphify skill into the isolated worktree HOME,
|
|
190
|
+
// the same way auth.json is carried over from the operator's real home. Codex
|
|
191
|
+
// runs with HOME=codexHomeRoot, so it looks for skills under
|
|
192
|
+
// <home>/.agents/skills; without this copy the one-time global install at
|
|
193
|
+
// ~/.agents/skills is invisible to codex. This is a plain filesystem copy of
|
|
194
|
+
// an existing install, not a `graphify install` invocation, and skips cleanly
|
|
195
|
+
// when the skill is absent.
|
|
196
|
+
const sourceSkillPath = path.join(os.homedir(), '.agents', 'skills', 'graphify');
|
|
197
|
+
if (fs.existsSync(sourceSkillPath)) {
|
|
198
|
+
fs.cpSync(
|
|
199
|
+
sourceSkillPath,
|
|
200
|
+
path.join(codexHomeRoot(worktree), '.agents', 'skills', 'graphify'),
|
|
201
|
+
{ recursive: true }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
codexAuthPath,
|
|
208
|
+
buildCodexDraftInvocation,
|
|
209
|
+
codexConfigPath,
|
|
210
|
+
codexHomeRoot,
|
|
211
|
+
ensureCodexHome,
|
|
212
|
+
extractCodexSessionId,
|
|
213
|
+
extractCodexTelemetry,
|
|
214
|
+
headlessCodexConfig,
|
|
215
|
+
resolveCodexCommand,
|
|
216
|
+
startCodexDraftAgent,
|
|
217
|
+
__setSpawnAndTeeForTest,
|
|
218
|
+
__setSessionsForTest
|
|
219
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FALLBACK_HOURS = 1;
|
|
4
|
+
const SIGINT_SHORT_BLOCK_MINUTES = 15;
|
|
5
|
+
|
|
6
|
+
const PATTERN_SETS = Object.freeze({
|
|
7
|
+
claude: [
|
|
8
|
+
/claude (?:ai )?usage limit reached/i,
|
|
9
|
+
/\b5-hour limit (?:reached|exceeded)\b/i,
|
|
10
|
+
/you(?:'|')?ve hit your limit(?:\b|$)/i,
|
|
11
|
+
/you(?:'|')?ve hit your (?:weekly|daily|monthly|usage) limit/i,
|
|
12
|
+
/(?:usage|rate)\s*limit (?:has been )?(?:reached|exceeded)/i,
|
|
13
|
+
/\b429\b[^\n]*?\b(?:rate|usage|quota)\b/i
|
|
14
|
+
],
|
|
15
|
+
codex: [
|
|
16
|
+
/you(?:'|’)?ve hit your (?:weekly|daily|monthly|usage) limit/i,
|
|
17
|
+
/\brate[_ ]?limit(?:_| )?(?:exceeded|reached)\b/i,
|
|
18
|
+
/\bweekly (?:codex )?usage limit\b/i,
|
|
19
|
+
/\b429\b[^\n]*?\b(?:rate|quota|usage)\b/i,
|
|
20
|
+
/resource_exhausted/i
|
|
21
|
+
],
|
|
22
|
+
|
|
23
|
+
qwen: [
|
|
24
|
+
/\b(?:rate|usage|quota)\s*limit\s*(?:reached|exceeded)\b/i,
|
|
25
|
+
/\b429\b[^\n]*?\b(?:rate|quota|usage)\b/i,
|
|
26
|
+
/\b429\b/i,
|
|
27
|
+
/\binsufficient[_ ]quota\b/i
|
|
28
|
+
],
|
|
29
|
+
mistral: [
|
|
30
|
+
/\bmistral (?:ai )?usage limit reached/i,
|
|
31
|
+
/\bmistral (?:ai )?quota exceeded/i,
|
|
32
|
+
/\bmistral (?:ai )?rate limit(?: reached| exceeded)/i,
|
|
33
|
+
/you(?:'|')?ve hit your (?:mistral|vibe) (?:weekly|daily|monthly|usage) limit/i,
|
|
34
|
+
/\b429\b[^\n]*?\b(?:rate|quota|usage)\b/i,
|
|
35
|
+
/\bvibe\b[^\n]*?\b(?:quota exceeded|rate limit exceeded)\b/i,
|
|
36
|
+
/\bmistral\b[^\n]*?\b(?:quota exceeded|rate limit exceeded)\b/i,
|
|
37
|
+
/\bresource[_ ]has[_ ]been[_ ]exhausted\b/i,
|
|
38
|
+
/\bresource_exhausted\b/i
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function getPatternsForAgent(agent) {
|
|
43
|
+
return PATTERN_SETS[agent] || [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findLimitHitMatch(agent, text) {
|
|
47
|
+
const patterns = getPatternsForAgent(agent);
|
|
48
|
+
for (const pattern of patterns) {
|
|
49
|
+
const match = pattern.exec(text);
|
|
50
|
+
if (match) {
|
|
51
|
+
return { pattern, index: match.index, length: match[0].length };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clipContext(text, index, length) {
|
|
58
|
+
const before = Math.max(0, index - 200);
|
|
59
|
+
const after = Math.min(text.length, index + length + 200);
|
|
60
|
+
return text.slice(before, after);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ISO_PATTERN = /(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?/;
|
|
64
|
+
const TWELVE_HOUR_PATTERN = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm|AM|PM)\b/;
|
|
65
|
+
const TWENTY_FOUR_HOUR_PATTERN = /\b([01]?\d|2[0-3]):([0-5]\d)\b/;
|
|
66
|
+
const RELATIVE_PATTERN = /\bin\s+(\d+)\s+(hour|hours|minute|minutes|second|seconds)\b/i;
|
|
67
|
+
const RETRY_AFTER_PATTERN = /retry[- ]?after[:\s]+(\d+)\s*(seconds?|s|minutes?|m)?/i;
|
|
68
|
+
const RETRY_AT_DATE_PATTERN = /(?:retry|reset|try again|reset(?:s)? at)[^\n]{0,40}?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i;
|
|
69
|
+
|
|
70
|
+
function parseIsoOffset(value) {
|
|
71
|
+
if (!value) return null;
|
|
72
|
+
if (value === 'Z') return 0;
|
|
73
|
+
const m = /([+-])(\d{2}):?(\d{2})/.exec(value);
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
const sign = m[1] === '-' ? -1 : 1;
|
|
76
|
+
return sign * (Number(m[2]) * 60 + Number(m[3]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseIso(text) {
|
|
80
|
+
const m = ISO_PATTERN.exec(text);
|
|
81
|
+
if (!m) return null;
|
|
82
|
+
const [, year, month, day, hour, minute, second, offset] = m;
|
|
83
|
+
const offsetMinutes = parseIsoOffset(offset);
|
|
84
|
+
if (offsetMinutes === null) {
|
|
85
|
+
return new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second || 0), 0);
|
|
86
|
+
}
|
|
87
|
+
const utcMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second || 0), 0);
|
|
88
|
+
return new Date(utcMs - offsetMinutes * 60 * 1000);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseTwelveHour(text, now) {
|
|
92
|
+
const m = TWELVE_HOUR_PATTERN.exec(text);
|
|
93
|
+
if (!m) return null;
|
|
94
|
+
let hour = Number(m[1]);
|
|
95
|
+
const minute = Number(m[2] || 0);
|
|
96
|
+
const meridiem = m[3].toLowerCase();
|
|
97
|
+
if (hour < 1 || hour > 12) return null;
|
|
98
|
+
if (meridiem === 'pm' && hour !== 12) hour += 12;
|
|
99
|
+
if (meridiem === 'am' && hour === 12) hour = 0;
|
|
100
|
+
return projectClockTime(now, hour, minute);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseTwentyFourHour(text, now) {
|
|
104
|
+
const m = TWENTY_FOUR_HOUR_PATTERN.exec(text);
|
|
105
|
+
if (!m) return null;
|
|
106
|
+
const hour = Number(m[1]);
|
|
107
|
+
const minute = Number(m[2]);
|
|
108
|
+
return projectClockTime(now, hour, minute);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseRelative(text, now) {
|
|
112
|
+
const m = RELATIVE_PATTERN.exec(text);
|
|
113
|
+
if (!m) return null;
|
|
114
|
+
const amount = Number(m[1]);
|
|
115
|
+
const unit = m[2].toLowerCase();
|
|
116
|
+
let ms;
|
|
117
|
+
if (unit.startsWith('hour')) ms = amount * 60 * 60 * 1000;
|
|
118
|
+
else if (unit.startsWith('minute')) ms = amount * 60 * 1000;
|
|
119
|
+
else ms = amount * 1000;
|
|
120
|
+
return new Date(now.getTime() + ms);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseRetryAfter(text, now) {
|
|
124
|
+
const m = RETRY_AFTER_PATTERN.exec(text);
|
|
125
|
+
if (!m) return null;
|
|
126
|
+
const amount = Number(m[1]);
|
|
127
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
128
|
+
let ms;
|
|
129
|
+
if (unit.startsWith('m') && unit !== 's') ms = amount * 60 * 1000;
|
|
130
|
+
else ms = amount * 1000;
|
|
131
|
+
return new Date(now.getTime() + ms);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseRetryAtDate(text) {
|
|
135
|
+
const m = RETRY_AT_DATE_PATTERN.exec(text);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
return parseIso(m[1]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function projectClockTime(now, hour, minute) {
|
|
141
|
+
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
|
|
142
|
+
if (candidate.getTime() <= now.getTime()) {
|
|
143
|
+
candidate.setDate(candidate.getDate() + 1);
|
|
144
|
+
}
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseResetTime(text, now = new Date()) {
|
|
149
|
+
if (!text) return null;
|
|
150
|
+
return (
|
|
151
|
+
parseRetryAtDate(text) ||
|
|
152
|
+
parseIso(text) ||
|
|
153
|
+
parseTwelveHour(text, now) ||
|
|
154
|
+
parseTwentyFourHour(text, now) ||
|
|
155
|
+
parseRelative(text, now) ||
|
|
156
|
+
parseRetryAfter(text, now)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatBlockUntil(date) {
|
|
161
|
+
const year = date.getFullYear();
|
|
162
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
163
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
164
|
+
const hour = String(date.getHours()).padStart(2, '0');
|
|
165
|
+
return `${year}-${month}-${day} ${hour}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ceilToNextHour(date) {
|
|
169
|
+
const ceil = new Date(date.getTime());
|
|
170
|
+
if (ceil.getMinutes() === 0 && ceil.getSeconds() === 0 && ceil.getMilliseconds() === 0) {
|
|
171
|
+
return ceil;
|
|
172
|
+
}
|
|
173
|
+
ceil.setMinutes(0, 0, 0);
|
|
174
|
+
ceil.setHours(ceil.getHours() + 1);
|
|
175
|
+
return ceil;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function detectLimitHit({
|
|
179
|
+
agent,
|
|
180
|
+
stdout = '',
|
|
181
|
+
stderr = '',
|
|
182
|
+
status,
|
|
183
|
+
signal,
|
|
184
|
+
error,
|
|
185
|
+
now = new Date()
|
|
186
|
+
} = {}) {
|
|
187
|
+
if (!agent) return null;
|
|
188
|
+
// Gate detection on a failed launcher invocation. A successful child (exit 0,
|
|
189
|
+
// no signal, no spawn error) means any limit-hit phrases in the transcript
|
|
190
|
+
// are quoted text — typically when the agent itself reviewed code, tests, or
|
|
191
|
+
// logs containing those strings. Treating that as a real limit hit would
|
|
192
|
+
// wrongly block a healthy agent in agents.local.json.
|
|
193
|
+
// `status === undefined` means the caller did not pass exit metadata; preserve
|
|
194
|
+
// the legacy behavior so callers that haven't been updated still get detection.
|
|
195
|
+
const launcherFailed =
|
|
196
|
+
error != null ||
|
|
197
|
+
signal != null ||
|
|
198
|
+
(typeof status === 'number' && status !== 0) ||
|
|
199
|
+
typeof status === 'undefined';
|
|
200
|
+
if (!launcherFailed) return null;
|
|
201
|
+
|
|
202
|
+
const combined = `${stdout || ''}\n${stderr || ''}`;
|
|
203
|
+
const match = findLimitHitMatch(agent, combined);
|
|
204
|
+
|
|
205
|
+
let target;
|
|
206
|
+
let source;
|
|
207
|
+
|
|
208
|
+
if (match) {
|
|
209
|
+
const context = clipContext(combined, match.index, match.length);
|
|
210
|
+
const parsed = parseResetTime(context, now);
|
|
211
|
+
|
|
212
|
+
if (parsed && !Number.isNaN(parsed.getTime()) && parsed.getTime() > now.getTime()) {
|
|
213
|
+
target = parsed;
|
|
214
|
+
source = 'parsed';
|
|
215
|
+
} else {
|
|
216
|
+
target = new Date(now.getTime() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000);
|
|
217
|
+
source = 'fallback';
|
|
218
|
+
}
|
|
219
|
+
} else if (signal != null) {
|
|
220
|
+
// Agent was killed by a signal (e.g. SIGINT/Ctrl-C) but no limit-hit
|
|
221
|
+
// pattern matched. Apply a short-term block so the next invocation
|
|
222
|
+
// selects a different agent instead of re-picking the same one.
|
|
223
|
+
target = new Date(now.getTime() + SIGINT_SHORT_BLOCK_MINUTES * 60 * 1000);
|
|
224
|
+
source = 'sigint';
|
|
225
|
+
} else {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const ceiled = ceilToNextHour(target);
|
|
230
|
+
return { until: formatBlockUntil(ceiled), source };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
detectLimitHit,
|
|
235
|
+
parseResetTime,
|
|
236
|
+
formatBlockUntil,
|
|
237
|
+
ceilToNextHour,
|
|
238
|
+
findLimitHitMatch,
|
|
239
|
+
clipContext,
|
|
240
|
+
parseIsoOffset,
|
|
241
|
+
parseIso,
|
|
242
|
+
parseTwelveHour,
|
|
243
|
+
parseTwentyFourHour,
|
|
244
|
+
parseRelative,
|
|
245
|
+
parseRetryAfter,
|
|
246
|
+
parseRetryAtDate,
|
|
247
|
+
projectClockTime,
|
|
248
|
+
getPatternsForAgent,
|
|
249
|
+
PATTERN_SETS,
|
|
250
|
+
DEFAULT_FALLBACK_HOURS,
|
|
251
|
+
SIGINT_SHORT_BLOCK_MINUTES
|
|
252
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mistral (Vibe) Telemetry Stub
|
|
3
|
+
*
|
|
4
|
+
* Mistral Vibe in programmatic mode does not output token-usage data to
|
|
5
|
+
* stdout/stderr, and has no CLI endpoint for querying session usage.
|
|
6
|
+
* This stub records honest zeros for all token columns so the stats CSV
|
|
7
|
+
* never contains fabricated data.
|
|
8
|
+
*
|
|
9
|
+
* Provider and model fields are set to "mistral" to indicate the agent family.
|
|
10
|
+
* The actual model is determined at runtime via VIBE_ACTIVE_MODEL env var
|
|
11
|
+
* but is not captured per-session, so model falls back to "mistral".
|
|
12
|
+
*
|
|
13
|
+
* See task-1285 for the full telemetry credibility design. The blocked
|
|
14
|
+
* verification is tracked as follow-up task-1288 ("Verify vibe/mistral
|
|
15
|
+
* telemetry once usage-unblocked"), which names the missing evidence and the
|
|
16
|
+
* reason it could not be collected in the task-1285 environment.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Attempt to extract telemetry from a mistral launcher result.
|
|
21
|
+
* Since mistral/vibe provides no parseable usage data, always returns null.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} result - The launcher result object
|
|
24
|
+
* @returns {null} Always null — mistral/vibe has no telemetry parser
|
|
25
|
+
*/
|
|
26
|
+
function extractMistralTelemetry(result) {
|
|
27
|
+
// Mistral Vibe does not emit token usage data. Honest zero.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return the provider/model pair for mistral tasks.
|
|
33
|
+
* Used as fallback when telemetry is null.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{provider: string, model: string}}
|
|
36
|
+
*/
|
|
37
|
+
function getMistralProviderModel() {
|
|
38
|
+
return { provider: 'mistral', model: 'mistral' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
extractMistralTelemetry,
|
|
43
|
+
getMistralProviderModel,
|
|
44
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { spawnAndTee } = require('../core/spawn-tee');
|
|
2
|
+
|
|
3
|
+
// Mistral Vibe in programmatic mode (-p/--prompt) does NOT output a resume
|
|
4
|
+
// hint to stdout/stderr like other agents do. Session IDs are stored in
|
|
5
|
+
// ~/.vibe/logs/session/session_<timestamp>_<short_id>/meta.json with UUID format,
|
|
6
|
+
// but there is no reliable stdout pattern to extract. Therefore, Mistral is
|
|
7
|
+
// NOT marked as RESUME_CAPABLE in agents.js. If future Vibe versions add
|
|
8
|
+
// a resume hint, update this regex and add 'mistral' to RESUME_CAPABLE.
|
|
9
|
+
// Current session ID format in meta.json: UUID like "a3dd3d4d-f97d-d57d-4942-a1f694e3a922"
|
|
10
|
+
// Directory naming uses first 8 chars: session_20260521_162703_a3dd3d4d
|
|
11
|
+
// No stdout marker detected in testing, so we leave this as null.
|
|
12
|
+
// Telemetry: mistral/vibe does not expose token-usage data. See mistral-telemetry.js
|
|
13
|
+
// for the honest-zero stub. Stats hooks in active.js and review-loop.js call
|
|
14
|
+
// recordStageStats which defaults to '0' for tokens when telemetry is null.
|
|
15
|
+
const MISTRAL_SESSION_ID_RE = null;
|
|
16
|
+
|
|
17
|
+
function extractMistralSessionId(stdout) {
|
|
18
|
+
// Vibe does not currently emit a resume hint in programmatic mode.
|
|
19
|
+
// Return null to indicate no resume capability via stdout parsing.
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveMistralCommand() {
|
|
24
|
+
return 'vibe';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildMistralInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null }) {
|
|
28
|
+
const args = ['--prompt', prompt, '--trust', '--output', 'text'];
|
|
29
|
+
|
|
30
|
+
// Vibe programmatic mode does not support --resume flag in the same way
|
|
31
|
+
// as other agents. The --resume flag exists but requires interactive selection
|
|
32
|
+
// or a session picker. Since we cannot reliably pass a session ID via
|
|
33
|
+
// programmatic mode, we do not add resume flags here.
|
|
34
|
+
// If resume capability is proven in a future version, update this.
|
|
35
|
+
|
|
36
|
+
// Vibe has no CLI model flag in programmatic mode; the active model is
|
|
37
|
+
// selected via the VIBE_ACTIVE_MODEL env var (verified from `vibe --help`).
|
|
38
|
+
const modelEnv = model ? { VIBE_ACTIVE_MODEL: model } : {};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
command: resolveMistralCommand(),
|
|
42
|
+
args,
|
|
43
|
+
options: {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
cwd: worktree,
|
|
46
|
+
env: { ...process.env, ...env, ...modelEnv }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }) {
|
|
52
|
+
const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
|
|
53
|
+
const resultPromise = spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions }).then(result => {
|
|
54
|
+
if (result && result.stdout) {
|
|
55
|
+
result.sessionId = extractMistralSessionId(result.stdout);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return { invocation, resultPromise };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
buildMistralInvocation,
|
|
65
|
+
extractMistralSessionId,
|
|
66
|
+
resolveMistralCommand,
|
|
67
|
+
startMistralAgent
|
|
68
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture the complete `opencode export <sessionId>` JSON document.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the limit-hit tail buffer in spawn-tee.js, telemetry needs the WHOLE
|
|
9
|
+
* export document (it is a single JSON object beginning with `{`), so a tail
|
|
10
|
+
* buffer would discard the prefix and yield invalid JSON. This collector keeps
|
|
11
|
+
* the full stdout in memory up to an explicit cap and, critically:
|
|
12
|
+
*
|
|
13
|
+
* - Enforces a timeout that kills a hung/non-exiting export child so the
|
|
14
|
+
* launcher can never block on it (mission risk + stop rule).
|
|
15
|
+
* - Fails explicitly (resolves null) when output exceeds `maxBytes` rather
|
|
16
|
+
* than silently truncating into unparseable JSON.
|
|
17
|
+
*
|
|
18
|
+
* Never rejects: any error/timeout/oversize resolves to null so telemetry
|
|
19
|
+
* capture stays best-effort and cannot break the launch.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} sessionId - opencode session id (e.g. ses_...)
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {string} [opts.worktree] - cwd for the export process
|
|
24
|
+
* @param {object} [opts.env] - extra env vars
|
|
25
|
+
* @param {number} [opts.timeoutMs] - hard timeout before the child is killed
|
|
26
|
+
* @param {number} [opts.maxBytes] - explicit byte cap; exceeding it fails
|
|
27
|
+
* @param {Function} [opts.spawn] - injectable spawn (defaults to child_process.spawn)
|
|
28
|
+
* @returns {Promise<string|null>} Full JSON string, or null on failure
|
|
29
|
+
*/
|
|
30
|
+
function captureOpencodeExport(sessionId, opts = {}) {
|
|
31
|
+
const {
|
|
32
|
+
worktree,
|
|
33
|
+
env,
|
|
34
|
+
timeoutMs = 30000,
|
|
35
|
+
maxBytes = 32 * 1024 * 1024,
|
|
36
|
+
spawn = childProcess.spawn,
|
|
37
|
+
} = opts;
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
if (!sessionId) {
|
|
41
|
+
resolve(null);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let settled = false;
|
|
46
|
+
let timer = null;
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let size = 0;
|
|
49
|
+
|
|
50
|
+
const finish = (value) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
if (timer) clearTimeout(timer);
|
|
54
|
+
resolve(value);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const killChild = (child) => {
|
|
58
|
+
try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let child;
|
|
62
|
+
try {
|
|
63
|
+
child = spawn('opencode', ['export', sessionId], {
|
|
64
|
+
cwd: worktree,
|
|
65
|
+
env: { ...process.env, ...(env || {}) },
|
|
66
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
67
|
+
});
|
|
68
|
+
} catch (_) {
|
|
69
|
+
finish(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
timer = setTimeout(() => {
|
|
74
|
+
// Hung or slow export: kill it and degrade to null. Do not block.
|
|
75
|
+
killChild(child);
|
|
76
|
+
finish(null);
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
// Intentionally NOT unref'd: the caller always awaits this Promise, and the
|
|
79
|
+
// timer is cleared via finish() the moment the export resolves. Leaving it
|
|
80
|
+
// ref'd keeps the timeout deterministic under Node's test runner (an unref'd
|
|
81
|
+
// timer can be skipped when the loop is otherwise idle, cancelling the test).
|
|
82
|
+
|
|
83
|
+
if (!child || !child.stdout) {
|
|
84
|
+
finish(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
child.stdout.on('data', (chunk) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
size += chunk.length;
|
|
91
|
+
if (size > maxBytes) {
|
|
92
|
+
// Explicit failure rather than silent truncation: a partial JSON
|
|
93
|
+
// document would parse to null/garbage and fabricate zero telemetry.
|
|
94
|
+
killChild(child);
|
|
95
|
+
finish(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
child.on('error', () => finish(null));
|
|
102
|
+
|
|
103
|
+
child.on('close', () => {
|
|
104
|
+
if (settled) return;
|
|
105
|
+
finish(Buffer.concat(chunks).toString('utf8'));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { captureOpencodeExport };
|