@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +334 -0
  4. package/bin/cli.js +31 -0
  5. package/dist/commands/bench.d.ts +8 -0
  6. package/dist/commands/bench.js +52 -0
  7. package/dist/commands/db.d.ts +22 -0
  8. package/dist/commands/db.js +415 -0
  9. package/dist/commands/doctor.d.ts +38 -0
  10. package/dist/commands/doctor.js +169 -0
  11. package/dist/commands/hook.d.ts +31 -0
  12. package/dist/commands/hook.js +105 -0
  13. package/dist/commands/init.d.ts +3 -0
  14. package/dist/commands/init.js +144 -0
  15. package/dist/commands/mcp.d.ts +5 -0
  16. package/dist/commands/mcp.js +16 -0
  17. package/dist/commands/setup.d.ts +17 -0
  18. package/dist/commands/setup.js +51 -0
  19. package/dist/commands/status.d.ts +5 -0
  20. package/dist/commands/status.js +75 -0
  21. package/dist/commands/task.d.ts +83 -0
  22. package/dist/commands/task.js +807 -0
  23. package/dist/commands/test-gen.d.ts +14 -0
  24. package/dist/commands/test-gen.js +52 -0
  25. package/dist/commands/triage.d.ts +70 -0
  26. package/dist/commands/triage.js +343 -0
  27. package/dist/commands/update.d.ts +14 -0
  28. package/dist/commands/update.js +112 -0
  29. package/dist/commands/validate.d.ts +1 -0
  30. package/dist/commands/validate.js +89 -0
  31. package/dist/commands/verify.d.ts +8 -0
  32. package/dist/commands/verify.js +65 -0
  33. package/dist/commands/worktree.d.ts +47 -0
  34. package/dist/commands/worktree.js +396 -0
  35. package/dist/core/benchmark.d.ts +57 -0
  36. package/dist/core/benchmark.js +772 -0
  37. package/dist/core/enforcement.d.ts +80 -0
  38. package/dist/core/enforcement.js +271 -0
  39. package/dist/core/legacy-templates.d.ts +24 -0
  40. package/dist/core/legacy-templates.js +60 -0
  41. package/dist/core/lock-manager.d.ts +54 -0
  42. package/dist/core/lock-manager.js +260 -0
  43. package/dist/core/root-resolver.d.ts +32 -0
  44. package/dist/core/root-resolver.js +149 -0
  45. package/dist/core/setup-assets.d.ts +123 -0
  46. package/dist/core/setup-assets.js +438 -0
  47. package/dist/core/telemetry.d.ts +156 -0
  48. package/dist/core/telemetry.js +416 -0
  49. package/dist/core/test-generator-types.d.ts +227 -0
  50. package/dist/core/test-generator-types.js +179 -0
  51. package/dist/core/test-generator.d.ts +34 -0
  52. package/dist/core/test-generator.js +1690 -0
  53. package/dist/core/tier1-liaison.d.ts +139 -0
  54. package/dist/core/tier1-liaison.js +320 -0
  55. package/dist/core/verifier.d.ts +52 -0
  56. package/dist/core/verifier.js +249 -0
  57. package/dist/core/version.d.ts +5 -0
  58. package/dist/core/version.js +14 -0
  59. package/dist/core/worktree-merge.d.ts +30 -0
  60. package/dist/core/worktree-merge.js +98 -0
  61. package/dist/db/data-engine.d.ts +46 -0
  62. package/dist/db/data-engine.js +802 -0
  63. package/dist/db/diff.d.ts +39 -0
  64. package/dist/db/diff.js +420 -0
  65. package/dist/db/env-parser.d.ts +86 -0
  66. package/dist/db/env-parser.js +549 -0
  67. package/dist/db/introspector.d.ts +21 -0
  68. package/dist/db/introspector.js +835 -0
  69. package/dist/db/types.d.ts +217 -0
  70. package/dist/db/types.js +9 -0
  71. package/dist/governor/circuit-breaker.d.ts +100 -0
  72. package/dist/governor/circuit-breaker.js +231 -0
  73. package/dist/governor/evaluator.d.ts +67 -0
  74. package/dist/governor/evaluator.js +851 -0
  75. package/dist/governor/index.d.ts +6 -0
  76. package/dist/governor/index.js +6 -0
  77. package/dist/governor/rules/api-rules.d.ts +2 -0
  78. package/dist/governor/rules/api-rules.js +140 -0
  79. package/dist/governor/rules/db-rules.d.ts +2 -0
  80. package/dist/governor/rules/db-rules.js +180 -0
  81. package/dist/governor/store.d.ts +33 -0
  82. package/dist/governor/store.js +84 -0
  83. package/dist/governor/types.d.ts +64 -0
  84. package/dist/governor/types.js +1 -0
  85. package/dist/index.d.ts +3 -0
  86. package/dist/index.js +358 -0
  87. package/dist/mcp/server.d.ts +3 -0
  88. package/dist/mcp/server.js +309 -0
  89. package/dist/providers/claude-api.d.ts +5 -0
  90. package/dist/providers/claude-api.js +75 -0
  91. package/dist/providers/claude-cli.d.ts +13 -0
  92. package/dist/providers/claude-cli.js +134 -0
  93. package/dist/providers/gemini.d.ts +8 -0
  94. package/dist/providers/gemini.js +78 -0
  95. package/dist/providers/index.d.ts +5 -0
  96. package/dist/providers/index.js +5 -0
  97. package/dist/providers/registry.d.ts +38 -0
  98. package/dist/providers/registry.js +121 -0
  99. package/dist/providers/types.d.ts +74 -0
  100. package/dist/providers/types.js +25 -0
  101. package/dist/runner/agent-supervisor.d.ts +363 -0
  102. package/dist/runner/agent-supervisor.js +1672 -0
  103. package/dist/scanner/context-builder.d.ts +2 -0
  104. package/dist/scanner/context-builder.js +29 -0
  105. package/dist/scanner/detector.d.ts +2 -0
  106. package/dist/scanner/detector.js +760 -0
  107. package/dist/scanner/types.d.ts +80 -0
  108. package/dist/scanner/types.js +50 -0
  109. package/dist/server/studio-server.d.ts +39 -0
  110. package/dist/server/studio-server.js +1931 -0
  111. package/dist/server/studio-ui.d.ts +15 -0
  112. package/dist/server/studio-ui.js +5321 -0
  113. package/package.json +70 -0
  114. package/plugin/.claude-plugin/plugin.json +18 -0
  115. package/plugin/.mcp.json +10 -0
  116. package/plugin/agents/architect.md +28 -0
  117. package/plugin/agents/verifier.md +28 -0
  118. package/plugin/agents/worker.md +26 -0
  119. package/plugin/hooks/hooks.json +28 -0
  120. package/plugin/skills/nativ/SKILL.md +75 -0
  121. package/templates/.claudeignore +45 -0
  122. package/templates/AGENTS.md +67 -0
  123. package/templates/CLAUDE.md +32 -0
  124. package/templates/GEMINI.md +66 -0
  125. package/templates/claude-agents/architect.md +28 -0
  126. package/templates/claude-agents/verifier.md +28 -0
  127. package/templates/claude-agents/worker.md +26 -0
  128. package/templates/dot-ai/api_contracts.json +66 -0
  129. package/templates/dot-ai/context.md +38 -0
  130. package/templates/dot-ai/db_schema.json +39 -0
  131. package/templates/dot-ai/master_plan.json +28 -0
  132. package/templates/dot-ai/subagents/backend.md +11 -0
  133. package/templates/dot-ai/subagents/database.md +10 -0
  134. package/templates/dot-ai/subagents/db-migration.md +11 -0
  135. package/templates/dot-ai/subagents/devops-agent.md +11 -0
  136. package/templates/dot-ai/subagents/flutter-developer.md +11 -0
  137. package/templates/dot-ai/subagents/frontend.md +126 -0
  138. package/templates/dot-ai/subagents/qa-tester.md +13 -0
  139. package/templates/dot-ai/subagents/security-auditor.md +11 -0
  140. package/templates/dot-ai/ui_specs.md +78 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * nativ · Single source for every file `nativ setup` generates.
3
+ *
4
+ * `planSetup` computes what each asset should contain and whether that differs from disk; `applySetup`
5
+ * writes the differences; `nativ doctor` reuses the same plan to detect drift. Everything merges into
6
+ * existing files: user-authored settings, servers and agent files are never overwritten.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { ROLE_ENV, loadEnforcementMode, parseJsonLoose } from './enforcement.js';
14
+ import { resolveProjectRoot } from './root-resolver.js';
15
+ import { isLegacyTemplate, normalizeForHash } from './legacy-templates.js';
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ export const MCP_SERVER_NAME = 'nativ';
18
+ export const AGENT_FILES = ['architect.md', 'worker.md', 'verifier.md'];
19
+ const MANAGED_PREFIX = '<!-- nativ:managed sha256=';
20
+ const MANAGED_PATTERN = /\n?<!-- nativ:managed sha256=([0-9a-f]{12}) -->\s*$/;
21
+ const PRE_TOOL_USE_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit';
22
+ const SESSION_START_MATCHER = 'startup|resume|clear|compact';
23
+ const HOOK_TIMEOUT_SECONDS = 10;
24
+ // ─── CLI invocation ────────────────────────────────────────────────────────────────────────────────
25
+ export function defaultHasBinary(name) {
26
+ try {
27
+ return spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' }).status === 0;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function quoteIfNeeded(part) {
34
+ return /[\s"]/.test(part) ? `"${part.replace(/"/g, '\\"')}"` : part;
35
+ }
36
+ export function resolveCliInvocation(options = {}) {
37
+ const explicit = options.command?.trim();
38
+ if (explicit) {
39
+ const [command, ...prefixArgs] = explicit.split(/\s+/);
40
+ return { command, prefixArgs, portable: ![command, ...prefixArgs].some((p) => path.isAbsolute(p)) };
41
+ }
42
+ if ((options.hasBinary ?? defaultHasBinary)('nativ'))
43
+ return { command: 'nativ', prefixArgs: [], portable: true };
44
+ // Not installed globally: fall back to this very build, which only works on this machine.
45
+ const self = path.resolve(__dirname, '..', '..', 'bin', 'cli.js').replace(/\\/g, '/');
46
+ return { command: 'node', prefixArgs: [self], portable: false };
47
+ }
48
+ /** The invocation as a shell string, e.g. `npx -y @njeybe/nativ`, quoting parts that need it. */
49
+ export function cliString(invocation) {
50
+ return [invocation.command, ...invocation.prefixArgs].map(quoteIfNeeded).join(' ');
51
+ }
52
+ // ─── Desired settings ──────────────────────────────────────────────────────────────────────────────
53
+ /** Secret files an agent must not read. Mirrors the env files the air-gap protects. */
54
+ const SECRET_READ_DENY = [
55
+ 'Read(./.env)',
56
+ 'Read(./.env.local)',
57
+ 'Read(./.env.development)',
58
+ 'Read(./.env.development.local)',
59
+ 'Read(./.env.production)',
60
+ 'Read(./.env.production.local)',
61
+ 'Read(./**/*.pem)',
62
+ 'Read(./**/*.key)',
63
+ ];
64
+ export function buildDesiredConfig(invocation) {
65
+ const cli = cliString(invocation);
66
+ const prefixes = [...new Set([cli, 'nativ'])];
67
+ return {
68
+ cli,
69
+ mcpServer: { command: invocation.command, args: [...invocation.prefixArgs, 'mcp'] },
70
+ allow: [`Bash(${cli} *)`, `mcp__${MCP_SERVER_NAME}__*`],
71
+ // Deny beats allow, so the broad `nativ *` allow cannot be used to lift a guardrail or write a contract.
72
+ deny: [
73
+ ...prefixes.flatMap((p) => [
74
+ `Bash(${p} task unlock *)`,
75
+ `Bash(${p} db sync *)`,
76
+ `Bash(${p} task complete * --no-verify)`,
77
+ `Bash(${p} task complete * --no-verify *)`,
78
+ ]),
79
+ ...SECRET_READ_DENY,
80
+ ],
81
+ // Contract edits always need a human's yes, whichever agent asks.
82
+ ask: ['Edit(./.ai/**)', 'Write(./.ai/**)'],
83
+ preToolUse: { matcher: PRE_TOOL_USE_MATCHER, command: `${cli} hook check`, timeout: HOOK_TIMEOUT_SECONDS },
84
+ sessionStart: { matcher: SESSION_START_MATCHER, command: `${cli} hook context`, timeout: HOOK_TIMEOUT_SECONDS },
85
+ };
86
+ }
87
+ function isObject(value) {
88
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
89
+ }
90
+ function unionStrings(existing, wanted) {
91
+ const list = Array.isArray(existing) ? existing.filter((v) => typeof v === 'string') : [];
92
+ const added = wanted.filter((w) => !list.includes(w));
93
+ return { list: [...list, ...added], added };
94
+ }
95
+ const isNativHookCommand = (command, kind) => typeof command === 'string' && new RegExp(`\\bhook ${kind}\\b`).test(command) && /nativ|cli\.js/.test(command);
96
+ /** Updates our hook in place when there is one, appends a group when there is not, leaves every other group alone. */
97
+ function mergeHook(groups, kind, want) {
98
+ const list = Array.isArray(groups) ? groups.filter(isObject) : [];
99
+ const desiredHook = { type: 'command', command: want.command, timeout: want.timeout };
100
+ for (const group of list) {
101
+ const hooks = Array.isArray(group.hooks) ? group.hooks : [];
102
+ const index = hooks.findIndex((h) => isNativHookCommand(h?.command, kind));
103
+ if (index === -1)
104
+ continue;
105
+ const before = JSON.stringify([group.matcher, hooks[index]]);
106
+ group.matcher = want.matcher;
107
+ hooks[index] = { ...hooks[index], ...desiredHook };
108
+ group.hooks = hooks;
109
+ return { groups: list, changed: JSON.stringify([group.matcher, hooks[index]]) !== before };
110
+ }
111
+ list.push({ matcher: want.matcher, hooks: [desiredHook] });
112
+ return { groups: list, changed: true };
113
+ }
114
+ export function mergeClaudeSettings(existing, desired) {
115
+ const merged = { ...existing };
116
+ const changes = [];
117
+ const warnings = [];
118
+ const permissions = isObject(merged.permissions) ? { ...merged.permissions } : {};
119
+ for (const [key, wanted] of [['allow', desired.allow], ['deny', desired.deny], ['ask', desired.ask]]) {
120
+ const { list, added } = unionStrings(permissions[key], wanted);
121
+ if (added.length) {
122
+ permissions[key] = list;
123
+ changes.push(`permissions.${key}: +${added.length}`);
124
+ }
125
+ }
126
+ merged.permissions = permissions;
127
+ const hooks = isObject(merged.hooks) ? { ...merged.hooks } : {};
128
+ const pre = mergeHook(hooks.PreToolUse, 'check', desired.preToolUse);
129
+ hooks.PreToolUse = pre.groups;
130
+ if (pre.changed)
131
+ changes.push('hooks.PreToolUse (nativ hook check)');
132
+ const start = mergeHook(hooks.SessionStart, 'context', desired.sessionStart);
133
+ hooks.SessionStart = start.groups;
134
+ if (start.changed)
135
+ changes.push('hooks.SessionStart (nativ hook context)');
136
+ merged.hooks = hooks;
137
+ const enabled = unionStrings(merged.enabledMcpjsonServers, [MCP_SERVER_NAME]);
138
+ if (enabled.added.length) {
139
+ merged.enabledMcpjsonServers = enabled.list;
140
+ changes.push('enabledMcpjsonServers: +nativ');
141
+ }
142
+ if (Array.isArray(merged.disabledMcpjsonServers) && merged.disabledMcpjsonServers.includes(MCP_SERVER_NAME)) {
143
+ warnings.push(`.claude/settings.json disables the "${MCP_SERVER_NAME}" MCP server; leaving that choice alone.`);
144
+ }
145
+ return { merged, changes, warnings };
146
+ }
147
+ export function mergeMcpConfig(existing, desired) {
148
+ const servers = isObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
149
+ const current = servers[MCP_SERVER_NAME];
150
+ const want = { command: desired.mcpServer.command, args: desired.mcpServer.args };
151
+ const same = isObject(current) && current.command === want.command && JSON.stringify(current.args) === JSON.stringify(want.args);
152
+ if (!same)
153
+ servers[MCP_SERVER_NAME] = isObject(current) ? { ...current, ...want } : want;
154
+ return { merged: { ...existing, mcpServers: servers }, changed: !same };
155
+ }
156
+ // ─── Managed markdown files ────────────────────────────────────────────────────────────────────────
157
+ const shortHash = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
158
+ /** CRLF and a leading BOM to plain LF. */
159
+ export const toLf = (text) => text.replace(/^/, '').replace(/\r\n/g, '\n');
160
+ /** Appends the marker that lets a later run tell "nativ wrote this, untouched" from "a person edited this". */
161
+ export function stampManaged(content) {
162
+ // Always LF: a Windows checkout (core.autocrlf) or an editor may hand us CRLF, and neither is an edit.
163
+ const body = toLf(content).replace(/\s+$/, '');
164
+ return `${body}\n\n${MANAGED_PREFIX}${shortHash(body)} -->\n`;
165
+ }
166
+ export function managedState(content) {
167
+ const lf = toLf(content);
168
+ const match = MANAGED_PATTERN.exec(lf);
169
+ if (!match)
170
+ return 'unmanaged';
171
+ const body = lf.slice(0, match.index).replace(/\s+$/, '');
172
+ return shortHash(body) === match[1] ? 'pristine' : 'modified';
173
+ }
174
+ /**
175
+ * Decides what to do with a file derived from a template (a directive or a role guide).
176
+ * Missing files are created; files nativ wrote and nobody edited (marker intact, or matching a template nativ
177
+ * shipped before markers existed) follow the template; anything edited by hand is kept unless `force` is set.
178
+ * `legacyKey` names the file in LEGACY_TEMPLATE_HASHES.
179
+ */
180
+ export function planTemplateFile(existing, template, legacyKey, force = false) {
181
+ const next = stampManaged(template);
182
+ if (existing === null)
183
+ return { action: 'created', content: next };
184
+ if (existing === next)
185
+ return { action: 'unchanged' };
186
+ const state = managedState(existing);
187
+ if (state === 'pristine')
188
+ return { action: 'updated', detail: 'template updated', content: next };
189
+ if (state === 'unmanaged') {
190
+ if (normalizeForHash(existing) === normalizeForHash(template))
191
+ return { action: 'updated', detail: 'now tracked by nativ', content: next };
192
+ if (isLegacyTemplate(legacyKey, existing))
193
+ return { action: 'updated', detail: 'refreshed from an earlier nativ template', content: next };
194
+ }
195
+ if (force)
196
+ return { action: 'updated', detail: 'forced', content: next };
197
+ return {
198
+ action: 'skipped',
199
+ detail: state === 'modified'
200
+ ? 'edited by hand (use --force to replace)'
201
+ : 'not an untouched nativ template, so treated as edited by hand (use --force to replace)',
202
+ };
203
+ }
204
+ // ─── Planning ──────────────────────────────────────────────────────────────────────────────────────
205
+ export function getTemplatesDir() {
206
+ const candidate = path.resolve(__dirname, '..', '..', 'templates');
207
+ return fs.existsSync(candidate) ? candidate : path.resolve(__dirname, '..', 'templates');
208
+ }
209
+ function readText(file) {
210
+ try {
211
+ return fs.readFileSync(file, 'utf8');
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ function serializeJson(value) {
218
+ return JSON.stringify(value, null, 2) + '\n';
219
+ }
220
+ /**
221
+ * The command already registered for the nativ MCP server in `.mcp.json`, when it has the shape setup writes
222
+ * (`<command> <args...> mcp`). Reusing it keeps hooks and permissions in step with what the project chose, so a
223
+ * plain `nativ setup` or `nativ doctor --fix` never silently swaps a deliberate `--command` for auto-detection.
224
+ */
225
+ export function configuredInvocation(root) {
226
+ const text = readText(path.join(root, '.mcp.json'));
227
+ if (text === null)
228
+ return null;
229
+ try {
230
+ const parsed = parseJsonLoose(text);
231
+ const server = isObject(parsed) && isObject(parsed.mcpServers) ? parsed.mcpServers[MCP_SERVER_NAME] : undefined;
232
+ if (!isObject(server) || typeof server.command !== 'string' || !server.command.trim())
233
+ return null;
234
+ const args = Array.isArray(server.args) ? server.args : [];
235
+ if (args.length === 0 || args[args.length - 1] !== 'mcp' || !args.every((a) => typeof a === 'string'))
236
+ return null;
237
+ const prefixArgs = args.slice(0, -1);
238
+ return { command: server.command, prefixArgs, portable: ![server.command, ...prefixArgs].some((p) => path.isAbsolute(p)) };
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
244
+ /** Computes what `nativ setup` would do, without touching disk. */
245
+ export function planSetup(rootArg, options = {}) {
246
+ const root = path.resolve(rootArg);
247
+ const invocation = options.command?.trim()
248
+ ? resolveCliInvocation({ command: options.command, hasBinary: options.hasBinary })
249
+ : configuredInvocation(root) ?? resolveCliInvocation({ hasBinary: options.hasBinary });
250
+ const desired = buildDesiredConfig(invocation);
251
+ const templatesDir = options.templatesDir ?? getTemplatesDir();
252
+ const changes = [];
253
+ const warnings = [];
254
+ const writes = new Map();
255
+ if (!invocation.portable) {
256
+ warnings.push('nativ is not on PATH, so the generated config points at this machine\'s install. ' +
257
+ 'Install it globally (npm i -g @njeybe/nativ) or pass --command "npx -y @njeybe/nativ" so the config works for your team.');
258
+ }
259
+ const record = (rel, existing, next, detail) => {
260
+ if (existing === null) {
261
+ changes.push({ path: rel, action: 'created', detail });
262
+ writes.set(rel, next);
263
+ }
264
+ else if (existing === next) {
265
+ changes.push({ path: rel, action: 'unchanged' });
266
+ }
267
+ else {
268
+ changes.push({ path: rel, action: 'updated', detail });
269
+ writes.set(rel, next);
270
+ }
271
+ };
272
+ const skip = (rel, detail) => {
273
+ changes.push({ path: rel, action: 'skipped', detail });
274
+ warnings.push(`${rel}: ${detail}`);
275
+ };
276
+ /** Reads a JSON file for merging. Returns null (after recording a skip) when it exists but is not valid JSON. */
277
+ const readJsonObject = (rel) => {
278
+ const text = readText(path.join(root, rel));
279
+ if (text === null || !text.trim())
280
+ return { text, value: {} };
281
+ try {
282
+ const parsed = parseJsonLoose(text);
283
+ if (isObject(parsed))
284
+ return { text, value: parsed };
285
+ }
286
+ catch {
287
+ // Fall through.
288
+ }
289
+ skip(rel, 'exists but is not a valid JSON object; not modified. Fix or delete it and re-run.');
290
+ return null;
291
+ };
292
+ // .mcp.json
293
+ const mcp = readJsonObject('.mcp.json');
294
+ if (mcp) {
295
+ const { merged, changed } = mergeMcpConfig(mcp.value, desired);
296
+ record('.mcp.json', mcp.text, changed || mcp.text === null ? serializeJson(merged) : mcp.text, changed ? `${MCP_SERVER_NAME} server -> ${desired.cli} mcp` : undefined);
297
+ }
298
+ // .claude/settings.json
299
+ const settings = readJsonObject('.claude/settings.json');
300
+ if (settings) {
301
+ const { merged, changes: settingChanges, warnings: settingWarnings } = mergeClaudeSettings(settings.value, desired);
302
+ warnings.push(...settingWarnings);
303
+ record('.claude/settings.json', settings.text, settingChanges.length || settings.text === null ? serializeJson(merged) : (settings.text ?? ''), settingChanges.join('; ') || undefined);
304
+ }
305
+ // .claude/agents/*.md: written only when missing, pristine, or forced
306
+ for (const file of AGENT_FILES) {
307
+ const rel = `.claude/agents/${file}`;
308
+ const template = readText(path.join(templatesDir, 'claude-agents', file));
309
+ if (template === null) {
310
+ skip(rel, `template templates/claude-agents/${file} is missing from this nativ install.`);
311
+ continue;
312
+ }
313
+ const next = stampManaged(template);
314
+ const existing = readText(path.join(root, rel));
315
+ if (existing === null || existing === next) {
316
+ record(rel, existing, next);
317
+ continue;
318
+ }
319
+ const state = managedState(existing);
320
+ if (state === 'pristine' || options.force)
321
+ record(rel, existing, next, state === 'pristine' ? 'template updated' : 'forced');
322
+ else
323
+ skip(rel, state === 'modified' ? 'was edited by hand; not overwritten (use --force to replace).' : 'exists and is not managed by nativ; not overwritten (use --force to replace).');
324
+ }
325
+ // AGENTS.md is the canonical directive; CLAUDE.md and GEMINI.md only ever get a pointer, and only when absent
326
+ const agents = readText(path.join(templatesDir, 'AGENTS.md'));
327
+ if (agents === null) {
328
+ skip('AGENTS.md', 'template templates/AGENTS.md is missing from this nativ install.');
329
+ }
330
+ else {
331
+ const next = stampManaged(agents);
332
+ const existing = readText(path.join(root, 'AGENTS.md'));
333
+ if (existing === null || existing === next)
334
+ record('AGENTS.md', existing, next);
335
+ else if (managedState(existing) === 'pristine' || options.force)
336
+ record('AGENTS.md', existing, next, 'template updated');
337
+ else
338
+ skip('AGENTS.md', 'exists and was not written by nativ (or was edited); not overwritten (use --force to replace).');
339
+ }
340
+ for (const [file, title] of [['CLAUDE.md', 'Claude Code'], ['GEMINI.md', 'Gemini / Antigravity']]) {
341
+ const existing = readText(path.join(root, file));
342
+ if (existing === null)
343
+ record(file, null, `# ${title} directive\n\n@AGENTS.md\n`);
344
+ else
345
+ changes.push({ path: file, action: 'unchanged', detail: /AGENTS\.md/.test(existing) ? undefined : 'exists; does not reference AGENTS.md' });
346
+ }
347
+ // .nativ/config.json
348
+ const config = readJsonObject('.nativ/config.json');
349
+ if (config) {
350
+ const next = { ...config.value };
351
+ let detail;
352
+ if (options.enforcement && next.enforcement !== options.enforcement) {
353
+ next.enforcement = options.enforcement;
354
+ detail = `enforcement=${options.enforcement}`;
355
+ }
356
+ else if (next.enforcement === undefined) {
357
+ next.enforcement = loadEnforcementMode(root);
358
+ detail = `enforcement=${String(next.enforcement)}`;
359
+ }
360
+ record('.nativ/config.json', config.text, detail || config.text === null ? serializeJson(next) : (config.text ?? ''), detail);
361
+ }
362
+ return { root, invocation, changes, warnings, writes };
363
+ }
364
+ /** Writes the planned differences. Idempotent: applying twice changes nothing the second time. */
365
+ export function applySetup(rootArg, options = {}) {
366
+ const plan = planSetup(rootArg, options);
367
+ if (!options.dryRun) {
368
+ for (const [rel, content] of plan.writes) {
369
+ const file = path.join(plan.root, rel);
370
+ fs.mkdirSync(path.dirname(file), { recursive: true });
371
+ const tmp = `${file}.${process.pid}.tmp`;
372
+ fs.writeFileSync(tmp, content, 'utf8');
373
+ fs.renameSync(tmp, file);
374
+ }
375
+ }
376
+ return { root: plan.root, dryRun: options.dryRun === true, invocation: plan.invocation, changes: plan.changes, warnings: plan.warnings };
377
+ }
378
+ function readPlanTasks(root) {
379
+ try {
380
+ const plan = parseJsonLoose(fs.readFileSync(path.join(root, '.ai', 'master_plan.json'), 'utf8'));
381
+ const tasks = [];
382
+ for (const milestone of plan.milestones ?? []) {
383
+ for (const t of milestone.tasks ?? []) {
384
+ if (typeof t.id === 'string') {
385
+ tasks.push({
386
+ id: t.id,
387
+ title: typeof t.title === 'string' ? t.title : '',
388
+ status: typeof t.status === 'string' ? t.status : 'pending',
389
+ dependencies: Array.isArray(t.dependencies) ? t.dependencies : [],
390
+ targetFiles: Array.isArray(t.targetFiles) ? t.targetFiles : [],
391
+ });
392
+ }
393
+ }
394
+ }
395
+ return tasks;
396
+ }
397
+ catch {
398
+ return [];
399
+ }
400
+ }
401
+ /** A few lines of orientation for a new Claude Code session, or null when this is not a nativ project. */
402
+ export function buildSessionContext(cwd, env = process.env) {
403
+ const root = resolveProjectRoot(cwd);
404
+ if (!fs.existsSync(path.join(root, '.ai', 'master_plan.json')))
405
+ return null;
406
+ const tasks = readPlanTasks(root);
407
+ const active = tasks.filter((t) => t.status === 'in_progress');
408
+ const done = new Set(tasks.filter((t) => t.status === 'completed').map((t) => t.id));
409
+ const next = tasks.find((t) => t.status === 'pending' && t.dependencies.every((d) => done.has(d)));
410
+ const role = (env[ROLE_ENV] ?? '').trim().toLowerCase() || 'project manager';
411
+ const lines = [
412
+ 'This is a nativ project (role-based multi-agent workflow).',
413
+ `Session role: ${role}. Enforcement: ${loadEnforcementMode(root)}. Edits outside the active task's targetFiles are flagged, and .ai/ contracts are architect-only.`,
414
+ ];
415
+ if (active.length) {
416
+ for (const t of active)
417
+ lines.push(`In progress: ${t.id} "${t.title}" (targetFiles: ${t.targetFiles.join(', ') || 'none'})`);
418
+ }
419
+ else if (next) {
420
+ lines.push(`Next available task: ${next.id} "${next.title}". Run \`nativ task next\` to load its context.`);
421
+ }
422
+ else {
423
+ lines.push('No task is in progress or ready.');
424
+ }
425
+ lines.push('Prefer the nativ_* MCP tools (or the `nativ` CLI). Do not edit .ai/ directly: escalate contract gaps with `nativ task escalate`.');
426
+ return lines.join('\n');
427
+ }
428
+ /** `nativ hook context`: prints the SessionStart hook output. Never throws and always prints valid JSON or nothing. */
429
+ export function runSessionContext(targetDirArg, write = (t) => process.stdout.write(t)) {
430
+ try {
431
+ const context = buildSessionContext(targetDirArg ? path.resolve(targetDirArg) : process.env.CLAUDE_PROJECT_DIR || process.cwd());
432
+ if (context)
433
+ write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } }));
434
+ }
435
+ catch {
436
+ // Orientation is a convenience; a failure must never disturb session start.
437
+ }
438
+ }
@@ -0,0 +1,156 @@
1
+ import { MasterPlanTask } from '../scanner/types.js';
2
+ import { VerificationResult } from './verifier.js';
3
+ export interface TokenEstimate {
4
+ inputEstimated: number;
5
+ outputEstimated: number;
6
+ totalEstimated: number;
7
+ costUsdEstimated: number;
8
+ }
9
+ export interface TaskTelemetryRecord {
10
+ taskId: string;
11
+ title: string;
12
+ assignedSubagent: string;
13
+ startedAt: string;
14
+ completedAt?: string;
15
+ durationMs: number;
16
+ status: 'in_progress' | 'completed' | 'failed' | 'blocked';
17
+ verification?: {
18
+ command: string;
19
+ durationMs: number;
20
+ exitCode: number;
21
+ skipped: boolean;
22
+ };
23
+ tokens?: TokenEstimate;
24
+ /** Grounded usage reported by the Messages API across every native run of this task. */
25
+ actualUsage?: ActualTokenUsage;
26
+ notes?: string;
27
+ }
28
+ /**
29
+ * Token counts reported by the Messages API (`response.usage`), accumulated
30
+ * across agent turns. `inputTokens` excludes cached tokens, matching the API.
31
+ */
32
+ export interface ActualTokenUsage {
33
+ model: string;
34
+ turns: number;
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ cacheCreationTokens: number;
38
+ cacheReadTokens: number;
39
+ /** Subset of `outputTokens` spent on internal reasoning (0 when not reported). */
40
+ thinkingTokens: number;
41
+ costUsd: number;
42
+ }
43
+ export interface TelemetrySummary {
44
+ totalTasksCompleted: number;
45
+ totalDurationMs: number;
46
+ estimatedTotalTokens: number;
47
+ estimatedTotalCostUsd: number;
48
+ verificationPassRate: number;
49
+ totalVerificationsRun: number;
50
+ totalVerificationsPassed: number;
51
+ circuitBreakerTrips: number;
52
+ /** Total role-enforcement violations recorded, including ones that aged out of `violations`. */
53
+ roleViolations?: number;
54
+ /** Grounded spend across all native runs, including failed ones (billing is billing). */
55
+ actualSpendUsd?: number;
56
+ actualInputTokens?: number;
57
+ actualOutputTokens?: number;
58
+ cacheReadTokens?: number;
59
+ cacheCreationTokens?: number;
60
+ thinkingTokens?: number;
61
+ /** cacheRead / (input + cacheRead + cacheCreation), in [0, 1]. */
62
+ cacheHitRate?: number;
63
+ }
64
+ /** One write that left its role: an out-of-scope edit, a contract edit by a non-architect, or a secret file. */
65
+ export interface RoleViolationRecord {
66
+ at: string;
67
+ rule: 'protected_path' | 'secret_path' | 'out_of_scope';
68
+ /** `warn` was logged and allowed; `block` was denied. */
69
+ mode: 'warn' | 'block';
70
+ tool: string;
71
+ /** Project-relative path with forward slashes. */
72
+ path: string;
73
+ taskIds: string[];
74
+ }
75
+ /** Oldest entries are dropped past this, so a chatty agent cannot grow telemetry.json without bound. */
76
+ export declare const MAX_ROLE_VIOLATIONS = 200;
77
+ export interface ProjectTelemetry {
78
+ $schema?: string;
79
+ version: string;
80
+ projectName: string;
81
+ lastUpdated: string;
82
+ modelTierDefault: string;
83
+ summary: TelemetrySummary;
84
+ tasks: TaskTelemetryRecord[];
85
+ violations?: RoleViolationRecord[];
86
+ }
87
+ /** Model whose rates price heuristic estimates; matches the native engine's default model. */
88
+ export declare const DEFAULT_TELEMETRY_MODEL = "claude-opus-5-5";
89
+ /** List price of DEFAULT_TELEMETRY_MODEL; also the fallback for models missing from MODEL_PRICING. */
90
+ export declare const INPUT_TOKEN_COST_PER_M = 4;
91
+ export declare const OUTPUT_TOKEN_COST_PER_M = 20;
92
+ export interface ModelPricing {
93
+ inputPerM: number;
94
+ outputPerM: number;
95
+ /** 5-minute ephemeral cache writes. */
96
+ cacheWritePerM: number;
97
+ cacheReadPerM: number;
98
+ }
99
+ /** First-party Claude API list prices in USD per 1M tokens. */
100
+ export declare const MODEL_PRICING: Readonly<Record<string, ModelPricing>>;
101
+ /** True when MODEL_PRICING knows the model (exactly or as a dated / suffixed variant). */
102
+ export declare function isPricedModel(model: unknown): model is string;
103
+ /** Resolves pricing by exact ID, then by longest known prefix (e.g. dated or `-fast` variants). */
104
+ export declare function resolveModelPricing(model: string): ModelPricing;
105
+ /** Prices one usage slice with the model's input, output, cache-write and cache-read rates. */
106
+ export declare function computeActualCostUsd(model: string, usage: Pick<ActualTokenUsage, 'inputTokens' | 'outputTokens' | 'cacheCreationTokens' | 'cacheReadTokens'>): number;
107
+ export declare function emptyActualUsage(model: string): ActualTokenUsage;
108
+ /** Sums two usage records; the result keeps `a.model` unless `a` is empty. */
109
+ export declare function mergeActualUsage(a: ActualTokenUsage, b: ActualTokenUsage): ActualTokenUsage;
110
+ export declare function cacheHitRate(usage: Pick<ActualTokenUsage, 'inputTokens' | 'cacheCreationTokens' | 'cacheReadTokens'>): number;
111
+ /** Prices estimated input/output tokens at a model's list rates. */
112
+ export declare function priceEstimate(model: string, inputEstimated: number, outputEstimated: number): number;
113
+ /**
114
+ * Calculates estimated tokens and cost based on contract slice, target files, and diff heuristics.
115
+ */
116
+ export declare function estimateTokens(targetDir: string, task: MasterPlanTask, model?: string): TokenEstimate;
117
+ /**
118
+ * Loads project telemetry from .ai/telemetry.json or returns a fresh initialized state.
119
+ */
120
+ export declare function loadTelemetry(telemetryPath: string, projectName?: string): ProjectTelemetry;
121
+ /**
122
+ * Atomically writes telemetry to disk with Windows EPERM resilience.
123
+ */
124
+ export declare function saveTelemetry(telemetryPath: string, telemetry: ProjectTelemetry): boolean;
125
+ /**
126
+ * Recomputes aggregate summary metrics across all recorded tasks.
127
+ */
128
+ export declare function recomputeTelemetrySummary(telemetry: ProjectTelemetry): void;
129
+ /**
130
+ * Records the start of an agent task in .ai/telemetry.json.
131
+ */
132
+ export declare function recordTaskStart(targetDir: string, task: MasterPlanTask): Promise<void>;
133
+ /**
134
+ * Records completion of an agent task, tracking duration, verification results, tokens, and cost.
135
+ */
136
+ export declare function recordTaskComplete(targetDir: string, task: MasterPlanTask, verificationResult?: VerificationResult | null, notes?: string): Promise<TaskTelemetryRecord | null>;
137
+ /**
138
+ * Folds one native run's API-reported usage into the task's grounded telemetry.
139
+ * Creates the task record when the run was dispatched without `nativ task start`.
140
+ */
141
+ export declare function recordRunnerUsage(targetDir: string, task: Pick<MasterPlanTask, 'id' | 'title' | 'assignedSubagent'>, usage: ActualTokenUsage): Promise<TaskTelemetryRecord>;
142
+ /**
143
+ * Logs a role-enforcement violation to .ai/telemetry.json. Best effort: it never throws, because the
144
+ * hook that calls it must not fail an agent's tool call over bookkeeping.
145
+ */
146
+ export declare function recordRoleViolation(targetDir: string, violation: Omit<RoleViolationRecord, 'at'>): Promise<void>;
147
+ /**
148
+ * Increments circuit breaker trips counter in telemetry.
149
+ */
150
+ export declare function recordCircuitBreakerTrip(targetDir: string, taskId: string): Promise<void>;
151
+ /**
152
+ * Returns formatted telemetry dashboard summary lines for CLI display.
153
+ */
154
+ export declare function formatTelemetrySummary(telemetry: ProjectTelemetry, plan?: {
155
+ completedTasks: number;
156
+ }): string;