@delegance/claude-autopilot 5.0.7 → 5.2.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 (50) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -1
  3. package/dist/src/adapters/review-engine/parse-output.js +21 -5
  4. package/dist/src/cli/fix.js +21 -3
  5. package/dist/src/cli/index.js +130 -2
  6. package/dist/src/cli/init-migrate.d.ts +35 -0
  7. package/dist/src/cli/init-migrate.js +299 -0
  8. package/dist/src/cli/migrate-doctor.d.ts +19 -0
  9. package/dist/src/cli/migrate-doctor.js +191 -0
  10. package/dist/src/core/migrate/alias-resolver.d.ts +18 -0
  11. package/dist/src/core/migrate/alias-resolver.js +150 -0
  12. package/dist/src/core/migrate/audit-log.d.ts +30 -0
  13. package/dist/src/core/migrate/audit-log.js +100 -0
  14. package/dist/src/core/migrate/contract.d.ts +27 -0
  15. package/dist/src/core/migrate/contract.js +35 -0
  16. package/dist/src/core/migrate/detector-rules.d.ts +26 -0
  17. package/dist/src/core/migrate/detector-rules.js +147 -0
  18. package/dist/src/core/migrate/detector.d.ts +16 -0
  19. package/dist/src/core/migrate/detector.js +105 -0
  20. package/dist/src/core/migrate/dispatcher.d.ts +19 -0
  21. package/dist/src/core/migrate/dispatcher.js +358 -0
  22. package/dist/src/core/migrate/doctor-checks.d.ts +19 -0
  23. package/dist/src/core/migrate/doctor-checks.js +304 -0
  24. package/dist/src/core/migrate/envelope.d.ts +25 -0
  25. package/dist/src/core/migrate/envelope.js +84 -0
  26. package/dist/src/core/migrate/executor.d.ts +33 -0
  27. package/dist/src/core/migrate/executor.js +102 -0
  28. package/dist/src/core/migrate/handshake.d.ts +17 -0
  29. package/dist/src/core/migrate/handshake.js +130 -0
  30. package/dist/src/core/migrate/migrator.d.ts +34 -0
  31. package/dist/src/core/migrate/migrator.js +302 -0
  32. package/dist/src/core/migrate/monorepo.d.ts +2 -0
  33. package/dist/src/core/migrate/monorepo.js +114 -0
  34. package/dist/src/core/migrate/policy-enforcer.d.ts +28 -0
  35. package/dist/src/core/migrate/policy-enforcer.js +111 -0
  36. package/dist/src/core/migrate/result-parser.d.ts +16 -0
  37. package/dist/src/core/migrate/result-parser.js +152 -0
  38. package/dist/src/core/migrate/schema-validator.d.ts +11 -0
  39. package/dist/src/core/migrate/schema-validator.js +103 -0
  40. package/dist/src/core/migrate/types.d.ts +49 -0
  41. package/dist/src/core/migrate/types.js +3 -0
  42. package/package.json +5 -1
  43. package/presets/aliases.lock.json +20 -0
  44. package/presets/schemas/migrate.schema.json +134 -0
  45. package/skills/autopilot/SKILL.md +29 -9
  46. package/skills/migrate/skill.manifest.json +7 -0
  47. package/skills/migrate-none/SKILL.md +40 -0
  48. package/skills/migrate-none/skill.manifest.json +7 -0
  49. package/skills/migrate-supabase/SKILL.md +126 -0
  50. package/skills/migrate-supabase/skill.manifest.json +7 -0
@@ -0,0 +1,147 @@
1
+ // src/core/migrate/detector-rules.ts
2
+ //
3
+ // Detection rules for the init flow. Each rule has explicit confidence;
4
+ // detector returns all matches with their confidence so the UI can
5
+ // auto-select (1 high) or prompt (>1 or any non-high).
6
+ export const DETECTION_RULES = [
7
+ {
8
+ name: 'nextjs-supabase',
9
+ stack: 'nextjs-supabase',
10
+ confidence: 'high',
11
+ requireAll: ['data/deltas', '.claude/supabase-envs.json'],
12
+ defaultSkill: 'migrate.supabase@1',
13
+ promptOnSelect: false,
14
+ },
15
+ {
16
+ name: 'supabase-cli',
17
+ stack: 'supabase-cli',
18
+ confidence: 'high',
19
+ requireAll: ['supabase/migrations'],
20
+ excludeIf: ['data/deltas'],
21
+ defaultSkill: 'migrate@1',
22
+ defaultCommand: { exec: 'supabase', args: ['migration', 'up'] },
23
+ promptOnSelect: false,
24
+ },
25
+ {
26
+ name: 'prisma-migrate',
27
+ stack: 'prisma-migrate',
28
+ confidence: 'high',
29
+ requireAll: ['prisma/schema.prisma', 'prisma/migrations'],
30
+ defaultSkill: 'migrate@1',
31
+ defaultCommand: { exec: 'prisma', args: ['migrate', 'dev'] },
32
+ promptOnSelect: false,
33
+ },
34
+ {
35
+ name: 'prisma-push',
36
+ stack: 'prisma-push',
37
+ confidence: 'low',
38
+ requireAll: ['prisma/schema.prisma'],
39
+ excludeIf: ['prisma/migrations'],
40
+ defaultSkill: 'migrate@1',
41
+ promptOnSelect: true,
42
+ },
43
+ {
44
+ name: 'drizzle-migrate',
45
+ stack: 'drizzle-migrate',
46
+ confidence: 'high',
47
+ requireAll: ['drizzle/migrations'],
48
+ requireAny: ['drizzle.config.ts', 'drizzle.config.js'],
49
+ defaultSkill: 'migrate@1',
50
+ defaultCommand: { exec: 'drizzle-kit', args: ['migrate'] },
51
+ promptOnSelect: false,
52
+ },
53
+ {
54
+ name: 'drizzle-push',
55
+ stack: 'drizzle-push',
56
+ confidence: 'low',
57
+ requireAll: [],
58
+ requireAny: ['drizzle.config.ts', 'drizzle.config.js'],
59
+ excludeIf: ['drizzle/migrations'],
60
+ defaultSkill: 'migrate@1',
61
+ promptOnSelect: true,
62
+ },
63
+ {
64
+ name: 'rails',
65
+ stack: 'rails',
66
+ confidence: 'high',
67
+ requireAll: ['db/migrate', 'Gemfile'],
68
+ contentMatches: { file: 'Gemfile', pattern: /\brails\b/ },
69
+ defaultSkill: 'migrate@1',
70
+ defaultCommand: { exec: 'rails', args: ['db:migrate'] },
71
+ promptOnSelect: false,
72
+ },
73
+ {
74
+ name: 'golang-migrate',
75
+ stack: 'golang-migrate',
76
+ confidence: 'high',
77
+ requireAll: ['go.mod', 'migrate'],
78
+ defaultSkill: 'migrate@1',
79
+ promptOnSelect: false,
80
+ },
81
+ {
82
+ name: 'flyway',
83
+ stack: 'flyway',
84
+ confidence: 'high',
85
+ requireAll: [],
86
+ requireAny: ['flyway.conf', 'flyway.toml'],
87
+ defaultSkill: 'migrate@1',
88
+ defaultCommand: { exec: 'flyway', args: ['migrate'] },
89
+ promptOnSelect: false,
90
+ },
91
+ {
92
+ name: 'dbmate',
93
+ stack: 'dbmate',
94
+ confidence: 'high',
95
+ requireAll: ['dbmate'],
96
+ defaultSkill: 'migrate@1',
97
+ defaultCommand: { exec: 'dbmate', args: ['up'] },
98
+ promptOnSelect: false,
99
+ },
100
+ {
101
+ name: 'alembic',
102
+ stack: 'alembic',
103
+ confidence: 'medium',
104
+ requireAll: ['alembic.ini'],
105
+ defaultSkill: 'migrate@1',
106
+ defaultCommand: { exec: 'alembic', args: ['upgrade', 'head'] },
107
+ promptOnSelect: true,
108
+ },
109
+ {
110
+ name: 'django',
111
+ stack: 'django',
112
+ confidence: 'medium',
113
+ requireAll: ['manage.py'],
114
+ requireGlob: ['*/migrations/0001_*.py'],
115
+ defaultSkill: 'migrate@1',
116
+ defaultCommand: { exec: 'python', args: ['manage.py', 'migrate'] },
117
+ promptOnSelect: true,
118
+ },
119
+ {
120
+ name: 'ecto',
121
+ stack: 'ecto',
122
+ confidence: 'medium',
123
+ requireAll: ['mix.exs', 'priv/repo/migrations'],
124
+ defaultSkill: 'migrate@1',
125
+ defaultCommand: { exec: 'mix', args: ['ecto.migrate'] },
126
+ promptOnSelect: true,
127
+ },
128
+ {
129
+ name: 'typeorm',
130
+ stack: 'typeorm',
131
+ confidence: 'medium',
132
+ requireAll: [],
133
+ requireAny: ['ormconfig.json', 'ormconfig.ts', 'ormconfig.js', 'data-source.ts'],
134
+ defaultSkill: 'migrate@1',
135
+ promptOnSelect: true,
136
+ },
137
+ {
138
+ name: 'supabase-bare',
139
+ stack: 'supabase-bare',
140
+ confidence: 'low',
141
+ requireAll: ['supabase'],
142
+ excludeIf: ['supabase/migrations', 'data/deltas'],
143
+ defaultSkill: 'migrate@1',
144
+ promptOnSelect: true,
145
+ },
146
+ ];
147
+ //# sourceMappingURL=detector-rules.js.map
@@ -0,0 +1,16 @@
1
+ import { type DetectionRule, type Confidence } from './detector-rules.ts';
2
+ export interface DetectionMatch {
3
+ rule: DetectionRule;
4
+ /** Same as rule.confidence — surfaced for the UI's convenience. */
5
+ confidence: Confidence;
6
+ }
7
+ export interface DetectionOutput {
8
+ matches: DetectionMatch[];
9
+ /** True when we have a single high-confidence match the caller can
10
+ * auto-select. False when caller should prompt the user. */
11
+ autoSelect: boolean;
12
+ /** True when caller should prompt the user (>1 match, or any non-high). */
13
+ prompt: boolean;
14
+ }
15
+ export declare function detect(projectRoot: string): DetectionOutput;
16
+ //# sourceMappingURL=detector.d.ts.map
@@ -0,0 +1,105 @@
1
+ // src/core/migrate/detector.ts
2
+ //
3
+ // Runs detection rules against a project root. Returns ALL matching
4
+ // rules with their confidence. The init flow uses this:
5
+ // - 1 high-confidence match → auto-select, write stack.md
6
+ // - >1 match OR any non-high → prompt user
7
+ // - 0 matches → fail closed (require --skip-migrate)
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { DETECTION_RULES } from "./detector-rules.js";
11
+ function entryExists(projectRoot, rel) {
12
+ try {
13
+ fs.statSync(path.join(projectRoot, rel));
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function fileMatches(projectRoot, rel, pattern) {
21
+ try {
22
+ const content = fs.readFileSync(path.join(projectRoot, rel), 'utf8');
23
+ return pattern.test(content);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function matchesGlob(projectRoot, glob) {
30
+ // Handle simple patterns: '*/migrations/0001_*.py'
31
+ // We do depth-2 directory walk for simplicity.
32
+ const parts = glob.split('/');
33
+ if (parts.length < 2)
34
+ return false;
35
+ const firstStar = parts[0] === '*';
36
+ if (!firstStar) {
37
+ // No leading wildcard; fall back to direct existence
38
+ return entryExists(projectRoot, glob);
39
+ }
40
+ // Depth-1 dirs under projectRoot, then check the rest
41
+ let dirs = [];
42
+ try {
43
+ dirs = fs.readdirSync(projectRoot, { withFileTypes: true })
44
+ .filter(d => d.isDirectory())
45
+ .map(d => d.name);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ const remaining = parts.slice(1);
51
+ for (const dir of dirs) {
52
+ const subPath = path.join(projectRoot, dir, ...remaining.slice(0, -1));
53
+ const lastPart = remaining[remaining.length - 1];
54
+ // last part may be a glob like 0001_*.py
55
+ let entries = [];
56
+ try {
57
+ entries = fs.readdirSync(subPath);
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ const lastRegex = new RegExp('^' + lastPart.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
63
+ if (entries.some(e => lastRegex.test(e)))
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ function ruleApplies(rule, projectRoot) {
69
+ for (const r of rule.requireAll) {
70
+ if (!entryExists(projectRoot, r))
71
+ return false;
72
+ }
73
+ if (rule.requireAny && rule.requireAny.length > 0) {
74
+ if (!rule.requireAny.some(r => entryExists(projectRoot, r)))
75
+ return false;
76
+ }
77
+ if (rule.requireGlob && rule.requireGlob.length > 0) {
78
+ if (!rule.requireGlob.every(g => matchesGlob(projectRoot, g)))
79
+ return false;
80
+ }
81
+ if (rule.contentMatches) {
82
+ if (!fileMatches(projectRoot, rule.contentMatches.file, rule.contentMatches.pattern))
83
+ return false;
84
+ }
85
+ if (rule.excludeIf) {
86
+ for (const ex of rule.excludeIf) {
87
+ if (entryExists(projectRoot, ex))
88
+ return false;
89
+ }
90
+ }
91
+ return true;
92
+ }
93
+ export function detect(projectRoot) {
94
+ const matches = [];
95
+ for (const rule of DETECTION_RULES) {
96
+ if (ruleApplies(rule, projectRoot)) {
97
+ matches.push({ rule, confidence: rule.confidence });
98
+ }
99
+ }
100
+ const highMatches = matches.filter(m => m.confidence === 'high');
101
+ const autoSelect = matches.length === 1 && highMatches.length === 1;
102
+ const prompt = matches.length > 0 && !autoSelect;
103
+ return { matches, autoSelect, prompt };
104
+ }
105
+ //# sourceMappingURL=detector.js.map
@@ -0,0 +1,19 @@
1
+ import type { ResultArtifact } from './types.ts';
2
+ export interface DispatchOptions {
3
+ repoRoot: string;
4
+ env: string;
5
+ yesFlag: boolean;
6
+ nonInteractive: boolean;
7
+ /** Runtime version, normally from package.json. */
8
+ currentRuntimeVersion: string;
9
+ /** Override env_file lookup (mainly for tests) */
10
+ envOverride?: Record<string, string>;
11
+ /** Optional changedFiles for envelope (passes through verbatim) */
12
+ changedFiles?: string[];
13
+ /** dryRun pass-through to the envelope */
14
+ dryRun?: boolean;
15
+ /** projectId for monorepo invocations */
16
+ projectId?: string;
17
+ }
18
+ export declare function dispatch(opts: DispatchOptions): Promise<ResultArtifact>;
19
+ //# sourceMappingURL=dispatcher.d.ts.map
@@ -0,0 +1,358 @@
1
+ // src/core/migrate/dispatcher.ts
2
+ //
3
+ // Orchestrates the full migrate flow:
4
+ // 1. Read .autopilot/stack.md, validate schema
5
+ // 2. Resolve migrate.skill via alias map (path-escape protected)
6
+ // 3. Skill manifest handshake (runtime range + API version)
7
+ // 4. Build invocation envelope (UUID, nonce, git refs)
8
+ // 5. Enforce policy (4-flag CI prod gate, clean git, etc.)
9
+ // 6. Execute the env command via spawn(shell:false), capturing the
10
+ // result artifact (file or nonce-bound stdout fallback)
11
+ // 7. Parse the result artifact, validate identity
12
+ // 8. Append audit log entry (seq + prev_hash)
13
+ //
14
+ // Fails closed at every step. Always emits an audit entry, even on
15
+ // failure, so operators can see what was attempted.
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+ import * as crypto from 'node:crypto';
20
+ import * as yaml from 'js-yaml';
21
+ import { validateStackMd } from "./schema-validator.js";
22
+ import { resolveSkill } from "./alias-resolver.js";
23
+ import { performHandshake } from "./handshake.js";
24
+ import { buildEnvelope } from "./envelope.js";
25
+ import { enforcePolicy } from "./policy-enforcer.js";
26
+ import { executeCommand } from "./executor.js";
27
+ import { parseResult } from "./result-parser.js";
28
+ import { appendAuditEvent } from "./audit-log.js";
29
+ import { ENVELOPE_CONTRACT_VERSION, RESULT_TEMPDIR_MODE } from "./contract.js";
30
+ const DEFAULT_POLICY = {
31
+ allow_prod_in_ci: false,
32
+ require_clean_git: true,
33
+ require_manual_approval: true,
34
+ require_dry_run_first: false,
35
+ };
36
+ function readStackMd(repoRoot) {
37
+ const stackPath = path.join(repoRoot, '.autopilot', 'stack.md');
38
+ if (!fs.existsSync(stackPath))
39
+ return { ok: false, reason: 'stack.md not found' };
40
+ const raw = fs.readFileSync(stackPath, 'utf8');
41
+ let parsed;
42
+ try {
43
+ parsed = yaml.load(raw);
44
+ }
45
+ catch {
46
+ return { ok: false, reason: 'stack.md YAML parse failed' };
47
+ }
48
+ return { ok: true, raw, parsed: parsed };
49
+ }
50
+ function loadEnvFile(envFilePath, repoRoot) {
51
+ const abs = path.resolve(repoRoot, envFilePath);
52
+ if (!fs.existsSync(abs))
53
+ return {};
54
+ const out = {};
55
+ for (const rawLine of fs.readFileSync(abs, 'utf8').split('\n')) {
56
+ const line = rawLine.trim();
57
+ if (!line || line.startsWith('#'))
58
+ continue;
59
+ // Accept lowercase, uppercase, and mixed-case identifiers — many real-world
60
+ // env files (e.g. local docker postgres) use lowercase like `database_url`.
61
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
62
+ if (!m)
63
+ continue;
64
+ let value = m[2];
65
+ // Strip balanced surrounding quotes (single or double) to mirror typical
66
+ // dotenv parsing behavior.
67
+ if (value.length >= 2 &&
68
+ ((value.startsWith('"') && value.endsWith('"')) ||
69
+ (value.startsWith("'") && value.endsWith("'")))) {
70
+ value = value.slice(1, -1);
71
+ }
72
+ out[m[1]] = value;
73
+ }
74
+ return out;
75
+ }
76
+ function hashEnvelope(env) {
77
+ return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(env)).digest('hex');
78
+ }
79
+ async function emitAuditFailure(repoRoot, opts, reasonCode, partial) {
80
+ try {
81
+ await appendAuditEvent(path.join(repoRoot, '.autopilot', 'audit.log'), {
82
+ invocationId: partial.invocationId ?? 'pre-envelope',
83
+ event: 'dispatch',
84
+ requested_skill: partial.requested ?? 'unknown',
85
+ resolved_skill: partial.resolved ?? 'unknown',
86
+ skill_path: partial.skillPath ?? '',
87
+ envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
88
+ skill_runtime_api_version: partial.apiVersion ?? 'unknown',
89
+ envelope_hash: partial.envelopeHash ?? '',
90
+ policy_decisions: partial.decisions ?? [],
91
+ mode: opts.dryRun ? 'dry-run' : 'apply',
92
+ actor: process.env.USER ?? 'unknown',
93
+ ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
94
+ ci_run_id: process.env.GITHUB_RUN_ID ?? null,
95
+ result_status: `error:${reasonCode}`,
96
+ duration_ms: partial.durationMs ?? 0,
97
+ });
98
+ }
99
+ catch {
100
+ // last-ditch: don't let audit-log failure mask the real error
101
+ }
102
+ }
103
+ function synthErr(reasonCode, invocationId = 'pre-envelope', nonce = '') {
104
+ return {
105
+ contractVersion: ENVELOPE_CONTRACT_VERSION,
106
+ skillId: 'unknown',
107
+ invocationId,
108
+ nonce,
109
+ status: 'error',
110
+ reasonCode,
111
+ appliedMigrations: [],
112
+ destructiveDetected: false,
113
+ sideEffectsPerformed: ['no-side-effects'],
114
+ nextActions: [],
115
+ };
116
+ }
117
+ export async function dispatch(opts) {
118
+ const t0 = Date.now();
119
+ // 1. Read + validate stack.md
120
+ const stackResult = readStackMd(opts.repoRoot);
121
+ if (!stackResult.ok) {
122
+ await emitAuditFailure(opts.repoRoot, opts, 'invalid-stack-config', {});
123
+ return synthErr('invalid-stack-config');
124
+ }
125
+ const validation = validateStackMd(stackResult.raw);
126
+ if (!validation.valid) {
127
+ const msg = validation.errors.map(e => e.message).join('; ');
128
+ // Map known validator errors to a more specific reason code where possible
129
+ const reasonCode = /stableSkillId|skillId-not-in-registry/.test(msg)
130
+ ? 'stable-id-unknown'
131
+ : 'invalid-stack-config';
132
+ await emitAuditFailure(opts.repoRoot, opts, reasonCode, {});
133
+ return synthErr(reasonCode);
134
+ }
135
+ const requestedSkill = stackResult.parsed.migrate.skill;
136
+ // 2. Resolve alias
137
+ const resolved = resolveSkill(requestedSkill, { repoRoot: opts.repoRoot });
138
+ if (!resolved.ok) {
139
+ await emitAuditFailure(opts.repoRoot, opts, resolved.reasonCode, {
140
+ requested: requestedSkill,
141
+ });
142
+ return synthErr(resolved.reasonCode);
143
+ }
144
+ // 3. Handshake
145
+ const handshake = performHandshake({
146
+ skillPath: resolved.skillPath,
147
+ runtimeVersion: opts.currentRuntimeVersion,
148
+ envelopeContractVersion: ENVELOPE_CONTRACT_VERSION,
149
+ });
150
+ if (!handshake.ok) {
151
+ await emitAuditFailure(opts.repoRoot, opts, handshake.reasonCode, {
152
+ requested: requestedSkill,
153
+ resolved: resolved.stableId,
154
+ skillPath: resolved.skillPath,
155
+ });
156
+ return synthErr(handshake.reasonCode);
157
+ }
158
+ // 4. Envelope
159
+ let envelope;
160
+ try {
161
+ envelope = buildEnvelope({
162
+ changedFiles: opts.changedFiles ?? [],
163
+ env: opts.env,
164
+ repoRoot: opts.repoRoot,
165
+ dryRun: opts.dryRun ?? false,
166
+ projectId: opts.projectId,
167
+ });
168
+ }
169
+ catch {
170
+ await emitAuditFailure(opts.repoRoot, opts, 'envelope-build-failed', {
171
+ requested: requestedSkill,
172
+ resolved: resolved.stableId,
173
+ skillPath: resolved.skillPath,
174
+ apiVersion: handshake.manifest.skill_runtime_api_version,
175
+ });
176
+ return synthErr('envelope-build-failed');
177
+ }
178
+ const envelopeHash = hashEnvelope(envelope);
179
+ // 5. Policy
180
+ const policy = { ...DEFAULT_POLICY, ...(stackResult.parsed.migrate.policy ?? {}) };
181
+ const enforced = enforcePolicy({
182
+ policy,
183
+ env: opts.env,
184
+ repoRoot: opts.repoRoot,
185
+ ci: envelope.ci,
186
+ yesFlag: opts.yesFlag,
187
+ nonInteractive: opts.nonInteractive,
188
+ gitHead: envelope.gitHead,
189
+ });
190
+ if (!enforced.ok) {
191
+ await emitAuditFailure(opts.repoRoot, opts, enforced.reasonCode, {
192
+ invocationId: envelope.invocationId,
193
+ requested: requestedSkill,
194
+ resolved: resolved.stableId,
195
+ skillPath: resolved.skillPath,
196
+ apiVersion: handshake.manifest.skill_runtime_api_version,
197
+ decisions: enforced.decisions,
198
+ envelopeHash,
199
+ });
200
+ return synthErr(enforced.reasonCode, envelope.invocationId, envelope.nonce);
201
+ }
202
+ // 6. Execute
203
+ //
204
+ // Different skills source their command shape differently:
205
+ // - migrate@1 — thin runner, requires envs.<env>.command
206
+ // - migrate.supabase@1 — rich runner; we hand the script the envelope
207
+ // via env vars and let it discover settings from
208
+ // stack.md's `migrate.supabase` block + envs_file.
209
+ // - none@1 — no-op skill; we synthesize a `skipped` result
210
+ // without spawning anything.
211
+ let envSpec;
212
+ if (resolved.stableId === 'none@1') {
213
+ // No-op skill — return synthetic skipped result without spawning anything.
214
+ // Still emit audit entry so the dispatch is observable.
215
+ const skipResult = {
216
+ contractVersion: ENVELOPE_CONTRACT_VERSION,
217
+ skillId: 'none@1',
218
+ invocationId: envelope.invocationId,
219
+ nonce: envelope.nonce,
220
+ status: 'skipped',
221
+ reasonCode: 'migration-disabled',
222
+ appliedMigrations: [],
223
+ destructiveDetected: false,
224
+ sideEffectsPerformed: ['no-side-effects'],
225
+ nextActions: [],
226
+ };
227
+ await appendAuditEvent(path.join(opts.repoRoot, '.autopilot', 'audit.log'), {
228
+ invocationId: envelope.invocationId,
229
+ event: 'dispatch',
230
+ requested_skill: requestedSkill,
231
+ resolved_skill: resolved.stableId,
232
+ skill_path: resolved.skillPath,
233
+ envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
234
+ skill_runtime_api_version: handshake.manifest.skill_runtime_api_version,
235
+ envelope_hash: envelopeHash,
236
+ policy_decisions: enforced.decisions,
237
+ mode: opts.dryRun ? 'dry-run' : 'apply',
238
+ actor: process.env.USER ?? 'unknown',
239
+ ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
240
+ ci_run_id: process.env.GITHUB_RUN_ID ?? null,
241
+ result_status: 'skipped',
242
+ duration_ms: Date.now() - t0,
243
+ });
244
+ return skipResult;
245
+ }
246
+ if (resolved.stableId === 'migrate.supabase@1') {
247
+ // Rich Supabase runner manages env discovery via the supabase block +
248
+ // envs_file. The script's envelope shim activates when AUTOPILOT_ENVELOPE
249
+ // is set, regardless of CLI args.
250
+ //
251
+ // We still honor envs.<env>.command when explicitly provided (tests +
252
+ // fixtures rely on it as an escape hatch). When omitted, fall back to
253
+ // invoking the packaged runner.
254
+ const supabaseBlock = stackResult.parsed.migrate.supabase;
255
+ if (!supabaseBlock) {
256
+ await emitAuditFailure(opts.repoRoot, opts, 'invalid-stack-config', {
257
+ invocationId: envelope.invocationId,
258
+ requested: requestedSkill,
259
+ resolved: resolved.stableId,
260
+ skillPath: resolved.skillPath,
261
+ apiVersion: handshake.manifest.skill_runtime_api_version,
262
+ decisions: enforced.decisions,
263
+ envelopeHash,
264
+ });
265
+ return synthErr('invalid-stack-config', envelope.invocationId, envelope.nonce);
266
+ }
267
+ envSpec = stackResult.parsed.migrate.envs?.[opts.env] ?? {
268
+ command: { exec: 'npx', args: ['tsx', 'scripts/supabase/migrate.ts'] },
269
+ };
270
+ }
271
+ else {
272
+ envSpec = stackResult.parsed.migrate.envs?.[opts.env];
273
+ }
274
+ if (!envSpec) {
275
+ await emitAuditFailure(opts.repoRoot, opts, 'env-not-configured', {
276
+ invocationId: envelope.invocationId,
277
+ requested: requestedSkill,
278
+ resolved: resolved.stableId,
279
+ skillPath: resolved.skillPath,
280
+ apiVersion: handshake.manifest.skill_runtime_api_version,
281
+ decisions: enforced.decisions,
282
+ envelopeHash,
283
+ });
284
+ return synthErr('env-not-configured', envelope.invocationId, envelope.nonce);
285
+ }
286
+ // Set up per-invocation result file with strict permissions
287
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'autopilot-result-'));
288
+ fs.chmodSync(tmpRoot, RESULT_TEMPDIR_MODE);
289
+ const resultPath = path.join(tmpRoot, `${envelope.invocationId}.json`);
290
+ // Pre-create result file with O_CREAT|O_EXCL|O_WRONLY, mode 0o600, no symlink follow.
291
+ // The skill will then open the existing file for writing — TOCTOU window closed
292
+ // against a malicious pre-placed file or symlink.
293
+ const fd = fs.openSync(resultPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
294
+ fs.closeSync(fd);
295
+ const childEnv = {
296
+ ...(envSpec.env_file ? loadEnvFile(envSpec.env_file, opts.repoRoot) : {}),
297
+ ...(opts.envOverride ?? {}),
298
+ AUTOPILOT_ENVELOPE: JSON.stringify(envelope),
299
+ AUTOPILOT_RESULT_PATH: resultPath,
300
+ };
301
+ let execResult;
302
+ try {
303
+ execResult = await executeCommand(envSpec.command, {
304
+ cwd: opts.repoRoot,
305
+ env: childEnv,
306
+ });
307
+ }
308
+ catch {
309
+ await emitAuditFailure(opts.repoRoot, opts, 'execute-threw', {
310
+ invocationId: envelope.invocationId,
311
+ requested: requestedSkill,
312
+ resolved: resolved.stableId,
313
+ skillPath: resolved.skillPath,
314
+ apiVersion: handshake.manifest.skill_runtime_api_version,
315
+ decisions: enforced.decisions,
316
+ envelopeHash,
317
+ });
318
+ try {
319
+ fs.rmSync(tmpRoot, { recursive: true });
320
+ }
321
+ catch { /* */ }
322
+ return synthErr('execute-threw', envelope.invocationId, envelope.nonce);
323
+ }
324
+ // 7. Parse result artifact
325
+ const allowStdoutFallback = handshake.manifest.stdoutFallback === true;
326
+ const result = parseResult({
327
+ filePath: resultPath,
328
+ stdout: execResult.stdout,
329
+ expected: { invocationId: envelope.invocationId, nonce: envelope.nonce },
330
+ allowStdoutFallback,
331
+ });
332
+ // Cleanup temp dir
333
+ try {
334
+ fs.rmSync(tmpRoot, { recursive: true });
335
+ }
336
+ catch { /* */ }
337
+ // 8. Audit log entry (always emitted on the success path)
338
+ const durationMs = Date.now() - t0;
339
+ await appendAuditEvent(path.join(opts.repoRoot, '.autopilot', 'audit.log'), {
340
+ invocationId: envelope.invocationId,
341
+ event: 'dispatch',
342
+ requested_skill: requestedSkill,
343
+ resolved_skill: resolved.stableId,
344
+ skill_path: resolved.skillPath,
345
+ envelope_contract_version: ENVELOPE_CONTRACT_VERSION,
346
+ skill_runtime_api_version: handshake.manifest.skill_runtime_api_version,
347
+ envelope_hash: envelopeHash,
348
+ policy_decisions: enforced.decisions,
349
+ mode: opts.dryRun ? 'dry-run' : 'apply',
350
+ actor: process.env.USER ?? 'unknown',
351
+ ci_provider: process.env.AUTOPILOT_CI_PROVIDER ?? null,
352
+ ci_run_id: process.env.GITHUB_RUN_ID ?? null,
353
+ result_status: result.status,
354
+ duration_ms: durationMs,
355
+ });
356
+ return result;
357
+ }
358
+ //# sourceMappingURL=dispatcher.js.map
@@ -0,0 +1,19 @@
1
+ export interface CheckResult {
2
+ ok: boolean;
3
+ message?: string;
4
+ fixHint?: string;
5
+ }
6
+ export interface NamedCheckResult {
7
+ name: string;
8
+ result: CheckResult;
9
+ }
10
+ export declare function stackMdExists(repoRoot: string): CheckResult;
11
+ export declare function schemaValidates(repoRoot: string): CheckResult;
12
+ export declare function skillResolves(repoRoot: string): CheckResult;
13
+ export declare function perEnvCommandsExplicit(repoRoot: string): CheckResult;
14
+ export declare function policyFieldsValid(repoRoot: string): CheckResult;
15
+ export declare function projectRootHasToolchain(repoRoot: string): CheckResult;
16
+ export declare function deprecatedKeysAbsent(repoRoot: string): CheckResult;
17
+ export declare function envFileSafety(repoRoot: string): CheckResult;
18
+ export declare function runAllChecks(repoRoot: string): NamedCheckResult[];
19
+ //# sourceMappingURL=doctor-checks.d.ts.map