@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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { parseJsonLoose } from './enforcement.js';
|
|
5
|
+
import { loadPlan, savePlan, withFileLock, writeJsonAtomicSync } from './lock-manager.js';
|
|
6
|
+
import { resolveMainRoot } from './root-resolver.js';
|
|
7
|
+
import { executeVerification } from './verifier/executor.js';
|
|
8
|
+
import { digestOutput } from './verifier/output-digest.js';
|
|
9
|
+
import { loadEscalationFile } from '../governor/store.js';
|
|
10
|
+
import { resolveGitHead } from '../governor/test-integrity.js';
|
|
11
|
+
import { CHECK_OVERRIDE_NAMES, } from '../scanner/types.js';
|
|
12
|
+
export const DEFAULT_SUITE_TIMEOUT_MS = 600_000;
|
|
13
|
+
/** An expected "no": the message is written for the person running the command. */
|
|
14
|
+
export class ArchiveRefusal extends Error {
|
|
15
|
+
}
|
|
16
|
+
/** Ids of tasks moved out to archive files; they count as existing, completed tasks. */
|
|
17
|
+
export function archivedTaskIds(plan) {
|
|
18
|
+
return plan.milestones.flatMap((m) => m.archived?.taskIds ?? []);
|
|
19
|
+
}
|
|
20
|
+
/** The plan as dependency checks should see it: archived tasks appear as completed. */
|
|
21
|
+
export function withArchivedAsDone(plan) {
|
|
22
|
+
const tasks = archivedTaskIds(plan).map((id) => ({ id, status: 'completed' }));
|
|
23
|
+
const done = { id: '', name: '', status: 'completed', tasks };
|
|
24
|
+
return { ...plan, milestones: [...plan.milestones, done] };
|
|
25
|
+
}
|
|
26
|
+
const planPathOf = (targetDir) => path.join(targetDir, '.ai', 'master_plan.json');
|
|
27
|
+
const archiveFile = (targetDir, id) => path.join(targetDir, '.ai', 'archive', `${id}.json`);
|
|
28
|
+
const configFile = (targetDir) => path.join(resolveMainRoot(targetDir), '.nativ', 'config.json');
|
|
29
|
+
function assertSafeId(id) {
|
|
30
|
+
if (!/^[\w-][\w.-]*$/.test(id)) {
|
|
31
|
+
throw new ArchiveRefusal(`"${id}" is not a valid milestone id.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readConfig(targetDir) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = fs.readFileSync(configFile(targetDir), 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err?.code === 'ENOENT')
|
|
41
|
+
return {};
|
|
42
|
+
throw new ArchiveRefusal(`.nativ/config.json cannot be read (${err?.message}).`);
|
|
43
|
+
}
|
|
44
|
+
if (!raw.trim())
|
|
45
|
+
return {};
|
|
46
|
+
try {
|
|
47
|
+
const parsed = parseJsonLoose(raw);
|
|
48
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Reported below, same as valid JSON of the wrong shape.
|
|
53
|
+
}
|
|
54
|
+
throw new ArchiveRefusal('.nativ/config.json is not a valid JSON object. Fix it first.');
|
|
55
|
+
}
|
|
56
|
+
/** The suite to run: --check wins over the saved regressionCommand. */
|
|
57
|
+
function resolveSuite(targetDir, check) {
|
|
58
|
+
const raw = check ?? readConfig(targetDir).regressionCommand;
|
|
59
|
+
const command = typeof raw === 'string' ? raw.trim() : '';
|
|
60
|
+
if (!command) {
|
|
61
|
+
throw new ArchiveRefusal('No test suite is set. Pass --check "<command>" once (it is saved), '
|
|
62
|
+
+ 'or set "regressionCommand" in .nativ/config.json.');
|
|
63
|
+
}
|
|
64
|
+
if (['none', 'skip'].includes(command.toLowerCase())) {
|
|
65
|
+
throw new ArchiveRefusal(`"${command}" runs no tests, so it cannot protect an archive.`);
|
|
66
|
+
}
|
|
67
|
+
if (CHECK_OVERRIDE_NAMES.test(command)) {
|
|
68
|
+
throw new ArchiveRefusal('The check command must not set NATIV_CI_OVERRIDE or NATIV_HEADLESS_OVERRIDE.');
|
|
69
|
+
}
|
|
70
|
+
return command;
|
|
71
|
+
}
|
|
72
|
+
/** Tracked files with changes, leaving out nativ's own folders; null when git cannot tell. */
|
|
73
|
+
function dirtyTrackedFiles(targetDir) {
|
|
74
|
+
const args = [
|
|
75
|
+
'status', '--porcelain', '--untracked-files=no', '--', '.', ':(exclude).ai', ':(exclude).nativ',
|
|
76
|
+
];
|
|
77
|
+
const res = spawnSync('git', args, { cwd: targetDir, encoding: 'utf8' });
|
|
78
|
+
if (res.status !== 0)
|
|
79
|
+
return null;
|
|
80
|
+
return res.stdout.split('\n').map((line) => line.slice(3).trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
function assertTreeClean(targetDir, inRepo) {
|
|
83
|
+
const dirty = dirtyTrackedFiles(targetDir);
|
|
84
|
+
if (dirty === null && inRepo) {
|
|
85
|
+
throw new ArchiveRefusal('Could not read the git status of this project.');
|
|
86
|
+
}
|
|
87
|
+
if (dirty?.length) {
|
|
88
|
+
const names = dirty.slice(0, 5).join(', ');
|
|
89
|
+
throw new ArchiveRefusal(`Tracked code has uncommitted changes: ${names}. Commit first.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function assertArchivable(plan, id, targetDir) {
|
|
93
|
+
const milestone = plan.milestones.find((m) => m.id === id);
|
|
94
|
+
if (!milestone)
|
|
95
|
+
throw new ArchiveRefusal(`Milestone "${id}" not found.`);
|
|
96
|
+
if (milestone.archived)
|
|
97
|
+
throw new ArchiveRefusal(`Milestone ${id} is already archived.`);
|
|
98
|
+
if (!milestone.tasks.length)
|
|
99
|
+
throw new ArchiveRefusal(`Milestone ${id} has no tasks to archive.`);
|
|
100
|
+
const open = milestone.tasks.filter((t) => t.status !== 'completed').map((t) => t.id);
|
|
101
|
+
if (open.length) {
|
|
102
|
+
throw new ArchiveRefusal(`Milestone ${id} has unfinished tasks: ${open.join(', ')}.`);
|
|
103
|
+
}
|
|
104
|
+
const taskIds = new Set(milestone.tasks.map((t) => t.id));
|
|
105
|
+
const escalated = loadEscalationFile(targetDir, plan.projectName).escalations
|
|
106
|
+
.filter((e) => e && taskIds.has(e.taskId) && (!e.status || e.status === 'pending_review'))
|
|
107
|
+
.map((e) => e.id);
|
|
108
|
+
if (escalated.length) {
|
|
109
|
+
const names = escalated.join(', ');
|
|
110
|
+
throw new ArchiveRefusal(`Milestone ${id} has open escalations: ${names}. Settle them first.`);
|
|
111
|
+
}
|
|
112
|
+
return milestone;
|
|
113
|
+
}
|
|
114
|
+
function assertRequest(ids, summaries) {
|
|
115
|
+
if (!ids.length)
|
|
116
|
+
throw new ArchiveRefusal('Name at least one milestone to archive.');
|
|
117
|
+
ids.forEach(assertSafeId);
|
|
118
|
+
const missing = ids.filter((id) => typeof summaries[id] !== 'string' || !summaries[id].trim());
|
|
119
|
+
if (missing.length) {
|
|
120
|
+
throw new ArchiveRefusal(`A summary is required for: ${missing.join(', ')}.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function runSuite(command, targetDir, timeout) {
|
|
124
|
+
const result = await executeVerification(command, targetDir, timeout);
|
|
125
|
+
if (result.success)
|
|
126
|
+
return;
|
|
127
|
+
const detail = digestOutput(`${result.stdout}\n${result.stderr}`.trim(), 15);
|
|
128
|
+
throw new ArchiveRefusal(`The test suite failed: ${result.error ?? command}\n${detail}`);
|
|
129
|
+
}
|
|
130
|
+
/** Writes the archive and proves it can be read back whole; the plan depends on this. */
|
|
131
|
+
function writeConfirmed(file, milestone, readBack) {
|
|
132
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
133
|
+
writeJsonAtomicSync(file, milestone);
|
|
134
|
+
if (JSON.stringify(JSON.parse(readBack(file))) !== JSON.stringify(milestone)) {
|
|
135
|
+
throw new Error('the file read back does not match the milestone');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** A file that is not there (or whose folder is a plain file) is already removed. */
|
|
139
|
+
const isAbsent = (err) => err?.code === 'ENOENT' || err?.code === 'ENOTDIR';
|
|
140
|
+
/** Tries every file; rethrows the first real error only after all were attempted. */
|
|
141
|
+
function removeArchives(files, remove = (f) => fs.rmSync(f, { force: true })) {
|
|
142
|
+
let failure = null;
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
try {
|
|
145
|
+
remove(file);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (!isAbsent(err) && failure === null)
|
|
149
|
+
failure = err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (files.length) {
|
|
153
|
+
try {
|
|
154
|
+
fs.rmdirSync(path.dirname(files[0]));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Other archives are still in the folder.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (failure !== null)
|
|
161
|
+
throw failure;
|
|
162
|
+
}
|
|
163
|
+
function toStub(milestone, summary, check, commit) {
|
|
164
|
+
const archived = {
|
|
165
|
+
summary: summary.trim(),
|
|
166
|
+
commit,
|
|
167
|
+
check,
|
|
168
|
+
archivedAt: new Date().toISOString(),
|
|
169
|
+
taskCount: milestone.tasks.length,
|
|
170
|
+
taskIds: milestone.tasks.map((t) => t.id),
|
|
171
|
+
file: `.ai/archive/${milestone.id}.json`,
|
|
172
|
+
};
|
|
173
|
+
return { ...milestone, status: 'completed', tasks: [], archived };
|
|
174
|
+
}
|
|
175
|
+
function moveActiveOffArchived(plan) {
|
|
176
|
+
const active = plan.milestones.find((m) => m.id === plan.activeMilestoneId);
|
|
177
|
+
if (!active?.archived)
|
|
178
|
+
return;
|
|
179
|
+
const open = plan.milestones.find((m) => !m.archived && m.status !== 'completed');
|
|
180
|
+
if (open)
|
|
181
|
+
plan.activeMilestoneId = open.id;
|
|
182
|
+
}
|
|
183
|
+
/** Under the plan lock: archive files first, confirmed, and only then the plan. */
|
|
184
|
+
function commitArchive(plan, request, suite, commit, targetDir, deps) {
|
|
185
|
+
const readBack = deps.readBack ?? ((f) => fs.readFileSync(f, 'utf8'));
|
|
186
|
+
const written = [];
|
|
187
|
+
const archived = [];
|
|
188
|
+
try {
|
|
189
|
+
for (const id of request.ids) {
|
|
190
|
+
const milestone = assertArchivable(plan, id, targetDir);
|
|
191
|
+
const file = archiveFile(targetDir, id);
|
|
192
|
+
written.push(file);
|
|
193
|
+
writeConfirmed(file, milestone, readBack);
|
|
194
|
+
const stub = toStub(milestone, request.summaries[id], suite, commit);
|
|
195
|
+
plan.milestones[plan.milestones.indexOf(milestone)] = stub;
|
|
196
|
+
archived.push({ id, file: stub.archived.file, taskCount: stub.archived.taskCount });
|
|
197
|
+
}
|
|
198
|
+
moveActiveOffArchived(plan);
|
|
199
|
+
if (!(deps.save ?? savePlan)(planPathOf(targetDir), plan)) {
|
|
200
|
+
throw new Error('the plan could not be saved');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
let message = err instanceof ArchiveRefusal
|
|
205
|
+
? err.message
|
|
206
|
+
: `Nothing was archived, the plan is unchanged: ${err?.message}.`;
|
|
207
|
+
try {
|
|
208
|
+
removeArchives(written, deps.remove);
|
|
209
|
+
}
|
|
210
|
+
catch (cleanup) {
|
|
211
|
+
message += ` Some archive files could not be removed: ${cleanup?.message}.`;
|
|
212
|
+
}
|
|
213
|
+
throw new ArchiveRefusal(message);
|
|
214
|
+
}
|
|
215
|
+
return { archived, activeMilestoneId: plan.activeMilestoneId, commit, warnings: [] };
|
|
216
|
+
}
|
|
217
|
+
function saveSuite(targetDir, command) {
|
|
218
|
+
try {
|
|
219
|
+
const config = { ...readConfig(targetDir), regressionCommand: command };
|
|
220
|
+
writeJsonAtomicSync(configFile(targetDir), config);
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return [`The archive is done, but --check was not saved: ${err?.message}`];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function loadOrRefuse(planPath) {
|
|
228
|
+
const plan = loadPlan(planPath);
|
|
229
|
+
if (!plan)
|
|
230
|
+
throw new ArchiveRefusal('.ai/master_plan.json could not be read.');
|
|
231
|
+
return plan;
|
|
232
|
+
}
|
|
233
|
+
/** Refuses without side effects, runs the suite once outside the lock, then re-checks and commits. */
|
|
234
|
+
export async function archiveMilestones(targetDir, request, deps = {}) {
|
|
235
|
+
const timeout = request.timeout ?? DEFAULT_SUITE_TIMEOUT_MS;
|
|
236
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
237
|
+
throw new ArchiveRefusal('--timeout must be a positive number.');
|
|
238
|
+
}
|
|
239
|
+
const ids = [...new Set(request.ids)];
|
|
240
|
+
assertRequest(ids, request.summaries);
|
|
241
|
+
const planPath = planPathOf(targetDir);
|
|
242
|
+
const before = loadOrRefuse(planPath);
|
|
243
|
+
ids.forEach((id) => assertArchivable(before, id, targetDir));
|
|
244
|
+
const commit = resolveGitHead(targetDir);
|
|
245
|
+
assertTreeClean(targetDir, commit !== null);
|
|
246
|
+
const suite = resolveSuite(targetDir, request.check);
|
|
247
|
+
// The lock stays free during the long suite, so everything is checked again once it is held.
|
|
248
|
+
await runSuite(suite, targetDir, timeout);
|
|
249
|
+
const result = await withFileLock(planPath, () => {
|
|
250
|
+
assertTreeClean(targetDir, commit !== null);
|
|
251
|
+
if (resolveGitHead(targetDir) !== commit) {
|
|
252
|
+
throw new ArchiveRefusal('HEAD moved while the suite ran. Run the command again.');
|
|
253
|
+
}
|
|
254
|
+
const plan = loadOrRefuse(planPath);
|
|
255
|
+
return commitArchive(plan, { ...request, ids }, suite, commit, targetDir, deps);
|
|
256
|
+
});
|
|
257
|
+
return { ...result, warnings: request.check !== undefined ? saveSuite(targetDir, suite) : [] };
|
|
258
|
+
}
|
|
259
|
+
function readArchive(targetDir, id, expected) {
|
|
260
|
+
let saved;
|
|
261
|
+
try {
|
|
262
|
+
saved = JSON.parse(fs.readFileSync(archiveFile(targetDir, id), 'utf8'));
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new ArchiveRefusal(`The archive file of ${id} cannot be read (${err?.message}). The plan is unchanged.`);
|
|
266
|
+
}
|
|
267
|
+
const taskIds = Array.isArray(saved?.tasks) ? saved.tasks.map((t) => t.id) : null;
|
|
268
|
+
if (saved?.id !== id || JSON.stringify(taskIds) !== JSON.stringify(expected.taskIds)) {
|
|
269
|
+
throw new ArchiveRefusal(`The archive file of ${id} does not match the plan. The plan is unchanged.`);
|
|
270
|
+
}
|
|
271
|
+
return saved;
|
|
272
|
+
}
|
|
273
|
+
/** Puts the tasks back; the archive file goes only after the plan is safely saved. */
|
|
274
|
+
export async function restoreMilestone(targetDir, id, deps = {}) {
|
|
275
|
+
assertSafeId(id);
|
|
276
|
+
const planPath = planPathOf(targetDir);
|
|
277
|
+
return withFileLock(planPath, () => {
|
|
278
|
+
const plan = loadOrRefuse(planPath);
|
|
279
|
+
const index = plan.milestones.findIndex((m) => m.id === id);
|
|
280
|
+
if (index < 0)
|
|
281
|
+
throw new ArchiveRefusal(`Milestone "${id}" not found.`);
|
|
282
|
+
const info = plan.milestones[index].archived;
|
|
283
|
+
if (!info)
|
|
284
|
+
throw new ArchiveRefusal(`Milestone ${id} is not archived.`);
|
|
285
|
+
const saved = readArchive(targetDir, id, info);
|
|
286
|
+
plan.milestones[index] = saved;
|
|
287
|
+
if (!(deps.save ?? savePlan)(planPath, plan)) {
|
|
288
|
+
throw new ArchiveRefusal(`The plan could not be saved. The archive file of ${id} is kept.`);
|
|
289
|
+
}
|
|
290
|
+
const warnings = [];
|
|
291
|
+
try {
|
|
292
|
+
removeArchives([archiveFile(targetDir, id)]);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
warnings.push(`Restored, but the archive file was not removed: ${err?.message}`);
|
|
296
|
+
}
|
|
297
|
+
return { id, taskCount: saved.tasks.length, warnings };
|
|
298
|
+
});
|
|
299
|
+
}
|
package/dist/core/profiles.d.ts
CHANGED
|
@@ -6,9 +6,17 @@ export interface ProfileSettings {
|
|
|
6
6
|
governorMode: 'auto_adopt' | 'guard';
|
|
7
7
|
testIntegrity: 'off' | 'warn' | 'block';
|
|
8
8
|
allowHeadlessBypass: boolean;
|
|
9
|
+
verifyIsolation: 'none' | 'clean';
|
|
10
|
+
verifyIsolationFailure: 'warn' | 'block';
|
|
11
|
+
skippedCheck: 'allow' | 'warn' | 'block';
|
|
9
12
|
description: string;
|
|
10
13
|
}
|
|
11
14
|
export declare const PROFILE_DEFAULTS: Record<NativProfile, ProfileSettings>;
|
|
12
15
|
export declare function loadProfile(root: string): NativProfile;
|
|
13
16
|
export declare function resolveActiveProfileSettings(root: string): ProfileSettings;
|
|
14
17
|
export declare function setProfile(root: string, profile: NativProfile): ProfileSettings;
|
|
18
|
+
export interface VerifySettings {
|
|
19
|
+
verifyEnv: string[];
|
|
20
|
+
verifyLinks: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function loadVerifySettings(root: string): VerifySettings;
|
package/dist/core/profiles.js
CHANGED
|
@@ -9,6 +9,9 @@ export const PROFILE_DEFAULTS = {
|
|
|
9
9
|
governorMode: 'auto_adopt',
|
|
10
10
|
testIntegrity: 'off',
|
|
11
11
|
allowHeadlessBypass: true,
|
|
12
|
+
verifyIsolation: 'none',
|
|
13
|
+
verifyIsolationFailure: 'warn',
|
|
14
|
+
skippedCheck: 'allow',
|
|
12
15
|
description: 'Hackathon & scratchpad mode: frictionless exploration, auto-adopting contracts, and relaxed scope.',
|
|
13
16
|
},
|
|
14
17
|
solo: {
|
|
@@ -17,7 +20,11 @@ export const PROFILE_DEFAULTS = {
|
|
|
17
20
|
governorMode: 'auto_adopt',
|
|
18
21
|
testIntegrity: 'warn',
|
|
19
22
|
allowHeadlessBypass: true,
|
|
20
|
-
|
|
23
|
+
verifyIsolation: 'clean',
|
|
24
|
+
verifyIsolationFailure: 'warn',
|
|
25
|
+
skippedCheck: 'warn',
|
|
26
|
+
description: 'Single-developer agility: advisory scope warnings, self-evolving contracts, '
|
|
27
|
+
+ 'and streamlined execution.',
|
|
21
28
|
},
|
|
22
29
|
enterprise: {
|
|
23
30
|
profile: 'enterprise',
|
|
@@ -25,7 +32,11 @@ export const PROFILE_DEFAULTS = {
|
|
|
25
32
|
governorMode: 'guard',
|
|
26
33
|
testIntegrity: 'block',
|
|
27
34
|
allowHeadlessBypass: false,
|
|
28
|
-
|
|
35
|
+
verifyIsolation: 'clean',
|
|
36
|
+
verifyIsolationFailure: 'block',
|
|
37
|
+
skippedCheck: 'block',
|
|
38
|
+
description: 'Full team rigor: strict contract boundaries, test integrity protection, '
|
|
39
|
+
+ 'circuit breakers, and air-gap.',
|
|
29
40
|
},
|
|
30
41
|
};
|
|
31
42
|
const CONFIG_FILE = path.join('.nativ', 'config.json');
|
|
@@ -55,6 +66,15 @@ export function resolveActiveProfileSettings(root) {
|
|
|
55
66
|
if (raw.testIntegrity === 'off' || raw.testIntegrity === 'warn' || raw.testIntegrity === 'block') {
|
|
56
67
|
base.testIntegrity = raw.testIntegrity;
|
|
57
68
|
}
|
|
69
|
+
if (raw.verifyIsolation === 'none' || raw.verifyIsolation === 'clean') {
|
|
70
|
+
base.verifyIsolation = raw.verifyIsolation;
|
|
71
|
+
}
|
|
72
|
+
if (raw.verifyIsolationFailure === 'warn' || raw.verifyIsolationFailure === 'block') {
|
|
73
|
+
base.verifyIsolationFailure = raw.verifyIsolationFailure;
|
|
74
|
+
}
|
|
75
|
+
if (raw.skippedCheck === 'allow' || raw.skippedCheck === 'warn' || raw.skippedCheck === 'block') {
|
|
76
|
+
base.skippedCheck = raw.skippedCheck;
|
|
77
|
+
}
|
|
58
78
|
}
|
|
59
79
|
catch {
|
|
60
80
|
// Keep base defaults
|
|
@@ -68,6 +88,27 @@ export function setProfile(root, profile) {
|
|
|
68
88
|
config.enforcement = profileSettings.enforcement;
|
|
69
89
|
config.governorMode = profileSettings.governorMode;
|
|
70
90
|
config.testIntegrity = profileSettings.testIntegrity;
|
|
91
|
+
config.verifyIsolation = profileSettings.verifyIsolation;
|
|
92
|
+
config.verifyIsolationFailure = profileSettings.verifyIsolationFailure;
|
|
93
|
+
config.skippedCheck = profileSettings.skippedCheck;
|
|
71
94
|
});
|
|
72
95
|
return profileSettings;
|
|
73
96
|
}
|
|
97
|
+
const DEFAULT_VERIFY_LINKS = ['node_modules', '.venv', 'venv'];
|
|
98
|
+
const BLOCKED_ENV = new Set(['CI', 'GITHUB_ACTIONS', 'GITLAB_CI']);
|
|
99
|
+
function stringList(value, fallback) {
|
|
100
|
+
if (!Array.isArray(value))
|
|
101
|
+
return fallback;
|
|
102
|
+
return value.filter((v) => typeof v === 'string' && v.length > 0);
|
|
103
|
+
}
|
|
104
|
+
export function loadVerifySettings(root) {
|
|
105
|
+
let raw = {};
|
|
106
|
+
try {
|
|
107
|
+
raw = parseJsonLoose(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Missing config keeps defaults
|
|
111
|
+
}
|
|
112
|
+
const verifyEnv = stringList(raw.verifyEnv, []).filter((n) => !BLOCKED_ENV.has(n) && !n.startsWith('NATIV_'));
|
|
113
|
+
return { verifyEnv, verifyLinks: stringList(raw.verifyLinks, DEFAULT_VERIFY_LINKS) };
|
|
114
|
+
}
|
|
@@ -35,3 +35,7 @@ export declare function linkWorktreeNodeModules(worktreeDir: string, rootDir: st
|
|
|
35
35
|
* project root are never deleted.
|
|
36
36
|
*/
|
|
37
37
|
export declare function safeUnlinkWorktreeNodeModules(worktreeDir: string): boolean;
|
|
38
|
+
/** Links a folder of the project root into a worktree (junction on Windows). */
|
|
39
|
+
export declare function linkWorktreeDir(worktreeDir: string, rootDir: string, name: string): boolean;
|
|
40
|
+
/** Removes only the link, never the folder it points to. */
|
|
41
|
+
export declare function safeUnlinkWorktreeDir(worktreeDir: string, name: string): boolean;
|
|
@@ -193,3 +193,35 @@ export function safeUnlinkWorktreeNodeModules(worktreeDir) {
|
|
|
193
193
|
}
|
|
194
194
|
return false;
|
|
195
195
|
}
|
|
196
|
+
/** Links a folder of the project root into a worktree (junction on Windows). */
|
|
197
|
+
export function linkWorktreeDir(worktreeDir, rootDir, name) {
|
|
198
|
+
const source = path.join(rootDir, name);
|
|
199
|
+
const target = path.join(worktreeDir, name);
|
|
200
|
+
if (!fs.existsSync(source) || fs.existsSync(target))
|
|
201
|
+
return false;
|
|
202
|
+
try {
|
|
203
|
+
fs.symlinkSync(source, target, process.platform === 'win32' ? 'junction' : 'dir');
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Removes only the link, never the folder it points to. */
|
|
211
|
+
export function safeUnlinkWorktreeDir(worktreeDir, name) {
|
|
212
|
+
const target = path.join(worktreeDir, name);
|
|
213
|
+
try {
|
|
214
|
+
if (!fs.lstatSync(target).isSymbolicLink())
|
|
215
|
+
return false;
|
|
216
|
+
try {
|
|
217
|
+
fs.unlinkSync(target);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
fs.rmdirSync(target);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const BLOCK_START = "# >>> nativ (managed) >>>";
|
|
2
|
+
export declare const BLOCK_END = "# <<< nativ (managed) <<<";
|
|
3
|
+
export type GitVisibility = 'hidden' | 'shared';
|
|
4
|
+
/** Secret and machine-local paths: ignored even when the team shares nativ's files. */
|
|
5
|
+
export declare const SHARED_PATHS: readonly ["/.nativ/*.local.json", "/.nativ/logs/", "/.nativ/*.log", "/.nativ/notify-state.json", "/.worktrees/"];
|
|
6
|
+
/** Root-anchored paths nativ generated in this project, per the git-visibility choice. */
|
|
7
|
+
export declare function listGeneratedPaths(root: string, visibility?: GitVisibility): string[];
|
|
8
|
+
export declare function renderBlock(paths: string[], eol?: string): string;
|
|
9
|
+
/** Inserts or replaces the managed block; text outside it and its line endings stay as they were. */
|
|
10
|
+
export declare function applyBlock(existing: string | null, paths: string[]): string;
|
|
11
|
+
export interface TrackedResult {
|
|
12
|
+
tracked: string[];
|
|
13
|
+
/** Command for the human to run; nativ never runs it. */
|
|
14
|
+
command: string;
|
|
15
|
+
}
|
|
16
|
+
/** Lists generated paths that git already tracks. Non-git folders give an empty list. */
|
|
17
|
+
export declare function trackedNativPaths(root: string, paths: string[]): TrackedResult;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { buildDesiredConfig, isObject } from './config-merger.js';
|
|
5
|
+
import { AGENT_FILES, MCP_SERVER_NAME, managedState } from './types.js';
|
|
6
|
+
export const BLOCK_START = '# >>> nativ (managed) >>>';
|
|
7
|
+
export const BLOCK_END = '# <<< nativ (managed) <<<';
|
|
8
|
+
/** Secret and machine-local paths: ignored even when the team shares nativ's files. */
|
|
9
|
+
export const SHARED_PATHS = [
|
|
10
|
+
'/.nativ/*.local.json',
|
|
11
|
+
'/.nativ/logs/',
|
|
12
|
+
'/.nativ/*.log',
|
|
13
|
+
'/.nativ/notify-state.json',
|
|
14
|
+
'/.worktrees/',
|
|
15
|
+
];
|
|
16
|
+
const read = (root, rel) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Exactly what older nativ versions wrote for CLAUDE.md / GEMINI.md. */
|
|
25
|
+
function isOldPointer(text) {
|
|
26
|
+
const lf = text.replace(/\r\n/g, '\n');
|
|
27
|
+
return ['Claude Code', 'Gemini / Antigravity'].some((t) => lf === `# ${t} directive\n\n@AGENTS.md\n`);
|
|
28
|
+
}
|
|
29
|
+
function isNativOwnedFile(text, allowPointer) {
|
|
30
|
+
if (text === null)
|
|
31
|
+
return false;
|
|
32
|
+
if (managedState(text) !== 'unmanaged')
|
|
33
|
+
return true;
|
|
34
|
+
return allowPointer && isOldPointer(text);
|
|
35
|
+
}
|
|
36
|
+
const parseObject = (text) => {
|
|
37
|
+
if (text === null)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(text);
|
|
41
|
+
return isObject(value) ? value : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
48
|
+
/** nativ's own server entry, for whatever command setup was configured to use. */
|
|
49
|
+
function ownedServer(entry) {
|
|
50
|
+
if (!isObject(entry) || typeof entry.command !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
if (Object.keys(entry).some((k) => k !== 'command' && k !== 'args'))
|
|
53
|
+
return false;
|
|
54
|
+
const args = entry.args;
|
|
55
|
+
if (!Array.isArray(args) || args.length === 0 || !args.every((a) => typeof a === 'string'))
|
|
56
|
+
return false;
|
|
57
|
+
const invocation = { command: entry.command, prefixArgs: args.slice(0, -1), portable: true };
|
|
58
|
+
const want = buildDesiredConfig(invocation).mcpServer;
|
|
59
|
+
return sameJson(entry, { command: want.command, args: want.args });
|
|
60
|
+
}
|
|
61
|
+
function onlyNativMcp(root) {
|
|
62
|
+
const json = parseObject(read(root, '.mcp.json'));
|
|
63
|
+
if (!json)
|
|
64
|
+
return false;
|
|
65
|
+
const keys = Object.keys(json).filter((k) => k !== 'mcpServers');
|
|
66
|
+
if (keys.length || !isObject(json.mcpServers))
|
|
67
|
+
return false;
|
|
68
|
+
const servers = json.mcpServers;
|
|
69
|
+
const names = Object.keys(servers);
|
|
70
|
+
return names.length > 0 && names.every((n) => n === MCP_SERVER_NAME && ownedServer(servers[n]));
|
|
71
|
+
}
|
|
72
|
+
/** CLI strings that setup wrote into hook commands; `nativ` is always a candidate. */
|
|
73
|
+
function clisFromHooks(hooks) {
|
|
74
|
+
const found = new Set(['nativ']);
|
|
75
|
+
if (!isObject(hooks))
|
|
76
|
+
return [...found];
|
|
77
|
+
for (const groups of Object.values(hooks)) {
|
|
78
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
79
|
+
for (const h of isObject(g) && Array.isArray(g.hooks) ? g.hooks : []) {
|
|
80
|
+
const m = isObject(h) ? /^(.+) hook (check|context)$/.exec(String(h.command)) : null;
|
|
81
|
+
if (m)
|
|
82
|
+
found.add(m[1]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...found];
|
|
87
|
+
}
|
|
88
|
+
/** The generated config for an already-rendered CLI string (it may hold spaces or quotes). */
|
|
89
|
+
function desiredFor(cli) {
|
|
90
|
+
const mark = 'CLIMARK';
|
|
91
|
+
const text = JSON.stringify(buildDesiredConfig({ command: mark, prefixArgs: [], portable: true }));
|
|
92
|
+
return JSON.parse(text.split(mark).join(JSON.stringify(cli).slice(1, -1)));
|
|
93
|
+
}
|
|
94
|
+
function ownedHooks(hooks, wants) {
|
|
95
|
+
if (!isObject(hooks))
|
|
96
|
+
return false;
|
|
97
|
+
const groupOk = (event, g) => wants.some((want) => {
|
|
98
|
+
const w = event === 'PreToolUse' ? want.preToolUse : event === 'SessionStart' ? want.sessionStart : null;
|
|
99
|
+
const hook = w && { type: 'command', command: w.command, timeout: w.timeout };
|
|
100
|
+
return w !== null && sameJson(g, { matcher: w.matcher, hooks: [hook] });
|
|
101
|
+
});
|
|
102
|
+
return Object.entries(hooks).every(([event, groups]) => Array.isArray(groups) && groups.every((g) => groupOk(event, g)));
|
|
103
|
+
}
|
|
104
|
+
// Setup unions entries, so a file may hold those of an older invocation too.
|
|
105
|
+
function ownedPermissions(perms, wants) {
|
|
106
|
+
if (!isObject(perms))
|
|
107
|
+
return false;
|
|
108
|
+
const keys = ['allow', 'deny', 'ask'];
|
|
109
|
+
return Object.entries(perms).every(([k, v]) => {
|
|
110
|
+
const key = keys.find((x) => x === k);
|
|
111
|
+
return key !== undefined && Array.isArray(v) &&
|
|
112
|
+
v.every((e) => typeof e === 'string' && wants.some((w) => w[key].includes(e)));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function onlyNativSettings(root) {
|
|
116
|
+
const json = parseObject(read(root, '.claude/settings.json'));
|
|
117
|
+
if (!json)
|
|
118
|
+
return false;
|
|
119
|
+
const allowed = new Set(['permissions', 'hooks', 'enabledMcpjsonServers']);
|
|
120
|
+
if (Object.keys(json).some((k) => !allowed.has(k)))
|
|
121
|
+
return false;
|
|
122
|
+
const wants = clisFromHooks(json.hooks).map(desiredFor);
|
|
123
|
+
if (json.permissions !== undefined && !ownedPermissions(json.permissions, wants))
|
|
124
|
+
return false;
|
|
125
|
+
if (json.hooks !== undefined && !ownedHooks(json.hooks, wants))
|
|
126
|
+
return false;
|
|
127
|
+
const enabled = json.enabledMcpjsonServers;
|
|
128
|
+
return enabled === undefined || (Array.isArray(enabled) && enabled.every((s) => s === MCP_SERVER_NAME));
|
|
129
|
+
}
|
|
130
|
+
/** Root-anchored paths nativ generated in this project, per the git-visibility choice. */
|
|
131
|
+
export function listGeneratedPaths(root, visibility = 'hidden') {
|
|
132
|
+
if (visibility === 'shared')
|
|
133
|
+
return [...SHARED_PATHS];
|
|
134
|
+
const paths = ['/.ai/', '/.nativ/', '/.worktrees/'];
|
|
135
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
136
|
+
if (isNativOwnedFile(read(root, file), true))
|
|
137
|
+
paths.push(`/${file}`);
|
|
138
|
+
}
|
|
139
|
+
for (const file of AGENT_FILES) {
|
|
140
|
+
const rel = `.claude/agents/${file}`;
|
|
141
|
+
if (isNativOwnedFile(read(root, rel), false))
|
|
142
|
+
paths.push(`/${rel}`);
|
|
143
|
+
}
|
|
144
|
+
if (onlyNativMcp(root))
|
|
145
|
+
paths.push('/.mcp.json');
|
|
146
|
+
if (onlyNativSettings(root))
|
|
147
|
+
paths.push('/.claude/settings.json');
|
|
148
|
+
return paths;
|
|
149
|
+
}
|
|
150
|
+
export function renderBlock(paths, eol = '\n') {
|
|
151
|
+
return [BLOCK_START, ...paths, BLOCK_END].join(eol);
|
|
152
|
+
}
|
|
153
|
+
/** Inserts or replaces the managed block; text outside it and its line endings stay as they were. */
|
|
154
|
+
export function applyBlock(existing, paths) {
|
|
155
|
+
const text = existing ?? '';
|
|
156
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
157
|
+
const block = renderBlock(paths, eol);
|
|
158
|
+
const start = text.indexOf(BLOCK_START);
|
|
159
|
+
const endAt = start === -1 ? -1 : text.indexOf(BLOCK_END, start);
|
|
160
|
+
if (start !== -1 && endAt !== -1) {
|
|
161
|
+
return text.slice(0, start) + block + text.slice(endAt + BLOCK_END.length);
|
|
162
|
+
}
|
|
163
|
+
if (text.trim() === '')
|
|
164
|
+
return block + eol;
|
|
165
|
+
const sep = text.endsWith('\n') ? eol : eol + eol;
|
|
166
|
+
return text + sep + block + eol;
|
|
167
|
+
}
|
|
168
|
+
/** Lists generated paths that git already tracks. Non-git folders give an empty list. */
|
|
169
|
+
export function trackedNativPaths(root, paths) {
|
|
170
|
+
const none = { tracked: [], command: '' };
|
|
171
|
+
const specs = paths.map((p) => p.replace(/^\//, '')).filter((p) => !p.includes('*'));
|
|
172
|
+
if (!specs.length)
|
|
173
|
+
return none;
|
|
174
|
+
const out = spawnSync('git', ['ls-files', '-z', '--', ...specs], { cwd: root, encoding: 'utf8' });
|
|
175
|
+
if (out.status !== 0 || !out.stdout)
|
|
176
|
+
return none;
|
|
177
|
+
const files = out.stdout.split('\0').filter(Boolean);
|
|
178
|
+
const tracked = specs.filter((s) => files.some((f) => f === s || f.startsWith(s.endsWith('/') ? s : `${s}/`)));
|
|
179
|
+
if (!tracked.length)
|
|
180
|
+
return none;
|
|
181
|
+
return { tracked, command: `git rm --cached -r -- ${tracked.join(' ')}` };
|
|
182
|
+
}
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* The project manager's playbook. The SessionStart hook prints it into the main session only:
|
|
3
3
|
* subagents load CLAUDE.md and AGENTS.md on every turn, so anything kept there is paid for by every worker.
|
|
4
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 (
|
|
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 or when a task is blocked or escalated (present the four-part summary).\n\n## Finished milestones\n\n- Ask the `verifier` for the milestone check. If it passes, run `nativ milestone archive <id> --summary \"<one line>\"` (MCP: `nativ_milestone_archive`).\n- Tell the human what was archived, then suggest `/clear`: the plan holds the state, so a fresh context loses nothing.\n- For an old task, use `nativ task show <id>` instead of opening the plan.\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)`, plus one line on when the other option would be the better choice, 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.";
|