karajan-code 1.36.1 → 1.38.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/README.md +31 -32
- package/docs/README.es.md +131 -237
- package/package.json +1 -1
- package/src/agents/resolve-bin.js +26 -14
- package/src/hu/store.js +57 -0
- package/src/orchestrator/hu-sub-pipeline.js +166 -0
- package/src/orchestrator/iteration-stages.js +50 -15
- package/src/orchestrator/post-loop-stages.js +25 -11
- package/src/orchestrator/pre-loop-stages.js +147 -43
- package/src/orchestrator/preflight-checks.js +3 -3
- package/src/orchestrator/solomon-escalation.js +3 -2
- package/src/orchestrator.js +100 -15
- package/src/planning-game/pipeline-adapter.js +37 -0
- package/src/prompts/hu-reviewer.js +55 -0
- package/src/prompts/triage.js +4 -2
- package/src/utils/display.js +133 -23
- package/src/utils/injection-guard.js +171 -0
- package/src/utils/os-detect.js +17 -8
- package/src/utils/process.js +7 -4
|
@@ -131,6 +131,61 @@ export function normalizeAcceptanceCriteria(criteria) {
|
|
|
131
131
|
return criteria.map(detectAcFormat);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Build a prompt that asks the AI to decompose a complex task into 2-5 formal HUs.
|
|
136
|
+
* Each HU follows the role/goal/benefit format with acceptance criteria and optional dependencies.
|
|
137
|
+
* @param {string} task - The complex task description to decompose.
|
|
138
|
+
* @returns {string} The assembled decomposition prompt.
|
|
139
|
+
*/
|
|
140
|
+
export function buildDecompositionPrompt(task) {
|
|
141
|
+
const sections = [SUBAGENT_PREAMBLE];
|
|
142
|
+
|
|
143
|
+
sections.push("## Task Decomposition");
|
|
144
|
+
sections.push(
|
|
145
|
+
"You are a senior product owner. Decompose the following complex task into 2-5 independent, implementable user stories (HUs).",
|
|
146
|
+
"Each HU must be small enough to implement in a single iteration and must follow the role/goal/benefit format."
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
sections.push(`## Task to Decompose\n${task}`);
|
|
150
|
+
|
|
151
|
+
sections.push(
|
|
152
|
+
"Return a single valid JSON object and nothing else.",
|
|
153
|
+
`JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string]}]}`,
|
|
154
|
+
"Rules:",
|
|
155
|
+
"- Generate between 2 and 5 HUs",
|
|
156
|
+
"- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
|
|
157
|
+
"- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
|
|
158
|
+
"- acceptanceCriteria should be concrete, testable statements",
|
|
159
|
+
"- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)"
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return sections.join("\n\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse the output from a decomposition prompt.
|
|
167
|
+
* @param {string} raw - Raw text output from the agent.
|
|
168
|
+
* @returns {Array<{id: string, title: string, role: string, goal: string, benefit: string, acceptanceCriteria: string[], dependsOn: string[]}>|null}
|
|
169
|
+
*/
|
|
170
|
+
export function parseDecompositionOutput(raw) {
|
|
171
|
+
const parsed = extractFirstJson(raw);
|
|
172
|
+
if (!parsed || !Array.isArray(parsed.stories)) return null;
|
|
173
|
+
|
|
174
|
+
const stories = parsed.stories
|
|
175
|
+
.filter(s => s && s.id && s.title)
|
|
176
|
+
.map(s => ({
|
|
177
|
+
id: s.id,
|
|
178
|
+
title: s.title,
|
|
179
|
+
role: s.role || "developer",
|
|
180
|
+
goal: s.goal || s.title,
|
|
181
|
+
benefit: s.benefit || "",
|
|
182
|
+
acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
|
|
183
|
+
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : []
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
return stories.length > 0 ? stories : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
134
189
|
/**
|
|
135
190
|
* Parse the raw output from the HU reviewer agent.
|
|
136
191
|
* @param {string} raw - Raw text output from the agent.
|
package/src/prompts/triage.js
CHANGED
|
@@ -11,7 +11,8 @@ const ROLE_DESCRIPTIONS = [
|
|
|
11
11
|
{ role: "security", description: "Audits code for security vulnerabilities. Checks auth, input validation, injection risks." },
|
|
12
12
|
{ role: "refactorer", description: "Cleans up and refactors code after the main implementation." },
|
|
13
13
|
{ role: "reviewer", description: "Reviews the code diff for quality issues. Standard quality gate." },
|
|
14
|
-
{ role: "architect", description: "Designs solution architecture — layers, patterns, data model, API contracts, tradeoffs. Activate when task creates new module/app, affects data model/APIs, complexity is medium/complex, or design is ambiguous." }
|
|
14
|
+
{ role: "architect", description: "Designs solution architecture — layers, patterns, data model, API contracts, tradeoffs. Activate when task creates new module/app, affects data model/APIs, complexity is medium/complex, or design is ambiguous." },
|
|
15
|
+
{ role: "hu-reviewer", description: "Certifies user stories before coding. Activate for medium/complex tasks that need story decomposition." }
|
|
15
16
|
];
|
|
16
17
|
|
|
17
18
|
export function buildTriagePrompt({ task, instructions, availableRoles }) {
|
|
@@ -37,13 +38,14 @@ export function buildTriagePrompt({ task, instructions, availableRoles }) {
|
|
|
37
38
|
"- **refactorer**: Enable only when explicitly requested or when the task is a refactoring task.",
|
|
38
39
|
"- **reviewer**: Enable for most tasks as a quality gate. Disable only for trivial, single-line changes.",
|
|
39
40
|
"- **architect**: Enable when creating new modules/apps, changing data models or APIs, medium/complex tasks, or when design approach is ambiguous. Disable for simple fixes, doc-only, or CSS-only changes.",
|
|
41
|
+
"- **hu-reviewer**: Enable for medium/complex tasks that benefit from story decomposition and certification. Disable for trivial/simple tasks, doc-only, or CSS-only changes.",
|
|
40
42
|
"",
|
|
41
43
|
"Note: coder is ALWAYS active — you don't need to decide on it."
|
|
42
44
|
].join("\n"),
|
|
43
45
|
"Classify the task complexity, determine its taskType, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
|
|
44
46
|
"Keep the reasoning short and practical.",
|
|
45
47
|
"Return a single valid JSON object and nothing else.",
|
|
46
|
-
'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security|architect"],"taskType":"sw|infra|doc|add-tests|refactor","reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}',
|
|
48
|
+
'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security|architect|hu-reviewer"],"taskType":"sw|infra|doc|add-tests|refactor","reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}',
|
|
47
49
|
`## Task\n${task}`
|
|
48
50
|
);
|
|
49
51
|
|
package/src/utils/display.js
CHANGED
|
@@ -47,6 +47,20 @@ const ICONS = {
|
|
|
47
47
|
"budget:update": "\ud83d\udcb8",
|
|
48
48
|
"iteration:end": "\u23f1\ufe0f",
|
|
49
49
|
"session:end": "\ud83c\udfc1",
|
|
50
|
+
"discover:start": "\ud83d\udd0e",
|
|
51
|
+
"discover:end": "\ud83d\udd0e",
|
|
52
|
+
"architect:start": "\ud83c\udfdb\ufe0f",
|
|
53
|
+
"architect:end": "\ud83c\udfdb\ufe0f",
|
|
54
|
+
"hu-reviewer:start": "\ud83d\udcdd",
|
|
55
|
+
"hu-reviewer:end": "\ud83d\udcdd",
|
|
56
|
+
"impeccable:start": "\ud83c\udfa8",
|
|
57
|
+
"impeccable:end": "\ud83c\udfa8",
|
|
58
|
+
"audit:start": "\ud83d\udccb",
|
|
59
|
+
"audit:end": "\ud83d\udccb",
|
|
60
|
+
"sonarcloud:start": "\u2601\ufe0f",
|
|
61
|
+
"sonarcloud:end": "\u2601\ufe0f",
|
|
62
|
+
"preflight:start": "\ud83d\udee1\ufe0f",
|
|
63
|
+
"preflight:end": "\ud83d\udee1\ufe0f",
|
|
50
64
|
question: "\u2753"
|
|
51
65
|
};
|
|
52
66
|
|
|
@@ -95,24 +109,37 @@ export function printHeader({ task, config }) {
|
|
|
95
109
|
console.log();
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
/* ── Helper: executor/provider tag ───────────────────────────── */
|
|
113
|
+
|
|
114
|
+
function formatExecutor(detail) {
|
|
115
|
+
if (!detail) return '';
|
|
116
|
+
const provider = detail.provider || '';
|
|
117
|
+
const type = detail.executorType || '';
|
|
118
|
+
if (provider) return ` ${ANSI.dim}${provider}${ANSI.reset}`;
|
|
119
|
+
if (type === 'local') return ` ${ANSI.dim}local${ANSI.reset}`;
|
|
120
|
+
if (type === 'system') return ` ${ANSI.dim}system${ANSI.reset}`;
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
/* ── Helper: role start/end one-liners ───────────────────────── */
|
|
99
125
|
|
|
100
126
|
function roleStart(icon, label, provider) {
|
|
101
127
|
console.log(` \u251c\u2500 ${icon} ${label} (${provider || "?"}) running...`);
|
|
102
128
|
}
|
|
103
129
|
|
|
104
|
-
function roleEnd(status, label, elapsed) {
|
|
105
|
-
console.log(` \u251c\u2500 ${status} ${label} completed ${elapsed}`);
|
|
130
|
+
function roleEnd(status, label, elapsed, executor) {
|
|
131
|
+
console.log(` \u251c\u2500 ${status} ${label} completed${executor || ''} ${elapsed}`);
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
/* ── Helper: pass/fail stage result ─────────────────────────── */
|
|
109
135
|
|
|
110
136
|
function passFailStage(detail, label, failDefault, elapsed) {
|
|
137
|
+
const executor = formatExecutor(detail);
|
|
111
138
|
if (detail?.ok === false) {
|
|
112
139
|
const summary = detail?.summary || failDefault;
|
|
113
|
-
console.log(` \u251c\u2500 ${ANSI.red}\u274c ${label}: ${summary}${ANSI.reset} ${elapsed}`);
|
|
140
|
+
console.log(` \u251c\u2500 ${ANSI.red}\u274c ${label}: ${summary}${ANSI.reset}${executor} ${elapsed}`);
|
|
114
141
|
} else {
|
|
115
|
-
console.log(` \u251c\u2500 ${ANSI.green}\u2705 ${label}: passed${ANSI.reset} ${elapsed}`);
|
|
142
|
+
console.log(` \u251c\u2500 ${ANSI.green}\u2705 ${label}: passed${ANSI.reset}${executor} ${elapsed}`);
|
|
116
143
|
}
|
|
117
144
|
}
|
|
118
145
|
|
|
@@ -270,49 +297,52 @@ const EVENT_HANDLERS = {
|
|
|
270
297
|
roleStart(icon, "Planner", event.detail?.planner);
|
|
271
298
|
},
|
|
272
299
|
|
|
273
|
-
"planner:end": (
|
|
274
|
-
roleEnd(status, "Planner", elapsed);
|
|
300
|
+
"planner:end": (event, _icon, elapsed, status) => {
|
|
301
|
+
roleEnd(status, "Planner", elapsed, formatExecutor(event.detail));
|
|
275
302
|
},
|
|
276
303
|
|
|
277
304
|
"coder:start": (event, icon) => {
|
|
278
305
|
roleStart(icon, "Coder", event.detail?.coder);
|
|
279
306
|
},
|
|
280
307
|
|
|
281
|
-
"coder:end": (
|
|
282
|
-
roleEnd(status, "Coder", elapsed);
|
|
308
|
+
"coder:end": (event, _icon, elapsed, status) => {
|
|
309
|
+
roleEnd(status, "Coder", elapsed, formatExecutor(event.detail));
|
|
283
310
|
},
|
|
284
311
|
|
|
285
312
|
"refactorer:start": (event, icon) => {
|
|
286
313
|
roleStart(icon, "Refactorer", event.detail?.refactorer);
|
|
287
314
|
},
|
|
288
315
|
|
|
289
|
-
"refactorer:end": (
|
|
290
|
-
roleEnd(status, "Refactorer", elapsed);
|
|
316
|
+
"refactorer:end": (event, _icon, elapsed, status) => {
|
|
317
|
+
roleEnd(status, "Refactorer", elapsed, formatExecutor(event.detail));
|
|
291
318
|
},
|
|
292
319
|
|
|
293
320
|
"tdd:result": (event, icon) => {
|
|
294
321
|
const tdd = event.detail || {};
|
|
295
322
|
const label = tdd.ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`;
|
|
296
323
|
const files = tdd.sourceFiles === undefined ? "" : ` (${tdd.sourceFiles} src, ${tdd.testFiles} test)`;
|
|
297
|
-
|
|
324
|
+
const executor = formatExecutor(event.detail);
|
|
325
|
+
console.log(` \u251c\u2500 ${icon} TDD policy: ${label}${files}${executor}`);
|
|
298
326
|
},
|
|
299
327
|
|
|
300
328
|
"researcher:start": (event, icon) => {
|
|
301
329
|
console.log(` \u251c\u2500 ${icon} Researcher (${event.detail?.researcher || "?"}) investigating...`);
|
|
302
330
|
},
|
|
303
331
|
|
|
304
|
-
"researcher:end": (
|
|
305
|
-
roleEnd(status, "Researcher", elapsed);
|
|
332
|
+
"researcher:end": (event, _icon, elapsed, status) => {
|
|
333
|
+
roleEnd(status, "Researcher", elapsed, formatExecutor(event.detail));
|
|
306
334
|
},
|
|
307
335
|
|
|
308
|
-
"sonar:start": (
|
|
309
|
-
|
|
336
|
+
"sonar:start": (event, icon) => {
|
|
337
|
+
const executor = formatExecutor(event.detail);
|
|
338
|
+
console.log(` \u251c\u2500 ${icon} SonarQube scanning...${executor}`);
|
|
310
339
|
},
|
|
311
340
|
|
|
312
341
|
"sonar:end": (event, _icon, elapsed, status) => {
|
|
313
342
|
const gate = event.detail?.gateStatus || "?";
|
|
314
343
|
const gateColor = gate === "OK" ? ANSI.green : ANSI.red;
|
|
315
|
-
|
|
344
|
+
const executor = formatExecutor(event.detail);
|
|
345
|
+
console.log(` \u251c\u2500 ${status} Quality gate: ${gateColor}${gate}${ANSI.reset}${executor} ${elapsed}`);
|
|
316
346
|
},
|
|
317
347
|
|
|
318
348
|
"reviewer:start": (event, icon) => {
|
|
@@ -321,11 +351,12 @@ const EVENT_HANDLERS = {
|
|
|
321
351
|
|
|
322
352
|
"reviewer:end": (event, _icon, elapsed) => {
|
|
323
353
|
const review = event.detail || {};
|
|
354
|
+
const executor = formatExecutor(event.detail);
|
|
324
355
|
if (review.approved) {
|
|
325
|
-
console.log(` \u251c\u2500 ${ANSI.green}\u2705 Review: APPROVED${ANSI.reset} ${elapsed}`);
|
|
356
|
+
console.log(` \u251c\u2500 ${ANSI.green}\u2705 Review: APPROVED${ANSI.reset}${executor} ${elapsed}`);
|
|
326
357
|
} else {
|
|
327
358
|
const count = review.blockingCount || 0;
|
|
328
|
-
console.log(` \u251c\u2500 ${ANSI.red}\u274c Review: REJECTED (${count} blocking)${ANSI.reset}`);
|
|
359
|
+
console.log(` \u251c\u2500 ${ANSI.red}\u274c Review: REJECTED (${count} blocking)${ANSI.reset}${executor}`);
|
|
329
360
|
if (review.issues) {
|
|
330
361
|
for (const issue of review.issues) {
|
|
331
362
|
console.log(` \u2502 ${ANSI.dim}${issue}${ANSI.reset}`);
|
|
@@ -334,16 +365,18 @@ const EVENT_HANDLERS = {
|
|
|
334
365
|
}
|
|
335
366
|
},
|
|
336
367
|
|
|
337
|
-
"tester:start": (
|
|
338
|
-
|
|
368
|
+
"tester:start": (event, icon) => {
|
|
369
|
+
const executor = formatExecutor(event.detail);
|
|
370
|
+
console.log(` \u251c\u2500 ${icon} Tester evaluating...${executor}`);
|
|
339
371
|
},
|
|
340
372
|
|
|
341
373
|
"tester:end": (event, _icon, elapsed) => {
|
|
342
374
|
passFailStage(event.detail, "Tester", "issues found", elapsed);
|
|
343
375
|
},
|
|
344
376
|
|
|
345
|
-
"security:start": (
|
|
346
|
-
|
|
377
|
+
"security:start": (event, icon) => {
|
|
378
|
+
const executor = formatExecutor(event.detail);
|
|
379
|
+
console.log(` \u251c\u2500 ${icon} Security auditing...${executor}`);
|
|
347
380
|
},
|
|
348
381
|
|
|
349
382
|
"security:end": (event, _icon, elapsed) => {
|
|
@@ -351,7 +384,8 @@ const EVENT_HANDLERS = {
|
|
|
351
384
|
},
|
|
352
385
|
|
|
353
386
|
"solomon:start": (event, icon) => {
|
|
354
|
-
|
|
387
|
+
const executor = formatExecutor(event.detail);
|
|
388
|
+
console.log(` \u251c\u2500 ${icon} Solomon arbitrating ${event.detail?.conflictStage || "?"} conflict...${executor}`);
|
|
355
389
|
},
|
|
356
390
|
|
|
357
391
|
"solomon:end": (event, _icon, elapsed) => {
|
|
@@ -422,6 +456,82 @@ const EVENT_HANDLERS = {
|
|
|
422
456
|
console.log(`${ANSI.dim}Resume with: kj resume ${event.sessionId} --answer "<response>"${ANSI.reset}`);
|
|
423
457
|
},
|
|
424
458
|
|
|
459
|
+
"preflight:start": (event) => {
|
|
460
|
+
const executor = formatExecutor(event.detail);
|
|
461
|
+
console.log(` \u251c\u2500 \ud83d\udee1\ufe0f Preflight checks running...${executor}`);
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
"preflight:end": (event, _icon, elapsed) => {
|
|
465
|
+
const executor = formatExecutor(event.detail);
|
|
466
|
+
const ok = event.status === "ok";
|
|
467
|
+
const statusIcon = ok ? `${ANSI.green}\u2705` : `${ANSI.red}\u274c`;
|
|
468
|
+
console.log(` \u251c\u2500 ${statusIcon} Preflight: ${event.message}${ANSI.reset}${executor} ${elapsed}`);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
"discover:start": (event, icon) => {
|
|
472
|
+
const provider = event.detail?.provider || event.detail?.discover || "?";
|
|
473
|
+
console.log(` \u251c\u2500 ${icon || "\ud83d\udd0e"} Discover (${provider}) analyzing...`);
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
"discover:end": (event, _icon, elapsed, status) => {
|
|
477
|
+
roleEnd(status, "Discover", elapsed, formatExecutor(event.detail));
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
"architect:start": (event) => {
|
|
481
|
+
const provider = event.detail?.provider || event.detail?.architect || "?";
|
|
482
|
+
console.log(` \u251c\u2500 \ud83c\udfdb\ufe0f Architect (${provider}) designing...`);
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
"architect:end": (event, _icon, elapsed, status) => {
|
|
486
|
+
roleEnd(status, "Architect", elapsed, formatExecutor(event.detail));
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
"hu-reviewer:start": (event) => {
|
|
490
|
+
const executor = formatExecutor(event.detail);
|
|
491
|
+
console.log(` \u251c\u2500 \ud83d\udcdd HU Reviewer certifying...${executor}`);
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
"hu-reviewer:end": (event, _icon, elapsed, status) => {
|
|
495
|
+
roleEnd(status, "HU Reviewer", elapsed, formatExecutor(event.detail));
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
"impeccable:start": (event) => {
|
|
499
|
+
const executor = formatExecutor(event.detail);
|
|
500
|
+
console.log(` \u251c\u2500 \ud83c\udfa8 Impeccable auditing design...${executor}`);
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
"impeccable:end": (event, _icon, elapsed, status) => {
|
|
504
|
+
roleEnd(status, "Impeccable", elapsed, formatExecutor(event.detail));
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
"audit:start": (event) => {
|
|
508
|
+
const executor = formatExecutor(event.detail);
|
|
509
|
+
console.log(` \u251c\u2500 \ud83d\udccb Final audit running...${executor}`);
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
"audit:end": (event, _icon, elapsed, status) => {
|
|
513
|
+
roleEnd(status, "Audit", elapsed, formatExecutor(event.detail));
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
"sonarcloud:start": (event) => {
|
|
517
|
+
const executor = formatExecutor(event.detail);
|
|
518
|
+
console.log(` \u251c\u2500 \u2601\ufe0f SonarCloud scanning...${executor}`);
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
"sonarcloud:end": (event, _icon, elapsed, status) => {
|
|
522
|
+
roleEnd(status, "SonarCloud", elapsed, formatExecutor(event.detail));
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
"guard:output": (event) => {
|
|
526
|
+
const executor = formatExecutor(event.detail);
|
|
527
|
+
console.log(` \u251c\u2500 \ud83d\udee1\ufe0f ${event.message}${executor}`);
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
"guard:perf": (event) => {
|
|
531
|
+
const executor = formatExecutor(event.detail);
|
|
532
|
+
console.log(` \u251c\u2500 \u26a1 ${event.message}${executor}`);
|
|
533
|
+
},
|
|
534
|
+
|
|
425
535
|
"pipeline:tracker": (event) => {
|
|
426
536
|
const trackerStages = event.detail?.stages || [];
|
|
427
537
|
console.log(` ${ANSI.dim}\u250c Pipeline${ANSI.reset}`);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-injection guard for AI-reviewed diffs and PR content.
|
|
3
|
+
*
|
|
4
|
+
* Scans text for patterns commonly used to hijack LLM instructions:
|
|
5
|
+
* directive overrides, role reassignment, invisible Unicode, and
|
|
6
|
+
* suspiciously large comment blocks that could hide payloads.
|
|
7
|
+
*
|
|
8
|
+
* @module utils/injection-guard
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Phrases that attempt to override system/reviewer instructions. */
|
|
12
|
+
const DIRECTIVE_PATTERNS = [
|
|
13
|
+
/ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?|guidelines?)/i,
|
|
14
|
+
/disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?)/i,
|
|
15
|
+
/forget\s+(all\s+)?(previous|prior|your)\s+(instructions?|rules?|context)/i,
|
|
16
|
+
/override\s+(the\s+)?(system|reviewer|review)\s+(prompt|instructions?|rules?)/i,
|
|
17
|
+
/do\s+not\s+follow\s+(the\s+)?(above|previous|system)\s+(instructions?|rules?)/i,
|
|
18
|
+
/new\s+instructions?\s*:/i,
|
|
19
|
+
/system\s*:\s*you\s+are/i,
|
|
20
|
+
/\bact\s+as\s+(a\s+)?(different|new|my|an?\s+)/i,
|
|
21
|
+
/you\s+are\s+now\s+(a\s+)?/i,
|
|
22
|
+
/from\s+now\s+on\s*(,|\s)?\s*you\s+(will|should|must|are)/i,
|
|
23
|
+
/pretend\s+(you\s+are|to\s+be)/i,
|
|
24
|
+
/respond\s+(only\s+)?with\s+["']?approved["']?/i,
|
|
25
|
+
/always\s+(return|respond|output)\s+.*approved/i,
|
|
26
|
+
/set\s+approved\s*[=:]\s*true/i,
|
|
27
|
+
/\bapproved["']?\s*:\s*true\b.*ignore/i,
|
|
28
|
+
/output\s+the\s+following\s+json/i,
|
|
29
|
+
/return\s+this\s+exact\s+(json|response|output)/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** Unicode categories that can hide or disguise content. */
|
|
33
|
+
const UNICODE_PATTERNS = [
|
|
34
|
+
/[\u200B-\u200F]/, // zero-width spaces, LTR/RTL marks
|
|
35
|
+
/[\u202A-\u202E]/, // bidi embedding/override
|
|
36
|
+
/[\u2066-\u2069]/, // bidi isolate
|
|
37
|
+
/[\uFEFF]/, // BOM mid-text
|
|
38
|
+
/[\u00AD]{3,}/, // repeated soft hyphens
|
|
39
|
+
/[\u2060-\u2064]/, // invisible operators
|
|
40
|
+
/[\u{E0000}-\u{E007F}]/u, // tag characters
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Maximum comment block size (chars) before flagging. */
|
|
44
|
+
const MAX_COMMENT_BLOCK = 2000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Comment block patterns for common languages.
|
|
48
|
+
* Each regex captures the full block content.
|
|
49
|
+
*/
|
|
50
|
+
const COMMENT_BLOCK_RE = [
|
|
51
|
+
/\/\*[\s\S]{0,20000}?\*\//g, // C-style /* ... */
|
|
52
|
+
/<!--[\s\S]{0,20000}?-->/g, // HTML <!-- ... -->
|
|
53
|
+
/"""\s*[\s\S]{0,20000}?\s*"""/g, // Python docstrings
|
|
54
|
+
/'''\s*[\s\S]{0,20000}?\s*'''/g, // Python docstrings (single)
|
|
55
|
+
/=begin[\s\S]{0,20000}?=end/g, // Ruby block comments
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} InjectionFinding
|
|
60
|
+
* @property {"directive"|"unicode"|"comment_block"} type
|
|
61
|
+
* @property {string} pattern - short label of what matched
|
|
62
|
+
* @property {string} snippet - excerpt of the offending text (max 120 chars)
|
|
63
|
+
* @property {number} [line] - approximate line number (1-based)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} GuardResult
|
|
68
|
+
* @property {boolean} clean - true if no threats detected
|
|
69
|
+
* @property {InjectionFinding[]} findings
|
|
70
|
+
* @property {string} summary - human-readable one-liner
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan a text (diff, PR description, comment) for prompt-injection signals.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text - content to scan
|
|
77
|
+
* @param {Object} [opts]
|
|
78
|
+
* @param {number} [opts.maxCommentBlock=2000] - flag comment blocks larger than this
|
|
79
|
+
* @returns {GuardResult}
|
|
80
|
+
*/
|
|
81
|
+
export function scanForInjection(text, opts = {}) {
|
|
82
|
+
if (!text || typeof text !== "string") {
|
|
83
|
+
return { clean: true, findings: [], summary: "Nothing to scan" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const maxBlock = opts.maxCommentBlock ?? MAX_COMMENT_BLOCK;
|
|
87
|
+
/** @type {InjectionFinding[]} */
|
|
88
|
+
const findings = [];
|
|
89
|
+
|
|
90
|
+
// 1. Directive patterns
|
|
91
|
+
for (const re of DIRECTIVE_PATTERNS) {
|
|
92
|
+
const match = re.exec(text);
|
|
93
|
+
if (match) {
|
|
94
|
+
findings.push({
|
|
95
|
+
type: "directive",
|
|
96
|
+
pattern: re.source.slice(0, 60),
|
|
97
|
+
snippet: excerpt(text, match.index),
|
|
98
|
+
line: lineAt(text, match.index),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Invisible Unicode
|
|
104
|
+
for (const re of UNICODE_PATTERNS) {
|
|
105
|
+
const match = re.exec(text);
|
|
106
|
+
if (match) {
|
|
107
|
+
const charCode = match[0].codePointAt(0).toString(16).toUpperCase();
|
|
108
|
+
findings.push({
|
|
109
|
+
type: "unicode",
|
|
110
|
+
pattern: `U+${charCode}`,
|
|
111
|
+
snippet: excerpt(text, match.index),
|
|
112
|
+
line: lineAt(text, match.index),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Oversized comment blocks
|
|
118
|
+
for (const re of COMMENT_BLOCK_RE) {
|
|
119
|
+
let m;
|
|
120
|
+
while ((m = re.exec(text)) !== null) {
|
|
121
|
+
if (m[0].length > maxBlock) {
|
|
122
|
+
findings.push({
|
|
123
|
+
type: "comment_block",
|
|
124
|
+
pattern: `block ${m[0].length} chars (max ${maxBlock})`,
|
|
125
|
+
snippet: excerpt(text, m.index),
|
|
126
|
+
line: lineAt(text, m.index),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const clean = findings.length === 0;
|
|
133
|
+
const summary = clean
|
|
134
|
+
? "No injection patterns detected"
|
|
135
|
+
: `${findings.length} potential injection(s): ${[...new Set(findings.map((f) => f.type))].join(", ")}`;
|
|
136
|
+
|
|
137
|
+
return { clean, findings, summary };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Scan a git diff, focusing only on added lines (lines starting with +).
|
|
142
|
+
* Removed lines cannot inject into the AI prompt.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} diff - unified diff content
|
|
145
|
+
* @param {Object} [opts]
|
|
146
|
+
* @returns {GuardResult}
|
|
147
|
+
*/
|
|
148
|
+
export function scanDiff(diff, opts = {}) {
|
|
149
|
+
if (!diff) return { clean: true, findings: [], summary: "Empty diff" };
|
|
150
|
+
|
|
151
|
+
// Extract only added lines (skip diff headers like +++ b/file)
|
|
152
|
+
const addedLines = diff
|
|
153
|
+
.split("\n")
|
|
154
|
+
.filter((l) => l.startsWith("+") && !l.startsWith("+++"))
|
|
155
|
+
.map((l) => l.slice(1))
|
|
156
|
+
.join("\n");
|
|
157
|
+
|
|
158
|
+
return scanForInjection(addedLines, opts);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Helpers ---
|
|
162
|
+
|
|
163
|
+
function excerpt(text, index) {
|
|
164
|
+
const start = Math.max(0, index - 20);
|
|
165
|
+
const end = Math.min(text.length, index + 100);
|
|
166
|
+
return text.slice(start, end).replace(/\n/g, "\\n").slice(0, 120);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function lineAt(text, index) {
|
|
170
|
+
return text.slice(0, index).split("\n").length;
|
|
171
|
+
}
|
package/src/utils/os-detect.js
CHANGED
|
@@ -5,37 +5,46 @@ import os from "node:os";
|
|
|
5
5
|
*/
|
|
6
6
|
export function getPlatform() {
|
|
7
7
|
const platform = os.platform();
|
|
8
|
-
|
|
8
|
+
if (platform === "win32") return "windows";
|
|
9
|
+
if (platform === "darwin") return "macos";
|
|
10
|
+
return "linux";
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const INSTALL_COMMANDS = {
|
|
12
14
|
rtk: {
|
|
13
15
|
macos: "brew install rtk && rtk init --global",
|
|
14
|
-
linux: "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh && rtk init --global"
|
|
16
|
+
linux: "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh && rtk init --global",
|
|
17
|
+
windows: "Download from https://github.com/rtk-ai/rtk/releases and add to PATH"
|
|
15
18
|
},
|
|
16
19
|
claude: {
|
|
17
20
|
macos: "npm install -g @anthropic-ai/claude-code",
|
|
18
|
-
linux: "npm install -g @anthropic-ai/claude-code"
|
|
21
|
+
linux: "npm install -g @anthropic-ai/claude-code",
|
|
22
|
+
windows: "npm install -g @anthropic-ai/claude-code"
|
|
19
23
|
},
|
|
20
24
|
codex: {
|
|
21
25
|
macos: "npm install -g @openai/codex",
|
|
22
|
-
linux: "npm install -g @openai/codex"
|
|
26
|
+
linux: "npm install -g @openai/codex",
|
|
27
|
+
windows: "npm install -g @openai/codex"
|
|
23
28
|
},
|
|
24
29
|
gemini: {
|
|
25
30
|
macos: "npm install -g @google/gemini-cli",
|
|
26
|
-
linux: "npm install -g @google/gemini-cli"
|
|
31
|
+
linux: "npm install -g @google/gemini-cli",
|
|
32
|
+
windows: "npm install -g @google/gemini-cli"
|
|
27
33
|
},
|
|
28
34
|
aider: {
|
|
29
35
|
macos: "pipx install aider-chat",
|
|
30
|
-
linux: "pipx install aider-chat || pip3 install aider-chat"
|
|
36
|
+
linux: "pipx install aider-chat || pip3 install aider-chat",
|
|
37
|
+
windows: "pip install aider-chat"
|
|
31
38
|
},
|
|
32
39
|
opencode: {
|
|
33
40
|
macos: "curl -fsSL https://opencode.ai/install | bash",
|
|
34
|
-
linux: "curl -fsSL https://opencode.ai/install | bash"
|
|
41
|
+
linux: "curl -fsSL https://opencode.ai/install | bash",
|
|
42
|
+
windows: "See https://github.com/nicepkg/opencode for Windows install"
|
|
35
43
|
},
|
|
36
44
|
docker: {
|
|
37
45
|
macos: "brew install --cask docker",
|
|
38
|
-
linux: "sudo apt install docker.io docker-compose-v2 (or see https://docs.docker.com/engine/install/)"
|
|
46
|
+
linux: "sudo apt install docker.io docker-compose-v2 (or see https://docs.docker.com/engine/install/)",
|
|
47
|
+
windows: "Download Docker Desktop from https://www.docker.com/products/docker-desktop/"
|
|
39
48
|
}
|
|
40
49
|
};
|
|
41
50
|
|
package/src/utils/process.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
2
|
|
|
3
|
+
const isWin = process.platform === "win32";
|
|
4
|
+
const KILL_SIGNAL = isWin ? "SIGTERM" : "SIGKILL";
|
|
5
|
+
|
|
3
6
|
function buildSilenceKilledResult(silenceTimeoutMs, stdout, signal) {
|
|
4
7
|
return {
|
|
5
8
|
exitCode: 143,
|
|
6
9
|
stdout,
|
|
7
10
|
stderr: `Command killed after ${Number(silenceTimeoutMs)}ms without output`,
|
|
8
11
|
timedOut: true,
|
|
9
|
-
signal: signal ||
|
|
12
|
+
signal: signal || KILL_SIGNAL
|
|
10
13
|
};
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -52,7 +55,7 @@ async function awaitWithTimeout(subprocess, timeout, getStdout) {
|
|
|
52
55
|
const timeoutResult = new Promise((resolve) => {
|
|
53
56
|
timer = setTimeout(() => {
|
|
54
57
|
try {
|
|
55
|
-
subprocess.kill(
|
|
58
|
+
subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
|
|
56
59
|
} catch {
|
|
57
60
|
// no-op
|
|
58
61
|
}
|
|
@@ -61,7 +64,7 @@ async function awaitWithTimeout(subprocess, timeout, getStdout) {
|
|
|
61
64
|
stdout: getStdout(),
|
|
62
65
|
stderr: `Command timed out after ${timeout}ms`,
|
|
63
66
|
timedOut: true,
|
|
64
|
-
signal:
|
|
67
|
+
signal: KILL_SIGNAL
|
|
65
68
|
});
|
|
66
69
|
}, timeout);
|
|
67
70
|
});
|
|
@@ -94,7 +97,7 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
94
97
|
outputSilenceTimer = setTimeout(() => {
|
|
95
98
|
silenceTimedOut = true;
|
|
96
99
|
try {
|
|
97
|
-
subprocess.kill(
|
|
100
|
+
subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
|
|
98
101
|
} catch {
|
|
99
102
|
// no-op
|
|
100
103
|
}
|