@njeybe/nativ 2.4.1 → 2.5.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +26 -0
  3. package/dist/commands/db/db-formatter.d.ts +5 -0
  4. package/dist/commands/db/db-formatter.js +116 -0
  5. package/dist/commands/db/db-introspect.d.ts +11 -0
  6. package/dist/commands/db/db-introspect.js +84 -0
  7. package/dist/commands/db.js +4 -206
  8. package/dist/commands/profile.d.ts +3 -0
  9. package/dist/commands/profile.js +39 -0
  10. package/dist/commands/task/task-add.d.ts +11 -0
  11. package/dist/commands/task/task-add.js +169 -0
  12. package/dist/commands/task/task-common.d.ts +57 -0
  13. package/dist/commands/task/task-common.js +45 -0
  14. package/dist/commands/task/task-escalate.d.ts +7 -0
  15. package/dist/commands/task/task-escalate.js +119 -0
  16. package/dist/commands/task/task-lifecycle.d.ts +5 -0
  17. package/dist/commands/task/task-lifecycle.js +252 -0
  18. package/dist/commands/task/task-query.d.ts +10 -0
  19. package/dist/commands/task/task-query.js +269 -0
  20. package/dist/commands/task.d.ts +9 -87
  21. package/dist/commands/task.js +8 -850
  22. package/dist/commands/triage/triage-store.d.ts +56 -0
  23. package/dist/commands/triage/triage-store.js +108 -0
  24. package/dist/commands/triage/triage-ui.d.ts +14 -0
  25. package/dist/commands/triage/triage-ui.js +104 -0
  26. package/dist/commands/triage.d.ts +3 -52
  27. package/dist/commands/triage.js +8 -216
  28. package/dist/core/enforcement.d.ts +1 -0
  29. package/dist/core/enforcement.js +19 -0
  30. package/dist/core/human-gate.d.ts +2 -1
  31. package/dist/core/human-gate.js +9 -2
  32. package/dist/core/profiles.d.ts +14 -0
  33. package/dist/core/profiles.js +82 -0
  34. package/dist/core/setup/config-merger.d.ts +59 -0
  35. package/dist/core/setup/config-merger.js +107 -0
  36. package/dist/core/setup/session-context.d.ts +4 -0
  37. package/dist/core/setup/session-context.js +65 -0
  38. package/dist/core/setup/setup-planner.d.ts +14 -0
  39. package/dist/core/setup/setup-planner.js +180 -0
  40. package/dist/core/setup/template-planner.d.ts +8 -0
  41. package/dist/core/setup/template-planner.js +32 -0
  42. package/dist/core/setup/types.d.ts +65 -0
  43. package/dist/core/setup/types.js +55 -0
  44. package/dist/core/setup-assets.d.ts +8 -119
  45. package/dist/core/setup-assets.js +7 -437
  46. package/dist/core/tier1-liaison.d.ts +2 -0
  47. package/dist/core/tier1-liaison.js +64 -3
  48. package/dist/core/verifier/executor.d.ts +5 -0
  49. package/dist/core/verifier/executor.js +62 -0
  50. package/dist/core/verifier/phases.d.ts +5 -0
  51. package/dist/core/verifier/phases.js +71 -0
  52. package/dist/core/verifier/task-verifier.d.ts +37 -0
  53. package/dist/core/verifier/task-verifier.js +233 -0
  54. package/dist/core/verifier/types.d.ts +51 -0
  55. package/dist/core/verifier/types.js +1 -0
  56. package/dist/core/verifier.d.ts +5 -93
  57. package/dist/core/verifier.js +5 -359
  58. package/dist/db/introspector.js +59 -59
  59. package/dist/governor/evaluator.d.ts +4 -64
  60. package/dist/governor/evaluator.js +21 -686
  61. package/dist/governor/patch-applier.d.ts +8 -0
  62. package/dist/governor/patch-applier.js +121 -0
  63. package/dist/governor/self-healing.d.ts +24 -0
  64. package/dist/governor/self-healing.js +351 -0
  65. package/dist/governor/test-integrity.d.ts +41 -0
  66. package/dist/governor/test-integrity.js +237 -0
  67. package/dist/index.js +8 -0
  68. package/dist/mcp/capture.d.ts +4 -0
  69. package/dist/mcp/capture.js +33 -0
  70. package/dist/mcp/resources.d.ts +39 -0
  71. package/dist/mcp/resources.js +44 -0
  72. package/dist/mcp/server.d.ts +11 -0
  73. package/dist/mcp/server.js +14 -89
  74. package/dist/mcp/tools.d.ts +1 -0
  75. package/dist/mcp/tools.js +1 -0
  76. package/dist/runner/agent-supervisor.d.ts +8 -302
  77. package/dist/runner/agent-supervisor.js +41 -1030
  78. package/dist/runner/cli-config.d.ts +84 -0
  79. package/dist/runner/cli-config.js +215 -0
  80. package/dist/runner/native-driver.d.ts +18 -0
  81. package/dist/runner/native-driver.js +208 -0
  82. package/dist/runner/native-engine.d.ts +27 -0
  83. package/dist/runner/native-engine.js +184 -0
  84. package/dist/runner/native-tools.d.ts +31 -0
  85. package/dist/runner/native-tools.js +241 -0
  86. package/dist/runner/process-utils.d.ts +12 -0
  87. package/dist/runner/process-utils.js +96 -0
  88. package/dist/runner/run-lifecycle.d.ts +6 -0
  89. package/dist/runner/run-lifecycle.js +42 -0
  90. package/dist/runner/types.d.ts +215 -0
  91. package/dist/runner/types.js +41 -0
  92. package/dist/scanner/detector.js +42 -663
  93. package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
  94. package/dist/scanner/detectors/ecosystem-detector.js +354 -0
  95. package/dist/scanner/detectors/orm-detector.d.ts +5 -0
  96. package/dist/scanner/detectors/orm-detector.js +59 -0
  97. package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
  98. package/dist/scanner/detectors/workspace-detector.js +266 -0
  99. package/dist/server/cli-runner.d.ts +15 -0
  100. package/dist/server/cli-runner.js +43 -0
  101. package/dist/server/event-hub.d.ts +42 -0
  102. package/dist/server/event-hub.js +169 -0
  103. package/dist/server/http-utils.d.ts +15 -0
  104. package/dist/server/http-utils.js +56 -0
  105. package/dist/server/plan-utils.d.ts +9 -0
  106. package/dist/server/plan-utils.js +31 -0
  107. package/dist/server/routes/db-routes.d.ts +57 -0
  108. package/dist/server/routes/db-routes.js +484 -0
  109. package/dist/server/routes/task-routes.d.ts +266 -0
  110. package/dist/server/routes/task-routes.js +285 -0
  111. package/dist/server/routes/telemetry-routes.d.ts +128 -0
  112. package/dist/server/routes/telemetry-routes.js +179 -0
  113. package/dist/server/routes/triage-routes.d.ts +81 -0
  114. package/dist/server/routes/triage-routes.js +244 -0
  115. package/dist/server/routes/worktree-routes.d.ts +31 -0
  116. package/dist/server/routes/worktree-routes.js +144 -0
  117. package/dist/server/studio-server.d.ts +4 -11
  118. package/dist/server/studio-server.js +34 -1578
  119. package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
  120. package/dist/server/studio-ui/shell/triage.js +10 -0
  121. package/package.json +2 -2
  122. package/plugin/.claude-plugin/plugin.json +1 -1
  123. package/plugin/agents/architect.md +4 -2
  124. package/plugin/agents/verifier.md +1 -1
  125. package/plugin/agents/worker.md +7 -4
  126. package/plugin/skills/nativ/SKILL.md +1 -0
  127. package/templates/.claudeignore +62 -45
  128. package/templates/AGENTS.md +1 -0
  129. package/templates/CLAUDE.md +13 -0
  130. package/templates/claude-agents/architect.md +4 -2
  131. package/templates/claude-agents/verifier.md +1 -1
  132. package/templates/claude-agents/worker.md +7 -4
  133. package/templates/dot-ai/api_contracts.json +66 -66
  134. package/templates/dot-ai/context.md +38 -38
  135. package/templates/dot-ai/db_schema.json +39 -39
  136. package/templates/dot-ai/subagents/architect.md +54 -0
  137. package/dist/commands/session.d.ts +0 -22
  138. package/dist/commands/session.js +0 -106
  139. package/dist/core/session-memory.d.ts +0 -42
  140. package/dist/core/session-memory.js +0 -121
@@ -0,0 +1,8 @@
1
+ import { AuditLogEntry, ContractPatch } from './types.js';
2
+ export declare function getContractHash(filePath: string): string | null;
3
+ export declare function appendAuditLog(targetDir: string, entry: AuditLogEntry): void;
4
+ export declare function applyDbPatch(schema: any, patch: ContractPatch): any;
5
+ export declare function applyApiPatch(contracts: any, patch: ContractPatch): any;
6
+ export declare function atomicWriteJson(filePath: string, data: any): boolean;
7
+ export declare function contractFileFor(target: ContractPatch['target']): string;
8
+ export declare function applyPatch(contract: any, patch: ContractPatch): any;
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ export function getContractHash(filePath) {
5
+ if (!fs.existsSync(filePath))
6
+ return null;
7
+ try {
8
+ const content = fs.readFileSync(filePath, 'utf8');
9
+ return crypto.createHash('sha256').update(content).digest('hex');
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export function appendAuditLog(targetDir, entry) {
16
+ const auditPath = path.join(targetDir, '.ai', 'audit_log.jsonl');
17
+ try {
18
+ fs.mkdirSync(path.dirname(auditPath), { recursive: true });
19
+ fs.appendFileSync(auditPath, JSON.stringify(entry) + '\n', 'utf8');
20
+ }
21
+ catch {
22
+ // Non-fatal
23
+ }
24
+ }
25
+ export function applyDbPatch(schema, patch) {
26
+ const { operation, path: targetPath, value } = patch;
27
+ const segments = targetPath.toLowerCase().split('.').filter(Boolean);
28
+ const tableName = segments[0] === 'tables' ? segments[1] : segments[0];
29
+ const targetType = segments.includes('columns')
30
+ ? 'column'
31
+ : (segments.includes('indexes') ? 'index' : (segments.includes('foreignkeys') ? 'foreignKey' : 'table'));
32
+ const propertyName = segments.length > 2 ? segments[segments.length - 1] : undefined;
33
+ schema.tables = schema.tables || [];
34
+ if (operation === 'ADD') {
35
+ if (targetType === 'table') {
36
+ schema.tables.push(value || { name: tableName, columns: [], indexes: [], foreignKeys: [] });
37
+ }
38
+ else {
39
+ const table = schema.tables.find((t) => t.name?.toLowerCase() === tableName.toLowerCase());
40
+ if (table) {
41
+ if (targetType === 'column') {
42
+ table.columns = table.columns || [];
43
+ table.columns.push(value || { name: propertyName, type: 'TEXT', nullable: true });
44
+ }
45
+ else if (targetType === 'index') {
46
+ table.indexes = table.indexes || [];
47
+ table.indexes.push(value);
48
+ }
49
+ else if (targetType === 'foreignKey') {
50
+ table.foreignKeys = table.foreignKeys || [];
51
+ table.foreignKeys.push(value);
52
+ }
53
+ }
54
+ }
55
+ }
56
+ else if (operation === 'ALTER') {
57
+ const table = schema.tables.find((t) => t.name?.toLowerCase() === tableName.toLowerCase());
58
+ if (table && targetType === 'column' && propertyName) {
59
+ const colIdx = table.columns?.findIndex((c) => c.name?.toLowerCase() === propertyName.toLowerCase());
60
+ if (colIdx !== undefined && colIdx >= 0) {
61
+ table.columns[colIdx] = { ...table.columns[colIdx], ...value };
62
+ }
63
+ }
64
+ }
65
+ schema.updatedAt = new Date().toISOString();
66
+ return schema;
67
+ }
68
+ export function applyApiPatch(contracts, patch) {
69
+ const { operation, path: targetPath, value } = patch;
70
+ const segments = targetPath.toLowerCase().split('.').filter(Boolean);
71
+ const endpointIdOrPath = segments[0] === 'endpoints' ? segments[1] : segments[0];
72
+ contracts.endpoints = contracts.endpoints || [];
73
+ if (operation === 'ADD') {
74
+ const isEndpoint = segments.length <= 2 && value?.path && value?.method;
75
+ if (isEndpoint) {
76
+ contracts.endpoints.push(value);
77
+ }
78
+ else {
79
+ const ep = contracts.endpoints.find((e) => e.id?.toLowerCase() === endpointIdOrPath.toLowerCase() ||
80
+ e.path?.toLowerCase() === endpointIdOrPath.toLowerCase());
81
+ if (ep) {
82
+ if (segments.includes('queryparams')) {
83
+ ep.request = ep.request || {};
84
+ ep.request.queryParams = ep.request.queryParams || {};
85
+ ep.request.queryParams[segments[segments.length - 1]] = value;
86
+ }
87
+ else if (segments.includes('responses') || segments.includes('response')) {
88
+ ep.responses = ep.responses || {};
89
+ const status = segments.find((s) => /^\d{3}$/.test(s)) || '200';
90
+ ep.responses[status] = ep.responses[status] || { description: 'Success', body: {} };
91
+ if (typeof value === 'object' && value !== null) {
92
+ ep.responses[status].body = { ...ep.responses[status].body, ...value };
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ contracts.updatedAt = new Date().toISOString();
99
+ return contracts;
100
+ }
101
+ export function atomicWriteJson(filePath, data) {
102
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
103
+ try {
104
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
105
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
106
+ fs.renameSync(tmpPath, filePath);
107
+ return true;
108
+ }
109
+ catch {
110
+ if (fs.existsSync(tmpPath)) {
111
+ fs.rmSync(tmpPath, { force: true });
112
+ }
113
+ return false;
114
+ }
115
+ }
116
+ export function contractFileFor(target) {
117
+ return target === 'db_schema' ? 'db_schema.json' : 'api_contracts.json';
118
+ }
119
+ export function applyPatch(contract, patch) {
120
+ return patch.target === 'db_schema' ? applyDbPatch(contract, patch) : applyApiPatch(contract, patch);
121
+ }
@@ -0,0 +1,24 @@
1
+ import { ContractPatch, RuleEvaluationResult } from './types.js';
2
+ import { type CandidatePatch, type ContractPatchProposal, type SelfHealingProposal, type VerificationProof } from './circuit-breaker.js';
3
+ /**
4
+ * Replays a candidate against a throwaway copy of the contract in the OS temp
5
+ * directory: governor rules, apply, re-parse, and proof that it changes
6
+ * something while the live `.ai/` file stays byte-identical.
7
+ */
8
+ export declare function verifyCandidateInSandbox(targetDir: string, taskId: string, candidate: CandidatePatch): VerificationProof;
9
+ /**
10
+ * Builds the first backward-compatible candidate that survives an isolated
11
+ * replay. Returns null when no candidate can be proven (a human decides).
12
+ */
13
+ export declare function generateSelfHealingProposal(targetDir: string, patch: ContractPatch, rule: RuleEvaluationResult): ContractPatchProposal | null;
14
+ export interface ProposalApplication {
15
+ applied: boolean;
16
+ ruleId: string;
17
+ message: string;
18
+ }
19
+ /**
20
+ * Applies an operator-approved proposal. Contract patches are re-checked
21
+ * against the live contract and refused if it moved since the proof was taken.
22
+ * Human-approved patches do not count against the task's patch budget.
23
+ */
24
+ export declare function applySelfHealingProposal(targetDir: string, taskId: string, proposal: SelfHealingProposal): ProposalApplication;
@@ -0,0 +1,351 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { evaluateDbPatch } from './rules/db-rules.js';
7
+ import { evaluateApiPatch } from './rules/api-rules.js';
8
+ import { CircuitBreaker, } from './circuit-breaker.js';
9
+ import { getContractHash, appendAuditLog, atomicWriteJson, contractFileFor, applyPatch, } from './patch-applier.js';
10
+ function runRules(patch, contract) {
11
+ return patch.target === 'db_schema' ? evaluateDbPatch(patch, contract) : evaluateApiPatch(patch, contract);
12
+ }
13
+ /** Hash of the contract's content, ignoring the timestamps every write refreshes. */
14
+ function semanticHash(data) {
15
+ const { updatedAt: _u, lastUpdated: _l, ...rest } = data ?? {};
16
+ return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex');
17
+ }
18
+ function toCandidate(patch) {
19
+ const { taskId: _taskId, ...candidate } = patch;
20
+ return candidate;
21
+ }
22
+ /** Mirrors the rule modules' path parsing: `[tables.]<table>.columns.<column>`. */
23
+ function locateDbColumn(contract, patchPath) {
24
+ const segments = patchPath.split('.').filter(Boolean);
25
+ const lower = segments.map((s) => s.toLowerCase());
26
+ const tableName = lower[0] === 'tables' ? lower[1] : lower[0];
27
+ const columnName = segments.length > 2 ? segments[segments.length - 1] : undefined;
28
+ const table = (contract?.tables || []).find((t) => t.name?.toLowerCase() === tableName);
29
+ const column = table?.columns?.find((c) => c.name?.toLowerCase() === columnName?.toLowerCase());
30
+ return { table, column, columnName };
31
+ }
32
+ function withLastSegment(patchPath, name) {
33
+ const segments = patchPath.split('.').filter(Boolean);
34
+ segments[segments.length - 1] = name;
35
+ return segments.join('.');
36
+ }
37
+ /**
38
+ * Backward-compatible alternatives for a rejected patch, ordered by how
39
+ * closely they preserve the agent's intent. Every candidate is still proven
40
+ * in a sandbox before it is offered.
41
+ */
42
+ function buildCandidates(patch, rule, contract, liveHash) {
43
+ const base = toCandidate(patch);
44
+ const note = (strategy) => `${patch.reason} [self-healing: ${strategy}]`;
45
+ const isColumn = /(^|\.)columns(\.|$)/i.test(patch.path);
46
+ const firstViolation = rule.violations[0] || rule.message;
47
+ const value = patch.value && typeof patch.value === 'object' ? patch.value : undefined;
48
+ const options = [];
49
+ switch (rule.ruleId) {
50
+ case 'CONCURRENT_MODIFICATION_DETECTED':
51
+ options.push({
52
+ strategy: 'rebase_on_current_contract',
53
+ rationale: 'The contract changed after the agent read it; replay the same patch against the current revision.',
54
+ candidate: { ...base, baseHash: liveHash ?? undefined, reason: note('rebase_on_current_contract') },
55
+ });
56
+ break;
57
+ case 'TASK_PATCH_BUDGET_EXCEEDED':
58
+ options.push({
59
+ strategy: 'budget_override',
60
+ rationale: 'The patch itself is safe; only the per-task budget stopped it. Approve to grant one more patch.',
61
+ candidate: { ...base, reason: note('budget_override') },
62
+ });
63
+ break;
64
+ case 'DB_ADD_NOT_NULL_FORBIDDEN':
65
+ case 'DB_CIRCULAR_FK_FORBIDDEN':
66
+ options.push({
67
+ strategy: 'relax_to_nullable',
68
+ rationale: 'Add the field as nullable so existing rows stay valid; tighten it later with a backfill and a default.',
69
+ candidate: { ...base, value: { ...(value ?? {}), nullable: true }, reason: note('relax_to_nullable') },
70
+ });
71
+ break;
72
+ case 'DB_DROP_FORBIDDEN':
73
+ if (isColumn) {
74
+ options.push({
75
+ strategy: 'deprecate_instead_of_drop',
76
+ rationale: 'Keep the column but mark it deprecated and nullable, so readers keep working while writers migrate off it.',
77
+ candidate: {
78
+ ...base,
79
+ operation: 'ALTER',
80
+ value: { nullable: true, deprecated: true },
81
+ reason: note('deprecate_instead_of_drop'),
82
+ },
83
+ });
84
+ }
85
+ break;
86
+ case 'DB_RENAME_FORBIDDEN': {
87
+ const newName = typeof patch.value === 'string' ? patch.value : value?.name ?? value?.to;
88
+ const { column } = locateDbColumn(contract, patch.path);
89
+ if (isColumn && column && typeof newName === 'string' && newName) {
90
+ options.push({
91
+ strategy: 'expand_contract_rename',
92
+ rationale: `Add '${newName}' alongside '${column.name}' (expand), backfill it, and retire the old column in a later reviewed migration (contract).`,
93
+ candidate: {
94
+ ...base,
95
+ operation: 'ADD',
96
+ path: withLastSegment(patch.path, newName),
97
+ value: { ...column, name: newName, nullable: true, primaryKey: false },
98
+ reason: note('expand_contract_rename'),
99
+ },
100
+ });
101
+ }
102
+ break;
103
+ }
104
+ case 'DB_ALTER_DESTRUCTIVE': {
105
+ const { column } = locateDbColumn(contract, patch.path);
106
+ if (isColumn && column && typeof value?.type === 'string') {
107
+ const suffix = value.type.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'v2';
108
+ const shadowName = `${column.name}_${suffix}`;
109
+ options.push({
110
+ strategy: 'shadow_column_for_type_change',
111
+ rationale: `Add '${shadowName}' with the new type next to '${column.name}', backfill it, then switch readers over.`,
112
+ candidate: {
113
+ ...base,
114
+ operation: 'ADD',
115
+ path: withLastSegment(patch.path, shadowName),
116
+ value: { ...column, ...value, name: shadowName, nullable: true, primaryKey: false },
117
+ reason: note('shadow_column_for_type_change'),
118
+ },
119
+ });
120
+ }
121
+ break;
122
+ }
123
+ case 'DB_COLLISION_OR_TARGET_MISMATCH': {
124
+ const { columnName } = locateDbColumn(contract, patch.path);
125
+ if (isColumn && patch.operation === 'ADD' && firstViolation.startsWith('PATH_COLLISION')) {
126
+ const { name: _name, ...changes } = value ?? {};
127
+ options.push({
128
+ strategy: 'alter_existing_instead_of_add',
129
+ rationale: 'The column already exists; alter it in place instead of adding a duplicate.',
130
+ candidate: { ...base, operation: 'ALTER', value: changes, reason: note('alter_existing_instead_of_add') },
131
+ });
132
+ }
133
+ else if (isColumn && patch.operation === 'ALTER' && columnName && firstViolation.startsWith('TARGET_NOT_FOUND')) {
134
+ options.push({
135
+ strategy: 'add_missing_target',
136
+ rationale: `Column '${columnName}' does not exist yet; add it as a nullable column instead of altering it.`,
137
+ candidate: {
138
+ ...base,
139
+ operation: 'ADD',
140
+ value: { name: columnName, type: 'TEXT', ...(value ?? {}), nullable: true },
141
+ reason: note('add_missing_target'),
142
+ },
143
+ });
144
+ }
145
+ break;
146
+ }
147
+ case 'API_ADD_REQUIRED_FORBIDDEN': {
148
+ const relaxed = typeof patch.value === 'string'
149
+ ? patch.value.replace(/required/gi, 'optional')
150
+ : { ...(value ?? {}), required: false, ...(value?.nullable === false ? { nullable: true } : {}) };
151
+ options.push({
152
+ strategy: 'relax_to_optional',
153
+ rationale: 'Add the parameter as optional so existing clients keep working; make it required in a versioned endpoint later.',
154
+ candidate: { ...base, value: relaxed, reason: note('relax_to_optional') },
155
+ });
156
+ break;
157
+ }
158
+ case 'API_RENAME_FORBIDDEN': {
159
+ const segments = patch.path.split('.').filter(Boolean);
160
+ const key = (segments[0]?.toLowerCase() === 'endpoints' ? segments[1] : segments[0])?.toLowerCase();
161
+ const endpoint = (contract?.endpoints || []).find((ep) => ep.id?.toLowerCase() === key || ep.path?.toLowerCase() === key);
162
+ const newPath = typeof patch.value === 'string' ? patch.value : value?.path;
163
+ if (endpoint && typeof newPath === 'string' && newPath) {
164
+ const newId = `${endpoint.id || 'endpoint'}-v2`;
165
+ options.push({
166
+ strategy: 'expand_contract_rename',
167
+ rationale: `Serve '${newPath}' as a new endpoint beside '${endpoint.path}' and deprecate the old route once clients have moved.`,
168
+ candidate: {
169
+ ...base,
170
+ operation: 'ADD',
171
+ path: `endpoints.${newId}`,
172
+ value: { ...JSON.parse(JSON.stringify(endpoint)), id: newId, path: newPath },
173
+ reason: note('expand_contract_rename'),
174
+ },
175
+ });
176
+ }
177
+ break;
178
+ }
179
+ }
180
+ return options;
181
+ }
182
+ /**
183
+ * Replays a candidate against a throwaway copy of the contract in the OS temp
184
+ * directory: governor rules, apply, re-parse, and proof that it changes
185
+ * something while the live `.ai/` file stays byte-identical.
186
+ */
187
+ export function verifyCandidateInSandbox(targetDir, taskId, candidate) {
188
+ const started = Date.now();
189
+ const fileName = contractFileFor(candidate.target);
190
+ const livePath = path.join(targetDir, '.ai', fileName);
191
+ const liveHashBefore = getContractHash(livePath);
192
+ const checks = [];
193
+ let candidateHash = null;
194
+ let verdict;
195
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'nativ-proof-'));
196
+ try {
197
+ const sandboxPath = path.join(sandbox, fileName);
198
+ fs.copyFileSync(livePath, sandboxPath);
199
+ checks.push({ name: 'sandbox_isolated', passed: true, detail: `Replayed against a copy of .ai/${fileName} outside the workspace` });
200
+ const before = JSON.parse(fs.readFileSync(sandboxPath, 'utf8'));
201
+ const patch = { taskId, ...candidate };
202
+ const result = runRules(patch, before);
203
+ verdict = { approved: result.approved, blastRadius: result.blastRadius, ruleId: result.ruleId, message: result.message };
204
+ const safe = result.approved && result.blastRadius === 'LOW_ADDITIVE';
205
+ checks.push({ name: 'governor_rules', passed: safe, detail: `${result.ruleId}: ${result.message}` });
206
+ if (safe) {
207
+ const after = applyPatch(JSON.parse(JSON.stringify(before)), patch);
208
+ const written = atomicWriteJson(sandboxPath, after);
209
+ let reparsed = null;
210
+ try {
211
+ reparsed = JSON.parse(fs.readFileSync(sandboxPath, 'utf8'));
212
+ }
213
+ catch {
214
+ // Reported by the check below.
215
+ }
216
+ const collection = candidate.target === 'db_schema' ? reparsed?.tables : reparsed?.endpoints;
217
+ checks.push({
218
+ name: 'contract_parses',
219
+ passed: written && Array.isArray(collection),
220
+ detail: written ? 'Patched contract round-trips as valid JSON' : 'Sandbox write failed',
221
+ });
222
+ const effective = reparsed !== null && semanticHash(reparsed) !== semanticHash(before);
223
+ checks.push({
224
+ name: 'patch_effective',
225
+ passed: effective,
226
+ detail: effective ? 'Candidate changes the contract' : 'Candidate would be a no-op for the governor applier',
227
+ });
228
+ candidateHash = getContractHash(sandboxPath);
229
+ }
230
+ }
231
+ catch (err) {
232
+ checks.push({ name: 'sandbox_replay', passed: false, detail: err?.message ?? String(err) });
233
+ }
234
+ finally {
235
+ fs.rmSync(sandbox, { recursive: true, force: true });
236
+ }
237
+ const untouched = getContractHash(livePath) === liveHashBefore;
238
+ checks.push({
239
+ name: 'live_contract_untouched',
240
+ passed: untouched,
241
+ detail: untouched ? `.ai/${fileName} unchanged during the replay` : `.ai/${fileName} changed during the replay`,
242
+ });
243
+ return {
244
+ isolated: true,
245
+ method: 'sandbox_governor_replay',
246
+ passed: checks.every((c) => c.passed),
247
+ checks,
248
+ baseContractHash: liveHashBefore,
249
+ candidateContractHash: candidateHash,
250
+ verdict,
251
+ verifiedAt: new Date().toISOString(),
252
+ durationMs: Date.now() - started,
253
+ };
254
+ }
255
+ /**
256
+ * Builds the first backward-compatible candidate that survives an isolated
257
+ * replay. Returns null when no candidate can be proven (a human decides).
258
+ */
259
+ export function generateSelfHealingProposal(targetDir, patch, rule) {
260
+ const livePath = path.join(targetDir, '.ai', contractFileFor(patch.target));
261
+ let contract;
262
+ try {
263
+ contract = JSON.parse(fs.readFileSync(livePath, 'utf8'));
264
+ }
265
+ catch {
266
+ return null; // Missing or corrupt contracts need a human, not a patch.
267
+ }
268
+ const options = buildCandidates(patch, rule, contract, getContractHash(livePath));
269
+ let evaluated = 0;
270
+ for (const option of options) {
271
+ evaluated++;
272
+ const proof = verifyCandidateInSandbox(targetDir, patch.taskId, option.candidate);
273
+ if (!proof.passed)
274
+ continue;
275
+ return {
276
+ kind: 'contract_patch',
277
+ proposalId: `heal-${crypto.randomUUID().slice(0, 8)}`,
278
+ strategy: option.strategy,
279
+ rationale: option.rationale,
280
+ requiresHumanApproval: true,
281
+ candidatesEvaluated: evaluated,
282
+ verificationProof: proof,
283
+ generatedAt: new Date().toISOString(),
284
+ candidate: option.candidate,
285
+ original: toCandidate(patch),
286
+ };
287
+ }
288
+ return null;
289
+ }
290
+ /**
291
+ * Applies an operator-approved proposal. Contract patches are re-checked
292
+ * against the live contract and refused if it moved since the proof was taken.
293
+ * Human-approved patches do not count against the task's patch budget.
294
+ */
295
+ export function applySelfHealingProposal(targetDir, taskId, proposal) {
296
+ if (proposal.kind === 'restore_tests') {
297
+ const res = spawnSync('git', ['checkout', proposal.baselineRef, '--', ...proposal.files], {
298
+ cwd: targetDir,
299
+ encoding: 'utf8',
300
+ windowsHide: true,
301
+ });
302
+ if (res.status !== 0) {
303
+ return { applied: false, ruleId: 'TEST_RESTORE_FAILED', message: String(res.stderr || res.stdout || 'git checkout failed').trim() };
304
+ }
305
+ CircuitBreaker.recordSuccess(targetDir, taskId);
306
+ return { applied: true, ruleId: 'TEST_SUITE_RESTORED', message: `Restored ${proposal.files.length} test file(s) from ${proposal.baselineRef}` };
307
+ }
308
+ const candidate = proposal.candidate;
309
+ const fileName = contractFileFor(candidate.target);
310
+ const livePath = path.join(targetDir, '.ai', fileName);
311
+ const liveHash = getContractHash(livePath);
312
+ const provenAgainst = proposal.verificationProof.baseContractHash;
313
+ if (provenAgainst && liveHash !== provenAgainst) {
314
+ return {
315
+ applied: false,
316
+ ruleId: 'PROPOSAL_STALE',
317
+ message: `.ai/${fileName} changed after proposal ${proposal.proposalId} was verified; regenerate it against the current contract.`,
318
+ };
319
+ }
320
+ let contract;
321
+ try {
322
+ contract = JSON.parse(fs.readFileSync(livePath, 'utf8'));
323
+ }
324
+ catch (err) {
325
+ return { applied: false, ruleId: 'CONTRACT_JSON_CORRUPT', message: `Failed to read .ai/${fileName}: ${err.message}` };
326
+ }
327
+ const patch = { taskId, ...candidate };
328
+ const result = runRules(patch, contract);
329
+ if (!result.approved || result.blastRadius !== 'LOW_ADDITIVE') {
330
+ return { applied: false, ruleId: result.ruleId, message: result.message };
331
+ }
332
+ const written = atomicWriteJson(livePath, applyPatch(contract, patch));
333
+ appendAuditLog(targetDir, {
334
+ timestamp: new Date().toISOString(),
335
+ taskId,
336
+ target: candidate.target,
337
+ operation: candidate.operation,
338
+ path: candidate.path,
339
+ blastRadius: 'LOW_ADDITIVE',
340
+ ruleId: 'SELF_HEALING_PROPOSAL_APPLIED',
341
+ reason: `${proposal.proposalId} (${proposal.strategy}): ${candidate.reason}`,
342
+ applied: written,
343
+ });
344
+ if (written)
345
+ CircuitBreaker.recordSuccess(targetDir, taskId);
346
+ return {
347
+ applied: written,
348
+ ruleId: written ? 'SELF_HEALING_PROPOSAL_APPLIED' : 'CONTRACT_WRITE_FAILED',
349
+ message: written ? `Applied ${proposal.strategy} to .ai/${fileName}` : `Could not write .ai/${fileName}`,
350
+ };
351
+ }
@@ -0,0 +1,41 @@
1
+ import { RuleEvaluationResult } from './types.js';
2
+ import { type TestRestorationProposal } from './circuit-breaker.js';
3
+ export type TestIntegrityRule = 'TEST_SUITE_DELETED' | 'TEST_ASSERTIONS_REMOVED' | 'TEST_CASES_REMOVED' | 'TESTS_SKIPPED' | 'TESTS_FOCUSED' | 'TEST_SCRIPT_WEAKENED';
4
+ export interface TestIntegrityFinding {
5
+ rule: TestIntegrityRule;
6
+ /** Path relative to the project root. */
7
+ path: string;
8
+ detail: string;
9
+ }
10
+ export interface TestIntegrityReport extends RuleEvaluationResult {
11
+ /** Resolved commit the working tree was compared against; null when the check was skipped. */
12
+ baselineRef: string | null;
13
+ /** True when the baseline came from `nativ task start` rather than falling back to HEAD. */
14
+ baselineRecorded: boolean;
15
+ findings: TestIntegrityFinding[];
16
+ skippedReason?: string;
17
+ }
18
+ export interface TestSuiteMetrics {
19
+ assertions: number;
20
+ testCases: number;
21
+ skipped: number;
22
+ focused: number;
23
+ }
24
+ export declare function isTestFile(file: string): boolean;
25
+ /** Heuristic, language-agnostic counts; only deltas against the baseline matter. */
26
+ export declare function measureTestSuite(source: string): TestSuiteMetrics;
27
+ /** Current commit of the checkout at `targetDir`, or null outside git / before the first commit. */
28
+ export declare function resolveGitHead(targetDir: string): string | null;
29
+ /**
30
+ * Enforces that a task never deletes or weakens the test suites that existed
31
+ * when it started: no deleted test files, no fewer assertions or cases, no new
32
+ * skip/only markers, and no test files dropped from package.json test scripts.
33
+ */
34
+ export declare class TestIntegrityGuard {
35
+ static evaluate(targetDir: string, taskId: string): TestIntegrityReport;
36
+ /**
37
+ * Proposal to restore every flagged file from the baseline, with proof that
38
+ * each baseline blob exists and what the restored suite measures.
39
+ */
40
+ static proposeRestoration(targetDir: string, report: TestIntegrityReport): TestRestorationProposal | null;
41
+ }