@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.
- package/CHANGELOG.md +38 -0
- package/README.md +29 -4
- package/dist/commands/init.js +2 -10
- package/dist/commands/notify.d.ts +2 -0
- package/dist/commands/notify.js +25 -0
- package/dist/commands/session.d.ts +22 -0
- package/dist/commands/session.js +106 -0
- package/dist/commands/task/task-add.js +1 -1
- package/dist/commands/task/task-common.js +6 -6
- package/dist/commands/task/task-escalate.js +8 -10
- package/dist/commands/task/task-lifecycle.d.ts +7 -1
- package/dist/commands/task/task-lifecycle.js +30 -5
- package/dist/commands/task/task-query.d.ts +2 -0
- package/dist/commands/task/task-query.js +27 -12
- package/dist/commands/task/task-reclaim.d.ts +20 -0
- package/dist/commands/task/task-reclaim.js +87 -0
- package/dist/commands/task.d.ts +2 -0
- package/dist/commands/task.js +1 -0
- package/dist/commands/triage.js +3 -3
- package/dist/commands/validate.js +1 -0
- package/dist/commands/verify.js +21 -5
- package/dist/core/enforcement.js +18 -17
- package/dist/core/lock-manager.d.ts +6 -0
- package/dist/core/lock-manager.js +50 -41
- package/dist/core/notify.d.ts +38 -0
- package/dist/core/notify.js +133 -0
- package/dist/core/profiles.js +7 -16
- package/dist/core/session-memory.d.ts +42 -0
- package/dist/core/session-memory.js +121 -0
- package/dist/core/setup/pm-playbook.d.ts +5 -0
- package/dist/core/setup/pm-playbook.js +26 -0
- package/dist/core/setup/session-context.js +35 -2
- package/dist/core/setup/setup-planner.js +2 -2
- package/dist/core/setup/types.d.ts +2 -0
- package/dist/core/setup/types.js +5 -1
- package/dist/core/task-claims.d.ts +15 -0
- package/dist/core/task-claims.js +32 -0
- package/dist/core/tier1-liaison.d.ts +1 -0
- package/dist/core/tier1-liaison.js +25 -27
- package/dist/core/verifier/output-digest.d.ts +19 -0
- package/dist/core/verifier/output-digest.js +48 -0
- package/dist/db/data-engine.js +33 -32
- package/dist/db/introspector.js +61 -60
- package/dist/db/sqlite.d.ts +3 -0
- package/dist/db/sqlite.js +10 -0
- package/dist/governor/circuit-breaker.js +12 -0
- package/dist/governor/patch-applier.d.ts +1 -0
- package/dist/governor/patch-applier.js +3 -7
- package/dist/index.js +29 -6
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +53 -14
- package/dist/providers/claude-api.js +3 -0
- package/dist/providers/gemini.js +4 -1
- package/dist/providers/registry.d.ts +6 -2
- package/dist/providers/registry.js +28 -16
- package/dist/providers/types.d.ts +4 -1
- package/dist/runner/agent-supervisor.js +2 -2
- package/dist/runner/cli-config.js +1 -1
- package/dist/scanner/types.d.ts +5 -1
- package/dist/scanner/types.js +7 -0
- package/dist/server/routes/task-routes.d.ts +2 -0
- package/dist/server/routes/triage-routes.js +3 -18
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +1 -1
- package/plugin/agents/explorer.md +1 -1
- package/plugin/agents/verifier.md +2 -2
- package/plugin/agents/worker.md +5 -7
- package/plugin/skills/nativ/SKILL.md +36 -49
- package/templates/AGENTS.md +36 -49
- package/templates/CLAUDE.md +1 -41
- package/templates/claude-agents/architect.md +1 -1
- package/templates/claude-agents/explorer.md +1 -1
- package/templates/claude-agents/verifier.md +2 -2
- package/templates/claude-agents/worker.md +5 -7
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/templates/dot-ai/subagents/architect.md +54 -0
|
@@ -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
|
-
'
|
|
40
|
-
`Session role: ${role}. Enforcement: ${loadEnforcementMode(root)}.
|
|
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(
|
|
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. */
|
package/dist/core/setup/types.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
+
triage.autoTriageEnabled = update.autoTriageEnabled;
|
|
136
126
|
if (update.riskThreshold)
|
|
137
|
-
|
|
127
|
+
triage.riskThreshold = update.riskThreshold;
|
|
138
128
|
if (update.model)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/db/data-engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 {
|