@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +26 -0
  3. package/dist/commands/db/db-formatter.d.ts +5 -0
  4. package/dist/commands/db/db-formatter.js +116 -0
  5. package/dist/commands/db/db-introspect.d.ts +11 -0
  6. package/dist/commands/db/db-introspect.js +84 -0
  7. package/dist/commands/db.js +4 -206
  8. package/dist/commands/profile.d.ts +3 -0
  9. package/dist/commands/profile.js +39 -0
  10. package/dist/commands/task/task-add.d.ts +11 -0
  11. package/dist/commands/task/task-add.js +169 -0
  12. package/dist/commands/task/task-common.d.ts +57 -0
  13. package/dist/commands/task/task-common.js +45 -0
  14. package/dist/commands/task/task-escalate.d.ts +7 -0
  15. package/dist/commands/task/task-escalate.js +119 -0
  16. package/dist/commands/task/task-lifecycle.d.ts +5 -0
  17. package/dist/commands/task/task-lifecycle.js +252 -0
  18. package/dist/commands/task/task-query.d.ts +10 -0
  19. package/dist/commands/task/task-query.js +269 -0
  20. package/dist/commands/task.d.ts +9 -87
  21. package/dist/commands/task.js +8 -850
  22. package/dist/commands/triage/triage-store.d.ts +56 -0
  23. package/dist/commands/triage/triage-store.js +108 -0
  24. package/dist/commands/triage/triage-ui.d.ts +14 -0
  25. package/dist/commands/triage/triage-ui.js +104 -0
  26. package/dist/commands/triage.d.ts +3 -52
  27. package/dist/commands/triage.js +8 -216
  28. package/dist/core/enforcement.d.ts +1 -0
  29. package/dist/core/enforcement.js +19 -0
  30. package/dist/core/human-gate.d.ts +2 -1
  31. package/dist/core/human-gate.js +9 -2
  32. package/dist/core/profiles.d.ts +14 -0
  33. package/dist/core/profiles.js +82 -0
  34. package/dist/core/setup/config-merger.d.ts +59 -0
  35. package/dist/core/setup/config-merger.js +107 -0
  36. package/dist/core/setup/session-context.d.ts +4 -0
  37. package/dist/core/setup/session-context.js +65 -0
  38. package/dist/core/setup/setup-planner.d.ts +14 -0
  39. package/dist/core/setup/setup-planner.js +180 -0
  40. package/dist/core/setup/template-planner.d.ts +8 -0
  41. package/dist/core/setup/template-planner.js +32 -0
  42. package/dist/core/setup/types.d.ts +65 -0
  43. package/dist/core/setup/types.js +55 -0
  44. package/dist/core/setup-assets.d.ts +8 -119
  45. package/dist/core/setup-assets.js +7 -437
  46. package/dist/core/tier1-liaison.d.ts +2 -0
  47. package/dist/core/tier1-liaison.js +64 -3
  48. package/dist/core/verifier/executor.d.ts +5 -0
  49. package/dist/core/verifier/executor.js +62 -0
  50. package/dist/core/verifier/phases.d.ts +5 -0
  51. package/dist/core/verifier/phases.js +71 -0
  52. package/dist/core/verifier/task-verifier.d.ts +37 -0
  53. package/dist/core/verifier/task-verifier.js +233 -0
  54. package/dist/core/verifier/types.d.ts +51 -0
  55. package/dist/core/verifier/types.js +1 -0
  56. package/dist/core/verifier.d.ts +5 -93
  57. package/dist/core/verifier.js +5 -359
  58. package/dist/db/introspector.js +59 -59
  59. package/dist/governor/evaluator.d.ts +4 -64
  60. package/dist/governor/evaluator.js +21 -686
  61. package/dist/governor/patch-applier.d.ts +8 -0
  62. package/dist/governor/patch-applier.js +121 -0
  63. package/dist/governor/self-healing.d.ts +24 -0
  64. package/dist/governor/self-healing.js +351 -0
  65. package/dist/governor/test-integrity.d.ts +41 -0
  66. package/dist/governor/test-integrity.js +237 -0
  67. package/dist/index.js +8 -0
  68. package/dist/mcp/capture.d.ts +4 -0
  69. package/dist/mcp/capture.js +33 -0
  70. package/dist/mcp/resources.d.ts +39 -0
  71. package/dist/mcp/resources.js +44 -0
  72. package/dist/mcp/server.d.ts +11 -0
  73. package/dist/mcp/server.js +14 -89
  74. package/dist/mcp/tools.d.ts +1 -0
  75. package/dist/mcp/tools.js +1 -0
  76. package/dist/runner/agent-supervisor.d.ts +8 -302
  77. package/dist/runner/agent-supervisor.js +41 -1030
  78. package/dist/runner/cli-config.d.ts +84 -0
  79. package/dist/runner/cli-config.js +215 -0
  80. package/dist/runner/native-driver.d.ts +18 -0
  81. package/dist/runner/native-driver.js +208 -0
  82. package/dist/runner/native-engine.d.ts +27 -0
  83. package/dist/runner/native-engine.js +184 -0
  84. package/dist/runner/native-tools.d.ts +31 -0
  85. package/dist/runner/native-tools.js +241 -0
  86. package/dist/runner/process-utils.d.ts +12 -0
  87. package/dist/runner/process-utils.js +96 -0
  88. package/dist/runner/run-lifecycle.d.ts +6 -0
  89. package/dist/runner/run-lifecycle.js +42 -0
  90. package/dist/runner/types.d.ts +215 -0
  91. package/dist/runner/types.js +41 -0
  92. package/dist/scanner/detector.js +42 -663
  93. package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
  94. package/dist/scanner/detectors/ecosystem-detector.js +354 -0
  95. package/dist/scanner/detectors/orm-detector.d.ts +5 -0
  96. package/dist/scanner/detectors/orm-detector.js +59 -0
  97. package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
  98. package/dist/scanner/detectors/workspace-detector.js +266 -0
  99. package/dist/server/cli-runner.d.ts +15 -0
  100. package/dist/server/cli-runner.js +43 -0
  101. package/dist/server/event-hub.d.ts +42 -0
  102. package/dist/server/event-hub.js +169 -0
  103. package/dist/server/http-utils.d.ts +15 -0
  104. package/dist/server/http-utils.js +56 -0
  105. package/dist/server/plan-utils.d.ts +9 -0
  106. package/dist/server/plan-utils.js +31 -0
  107. package/dist/server/routes/db-routes.d.ts +57 -0
  108. package/dist/server/routes/db-routes.js +484 -0
  109. package/dist/server/routes/task-routes.d.ts +266 -0
  110. package/dist/server/routes/task-routes.js +285 -0
  111. package/dist/server/routes/telemetry-routes.d.ts +128 -0
  112. package/dist/server/routes/telemetry-routes.js +179 -0
  113. package/dist/server/routes/triage-routes.d.ts +81 -0
  114. package/dist/server/routes/triage-routes.js +244 -0
  115. package/dist/server/routes/worktree-routes.d.ts +31 -0
  116. package/dist/server/routes/worktree-routes.js +144 -0
  117. package/dist/server/studio-server.d.ts +4 -11
  118. package/dist/server/studio-server.js +34 -1578
  119. package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
  120. package/dist/server/studio-ui/shell/triage.js +10 -0
  121. package/package.json +2 -2
  122. package/plugin/.claude-plugin/plugin.json +1 -1
  123. package/plugin/agents/architect.md +4 -2
  124. package/plugin/agents/verifier.md +1 -1
  125. package/plugin/agents/worker.md +7 -4
  126. package/plugin/skills/nativ/SKILL.md +1 -0
  127. package/templates/.claudeignore +62 -45
  128. package/templates/AGENTS.md +1 -0
  129. package/templates/CLAUDE.md +13 -0
  130. package/templates/claude-agents/architect.md +4 -2
  131. package/templates/claude-agents/verifier.md +1 -1
  132. package/templates/claude-agents/worker.md +7 -4
  133. package/templates/dot-ai/api_contracts.json +66 -66
  134. package/templates/dot-ai/context.md +38 -38
  135. package/templates/dot-ai/db_schema.json +39 -39
  136. package/templates/dot-ai/subagents/architect.md +54 -0
  137. package/dist/commands/session.d.ts +0 -22
  138. package/dist/commands/session.js +0 -106
  139. package/dist/core/session-memory.d.ts +0 -42
  140. package/dist/core/session-memory.js +0 -121
@@ -0,0 +1,179 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { cacheHitRate, emptyActualUsage, loadTelemetry, mergeActualUsage, resolveModelPricing, } from '../../core/telemetry.js';
4
+ import { runBench } from '../../commands/bench.js';
5
+ import { HttpError } from '../http-utils.js';
6
+ import { AI_DIR, planMilestones, readPlan } from '../plan-utils.js';
7
+ import { runCaptured, truncateOutput } from '../cli-runner.js';
8
+ export function finiteOr(value, fallback) {
9
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
10
+ }
11
+ export function percent(part, total) {
12
+ return total > 0 ? Math.round((part / total) * 100) : 0;
13
+ }
14
+ export function roundUsd(value) {
15
+ return Math.round(value * 1_000_000) / 1_000_000;
16
+ }
17
+ /** Telemetry is hand-editable JSON: coerce every counter so one bad record cannot NaN the totals. */
18
+ export function sanitizeUsage(value) {
19
+ if (!value || typeof value !== 'object')
20
+ return null;
21
+ const u = value;
22
+ const n = (v) => Math.max(0, finiteOr(v, 0));
23
+ return {
24
+ model: typeof u.model === 'string' ? u.model : 'unknown',
25
+ turns: n(u.turns),
26
+ inputTokens: n(u.inputTokens),
27
+ outputTokens: n(u.outputTokens),
28
+ cacheCreationTokens: n(u.cacheCreationTokens),
29
+ cacheReadTokens: n(u.cacheReadTokens),
30
+ thinkingTokens: n(u.thinkingTokens),
31
+ costUsd: n(u.costUsd),
32
+ };
33
+ }
34
+ export function aggregateActualUsage(records) {
35
+ let total = emptyActualUsage('');
36
+ for (const record of records) {
37
+ const usage = sanitizeUsage(record?.actualUsage);
38
+ if (usage)
39
+ total = mergeActualUsage(total, usage);
40
+ }
41
+ return total;
42
+ }
43
+ /** Usage as served by the API: the stored counters plus the derived cache hit rate. */
44
+ export function usageView(usage) {
45
+ return usage ? { ...usage, costUsd: roundUsd(usage.costUsd), cacheHitRate: cacheHitRate(usage) } : null;
46
+ }
47
+ /** GET /api/pipeline/benchmarks: the cached report written by `nativ bench` or benchmarks/run. */
48
+ export function handleBenchmarks(root) {
49
+ let report = null;
50
+ try {
51
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, AI_DIR, 'benchmark_report.json'), 'utf8'));
52
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
53
+ report = parsed;
54
+ }
55
+ catch {
56
+ // No benchmark has been run yet, or the report is unreadable.
57
+ }
58
+ return { ok: true, report };
59
+ }
60
+ export function createBenchmarkRunner(root) {
61
+ let benchmarkRun = null;
62
+ return () => {
63
+ benchmarkRun ??= runCaptured(() => runBench(root, { json: true }))
64
+ .then(({ result, output }) => {
65
+ if (!result)
66
+ throw new HttpError(500, 'BENCHMARK_FAILED', truncateOutput(output || 'Benchmark suite failed to run'));
67
+ return result;
68
+ })
69
+ .finally(() => {
70
+ benchmarkRun = null;
71
+ });
72
+ return benchmarkRun;
73
+ };
74
+ }
75
+ /**
76
+ * GET /api/pipeline/telemetry/detailed: per-task financial audit. Each breakdown pairs the heuristic
77
+ * estimate with API-reported usage and lists the task's runs (engine, model, per-run usage).
78
+ */
79
+ export function handleTelemetryDetailed(root, supervisor) {
80
+ const telemetry = loadTelemetry(path.join(root, AI_DIR, 'telemetry.json'));
81
+ const records = telemetry.tasks.filter((t) => Boolean(t) && typeof t.taskId === 'string');
82
+ const planTasks = new Map(planMilestones(readPlan(root)).flatMap((m) => m.tasks).map((t) => [t.id, t]));
83
+ const runsByTask = new Map();
84
+ for (const run of supervisor.listRuns()) {
85
+ const list = runsByTask.get(run.taskId) ?? [];
86
+ list.push(run);
87
+ runsByTask.set(run.taskId, list);
88
+ }
89
+ const taskIds = [...new Set([...records.map((r) => r.taskId), ...runsByTask.keys()])];
90
+ const taskBreakdowns = taskIds
91
+ .map((taskId) => {
92
+ const record = records.find((r) => r.taskId === taskId);
93
+ const planTask = planTasks.get(taskId);
94
+ const actual = sanitizeUsage(record?.actualUsage);
95
+ const estimate = record?.tokens;
96
+ const estimated = estimate
97
+ ? {
98
+ inputTokens: finiteOr(estimate.inputEstimated, 0),
99
+ outputTokens: finiteOr(estimate.outputEstimated, 0),
100
+ totalTokens: finiteOr(estimate.totalEstimated, 0),
101
+ costUsd: finiteOr(estimate.costUsdEstimated, 0),
102
+ }
103
+ : null;
104
+ return {
105
+ taskId,
106
+ title: record?.title ?? planTask?.title ?? taskId,
107
+ assignedSubagent: record?.assignedSubagent ?? planTask?.assignedSubagent ?? null,
108
+ status: planTask?.status ?? record?.status ?? null,
109
+ startedAt: record?.startedAt ?? null,
110
+ completedAt: record?.completedAt ?? null,
111
+ durationMs: finiteOr(record?.durationMs, 0),
112
+ verification: record?.verification ?? null,
113
+ estimated,
114
+ actual: usageView(actual),
115
+ // Positive: the task cost more than estimated.
116
+ varianceUsd: actual && estimated ? roundUsd(actual.costUsd - estimated.costUsd) : null,
117
+ runs: (runsByTask.get(taskId) ?? []).map((run) => ({
118
+ runId: run.runId,
119
+ engine: run.engine,
120
+ model: run.model,
121
+ status: run.status,
122
+ startedAt: run.startedAt,
123
+ endedAt: run.endedAt,
124
+ durationMs: run.durationMs,
125
+ thinking: run.thinking,
126
+ usage: usageView(sanitizeUsage(run.usage)),
127
+ })),
128
+ };
129
+ })
130
+ // Most expensive first; tasks without grounded usage keep their telemetry order at the end.
131
+ .sort((a, b) => (b.actual?.costUsd ?? -1) - (a.actual?.costUsd ?? -1));
132
+ const byModel = new Map();
133
+ for (const record of records) {
134
+ const usage = sanitizeUsage(record.actualUsage);
135
+ if (!usage)
136
+ continue;
137
+ byModel.set(usage.model, mergeActualUsage(byModel.get(usage.model) ?? emptyActualUsage(usage.model), usage));
138
+ }
139
+ const { summary } = telemetry;
140
+ const actual = aggregateActualUsage(records);
141
+ return {
142
+ ok: true,
143
+ summary: {
144
+ projectName: telemetry.projectName,
145
+ lastUpdated: telemetry.lastUpdated,
146
+ modelTierDefault: telemetry.modelTierDefault,
147
+ tasksTracked: records.length,
148
+ totalTasksCompleted: finiteOr(summary?.totalTasksCompleted, 0),
149
+ totalDurationMs: finiteOr(summary?.totalDurationMs, 0),
150
+ estimated: {
151
+ totalTokens: finiteOr(summary?.estimatedTotalTokens, 0),
152
+ costUsd: finiteOr(summary?.estimatedTotalCostUsd, 0),
153
+ },
154
+ actual: {
155
+ turns: actual.turns,
156
+ inputTokens: actual.inputTokens,
157
+ outputTokens: actual.outputTokens,
158
+ cacheReadTokens: actual.cacheReadTokens,
159
+ cacheCreationTokens: actual.cacheCreationTokens,
160
+ thinkingTokens: actual.thinkingTokens,
161
+ spendUsd: roundUsd(actual.costUsd),
162
+ cacheHitRate: cacheHitRate(actual),
163
+ // What the cache reads would have cost at each model's full input rate, minus what they did cost.
164
+ cacheSavingsUsd: roundUsd([...byModel.values()].reduce((sum, u) => {
165
+ const rates = resolveModelPricing(u.model);
166
+ return sum + (u.cacheReadTokens * (rates.inputPerM - rates.cacheReadPerM)) / 1_000_000;
167
+ }, 0)),
168
+ },
169
+ byModel: [...byModel.values()].map(usageView).sort((a, b) => b.costUsd - a.costUsd),
170
+ verification: {
171
+ passRate: finiteOr(summary?.verificationPassRate, 1),
172
+ runs: finiteOr(summary?.totalVerificationsRun, 0),
173
+ passed: finiteOr(summary?.totalVerificationsPassed, 0),
174
+ },
175
+ circuitBreakerTrips: finiteOr(summary?.circuitBreakerTrips, 0),
176
+ },
177
+ taskBreakdowns,
178
+ };
179
+ }
@@ -0,0 +1,81 @@
1
+ import { type SelfHealingEscalationRecord } from '../../governor/index.js';
2
+ import { Tier1Liaison, type TriageEvaluation } from '../../core/tier1-liaison.js';
3
+ import type { EscalationFile } from '../../scanner/types.js';
4
+ export declare const ESCALATION_FILTERS: readonly ["all", "pending_review", "resolved"];
5
+ export type EscalationFilter = (typeof ESCALATION_FILTERS)[number];
6
+ /** Escalation records as the resolve endpoint leaves them. */
7
+ export interface ResolvedEscalationRecord extends SelfHealingEscalationRecord {
8
+ resolvedAt?: string;
9
+ resolution?: {
10
+ decision: 'approve' | 'reject';
11
+ proposalApplied: boolean;
12
+ ruleId: string | null;
13
+ unblockedTaskId: string | null;
14
+ };
15
+ }
16
+ export declare function escalationFilePath(root: string): string;
17
+ /** Null when no escalation has ever been filed. */
18
+ export declare function readEscalationFile(root: string): EscalationFile | null;
19
+ /** tmp-file + rename so the SSE watcher and concurrent readers never observe a half-written file. */
20
+ export declare function writeJsonAtomic(file: string, data: unknown): void;
21
+ /** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
22
+ export declare function unblockTask(root: string, taskId: string, note: string): Promise<boolean>;
23
+ /** Test restorations run where the task's files live: its agent worktree if one exists, else the project. */
24
+ export declare function taskWorkspace(root: string, taskId: string): string;
25
+ /**
26
+ * GET /api/pipeline/escalations?status=pending_review|resolved|all: escalations newest first, each
27
+ * with its optional self-healing `proposedPatch`. `resolved` covers every closed record (approved or rejected).
28
+ */
29
+ export declare function handleEscalations(root: string, searchParams: URLSearchParams): {
30
+ ok: boolean;
31
+ escalations: import("../../scanner/types.js").EscalationRecord[];
32
+ };
33
+ /**
34
+ * POST /api/pipeline/escalations/resolve: `approve` applies the self-healing proposal (re-checked
35
+ * against the live contract), resets the circuit breaker and unblocks the task; `reject` dismisses
36
+ * the escalation and leaves the task blocked. A proposal that no longer applies leaves the
37
+ * escalation pending and returns 400.
38
+ */
39
+ export declare function handleEscalationResolve(root: string, body: Record<string, unknown>): Promise<{
40
+ ok: boolean;
41
+ message: string;
42
+ unblockedTaskId: string | null;
43
+ }>;
44
+ /** The contract's evaluate body; the full verdict (patch, proof, guardrails) is stored on the escalation. */
45
+ export declare function triageView(result: TriageEvaluation): {
46
+ humanCard?: import("../../core/tier1-liaison.js").HumanDecisionCard | undefined;
47
+ ok: boolean;
48
+ escalationId: string;
49
+ provider: string;
50
+ model: string;
51
+ source: import("../../core/tier1-liaison.js").TriageSource;
52
+ latencyMs: number;
53
+ classification: import("../../core/tier1-liaison.js").TriageClassification;
54
+ reasoning: string;
55
+ autoPatchApplied: boolean;
56
+ };
57
+ /**
58
+ * POST /api/pipeline/triage/evaluate: runs the Tier 1 strategist on one escalation. A proven additive
59
+ * patch is written only while auto-triage is enabled, and then unblocks the task. Concurrent requests
60
+ * for the same escalation share one evaluation, so a double-click cannot apply or count twice.
61
+ */
62
+ export declare function handleTriageEvaluate(root: string, liaison: Tier1Liaison, inFlight: Map<string, Promise<ReturnType<typeof triageView>>>, body: Record<string, unknown>): Promise<{
63
+ humanCard?: import("../../core/tier1-liaison.js").HumanDecisionCard | undefined;
64
+ ok: boolean;
65
+ escalationId: string;
66
+ provider: string;
67
+ model: string;
68
+ source: import("../../core/tier1-liaison.js").TriageSource;
69
+ latencyMs: number;
70
+ classification: import("../../core/tier1-liaison.js").TriageClassification;
71
+ reasoning: string;
72
+ autoPatchApplied: boolean;
73
+ }>;
74
+ export declare const RISK_THRESHOLDS: string[];
75
+ /** POST /api/pipeline/triage/config: session-only auto-triage settings, validated before they reach the liaison. */
76
+ export declare function handleTriageConfig(liaison: Tier1Liaison, body: Record<string, unknown>, onToggle?: () => void): {
77
+ ok: true;
78
+ config: import("../../core/tier1-liaison.js").TriageConfig;
79
+ };
80
+ /** Automatically triages pending escalations when autoTriage is enabled. */
81
+ export declare function runAutoTriageQueue(root: string, liaison: Tier1Liaison, inFlight: Map<string, Promise<ReturnType<typeof triageView>>>): Promise<void>;
@@ -0,0 +1,244 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { withFileLock, withPlanLock } from '../../core/lock-manager.js';
4
+ import { CircuitBreaker, applySelfHealingProposal, } from '../../governor/index.js';
5
+ import { finalizeTriage, triageBlocker } from '../../commands/triage.js';
6
+ import { HttpError } from '../http-utils.js';
7
+ import { AI_DIR, TASK_ID_PATTERN, findTask } from '../plan-utils.js';
8
+ export const ESCALATION_FILTERS = ['all', 'pending_review', 'resolved'];
9
+ const MAX_REASON_LENGTH = 1000;
10
+ export function escalationFilePath(root) {
11
+ return path.join(root, AI_DIR, 'escalation.json');
12
+ }
13
+ /** Null when no escalation has ever been filed. */
14
+ export function readEscalationFile(root) {
15
+ let raw;
16
+ try {
17
+ raw = fs.readFileSync(escalationFilePath(root), 'utf8');
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ // withFileLock creates the file empty when it takes the first lock.
23
+ if (!raw.trim())
24
+ return null;
25
+ try {
26
+ const parsed = JSON.parse(raw);
27
+ if (!Array.isArray(parsed?.escalations))
28
+ parsed.escalations = [];
29
+ return parsed;
30
+ }
31
+ catch {
32
+ throw new HttpError(500, 'ESCALATION_FILE_CORRUPT', '.ai/escalation.json is not valid JSON');
33
+ }
34
+ }
35
+ /** tmp-file + rename so the SSE watcher and concurrent readers never observe a half-written file. */
36
+ export function writeJsonAtomic(file, data) {
37
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
38
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
39
+ for (let attempt = 0;; attempt++) {
40
+ try {
41
+ fs.renameSync(tmp, file);
42
+ return;
43
+ }
44
+ catch (err) {
45
+ const code = err.code;
46
+ if (attempt >= 10 || (code !== 'EPERM' && code !== 'EBUSY')) {
47
+ fs.rmSync(tmp, { force: true });
48
+ throw err;
49
+ }
50
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
51
+ }
52
+ }
53
+ }
54
+ /** Moves a blocked task back to pending so it can be dispatched again. False when it was not blocked. */
55
+ export async function unblockTask(root, taskId, note) {
56
+ const planPath = path.join(root, AI_DIR, 'master_plan.json');
57
+ if (!fs.existsSync(planPath))
58
+ return false;
59
+ const result = await withPlanLock(planPath, (plan, ctx) => {
60
+ const task = findTask(plan, taskId);
61
+ if (!task || task.status !== 'blocked') {
62
+ ctx.abort();
63
+ return false;
64
+ }
65
+ task.status = 'pending';
66
+ task.notes = note;
67
+ return true;
68
+ });
69
+ return result === true;
70
+ }
71
+ /** Test restorations run where the task's files live: its agent worktree if one exists, else the project. */
72
+ export function taskWorkspace(root, taskId) {
73
+ const worktree = path.join(root, '.worktrees', `task-${taskId}`);
74
+ return fs.existsSync(path.join(worktree, '.git')) ? worktree : root;
75
+ }
76
+ /**
77
+ * GET /api/pipeline/escalations?status=pending_review|resolved|all: escalations newest first, each
78
+ * with its optional self-healing `proposedPatch`. `resolved` covers every closed record (approved or rejected).
79
+ */
80
+ export function handleEscalations(root, searchParams) {
81
+ const rawFilter = searchParams.get('status') || 'all';
82
+ if (!ESCALATION_FILTERS.includes(rawFilter)) {
83
+ throw new HttpError(400, 'VALIDATION_ERROR', `"status" must be one of: ${ESCALATION_FILTERS.join(', ')}`);
84
+ }
85
+ const filter = rawFilter;
86
+ const escalations = (readEscalationFile(root)?.escalations ?? [])
87
+ .filter((e) => e && typeof e.id === 'string')
88
+ .filter((e) => filter === 'all' || (filter === 'pending_review' ? e.status === 'pending_review' : e.status !== 'pending_review'))
89
+ .sort((a, b) => (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0));
90
+ return { ok: true, escalations };
91
+ }
92
+ /**
93
+ * POST /api/pipeline/escalations/resolve: `approve` applies the self-healing proposal (re-checked
94
+ * against the live contract), resets the circuit breaker and unblocks the task; `reject` dismisses
95
+ * the escalation and leaves the task blocked. A proposal that no longer applies leaves the
96
+ * escalation pending and returns 400.
97
+ */
98
+ export async function handleEscalationResolve(root, body) {
99
+ const escalationId = typeof body.escalationId === 'string' ? body.escalationId.trim() : '';
100
+ if (!TASK_ID_PATTERN.test(escalationId)) {
101
+ throw new HttpError(400, 'VALIDATION_ERROR', '"escalationId" must be an escalation id such as esc-01');
102
+ }
103
+ const { decision } = body;
104
+ if (decision !== 'approve' && decision !== 'reject') {
105
+ throw new HttpError(400, 'VALIDATION_ERROR', `"decision" must be 'approve' or 'reject'`);
106
+ }
107
+ if (body.notes !== undefined && body.notes !== null && typeof body.notes !== 'string') {
108
+ throw new HttpError(400, 'VALIDATION_ERROR', '"notes" must be a string');
109
+ }
110
+ const notes = typeof body.notes === 'string' ? body.notes.trim() : '';
111
+ if (notes.length > MAX_REASON_LENGTH) {
112
+ throw new HttpError(400, 'VALIDATION_ERROR', `"notes" must be at most ${MAX_REASON_LENGTH} characters`);
113
+ }
114
+ const file = escalationFilePath(root);
115
+ if (!readEscalationFile(root))
116
+ throw new HttpError(400, 'ESCALATION_NOT_FOUND', `Escalation "${escalationId}" not found`);
117
+ // One resolution at a time per project: a double-click must not apply a proposal twice.
118
+ return withFileLock(file, async () => {
119
+ const data = readEscalationFile(root);
120
+ const record = data?.escalations.find((e) => e?.id === escalationId);
121
+ if (!data || !record)
122
+ throw new HttpError(400, 'ESCALATION_NOT_FOUND', `Escalation "${escalationId}" not found`);
123
+ if (record.status !== 'pending_review') {
124
+ throw new HttpError(400, 'ESCALATION_ALREADY_RESOLVED', `Escalation "${escalationId}" is already ${record.status}`);
125
+ }
126
+ const proposal = record.proposedPatch;
127
+ const now = new Date().toISOString();
128
+ let applied = null;
129
+ let unblockedTaskId = null;
130
+ let message;
131
+ if (decision === 'approve') {
132
+ if (proposal) {
133
+ const workspace = proposal.kind === 'restore_tests' ? taskWorkspace(root, record.taskId) : root;
134
+ applied = applySelfHealingProposal(workspace, record.taskId, proposal);
135
+ if (!applied.applied) {
136
+ throw new HttpError(400, applied.ruleId, `Proposal ${proposal.proposalId} was not applied: ${applied.message}`);
137
+ }
138
+ }
139
+ else {
140
+ CircuitBreaker.recordSuccess(root, record.taskId);
141
+ }
142
+ const unblocked = await unblockTask(root, record.taskId, `Unblocked by escalation ${escalationId}${notes ? `: ${notes}` : ''}`);
143
+ unblockedTaskId = unblocked ? record.taskId : null;
144
+ record.status = 'resolved';
145
+ record.resolutionNotes = notes || applied?.message || 'Approved by operator';
146
+ message = [
147
+ applied ? `${applied.message}.` : `Escalation ${escalationId} approved.`,
148
+ unblocked ? `Task ${record.taskId} is unblocked and pending.` : `Task ${record.taskId} was not blocked.`,
149
+ ].join(' ');
150
+ }
151
+ else {
152
+ record.status = 'dismissed';
153
+ record.resolutionNotes = notes || (proposal ? `Proposal ${proposal.proposalId} rejected by operator` : 'Rejected by operator');
154
+ message = `Escalation ${escalationId} rejected; task ${record.taskId} stays blocked.`;
155
+ }
156
+ record.resolvedAt = now;
157
+ record.resolution = {
158
+ decision,
159
+ proposalApplied: Boolean(applied?.applied),
160
+ ruleId: applied?.ruleId ?? null,
161
+ unblockedTaskId,
162
+ };
163
+ data.lastUpdated = now;
164
+ writeJsonAtomic(file, data);
165
+ return { ok: true, message, unblockedTaskId };
166
+ });
167
+ }
168
+ /** The contract's evaluate body; the full verdict (patch, proof, guardrails) is stored on the escalation. */
169
+ export function triageView(result) {
170
+ return {
171
+ ok: true,
172
+ escalationId: result.escalationId,
173
+ provider: result.provider,
174
+ model: result.model,
175
+ source: result.source,
176
+ latencyMs: result.latencyMs,
177
+ classification: result.classification,
178
+ reasoning: result.reasoning,
179
+ autoPatchApplied: result.autoPatchApplied,
180
+ ...(result.humanCard ? { humanCard: result.humanCard } : {}),
181
+ };
182
+ }
183
+ /**
184
+ * POST /api/pipeline/triage/evaluate: runs the Tier 1 strategist on one escalation. A proven additive
185
+ * patch is written only while auto-triage is enabled, and then unblocks the task. Concurrent requests
186
+ * for the same escalation share one evaluation, so a double-click cannot apply or count twice.
187
+ */
188
+ export function handleTriageEvaluate(root, liaison, inFlight, body) {
189
+ const escalationId = typeof body.escalationId === 'string' ? body.escalationId.trim() : '';
190
+ if (!TASK_ID_PATTERN.test(escalationId)) {
191
+ throw new HttpError(400, 'VALIDATION_ERROR', '"escalationId" must be an escalation id such as esc-01');
192
+ }
193
+ const running = inFlight.get(escalationId);
194
+ if (running)
195
+ return running;
196
+ const evaluation = (async () => {
197
+ const blocker = triageBlocker(root, escalationId);
198
+ if (blocker)
199
+ throw new HttpError(400, 'TRIAGE_REJECTED', blocker);
200
+ const result = await liaison.evaluate(escalationId);
201
+ if (!result.ok)
202
+ throw new HttpError(400, 'TRIAGE_REJECTED', result.error);
203
+ await finalizeTriage(root, result);
204
+ return triageView(result);
205
+ })().finally(() => inFlight.delete(escalationId));
206
+ inFlight.set(escalationId, evaluation);
207
+ return evaluation;
208
+ }
209
+ export const RISK_THRESHOLDS = ['safe_contracts_only', 'all_non_destructive'];
210
+ /** POST /api/pipeline/triage/config: session-only auto-triage settings, validated before they reach the liaison. */
211
+ export function handleTriageConfig(liaison, body, onToggle) {
212
+ if (typeof body.autoTriageEnabled !== 'boolean') {
213
+ throw new HttpError(400, 'VALIDATION_ERROR', '"autoTriageEnabled" must be a boolean');
214
+ }
215
+ if (body.riskThreshold !== undefined && !RISK_THRESHOLDS.includes(body.riskThreshold)) {
216
+ throw new HttpError(400, 'VALIDATION_ERROR', `"riskThreshold" must be one of: ${RISK_THRESHOLDS.join(', ')}`);
217
+ }
218
+ const result = liaison.updateConfig({ autoTriageEnabled: body.autoTriageEnabled, riskThreshold: body.riskThreshold });
219
+ if (!result.ok)
220
+ throw new HttpError(400, 'VALIDATION_ERROR', result.error);
221
+ if (body.autoTriageEnabled && onToggle)
222
+ onToggle();
223
+ return result;
224
+ }
225
+ /** Automatically triages pending escalations when autoTriage is enabled. */
226
+ export async function runAutoTriageQueue(root, liaison, inFlight) {
227
+ const status = liaison.getStatus();
228
+ if (!status.autoTriageEnabled)
229
+ return;
230
+ const data = readEscalationFile(root);
231
+ if (!data?.escalations)
232
+ return;
233
+ const pending = data.escalations.filter((e) => e && e.status === 'pending_review' && !e.triage);
234
+ for (const esc of pending) {
235
+ if (inFlight.has(esc.id))
236
+ continue;
237
+ try {
238
+ await handleTriageEvaluate(root, liaison, inFlight, { escalationId: esc.id });
239
+ }
240
+ catch {
241
+ // Background auto-triage gracefully ignores individual failures
242
+ }
243
+ }
244
+ }
@@ -0,0 +1,31 @@
1
+ import { type WorktreeInfo } from '../../commands/worktree.js';
2
+ import type { MasterPlanTask } from '../../scanner/types.js';
3
+ export interface PipelineWorktree extends WorktreeInfo {
4
+ taskStatus: MasterPlanTask['status'] | null;
5
+ mergeEligible: boolean;
6
+ mergeBlockedReason: string | null;
7
+ }
8
+ export declare function git(cwd: string, args: string[]): Promise<string>;
9
+ /** `git diff --no-index` exits 1 when the inputs differ, so stdout is also read from that "error". */
10
+ export declare function gitNoIndexDiff(cwd: string, file: string): Promise<string>;
11
+ /** Async twin of `nativ worktree list --json` (which blocks on execSync and prints to the console). */
12
+ export declare function listGitWorktrees(root: string): Promise<WorktreeInfo[]>;
13
+ /**
14
+ * GET /api/pipeline/worktrees. Merge eligibility mirrors the `nativ worktree merge` gatekeeper (task completed,
15
+ * circuit breaker not tripped), and additionally requires the task to be declared in the plan.
16
+ */
17
+ export declare function describeWorktrees(root: string): Promise<PipelineWorktree[]>;
18
+ /** POST /api/pipeline/worktrees/action: `nativ worktree merge|remove`, never forced past the merge gatekeeper. */
19
+ export declare function handleWorktreeAction(root: string, body: Record<string, unknown>): Promise<{
20
+ ok: boolean;
21
+ message: string;
22
+ }>;
23
+ /** GET /api/pipeline/worktrees/diff: uncommitted changes and unified diff for an agent worktree. */
24
+ export declare function handleWorktreeDiff(root: string, searchParams: URLSearchParams): Promise<{
25
+ ok: boolean;
26
+ taskId: string;
27
+ branch: string;
28
+ hasChanges: boolean;
29
+ filesChanged: string[];
30
+ diff: string;
31
+ }>;