@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.
- package/CHANGELOG.md +42 -0
- package/README.md +5 -1
- package/dist/src/adapters/review-engine/parse-output.js +21 -5
- package/dist/src/cli/fix.js +21 -3
- package/dist/src/cli/index.js +130 -2
- package/dist/src/cli/init-migrate.d.ts +35 -0
- package/dist/src/cli/init-migrate.js +299 -0
- package/dist/src/cli/migrate-doctor.d.ts +19 -0
- package/dist/src/cli/migrate-doctor.js +191 -0
- package/dist/src/core/migrate/alias-resolver.d.ts +18 -0
- package/dist/src/core/migrate/alias-resolver.js +150 -0
- package/dist/src/core/migrate/audit-log.d.ts +30 -0
- package/dist/src/core/migrate/audit-log.js +100 -0
- package/dist/src/core/migrate/contract.d.ts +27 -0
- package/dist/src/core/migrate/contract.js +35 -0
- package/dist/src/core/migrate/detector-rules.d.ts +26 -0
- package/dist/src/core/migrate/detector-rules.js +147 -0
- package/dist/src/core/migrate/detector.d.ts +16 -0
- package/dist/src/core/migrate/detector.js +105 -0
- package/dist/src/core/migrate/dispatcher.d.ts +19 -0
- package/dist/src/core/migrate/dispatcher.js +358 -0
- package/dist/src/core/migrate/doctor-checks.d.ts +19 -0
- package/dist/src/core/migrate/doctor-checks.js +304 -0
- package/dist/src/core/migrate/envelope.d.ts +25 -0
- package/dist/src/core/migrate/envelope.js +84 -0
- package/dist/src/core/migrate/executor.d.ts +33 -0
- package/dist/src/core/migrate/executor.js +102 -0
- package/dist/src/core/migrate/handshake.d.ts +17 -0
- package/dist/src/core/migrate/handshake.js +130 -0
- package/dist/src/core/migrate/migrator.d.ts +34 -0
- package/dist/src/core/migrate/migrator.js +302 -0
- package/dist/src/core/migrate/monorepo.d.ts +2 -0
- package/dist/src/core/migrate/monorepo.js +114 -0
- package/dist/src/core/migrate/policy-enforcer.d.ts +28 -0
- package/dist/src/core/migrate/policy-enforcer.js +111 -0
- package/dist/src/core/migrate/result-parser.d.ts +16 -0
- package/dist/src/core/migrate/result-parser.js +152 -0
- package/dist/src/core/migrate/schema-validator.d.ts +11 -0
- package/dist/src/core/migrate/schema-validator.js +103 -0
- package/dist/src/core/migrate/types.d.ts +49 -0
- package/dist/src/core/migrate/types.js +3 -0
- package/package.json +5 -1
- package/presets/aliases.lock.json +20 -0
- package/presets/schemas/migrate.schema.json +134 -0
- package/skills/autopilot/SKILL.md +29 -9
- package/skills/migrate/skill.manifest.json +7 -0
- package/skills/migrate-none/SKILL.md +40 -0
- package/skills/migrate-none/skill.manifest.json +7 -0
- package/skills/migrate-supabase/SKILL.md +126 -0
- package/skills/migrate-supabase/skill.manifest.json +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.2.0 — Migrate skill generalization
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Generalized migrate phase** — `migrate@1` (thin orchestrator), `migrate.supabase@1` (rich Delegance runner, paths now parameterized via stack.md), `none@1` (no-op for `--skip-migrate`). Pipeline reads `.autopilot/stack.md` to dispatch the right skill.
|
|
8
|
+
- **Auto-detection at init** — `claude-autopilot init` walks the repo, sniffs for Prisma / Drizzle / Rails / Go-migrate / Flyway / dbmate / Alembic / Django / Ecto / TypeORM / Supabase signals, writes a stack.md non-interactively when there's a single high-confidence match. Prompts otherwise.
|
|
9
|
+
- **Stack.md schema validation** with security rules: shell metachars rejected in command args, env_file paths confined under project_root, dev_command-as-prod-command blocked.
|
|
10
|
+
- **Versioned alias map** (`presets/aliases.lock.json`) with stable IDs (`migrate@1`, `migrate.supabase@1`, `none@1`) so future renames don't break user configs.
|
|
11
|
+
- **Skill manifest version handshake** — every skill ships `skill.manifest.json` declaring `skill_runtime_api_version`, `min_runtime`, `max_runtime`. Dispatcher fails closed on incompatibility with explicit upgrade hints.
|
|
12
|
+
- **Hash-chained audit log** at `.autopilot/audit.log` (JSONL, monotonic seq + SHA-256 prev_hash chain) for every migrate dispatch. `claude-autopilot doctor` validates the chain.
|
|
13
|
+
- **Per-policy enforcement points** — `allow_prod_in_ci`, `require_clean_git`, `require_manual_approval`, `require_dry_run_first`. CI prod migrations require all 4 of: `--yes` flag, `AUTOPILOT_CI_POLICY=allow-prod`, `AUTOPILOT_TARGET_ENV=<env>`, stack.md `policy.allow_prod_in_ci: true`. Plus a recognized CI provider env (or `AUTOPILOT_CI_PROVIDER` override).
|
|
14
|
+
- **Structured argv execution** — commands stored as `{ exec, args[] }` and executed via `spawn(shell:false)`. No shell injection vector. Legacy string form deprecated, auto-migrated by `doctor --fix`.
|
|
15
|
+
- **`migrate doctor`** with read-only mode (default) and `--fix` mode for safe auto-corrections.
|
|
16
|
+
- **Monorepo support** — per-workspace `.autopilot/stack.md` plus root `.autopilot/manifest.yaml` for cross-workspace coordination.
|
|
17
|
+
- **Legacy migrate skill migrator** — automatically archives the existing `skills/migrate/` (legacy Delegance Supabase shape) to `skills/migrate.backup-<ISO>/` on `doctor --fix`, replaces with the thin shape.
|
|
18
|
+
- **Multi-CI provider detection** — GitHub Actions, GitLab CI, CircleCI, Buildkite, Jenkins recognized out of the box. `AUTOPILOT_CI_PROVIDER` override for self-hosted.
|
|
19
|
+
- **Delegance regression CI lane** — required GitHub Actions job that runs the full migrate-supabase flow against an anonymized fixture, asserting byte-for-byte ledger compatibility with pre-dispatcher behavior.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `skills/autopilot/SKILL.md` Step 4 (Migrate) rewritten to describe the envelope-based dispatcher contract instead of invoking `/migrate` directly.
|
|
24
|
+
|
|
25
|
+
### Backward-compat
|
|
26
|
+
|
|
27
|
+
- Delegance's existing `npx tsx scripts/supabase/migrate.ts` CLI invocation still works unchanged. The script now ALSO honors the autopilot dispatcher when invoked with `AUTOPILOT_ENVELOPE` + `AUTOPILOT_RESULT_PATH` env vars; falls back to legacy CLI parsing otherwise.
|
|
28
|
+
- The old `skills/migrate/` legacy SKILL.md is preserved (and will be auto-archived on first `doctor --fix` post-upgrade).
|
|
29
|
+
|
|
30
|
+
### Migration guide for existing users
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Upgrade
|
|
34
|
+
npm install -g @delegance/claude-autopilot@5.2.0
|
|
35
|
+
|
|
36
|
+
# Audit current state (read-only, exits non-zero if migration needed)
|
|
37
|
+
claude-autopilot doctor
|
|
38
|
+
|
|
39
|
+
# Apply auto-fixes (archives legacy /migrate skill, writes new stack.md)
|
|
40
|
+
claude-autopilot doctor --fix
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Existing `npx tsx scripts/supabase/migrate.ts <file> --env dev` workflows are unaffected.
|
|
44
|
+
|
|
3
45
|
## [5.0.0] — 2026-04-27
|
|
4
46
|
|
|
5
47
|
First GA release after a five-alpha soak cycle. Promotes `5.0.0-alpha.5` to GA unchanged on the code side; the only diff is the version bump, README rebranding away from `@alpha` channel guidance, and a new "Reproducing the benchmark" section.
|
package/README.md
CHANGED
|
@@ -78,12 +78,16 @@ Each phase is a Claude Code skill (`.claude/skills/<name>/SKILL.md`). You can in
|
|
|
78
78
|
| **Plan** | `writing-plans` | Breaks spec into phased, checklist-shaped implementation plan | Claude |
|
|
79
79
|
| **Plan review** | `codex-review` | Second model critiques the plan before you execute it | Codex / GPT-5 |
|
|
80
80
|
| **Implement** | `subagent-driven-development` | Executes plan in a git worktree, one phase at a time, with per-phase tests | Claude |
|
|
81
|
-
| **Migrate** | `migrate` |
|
|
81
|
+
| **Migrate** | `migrate` | Dispatches to the configured migration skill (see [Migrate phase](#migrate-phase)) — runs your migration tool dev → QA → prod with per-env validation | Deterministic |
|
|
82
82
|
| **Validate** | `validate` | Static rules + tests + type check + security scan + LLM review | Any |
|
|
83
83
|
| **PR** | `commit-push-pr` | Opens the PR with auto-generated title, summary, and test plan | Claude |
|
|
84
84
|
| **Review** | `review-2pass` / `council` | Multi-model review of the diff (critical pass + informational pass) | Multiple |
|
|
85
85
|
| **Triage** | `bugbot` | Fetches automated reviewer findings, auto-fixes real bugs, dismisses false positives | Claude |
|
|
86
86
|
|
|
87
|
+
### Migrate phase
|
|
88
|
+
|
|
89
|
+
Configure your migration tool in `.autopilot/stack.md`. The pipeline reads stack.md, dispatches to the configured skill (`migrate@1` for generic; `migrate.supabase@1` for rich Supabase ledger; `none@1` to skip), and runs your tool with full safety: structured argv (no shell injection), 4-flag CI prod gate, hash-chained audit log. Run `claude-autopilot init` to auto-detect your stack. See [docs/skills/rich-migrate-contract.md](docs/skills/rich-migrate-contract.md) for the skill contract and [docs/skills/version-compatibility.md](docs/skills/version-compatibility.md) for the version model.
|
|
90
|
+
|
|
87
91
|
## What's distinctive
|
|
88
92
|
|
|
89
93
|
Features that are hard or impossible to find in the competitive set:
|
|
@@ -35,10 +35,20 @@ const FILE_REF = new RegExp(String.raw `(?:` +
|
|
|
35
35
|
String.raw `\x60([^\x60]+\.[a-z]{1,6})\x60` +
|
|
36
36
|
String.raw `|(\b[\w./\-]+\.` + CODE_EXT + String.raw `)(?::(\d+))?` +
|
|
37
37
|
String.raw `)`, 'i');
|
|
38
|
+
// Matches "line 42", "on line 42", "at line 42" — used as a fallback when the
|
|
39
|
+
// LLM mentions a line number separately from the file ref. Critical for `fix`:
|
|
40
|
+
// without a line, the fixer can't extract a code snippet, so findings without
|
|
41
|
+
// `line` got silently dropped from `fix --dry-run` (the path-only finding case
|
|
42
|
+
// was the most-cited demo torpedo from the 5.0.7 stress test).
|
|
43
|
+
const LINE_REF = /\b(?:on |at )?line\s+(\d+)\b/i;
|
|
38
44
|
function extractFileRef(text) {
|
|
39
45
|
const m = text.match(FILE_REF);
|
|
40
|
-
if (!m)
|
|
41
|
-
|
|
46
|
+
if (!m) {
|
|
47
|
+
// No file ref at all — but maybe the body still has "line N" prose we can
|
|
48
|
+
// surface. Caller treats file `<unspecified>` as a sentinel either way.
|
|
49
|
+
const lm = text.match(LINE_REF);
|
|
50
|
+
return lm ? { file: '<unspecified>', line: parseInt(lm[1], 10) } : { file: '<unspecified>' };
|
|
51
|
+
}
|
|
42
52
|
const raw = (m[1] ?? m[2]);
|
|
43
53
|
// Skip version strings (v1.2.3), bare dotfile extensions with no path
|
|
44
54
|
// separator, and known prose abbreviations that slipped through the regex
|
|
@@ -47,10 +57,16 @@ function extractFileRef(text) {
|
|
|
47
57
|
if (/^v?\d/.test(raw) ||
|
|
48
58
|
(!raw.includes('/') && raw.startsWith('.') && raw.split('.').length === 2) ||
|
|
49
59
|
/^(?:e\.g|i\.e|etc|vs|cf|al|U\.S|U\.K)$/i.test(raw)) {
|
|
50
|
-
|
|
60
|
+
const lm = text.match(LINE_REF);
|
|
61
|
+
return lm ? { file: '<unspecified>', line: parseInt(lm[1], 10) } : { file: '<unspecified>' };
|
|
51
62
|
}
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
// Prefer the colon-line from the file ref (`foo.ts:42`); fall back to a
|
|
64
|
+
// separately-mentioned line ("line 42") only when the file ref didn't carry one.
|
|
65
|
+
const colonLine = m[3] ? parseInt(m[3], 10) : undefined;
|
|
66
|
+
if (colonLine !== undefined)
|
|
67
|
+
return { file: raw, line: colonLine };
|
|
68
|
+
const lm = text.match(LINE_REF);
|
|
69
|
+
return lm ? { file: raw, line: parseInt(lm[1], 10) } : { file: raw };
|
|
54
70
|
}
|
|
55
71
|
// Accepts any of: `### [CRITICAL] title`, `### CRITICAL title`, `### **CRITICAL** title`,
|
|
56
72
|
// `### **[CRITICAL]** title`. Severity capture works across variants.
|
package/dist/src/cli/fix.js
CHANGED
|
@@ -38,8 +38,13 @@ export async function runFix(options = {}) {
|
|
|
38
38
|
console.log(fmt('yellow', '[fix] No cached findings — run `guardrail scan <path>` or `guardrail run` first.'));
|
|
39
39
|
return 0;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Two gates:
|
|
42
|
+
// - "actionable": has a real file path. Surfaced in dry-run so the user sees
|
|
43
|
+
// findings even when the LLM didn't pin a line number.
|
|
44
|
+
// - "fixable": also has a line. The LLM-fix loop needs both to extract a
|
|
45
|
+
// code snippet around the finding location.
|
|
46
|
+
const actionable = findings.filter(f => {
|
|
47
|
+
if (!f.file || f.file === '<unspecified>' || f.file === '<pipeline>')
|
|
43
48
|
return false;
|
|
44
49
|
if (severityFilter === 'all')
|
|
45
50
|
return true;
|
|
@@ -47,8 +52,21 @@ export async function runFix(options = {}) {
|
|
|
47
52
|
return f.severity === 'critical';
|
|
48
53
|
return f.severity === 'critical' || f.severity === 'warning';
|
|
49
54
|
});
|
|
55
|
+
const fixable = actionable.filter(f => f.line && f.line > 0);
|
|
56
|
+
if (actionable.length === 0) {
|
|
57
|
+
console.log(fmt('yellow', `[fix] No actionable findings (severity=${severityFilter}, need file path).`));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
50
60
|
if (fixable.length === 0) {
|
|
51
|
-
|
|
61
|
+
const verb = actionable.length === 1 ? 'has' : 'have';
|
|
62
|
+
const noun = actionable.length === 1 ? 'finding' : 'findings';
|
|
63
|
+
console.log(fmt('yellow', `[fix] ${actionable.length} ${noun} ${verb} file but no line — model output was line-less. Re-run scan with --ask "include line numbers" or run \`claude-autopilot run\` for richer extraction.`));
|
|
64
|
+
for (const f of actionable) {
|
|
65
|
+
const sev = f.severity === 'critical' ? fmt('red', 'CRITICAL')
|
|
66
|
+
: f.severity === 'warning' ? fmt('yellow', 'WARNING ')
|
|
67
|
+
: fmt('dim', 'NOTE ');
|
|
68
|
+
console.log(` [${sev}] ${fmt('dim', f.file)} ${f.message}`);
|
|
69
|
+
}
|
|
52
70
|
return 0;
|
|
53
71
|
}
|
|
54
72
|
const modeNote = options.dryRun ? ' (dry run)' : options.yes ? '' : ' (interactive — use --yes to skip prompts)';
|
package/dist/src/cli/index.js
CHANGED
|
@@ -27,6 +27,9 @@ import { runWorker } from "./worker.js";
|
|
|
27
27
|
import { runTestGen } from "./test-gen.js";
|
|
28
28
|
import { runCouncilCmd } from "./council.js";
|
|
29
29
|
import { runMigrateV4 } from "./migrate-v4.js";
|
|
30
|
+
import { runMigrateDoctor } from "./migrate-doctor.js";
|
|
31
|
+
import { initMigrate, NoMigrationToolDetectedError } from "./init-migrate.js";
|
|
32
|
+
import { dispatch as runMigrateDispatch } from "../core/migrate/dispatcher.js";
|
|
30
33
|
import { findPackageRoot } from "./_pkg-root.js";
|
|
31
34
|
import { GuardrailError } from "../core/errors.js";
|
|
32
35
|
// Format unhandled errors as a one-line user-facing message instead of dumping a
|
|
@@ -156,7 +159,7 @@ These are aliases for the flat subcommands; they still work without the 'advance
|
|
|
156
159
|
}
|
|
157
160
|
args.shift(); // drop 'advanced'
|
|
158
161
|
}
|
|
159
|
-
const SUBCOMMANDS = ['init', 'run', 'scan', 'report', 'explain', 'ignore', 'ci', 'pr', 'fix', 'costs', 'watch', 'hook', 'autoregress', 'baseline', 'triage', 'lsp', 'worker', 'mcp', 'test-gen', 'pr-desc', 'doctor', 'preflight', 'setup', 'council', 'migrate-v4', 'brainstorm', 'help', '--help', '-h'];
|
|
162
|
+
const SUBCOMMANDS = ['init', 'run', 'scan', 'report', 'explain', 'ignore', 'ci', 'pr', 'fix', 'costs', 'watch', 'hook', 'autoregress', 'baseline', 'triage', 'lsp', 'worker', 'mcp', 'test-gen', 'pr-desc', 'doctor', 'preflight', 'setup', 'council', 'migrate-v4', 'migrate', 'migrate-doctor', 'brainstorm', 'help', '--help', '-h'];
|
|
160
163
|
const VALUE_FLAGS = ['base', 'config', 'files', 'format', 'output', 'debounce', 'ask', 'focus', 'fail-on', 'note', 'reason', 'expires', 'profile', 'severity', 'prompt', 'context-file', 'path'];
|
|
161
164
|
// Bare invocation — no subcommand, no flags → show welcome guide
|
|
162
165
|
if (args.length === 0) {
|
|
@@ -210,6 +213,32 @@ function flag(name) {
|
|
|
210
213
|
function boolFlag(name) {
|
|
211
214
|
return args.includes(`--${name}`);
|
|
212
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Run the migrate-doctor with shared CLI formatting and exit handling.
|
|
218
|
+
*
|
|
219
|
+
* Both `migrate doctor` (two-word) and `migrate-doctor` (single-verb alias)
|
|
220
|
+
* resolve to this helper to keep their behavior locked together.
|
|
221
|
+
*/
|
|
222
|
+
async function runMigrateDoctorCLI() {
|
|
223
|
+
const fix = args.includes('--fix');
|
|
224
|
+
const result = await runMigrateDoctor({ repoRoot: process.cwd(), fix });
|
|
225
|
+
for (const r of result.results) {
|
|
226
|
+
const mark = r.result.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
|
|
227
|
+
console.log(`${mark} ${r.name}${r.result.message ? ` — ${r.result.message}` : ''}`);
|
|
228
|
+
if (!r.result.ok && r.result.fixHint) {
|
|
229
|
+
console.log(` \x1b[2mhint: ${r.result.fixHint}\x1b[0m`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (result.mutations && result.mutations.length > 0) {
|
|
233
|
+
console.log(`\n\x1b[1mFixes applied:\x1b[0m`);
|
|
234
|
+
for (const m of result.mutations)
|
|
235
|
+
console.log(` - ${m}`);
|
|
236
|
+
}
|
|
237
|
+
if (result.migrationReportPath) {
|
|
238
|
+
console.log(`\n\x1b[2mMigration report: ${result.migrationReportPath}\x1b[0m`);
|
|
239
|
+
}
|
|
240
|
+
process.exit(result.allOk ? 0 : 1);
|
|
241
|
+
}
|
|
213
242
|
function printUsage() {
|
|
214
243
|
console.log(`
|
|
215
244
|
Usage: claude-autopilot <command> [options] (legacy alias: guardrail)
|
|
@@ -225,7 +254,9 @@ Commands:
|
|
|
225
254
|
fix Auto-fix cached findings using the configured LLM
|
|
226
255
|
costs Show per-run cost summary
|
|
227
256
|
ci Opinionated CI entrypoint (post comments + SARIF)
|
|
228
|
-
init Scaffold guardrail.config.yaml
|
|
257
|
+
init Scaffold guardrail.config.yaml + auto-detect migrate stack (writes .autopilot/stack.md)
|
|
258
|
+
migrate Run database migrations via the stack-aware dispatcher
|
|
259
|
+
migrate doctor Validate .autopilot/stack.md and skill manifests (alias: migrate-doctor)
|
|
229
260
|
setup Auto-detect stack, write config, install pre-push hook
|
|
230
261
|
doctor Check prerequisites (alias: preflight)
|
|
231
262
|
preflight Check prerequisites (alias: doctor)
|
|
@@ -280,6 +311,14 @@ Options (autoregress):
|
|
|
280
311
|
--since <ref> Git ref for changed-files detection
|
|
281
312
|
--snapshot <slug> Target a single snapshot
|
|
282
313
|
--files <a,b,c> Explicit file list for generate (skips git detection)
|
|
314
|
+
|
|
315
|
+
Options (migrate):
|
|
316
|
+
--env <name> Target environment from .autopilot/stack.md (default: dev)
|
|
317
|
+
--dry-run Run skill in dry-run mode (no side effects)
|
|
318
|
+
--yes Required to apply prod migrations in CI
|
|
319
|
+
|
|
320
|
+
Options (migrate doctor / migrate-doctor):
|
|
321
|
+
--fix Apply auto-fixable mutations (legacy stack.md, skills/migrate/, schema_version)
|
|
283
322
|
`);
|
|
284
323
|
}
|
|
285
324
|
switch (subcommand) {
|
|
@@ -310,6 +349,42 @@ switch (subcommand) {
|
|
|
310
349
|
// `init` and `setup` are aliases. Keep both supported — no nag banner.
|
|
311
350
|
const force = args.includes('--force');
|
|
312
351
|
await runSetup({ force });
|
|
352
|
+
// After the existing init/setup logic, sniff for a migration tool and write
|
|
353
|
+
// .autopilot/stack.md. Non-interactive: high-confidence single matches are
|
|
354
|
+
// auto-selected; ambiguity / no-match downgrades to a TODO 'none@1' shape so
|
|
355
|
+
// we don't block the user. (Interactive prompts come from the autopilot skill,
|
|
356
|
+
// not the CLI.)
|
|
357
|
+
try {
|
|
358
|
+
const result = await initMigrate({
|
|
359
|
+
repoRoot: process.cwd(),
|
|
360
|
+
force,
|
|
361
|
+
});
|
|
362
|
+
for (const ws of result.workspaces) {
|
|
363
|
+
const rel = ws.workspace === process.cwd() ? '.' : ws.workspace;
|
|
364
|
+
console.log(`\x1b[2m[init-migrate] ${ws.action} ${rel}/.autopilot/stack.md (skill: ${ws.skill})\x1b[0m`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
if (err instanceof NoMigrationToolDetectedError) {
|
|
369
|
+
// No high-confidence match — fall back to skipMigrate shape so the user
|
|
370
|
+
// can edit it later. This matches the auto-detection contract documented
|
|
371
|
+
// in the v5.2.0 CHANGELOG.
|
|
372
|
+
try {
|
|
373
|
+
await initMigrate({
|
|
374
|
+
repoRoot: process.cwd(),
|
|
375
|
+
force,
|
|
376
|
+
skipMigrate: true,
|
|
377
|
+
});
|
|
378
|
+
console.log(`\x1b[33m[init-migrate] No migration tool detected — wrote 'none@1' stack.md (edit .autopilot/stack.md to configure)\x1b[0m`);
|
|
379
|
+
}
|
|
380
|
+
catch (fallbackErr) {
|
|
381
|
+
console.error(`\x1b[31m[init-migrate] failed: ${fallbackErr.message}\x1b[0m`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
console.error(`\x1b[31m[init-migrate] failed: ${err.message}\x1b[0m`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
313
388
|
break;
|
|
314
389
|
}
|
|
315
390
|
case 'doctor':
|
|
@@ -577,6 +652,59 @@ switch (subcommand) {
|
|
|
577
652
|
process.exit(code);
|
|
578
653
|
break;
|
|
579
654
|
}
|
|
655
|
+
case 'migrate': {
|
|
656
|
+
// Two-word `migrate doctor` is dispatched here before the generic migrate
|
|
657
|
+
// path so we don't try to read a stack.md or pick an env when the user is
|
|
658
|
+
// really asking for the doctor. `migrate-doctor` (single verb, below) is
|
|
659
|
+
// an equivalent alias.
|
|
660
|
+
if (args[1] === 'doctor') {
|
|
661
|
+
await runMigrateDoctorCLI();
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
// Plain `migrate [--env <name>] [--dry-run] [--yes]` → dispatcher.
|
|
665
|
+
const envName = flag('env') ?? 'dev';
|
|
666
|
+
const dryRun = boolFlag('dry-run');
|
|
667
|
+
const yesFlag = boolFlag('yes');
|
|
668
|
+
// Read package version for the runtime handshake.
|
|
669
|
+
const root = findPackageRoot(import.meta.url);
|
|
670
|
+
let runtimeVersion = 'unknown';
|
|
671
|
+
if (root) {
|
|
672
|
+
try {
|
|
673
|
+
const nodeFs = await import('node:fs');
|
|
674
|
+
const nodePath = await import('node:path');
|
|
675
|
+
const pkg = JSON.parse(nodeFs.readFileSync(nodePath.join(root, 'package.json'), 'utf8'));
|
|
676
|
+
runtimeVersion = pkg.version;
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
/* fall through with 'unknown' — handshake will fail closed */
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const result = await runMigrateDispatch({
|
|
683
|
+
repoRoot: process.cwd(),
|
|
684
|
+
env: envName,
|
|
685
|
+
yesFlag,
|
|
686
|
+
nonInteractive: !process.stdin.isTTY,
|
|
687
|
+
currentRuntimeVersion: runtimeVersion,
|
|
688
|
+
dryRun,
|
|
689
|
+
});
|
|
690
|
+
const ok = result.status === 'applied' || result.status === 'skipped';
|
|
691
|
+
const color = ok ? '\x1b[32m' : '\x1b[31m';
|
|
692
|
+
console.log(`${color}[migrate] status=${result.status} reason=${result.reasonCode}\x1b[0m`);
|
|
693
|
+
if (result.appliedMigrations.length > 0) {
|
|
694
|
+
console.log(` applied: ${result.appliedMigrations.join(', ')}`);
|
|
695
|
+
}
|
|
696
|
+
if (result.nextActions.length > 0) {
|
|
697
|
+
console.log(` next: ${result.nextActions.join('; ')}`);
|
|
698
|
+
}
|
|
699
|
+
process.exit(ok ? 0 : 1);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
case 'migrate-doctor': {
|
|
703
|
+
// Single-verb alias for `migrate doctor`. Documented for users whose shells
|
|
704
|
+
// or CI configs handle multi-word verbs awkwardly.
|
|
705
|
+
await runMigrateDoctorCLI();
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
580
708
|
case 'brainstorm': {
|
|
581
709
|
// `brainstorm` is the front of the pipeline and is implemented as a Claude
|
|
582
710
|
// Code skill (superpowers:brainstorming → autopilot), not a standalone CLI.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DetectionMatch } from '../core/migrate/detector.ts';
|
|
2
|
+
export declare class NoMigrationToolDetectedError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
export interface PrompterArgs {
|
|
6
|
+
workspace: string;
|
|
7
|
+
matches: DetectionMatch[];
|
|
8
|
+
}
|
|
9
|
+
export type Prompter = (args: PrompterArgs) => Promise<DetectionMatch>;
|
|
10
|
+
export interface InitMigrateOptions {
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
skipMigrate?: boolean;
|
|
13
|
+
force?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When true, computes what would be written but performs no write.
|
|
16
|
+
* Each workspace result includes a `diff` field showing the
|
|
17
|
+
* proposed change against the existing stack.md (or "would create
|
|
18
|
+
* new file" if missing). Used by `--force-rewrite` to preview
|
|
19
|
+
* changes before user confirmation.
|
|
20
|
+
*/
|
|
21
|
+
dryRunPreview?: boolean;
|
|
22
|
+
prompter?: Prompter;
|
|
23
|
+
}
|
|
24
|
+
export interface WorkspaceResult {
|
|
25
|
+
workspace: string;
|
|
26
|
+
action: 'wrote' | 'updated' | 'skipped' | 'preview';
|
|
27
|
+
skill: string;
|
|
28
|
+
/** Populated only when dryRunPreview is true. */
|
|
29
|
+
diff?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface InitMigrateResult {
|
|
32
|
+
workspaces: WorkspaceResult[];
|
|
33
|
+
}
|
|
34
|
+
export declare function initMigrate(opts: InitMigrateOptions): Promise<InitMigrateResult>;
|
|
35
|
+
//# sourceMappingURL=init-migrate.d.ts.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// src/cli/init-migrate.ts
|
|
2
|
+
//
|
|
3
|
+
// Init flow extension for the migrate skill. Walks workspaces, runs
|
|
4
|
+
// detection per workspace, and writes a per-workspace
|
|
5
|
+
// `<workspace>/.autopilot/stack.md` (plus a root `<repoRoot>/.autopilot/
|
|
6
|
+
// manifest.yaml` for monorepos).
|
|
7
|
+
//
|
|
8
|
+
// Decision tree per workspace:
|
|
9
|
+
// - --skipMigrate: write `migrate.skill: "none@1"` shape with TODO
|
|
10
|
+
// - autoSelect (1 high-confidence match): write the rule's defaults
|
|
11
|
+
// - prompt required (>1 match or non-high): call injected prompter
|
|
12
|
+
// - zero matches: throw NoMigrationToolDetectedError
|
|
13
|
+
//
|
|
14
|
+
// Idempotent: existing stack.md is loaded and merged. User-edited fields
|
|
15
|
+
// (envs.*.command, custom env_file, etc.) are preserved; only
|
|
16
|
+
// `detected_at` is refreshed and missing default policy keys are added.
|
|
17
|
+
// `force: true` regenerates from scratch.
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import * as yaml from 'js-yaml';
|
|
21
|
+
import { detect } from "../core/migrate/detector.js";
|
|
22
|
+
import { findWorkspaces } from "../core/migrate/monorepo.js";
|
|
23
|
+
export class NoMigrationToolDetectedError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'NoMigrationToolDetectedError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const DEFAULT_POLICY_GENERIC = {
|
|
30
|
+
allow_prod_in_ci: false,
|
|
31
|
+
require_clean_git: true,
|
|
32
|
+
require_manual_approval: true,
|
|
33
|
+
require_dry_run_first: false,
|
|
34
|
+
};
|
|
35
|
+
const DEFAULT_POLICY_SUPABASE = {
|
|
36
|
+
allow_prod_in_ci: false,
|
|
37
|
+
};
|
|
38
|
+
const DEFAULT_PROMPTER = async () => {
|
|
39
|
+
throw new Error('interactive prompt not available — pass `prompter` in initMigrate options or run with --skip-migrate');
|
|
40
|
+
};
|
|
41
|
+
function buildFreshStack(rule, skipMigrate) {
|
|
42
|
+
const detectedAt = new Date().toISOString();
|
|
43
|
+
if (skipMigrate) {
|
|
44
|
+
return {
|
|
45
|
+
schema_version: 1,
|
|
46
|
+
migrate: {
|
|
47
|
+
skill: 'none@1',
|
|
48
|
+
detected_at: detectedAt,
|
|
49
|
+
project_root: '.',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (rule.defaultSkill === 'migrate.supabase@1') {
|
|
54
|
+
return {
|
|
55
|
+
schema_version: 1,
|
|
56
|
+
migrate: {
|
|
57
|
+
skill: 'migrate.supabase@1',
|
|
58
|
+
supabase: {
|
|
59
|
+
deltas_dir: 'data/deltas',
|
|
60
|
+
types_out: 'types/supabase.ts',
|
|
61
|
+
envs_file: '.claude/supabase-envs.json',
|
|
62
|
+
},
|
|
63
|
+
policy: { ...DEFAULT_POLICY_SUPABASE },
|
|
64
|
+
detected_at: detectedAt,
|
|
65
|
+
project_root: '.',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Generic migrate@1 shape
|
|
70
|
+
const stack = {
|
|
71
|
+
schema_version: 1,
|
|
72
|
+
migrate: {
|
|
73
|
+
skill: 'migrate@1',
|
|
74
|
+
policy: { ...DEFAULT_POLICY_GENERIC },
|
|
75
|
+
detected_at: detectedAt,
|
|
76
|
+
project_root: '.',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
if (rule.defaultCommand) {
|
|
80
|
+
stack.migrate.envs = {
|
|
81
|
+
dev: { command: { ...rule.defaultCommand, args: [...rule.defaultCommand.args] } },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// No default command in the rule; init still writes envs.dev as a
|
|
86
|
+
// placeholder so schema validates. User is expected to fill it in.
|
|
87
|
+
stack.migrate.envs = {
|
|
88
|
+
dev: { command: { exec: 'TODO', args: ['configure-dev-command'] } },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return stack;
|
|
92
|
+
}
|
|
93
|
+
function mergePreserving(existing, fresh) {
|
|
94
|
+
// Preserve all user fields; only update detected_at and add missing
|
|
95
|
+
// defaults (schema_version, missing policy keys, project_root).
|
|
96
|
+
const merged = {
|
|
97
|
+
schema_version: existing.schema_version ?? fresh.schema_version,
|
|
98
|
+
migrate: { ...existing.migrate },
|
|
99
|
+
};
|
|
100
|
+
// Always refresh detected_at
|
|
101
|
+
merged.migrate.detected_at = fresh.migrate.detected_at;
|
|
102
|
+
// Backfill project_root if missing
|
|
103
|
+
if (!merged.migrate.project_root && fresh.migrate.project_root) {
|
|
104
|
+
merged.migrate.project_root = fresh.migrate.project_root;
|
|
105
|
+
}
|
|
106
|
+
// Merge missing default policy keys (do not overwrite user-set keys)
|
|
107
|
+
if (fresh.migrate.policy) {
|
|
108
|
+
const existingPolicy = (merged.migrate.policy ?? {});
|
|
109
|
+
const mergedPolicy = { ...existingPolicy };
|
|
110
|
+
for (const [k, v] of Object.entries(fresh.migrate.policy)) {
|
|
111
|
+
if (!(k in mergedPolicy))
|
|
112
|
+
mergedPolicy[k] = v;
|
|
113
|
+
}
|
|
114
|
+
merged.migrate.policy = mergedPolicy;
|
|
115
|
+
}
|
|
116
|
+
// Backfill supabase block if missing (preserve user values otherwise)
|
|
117
|
+
if (fresh.migrate.supabase && !merged.migrate.supabase) {
|
|
118
|
+
merged.migrate.supabase = { ...fresh.migrate.supabase };
|
|
119
|
+
}
|
|
120
|
+
// Backfill envs.dev if missing (preserve user-set commands otherwise)
|
|
121
|
+
if (fresh.migrate.envs?.dev && !merged.migrate.envs) {
|
|
122
|
+
merged.migrate.envs = {
|
|
123
|
+
dev: { ...fresh.migrate.envs.dev },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
else if (fresh.migrate.envs?.dev && merged.migrate.envs && !merged.migrate.envs.dev) {
|
|
127
|
+
merged.migrate.envs.dev = { ...fresh.migrate.envs.dev };
|
|
128
|
+
}
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
function readExistingStackMd(stackPath) {
|
|
132
|
+
try {
|
|
133
|
+
const content = fs.readFileSync(stackPath, 'utf8');
|
|
134
|
+
const parsed = yaml.load(content);
|
|
135
|
+
if (parsed &&
|
|
136
|
+
typeof parsed === 'object' &&
|
|
137
|
+
'migrate' in parsed) {
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function serializeStackMd(stack, options) {
|
|
147
|
+
// YAML body
|
|
148
|
+
const body = yaml.dump(stack, { lineWidth: 120, noRefs: true });
|
|
149
|
+
if (options.skipMigrate) {
|
|
150
|
+
return (body +
|
|
151
|
+
'# TODO: configure your migration tool. See docs/skills/rich-migrate-contract.md\n');
|
|
152
|
+
}
|
|
153
|
+
return body;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Produce a unified-diff-style summary of the change from `oldText` to
|
|
157
|
+
* `newText`. Pure-line LCS — no external dependency. Output is for human
|
|
158
|
+
* review only; not intended to round-trip via `patch`.
|
|
159
|
+
*/
|
|
160
|
+
function unifiedDiff(oldText, newText) {
|
|
161
|
+
if (oldText === newText)
|
|
162
|
+
return '';
|
|
163
|
+
const a = oldText.split('\n');
|
|
164
|
+
const b = newText.split('\n');
|
|
165
|
+
const m = a.length;
|
|
166
|
+
const n = b.length;
|
|
167
|
+
// LCS table
|
|
168
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
169
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
170
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
171
|
+
if (a[i] === b[j]) {
|
|
172
|
+
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const out = [];
|
|
180
|
+
let i = 0;
|
|
181
|
+
let j = 0;
|
|
182
|
+
while (i < m && j < n) {
|
|
183
|
+
if (a[i] === b[j]) {
|
|
184
|
+
out.push(` ${a[i]}`);
|
|
185
|
+
i++;
|
|
186
|
+
j++;
|
|
187
|
+
}
|
|
188
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
189
|
+
out.push(`- ${a[i]}`);
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
out.push(`+ ${b[j]}`);
|
|
194
|
+
j++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
while (i < m) {
|
|
198
|
+
out.push(`- ${a[i]}`);
|
|
199
|
+
i++;
|
|
200
|
+
}
|
|
201
|
+
while (j < n) {
|
|
202
|
+
out.push(`+ ${b[j]}`);
|
|
203
|
+
j++;
|
|
204
|
+
}
|
|
205
|
+
return out.join('\n');
|
|
206
|
+
}
|
|
207
|
+
function chooseRule(matches, autoSelect, prompter, workspace) {
|
|
208
|
+
if (autoSelect)
|
|
209
|
+
return Promise.resolve(matches[0]);
|
|
210
|
+
return prompter({ workspace, matches });
|
|
211
|
+
}
|
|
212
|
+
export async function initMigrate(opts) {
|
|
213
|
+
const repoRoot = path.resolve(opts.repoRoot);
|
|
214
|
+
const skipMigrate = opts.skipMigrate ?? false;
|
|
215
|
+
const force = opts.force ?? false;
|
|
216
|
+
const dryRunPreview = opts.dryRunPreview ?? false;
|
|
217
|
+
const prompter = opts.prompter ?? DEFAULT_PROMPTER;
|
|
218
|
+
const workspaces = findWorkspaces(repoRoot);
|
|
219
|
+
const results = [];
|
|
220
|
+
for (const workspace of workspaces) {
|
|
221
|
+
const stackDir = path.join(workspace, '.autopilot');
|
|
222
|
+
const stackPath = path.join(stackDir, 'stack.md');
|
|
223
|
+
const exists = fs.existsSync(stackPath);
|
|
224
|
+
let chosenRule = null;
|
|
225
|
+
if (!skipMigrate) {
|
|
226
|
+
const det = detect(workspace);
|
|
227
|
+
if (det.matches.length === 0) {
|
|
228
|
+
throw new NoMigrationToolDetectedError(`No migration tool detected in ${workspace}. Re-run with --skip-migrate to write a 'none@1' stack.md and configure later.`);
|
|
229
|
+
}
|
|
230
|
+
const chosen = await chooseRule(det.matches, det.autoSelect, prompter, workspace);
|
|
231
|
+
chosenRule = chosen.rule;
|
|
232
|
+
}
|
|
233
|
+
// For skipMigrate, chosenRule stays null — buildFreshStack ignores it.
|
|
234
|
+
const fresh = buildFreshStack(chosenRule ?? {}, skipMigrate);
|
|
235
|
+
let toWrite;
|
|
236
|
+
let action;
|
|
237
|
+
if (dryRunPreview) {
|
|
238
|
+
// Preview always reflects the *fresh* content (mirrors `force: true`).
|
|
239
|
+
// Merge-preserving previews can be added later if needed.
|
|
240
|
+
toWrite = fresh;
|
|
241
|
+
action = 'preview';
|
|
242
|
+
}
|
|
243
|
+
else if (exists && !force) {
|
|
244
|
+
const existing = readExistingStackMd(stackPath);
|
|
245
|
+
if (existing) {
|
|
246
|
+
toWrite = mergePreserving(existing, fresh);
|
|
247
|
+
action = 'updated';
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
toWrite = fresh;
|
|
251
|
+
action = 'wrote';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
toWrite = fresh;
|
|
256
|
+
action = 'wrote';
|
|
257
|
+
}
|
|
258
|
+
const newContent = serializeStackMd(toWrite, { skipMigrate });
|
|
259
|
+
if (dryRunPreview) {
|
|
260
|
+
// Compute diff against existing on disk; do NOT write.
|
|
261
|
+
const oldContent = exists ? fs.readFileSync(stackPath, 'utf8') : '';
|
|
262
|
+
const diff = exists
|
|
263
|
+
? unifiedDiff(oldContent, newContent)
|
|
264
|
+
: `would create new file ${path.relative(repoRoot, stackPath) || stackPath}\n${newContent
|
|
265
|
+
.split('\n')
|
|
266
|
+
.map(l => `+ ${l}`)
|
|
267
|
+
.join('\n')}`;
|
|
268
|
+
results.push({
|
|
269
|
+
workspace,
|
|
270
|
+
action,
|
|
271
|
+
skill: toWrite.migrate.skill,
|
|
272
|
+
diff,
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
fs.mkdirSync(stackDir, { recursive: true });
|
|
277
|
+
fs.writeFileSync(stackPath, newContent, 'utf8');
|
|
278
|
+
results.push({
|
|
279
|
+
workspace,
|
|
280
|
+
action,
|
|
281
|
+
skill: toWrite.migrate.skill,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Multi-workspace repos: write a root manifest.yaml listing the workspaces.
|
|
285
|
+
if (workspaces.length > 1 && !dryRunPreview) {
|
|
286
|
+
const manifestDir = path.join(repoRoot, '.autopilot');
|
|
287
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
288
|
+
const manifest = {
|
|
289
|
+
schema_version: 1,
|
|
290
|
+
workspaces: results.map(r => ({
|
|
291
|
+
path: path.relative(repoRoot, r.workspace) || '.',
|
|
292
|
+
skill: r.skill,
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
fs.writeFileSync(path.join(manifestDir, 'manifest.yaml'), yaml.dump(manifest, { lineWidth: 120, noRefs: true }), 'utf8');
|
|
296
|
+
}
|
|
297
|
+
return { workspaces: results };
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=init-migrate.js.map
|