@njeybe/nativ 2.4.1 → 2.5.1
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 +26 -0
- package/README.md +26 -0
- package/dist/commands/db/db-formatter.d.ts +5 -0
- package/dist/commands/db/db-formatter.js +116 -0
- package/dist/commands/db/db-introspect.d.ts +11 -0
- package/dist/commands/db/db-introspect.js +84 -0
- package/dist/commands/db.js +4 -206
- package/dist/commands/profile.d.ts +3 -0
- package/dist/commands/profile.js +39 -0
- package/dist/commands/task/task-add.d.ts +11 -0
- package/dist/commands/task/task-add.js +169 -0
- package/dist/commands/task/task-common.d.ts +57 -0
- package/dist/commands/task/task-common.js +45 -0
- package/dist/commands/task/task-escalate.d.ts +7 -0
- package/dist/commands/task/task-escalate.js +119 -0
- package/dist/commands/task/task-lifecycle.d.ts +5 -0
- package/dist/commands/task/task-lifecycle.js +252 -0
- package/dist/commands/task/task-query.d.ts +10 -0
- package/dist/commands/task/task-query.js +269 -0
- package/dist/commands/task.d.ts +9 -87
- package/dist/commands/task.js +8 -850
- package/dist/commands/triage/triage-store.d.ts +56 -0
- package/dist/commands/triage/triage-store.js +108 -0
- package/dist/commands/triage/triage-ui.d.ts +14 -0
- package/dist/commands/triage/triage-ui.js +104 -0
- package/dist/commands/triage.d.ts +3 -52
- package/dist/commands/triage.js +8 -216
- package/dist/core/enforcement.d.ts +1 -0
- package/dist/core/enforcement.js +19 -0
- package/dist/core/human-gate.d.ts +2 -1
- package/dist/core/human-gate.js +9 -2
- package/dist/core/profiles.d.ts +14 -0
- package/dist/core/profiles.js +82 -0
- package/dist/core/setup/config-merger.d.ts +59 -0
- package/dist/core/setup/config-merger.js +107 -0
- package/dist/core/setup/session-context.d.ts +4 -0
- package/dist/core/setup/session-context.js +65 -0
- package/dist/core/setup/setup-planner.d.ts +14 -0
- package/dist/core/setup/setup-planner.js +180 -0
- package/dist/core/setup/template-planner.d.ts +8 -0
- package/dist/core/setup/template-planner.js +32 -0
- package/dist/core/setup/types.d.ts +65 -0
- package/dist/core/setup/types.js +55 -0
- package/dist/core/setup-assets.d.ts +8 -119
- package/dist/core/setup-assets.js +7 -437
- package/dist/core/tier1-liaison.d.ts +2 -0
- package/dist/core/tier1-liaison.js +64 -3
- package/dist/core/verifier/executor.d.ts +5 -0
- package/dist/core/verifier/executor.js +62 -0
- package/dist/core/verifier/phases.d.ts +5 -0
- package/dist/core/verifier/phases.js +71 -0
- package/dist/core/verifier/task-verifier.d.ts +37 -0
- package/dist/core/verifier/task-verifier.js +233 -0
- package/dist/core/verifier/types.d.ts +51 -0
- package/dist/core/verifier/types.js +1 -0
- package/dist/core/verifier.d.ts +5 -93
- package/dist/core/verifier.js +5 -359
- package/dist/db/introspector.js +59 -59
- package/dist/governor/evaluator.d.ts +4 -64
- package/dist/governor/evaluator.js +21 -686
- package/dist/governor/patch-applier.d.ts +8 -0
- package/dist/governor/patch-applier.js +121 -0
- package/dist/governor/self-healing.d.ts +24 -0
- package/dist/governor/self-healing.js +351 -0
- package/dist/governor/test-integrity.d.ts +41 -0
- package/dist/governor/test-integrity.js +237 -0
- package/dist/index.js +8 -0
- package/dist/mcp/capture.d.ts +4 -0
- package/dist/mcp/capture.js +33 -0
- package/dist/mcp/resources.d.ts +39 -0
- package/dist/mcp/resources.js +44 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +14 -89
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/runner/agent-supervisor.d.ts +8 -302
- package/dist/runner/agent-supervisor.js +41 -1030
- package/dist/runner/cli-config.d.ts +84 -0
- package/dist/runner/cli-config.js +215 -0
- package/dist/runner/native-driver.d.ts +18 -0
- package/dist/runner/native-driver.js +208 -0
- package/dist/runner/native-engine.d.ts +27 -0
- package/dist/runner/native-engine.js +184 -0
- package/dist/runner/native-tools.d.ts +31 -0
- package/dist/runner/native-tools.js +241 -0
- package/dist/runner/process-utils.d.ts +12 -0
- package/dist/runner/process-utils.js +96 -0
- package/dist/runner/run-lifecycle.d.ts +6 -0
- package/dist/runner/run-lifecycle.js +42 -0
- package/dist/runner/types.d.ts +215 -0
- package/dist/runner/types.js +41 -0
- package/dist/scanner/detector.js +42 -663
- package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
- package/dist/scanner/detectors/ecosystem-detector.js +354 -0
- package/dist/scanner/detectors/orm-detector.d.ts +5 -0
- package/dist/scanner/detectors/orm-detector.js +59 -0
- package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
- package/dist/scanner/detectors/workspace-detector.js +266 -0
- package/dist/server/cli-runner.d.ts +15 -0
- package/dist/server/cli-runner.js +43 -0
- package/dist/server/event-hub.d.ts +42 -0
- package/dist/server/event-hub.js +169 -0
- package/dist/server/http-utils.d.ts +15 -0
- package/dist/server/http-utils.js +56 -0
- package/dist/server/plan-utils.d.ts +9 -0
- package/dist/server/plan-utils.js +31 -0
- package/dist/server/routes/db-routes.d.ts +57 -0
- package/dist/server/routes/db-routes.js +484 -0
- package/dist/server/routes/task-routes.d.ts +266 -0
- package/dist/server/routes/task-routes.js +285 -0
- package/dist/server/routes/telemetry-routes.d.ts +128 -0
- package/dist/server/routes/telemetry-routes.js +179 -0
- package/dist/server/routes/triage-routes.d.ts +81 -0
- package/dist/server/routes/triage-routes.js +244 -0
- package/dist/server/routes/worktree-routes.d.ts +31 -0
- package/dist/server/routes/worktree-routes.js +144 -0
- package/dist/server/studio-server.d.ts +4 -11
- package/dist/server/studio-server.js +34 -1578
- package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
- package/dist/server/studio-ui/shell/triage.js +10 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +4 -2
- package/plugin/agents/verifier.md +1 -1
- package/plugin/agents/worker.md +7 -4
- package/plugin/skills/nativ/SKILL.md +1 -0
- package/templates/.claudeignore +62 -45
- package/templates/AGENTS.md +1 -0
- package/templates/CLAUDE.md +13 -0
- package/templates/claude-agents/architect.md +4 -2
- package/templates/claude-agents/verifier.md +1 -1
- package/templates/claude-agents/worker.md +7 -4
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/templates/dot-ai/subagents/architect.md +54 -0
- package/dist/commands/session.d.ts +0 -22
- package/dist/commands/session.js +0 -106
- package/dist/core/session-memory.d.ts +0 -42
- package/dist/core/session-memory.js +0 -121
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CandidatePatch, SelfHealingEscalationRecord } from '../../governor/circuit-breaker.js';
|
|
2
|
+
import type { HumanDecisionCard, TriageClassification, TriageEvaluation, TriageSource } from '../../core/tier1-liaison.js';
|
|
3
|
+
export declare const ESCALATION_ID: RegExp;
|
|
4
|
+
/** Latest Tier 1 verdict, stored on the escalation so Studio and the architect can show it after a reload. */
|
|
5
|
+
export interface TriageSnapshot {
|
|
6
|
+
evaluatedAt: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
model: string;
|
|
9
|
+
source: TriageSource;
|
|
10
|
+
latencyMs: number;
|
|
11
|
+
classification: TriageClassification;
|
|
12
|
+
reasoning: string;
|
|
13
|
+
autoPatchApplied: boolean;
|
|
14
|
+
unblockedTaskId: string | null;
|
|
15
|
+
guardrails: string[];
|
|
16
|
+
resolution?: {
|
|
17
|
+
summary: string;
|
|
18
|
+
patch: CandidatePatch;
|
|
19
|
+
proofPassed: boolean;
|
|
20
|
+
proposalId?: string;
|
|
21
|
+
};
|
|
22
|
+
humanCard?: HumanDecisionCard;
|
|
23
|
+
}
|
|
24
|
+
/** The product owner's pick from a decision card. Acting on it stays with the approve/reject gate. */
|
|
25
|
+
export interface HumanDecision {
|
|
26
|
+
optionId: string;
|
|
27
|
+
label: string;
|
|
28
|
+
outcome?: string;
|
|
29
|
+
instructions?: string;
|
|
30
|
+
decidedAt: string;
|
|
31
|
+
decidedVia: 'cli';
|
|
32
|
+
}
|
|
33
|
+
export type TriagedEscalationRecord = SelfHealingEscalationRecord & {
|
|
34
|
+
triage?: TriageSnapshot;
|
|
35
|
+
humanDecision?: HumanDecision;
|
|
36
|
+
};
|
|
37
|
+
export interface TriageFinalization {
|
|
38
|
+
taskId: string | null;
|
|
39
|
+
unblockedTaskId: string | null;
|
|
40
|
+
persisted: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function mutateEscalation(root: string, escalationId: string, mutate: (record: TriagedEscalationRecord) => void): boolean;
|
|
43
|
+
/** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
|
|
44
|
+
export declare function unblockTask(root: string, taskId: string, note: string): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Only pending escalations may be triaged: re-running a resolved one could re-apply its proposal.
|
|
47
|
+
* Returns an error message, or null when the escalation can be evaluated.
|
|
48
|
+
*/
|
|
49
|
+
export declare function triageBlocker(root: string, escalationId: string): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* After an evaluation: unblocks the task and closes the escalation when a patch was auto-applied,
|
|
52
|
+
* then stores the verdict on the escalation. Never throws; a bookkeeping failure must not hide an
|
|
53
|
+
* applied patch.
|
|
54
|
+
*/
|
|
55
|
+
export declare function finalizeTriage(root: string, evaluation: TriageEvaluation): Promise<TriageFinalization>;
|
|
56
|
+
export declare function recordHumanDecision(root: string, escalationId: string, decision: HumanDecision): boolean;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { withFileLockSync, withPlanLock, writeJsonAtomicSync } from '../../core/lock-manager.js';
|
|
4
|
+
import { escalationPath, loadEscalationFile } from '../../governor/store.js';
|
|
5
|
+
export const ESCALATION_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
6
|
+
export function mutateEscalation(root, escalationId, mutate) {
|
|
7
|
+
const file = escalationPath(root);
|
|
8
|
+
return withFileLockSync(file, () => {
|
|
9
|
+
const data = loadEscalationFile(root, path.basename(root));
|
|
10
|
+
const record = data.escalations.find((e) => e?.id === escalationId);
|
|
11
|
+
if (!record)
|
|
12
|
+
return false;
|
|
13
|
+
mutate(record);
|
|
14
|
+
data.lastUpdated = new Date().toISOString();
|
|
15
|
+
writeJsonAtomicSync(file, data);
|
|
16
|
+
return true;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
|
|
20
|
+
export async function unblockTask(root, taskId, note) {
|
|
21
|
+
const planPath = path.join(root, '.ai', 'master_plan.json');
|
|
22
|
+
if (!fs.existsSync(planPath))
|
|
23
|
+
return false;
|
|
24
|
+
const result = await withPlanLock(planPath, (plan, ctx) => {
|
|
25
|
+
const task = plan.milestones.flatMap((m) => m.tasks ?? []).find((t) => t.id === taskId);
|
|
26
|
+
if (!task || task.status !== 'blocked') {
|
|
27
|
+
ctx.abort();
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
task.status = 'pending';
|
|
31
|
+
task.notes = note;
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
return result === true;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Only pending escalations may be triaged: re-running a resolved one could re-apply its proposal.
|
|
38
|
+
* Returns an error message, or null when the escalation can be evaluated.
|
|
39
|
+
*/
|
|
40
|
+
export function triageBlocker(root, escalationId) {
|
|
41
|
+
const record = loadEscalationFile(root, path.basename(root)).escalations.find((e) => e?.id === escalationId);
|
|
42
|
+
if (!record)
|
|
43
|
+
return `Escalation "${escalationId}" not found in .ai/escalation.json`;
|
|
44
|
+
if (record.status !== 'pending_review')
|
|
45
|
+
return `Escalation "${escalationId}" is already ${record.status}`;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* After an evaluation: unblocks the task and closes the escalation when a patch was auto-applied,
|
|
50
|
+
* then stores the verdict on the escalation. Never throws; a bookkeeping failure must not hide an
|
|
51
|
+
* applied patch.
|
|
52
|
+
*/
|
|
53
|
+
export async function finalizeTriage(root, evaluation) {
|
|
54
|
+
let taskId = null;
|
|
55
|
+
let unblockedTaskId = null;
|
|
56
|
+
let persisted = false;
|
|
57
|
+
try {
|
|
58
|
+
taskId = loadEscalationFile(root, path.basename(root)).escalations.find((e) => e?.id === evaluation.escalationId)?.taskId ?? null;
|
|
59
|
+
if (evaluation.autoPatchApplied && taskId) {
|
|
60
|
+
const reason = evaluation.resolution?.summary ?? 'contract patch applied';
|
|
61
|
+
if (await unblockTask(root, taskId, `Unblocked by Tier 1 auto-resolution of ${evaluation.escalationId}: ${reason}`)) {
|
|
62
|
+
unblockedTaskId = taskId;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const snapshot = {
|
|
66
|
+
evaluatedAt: new Date().toISOString(),
|
|
67
|
+
provider: evaluation.provider,
|
|
68
|
+
model: evaluation.model,
|
|
69
|
+
source: evaluation.source,
|
|
70
|
+
latencyMs: evaluation.latencyMs,
|
|
71
|
+
classification: evaluation.classification,
|
|
72
|
+
reasoning: evaluation.reasoning,
|
|
73
|
+
autoPatchApplied: evaluation.autoPatchApplied,
|
|
74
|
+
unblockedTaskId,
|
|
75
|
+
guardrails: evaluation.guardrails,
|
|
76
|
+
...(evaluation.resolution
|
|
77
|
+
? {
|
|
78
|
+
resolution: {
|
|
79
|
+
summary: evaluation.resolution.summary,
|
|
80
|
+
patch: evaluation.resolution.patch,
|
|
81
|
+
proofPassed: evaluation.resolution.proof.passed,
|
|
82
|
+
...(evaluation.resolution.proposalId ? { proposalId: evaluation.resolution.proposalId } : {}),
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
86
|
+
...(evaluation.humanCard ? { humanCard: evaluation.humanCard } : {}),
|
|
87
|
+
};
|
|
88
|
+
persisted = mutateEscalation(root, evaluation.escalationId, (record) => {
|
|
89
|
+
record.triage = snapshot;
|
|
90
|
+
if (evaluation.autoPatchApplied && record.status === 'pending_review') {
|
|
91
|
+
const closed = record;
|
|
92
|
+
closed.status = 'resolved';
|
|
93
|
+
closed.resolutionNotes = `Auto-resolved by Tier 1 (${evaluation.model}): ${evaluation.resolution?.summary ?? 'contract patch applied'}`;
|
|
94
|
+
closed.resolvedAt = snapshot.evaluatedAt;
|
|
95
|
+
closed.resolution = { decision: 'approve', proposalApplied: true, ruleId: 'TIER1_AUTO_RESOLVED', unblockedTaskId };
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Reported through `persisted: false`.
|
|
101
|
+
}
|
|
102
|
+
return { taskId, unblockedTaskId, persisted };
|
|
103
|
+
}
|
|
104
|
+
export function recordHumanDecision(root, escalationId, decision) {
|
|
105
|
+
return mutateEscalation(root, escalationId, (record) => {
|
|
106
|
+
record.humanDecision = decision;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HumanDecisionCard, TriageEvaluation } from '../../core/tier1-liaison.js';
|
|
2
|
+
import type { HumanDecision } from './triage-store.js';
|
|
3
|
+
export declare const MAX_INSTRUCTIONS = 1000;
|
|
4
|
+
export declare const MAX_PROMPT_ATTEMPTS = 3;
|
|
5
|
+
export interface LineReader {
|
|
6
|
+
ask(prompt: string): Promise<string | null>;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
/** Queues lines so answers typed (or piped) before the prompt appears are not lost. */
|
|
10
|
+
export declare function createLineReader(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): LineReader;
|
|
11
|
+
/** Asks for a number in 1..max; null on Enter, end of input, or repeated invalid answers. */
|
|
12
|
+
export declare function askChoice(reader: LineReader, prompt: string, max: number, print: (line?: string) => void): Promise<number | null>;
|
|
13
|
+
export declare function printEvaluation(print: (line?: string) => void, result: TriageEvaluation, taskId: string | null, unblockedTaskId: string | null): void;
|
|
14
|
+
export declare function askDecision(reader: LineReader, card: HumanDecisionCard, print: (line?: string) => void): Promise<Omit<HumanDecision, 'decidedAt' | 'decidedVia'> | null>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
export const MAX_INSTRUCTIONS = 1000;
|
|
4
|
+
export const MAX_PROMPT_ATTEMPTS = 3;
|
|
5
|
+
/** Queues lines so answers typed (or piped) before the prompt appears are not lost. */
|
|
6
|
+
export function createLineReader(input, output) {
|
|
7
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
8
|
+
const queue = [];
|
|
9
|
+
const waiters = [];
|
|
10
|
+
let closed = false;
|
|
11
|
+
rl.on('line', (line) => {
|
|
12
|
+
const waiter = waiters.shift();
|
|
13
|
+
if (waiter)
|
|
14
|
+
waiter(line);
|
|
15
|
+
else
|
|
16
|
+
queue.push(line);
|
|
17
|
+
});
|
|
18
|
+
rl.on('close', () => {
|
|
19
|
+
closed = true;
|
|
20
|
+
while (waiters.length)
|
|
21
|
+
waiters.shift()(null);
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
ask(prompt) {
|
|
25
|
+
output.write(prompt);
|
|
26
|
+
if (queue.length)
|
|
27
|
+
return Promise.resolve(queue.shift());
|
|
28
|
+
if (closed)
|
|
29
|
+
return Promise.resolve(null);
|
|
30
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
31
|
+
},
|
|
32
|
+
close: () => rl.close(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Asks for a number in 1..max; null on Enter, end of input, or repeated invalid answers. */
|
|
36
|
+
export async function askChoice(reader, prompt, max, print) {
|
|
37
|
+
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
|
|
38
|
+
const answer = await reader.ask(prompt);
|
|
39
|
+
if (answer === null || !answer.trim())
|
|
40
|
+
return null;
|
|
41
|
+
const n = Number(answer.trim());
|
|
42
|
+
if (Number.isInteger(n) && n >= 1 && n <= max)
|
|
43
|
+
return n;
|
|
44
|
+
print(pc.yellow(` Enter a number from 1 to ${max}, or press Enter to skip.`));
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function printEvaluation(print, result, taskId, unblockedTaskId) {
|
|
49
|
+
print('');
|
|
50
|
+
print(pc.bold(`Tier 1 AI Strategist`) + pc.dim(` ${result.escalationId}${taskId ? ` / ${taskId}` : ''} (${result.provider === 'deterministic' ? 'offline rules' : `${result.provider}: ${result.model}`}, ${result.latencyMs}ms)`));
|
|
51
|
+
if (result.classification === 'AUTO_RESOLVE' && !result.resolution) {
|
|
52
|
+
print(pc.green(pc.bold('Safe to proceed')) + pc.green(' No contract change is needed and nothing was written.'));
|
|
53
|
+
}
|
|
54
|
+
else if (result.classification === 'AUTO_RESOLVE' && result.resolution) {
|
|
55
|
+
const { patch, summary } = result.resolution;
|
|
56
|
+
print(pc.green(pc.bold(result.autoPatchApplied ? 'Auto-resolved' : 'Safe fix found')) + pc.green(` ${summary}`));
|
|
57
|
+
print(pc.dim(` ${patch.operation} ${patch.target} ${patch.path}`));
|
|
58
|
+
if (result.autoPatchApplied) {
|
|
59
|
+
print(pc.green(` Patch written to .ai/${patch.target === 'db_schema' ? 'db_schema.json' : 'api_contracts.json'}.`));
|
|
60
|
+
if (unblockedTaskId)
|
|
61
|
+
print(pc.green(` Task ${unblockedTaskId} is unblocked and pending.`));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
print(pc.white(' Proven in a sandbox copy of the contract. Re-run with --apply to write it.'));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (result.humanCard) {
|
|
68
|
+
const card = result.humanCard;
|
|
69
|
+
print(pc.yellow(pc.bold('Human Decision Required')));
|
|
70
|
+
print('');
|
|
71
|
+
print(pc.bold('1. What is happening?'));
|
|
72
|
+
print(` ${card.symptom}`);
|
|
73
|
+
print(pc.bold('2. Why is this happening?'));
|
|
74
|
+
print(` ${card.rootCause}`);
|
|
75
|
+
print(pc.bold('3. Who and what is affected?'));
|
|
76
|
+
print(` ${card.blastRadius}`);
|
|
77
|
+
print(pc.bold('4. Your options'));
|
|
78
|
+
card.options.forEach((option, i) => {
|
|
79
|
+
const tag = option.recommended && !/recommended/i.test(option.label) ? pc.cyan(' (Recommended)') : '';
|
|
80
|
+
print(` [${i + 1}] ${option.label}${tag}`);
|
|
81
|
+
print(pc.dim(` ${option.outcome}`));
|
|
82
|
+
});
|
|
83
|
+
print(` [${card.options.length + 1}] Give custom instructions`);
|
|
84
|
+
}
|
|
85
|
+
if (result.reasoning)
|
|
86
|
+
print(pc.dim(`\n Reasoning: ${result.reasoning}`));
|
|
87
|
+
for (const note of result.guardrails)
|
|
88
|
+
print(pc.dim(` Guardrail: ${note}`));
|
|
89
|
+
}
|
|
90
|
+
export async function askDecision(reader, card, print) {
|
|
91
|
+
const count = card.options.length + 1;
|
|
92
|
+
const keys = Array.from({ length: count }, (_, i) => i + 1).join('/');
|
|
93
|
+
const choice = await askChoice(reader, `\nChoose [${keys}] (Enter to decide later): `, count, print);
|
|
94
|
+
if (choice === null)
|
|
95
|
+
return null;
|
|
96
|
+
if (choice <= card.options.length) {
|
|
97
|
+
const option = card.options[choice - 1];
|
|
98
|
+
return { optionId: option.id, label: option.label, outcome: option.outcome };
|
|
99
|
+
}
|
|
100
|
+
const text = (await reader.ask('Instructions for the team: '))?.trim() ?? '';
|
|
101
|
+
if (!text)
|
|
102
|
+
return null;
|
|
103
|
+
return { optionId: 'custom', label: 'Custom instructions', instructions: text.slice(0, MAX_INSTRUCTIONS) };
|
|
104
|
+
}
|
|
@@ -1,55 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
export interface TriageSnapshot {
|
|
5
|
-
evaluatedAt: string;
|
|
6
|
-
provider: string;
|
|
7
|
-
model: string;
|
|
8
|
-
source: TriageSource;
|
|
9
|
-
latencyMs: number;
|
|
10
|
-
classification: TriageClassification;
|
|
11
|
-
reasoning: string;
|
|
12
|
-
autoPatchApplied: boolean;
|
|
13
|
-
unblockedTaskId: string | null;
|
|
14
|
-
guardrails: string[];
|
|
15
|
-
resolution?: {
|
|
16
|
-
summary: string;
|
|
17
|
-
patch: CandidatePatch;
|
|
18
|
-
proofPassed: boolean;
|
|
19
|
-
proposalId?: string;
|
|
20
|
-
};
|
|
21
|
-
humanCard?: HumanDecisionCard;
|
|
22
|
-
}
|
|
23
|
-
/** The product owner's pick from a decision card. Acting on it stays with the approve/reject gate. */
|
|
24
|
-
export interface HumanDecision {
|
|
25
|
-
optionId: string;
|
|
26
|
-
label: string;
|
|
27
|
-
outcome?: string;
|
|
28
|
-
instructions?: string;
|
|
29
|
-
decidedAt: string;
|
|
30
|
-
decidedVia: 'cli';
|
|
31
|
-
}
|
|
32
|
-
export type TriagedEscalationRecord = SelfHealingEscalationRecord & {
|
|
33
|
-
triage?: TriageSnapshot;
|
|
34
|
-
humanDecision?: HumanDecision;
|
|
35
|
-
};
|
|
36
|
-
export interface TriageFinalization {
|
|
37
|
-
taskId: string | null;
|
|
38
|
-
unblockedTaskId: string | null;
|
|
39
|
-
persisted: boolean;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Only pending escalations may be triaged: re-running a resolved one could re-apply its proposal.
|
|
43
|
-
* Returns an error message, or null when the escalation can be evaluated.
|
|
44
|
-
*/
|
|
45
|
-
export declare function triageBlocker(root: string, escalationId: string): string | null;
|
|
46
|
-
/**
|
|
47
|
-
* After an evaluation: unblocks the task and closes the escalation when a patch was auto-applied,
|
|
48
|
-
* then stores the verdict on the escalation. Never throws; a bookkeeping failure must not hide an
|
|
49
|
-
* applied patch.
|
|
50
|
-
*/
|
|
51
|
-
export declare function finalizeTriage(root: string, evaluation: TriageEvaluation): Promise<TriageFinalization>;
|
|
52
|
-
export declare function recordHumanDecision(root: string, escalationId: string, decision: HumanDecision): boolean;
|
|
1
|
+
import { type Tier1LiaisonOptions, type TriageEvaluation, type TriageFailure } from '../core/tier1-liaison.js';
|
|
2
|
+
import { type HumanDecision } from './triage/triage-store.js';
|
|
3
|
+
export * from './triage/triage-store.js';
|
|
53
4
|
export interface RunTriageOptions {
|
|
54
5
|
all?: boolean;
|
|
55
6
|
apply?: boolean;
|
package/dist/commands/triage.js
CHANGED
|
@@ -1,227 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import readline from 'node:readline';
|
|
4
3
|
import pc from 'picocolors';
|
|
5
4
|
import { resolveProjectRoot } from '../core/root-resolver.js';
|
|
6
|
-
import {
|
|
7
|
-
import { escalationPath, loadEscalationFile } from '../governor/store.js';
|
|
5
|
+
import { loadEscalationFile } from '../governor/store.js';
|
|
8
6
|
import { Tier1Liaison, } from '../core/tier1-liaison.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
* verdict onto its escalation record, shared with the Studio triage endpoints.
|
|
13
|
-
*/
|
|
14
|
-
const ESCALATION_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
15
|
-
const MAX_INSTRUCTIONS = 1000;
|
|
16
|
-
const MAX_PROMPT_ATTEMPTS = 3;
|
|
17
|
-
function mutateEscalation(root, escalationId, mutate) {
|
|
18
|
-
const file = escalationPath(root);
|
|
19
|
-
return withFileLockSync(file, () => {
|
|
20
|
-
const data = loadEscalationFile(root, path.basename(root));
|
|
21
|
-
const record = data.escalations.find((e) => e?.id === escalationId);
|
|
22
|
-
if (!record)
|
|
23
|
-
return false;
|
|
24
|
-
mutate(record);
|
|
25
|
-
data.lastUpdated = new Date().toISOString();
|
|
26
|
-
writeJsonAtomicSync(file, data);
|
|
27
|
-
return true;
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
/** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
|
|
31
|
-
async function unblockTask(root, taskId, note) {
|
|
32
|
-
const planPath = path.join(root, '.ai', 'master_plan.json');
|
|
33
|
-
if (!fs.existsSync(planPath))
|
|
34
|
-
return false;
|
|
35
|
-
const result = await withPlanLock(planPath, (plan, ctx) => {
|
|
36
|
-
const task = plan.milestones.flatMap((m) => m.tasks ?? []).find((t) => t.id === taskId);
|
|
37
|
-
if (!task || task.status !== 'blocked') {
|
|
38
|
-
ctx.abort();
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
task.status = 'pending';
|
|
42
|
-
task.notes = note;
|
|
43
|
-
return true;
|
|
44
|
-
});
|
|
45
|
-
return result === true;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Only pending escalations may be triaged: re-running a resolved one could re-apply its proposal.
|
|
49
|
-
* Returns an error message, or null when the escalation can be evaluated.
|
|
50
|
-
*/
|
|
51
|
-
export function triageBlocker(root, escalationId) {
|
|
52
|
-
const record = loadEscalationFile(root, path.basename(root)).escalations.find((e) => e?.id === escalationId);
|
|
53
|
-
if (!record)
|
|
54
|
-
return `Escalation "${escalationId}" not found in .ai/escalation.json`;
|
|
55
|
-
if (record.status !== 'pending_review')
|
|
56
|
-
return `Escalation "${escalationId}" is already ${record.status}`;
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* After an evaluation: unblocks the task and closes the escalation when a patch was auto-applied,
|
|
61
|
-
* then stores the verdict on the escalation. Never throws; a bookkeeping failure must not hide an
|
|
62
|
-
* applied patch.
|
|
63
|
-
*/
|
|
64
|
-
export async function finalizeTriage(root, evaluation) {
|
|
65
|
-
let taskId = null;
|
|
66
|
-
let unblockedTaskId = null;
|
|
67
|
-
let persisted = false;
|
|
68
|
-
try {
|
|
69
|
-
taskId = loadEscalationFile(root, path.basename(root)).escalations.find((e) => e?.id === evaluation.escalationId)?.taskId ?? null;
|
|
70
|
-
if (evaluation.autoPatchApplied && taskId) {
|
|
71
|
-
const reason = evaluation.resolution?.summary ?? 'contract patch applied';
|
|
72
|
-
if (await unblockTask(root, taskId, `Unblocked by Tier 1 auto-resolution of ${evaluation.escalationId}: ${reason}`)) {
|
|
73
|
-
unblockedTaskId = taskId;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const snapshot = {
|
|
77
|
-
evaluatedAt: new Date().toISOString(),
|
|
78
|
-
provider: evaluation.provider,
|
|
79
|
-
model: evaluation.model,
|
|
80
|
-
source: evaluation.source,
|
|
81
|
-
latencyMs: evaluation.latencyMs,
|
|
82
|
-
classification: evaluation.classification,
|
|
83
|
-
reasoning: evaluation.reasoning,
|
|
84
|
-
autoPatchApplied: evaluation.autoPatchApplied,
|
|
85
|
-
unblockedTaskId,
|
|
86
|
-
guardrails: evaluation.guardrails,
|
|
87
|
-
...(evaluation.resolution
|
|
88
|
-
? {
|
|
89
|
-
resolution: {
|
|
90
|
-
summary: evaluation.resolution.summary,
|
|
91
|
-
patch: evaluation.resolution.patch,
|
|
92
|
-
proofPassed: evaluation.resolution.proof.passed,
|
|
93
|
-
...(evaluation.resolution.proposalId ? { proposalId: evaluation.resolution.proposalId } : {}),
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
: {}),
|
|
97
|
-
...(evaluation.humanCard ? { humanCard: evaluation.humanCard } : {}),
|
|
98
|
-
};
|
|
99
|
-
persisted = mutateEscalation(root, evaluation.escalationId, (record) => {
|
|
100
|
-
record.triage = snapshot;
|
|
101
|
-
if (evaluation.autoPatchApplied && record.status === 'pending_review') {
|
|
102
|
-
// Same closing fields the Studio approve/reject endpoint writes.
|
|
103
|
-
const closed = record;
|
|
104
|
-
closed.status = 'resolved';
|
|
105
|
-
closed.resolutionNotes = `Auto-resolved by Tier 1 (${evaluation.model}): ${evaluation.resolution?.summary ?? 'contract patch applied'}`;
|
|
106
|
-
closed.resolvedAt = snapshot.evaluatedAt;
|
|
107
|
-
closed.resolution = { decision: 'approve', proposalApplied: true, ruleId: 'TIER1_AUTO_RESOLVED', unblockedTaskId };
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
// Reported through `persisted: false`.
|
|
113
|
-
}
|
|
114
|
-
return { taskId, unblockedTaskId, persisted };
|
|
115
|
-
}
|
|
116
|
-
export function recordHumanDecision(root, escalationId, decision) {
|
|
117
|
-
return mutateEscalation(root, escalationId, (record) => {
|
|
118
|
-
record.humanDecision = decision;
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
/** Queues lines so answers typed (or piped) before the prompt appears are not lost. */
|
|
122
|
-
function createLineReader(input, output) {
|
|
123
|
-
const rl = readline.createInterface({ input, terminal: false });
|
|
124
|
-
const queue = [];
|
|
125
|
-
const waiters = [];
|
|
126
|
-
let closed = false;
|
|
127
|
-
rl.on('line', (line) => {
|
|
128
|
-
const waiter = waiters.shift();
|
|
129
|
-
if (waiter)
|
|
130
|
-
waiter(line);
|
|
131
|
-
else
|
|
132
|
-
queue.push(line);
|
|
133
|
-
});
|
|
134
|
-
rl.on('close', () => {
|
|
135
|
-
closed = true;
|
|
136
|
-
while (waiters.length)
|
|
137
|
-
waiters.shift()(null);
|
|
138
|
-
});
|
|
139
|
-
return {
|
|
140
|
-
ask(prompt) {
|
|
141
|
-
output.write(prompt);
|
|
142
|
-
if (queue.length)
|
|
143
|
-
return Promise.resolve(queue.shift());
|
|
144
|
-
if (closed)
|
|
145
|
-
return Promise.resolve(null);
|
|
146
|
-
return new Promise((resolve) => waiters.push(resolve));
|
|
147
|
-
},
|
|
148
|
-
close: () => rl.close(),
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
/** Asks for a number in 1..max; null on Enter, end of input, or repeated invalid answers. */
|
|
152
|
-
async function askChoice(reader, prompt, max, print) {
|
|
153
|
-
for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt++) {
|
|
154
|
-
const answer = await reader.ask(prompt);
|
|
155
|
-
if (answer === null || !answer.trim())
|
|
156
|
-
return null;
|
|
157
|
-
const n = Number(answer.trim());
|
|
158
|
-
if (Number.isInteger(n) && n >= 1 && n <= max)
|
|
159
|
-
return n;
|
|
160
|
-
print(pc.yellow(` Enter a number from 1 to ${max}, or press Enter to skip.`));
|
|
161
|
-
}
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
7
|
+
import { ESCALATION_ID, finalizeTriage, recordHumanDecision, triageBlocker, } from './triage/triage-store.js';
|
|
8
|
+
import { askChoice, askDecision, createLineReader, printEvaluation, } from './triage/triage-ui.js';
|
|
9
|
+
export * from './triage/triage-store.js';
|
|
164
10
|
function pendingEscalations(root) {
|
|
165
11
|
return loadEscalationFile(root, path.basename(root))
|
|
166
12
|
.escalations.filter((e) => e && typeof e.id === 'string' && e.status === 'pending_review');
|
|
167
13
|
}
|
|
168
|
-
function printEvaluation(print, result, taskId, unblockedTaskId) {
|
|
169
|
-
print('');
|
|
170
|
-
print(pc.bold(`Tier 1 AI Strategist`) + pc.dim(` ${result.escalationId}${taskId ? ` / ${taskId}` : ''} (${result.provider === 'deterministic' ? 'offline rules' : `${result.provider}: ${result.model}`}, ${result.latencyMs}ms)`));
|
|
171
|
-
if (result.classification === 'AUTO_RESOLVE' && !result.resolution) {
|
|
172
|
-
print(pc.green(pc.bold('Safe to proceed')) + pc.green(' No contract change is needed and nothing was written.'));
|
|
173
|
-
}
|
|
174
|
-
else if (result.classification === 'AUTO_RESOLVE' && result.resolution) {
|
|
175
|
-
const { patch, summary } = result.resolution;
|
|
176
|
-
print(pc.green(pc.bold(result.autoPatchApplied ? 'Auto-resolved' : 'Safe fix found')) + pc.green(` ${summary}`));
|
|
177
|
-
print(pc.dim(` ${patch.operation} ${patch.target} ${patch.path}`));
|
|
178
|
-
if (result.autoPatchApplied) {
|
|
179
|
-
print(pc.green(` Patch written to .ai/${patch.target === 'db_schema' ? 'db_schema.json' : 'api_contracts.json'}.`));
|
|
180
|
-
if (unblockedTaskId)
|
|
181
|
-
print(pc.green(` Task ${unblockedTaskId} is unblocked and pending.`));
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
print(pc.white(' Proven in a sandbox copy of the contract. Re-run with --apply to write it.'));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
else if (result.humanCard) {
|
|
188
|
-
const card = result.humanCard;
|
|
189
|
-
print(pc.yellow(pc.bold('Human Decision Required')));
|
|
190
|
-
print('');
|
|
191
|
-
print(pc.bold('1. What is happening?'));
|
|
192
|
-
print(` ${card.symptom}`);
|
|
193
|
-
print(pc.bold('2. Why is this happening?'));
|
|
194
|
-
print(` ${card.rootCause}`);
|
|
195
|
-
print(pc.bold('3. Who and what is affected?'));
|
|
196
|
-
print(` ${card.blastRadius}`);
|
|
197
|
-
print(pc.bold('4. Your options'));
|
|
198
|
-
card.options.forEach((option, i) => {
|
|
199
|
-
const tag = option.recommended && !/recommended/i.test(option.label) ? pc.cyan(' (Recommended)') : '';
|
|
200
|
-
print(` [${i + 1}] ${option.label}${tag}`);
|
|
201
|
-
print(pc.dim(` ${option.outcome}`));
|
|
202
|
-
});
|
|
203
|
-
print(` [${card.options.length + 1}] Give custom instructions`);
|
|
204
|
-
}
|
|
205
|
-
if (result.reasoning)
|
|
206
|
-
print(pc.dim(`\n Reasoning: ${result.reasoning}`));
|
|
207
|
-
for (const note of result.guardrails)
|
|
208
|
-
print(pc.dim(` Guardrail: ${note}`));
|
|
209
|
-
}
|
|
210
|
-
async function askDecision(reader, card, print) {
|
|
211
|
-
const count = card.options.length + 1;
|
|
212
|
-
const keys = Array.from({ length: count }, (_, i) => i + 1).join('/');
|
|
213
|
-
const choice = await askChoice(reader, `\nChoose [${keys}] (Enter to decide later): `, count, print);
|
|
214
|
-
if (choice === null)
|
|
215
|
-
return null;
|
|
216
|
-
if (choice <= card.options.length) {
|
|
217
|
-
const option = card.options[choice - 1];
|
|
218
|
-
return { optionId: option.id, label: option.label, outcome: option.outcome };
|
|
219
|
-
}
|
|
220
|
-
const text = (await reader.ask('Instructions for the team: '))?.trim() ?? '';
|
|
221
|
-
if (!text)
|
|
222
|
-
return null;
|
|
223
|
-
return { optionId: 'custom', label: 'Custom instructions', instructions: text.slice(0, MAX_INSTRUCTIONS) };
|
|
224
|
-
}
|
|
225
14
|
export async function runTriage(escalationIdArg, targetDirArg, options = {}) {
|
|
226
15
|
const output = options.output ?? process.stdout;
|
|
227
16
|
const print = (line = '') => {
|
|
@@ -252,7 +41,10 @@ export async function runTriage(escalationIdArg, targetDirArg, options = {}) {
|
|
|
252
41
|
}
|
|
253
42
|
const liaison = new Tier1Liaison(root, {
|
|
254
43
|
...options.liaison,
|
|
255
|
-
config: {
|
|
44
|
+
config: {
|
|
45
|
+
...(options.apply !== undefined ? { autoTriageEnabled: Boolean(options.apply) } : {}),
|
|
46
|
+
...(threshold ? { riskThreshold: threshold } : {}),
|
|
47
|
+
},
|
|
256
48
|
});
|
|
257
49
|
const interactive = !options.json && (options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY));
|
|
258
50
|
const reader = interactive ? createLineReader(options.input ?? process.stdin, output) : null;
|
|
@@ -42,6 +42,7 @@ export interface WriteCheckResult {
|
|
|
42
42
|
taskIds: string[];
|
|
43
43
|
}
|
|
44
44
|
export declare function loadEnforcementMode(root: string): EnforcementMode;
|
|
45
|
+
export declare function loadSafePeripherals(root: string): string[];
|
|
45
46
|
export declare function isTaskUnlocked(root: string, taskId: string): boolean;
|
|
46
47
|
/** Lifts scope enforcement for one task until it is revoked or the task stops being in progress. */
|
|
47
48
|
export declare function addTaskUnlock(root: string, taskId: string, reason?: string): void;
|
package/dist/core/enforcement.js
CHANGED
|
@@ -35,12 +35,28 @@ export function loadEnforcementMode(root) {
|
|
|
35
35
|
const raw = parseJsonLoose(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
|
|
36
36
|
if (raw.enforcement === 'off' || raw.enforcement === 'warn' || raw.enforcement === 'block')
|
|
37
37
|
return raw.enforcement;
|
|
38
|
+
if (raw.profile === 'prototype')
|
|
39
|
+
return 'off';
|
|
40
|
+
if (raw.profile === 'solo')
|
|
41
|
+
return 'warn';
|
|
38
42
|
}
|
|
39
43
|
catch {
|
|
40
44
|
// Missing or corrupt config means the default.
|
|
41
45
|
}
|
|
42
46
|
return DEFAULT_ENFORCEMENT_MODE;
|
|
43
47
|
}
|
|
48
|
+
export function loadSafePeripherals(root) {
|
|
49
|
+
try {
|
|
50
|
+
const raw = parseJsonLoose(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
|
|
51
|
+
if (Array.isArray(raw.safePeripherals)) {
|
|
52
|
+
return raw.safePeripherals.filter((p) => typeof p === 'string' && Boolean(p.trim()));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Missing or invalid config
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
44
60
|
function readUnlocks(root) {
|
|
45
61
|
try {
|
|
46
62
|
const raw = parseJsonLoose(fs.readFileSync(path.join(root, UNLOCK_FILE), 'utf8'));
|
|
@@ -234,6 +250,9 @@ export function checkWrite(input) {
|
|
|
234
250
|
return { ...allow, taskIds };
|
|
235
251
|
if (tasks.some((t) => t.targetFiles.some((target) => matchesTarget(relPath, target))))
|
|
236
252
|
return { ...allow, taskIds };
|
|
253
|
+
const safePeripherals = loadSafePeripherals(mainRoot);
|
|
254
|
+
if (safePeripherals.some((pattern) => matchesTarget(relPath, pattern)))
|
|
255
|
+
return { ...allow, taskIds };
|
|
237
256
|
const label = taskIds.length === 1 ? `task ${taskIds[0]}` : `tasks ${taskIds.join(', ')}`;
|
|
238
257
|
const scope = tasks.flatMap((t) => t.targetFiles);
|
|
239
258
|
return verdict(mode, root, relPath, taskIds, 'out_of_scope', `${relPath} is outside the target files of ${label}` +
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
* where no permission rule is installed. It is one layer, not a sandbox: the hook and ask rules are others.
|
|
4
4
|
*/
|
|
5
5
|
export declare function isInteractive(): boolean;
|
|
6
|
-
|
|
6
|
+
export declare function isCiEnvironment(): boolean;
|
|
7
|
+
/** Prints why and sets a failing exit code when there is no terminal, unless in CI or overridden. */
|
|
7
8
|
export declare function refuseHeadless(command: string): boolean;
|
package/dist/core/human-gate.js
CHANGED
|
@@ -6,9 +6,16 @@ import pc from 'picocolors';
|
|
|
6
6
|
export function isInteractive() {
|
|
7
7
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
export function isCiEnvironment() {
|
|
10
|
+
return Boolean(process.env.CI === 'true' ||
|
|
11
|
+
process.env.GITHUB_ACTIONS === 'true' ||
|
|
12
|
+
process.env.GITLAB_CI === 'true' ||
|
|
13
|
+
process.env.NATIV_CI_OVERRIDE ||
|
|
14
|
+
process.env.NATIV_HEADLESS_OVERRIDE === '1');
|
|
15
|
+
}
|
|
16
|
+
/** Prints why and sets a failing exit code when there is no terminal, unless in CI or overridden. */
|
|
10
17
|
export function refuseHeadless(command) {
|
|
11
|
-
if (isInteractive())
|
|
18
|
+
if (isInteractive() || isCiEnvironment())
|
|
12
19
|
return false;
|
|
13
20
|
console.error(pc.red(`✖ ${command} needs an interactive terminal; agents cannot run it.`));
|
|
14
21
|
process.exitCode = 1;
|