@njeybe/nativ 2.4.1 → 2.5.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +20 -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/dist/commands/session.d.ts +0 -22
  137. package/dist/commands/session.js +0 -106
  138. package/dist/core/session-memory.d.ts +0 -42
  139. 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 { CandidatePatch, SelfHealingEscalationRecord } from '../governor/circuit-breaker.js';
2
- import { type HumanDecisionCard, type Tier1LiaisonOptions, type TriageClassification, type TriageEvaluation, type TriageFailure, type TriageSource } from '../core/tier1-liaison.js';
3
- /** Latest Tier 1 verdict, stored on the escalation so Studio and the architect can show it after a reload. */
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;
@@ -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 { withFileLockSync, withPlanLock, writeJsonAtomicSync } from '../core/lock-manager.js';
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
- * `nativ triage`: runs the Tier 1 AI Strategist on pending escalations from the terminal and
11
- * presents human decision cards with numbered choices. Also home of the helpers that persist a
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: { autoTriageEnabled: Boolean(options.apply), riskThreshold: threshold ?? 'safe_contracts_only' },
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;
@@ -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
- /** Prints why and sets a failing exit code when there is no terminal. Returns true when refused. */
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;
@@ -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
- /** Prints why and sets a failing exit code when there is no terminal. Returns true when refused. */
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;