@njeybe/nativ 2.8.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +62 -6
- package/bin/cli.js +1 -1
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +36 -0
- package/dist/commands/doctor.js +7 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/milestone.d.ts +10 -0
- package/dist/commands/milestone.js +84 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +1 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +39 -8
- package/dist/commands/task/task-add.js +62 -9
- package/dist/commands/task/task-common.d.ts +6 -0
- package/dist/commands/task/task-lifecycle.d.ts +3 -1
- package/dist/commands/task/task-lifecycle.js +48 -4
- package/dist/commands/task/task-query.js +4 -2
- package/dist/commands/task/task-show.d.ts +9 -0
- package/dist/commands/task/task-show.js +74 -0
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +1 -1
- package/dist/commands/validate.js +24 -0
- package/dist/commands/verify.d.ts +7 -0
- package/dist/commands/verify.js +26 -2
- package/dist/core/checks/docs-check.d.ts +18 -0
- package/dist/core/checks/docs-check.js +186 -0
- package/dist/core/code-shape.d.ts +7 -2
- package/dist/core/code-shape.js +46 -16
- package/dist/core/env-reads.d.ts +15 -0
- package/dist/core/env-reads.js +126 -0
- package/dist/core/human-gate.js +6 -3
- package/dist/core/milestone-archive.d.ts +40 -0
- package/dist/core/milestone-archive.js +299 -0
- package/dist/core/profiles.d.ts +8 -0
- package/dist/core/profiles.js +43 -2
- package/dist/core/root-resolver.d.ts +4 -0
- package/dist/core/root-resolver.js +32 -0
- package/dist/core/setup/gitignore-block.d.ts +17 -0
- package/dist/core/setup/gitignore-block.js +182 -0
- package/dist/core/setup/pm-playbook.d.ts +1 -1
- package/dist/core/setup/pm-playbook.js +7 -1
- package/dist/core/setup/session-context.d.ts +9 -1
- package/dist/core/setup/session-context.js +24 -2
- package/dist/core/setup/setup-planner.js +36 -3
- package/dist/core/setup/types.d.ts +6 -0
- package/dist/core/verifier/clean-tree.d.ts +14 -0
- package/dist/core/verifier/clean-tree.js +151 -0
- package/dist/core/verifier/executor.d.ts +3 -1
- package/dist/core/verifier/executor.js +54 -31
- package/dist/core/verifier/isolation.d.ts +25 -0
- package/dist/core/verifier/isolation.js +77 -0
- package/dist/core/verifier/portability-lint.d.ts +2 -0
- package/dist/core/verifier/portability-lint.js +64 -0
- package/dist/core/verifier/skip-policy.d.ts +8 -0
- package/dist/core/verifier/skip-policy.js +13 -0
- package/dist/core/verifier/task-verifier.d.ts +6 -8
- package/dist/core/verifier/task-verifier.js +95 -41
- package/dist/core/verifier/types.d.ts +4 -0
- package/dist/core/verifier/verify-env.d.ts +2 -0
- package/dist/core/verifier/verify-env.js +22 -0
- package/dist/index.js +47 -7
- package/dist/mcp/server.js +27 -2
- package/dist/runner/agent-supervisor.js +4 -1
- package/dist/scanner/types.d.ts +18 -0
- package/dist/scanner/types.js +11 -0
- package/dist/server/routes/task-routes.d.ts +2 -0
- package/dist/server/routes/task-routes.js +13 -5
- package/dist/server/studio-ui/shell/model.js +16 -9
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +14 -7
- package/plugin/agents/verifier.md +16 -14
- package/plugin/agents/worker.md +3 -3
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/skills/nativ/SKILL.md +13 -9
- package/templates/AGENTS.md +13 -9
- package/templates/claude-agents/architect.md +14 -7
- package/templates/claude-agents/verifier.md +16 -14
- package/templates/claude-agents/worker.md +3 -3
- package/templates/dot-ai/subagents/qa-tester.md +14 -13
|
@@ -19,7 +19,13 @@ When told to run autonomously ("run the queue", "execute milestone", AFK mode):
|
|
|
19
19
|
2. Delegate to the \`worker\` agent with the task id and recommended model.
|
|
20
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
21
|
4. Loop to the next task immediately, without asking the human.
|
|
22
|
-
5. Stop only when the milestone is done
|
|
22
|
+
5. Stop only when the milestone is done or when a task is blocked or escalated (present the four-part summary).
|
|
23
|
+
|
|
24
|
+
## Finished milestones
|
|
25
|
+
|
|
26
|
+
- Ask the \`verifier\` for the milestone check. If it passes, run \`nativ milestone archive <id> --summary "<one line>"\` (MCP: \`nativ_milestone_archive\`).
|
|
27
|
+
- Tell the human what was archived, then suggest \`/clear\`: the plan holds the state, so a fresh context loses nothing.
|
|
28
|
+
- For an old task, use \`nativ task show <id>\` instead of opening the plan.
|
|
23
29
|
|
|
24
30
|
## Talking to the human
|
|
25
31
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
/** A few lines of orientation for a new Claude Code session, or null when this is not a nativ project. */
|
|
2
2
|
export declare function buildSessionContext(cwd: string, env?: NodeJS.ProcessEnv): string | null;
|
|
3
|
+
/** Marks the plugin's copy of the hook so it can stay silent when the project has its own. */
|
|
4
|
+
export declare const FROM_PLUGIN_FLAG = "--from-plugin";
|
|
5
|
+
export interface SessionContextOptions {
|
|
6
|
+
/** Set by the plugin's hook: stay silent when the project already runs the hook. */
|
|
7
|
+
fromPlugin?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** True when .claude/settings.json already runs `nativ hook context` without the plugin flag. */
|
|
10
|
+
export declare function projectHasNativSessionHook(root: string): boolean;
|
|
3
11
|
/** `nativ hook context`: prints the SessionStart hook output. Never throws and always prints valid JSON or nothing. */
|
|
4
|
-
export declare function runSessionContext(targetDirArg?: string, write?: (text: string) => void): void;
|
|
12
|
+
export declare function runSessionContext(targetDirArg?: string, write?: (text: string) => void, options?: SessionContextOptions): void;
|
|
@@ -85,10 +85,32 @@ export function buildSessionContext(cwd, env = process.env) {
|
|
|
85
85
|
lines.push('', PM_PLAYBOOK);
|
|
86
86
|
return lines.join('\n');
|
|
87
87
|
}
|
|
88
|
+
/** Marks the plugin's copy of the hook so it can stay silent when the project has its own. */
|
|
89
|
+
export const FROM_PLUGIN_FLAG = '--from-plugin';
|
|
90
|
+
/** True when .claude/settings.json already runs `nativ hook context` without the plugin flag. */
|
|
91
|
+
export function projectHasNativSessionHook(root) {
|
|
92
|
+
let settings;
|
|
93
|
+
try {
|
|
94
|
+
settings = parseJsonLoose(fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8'));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const commands = (settings.hooks?.SessionStart ?? []).flatMap((group) => group.hooks ?? []);
|
|
100
|
+
return commands.some(({ command }) => {
|
|
101
|
+
if (typeof command !== 'string' || command.includes(FROM_PLUGIN_FLAG))
|
|
102
|
+
return false;
|
|
103
|
+
return /\bhook context\b/.test(command) && /nativ|cli\.js/.test(command);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
88
106
|
/** `nativ hook context`: prints the SessionStart hook output. Never throws and always prints valid JSON or nothing. */
|
|
89
|
-
export function runSessionContext(targetDirArg, write = (t) => process.stdout.write(t)) {
|
|
107
|
+
export function runSessionContext(targetDirArg, write = (t) => process.stdout.write(t), options = {}) {
|
|
90
108
|
try {
|
|
91
|
-
const
|
|
109
|
+
const cwd = targetDirArg ? path.resolve(targetDirArg) : process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
110
|
+
// With both installs, the project hook prints the orientation and the plugin's copy stays quiet.
|
|
111
|
+
if (options.fromPlugin && projectHasNativSessionHook(resolveProjectRoot(cwd)))
|
|
112
|
+
return;
|
|
113
|
+
const context = buildSessionContext(cwd);
|
|
92
114
|
if (context)
|
|
93
115
|
write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } }));
|
|
94
116
|
}
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
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
|
+
import { applyBlock, listGeneratedPaths, trackedNativPaths } from './gitignore-block.js';
|
|
6
7
|
import { loadEnforcementMode, parseJsonLoose } from '../enforcement.js';
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
export function getTemplatesDir() {
|
|
@@ -44,6 +45,18 @@ export function configuredInvocation(root) {
|
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
/** Disk-derived paths plus files this run creates, in a fixed order so reruns match. */
|
|
49
|
+
function blockPaths(root, visibility, changes) {
|
|
50
|
+
const disk = listGeneratedPaths(root, visibility);
|
|
51
|
+
if (visibility === 'shared')
|
|
52
|
+
return disk;
|
|
53
|
+
const created = new Set(changes.filter((c) => c.action === 'created').map((c) => `/${c.path}`));
|
|
54
|
+
const canonical = [
|
|
55
|
+
'/.ai/', '/.nativ/', '/.worktrees/', '/AGENTS.md', '/CLAUDE.md', '/GEMINI.md',
|
|
56
|
+
...AGENT_FILES.map((f) => `/.claude/agents/${f}`), '/.mcp.json', '/.claude/settings.json',
|
|
57
|
+
];
|
|
58
|
+
return canonical.filter((p) => disk.includes(p) || created.has(p));
|
|
59
|
+
}
|
|
47
60
|
/** Computes what `nativ setup` would do, without touching disk. */
|
|
48
61
|
export function planSetup(rootArg, options = {}) {
|
|
49
62
|
const root = path.resolve(rootArg);
|
|
@@ -55,6 +68,7 @@ export function planSetup(rootArg, options = {}) {
|
|
|
55
68
|
const changes = [];
|
|
56
69
|
const warnings = [];
|
|
57
70
|
const writes = new Map();
|
|
71
|
+
let visibility = options.gitVisibility ?? 'hidden';
|
|
58
72
|
if (!invocation.portable) {
|
|
59
73
|
warnings.push('nativ is not on PATH, so the generated config points at this machine\'s install. ' +
|
|
60
74
|
'Install it globally (npm i -g @njeybe/nativ) or pass --command "npx -y @njeybe/nativ" so the config works for your team.');
|
|
@@ -143,7 +157,7 @@ export function planSetup(rootArg, options = {}) {
|
|
|
143
157
|
for (const [file, title] of [['CLAUDE.md', 'Claude Code'], ['GEMINI.md', 'Gemini / Antigravity']]) {
|
|
144
158
|
const existing = readText(path.join(root, file));
|
|
145
159
|
if (existing === null)
|
|
146
|
-
record(file, null, `# ${title} directive\n\n@AGENTS.md\n`);
|
|
160
|
+
record(file, null, stampManaged(`# ${title} directive\n\n@AGENTS.md\n`));
|
|
147
161
|
else
|
|
148
162
|
changes.push({ path: file, action: 'unchanged', detail: /AGENTS\.md/.test(existing) ? undefined : 'exists; does not reference AGENTS.md' });
|
|
149
163
|
}
|
|
@@ -160,9 +174,21 @@ export function planSetup(rootArg, options = {}) {
|
|
|
160
174
|
next.enforcement = loadEnforcementMode(root);
|
|
161
175
|
detail = `enforcement=${String(next.enforcement)}`;
|
|
162
176
|
}
|
|
177
|
+
if (options.gitVisibility && next.gitVisibility !== options.gitVisibility) {
|
|
178
|
+
next.gitVisibility = options.gitVisibility;
|
|
179
|
+
detail = [detail, `gitVisibility=${options.gitVisibility}`].filter(Boolean).join(', ');
|
|
180
|
+
}
|
|
163
181
|
record('.nativ/config.json', config.text, detail || config.text === null ? serializeJson(next) : (config.text ?? ''), detail);
|
|
182
|
+
visibility = options.gitVisibility ?? (next.gitVisibility === 'shared' ? 'shared' : 'hidden');
|
|
164
183
|
}
|
|
165
|
-
|
|
184
|
+
// .gitignore: the managed block, from what is on disk plus what this run creates
|
|
185
|
+
const paths = blockPaths(root, visibility, changes);
|
|
186
|
+
const ignore = readText(path.join(root, '.gitignore'));
|
|
187
|
+
record('.gitignore', ignore, applyBlock(ignore, paths), 'nativ block');
|
|
188
|
+
const gitRmCommand = trackedNativPaths(root, paths).command;
|
|
189
|
+
if (gitRmCommand)
|
|
190
|
+
warnings.push(`nativ files are already tracked by git. To stop tracking them, run: ${gitRmCommand}`);
|
|
191
|
+
return { root, invocation, changes, warnings, writes, gitRmCommand: gitRmCommand || undefined };
|
|
166
192
|
}
|
|
167
193
|
/** Writes the planned differences. Idempotent: applying twice changes nothing the second time. */
|
|
168
194
|
export function applySetup(rootArg, options = {}) {
|
|
@@ -176,5 +202,12 @@ export function applySetup(rootArg, options = {}) {
|
|
|
176
202
|
fs.renameSync(tmp, file);
|
|
177
203
|
}
|
|
178
204
|
}
|
|
179
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
root: plan.root,
|
|
207
|
+
dryRun: options.dryRun === true,
|
|
208
|
+
invocation: plan.invocation,
|
|
209
|
+
changes: plan.changes,
|
|
210
|
+
warnings: plan.warnings,
|
|
211
|
+
gitRmCommand: plan.gitRmCommand,
|
|
212
|
+
};
|
|
180
213
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EnforcementMode } from '../enforcement.js';
|
|
2
|
+
import type { GitVisibility } from './gitignore-block.js';
|
|
2
3
|
export declare const MCP_SERVER_NAME = "nativ";
|
|
3
4
|
export declare const AGENT_FILES: readonly ["architect.md", "worker.md", "verifier.md", "explorer.md"];
|
|
4
5
|
export declare const MANAGED_PREFIX = "<!-- nativ:managed sha256=";
|
|
@@ -27,6 +28,8 @@ export interface SetupOptions {
|
|
|
27
28
|
enforcement?: EnforcementMode;
|
|
28
29
|
/** Also overwrite agent files that were edited by hand or are not nativ's. */
|
|
29
30
|
force?: boolean;
|
|
31
|
+
/** Whether nativ's files stay out of git (hidden, the default) or are shared with the team. */
|
|
32
|
+
gitVisibility?: GitVisibility;
|
|
30
33
|
hasBinary?: (name: string) => boolean;
|
|
31
34
|
templatesDir?: string;
|
|
32
35
|
}
|
|
@@ -37,6 +40,8 @@ export interface SetupPlan {
|
|
|
37
40
|
warnings: string[];
|
|
38
41
|
/** Full new content per changed path, keyed by the same relative path. */
|
|
39
42
|
writes: Map<string, string>;
|
|
43
|
+
/** `git rm --cached` command for nativ files git already tracks; never run by nativ. */
|
|
44
|
+
gitRmCommand?: string;
|
|
40
45
|
}
|
|
41
46
|
export interface SetupResult {
|
|
42
47
|
root: string;
|
|
@@ -44,6 +49,7 @@ export interface SetupResult {
|
|
|
44
49
|
invocation: CliInvocation;
|
|
45
50
|
changes: AssetChange[];
|
|
46
51
|
warnings: string[];
|
|
52
|
+
gitRmCommand?: string;
|
|
47
53
|
}
|
|
48
54
|
export interface TemplateFilePlan {
|
|
49
55
|
action: AssetAction;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const CLEAN_TREE_PREFIX = "nativ-verify-";
|
|
2
|
+
export type CleanTree = {
|
|
3
|
+
ok: true;
|
|
4
|
+
dir: string;
|
|
5
|
+
dispose: () => Promise<void>;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function disposeTree(root: string, dir: string, names: string[], unlink?: (dir: string, name: string) => boolean): Promise<void>;
|
|
11
|
+
export declare function createCleanTree(root: string, opts?: {
|
|
12
|
+
links?: string[];
|
|
13
|
+
}): Promise<CleanTree>;
|
|
14
|
+
export declare function sweepStaleCleanTrees(root: string): Promise<number>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { linkWorktreeDir, safeUnlinkWorktreeDir } from '../root-resolver.js';
|
|
6
|
+
export const CLEAN_TREE_PREFIX = 'nativ-verify-';
|
|
7
|
+
const STALE_MS = 60 * 60 * 1000;
|
|
8
|
+
function git(cwd, args) {
|
|
9
|
+
const opts = { cwd, maxBuffer: 64 * 1024 * 1024, windowsHide: true };
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
execFile('git', args, opts, (err, stdout) => {
|
|
12
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
13
|
+
resolve({ code, out: String(stdout ?? '') });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
async function removeDir(dir) {
|
|
19
|
+
for (let i = 0; i < 6; i++) {
|
|
20
|
+
try {
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
if (!fs.existsSync(dir))
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
const code = e.code;
|
|
27
|
+
if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY')
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await sleep(150 * (i + 1));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Links go first: removing a tree can follow a junction into the real folder.
|
|
34
|
+
function linkRemains(dir, names) {
|
|
35
|
+
return names.some((n) => {
|
|
36
|
+
try {
|
|
37
|
+
return fs.lstatSync(path.join(dir, n)).isSymbolicLink();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// unlink is injectable so tests can simulate a link that refuses to go away.
|
|
45
|
+
export async function disposeTree(root, dir, names, unlink = safeUnlinkWorktreeDir) {
|
|
46
|
+
try {
|
|
47
|
+
for (const n of names)
|
|
48
|
+
unlink(dir, n);
|
|
49
|
+
// A surviving link means a recursive delete could reach the real folder: leave it for the sweep.
|
|
50
|
+
if (linkRemains(dir, names))
|
|
51
|
+
return;
|
|
52
|
+
for (let i = 0; i < 4; i++) {
|
|
53
|
+
const r = await git(root, ['worktree', 'remove', '--force', dir]);
|
|
54
|
+
if (r.code === 0 || !fs.existsSync(dir))
|
|
55
|
+
break;
|
|
56
|
+
await sleep(150 * (i + 1));
|
|
57
|
+
}
|
|
58
|
+
await removeDir(dir);
|
|
59
|
+
await git(root, ['worktree', 'prune']);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Cleanup never throws.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function copyUntracked(root, dir) {
|
|
66
|
+
const r = await git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
67
|
+
for (const rel of r.out.split('\0').filter(Boolean)) {
|
|
68
|
+
const from = path.join(root, rel);
|
|
69
|
+
const to = path.join(dir, rel);
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.statSync(from).isFile())
|
|
72
|
+
continue;
|
|
73
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
74
|
+
fs.copyFileSync(from, to);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A file that vanished mid-copy is not worth failing the tree.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function createCleanTree(root, opts = {}) {
|
|
82
|
+
const inRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
83
|
+
if (inRepo.code !== 0)
|
|
84
|
+
return { ok: false, reason: 'clean isolation needs a git repository' };
|
|
85
|
+
const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
|
|
86
|
+
if (head.code !== 0)
|
|
87
|
+
return { ok: false, reason: 'clean isolation needs at least one commit' };
|
|
88
|
+
const stash = await git(root, ['stash', 'create']);
|
|
89
|
+
const commit = stash.code === 0 && stash.out.trim() ? stash.out.trim() : head.out.trim();
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), CLEAN_TREE_PREFIX));
|
|
91
|
+
fs.rmdirSync(dir);
|
|
92
|
+
const args = ['-c', 'core.autocrlf=false', 'worktree', 'add', '--detach', dir, commit];
|
|
93
|
+
const add = await git(root, args);
|
|
94
|
+
if (add.code !== 0) {
|
|
95
|
+
await removeDir(dir);
|
|
96
|
+
return { ok: false, reason: 'could not create the clean tree' };
|
|
97
|
+
}
|
|
98
|
+
await copyUntracked(root, dir);
|
|
99
|
+
const linked = (opts.links ?? []).filter((n) => linkWorktreeDir(dir, root, n));
|
|
100
|
+
return { ok: true, dir, dispose: () => disposeTree(root, dir, linked) };
|
|
101
|
+
}
|
|
102
|
+
function ownedBy(root, dir) {
|
|
103
|
+
try {
|
|
104
|
+
const text = fs.readFileSync(path.join(dir, '.git'), 'utf8');
|
|
105
|
+
const gitdir = text.replace(/^gitdir:\s*/, '').trim();
|
|
106
|
+
const main = realOf(path.resolve(root, '.git')).toLowerCase();
|
|
107
|
+
return realOf(path.resolve(gitdir)).toLowerCase().startsWith(main);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Real paths make Windows short (8.3) and aliased paths compare equal.
|
|
114
|
+
function realOf(p) {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync.native(p);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function linkNames(dir) {
|
|
123
|
+
return fs.readdirSync(dir).filter((n) => fs.lstatSync(path.join(dir, n)).isSymbolicLink());
|
|
124
|
+
}
|
|
125
|
+
export async function sweepStaleCleanTrees(root) {
|
|
126
|
+
let removed = 0;
|
|
127
|
+
try {
|
|
128
|
+
const tmp = os.tmpdir();
|
|
129
|
+
for (const name of fs.readdirSync(tmp)) {
|
|
130
|
+
if (!name.startsWith(CLEAN_TREE_PREFIX))
|
|
131
|
+
continue;
|
|
132
|
+
const dir = path.join(tmp, name);
|
|
133
|
+
try {
|
|
134
|
+
if (Date.now() - fs.statSync(dir).mtimeMs < STALE_MS)
|
|
135
|
+
continue;
|
|
136
|
+
if (!ownedBy(root, dir))
|
|
137
|
+
continue;
|
|
138
|
+
await disposeTree(root, dir, linkNames(dir));
|
|
139
|
+
if (!fs.existsSync(dir))
|
|
140
|
+
removed++;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Skip entries we cannot read.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Nothing to sweep.
|
|
149
|
+
}
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
@@ -2,4 +2,6 @@ import type { VerificationResult } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Execute a verification command in the workspace directory with timeout and output capture.
|
|
4
4
|
*/
|
|
5
|
-
export declare function executeVerification(command: string, cwd: string, timeoutMs?: number
|
|
5
|
+
export declare function executeVerification(command: string, cwd: string, timeoutMs?: number, options?: {
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
}): Promise<VerificationResult>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
const MAX_OUTPUT = 10 * 1024 * 1024;
|
|
2
3
|
/**
|
|
3
4
|
* Execute a verification command in the workspace directory with timeout and output capture.
|
|
4
5
|
*/
|
|
5
|
-
export async function executeVerification(command, cwd, timeoutMs = 120_000) {
|
|
6
|
+
export async function executeVerification(command, cwd, timeoutMs = 120_000, options = {}) {
|
|
6
7
|
const trimmed = (command || '').trim();
|
|
7
8
|
// Treat empty, 'none', or 'skip' as an informational pass
|
|
8
9
|
if (!trimmed || trimmed.toLowerCase() === 'none' || trimmed.toLowerCase() === 'skip') {
|
|
@@ -26,37 +27,59 @@ export async function executeVerification(command, cwd, timeoutMs = 120_000) {
|
|
|
26
27
|
}
|
|
27
28
|
const start = Date.now();
|
|
28
29
|
return new Promise((resolve) => {
|
|
29
|
-
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let out = '';
|
|
32
|
+
let errOut = '';
|
|
33
|
+
const child = spawn(finalCommand, {
|
|
30
34
|
cwd,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
shell: true,
|
|
36
|
+
env: options.env ?? { ...process.env, NATIV_VERIFY_SANDBOX: '1' },
|
|
37
|
+
detached: process.platform !== 'win32',
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
const cap = (cur, chunk) => cur.length < MAX_OUTPUT ? cur + chunk.toString() : cur;
|
|
41
|
+
child.stdout?.on('data', (c) => (out = cap(out, c)));
|
|
42
|
+
child.stderr?.on('data', (c) => (errOut = cap(errOut, c)));
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
killTree(child.pid);
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
const finish = (code, message) => {
|
|
48
|
+
clearTimeout(timer);
|
|
35
49
|
const durationMs = Date.now() - start;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const exitCode = typeof err.code === 'number' ? err.code : 1;
|
|
40
|
-
resolve({
|
|
41
|
-
success: false,
|
|
42
|
-
exitCode,
|
|
43
|
-
command: trimmed,
|
|
44
|
-
stdout: out,
|
|
45
|
-
stderr: errOut || err.message,
|
|
46
|
-
durationMs,
|
|
47
|
-
error: err.killed ? `Command timed out after ${timeoutMs}ms` : err.message,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
resolve({
|
|
52
|
-
success: true,
|
|
53
|
-
exitCode: 0,
|
|
54
|
-
command: trimmed,
|
|
55
|
-
stdout: out,
|
|
56
|
-
stderr: errOut,
|
|
57
|
-
durationMs,
|
|
58
|
-
});
|
|
50
|
+
if (code === 0 && !message && !timedOut) {
|
|
51
|
+
resolve({ success: true, exitCode: 0, command: trimmed, stdout: out, stderr: errOut, durationMs });
|
|
52
|
+
return;
|
|
59
53
|
}
|
|
60
|
-
|
|
54
|
+
const error = timedOut
|
|
55
|
+
? `Command timed out after ${timeoutMs}ms`
|
|
56
|
+
: message ?? `Command failed: ${trimmed}`;
|
|
57
|
+
resolve({
|
|
58
|
+
success: false,
|
|
59
|
+
exitCode: typeof code === 'number' && code !== 0 ? code : 1,
|
|
60
|
+
command: trimmed,
|
|
61
|
+
stdout: out,
|
|
62
|
+
stderr: errOut || error,
|
|
63
|
+
durationMs,
|
|
64
|
+
error,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
child.on('error', (e) => finish(null, e.message));
|
|
68
|
+
child.on('close', (code) => finish(code));
|
|
61
69
|
});
|
|
62
70
|
}
|
|
71
|
+
/** Kills the process and all its descendants. */
|
|
72
|
+
function killTree(pid) {
|
|
73
|
+
if (!pid)
|
|
74
|
+
return;
|
|
75
|
+
if (process.platform === 'win32') {
|
|
76
|
+
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], () => { });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-pid, 'SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Group already gone
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MasterPlanTask } from '../../scanner/types.js';
|
|
2
|
+
import type { PhasesOutcome, VerificationResult } from './types.js';
|
|
3
|
+
/** Where a verification runs: the real tree, or a disposable clean copy. */
|
|
4
|
+
export interface IsolationPlan {
|
|
5
|
+
mode: 'none' | 'clean';
|
|
6
|
+
dir?: string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
warnings: string[];
|
|
9
|
+
dispose?: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare const NO_ISOLATION: IsolationPlan;
|
|
12
|
+
export declare function needsRun(commands: string[], configDir: string, sharedPhases: boolean): boolean;
|
|
13
|
+
export declare function openIsolation(cwd: string, configDir: string, wanted: 'none' | 'clean'): Promise<IsolationPlan>;
|
|
14
|
+
/** Like runVerifyPhases, but with an explicit environment for each phase. */
|
|
15
|
+
export declare function runPhasesIn(dir: string, configDir: string, timeout: number | undefined, env?: NodeJS.ProcessEnv): Promise<PhasesOutcome>;
|
|
16
|
+
export interface RunOptions {
|
|
17
|
+
cwd: string;
|
|
18
|
+
configDir?: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
phases?: PhasesOutcome;
|
|
21
|
+
isolation?: 'none' | 'clean';
|
|
22
|
+
plan?: IsolationPlan;
|
|
23
|
+
}
|
|
24
|
+
export declare function runInPlan(task: MasterPlanTask, o: RunOptions, plan: IsolationPlan): Promise<VerificationResult>;
|
|
25
|
+
export declare function diagnose(task: MasterPlanTask, o: RunOptions, failed: VerificationResult, failure: 'warn' | 'block'): Promise<VerificationResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { loadVerifySettings } from '../profiles.js';
|
|
2
|
+
import { resolveMainRoot } from '../root-resolver.js';
|
|
3
|
+
import { executeVerification } from './executor.js';
|
|
4
|
+
import { createCleanTree, sweepStaleCleanTrees } from './clean-tree.js';
|
|
5
|
+
import { lintPortability } from './portability-lint.js';
|
|
6
|
+
import { buildVerifyEnv } from './verify-env.js';
|
|
7
|
+
import { loadVerifyPhases, runVerifyPhases } from './phases.js';
|
|
8
|
+
export const NO_ISOLATION = { mode: 'none', warnings: [] };
|
|
9
|
+
function isSkipped(command) {
|
|
10
|
+
const t = (command || '').trim().toLowerCase();
|
|
11
|
+
return !t || t === 'none' || t === 'skip';
|
|
12
|
+
}
|
|
13
|
+
export function needsRun(commands, configDir, sharedPhases) {
|
|
14
|
+
if (commands.some((c) => !isSkipped(c)))
|
|
15
|
+
return true;
|
|
16
|
+
return !sharedPhases && loadVerifyPhases(configDir).phases.length > 0;
|
|
17
|
+
}
|
|
18
|
+
export async function openIsolation(cwd, configDir, wanted) {
|
|
19
|
+
if (wanted !== 'clean')
|
|
20
|
+
return NO_ISOLATION;
|
|
21
|
+
await sweepStaleCleanTrees(cwd);
|
|
22
|
+
const settings = loadVerifySettings(resolveMainRoot(configDir));
|
|
23
|
+
const tree = await createCleanTree(cwd, { links: settings.verifyLinks });
|
|
24
|
+
if (!tree.ok) {
|
|
25
|
+
return { mode: 'none', warnings: [`Clean isolation unavailable (${tree.reason}); ran in place.`] };
|
|
26
|
+
}
|
|
27
|
+
const env = buildVerifyEnv(settings.verifyEnv);
|
|
28
|
+
return { mode: 'clean', dir: tree.dir, env, warnings: [], dispose: tree.dispose };
|
|
29
|
+
}
|
|
30
|
+
/** Like runVerifyPhases, but with an explicit environment for each phase. */
|
|
31
|
+
export async function runPhasesIn(dir, configDir, timeout, env) {
|
|
32
|
+
const config = loadVerifyPhases(configDir);
|
|
33
|
+
if (!env || config.error)
|
|
34
|
+
return runVerifyPhases(dir, configDir, timeout);
|
|
35
|
+
const reports = [];
|
|
36
|
+
for (const phase of config.phases) {
|
|
37
|
+
const res = await executeVerification(phase.run, dir, timeout, { env });
|
|
38
|
+
reports.push({ name: phase.name, command: res.command, success: res.success, durationMs: res.durationMs });
|
|
39
|
+
if (!res.success) {
|
|
40
|
+
const cause = res.error ? `: ${res.error.trim()}` : '';
|
|
41
|
+
return { reports, failure: { ...res, phases: reports, error: `Phase "${phase.name}" failed${cause}` } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { reports };
|
|
45
|
+
}
|
|
46
|
+
// The phases then the task command, in the plan's directory; code-shape is not part of this.
|
|
47
|
+
export async function runInPlan(task, o, plan) {
|
|
48
|
+
const configDir = o.configDir ?? o.cwd;
|
|
49
|
+
const dir = plan.dir ?? o.cwd;
|
|
50
|
+
const outcome = o.phases ?? (await runPhasesIn(dir, configDir, o.timeout, plan.env));
|
|
51
|
+
// In a batch the shared phase time is counted once, by the batch, not again for every task.
|
|
52
|
+
if (outcome.failure) {
|
|
53
|
+
return { ...outcome.failure, phases: outcome.reports, ...(o.phases ? { durationMs: 0 } : {}) };
|
|
54
|
+
}
|
|
55
|
+
const raw = await executeVerification(task.verificationCommand, dir, o.timeout, { env: plan.env });
|
|
56
|
+
if (outcome.reports.length) {
|
|
57
|
+
raw.phases = outcome.reports;
|
|
58
|
+
raw.durationMs += o.phases ? 0 : outcome.reports.reduce((sum, r) => sum + r.durationMs, 0);
|
|
59
|
+
raw.skipped = false;
|
|
60
|
+
}
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
// A clean failure is re-run in place only to learn whether the clean copy is the cause.
|
|
64
|
+
export async function diagnose(task, o, failed, failure) {
|
|
65
|
+
if (failed.error?.startsWith('Command timed out'))
|
|
66
|
+
return failed;
|
|
67
|
+
const local = await runInPlan(task, { ...o, phases: undefined }, NO_ISOLATION);
|
|
68
|
+
if (!local.success)
|
|
69
|
+
return failed;
|
|
70
|
+
const base = 'Passes only on this machine: it failed in a clean copy of the project.';
|
|
71
|
+
const note = [base, ...lintPortability(o.cwd, task.targetFiles || [])].join(' ');
|
|
72
|
+
if (failure === 'warn') {
|
|
73
|
+
return { ...local, isolation: 'clean', warnings: [...(failed.warnings ?? []), note] };
|
|
74
|
+
}
|
|
75
|
+
const error = `${failed.error ?? 'Verification failed'}. ${note} Isolation failure is set to block.`;
|
|
76
|
+
return { ...failed, error, warnings: [...(failed.warnings ?? []), note] };
|
|
77
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
const TEST_DIR = /(^|\/)(test|tests|__tests__|scripts)\//;
|
|
5
|
+
const TEST_NAME = /(\.test\.|\.spec\.)|(^|\/)test_[^/]*\.py$|_test\.go$/;
|
|
6
|
+
const LITERAL = /(["'`])([^"'`\n$]{3,200}?)\1/g;
|
|
7
|
+
const SPAWN_NATIV = /(spawn|spawnSync|exec|execSync|execFile|execFileSync)\(\s*['"`]nativ\b/;
|
|
8
|
+
const AI_HINT = 'track a fixture copy in the repo instead';
|
|
9
|
+
export function isPortabilityTarget(file) {
|
|
10
|
+
const f = file.split(String.fromCharCode(92)).join('/');
|
|
11
|
+
return TEST_DIR.test(f) || TEST_NAME.test(f);
|
|
12
|
+
}
|
|
13
|
+
function looksLikePath(s) {
|
|
14
|
+
if (/^[a-z]+:\/\//i.test(s) || /\s/.test(s) || s.startsWith('-'))
|
|
15
|
+
return false;
|
|
16
|
+
return /^\.{0,2}\/?[\w.@-]+(\/[\w.@-]+)+\/?$/.test(s) || /^\.[\w-]+(\/|$)/.test(s);
|
|
17
|
+
}
|
|
18
|
+
function scan(cwd, file, hits, spawns) {
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
27
|
+
if (SPAWN_NATIV.test(line))
|
|
28
|
+
spawns.push(`${file}:${i + 1} spawns nativ, which may not be installed in CI`);
|
|
29
|
+
for (const m of line.matchAll(LITERAL)) {
|
|
30
|
+
if (looksLikePath(m[2]))
|
|
31
|
+
hits.push({ file, line: i + 1, lit: m[2] });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function ignoredSet(cwd, paths) {
|
|
36
|
+
const r = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
37
|
+
cwd, input: paths.join('\n'), encoding: 'utf8',
|
|
38
|
+
});
|
|
39
|
+
if (r.error || (r.status !== 0 && r.status !== 1))
|
|
40
|
+
return null;
|
|
41
|
+
return new Set(r.stdout.split(/\r?\n/).filter(Boolean));
|
|
42
|
+
}
|
|
43
|
+
// Cheap warning pass: findings only, never changes a result.
|
|
44
|
+
export function lintPortability(cwd, targetFiles) {
|
|
45
|
+
const files = targetFiles.map((f) => f.split(String.fromCharCode(92)).join('/')).filter(isPortabilityTarget);
|
|
46
|
+
if (!files.length)
|
|
47
|
+
return [];
|
|
48
|
+
const hits = [];
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const f of files)
|
|
51
|
+
scan(cwd, f, hits, out);
|
|
52
|
+
const uniq = [...new Set(hits.map((h) => h.lit.replace(/^\.\//, '')))];
|
|
53
|
+
const ignored = uniq.length ? ignoredSet(cwd, uniq) : new Set();
|
|
54
|
+
if (!ignored)
|
|
55
|
+
return [];
|
|
56
|
+
for (const h of hits) {
|
|
57
|
+
const key = h.lit.replace(/^\.\//, '');
|
|
58
|
+
if (!ignored.has(key) && !ignored.has(`${key}/`))
|
|
59
|
+
continue;
|
|
60
|
+
const hint = key.startsWith('.ai/') ? `; ${AI_HINT}` : '';
|
|
61
|
+
out.push(`${h.file}:${h.line} reads ${h.lit}, which is gitignored: this check cannot run in CI${hint}`);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MasterPlanTask } from '../../scanner/types.js';
|
|
2
|
+
export type SkipMode = 'allow' | 'warn' | 'block';
|
|
3
|
+
export interface SkipDecision {
|
|
4
|
+
action: 'pass' | 'warn' | 'block';
|
|
5
|
+
message?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Decides what a skipped check (empty, none or skip) means for a task under the profile. */
|
|
8
|
+
export declare function evaluateSkip(task: MasterPlanTask, mode: SkipMode): SkipDecision;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const FIX = 'Add a real verificationCommand, or set verificationKind "manual" with notes saying how it was checked.';
|
|
2
|
+
/** Decides what a skipped check (empty, none or skip) means for a task under the profile. */
|
|
3
|
+
export function evaluateSkip(task, mode) {
|
|
4
|
+
if (task.verificationKind === 'manual' && (task.notes ?? '').trim())
|
|
5
|
+
return { action: 'pass' };
|
|
6
|
+
if (mode === 'allow')
|
|
7
|
+
return { action: 'pass' };
|
|
8
|
+
const why = `Task [${task.id}] has no real check, so nothing proves it works. ${FIX}`;
|
|
9
|
+
// Older plans without a kind only warn, so work already in progress does not stall.
|
|
10
|
+
if (mode === 'block' && task.verificationKind !== undefined)
|
|
11
|
+
return { action: 'block', message: why };
|
|
12
|
+
return { action: 'warn', message: why };
|
|
13
|
+
}
|