@njeybe/nativ 2.5.0 → 2.6.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 (79) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +29 -4
  3. package/dist/commands/init.js +2 -10
  4. package/dist/commands/notify.d.ts +2 -0
  5. package/dist/commands/notify.js +25 -0
  6. package/dist/commands/session.d.ts +22 -0
  7. package/dist/commands/session.js +106 -0
  8. package/dist/commands/task/task-add.js +1 -1
  9. package/dist/commands/task/task-common.js +6 -6
  10. package/dist/commands/task/task-escalate.js +8 -10
  11. package/dist/commands/task/task-lifecycle.d.ts +7 -1
  12. package/dist/commands/task/task-lifecycle.js +30 -5
  13. package/dist/commands/task/task-query.d.ts +2 -0
  14. package/dist/commands/task/task-query.js +27 -12
  15. package/dist/commands/task/task-reclaim.d.ts +20 -0
  16. package/dist/commands/task/task-reclaim.js +87 -0
  17. package/dist/commands/task.d.ts +2 -0
  18. package/dist/commands/task.js +1 -0
  19. package/dist/commands/triage.js +3 -3
  20. package/dist/commands/validate.js +1 -0
  21. package/dist/commands/verify.js +21 -5
  22. package/dist/core/enforcement.js +18 -17
  23. package/dist/core/lock-manager.d.ts +6 -0
  24. package/dist/core/lock-manager.js +50 -41
  25. package/dist/core/notify.d.ts +38 -0
  26. package/dist/core/notify.js +133 -0
  27. package/dist/core/profiles.js +7 -16
  28. package/dist/core/session-memory.d.ts +42 -0
  29. package/dist/core/session-memory.js +121 -0
  30. package/dist/core/setup/pm-playbook.d.ts +5 -0
  31. package/dist/core/setup/pm-playbook.js +26 -0
  32. package/dist/core/setup/session-context.js +35 -2
  33. package/dist/core/setup/setup-planner.js +2 -2
  34. package/dist/core/setup/types.d.ts +2 -0
  35. package/dist/core/setup/types.js +5 -1
  36. package/dist/core/task-claims.d.ts +15 -0
  37. package/dist/core/task-claims.js +32 -0
  38. package/dist/core/tier1-liaison.d.ts +1 -0
  39. package/dist/core/tier1-liaison.js +25 -27
  40. package/dist/core/verifier/output-digest.d.ts +19 -0
  41. package/dist/core/verifier/output-digest.js +48 -0
  42. package/dist/db/data-engine.js +33 -32
  43. package/dist/db/introspector.js +61 -60
  44. package/dist/db/sqlite.d.ts +3 -0
  45. package/dist/db/sqlite.js +10 -0
  46. package/dist/governor/circuit-breaker.js +12 -0
  47. package/dist/governor/patch-applier.d.ts +1 -0
  48. package/dist/governor/patch-applier.js +3 -7
  49. package/dist/index.js +29 -6
  50. package/dist/mcp/server.d.ts +1 -1
  51. package/dist/mcp/server.js +53 -14
  52. package/dist/providers/claude-api.js +3 -0
  53. package/dist/providers/gemini.js +4 -1
  54. package/dist/providers/registry.d.ts +6 -2
  55. package/dist/providers/registry.js +28 -16
  56. package/dist/providers/types.d.ts +4 -1
  57. package/dist/runner/agent-supervisor.js +2 -2
  58. package/dist/runner/cli-config.js +1 -1
  59. package/dist/scanner/types.d.ts +5 -1
  60. package/dist/scanner/types.js +7 -0
  61. package/dist/server/routes/task-routes.d.ts +2 -0
  62. package/dist/server/routes/triage-routes.js +3 -18
  63. package/package.json +2 -2
  64. package/plugin/.claude-plugin/plugin.json +1 -1
  65. package/plugin/agents/architect.md +1 -1
  66. package/plugin/agents/explorer.md +1 -1
  67. package/plugin/agents/verifier.md +2 -2
  68. package/plugin/agents/worker.md +5 -7
  69. package/plugin/skills/nativ/SKILL.md +36 -49
  70. package/templates/AGENTS.md +36 -49
  71. package/templates/CLAUDE.md +1 -41
  72. package/templates/claude-agents/architect.md +1 -1
  73. package/templates/claude-agents/explorer.md +1 -1
  74. package/templates/claude-agents/verifier.md +2 -2
  75. package/templates/claude-agents/worker.md +5 -7
  76. package/templates/dot-ai/api_contracts.json +66 -66
  77. package/templates/dot-ai/context.md +38 -38
  78. package/templates/dot-ai/db_schema.json +39 -39
  79. package/templates/dot-ai/subagents/architect.md +54 -0
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function getSessionMemoryDir(targetDir) {
4
+ return path.join(targetDir, '.ai', 'memory', 'sessions');
5
+ }
6
+ export function getSessionIndexPath(targetDir) {
7
+ return path.join(getSessionMemoryDir(targetDir), 'index.json');
8
+ }
9
+ export function getLatestSessionPath(targetDir) {
10
+ return path.join(getSessionMemoryDir(targetDir), 'latest.json');
11
+ }
12
+ export function loadSessionIndex(targetDir) {
13
+ const indexPath = getSessionIndexPath(targetDir);
14
+ if (!fs.existsSync(indexPath)) {
15
+ return {
16
+ version: '1.0.0',
17
+ updatedAt: new Date().toISOString(),
18
+ sessions: [],
19
+ };
20
+ }
21
+ try {
22
+ const raw = fs.readFileSync(indexPath, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ if (!Array.isArray(parsed.sessions)) {
25
+ return {
26
+ version: '1.0.0',
27
+ updatedAt: new Date().toISOString(),
28
+ sessions: [],
29
+ };
30
+ }
31
+ return parsed;
32
+ }
33
+ catch {
34
+ return {
35
+ version: '1.0.0',
36
+ updatedAt: new Date().toISOString(),
37
+ sessions: [],
38
+ };
39
+ }
40
+ }
41
+ export function saveSessionIndex(targetDir, index) {
42
+ const dir = getSessionMemoryDir(targetDir);
43
+ if (!fs.existsSync(dir)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ }
46
+ index.updatedAt = new Date().toISOString();
47
+ fs.writeFileSync(getSessionIndexPath(targetDir), JSON.stringify(index, null, 2), 'utf8');
48
+ }
49
+ export function saveSessionCheckpoint(targetDir, input) {
50
+ const dir = getSessionMemoryDir(targetDir);
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ }
54
+ const now = new Date();
55
+ const dateStr = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
56
+ const sessionId = input.sessionId || `sess-${dateStr}`;
57
+ const checkpoint = {
58
+ sessionId,
59
+ timestamp: now.toISOString(),
60
+ ...(input.role ? { role: input.role.trim() } : {}),
61
+ ...(input.taskId ? { taskId: input.taskId.trim() } : {}),
62
+ summary: input.summary.trim(),
63
+ ...(input.decisions && input.decisions.length > 0 ? { decisions: input.decisions.map((d) => d.trim()) } : {}),
64
+ ...(input.obstacles && input.obstacles.length > 0 ? { obstacles: input.obstacles.map((o) => o.trim()) } : {}),
65
+ ...(input.nextSteps && input.nextSteps.length > 0 ? { nextSteps: input.nextSteps.map((n) => n.trim()) } : {}),
66
+ ...(input.activeFiles && input.activeFiles.length > 0 ? { activeFiles: input.activeFiles.map((f) => f.trim()) } : {}),
67
+ };
68
+ // Write individual session file
69
+ const sessionPath = path.join(dir, `${sessionId}.json`);
70
+ fs.writeFileSync(sessionPath, JSON.stringify(checkpoint, null, 2), 'utf8');
71
+ // Write latest.json
72
+ fs.writeFileSync(getLatestSessionPath(targetDir), JSON.stringify(checkpoint, null, 2), 'utf8');
73
+ // Update index
74
+ const index = loadSessionIndex(targetDir);
75
+ index.latestSessionId = sessionId;
76
+ // Remove existing entry with same sessionId if updating
77
+ index.sessions = index.sessions.filter((s) => s.sessionId !== sessionId);
78
+ index.sessions.unshift({
79
+ sessionId,
80
+ timestamp: checkpoint.timestamp,
81
+ role: checkpoint.role,
82
+ taskId: checkpoint.taskId,
83
+ summary: checkpoint.summary,
84
+ });
85
+ // Keep index trimmed to 50 most recent sessions
86
+ if (index.sessions.length > 50) {
87
+ index.sessions = index.sessions.slice(0, 50);
88
+ }
89
+ saveSessionIndex(targetDir, index);
90
+ return checkpoint;
91
+ }
92
+ export function getLatestSession(targetDir) {
93
+ const latestPath = getLatestSessionPath(targetDir);
94
+ if (!fs.existsSync(latestPath)) {
95
+ return null;
96
+ }
97
+ try {
98
+ const raw = fs.readFileSync(latestPath, 'utf8');
99
+ return JSON.parse(raw);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ export function getSession(targetDir, sessionId) {
106
+ const sessionPath = path.join(getSessionMemoryDir(targetDir), `${sessionId}.json`);
107
+ if (!fs.existsSync(sessionPath)) {
108
+ return null;
109
+ }
110
+ try {
111
+ const raw = fs.readFileSync(sessionPath, 'utf8');
112
+ return JSON.parse(raw);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ export function listSessions(targetDir, limit = 10) {
119
+ const index = loadSessionIndex(targetDir);
120
+ return index.sessions.slice(0, limit);
121
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The project manager's playbook. The SessionStart hook prints it into the main session only:
3
+ * subagents load CLAUDE.md and AGENTS.md on every turn, so anything kept there is paid for by every worker.
4
+ */
5
+ export declare const PM_PLAYBOOK = "## Working as Project Manager\n\n- Start each session with `nativ task next --json` (or the `nativ_task_next` MCP tool).\n- Delegate, do not implement. Hand each task to the `worker` agent by task id, passing the task's `recommendedModel` (haiku, sonnet or opus) as the Agent tool's `model`; with none, the worker's default applies. Ask the `verifier` agent for an independent check before you mark a milestone done. Ask the `architect` agent for anything that changes a contract.\n- Before the architect designs on existing code, run the `explorer` when `.ai/codebase_map.md` is missing or its commit is well behind `HEAD`, and pass its report to the architect. Give the explorer the current commit (`git rev-parse --short HEAD`); it cannot look it up. Pass `model: haiku` for a single lookup such as \"where is the auth middleware?\".\n- Keep usage low. Run this session on Sonnet. Agent defaults: architect Opus, worker Sonnet, verifier Haiku. Pass `haiku` for mechanical tasks (wording, docs, renames) and `sonnet` for the verifier at the end of a milestone. Never pass a model the human did not choose.\n- Independent tasks can run in parallel in worktrees (`nativ worktree create <taskId>`). On a Claude subscription every agent draws from the same usage limit, so run two or three at a time, not ten.\n- You may not edit `.ai/`. If the enforcement hook warns about a file, that is a scope or contract gap: escalate with `nativ task escalate`, do not edit around it.\n- `nativ doctor` confirms the integration is intact. Run it after updating Claude Code or nativ, and whenever hooks or MCP tools seem to be missing.\n\n## Autonomous execution\n\nWhen told to run autonomously (\"run the queue\", \"execute milestone\", AFK mode):\n1. `nativ_task_next`, then `nativ_task_start`.\n2. Delegate to the `worker` agent with the task id and recommended model.\n3. The worker completes the task itself; `nativ task complete` already ran the check. Do not run `nativ_verify` or `nativ_task_complete` again for it.\n4. Loop to the next task immediately, without asking the human.\n5. Stop only when the milestone is done (ask the `verifier` for a milestone check, then tell the human) or when a task is blocked or escalated (present the four-part summary).\n\n## Talking to the human\n\nThe human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked `(Recommended)`, then one direct question. Say \"the app tried to link a record to an item that does not exist yet\", not \"foreign key constraint violation\". Never paste stack traces; put raw output in a collapsed `<details>` block.";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The project manager's playbook. The SessionStart hook prints it into the main session only:
3
+ * subagents load CLAUDE.md and AGENTS.md on every turn, so anything kept there is paid for by every worker.
4
+ */
5
+ export const PM_PLAYBOOK = `## Working as Project Manager
6
+
7
+ - Start each session with \`nativ task next --json\` (or the \`nativ_task_next\` MCP tool).
8
+ - Delegate, do not implement. Hand each task to the \`worker\` agent by task id, passing the task's \`recommendedModel\` (haiku, sonnet or opus) as the Agent tool's \`model\`; with none, the worker's default applies. Ask the \`verifier\` agent for an independent check before you mark a milestone done. Ask the \`architect\` agent for anything that changes a contract.
9
+ - Before the architect designs on existing code, run the \`explorer\` when \`.ai/codebase_map.md\` is missing or its commit is well behind \`HEAD\`, and pass its report to the architect. Give the explorer the current commit (\`git rev-parse --short HEAD\`); it cannot look it up. Pass \`model: haiku\` for a single lookup such as "where is the auth middleware?".
10
+ - Keep usage low. Run this session on Sonnet. Agent defaults: architect Opus, worker Sonnet, verifier Haiku. Pass \`haiku\` for mechanical tasks (wording, docs, renames) and \`sonnet\` for the verifier at the end of a milestone. Never pass a model the human did not choose.
11
+ - Independent tasks can run in parallel in worktrees (\`nativ worktree create <taskId>\`). On a Claude subscription every agent draws from the same usage limit, so run two or three at a time, not ten.
12
+ - You may not edit \`.ai/\`. If the enforcement hook warns about a file, that is a scope or contract gap: escalate with \`nativ task escalate\`, do not edit around it.
13
+ - \`nativ doctor\` confirms the integration is intact. Run it after updating Claude Code or nativ, and whenever hooks or MCP tools seem to be missing.
14
+
15
+ ## Autonomous execution
16
+
17
+ When told to run autonomously ("run the queue", "execute milestone", AFK mode):
18
+ 1. \`nativ_task_next\`, then \`nativ_task_start\`.
19
+ 2. Delegate to the \`worker\` agent with the task id and recommended model.
20
+ 3. The worker completes the task itself; \`nativ task complete\` already ran the check. Do not run \`nativ_verify\` or \`nativ_task_complete\` again for it.
21
+ 4. Loop to the next task immediately, without asking the human.
22
+ 5. Stop only when the milestone is done (ask the \`verifier\` for a milestone check, then tell the human) or when a task is blocked or escalated (present the four-part summary).
23
+
24
+ ## Talking to the human
25
+
26
+ The human is the product owner, not a log reader. For a blocker, trade-off or escalation use four parts in plain words: what the user would see; why it happens, as simple cause and effect; what is affected and what is safe; two or three options with the best marked \`(Recommended)\`, then one direct question. Say "the app tried to link a record to an item that does not exist yet", not "foreign key constraint violation". Never paste stack traces; put raw output in a collapsed \`<details>\` block.`;
@@ -2,6 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ROLE_ENV, loadEnforcementMode, parseJsonLoose } from '../enforcement.js';
4
4
  import { resolveProjectRoot } from '../root-resolver.js';
5
+ import { loadEscalationFile } from '../../governor/store.js';
6
+ import { DEFAULT_STALE_HOURS, claimAgeHours, isStaleClaim } from '../task-claims.js';
7
+ import { PM_PLAYBOOK } from './pm-playbook.js';
5
8
  function readPlanTasks(root) {
6
9
  try {
7
10
  const plan = parseJsonLoose(fs.readFileSync(path.join(root, '.ai', 'master_plan.json'), 'utf8'));
@@ -15,6 +18,7 @@ function readPlanTasks(root) {
15
18
  status: typeof t.status === 'string' ? t.status : 'pending',
16
19
  dependencies: Array.isArray(t.dependencies) ? t.dependencies : [],
17
20
  targetFiles: Array.isArray(t.targetFiles) ? t.targetFiles : [],
21
+ ...(typeof t.claimedAt === 'string' ? { claimedAt: t.claimedAt } : {}),
18
22
  });
19
23
  }
20
24
  }
@@ -25,6 +29,30 @@ function readPlanTasks(root) {
25
29
  return [];
26
30
  }
27
31
  }
32
+ /** Pending escalations and blocked tasks, so a fresh session resumes where the last one stopped. */
33
+ function openItems(root, tasks) {
34
+ let pending = 0;
35
+ try {
36
+ pending = loadEscalationFile(root, path.basename(root)).escalations.filter((e) => e?.status === 'pending_review').length;
37
+ }
38
+ catch {
39
+ // An unreadable escalation file must not hide the rest of the orientation.
40
+ }
41
+ const blocked = tasks.filter((t) => t.status === 'blocked').map((t) => t.id);
42
+ const stale = tasks.filter((t) => isStaleClaim(t, DEFAULT_STALE_HOURS));
43
+ if (!pending && !blocked.length && !stale.length)
44
+ return null;
45
+ const parts = [];
46
+ if (blocked.length)
47
+ parts.push(`blocked: ${blocked.slice(0, 5).join(', ')}${blocked.length > 5 ? ` +${blocked.length - 5}` : ''}`);
48
+ if (stale.length) {
49
+ const list = stale.slice(0, 5).map((t) => `${t.id} (${Math.floor(claimAgeHours(t) ?? 0)}h)`).join(', ');
50
+ parts.push(`stale in_progress: ${list}: run \`nativ task reclaim\``);
51
+ }
52
+ if (pending)
53
+ parts.push(`${pending} escalation(s) pending: run \`nativ_triage\` (or \`nativ triage --all --apply --json\`)`);
54
+ return `Open: ${parts.join('; ')}.`;
55
+ }
28
56
  /** A few lines of orientation for a new Claude Code session, or null when this is not a nativ project. */
29
57
  export function buildSessionContext(cwd, env = process.env) {
30
58
  const root = resolveProjectRoot(cwd);
@@ -36,8 +64,8 @@ export function buildSessionContext(cwd, env = process.env) {
36
64
  const next = tasks.find((t) => t.status === 'pending' && t.dependencies.every((d) => done.has(d)));
37
65
  const role = (env[ROLE_ENV] ?? '').trim().toLowerCase() || 'project manager';
38
66
  const lines = [
39
- 'This is a nativ project (role-based multi-agent workflow).',
40
- `Session role: ${role}. Enforcement: ${loadEnforcementMode(root)}. Edits outside the active task's targetFiles are flagged, and .ai/ contracts are architect-only.`,
67
+ 'nativ project (role-based workflow).',
68
+ `Session role: ${role}. Enforcement: ${loadEnforcementMode(root)}. Writes outside the task's targetFiles are flagged; .ai/ is architect-only.`,
41
69
  ];
42
70
  if (active.length) {
43
71
  for (const t of active)
@@ -49,7 +77,12 @@ export function buildSessionContext(cwd, env = process.env) {
49
77
  else {
50
78
  lines.push('No task is in progress or ready.');
51
79
  }
80
+ const open = openItems(root, tasks);
81
+ if (open)
82
+ lines.push(open);
52
83
  lines.push('Prefer the nativ_* MCP tools (or the `nativ` CLI). Do not edit .ai/ directly: escalate contract gaps with `nativ task escalate`.');
84
+ if (role === 'project manager')
85
+ lines.push('', PM_PLAYBOOK);
53
86
  return lines.join('\n');
54
87
  }
55
88
  /** `nativ hook context`: prints the SessionStart hook output. Never throws and always prints valid JSON or nothing. */
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { MCP_SERVER_NAME, AGENT_FILES, resolveCliInvocation, stampManaged, managedState, } from './types.js';
4
+ import { MCP_SERVER_NAME, AGENT_FILES, resolveCliInvocation, isMachineBoundPath, stampManaged, managedState, } from './types.js';
5
5
  import { buildDesiredConfig, mergeClaudeSettings, mergeMcpConfig, isObject, } from './config-merger.js';
6
6
  import { loadEnforcementMode, parseJsonLoose } from '../enforcement.js';
7
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -38,7 +38,7 @@ export function configuredInvocation(root) {
38
38
  if (args.length === 0 || args[args.length - 1] !== 'mcp' || !args.every((a) => typeof a === 'string'))
39
39
  return null;
40
40
  const prefixArgs = args.slice(0, -1);
41
- return { command: server.command, prefixArgs, portable: ![server.command, ...prefixArgs].some((p) => path.isAbsolute(p)) };
41
+ return { command: server.command, prefixArgs, portable: ![server.command, ...prefixArgs].some(isMachineBoundPath) };
42
42
  }
43
43
  catch {
44
44
  return null;
@@ -6,6 +6,8 @@ export declare const MANAGED_PATTERN: RegExp;
6
6
  export declare const PRE_TOOL_USE_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
7
7
  export declare const SESSION_START_MATCHER = "startup|resume|clear|compact";
8
8
  export declare const HOOK_TIMEOUT_SECONDS = 10;
9
+ /** `.mcp.json` is shared between machines, so a path absolute on either OS ties it to one machine. */
10
+ export declare function isMachineBoundPath(p: string): boolean;
9
11
  export type AssetAction = 'created' | 'updated' | 'unchanged' | 'skipped';
10
12
  export interface AssetChange {
11
13
  /** Path relative to the project root, forward slashes. */
@@ -10,6 +10,10 @@ export const MANAGED_PATTERN = /\n?<!-- nativ:managed sha256=([0-9a-f]{12}) -->\
10
10
  export const PRE_TOOL_USE_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit';
11
11
  export const SESSION_START_MATCHER = 'startup|resume|clear|compact';
12
12
  export const HOOK_TIMEOUT_SECONDS = 10;
13
+ /** `.mcp.json` is shared between machines, so a path absolute on either OS ties it to one machine. */
14
+ export function isMachineBoundPath(p) {
15
+ return path.posix.isAbsolute(p) || path.win32.isAbsolute(p);
16
+ }
13
17
  export function defaultHasBinary(name) {
14
18
  try {
15
19
  return spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' }).status === 0;
@@ -25,7 +29,7 @@ export function resolveCliInvocation(options = {}) {
25
29
  const explicit = options.command?.trim();
26
30
  if (explicit) {
27
31
  const [command, ...prefixArgs] = explicit.split(/\s+/);
28
- return { command, prefixArgs, portable: ![command, ...prefixArgs].some((p) => path.isAbsolute(p)) };
32
+ return { command, prefixArgs, portable: ![command, ...prefixArgs].some(isMachineBoundPath) };
29
33
  }
30
34
  if ((options.hasBinary ?? defaultHasBinary)('nativ'))
31
35
  return { command: 'nativ', prefixArgs: [], portable: true };
@@ -0,0 +1,15 @@
1
+ import type { MasterPlan, MasterPlanTask } from '../scanner/types.js';
2
+ /** A claim with no fresh `task start` for this long is presumed abandoned (crashed or closed agent). */
3
+ export declare const DEFAULT_STALE_HOURS = 4;
4
+ export declare const AGENT_ID_ENV = "NATIV_AGENT_ID";
5
+ export declare function resolveAgentId(explicit?: string, env?: NodeJS.ProcessEnv): string | undefined;
6
+ /** Hours since the task was claimed, or null when the claim time is unknown (claimed before claims were recorded). */
7
+ export declare function claimAgeHours(task: {
8
+ claimedAt?: string;
9
+ }, now?: number): number | null;
10
+ export declare function isStaleClaim(task: {
11
+ status: string;
12
+ claimedAt?: string;
13
+ }, hours?: number, now?: number): boolean;
14
+ /** Why `task start` must not claim this task, or null when it may. */
15
+ export declare function startRefusal(plan: MasterPlan, task: MasterPlanTask, agent: string | undefined, now?: number): string | null;
@@ -0,0 +1,32 @@
1
+ /** A claim with no fresh `task start` for this long is presumed abandoned (crashed or closed agent). */
2
+ export const DEFAULT_STALE_HOURS = 4;
3
+ export const AGENT_ID_ENV = 'NATIV_AGENT_ID';
4
+ export function resolveAgentId(explicit, env = process.env) {
5
+ return (explicit ?? env[AGENT_ID_ENV] ?? '').trim() || undefined;
6
+ }
7
+ /** Hours since the task was claimed, or null when the claim time is unknown (claimed before claims were recorded). */
8
+ export function claimAgeHours(task, now = Date.now()) {
9
+ const at = task.claimedAt ? Date.parse(task.claimedAt) : NaN;
10
+ return Number.isFinite(at) ? Math.max(0, (now - at) / 3_600_000) : null;
11
+ }
12
+ export function isStaleClaim(task, hours = DEFAULT_STALE_HOURS, now = Date.now()) {
13
+ const age = claimAgeHours(task, now);
14
+ return task.status === 'in_progress' && age !== null && age >= hours;
15
+ }
16
+ /** Why `task start` must not claim this task, or null when it may. */
17
+ export function startRefusal(plan, task, agent, now = Date.now()) {
18
+ if (task.status === 'completed')
19
+ return `Task [${task.id}] is already completed.`;
20
+ if (task.status === 'blocked') {
21
+ const why = task.notes ? ` (${task.notes})` : '';
22
+ return `Task [${task.id}] is blocked${why}. Resolve it with \`nativ triage\` first; a human can override with \`nativ task start ${task.id} --force\`.`;
23
+ }
24
+ const done = new Set(plan.milestones.flatMap((m) => m.tasks).filter((t) => t.status === 'completed').map((t) => t.id));
25
+ const waiting = (task.dependencies ?? []).filter((d) => !done.has(d));
26
+ if (waiting.length)
27
+ return `Task [${task.id}] waits on unfinished dependencies: ${waiting.join(', ')}.`;
28
+ if (task.status === 'in_progress' && task.claimedBy && agent && task.claimedBy !== agent && !isStaleClaim(task, DEFAULT_STALE_HOURS, now)) {
29
+ return `Task [${task.id}] is already claimed by ${task.claimedBy} since ${task.claimedAt}. Pick another task with \`nativ task next\`.`;
30
+ }
31
+ return null;
32
+ }
@@ -109,6 +109,7 @@ export declare function hasGeminiApiKey(root?: string): boolean;
109
109
  */
110
110
  export declare function redactSecrets(text: string, extraSecrets?: string[]): string;
111
111
  export declare function loadTriageConfig(root: string): Partial<TriageConfig>;
112
+ /** Merges triage settings into `.nativ/config.json` under its lock. Throws when the file cannot be written. */
112
113
  export declare function saveTriageConfig(root: string, update: Partial<TriageConfig>): void;
113
114
  /**
114
115
  * Tier 1 AI Strategist Liaison instance managing escalation evaluation,
@@ -15,6 +15,8 @@ import path from 'node:path';
15
15
  import { MASK } from '../db/env-parser.js';
16
16
  import { escalationPath, loadEscalationFile } from '../governor/store.js';
17
17
  import { applySelfHealingProposal } from '../governor/index.js';
18
+ import { notifyHuman } from './notify.js';
19
+ import { mutateJsonFileSync } from './lock-manager.js';
18
20
  import { CLAUDE_CLI_DEFAULT_MODEL, completeWithChain, resolveAnthropicApiKey, resolveGeminiApiKey as resolveGeminiKey, resolveProviderChain, } from '../providers/index.js';
19
21
  /** Triage runs often and in the background, so it defaults to the cheapest Claude tier. */
20
22
  export const DEFAULT_MODEL = CLAUDE_CLI_DEFAULT_MODEL;
@@ -115,36 +117,18 @@ export function loadTriageConfig(root) {
115
117
  }
116
118
  return {};
117
119
  }
120
+ /** Merges triage settings into `.nativ/config.json` under its lock. Throws when the file cannot be written. */
118
121
  export function saveTriageConfig(root, update) {
119
- const nativDir = path.join(root, '.nativ');
120
- const configFile = path.join(nativDir, 'config.json');
121
- try {
122
- if (!fs.existsSync(nativDir))
123
- fs.mkdirSync(nativDir, { recursive: true });
124
- let existing = {};
125
- if (fs.existsSync(configFile)) {
126
- try {
127
- existing = JSON.parse(fs.readFileSync(configFile, 'utf8'));
128
- }
129
- catch {
130
- existing = {};
131
- }
132
- }
133
- const currentTriage = (existing.triage && typeof existing.triage === 'object') ? existing.triage : {};
122
+ mutateJsonFileSync(path.join(root, '.nativ', 'config.json'), (config) => {
123
+ const triage = config.triage && typeof config.triage === 'object' ? config.triage : {};
134
124
  if (typeof update.autoTriageEnabled === 'boolean')
135
- currentTriage.autoTriageEnabled = update.autoTriageEnabled;
125
+ triage.autoTriageEnabled = update.autoTriageEnabled;
136
126
  if (update.riskThreshold)
137
- currentTriage.riskThreshold = update.riskThreshold;
127
+ triage.riskThreshold = update.riskThreshold;
138
128
  if (update.model)
139
- currentTriage.model = update.model;
140
- existing.triage = currentTriage;
141
- const tmp = `${configFile}.${process.pid}.${Date.now()}.tmp`;
142
- fs.writeFileSync(tmp, JSON.stringify(existing, null, 2) + '\n', 'utf8');
143
- fs.renameSync(tmp, configFile);
144
- }
145
- catch {
146
- // Ignore write failures gracefully
147
- }
129
+ triage.model = update.model;
130
+ config.triage = triage;
131
+ });
148
132
  }
149
133
  /**
150
134
  * Tier 1 AI Strategist Liaison instance managing escalation evaluation,
@@ -215,7 +199,12 @@ export class Tier1Liaison {
215
199
  if (typeof body.model === 'string' && body.model.trim()) {
216
200
  this.config.model = body.model.trim();
217
201
  }
218
- saveTriageConfig(this.root, this.config);
202
+ try {
203
+ saveTriageConfig(this.root, this.config);
204
+ }
205
+ catch (err) {
206
+ return { ok: false, error: `Could not save .nativ/config.json: ${err instanceof Error ? err.message : String(err)}` };
207
+ }
219
208
  return { ok: true, config: { ...this.config } };
220
209
  }
221
210
  async evaluate(escalationId) {
@@ -349,6 +338,15 @@ Respond with strictly valid JSON:
349
338
  this.stats.autoResolved++;
350
339
  else if (classification === 'REQUIRE_HUMAN_DECISION')
351
340
  this.stats.escalatedToHuman++;
341
+ if (classification === 'REQUIRE_HUMAN_DECISION' && !autoPatchApplied) {
342
+ await notifyHuman(this.root, {
343
+ event: 'human_decision',
344
+ taskId,
345
+ escalationId,
346
+ summary: record.summary || 'A decision is needed',
347
+ ...(humanCard ? { question: humanCard.symptom, options: humanCard.options.map((o) => o.label) } : {}),
348
+ }).catch(() => { });
349
+ }
352
350
  const latencyMs = Date.now() - startTime;
353
351
  return {
354
352
  ok: true,
@@ -0,0 +1,19 @@
1
+ import type { VerificationResult } from './types.js';
2
+ /** Lines of each output stream an agent sees for a failed check; the rest goes to the log file. */
3
+ export declare const DIGEST_MAX_LINES = 40;
4
+ /**
5
+ * Picks at most `maxLines` lines from a long test run, in their original order.
6
+ * Only called when `lines.length > maxLines`.
7
+ */
8
+ export declare function digestLines(lines: string[], maxLines: number): string[];
9
+ /** Short enough to keep in an agent's context; says how much was left out. */
10
+ export declare function digestOutput(text: string, maxLines?: number): string;
11
+ /** Writes the whole run to .nativ/logs/ and returns its project-relative path, or null if it cannot. */
12
+ export declare function saveFullLog(root: string, taskId: string, result: VerificationResult): string | null;
13
+ export interface FailureDigest {
14
+ stderr: string;
15
+ stdout: string;
16
+ logFile: string | null;
17
+ }
18
+ /** What a failed check shows an agent: a digest of each non-empty stream and where the full log is. */
19
+ export declare function digestFailure(root: string, taskId: string, result: VerificationResult): FailureDigest;
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Lines of each output stream an agent sees for a failed check; the rest goes to the log file. */
4
+ export const DIGEST_MAX_LINES = 40;
5
+ /**
6
+ * Picks at most `maxLines` lines from a long test run, in their original order.
7
+ * Only called when `lines.length > maxLines`.
8
+ */
9
+ export function digestLines(lines, maxLines) {
10
+ // TODO(human): choose which lines an agent needs to fix the failure.
11
+ return lines.slice(-maxLines);
12
+ }
13
+ /** Short enough to keep in an agent's context; says how much was left out. */
14
+ export function digestOutput(text, maxLines = DIGEST_MAX_LINES) {
15
+ const lines = text.trim().split(/\r?\n/);
16
+ if (lines.length <= maxLines)
17
+ return lines.join('\n');
18
+ const kept = digestLines(lines, maxLines).slice(0, maxLines);
19
+ return `${kept.join('\n')}\n[... ${lines.length - kept.length} of ${lines.length} lines left out ...]`;
20
+ }
21
+ /** Writes the whole run to .nativ/logs/ and returns its project-relative path, or null if it cannot. */
22
+ export function saveFullLog(root, taskId, result) {
23
+ try {
24
+ const dir = path.join(root, '.nativ', 'logs');
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ // Logs can quote test data; keep them out of commits whatever the project's own ignore rules say.
27
+ const ignore = path.join(dir, '.gitignore');
28
+ if (!fs.existsSync(ignore))
29
+ fs.writeFileSync(ignore, '*\n');
30
+ const file = path.join(dir, `verify-${taskId.replace(/[^\w.-]/g, '_')}.log`);
31
+ const body = [`$ ${result.command}`, '--- stdout ---', result.stdout, '--- stderr ---', result.stderr];
32
+ if (result.error)
33
+ body.push('--- error ---', result.error);
34
+ fs.writeFileSync(file, body.join('\n'));
35
+ return path.relative(root, file).split(path.sep).join('/');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** What a failed check shows an agent: a digest of each non-empty stream and where the full log is. */
42
+ export function digestFailure(root, taskId, result) {
43
+ return {
44
+ stderr: result.stderr?.trim() ? digestOutput(result.stderr) : '',
45
+ stdout: result.stdout?.trim() ? digestOutput(result.stdout) : '',
46
+ logFile: saveFullLog(root, taskId, result),
47
+ };
48
+ }
@@ -1,4 +1,5 @@
1
1
  import { detectEngine, maskConnectionString, MASK, parseFirestoreUrl, sqlitePathFromUrl } from './env-parser.js';
2
+ import { loadSqlite } from './sqlite.js';
2
3
  /** Validates entity and identifier names to guard against SQL injection in DDL/identifiers. */
3
4
  function assertValidIdentifier(name, label = 'Identifier') {
4
5
  if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(name.trim())) {
@@ -83,16 +84,16 @@ async function postgresFetch(url, entity, options) {
83
84
  await client.connect();
84
85
  try {
85
86
  // 1. Detect primary key
86
- const pkRes = await client.query(`SELECT kcu.column_name
87
- FROM information_schema.table_constraints tc
88
- JOIN information_schema.key_column_usage kcu
89
- ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
90
- WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
87
+ const pkRes = await client.query(`SELECT kcu.column_name
88
+ FROM information_schema.table_constraints tc
89
+ JOIN information_schema.key_column_usage kcu
90
+ ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
91
+ WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
91
92
  LIMIT 1`, [entity]);
92
93
  const primaryKey = pkRes.rows[0]?.column_name ?? 'id';
93
94
  // 2. Detect searchable text columns
94
- const colRes = await client.query(`SELECT column_name, data_type
95
- FROM information_schema.columns
95
+ const colRes = await client.query(`SELECT column_name, data_type
96
+ FROM information_schema.columns
96
97
  WHERE table_schema = current_schema() AND table_name = $1`, [entity]);
97
98
  const allCols = colRes.rows.map((r) => r.column_name);
98
99
  // 3. Build search clause
@@ -171,11 +172,11 @@ async function postgresUpdate(url, entity, primaryKey, updates) {
171
172
  const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
172
173
  await client.connect();
173
174
  try {
174
- const pkRes = await client.query(`SELECT kcu.column_name
175
- FROM information_schema.table_constraints tc
176
- JOIN information_schema.key_column_usage kcu
177
- ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
178
- WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
175
+ const pkRes = await client.query(`SELECT kcu.column_name
176
+ FROM information_schema.table_constraints tc
177
+ JOIN information_schema.key_column_usage kcu
178
+ ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
179
+ WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
179
180
  LIMIT 1`, [entity]);
180
181
  const pkCol = pkRes.rows[0]?.column_name ?? 'id';
181
182
  const keys = Object.keys(updates).filter((k) => k !== pkCol);
@@ -204,11 +205,11 @@ async function postgresDelete(url, entity, primaryKey) {
204
205
  const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 8000, statement_timeout: 15000 });
205
206
  await client.connect();
206
207
  try {
207
- const pkRes = await client.query(`SELECT kcu.column_name
208
- FROM information_schema.table_constraints tc
209
- JOIN information_schema.key_column_usage kcu
210
- ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
211
- WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
208
+ const pkRes = await client.query(`SELECT kcu.column_name
209
+ FROM information_schema.table_constraints tc
210
+ JOIN information_schema.key_column_usage kcu
211
+ ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
212
+ WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1
212
213
  LIMIT 1`, [entity]);
213
214
  const pkCol = pkRes.rows[0]?.column_name ?? 'id';
214
215
  const query = `DELETE FROM "${entity.replace(/"/g, '""')}" WHERE "${pkCol.replace(/"/g, '""')}" = $1`;
@@ -230,15 +231,15 @@ async function mysqlFetch(url, entity, options) {
230
231
  try {
231
232
  // 1. Detect primary key
232
233
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
- const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
234
- FROM information_schema.KEY_COLUMN_USAGE
235
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
234
+ const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
235
+ FROM information_schema.KEY_COLUMN_USAGE
236
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
236
237
  LIMIT 1`, [entity]));
237
238
  const primaryKey = pkRows[0]?.COLUMN_NAME ?? 'id';
238
239
  // 2. Detect column names
239
240
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
- const [colRows] = (await conn.query(`SELECT COLUMN_NAME
241
- FROM information_schema.COLUMNS
241
+ const [colRows] = (await conn.query(`SELECT COLUMN_NAME
242
+ FROM information_schema.COLUMNS
242
243
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [entity]));
243
244
  const allCols = colRows.map((r) => r.COLUMN_NAME);
244
245
  // 3. Search clause
@@ -309,9 +310,9 @@ async function mysqlUpdate(url, entity, primaryKey, updates) {
309
310
  const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
310
311
  try {
311
312
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
- const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
313
- FROM information_schema.KEY_COLUMN_USAGE
314
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
313
+ const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
314
+ FROM information_schema.KEY_COLUMN_USAGE
315
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
315
316
  LIMIT 1`, [entity]));
316
317
  const pkCol = pkRows[0]?.COLUMN_NAME ?? 'id';
317
318
  const keys = Object.keys(updates).filter((k) => k !== pkCol);
@@ -340,9 +341,9 @@ async function mysqlDelete(url, entity, primaryKey) {
340
341
  const conn = await mysql.createConnection({ uri: url, connectTimeout: 8000 });
341
342
  try {
342
343
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
343
- const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
344
- FROM information_schema.KEY_COLUMN_USAGE
345
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
344
+ const [pkRows] = (await conn.query(`SELECT COLUMN_NAME
345
+ FROM information_schema.KEY_COLUMN_USAGE
346
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
346
347
  LIMIT 1`, [entity]));
347
348
  const pkCol = pkRows[0]?.COLUMN_NAME ?? 'id';
348
349
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -359,7 +360,7 @@ async function mysqlDelete(url, entity, primaryKey) {
359
360
  // ─── SQLite Driver (node:sqlite) ──────────────────────────────────────────────
360
361
  async function sqliteFetch(url, entity, options) {
361
362
  assertValidIdentifier(entity, 'table name');
362
- const { DatabaseSync } = await import('node:sqlite');
363
+ const { DatabaseSync } = await loadSqlite();
363
364
  const filePath = sqlitePathFromUrl(url);
364
365
  const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath, { readOnly: true });
365
366
  try {
@@ -408,7 +409,7 @@ async function sqliteFetch(url, entity, options) {
408
409
  }
409
410
  async function sqliteInsert(url, entity, record) {
410
411
  assertValidIdentifier(entity, 'table name');
411
- const { DatabaseSync } = await import('node:sqlite');
412
+ const { DatabaseSync } = await loadSqlite();
412
413
  const filePath = sqlitePathFromUrl(url);
413
414
  const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
414
415
  try {
@@ -452,7 +453,7 @@ async function sqliteInsert(url, entity, record) {
452
453
  }
453
454
  async function sqliteUpdate(url, entity, primaryKey, updates) {
454
455
  assertValidIdentifier(entity, 'table name');
455
- const { DatabaseSync } = await import('node:sqlite');
456
+ const { DatabaseSync } = await loadSqlite();
456
457
  const filePath = sqlitePathFromUrl(url);
457
458
  const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
458
459
  try {
@@ -482,7 +483,7 @@ async function sqliteUpdate(url, entity, primaryKey, updates) {
482
483
  }
483
484
  async function sqliteDelete(url, entity, primaryKey) {
484
485
  assertValidIdentifier(entity, 'table name');
485
- const { DatabaseSync } = await import('node:sqlite');
486
+ const { DatabaseSync } = await loadSqlite();
486
487
  const filePath = sqlitePathFromUrl(url);
487
488
  const db = filePath === ':memory:' ? new DatabaseSync(':memory:') : new DatabaseSync(filePath);
488
489
  try {