@njeybe/nativ 2.0.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Role enforcement core.
|
|
3
|
+
*
|
|
4
|
+
* Decides whether a file write by an agent stays inside its role: the active task's `targetFiles`,
|
|
5
|
+
* never a protected contract, never a secret. Claude Code calls this through the `nativ hook check`
|
|
6
|
+
* PreToolUse hook, so the boundary is enforced on the tool call itself, not by prompt text.
|
|
7
|
+
*
|
|
8
|
+
* Modes (`enforcement` in .nativ/config.json): `warn` (default) allows the call and tells the agent,
|
|
9
|
+
* `block` denies it, `off` disables the check. The check is advisory infrastructure: anything it
|
|
10
|
+
* cannot understand is allowed.
|
|
11
|
+
*/
|
|
12
|
+
export type EnforcementMode = 'off' | 'warn' | 'block';
|
|
13
|
+
export type ViolationRule = 'protected_path' | 'secret_path' | 'out_of_scope';
|
|
14
|
+
export type EnforcementDecision = 'allow' | 'warn' | 'deny';
|
|
15
|
+
export declare const DEFAULT_ENFORCEMENT_MODE: EnforcementMode;
|
|
16
|
+
/** Roles other than these are treated as ordinary workers. The architect is the only role that may edit contracts. */
|
|
17
|
+
export declare const ARCHITECT_ROLE = "architect";
|
|
18
|
+
export declare const ROLE_ENV = "NATIV_ROLE";
|
|
19
|
+
/** Parses JSON that may start with a byte-order mark, which Windows PowerShell and many editors write. */
|
|
20
|
+
export declare function parseJsonLoose<T = unknown>(text: string): T;
|
|
21
|
+
export interface WriteCheckInput {
|
|
22
|
+
/** Path the tool wants to write, absolute or relative to `cwd`. */
|
|
23
|
+
filePath: string;
|
|
24
|
+
toolName: string;
|
|
25
|
+
/** Working directory of the agent; decides the project root and, in a worktree, which task it belongs to. */
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** `architect` may edit `.ai/` contracts; everything else may not. Defaults to NATIV_ROLE. */
|
|
28
|
+
role?: string;
|
|
29
|
+
/** Overrides the configured mode (tests and `nativ hook check --mode`). */
|
|
30
|
+
mode?: EnforcementMode;
|
|
31
|
+
}
|
|
32
|
+
export interface WriteCheckResult {
|
|
33
|
+
decision: EnforcementDecision;
|
|
34
|
+
mode: EnforcementMode;
|
|
35
|
+
rule?: ViolationRule;
|
|
36
|
+
/** Human-readable explanation, addressed to the agent. */
|
|
37
|
+
reason?: string;
|
|
38
|
+
root: string;
|
|
39
|
+
/** Path relative to the project root, forward slashes; null when the file is outside the project. */
|
|
40
|
+
relPath: string | null;
|
|
41
|
+
/** In-progress task(s) the write was judged against. */
|
|
42
|
+
taskIds: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function loadEnforcementMode(root: string): EnforcementMode;
|
|
45
|
+
export declare function isTaskUnlocked(root: string, taskId: string): boolean;
|
|
46
|
+
/** Lifts scope enforcement for one task until it is revoked or the task stops being in progress. */
|
|
47
|
+
export declare function addTaskUnlock(root: string, taskId: string, reason?: string): void;
|
|
48
|
+
export declare function removeTaskUnlock(root: string, taskId: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Path relative to the project root with forward slashes. A path inside `.worktrees/task-<id>/` maps
|
|
51
|
+
* to the same relative path in the main tree, so a worktree agent is judged by the same target files.
|
|
52
|
+
* Returns null for paths outside the project.
|
|
53
|
+
*/
|
|
54
|
+
export declare function toProjectRelative(root: string, filePath: string, cwd: string): string | null;
|
|
55
|
+
/** A target entry matches an exact file, a directory prefix (`src/x` or `src/x/`), or a glob. */
|
|
56
|
+
export declare function matchesTarget(relPath: string, target: string): boolean;
|
|
57
|
+
export declare function isSecretPath(relPath: string): boolean;
|
|
58
|
+
/** Everything under `.ai/` is written by nativ commands or the architect, never by a worker's file tools. */
|
|
59
|
+
export declare function isProtectedContractPath(relPath: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Judges one file write. Never throws: unreadable state means "allow".
|
|
62
|
+
* Order: secrets (everyone), `.ai/` contracts (non-architects), then the active task's target files.
|
|
63
|
+
*/
|
|
64
|
+
export declare function checkWrite(input: WriteCheckInput): WriteCheckResult;
|
|
65
|
+
export interface HookCheck {
|
|
66
|
+
/** JSON to print on stdout, or null when there is nothing to say. */
|
|
67
|
+
output: Record<string, unknown> | null;
|
|
68
|
+
result: WriteCheckResult | null;
|
|
69
|
+
}
|
|
70
|
+
/** Reads the path a write tool targets. Real payloads use `file_path` (`notebook_path` for notebooks); `path` is tolerated. */
|
|
71
|
+
export declare function extractWritePath(toolInput: unknown): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Turns a PreToolUse payload into the hook's answer. Warn adds context for the model; block denies the call.
|
|
74
|
+
* Malformed or unrecognized payloads yield no output, which Claude Code treats as "no opinion".
|
|
75
|
+
*/
|
|
76
|
+
export declare function evaluateHookPayload(payload: unknown, options?: {
|
|
77
|
+
mode?: EnforcementMode;
|
|
78
|
+
role?: string;
|
|
79
|
+
fallbackCwd?: string;
|
|
80
|
+
}): HookCheck;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Role enforcement core.
|
|
3
|
+
*
|
|
4
|
+
* Decides whether a file write by an agent stays inside its role: the active task's `targetFiles`,
|
|
5
|
+
* never a protected contract, never a secret. Claude Code calls this through the `nativ hook check`
|
|
6
|
+
* PreToolUse hook, so the boundary is enforced on the tool call itself, not by prompt text.
|
|
7
|
+
*
|
|
8
|
+
* Modes (`enforcement` in .nativ/config.json): `warn` (default) allows the call and tells the agent,
|
|
9
|
+
* `block` denies it, `off` disables the check. The check is advisory infrastructure: anything it
|
|
10
|
+
* cannot understand is allowed.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { EXAMPLE_ENV_FILES } from '../db/env-parser.js';
|
|
15
|
+
import { resolveProjectRoot } from './root-resolver.js';
|
|
16
|
+
export const DEFAULT_ENFORCEMENT_MODE = 'warn';
|
|
17
|
+
/** Roles other than these are treated as ordinary workers. The architect is the only role that may edit contracts. */
|
|
18
|
+
export const ARCHITECT_ROLE = 'architect';
|
|
19
|
+
export const ROLE_ENV = 'NATIV_ROLE';
|
|
20
|
+
/**
|
|
21
|
+
* Agents that ship in a plugin report agent_type as `<plugin>:<agent>`, so nativ's own plugin architect arrives
|
|
22
|
+
* as `nativ:architect`. Exactly these two names count. Stripping arbitrary prefixes would hand contract access
|
|
23
|
+
* to any other plugin's `x:architect`.
|
|
24
|
+
*/
|
|
25
|
+
const ARCHITECT_ROLES = new Set([ARCHITECT_ROLE, `nativ:${ARCHITECT_ROLE}`]);
|
|
26
|
+
/** Parses JSON that may start with a byte-order mark, which Windows PowerShell and many editors write. */
|
|
27
|
+
export function parseJsonLoose(text) {
|
|
28
|
+
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
29
|
+
}
|
|
30
|
+
const CONFIG_FILE = path.join('.nativ', 'config.json');
|
|
31
|
+
const UNLOCK_FILE = path.join('.nativ', 'unlocks.json');
|
|
32
|
+
// ─── Config and unlocks ────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
export function loadEnforcementMode(root) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = parseJsonLoose(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
|
|
36
|
+
if (raw.enforcement === 'off' || raw.enforcement === 'warn' || raw.enforcement === 'block')
|
|
37
|
+
return raw.enforcement;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Missing or corrupt config means the default.
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_ENFORCEMENT_MODE;
|
|
43
|
+
}
|
|
44
|
+
function readUnlocks(root) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = parseJsonLoose(fs.readFileSync(path.join(root, UNLOCK_FILE), 'utf8'));
|
|
47
|
+
if (raw && Array.isArray(raw.unlocks))
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Fall through to empty.
|
|
52
|
+
}
|
|
53
|
+
return { unlocks: [] };
|
|
54
|
+
}
|
|
55
|
+
function writeUnlocks(root, data) {
|
|
56
|
+
const file = path.join(root, UNLOCK_FILE);
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
59
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
60
|
+
fs.renameSync(tmp, file);
|
|
61
|
+
}
|
|
62
|
+
export function isTaskUnlocked(root, taskId) {
|
|
63
|
+
return readUnlocks(root).unlocks.some((u) => u.taskId === taskId);
|
|
64
|
+
}
|
|
65
|
+
/** Lifts scope enforcement for one task until it is revoked or the task stops being in progress. */
|
|
66
|
+
export function addTaskUnlock(root, taskId, reason) {
|
|
67
|
+
const data = readUnlocks(root);
|
|
68
|
+
if (data.unlocks.some((u) => u.taskId === taskId))
|
|
69
|
+
return;
|
|
70
|
+
data.unlocks.push({ taskId, at: new Date().toISOString(), ...(reason ? { reason } : {}) });
|
|
71
|
+
writeUnlocks(root, data);
|
|
72
|
+
}
|
|
73
|
+
export function removeTaskUnlock(root, taskId) {
|
|
74
|
+
const data = readUnlocks(root);
|
|
75
|
+
const next = data.unlocks.filter((u) => u.taskId !== taskId);
|
|
76
|
+
if (next.length === data.unlocks.length)
|
|
77
|
+
return false;
|
|
78
|
+
writeUnlocks(root, { unlocks: next });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
// ─── Path handling ─────────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
|
|
83
|
+
function normalizeSlashes(p) {
|
|
84
|
+
return p.replace(/\\/g, '/');
|
|
85
|
+
}
|
|
86
|
+
function comparable(p) {
|
|
87
|
+
const n = normalizeSlashes(p);
|
|
88
|
+
return CASE_INSENSITIVE ? n.toLowerCase() : n;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Path relative to the project root with forward slashes. A path inside `.worktrees/task-<id>/` maps
|
|
92
|
+
* to the same relative path in the main tree, so a worktree agent is judged by the same target files.
|
|
93
|
+
* Returns null for paths outside the project.
|
|
94
|
+
*/
|
|
95
|
+
export function toProjectRelative(root, filePath, cwd) {
|
|
96
|
+
const absolute = path.resolve(cwd, filePath);
|
|
97
|
+
let rel = path.relative(path.resolve(root), absolute);
|
|
98
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
99
|
+
return null;
|
|
100
|
+
rel = normalizeSlashes(rel);
|
|
101
|
+
const worktree = /^\.worktrees\/[^/]+\/(.+)$/.exec(rel);
|
|
102
|
+
return worktree ? worktree[1] : rel;
|
|
103
|
+
}
|
|
104
|
+
/** The task a working directory belongs to when it is inside `.worktrees/task-<id>`, else null. */
|
|
105
|
+
function worktreeTaskId(root, cwd) {
|
|
106
|
+
const rel = normalizeSlashes(path.relative(path.resolve(root), path.resolve(cwd)));
|
|
107
|
+
const match = /^\.worktrees\/task-([^/]+)/.exec(rel);
|
|
108
|
+
return match ? match[1] : null;
|
|
109
|
+
}
|
|
110
|
+
function globToRegExp(glob) {
|
|
111
|
+
let out = '';
|
|
112
|
+
for (let i = 0; i < glob.length; i++) {
|
|
113
|
+
const c = glob[i];
|
|
114
|
+
if (c === '*') {
|
|
115
|
+
if (glob[i + 1] === '*') {
|
|
116
|
+
out += '.*';
|
|
117
|
+
i++;
|
|
118
|
+
if (glob[i + 1] === '/')
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
out += '[^/]*';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (c === '?')
|
|
126
|
+
out += '[^/]';
|
|
127
|
+
else
|
|
128
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
129
|
+
}
|
|
130
|
+
return new RegExp(`^${out}$`, CASE_INSENSITIVE ? 'i' : '');
|
|
131
|
+
}
|
|
132
|
+
/** A target entry matches an exact file, a directory prefix (`src/x` or `src/x/`), or a glob. */
|
|
133
|
+
export function matchesTarget(relPath, target) {
|
|
134
|
+
const entry = normalizeSlashes(target.trim()).replace(/^\.\//, '');
|
|
135
|
+
if (!entry)
|
|
136
|
+
return false;
|
|
137
|
+
const file = comparable(relPath);
|
|
138
|
+
if (/[*?]/.test(entry))
|
|
139
|
+
return globToRegExp(entry).test(relPath);
|
|
140
|
+
const dir = comparable(entry.replace(/\/$/, ''));
|
|
141
|
+
return file === dir || (entry.endsWith('/') && file.startsWith(`${dir}/`));
|
|
142
|
+
}
|
|
143
|
+
// ─── Rules ─────────────────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
const EXAMPLE_ENV = new Set(EXAMPLE_ENV_FILES.map((f) => f.toLowerCase()));
|
|
145
|
+
export function isSecretPath(relPath) {
|
|
146
|
+
const lower = relPath.toLowerCase();
|
|
147
|
+
const base = lower.split('/').pop() ?? lower;
|
|
148
|
+
if (base === '.env' || (base.startsWith('.env.') && !EXAMPLE_ENV.has(base)))
|
|
149
|
+
return true;
|
|
150
|
+
if (/^\.(nativ|agentj)\/[^/]*\.local\.json$/.test(lower))
|
|
151
|
+
return true;
|
|
152
|
+
return base.endsWith('.pem') || base.endsWith('.key');
|
|
153
|
+
}
|
|
154
|
+
/** Everything under `.ai/` is written by nativ commands or the architect, never by a worker's file tools. */
|
|
155
|
+
export function isProtectedContractPath(relPath) {
|
|
156
|
+
return relPath === '.ai' || relPath.toLowerCase().startsWith('.ai/');
|
|
157
|
+
}
|
|
158
|
+
function loadInProgressTasks(root) {
|
|
159
|
+
try {
|
|
160
|
+
const plan = parseJsonLoose(fs.readFileSync(path.join(root, '.ai', 'master_plan.json'), 'utf8'));
|
|
161
|
+
const tasks = [];
|
|
162
|
+
for (const milestone of plan.milestones ?? []) {
|
|
163
|
+
for (const task of milestone.tasks ?? []) {
|
|
164
|
+
if (typeof task.id === 'string' && task.status === 'in_progress') {
|
|
165
|
+
tasks.push({
|
|
166
|
+
id: task.id,
|
|
167
|
+
status: task.status,
|
|
168
|
+
targetFiles: Array.isArray(task.targetFiles) ? task.targetFiles.filter((f) => typeof f === 'string') : [],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return tasks;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function verdict(mode, root, relPath, taskIds, rule, reason) {
|
|
180
|
+
return { decision: mode === 'block' ? 'deny' : 'warn', mode, rule, reason, root, relPath, taskIds };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Judges one file write. Never throws: unreadable state means "allow".
|
|
184
|
+
* Order: secrets (everyone), `.ai/` contracts (non-architects), then the active task's target files.
|
|
185
|
+
*/
|
|
186
|
+
export function checkWrite(input) {
|
|
187
|
+
const root = resolveProjectRoot(input.cwd);
|
|
188
|
+
const mode = input.mode ?? loadEnforcementMode(root);
|
|
189
|
+
const relPath = toProjectRelative(root, input.filePath, input.cwd);
|
|
190
|
+
const allow = { decision: 'allow', mode, root, relPath, taskIds: [] };
|
|
191
|
+
if (mode === 'off' || relPath === null)
|
|
192
|
+
return allow;
|
|
193
|
+
if (isSecretPath(relPath)) {
|
|
194
|
+
return verdict(mode, root, relPath, [], 'secret_path', `${relPath} holds credentials. Agents must not write secret files; ask the user to edit it or use \`nativ db\` for structure-only access.`);
|
|
195
|
+
}
|
|
196
|
+
const role = (input.role ?? process.env[ROLE_ENV] ?? '').toLowerCase();
|
|
197
|
+
const isArchitect = ARCHITECT_ROLES.has(role);
|
|
198
|
+
if (isProtectedContractPath(relPath)) {
|
|
199
|
+
if (isArchitect)
|
|
200
|
+
return allow;
|
|
201
|
+
return verdict(mode, root, relPath, [], 'protected_path', `${relPath} is a protected contract. Only the architect role edits .ai/. Propose a change with \`nativ task propose-patch\` or escalate with \`nativ task escalate <taskId> --type schema_flaw --details "..."\`.`);
|
|
202
|
+
}
|
|
203
|
+
if (isArchitect)
|
|
204
|
+
return allow;
|
|
205
|
+
let tasks = loadInProgressTasks(root);
|
|
206
|
+
if (!tasks.length)
|
|
207
|
+
return allow;
|
|
208
|
+
const fromWorktree = worktreeTaskId(root, input.cwd);
|
|
209
|
+
if (fromWorktree) {
|
|
210
|
+
const own = tasks.filter((t) => t.id === fromWorktree);
|
|
211
|
+
if (own.length)
|
|
212
|
+
tasks = own;
|
|
213
|
+
}
|
|
214
|
+
const taskIds = tasks.map((t) => t.id);
|
|
215
|
+
if (tasks.some((t) => isTaskUnlocked(root, t.id)))
|
|
216
|
+
return { ...allow, taskIds };
|
|
217
|
+
if (tasks.some((t) => t.targetFiles.some((target) => matchesTarget(relPath, target))))
|
|
218
|
+
return { ...allow, taskIds };
|
|
219
|
+
const label = taskIds.length === 1 ? `task ${taskIds[0]}` : `tasks ${taskIds.join(', ')}`;
|
|
220
|
+
const scope = tasks.flatMap((t) => t.targetFiles);
|
|
221
|
+
return verdict(mode, root, relPath, taskIds, 'out_of_scope', `${relPath} is outside the target files of ${label}` +
|
|
222
|
+
`${scope.length ? ` (${scope.join(', ')})` : ''}. ` +
|
|
223
|
+
'Stay inside the task scope. If this file really needs to change, run `nativ task escalate <taskId> --type architectural_ambiguity --details "..."` instead of editing it.');
|
|
224
|
+
}
|
|
225
|
+
// ─── Hook I/O ──────────────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
/** Tools whose input names the file they write, per the payloads Claude Code delivers to PreToolUse. */
|
|
227
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
228
|
+
/** Reads the path a write tool targets. Real payloads use `file_path` (`notebook_path` for notebooks); `path` is tolerated. */
|
|
229
|
+
export function extractWritePath(toolInput) {
|
|
230
|
+
if (!toolInput || typeof toolInput !== 'object')
|
|
231
|
+
return null;
|
|
232
|
+
const input = toolInput;
|
|
233
|
+
for (const key of ['file_path', 'notebook_path', 'path']) {
|
|
234
|
+
const value = input[key];
|
|
235
|
+
if (typeof value === 'string' && value.trim())
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Turns a PreToolUse payload into the hook's answer. Warn adds context for the model; block denies the call.
|
|
242
|
+
* Malformed or unrecognized payloads yield no output, which Claude Code treats as "no opinion".
|
|
243
|
+
*/
|
|
244
|
+
export function evaluateHookPayload(payload, options = {}) {
|
|
245
|
+
if (!payload || typeof payload !== 'object')
|
|
246
|
+
return { output: null, result: null };
|
|
247
|
+
const p = payload;
|
|
248
|
+
if (typeof p.tool_name !== 'string' || !WRITE_TOOLS.has(p.tool_name))
|
|
249
|
+
return { output: null, result: null };
|
|
250
|
+
const filePath = extractWritePath(p.tool_input);
|
|
251
|
+
if (!filePath)
|
|
252
|
+
return { output: null, result: null };
|
|
253
|
+
const cwd = typeof p.cwd === 'string' && p.cwd ? p.cwd : options.fallbackCwd ?? process.cwd();
|
|
254
|
+
// Inside a subagent Claude Code adds agent_type (the agent's name) to the payload; the main session has none.
|
|
255
|
+
// That identity outranks NATIV_ROLE, so a worker spawned from an architect session is still judged as a worker.
|
|
256
|
+
const agentType = typeof p.agent_type === 'string' && p.agent_type.trim() ? p.agent_type.trim() : undefined;
|
|
257
|
+
const result = checkWrite({ filePath, toolName: p.tool_name, cwd, role: options.role ?? agentType, mode: options.mode });
|
|
258
|
+
if (result.decision === 'allow')
|
|
259
|
+
return { output: null, result };
|
|
260
|
+
const reason = `nativ role enforcement: ${result.reason}`;
|
|
261
|
+
if (result.decision === 'deny') {
|
|
262
|
+
return {
|
|
263
|
+
output: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } },
|
|
264
|
+
result,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
output: { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `${reason} (warning only; enforcement is in warn mode)` } },
|
|
269
|
+
result,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Fingerprints of directive and role-guide templates that shipped before files carried a
|
|
3
|
+
* managed marker.
|
|
4
|
+
*
|
|
5
|
+
* `nativ update` refreshes a file nativ wrote earlier, and keeps one a person edited. Files written since the
|
|
6
|
+
* marker was introduced say so themselves (see stampManaged in setup-assets.ts). Older copies carry no marker,
|
|
7
|
+
* so they are recognised here instead: a file whose normalised content matches a template nativ once shipped is
|
|
8
|
+
* untouched and safe to refresh; anything else was edited and is kept.
|
|
9
|
+
*
|
|
10
|
+
* GENERATED from the git history of templates/CLAUDE.md, templates/GEMINI.md and templates/dot-ai/subagents/*.md
|
|
11
|
+
* (as of commit 327b146). Only add hashes for versions that shipped before markers. To regenerate, for each file:
|
|
12
|
+
*
|
|
13
|
+
* git log --format=%H -- <template path> # every commit that touched it
|
|
14
|
+
* git show <commit>:<template path> # the content at that commit
|
|
15
|
+
*
|
|
16
|
+
* then hash each with `legacyTemplateHash` (sha256 of the normalised text, first 16 hex characters).
|
|
17
|
+
*/
|
|
18
|
+
/** Line endings, a BOM and trailing whitespace differ between checkouts and editors; none of them is an edit. */
|
|
19
|
+
export declare function normalizeForHash(text: string): string;
|
|
20
|
+
export declare function legacyTemplateHash(text: string): string;
|
|
21
|
+
/** Keyed by `CLAUDE.md`, `GEMINI.md` or `subagents/<file>`. */
|
|
22
|
+
export declare const LEGACY_TEMPLATE_HASHES: Readonly<Record<string, readonly string[]>>;
|
|
23
|
+
/** True when `content` is, apart from line endings and trailing whitespace, a template nativ shipped before markers. */
|
|
24
|
+
export declare function isLegacyTemplate(key: string, content: string): boolean;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Fingerprints of directive and role-guide templates that shipped before files carried a
|
|
3
|
+
* managed marker.
|
|
4
|
+
*
|
|
5
|
+
* `nativ update` refreshes a file nativ wrote earlier, and keeps one a person edited. Files written since the
|
|
6
|
+
* marker was introduced say so themselves (see stampManaged in setup-assets.ts). Older copies carry no marker,
|
|
7
|
+
* so they are recognised here instead: a file whose normalised content matches a template nativ once shipped is
|
|
8
|
+
* untouched and safe to refresh; anything else was edited and is kept.
|
|
9
|
+
*
|
|
10
|
+
* GENERATED from the git history of templates/CLAUDE.md, templates/GEMINI.md and templates/dot-ai/subagents/*.md
|
|
11
|
+
* (as of commit 327b146). Only add hashes for versions that shipped before markers. To regenerate, for each file:
|
|
12
|
+
*
|
|
13
|
+
* git log --format=%H -- <template path> # every commit that touched it
|
|
14
|
+
* git show <commit>:<template path> # the content at that commit
|
|
15
|
+
*
|
|
16
|
+
* then hash each with `legacyTemplateHash` (sha256 of the normalised text, first 16 hex characters).
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
/** Line endings, a BOM and trailing whitespace differ between checkouts and editors; none of them is an edit. */
|
|
20
|
+
export function normalizeForHash(text) {
|
|
21
|
+
return text.replace(/^/, '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
|
22
|
+
}
|
|
23
|
+
export function legacyTemplateHash(text) {
|
|
24
|
+
return crypto.createHash('sha256').update(normalizeForHash(text)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
/** Keyed by `CLAUDE.md`, `GEMINI.md` or `subagents/<file>`. */
|
|
27
|
+
export const LEGACY_TEMPLATE_HASHES = {
|
|
28
|
+
'CLAUDE.md': [
|
|
29
|
+
'1522ccdd0a545518',
|
|
30
|
+
'33e4bd039c178fd5',
|
|
31
|
+
'3691651e16065c8e',
|
|
32
|
+
'8c7349cdb74b7eb5',
|
|
33
|
+
'd0b1c84ead870190',
|
|
34
|
+
'd2c4c15cd27f836c',
|
|
35
|
+
'd367e97db352f3bc',
|
|
36
|
+
'd793ae4f685adcd5',
|
|
37
|
+
'f2e7281fde12a1c5',
|
|
38
|
+
],
|
|
39
|
+
'GEMINI.md': [
|
|
40
|
+
'1d4d3812e725ef39',
|
|
41
|
+
'38740208b5ace52d',
|
|
42
|
+
'58fab39c4a3b6d54',
|
|
43
|
+
'7daa75506dfa59d3',
|
|
44
|
+
'b2a909ab7c4247ad',
|
|
45
|
+
'b6d6773bcd93e3f5',
|
|
46
|
+
'be5a2bf2e06166d2',
|
|
47
|
+
],
|
|
48
|
+
'subagents/backend.md': ['e94062c5444507ef'],
|
|
49
|
+
'subagents/database.md': ['d63d9a68aa765738'],
|
|
50
|
+
'subagents/db-migration.md': ['7d9f0e865cf0ea4d'],
|
|
51
|
+
'subagents/devops-agent.md': ['54e72ef616a35eab'],
|
|
52
|
+
'subagents/flutter-developer.md': ['0888a76b3409f903'],
|
|
53
|
+
'subagents/frontend.md': ['01bd98d7028e18fd', 'de29dab2615b95d2'],
|
|
54
|
+
'subagents/qa-tester.md': ['ceb00030e68c3369'],
|
|
55
|
+
'subagents/security-auditor.md': ['1656948f6f454fcb'],
|
|
56
|
+
};
|
|
57
|
+
/** True when `content` is, apart from line endings and trailing whitespace, a template nativ shipped before markers. */
|
|
58
|
+
export function isLegacyTemplate(key, content) {
|
|
59
|
+
return (LEGACY_TEMPLATE_HASHES[key] ?? []).includes(legacyTemplateHash(content));
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { MasterPlan } from '../scanner/types.js';
|
|
2
|
+
export interface LockOptions {
|
|
3
|
+
stale?: number;
|
|
4
|
+
retries?: number | {
|
|
5
|
+
retries?: number;
|
|
6
|
+
factor?: number;
|
|
7
|
+
minTimeout?: number;
|
|
8
|
+
maxTimeout?: number;
|
|
9
|
+
};
|
|
10
|
+
realpath?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const DEFAULT_LOCK_OPTIONS: LockOptions;
|
|
13
|
+
export interface PlanMutationContext {
|
|
14
|
+
abort: () => void;
|
|
15
|
+
isAborted: () => boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Executes an operation holding an advisory lock on a given file.
|
|
19
|
+
* Automatically releases the lock when the operation finishes (even on error).
|
|
20
|
+
*/
|
|
21
|
+
export declare function withFileLock<T>(filePath: string, fn: () => Promise<T> | T, options?: LockOptions): Promise<T>;
|
|
22
|
+
/**
|
|
23
|
+
* Loads a master plan from disk without acquiring an advisory lock.
|
|
24
|
+
* Includes automatic transient retries to handle atomic file swap collisions (e.g. Windows EBUSY).
|
|
25
|
+
* Returns null if the file does not exist or fails to parse.
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadPlan(planPath: string, options?: {
|
|
28
|
+
silent?: boolean;
|
|
29
|
+
retries?: number;
|
|
30
|
+
}): MasterPlan | null;
|
|
31
|
+
/**
|
|
32
|
+
* Atomically writes the plan using a temp file rename with Windows EPERM/EBUSY resilience.
|
|
33
|
+
* Creates a unique temp file to prevent rename collisions across threads/processes.
|
|
34
|
+
*/
|
|
35
|
+
export declare function savePlan(planPath: string, plan: MasterPlan): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Safely executes a mutation on master_plan.json under an OS-level advisory lock.
|
|
38
|
+
* Ensures an atomic Read-Modify-Write cycle across concurrent agent processes.
|
|
39
|
+
*/
|
|
40
|
+
export declare function withPlanLock<T>(planPath: string, mutator: (plan: MasterPlan, ctx: PlanMutationContext) => Promise<T> | T, options?: LockOptions): Promise<T | null>;
|
|
41
|
+
/**
|
|
42
|
+
* Synchronous counterpart of withFileLock for callers that cannot await (the governor runs inside
|
|
43
|
+
* synchronous verdicts). Uses the same `<file>.lock` directory, so it excludes async holders too.
|
|
44
|
+
*/
|
|
45
|
+
export declare function withFileLockSync<T>(filePath: string, fn: () => T, options?: LockOptions): T;
|
|
46
|
+
/**
|
|
47
|
+
* Writes JSON through a unique temp file and a rename, so readers never observe a half-written
|
|
48
|
+
* file. Retries the rename on Windows sharing violations, like savePlan().
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeJsonAtomicSync(filePath: string, data: unknown): void;
|
|
51
|
+
/**
|
|
52
|
+
* Synchronous variant of withPlanLock using proper-lockfile lockSync.
|
|
53
|
+
*/
|
|
54
|
+
export declare function withPlanLockSync<T>(planPath: string, mutator: (plan: MasterPlan, ctx: PlanMutationContext) => T, options?: LockOptions): T | null;
|