From 3f5ac6caae580f9c73e3ff02e80031579b8fb939 Mon Sep 17 00:00:00 2001
From: "migrations-devops[bot]"
Date: Sun, 30 Aug 2026 01:21:17 +0000
Subject: [PATCH] Sync plugin v2.42.0 from migrations-ai-skills
---
plugin/.cortex-plugin/plugin.json | 25 +-
plugin/VERSION | 2 +-
plugin/agents/business_logic.md | 51 +
.../agents/data_driven.md | 40 +-
.../agents/edge_cases.md | 34 +-
plugin/agents/general-task.md | 543 +++++++
plugin/agents/task-invalidate.md | 100 ++
plugin/agents/test_case_verifier.md | 159 ++
plugin/commands/dash.md | 46 -
plugin/hooks/dev-build-reminder.txt | 3 +
plugin/hooks/install-dependencies.ps1 | 51 +-
plugin/hooks/install-dependencies.sh | 44 +-
plugin/hooks/session-context.ps1 | 24 +-
plugin/hooks/session-context.sh | 18 +-
plugin/hooks/tests/session-context.test.ps1 | 65 +
plugin/hooks/tests/session-context.test.sh | 54 +
plugin/mcp-server/README.md | 23 +-
.../migration/ENTRY.app.md} | 75 +-
plugin/skills/migration/SKILL.md | 15 +-
plugin/skills/migration/assessment/SKILL.md | 49 +-
.../assessment/effort-estimate/SKILL.md | 37 +
.../assessment/scripts/Base_estimates.csv | 19 -
.../scripts/Base_estimates.redshift.csv | 22 -
.../assessment/scripts/effort_estimation.py | 1289 +----------------
.../scripts/generate_multi_report.py | 87 +-
.../snowconvert_reports/ARCHITECTURE.md | 2 +-
.../loaders/project_config.py | 5 -
.../snowconvert_reports/type_coverage.py | 103 +-
.../migration/code-conversion-only/SKILL.md | 52 +-
.../connection/db2-connection/SKILL.md | 120 ++
.../db2-connection/references/REFERENCE.md | 172 +++
.../connection/snowflake-connection/SKILL.md | 63 +
.../migration/data-infrastructure/SKILL.md | 57 +-
.../advanced-operations-reference.md | 182 +++
.../metadata-storage-mode-reference.md | 64 +
.../references/worker-config-reference.md | 33 +
.../data-infrastructure/teardown/SKILL.md | 215 +--
.../data-infrastructure/worker-spcs/SKILL.md | 2 +
.../skills/migration/extensibility/TASKS.md | 102 +-
.../skills/migration/migrate-objects/SKILL.md | 20 +-
.../actions/data-migration/RUN.md | 14 +-
.../actions/data-migration/SKILL.md | 152 +-
.../references/task-model-reference.md | 44 +-
.../references/troubleshooting-reference.md | 73 +
.../references/workflow-config-reference.md | 49 +-
.../actions/etl-stabilization/SKILL.md | 49 +-
.../etl-stabilization/dbt-fixer/SKILL.md | 9 +-
.../etl-stabilization/dbt-test-gen/SKILL.md | 2 +-
.../orchestration-fixer/SKILL.md | 43 +
.../orchestration-test-gen/SKILL.md | 18 +-
.../agent-prompts/dbt-context-mapper.md | 1 +
.../etl-stabilization/reference/examples.md | 4 +-
.../reference/protocols/phase-execution.md | 1 +
.../reference/templates/ROADMAP_TEMPLATE.md | 22 +-
.../reference/templates/batch-artifacts.md | 17 +-
.../reference/templates/fix-log-format.md | 24 +-
.../etl-stabilization/reference/tools.md | 2 +-
.../reference/troubleshooting.md | 1 +
.../scripts/check_sync_leaks.py | 109 ++
.../scripts/path_resolver.py | 27 +-
.../etl-stabilization/scripts/scan_unit.py | 17 +-
.../scripts/tests/__init__.py | 0
.../fixtures/session_status_sanitized.json | 23 +
.../scripts/tests/helpers.py | 127 ++
.../scripts/tests/test_check_sync_leaks.py | 77 +
.../scripts/tests/test_scan_unit.py | 75 +
.../scripts/tests/test_track_status.py | 606 ++++++++
.../etl-stabilization/scripts/track_status.py | 685 ++++++---
.../migrate-objects/actions/finish_objects.md | 9 +-
.../migrate-objects/autonomous/SKILL.md | 504 +++++++
.../baseline-capture/CAPTURE.md | 10 +-
.../migrate-objects/baseline-capture/SWARM.md | 41 +-
.../baseline-capture/agents/business_logic.md | 56 -
.../testbed-generator/SKILL.md | 42 +-
.../prompts/critics/joins_critic.md | 32 +
.../prompts/critics/spec_critic.md | 28 +
.../prompts/critics/verdict-contract.md | 59 +
.../testbed-generator/scripts/assembler.py | 11 +-
.../scripts/critic_backbone.py | 414 ++++++
.../scripts/critic_benchmark.py | 95 ++
.../testbed-generator/scripts/critic_gate.py | 214 +++
.../testbed-generator/scripts/critic_index.py | 281 ++++
.../scripts/critic_normalize.py | 119 ++
.../testbed-generator/scripts/manifest.py | 86 +-
.../testbed-generator/scripts/run_pipeline.py | 203 ++-
.../testbed-generator/scripts/subcommands.py | 35 +-
.../migrate-etl/etl-validate/SKILL.md | 23 +-
.../migrate-objects/migrate-object/DEPLOY.md | 17 +-
.../migrate-object/DIAGNOSE_FIX.md | 416 ++----
.../migrate-object/RUN_TESTS.md | 44 +-
.../migrate-objects/migrate-object/SKILL.md | 7 +-
.../migrate-object/VALIDATE_VIEW.md | 34 +-
.../migrate-objects/migrate-object/VERIFY.md | 16 +-
.../references/BTEQ_TEST_YAML.md | 2 +-
.../references/collaboration-model.md | 3 +-
.../migrate-objects/references/overrides.md | 15 +-
.../migrate-objects/references/sql-queries.md | 39 +-
.../references/testing-framework-perms.md | 2 +-
.../references/troubleshooting.md | 11 +-
.../rule-engine/apply/BATCH.md | 2 +-
.../extract-code-units/SKILL.md | 35 +-
plugin/skills/migration/sas/INTEGRATION.md | 62 +
plugin/skills/migration/sas/README.md | 55 +
plugin/skills/migration/sas/SKILL.md | 36 +
.../sas/assess-sas-migration/SKILL.md | 284 ++++
.../references/complexity-analysis.md | 82 ++
.../references/sizing-model.md | 72 +
.../references/threshold-calibration.md | 102 ++
.../references/wave-planning.md | 100 ++
.../templates/assessment-report.md | 286 ++++
.../templates/effort-staffing-report.md | 94 ++
.../sas/assess-sas-migration/tool/README.md | 74 +
.../assess-sas-migration/tool/assess_sas.py | 155 ++
.../sas/assess-sas-migration/tool/config.json | 10 +
.../tool/requirements.txt | 2 +
.../tool/sas_analyzer/__init__.py | 14 +
.../assets/snowconvert_ai_logo.svg | 15 +
.../sas_analyzer/assets/snowflake_logo.svg | 5 +
.../tool/sas_analyzer/classifier.py | 122 ++
.../tool/sas_analyzer/constants.py | 98 ++
.../tool/sas_analyzer/dependency.py | 200 +++
.../tool/sas_analyzer/html_report.py | 419 ++++++
.../tool/sas_analyzer/parser.py | 387 +++++
.../tool/sas_analyzer/reporter.py | 261 ++++
.../tool/sas_analyzer/scorer.py | 177 +++
.../sas/convert-sas-to-snowflake/SKILL.md | 760 ++++++++++
.../references/auto-validation.md | 321 ++++
.../references/batch-mode.md | 168 +++
.../references/checkpoint-logging.md | 119 ++
.../references/classification-logic.md | 133 ++
.../references/common-patterns.md | 497 +++++++
.../references/comparison-rules.md | 180 +++
.../references/consolidation-patterns.md | 433 ++++++
.../references/conversion-rules.md | 225 +++
.../references/data-steps.md | 505 +++++++
.../references/e2e-orchestration-test.md | 188 +++
.../references/function-mappings.md | 442 ++++++
.../references/large-file-rules.md | 121 ++
.../references/macros.md | 572 ++++++++
.../references/mermaid-diagrams.md | 279 ++++
.../references/multi-block-orchestration.md | 197 +++
.../references/proc-sql.md | 305 ++++
.../references/proc-steps.md | 723 +++++++++
.../references/pyspark-fallback.md | 216 +++
.../references/python-datascience.md | 340 +++++
.../references/schema-inference.md | 168 +++
.../references/snowflake-compile.md | 213 +++
.../references/snowflake-execution.md | 281 ++++
.../references/state-tracker-schema.md | 189 +++
.../references/synthetic-data-rules.md | 219 +++
.../references/validation-execution.md | 279 ++++
.../references/vendor-function-mappings.md | 85 ++
.../references/vendor-passthrough.md | 54 +
.../templates/consolidation-report.md | 270 ++++
.../templates/conversion-report.md | 713 +++++++++
.../templates/output-mixed.md | 332 +++++
.../templates/output-sql.md | 179 +++
.../validate-sas-conversion/SKILL.md | 552 +++++++
.../workflows/steps-7-validation.md | 164 +++
.../workflows/steps-8-10-post-conversion.md | 411 ++++++
.../workflows/validation-pipeline.md | 353 +++++
.../migrate-sas7bdat-to-snowflake/SKILL.md | 226 +++
.../assets/control_table.sql | 31 +
.../assets/directory_table_setup.sql | 23 +
.../assets/loader_sproc.sql | 209 +++
.../assets/task_setup.sql | 68 +
.../references/architecture.md | 139 ++
.../references/testing.md | 105 ++
.../sas/references/block-tiering-spec.md | 131 ++
plugin/skills/migration/setup/SKILL.md | 2 +
.../setup/configure-snowflake-target.md | 5 +
.../setup/configure-source-connection.md | 1 +
.../migration/setup/data-strategy/SKILL.md | 20 +
.../migration/setup/data-validation/SKILL.md | 3 +-
.../references/workflow-config-reference.md | 60 +-
.../migration/setup/discover-extras/SKILL.md | 293 ++++
.../discover-extras/agents/investigate.md | 129 ++
.../discover-extras/cookbook-template.md | 50 +
.../migration/validate-objects/SKILL.md | 16 +-
.../migration/validate-objects/actions/RUN.md | 22 +-
.../references/validation-levels-reference.md | 2 +-
.../actions/validate_tables.md | 126 +-
182 files changed, 22687 insertions(+), 2515 deletions(-)
create mode 100644 plugin/agents/business_logic.md
rename plugin/{skills/migration/migrate-objects/baseline-capture => }/agents/data_driven.md (55%)
rename plugin/{skills/migration/migrate-objects/baseline-capture => }/agents/edge_cases.md (51%)
create mode 100644 plugin/agents/general-task.md
create mode 100644 plugin/agents/task-invalidate.md
create mode 100644 plugin/agents/test_case_verifier.md
delete mode 100644 plugin/commands/dash.md
create mode 100644 plugin/hooks/dev-build-reminder.txt
create mode 100644 plugin/hooks/tests/session-context.test.ps1
create mode 100644 plugin/hooks/tests/session-context.test.sh
rename plugin/{commands/migrate.md => skills/migration/ENTRY.app.md} (51%)
create mode 100644 plugin/skills/migration/assessment/effort-estimate/SKILL.md
delete mode 100644 plugin/skills/migration/assessment/scripts/Base_estimates.csv
delete mode 100644 plugin/skills/migration/assessment/scripts/Base_estimates.redshift.csv
create mode 100644 plugin/skills/migration/connection/db2-connection/SKILL.md
create mode 100644 plugin/skills/migration/connection/db2-connection/references/REFERENCE.md
create mode 100644 plugin/skills/migration/connection/snowflake-connection/SKILL.md
create mode 100644 plugin/skills/migration/data-infrastructure/references/advanced-operations-reference.md
create mode 100644 plugin/skills/migration/data-infrastructure/references/metadata-storage-mode-reference.md
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/check_sync_leaks.py
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/__init__.py
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/fixtures/session_status_sanitized.json
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/helpers.py
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_check_sync_leaks.py
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_scan_unit.py
create mode 100644 plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_track_status.py
create mode 100644 plugin/skills/migration/migrate-objects/autonomous/SKILL.md
delete mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/agents/business_logic.md
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/prompts/critics/joins_critic.md
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/prompts/critics/spec_critic.md
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/prompts/critics/verdict-contract.md
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/scripts/critic_backbone.py
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/scripts/critic_benchmark.py
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/scripts/critic_gate.py
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/scripts/critic_index.py
create mode 100644 plugin/skills/migration/migrate-objects/baseline-capture/testbed-generator/scripts/critic_normalize.py
create mode 100644 plugin/skills/migration/sas/INTEGRATION.md
create mode 100644 plugin/skills/migration/sas/README.md
create mode 100644 plugin/skills/migration/sas/SKILL.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/SKILL.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/references/complexity-analysis.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/references/sizing-model.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/references/threshold-calibration.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/references/wave-planning.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/templates/assessment-report.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/templates/effort-staffing-report.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/README.md
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/assess_sas.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/config.json
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/requirements.txt
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/__init__.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/assets/snowconvert_ai_logo.svg
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/assets/snowflake_logo.svg
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/classifier.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/constants.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/dependency.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/html_report.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/parser.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/reporter.py
create mode 100644 plugin/skills/migration/sas/assess-sas-migration/tool/sas_analyzer/scorer.py
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/SKILL.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/auto-validation.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/batch-mode.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/checkpoint-logging.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/classification-logic.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/common-patterns.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/comparison-rules.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/consolidation-patterns.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/conversion-rules.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/data-steps.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/e2e-orchestration-test.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/function-mappings.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/large-file-rules.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/macros.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/mermaid-diagrams.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/multi-block-orchestration.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/proc-sql.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/proc-steps.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/pyspark-fallback.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/python-datascience.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/schema-inference.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/snowflake-compile.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/snowflake-execution.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/state-tracker-schema.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/synthetic-data-rules.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/validation-execution.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/vendor-function-mappings.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/references/vendor-passthrough.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/templates/consolidation-report.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/templates/conversion-report.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/templates/output-mixed.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/templates/output-sql.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/validate-sas-conversion/SKILL.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/workflows/steps-7-validation.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/workflows/steps-8-10-post-conversion.md
create mode 100644 plugin/skills/migration/sas/convert-sas-to-snowflake/workflows/validation-pipeline.md
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/SKILL.md
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/assets/control_table.sql
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/assets/directory_table_setup.sql
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/assets/loader_sproc.sql
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/assets/task_setup.sql
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/references/architecture.md
create mode 100644 plugin/skills/migration/sas/migrate-sas7bdat-to-snowflake/references/testing.md
create mode 100644 plugin/skills/migration/sas/references/block-tiering-spec.md
create mode 100644 plugin/skills/migration/setup/discover-extras/SKILL.md
create mode 100644 plugin/skills/migration/setup/discover-extras/agents/investigate.md
create mode 100644 plugin/skills/migration/setup/discover-extras/cookbook-template.md
diff --git a/plugin/.cortex-plugin/plugin.json b/plugin/.cortex-plugin/plugin.json
index 0988afb..3b3824f 100644
--- a/plugin/.cortex-plugin/plugin.json
+++ b/plugin/.cortex-plugin/plugin.json
@@ -1,15 +1,25 @@
{
"name": "snowflake-migration",
- "version": "2.41.0",
- "description": "End-to-end database migration to Snowflake — assessment, conversion, validation, and deployment",
- "author": { "name": "db-eng-migrations" },
- "skills": ["skills/migration"],
+ "version": "2.42.0",
+ "description": "End-to-end database migration to Snowflake \u2014 assessment, conversion, validation, and deployment",
+ "author": {
+ "name": "db-eng-migrations"
+ },
+ "skills": [
+ "skills/migration"
+ ],
"mcpServers": {
"mcp": {
"type": "stdio",
"command": "scai",
- "args": ["mcp", "run"],
- "env": { }
+ "args": [
+ "mcp",
+ "run"
+ ],
+ "env": {
+ "CUSTOM_SNOWCONVERT_DATABASE": "${CUSTOM_SNOWCONVERT_DATABASE:-}",
+ "CUSTOM_SNOWFLAKE_DATABASE_FOR_METADATA": "${CUSTOM_SNOWFLAKE_DATABASE_FOR_METADATA:-}"
+ }
}
},
"hooks": {
@@ -35,5 +45,6 @@
"matcher": "*"
}
]
- }
+ },
+ "buildChannel": "preview"
}
diff --git a/plugin/VERSION b/plugin/VERSION
index 2d4c52e..345a83e 100644
--- a/plugin/VERSION
+++ b/plugin/VERSION
@@ -1 +1 @@
-2.41.0
+2.42.0
diff --git a/plugin/agents/business_logic.md b/plugin/agents/business_logic.md
new file mode 100644
index 0000000..237ad0d
--- /dev/null
+++ b/plugin/agents/business_logic.md
@@ -0,0 +1,51 @@
+---
+name: business_logic
+description: Produce test_cases rows for an existing step-based YAML stub that cover IF/ELSE branches, CASE WHEN arms, happy paths, and error paths. Triggers: business_logic, business logic tests, code coverage tests, branch coverage tests.
+license: Proprietary. See License-Skills for complete terms
+---
+
+You produce **`test_cases:` rows** for the object in your prompt that
+exercise every code path in the source SQL.
+
+> You are NOT writing a YAML file. The stub YAML already exists (created by `scai test seed`). Your job is to produce **just the `test_cases:` rows** that will be merged into the existing stub.
+>
+> See [`../skills/migration/migrate-objects/references/step-based-yaml.md` → Placeholders and `test_cases`](../skills/migration/migrate-objects/references/step-based-yaml.md#placeholders-and-test_cases) for the row shape and dialect literal formatting.
+
+## Inputs
+
+The prompt carries `object_name`, `signature`, `source_code`, and
+`project_dir`. A `split` of `A` or `B` means this is one half of a pair
+— write the matching tmp file below.
+
+## Instructions
+
+Analyze the source code and produce rows that:
+
+- Exercise each `IF` / `ELSEIF` / `ELSE` branch.
+- Cover each `CASE WHEN` arm.
+- Hit the happy path with typical values.
+- Trigger early-return conditions.
+- Trigger error / exception paths (invalid inputs the proc must reject or handle).
+
+**Do not query the source database.** Generate rows purely from code analysis. For parameter values that depend on data (e.g. valid IDs), use synthetic placeholder values (`1`, `2`, `100`, `999`) — the `data_driven` agent handles real-data lookups.
+
+When `split` is `A`, focus on happy paths and main branches. When it is
+`B`, focus on error paths, exceptions, and edge conditions found in the
+source SQL.
+
+## Output
+
+Write your rows to `/.scai/tmp/_business_logic.yml`,
+or `_business_logic_a.yml` / `_business_logic_b.yml` when `split` is set.
+
+The file must contain only valid YAML starting with `test_cases:`. Also print the rows to stdout as a backup.
+
+```yaml
+test_cases:
+ - [1, 100.00] # happy path - main IF branch
+ - [1, 1500.00] # high-value branch - CASE WHEN amount > 1000
+ - [-1, 10.00] # error path - negative ID
+ - [null, 10.00] # NULL guard - COALESCE branch
+```
+
+Each row is a JSON-ish array of literals matching the proc's parameter order. Add a trailing `# ...` comment explaining which branch the row exercises — this helps the orchestrator dedupe.
diff --git a/plugin/skills/migration/migrate-objects/baseline-capture/agents/data_driven.md b/plugin/agents/data_driven.md
similarity index 55%
rename from plugin/skills/migration/migrate-objects/baseline-capture/agents/data_driven.md
rename to plugin/agents/data_driven.md
index 063cda5..bb57c41 100644
--- a/plugin/skills/migration/migrate-objects/baseline-capture/agents/data_driven.md
+++ b/plugin/agents/data_driven.md
@@ -1,26 +1,22 @@
---
-name: data-driven-test-agent
-description: Produces `test_cases:` rows for an existing step-based YAML stub by querying realistic parameter values from the source database. The most important swarm agent — real data produces the highest-confidence baselines. Triggers: data-driven tests, real data test cases, query source for test values.
-parent_skill: baseline-capture
+name: data_driven
+description: Produce test_cases rows for an existing step-based YAML stub from realistic source-database values. Triggers: data_driven, data-driven tests, real data test cases, query source for test values.
+license: Proprietary. See License-Skills for complete terms
---
-# Agent: Data-Driven Test Cases
-
-You produce **`test_cases:` rows** for ``, using realistic values queried from the source database.
-
-This is the most important swarm agent — real data produces the highest-confidence baselines.
+You produce **`test_cases:` rows** for the object in your prompt, using
+realistic values queried from the source database.
> You are NOT writing a YAML file. The stub YAML already exists (created by `scai test seed`). Your job is to produce **just the `test_cases:` rows** that will be merged into the existing stub.
>
-> See [`../../references/step-based-yaml.md` → Placeholders and `test_cases`](../../references/step-based-yaml.md#placeholders-and-test_cases) for the row shape and dialect literal formatting.
+> See [`../skills/migration/migrate-objects/references/step-based-yaml.md` → Placeholders and `test_cases`](../skills/migration/migrate-objects/references/step-based-yaml.md#placeholders-and-test_cases) for the row shape and dialect literal formatting.
## Inputs
-- **Object signature**: ``
-- **Source code**: ``
-- **Referenced tables**: ``
-- **Source connection name**: ``
-- **Project directory**: ``
+The prompt carries `object_name`, `signature`, `source_code`,
+`referenced_tables`, `source_connection`, and `project_dir`. A `split`
+of `A` or `B` means this is one half of a pair — write the matching
+tmp file below.
## Instructions
@@ -29,6 +25,10 @@ This is the most important swarm agent — real data produces the highest-confid
3. Build positional arrays matching the proc's parameter order. Use literals (`null`, numbers, strings) — no quoting; the runner formats them per dialect.
4. Include rows that should return data **and** rows that return empty results (real cases the proc must handle).
+When `split` is `A`, focus on cases that return data (valid lookups, common
+params). When it is `B`, focus on edge data (oldest / newest records,
+boundary dates from the actual table contents).
+
### Testbed Fallback (No Live Source Connection)
When `query_source` is unavailable (e.g. Teradata migrations without a live connection):
@@ -39,7 +39,8 @@ When `query_source` is unavailable (e.g. Teradata migrations without a live conn
## Output
-Write your rows to: `/.scai/tmp/_data_driven.yml`
+Write your rows to `/.scai/tmp/_data_driven.yml`,
+or `_data_driven_a.yml` / `_data_driven_b.yml` when `split` is set.
The file must contain only valid YAML starting with `test_cases:`. Also print the rows to stdout as a backup.
@@ -51,12 +52,3 @@ test_cases:
```
Each row is a JSON-ish array of literals: `null` for SQL NULL, unquoted numbers, strings in double-quotes if they contain colons / special chars (otherwise unquoted is fine in YAML).
-
-## When the orchestrator splits data-driven into A/B
-
-For complex objects, two data-driven agents may be spawned:
-
-- **Agent 1A** — focus on cases that return data (valid lookups, common params).
-- **Agent 1B** — focus on edge data (oldest / newest records, boundary dates from the actual table contents).
-
-Each writes its own tmp file (`_data_driven_a.yml` vs `_b.yml`); the orchestrator merges them.
diff --git a/plugin/skills/migration/migrate-objects/baseline-capture/agents/edge_cases.md b/plugin/agents/edge_cases.md
similarity index 51%
rename from plugin/skills/migration/migrate-objects/baseline-capture/agents/edge_cases.md
rename to plugin/agents/edge_cases.md
index 93462f3..de3b89b 100644
--- a/plugin/skills/migration/migrate-objects/baseline-capture/agents/edge_cases.md
+++ b/plugin/agents/edge_cases.md
@@ -1,22 +1,21 @@
---
-name: edge-cases-test-agent
-description: Produces `test_cases:` rows for an existing step-based YAML stub focusing on edge cases and boundary values — NULLs, zeros, empty strings, type limits, overflow, precision boundaries. No source DB access required. Triggers: edge case tests, boundary value tests, null handling tests.
-parent_skill: baseline-capture
+name: edge_cases
+description: Produce test_cases rows for an existing step-based YAML stub covering NULLs, zeros, empty strings, type limits, overflow, and precision boundaries. Triggers: edge_cases, edge case tests, boundary value tests, null handling tests.
+license: Proprietary. See License-Skills for complete terms
---
-# Agent: Edge Cases & Boundaries
-
-You produce **`test_cases:` rows** for `` focusing on edge cases and boundary values.
+You produce **`test_cases:` rows** for the object in your prompt focusing
+on edge cases and boundary values.
> You are NOT writing a YAML file. The stub YAML already exists (created by `scai test seed`). Your job is to produce **just the `test_cases:` rows** that will be merged into the existing stub.
>
-> See [`../../references/step-based-yaml.md` → Placeholders and `test_cases`](../../references/step-based-yaml.md#placeholders-and-test_cases) for the row shape and dialect literal formatting.
+> See [`../skills/migration/migrate-objects/references/step-based-yaml.md` → Placeholders and `test_cases`](../skills/migration/migrate-objects/references/step-based-yaml.md#placeholders-and-test_cases) for the row shape and dialect literal formatting.
## Inputs
-- **Object signature**: ``
-- **Source code**: ``
-- **Project directory**: ``
+The prompt carries `object_name`, `signature`, `source_code`, and
+`project_dir`. A `split` of `A` or `B` means this is one half of a pair
+— write the matching tmp file below.
## Instructions
@@ -36,9 +35,13 @@ Produce rows covering:
- Date boundaries: min/max SQL dates, year/month boundaries.
- Values likely to trigger overflow or truncation in either dialect.
+When `split` is `A`, focus on NULL handling and zero / empty values.
+When it is `B`, focus on type limits, overflow, and precision boundaries.
+
## Output
-Write your rows to: `/.scai/tmp/_edge_cases.yml`
+Write your rows to `/.scai/tmp/_edge_cases.yml`,
+or `_edge_cases_a.yml` / `_edge_cases_b.yml` when `split` is set.
The file must contain only valid YAML starting with `test_cases:`. Also print the rows to stdout as a backup.
@@ -52,12 +55,3 @@ test_cases:
```
Each row is a JSON-ish array of literals matching the proc's parameter order.
-
-## When the orchestrator splits edge cases into A/B
-
-For complex objects, two edge-case agents may be spawned:
-
-- **Agent 2A** — focus on NULL handling and zero / empty values.
-- **Agent 2B** — focus on type limits, overflow, and precision boundaries.
-
-Each writes its own tmp file (`_edge_cases_a.yml` vs `_b.yml`); the orchestrator merges them.
diff --git a/plugin/agents/general-task.md b/plugin/agents/general-task.md
new file mode 100644
index 0000000..a7308f0
--- /dev/null
+++ b/plugin/agents/general-task.md
@@ -0,0 +1,543 @@
+---
+name: general-task
+description: Walks ONE object end-to-end through the migration machine — resolves its next task, runs it, stamps the outcome, and repeats until the object is done or needs a human. Dispatched by the autonomous migration loop with a single object id.
+license: Proprietary. See License-Skills for complete terms
+---
+
+You migrate **one object**, through as many tasks as it takes. The first prompt
+carries `objectId`, `agentId`, `projectDir`, `pluginDir`,
+`snowflakeConnection`, and `snowflakeDatabase`. Later turns of
+**this same conversation** (the orchestrator resumes you; it does not spawn a
+new agent) carry only what is new: `guidance` — a human's words from an
+earlier escalation, which outrank your own first instinct — `relay_wake:`
+when it is sending you back to read a job you already started — or a continue
+line when the machine routed recovery. Pick up from §2; do not treat a later
+turn as a new object.
+
+`agentId` is your identity for the run: **pass it on every call that writes.** One MCP
+server serves you and every other agent on this wave, so it is the only thing that
+tells your writes from theirs. Claiming records it on your object, and every later
+write of yours has to carry the same id — a write aimed at an object you do not hold is
+refused, and so is an id the server did not issue. It is four hex digits, e.g. `a3f2`;
+never invent one, never substitute `objectId`, and never use `0000` (the
+orchestrator's).
+
+You **MUST** use the state machine through the MCP tools to work on your object. If you're stuck, do not invent things or
+go off the rail. More below on escalations.
+
+Do not call the Skill tool and do not load `snowflake-migration:migration`.
+That skill is the interactive session router (welcome, checklist, prescribed
+path, skill-match). You already have this definition. Task skills are files:
+Read `/skills/migration/`. If the host dumps the
+root skill anyway, ignore Step 0 / Skill Match / the progress checklist.
+
+## 1. Attach and claim
+
+```
+configure(project_dir="", agent_id="")
+transition_status(status="begin", where="id = ''", agent_id="")
+```
+
+`configure` is first, always: it names you so a live job is announced to this
+conversation. The orchestrator already set the session; this call does not bind
+a dashboard or rewrite plugin.yml. Claiming
+before you work means a dispatch that dies never leaves the object locked by nobody.
+
+Skip this section when this conversation already attached — a `relay_wake:`
+turn is that case. A `guidance:` turn may have been un-parked after the claim
+was released: run §1 again, then continue; the guidance still outranks.
+
+Your `agentId` belongs on that first call too. If you are subscribed to a live
+job, `configure` tells you once to return `waiting` (unless this turn is a
+`relay_wake:`). It changes nothing else about the call.
+
+## 2. Loop
+
+```
+migration_status(mode="next_task", object_ids=[""], agent_id="")
+```
+
+That call is your board: one id, one next task. `my_objects_board`,
+`my_objects_summary`, `my_objects_details`, and `next_objects` are the
+parent's wave views — they name other objects and will not advance yours.
+
+The response is keyed by object id. Read `response[""]` and take the
+first case that matches:
+
+| Case | Do |
+|---|---|
+| `error` | The id is unknown or the registry read failed. Return `stuck`. |
+| `nextTask: null`, not `errored`, not `blocked` | Terminal — go to **Finish**. |
+| `wait` | The machine parked you on another object's `migrateData` / `validateData`. Return `waiting`. The orchestrator resumes this conversation with `relay_wake:` when that job succeeds. Do not call `job_status(wake=true)` on their job — `next_task` already registered the wait. |
+| `blocked: true` without `wait`, or `nextTaskBlocked` without `wait` | Report every `blockedOn` / `nextTaskBlockedOn` entry with its `reason`, return `partial`. Blocks are the orchestrator's call and will not clear by looping. A `reason` you can disprove is still not yours to fix: put what you found in `notes` and leave it. The blocking object is outside your dispatch: it has its own walk, and the block clears when that walk finishes, not when you act on it. Two writers on one object race on the same files and registry entries — never `deploy` it, never run SQL that changes it, never edit its files, never stamp a task on its behalf. When this arrives on an advance response, call `next_task` with your `agent_id` before you return: that is what registers a job-backed `wait` if there is one. |
+| `prompt` | The machine wants an answer you have no user to give. **Escalate**, with the question as your `asks`. |
+| `needsHuman: true` | Already parked on someone else's escalation. Leave it, return `stuck`, pass its `asks` through unchanged — a second row for one question is just noise in the queue. |
+| otherwise | Run it, stamp it, loop. |
+
+To run it, read `executor`:
+
+- `kind: "mcpTool"` — call `executor.tool` with `executor.args`.
+- `kind: "shell"` — run `executor.command`.
+- `kind: "agent"` — follow `executor.skill`, resolved as the response's `skillPath`
+ when it has one (already absolute) and otherwise as
+ `/skills/migration/`. It is a file path, not a host
+ skill name — a skill tool will not find it. Read it before acting; it carries the
+ failure modes for that task.
+
+Read `executor.skill` on **every** kind that has one, not only `kind: "agent"` —
+`deploy`, `migrateData` and `validateData` all carry a skill, and it holds the
+failure modes the bare tool call doesn't.
+
+When the executor is `update_registry`, add `agent_id=""` yourself.
+`executor.args` and `invocation` are rendered from the machine, which does not know
+who you are, so the id is never in them and the call is refused without it. Pass
+`agent_id` on `next_task` too — that is how a job-backed block becomes `wait`
+instead of a silent `partial`. Apart from `transition_status`, `update_registry`,
+`configure`, and `next_task`, the tools take no `agent_id` yet — which is not
+permission to touch another object with them; the row above still holds.
+
+Prefer `invocation` over `executor` when the response has one. It is the same call
+already scoped to your object; `executor` renders without its filter, so
+`executor.command` run literally would operate on the **whole project**.
+
+When diagnosis needs target data, call the permitted `sql_execute` tool with one
+read-only `SELECT`. Pass `connection` (`-c`) as the attach /
+first-prompt `snowflakeConnection` on **every** call — Cortex does not inherit
+`configure`, and omitting it uses Cortex's default connection, which is a
+different account. Fully qualify every target relation as
+`..
"
)
_DDL_EXCLUDED_DISPLAY_TYPES = frozenset({"Index", "Flow Control"})
-_DATA_MIGRATION_FLAT_HOURS = 8.0
-DEFAULT_BASE_ESTIMATES_CSV = Path(__file__).parent / "Base_estimates.csv"
-_FIXED_BUDGET_COMPONENTS = frozenset(
- {
- "Fixed Budget",
- "Data Migration",
- "Optimization",
- "Integration Testing",
- "Integration Testing Bug Fix",
- "UAT",
- "Delivery",
- }
-)
-
-_CATEGORY_DISPLAY = {
- "TABLE": "Table",
- "EXTERNAL TABLE": "External Table",
- "VIEW": "View",
- "MATERIALIZED VIEW": "Materialized View",
- "PROCEDURE": "Procedure",
- "FUNCTION": "Function",
- "INDEX": "Index",
- "SYNONYM": "Synonym",
- "SCHEMA": "Schema",
- "TYPE": "Type",
- "DATABASE": "Database",
-}
-
-_DDL_NOTES = {
- "Synonym": "Replace with Snowflake aliases or views; flat effort in Fixed Budget",
-}
-
-_CALCULATOR_TO_DDL = {
- "tables": "Table",
- "views": "View",
- "functions": "Function",
- "stored procedures": "Procedure",
-}
-
-
-# SnowConvert ``SourceLanguage`` values and common aliases for SQL Server / T-SQL.
-_SQL_SERVER_DIALECT_VALUES = frozenset({
- "transact",
- "sql server",
- "sqlserver",
- "t-sql",
- "tsql",
- "t sql",
- "mssql",
- "ms sql",
- "microsoft sql server",
-})
-
-# SnowConvert ``SourceLanguage`` values and common aliases for Amazon Redshift.
-_REDSHIFT_DIALECT_VALUES = frozenset({
- "redshift",
- "amazon redshift",
- "aws redshift",
-})
-
-# effort dialect key → the frozenset of source_language values that map to it
-_DIALECT_VALUE_SETS: Dict[str, frozenset] = {
- "sqlserver": _SQL_SERVER_DIALECT_VALUES,
- "redshift": _REDSHIFT_DIALECT_VALUES,
-}
-
-# Each dialect's rates are tuned independently, so each gets its own bundled CSV.
-_DIALECT_BASE_ESTIMATES = {
- "sqlserver": "Base_estimates.csv",
- "redshift": "Base_estimates.redshift.csv",
-}
-
-
-def _normalize_dialect(dialect: str) -> str:
- return re.sub(r"[\s_\-]+", " ", (dialect or "").strip().lower())
-
-
-def resolve_effort_dialect(source_language: str) -> Optional[str]:
- """Map a project ``source_language`` to a supported effort dialect key.
-
- Returns ``"sqlserver"``, ``"redshift"``, or ``None`` for unsupported dialects.
- Matches the whole normalized string against a set (no substring matching), so a
- word merely containing a dialect name never enables the feature.
- """
- normalized = _normalize_dialect(source_language)
- if not normalized:
- return None
- for key, values in _DIALECT_VALUE_SETS.items():
- if normalized in values:
- return key
- return None
-
-
-def read_project_source_language(project_dir: Optional[Path]) -> str:
- """Read ``source_language`` from ``{project_dir}/.scai/config/project.yml``.
-
- ``project.yml`` is a flat ``key: value`` document, so a dependency-free line reader
- is used (PyYAML is not available in the assessment environment). Returns ``""`` when
- ``project_dir`` is falsy, the file is absent/unreadable, or the key is missing.
- """
- if not project_dir:
- return ""
- yml = Path(project_dir) / ".scai" / "config" / "project.yml"
- if not yml.exists():
- return ""
- try:
- for line in yml.read_text(encoding="utf-8-sig").splitlines():
- stripped = line.strip()
- if not stripped or stripped.startswith("#") or ":" not in stripped:
- continue
- key, _, value = stripped.partition(":")
- if key.strip() == "source_language":
- return value.strip().strip('"').strip("'")
- except OSError:
- return ""
- return ""
-
-
-def _default_base_estimates_for(dialect_key: str) -> Path:
- """Resolve the bundled Base_estimates CSV for a dialect key."""
- filename = _DIALECT_BASE_ESTIMATES.get(dialect_key, "Base_estimates.csv")
- return Path(__file__).parent / filename
-
-
-def is_effort_estimation_supported(project_dir: Path) -> bool:
- """Return True when the project's ``source_language`` is a supported effort dialect.
-
- Dialect is read exclusively from ``{project_dir}/.scai/config/project.yml``. Runs
- without a project directory are unsupported and produce no effort tab.
- """
- return resolve_effort_dialect(read_project_source_language(project_dir)) is not None
-
-
-def classify_workload_size(
- total_objects: int,
- config: Optional[EffortEstimateConfig] = None,
-) -> str:
- """Map total workload object count to Small / Medium / Large tier."""
- cfg = config or get_effort_estimate_config()
- if total_objects <= cfg.workload_small_max:
- return "small"
- if total_objects <= cfg.workload_medium_max:
- return "medium"
- return "large"
-
-
-def workload_size_label(
- tier: str,
- config: Optional[EffortEstimateConfig] = None,
-) -> str:
- """Human-readable workload tier label for overview UI."""
- cfg = config or get_effort_estimate_config()
- labels = {
- "small": f"Small (up to {cfg.workload_small_max:,} objects)",
- "medium": (
- f"Medium ({cfg.workload_small_max + 1:,}"
- f"–{cfg.workload_medium_max:,} objects)"
- ),
- "large": f"Large (more than {cfg.workload_medium_max:,} objects)",
- }
- return labels.get(tier, tier.title())
-
-
-def count_workload_objects(ddl_summary: Dict[str, Dict[str, Any]]) -> int:
- """Count migratable objects used for workload tier sizing."""
- return sum(
- st.get("total", 0)
- for name, st in ddl_summary.items()
- if name not in _DDL_EXCLUDED_DISPLAY_TYPES
- )
-
-
-def compute_tiered_phase_budgets(
- total_objects: int,
- config: Optional[EffortEstimateConfig] = None,
-) -> Dict[Tuple[str, str], float]:
- """Return phase fixed budgets scaled by workload size tier.
-
- Keyed by (component, object_type) lowercased display labels — not the CSV
- ``Key`` slug — so callers can look up a phase the same way it's rendered.
- """
- cfg = config or get_effort_estimate_config()
- tier = classify_workload_size(total_objects, cfg)
- return {
- (phase.component.lower(), phase.object_type.lower()): phase.hours_by_tier[tier]
- for phase in cfg.phase_budgets
- }
-
-
-def _parse_tier_to_min_pct(tier_str: str) -> int:
- t = tier_str.strip().lower()
- if "full" in t:
- return 100
- if "partially" in t:
- return 0
- m = re.search(r"(\d+)\s*[-–]\s*\d+\s*%", t)
- if m:
- return int(m.group(1))
- return 0
-
-
-def _loc_pct_is_measured(loc_pct_str: str) -> bool:
- """True when the report actually carries a parseable LoC conversion percentage.
-
- ``_parse_loc_pct`` infers 100% from ``ConversionStatus == Success`` when the cell is
- missing, which is fine for tier bucketing but must not drive an effort discount —
- otherwise absent data reads as perfect conversion and zeroes the budget.
- """
- return bool(loc_pct_str) and bool(re.sub(r"[^0-9.]", "", str(loc_pct_str)))
-
-
-def _parse_loc_pct(loc_pct_str: str, conversion_status: str) -> float:
- if loc_pct_str:
- cleaned = re.sub(r"[^0-9.]", "", str(loc_pct_str))
- if cleaned:
- return float(cleaned)
- status = str(conversion_status).strip().lower()
- if status == "success":
- return 100.0
- if status in ("failure", "notsupported", "not supported"):
- return 0.0
- return 0.0
-
-
-def _conversion_bucket(category: str, pct: float) -> str:
- """Map an object to a tier bucket key used for quantity counting."""
- cat = category.upper()
- if cat in ("TABLE", "VIEW", "FUNCTION"):
- return "full" if pct >= 100 else "partial"
- if cat == "PROCEDURE":
- if pct >= 100:
- return "full"
- if pct >= 75:
- return "75-99"
- if pct >= 50:
- return "50-75"
- if pct >= 25:
- return "25-50"
- return "0-25"
- return "partial"
-
-
-def _normalize_status(status: str) -> str:
- s = (status or "").strip().lower()
- if s == "success":
- return "Success"
- if s in ("partial", "action required", "actionrequired"):
- return "Partial"
- if s in ("notsupported", "not supported", "failure"):
- return "Unsupported"
- return "Partial"
@dataclass
class CalculatorRow:
component: str
object_type: str
- quantity: Any # int, float, or display str
+ quantity: Any
baseline_hours: float
total_baseline_hours: float
fde_hours: float
+ unweighted_fde_hours: float
comments: str = ""
- # Pre-weighting hours, so the summary can report what conversion automation saved.
- unweighted_fde_hours: float = 0.0
-
-@dataclass(frozen=True)
-class PhaseBudgetTemplate:
- key: str
- component: str
- object_type: str
- hours_by_tier: Dict[str, float]
-
-@dataclass(frozen=True)
-class CalculatorRowTemplate:
- key: str
- component: str
- object_type: str
- quantity_rule: str
- baseline_hours: float
- comments: str = ""
-
-
-@dataclass
-class EffortEstimateConfig:
- workload_small_max: int = _WORKLOAD_SIZE_SMALL_MAX
- workload_medium_max: int = _WORKLOAD_SIZE_MEDIUM_MAX
- tables_views_flat_hours: float = 4.0
- code_conversion_per_object_hours: float = 1.0
- data_migration_flat_hours: float = _DATA_MIGRATION_FLAT_HOURS
- conversion_weighted: bool = False
- phase_budgets: Tuple[PhaseBudgetTemplate, ...] = ()
- calculator_rows: Tuple[CalculatorRowTemplate, ...] = ()
-
-
-_CONFIG_CACHE: Dict[str, EffortEstimateConfig] = {}
-
-
-def _parse_config_float(value: Any, default: float = 0.0) -> float:
- raw = str(value or "").strip()
- if not raw:
- return default
+def load_effort_assessment(path: Path) -> Optional[Dict[str, Any]]:
+ """Load an effort artifact and rehydrate its calculator rows for HTML renderers."""
try:
- return float(raw)
- except (TypeError, ValueError):
- logger.warning(
- "Base_estimates.csv: could not parse numeric value %r; using default %s",
- value,
- default,
- )
- return default
-
-
-def _parse_config_int(value: Any, default: int = 0) -> int:
- return int(_parse_config_float(value, float(default)))
-
-
-def load_effort_estimate_config(csv_path: Path) -> EffortEstimateConfig:
- """Load calculator templates, rates, and workload tier thresholds from CSV."""
- path = Path(csv_path)
- cache_key = str(path.resolve())
- if cache_key in _CONFIG_CACHE:
- return _CONFIG_CACHE[cache_key]
-
- cfg = EffortEstimateConfig()
- phase_rows: List[PhaseBudgetTemplate] = []
- calculator_rows: List[CalculatorRowTemplate] = []
-
- with open(path, newline="", encoding="utf-8-sig") as f:
- reader = csv.DictReader(f)
- for row in reader:
- section = (row.get("Section") or "").strip().lower()
- key = (row.get("Key") or "").strip()
- if not section:
- continue
-
- if section == "meta":
- if key == "workload_small_max_objects":
- cfg.workload_small_max = _parse_config_int(
- row.get("Small"), cfg.workload_small_max
- )
- elif key == "workload_medium_max_objects":
- cfg.workload_medium_max = _parse_config_int(
- row.get("Medium"), cfg.workload_medium_max
- )
- elif key == "conversion_weighted":
- cfg.conversion_weighted = _parse_config_int(row.get("Small")) == 1
- continue
-
- if section == "rate":
- baseline = _parse_config_float(row.get("Baseline Hours"))
- if key == "tables_views_flat":
- cfg.tables_views_flat_hours = baseline
- elif key == "code_conversion_per_object":
- cfg.code_conversion_per_object_hours = baseline
- elif key == "data_migration_flat":
- cfg.data_migration_flat_hours = baseline
- continue
-
- if section == "phase":
- phase_rows.append(
- PhaseBudgetTemplate(
- key=key,
- component=(row.get("Migration Component") or "").strip(),
- object_type=(row.get("Object Type") or "").strip(),
- hours_by_tier={
- "small": _parse_config_float(row.get("Small")),
- "medium": _parse_config_float(row.get("Medium")),
- "large": _parse_config_float(row.get("Large")),
- },
- )
- )
- continue
-
- if section == "calculator":
- calculator_rows.append(
- CalculatorRowTemplate(
- key=key,
- component=(row.get("Migration Component") or "").strip(),
- object_type=(row.get("Object Type") or "").strip(),
- quantity_rule=(row.get("Quantity Rule") or "").strip().lower(),
- baseline_hours=_parse_config_float(row.get("Baseline Hours")),
- comments=(row.get("Comments") or "").strip(),
- )
- )
-
- cfg.phase_budgets = tuple(phase_rows)
- cfg.calculator_rows = tuple(calculator_rows)
- _CONFIG_CACHE[cache_key] = cfg
- return cfg
-
-
-def get_effort_estimate_config(
- csv_path: Optional[Path] = None,
-) -> EffortEstimateConfig:
- """Return cached effort config, defaulting to bundled Base_estimates.csv."""
- return load_effort_estimate_config(csv_path or DEFAULT_BASE_ESTIMATES_CSV)
-
-
-def _find_toplevel_code_units_csv(reports_dir: Path) -> Optional[Path]:
- reports_dir = Path(reports_dir)
- dirs = [reports_dir]
- sub = reports_dir / "SnowConvert"
- if sub.exists():
- dirs.append(sub)
- for d in dirs:
- for pattern in ("TopLevelCodeUnits.NA.csv", "TopLevelCodeUnits.*.csv"):
- matches = list(d.glob(pattern))
- if matches:
- return matches[0]
- return None
-
-
-def _find_report_csv(reports_dir: Path, base_name: str) -> Optional[Path]:
- reports_dir = Path(reports_dir)
- dirs = [reports_dir]
- sub = reports_dir / "SnowConvert"
- if sub.exists():
- dirs.append(sub)
- for d in dirs:
- for pattern in (f"{base_name}.NA.csv", f"{base_name}.*.csv"):
- matches = sorted(d.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
- if matches:
- return matches[0]
- return None
-
-
-def _csv_field(row: dict, *names: str) -> str:
- for name in names:
- for key, val in row.items():
- if key.strip().lower() == name.lower():
- return str(val or "").strip()
- return ""
-
-
-def _severity_bucket(severity: str) -> str:
- s = (severity or "").strip().lower()
- if s in ("none", "info"):
- return "none_info"
- if s == "low":
- return "low"
- if s == "medium":
- return "medium"
- if s == "high":
- return "high"
- if s == "critical":
- return "critical"
- return "none_info"
-
-
-def _new_ddl_row() -> Dict[str, Any]:
- return {
- "total": 0,
- "success": 0,
- "partial": 0,
- "unsupported": 0,
- "lines_of_code": 0,
- "issues_none_info": 0,
- "issues_low": 0,
- "issues_medium": 0,
- "issues_high": 0,
- "issues_critical": 0,
- "effort_hours": 0.0,
- "notes": "",
- "pct_auto": 0.0,
- }
-
-
-def merge_issue_counts_into_ddl(
- ddl_summary: Dict[str, Dict[str, Any]],
- issues_path: Path,
- code_unit_map: Dict[str, str],
-) -> None:
- """Attach Issues.csv severity counts to each DDL object type."""
- with open(issues_path, newline="", encoding="utf-8-sig") as f:
- for row in csv.DictReader(f):
- cu_id = _csv_field(row, "CodeUnitId", "Code Unit Id")
- category = code_unit_map.get(cu_id, "")
- if not category:
- continue
- display = _CATEGORY_DISPLAY.get(category, category.title())
- if display not in ddl_summary:
- continue
- bucket = _severity_bucket(_csv_field(row, "Severity"))
- key = f"issues_{bucket}"
- if key in ddl_summary[display]:
- ddl_summary[display][key] += 1
-
-
-def build_top_ddl_issues(issues_path: Optional[Path], limit: int = 10) -> List[Dict[str, Any]]:
- if not issues_path or not issues_path.exists():
- return []
- counts: Counter[str] = Counter()
- meta: Dict[str, Dict[str, str]] = {}
- with open(issues_path, newline="", encoding="utf-8-sig") as f:
- for row in csv.DictReader(f):
- code = _csv_field(row, "Code")
- if not code:
- continue
- counts[code] += 1
- if code not in meta:
- meta[code] = {
- "code": code,
- "name": _csv_field(row, "Name"),
- "severity": _csv_field(row, "Severity") or "—",
- }
- result = []
- for code, count in counts.most_common(limit):
- entry = dict(meta[code])
- entry["occurrences"] = count
- result.append(entry)
- return result
-
-
-def assign_ddl_effort_from_calculator(
- ddl_summary: Dict[str, Dict[str, Any]],
- calculator_rows: List[CalculatorRow],
-) -> None:
- """Sum FDE hours from calculator tiers into DDL object types."""
- for row in calculator_rows:
- comp = row.component.strip().lower()
- if not comp.startswith("code conversion"):
- continue
- m = re.match(r"^(.+?)\s*\(", row.object_type, re.I)
- if not m:
- continue
- display = _CALCULATOR_TO_DDL.get(m.group(1).strip().lower())
- if display and display in ddl_summary:
- ddl_summary[display]["effort_hours"] += row.fde_hours
- for display, note in _DDL_NOTES.items():
- if display in ddl_summary and not ddl_summary[display]["notes"]:
- ddl_summary[display]["notes"] = note
-
-
-def _exclude_effort_category(category: str) -> bool:
- return category.upper() in _SKIP_EFFORT_CATEGORIES
-
-
-def _partial_effort_hours(category: str, loc: int) -> float:
- """Partial-conversion effort (50% testing-framework discount already applied)."""
- if category == "PROCEDURE":
- return 0.25 if loc < 50 else 0.50
- if loc < 50:
- return 0.25
- if loc <= 200:
- return 0.50
- return 1.25
-
-
-def count_quantities_from_code_units(
- csv_path: Path,
-) -> Tuple[Dict[str, int], Dict[str, Any], Dict[str, str]]:
- """Count unique CodeUnitId per tier and build DDL summary (migration inventory rules).
-
- Excludes SESSION / BATCH CONTROL and OUT OF SCOPE rows. Each CodeUnitId is
- counted once (duplicate CSV rows from batch/session artifacts are ignored).
-
- Also returns a CodeUnitId → Category map built in this same pass, so callers
- merging in Issues.csv counts (see ``merge_issue_counts_into_ddl``) don't need
- a second full scan of the same CSV just to look up categories.
- """
- tier_counts: Dict[str, int] = {}
- ddl: Dict[str, Dict[str, Any]] = {}
- code_unit_categories: Dict[str, str] = {}
- seen_ids: set[str] = set()
-
- with open(csv_path, newline="", encoding="utf-8-sig") as f:
- for row in csv.DictReader(f):
- category = _csv_field(row, "Category").upper()
- if not category or _exclude_effort_category(category) or category in ("INDEX", "FLOW CONTROL"):
- continue
- cu_id = _csv_field(row, "CodeUnitId", "Code Unit Id")
- if not cu_id or cu_id in seen_ids:
- continue
- seen_ids.add(cu_id)
- code_unit_categories[cu_id] = category
-
- loc_pct_cell = _csv_field(row, "LoCConversionPercentage")
- pct = _parse_loc_pct(loc_pct_cell, _csv_field(row, "ConversionStatus"))
- pct_measured = _loc_pct_is_measured(loc_pct_cell)
- status = _normalize_status(_csv_field(row, "ConversionStatus"))
- try:
- loc = int(_csv_field(row, "Lines of Code", "LinesOfCode") or 0)
- except ValueError:
- loc = 0
-
- if category in _OBJECT_TYPE_MAP.values():
- bucket = _conversion_bucket(category, pct)
- obj_label = next(
- (k for k, v in _OBJECT_TYPE_MAP.items() if v == category),
- category.lower(),
- )
- tier_label = _tier_label_for_bucket(bucket)
- key = f"{obj_label} ({tier_label})".lower()
- tier_counts[key] = tier_counts.get(key, 0) + 1
-
- cat_key = _CATEGORY_DISPLAY.get(category, category.title())
- if cat_key not in ddl:
- ddl[cat_key] = _new_ddl_row()
- ddl[cat_key]["total"] += 1
- if status == "Success":
- ddl[cat_key]["success"] += 1
- elif status == "Unsupported":
- ddl[cat_key]["unsupported"] += 1
- else:
- ddl[cat_key]["partial"] += 1
- ddl[cat_key]["lines_of_code"] += loc
- ddl[cat_key].setdefault("_objects", []).append(
- {
- "category": category,
- "status": status,
- "loc": loc,
- "pct": pct,
- "pct_measured": pct_measured,
- }
- )
-
- for stats in ddl.values():
- total = stats["total"]
- stats["pct_auto"] = round(stats["success"] / total, 4) if total else 0.0
-
- return tier_counts, ddl, code_unit_categories
-
-
-def _per_object_ddl_effort(display: str, obj: Dict[str, Any]) -> float:
- """Incremental per-object DDL effort for object types without a flat table/view budget."""
- cat = obj["category"]
- loc = obj["loc"]
- status = obj["status"]
- effort = 0.0
- if status == "Partial":
- effort = _partial_effort_hours(cat, loc)
- elif status == "Success" and display not in ("Procedure",):
- effort = _SUCCESS_REVIEW_HOURS
- return effort
-
-
-def _table_view_flat_effort(
- stats: Dict[str, Any],
- config: Optional[EffortEstimateConfig] = None,
-) -> float:
- """Flat conversion effort for all tables or all views when at least one object exists."""
- cfg = config or get_effort_estimate_config()
- return cfg.tables_views_flat_hours if stats.get("total", 0) else 0.0
-
-
-def _code_conversion_object_effort(
- stats: Dict[str, Any],
- config: Optional[EffortEstimateConfig] = None,
-) -> float:
- """Flat per-object code conversion effort (functions and stored procedures)."""
- cfg = config or get_effort_estimate_config()
- return round(stats.get("total", 0) * cfg.code_conversion_per_object_hours, 2)
-
-
-# Code-conversion calculator quantity rules → the DDL display type they cost.
-_CODE_CONVERSION_RULE_TO_DISPLAY = {
- "ddl_table_count": "Table",
- "ddl_view_count": "View",
- "ddl_external_table_count": "External Table",
- "ddl_materialized_view_count": "Materialized View",
- "ddl_function_count": "Function",
- "ddl_procedure_count": "Procedure",
-}
-
-# DDL display types priced as a single flat budget for the whole category.
-_ALWAYS_FLAT_CODE_CONVERSION_TYPES = frozenset({"Table", "View"})
-
-# Flat only for dialects whose CSV declares the matching calculator row. Pricing these
-# flat without that row moves the DDL breakdown without moving the calculator total the
-# Overview card reads, so the two tables would disagree.
-_CSV_GATED_FLAT_CODE_CONVERSION_TYPES = frozenset(
- {"External Table", "Materialized View"}
-)
-
-_FLAT_CATEGORY_LABELS = {
- "External Table": "external tables",
- "Materialized View": "materialized views",
-}
-
-
-def _flat_code_conversion_types(config: EffortEstimateConfig) -> FrozenSet[str]:
- """DDL display types this dialect prices as one flat budget for the whole category."""
- declared = {
- _CODE_CONVERSION_RULE_TO_DISPLAY.get(tmpl.quantity_rule)
- for tmpl in config.calculator_rows
- }
- return _ALWAYS_FLAT_CODE_CONVERSION_TYPES | (
- _CSV_GATED_FLAT_CODE_CONVERSION_TYPES & declared
- )
-
-
-def _object_manual_fractions(stats: Dict[str, Any]) -> List[float]:
- """Per-object share still needing manual work, clamped to [0, 1].
-
- An object whose LoC conversion percentage was never measured counts as fully manual:
- the alternative credits automation for data the report does not contain.
- """
- fractions: List[float] = []
- for o in stats.get("_objects", []):
- if not o.get("pct_measured", False):
- fractions.append(1.0)
- continue
- fractions.append(min(1.0, max(0.0, 1.0 - (o.get("pct", 0.0) / 100.0))))
- return fractions
-
-
-def _type_manual_fraction(stats: Dict[str, Any], flat_budget: bool = False) -> float:
- """Share of an object type's budget still needing manual work.
-
- ``1.0`` means nothing auto-converted (full budget), ``0.0`` means SnowConvert
- converted everything (no manual effort).
-
- Per-object types use the mean, which is identical to summing
- ``baseline × (1 - conversion_rate)`` over the objects.
-
- Flat-budget types are all-or-nothing. Their rate is one budget for the whole
- category "regardless of count", so a mean would price the same unconverted object at
- the full budget when it stands alone and at ~0 among converted siblings — 4.0h vs
- 0.04h for one failed table among 99 successes, and exactly 0.0h among 999.
- """
- fractions = _object_manual_fractions(stats)
- if not fractions:
- return 1.0
- if flat_budget:
- return 1.0 if any(f > 0.0 for f in fractions) else 0.0
- return sum(fractions) / len(fractions)
-
-
-def _ddl_notes_for_display(
- display: str,
- config: EffortEstimateConfig,
- flat_types: FrozenSet[str],
-) -> str:
- if display == "Table":
- return (
- f"Flat {config.tables_views_flat_hours:.0f}h for all tables "
- "(half business day)"
- )
- if display == "View":
- return (
- f"Flat {config.tables_views_flat_hours:.0f}h for all views "
- "(half business day)"
- )
- if display in _CSV_GATED_FLAT_CODE_CONVERSION_TYPES and display in flat_types:
- return (
- f"Flat {config.tables_views_flat_hours:.0f}h for all "
- f"{_FLAT_CATEGORY_LABELS[display]}"
- )
- if display == "Function":
- return f"{config.code_conversion_per_object_hours:g}h per function"
- if display == "Procedure":
- return f"{config.code_conversion_per_object_hours:g}h per stored procedure"
- return ""
-
-
-def compute_fixed_budget_items(
- ddl_summary: Dict[str, Dict[str, Any]],
- total_objects: Optional[int] = None,
- config: Optional[EffortEstimateConfig] = None,
-) -> Dict[str, float]:
- """Flat migration budgets shown separately from per-object DDL effort."""
- cfg = config or get_effort_estimate_config()
- if total_objects is None:
- total_objects = count_workload_objects(ddl_summary)
- tier = classify_workload_size(total_objects, cfg)
- items: Dict[str, float] = {}
- if ddl_summary.get("Synonym", {}).get("total", 0):
- items["Synonym conversion"] = _SYNONYM_FLAT_HOURS
- items["Data migration (DMVA)"] = cfg.data_migration_flat_hours
- items["Data refresh and validation"] = cfg.data_migration_flat_hours
- for phase in cfg.phase_budgets:
- label = f"{phase.component} — {phase.object_type}"
- items[label] = phase.hours_by_tier[tier]
- return items
-
-
-def compute_ddl_effort(
- ddl_summary: Dict[str, Dict[str, Any]],
- config: Optional[EffortEstimateConfig] = None,
-) -> None:
- """Apply per-object DDL effort for the conversion assessment table."""
- cfg = config or get_effort_estimate_config()
- flat_types = _flat_code_conversion_types(cfg)
- for display in list(ddl_summary.keys()):
- if display in _DDL_EXCLUDED_DISPLAY_TYPES:
- ddl_summary.pop(display, None)
- continue
-
- stats = ddl_summary[display]
- stats["effort_hours"] = 0.0
- objects = stats.get("_objects", [])
-
- note = _DDL_NOTES.get(display) or _ddl_notes_for_display(display, cfg, flat_types)
- if note:
- stats["notes"] = note
-
- if display in flat_types:
- stats["effort_hours"] = _table_view_flat_effort(stats, cfg)
- elif display in ("Function", "Procedure"):
- stats["effort_hours"] = _code_conversion_object_effort(stats, cfg)
- else:
- for obj in objects:
- stats["effort_hours"] += _per_object_ddl_effort(display, obj)
- stats["effort_hours"] += (
- stats.get("issues_high", 0) * _ISSUE_SEVERITY_HOURS["high"]
- + stats.get("issues_critical", 0) * _ISSUE_SEVERITY_HOURS["critical"]
- )
-
- # Scale code-conversion effort by the un-converted share so the breakdown
- # matches the weighted calculator (fully auto-converted types read 0h).
- if cfg.conversion_weighted and display in _CODE_CONVERSION_RULE_TO_DISPLAY.values():
- stats["effort_hours"] *= _type_manual_fraction(
- stats, flat_budget=display in flat_types
- )
-
- stats["effort_hours"] = round(stats["effort_hours"], 2)
-
-
-def _calc_row(
- component: str,
- object_type: str,
- quantity: Any,
- baseline: float,
- total_baseline: float,
- fde: float,
- comments: str = "",
-) -> CalculatorRow:
- return CalculatorRow(
- component=component,
- object_type=object_type,
- quantity=quantity,
- baseline_hours=baseline,
- total_baseline_hours=round(total_baseline, 2),
- fde_hours=round(fde, 2),
- comments=comments,
- unweighted_fde_hours=round(fde, 2),
- )
-
-
-def _calculator_code_conversion_fde(
- fde_mode: str,
- tier_key: str,
- qty: int,
- tier_counts: Dict[str, int],
- ddl_summary: Dict[str, Dict[str, Any]],
-) -> float:
- """Compute FDE hours for one code-conversion calculator row."""
- if fde_mode == "flat_zero":
- return 0.0
- if fde_mode == "flat_table":
- return _TABLE_FLAT_HOURS if ddl_summary.get("Table", {}).get("total", 0) else 0.0
- if fde_mode == "flat_proc":
- return _PROC_SUCCESS_FLAT_HOURS if qty else 0.0
- if fde_mode == "success_review":
- return qty * _SUCCESS_REVIEW_HOURS
- if fde_mode == "partial_view":
- return sum(
- _partial_effort_hours("VIEW", o["loc"])
- for o in ddl_summary.get("View", {}).get("_objects", [])
- if o["status"] == "Partial"
- )
- if fde_mode == "partial_function":
- return sum(
- _partial_effort_hours("FUNCTION", o["loc"])
- for o in ddl_summary.get("Function", {}).get("_objects", [])
- if o["status"] == "Partial"
- )
- if fde_mode == "partial_proc_tier":
- fde = 0.0
- for o in ddl_summary.get("Procedure", {}).get("_objects", []):
- if o["status"] != "Partial":
- continue
- bucket = _conversion_bucket("PROCEDURE", o["pct"])
- label = _tier_label_for_bucket(bucket).lower()
- if tier_key.endswith(f"({label})"):
- fde += _partial_effort_hours("PROCEDURE", o["loc"])
- return fde
- return 0.0
-
-
-def _resolve_calculator_quantity(
- rule: str,
- ddl_summary: Dict[str, Dict[str, Any]],
- tier_counts: Dict[str, int],
-) -> Tuple[Any, int]:
- if rule == "constant":
- return "Constant", 1
- if rule == "ddl_table_count":
- qty = ddl_summary.get("Table", {}).get("total", 0)
- return qty, qty
- if rule == "ddl_view_count":
- qty = ddl_summary.get("View", {}).get("total", 0)
- return qty, qty
- if rule == "ddl_external_table_count":
- qty = ddl_summary.get("External Table", {}).get("total", 0)
- return qty, qty
- if rule == "ddl_materialized_view_count":
- qty = ddl_summary.get("Materialized View", {}).get("total", 0)
- return qty, qty
- if rule == "ddl_function_count":
- qty = ddl_summary.get("Function", {}).get("total", 0)
- return qty, qty
- if rule == "ddl_procedure_count":
- qty = ddl_summary.get("Procedure", {}).get("total", 0)
- return qty, qty
- logger.warning(
- "Base_estimates.csv: unrecognized Quantity Rule %r; row will render as 0. "
- "Known rules: constant, ddl_table_count, ddl_view_count, "
- "ddl_external_table_count, ddl_materialized_view_count, ddl_function_count, "
- "ddl_procedure_count.",
- rule,
- )
- return 0, 0
-
-
-def _resolve_calculator_fde(
- rule: str,
- qty_num: int,
- baseline: float,
- comments: str,
- config: EffortEstimateConfig,
-) -> float:
- if "included" in comments.lower():
- return 0.0
- if rule == "constant":
- return baseline
- if rule in (
- "ddl_table_count",
- "ddl_view_count",
- "ddl_external_table_count",
- "ddl_materialized_view_count",
- ):
- return baseline if qty_num > 0 else 0.0
- if rule in ("ddl_function_count", "ddl_procedure_count"):
- return round(baseline * qty_num, 2)
- return round(baseline * qty_num, 2)
-
-
-def build_effort_calculator(
- tier_counts: Dict[str, int],
- ddl_summary: Dict[str, Dict[str, Any]],
- config: EffortEstimateConfig,
-) -> List[CalculatorRow]:
- """Build migration calculator rows from Base_estimates.csv templates."""
- rows: List[CalculatorRow] = []
- total_objects = count_workload_objects(ddl_summary)
- workload_tier = classify_workload_size(total_objects, config)
- tier_comment = f"{workload_size_label(workload_tier, config)} flat budget"
- flat_types = _flat_code_conversion_types(config)
-
- for tmpl in config.calculator_rows:
- qty_display, qty_num = _resolve_calculator_quantity(
- tmpl.quantity_rule,
- ddl_summary,
- tier_counts,
- )
- baseline = tmpl.baseline_hours
- naive_fde = _resolve_calculator_fde(
- tmpl.quantity_rule, qty_num, baseline, tmpl.comments, config
- )
- fde = naive_fde
- # Charge manual effort only for the portion SnowConvert did not auto-convert.
- # Applies to code conversion only — converted objects still need unit testing.
- if config.conversion_weighted and tmpl.component.strip().lower() == "code conversion":
- display = _CODE_CONVERSION_RULE_TO_DISPLAY.get(tmpl.quantity_rule)
- if display:
- fraction = _type_manual_fraction(
- ddl_summary.get(display, {}),
- flat_budget=display in flat_types,
- )
- fde = round(naive_fde * fraction, 2)
- total_baseline = (
- baseline
- if tmpl.quantity_rule == "constant"
- else round(baseline * qty_num, 2)
- )
- row = _calc_row(
- tmpl.component,
- tmpl.object_type,
- qty_display,
- baseline,
- total_baseline,
- fde,
- tmpl.comments,
- )
- row.unweighted_fde_hours = round(naive_fde, 2)
- rows.append(row)
-
- for phase in config.phase_budgets:
- hours = phase.hours_by_tier[workload_tier]
- rows.append(
- _calc_row(
- phase.component,
- phase.object_type,
- "Flat budget",
- hours,
- hours,
- hours,
- tier_comment,
- )
- )
-
- return rows
-
-
-def _tier_label_for_bucket(bucket: str) -> str:
- mapping = {
- "full": "Full Converted",
- "partial": "Partially Converted",
- "75-99": "75-99% Converted",
- "50-75": "50-75% Converted",
- "25-50": "25-50% Converted",
- "0-25": "0-25% Converted",
- }
- return mapping.get(bucket, bucket)
-
-
-def _is_fixed_budget_calculator_row(row: CalculatorRow) -> bool:
- return row.component.strip() in _FIXED_BUDGET_COMPONENTS
-
-
-def _code_conversion_fde(rows: List[CalculatorRow]) -> float:
- return round(
- sum(r.fde_hours for r in rows if r.component.strip() == "Code Conversion"),
- 2,
- )
-
-
-def _code_conversion_testing_fde(rows: List[CalculatorRow]) -> float:
- return round(
- sum(
- r.fde_hours
- for r in rows
- if r.component.strip().lower() == "code conversion testing"
- ),
- 2,
- )
-
-
-def summarize_calculator(
- rows: List[CalculatorRow],
- ddl_summary: Dict[str, Any],
- config: Optional[EffortEstimateConfig] = None,
-) -> Dict[str, Any]:
- """Aggregate totals for overview summary cards."""
- cfg = config or get_effort_estimate_config()
- total_baseline = round(sum(r.total_baseline_hours for r in rows), 2)
- ddl_objects = count_workload_objects(ddl_summary)
- workload_size_tier = classify_workload_size(ddl_objects, cfg)
- fixed_budget_items = compute_fixed_budget_items(ddl_summary, ddl_objects, cfg)
-
- conversion_fde = _code_conversion_fde(rows)
- conversion_naive = round(
- sum(
- r.unweighted_fde_hours
- for r in rows
- if r.component.strip() == "Code Conversion"
- ),
- 2,
- )
- hours_saved = round(max(0.0, conversion_naive - conversion_fde), 2)
- # Share of the conversion budget automation removed. Derived from the same two
- # figures as hours_saved, so the headline can never disagree with the hours beside
- # it — unlike ddl_auto_pct, which counts objects by ConversionStatus and is free to
- # diverge from the LoC-based weighting.
- conversion_automated_pct = (
- round(hours_saved / conversion_naive, 4) if conversion_naive else 0.0
- )
- testing_fde = _code_conversion_testing_fde(rows)
- # "Synonym conversion" is a fixed-budget line item with no matching
- # Base_estimates.csv calculator row (no Quantity Rule fits a per-synonym flat
- # budget), so it must be folded in here explicitly or its hours never reach
- # fixed_budget_fde_hours / total_fde_hours despite showing in the tooltip.
- synonym_fde = fixed_budget_items.get("Synonym conversion", 0.0)
- fixed_budget_fde = round(
- sum(r.fde_hours for r in rows if _is_fixed_budget_calculator_row(r)) + synonym_fde,
- 2,
- )
- ddl_fde = round(conversion_fde + testing_fde, 2)
- total_fde = round(sum(r.fde_hours for r in rows) + synonym_fde, 2)
+ with Path(path).open(encoding="utf-8") as stream:
+ assessment = json.load(stream)
+ assessment["calculator_rows"] = [
+ CalculatorRow(**row) for row in assessment.get("calculator_rows") or []
+ ]
+ return assessment
+ except (OSError, json.JSONDecodeError, TypeError, ValueError, AttributeError) as exc:
+ print(f"Warning: Could not load effort estimates data: {exc}", file=sys.stderr)
+ return None
- ddl_success = sum(s["success"] for s in ddl_summary.values())
- ddl_auto_pct = round(ddl_success / ddl_objects, 4) if ddl_objects else 0.0
- return {
- "total_baseline_hours": total_baseline,
- "total_fde_hours": total_fde,
- "ddl_fde_hours": ddl_fde,
- "conversion_fde_hours": conversion_fde,
- "code_conversion_naive_hours": conversion_naive,
- "hours_saved_by_automation": hours_saved,
- "conversion_automated_pct": conversion_automated_pct,
- "conversion_weighted": cfg.conversion_weighted,
- "testing_fde_hours": testing_fde,
- "fixed_budget_fde_hours": fixed_budget_fde,
- "fixed_budget_items": fixed_budget_items,
- "workload_size_tier": workload_size_tier,
- "workload_object_count": ddl_objects,
- "workload_small_max": cfg.workload_small_max,
- "workload_medium_max": cfg.workload_medium_max,
- "ddl_objects": ddl_objects,
- "ddl_auto_pct": ddl_auto_pct,
- "ddl_summary": ddl_summary,
+def workload_size_label(tier: str, small_max: int, medium_max: int) -> str:
+ """Human-readable workload tier label using thresholds from the artifact."""
+ labels = {
+ "small": f"Small (up to {small_max:,} objects)",
+ "medium": f"Medium ({small_max + 1:,}–{medium_max:,} objects)",
+ "large": f"Large (more than {medium_max:,} objects)",
}
+ return labels.get(tier, tier.title())
def _esc(text: Any) -> str:
@@ -1198,20 +96,24 @@ def _days_label(days: int) -> str:
def _render_fixed_budget_info_icon(
- fixed_items: Dict[str, float],
+ fixed_items: Dict[str, Optional[float]],
workload_tier: str,
total_objects: int,
+ small_max: int,
+ medium_max: int,
) -> str:
"""Info icon with hover tooltip listing fixed budget line items."""
if not fixed_items:
return ""
header = (
f"
"""
-def _render_effort_formulas_legend(s: Dict[str, Any]) -> str:
+def _render_effort_formulas_legend(summary: Dict[str, Any]) -> str:
"""Collapsible legend explaining how each effort figure is derived."""
weighting_note = ""
- if s.get("conversion_weighted"):
+ if summary.get("conversion_weighted"):
weighting_note = (
"
Automated conversion: code-conversion effort is charged "
"only for the share SnowConvert did not convert automatically. Per-object "
@@ -1451,7 +377,7 @@ def _render_effort_formulas_legend(s: Dict[str, Any]) -> str:
"Flat-category budgets (tables, views) are all-or-nothing: a category costs 0h "
"once every object in it auto-converted, and its full budget while any object "
"still needs manual work. "
- f"On this workload automation avoided ≈ {s.get('hours_saved_by_automation', 0):,.0f} h "
+ f"On this workload automation avoided ≈ {summary.get('hours_saved_by_automation', 0):,.0f} h "
"of manual conversion.
DDL: Tables = flat 4h total; Views = flat 4h total; Functions and stored procedures = 1h each for conversion (plus 1h each for unit testing in the calculator).
{weighting_note}
-
Fixed Budget: Data migration setup plus phase budgets scaled by workload size — Small (≤{s.get('workload_small_max', 500):,} objects), Medium ({s.get('workload_small_max', 500) + 1:,}–{s.get('workload_medium_max', 1500):,}), Large (>{s.get('workload_medium_max', 1500):,}). Rates are configured in Base_estimates.csv.
+
Fixed Budget: Data migration setup plus phase budgets scaled by workload size — Small (≤{summary.get('workload_small_max', 500):,} objects), Medium ({summary.get('workload_small_max', 500) + 1:,}–{summary.get('workload_medium_max', 1500):,}), Large (>{summary.get('workload_medium_max', 1500):,}).
"""
-
-
-def _warn_on_unmeasured_conversion(
- ddl_summary: Dict[str, Dict[str, Any]],
- csv_path: Path,
-) -> None:
- """Warn when conversion weighting ran without LoC conversion data to weight by."""
- objects = [o for s in ddl_summary.values() for o in s.get("_objects", [])]
- if not objects:
- return
- unmeasured = sum(1 for o in objects if not o.get("pct_measured", False))
- if not unmeasured:
- return
- logger.warning(
- "%s: %d of %d code units have no parseable LoCConversionPercentage. Conversion "
- "weighting charges full manual effort for those objects rather than crediting "
- "automation for data the report does not contain.",
- csv_path.name,
- unmeasured,
- len(objects),
- )
-
-
-def build_effort_assessment(
- reports_dir: Path,
- base_estimates_csv: Optional[Path] = None,
- project_dir: Optional[Path] = None,
-) -> Optional[Dict[str, Any]]:
- """Build the full effort assessment payload for a supported source dialect.
-
- The dialect is resolved from ``{project_dir}/.scai/config/project.yml`` only.
- ``base_estimates_csv`` overrides the CSV; when omitted the per-dialect bundled CSV
- is used (``Base_estimates.redshift.csv`` for Redshift, ``Base_estimates.csv`` for
- SQL Server). Returns ``None`` for unsupported dialects so the tab is omitted
- entirely rather than rendered half-populated.
- """
- source_dialect = read_project_source_language(project_dir)
- dialect_key = resolve_effort_dialect(source_dialect)
- if dialect_key is None:
- return None
-
- csv_path = _find_toplevel_code_units_csv(reports_dir)
- if not csv_path:
- return None
-
- config_csv = base_estimates_csv or _default_base_estimates_for(dialect_key)
- config = load_effort_estimate_config(config_csv)
- tier_counts, ddl_summary, code_unit_categories = count_quantities_from_code_units(csv_path)
- issues_path = _find_report_csv(reports_dir, "Issues")
- if issues_path:
- merge_issue_counts_into_ddl(ddl_summary, issues_path, code_unit_categories)
-
- calculator_rows = build_effort_calculator(tier_counts, ddl_summary, config)
- compute_ddl_effort(ddl_summary, config)
- top_issues = build_top_ddl_issues(issues_path)
- summary = summarize_calculator(calculator_rows, ddl_summary, config)
-
- if config.conversion_weighted:
- _warn_on_unmeasured_conversion(ddl_summary, csv_path)
-
- for stats in ddl_summary.values():
- stats.pop("_objects", None)
-
- return {
- "source_dialect": source_dialect,
- "calculator_rows": calculator_rows,
- "summary": summary,
- "top_issues": top_issues,
- }
diff --git a/plugin/skills/migration/assessment/scripts/generate_multi_report.py b/plugin/skills/migration/assessment/scripts/generate_multi_report.py
index 3644af6..d08f354 100644
--- a/plugin/skills/migration/assessment/scripts/generate_multi_report.py
+++ b/plugin/skills/migration/assessment/scripts/generate_multi_report.py
@@ -96,11 +96,10 @@
print(f"Warning: Anti-patterns report generator not available: {e}", file=sys.stderr)
ANTI_PATTERNS_SUPPORT = False
-# Effort estimation (dialect-gated calculator tab: SQL Server, Redshift)
+# Effort estimation artifact loader and renderers
try:
from effort_estimation import (
- build_effort_assessment,
- is_effort_estimation_supported,
+ load_effort_assessment,
render_effort_tab_html,
render_overview_section_b_html,
)
@@ -1025,7 +1024,7 @@ def generate_multi_report(
anti_patterns_json: Path = None,
informatica_json: Path = None,
informatica_source_dir: Path = None,
- base_estimates_csv: Path = None,
+ effort_estimates_json: Path = None,
project_dir: Path = None,
) -> None:
"""Generate multi-tab HTML report"""
@@ -1097,8 +1096,18 @@ def generate_multi_report(
print(f"Warning: Could not load anti-patterns data: {e}", file=sys.stderr)
has_anti_patterns = False
- if not exclusion_data and not dynamic_sql_data and not waves_info and not ssis_data and not informatica_data and not has_anti_patterns:
- raise ValueError("At least one data source (exclusion, dynamic SQL, waves, SSIS, Informatica, or anti-patterns) must be provided")
+ effort_assessment = None
+ if effort_estimates_json and EFFORT_SUPPORT:
+ print(f"Loading effort estimates data from {effort_estimates_json}...")
+ effort_assessment = load_effort_assessment(effort_estimates_json)
+ if effort_assessment:
+ print(
+ f" - Effort estimates: {effort_assessment['summary']['total_fde_hours']:,.1f} "
+ f"FDE hours ({effort_assessment['source_dialect']})"
+ )
+
+ if not exclusion_data and not dynamic_sql_data and not waves_info and not ssis_data and not informatica_data and not has_anti_patterns and not effort_estimates_json:
+ raise ValueError("At least one data source (exclusion, dynamic SQL, waves, SSIS, Informatica, anti-patterns, or effort estimates) must be provided")
# Process exclusion data — schema produced by `scai assessment object-exclusion`
# is the single source of truth; field names below match that schema directly.
@@ -1209,30 +1218,6 @@ def tag_objects_for_export(objects: List, category: str) -> List:
overview_stats['source_dialect'] = scai_lang
print(f" - Using source dialect from SQL Dynamic: {scai_lang}")
- # Dialect-gated effort calculator (SQL Server, Redshift). The dialect is read from
- # {project_dir}/.scai/config/project.yml only, so runs without --project-dir get no effort tab.
- effort_assessment = None
- if EFFORT_SUPPORT and snowconvert_reports_dir and project_dir:
- reports_path = Path(snowconvert_reports_dir)
- project_path = Path(project_dir)
- if is_effort_estimation_supported(project_path):
- effort_assessment = build_effort_assessment(
- reports_path,
- base_estimates_csv, # None → per-dialect bundled CSV is resolved
- project_dir=project_path,
- )
- if effort_assessment:
- print(
- f" - Effort estimates: {effort_assessment['summary']['total_fde_hours']:,.1f} "
- f"FDE hours ({effort_assessment['source_dialect']})"
- )
- else:
- print(
- " - Warning: supported dialect but effort assessment could not be built "
- "(missing TopLevelCodeUnits report?)",
- file=sys.stderr,
- )
-
# Set default tab to overview if available
if waves_json:
default_tab = 'overview'
@@ -2671,6 +2656,11 @@ def _callers_count(item: Dict[str, Any]) -> int:
background: #D6E6FF;
color: #1A6CE7;
}}
+ /* Only nav row carrying a badge: without nowrap the flex label gives way
+ to it and the wrapped line is clipped by the fixed 30px height. */
+ .nav-link[data-tab="effort-estimates"] {{
+ white-space: nowrap;
+ }}
.nav-sublist {{
padding: 4px 0 8px 0;
}}
@@ -6501,11 +6491,9 @@ def main():
)
parser.add_argument(
- '--base-estimates',
+ '--effort-estimates-json',
type=Path,
- help='Path to Base_estimates CSV file with per-object-type hourly rates. '
- 'Defaults to the bundled CSV for the project source dialect '
- '(Base_estimates.csv for SQL Server, Base_estimates.redshift.csv for Redshift).'
+ help='Path to effort-estimates JSON produced by `scai assessment effort-estimate`.'
)
parser.add_argument(
@@ -6516,6 +6504,7 @@ def main():
)
args = parser.parse_args()
+ explicit_effort_estimates_json = args.effort_estimates_json
# --project-dir auto-discovery: fill in registry-dir and snowconvert-reports-dir
# from the conventional layout if they weren't set explicitly.
@@ -6568,6 +6557,18 @@ def main():
if ap_candidates:
args.anti_patterns_json = ap_candidates[-1]
print(f"Using anti-patterns JSON: {args.anti_patterns_json}", file=sys.stderr)
+ if not args.effort_estimates_json:
+ effort_dir = args.project_dir / "artifacts" / "assessment"
+ if effort_dir.is_dir():
+ effort_candidates = sorted(
+ effort_dir.glob("effort-estimates-*.json")
+ )
+ if effort_candidates:
+ args.effort_estimates_json = effort_candidates[-1]
+ print(
+ f"Using effort estimates JSON: {args.effort_estimates_json}",
+ file=sys.stderr,
+ )
if not args.informatica_json:
# Check common locations for Informatica analysis output
candidates = [
@@ -6580,8 +6581,8 @@ def main():
print(f"Using Informatica JSON: {args.informatica_json}", file=sys.stderr)
break
- if not args.exclusion_json and not args.dynamic_sql_json and not args.waves_json and not args.ssis_json and not args.informatica_json and not args.anti_patterns_json and not args.registry_dir:
- print("Error: At least one data source (--exclusion-json, --dynamic-sql-json, --waves-json, --ssis-json, --informatica-json, --anti-patterns-json, --registry-dir, or --project-dir) must be provided", file=sys.stderr)
+ if not args.exclusion_json and not args.dynamic_sql_json and not args.waves_json and not args.ssis_json and not args.informatica_json and not args.anti_patterns_json and not args.effort_estimates_json and not args.registry_dir:
+ print("Error: At least one data source (--exclusion-json, --dynamic-sql-json, --waves-json, --ssis-json, --informatica-json, --anti-patterns-json, --effort-estimates-json, --registry-dir, or --project-dir) must be provided", file=sys.stderr)
print_usage()
sys.exit(1)
@@ -6605,10 +6606,14 @@ def main():
print(f"Error: Anti-Patterns JSON file not found: {args.anti_patterns_json}", file=sys.stderr)
sys.exit(1)
- # Validated here rather than at load time: load_effort_estimate_config() opens the
- # path unguarded, so a typo reaching the render would abort the whole report.
- if args.base_estimates and not args.base_estimates.exists():
- print(f"Error: Base estimates CSV not found: {args.base_estimates}", file=sys.stderr)
+ if (
+ explicit_effort_estimates_json
+ and not explicit_effort_estimates_json.exists()
+ ):
+ print(
+ f"Error: Effort estimates JSON file not found: {explicit_effort_estimates_json}",
+ file=sys.stderr,
+ )
sys.exit(1)
# Registry-driven waves data. Two modes:
@@ -6669,7 +6674,7 @@ def main():
anti_patterns_json=args.anti_patterns_json,
informatica_json=args.informatica_json,
informatica_source_dir=getattr(args, 'informatica_source_dir', None),
- base_estimates_csv=getattr(args, 'base_estimates', None),
+ effort_estimates_json=getattr(args, 'effort_estimates_json', None),
project_dir=getattr(args, 'project_dir', None),
)
except Exception as e:
diff --git a/plugin/skills/migration/assessment/scripts/snowconvert_reports/ARCHITECTURE.md b/plugin/skills/migration/assessment/scripts/snowconvert_reports/ARCHITECTURE.md
index 90fa7ec..6bf3195 100644
--- a/plugin/skills/migration/assessment/scripts/snowconvert_reports/ARCHITECTURE.md
+++ b/plugin/skills/migration/assessment/scripts/snowconvert_reports/ARCHITECTURE.md
@@ -282,7 +282,7 @@ ETL doesn't subclass `Element`. It composes a richer domain model:
|---|---|---|
| CSV parsing, encoding | `snowconvert_reports/loaders/csv_reader.py` | Single implementation for all sub-skills |
| Report file discovery | `snowconvert_reports/services/report_finder.py` | Consistent glob patterns |
-| Project facts (`project_name`) | `snowconvert_reports/loaders/project_config.py` | Reads `.scai/config/project.yml`, which scai owns and writes. Duplicates the flat-YAML scan in `effort_estimation.py` rather than sharing it — that module deliberately imports nothing from here (`ai/CLAUDE.md` pitfall #8) |
+| Project facts (`project_name`) | `snowconvert_reports/loaders/project_config.py` | Reads the scai-owned `.scai/config/project.yml` for assessment-name fallback and metadata tooling |
| Report display name (`assessment.json`) | `snowconvert_reports/services/assessment_metadata.py` | Composes both sources behind `resolve_assessment_name()`, so the name offered at the `SKILL.md` prompt and the name rendered in the report cannot disagree. The only module here that **writes** |
| Data models (raw rows) | `snowconvert_reports/models/` | One frozen dataclass per CSV file type |
| Effort calculation | `snowconvert_reports/services/issue_effort_service.py` | Unified EWI/non-EWI logic |
diff --git a/plugin/skills/migration/assessment/scripts/snowconvert_reports/loaders/project_config.py b/plugin/skills/migration/assessment/scripts/snowconvert_reports/loaders/project_config.py
index 20a260c..118df0e 100644
--- a/plugin/skills/migration/assessment/scripts/snowconvert_reports/loaders/project_config.py
+++ b/plugin/skills/migration/assessment/scripts/snowconvert_reports/loaders/project_config.py
@@ -32,11 +32,6 @@ def _read_flat_yaml_value(project_dir: Optional[Path], key: str) -> str:
A line reader rather than a YAML parse because PyYAML is not available —
``assessment/pyproject.toml`` declares ``dependencies = []``. Returns ``""``
on every failure so callers never need a ``try``/``except``.
-
- ``effort_estimation.py`` holds a near-identical scan for ``source_language``.
- It is duplicated rather than shared: that module deliberately imports nothing
- from ``snowconvert_reports`` (``ai/CLAUDE.md`` pitfall #8), and its copy is
- under test.
"""
if not project_dir:
return ""
diff --git a/plugin/skills/migration/assessment/scripts/snowconvert_reports/type_coverage.py b/plugin/skills/migration/assessment/scripts/snowconvert_reports/type_coverage.py
index bb98b29..19f4ca6 100644
--- a/plugin/skills/migration/assessment/scripts/snowconvert_reports/type_coverage.py
+++ b/plugin/skills/migration/assessment/scripts/snowconvert_reports/type_coverage.py
@@ -96,15 +96,35 @@ def is_clean(self) -> bool:
"SQL Server TIMESTAMP is a synonym for ROWVERSION, "
"not a datetime; it migrates as BINARY."
)
-_WIDENED_NUMERIC = (
- "Precision and scale are widened on the target, so the converted DDL "
- "deliberately differs from the source."
-)
_SUB_MICROSECOND = (
"The 7th fractional-second digit truncates on readback. On high-precision "
"columns that surfaces as level 2 and 3 differences, which are not data loss."
)
_INTERVAL = "Compared as a native INTERVAL by default; the handling is configurable."
+_ORACLE_CHAR_ANTI_CASE = (
+ "Snowflake stores CHAR internally as VARCHAR/TEXT; DM aligns the dict "
+ "target with runtime rather than SCAI's DDL literal."
+)
+_ORACLE_RAW_ANTI_CASE = (
+ "SCAI drops the size when converting RAW; DM keeps catalog "
+ "char_length and emits BINARY(n) because it is strictly more "
+ "informative than SCAI's bare BINARY."
+)
+_ORACLE_BARE_NUMBER = (
+ "SCAI emits NUMBER(38,18) for bare NUMBER; DM cannot distinguish bare "
+ "NUMBER from NUMBER(*) via catalog and keeps the default helper. Per-column "
+ "validationCustomTypeRules is the escape hatch."
+)
+_ORACLE_DATE_AS_TIMESTAMP = (
+ "Oracle DATE carries century/year/month/day/hour/minute/second; Snowflake "
+ "DATE drops the time component, so migrations preserve it as TIMESTAMP_NTZ."
+)
+_ORACLE_FIXED_ROWID_SIZE = (
+ "Emits as VARCHAR(18) per SCAI OraSimpleDataTypeReplacer fixed size."
+)
+_ORACLE_FIXED_UROWID_SIZE = (
+ "Emits as VARCHAR(4000) per SCAI (Oracle SQL Reference default width)."
+)
def _row(name: str, target: str, validation: str, note: str = "") -> TypeCoverage:
@@ -120,8 +140,8 @@ def _row(name: str, target: str, validation: str, note: str = "") -> TypeCoverag
_row("SMALLINT", "NUMBER", _S),
_row("INT", "NUMBER", _S),
_row("BIGINT", "NUMBER", _S),
- _row("DECIMAL", "NUMBER(p+2, s+4)", _S, _WIDENED_NUMERIC),
- _row("NUMERIC", "NUMBER(p+2, s+4)", _S, _WIDENED_NUMERIC),
+ _row("DECIMAL", "NUMBER", _S),
+ _row("NUMERIC", "NUMBER", _S),
_row("MONEY", "NUMBER", _S),
_row("SMALLMONEY", "NUMBER", _S),
_row("FLOAT", "FLOAT", _S),
@@ -209,9 +229,80 @@ def _row(name: str, target: str, validation: str, note: str = "") -> TypeCoverag
),
)
+_ORACLE: tuple[TypeCoverage, ...] = (
+ _row("NUMBER", "NUMBER", _S, _ORACLE_BARE_NUMBER),
+ _row("INTEGER", "NUMBER", _S),
+ _row("INT", "NUMBER", _S),
+ _row("SMALLINT", "NUMBER", _S),
+ _row("DECIMAL", "NUMBER", _S),
+ _row("NUMERIC", "NUMBER", _S),
+ _row("FLOAT", "FLOAT", _S),
+ _row("REAL", "FLOAT", _S),
+ _row("BINARY_FLOAT", "FLOAT", _S),
+ _row("BINARY_DOUBLE", "FLOAT", _S),
+ _row("CHAR", "VARCHAR", _S, _ORACLE_CHAR_ANTI_CASE),
+ _row("VARCHAR", "VARCHAR", _S),
+ _row("VARCHAR2", "VARCHAR", _S),
+ _row("NCHAR", "VARCHAR", _S),
+ _row("NVARCHAR2", "VARCHAR", _S),
+ _row("CLOB", "VARCHAR", _S),
+ _row("NCLOB", "VARCHAR", _S),
+ _row("LONG", "VARCHAR", _S),
+ _row("RAW", "BINARY", _S, _ORACLE_RAW_ANTI_CASE),
+ _row("LONG RAW", "BINARY", _S),
+ _row("BLOB", "BINARY", _S),
+ _row("BFILE", "VARCHAR", _S),
+ _row("ROWID", "VARCHAR", _S, _ORACLE_FIXED_ROWID_SIZE),
+ _row("UROWID", "VARCHAR", _S, _ORACLE_FIXED_UROWID_SIZE),
+ _row("DATE", "TIMESTAMP_NTZ", _S, _ORACLE_DATE_AS_TIMESTAMP),
+ _row("TIMESTAMP", "TIMESTAMP_NTZ", _S),
+ _row("TIMESTAMP WITH TIME ZONE", "TIMESTAMP_TZ", _S),
+ _row("TIMESTAMP WITH LOCAL TIME ZONE", "TIMESTAMP_LTZ", _S),
+ _row("INTERVAL YEAR TO MONTH", "INTERVAL", _S, _INTERVAL),
+ _row("INTERVAL DAY TO SECOND", "INTERVAL", _S, _INTERVAL),
+ _row("BOOLEAN", "BOOLEAN", _S),
+ _row("BOOL", "BOOLEAN", _S),
+ _row("JSON", "VARIANT", _S),
+ _row("XMLTYPE", "VARIANT", _S),
+ _row("SDO_GEOMETRY", "GEOGRAPHY", _SCHEMA, _CONTENTS_NEVER_COMPARED),
+ _row("VECTOR", "VECTOR", VALIDATION_UNDOCUMENTED, _UNDOCUMENTED),
+)
+
+_AZURE_SYNAPSE: tuple[TypeCoverage, ...] = (
+ _row("BIT", "BOOLEAN", _S),
+ _row("TINYINT", "NUMBER", _S),
+ _row("SMALLINT", "NUMBER", _S),
+ _row("INT", "NUMBER", _S),
+ _row("BIGINT", "NUMBER", _S),
+ _row("DECIMAL", "NUMBER", _S),
+ _row("NUMERIC", "NUMBER", _S),
+ _row("MONEY", "NUMBER", _S),
+ _row("SMALLMONEY", "NUMBER", _S),
+ _row("FLOAT", "FLOAT", _S),
+ _row("REAL", "FLOAT", _S),
+ _row("CHAR", "VARCHAR", _S),
+ _row("VARCHAR", "VARCHAR", _S),
+ _row("NCHAR", "VARCHAR", _S, "Row comparison applies TRIM when the target is VARCHAR."),
+ _row("NVARCHAR", "VARCHAR", _S),
+ _row("SYSNAME", "VARCHAR", _S),
+ _row("DATE", "DATE", _S),
+ _row("TIME", "TIME", _S),
+ _row("DATETIME", "TIMESTAMP_NTZ", _S),
+ _row("SMALLDATETIME", "TIMESTAMP_NTZ", _S),
+ _row("DATETIME2", "TIMESTAMP_NTZ", VALIDATION_DRIFT, _SUB_MICROSECOND),
+ _row("DATETIMEOFFSET", "TIMESTAMP_TZ", VALIDATION_DRIFT, _SUB_MICROSECOND),
+ _row("BINARY", "BINARY", _S),
+ _row("VARBINARY", "BINARY", _S),
+ _row("UNIQUEIDENTIFIER", "VARCHAR", _S,
+ "Stored as an uppercase UUID string, so case-sensitive joins on it need review."),
+)
+
+
COVERAGE: dict[str, tuple[TypeCoverage, ...]] = {
"sqlserver": _SQLSERVER,
"redshift": _REDSHIFT,
+ "oracle": _ORACLE,
+ "azure_synapse": _AZURE_SYNAPSE,
}
# The six rows where the published page and the shipped orchestrator disagree.
diff --git a/plugin/skills/migration/code-conversion-only/SKILL.md b/plugin/skills/migration/code-conversion-only/SKILL.md
index 103b5a9..efa9f5a 100644
--- a/plugin/skills/migration/code-conversion-only/SKILL.md
+++ b/plugin/skills/migration/code-conversion-only/SKILL.md
@@ -1,6 +1,6 @@
---
name: code-conversion-only
-description: Convert local source code to Snowflake SQL for code-conversion-only source systems such as Sybase IQ, Azure Synapse, Spark SQL, Databricks SQL, BigQuery, Greenplum, Netezza, Vertica, Hive, and IBM DB2. Optionally repoints Power BI reports. Use when configure returns project_type code_conversion_only for these sources.
+description: Convert local source code to Snowflake SQL for code-conversion-only source systems such as Sybase IQ, Azure Synapse, Spark SQL, Databricks SQL, BigQuery, Greenplum, Netezza, Vertica, and Hive. Optionally repoints Power BI reports. Use when configure returns project_type code_conversion_only for these sources.
parent_skill: migration
license: Proprietary. See License-Skills for complete terms
---
@@ -34,7 +34,7 @@ If `configure()` does not already have source_language configured, use the CLI d
If the user says "PostgreSQL & Based Languages" but does not specify one, ask whether they mean PostgreSQL, Greenplum, or Netezza.
-> **Note on PostgreSQL:** New PostgreSQL projects default to `project_type: full_migration` and are routed through `setup/SKILL.md`. PostgreSQL appears in this table only for **legacy** projects whose `project.yml` already has `project_type: code_conversion_only` persisted from before full-pipeline support shipped. If you reach this skill for PostgreSQL, the legacy project configuration is being honored.
+> **Note on PostgreSQL and IBM DB2:** New PostgreSQL and DB2 projects default to `project_type: full_migration` and are routed through `setup/SKILL.md`. They appear in this table only for **legacy** projects whose `project.yml` already has `project_type: code_conversion_only` persisted from before full-pipeline support shipped. If you reach this skill for PostgreSQL or DB2, the legacy project configuration is being honored.
## Workflow
@@ -75,29 +75,57 @@ scai code add -i --skip-split --json
If the user explicitly wants to replace existing source files, add `--overwrite`.
-### Step 3: Check for Power BI Reports
+### Step 3: Check for ETL Code
+
+Any ETL imported by `scai code add` lands in `source/_etl/`. That is where `convert` reads it from.
+
+Check whether `source/_etl/` exists and contains ETL files — `.dtsx` for SSIS, `.xml` for Informatica PowerCenter (use whichever portable form fits the host).
+
+- If it is **missing or empty**, there is no ETL to convert; proceed to Step 4.
+- If it contains **SSIS** packages only, no conversion-target prompt is needed; proceed to Step 4.
+- If it contains **Informatica** PowerCenter XML, ask the remaining ETL questions up front, in one sequence, before running the conversion:
+ 1. Conversion target, via `ask_user_question` (`multiSelect = false`):
+ > "How should Informatica mappings be converted?
+ > 1. **dbt** (default): each mapping becomes a dbt model orchestrated by Snowflake Tasks.
+ > 2. **Snowflake Scripting** (preview): each mapping becomes a standalone Snowflake stored procedure the Task graph calls. Stabilization and deploy are skipped for this preview flavor."
+ 2. If the answer is **dbt**, also ask (`multiSelect = false`): "Consolidate dbt model chains to reduce the number of generated model files?". On yes, set `CONSOLIDATE_DBT = true` for Step 5.
+ 3. If the answer is **Snowflake Scripting**, set `SCRIPTING_MODE = true` for Step 5.
+
+ Persist the choice with the MCP `configure` tool: `etl_informatica_target = "dbt"` or `"scripting"`.
+
+These questions are still required: the conversion target is not something `scai code add` can infer from the imported files. Ask them on every conversion, re-runs included — `scai code convert` defaults to dbt, so a missing flag in Step 5 silently changes the output.
+
+### Step 4: Check for Power BI Reports
Ask the user:
> "Do you have Power BI reports (`.pbit` files) you'd like to repoint to Snowflake?"
-If **yes**, load `../powerbi-repointing/SKILL.md`. It collects `PBIT_PATH` and tells you to append `--powerbi-repointing ` to the convert command in Step 4. Return here when complete.
+If **yes**, load `../powerbi-repointing/SKILL.md`. It collects `PBIT_PATH` and tells you to append `--powerbi-repointing ` to the convert command in Step 5. Return here when complete.
-If **no**, proceed to Step 4. `PBIT_PATH` remains unset; do not pass `--powerbi-repointing` to scai.
+If **no**, proceed to Step 5. `PBIT_PATH` remains unset; do not pass `--powerbi-repointing` to scai.
-### Step 4: Convert
+### Step 5: Convert
-Before running, tell the user what the conversion will cover. If `PBIT_PATH` was set, mention Power BI repointing.
+Before running, tell the user what the conversion will cover. If `PBIT_PATH` was set, mention Power BI repointing. If `SCRIPTING_MODE` was set, mention the Snowflake Scripting target.
-Always include `--json` so the agent can parse the result envelope. Append `--powerbi-repointing ` only if `PBIT_PATH` was set.
+Always include `--json` so the agent can parse the result envelope. Start from the base command and append one flag per decision already recorded in the steps above — nothing else:
```bash
scai code convert --json
```
-Substitute `` with the actual folder path you stored. Do not emit the literal `` token to the shell.
+| Append | When |
+|--------|------|
+| `--informatica-to-snowflake-scripting` | `SCRIPTING_MODE` was set in Step 3 (Informatica target is Snowflake Scripting) |
+| `--consolidate-dbt-model-chains` | `CONSOLIDATE_DBT` was set in Step 3 (Informatica target is dbt and the user chose to consolidate model chains) |
+| `--powerbi-repointing ` | `PBIT_PATH` was set in Step 4 |
+
+The two Informatica flags are mutually exclusive — they come from the same single-select answer, so at most one can apply. Either combines with `--powerbi-repointing`. If none of the conditions hold, run the base command as-is.
+
+Substitute `` with the actual folder path you stored. Do not emit literal placeholder tokens to the shell.
-Per-EWI details (code, description, severity) are written to `reports/SnowConvert/Issues.*.csv`; read those files in Step 5 when working on **Review EWIs** or **Resolve EWIs with Cortex Code**.
+Per-EWI details (code, description, severity) are written to `reports/SnowConvert/Issues.*.csv`; read those files in Step 6 when working on **Review EWIs** or **Resolve EWIs with Cortex Code**.
For Power BI output paths and the CHECKPOINT addendum, see `../powerbi-repointing/SKILL.md`.
@@ -107,10 +135,12 @@ For Power BI output paths and the CHECKPOINT addendum, see `../powerbi-repointin
|--------|-------------|
| `-x, --show-ewis` | Show detailed EWI breakdown |
| `--overwrite-working-directory` | Overwrite output files in `snowflake/` and registry |
+| `--informatica-to-snowflake-scripting` | Convert Informatica mappings to standalone Snowflake stored procedures (Snowflake Scripting) instead of dbt projects. Preview flavor. |
+| `--consolidate-dbt-model-chains` | Consolidate Informatica dbt model chains to reduce the number of generated model files. Applies when the Informatica target is dbt. |
For Power BI options, see `../powerbi-repointing/SKILL.md`.
-### Step 5: Ask the user
+### Step 6: Ask the user
After conversion, ask:
diff --git a/plugin/skills/migration/connection/db2-connection/SKILL.md b/plugin/skills/migration/connection/db2-connection/SKILL.md
new file mode 100644
index 0000000..d53bd04
--- /dev/null
+++ b/plugin/skills/migration/connection/db2-connection/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: db2-connection
+description: Connect to a source IBM DB2 (LUW) database for migration to Snowflake using scai CLI. Triggers: db2, ibm db2, source connection, source database, connect to db2, add db2 connection.
+license: Proprietary. See License-Skills for complete terms
+---
+
+# DB2 Connection Skill
+
+## On Entry
+
+Tell the user:
+> **Setting up DB2 connection** — I'll configure and test a connection to your source IBM DB2 (LUW) database. I'll need a few connection details.
+
+## Prerequisites
+
+- Network access to the DB2 host (self-hosted LUW or cloud-hosted)
+- DB2 username and password with read access to the system catalog (`SYSCAT`)
+- The `scai` CLI installed and available
+
+## Required Connection Details
+
+### Standard Auth (only auth method supported in MVP)
+
+| Parameter | Required | Description |
+|-----------|----------|-------------|
+| `-s, --source-connection` | Yes | Friendly name for this source connection |
+| `--auth` | Yes | `standard` |
+| `--host` | Yes | DB2 host |
+| `--port` | No | TCP port (default: `50000`) |
+| `--database` | Yes | Database name |
+| `--user` | Yes | DB2 username |
+| `--password` | Yes | DB2 password |
+| `--connection-timeout` | No | Connection timeout in seconds |
+
+> DB2 is read through the `ibm_db` DB-API driver, which stages Parquet — there is no ODBC install required on the worker host.
+
+## Workflow
+
+### Step 1: Ask How to Provide Credentials
+
+Ask the user:
+> "I need the following to connect to DB2:
+> - **Host**
+> - **Port** (default `50000`)
+> - **Database name**
+> - **Username** and **password**
+>
+> How would you like to provide these?"
+
+Options:
+1. **1Password** — Credentials stored in 1Password vault
+2. **Enter manually** — Provide values directly
+
+### Step 2: Route Based on Answer
+
+| User says | Action |
+|-----------|--------|
+| "1Password" | Follow `../1PASSWORD.md` (DB2 section) |
+| "Enter manually" | Proceed to Step 3 |
+| Other credential manager | Check if `../references/.md` exists; if not, ask user to explain their setup |
+
+### Step 3: Add the Connection
+
+```bash
+scai connection add-db2 \
+ -s \
+ --auth standard \
+ --host \
+ --port \
+ --database \
+ --user \
+ --password
+```
+
+- Omit `--port` if the server uses the default (`50000`).
+
+### Step 4: Save and Test Source Connection
+
+Call the `configure` tool with `source_connection` set to ``. The MCP server runs `scai connection test` internally and only persists the connection if the test passes.
+
+- **On success:** the response includes `connection_test: ok`.
+- **On failure:** the tool returns an error containing the scai message. Surface it to the user, help them fix the issue, then re-run `configure(source_connection=)`.
+
+**Common errors:**
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| `Operation timed out` | Network / firewall | Check VPN, security groups, firewall rules |
+| `SQL30081N` (communication error) | Wrong host/port or server down | Verify host, port (default `50000`), and that DB2 is listening |
+| `SQL1013N` (database not found) | Wrong database name | Verify the database alias / name on the server |
+| `SQL30082N` (auth failed) | Bad credentials | Re-check username and password |
+
+## CHECKPOINT
+
+Confirm with user:
+- [ ] `configure` returned `connection_test: ok`
+- [ ] Connection appears in `scai connection list -l db2 --json`
+- [ ] Source connection saved to session config
+
+## On Completion
+
+After the CHECKPOINT passes, tell the user:
+> **Connection configured** — Successfully connected to DB2 using connection ``.
+
+Then return to the calling skill.
+
+## Security Rules
+
+- **NEVER** log or display secrets (passwords) in plain text.
+- **NEVER** include secrets in command-line arguments that might be logged. Prefer a credential manager (e.g. 1Password `op run`, or use /secrets capability from Cortex Code to store them).
+
+## Quick Reference
+
+| Action | Command |
+|--------|---------|
+| Add connection | `scai connection add-db2 -s NAME --auth standard --host HOST --port 50000 --database DB --user USER --password PASS` |
+| Test connection | `configure(source_connection=NAME)` (runs the test internally) |
+| List connections | `scai connection list -l db2 --json` |
+| Set default | `scai connection set-default -l db2 -c NAME` |
+| Extract code | `scai code extract -s NAME --json` |
diff --git a/plugin/skills/migration/connection/db2-connection/references/REFERENCE.md b/plugin/skills/migration/connection/db2-connection/references/REFERENCE.md
new file mode 100644
index 0000000..e35fd77
--- /dev/null
+++ b/plugin/skills/migration/connection/db2-connection/references/REFERENCE.md
@@ -0,0 +1,172 @@
+# DB2 Connection Reference
+
+Detailed reference for IBM DB2 (LUW) connection options, authentication, extraction, and troubleshooting.
+
+## Connection Options
+
+### Required Parameters
+
+| Parameter | Flag | Description |
+|-----------|------|-------------|
+| Connection name | `-s, --source-connection` | Unique identifier for this connection |
+| Authentication | `--auth` | Authentication method: `standard` |
+| Host | `--host` | DB2 hostname or IP address |
+| Database | `--database` | Database name (bound at connection time) |
+| Username | `--user` | DB2 username |
+| Password | `--password` | DB2 password |
+
+### Optional Parameters
+
+| Parameter | Flag | Default | Description |
+|-----------|------|---------|-------------|
+| Port | `--port` | 50000 | TCP port number |
+| Connection timeout | `--connection-timeout` | 30 | Timeout in seconds |
+
+## Authentication Methods
+
+### Standard Authentication (Username/Password)
+
+The only supported authentication method for DB2.
+
+```bash
+scai connection add-db2 \
+ -s my-db2 \
+ --auth standard \
+ --host db2-server.example.com \
+ --port 50000 \
+ --database SAMPLE \
+ --user myuser \
+ --password mypassword
+```
+
+**When to use:**
+- All DB2 connections (only method available in MVP)
+
+## Data Exchange Worker (cloud migration / validation)
+
+The worker connects via the `ibm_db` DB-API driver using the same credentials as
+`scai connection add-db2`. DB2 is read off a DB-API cursor and staged as **Parquet**
+(not CSV) — Parquet preserves NULL-vs-empty-string and multi-byte data that a CSV
+extract would corrupt. The generated worker TOML:
+
+```toml
+[connections.source.db2]
+user = ""
+password = ""
+database = ""
+host = ""
+port = 50000
+```
+
+Ensure the DB2 user has:
+
+- `SELECT` on all tables being migrated or validated
+- Read access to `SYSCAT.COLUMNS` / `SYSCAT.TABLES` for schema discovery
+
+Network: the worker host must reach the DB2 server on the configured port. For SPCS
+workers, ensure `EXTERNAL_ACCESS_INTEGRATIONS` covers the database host. DB2 requires
+the `ibm_db` driver, which is bundled with the worker — no separate ODBC install.
+
+Worker TOML `[connections.source.db2].database` must match `source.databaseName` in the
+migration/validation workflow YAML.
+
+## scai TOML Fields (`~/.snowflake/snowct/db2.toml`)
+
+Populated by `scai connection add-db2`. The MCP server reads this file when generating
+the DEW worker config.
+
+| Field | Required | Description |
+|---|---|---|
+| `auth_method` | Yes | Always `standard` |
+| `user` | Yes | DB2 username |
+| `password` | Yes | DB2 password |
+| `host` | Yes | DB2 host |
+| `port` | No | TCP port (default: `50000`) |
+| `database` | Yes | Database name (bound at connection time) |
+| `connection_timeout` | No | Timeout in seconds (if set) |
+
+## Workflow YAML
+
+`sourcePlatform` for DB2 validation workflows:
+
+```yaml
+sourcePlatform: db2
+```
+
+## `whereClauseCriteria`
+
+DB2 folds unquoted identifiers to uppercase and uses double quotes for case-sensitive names:
+
+```yaml
+tables:
+ - source:
+ schemaName: MYSCHEMA
+ name: MYTABLE
+ whereClauseCriteria: '"ID" > 1000'
+```
+
+## Partition Column Guidance
+
+DB2 has no portable system row identifier suitable for partitioning. Use:
+
+- A monotonic integer primary key (e.g. `ID BIGINT`) — most common and reliable
+- A timestamp column with a narrow range — for time-partitioned loads
+- No partition column — omit `columnNamesToPartitionBy` for a single-partition full extract
+
+## Troubleshooting
+
+### Communication Error (SQL30081N)
+
+**Symptoms:**
+- `SQL30081N ... communication error`
+- Connection hangs then times out
+
+**Solutions:**
+1. Verify host and port (default `50000`)
+2. Check VPN / firewall / security-group rules allow the port
+3. Confirm the DB2 instance is listening: `db2 get dbm cfg | grep SVCENAME`
+4. Test network path: `nc -zv 50000`
+
+### Authentication Failed (SQL30082N)
+
+**Solutions:**
+1. Verify username and password are correct
+2. DB2 usernames map to OS/LDAP accounts — verify the account is valid on the server
+
+### Database Not Found (SQL1013N)
+
+**Solutions:**
+1. Verify the database name / alias (`db2 list database directory`)
+2. Confirm the database is cataloged on the target instance
+
+### Insufficient Privileges
+
+**Solutions:**
+1. Grant read access: `GRANT SELECT ON .
TO ;`
+2. Ensure the user can read `SYSCAT` catalog views (granted to `PUBLIC` by default)
+
+## Verifying Connectivity
+
+```bash
+scai connection test -l db2 -c --json
+```
+
+Manual network test:
+
+```bash
+nc -zv 50000
+```
+
+## Stored Connection Format
+
+SCAI stores DB2 connections in TOML format with these fields:
+
+| TOML key | Description |
+|----------|-------------|
+| `auth_method` | Always `standard` |
+| `user` | DB2 username |
+| `host` | DB2 hostname or IP |
+| `database` | Database name |
+| `port` | Port number (default `50000`) |
+| `password` | Encrypted password |
+| `connection_timeout` | Timeout in seconds (if set) |
diff --git a/plugin/skills/migration/connection/snowflake-connection/SKILL.md b/plugin/skills/migration/connection/snowflake-connection/SKILL.md
new file mode 100644
index 0000000..34404d3
--- /dev/null
+++ b/plugin/skills/migration/connection/snowflake-connection/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: snowflake-connection
+description: Choose and configure a Snowflake target authenticator. Use when creating or repairing a Snowflake connection, especially Microsoft Entra ID / Azure AD / OIDC SSO, or when `externalbrowser` fails against an OIDC IdP.
+license: Proprietary. See License-Skills for complete terms
+---
+
+# Snowflake target connection
+
+`scai` opens Snowflake through Snowflake.Data. Pick the authenticator that matches the identity provider. Do **not** guess; ask if the user is unsure.
+
+## When to use which authenticator
+
+| Situation | Authenticator | Notes |
+|-----------|---------------|--------|
+| Microsoft Entra ID, Azure AD, or any OIDC IdP | `oauth_authorization_code` | Browser PKCE flow. Required for Entra. |
+| Snowflake SAML SSO / classic IdP SSO | `externalbrowser` | Do **not** use this for Entra OIDC. |
+| Password + MFA | omit / `username_password_mfa` | Existing default. |
+| Programmatic access token | `programmatic_access_token` | Headless / CI. |
+| Key-pair | `snowflake_jwt` | Headless / CI. |
+| Token already in hand (SPCS) | `oauth` | Non-interactive. |
+| Service principal | `oauth_client_credentials` | Non-interactive. |
+
+If a connection using `externalbrowser` fails with an OIDC / Entra / AADSTS error, switch it to `oauth_authorization_code`. Do not keep retrying SAML.
+
+Full field reference: `Snowflake.SnowConvertDesktop/Snowflake.SnowConvert.Cli/docs/entra-oidc-oauth.md`.
+
+## Entra / OIDC (`oauth_authorization_code`)
+
+Requires a desktop session (system browser + loopback listener). Refuse it in containers, CI, or headless Linux (`DISPLAY` / `WAYLAND_DISPLAY` unset). Suggest PAT or key-pair instead.
+
+Required `connections.toml` keys:
+
+- `account`, `user` — `user` is required so Snowflake.Data can cache and refresh tokens
+- `authenticator = "oauth_authorization_code"`
+- `oauth_client_id`, `oauth_client_secret`
+- `oauth_scope` — forwarded verbatim; do not trim or reorder
+- `oauth_authorization_url` and `oauth_token_request_url` — both HTTPS, set together
+- `oauth_redirect_uri` — **required whenever those external endpoints are set**. Fixed absolute loopback URI registered **exactly** in Entra (scheme, host, port, path). Example: `http://127.0.0.1:8080/`
+
+Do not omit `oauth_redirect_uri` for Entra. The connector's random-port `127.0.0.1` callback is not Entra-compatible (`localhost` any-port does not apply to `127.0.0.1`).
+
+Example:
+
+```toml
+[entra_oidc]
+authenticator = "oauth_authorization_code"
+account = "myorg-myaccount"
+user = "first.last@example.com"
+oauth_client_id = ""
+oauth_client_secret = ""
+oauth_scope = ""
+oauth_authorization_url = "https://login.microsoftonline.com//oauth2/v2.0/authorize"
+oauth_token_request_url = "https://login.microsoftonline.com//oauth2/v2.0/token"
+oauth_redirect_uri = "http://127.0.0.1:8080/"
+```
+
+Select it with `scai … --connection entra_oidc` (or the project's `snowflake_connection`).
+
+## Limits the agent must not ignore
+
+- **Data validation and test generation do not support this authenticator.** Their DTO cannot carry client, endpoints, scope, or redirect. Use PAT, key-pair, or password for those jobs (`CNX0037`).
+- **Browser single-flight is process-local** and covers SCAI Jobs/Databases native opens only. Do not start overlapping Authorization Code data-migration / SMA opens; cache the first login, then run them one at a time.
+- Never log client secrets, authorization codes, tokens, or full authorize URLs.
diff --git a/plugin/skills/migration/data-infrastructure/SKILL.md b/plugin/skills/migration/data-infrastructure/SKILL.md
index 6e82708..39409f0 100644
--- a/plugin/skills/migration/data-infrastructure/SKILL.md
+++ b/plugin/skills/migration/data-infrastructure/SKILL.md
@@ -19,6 +19,8 @@ Before starting any configuration, tell the user verbatim:
>
> **The same infrastructure is used for validation.** You choose what level of validation to run: **schema validation**, **metrics validation**, and **row-level validation**.
+> **Metadata storage mode (trial accounts):** On Snowflake accounts **without Hybrid Table** support, the orchestrator falls back to standard/`TRANSIENT` metadata tables automatically. That path preserves correctness but **reduces task-queue throughput under contention** and effectively **limits how many workers you should run in parallel** — start with fewer workers and scale up carefully. This is **not** the same as L3 “hybrid” row validation. See [Metadata storage mode reference](./references/metadata-storage-mode-reference.md).
+
> **Always use the official tooling for data movement and validation.** Route table migration through `migrate_data` and validation through `validate_data`. Do not suggest ad-hoc extract/copy/compare scripts — the DMVF orchestrator and workers handle partitioning, loading, and multi-level validation.
> **Supported sources**: SQL Server, Redshift, Oracle, Teradata, PostgreSQL
@@ -50,6 +52,21 @@ Use the `data_infrastructure` tool (modes `up` / `down` / `status`) to manage th
## Idempotency
+**One project setting, not one choice per action.** The orchestrator/worker
+placement selected in Step 0 is shared by migration and validation and remains
+in effect until the user explicitly asks to change it. Do not re-ask local vs
+SPCS when validation begins, and do not ask a per-object subagent to start,
+stop, repair, or reconfigure infrastructure. Subagents only dispatch against
+the setup already in place; if infrastructure is unavailable, they return the
+remediation to the main agent.
+
+If the user explicitly asks to change placement or a persisted data strategy,
+the **main agent** owns that setup change. Re-enter this skill for placement or
+worker changes, and load [`../setup/data-strategy/SKILL.md`](../setup/data-strategy/SKILL.md)
+for migration/validation strategy changes. Complete the change once, persist
+it, bring infrastructure up as needed, and only then resume per-object
+dispatch.
+
If this sub-skill has already been completed in the current project — i.e., the project's `.scai/config/dew_configuration.toml` (path relative to the SCAI project root) exists with no remaining `` values — the infrastructure is **configured**, but that does **not** mean it is **running**. Completion is durable; "up" is ephemeral: an SPCS orchestrator auto-suspends when idle, and a freshly-resumed session owns no local worker process. A config-only check (`scai data doctor` alone) passes while the service is suspended and the worker is dead — so dispatch would then sit at `Tables=0/N` with no error. Do **not** stop at a config check: bring the shared infrastructure back up.
Tell the user verbatim: "Data infrastructure already configured — bringing the shared orchestrator + worker back up (this also runs `scai data doctor` to confirm nothing has drifted)." Say it out loud rather than acting silently, because this runs live checks against Snowflake and the source. Then call `data_infrastructure(mode="up")` — it runs the [Level 1 Data Doctor](./references/data-doctor-reference.md#level-1-infrastructure-no-workflow-yaml) gate first (iterate until it stops reporting `doctor_failures`), then starts/resumes the orchestrator and (unless this project runs no local worker) the worker; relay its `cost_reminder`. **Also state the placement it resolved** — the response carries the project's recorded decision: `orchestrator_placement` (`local` = on this machine, no compute pool; `spcs` = SPCS compute pool) and `worker_placement` (`local` | `spcs` | `none` for Iceberg / externally-managed). `up` persists this the first time it resolves it, so on later resumes it is the project's saved choice — relayed consistently rather than re-inferred silently. If `orchestrator_placement` is `local` and they meant SPCS, they can switch by re-running with `compute_pool=""`. Return to the caller without re-prompting. Only run a bare Level 1 Data Doctor instead when you specifically need a config-drift check *without* bringing infrastructure up.
@@ -88,7 +105,7 @@ Ask:
| Answer | Next step |
|--------|-----------|
| **Local** | **Skip Question 0** (no compute pool). Bring it up later with `data_infrastructure(mode="up")` (omit `compute_pool`). |
-| **SPCS** | Set up the compute pool in **Question 0**; bring it up with `data_infrastructure(mode="up", compute_pool="")`. |
+| **SPCS** | Now that SPCS is chosen, call `configure(needs_compute_pools=true)` so the response lists the accessible pools, then resolve the pool in **Question 0**. Bring it up with `data_infrastructure(mode="up", compute_pool="")`. |
### 0.c — Where should each worker run?
@@ -106,14 +123,24 @@ Having chosen placement in Step 0, gather the remaining Snowflake-side details *
If the user chose **local** in Step 0.b, **do not ask Question 0** — continue from **Question 1**.
-Ask the user:
+The orchestrator on SPCS needs a compute pool. **Always ask** whether the user already has one or needs to create one — but first surface the pools their role can already reach, so they pick from a short list instead of recalling a name. Call `configure(needs_compute_pools=true)`; its response carries an `existing_compute_pools:` line listing them as `NAME (state)` (the compute-pool analogue of the `existing_connections:` line from `needs_source_connection`).
+
+Then ask, presenting those pools as a short list (name + `state`):
+
+> The Orchestrator needs a compute pool to run on SPCS. Here are the pools your role can access:
+> `` (one per line)
+> Do you already have one to use, or do you need to create one?
+
+Handle the `existing_compute_pools:` value:
-> The Orchestrator needs a compute pool to run inside your Snowpark Container Services. Do you already have a compute pool, or will you need to set one up?
+- **Pools listed** → present them and let the user pick, or create a new one.
+- **`none`** → the role sees no pool; say so and go straight to the create path.
+- **`(unavailable: …)`** → couldn't reach Snowflake; tell the user, then fall back to asking them to name an existing pool or create one.
| Answer | Action |
|--------|--------|
-| **I have one** | Verify it is active (see below), then save it and continue to Question 1. |
-| **I need to set one up** | Route to → `./compute-pool-setup/SKILL.md` — guide the user through creating and configuring a compute pool. After that skill completes, return here and run the steps below. |
+| **Use an existing pool** | Confirm its `state` (from the list) is **ACTIVE** or **IDLE**. If `SUSPENDED`, run `ALTER COMPUTE POOL RESUME;` and re-check; if `STARTING`, wait a moment and re-check. Then save it and continue to Question 1. |
+| **Create a new one** | Route to → `./compute-pool-setup/SKILL.md` — guide the user through creating and configuring a compute pool. After that skill completes, return here and run the steps below. |
**Bring the shared infrastructure up on this pool:**
@@ -203,8 +230,28 @@ Return control to the calling skill.
---
+## Advanced operations (when the customer asks)
+
+These are **optional** — not part of default setup. Load [Advanced operations reference](./references/advanced-operations-reference.md) when the user mentions:
+
+| Topic | Trigger phrases | Summary |
+|-------|-----------------|---------|
+| **Rate limiting** | Source overloaded, throttle extractions/loads, pause one workflow | SQL rules in `DATA_MIGRATION.RATE_LIMIT`; soft cap on worker task pulls |
+| **Preflight migration** | Dry-run, smoke test pipeline, test before full load | `preflight: true` in DM YAML → transient `PREFLIGHT_` schema (not doctor, not Preliminary) |
+| **Incremental validation** | Re-validate only changed partitions, ongoing DV | `synchronization` + watermark/checksum in DV YAML; baseline run required first |
+| **Re-validation** | Retry failed validation partitions | `validate_data(mode="revalidate", workflow_name=…)` after parent workflow finishes |
+| **Custom L3 normalization** | Formatting drift, case/spatial/period compare | `validationCustomNormalizationRules` in DV YAML — not DM `columnTypeMappings` |
+| **Checksum blind spots** | Incremental sync missed a column change | Some types excluded/rounded from checksum — see advanced ref |
+| **Non-hybrid metadata (trial)** | Trial account, HYBRID TABLE unsupported, slow pulls with many workers | Standard/`TRANSIENT` fallback — limit workers; see metadata storage mode ref |
+
+Do not suggest ad-hoc throttling via polling env vars. Rate limits are metadata SQL, not workflow YAML fields.
+
+---
+
## Reference
+- [Metadata storage mode reference](./references/metadata-storage-mode-reference.md) — Hybrid vs standard metadata (trial fallback, worker scaling)
+- [Advanced operations reference](./references/advanced-operations-reference.md) — rate limiting, preflight, incremental DV, revalidate
- [Data Doctor reference](./references/data-doctor-reference.md)
- [Worker Config Reference](./references/worker-config-reference.md)
- [Teardown (cost-saving suspend)](./teardown/SKILL.md)
diff --git a/plugin/skills/migration/data-infrastructure/references/advanced-operations-reference.md b/plugin/skills/migration/data-infrastructure/references/advanced-operations-reference.md
new file mode 100644
index 0000000..67ec533
--- /dev/null
+++ b/plugin/skills/migration/data-infrastructure/references/advanced-operations-reference.md
@@ -0,0 +1,182 @@
+# Advanced operations reference
+
+Use this when a customer asks about **protecting the source**, **bounded migration dry-runs**, **incremental validation**, **re-validating failed partitions**, **throttling concurrent tasks**, **custom L3 normalization**, or **why checksum/incremental sync missed a change**. These are optional — default migrate/validate flows do not require them.
+
+**Related references:** [Metadata storage mode (Hybrid vs standard)](./metadata-storage-mode-reference.md), [DM workflow config](../../migrate-objects/actions/data-migration/references/workflow-config-reference.md), [DV workflow config](../../setup/data-validation/references/workflow-config-reference.md), [validate_tables.md](../../validate-objects/actions/validate_tables.md).
+
+---
+
+## Rate limiting (protect source or shared resources)
+
+**When the customer asks:** source DB is overloaded during migration; they need to cap concurrent extractions/loads without stopping workers entirely; they want to pause one workflow while others continue.
+
+**What it is:** Scope-pattern rules in Snowflake table `DATA_MIGRATION.RATE_LIMIT` (or your `CUSTOM_SNOWFLAKE_SCHEMA_FOR_DATA_MIGRATION_METADATA` schema). Each rule limits how many **executing** tasks whose `SCOPE` matches a SQL `LIKE` pattern can run at once.
+
+**Agent guidance:**
+
+1. Prefer explaining rate limits **before** lowering `max_parallel_tasks` globally or stopping workers — workers can stay up while matching tasks wait in `pending`.
+2. `TARGET_CONCURRENT_TASKS` is a **target**, not a hard ceiling — eligibility is snapshot-based, so many workers polling together can overshoot before claims settle. Worst case is roughly **2× the target** (target `5` → up to ~10 executing). Set the target **below** a hard source limit with headroom for your worker count. **`TARGET_CONCURRENT_TASKS = 0`** is the one exact value — it reliably pauses matching scopes.
+3. Enforcement applies to **worker (DEA) single-task pulls** only — not orchestrator batch pulls. Workers with `max_parallel_tasks > 1` batch-fetch bypass rate limiting; keep default single-task fetch when using limits.
+4. Insert rules with SQL in a Snowflake session (admin on migration metadata schema):
+
+```sql
+-- At most 5 concurrent loading tasks (any table):
+INSERT INTO SNOWCONVERT_AI.DATA_MIGRATION.RATE_LIMIT (SCOPE_PATTERN, TARGET_CONCURRENT_TASKS)
+VALUES ('Table[%]::Loading', 5);
+
+-- Cap one workflow's partition loads:
+INSERT INTO SNOWCONVERT_AI.DATA_MIGRATION.RATE_LIMIT (SCOPE_PATTERN, WORKFLOW_ID, TARGET_CONCURRENT_TASKS)
+VALUES ('Table[%]::Partition[%]::Loading', , 2);
+
+-- Pause all tasks matching a pattern:
+INSERT INTO SNOWCONVERT_AI.DATA_MIGRATION.RATE_LIMIT (SCOPE_PATTERN, TARGET_CONCURRENT_TASKS)
+VALUES ('Table[MY_DB.%]::%', 0);
+```
+
+| Column | Meaning |
+|--------|---------|
+| `SCOPE_PATTERN` | `LIKE` pattern on task scope (e.g. `Table[DB.SCHEMA.TABLE]::Partition[3]::Extraction`) |
+| `WORKFLOW_ID` | Optional — restrict rule to one workflow |
+| `AFFINITY` | Optional — restrict to one worker affinity |
+| `TARGET_CONCURRENT_TASKS` | Target concurrent executing matches (~2× worst-case overshoot; `0` = exact pause) |
+| `ENABLED` | Set `FALSE` to disable without deleting |
+
+Remove with `DELETE FROM … RATE_LIMIT WHERE …` or disable with `ENABLED = FALSE`. Empty table = no limiting.
+
+**Not in workflow YAML** — rate limits are metadata-table SQL, not `migrate_data` / `validate_data` parameters.
+
+---
+
+## Preflight workflows (bounded migration dry-run)
+
+**When the customer asks:** test connectivity and pipeline end-to-end on a small slice before a full migration; validate types/partitioning without writing to production target schemas.
+
+**What it is:** A **data migration** workflow flag — not validation, not `scai data doctor`. Each table runs as **one partition**; targets land in transient schema `PREFLIGHT_` instead of the configured target schema.
+
+**Not the same as:**
+
+| Concept | Purpose |
+|---------|---------|
+| **Preflight workflow** (`preflight: true`) | Bounded DM dry-run to transient schema |
+| **Preliminary migration type** | Row-limited full migration via `whereClauseCriteria` to real target |
+| **`scai data doctor`** | Infra/config health before start — blocks local start on fail |
+
+**Agent guidance:**
+
+1. Offer preflight when the user wants a **pipeline smoke test**, not when they only need row sampling to production (use **Preliminary** + `whereClauseCriteria` instead).
+2. Set in workflow YAML at top level (Step 2a edit after `migrate_data(mode="setup")`):
+
+```yaml
+preflight: true
+preflightKeepSchema: false # true = leave PREFLIGHT_ for manual inspection
+```
+
+3. Run with normal `migrate_data(mode="run", workflow_path=...)`. Inspect transient schema objects; production target schemas are not used.
+4. For a real migration, turn `preflight` off (or generate a new workflow without it) before production load.
+
+See [workflow-config-reference.md](../../migrate-objects/actions/data-migration/references/workflow-config-reference.md#preflight-bounded-dry-run).
+
+---
+
+## Incremental data validation
+
+**When the customer asks:** re-run validation without scanning every partition; validate only what changed since last run; ongoing validation after initial full pass.
+
+**What it is:** DV **`synchronization`** block (`watermark` or `checksum`) — same JSON shape as DM incremental sync, but **read-only** (no data movement, no duplicate rows on target).
+
+**Agent guidance:**
+
+1. Capture mode at setup: `validation_type=incremental` + `sync_strategy=watermark|checksum` via `validate_data(mode="setup", …)` (or `progress_setup(mode="data_validation")` wizard). Setup patches `defaultTableConfiguration.synchronization.strategy`.
+2. **Prerequisites:** table partitioned (`columnNamesToPartitionBy`); **at least one prior full validation** completed for baseline metadata. First incremental-configured run still validates everything (establishes baseline).
+3. Edit YAML for `watermarkColumn` or `checksumExpression` as needed — see [DV workflow config reference](../../setup/data-validation/references/workflow-config-reference.md#incremental-validation-synchronization).
+4. Later unchanged runs may report **Not validated** (skipped partitions) — that is expected, not a failure.
+5. DV ignores DM-only sync fields (`trackModifications`, `trackDeletions`) — do not copy DM incremental examples that rely on them.
+6. **Checksum blind spots:** default partition checksums skip or round some types (SQL Server `text`/`ntext`/`image`, Oracle LOBs, float rounding, etc.) — a change only in those columns may **not** trigger re-validation. See [Checksum / incremental sync — types that may not trigger re-sync](#checksum--incremental-sync--types-that-may-not-trigger-re-sync).
+
+---
+
+## Re-validation (retry failed partitions)
+
+**When the customer asks:** validation finished but some tables/partitions failed; fix data and retry without re-running the whole workflow; cheaper retry after YAML or source fixes.
+
+**What it is:** **`validate_data(mode="revalidate", workflow_name=…)`** — creates a child `data-re-validate` workflow that re-runs **only failed partitions/levels** from a **finished** parent workflow. Not a full `mode="run"` replay.
+
+**Agent guidance:**
+
+1. Parent workflow must be **finished** with failures — use `details.progress.output.workflowName` from the run report.
+2. After data or config fixes, offer revalidate before full re-setup:
+
+```
+validate_data(mode="revalidate", workflow_name="")
+```
+
+3. Repeat monitor + error-first report (same as `mode="run"`). Shared orchestrator + worker must still be up.
+4. **Not the same as incremental validation** — revalidate retries **failed** work from one run; incremental skips **unchanged** partitions on subsequent scheduled runs.
+5. Task-queue automatic retries (`MAX_RETRIES`, lease expiry) happen inside a single workflow — revalidate is an explicit user/agent action after the parent completes.
+
+See [validate_tables.md](../../validate-objects/actions/validate_tables.md) Step 5.G and [background-monitoring.md](../../validate-objects/actions/references/background-monitoring.md).
+
+CLI equivalent: `scai data validate revalidate `.
+
+---
+
+## Custom normalization (Data Validation L3)
+
+**When the customer asks:** known benign formatting differences (case, trim, spatial WKT, Teradata PERIOD cast strings); L3 row-hash fails but values are "effectively equal"; how to whitelist normalization without `acceptedTransformations` per cell.
+
+**What it is:** SQL expressions applied **before** L3 row-hash and cell compare so source and target values hash/compare on a common form. Configured in DV workflow YAML — **not** DM `columnTypeMappings` (those affect extraction/load only).
+
+**Key fields** (workflow root, `validationConfiguration`, or per-table):
+
+| Field | Purpose |
+|-------|---------|
+| `validationCustomNormalizationRules` | **Preferred** — per `column`, `columnPattern`, or `dataType`; `sourceExpression` / `targetExpression` with `{{ col_name }}` placeholder |
+| `validationCustomNormalizations` | Legacy datatype-keyed lists (still supported; granular rules win when both match) |
+| `validationCustomTypes` / `validationCustomTypeRules` | L1 schema type expectations — pair with normalization when types differ but values should compare equal |
+
+**Agent guidance:**
+
+1. **Hybrid L3 requires L1** (`schemaValidation: true`) — normalization rules need L1 column metadata.
+2. Use **`validationCustomNormalizationRules`** for new edits. Example — case-insensitive text:
+
+```yaml
+validationCustomNormalizationRules:
+ - column: STATUS_CODE
+ sourceExpression: 'UPPER("{{ col_name }}")'
+ targetExpression: 'UPPER("{{ col_name }}")'
+```
+
+3. **`acceptedTransformations`** is for known source→target *value pairs*; **normalization rules** are for *expressions* applied to both sides.
+4. DM **`columnTypeMappings` do not apply to DV** — do not copy migration type overrides into validation YAML expecting L3 to follow them.
+
+Workflow fields: [DV workflow config reference](../../setup/data-validation/references/workflow-config-reference.md#custom-normalization-l3).
+
+---
+
+## Checksum / incremental sync — types that may not trigger re-sync
+
+**When the customer asks:** "I changed column X but incremental migration/validation did not re-run the partition"; checksum stayed the same; only `text`/`ntext`/`datetime`/`float`/spatial columns changed.
+
+**What it is:** **DM partition checksums** (and **DV incremental checksum** probes) hash a **normalized subset** of columns — not always every byte of every type. Some types are **skipped**, **rounded**, or **canonicalized** so small or lossy changes do not change the checksum.
+
+**Common blind spots (DM checksum):**
+
+| Category | Examples | Effect |
+|----------|----------|--------|
+| Skipped legacy LOBs | SQL Server `text`, `ntext`, `image`; Oracle LOBs, `LONG`, `XMLTYPE`, `VECTOR` | Column excluded from checksum input — changes invisible to default checksum |
+| Float rounding | SQL Server float (`CONVERT(,2)`), Redshift REAL, VECTOR TME | Binary noise or tail bits may not change hash |
+| Timestamp precision | High-precision `datetime2`, Redshift TIMESTAMP | Sub-nanosecond / readback truncation |
+| Spatial as WKT | SQL Server / Redshift / Oracle geometry | Compared as despaced WKT; Oracle L3 WKT truncated at 4000 chars |
+| Redshift `HLLSKETCH` | Redshift only | Extraction normalizes to NULL — changes invisible |
+| Custom expression only | `checksumExpression: MAX(ORA_ROWSCN)` | Only that expression drives change detection — data edits elsewhere ignored |
+
+**DV vs DM:** DM checksum skipped columns may still appear in **DV L3 row-hash** (different pipeline). Do not tell the user "DV will catch it" without checking column selection and L3 config.
+
+**Agent guidance when user is confused:**
+
+1. Confirm **sync strategy** — watermark only sees rows above the watermark; checksum only sees partition aggregate change.
+2. Identify column **data type** — if in skipped/lossy list, explain that default checksum may not detect the edit.
+3. **Remediation options:** run a **full** migration/validation once; set a custom **`checksumExpression`** covering the column (DM/DV incremental checksum); switch affected columns to **watermark** if a monotonic column exists; use **DV L3** with `validationCustomNormalizationRules` when the issue is compare semantics, not sync detection.
+4. For SQL Server **`text`/`ntext`/`datetime`** specifically: legacy LOBs are checksum-excluded; datetime formatting uses ODBC-style text — sub-second or timezone-only edits may not move the hash.
+
+DM sync field reference: [SynchronizationStrategy](../../migrate-objects/actions/data-migration/references/workflow-config-reference.md#synchronizationstrategy).
diff --git a/plugin/skills/migration/data-infrastructure/references/metadata-storage-mode-reference.md b/plugin/skills/migration/data-infrastructure/references/metadata-storage-mode-reference.md
new file mode 100644
index 0000000..118f6fe
--- /dev/null
+++ b/plugin/skills/migration/data-infrastructure/references/metadata-storage-mode-reference.md
@@ -0,0 +1,64 @@
+# Metadata storage mode (Hybrid vs standard tables)
+
+Use this when a customer runs on a **trial / lower-tier Snowflake account**, sees bootstrap errors mentioning **`HYBRID TABLE`**, or asks why migration/validation feels **slower** or **workers seem capped** despite a healthy source.
+
+> **Not the same as L3 “hybrid” validation.** This topic is Snowflake **Hybrid Tables** used for orchestrator **metadata** (`TASK_QUEUE`, `TABLE_METADATA`, `PARTITION_METADATA`). It is **unrelated** to `rowValidationMode: hybrid` (L3 row-hash + cell drill-down).
+
+## What happens on accounts without Hybrid Tables
+
+The orchestrator probes Snowflake at bootstrap (or reads `SCHEMA_DEPLOYMENT_PROFILE` / env overrides). When Hybrid Tables are **unsupported**, it falls back to **standard / `TRANSIENT`** metadata tables with the same logical schema.
+
+| Path | Metadata DDL | Task claiming |
+|------|--------------|---------------|
+| **Hybrid (default on capable accounts)** | `HYBRID TABLE` + hybrid indexes | High-throughput batch pull with row locking |
+| **Standard (fallback)** | `TRANSIENT` tables, no hybrid indexes | `PULL_SINGLE` loop + `MERGE` idempotency |
+
+Manual overrides (orchestrator / SPCS service env):
+
+- `SNOWFLAKE_METADATA_STORAGE_MODE=HYBRID|STANDARD|ICEBERG` — preferred three-way selector
+- `SNOWFLAKE_USE_HYBRID_TABLES=1` — force hybrid when three-way mode is unset
+- `SNOWFLAKE_USE_HYBRID_TABLES=0` — force standard/`TRANSIENT` (typical on trial)
+
+**Restart the orchestrator** after changing storage-mode env vars — resolution is cached for the process lifetime.
+
+In-place conversion between hybrid and standard metadata schemas is **unsupported** — match env to the existing schema or deploy a **fresh** metadata schema.
+
+See also: [Worker config reference](./worker-config-reference.md) (limit worker count on the standard path), [Advanced operations — rate limiting](./advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources).
+
+---
+
+## Disclaimer for customers (consistency, performance, workers)
+
+When Hybrid Tables are **not** used and the framework falls back to regular/`TRANSIENT` metadata:
+
+### Consistency
+
+- **Correctness is preserved** via conditional `UPDATE`, transactional `MERGE`, and idempotent metadata writes — not via hybrid-table `SELECT … FOR UPDATE`.
+- Under **high concurrent task claiming**, the standard path may show **more retries** (for example Snowflake `90232` transaction aborts) before a worker successfully leases a task. Retries are expected; do not treat them alone as data corruption.
+- Metadata **`TRANSIENT`** tables have **no Time Travel** — orchestration state is rebuildable but not point-in-time recoverable like hybrid metadata.
+
+### Performance
+
+- **Task-queue throughput is lower** under contention: more round-trips per pull, more warehouse scan work without hybrid secondary indexes.
+- Large backlogs of pending tasks can show a **degrading pull curve** until completed rows are purged/archived.
+- This is **metadata-layer** latency — it can make the pipeline feel slow even when the **source database** and **Snowflake warehouse** have spare capacity.
+
+### Worker count — practical limits
+
+- **Do not assume unlimited horizontal scale** on the standard metadata path. Many workers × many `max_parallel_tasks` threads all compete for the same `TASK_QUEUE` rows.
+- **Agent guidance:** on trial / confirmed non-hybrid metadata, **start conservatively** — for example **1–2 workers** with **`max_parallel_tasks` 2–4** — then increase gradually while watching orchestrator logs for repeated pull retries and end-to-end workflow time.
+- **Source-side rate limiting** (`RATE_LIMIT`) protects the source DB but **does not** remove metadata-queue contention — both may be needed.
+- If the account **supports Hybrid Tables**, prefer the hybrid metadata path (default probe) for production-scale parallelism unless the customer explicitly standardizes on trial-style deployment.
+
+---
+
+## When to mention this to the user
+
+| Situation | What to say |
+|-----------|-------------|
+| Trial / Enterprise trial account | Fallback is automatic; set expectations on throughput and worker scaling |
+| `Unsupported feature 'HYBRID TABLE'` during bootstrap | Standard path is expected; use `SNOWFLAKE_USE_HYBRID_TABLES=0` if probe is ambiguous |
+| Many workers but tasks stay `pending` | Check metadata mode before adding more workers — standard path may need fewer concurrent claimers |
+| Customer compares to “production Mobilize” timing | Hybrid metadata + indexes explain much of the gap on trial |
+
+Do **not** confuse with [rate limiting](./advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources) (source protection) or [L3 hybrid validation](../../setup/data-validation/references/workflow-config-reference.md#l3-result-codes-hybrid-mode).
diff --git a/plugin/skills/migration/data-infrastructure/references/worker-config-reference.md b/plugin/skills/migration/data-infrastructure/references/worker-config-reference.md
index 61543f9..01c3ee4 100644
--- a/plugin/skills/migration/data-infrastructure/references/worker-config-reference.md
+++ b/plugin/skills/migration/data-infrastructure/references/worker-config-reference.md
@@ -2,6 +2,8 @@
The project-default `.scai/config/dew_configuration.toml` (path relative to the SCAI project root) is generated by `scai data worker generate-config`, which pre-fills `[connections.source.]` from the project's scai source connection (referenced by name and hydrated to real credentials at `worker start`, never stored in the file). This reference documents the fields, advanced options (e.g., Redshift UNLOAD), and operational guidance.
+> **Advanced options:** [Secrets management](#advanced-secrets-management) (external vaults, `$(...)` substitution), [query modifiers](#query-modifiers-toml), [custom extraction plugins](#custom-extraction-plugins), workflow-level [extraction strategies](../../migrate-objects/actions/data-migration/references/extraction-strategies-reference.md), and [advanced operations](./advanced-operations-reference.md) (rate limiting, preflight, incremental/revalidate DV).
+
> **Prefer SCAI CLI setup:** Use `scai data worker generate-config` and `scai data worker setup` (SPCS) or `scai data worker start --local` instead of hand-authoring TOML. Manual SPCS worker setup is only needed when the CLI path is unavailable — see official docs for egress IP allowlisting and driver host requirements on the source firewall.
## Configuration Sections
@@ -110,6 +112,33 @@ extraction:
externalStage: MY_DB.MY_SCHEMA.S3_EXTERNAL_STAGE
```
+## Advanced: Secrets management
+
+For **local or non-SPCS workers**, resolve credentials from external secret stores instead of embedding plaintext in TOML or Snowflake Secrets.
+
+**REST providers** — register HTTP secret backends in a separate TOML file pointed to by `SECRET_MANAGERS_CONFIG_FILE`:
+
+```toml
+[secret_managers.providers.vault]
+type = "rest"
+base_url = "https://vault.example.com/v1/secret/data/dea"
+```
+
+Reference resolved values in connection string fields using the provider scheme (for example `vault://path/to/secret#field`).
+
+**Command substitution** — embed `$(...)` recipes in connection fields (for example `$( aws secretsmanager get-secret-value ... )#password`). Gated by `SECRET_MANAGERS_ALLOW_CMD_SUBSTITUTION=true` (default `false`).
+
+> **Inline `[secret_managers]` is ignored:** Do **not** put `[secret_managers]` blocks in `dew_configuration.toml` — the worker loader ignores unrecognized top-level sections. Use `SECRET_MANAGERS_CONFIG_FILE` or the env vars below.
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `SECRET_MANAGERS_CONFIG_FILE` | Unset | Path to TOML with `[secret_managers.providers.*]` blocks. |
+| `SECRET_MANAGERS_ALLOW_CMD_SUBSTITUTION` | `false` | Allow `$(...)` recipes in config strings. |
+| `SECRET_MANAGERS_CACHE_TTL_SECONDS` | `300` | Cache resolved secrets (seconds). |
+| `SECRET_MANAGERS_RESOLVE_TIMEOUT_SECONDS` | `10` | Per-resolve timeout (seconds). |
+
+**SPCS workers** use Snowflake Secrets for source credentials (see [worker-spcs/SKILL.md](../worker-spcs/SKILL.md) Step 3) — distinct from external secret managers above.
+
## Managing Workers
- Increase `max_parallel_tasks` for more parallelism on a single machine — no need to run multiple workers on the same machine.
@@ -117,6 +146,10 @@ extraction:
- Keep a low worker count to avoid overloading your source system.
- Stop workers during peak source system usage to avoid disrupting existing operations.
+> **Non-hybrid metadata (trial / standard path):** When the orchestrator uses standard/`TRANSIENT` metadata instead of Snowflake Hybrid Tables, **task-queue contention** — not source capacity — often caps useful parallelism. Prefer **fewer workers** and lower `max_parallel_tasks` on trial accounts; scale up gradually. See [Metadata storage mode reference](./metadata-storage-mode-reference.md).
+
+For finer-grained control than starting and stopping whole workers — for example capping only the loading tasks, or only one workflow — see [rate limiting](./advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources) (source-side; does not fix metadata-queue contention on the standard path).
+
## Note: Iceberg Migrations and Workers
The Worker TOML configuration **does not change** for Iceberg migrations. However, most Iceberg strategies bypass the Data Exchange Agent (Worker) entirely:
diff --git a/plugin/skills/migration/data-infrastructure/teardown/SKILL.md b/plugin/skills/migration/data-infrastructure/teardown/SKILL.md
index 9de45cb..eaec5e0 100644
--- a/plugin/skills/migration/data-infrastructure/teardown/SKILL.md
+++ b/plugin/skills/migration/data-infrastructure/teardown/SKILL.md
@@ -1,15 +1,27 @@
---
name: data-infrastructure-teardown
-description: Cost-saving teardown for shared data infrastructure — verify no in-flight workflows, stop/suspend orchestrator and worker (SPCS or local, as configured), and suspend the compute pool when applicable. Invoked after every migrate_data() / validate_data() cycle and at end-of-migration.
+description: Cost-saving teardown for shared data infrastructure — verify no in-flight workflows, then call data_infrastructure(mode="down") to stop/suspend orchestrator and worker (SPCS or local, as configured). The compute pool auto-suspends. Invoked after every migrate_data() / validate_data() cycle and at end-of-migration.
parent_skill: data-infrastructure-setup
license: Proprietary. See License-Skills for complete terms
---
# Data Infrastructure Teardown
-Tear down only what this project actually provisioned: **SPCS orchestrator + compute pool**, **local orchestrator process**, **SPCS DEW worker**, and/or **local worker process**. Nothing auto-resumes on the next dispatch — `migrate_data()` / `validate_data()` are pure dispatch and assume the infrastructure is already up. To run another wave after teardown, bring it back explicitly with `data_infrastructure(mode="up")` (per `../SKILL.md`).
+`data_infrastructure(mode="down")` does the teardown for **both** placements in one call:
-> **Why this exists:** an SPCS `DATA_MIGRATION_SERVICE` consumes compute pool seconds; a local orchestrator or local worker polling `TASK_QUEUE` wakes the warehouse on every interval — all accrue cost when idle.
+- **Local** — reaps the orchestrator + worker processes this MCP session started.
+- **SPCS** — when a `compute_pool` is configured for the project, it also suspends the SPCS orchestrator
+ service and the Data Exchange Worker. The compute pool then **auto-suspends** on its own
+ (`AUTO_SUSPEND_SECS`), so there is no separate pool step. Pass `drop=true` to remove the DEW service
+ permanently instead of suspending it.
+
+So teardown is normally just: **call `data_infrastructure(mode="down")`**. Everything below is the
+in-flight check to do first, plus the few cases the tool cannot cover on its own. Nothing auto-resumes —
+bring it back with `data_infrastructure(mode="up")` before the next wave (per `../SKILL.md`).
+
+> **Why this exists:** an SPCS `DATA_MIGRATION_SERVICE` consumes compute pool seconds; a local
+> orchestrator or local worker polling `TASK_QUEUE` wakes the warehouse on every interval — all accrue
+> cost when idle.
## When to Invoke
@@ -19,45 +31,20 @@ Tear down only what this project actually provisioned: **SPCS orchestrator + com
| After `validate_data()` reaches `completed` or `failed` for a wave | Caller prompts the user (default Yes) before loading this skill |
| End of full migration (no more waves) | Caller invokes this skill unconditionally |
-**Skip entirely** when the project never configured data infrastructure (no `compute_pool` and no local orchestrator/worker was started).
+**Skip entirely** when the project never configured data infrastructure (no `compute_pool` and no local
+orchestrator/worker was started).
---
-## Which steps apply
-
-Determine from infrastructure setup and `configure()` / `.scai/settings/cloud-migration.yaml`:
-
-| Component | Signal | Teardown steps |
-|-----------|--------|----------------|
-| SPCS orchestrator | `compute_pool` configured | **Step 2a**, **Step 3** |
-| Local orchestrator | No `compute_pool`; user chose local orchestrator in setup | **Step 2b** |
-| SPCS DEW worker | `scai data worker setup` / `worker-spcs` completed | **Step 4a** (`scai data worker stop`; DMG0024 → no SPCS worker) |
-| Local worker | `worker-local-setup` or `scai data worker start --local` | **Step 4b** |
+## Step 1: Verify no in-flight workflows
-Run **Step 1** always when any orchestrator/worker was used. Skip rows that do not apply — do not suspend a compute pool or SPCS service that was never provisioned.
+`data_infrastructure(mode="down")` already **refuses** while a migrate/validate job it can see is running
+(it reads the project's relay ledger) — so a plain `down` is safe by default. But that ledger only covers
+jobs dispatched from **this project on this machine**. Before tearing down shared SPCS infrastructure that
+**another machine** might be using, confirm the queue is quiet directly:
-## Privilege Prerequisites
-
-Before running any step, ensure the active role has the following privileges. The role that ran `scai data orchestrator setup` is granted these automatically; if a different role is performing teardown (e.g. `ACCOUNTADMIN` cleaning up after the project owner), grant them explicitly:
-
-```sql
-GRANT SELECT ON ALL TABLES IN SCHEMA SNOWCONVERT_AI.DATA_MIGRATION TO ROLE ;
-GRANT MONITOR, OPERATE ON SERVICE SNOWCONVERT_AI.DATA_MIGRATION.DATA_MIGRATION_SERVICE TO ROLE ;
--- If the DEW worker service is in use:
-GRANT MONITOR, OPERATE ON SERVICE SNOWCONVERT_AI.DATA_MIGRATION.DATA_EXCHANGE_WORKER_SERVICE TO ROLE ;
-```
-
-If Step 1 or `scai data orchestrator stop` fails with an insufficient-privileges error, apply the grants above and retry from the failed step.
-
----
-
-## Step 1: Verify No In-Flight Workflows
-
-Do **not** suspend mid-job. Check the most recently observed job(s) first:
-
-- `job_status()` — every job must report `terminal: true`.
-
-Then probe the orchestrator's queue directly:
+- `job_status()` — every job you know about must report `terminal: true`.
+- Then probe the orchestrator's queue (SPCS, cross-machine):
```sql
SELECT WORKFLOW_ID, STATUS, COUNT(*) AS N
@@ -67,153 +54,101 @@ GROUP BY WORKFLOW_ID, STATUS
ORDER BY WORKFLOW_ID;
```
-If any rows return, **abort teardown** and report to the user:
+If any rows return, **abort teardown** and report:
> Teardown skipped — workflow `` still has `` `` task(s). Wait for completion (or cancel via the troubleshooting reference) before suspending.
-Otherwise continue to Step 2 (or Step 4 only if no orchestrator was ever started).
+If a job is running only on this machine and you intend to stop it anyway, pass
+`data_infrastructure(mode="down", force=true)`.
---
-## Step 2: Stop the orchestrator
-
-### 2a. SPCS orchestrator — when `compute_pool` is configured
+## Step 2: Tear down
-**Skip 2a and Step 3** when there is no `compute_pool` (local orchestrator path).
+Call the tool:
-The orchestrator service is the dominant SPCS cost driver — it pins the compute pool active. Suspend it first:
-
-```bash
-scai data orchestrator stop
```
-
-Then poll status until it reports `SUSPENDED`. **Poll every 15 s for up to 3 minutes (12 polls).** If the service has not reached `SUSPENDED` after 12 polls, surface the last CLI output and stop — do not proceed to Step 3 while the orchestrator may still be running.
-
-```bash
-scai data orchestrator status
+data_infrastructure(mode="down") # suspend (resumable) — the default
+data_infrastructure(mode="down", drop=true) # SPCS: also permanently drop the DEW service
```
-The CLI returns `{"status": "SUSPENDING"}` for ~60-90s before settling at `{"status": "SUSPENDED"}`.
+The response reports `execution` (`local` | `cloud`) and the `orchestrator` / `worker` actions
+(`stopped_local` | `suspended_spcs` | `dropped` | `stop_failed`). Relay it to the user. On SPCS the
+compute pool auto-suspends shortly after the service stops — no pool call needed.
-> **Why not raw SQL?** `scai data orchestrator stop` wraps `ALTER SERVICE … SUSPEND` and uses the connection / role / warehouse from the active project. Use raw `ALTER SERVICE SNOWCONVERT_AI.DATA_MIGRATION.DATA_MIGRATION_SERVICE SUSPEND` only if the CLI is unavailable.
-
-### 2b. Local orchestrator — when no `compute_pool` / user chose local orchestrator in setup
-
-**Skip 2a and Step 3.** The local orchestrator runs as a **foreground process** (`scai data orchestrator start --local`). `scai data orchestrator stop --local` is **advisory only** — it does not kill a running process.
-
-Tell the user:
-
-> Stop the local orchestrator so it stops polling Snowflake.
-> - Find the terminal running `scai data orchestrator start --local` and press `Ctrl+C`.
-> - If it was launched in the background, end that process (Task Manager / `pkill` as appropriate).
-
----
+If the payload reports a `partial` status with `spcs_errors`, read the error to decide what it means:
+- An **insufficient-privileges** failure on the `ALTER SERVICE … SUSPEND` the tool runs through scai —
+ grant the privileges below and re-run `data_infrastructure(mode="down")`.
+- A **"service … does not exist"** failure on `worker stop` — this project has no SPCS DEW worker (Iceberg,
+ a local worker, or `start_worker=false` at `up`); nothing to suspend, so it is safe to ignore.
-## Step 3: Suspend the compute pool (SPCS only)
+### Privilege prerequisites (SPCS)
-**Skip Step 3** when there is no `compute_pool`.
+The tool suspends the SPCS service via `scai`, using the project's connection/role. The role that ran
+`scai data orchestrator setup` holds these already; a **different** teardown role (e.g. `ACCOUNTADMIN`
+cleaning up after the owner) needs them granted explicitly:
```sql
-ALTER COMPUTE POOL SUSPEND;
-SHOW COMPUTE POOLS LIKE '';
-```
-
-`` is the value persisted by `data_infrastructure(mode="up", compute_pool=...)` — read from `.scai/settings/cloud-migration.yaml`.
-
-The pool's `STATE` should report `SUSPENDED` (or `STOPPING` for a few seconds, then `SUSPENDED`).
-
----
-
-## Step 4: Stop the Worker
-
-There are two worker variants and at most one is in use per project:
-
-### 4a. SPCS Data Exchange Worker (DEW) service — if `scai data worker setup` was used
-
-If the worker runs as a Snowpark Container Services service, suspend it via the CLI:
-
-```bash
-scai data worker stop
-scai data worker status
+GRANT MONITOR, OPERATE ON SERVICE SNOWCONVERT_AI.DATA_MIGRATION.DATA_MIGRATION_SERVICE TO ROLE ;
+-- If the DEW worker service is in use:
+GRANT MONITOR, OPERATE ON SERVICE SNOWCONVERT_AI.DATA_MIGRATION.DATA_EXCHANGE_WORKER_SERVICE TO ROLE ;
```
-`stop` suspends the `DATA_EXCHANGE_WORKER_SERVICE` (default; pass `--drop` to remove permanently). `status` is the inverse of `setup`. If no DEW service exists, `scai data worker stop` returns error code `DMG0024` ("Service `SNOWCONVERT_AI.DATA_MIGRATION.DATA_EXCHANGE_WORKER_SERVICE` does not exist") — that's the signal you have no SPCS worker and should fall through to 4b.
-
-### 4b. Local worker process — if launched with `scai data worker start --local`
-
-The local worker polls the warehouse every `task_fetch_interval` seconds (see `../references/worker-config-reference.md`). Each poll wakes the warehouse and accrues credits — even with the SPCS orchestrator suspended.
+### Local orchestrator started outside MCP (the tool can't reap it)
-When the worker was started by **`migrate_data()` / `validate_data()`** through the MCP server, it **stops automatically when the MCP session ends**. Teardown 4b still applies when the user chose **No, keep running** on a prior wave, when the worker was started manually, or when you need to stop it before the session ends.
+`data_infrastructure(mode="down")` reaps the local orchestrator/worker **this MCP session** spawned. A
+local orchestrator the user launched **in their own terminal** (`scai data orchestrator start --local`) is
+a foreground process the tool did not spawn and cannot kill (`scai data orchestrator stop --local` is
+advisory only). Tell the user:
-When the worker was started by **`migrate_data()` / `validate_data()`** through the MCP server, it is **stopped automatically when the MCP session ends** (stdio or HTTP server task exit). Teardown step 4b still applies when the user chose **No, keep running** on a prior wave, when the worker was started manually, or when you need to stop it before the session ends.
-
-Tell the user:
-
-> Stop the local worker so the warehouse can auto-suspend.
-> - If you are ending the Coco/MCP session, the worker started by `migrate_data` / `validate_data` will stop on exit — no manual kill needed unless you started the worker outside MCP.
-> - Otherwise find the terminal running `scai data worker start --local` and press `Ctrl+C`.
-> - If the worker was launched in the background outside MCP, kill it: `pkill -f "scai data.*worker.*start"` (macOS / Linux) or end the process in Task Manager (Windows).
-
-Verify polling has stopped:
-
-```sql
-SELECT QUERY_ID, USER_NAME, WAREHOUSE_NAME, START_TIME, LEFT(QUERY_TEXT, 120) AS QUERY_TEXT
-FROM TABLE(INFORMATION_SCHEMA.QUERY_HISTORY_BY_USER(USER_NAME => CURRENT_USER(), RESULT_LIMIT => 20))
-WHERE QUERY_TEXT ILIKE '%TASK_QUEUE%'
-ORDER BY START_TIME DESC;
-```
+> Stop the local orchestrator so it stops polling Snowflake:
+> - Find the terminal running `scai data orchestrator start --local` and press `Ctrl+C`.
+> - If it was launched in the background, end that process (`pkill -f "scai data orchestrator.*start"`, or
+> Task Manager on Windows).
-Within `task_fetch_interval` seconds (default 30s) the worker's `TASK_QUEUE` queries should stop appearing. If they keep appearing, the worker process is still running.
+The same applies to a local **worker** started outside MCP (`scai data worker start --local`): the
+MCP-managed worker stops on session exit, but a manually-launched one needs a `Ctrl+C` /
+`pkill -f "scai data.*worker.*start"`.
---
-## Step 5: Cost-Hygiene Recommendations (Idempotent)
+## Step 3: Cost-hygiene recommendations (idempotent, optional)
-Run these once per project — they make future suspend cycles tighter. **Skip compute-pool items** when no `compute_pool` is configured.
-
-**Compute pool auto-suspend** — drop to ~60s so the pool unblocks even if Step 3 is skipped:
+Run these once per project — they make future suspend cycles tighter. **Skip compute-pool items** when no
+`compute_pool` is configured.
```sql
+-- Compute pool auto-suspend — the pool unblocks quickly after the service stops:
ALTER COMPUTE POOL SET AUTO_SUSPEND_SECS = 60;
-```
-**Warehouse auto-suspend** — verify the warehouse used by the Snowflake connection auto-suspends quickly:
-
-```sql
+-- Warehouse auto-suspend — verify the connection's warehouse suspends quickly:
SHOW PARAMETERS LIKE 'AUTO_SUSPEND' IN WAREHOUSE ;
-- If value > 60:
ALTER WAREHOUSE SET AUTO_SUSPEND = 60;
```
-Both changes are persistent and safe to apply outside this teardown.
+`` is the value persisted by `data_infrastructure(mode="up", compute_pool=...)` — read from
+`.scai/settings/cloud-migration.yaml`. Both changes are persistent and safe to apply outside teardown.
---
-## Resuming for the Next Wave
+## Resuming for the next wave
-Nothing auto-resumes on the next dispatch. Bring the shared infrastructure back up **once** with `data_infrastructure(mode="up")` before the next `migrate_data` / `validate_data` — it resumes the SPCS orchestrator (expect a 30–60s warm-up after suspend) or starts a persistent local orchestrator, plus the worker, depending on whether a `compute_pool` is configured.
-
-`data_infrastructure(mode="up")` wraps these underlying commands; run them directly only when debugging outside the tool:
-
-```bash
-# SPCS
-scai data orchestrator start
-scai data worker start # SPCS DEW worker, if used
-# Local
-scai data orchestrator start --local
-scai data worker start --local
-```
+Nothing auto-resumes on the next dispatch. Bring the shared infrastructure back up **once** with
+`data_infrastructure(mode="up")` before the next `migrate_data` / `validate_data` — it resumes the SPCS
+orchestrator (expect a 30–60s warm-up after suspend) or starts a persistent local orchestrator, plus the
+worker, depending on whether a `compute_pool` is configured.
---
## Checklist
```
-- [ ] No in-flight workflows in TASK_QUEUE (Step 1)
-- [ ] SPCS orchestrator suspended (2a), or local orchestrator process stopped (2b), or N/A
-- [ ] Compute pool suspended when SPCS orchestrator was used (Step 3), or N/A
-- [ ] DEW worker service suspended (4a) or local worker process stopped (4b), or N/A
-- [ ] Warehouse AUTO_SUSPEND <= 60s (one-time, Step 5)
+- [ ] No in-flight workflows — job_status() terminal + TASK_QUEUE quiet (Step 1)
+- [ ] data_infrastructure(mode="down") called; response relayed (Step 2)
+- [ ] Local orchestrator/worker started outside MCP stopped by the user, or N/A
+- [ ] Warehouse AUTO_SUSPEND <= 60s (one-time, Step 3), or N/A
```
Return control to the caller.
+
diff --git a/plugin/skills/migration/data-infrastructure/worker-spcs/SKILL.md b/plugin/skills/migration/data-infrastructure/worker-spcs/SKILL.md
index 9eb2d7c..d030c77 100644
--- a/plugin/skills/migration/data-infrastructure/worker-spcs/SKILL.md
+++ b/plugin/skills/migration/data-infrastructure/worker-spcs/SKILL.md
@@ -84,6 +84,8 @@ Note the exact secret names — they are referenced in Step 4.
**Wait for the user to confirm the secrets are created before continuing.**
+> **Non-SPCS / local workers:** Snowflake Secrets above apply to SPCS container services only. For external vaults, AWS Secrets Manager REST providers, or `$(...)` command substitution in worker TOML, see [Advanced: Secrets management](../references/worker-config-reference.md#advanced-secrets-management).
+
**Oracle and Teradata only:** The container requires outbound network access for two destinations: the source database host and the NuGet driver download endpoint. Ask the user to provide an `EXTERNAL_ACCESS_INTEGRATION` covering both. If they do not have one, show:
```sql
diff --git a/plugin/skills/migration/extensibility/TASKS.md b/plugin/skills/migration/extensibility/TASKS.md
index bef8b13..817af2e 100644
--- a/plugin/skills/migration/extensibility/TASKS.md
+++ b/plugin/skills/migration/extensibility/TASKS.md
@@ -15,6 +15,8 @@ Drop a `SKILL.md` under either path; the plugin loads it instead of the built-in
Your override SKILL.md is loaded as a normal agent skill — write it the same way you would any other skill. There is no template to subclass and no required imports.
+To replace the entire `main` pipeline for one `customKind` (FiveTran, Airflow, …), discovery writes `/.scai/skills/.md` with the customer — see [Custom code units](#custom-code-units-kindcustom). That is a file, not a `/SKILL.md` directory.
+
To check what's currently in effect, run:
```
@@ -75,7 +77,7 @@ Tasks fall into two categories: `setup` (one-time per project) and `main` (per-o
| `migrateData` | Migrates data into a deployed table (pure dispatch — requires the shared orchestrator+worker to be up). |
| `validateData` | Validates migrated data against the source (pure dispatch — requires the shared orchestrator+worker to be up). |
| `runTests` | Runs the scai test suite for an object. |
-| `verify` | Catch-all verification for a converted object whose type has no deploy/test path of its own (Oracle PACKAGE, PACKAGE_BODY, TYPE, TYPE_BODY, SYNONYM, ...). Reached via convert's unfiltered `completed` transition, which must stay last so every type-gated route wins first. |
+| `verify` | Catch-all verification for a converted object whose type has no deploy/test path of its own (Oracle PACKAGE, PACKAGE_BODY, TYPE, TYPE_BODY, SYNONYM, ...), and for procedures/functions with no source side after they deploy (SnowConvert UDF helpers). Reached via convert's unfiltered `completed` transition (must stay last) or via deploy when `source IS NULL`. |
| `extractRules` | Extracts reusable migration rules from a fix. |
| `applyRules` | Applies matched migration rules to an object. |
| `fixCode` | Diagnoses and fixes a failing object. |
@@ -89,18 +91,18 @@ Every task resolves to one **outcome**. Read-only detection (the resolver seeing
| Outcome | Meaning |
|---|---|
| `completed` | The task finished successfully. |
-| `failed` | The task could not finish; carries an `error` class (below). Routes into the fix loop, or the errored bucket for a dependency block. |
+| `failed` | The task could not finish; carries an `error` class (below). Routes into the fix loop, or the errored bucket. |
| `excluded` | The task was disabled for this project/object (see [How to exclude a task](#how-to-exclude-a-task)); the machine follows the task's `excluded` branch. |
| `skipped` | Deferred for now (not disabled) — the task may still run later. |
-| `inProgress` | An async task (`migrateData` / `validateData`) is still running. |
+| `inProgress` | The task has started but has not completed. It remains the current blocking task. |
**`error` classes** (required on `failed`):
| Class | Use for |
|---|---|
| `sql` | A SQL/DDL bug the fix loop can address. |
-| `dependency` | Blocked on another object not yet migrated/deployed — lands in the errored bucket, not the fix loop. |
| `infra` | A transient environment failure (timeout, connection drop, cancelled run) — retry with `reset`. |
+| `human` | Only a person can resolve it. Don't set this class by hand — call `transition_status(status='escalate', task=…, asks=[…])`, which sets it for you. Passing `outcome='failed'` without an error class instead sends the object into the fix loop, where no code change resolves it. A judgment you *can* make is `status='note'` (does not park; review later), not this class. |
## Per-task contracts
@@ -153,6 +155,10 @@ Every entry below names the task id, what your override needs as input, and the
- **Inputs:** A source language chosen in setup and the intent to migrate/validate table data (the data-infrastructure step establishes that intent).
- **Done when:** Session config has `data_migration_type` (and/or `data_validation_type`) set — the data-migration-setup and data-validation-setup wizards (`progress_setup(mode="data_migration"|"data_validation")`) have run to completion.
+#### `discoverExtras`
+- **Inputs:** A converted project with registry populated; the list of non-built-in assets the user wants tracked.
+- **Done when:** A marker file exists at `/.scai/extras_discovered` (written by the discover-extras skill once the user is done adding units).
+
### `main` (per-object migration)
#### `registration`
@@ -173,7 +179,7 @@ Every entry below names the task id, what your override needs as input, and the
#### `etlValidate`
- **Inputs:** ETL test YAML present (from etlSeed or hand-authored); ETL unit deployed to Snowflake and source/Snowflake connections configured — all enforced via preconditions.
-- **Done when:** Registry field `codeStatus.etlValidate` reads completed.
+- **Done when:** `scai test etl-validate` stamps registry field `codeStatus.etlValidate` completed (failed runs stamp failed + error). Units skipped for a missing YAML stay pending.
#### `generateTestCases`
- **Inputs:** Object that needs test inputs; configured source connection.
@@ -189,7 +195,7 @@ Every entry below names the task id, what your override needs as input, and the
#### `captureBaseline`
- **Inputs:** Object with seed data; configured source connection.
-- **Done when:** Procedures/functions: the per-object YAML exists (proc seeding captures the baseline into it). BTEQ: `extensions.tasks.captureBaseline` is set — `scai test capture` uploads the baseline to the Snowflake stage and writes no local artifact, so the YAML (which `seedScript` already wrote) cannot signal capture; the agent stamps this after running capture.
+- **Done when:** Procedures/functions: `VALIDATION.BASELINE_METADATA` has a row for the object's target name whose `ROW_COUNTS` sum to more than zero. BTEQ scripts have no rows in that table, so they fall through to registry field `extensions.tasks.captureBaseline`.
#### `deploy`
- **Inputs:** Converted SQL for the object.
@@ -201,18 +207,18 @@ Every entry below names the task id, what your override needs as input, and the
#### `migrateData`
- **Inputs:** Deployed table; configured source connection; shared data infrastructure brought up once via data_infrastructure(mode="up").
-- **Done when:** Registry field `extensions.dataMigration` reads completed.
+- **Done when:** Live cloud migration job reports the table loaded (DATA_MIGRATION.TABLE_PROGRESS).
#### `validateData`
- **Inputs:** Object with migrated data; shared data infrastructure brought up once via data_infrastructure(mode="up").
-- **Done when:** Registry field `extensions.dataValidation` reads completed.
+- **Done when:** Live cloud validation job reports every enabled level done (DATA_VALIDATION.TABLE_PROGRESS_DETAIL).
#### `runTests`
- **Inputs:** Object with a captured baseline; procedures and functions are also deployed first (BTEQ scripts are not).
-- **Done when:** Registry field `codeStatus.testing` reads completed.
+- **Done when:** The latest run of every test case in `VALIDATION.RESULTS` passed. Procedures and functions are judged there; BTEQ scripts have no rows in that table, so they fall through to registry field `codeStatus.testing`.
#### `verify`
-- **Inputs:** A converted object of a type the machine routes nowhere else.
+- **Inputs:** A converted object of a type the machine routes nowhere else, or a deployed procedure/function with no source counterpart.
- **Done when:** Registry field `extensions.tasks.verify` reads completed.
#### `extractRules`
@@ -228,3 +234,79 @@ Every entry below names the task id, what your override needs as input, and the
- **Done when:** Registry field `extensions.tasks.fixCode` is set.
+
+## Custom code units (`kind=custom`)
+
+Per-task overrides and custom machines are about **how** work runs. Custom code units are about **what** work runs on. The conversion engine generates a known set of object kinds (tables, views, procedures, functions, SSIS packages, …); anything outside that — orchestration tools (FiveTran, Airflow, Informatica), BI assets (SSAS cubes, Tableau extracts, dbt models), object kinds the engine doesn't generate yet (Oracle PACKAGE bodies, SQL Server triggers in some flows), or hand-maintained scripts — won't show up in the registry unless you put it there yourself.
+
+The registry's top-level `kind` is a closed enum with four values: `"databaseObject"`, `"script"`, `"etl"` (the three the conversion engine emits) and `"custom"` (everything else). Custom units carry an additional `customKind` discriminator on `source` and `target` — that's the free-form string the agent uses to group, filter, and route. Each custom unit carries:
+
+- **`kind`** — always `"custom"` for these units. The closed enum keeps registry queries and bindings simple.
+- **`source.customKind` / `target.customKind`** — the free-form discriminator (`"fivetran"`, `"ssasCube"`, `"oraclePackage"`, `"airflowDag"`, …). Pick a stable name; this is what `query_registry where="source.customKind = ''"` filters on. Cannot be one of the four reserved Kind values.
+- **`source.name`** — display name.
+- **`source.objectType`** — *optional*. When the asset maps cleanly to a built-in `ObjectType` (e.g. an Oracle PACKAGE → `package`), set it so the unit groups with its siblings in `migration_status`. `"other"` is fine when nothing fits.
+- **`files.source.path`** — *optional* path to a config file or definition, **relative to the project root** (e.g. `"source/fivetran/orders_sync/connector.py"`). Assets living outside the project must be copied under `/source/` first; an absolute or escaping path is reported in `warnings[]` because it breaks for every other checkout.
+- **`dependencies.dependsOn[]`** — ids of other units (built-in or custom) this one reads/writes. `requiredBy` back-edges and `planning.topologicalRank` are derived from this by the registry on every write — never hand-write either.
+- **`extensions.machine`** — name of the state machine that drives this unit, as a plain string. Only the four compiled-in machines resolve (`main`, `setup`, `data-migration-setup`, `data-validation-setup`); see the gap note below. When unset or unresolvable, a `.scai/skills/.md` skill (if present) replaces `main`; otherwise the unit walks `main`.
+
+### Registering custom units
+
+One tool, `register_units`, three shapes. Search that name if the tool is not already loaded — there is no `register_custom_unit` / `register_custom_units_from_manifest`.
+
+| Shape | Call | When |
+|---|---|---|
+| One unit | `register_units(custom_kind, name, ...)` | Walking the user through one item. |
+| A list | `register_units(entries=[...])` | Manifest with many entries (CSV/JSON/YAML the user has on hand). |
+| Findings | `register_units(expected_slugs=[...])` | Investigation agents already wrote `.scai/tmp/extras/findings/.json`. |
+
+It rejects the four reserved Kind values (`databaseObject`, `script`, `etl`, `custom`) as a `customKind`. Built-in kinds go through the regular `scai code add` / `scai code extract` paths. Per-row failures are collected into `failed[]` rather than aborting the run.
+
+Snake_case (`custom_kind`, `source_path`, `depends_on`, `expected_slugs`) and camelCase (`customKind`, `sourcePath`, `dependsOn`, `expectedSlugs`) both work. `dependsOn` takes either a JSON array of ids or a comma/newline-separated string.
+
+Every shape returns the same envelope — `registered[]` (each row has `id`, `customKind`, `name`, `machine`, `dependsOnResolved`, `dependsOnMissing`, `warnings`) plus `failed[]` / `withMissingDependencies[]` / `withWarnings[]`. Read `dependsOnMissing`: ids in it are recorded but match no unit, which is expected when the dependency is registered later and a typo otherwise. Read `warnings`: an unresolvable machine name (unit uses a `.scai/skills/.md` skill if present, otherwise `main`) or a `sourcePath` that isn't repo-relative. Don't re-query to confirm the write — `query_registry`'s default projection is `id` / `source` / `files`, so `dependencies` and `extensions` come back absent and the unit looks empty. Pass `fields=["*"]` if you do need to read them back.
+
+Investigation fans out; the write does not. Investigation agents write one JSON fragment each under `.scai/tmp/extras/findings/.json`. The orchestrator calls `register_units(expected_slugs=[...])`, which merges those files and registers — it does not concatenate fragments in context. Registry writes take an exclusive lock and each triggers a registry-wide graph refresh, so parallel writes from the agents themselves are slower than one batch and fragment the failure report.
+
+### Per-customKind skills
+
+Discovery writes `/.scai/skills/.md` with the customer (one cookbook per kind, reused for every object of that kind). When that file exists, those units skip `main` (register → convert → …) and run the skill as their whole workflow. Completion is the same stamp `verify` already uses:
+
+```
+transition_status(status='advance', task='verify', outcome='completed', where="id IN ('')")
+```
+
+Done when `extensions.tasks.verify` reads completed. `next_objects` / `next_task` carry `skillPath` pointing at the file. The file must be a procedure (On Entry → steps → stamp); see `setup/discover-extras/cookbook-template.md`.
+
+A loaded `extensions.machine` still wins when that name is compiled in. A `verify/SKILL.md` task override does not steal these units.
+
+### Gap: per-customKind machines are not implemented
+
+`Machines` loads only the four machines compiled into the server. Nothing reads `/.scai/machines/`, so a machine file written there is inert, and `extensions.machine` naming it resolves to nothing — `machine_for_unit` then uses a `.scai/skills/.md` skill if present, otherwise `main`.
+
+Consequences to keep in mind when extending this area:
+
+- Don't author a per-`customKind` machine file and report the flow as wired. The fallback is silent at resolve time; the only signals are `warnings[]` on each `registered[]` row (`withWarnings[]` on the `register_units` report) and the `Warning:` line from `update_registry(field="extensions.machine", ...)`.
+- The executor kinds are `mcpTool`, `shell`, and `agent`. There is no `manual` kind and no `instructions` field — a machine using them fails to deserialize, which is a second reason a hand-written machine never takes effect.
+- Making this real means loading and validating `.scai/machines/*.json` into `by_name` alongside the built-ins, and threading `project_dir` into the ~15 `Machines::load_builtin()` call sites. That's a feature, not a doc fix.
+
+### Invoking discovery
+
+The skill at `setup/discover-extras/SKILL.md` registers the units, then co-authors a `.scai/skills/.md` cookbook per kind with the customer. Load it when the user has extras to register — it is not a step in the compiled `setup` machine.
+
+Re-entering the skill at any time afterward is safe — it picks up where the user left off and lets them add more units.
+
+### Worked example: FiveTran sync depends on a table
+
+1. Register the FiveTran sync as a custom unit pointing at the table it reads from:
+ ```
+ register_units(
+ custom_kind="fivetran",
+ name="orders_sync",
+ source_path="source/fivetran/orders_sync.yaml",
+ depends_on=[""],
+ description="Daily ingest from Shopify",
+ )
+ ```
+ Check `dependsOnMissing` on the registered row — if the table id is in there, look it up again with `query_registry` before moving on. `requiredBy` on the table and `topologicalRank` on the sync are filled in by the registry; don't touch them.
+2. Discovery writes `.scai/skills/fivetran.md` with the customer (what "done" means, how one connector is migrated). Leave `machine` unset — a `fivetran-flow` machine can't load (see the gap above).
+3. Run `migration_status(mode="next_objects")` — once the table the sync depends on is migrated, the FiveTran sync appears in the queue with `skillPath` pointing at that cookbook.
diff --git a/plugin/skills/migration/migrate-objects/SKILL.md b/plugin/skills/migration/migrate-objects/SKILL.md
index 546e1bf..4d6dd7f 100644
--- a/plugin/skills/migration/migrate-objects/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/SKILL.md
@@ -44,12 +44,24 @@ This rule is the same for **every** task in the loop below — deploy, test, cap
1. **Do the task's work** as its skill describes.
2. **Ask what's next.** Re-pull `migration_status(mode="my_objects_summary")` (or `migration_status(mode="next_task", object_id="")` for one object). The machine advances you when it can see the work is done — a tool wrote the registry field, the object exists in Snowflake, or the expected file exists.
3. **Call `transition_status` to report or override:**
- - a **failure** you can't fix — `transition_status(status='advance', task='', outcome='failed', error='')`;
+ - a **failure** you can't fix — `transition_status(status='advance', task='', outcome='failed', error='')`;
- an outcome the system **can't observe** and you had to judge — e.g. "all tests passed" ([migrate-object/RUN_TESTS.md](migrate-object/RUN_TESTS.md)), view parity ([migrate-object/VALIDATE_VIEW.md](migrate-object/VALIDATE_VIEW.md)), ETL stabilization ([migrate-etl/SKILL.md](migrate-etl/SKILL.md));
- an **override** — `bypass` a precondition, `reset` an errored task, or `skip`.
The outcome and error vocabulary is defined once in [../extensibility/TASKS.md](../extensibility/TASKS.md#outcome-vocabulary).
+## Autonomous mode
+
+If the user asks to run the wave unattended — "autonomous", "auto-pilot", "just
+migrate everything", "run objects in parallel" — load
+[autonomous/SKILL.md](autonomous/SKILL.md) instead of the loop below and follow
+it. That skill claims work itself and dispatches one subagent per ready task
+group, up to a parallelism the user picks, escalating only when one gets stuck.
+
+Everything below is the interactive loop: one batch at a time, the user picks
+every group and every claim. It stays the default — do not offer autonomous mode
+as a menu item in 2b, and do not switch to it unless the user asks.
+
## Step 2: Object Loop
**IMPORTANT** Ask the user to `/compact` between work units to free up context. **IMPORTANT**
@@ -77,7 +89,7 @@ Show **only** the following status lines, all derived from the cached summary re
Then ask the user to pick a next action. **Only list actions you are actually offering** — never include an absent option just to acknowledge it. Build the action menu like this:
-1. One numbered item per task group, labelled ` for the (s)` — e.g. `Deploy to Snowflake for the 5 tables`, `Generate test cases from source database for the 1 function`. Use `group.user_label` verbatim.
+1. One numbered item per task group, labelled ` for the (s)` — e.g. `Deploy for the 5 tables`, `Generate test cases from source database for the 1 function`. Use `group.user_label` verbatim.
2. One numbered item per `blocked_groups` entry, labelled `Resolve blocked s waiting on "" (see deps)` — only if `blocked_groups` is non-empty.
3. `finishObjects` — only if `done_count > 0`.
4. `claimObjects` — only if `done_count == 0` (when `done_count > 0`, omit this entirely; the user must merge first). The exception: if the user *explicitly overrides* on a later turn ("I know, claim anyway", "skip the merge for now"), proceed to claim and flag the unmerged done objects in your reply.
@@ -118,9 +130,9 @@ VERY IMPORTANT: **Wait for user input before acting.** Once the user confirms wh
**User picked the errored bucket** → call `migration_status(mode="my_objects_details", group="errored")` to fetch `ErroredObject` entries and present them. **Wait for the user to pick which errored object(s) to address** before attempting any resolution; resolution depends on each `task`/`reason`.
-### Resolving errored async tasks (`migrateData`, `validateData`, `runTests`)
+### Resolving errored long-running tasks (`migrateData`, `validateData`, `runTests`)
-When the errored task is a long-running async one and the failure is
+When a long-running task fails and the failure is
**transient** (Snowflake connection drop, source timeout, cancelled
run), don't try to fix anything — just retry. After confirming with
the user, call:
diff --git a/plugin/skills/migration/migrate-objects/actions/data-migration/RUN.md b/plugin/skills/migration/migrate-objects/actions/data-migration/RUN.md
index 941db1a..d7ac76c 100644
--- a/plugin/skills/migration/migrate-objects/actions/data-migration/RUN.md
+++ b/plugin/skills/migration/migrate-objects/actions/data-migration/RUN.md
@@ -4,7 +4,13 @@ Guide for the `migrateData` task — migrate the batch of tables the machine han
## 1. Choose the approach (once per batch, if not already set)
-If the migration approach hasn't been chosen for this run, call `progress_setup(mode="data_migration")` and answer the prompts (full vs incremental, sync strategy, extraction mechanism, target table type). The choice is persisted as session defaults — skip this if it's already set.
+The migration approach was chosen during setup and is persisted for all
+objects. Do not re-ask or change it here. If it is unexpectedly missing, or
+the user asks to change it (for example full → incremental watermark), return
+the request to the main agent. The main agent must route through
+[`../../../data-infrastructure/SKILL.md`](../../../data-infrastructure/SKILL.md),
+which owns any setup delegation, persist the change once, and then redispatch
+this task.
## 2. Generate the workflow
@@ -12,7 +18,7 @@ Call `migrate_data(mode="setup", where=)`. It writes
## 3. Dispatch
-Call `migrate_data(mode="run", workflow_path=)`. This is **pure dispatch** against the already-running infrastructure — there are no infra flags to pass. If the response is a `remediation` saying infrastructure is not up, **stop here — dispatch does not bring infrastructure up.** Hand back to [`../../../data-infrastructure/SKILL.md`](../../../data-infrastructure/SKILL.md): it is the single place the shared orchestrator + worker come up, and the only place the **local vs SPCS** placement is confirmed. Once it reports ready, retry this dispatch.
+Call `migrate_data(mode="run", workflow_path=)`. This is **pure dispatch** against the already-running infrastructure — there are no infra flags to pass. If the response says infrastructure is not up, return the remediation to the main agent. Do not call `data_infrastructure` from this subagent; the main agent resumes the persisted setup and redispatches the task.
The response carries a `monitor` block (a `job_id` and a ready-made `watch_command`).
@@ -22,4 +28,6 @@ Arm the `monitor.watch_command` with the Monitor tool, or call `job_status(job_i
## When it finishes
-Completion stamps the registry field `extensions.dataMigration`, which advances the machine. Present the migration summary (`SKILL.md` Step 6) and offer to tear the shared infrastructure down when the wave is done.
+The machine reads live `DATA_MIGRATION.TABLE_PROGRESS` — do not stamp the registry. Present the migration summary (`SKILL.md` Step 6), and offer to tear the shared infrastructure down when the wave is done — unless you were dispatched for a single object, in which case leave it alone: `data_infrastructure(mode="down")` stops the worker every other slot is using, so that offer belongs to whoever owns the wave.
+
+If the job **failed**, do not re-run it and do not change Snowflake with `sql_execute`. Call `migration_status(mode="next_task")`. A failed load is `error=sql` and the machine owns the next step. A judgment you made in the converted file (meanings vs compile) is a `note` after the fix, not a live `ALTER` and not a re-dispatch of the same workflow.
diff --git a/plugin/skills/migration/migrate-objects/actions/data-migration/SKILL.md b/plugin/skills/migration/migrate-objects/actions/data-migration/SKILL.md
index bd2ca97..79e84e0 100644
--- a/plugin/skills/migration/migrate-objects/actions/data-migration/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/data-migration/SKILL.md
@@ -1,20 +1,20 @@
---
name: data-migration-setup
-description: Setup, run, and report on cloud data migration — workflow YAML, migrate_data run, background Monitor (or poll fallback), and end-of-run summary for the user.
+description: Setup, run, and report on cloud data migration — workflow YAML, migrate_data run, background Monitor, and end-of-run summary for the user.
parent_skill: migration
license: Proprietary. See License-Skills for complete terms
---
# Data Migration Setup
-One-time configuration for migrating data from a source database into Snowflake via the **scai CLI**, plus **run → monitor → report** after `migrate_data(mode="run")` (passive Monitor — always prefer it; active polling only when Monitor cannot be invoked).
+One-time configuration for migrating data from a source database into Snowflake via the **scai CLI**, plus **run → monitor → report** after `migrate_data(mode="run")`.
> **Always use the official tooling.** Run data migration through `migrate_data(mode="setup")` / `migrate_data(mode="run")` (backed by `scai data migrate`). **Never** suggest writing ad-hoc scripts to extract, copy, or load data outside the DMVF task pipeline — the orchestrator handles partitioning, retries, incremental sync, and load orchestration.
> **Supported sources**: SQL Server, Redshift, Oracle, Teradata, PostgreSQL
> **Supported targets**: Native Snowflake tables (default). **Iceberg** targets are **Redshift-only** (partial support) — see [Extraction strategies reference](./references/extraction-strategies-reference.md#iceberg-target-redshift-only--partial-support).
-> **Run-only entry:** If you were routed here only to execute `migrateData` (registry task) and the workflow YAML already exists, skip Steps 1–2 and complete **Step 2a** (display the existing `workflow_path` and offer optional updates) before **Step 4**. You **must** complete **Steps 5–7** (background monitor or poll fallback, error-first migration report, teardown offer) before returning to the parent skill — even when the state machine invoked `migrate_data(mode="run")` without walking setup.
+> **Run-only entry:** If you were routed here only to execute `migrateData` (registry task) and the workflow YAML already exists, skip Steps 1–2 and complete **Step 2a** (display the existing `workflow_path` and offer optional updates) before **Step 4**. Complete **Steps 5–6** (background Monitor and error-first migration report), then return the result to the parent. Per-object subagents do not manage shared infrastructure or teardown.
## Prerequisite
@@ -77,10 +77,24 @@ confirmation before continuing.
The migration strategy — **migration type, sync strategy, extraction mechanism, and target table type** — is chosen **once during setup** by the `dataStrategy` task (executor [`setup/data-strategy/SKILL.md`](../../../setup/data-strategy/SKILL.md)) and committed to the git main branch, so by the time you dispatch it is **already set**. Do not re-ask it here.
-**Fallback only** — if the strategy is unset (e.g. a project set up before setup-phase capture): run the `data-migration-setup` wizard as a catch-up — `progress_setup(mode="data_migration")` in a loop until `completed` (idempotent; a no-op once the keys exist). Extraction is **not always ODBC** (PostgreSQL COPY; Oracle ODP.NET/DBMS_CLOUD; Redshift ODBC/UNLOAD + optional Iceberg; Teradata direct/TPT/WRITE_NOS) and each strategy has worker/infra prerequisites (worker TOML fields, `externalStage`, Oracle grants, S3/IAM, TTU) — see [Extraction strategies reference](./references/extraction-strategies-reference.md) for the per-dialect matrix. `target_table_type=iceberg` is Redshift-only.
+If the user asks to change that persisted strategy, do not apply the change
+inside this per-object action. Return control to the main agent, which owns the
+one-time setup and must route through
+[`../../../data-infrastructure/SKILL.md`](../../../data-infrastructure/SKILL.md),
+which owns any setup delegation, before redispatching object work.
+
+If the strategy is unexpectedly unset (for example, an older project), return
+that setup remediation to the main agent as well. Do not run a catch-up wizard
+from an object subagent. Extraction is **not always ODBC** (PostgreSQL COPY;
+Oracle ODP.NET/DBMS_CLOUD; Redshift ODBC/UNLOAD + optional Iceberg; Teradata
+direct/TPT/WRITE_NOS), and each strategy has worker/infra prerequisites owned
+by setup; see [Extraction strategies reference](./references/extraction-strategies-reference.md)
+for the per-dialect matrix. `target_table_type=iceberg` is Redshift-only.
For **Preliminary** migrations, after YAML generation (Step 2a) add `whereClauseCriteria` per table with a valid WHERE predicate; see `./references/workflow-config-reference.md`.
+> **Advanced — preflight dry-run:** If the customer wants a **bounded pipeline smoke test** (one partition per table, transient `PREFLIGHT_` schema, not production targets), set `preflight: true` in the workflow YAML at Step 2a — see [Advanced operations reference](../../../data-infrastructure/references/advanced-operations-reference.md#preflight-workflows-bounded-migration-dry-run). This is **not** Preliminary row sampling to production and **not** `scai data doctor`.
+
### 1.C — Confirm
```
@@ -130,6 +144,8 @@ Notes:
`configure(snowflake_database=...)`), and Oracle `columnNamesToPartitionBy`
(`ROWID`) when the CLI left them empty.
+> **Duplicate data on re-run:** `migration_type=full` or `preliminary`, or `sync_strategy=none`, performs a **full extract and load every run**. Re-running the same workflow against a target that already has rows from a prior run **appends duplicate/extra data**. For repeatable runs, use `sync_strategy=watermark` or `checksum` (and set `primaryKeyColumns` / `watermarkColumn` / `checksumExpression` as needed — see [workflow-config-reference.md](./references/workflow-config-reference.md#synchronizationstrategy)). **Never** `TRUNCATE` or bulk-`DELETE` the target without explicit user confirmation — the table may legitimately contain pre-existing or expected rows. Before any cleanup: confirm with the user, compare source vs target row counts, and prefer switching to incremental sync for future runs.
+
### Step 2a: Display, optional edits, confirm
1. Read `workflow_path`.
@@ -137,7 +153,7 @@ Notes:
- **Small/medium files** — show the full YAML in chat.
- **Large files** — show the path, `tables:` count, `defaultTableConfiguration`, and table names; offer to show the full file or specific tables on request.
- Note whether setup **reused** an existing file (`workflow_reused`) or regenerated it.
-3. **Summarize:** table count, `migration_type`, sync strategy, extraction strategy, `target_table_type`, and any `partition_key_findings` from setup.
+3. **Summarize:** table count, `migration_type`, sync strategy, extraction strategy, `target_table_type`, and any `partition_key_findings` and `computed_column_findings` from setup.
4. Ask verbatim:
> Here is the migration workflow at ``.
@@ -159,12 +175,28 @@ Notes:
| Column rename/type map | `columnNameMappings`, `columnTypeMappings` |
| Server-side export | `extraction.strategy`, `externalStage` + worker TOML (UNLOAD/WRITE_NOS/DBMS_CLOUD) |
| Iceberg target | `target.tableType`, `target.icebergConfig`, `migrationStrategy` |
+ | Teradata mixed charsets / untranslatable bytes | `onUntranslatable` (`substitute` default, `fail` to stop on Error 6706); applies to ODBC, TPT, and `write_nos` — see `dmvf/docs/data-migration-orchestrator/teradata-charset-extraction.md` |
For stalled or partially finished runs, see [Task model reference](./references/task-model-reference.md) and [Troubleshooting reference](./references/troubleshooting-reference.md).
6. **If the user chooses "Proceed":** skip discretionary edits unless agent-only blockers remain (step 7).
7. **Agent-only blockers** — apply without re-prompting unless you need a value from the user:
- Resolve `partition_key_findings` and required `columnNamesToPartitionBy` per `edit_hints` (empty `[]` finishes the workflow without moving data; SQL Server / Redshift need an explicit PK or partition column; Oracle defaults to `ROWID`; PostgreSQL: monotonic integer PK or timestamp — avoid `ctid`).
+
+ **Show the table's columns first.** A partition column can only be judged against
+ the alternatives, and the user sees your edit as a one-line diff — so in the message
+ **before** you write `columnNamesToPartitionBy`, give per affected table the column
+ you picked, why it beats the others (primary key, non-nullable, cardinality / NULL
+ ratio from the finding's `detail`), and the columns it was picked from. Read those
+ from the table's DDL under `snowflake/`, or with `query_source` against the source
+ catalog.
+ - **Narrow tables** — list every column with its type.
+ - **Wide tables, or several tables at once** — do not paste every column. Give the
+ column count and the **partition candidates** only (unique / non-nullable keys,
+ date and numeric columns), and offer the full list per table on request.
+
+ A bare `suggestion` string leaves the user nothing to approve or reject on.
+ - Resolve `computed_column_findings` (SQL Server COMPUTED columns): exclude each listed column from the table's migration — drop it from the column list / `columnNameMappings` so its stored expression value is not migrated. The target column is redefined or recomputed post-migration.
- **Preliminary type:** add `whereClauseCriteria: ""` per table or `defaultTableConfiguration` when missing.
- Required **extraction strategy** fields (`extraction.strategy`, `externalStage`, worker TOML cross-refs for `unload` / `write_nos` / `dbms_cloud` / `tpt`).
- **Redshift Iceberg** (`target_table_type=iceberg`): required fields from [iceberg-setup-reference.md](./references/iceberg-setup-reference.md).
@@ -200,25 +232,31 @@ migrate_data(mode="run", workflow_path="")
Returns immediately with `job_id` — migration is **dispatched** in the background via `scai data migrate create-workflow` against the already-running shared orchestrator + worker.
-**Prerequisite:** the shared infrastructure must already be up. If you have not done so this session, run `data_infrastructure(mode="up")` once first — it brings up the orchestrator + worker (SPCS when a `compute_pool` is configured, otherwise local), runs the doctor gate, and returns the `cost_reminder` to relay. If infrastructure is not up, `migrate_data(mode="run")` returns a `remediation` pointing at `data_infrastructure(mode="up")` — bring it up and retry.
+**Prerequisite:** the main agent brought the shared infrastructure up once
+during setup. A per-object task never starts, repairs, or reconfigures it. If
+`migrate_data(mode="run")` returns an infrastructure remediation, return that
+remediation to the main agent; it resumes the persisted placement and
+redispatches the object task.
The `execution` field (`"local"` | `"cloud"`) and `cost_reminder` are on the `data_infrastructure(mode="up")` response — **relay the reminder to the user verbatim**. If absent, use the note that matches the `execution` field:
-> **Cost note (cloud):** The SPCS orchestrator and local worker are running and shared across every dispatch this session; they can keep using Snowflake credits while idle (the worker polls the warehouse on an interval). When you are done, tear the infrastructure down — `data_infrastructure(mode="down")` for the local worker, and the teardown skill to suspend the SPCS orchestrator and compute pool. The local worker stops when this session ends; the SPCS orchestrator does not.
+> **Cost note (cloud):** The SPCS orchestrator and local worker are running and shared across every dispatch this session; they can keep using Snowflake credits while idle (the worker polls the warehouse on an interval). When you are done, tear the infrastructure down with `data_infrastructure(mode="down")` — it stops the local worker **and** suspends the SPCS orchestrator (the compute pool then auto-suspends). The local worker also stops when this session ends; the SPCS orchestrator does not, so call `down` explicitly.
>
> **Cost note (local):** A local orchestrator and worker run for this session, shared across every dispatch, and stop when the session ends or you call `data_infrastructure(mode="down")`. Source extraction still runs SQL against your warehouse while active.
Retain `workflow_path` for the report in Step 6.
-After run starts, go to **Step 5** (do not busy-poll every 30–60s unless you are on the fallback path).
+After run starts, go to **Step 5**.
---
-## Step 5: Wait for completion (Monitor, or poll fallback)
+## Step 5: Wait for completion with Monitor
-Load [Background monitoring](./references/background-monitoring.md) and follow it. Summary below; the reference is authoritative for capability checks, event phases, and crash fallback.
+Use Monitor for the asynchronous wait. `job_status` is for a user-requested
+status update, handling a Monitor event, or producing the final report; it is
+not a second polling loop.
-**Status tool** (both paths):
+**Status tool:**
```
job_status(job_id="") # cheap summary
@@ -227,20 +265,7 @@ job_status(job_id="", details=true) # + full progress and failure repo
`job_id` is `monitor.job_id` from the `migrate_data(mode="run")` response. `details` may report `details_unavailable` until `create-workflow` returns a workflow name; that is normal early in the run.
-### 5.A — Choose path
-
-**Always prefer Monitor when it can be invoked.** Polling is never the better choice while Monitor is available — one relay poller serves every watcher, it wakes you on trouble and not only on completion, and it spends no tool call per check.
-
-| Condition | Path |
-|-----------|------|
-| Monitor tool can be invoked — **the default** | **Background** — Phases A–D in the reference |
-| Monitor genuinely unavailable (absent from the tool list, or invoking it fails) | **Fallback** — active poll below |
-
-If you are unsure whether Monitor is available, **try it** rather than defaulting to the poll. Calling `job_status(job_id)` because the user asked for an update is not the polling path and is always fine.
-
-**One completion owner:** only the background Monitor path **or** the fallback poll may present Step 6 — never both.
-
-### 5.A.1 — Background path (always use this when Monitor is available)
+### 5.A — Background monitoring
1. **Phase A — Monitor:** Start the **Monitor** tool (`persistent: true`) with `monitor.watch_command` from the run response, verbatim, from the project root. No wait for a workflow name, and no hand-built command — the cursor baked into it is what prevents replays and gaps.
2. **Phase B — Monitor fire:** Branch on the event's `phase`. `failure` / `stalled` / `relay_error` are warnings — surface them and keep watching. On `terminal`: `job_status(job_id, details=true)` once → Step 5.C if needed → **Step 6**.
@@ -253,39 +278,18 @@ Tell the user once that you'll report back when the job finishes or hits trouble
**If the watch goes silent,** re-arm with `job_status(job_id, monitor=true)` — it returns a fresh cursor and watch command and restores the relay's poller if it was lost. With no progress loop there is no second timer cross-checking the watch, so this is the recovery path after a compaction, a session restart, or an answer that looks stale.
-### 5.A.2 — Fallback path (last resort — only when Monitor cannot be invoked)
-
-Poll until the job is terminal:
-
-- Call `job_status(job_id)` repeatedly until `terminal` is `true` (or when the user asks for an update). A natural gap between turns is enough — **do not insert your own timer**.
-- On polls where you are **not** running a health check (Step 5.B), optionally share a one-line update from `summary`, or from `details.progress.output` when you pass `details=true` (e.g. `preprocessedTables`/`totalTables`, `aggregatedCounts.loadedPartitions`/`totalPartitions`).
-
-**Never wait with `bash sleep` (or by tailing worker logs) for migrate progress.** That burns wall-clock and skips the status tool. The only allowed wait signals are Monitor (preferred) or another `job_status` call. Short `sleep` after killing a process (1–3s) is fine; multi-tens-of-seconds sleeps to "give the workflow time" are not.
-
-**Stop when** `terminal` is `true`.
-
-Then Step 5.C → Step 6.
-
### 5.B — Health monitoring (while waiting)
-On the background path the relay emits a `stalled` event when counters stop moving, so you do not compute stalls yourself — warn the user and keep watching.
-
-On the fallback path, derive health from consecutive `job_status(job_id, details=true)` responses. Keep the previous response in memory (at least `details.progress.output.aggregatedCounts`, `preprocessedTables`, `totalTables`, and `tablePartitions`).
-
-**When to run:**
-
-- **Background path:** the relay reports stalls itself; check health when it fires an event or the user asks.
-- **Fallback path:** every **2nd or 3rd** poll, or when the user asks. Skip until `details.progress.output` exists.
-
-**Progress key:** `loadedPartitions` from `details.progress.output.aggregatedCounts` (fallback: sum of `tablePartitions[].loadedPartitions`). Record the poll/tick time when this key last increased.
+Monitor emits `stalled` and `failure` events. On either event, call
+`job_status(job_id, details=true)` once to inspect the current state, surface
+the warning, and keep the Monitor armed.
-| Signal | Background (relay events) | Fallback (30–60s poll) | Severity |
-|--------|---------------------------|-------------------------|----------|
-| Stall | `stalled` event | `loadedPartitions` unchanged **≥10 minutes** | **Warning** |
-| Stuck | a second `stalled` event, or one still standing when you next look | unchanged **≥20 minutes** | **Critical** |
-| Partition failures | `failure` event, or `failed: true` on any event | `aggregatedCounts.failedPartitions > 0` | **Warning** (immediate) |
-| Preprocessing lag | `preprocessedTables < totalTables` and running **≥30 minutes** | running **≥15 minutes** | **Warning** |
-| Partial table failure | Any `tablePartitions[]` with `failedPartitions > 0` or `hasBeenPreprocessed == false` while still running | same | **Warning** |
+| Signal | Severity |
+|--------|----------|
+| `stalled` event | **Warning** |
+| Repeated unresolved `stalled` event | **Critical** |
+| `failure` event, or `failed: true` on any event | **Warning** (immediate) |
+| Any `tablePartitions[]` with failed partitions or incomplete preprocessing | **Warning** |
When **`reports`** is present, also scan `details.reports.files.errors` — any rows mean at least one task/partition has failed even if aggregate counters look healthy.
@@ -304,13 +308,13 @@ On **Warning** or **Critical**, point to [Troubleshooting Reference](./reference
Fold the worst severity seen while waiting into Step 6 **Infrastructure** or **Load** only if it was never surfaced to the user.
-A stall or stuck warning does **not** end monitoring — only a `terminal` event (or a fallback poll seeing `terminal: true`) does.
+A stall or stuck warning does **not** end monitoring — only a `terminal` event does.
When a job fails before a workflow exists, the failure text is in the job's `summary` — surface it under **Infrastructure** in `### Errors`.
### 5.C — Finished workflow with incomplete tables (anomaly)
-After the terminal status (Monitor fire + confirm, or fallback poll), if `details.progress.output.isFinished == true` **and** any of:
+After the terminal Monitor event and final status confirmation, if `details.progress.output.isFinished == true` **and** any of:
- `preprocessedTables < totalTables`
- any `tablePartitions[].hasBeenPreprocessed == false`
@@ -325,6 +329,12 @@ After the terminal status (Monitor fire + confirm, or fallback poll), if `detail
---
+If the job **failed**, do not re-run it and do not change Snowflake with `sql_execute`. Call `migration_status(mode="next_task")`. A failed load is `error=sql`; the machine sends you to `applyRules` → `fixCode`. Edit the converted `snowflake/` file, **deploy**, then the fix loop retries `migrateData`.
+
+A live `ALTER` / `DROP` / `CREATE` that is not in `snowflake/` is not a `note` — the next `deploy` overwrites it. `note` is for a judgment you already made in the file (you chose among meanings and can name the inverse). See the walker definition: meanings vs compile. Do not `note` as a substitute for the fix, and do not re-dispatch the same failed workflow.
+
+---
+
## Step 6: Report — data migration summary
**Do not skip.** Present an **error-first** summary in chat (markdown). Build it from the final `job_status(job_id, details=true)` response (`details.progress`, `details.reports`) and classify failures — **not** a per-table results grid unless the user asks.
@@ -378,7 +388,7 @@ Number fixes in the **same category order** as **Errors** (Preprocessing → Ext
- [Troubleshooting Reference](./references/troubleshooting-reference.md) for worker/orchestrator/partition issues
- YAML / TOML edits: `whereClauseCriteria`, partitions, `source.databaseName`, extraction strategy, worker connection fields — as required by the error category
-- **Re-run only as a follow-up:** mention `migrate_data(mode="run", workflow_path=...)` in `### Suggested fixes` only when prerequisites are clear, or label it “after the steps above” — do not list re-run as the first or only fix
+- **Re-run only as a follow-up:** mention `migrate_data(mode="run", workflow_path=...)` in `### Suggested fixes` only when prerequisites are clear, or label it “after the steps above” — do not list re-run as the first or only fix. Before suggesting re-run, confirm the workflow uses incremental sync (`watermark` / `checksum`) or that the user accepts a full reload; a non-incremental re-run against a populated target duplicates rows (see duplicate-data callout in Step 2).
- After all tables succeed → offer [validation](../../../validate-objects/actions/validate_tables.md) for the same scope
Do **not** include a per-table markdown table unless the user asks for a full audit.
@@ -435,7 +445,7 @@ Omit empty category subsections. If every table completed **and Step 5.C does no
- **All tables succeeded** — offer validation for this scope or continuing the wave.
- **Any failures** — do **not** jump to re-run. Summarize the prerequisite actions from `### Suggested fixes`, then **ask the user** how to proceed, for example:
1. Apply fixes (YAML/TOML/config) — you or the user edits files; confirm when done
- 2. Re-run the same workflow — only after prerequisites are done or the user explicitly accepts re-run without fixes (e.g. transient infra)
+ 2. Re-run the same workflow — only after prerequisites are done or the user explicitly accepts re-run without fixes (e.g. transient infra). **Warn:** if `sync_strategy=none` (or no `synchronization` block), re-run reloads all rows and **duplicates data** on the target; prefer adding `watermark`/`checksum` or scoped cleanup confirmed with the user — never blind `TRUNCATE`/`DELETE`.
3. Narrow scope — adjust `where` / workflow YAML and run setup + run for failed tables only
4. Investigate further — troubleshooting reference, logs, health signals from Step 5.B
5. Stop — proceed to Step 7 (teardown) without re-running
@@ -446,14 +456,20 @@ Then continue to Step 7 (teardown offer) when wave data work is done for this pa
## Step 7: Offer to tear down infrastructure (cost saving)
-When the wave's data work is done, offer to tear down idle infrastructure to save cost (default **Yes**), then **delegate** — do **not** re-derive what is running here:
-
-- **Local** orchestrator/worker this session started → `data_infrastructure(mode="down")`.
-- **SPCS** orchestrator / compute pool / DEW worker (or any mixed setup) → load [`../../../data-infrastructure/teardown/SKILL.md`](../../../data-infrastructure/teardown/SKILL.md); it owns the "what's running" detection (its *Which steps apply* table) and runs only the applicable steps.
+When the wave's data work is done, offer to tear down the shared infrastructure to save idle cost — a single
+prompt regardless of placement (default **Yes**):
-Nothing auto-resumes — dispatch is pure, so bring infrastructure back for the next wave with `data_infrastructure(mode="up")`. (A local worker started via MCP also stops when the session ends.)
+> Tear down the shared data infrastructure to stop idle cost? Bring it back for the next wave with
+> `data_infrastructure(mode="up")` (dispatch does not auto-resume).
+>
+> 1. **Yes (default)** — call `data_infrastructure(mode="down")`.
+> 2. **No, keep running** — next batch soon; avoids the ~60s SPCS warm-up.
-If the user picks **Yes** (or doesn't respond), run the teardown, then return to the parent skill.
+On **Yes** (or no response), call `data_infrastructure(mode="down")` and relay the returned `execution` +
+`orchestrator`/`worker` actions. Then load `../../../data-infrastructure/teardown/SKILL.md` **only** for the
+cases the tool cannot cover on its own: the cross-machine in-flight `TASK_QUEUE` check before suspending
+shared SPCS, a local orchestrator/worker the user started **outside** MCP (needs Ctrl+C / `pkill`), or a
+`partial` payload reporting an SPCS privilege failure. Otherwise return to the parent skill.
---
@@ -462,7 +478,7 @@ If the user picks **Yes** (or doesn't respond), run the teardown, then return to
Shared infrastructure checklist is owned by `../../../data-infrastructure/SKILL.md`. Migration-specific items:
```
-- [ ] Migration strategy set (captured at setup via dataStrategy; fallback wizard only if unset)
+- [ ] Migration strategy set during main-agent setup
- [ ] Strategy-specific worker/infra prerequisites met per extraction-strategies-reference.md
- [ ] Workflow YAML generated via migrate_data(mode="setup", ...) (or existing file reviewed at Step 2a)
- [ ] User saw workflow YAML and was offered optional field updates (Step 2a)
@@ -471,7 +487,7 @@ Shared infrastructure checklist is owned by `../../../data-infrastructure/SKILL.
- [ ] Target database and schema exist
- [ ] Iceberg prerequisites validated — if Redshift + `target_table_type=iceberg`
- [ ] migrate_data(mode="run") started
-- [ ] Monitor used (the default); active polling only if Monitor could not be invoked (Step 5)
+- [ ] Monitor armed with the run response's watch command (Step 5)
- [ ] Waited until the job reported a `terminal` event
- [ ] Finished-but-incomplete anomaly checked (Step 5.C — do not report success if tables never preprocessed)
- [ ] Health monitoring run while waiting when triggered (Step 5.B — stall/failure signals surfaced)
@@ -485,11 +501,13 @@ Return control to the parent skill.
## Reference
+- [Advanced operations reference](../../../data-infrastructure/references/advanced-operations-reference.md) — rate limiting, preflight, incremental/revalidate DV
- [Background monitoring](./references/background-monitoring.md)
- [Workflow Config Reference](./references/workflow-config-reference.md)
- [Task Model Reference](./references/task-model-reference.md)
- [Extraction Strategies Reference](./references/extraction-strategies-reference.md)
- [Iceberg Setup Reference](./references/iceberg-setup-reference.md)
- [Troubleshooting Reference](./references/troubleshooting-reference.md)
+- [Advanced operations reference](../../../data-infrastructure/references/advanced-operations-reference.md) — rate limiting, preflight dry-run
- [Data Doctor reference](../../../data-infrastructure/references/data-doctor-reference.md)
- [Teardown (cost-saving suspend)](../../../data-infrastructure/teardown/SKILL.md)
diff --git a/plugin/skills/migration/migrate-objects/actions/data-migration/references/task-model-reference.md b/plugin/skills/migration/migrate-objects/actions/data-migration/references/task-model-reference.md
index 191a33f..8aaf726 100644
--- a/plugin/skills/migration/migrate-objects/actions/data-migration/references/task-model-reference.md
+++ b/plugin/skills/migration/migrate-objects/actions/data-migration/references/task-model-reference.md
@@ -51,18 +51,46 @@ Validation adds L1/L2/L3 chains with Snowpipe drain barriers when `useSnowpipeFo
## Scope grammar
-Scopes are hierarchical strings used for filtering and pause/cancel operations:
+Every task has a `SCOPE`: a hierarchical string of `::`-joined fragments, ordered least → most specific, describing the task's purpose and target. Scopes drive prefix queries, **rate limiting** (`RATE_LIMIT.SCOPE_PATTERN`), and the pause/resume/cancel procedures — all match `SCOPE` with SQL `LIKE`. The owning workflow is tracked separately in `TASK_QUEUE.WORKFLOW_ID` (not embedded in table/partition scopes).
+
+### Data migration scopes
| Pattern | Meaning |
|---------|---------|
-| `Table[DB.SCHEMA.TABLE]::Preprocessing` | Table setup phase |
-| `Table[DB.SCHEMA.TABLE]::Partition[N]::Extraction` | Partition extraction |
-| `Table[DB.SCHEMA.TABLE]::Partition[N]::Loading` | Partition load |
-| `preflight::::schema_drop` | Preflight cleanup |
+| `Table[DB.SCHEMA.TABLE]::Preprocessing` | Table setup phase (metadata + partition strategy) |
+| `Table[DB.SCHEMA.TABLE]::Partition[N]::Extraction` | Partition extraction (DEA) |
+| `Table[DB.SCHEMA.TABLE]::Partition[N]::DeletionKeysExtraction` | Primary-key extraction for `trackDeletions` (distinct from `Extraction`) |
+| `Table[DB.SCHEMA.TABLE]::Partition[N]::Loading` | Partition load (`COPY INTO` / Snowpipe) |
+| `Table[DB.SCHEMA.TABLE]::Preprocessing::SnowpipeSetup` / `::SnowpipeTeardown` | Snowpipe pipe create / drop |
+| `Table[DB.SCHEMA.TABLE]::Preprocessing::PreflightSetup` | Preflight (bounded dry-run) setup |
+| `preflight::::schema_drop` | Drop transient `PREFLIGHT_` schema |
+| `workflow::::transient_cleanup` | Clean up transient resources at workflow end |
+
+The table identifier in `Table[...]` is the **normalized source FQN** (for example `MY_DB.DBO.CUSTOMERS`).
+
+### Data validation scopes
-Query tasks by scope prefix:
+Validation tasks are prefixed with `DV::` so they never collide with migration scopes.
+
+| Pattern | Meaning |
+|---------|---------|
+| `DV::Table[ID]::Preprocessing` | Validation metadata / table prep |
+| `DV::Table[ID]::SchemaValidation` | L1 schema validation |
+| `DV::Table[ID]::Partition[N]::MetricsValidation` | L2 metrics validation |
+| `DV::Table[ID]::Partition[N]::RowValidation` | L3 row-hash validation |
+| `DV::Table[ID]::Partition[N]::CellDrilldown` | Hybrid L3 cell drill-down (may carry `Batch[k]` before the op) |
+| `DV::Table[ID]::Partition[N]::WriteResults[row\|cell]` | Write results (row-hash or cell); batched as `...::Batch[k]::WriteResults[cell]` |
+| `DV::Table[ID]::Evaluate[LEVEL]` | Evaluate a completed level |
+| `DV::Table[ID]::ReconcilePossibleMismatches` | Post-drilldown reconcile of `POSSIBLE_MISMATCH` |
+| `DV::Table[ID]::L3EarlyStopMonitor` | Periodic L3 early-stop monitor |
+| `DV::Table[ID]::DetectionComplete` / `::SyncBaseline` / `::SyncFinalize` | Incremental validation bookkeeping |
+| `DV::Pipe[KEY]::SnowpipeSetup\|SnowpipeTeardown\|SnowpipeDrain\|SnowpipePrepareDrain` | Snowpipe ops for validation results |
+| `DV::ObjectTypeDetection::Preprocessing` | Object-type dispatch task |
+
+### Querying / matching by scope
```sql
+-- All tasks for one table (migration):
SELECT ID, NAME, STATUS, LAST_ERROR_MESSAGE
FROM SNOWCONVERT_AI.DATA_MIGRATION.TASK_QUEUE
WHERE WORKFLOW_ID =
@@ -70,7 +98,7 @@ WHERE WORKFLOW_ID =
ORDER BY ID;
```
-The table identifier in `Table[...]` is the **normalized source FQN** (for example `MY_DB.DBO.CUSTOMERS`).
+The same `LIKE` matching powers rate-limit `SCOPE_PATTERN` rules (e.g. `Table[%]::Loading` caps concurrent loads) and scope-filtered queries or pause/cancel (e.g. `DV::Table[%]::Partition%::RowValidation` selects L3 tasks). See [rate limiting](../../../../data-infrastructure/references/advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources).
## Dependency model
@@ -138,4 +166,4 @@ Lower number = higher priority. Fan-out / strategy tasks ≈ 1; extraction sprea
- [Troubleshooting reference](./troubleshooting-reference.md)
- [Workflow config reference](./workflow-config-reference.md)
-- [Data Doctor reference](../../../data-infrastructure/references/data-doctor-reference.md)
+- [Data Doctor reference](../../../../data-infrastructure/references/data-doctor-reference.md)
diff --git a/plugin/skills/migration/migrate-objects/actions/data-migration/references/troubleshooting-reference.md b/plugin/skills/migration/migrate-objects/actions/data-migration/references/troubleshooting-reference.md
index 4718cb7..ad33270 100644
--- a/plugin/skills/migration/migrate-objects/actions/data-migration/references/troubleshooting-reference.md
+++ b/plugin/skills/migration/migrate-objects/actions/data-migration/references/troubleshooting-reference.md
@@ -257,12 +257,41 @@ ORDER BY ID;
If metadata/schema extraction completes with **zero rows** but no worker error, compare worker TOML `[connections.source.*].database` against workflow `source.databaseName` (Oracle: service name; Teradata: database name). This is the most common silent failure mode.
+### Teradata Error 6701 / 5355 (mixed charsets)
+
+**Symptom:** Extraction task fails on Teradata with Error **6701** or **5355** when the source table has columns in different character sets (for example LATIN + KANJISJIS + GRAPHIC in one row).
+
+**Fix:** Ensure the workflow uses a current orchestrator build with charset-aware Teradata extraction (automatic `_TO_UNICODE` per column). If the customer still hits untranslatable-byte edge cases, set `onUntranslatable: substitute` (default) or `fail` for strict tables. See `dmvf/docs/data-migration-orchestrator/teradata-charset-extraction.md`.
+
+### Teradata Error 6706 (untranslatable bytes, fail mode)
+
+**Symptom:** Extraction fails with **6706** on a table configured with `onUntranslatable: fail`.
+
+**Fix:** Expected behavior — the table contains bytes that cannot map to Unicode under the chosen charset translation. Either clean/source-fix the data, use `onUntranslatable: substitute` for lossy U+FFFD replacement, or scope `whereClauseCriteria` to exclude bad rows (if acceptable).
+
---
## `POSSIBLE_MISMATCH` after validation completes
Hybrid L3 validation may stop early when `earlyStoppingForRowHashing` or `maxFailedRowsNumber` is reached. A workflow can finish with `POSSIBLE_MISMATCH` result codes — **do not treat as a clean pass**. Review L3 result tables and consider re-running with adjusted early-stop settings or narrower `sourceWhereClause`/`targetWhereClause` filters.
+> **Data validation is read-only** — re-running a DV workflow compares source and target; it does not move or duplicate data on either side.
+
+---
+
+## Target has more/duplicate rows after re-running a migration
+
+**Symptom:** Target row count exceeds source (or a prior migration run), or users report duplicate keys/rows after a second `migrate_data(mode="run")`.
+
+**Cause:** The workflow used **non-incremental** sync (`sync_strategy=none`, no `synchronization` block, or `migration_type=full`/`preliminary` without watermark/checksum). Each run extracts and loads **all** matching rows again — `COPY INTO` appends to the target; the orchestrator does not deduplicate on full reload.
+
+**Remediation (careful — do not destroy legitimate data):**
+
+1. **Verify:** Compare source vs target row counts (and sample keys if available). Confirm whether extra rows came from a re-run vs pre-existing target data.
+2. **Confirm with the user** before any destructive action. The target may hold rows that are expected or unrelated to this migration.
+3. **Do not** blindly `TRUNCATE` or bulk-`DELETE` the target. If cleanup is required, scope it (for example delete rows loaded in a specific partition/window, or dedupe by primary key) only after explicit user approval.
+4. **Going forward:** Add `synchronization.strategy: watermark` or `checksum` (with `watermarkColumn`, `checksumExpression`, and `primaryKeyColumns` as needed) so subsequent runs are incremental. See [workflow-config-reference.md](./workflow-config-reference.md#synchronizationstrategy).
+
---
## Workflow finished but tables incomplete
@@ -327,6 +356,50 @@ ORDER BY WORKFLOW_ID;
---
+## Source overloaded or too many concurrent extractions/loads
+
+**Symptom:** Migration or validation is slow; source DBA reports connection pressure; many extraction/load tasks run at once; customer wants to throttle without stopping workers entirely.
+
+**Cause:** Default parallelism (`max_parallel_tasks` per worker × number of workers) may exceed what the source can sustain.
+
+**Fix path (prefer in order):**
+
+1. **Rate limiting (advanced):** Insert rules into `DATA_MIGRATION.RATE_LIMIT` to cap concurrent tasks by scope pattern — see [Advanced operations reference](../../../../data-infrastructure/references/advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources). This is metadata SQL, not workflow YAML.
+2. Lower `max_parallel_tasks` in worker TOML (per-machine parallelism).
+3. Reduce worker count or pause workers during peak source hours.
+
+Do **not** suggest orchestrator polling-interval env vars as a throttle mechanism.
+
+---
+
+## Tasks stay pending on trial / non-hybrid metadata accounts
+
+**Symptom:** Workers are running, source is healthy, but `TASK_QUEUE` rows remain `pending`; adding more workers does not help or makes it worse.
+
+**Cause:** Snowflake account lacks **Hybrid Table** support — orchestrator metadata fell back to **standard/`TRANSIENT`** tables. Task claiming uses a slower, contention-sensitive path; **too many workers** compete for the same queue rows.
+
+**Agent guidance:**
+
+1. Confirm metadata mode (trial account, `Unsupported feature 'HYBRID TABLE'` at bootstrap, or `SNOWFLAKE_USE_HYBRID_TABLES=0`).
+2. **Reduce** worker count and `max_parallel_tasks` before adding more infrastructure — see [Metadata storage mode reference](../../../../data-infrastructure/references/metadata-storage-mode-reference.md).
+3. Distinguish from source overload ([rate limiting](../../../../data-infrastructure/references/advanced-operations-reference.md#rate-limiting-protect-source-or-shared-resources)) and missing workers (affinity mismatch).
+
+---
+
+## Incremental sync or checksum did not detect a column change
+
+**Symptom:** Customer edited data (especially in `text`/`ntext`/`image`, LOBs, floats, spatial, or high-precision timestamps) but the next incremental migration or incremental validation run did not re-process the partition; checksum unchanged.
+
+**Cause:** Built-in **partition checksums** exclude or normalize some types before hashing. A custom `checksumExpression` only reflects that SQL aggregate. **Watermark** sync ignores columns that are not the watermark. DM checksum and DV L3 row-hash use **different** pipelines — a column skipped from DM checksum may still be compared at L3.
+
+**Agent guidance:**
+
+1. Confirm sync strategy (`checksum` vs `watermark`) and whether the changed column is in the checksum input.
+2. Explain using the skipped/lossy type table — see [Advanced operations reference](../../../../data-infrastructure/references/advanced-operations-reference.md#checksum--incremental-sync--types-that-may-not-trigger-re-sync).
+3. Offer remediation: one-time **full** run; custom **`checksumExpression`**; switch to **watermark** if appropriate; **DV L3** + `validationCustomNormalizationRules` when the issue is compare semantics.
+
+---
+
## Extra / unexpected tables in the migration workflow
**Symptom:** The user asked to migrate N specific tables, but the workflow YAML
diff --git a/plugin/skills/migration/migrate-objects/actions/data-migration/references/workflow-config-reference.md b/plugin/skills/migration/migrate-objects/actions/data-migration/references/workflow-config-reference.md
index 57604f5..1cd0e6d 100644
--- a/plugin/skills/migration/migrate-objects/actions/data-migration/references/workflow-config-reference.md
+++ b/plugin/skills/migration/migrate-objects/actions/data-migration/references/workflow-config-reference.md
@@ -12,9 +12,47 @@
| `affinity` | String | No | Only orchestrator and worker instances with a matching affinity will process this workflow. If the SPCS orchestrator was started with a specific affinity (visible in service logs as `Orchestrator affinity: `), the workflow **must** set the same value or it will be silently skipped. The worker's `[application].affinity` must also match. Omit from all sides for fresh setups. |
| `preflight` | Boolean | No | When `true`, cap each table to one partition and run against a transient `PREFLIGHT_` schema (bounded dry-run). Default `false`. |
| `preflightKeepSchema` | Boolean | No | When `preflight` is `true`, skip cleanup so the transient schema remains for manual inspection. Default `false`. |
-| `cleanUpTransientResources` | `"never"` \| `"on-success"` \| `"always"` | No | Delete intermediate stage files for this workflow after it finishes (`TASK_RESULTS` and any external stages used by extraction). Default `"never"`. Underscores are accepted (`on_success`). |
+| `cleanUpTransientResources` | `"never"` \| `"on-success"` \| `"always"` | No | Delete intermediate stage files for this workflow after it finishes (`TASK_RESULTS` and any external stages used by extraction). Default `"on-success"`. Underscores are accepted (`on_success`). Set `"never"` to retain stage files for debugging. When the orchestrator runs in Iceberg metadata mode, the omitted-key default may be `"always"` instead — check your deployment profile if you rely on the default. |
| `intervalHandling` | `"interval"` \| `"varchar"` | No | How PostgreSQL/BigQuery mixed-family interval columns are mapped. Default `"interval"`. Can be overridden per table. |
+## Preflight (bounded dry-run)
+
+Set top-level `preflight: true` for a **migration smoke test**: each table runs as a single partition; loads go to transient schema `PREFLIGHT_`, not the configured production target. Optional `preflightKeepSchema: true` retains the schema after the workflow for inspection.
+
+**Not the same as** Preliminary migration type (`whereClauseCriteria` loads to real targets) or `scai data doctor` (infra health checks).
+
+When a customer asks for a dry-run or pipeline test before full migration, offer preflight at Step 2a. Full agent guidance: [Advanced operations reference](../../../../data-infrastructure/references/advanced-operations-reference.md#preflight-workflows-bounded-migration-dry-run).
+
+## Teradata: mixed charsets and `onUntranslatable`
+
+**When:** Teradata extraction fails with **6701** / **5355** (mixed charsets in one row), or the customer asks how to handle **untranslatable** non-Unicode bytes during migration.
+
+**Default:** omit `onUntranslatable` or set `"substitute"` under `defaultTableConfiguration` — untranslatable bytes become **U+FFFD** and migration continues.
+
+**Strict mode:** per-table `"onUntranslatable": "fail"` when any untranslatable byte must abort the partition (Teradata **6706**).
+
+Applies to **`regular`**, worker-side **TPT**, and **`write_nos`** (orchestrator-built `SELECT`; no DEA TOML keys). Detail: `dmvf/docs/data-migration-orchestrator/teradata-charset-extraction.md`.
+
+```yaml
+defaultTableConfiguration:
+ onUntranslatable: substitute
+ extraction:
+ strategy: regular # or write_nos + externalStage
+tables:
+ - source: { databaseName: ecommerce, tableName: mixed_charset_orders }
+ target: { databaseName: TARGET_DB, schemaName: ECOMMERCE_TD, tableName: MIXED_CHARSET_ORDERS }
+ columnNamesToPartitionBy: [order_id]
+ - source: { databaseName: ecommerce, tableName: strict_audit }
+ target: { databaseName: TARGET_DB, schemaName: ECOMMERCE_TD, tableName: STRICT_AUDIT }
+ columnNamesToPartitionBy: [id]
+ onUntranslatable: fail
+```
+
+```yaml
+preflight: true
+preflightKeepSchema: false
+```
+
## TableConfiguration
| Property | Type | Required | Description |
@@ -34,6 +72,7 @@
| `loading` | Object | No | Loading strategy: `warehouse` (default, `COPY INTO`) or `snowpipe`. |
| `queryModifiers` | Object | No | SQL hints to reduce locking on busy source tables during extraction (see [Query modifiers](#query-modifiers)). |
| `intervalHandling` | `"interval"` \| `"varchar"` | No | Per-table override of top-level `intervalHandling`. |
+| `onUntranslatable` | `"substitute"` \| `"fail"` | No | **Teradata only.** How non-Unicode string columns handle untranslatable bytes during extraction. Default `"substitute"`. Set under `defaultTableConfiguration` for a workflow-wide default, or per table to override. Use `"fail"` when untranslatable non-Unicode bytes must abort the partition (Error 6706) instead of substituting U+FFFD. Detail: `dmvf/docs/data-migration-orchestrator/teradata-charset-extraction.md`. |
| `executionTimeoutMinutes` | Integer | No | Wall-clock timeout in minutes for the **Analyze boundaries** DEA task only (orchestrator default is **20** when omitted). Does **not** apply to extraction or load. Use per table for large/slow boundary queries, or under `defaultTableConfiguration` to apply to all tables. |
## SourceTargetIdentifier
@@ -139,10 +178,14 @@ extraction:
| Strategy | Description | Best for |
|----------|-------------|----------|
-| `none` (default) | Full extraction every run | Small tables or unpredictable changes |
-| `checksum` | Hash all column values per partition; re-extract changed partitions only | Dimension tables without a monotonic column |
+| `none` (default) | Full extraction every run | One-time loads, or tables you will not re-migrate without clearing the target first |
+| `checksum` | Hash all column values per partition; re-extract changed partitions only | Dimension tables without a monotonic column. **Oracle:** built-in partition checksum is supported (`STANDARD_HASH` over normalized columns); optional `checksumExpression` (for example `MAX(ORA_ROWSCN)`) overrides the default hash. Some Oracle types are excluded from the default hash — see checksum type coverage below. |
| `watermark` | Track a monotonic column; sync only rows newer than the last observed value | Fact tables, event logs with a reliable `UPDATED_AT` / ID column |
+> **Re-running without incremental sync:** With `strategy: none` (or no `synchronization` block), every migration run extracts and loads **all** matching rows again. Re-running the same workflow against a target that already holds data from a prior run **appends duplicate rows** (or loads more data than expected). Use `watermark` or `checksum` for repeatable incremental runs. Do **not** `TRUNCATE` or bulk-`DELETE` the target without explicit user confirmation — the table may legitimately contain pre-existing or expected rows.
+
+> **Checksum type coverage:** Built-in partition checksums **skip or normalize** some types (SQL Server `text`/`ntext`/`image`; Oracle LOBs/`LONG`/`XMLTYPE`/`VECTOR`; float rounding; spatial WKT; Redshift `HLLSKETCH`). Changes only in those columns may **not** change the checksum — no re-extract on the next run. Custom `checksumExpression` (for example `MAX(ORA_ROWSCN)`) only reflects what that expression measures. See [Advanced operations reference](../../../../data-infrastructure/references/advanced-operations-reference.md#checksum--incremental-sync--types-that-may-not-trigger-re-sync).
+
```yaml
synchronization:
strategy: none
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/SKILL.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/SKILL.md
index 06c2178..fbc81da 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/SKILL.md
@@ -25,6 +25,18 @@ Fix SnowConvert ETL conversion gaps through phased execution with upfront unit a
- Active Snowflake connection with a warehouse
- DATABASE + SCHEMA with write privileges (`CREATE TABLE`, `CREATE FUNCTION`, `CREATE PROCEDURE`)
+### When the converted-output folder is not isolated
+
+The default contract above assumes one folder per unit. If instead you're handed a flat, whole-repository conversion output where a dbt project is referenced by more than one sibling unit's orchestration file (e.g. Informatica `SHORTCUT` mappings reused across workflows), do not edit the shared project in place:
+
+1. Stage a real copy of the orchestration file and every dbt project it references under `{PACKAGE_FOLDER}/Output/ETL/{unit}/`. Treat the original location read-only until sync-back.
+2. If a staged dbt project's `packages.yml` has a local `path:` dependency, do **not** edit the path to account for the extra staging depth — that breaks canonical when synced back. Instead, symlink the shared-assets directory into the unit folder at the depth `packages.yml` already expects.
+3. Before syncing any fix back, run the leak gate on every touched file, then diff against the original — test-environment values (schema/database names, credentials) must never reach the canonical copy:
+ ```bash
+ uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/check_sync_leaks.py {SESSION_JSON} [ ...]
+ ```
+ Do not copy until this exits 0.
+
## Persistent Files
All files stored in `{UNIT}/stabilization/`:
@@ -47,14 +59,18 @@ All files stored in `{UNIT}/stabilization/`:
**NEVER use `bash sleep`, `bash_output`, or `cortex agent output` to wait for agents.**
-Background agents deliver results via **automatic task notifications** — a new conversation turn arrives when each agent finishes. The correct flow:
+You are the orchestrator. Do **not** spawn a subagent whose job is "run etl-stabilization" or "execute Phase N". Named teammates (context mappers, test-gen, fixer, apply-fixes) are the only valid spawns.
+
+**Claim in-flight work only from this turn's spawn tool results.** If this turn did not return live agent ids, nothing is running. Do not tell the user a subagent is executing.
-1. Spawn all background agents in a **single message** (parallel tool calls)
-2. **End your turn** — do not issue further tool calls or narrate while waiting
-3. When a notification arrives, process that agent's result
-4. When all notifications have arrived, verify output files exist and continue
+How to wait, by spawn mode:
-Do not attempt to call `agent_output` — it may not be available and failed attempts waste a turn. Automatic task notifications are the only reliable mechanism.
+- **`run_in_background=false`** (planning mappers, and any blocking spawn): results return in **this** turn. Process them here. Do **not** end the turn to wait.
+- **`run_in_background=true`**: spawn all agents in one message (parallel tool calls). Confirm each spawn returned an agent id. **Then** end the turn — no extra tool calls, no "still running" narration. A new turn arrives when each agent finishes. Process that result, then verify output files exist.
+
+Do not attempt to call `agent_output` — it may not be available and failed attempts waste a turn.
+
+**Stall:** If `STATE.md` / phase artifacts have not changed and you have no live agent ids, you are stalled. Resume from `STATE.md` (Execution Workflow) or tell the user it stalled — do not keep claiming work is in progress.
## Entry Point
@@ -67,7 +83,7 @@ On every invocation:
Store the flavor in session. **dbt flavor** runs the standard path below unchanged. **Scripting flavor** replaces the data-flow half of stabilization with the mapping-procedure pair (proc-test-gen → proc-fixer) while keeping the orchestration half identical — see [Scripting Flavor Routing](#scripting-flavor-routing). In the guided flow, whether a scripting unit reaches this skill is controlled upstream by the migration state machine; when it does — or on direct invocation — proceed with the scripting path.
1. Check if `{UNIT}/stabilization/tracking/STATE.md` exists
2. **If no STATE.md** → new unit → run **Planning Workflow**
-3. **If STATE.md exists** → read it → run **Execution Workflow** for next pending phase
+3. **If STATE.md exists** → read it → run **Execution Workflow** for next pending phase. If status is "Ready to execute" (or next-action is Execute Phase N) and Phase N has no cortex task / `start-phase` has not run, that is the post-ROADMAP stall: **start Execution Step 2 in this turn**. Do not wait for a subagent.
---
@@ -117,7 +133,7 @@ cortex ctx step add -t \
"Step 3: Initialize tracking" \
"Step 4: Configure test environment" \
"Step 5: Backup and strip dead code" \
- "Step 6: Context mapping — spawn parallel agents, wait for notifications" \
+ "Step 6: Context mapping — spawn parallel blocking agents, process results this turn" \
"Step 6b: Classify dbt project readiness" \
"Step 7: Create ROADMAP (7a-7c)" \
"Step 8: Create STATE.md" \
@@ -227,7 +243,7 @@ Output: `dbt-context.md` — project health, model inventory, macro inventory, s
mapping and its defective blocks (block-locate / ewi-extract). The orchestration context mapper still runs for
the workflow task graph.
-**Spawn both agents in a single message** (parallel tool calls), then follow the **Agent Wait Protocol**: end your turn and wait for task notifications. When both notifications arrive, verify both output files exist and are non-empty before continuing.
+**Spawn both agents in a single message** (parallel tool calls) with `run_in_background=false`. Process both tool results in this turn. Verify both output files exist and are non-empty before continuing. Do not end the turn to wait — blocking spawns are not background jobs.
Use `team_delete` tool after verifying outputs.
@@ -241,6 +257,7 @@ If `scan.json` contains dbt_projects:
- **Ready**: `has_valid_config=true`, zero or low EWI count → standard dbt phase
- **Needs bootstrap**: `has_valid_config=false` or `has_placeholder_config=true` → dbt phase with bootstrap sub-phase (config + macro fixes before model testing)
- **Heavy EWI**: high EWI count relative to model count → dbt phase with expected baseline failures, longer fix cycle
+ - **Reused (pre-existing) project**: if the dbt project directory predates this unit — more than one sibling unit's orchestration file references it, or `dbt-context.md` shows it wasn't newly generated for this unit — cross-check every var default the models actually read (`sources.yml`, `dbt_project.yml` vars) against *this* unit's own source-XML identity (folder/repository/workflow name), even when `has_valid_config=true`. A previously-stabilized shared project is not a smoke-check target; wrong-but-valid-looking defaults are a silent data-correctness defect, not a compile error.
These classifications inform the ROADMAP phase design in Step 7. Do NOT mark projects as `needs-user` at planning time — that decision is made by the dbt-test-gen agent after attempting test generation.
@@ -258,6 +275,7 @@ Using orchestration-context.md, dbt-context.md (if dbt projects exist), and scan
- **Large item**: a dbt project with >20 models. **Only dbt projects can be classified as large** — orchestration elements are always small-medium
- **Phase cap**: pack up to **40-50 small-medium items per phase** OR up to **20 large items per phase**
- **Do NOT mix classes in the same phase** — route small-medium and large items into separate phases so sizing stays predictable
+ *(This mixing rule governs phases approaching the item cap — for a phase with only a handful of items, keep them together even if one crosses a size threshold; splitting a 3-item phase into two is needless fragmentation.)*
- **Batch cap**: ~10 small-medium items per batch, ~5 large items per batch
- **Concurrency cap**: max 5 parallel batches per phase regardless of item size
- Push each phase toward its cap rather than creating many small phases — a 48-project small-medium phase is preferable to two 24-project phases when the items share patterns
@@ -331,7 +349,13 @@ Display the ROADMAP to the user. Highlight:
### Step 10: Begin Execution
-After user approval, proceed to execute Phase 1 using the Execution Workflow below.
+After user approval, **in the same turn** (before any wait, and without spawning an orchestrator subagent):
+
+1. Run Execution Workflow **Step 2** (create/verify the Phase 1 cortex task).
+2. Run `track_status.py start-phase` as Step {1}.1 requires.
+3. Continue Execution Step 3 for Phase 1.
+
+`STATE.md` saying "Ready to execute" is not evidence that work is running. If you stop after Step 8/9 without Step 2, the session is stalled at 0%.
---
@@ -431,6 +455,7 @@ always has a pending task.
- **Catastrophic file corruption**: Restore from `checkpoints/phase_{N}/` or `original/`.
- **Snowflake connection failure**: Verify connection. Re-run `track_status.py set-test-env` if credentials changed.
- **ROADMAP amendment needed**: Log via `track_status.py add-decision`, amend future phases only, document reasoning.
+- **Stalled after ROADMAP / fake subagent progress**: `STATE.md` still "Ready to execute", no new artifacts, agent claiming a subagent is running. Nothing is running. Resume at Execution Step 2 in this turn. Tell the user it stalled if you cannot start.
---
@@ -472,11 +497,13 @@ See [reference/examples.md](reference/examples.md).
See [reference/troubleshooting.md](reference/troubleshooting.md).
+`scan_unit.py` fails, or the unit folder doesn't match Prerequisites → check whether the input is a flat, multi-unit conversion output rather than an isolated per-unit folder; see "When the converted-output folder is not isolated" above.
+
## Output
- Fixed orchestration `.sql` file with EWI gaps resolved
- Fixed dbt model files (per sub-project)
- Test artifacts in `{UNIT}/stabilization/tests/`
- `{UNIT}/stabilization/report.html` — self-contained HTML report aggregating all artifacts (generated during Final Validation)
-- `artifacts/tracking/fix_log.md` — append-only record of every fix applied
+- `artifacts/tracking/fix_log.md` — append-only record of every fix applied. Each entry carries per-instance anchors (file + stable symbol/tag anchor into the fixed tree and the `stabilization/original/` backup) plus a `Classification` — `engine-defect | conversion-improvement | intentional-decline | context-dependent`. **An `!!!RESOLVE EWI!!!` breaking wrapper (or any correctly emitted supported EWI/FDM) is `intentional-decline`, resolved manually — never log it as `engine-defect`.** See [reference/templates/fix-log-format.md](reference/templates/fix-log-format.md) and [reference/templates/batch-artifacts.md](reference/templates/batch-artifacts.md).
- `artifacts/phases/phase_{N}/` — per-phase baselines, batch reports, learnings
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-fixer/SKILL.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-fixer/SKILL.md
index 286caaa..99a1647 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-fixer/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-fixer/SKILL.md
@@ -17,7 +17,7 @@ Fix dbt projects generated by SnowConvert. Scope includes: model SQL files (stag
### Input
-> **Concurrent execution note:** You may be spawned concurrently with other agents fixing different dbt projects in the same ETL unit. Each agent operates on its own project — do not access or modify files belonging to other dbt projects.
+> **Concurrent execution note:** You may be spawned concurrently with other agents fixing different dbt projects in the same ETL unit. Each agent operates on its own project — do not access or modify files belonging to other dbt projects. Writing test/fix artifacts for another agent's project (even to be helpful) breaks that project's own validator lookup and causes a false missing-artifact failure at phase completion.
This sub-skill expects:
- `session_status.json` — from track_status.py init (contains `dbt_projects` array with project names and paths, and `source_file_path`)
@@ -271,6 +271,13 @@ Follow the Main Mode workflow in full. Apply these behavioral overrides:
- Process only the dbt project(s) assigned to the current phase in ROADMAP.md
- Do NOT write directly to `tracking/fix-log.md` — use per-project learning file instead. The orchestrator merges these after all dbt agents complete.
+Before summarizing, re-read the final state of every file you edited (`dbt_project.yml`, `sources.yml`,
+`profiles.yml`) — confirm placeholder keys were actually renamed (not left alongside a new one), and
+that `profile:` matches this repository's established per-project naming convention (`{project_name}`,
+matching sibling projects). Do not summarize from memory of what you intended to change — the hard
+`models:`/`name:` mismatch check now runs automatically at phase completion, but naming-convention
+drift does not.
+
### Context Management
If you feel context pressure after completing a node, **stop and report partial completion** — list which nodes were fixed and which were not. Prefer stopping early over running to exhaustion.
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-test-gen/SKILL.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-test-gen/SKILL.md
index 20d191f..66dcc12 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-test-gen/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/dbt-test-gen/SKILL.md
@@ -20,7 +20,7 @@ Tests follow the **Arrange-Act-Assert (AAA)** pattern: Arrange (dbt seeds), Act
## Scope Filtering
-> **Concurrent execution note:** You may be spawned concurrently with other agents targeting different dbt projects in the same package. Each agent operates on its own project — do not access or modify files belonging to other dbt projects.
+> **Concurrent execution note:** You may be spawned concurrently with other agents targeting different dbt projects in the same package. Each agent operates on its own project — do not access or modify files belonging to other dbt projects. Writing test/fix artifacts for another agent's project (even to be helpful) breaks that project's own validator lookup and causes a false missing-artifact failure at phase completion.
Read ROADMAP.md for current phase (authored by stabilization with package-specific reasoning). Process only the dbt project(s) assigned to this phase.
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-fixer/SKILL.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-fixer/SKILL.md
index b317864..fb96811 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-fixer/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-fixer/SKILL.md
@@ -118,6 +118,42 @@ After user confirms, mark all `EXECUTE DBT PROJECT` elements as `skipped` with r
```
Update status `skipped --reason disabled-in-source`. Continue to next element.
+ **Check downstream dependents before moving on.** Search the orchestration SQL for **every**
+ `AFTER` clause that lists this element's Snowflake task name ``. Snowflake never executes a
+ task whose required predecessor is permanently suspended, so each of those tasks (and everything
+ `AFTER` them) would otherwise silently stop running once deployed, with no error anywhere.
+ Fan-out is the common case: several downstream tasks can reference the same disabled ``.
+ Snowflake `AFTER` can list multiple predecessors (`AFTER T1, T2`); when rewriting a clause,
+ replace only `` and preserve every other name already in that list.
+
+ **Detect a real data dependency (required recipe — do not invent a check):**
+ 1. From the source definition of disabled ``, collect its write targets. Informatica: the
+ mapping's `TARGETINSTANCE` / `TARGETLOADORDER` names and any `CONNECTOR TOINSTANCE` that
+ lands on a target (see `platforms/informatica/mapping-guide.md`). Other platforms: the
+ equivalent target/output list in the source definition.
+ 2. A downstream task **depends** on `` iff its converted body references any of those
+ concrete target names (table, view, or stream identifiers).
+ 3. Known limitation: names built via variables / dynamic CTAS, plus secondary effects such as
+ control-table row counts or session variables `` would have set, are **not** covered by
+ step 2. If those are the only signals, treat as a data dependency (`needs-user`) rather
+ than rewiring.
+
+ - **If the downstream task does not depend on ``'s output** — for **every** downstream
+ task whose `AFTER` list contains ``, replace `` in that list with the set of ``'s
+ enabled predecessors (walk further back through any chain of disabled predecessors). If
+ that walk finds no enabled ancestor, mark the downstream element `needs-user` rather than
+ dropping the `AFTER` clause (dropping it would turn a scheduled dependent into a root task
+ the customer never authored). Add a one-line comment explaining the rewire and, if
+ applicable, cite the source `$.PrevTaskStatus` workflow variable's own description
+ (it documents PowerCenter's built-in skip-and-continue semantics for disabled predecessors
+ — check the source XML for a `WORKFLOWVARIABLE` with `DESCRIPTION` containing "not
+ disabled"). This is a required fix, not optional.
+ - **If the downstream task does depend on ``'s output** — do not rewire silently.
+ Mark the downstream element `needs-user`, with a reason describing the data gap (the disabled
+ step's output is missing and the downstream logic needs it). This is a real functional question
+ for the customer, not something to resolve unilaterally.
+
+
2. **`EXECUTE DBT PROJECT`** — mark `skipped --reason dbt-dependency`. Continue.
3. **Identify issues** — look for `!!!RESOLVE EWI!!!` markers and `--** SSC-*` comments
@@ -402,6 +438,13 @@ After completing each element, if you feel context pressure (large accumulated o
**Prefer stopping early with artifacts on disk over running to exhaustion.** When stopping early, send a completion message listing: elements completed (with statuses), elements NOT processed, reason: `"partial-completion: context pressure after N elements"`.
+Before summarizing, re-read the final state of every file you edited (`dbt_project.yml`, `sources.yml`,
+`profiles.yml`) — confirm placeholder keys were actually renamed (not left alongside a new one), and
+that `profile:` matches this repository's established per-project naming convention (`{project_name}`,
+matching sibling projects). Do not summarize from memory of what you intended to change — the hard
+`models:`/`name:` mismatch check now runs automatically at phase completion, but naming-convention
+drift does not.
+
### Team Protocol
When your work is complete, your agent will automatically return results to the orchestrator.
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-test-gen/SKILL.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-test-gen/SKILL.md
index ea79e39..88fd778 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-test-gen/SKILL.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/orchestration-test-gen/SKILL.md
@@ -39,8 +39,8 @@ This sub-skill expects:
```
/stabilization/tests/orchestration/
- /
- .sql # Isolated test (one per element)
+ / # Snowflake schema tests create objects in (typically public)
+ .sql # Isolated test (segment after last '.' in session_status name)
grouped_.sql # Grouped test (shared ARRANGE for group)
...
test_report.md # Report consumed by orchestration-fixer
@@ -136,8 +136,12 @@ Read `ROADMAP.md` to identify the current phase number, then scope to the assign
Read `session_status.json` and filter elements where `phase == `. **Only generate tests for these elements.** Elements from other phases are ignored entirely in this invocation.
Skip test generation for elements that already have test files from a prior phase. Check for existing files at:
-- Isolated: `/stabilization/tests/orchestration//.sql`
-- Grouped: `/stabilization/tests/orchestration//grouped_.sql`
+- Isolated: `/stabilization/tests/orchestration//.sql`
+ (`` = the Snowflake schema the test objects are created in, typically `public`;
+ `` = the segment after the last `.` in the element's dotted
+ `session_status.json` name — e.g. `s_m_last_run_date`, not the fully-qualified
+ `f_Warehouse_presentation.wf_bs_facts_fl_to_pl.s_m_last_run_date`)
+- Grouped: `/stabilization/tests/orchestration//grouped_.sql`
If a test file already exists, mark the element as `already-tested` and skip it.
@@ -360,7 +364,7 @@ Categorize each failure:
- **Baseline Results**: pass/fail per element, test strategy (isolated/grouped), source definition per failure, ACT vs ASSERT failure classification
- **Coverage Gaps**: elements with external deps, opaque ScriptTasks, File Enumerator loops, ARRANGE:SETUP failures
-5. **MANDATORY self-check before returning** — verify that `{UNIT}/stabilization/tests/orchestration//` contains at least 1 `.sql` test file and `test_report.md` exists. If ANY file is missing, generate it before returning. If you cannot write files, include full contents in your completion message so the orchestrator can write them.
+5. **MANDATORY self-check before returning** — verify that `{UNIT}/stabilization/tests/orchestration//` contains at least 1 `.sql` test file and `test_report.md` exists. If ANY file is missing, generate it before returning. If you cannot write files, include full contents in your completion message so the orchestrator can write them.
6. Return to parent skill (stabilization/SKILL.md) for orchestration fixing.
@@ -420,7 +424,7 @@ Do NOT include any of these in ARRANGE:SETUP. **SETUP = element-specific DDL onl
### Output
-- Test files: `PACKAGE/stabilization/tests/orchestration//`
+- Test files: `PACKAGE/stabilization/tests/orchestration//`
- Baseline report: `PHASES_DIR/baseline_batch_{B}.md`
Write artifacts **incrementally** — one element section appended at a time. The orchestrator handles partial artifacts: completed elements are already on disk and will not be re-processed on retry.
@@ -458,7 +462,7 @@ Process one element at a time in the order provided:
- Use `TASK_SCHEMA` as the test schema (not DATABASE.PUBLIC or any other schema)
- Use **batched assertion format** (UNION ALL) for all assertions
- For **clone elements**: adapt the archetype element's test file with appropriate substitutions (schema, table names, parameters) rather than generating from scratch
- - Write test files to: `PACKAGE/stabilization/tests/orchestration//`
+ - Write test files to: `PACKAGE/stabilization/tests/orchestration//`
- Follow the Main Mode workflow for ARRANGE:SETUP, ARRANGE:SEED, ACT, and ASSERT generation — applying these overrides:
- Do NOT call `set-test-env` — use `TASK_SCHEMA` directly
- ARRANGE:SETUP contains ONLY element-specific DDL (infrastructure is pre-created)
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/agent-prompts/dbt-context-mapper.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/agent-prompts/dbt-context-mapper.md
index d1243d4..1916255 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/agent-prompts/dbt-context-mapper.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/agent-prompts/dbt-context-mapper.md
@@ -30,6 +30,7 @@ For each project:
1. Read `dbt_project.yml` — extract: project name, profile name, vars, model-paths, seed-paths
2. Flag placeholder values (YOUR_PROJECT_NAME, YOUR_PROFILE_NAME) as bootstrap blockers
3. Check if `vars:` section defines variables used by models
+4. If this project predates the current unit — more than one sibling unit's orchestration file references it, or it wasn't newly generated for this unit — cross-check every var default the models actually read (`sources.yml`, `dbt_project.yml` vars) against *this* unit's own source-XML identity (folder/repository/workflow name), even when `has_valid_config=true`. A previously-stabilized shared project is not a smoke-check target; wrong-but-valid-looking defaults are a silent data-correctness defect, not a compile error. Surface this as a bootstrap blocker with Fix Owner `needs-user` or `dbt-fixer (bootstrap)` as appropriate.
### 3. Source Definitions
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/examples.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/examples.md
index 621b71a..935c311 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/examples.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/examples.md
@@ -7,11 +7,11 @@ User says: "Fix this ETL package at /data/packages/SalesLoad"
Actions:
1. No STATE.md found → run Planning Workflow
2. Scan package, initialize tracking, gather test env (user provides DATABASE with CREATE SCHEMA privileges)
-3. Use the `task` tool to spawn `name="context-mapper"` with `run_in_background=false` (no team during planning) → wait for task notification → verify `orchestration-context.md` and `dbt-context.md`
+3. Use the `task` tool to spawn `name="context-mapper"` with `run_in_background=false` (no team during planning) → process the tool result in this turn → verify `orchestration-context.md` and `dbt-context.md`
4. Author ROADMAP.md from template (Write tool) with 3 phases (2 orchestration + 1 final validation), each with task definitions (task table, element-to-task assignments, schema names)
5. Register phases in `artifacts/tracking/session_status.json` via `init-roadmap --phases-json`, assign elements via `assign-phases`
6. Present ROADMAP for approval → user approves
-7. Execute Phase 1:
+7. **Same turn:** Execution Step 2 (cortex task) + `start-phase` + Phase 1 Setup. Do not spawn an orchestrator subagent and wait.
- Create schemas: `ETL_FIX_P1_B1`, `ETL_FIX_P1_B2`, `ETL_FIX_P1_B3` (batches B1.1, B1.2, B1.3)
- Use `team_create` tool: team_name="etl-fix-SalesLoad-p1"
- Use `task` tool to spawn 3 test-gen agents in single message: `orchestration-test-gen-B1.1`, `orchestration-test-gen-B1.2`, `orchestration-test-gen-B1.3`
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/protocols/phase-execution.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/protocols/phase-execution.md
index eeffdfe..d4319c3 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/protocols/phase-execution.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/protocols/phase-execution.md
@@ -34,6 +34,7 @@ Auto-compact may have occurred since the last action. Re-read canonical state be
> **Batch ID format:** `B{P}.{M}` (e.g., `B1.1` = Phase 1 Batch 1). The `{B}` placeholder throughout this document represents the full phase-qualified batch ID.
**Resume logic:**
+- If `STATE.md` says "Ready to execute" and this phase has no `start-phase` / no artifacts yet → you are stalled after ROADMAP approval. Run SKILL.md Execution Step 2 in this turn. Do not wait for a subagent.
- If `baseline_batch_*.md` files already exist for all tasks → skip step b (test-gen already ran), proceed to step c
- If `batch_*.md` files already exist for all tasks → skip steps b-e (fixes already ran), proceed to step f
- If `apply_report.md` exists → skip steps b-f, proceed to step g
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/ROADMAP_TEMPLATE.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/ROADMAP_TEMPLATE.md
index 2fba6ab..2ee1ab0 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/ROADMAP_TEMPLATE.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/ROADMAP_TEMPLATE.md
@@ -139,7 +139,7 @@ Spawn agents (MANDATORY — do NOT skip, do NOT do the work yourself):
Instruction: Read {SKILL_DIR}/orchestration-test-gen/SKILL.md
{end for}
Max 5 agents per wave. If more, spawn first 5, wait, then remaining.
-Wait for all agents: end your turn and wait for automatic task notifications.
+Wait per SKILL.md Agent Wait Protocol. End the turn only if spawn tool results returned live agent ids (`run_in_background=true`). If this turn spawned nothing, do not wait.
Each notification confirms one agent completed — process its result immediately.
NEVER use `bash sleep`, `bash_output`, or `cortex agent output` CLI.
Validate: baseline_batch_{B}.md MUST exist for EVERY batch.
@@ -148,6 +148,7 @@ Validate: baseline_batch_{B}.md MUST exist for EVERY batch.
After 2 retries: mark remaining elements `failed` reason `context-exhaustion`.
For partial-completion recovery (agent processed some elements then died):
see reference/protocols/phase-execution.md § Partial-Artifact Recovery.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL — one call per invocation, never batched):
For each element in baseline summary:
uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/track_status.py \
@@ -163,10 +164,11 @@ Spawn fix agents ONLY for batches where baseline has >=1 failing element:
{end for}
For batches where ALL elements passed or skipped: do NOT spawn agent.
Write minimal batch_{B}.md and empty learnings_batch_{B}.md yourself.
-Wait for all agents via task notifications. Validate: batch_{B}.md AND learnings_batch_{B}.md MUST exist for every batch.
+Wait per SKILL.md Agent Wait Protocol. Validate: batch_{B}.md AND learnings_batch_{B}.md MUST exist for every batch.
Re-read session_status.json — confirm all fixed elements have terminal status.
Same retry logic as Step {P}.2 (max 2, then mark failed).
For partial-completion recovery: see reference/protocols/phase-execution.md § Partial-Artifact Recovery.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
For each element in task artifacts:
uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/track_status.py \
@@ -242,6 +244,7 @@ NO test-gen agents — patterns are already proven from the archetype phase.
Wait + validate: batch_{B}.md AND learnings_batch_{B}.md for every batch.
Retry logic: max 2 retries per batch, then mark failed.
For partial-completion recovery: see reference/protocols/phase-execution.md § Partial-Artifact Recovery.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
For each element in task artifacts:
uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/track_status.py \
@@ -305,6 +308,11 @@ Register dbt nodes (SEQUENTIAL — one call per project):
uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/track_status.py \
init-dbt {SESSION_JSON} {PROJECT_NAME} {DBT_PROJECT_PATH}
{end for}
+Assign dbt phase (SEQUENTIAL — one call per project):
+{for each dbt_project:}
+ uv run --project {SKILL_DIR} python {SKILL_DIR}/scripts/track_status.py \
+ assign-dbt-phase {SESSION_JSON} --phase {P} --project {PROJECT_NAME}
+{end for}
- [ ] **Step {P}.2: dbt-Test-Gen Wave**
Resume guard: if test_report.md already exists for ALL projects
@@ -320,7 +328,7 @@ Spawn even if: placeholder config, broken macros, missing sources, heavy EWI.
Test generation IS the assessment — agent documents blockers in test_report.md.
Early exit WITHOUT test artifacts is a protocol violation.
Do NOT do this work yourself. Do NOT edit dbt files directly.
-Wait for all agents: end your turn and wait for automatic task notifications.
+Wait per SKILL.md Agent Wait Protocol. End the turn only if spawn tool results returned live agent ids (`run_in_background=true`). If this turn spawned nothing, do not wait.
NEVER use `bash sleep`, `bash_output`, or `cortex agent output` CLI.
Validate for EACH project — ALL must exist:
- stabilization/tests/dbt/{PROJECT}/seeds/ — at least 1 .csv
@@ -328,6 +336,7 @@ Validate for EACH project — ALL must exist:
- stabilization/tests/dbt/{PROJECT}/test_report.md
If ANY missing: respawn (max 2 retries), then mark nodes `failed` reason `test-gen-exhaustion`.
For partial-completion recovery: see reference/protocols/phase-execution.md § Partial-Artifact Recovery.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
uv run ... track_status.py update-dbt {SESSION_JSON} {PROJECT} --status dbt-tested
uv run ... track_status.py update-dbt-node {SESSION_JSON} {PROJECT} {NODE} --status {result}
@@ -344,6 +353,7 @@ Spawn fix agents for projects with: failing tests, compilation errors, or bootst
Projects where ALL nodes passed and no compilation errors: no agent needed.
Write minimal dbt_learnings_{project}.md with no-fix-needed.
Wait + validate: dbt_learnings_{project}.md MUST exist for every project.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
uv run ... track_status.py update-dbt-node per node
uv run ... track_status.py update-dbt per project
@@ -400,12 +410,13 @@ Spawn proc-test-gen agents (MANDATORY — UNCONDITIONAL — do NOT skip):
test_schema={SCHEMA}, block FullName(s), ROADMAP_path, SESSION_JSON
{end for}
Max 5 agents per wave. If more, spawn first 5, wait, then remaining.
-Wait for all agents: end your turn and wait for automatic task notifications.
+Wait per SKILL.md Agent Wait Protocol. End the turn only if spawn tool results returned live agent ids (`run_in_background=true`). If this turn spawned nothing, do not wait.
NEVER use `bash sleep`, `bash_output`, or `cortex agent output` CLI.
Validate for EACH mapping proc — ALL must exist under stabilization/tests/proc/{proc_name}/:
- {proc_name}.seed.sql, {proc_name}.assert.sql, test_report.md
If ANY missing: respawn (max 2 retries), then mark the proc `failed` reason `test-gen-exhaustion`.
For partial-completion recovery: see reference/protocols/phase-execution.md § Partial-Artifact Recovery.
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
uv run ... track_status.py update {SESSION_JSON} {PROC} --status proc-tested
@@ -421,9 +432,10 @@ Spawn fix agents ONLY for procs whose baseline did not create-clean or has faili
Procs whose baseline already creates clean and passes all assertions: no agent needed.
Write a minimal fix record with Outcome no-fix-needed, then set the proc's status:
uv run ... track_status.py update {SESSION_JSON} {PROC} --status no-fix-needed
-Wait for all agents via task notifications. Validate: a fix record exists for every proc.
+Wait per SKILL.md Agent Wait Protocol. Validate: a fix record exists for every proc.
Re-read session_status.json — confirm all fixed procs have terminal status.
Same retry logic as Step {P}.2 (max 2, then mark failed).
+> **You (the orchestrator) run this — never inside a spawned agent's prompt.** See dbt-fixer/SKILL.md "Do NOT call track_status.py directly."
Update tracking (SEQUENTIAL):
uv run ... track_status.py update {SESSION_JSON} {PROC} --status {status_from_artifact}
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/batch-artifacts.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/batch-artifacts.md
index 21b6650..01e3c6b 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/batch-artifacts.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/batch-artifacts.md
@@ -46,13 +46,22 @@ Each batch agent writes a per-batch learning file at `{UNIT}/stabilization/phase
## {element_name}: {one-line summary of fix}
- **EWI/FDM**: {code} — {title}
+- **Marker status**: NO_MARKER | MARKER_BROKEN | MARKER_PLUS_BUG | EWI_MARKED | FDM_MARKED | EWI_CRASH
+- **Classification**: engine-defect | conversion-improvement | intentional-decline | context-dependent
+ (Intentional `!!!RESOLVE EWI!!!` declines use marker `EWI_MARKED` + classification `intentional-decline` — do not invent a separate marker code.)
- **Root cause**: {what the converter did wrong or couldn't handle}
- **Fix pattern**: {the SQL transformation applied}
-- **Before** (abbreviated):
+- **Fix instances**: (one row per file the fix touched — anchors let a consumer pull arbitrary context on demand)
+
+| File (repo-relative) | Anchor (fixed) | Original ref | Source XML |
+|---|---|---|---|
+| {models/.../foo.sql} | {block tag / macro / model + line hint} | {stabilization/original/.../foo.sql + line hint, or `new-file`} | {source.XML :: TRANSFORMATION NAME} |
+
+- **Before** (short inline excerpt for humans — full context recoverable via `Original ref`):
\```sql
{original snippet}
\```
-- **After** (abbreviated):
+- **After** (short inline excerpt — full context recoverable via `Anchor (fixed)`):
\```sql
{fixed snippet}
\```
@@ -64,5 +73,7 @@ Each batch agent writes a per-batch learning file at `{UNIT}/stabilization/phase
**Rules:**
- Only include entries for elements where a fix was actually applied (`test-passed` or `auto-fixed-needs-review`)
- `Reusable: yes` means this pattern can be applied to similar EWI/FDM codes in future phases
-- Keep snippets abbreviated (relevant lines only, not full procedure bodies)
+- **`Fix instances` is required**: list every file the fix touched with a stable anchor. Prefer symbol/tag anchors (`---- Start block ''`, macro name, model name) as primary and line numbers only as a hint, since line numbers drift across later phases. `Original ref` points into the `stabilization/original/` backup (or `new-file` when the fix created the artifact). This is what lets downstream consumers (e.g. bug reports) expand context instead of re-deriving it by diffing trees.
+- **`Classification` is required and must be honest about intent.** Use `intentional-decline` when the converter *correctly* emitted a declined-conversion signal (the `!!!RESOLVE EWI!!!` breaking wrapper, or a supported EWI/FDM) and the fix was manual resolution — this is **not** an engine defect. Use `engine-defect` only for wrong output with no/broken signal. Use `conversion-improvement` when a better deterministic conversion is possible. Never log an intentional `!!!RESOLVE EWI!!!` decline as `engine-defect`.
+- Keep the inline Before/After excerpts short (relevant lines only) — full surrounding context is reachable through the anchors, so the log stays lean and doesn't drift.
- If no fixes were applied (all elements skipped or passed without changes), write an empty learnings file with a note: `No fixes applied in this batch.`
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/fix-log-format.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/fix-log-format.md
index 492ea79..169f37f 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/fix-log-format.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/templates/fix-log-format.md
@@ -8,9 +8,9 @@ The orchestrator merges per-batch `learnings_batch_{B}.md` files into this file
### Index (rebuilt after each phase merge)
-| EWI Code | Pattern Count | Phases |
-|----------|--------------|--------|
-| {code} | {N} | P1, P3 |
+| EWI Code | Pattern Count | Instances | Classification | Phases |
+|----------|--------------|-----------|----------------|--------|
+| {code} | {N} | {M fix instances} | engine-defect / conversion-improvement / intentional-decline / context-dependent | P1, P3 |
### Patterns (one section per unique fix pattern)
@@ -18,11 +18,18 @@ The orchestrator merges per-batch `learnings_batch_{B}.md` files into this file
- **Applicability:** {which element types/contexts this applies to}
- **Preconditions:** {what must be true — e.g., "element has control variables", "pure SQL element"}
-- **Before:**
+- **Classification:** engine-defect | conversion-improvement | intentional-decline | context-dependent — {one-line why; an `intentional-decline` (`!!!RESOLVE EWI!!!` / supported EWI/FDM) is NOT an engine bug}
+- **Fix instances:** (carried from batch learnings — one row per file touched; anchors let a consumer expand context on demand)
+
+| Element | File (repo-relative) | Anchor (fixed) | Original ref | Marker status | Source XML |
+|---|---|---|---|---|---|
+| {element} | {models/.../foo.sql} | {block tag / macro / model + line hint} | {stabilization/original/.../foo.sql + line hint, or `new-file`} | {NO_MARKER / MARKER_BROKEN / MARKER_PLUS_BUG / EWI_MARKED / FDM_MARKED / EWI_CRASH} | {source.XML :: TRANSFORMATION NAME} |
+
+- **Before** (representative, short — full context via `Original ref`):
```sql
{original code snippet}
```
-- **After:**
+- **After** (representative, short — full context via `Anchor (fixed)`):
```sql
{fixed code snippet}
```
@@ -34,6 +41,7 @@ The orchestrator merges per-batch `learnings_batch_{B}.md` files into this file
When merging `learnings_batch_*.md` into fix_log.md:
1. Read all learnings files from the phase
2. For each learning entry: check if a pattern with the same EWI code already exists
-3. If new pattern: add new section
-4. If existing pattern with new variant: add as sub-pattern
-5. Rebuild the Index table at the top
+3. If new pattern: add new section (copy its `Classification` and all `Fix instances` rows verbatim)
+4. If existing pattern with new variant: add as sub-pattern, and **append** its `Fix instances` rows to the pattern's instance table (never collapse instances away — per-instance anchors are the whole point)
+5. Rebuild the Index table at the top, updating `Instances` (total fix-instance rows) and `Classification` for each code
+6. If two instances of the same code disagree on `Classification`, keep them as separate rows and flag the pattern `Classification` as `mixed — see instances` rather than silently picking one
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/tools.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/tools.md
index 4328bd3..c85378f 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/tools.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/tools.md
@@ -134,7 +134,7 @@ These are built-in tools invoked through the tool-use interface, the same way yo
| `snowflake_sql_execute` | Run SQL against Snowflake | SQL string, connection |
| `ask_user_question` | Ask the user a question | question text, options |
-**`agent_output`** — documented in CoCo guides but not reliably available. Do not attempt to call it — failed attempts waste a turn. Use **automatic task notifications** as the only mechanism for receiving background agent results (see Agent Wait Protocol in SKILL.md).
+**`agent_output`** — documented in CoCo guides but not reliably available. Do not attempt to call it — failed attempts waste a turn. Use the **Agent Wait Protocol** in SKILL.md. Never tell the user a subagent is running unless this turn's spawn tool results include live agent ids.
#### How to reference tools in this skill
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/troubleshooting.md b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/troubleshooting.md
index 1c8d9f5..6bbf6bd 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/troubleshooting.md
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/reference/troubleshooting.md
@@ -5,6 +5,7 @@
- Verify source definition file path is correct and readable
- Verify `uv` is installed: `which uv`
- If using `--platform`, verify the platform ID matches a directory under `platforms/`
+- Check whether the input is a flat, multi-unit conversion output rather than an isolated per-unit folder; see SKILL.md "When the converted-output folder is not isolated"
### track_status.py errors
- Verify `scan.json` exists in `{UNIT}/stabilization/planning/`
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/check_sync_leaks.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/check_sync_leaks.py
new file mode 100644
index 0000000..70367c0
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/check_sync_leaks.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+# Copyright 2026 Snowflake Inc.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Detect test-environment / credential leaks before syncing fixes back.
+
+Usage:
+ python check_sync_leaks.py [ ...]
+
+Exit 1 if any leak is found; exit 0 with a one-line summary otherwise.
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+ETL_FIX_RE = re.compile(r"ETL_FIX_P\d+_\w+")
+CREDENTIAL_KEY_RE = re.compile(
+ r"^\s*(password|account|authenticator|private_key_path)\s*:\s*(.*?)\s*$"
+)
+PLACEHOLDER_VALUE_RE = re.compile(r"^(YOUR_|your_)")
+ENV_VAR_RE = re.compile(r"\{\{\s*env_var\s*\(")
+
+
+def load_json(path: Path) -> dict:
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def _is_placeholder_credential(value: str) -> bool:
+ stripped = value.strip().strip("\"'")
+ if not stripped:
+ return True
+ if PLACEHOLDER_VALUE_RE.match(stripped):
+ return True
+ if ENV_VAR_RE.search(stripped):
+ return True
+ return False
+
+
+def check_file(path: Path, database: str, schema: str) -> list[tuple[int, str]]:
+ hits: list[tuple[int, str]] = []
+ text = path.read_text(encoding="utf-8", errors="replace")
+ is_profiles = path.name == "profiles.yml"
+ for i, line in enumerate(text.splitlines(), 1):
+ if database and database in line:
+ hits.append((i, database))
+ if schema and schema in line:
+ hits.append((i, schema))
+ etl_match = ETL_FIX_RE.search(line)
+ if etl_match:
+ hits.append((i, etl_match.group(0)))
+ if is_profiles:
+ cred = CREDENTIAL_KEY_RE.match(line)
+ if cred and not _is_placeholder_credential(cred.group(2)):
+ hits.append((i, cred.group(1) + ":"))
+ return hits
+
+
+def main() -> None:
+ if len(sys.argv) < 3:
+ print(
+ "Usage: python check_sync_leaks.py [ ...]",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ status_path = Path(sys.argv[1])
+ if not status_path.is_file():
+ print(f"Error: '{status_path}' not found", file=sys.stderr)
+ sys.exit(1)
+
+ session = load_json(status_path)
+ test_env = session.get("test_environment") or {}
+ database = test_env.get("database") or ""
+ schema = test_env.get("schema") or ""
+
+ files = [Path(p) for p in sys.argv[2:]]
+ any_hits = False
+ for path in files:
+ if not path.is_file():
+ print(f"Error: '{path}' not found", file=sys.stderr)
+ sys.exit(1)
+ for line_no, pattern in check_file(path, database, schema):
+ print(f"{path}:{line_no}: matched '{pattern}'")
+ any_hits = True
+
+ if any_hits:
+ sys.exit(1)
+
+ print(f"no leaks found in {len(files)} files")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/path_resolver.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/path_resolver.py
index e4d755e..7088e02 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/path_resolver.py
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/path_resolver.py
@@ -38,10 +38,35 @@ def phases_dir(code_unit_dir: str | Path) -> Path:
def phase_dir(code_unit_dir: str | Path, phase_num: int) -> Path:
- """Return a specific phase directory."""
+ """Return the canonical phase directory (hyphenated `phase-{N}`)."""
return phases_dir(code_unit_dir) / f"phase-{phase_num}"
+def phase_dir_candidates(code_unit_dir: str | Path, phase_num: int) -> tuple[Path, ...]:
+ """Hyphen (`phase-N`) is canonical; underscore (`phase_N`) is still read."""
+ root = phases_dir(code_unit_dir)
+ candidates = (root / f"phase-{phase_num}", root / f"phase_{phase_num}")
+ unique: list[Path] = []
+ seen: set[Path] = set()
+ for path in candidates:
+ key = path.resolve() if path.exists() else path
+ if key in seen:
+ continue
+ seen.add(key)
+ unique.append(path)
+ return tuple(unique)
+
+
+def resolve_phase_dir(code_unit_dir: str | Path, phase_num: int) -> Path:
+ """Existing phase dir, preferring a populated `phase-N` then `phase_N`."""
+ candidates = phase_dir_candidates(code_unit_dir, phase_num)
+ existing = [path for path in candidates if path.is_dir()]
+ if not existing:
+ return candidates[0]
+ populated = [path for path in existing if any(path.iterdir())]
+ return populated[0] if populated else existing[0]
+
+
def original_backup_dir(code_unit_dir: str | Path) -> Path:
"""Return the original backup directory."""
return stabilization_root(code_unit_dir) / "original"
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/scan_unit.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/scan_unit.py
index da4c131..07c38c8 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/scan_unit.py
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/scan_unit.py
@@ -81,7 +81,14 @@ def find_orchestration_file(unit_path: Path) -> Path | None:
return None
-PLACEHOLDER_VALUES = {"YOUR_PROJECT_NAME", "YOUR_PROFILE_NAME", "your_project_name", "your_profile_name"}
+PLACEHOLDER_VALUES = {
+ "YOUR_PROJECT_NAME",
+ "YOUR_PROFILE_NAME",
+ "your_project_name",
+ "your_profile_name",
+ "YOUR_SCHEMA",
+ "YOUR_DB",
+}
def assess_dbt_health(project_path: Path) -> dict:
@@ -119,6 +126,14 @@ def assess_dbt_health(project_path: Path) -> dict:
health["has_placeholder_config"] = True
health["health_issues"].append(f"Placeholder '{placeholder}' in dbt_project.yml")
+ sources_yml = project_path / "models" / "sources.yml"
+ if sources_yml.is_file():
+ sources_content = sources_yml.read_text(encoding="utf-8", errors="replace")
+ for placeholder in PLACEHOLDER_VALUES:
+ if placeholder in sources_content:
+ health["has_placeholder_config"] = True
+ health["health_issues"].append(f"Placeholder '{placeholder}' in sources.yml")
+
models_dir = project_path / "models"
if models_dir.is_dir():
model_files = list(models_dir.rglob("*.sql"))
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/__init__.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/fixtures/session_status_sanitized.json b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/fixtures/session_status_sanitized.json
new file mode 100644
index 0000000..827b75b
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/fixtures/session_status_sanitized.json
@@ -0,0 +1,23 @@
+# Sanitized session_status.json derived from wf_op_ar_accomprice (credentials/schema names stripped).
+# Used as a shape reference; tests build working copies via helpers.base_session().
+{
+ "migration_object": "wf_op_ar_accomprice",
+ "migration_object_path": "/sanitized/unit",
+ "orchestration_file": null,
+ "source_file_path": "/sanitized/source.xml",
+ "platform_id": "informatica",
+ "elements": [
+ {"name": "el_fixed_a", "statement": "public.wf_op_ar_accomprice", "status": "fixed", "phase": 1, "test_strategy": "isolated"},
+ {"name": "el_fixed_b", "statement": "public.wf_op_ar_accomprice", "status": "fixed", "phase": 1, "test_strategy": "isolated"},
+ {"name": "el_needs_user", "statement": "public.wf_op_ar_accomprice", "status": "needs-user", "phase": 1, "test_strategy": "grouped:inf0058_dg1"}
+ ],
+ "dbt_projects": [{"name": "m_op_ar_accomprice", "path": "m_op_ar_accomprice", "status": "pending"}],
+ "dbt_nodes": [],
+ "test_environment": {"database": "TEST_DB", "schema": "TEST_SCHEMA"},
+ "roadmap": {
+ "phases": [
+ {"phase": 1, "name": "Orchestration", "scope": "orchestration", "status": "in_progress", "goal": "fix orch", "decisions": [], "parallel_safe": false, "depends_on": []},
+ {"phase": 2, "name": "dbt", "scope": "dbt", "status": "pending", "goal": "fix dbt", "decisions": [], "parallel_safe": false, "depends_on": [1]}
+ ]
+ }
+}
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/helpers.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/helpers.py
new file mode 100644
index 0000000..4346cd9
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/helpers.py
@@ -0,0 +1,127 @@
+"""Shared helpers for etl-stabilization script tests."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS_DIR = Path(__file__).resolve().parent.parent
+TESTS_DIR = Path(__file__).resolve().parent
+FIXTURES_DIR = TESTS_DIR / "fixtures"
+
+
+def run_script(script_name: str, *args: str, check: bool = False) -> subprocess.CompletedProcess:
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
+ return subprocess.run(
+ [sys.executable, str(SCRIPTS_DIR / script_name), *args],
+ capture_output=True,
+ text=True,
+ env=env,
+ check=check,
+ )
+
+
+def run_track_status(*args: str) -> subprocess.CompletedProcess:
+ return run_script("track_status.py", *args)
+
+
+def write_json(path: Path, data: dict) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+ return path
+
+
+def load_json(path: Path) -> dict:
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def minimal_scan(*, source_file_path: str | None = "/sanitized/source.xml") -> dict:
+ return {
+ "unit_name": "wf_op_ar_accomprice",
+ "unit_path": "/sanitized/unit",
+ "orchestration_file": None,
+ "source_file_path": source_file_path,
+ "platform_id": "informatica",
+ "statements": [
+ {
+ "name": "public.wf_op_ar_accomprice",
+ "elements": [
+ {"name": "el_fixed_a", "issues": []},
+ {"name": "el_fixed_b", "issues": []},
+ {"name": "el_needs_user", "issues": []},
+ ],
+ }
+ ],
+ "dbt_projects": [{"name": "m_op_ar_accomprice", "path": "m_op_ar_accomprice"}],
+ }
+
+
+def base_session(*, unit_path: str = "/sanitized/unit") -> dict:
+ """Sanitized session shaped like wf_op_ar_accomprice (credentials stripped)."""
+ return {
+ "migration_object": "wf_op_ar_accomprice",
+ "migration_object_path": unit_path,
+ "orchestration_file": None,
+ "orchestration_file_hash": None,
+ "source_file_path": "/sanitized/source.xml",
+ "platform_id": "informatica",
+ "started_at": "2026-08-18T00:00:00+00:00",
+ "last_updated": "2026-08-18T00:00:00+00:00",
+ "elements": [
+ {
+ "name": "el_fixed_a",
+ "statement": "public.wf_op_ar_accomprice",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "el_fixed_b",
+ "statement": "public.wf_op_ar_accomprice",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "el_needs_user",
+ "statement": "public.wf_op_ar_accomprice",
+ "status": "needs-user",
+ "phase": 1,
+ "test_strategy": "grouped:inf0058_dg1",
+ "reason": "stage path needs-user",
+ },
+ ],
+ "dbt_projects": [
+ {"name": "m_op_ar_accomprice", "path": "m_op_ar_accomprice", "status": "pending", "nodes": []},
+ ],
+ "dbt_nodes": [],
+ "test_environment": {"database": "TEST_DB", "schema": "TEST_SCHEMA"},
+ "roadmap": {
+ "phases": [
+ {
+ "phase": 1,
+ "name": "Orchestration",
+ "goal": "fix orch",
+ "scope": "orchestration",
+ "status": "in_progress",
+ "decisions": [],
+ "parallel_safe": False,
+ "depends_on": [],
+ },
+ {
+ "phase": 2,
+ "name": "dbt",
+ "goal": "fix dbt",
+ "scope": "dbt",
+ "status": "pending",
+ "decisions": [],
+ "parallel_safe": False,
+ "depends_on": [1],
+ },
+ ]
+ },
+ }
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_check_sync_leaks.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_check_sync_leaks.py
new file mode 100644
index 0000000..83f25ed
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_check_sync_leaks.py
@@ -0,0 +1,77 @@
+"""Tests for check_sync_leaks.py (PR-4)."""
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from helpers import base_session, run_script, write_json
+
+PROFILES_WITH_LEAKS = """\
+m_op_ar_accomprice:
+ target: dev
+ outputs:
+ dev:
+ type: snowflake
+ account: preprod_joel
+ user: "{{ env_var('SNOWFLAKE_USER', 'cortex_code') }}"
+ password: "{{ env_var('SNOWFLAKE_PASSWORD', '') }}"
+ database: TEST_DB
+ schema: ETL_FIX_P2_ARACR
+ warehouse: COMPUTE_WH
+ role: SYSADMIN
+ threads: 1
+"""
+
+PROFILES_CLEAN = """\
+m_op_ar_accomprice:
+ target: dev
+ outputs:
+ dev:
+ type: snowflake
+ account: YOUR_ACCOUNT
+ user: "{{ env_var('SNOWFLAKE_USER') }}"
+ password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
+ database: "{{ env_var('SNOWFLAKE_DATABASE') }}"
+ schema: "{{ env_var('SNOWFLAKE_SCHEMA') }}"
+"""
+
+
+class TestCheckSyncLeaks(unittest.TestCase):
+ def test_profiles_yml_with_credentials_and_etl_fix_schema_fails(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, base_session())
+ profiles = Path(tmp) / "profiles.yml"
+ profiles.write_text(PROFILES_WITH_LEAKS, encoding="utf-8")
+
+ result = run_script("check_sync_leaks.py", str(status_path), str(profiles))
+ self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertIn("profiles.yml", result.stdout)
+ self.assertRegex(result.stdout, r"profiles\.yml:\d+: matched ")
+ self.assertTrue(
+ "account" in result.stdout or "preprod_joel" in result.stdout,
+ result.stdout,
+ )
+ self.assertIn("ETL_FIX_P2_ARACR", result.stdout)
+
+ def test_clean_files_exit_zero(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ status_path = Path(tmp) / "session_status.json"
+ session = base_session()
+ session["test_environment"] = {"database": "TEST_DB", "schema": "TEST_SCHEMA"}
+ write_json(status_path, session)
+ profiles = Path(tmp) / "profiles.yml"
+ profiles.write_text(PROFILES_CLEAN, encoding="utf-8")
+ model = Path(tmp) / "model.sql"
+ model.write_text("SELECT 1 AS id;\n", encoding="utf-8")
+
+ result = run_script(
+ "check_sync_leaks.py", str(status_path), str(profiles), str(model)
+ )
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ self.assertIn("no leaks found in 2 files", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_scan_unit.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_scan_unit.py
new file mode 100644
index 0000000..9b9b875
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_scan_unit.py
@@ -0,0 +1,75 @@
+"""Tests for scan_unit.py placeholder detection in sources.yml (PR-3)."""
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from helpers import SCRIPTS_DIR
+
+sys.path.insert(0, str(SCRIPTS_DIR))
+from scan_unit import PLACEHOLDER_VALUES, assess_dbt_health # noqa: E402
+
+
+def _write_project(root: Path, *, dbt_project: str, sources: str | None) -> Path:
+ (root / "models").mkdir(parents=True)
+ (root / "dbt_project.yml").write_text(dbt_project, encoding="utf-8")
+ if sources is not None:
+ (root / "models" / "sources.yml").write_text(sources, encoding="utf-8")
+ return root
+
+
+class TestAssessDbtHealthSourcesPlaceholders(unittest.TestCase):
+ def test_sources_yml_your_schema_your_db_sets_has_placeholder_config(self) -> None:
+ self.assertIn("YOUR_SCHEMA", PLACEHOLDER_VALUES)
+ self.assertIn("YOUR_DB", PLACEHOLDER_VALUES)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ project = _write_project(
+ Path(tmp) / "m_last_run_date",
+ dbt_project=(
+ "name: m_last_run_date\n"
+ "profile: m_last_run_date\n"
+ "version: '1.0.0'\n"
+ ),
+ sources=(
+ "version: 2\n"
+ "sources:\n"
+ " - name: raw\n"
+ " schema: \"{{ var('m_last_run_date_schema', 'YOUR_SCHEMA') }}\"\n"
+ " database: \"{{ var('m_last_run_date_database', 'YOUR_DB') }}\"\n"
+ " tables:\n"
+ " - name: BATCH_INSTANCE\n"
+ ),
+ )
+ health = assess_dbt_health(project)
+ self.assertTrue(health["has_placeholder_config"])
+ issues = " ".join(health["health_issues"])
+ self.assertIn("sources.yml", issues)
+ self.assertTrue(
+ "YOUR_SCHEMA" in issues or "YOUR_DB" in issues,
+ health["health_issues"],
+ )
+
+ def test_clean_sources_yml_does_not_flag_placeholder_config(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ project = _write_project(
+ Path(tmp) / "m_clean",
+ dbt_project="name: m_clean\nprofile: m_clean\nversion: '1.0.0'\n",
+ sources=(
+ "version: 2\n"
+ "sources:\n"
+ " - name: raw\n"
+ " schema: ANALYTICS\n"
+ " database: PROD_DB\n"
+ " tables:\n"
+ " - name: T\n"
+ ),
+ )
+ health = assess_dbt_health(project)
+ self.assertFalse(health["has_placeholder_config"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_track_status.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_track_status.py
new file mode 100644
index 0000000..abe1e90
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/tests/test_track_status.py
@@ -0,0 +1,606 @@
+"""Acceptance tests for track_status.py (PR-1, PR-2, PR-3, PR-5)."""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+from helpers import (
+ SCRIPTS_DIR,
+ base_session,
+ load_json,
+ minimal_scan,
+ run_track_status,
+ write_json,
+)
+
+
+class TestPr1InitSourceFilePath(unittest.TestCase):
+ def test_init_copies_source_file_path_from_scan(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ scan_path = unit / "planning" / "scan.json"
+ write_json(scan_path, minimal_scan(source_file_path="/sanitized/source.xml"))
+
+ result = run_track_status("init", str(scan_path))
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ session = load_json(unit / "tracking" / "session_status.json")
+ self.assertEqual(session.get("source_file_path"), "/sanitized/source.xml")
+
+
+class TestPr1UsageStartPhase(unittest.TestCase):
+ def test_bare_invocation_usage_includes_start_phase(self) -> None:
+ result = run_track_status()
+ self.assertNotEqual(result.returncode, 0)
+ combined = result.stdout + result.stderr
+ self.assertIn("start-phase", combined)
+ self.assertIn("session_status.json", combined)
+
+
+class TestPr1ValidatePhaseBreakdown(unittest.TestCase):
+ def test_validate_phase_prints_needs_user_distinctly(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, base_session())
+
+ result = run_track_status("validate-phase", str(status_path), "1")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("Needs-user: 1", result.stdout)
+ self.assertIn("Fixed/passed: 2", result.stdout)
+ self.assertIn("PASS:", result.stdout)
+
+
+class TestPr1ListTestFiles(unittest.TestCase):
+ def test_list_test_files_uses_tests_orchestration_not_phase_dir(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ stmt = "public.wf_op_ar_accomprice"
+ orch_tests = unit / "stabilization" / "tests" / "orchestration" / stmt
+ orch_tests.mkdir(parents=True)
+ (orch_tests / "el_fixed_a.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ (orch_tests / "grouped_inf0058_dg1.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ # decoy in the old (wrong) phase-dir location
+ decoy = unit / "stabilization" / "phases" / "phase-1" / stmt
+ decoy.mkdir(parents=True)
+ (decoy / "el_fixed_a.sql").write_text("SELECT decoy;\n", encoding="utf-8")
+
+ session = base_session(unit_path=str(unit))
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "validate-phase", str(status_path), "1", "--list-test-files"
+ )
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("tests/orchestration/", result.stdout)
+ self.assertNotIn("phases/phase-1/", result.stdout)
+ self.assertGreaterEqual(result.stdout.count("el_fixed_a.sql"), 1)
+
+ def test_list_test_files_finds_schema_short_name_layout(self) -> None:
+ """Real agent output: tests/orchestration//.sql."""
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ public = unit / "stabilization" / "tests" / "orchestration" / "public"
+ public.mkdir(parents=True)
+ files = [
+ "s_m_last_run_date.sql",
+ "wk_pl_acr_booking_fact.sql",
+ "s_m_fl_tmp_bookings_services_upd.sql",
+ "s_GEN_PARAMETER_FILE.sql",
+ "s_m_pl_acr_booking_fact_trans_point_upd.sql",
+ "wf_bs_facts_fl_to_pl.sql",
+ ]
+ for name in files:
+ (public / name).write_text("SELECT 1;\n", encoding="utf-8")
+
+ session = base_session(unit_path=str(unit))
+ session["elements"] = [
+ {
+ "name": "public.wf_bs_facts_fl_to_pl",
+ "statement": "public.wf_bs_facts_fl_to_pl",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_bs_facts_fl_to_pl.s_m_pl_acr_booking_fact_trans_point_upd",
+ "statement": "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_s_m_pl_acr_booking_fact_trans_point_upd",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_bs_facts_fl_to_pl.s_GEN_PARAMETER_FILE",
+ "statement": "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_s_GEN_PARAMETER_FILE",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_bs_facts_fl_to_pl.s_m_fl_tmp_bookings_services_upd",
+ "statement": "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_s_m_fl_tmp_bookings_services_upd",
+ "status": "skipped",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_bs_facts_fl_to_pl.wk_pl_acr_booking_fact",
+ "statement": "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_wk_pl_acr_booking_fact",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_bs_facts_fl_to_pl.s_m_last_run_date",
+ "statement": "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_s_m_last_run_date",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "validate-phase", str(status_path), "1", "--list-test-files"
+ )
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("Test files (6):", result.stdout)
+ for name in files:
+ self.assertIn(name, result.stdout)
+ self.assertNotIn("(not found)", result.stdout)
+ # Old dotted-statement / fully-qualified-name construction must not be used
+ self.assertNotIn(
+ "public.f_Warehouse_presentation_wf_bs_facts_fl_to_pl_s_m_last_run_date/",
+ result.stdout,
+ )
+
+ def test_list_test_files_reports_all_short_name_collisions(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ orch = unit / "stabilization" / "tests" / "orchestration"
+ (orch / "public").mkdir(parents=True)
+ (orch / "other_schema").mkdir(parents=True)
+ (orch / "public" / "s_m_last_run_date.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ (orch / "other_schema" / "s_m_last_run_date.sql").write_text("SELECT 2;\n", encoding="utf-8")
+
+ session = base_session(unit_path=str(unit))
+ session["elements"] = [
+ {
+ "name": "f_Warehouse_presentation.wf_a.s_m_last_run_date",
+ "statement": "public.wf_a_s_m_last_run_date",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ {
+ "name": "f_Warehouse_presentation.wf_b.s_m_last_run_date",
+ "statement": "public.wf_b_s_m_last_run_date",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "validate-phase", str(status_path), "1", "--list-test-files"
+ )
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("2 candidates", result.stdout)
+ self.assertIn("(verify)", result.stdout)
+ self.assertIn("public/s_m_last_run_date.sql", result.stdout.replace("\\", "/"))
+ self.assertIn("other_schema/s_m_last_run_date.sql", result.stdout.replace("\\", "/"))
+
+ def test_list_test_files_reports_missing_short_name(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ (unit / "stabilization" / "tests" / "orchestration").mkdir(parents=True)
+ session = base_session(unit_path=str(unit))
+ session["elements"] = [
+ {
+ "name": "f_Warehouse_presentation.wf.s_missing",
+ "statement": "public.wf_s_missing",
+ "status": "fixed",
+ "phase": 1,
+ "test_strategy": "isolated",
+ },
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "validate-phase", str(status_path), "1", "--list-test-files"
+ )
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("s_missing.sql: (not found)", result.stdout)
+
+
+class TestPr1UnverifiedDoesNotBlockComplete(unittest.TestCase):
+ def test_unverified_dbt_node_does_not_block_complete_phase(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "m_op_ar_accomprice"
+ tests_root = unit / "stabilization" / "tests" / "dbt" / project
+ (tests_root / "seeds").mkdir(parents=True)
+ (tests_root / "tests").mkdir(parents=True)
+ (tests_root / "seeds" / "seed.csv").write_text("id\n1\n", encoding="utf-8")
+ (tests_root / "tests" / "test_x.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ (tests_root / "test_report.md").write_text("# report\n", encoding="utf-8")
+ learnings = unit / "stabilization" / "phases" / "phase-2"
+ learnings.mkdir(parents=True)
+ (learnings / f"dbt_learnings_{project}.md").write_text("# learnings\n", encoding="utf-8")
+
+ session = base_session(unit_path=str(unit))
+ session["dbt_nodes"] = [
+ {
+ "name": "stg_raw",
+ "path": "models/staging/stg_raw.sql",
+ "status": "unverified",
+ "project": project,
+ "phase": 2,
+ }
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("marked as completed", result.stdout)
+
+
+class TestPr2AssignDbtPhase(unittest.TestCase):
+ def test_assign_dbt_phase_sets_phase_on_matching_nodes(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ session = base_session()
+ session["dbt_nodes"] = [
+ {"name": "n1", "path": "models/n1.sql", "status": "pending", "project": "m_op_ar_accomprice", "phase": None},
+ {"name": "n2", "path": "models/n2.sql", "status": "pending", "project": "m_op_ar_accomprice", "phase": None},
+ {"name": "other", "path": "models/o.sql", "status": "pending", "project": "m_other", "phase": None},
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "assign-dbt-phase", str(status_path), "--phase", "2", "--project", "m_op_ar_accomprice"
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Assigned 2 dbt nodes", result.stdout)
+
+ saved = load_json(status_path)
+ by_name = {n["name"]: n for n in saved["dbt_nodes"]}
+ self.assertEqual(by_name["n1"]["phase"], 2)
+ self.assertEqual(by_name["n2"]["phase"], 2)
+ self.assertIsNone(by_name["other"]["phase"])
+
+ def test_assign_dbt_phase_listed_in_usage(self) -> None:
+ result = run_track_status()
+ combined = result.stdout + result.stderr
+ self.assertIn("assign-dbt-phase", combined)
+
+ def test_validate_phase_dbt_scope_does_not_require_elements(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ session = base_session()
+ session["elements"] = [] # dbt-scope phases have zero orchestration elements
+ session["dbt_nodes"] = [
+ {"name": "n1", "path": "models/n1.sql", "status": "fixed", "project": "m_op_ar_accomprice", "phase": 2},
+ {"name": "n2", "path": "models/n2.sql", "status": "unverified", "project": "m_op_ar_accomprice", "phase": 2},
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status("validate-phase", str(status_path), "2")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertNotIn("no elements assigned", result.stderr)
+ self.assertIn("Validation (dbt)", result.stdout)
+ self.assertIn("PASS: All dbt nodes resolved.", result.stdout)
+
+ def test_validate_phase_infers_dbt_from_assigned_nodes_when_scope_is_missing(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ session = base_session()
+ session["elements"] = []
+ session["roadmap"]["phases"][1].pop("scope")
+ session["dbt_nodes"] = [
+ {
+ "name": "n1",
+ "path": "models/n1.sql",
+ "status": "fixed",
+ "project": "m_op_ar_accomprice",
+ "phase": 2,
+ }
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status("validate-phase", str(status_path), "2")
+
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertNotIn("no elements assigned", result.stderr)
+ self.assertIn("Validation (dbt)", result.stdout)
+ self.assertIn("PASS: All dbt nodes resolved.", result.stdout)
+
+ def test_update_dbt_node_syncs_top_level_dbt_nodes(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ session = base_session()
+ session["dbt_projects"] = [
+ {
+ "name": "m_op_ar_accomprice",
+ "path": "m_op_ar_accomprice",
+ "status": "pending",
+ "nodes": [{"name": "n1", "path": "models/n1.sql", "status": "pending"}],
+ }
+ ]
+ session["dbt_nodes"] = [
+ {"name": "n1", "path": "models/n1.sql", "status": "pending", "project": "m_op_ar_accomprice", "phase": 2},
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status(
+ "update-dbt-node", str(status_path), "m_op_ar_accomprice", "n1",
+ "--status", "unverified", "--reason", "no warehouse",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ saved = load_json(status_path)
+ self.assertEqual(saved["dbt_nodes"][0]["status"], "unverified")
+ self.assertEqual(saved["dbt_projects"][0]["nodes"][0]["status"], "unverified")
+
+
+class TestPr5ConcurrentUpdates(unittest.TestCase):
+ def test_concurrent_update_dbt_node_both_survive(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ session = base_session()
+ session["dbt_projects"] = [
+ {
+ "name": "m_op_ar_accomprice",
+ "path": "m_op_ar_accomprice",
+ "status": "pending",
+ "nodes": [
+ {"name": "node_a", "path": "models/a.sql", "status": "pending"},
+ {"name": "node_b", "path": "models/b.sql", "status": "pending"},
+ ],
+ }
+ ]
+ session["dbt_nodes"] = [
+ {"name": "node_a", "path": "models/a.sql", "status": "pending", "project": "m_op_ar_accomprice", "phase": 2},
+ {"name": "node_b", "path": "models/b.sql", "status": "pending", "project": "m_op_ar_accomprice", "phase": 2},
+ ]
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
+
+ def update(node: str, status: str) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [
+ sys.executable, str(SCRIPTS_DIR / "track_status.py"),
+ "update-dbt-node", str(status_path), "m_op_ar_accomprice", node,
+ "--status", status,
+ ],
+ capture_output=True, text=True, env=env,
+ )
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ futs = [
+ pool.submit(update, "node_a", "fixed"),
+ pool.submit(update, "node_b", "unverified"),
+ ]
+ results = [f.result() for f in futs]
+
+ for r in results:
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ saved = load_json(status_path)
+ by_name = {n["name"]: n["status"] for n in saved["dbt_nodes"]}
+ self.assertEqual(by_name["node_a"], "fixed")
+ self.assertEqual(by_name["node_b"], "unverified")
+ nested = {n["name"]: n["status"] for n in saved["dbt_projects"][0]["nodes"]}
+ self.assertEqual(nested["node_a"], "fixed")
+ self.assertEqual(nested["node_b"], "unverified")
+
+
+def _write_dbt_complete_phase_artifacts(unit: Path, project: str) -> None:
+ tests_root = unit / "stabilization" / "tests" / "dbt" / project
+ (tests_root / "seeds").mkdir(parents=True)
+ (tests_root / "tests").mkdir(parents=True)
+ (tests_root / "seeds" / "seed.csv").write_text("id\n1\n", encoding="utf-8")
+ (tests_root / "tests" / "test_x.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ (tests_root / "test_report.md").write_text("# report\n", encoding="utf-8")
+ learnings = unit / "stabilization" / "phases" / "phase-2"
+ learnings.mkdir(parents=True)
+ (learnings / f"dbt_learnings_{project}.md").write_text("# learnings\n", encoding="utf-8")
+
+
+def _dbt_session_for_project(unit: Path, project: str) -> dict:
+ session = base_session(unit_path=str(unit))
+ session["dbt_projects"] = [
+ {"name": project, "path": project, "status": "pending", "nodes": []}
+ ]
+ session["dbt_nodes"] = [
+ {
+ "name": "stg_raw",
+ "path": "models/staging/stg_raw.sql",
+ "status": "unverified",
+ "project": project,
+ "phase": 2,
+ }
+ ]
+ return session
+
+
+class TestPr3DbtProjectYmlSanity(unittest.TestCase):
+ def test_complete_phase_fails_when_models_key_is_placeholder(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ _write_dbt_complete_phase_artifacts(unit, project)
+ (unit / project).mkdir(parents=True)
+ (unit / project / "dbt_project.yml").write_text(
+ "name: foo\n"
+ "profile: snowflake_test\n"
+ "models:\n"
+ " YOUR_PROJECT_NAME:\n"
+ " staging:\n"
+ " +materialized: view\n",
+ encoding="utf-8",
+ )
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, _dbt_session_for_project(unit, project))
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+ self.assertNotEqual(result.returncode, 0)
+ combined = result.stderr + result.stdout
+ self.assertIn("dbt_project.yml", combined)
+ self.assertIn("YOUR_PROJECT_NAME", combined)
+ self.assertIn("name: 'foo'", combined)
+
+ def test_complete_phase_passes_when_models_key_matches_name(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ _write_dbt_complete_phase_artifacts(unit, project)
+ (unit / project).mkdir(parents=True)
+ (unit / project / "dbt_project.yml").write_text(
+ "name: foo\n"
+ "profile: foo\n"
+ "models:\n"
+ " foo:\n"
+ " staging:\n"
+ " +materialized: view\n"
+ " intermediate:\n"
+ " +materialized: ephemeral\n"
+ " marts:\n"
+ " +materialized: incremental\n",
+ encoding="utf-8",
+ )
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, _dbt_session_for_project(unit, project))
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("marked as completed", result.stdout)
+
+ def test_complete_phase_infers_dbt_from_assigned_nodes_when_scope_is_missing(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ _write_dbt_complete_phase_artifacts(unit, project)
+ session = _dbt_session_for_project(unit, project)
+ session["elements"] = []
+ session["roadmap"]["phases"][1].pop("scope")
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("marked as completed", result.stdout)
+
+ def test_complete_phase_skips_check_when_dbt_project_yml_missing(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ _write_dbt_complete_phase_artifacts(unit, project)
+ (unit / project).mkdir(parents=True)
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, _dbt_session_for_project(unit, project))
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertNotIn("models: key", result.stderr + result.stdout)
+
+ def test_validate_phase_fails_on_models_key_mismatch(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ (unit / project).mkdir(parents=True)
+ (unit / project / "dbt_project.yml").write_text(
+ "name: foo\nmodels:\n YOUR_PROJECT_NAME:\n +materialized: view\n",
+ encoding="utf-8",
+ )
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, _dbt_session_for_project(unit, project))
+
+ result = run_track_status("validate-phase", str(status_path), "2")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("YOUR_PROJECT_NAME", result.stderr + result.stdout)
+
+
+class TestCompletePhaseArtifactLayout(unittest.TestCase):
+ def test_complete_phase_dbt_finds_learnings_under_underscore_phase_dir(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ project = "foo"
+ tests_root = unit / "stabilization" / "tests" / "dbt" / project
+ (tests_root / "seeds").mkdir(parents=True)
+ (tests_root / "tests").mkdir(parents=True)
+ (tests_root / "seeds" / "seed.csv").write_text("id\n1\n", encoding="utf-8")
+ (tests_root / "tests" / "test_x.sql").write_text("SELECT 1;\n", encoding="utf-8")
+ (tests_root / "test_report.md").write_text("# report\n", encoding="utf-8")
+ underscore = unit / "stabilization" / "phases" / "phase_2"
+ underscore.mkdir(parents=True)
+ (underscore / f"dbt_learnings_{project}.md").write_text("# learnings\n", encoding="utf-8")
+
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, _dbt_session_for_project(unit, project))
+
+ result = run_track_status("complete-phase", str(status_path), "2")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("marked as completed", result.stdout)
+
+ def test_complete_phase_orch_accepts_fix_log_instead_of_batch_files(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ tracking = unit / "stabilization" / "tracking"
+ tracking.mkdir(parents=True)
+ (tracking / "fix-log.md").write_text("# fixes applied in main session\n", encoding="utf-8")
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, base_session(unit_path=str(unit)))
+
+ result = run_track_status("complete-phase", str(status_path), "1")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertNotIn("baseline_batch_", result.stderr)
+ self.assertIn("marked as completed", result.stdout)
+
+ def test_complete_phase_orch_finds_batch_files_under_underscore_phase_dir(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ phase = unit / "stabilization" / "phases" / "phase_1"
+ phase.mkdir(parents=True)
+ (phase / "baseline_batch_1.md").write_text("# baseline\n", encoding="utf-8")
+ (phase / "batch_1.md").write_text("# batch\n", encoding="utf-8")
+ (phase / "learnings_batch_1.md").write_text("# learnings\n", encoding="utf-8")
+ (phase / "apply_report.md").write_text("# apply\n", encoding="utf-8")
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, base_session(unit_path=str(unit)))
+
+ result = run_track_status("complete-phase", str(status_path), "1")
+ self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
+ self.assertIn("marked as completed", result.stdout)
+
+ def test_complete_phase_orch_still_requires_terminal_elements(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ unit = Path(tmp)
+ tracking = unit / "stabilization" / "tracking"
+ tracking.mkdir(parents=True)
+ (tracking / "fix-log.md").write_text("# fixes\n", encoding="utf-8")
+ session = base_session(unit_path=str(unit))
+ session["elements"][0]["status"] = "pending"
+ status_path = Path(tmp) / "session_status.json"
+ write_json(status_path, session)
+
+ result = run_track_status("complete-phase", str(status_path), "1")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("not in terminal status", result.stderr + result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/track_status.py b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/track_status.py
index ba811ef..b9ecabd 100644
--- a/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/track_status.py
+++ b/plugin/skills/migration/migrate-objects/actions/etl-stabilization/scripts/track_status.py
@@ -22,6 +22,7 @@
python track_status.py set-test-env
python track_status.py init-roadmap --phases-json ''
python track_status.py init-roadmap --phases-file
+ python track_status.py start-phase
python track_status.py assign-phases --phase --elements --strategy
python track_status.py assign-phases --phase --elements-file --strategy
python track_status.py batch-assign-phases --assignments-file
@@ -34,10 +35,13 @@
python track_status.py add-decision --phase --decision
python track_status.py update-dbt --status [--reason ]
python track_status.py init-dbt
+ python track_status.py assign-dbt-phase --phase --project
python track_status.py update-dbt-node --status [--reason ]
"""
from __future__ import annotations
+import contextlib
+import fcntl
import hashlib
import json
import os
@@ -47,7 +51,14 @@
from datetime import datetime, timezone
from pathlib import Path
-from path_resolver import phase_dir, report_path, stabilization_root, tests_dir
+from path_resolver import (
+ fix_log_path,
+ phase_dir_candidates,
+ report_path,
+ resolve_phase_dir,
+ stabilization_root,
+ tests_dir,
+)
VALID_STATUSES = {"pending", "in_progress", "fixed", "skipped", "needs-user", "failed", "no-fix-needed", "orch-tested", "proc-tested", "test-passed", "test-failed", "auto-fixed-needs-review"}
@@ -108,6 +119,17 @@ def save_json(path: Path, data: dict) -> None:
raise
+@contextlib.contextmanager
+def _locked(status_path: Path):
+ lock_path = status_path.with_suffix(status_path.suffix + ".lock")
+ with open(lock_path, "w") as lf:
+ fcntl.flock(lf, fcntl.LOCK_EX)
+ try:
+ yield
+ finally:
+ fcntl.flock(lf, fcntl.LOCK_UN)
+
+
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -182,7 +204,7 @@ def cmd_init(scan_path: Path) -> None:
if orch_path.is_file():
orch_hash = _file_hash(orch_path)
- source_file_path = scan.get("source_definition_path")
+ source_file_path = scan.get("source_file_path")
platform_id = scan.get("platform_id")
if platform_id is None and "platform_id" not in scan:
# Backward compat: old scan results created before multi-platform support
@@ -220,44 +242,45 @@ def cmd_update(status_path: Path, element_name: str, status: str, reason: str |
print(f"Error: invalid status '{status}'. Valid: {', '.join(sorted(VALID_STATUSES))}", file=sys.stderr)
sys.exit(1)
- session = load_json(status_path)
- found = None
- for el in session["elements"]:
- if el["name"] == element_name:
- found = el
- break
-
- if found is None:
- print(f"Error: element '{element_name}' not found", file=sys.stderr)
- sys.exit(1)
+ with _locked(status_path):
+ session = load_json(status_path)
+ found = None
+ for el in session["elements"]:
+ if el["name"] == element_name:
+ found = el
+ break
- current = found["status"]
- allowed = VALID_TRANSITIONS.get(current)
- if allowed and status not in allowed:
- print(f"Warning: unusual transition '{current}' -> '{status}' for '{element_name}'", file=sys.stderr)
+ if found is None:
+ print(f"Error: element '{element_name}' not found", file=sys.stderr)
+ sys.exit(1)
- # Validate needs-user requires reason
- if status == "needs-user" and reason is None:
- print("Error: 'needs-user' status requires --reason documenting prior fix attempts", file=sys.stderr)
- sys.exit(1)
+ current = found["status"]
+ allowed = VALID_TRANSITIONS.get(current)
+ if allowed and status not in allowed:
+ print(f"Warning: unusual transition '{current}' -> '{status}' for '{element_name}'", file=sys.stderr)
- # Validate skipped requires valid reason
- if status == "skipped":
- if reason is None:
- print("Error: 'skipped' status requires --reason. Valid: " + ", ".join(sorted(VALID_SKIP_REASONS)), file=sys.stderr)
- sys.exit(1)
- if reason not in VALID_SKIP_REASONS:
- print(f"Error: invalid skip reason '{reason}'. Valid skip reasons: " + ", ".join(sorted(VALID_SKIP_REASONS)), file=sys.stderr)
+ # Validate needs-user requires reason
+ if status == "needs-user" and reason is None:
+ print("Error: 'needs-user' status requires --reason documenting prior fix attempts", file=sys.stderr)
sys.exit(1)
- found["status"] = status
- if reason is not None:
- found["reason"] = reason
- elif "reason" in found and status not in ("skipped", "failed", "needs-user", "test-failed", "orch-tested"):
- del found["reason"]
+ # Validate skipped requires valid reason
+ if status == "skipped":
+ if reason is None:
+ print("Error: 'skipped' status requires --reason. Valid: " + ", ".join(sorted(VALID_SKIP_REASONS)), file=sys.stderr)
+ sys.exit(1)
+ if reason not in VALID_SKIP_REASONS:
+ print(f"Error: invalid skip reason '{reason}'. Valid skip reasons: " + ", ".join(sorted(VALID_SKIP_REASONS)), file=sys.stderr)
+ sys.exit(1)
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ found["status"] = status
+ if reason is not None:
+ found["reason"] = reason
+ elif "reason" in found and status not in ("skipped", "failed", "needs-user", "test-failed", "orch-tested"):
+ del found["reason"]
+
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Updated '{element_name}' -> {status}")
@@ -315,29 +338,37 @@ def cmd_init_roadmap(status_path: Path, phases_json: str) -> None:
# complete-phase
# ---------------------------------------------------------------------------
+def _phase_has_rglob(pkg_path: str, phase_num: int, pattern: str) -> bool:
+ return any(
+ path.is_dir() and any(path.rglob(pattern))
+ for path in phase_dir_candidates(pkg_path, phase_num)
+ )
+
+
def _validate_orch_artifacts(session: dict, phase_num: int, pkg_path: str) -> list[str]:
"""Return a list of validation errors for an orchestration phase.
- Checks:
- - baseline_batch_*.md exists in the phase dir
- - batch_*.md exists
- - learnings_batch_*.md exists
- - apply_report.md exists
- - All elements assigned to the phase have terminal status
+ Batch-agent filenames (`baseline_batch_*.md`, `batch_*.md`,
+ `learnings_batch_*.md`, `apply_report.md`) are one valid layout, not a
+ required one: a main-session run writes `tracking/fix-log.md` instead.
+ Phase dirs may be `phase-{N}` or `phase_{N}`.
"""
errors: list[str] = []
if pkg_path:
- phase_directory = phase_dir(pkg_path, phase_num)
- # Search recursively: artifacts may be in task subdirectories
- if not any(phase_directory.rglob("baseline_batch_*.md")):
- errors.append(f"Missing artifact: baseline_batch_*.md not found under {phase_directory}")
- if not any(phase_directory.rglob("batch_*.md")):
- errors.append(f"Missing artifact: batch_*.md not found under {phase_directory}")
- if not any(phase_directory.rglob("learnings_batch_*.md")):
- errors.append(f"Missing artifact: learnings_batch_*.md not found under {phase_directory}")
- if not any(phase_directory.rglob("apply_report.md")):
- errors.append(f"Missing artifact: apply_report.md not found under {phase_directory}")
+ has_batch_layout = (
+ _phase_has_rglob(pkg_path, phase_num, "baseline_batch_*.md")
+ or _phase_has_rglob(pkg_path, phase_num, "batch_*.md")
+ or _phase_has_rglob(pkg_path, phase_num, "learnings_batch_*.md")
+ or _phase_has_rglob(pkg_path, phase_num, "apply_report.md")
+ )
+ has_fix_log = fix_log_path(pkg_path).is_file()
+ if not has_batch_layout and not has_fix_log:
+ phase_directory = resolve_phase_dir(pkg_path, phase_num)
+ errors.append(
+ f"Missing phase artifacts: no batch reports under {phase_directory} "
+ f"and {fix_log_path(pkg_path)} not found"
+ )
else:
errors.append("Missing artifact: migration_object_path not set in session; cannot locate phase artifacts")
@@ -352,6 +383,111 @@ def _validate_orch_artifacts(session: dict, phase_num: int, pkg_path: str) -> li
return errors
+def _dbt_project_dir(session: dict, pkg_path: str, project: str) -> Path | None:
+ """Resolve a dbt project directory from session `dbt_projects[].path`."""
+ rel = None
+ for proj in session.get("dbt_projects", []):
+ if proj.get("name") == project:
+ rel = proj.get("path")
+ break
+ if not rel:
+ rel = project
+ path = Path(rel)
+ if path.is_absolute():
+ return path
+ if pkg_path:
+ return Path(pkg_path) / rel
+ return None
+
+
+def _top_level_yaml_scalar(text: str, key: str) -> str | None:
+ prefix = f"{key}:"
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].rstrip()
+ if not line.strip():
+ continue
+ indent = len(line) - len(line.lstrip())
+ if indent != 0:
+ continue
+ content = line.strip()
+ if content.startswith(prefix):
+ value = content[len(prefix):].strip().strip("'\"")
+ return value or None
+ return None
+
+
+def _first_level_yaml_mapping_keys(text: str, section: str) -> list[str]:
+ keys: list[str] = []
+ in_section = False
+ child_indent: int | None = None
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].rstrip()
+ if not line.strip():
+ continue
+ indent = len(line) - len(line.lstrip())
+ content = line.strip()
+ if indent == 0:
+ in_section = content == f"{section}:" or content.startswith(f"{section}:")
+ child_indent = None
+ continue
+ if not in_section:
+ continue
+ if child_indent is None:
+ child_indent = indent
+ if indent < child_indent:
+ in_section = False
+ continue
+ if indent != child_indent:
+ continue
+ if ":" not in content:
+ continue
+ key = content.split(":", 1)[0].strip().strip("'\"")
+ if not key or key.startswith("+"):
+ continue
+ keys.append(key)
+ return keys
+
+
+def _check_dbt_project_yml_sanity(project_path: Path) -> list[str]:
+ """Structural sanity checks independent of placeholder-scanning (scan_unit.py's job).
+
+ Catches a specific, deterministic class of incomplete-bootstrap bug: the `models:`
+ block's key must match the project's own `name:` — if not, +materialized config
+ for the real project silently never applies to any model.
+ """
+ errors: list[str] = []
+ dbt_project_yml = project_path / "dbt_project.yml"
+ if not dbt_project_yml.is_file():
+ return errors
+ try:
+ text = dbt_project_yml.read_text(encoding="utf-8")
+ except OSError:
+ return errors
+ name = _top_level_yaml_scalar(text, "name")
+ models_keys = _first_level_yaml_mapping_keys(text, "models")
+ if name and models_keys and name not in models_keys:
+ errors.append(
+ f"{dbt_project_yml}: models: key(s) {models_keys} do not match name: '{name}' "
+ f"— placeholder rename incomplete (expected a 'models: {name}:' block)"
+ )
+ return errors
+
+
+def _dbt_project_yml_sanity_errors(session: dict, phase_num: int, pkg_path: str) -> list[str]:
+ dbt_nodes = session.get("dbt_nodes", [])
+ projects = sorted({
+ node.get("project")
+ for node in dbt_nodes
+ if node.get("phase") == phase_num and node.get("project")
+ })
+ errors: list[str] = []
+ for project in projects:
+ project_dir = _dbt_project_dir(session, pkg_path, project)
+ if project_dir:
+ errors.extend(_check_dbt_project_yml_sanity(project_dir))
+ return errors
+
+
def _validate_dbt_artifacts(session: dict, phase_num: int, pkg_path: str) -> list[str]:
"""Return a list of validation errors for a dbt phase.
@@ -361,6 +497,7 @@ def _validate_dbt_artifacts(session: dict, phase_num: int, pkg_path: str) -> lis
- stabilization/tests/dbt/{PROJECT}/test_report.md exists
- dbt_learnings_{project}.md exists in phase dir
- All dbt nodes assigned to this phase have terminal status
+ - dbt_project.yml `models:` top-level key matches `name:` (incomplete bootstrap)
"""
errors: list[str] = []
@@ -377,8 +514,15 @@ def _validate_dbt_artifacts(session: dict, phase_num: int, pkg_path: str) -> lis
seeds_directory = stab_tests_dir / "dbt" / project / "seeds"
tests_directory = stab_tests_dir / "dbt" / project / "tests"
report_file = stab_tests_dir / "dbt" / project / "test_report.md"
- phase_directory = phase_dir(pkg_path, phase_num)
- learnings_file = phase_directory / f"dbt_learnings_{project}.md"
+ learnings_name = f"dbt_learnings_{project}.md"
+ learnings_file = next(
+ (
+ candidate / learnings_name
+ for candidate in phase_dir_candidates(pkg_path, phase_num)
+ if (candidate / learnings_name).is_file()
+ ),
+ resolve_phase_dir(pkg_path, phase_num) / learnings_name,
+ )
if not seeds_directory.is_dir() or not any(seeds_directory.glob("*.csv")):
errors.append(f"Missing dbt artifact: {seeds_directory} has no .csv seed files")
@@ -386,12 +530,15 @@ def _validate_dbt_artifacts(session: dict, phase_num: int, pkg_path: str) -> lis
errors.append(f"Missing dbt artifact: {tests_directory} has no .sql test files")
if not report_file.is_file():
errors.append(f"Missing dbt artifact: {report_file} not found")
- if not learnings_file.is_file():
+ if not learnings_file.is_file() and not fix_log_path(pkg_path).is_file():
errors.append(f"Missing dbt artifact: {learnings_file} not found")
+ project_dir = _dbt_project_dir(session, pkg_path, project)
+ if project_dir:
+ errors.extend(_check_dbt_project_yml_sanity(project_dir))
else:
errors.append("Missing artifact: migration_object_path not set in session; cannot locate dbt test artifacts")
- node_terminal_statuses = TERMINAL_STATUSES | {"passing"}
+ node_terminal_statuses = TERMINAL_STATUSES | {"passing", "unverified"}
non_terminal_nodes = [
node["name"]
for node in dbt_nodes
@@ -440,6 +587,19 @@ def _validate_dataflow_proc_artifacts(session: dict, phase_num: int, pkg_path: s
return errors
+def _phase_scope(session: dict, phase_num: int) -> str:
+ phases = session.get("roadmap", {}).get("phases", [])
+ phase_data = next((p for p in phases if p["phase"] == phase_num), None)
+ explicit_scope = (phase_data or {}).get("scope")
+ if explicit_scope:
+ return explicit_scope
+
+ if any(node.get("phase") == phase_num for node in session.get("dbt_nodes", [])):
+ return "dbt"
+
+ return "orchestration"
+
+
def _validate_phase_artifacts(session: dict, phase_num: int) -> list[str]:
"""Return validation errors for a phase based on its scope.
@@ -449,9 +609,7 @@ def _validate_phase_artifacts(session: dict, phase_num: int) -> list[str]:
- dataflow-proc → _validate_dataflow_proc_artifacts
- final-validation → checks all elements terminal + artifacts/report.html
"""
- phases = session.get("roadmap", {}).get("phases", [])
- phase_data = next((p for p in phases if p["phase"] == phase_num), None)
- scope = (phase_data or {}).get("scope", "orchestration")
+ scope = _phase_scope(session, phase_num)
pkg_path = session.get("migration_object_path", "")
if scope == "dbt":
@@ -611,19 +769,20 @@ def _generate_state_md(session: dict, current_phase: int, phase_status: str, nex
def cmd_update_state(status_path: Path, current_phase: int, phase_status: str, next_action: str) -> None:
- session = load_json(status_path)
- if "roadmap" not in session:
- print("Error: no roadmap in session. Run init-roadmap first.", file=sys.stderr)
- sys.exit(1)
- session["last_updated"] = now_iso()
- if "planning_completed_at" not in session:
- session["planning_completed_at"] = now_iso()
- save_json(status_path, session)
+ with _locked(status_path):
+ session = load_json(status_path)
+ if "roadmap" not in session:
+ print("Error: no roadmap in session. Run init-roadmap first.", file=sys.stderr)
+ sys.exit(1)
+ session["last_updated"] = now_iso()
+ if "planning_completed_at" not in session:
+ session["planning_completed_at"] = now_iso()
+ save_json(status_path, session)
- state_md = _generate_state_md(session, current_phase, phase_status, next_action)
- state_dir = status_path.parent
- state_path = state_dir / "STATE.md"
- state_path.write_text(state_md, encoding="utf-8")
+ state_md = _generate_state_md(session, current_phase, phase_status, next_action)
+ state_dir = status_path.parent
+ state_path = state_dir / "STATE.md"
+ state_path.write_text(state_md, encoding="utf-8")
print(f"STATE.md generated: {state_path}")
@@ -694,24 +853,47 @@ def cmd_add_decision(status_path: Path, phase_num: int, decision: str) -> None:
# ---------------------------------------------------------------------------
def cmd_assign_phases(status_path: Path, phase: int, element_names: list[str], strategy: str) -> None:
- session = load_json(status_path)
- updated = 0
- for el in session["elements"]:
- if el["name"] in element_names:
- el["phase"] = phase
- el["test_strategy"] = strategy
- updated += 1
-
- if updated == 0:
- print("Warning: no elements matched the provided names", file=sys.stderr)
- sys.exit(1)
+ with _locked(status_path):
+ session = load_json(status_path)
+ updated = 0
+ for el in session["elements"]:
+ if el["name"] in element_names:
+ el["phase"] = phase
+ el["test_strategy"] = strategy
+ updated += 1
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ if updated == 0:
+ print("Warning: no elements matched the provided names", file=sys.stderr)
+ sys.exit(1)
+
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Assigned {updated} elements to phase {phase} with strategy '{strategy}'")
+# ---------------------------------------------------------------------------
+# assign-dbt-phase
+# ---------------------------------------------------------------------------
+
+def cmd_assign_dbt_phase(status_path: Path, phase: int, project: str) -> None:
+ with _locked(status_path):
+ session = load_json(status_path)
+ updated = 0
+ for node in session.get("dbt_nodes", []):
+ if node.get("project") == project:
+ node["phase"] = phase
+ updated += 1
+
+ if updated == 0:
+ print(f"Warning: no dbt_nodes found for project '{project}'", file=sys.stderr)
+ sys.exit(1)
+
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
+ print(f"Assigned {updated} dbt nodes in project '{project}' to phase {phase}")
+
+
# ---------------------------------------------------------------------------
# batch-assign-phases
# ---------------------------------------------------------------------------
@@ -734,31 +916,32 @@ def cmd_batch_assign_phases(status_path: Path, assignments_json: str) -> None:
print(f"Error: assignment[{i}] missing required key '{key}'", file=sys.stderr)
sys.exit(1)
- session = load_json(status_path)
-
- roadmap_phases = {p["phase"] for p in session.get("roadmap", {}).get("phases", [])}
- if roadmap_phases:
- for i, entry in enumerate(assignments):
- if entry["phase"] not in roadmap_phases:
- print(f"Warning: assignment[{i}] references phase {entry['phase']} which does not exist in roadmap (available: {sorted(roadmap_phases)})", file=sys.stderr)
-
- total_updated = 0
- for entry in assignments:
- phase = entry["phase"]
- element_set = set(entry["elements"])
- strategy = entry["strategy"]
- for el in session["elements"]:
- if el["name"] in element_set:
- el["phase"] = phase
- el["test_strategy"] = strategy
- total_updated += 1
-
- if total_updated == 0:
- print("Warning: no elements matched any provided names", file=sys.stderr)
- sys.exit(1)
+ with _locked(status_path):
+ session = load_json(status_path)
+
+ roadmap_phases = {p["phase"] for p in session.get("roadmap", {}).get("phases", [])}
+ if roadmap_phases:
+ for i, entry in enumerate(assignments):
+ if entry["phase"] not in roadmap_phases:
+ print(f"Warning: assignment[{i}] references phase {entry['phase']} which does not exist in roadmap (available: {sorted(roadmap_phases)})", file=sys.stderr)
+
+ total_updated = 0
+ for entry in assignments:
+ phase = entry["phase"]
+ element_set = set(entry["elements"])
+ strategy = entry["strategy"]
+ for el in session["elements"]:
+ if el["name"] in element_set:
+ el["phase"] = phase
+ el["test_strategy"] = strategy
+ total_updated += 1
+
+ if total_updated == 0:
+ print("Warning: no elements matched any provided names", file=sys.stderr)
+ sys.exit(1)
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Batch-assigned {total_updated} elements across {len(assignments)} phases")
@@ -770,6 +953,12 @@ def cmd_batch_assign_phases(status_path: Path, assignments_json: str) -> None:
def cmd_validate_phase(status_path: Path, phase_num: int, list_test_files: bool = False) -> None:
session = load_json(status_path)
+ scope = _phase_scope(session, phase_num)
+
+ if scope == "dbt":
+ _validate_dbt_phase(session, status_path, phase_num, list_test_files)
+ return
+
phase_els = [e for e in session["elements"] if e.get("phase") == phase_num]
if not phase_els:
print(f"Error: no elements assigned to phase {phase_num}", file=sys.stderr)
@@ -789,6 +978,7 @@ def cmd_validate_phase(status_path: Path, phase_num: int, list_test_files: bool
status_counts[s] = status_counts.get(s, 0) + 1
fixed = status_counts.get("fixed", 0) + status_counts.get("test-passed", 0) + status_counts.get("no-fix-needed", 0)
+ needs_user = status_counts.get("needs-user", 0) + status_counts.get("auto-fixed-needs-review", 0)
failed = status_counts.get("failed", 0) + status_counts.get("test-failed", 0)
pending = status_counts.get("pending", 0) + status_counts.get("in_progress", 0)
skipped = status_counts.get("skipped", 0)
@@ -797,30 +987,38 @@ def cmd_validate_phase(status_path: Path, phase_num: int, list_test_files: bool
print(f"Orchestration file: {line_count} lines, {ewi_count} EWI markers (file-wide, not phase-scoped)")
print(f"Elements: {len(phase_els)} total")
print(f" Fixed/passed: {fixed}")
+ print(f" Needs-user: {needs_user}")
print(f" Failed: {failed}")
print(f" Pending: {pending}")
print(f" Skipped: {skipped}")
if list_test_files:
pkg_path = session.get("migration_object_path", "")
- test_directory = phase_dir(pkg_path, phase_num) if pkg_path else None
+ test_directory = (tests_dir(pkg_path) / "orchestration") if pkg_path else None
seen: set[str] = set()
found: list[str] = []
for el in phase_els:
strategy = el.get("test_strategy") or ""
- stmt = el.get("statement", "")
if strategy.startswith("grouped:"):
group_name = strategy.split(":", 1)[1]
- fname = f"grouped_{group_name}.sql"
+ short_name = f"grouped_{group_name}"
else:
- fname = f"{el['name']}.sql"
+ short_name = el["name"].rsplit(".", 1)[-1]
+ fname = f"{short_name}.sql"
if fname in seen:
continue
seen.add(fname)
if test_directory:
- candidate = test_directory / stmt / fname
- if candidate.is_file():
- found.append(str(candidate))
+ matches = sorted(test_directory.rglob(fname))
+ if not matches:
+ found.append(f"{fname}: (not found)")
+ elif len(matches) == 1:
+ found.append(str(matches[0]))
+ else:
+ found.append(
+ f"{fname}: {len(matches)} candidates — "
+ f"{', '.join(str(m) for m in matches)} (verify)"
+ )
print(f"\nTest files ({len(found)}):")
for f in found:
print(f" {f}")
@@ -838,6 +1036,64 @@ def cmd_validate_phase(status_path: Path, phase_num: int, list_test_files: bool
sys.exit(1)
+def _validate_dbt_phase(session: dict, status_path: Path, phase_num: int, list_test_files: bool) -> None:
+ pkg_path = session.get("migration_object_path", "")
+ dbt_nodes = [n for n in session.get("dbt_nodes", []) if n.get("phase") == phase_num]
+ if not dbt_nodes:
+ print(f"Error: no dbt nodes assigned to phase {phase_num}", file=sys.stderr)
+ sys.exit(1)
+
+ status_counts: dict[str, int] = {}
+ for node in dbt_nodes:
+ s = node["status"]
+ status_counts[s] = status_counts.get(s, 0) + 1
+
+ fixed = (
+ status_counts.get("fixed", 0)
+ + status_counts.get("test-passed", 0)
+ + status_counts.get("no-fix-needed", 0)
+ + status_counts.get("passing", 0)
+ + status_counts.get("unverified", 0)
+ )
+ needs_user = status_counts.get("needs-user", 0) + status_counts.get("auto-fixed-needs-review", 0)
+ failed = status_counts.get("failed", 0) + status_counts.get("test-failed", 0)
+ pending = status_counts.get("pending", 0) + status_counts.get("in_progress", 0)
+
+ print(f"=== Phase {phase_num} Validation (dbt) ===")
+ print(f"dbt nodes: {len(dbt_nodes)} total")
+ print(f" Fixed/passed: {fixed}")
+ print(f" Needs-user: {needs_user}")
+ print(f" Failed: {failed}")
+ print(f" Pending: {pending}")
+
+ if list_test_files:
+ projects = sorted({n.get("project") for n in dbt_nodes if n.get("project")})
+ stab_tests_dir = tests_dir(pkg_path) if pkg_path else None
+ for project in projects:
+ if stab_tests_dir:
+ project_tests = stab_tests_dir / "dbt" / project / "tests"
+ for f in sorted(project_tests.glob("*.sql")) if project_tests.is_dir() else []:
+ print(f" {f}")
+
+ yml_errors = _dbt_project_yml_sanity_errors(session, phase_num, pkg_path) if pkg_path else []
+ for err in yml_errors:
+ print(f"Error: {err}", file=sys.stderr)
+
+ ok = failed == 0 and pending == 0 and not yml_errors
+ if ok:
+ print("PASS: All dbt nodes resolved.")
+ else:
+ reasons = []
+ if pending > 0:
+ reasons.append(f"{pending} nodes still pending")
+ if failed > 0:
+ reasons.append(f"{failed} nodes failed")
+ if yml_errors:
+ reasons.append(f"{len(yml_errors)} dbt_project.yml sanity error(s)")
+ print(f"FAIL: {'; '.join(reasons)}")
+ sys.exit(1)
+
+
# ---------------------------------------------------------------------------
# update-dbt
# ---------------------------------------------------------------------------
@@ -847,27 +1103,28 @@ def cmd_update_dbt(status_path: Path, project_name: str, status: str, reason: st
print(f"Error: invalid dbt status '{status}'. Valid: {', '.join(sorted(VALID_DBT_STATUSES))}", file=sys.stderr)
sys.exit(1)
- session = load_json(status_path)
- dbt_projects = session.get("dbt_projects", [])
+ with _locked(status_path):
+ session = load_json(status_path)
+ dbt_projects = session.get("dbt_projects", [])
- found = None
- for proj in dbt_projects:
- if proj["name"] == project_name:
- found = proj
- break
+ found = None
+ for proj in dbt_projects:
+ if proj["name"] == project_name:
+ found = proj
+ break
- if found is None:
- print(f"Error: dbt project '{project_name}' not found", file=sys.stderr)
- sys.exit(1)
+ if found is None:
+ print(f"Error: dbt project '{project_name}' not found", file=sys.stderr)
+ sys.exit(1)
- found["status"] = status
- if reason is not None:
- found["reason"] = reason
- elif "reason" in found and status not in ("dbt-failed", "skipped"):
- del found["reason"]
+ found["status"] = status
+ if reason is not None:
+ found["reason"] = reason
+ elif "reason" in found and status not in ("dbt-failed", "skipped"):
+ del found["reason"]
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Updated dbt project '{project_name}' -> {status}")
@@ -876,46 +1133,47 @@ def cmd_update_dbt(status_path: Path, project_name: str, status: str, reason: st
# ---------------------------------------------------------------------------
def cmd_init_dbt(status_path: Path, project_name: str, dbt_project_path: Path) -> None:
- session = load_json(status_path)
- dbt_projects = session.get("dbt_projects", [])
-
- found = None
- for proj in dbt_projects:
- if proj["name"] == project_name:
- found = proj
- break
-
- if found is None:
- print(f"Error: dbt project '{project_name}' not found in session", file=sys.stderr)
- sys.exit(1)
+ with _locked(status_path):
+ session = load_json(status_path)
+ dbt_projects = session.get("dbt_projects", [])
+
+ found = None
+ for proj in dbt_projects:
+ if proj["name"] == project_name:
+ found = proj
+ break
+
+ if found is None:
+ print(f"Error: dbt project '{project_name}' not found in session", file=sys.stderr)
+ sys.exit(1)
- models_dir = dbt_project_path / "models"
- nodes: list[dict] = []
- if models_dir.is_dir():
- for sql_file in sorted(models_dir.rglob("*.sql")):
- rel = str(sql_file.relative_to(dbt_project_path))
- node_name = sql_file.stem
- nodes.append({
- "name": node_name,
- "path": rel,
- "status": "pending",
+ models_dir = dbt_project_path / "models"
+ nodes: list[dict] = []
+ if models_dir.is_dir():
+ for sql_file in sorted(models_dir.rglob("*.sql")):
+ rel = str(sql_file.relative_to(dbt_project_path))
+ node_name = sql_file.stem
+ nodes.append({
+ "name": node_name,
+ "path": rel,
+ "status": "pending",
+ })
+
+ found["nodes"] = nodes
+ # Also populate top-level dbt_nodes for validation queries
+ top_nodes = session.setdefault("dbt_nodes", [])
+ # Remove existing nodes for this project (idempotent)
+ top_nodes[:] = [n for n in top_nodes if n.get("project") != project_name]
+ for node in nodes:
+ top_nodes.append({
+ "name": node["name"],
+ "path": node["path"],
+ "status": node["status"],
+ "project": project_name,
+ "phase": None,
})
-
- found["nodes"] = nodes
- # Also populate top-level dbt_nodes for validation queries
- top_nodes = session.setdefault("dbt_nodes", [])
- # Remove existing nodes for this project (idempotent)
- top_nodes[:] = [n for n in top_nodes if n.get("project") != project_name]
- for node in nodes:
- top_nodes.append({
- "name": node["name"],
- "path": node["path"],
- "status": node["status"],
- "project": project_name,
- "phase": None,
- })
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Initialized {len(nodes)} dbt nodes for project '{project_name}'")
@@ -928,34 +1186,44 @@ def cmd_update_dbt_node(status_path: Path, project_name: str, node_name: str, st
print(f"Error: invalid dbt node status '{status}'. Valid: {', '.join(sorted(VALID_DBT_NODE_STATUSES))}", file=sys.stderr)
sys.exit(1)
- session = load_json(status_path)
- proj = None
- for p in session.get("dbt_projects", []):
- if p["name"] == project_name:
- proj = p
- break
- if proj is None:
- print(f"Error: dbt project '{project_name}' not found", file=sys.stderr)
- sys.exit(1)
-
- nodes = proj.get("nodes", [])
- found = None
- for node in nodes:
- if node["name"] == node_name:
- found = node
- break
- if found is None:
- print(f"Error: node '{node_name}' not found in project '{project_name}'", file=sys.stderr)
- sys.exit(1)
+ with _locked(status_path):
+ session = load_json(status_path)
+ proj = None
+ for p in session.get("dbt_projects", []):
+ if p["name"] == project_name:
+ proj = p
+ break
+ if proj is None:
+ print(f"Error: dbt project '{project_name}' not found", file=sys.stderr)
+ sys.exit(1)
- found["status"] = status
- if reason is not None:
- found["reason"] = reason
- elif "reason" in found and status not in ("failed", "skipped", "unverified"):
- del found["reason"]
+ nodes = proj.get("nodes", [])
+ found = None
+ for node in nodes:
+ if node["name"] == node_name:
+ found = node
+ break
+ if found is None:
+ print(f"Error: node '{node_name}' not found in project '{project_name}'", file=sys.stderr)
+ sys.exit(1)
- session["last_updated"] = now_iso()
- save_json(status_path, session)
+ found["status"] = status
+ if reason is not None:
+ found["reason"] = reason
+ elif "reason" in found and status not in ("failed", "skipped", "unverified"):
+ del found["reason"]
+
+ for top_node in session.get("dbt_nodes", []):
+ if top_node.get("project") == project_name and top_node.get("name") == node_name:
+ top_node["status"] = status
+ if reason is not None:
+ top_node["reason"] = reason
+ elif "reason" in top_node and status not in ("failed", "skipped", "unverified"):
+ del top_node["reason"]
+ break
+
+ session["last_updated"] = now_iso()
+ save_json(status_path, session)
print(f"Updated node '{node_name}' in '{project_name}' -> {status}")
@@ -1098,6 +1366,35 @@ def main() -> None:
cmd_assign_phases(status_path, phase, element_names, strategy)
+ elif command == "assign-dbt-phase":
+ if len(sys.argv) < 3:
+ print("Usage: python track_status.py assign-dbt-phase --phase --project ", file=sys.stderr)
+ sys.exit(1)
+ status_path = Path(sys.argv[2])
+ if not status_path.is_file():
+ print(f"Error: '{status_path}' not found", file=sys.stderr)
+ sys.exit(1)
+
+ phase = None
+ project = None
+ i = 3
+ while i < len(sys.argv):
+ if sys.argv[i] == "--phase" and i + 1 < len(sys.argv):
+ phase = parse_int(sys.argv[i + 1], "phase")
+ i += 2
+ elif sys.argv[i] == "--project" and i + 1 < len(sys.argv):
+ project = sys.argv[i + 1]
+ i += 2
+ else:
+ print(f"Error: unexpected argument '{sys.argv[i]}'", file=sys.stderr)
+ sys.exit(1)
+
+ if phase is None or project is None:
+ print("Error: --phase and --project are required", file=sys.stderr)
+ sys.exit(1)
+
+ cmd_assign_dbt_phase(status_path, phase, project)
+
elif command == "batch-assign-phases":
if len(sys.argv) < 3:
print("Usage: python track_status.py batch-assign-phases --assignments-file | --assignments-json ''", file=sys.stderr)
diff --git a/plugin/skills/migration/migrate-objects/actions/finish_objects.md b/plugin/skills/migration/migrate-objects/actions/finish_objects.md
index c2bfcf3..a9239f1 100644
--- a/plugin/skills/migration/migrate-objects/actions/finish_objects.md
+++ b/plugin/skills/migration/migrate-objects/actions/finish_objects.md
@@ -22,9 +22,6 @@ The response includes a `git_activity` array of human-readable strings describin
> - Pushed commit abc1234f to origin/main with 3 files (registry/obj-1.json, snowflake/proc_1.sql, snowflake/proc_1_test.sql)
> - Rebased branch 'migrate-alice' onto origin/main
-> - Unblocked 2 object(s) that depended on the finished objects
-
-
On error (e.g. merge conflict), attempt to resolve it:
1. Read each conflicted file and decide the correct resolution (accept incoming, keep current, or merge both sides).
@@ -41,8 +38,6 @@ The handler stamps each object done (`isDone`) and closes Snowflake claims. No g
### After success (both modes)
-After the handler succeeds, it automatically scans for objects that were blocked with `error="dependency"` and depend on any of the just-finished objects. Their error stamps are cleared so the next `migration_status` walk picks them up as ready. If the response includes `"woke_dependents": N` (N > 0), tell the user:
-
-> Also unblocked **N** object(s) that were waiting on these dependencies. They'll appear in your next status check.
+The finish payload is `{status, action, actual_size}` plus git extras (`merge_commit`, `git_activity`, …) or `git_disabled`. It does **not** include `my_objects_summary` — pull claim status with `migration_status(mode="my_objects_summary")` if you need it.
-Return to [../SKILL.md](../SKILL.md).
+Dependents waiting on these objects are offered again on the next walk — finish does not stamp or clear a dependency wait. Return to [../SKILL.md](../SKILL.md).
diff --git a/plugin/skills/migration/migrate-objects/autonomous/SKILL.md b/plugin/skills/migration/migrate-objects/autonomous/SKILL.md
new file mode 100644
index 0000000..1fc36cf
--- /dev/null
+++ b/plugin/skills/migration/migrate-objects/autonomous/SKILL.md
@@ -0,0 +1,504 @@
+---
+name: migrate-objects-auto
+description: Autonomous version of migrate-objects — dispatches one subagent per ready task group, refills as they finish, and only stops to ask when a subagent is stuck. Triggers: autonomous migration, run unattended, migrate objects automatically, auto-pilot the wave, migrate everything in parallel, swarm the objects.
+parent_skill: migrate-objects
+license: Proprietary. See License-Skills for complete terms
+---
+
+# Migrate Objects — Autonomous
+
+## On Entry **IMPORTANT DO NOT SKIP**
+
+Tell the user:
+
+> **Autonomous mode.** I'll work the wave by giving each object its own subagent,
+> which walks it the whole way — convert, deploy, tests, fixes — and I'll refill as
+> they finish. I'll interrupt you only when an agent is stuck, when a dependency
+> needs a human decision, and before any data moves. Say "stop" at any point and
+> I'll let in-flight work land.
+
+## Step 0: Preflight
+
+1. `configure(project_dir=, subagent_mode=true, snowflake_connection=)`. Read the appended migration-status block. Bring
+ the shared data infrastructure up: `data_infrastructure(mode="up")`. Relay its
+ `cost_reminder`. `status="not_ready"` is not a green light — a first local bring-up
+ runs every schema migration and can need more than one call, so follow its `remediation`
+ and call `up` again until it reports `ready`. Once means one *successful* bring-up for
+ the wave, not one call.
+2. If `configure()` reports setup is not finished, read
+ [../../setup/SKILL.md](../../setup/SKILL.md) then come back here.
+
+`subagent_mode` attributes writes per agent. Set it once here; it lasts the
+life of the server and cannot be turned off. Yours is `0000`: park a looping
+object, reset a remediated transient failure, and relay non-terminal guidance.
+Acknowledge unreviewed notes only in Step 3 (`review`), never in the dispatch
+loop. Object-level outcomes stay parked for a person in an interactive session.
+`deploy`, `migrate_data`, and `validate_data` are not binding-checked — keep
+each agent on its own object.
+
+## Step 1: Ask how many subagents to run at once
+
+Offer 2 / 4 / 6, recommend **4**. State the
+trade-off: more slots finish the wave faster, burn proportionally more tokens,
+and produce more escalations competing for their attention. Call it `N`. Never
+exceed it, and never quietly raise it.
+
+**Wait for the user's response — do not dispatch until they answer.**
+
+## Step 2: The loop
+
+The unit of dispatch is an **object**: one subagent takes one object and walks it
+through as many tasks as it takes, then stops. You hold no migration state —
+[the server does](#where-the-state-lives). Track three things per in-flight
+object so you never put two agents on one object and so a wake can continue the
+same conversation: the **object id**, the minted `agentId`, and the Cortex
+`resume` id (`task` returns it as `agentId` — a UUID, not the four-hex mint).
+
+### 2a. Read the board
+
+```
+migration_status(mode="my_objects_board")
+migration_status(mode="escalations")
+```
+
+`my_objects_board` is the only status shape you read for dispatch. Each row is
+one object: `objectId`, `name`, `type`, `agentId`, `bucket`
+(`ready` | `blocked` | `done` | `escalated` | `errored`). Overlay your own
+in-flight set — an object you have a live child on is in-flight even if the
+board says `ready`.
+
+Do not call `my_objects_summary`, `my_objects_details`, `next_task`, or
+`task_views`. Those name the current task and why it is waiting; they are for
+interactive sessions and for the child walking the object.
+
+`escalations` is the human queue. Read `escalations` (open asks) and `answered`
+(`guidance` + `agentId`). Do not pass `details=true`. Do not read `notes[].asks`
+/ `choice` or `overrideAcceptedCases` — default payloads omit those bodies.
+`unreviewedCount` is a tally for Step 3, not a reason to stop.
+
+Re-read both every time a child **returns**.
+
+### 2b. Top off the object pool
+
+A slot is occupied only by a **live Cortex child**. `waiting` on a relay
+job, `stuck` / `escalated` parks, leftover claims from a dead session, and
+`completed` objects do not occupy one. `free_slots` is `N` minus the live
+child count. When it is greater than zero, pull:
+
+```
+migration_status(mode="next_objects", limit=)
+```
+
+The board lists only claimed objects. Unclaimed work is invisible there —
+`next_objects` is the only way to see it. Do not skip this pull because a
+leftover looks blocked on a sibling still in flight: the picker returns what
+can start, or comes back empty. Do not wait for the flight to empty.
+
+`next_objects` also returns `leftover_claims`: open claims this run minted
+whose objects are **hidden** from `objects` (the picker will not double-book a
+live walker). Each entry is `{object_id, name, agentId}`. **Before** waiting,
+first-send every leftover whose `agentId` is **not** a live child and whose
+object is not `done` — same minted `agentId`, no `resume`, no fresh mint.
+Skip leftovers you still have a live child for. Count those first-sends
+against `free_slots`, then spawn from `objects` for whatever slots remain.
+
+Each `objects` entry has `object_id`, `name`, `display_name`, `type`, and `agentId`. Hand the
+ids to the subagents you dispatch — **each subagent claims its own object**. You never
+call `transition_status(status="begin")` yourself: the agent that does the work owns
+the claim. A spawn that dies *before* `begin` leaves the object unclaimed (2b will
+offer it again). A child that dies *after* `begin` still binds that minted
+`agentId` — first-send that id again from `leftover_claims` (or from 2d when
+the child returns `completed` without `done`).
+
+`agentId` is minted here, one per object, and this and `answered` in 2e are the only
+places you can get one. Copy it verbatim into that object's **first** dispatch
+prompt: the server refuses an id it did not issue, so an id you compose yourself
+fails the agent's first call. Keep the triple — object id, minted `agentId`,
+Cortex `resume` id — in the same bookkeeping line. A later send to the same
+object reuses **the same minted `agentId`** (the claim is bound to it) and
+**resumes** the Cortex conversation; a fresh mint would be refused as an
+attempt to take a claim it does not hold.
+
+If you have lost the minted pairing, the refusal tells you: `begin` under the
+wrong id comes back naming the object and the agent that holds its claim, and
+that name is the minted id to send with.
+
+> **Claim narrowly.** [../actions/claim_objects.md](../actions/claim_objects.md)
+> forbids auto-claiming because a claim hides an object from every teammate's
+> picker. Autonomous mode claims without asking, so keep the batch small: only the
+> free slots, only ids from this turn's `next_objects`, never a category `where`
+> predicate. The user authorized `N` slots, not the whole wave.
+
+### 2c. Dispatch
+
+**One object per subagent, and the agent is always
+[`general-task`](../../../../agents/general-task.md).** It walks its object through
+every task the machine offers — convert, deploy, tests, fixes — and stops when the
+object is done or needs a human. You do not route by task, and there is no
+per-task agent to choose.
+
+Up to `N` in flight. First send and later send are different `task` calls.
+
+**First send** — you have no Cortex `resume` id for this object. Spawn in a
+single turn, one prompt each, and always pass `description` (a short object
+label). Do not pass `resume` or `fork_conversation_history`.
+
+```
+Migrate this object end-to-end, following your agent definition.
+
+objectId:
+agentId:
+projectDir:
+pluginDir:
+snowflakeConnection:
+snowflakeDatabase:
+guidance:
+```
+
+`task` returns an `agentId` (UUID). Store it as this object's `resume` id. That
+is the conversation. A later `task(resume=…)` returns a **different** UUID —
+that one is only the wait handle for `agent_output`. Do not overwrite the
+stored `resume` id with it.
+
+One imperative line, then values. The line matters: four bare `key: value` pairs
+read as context rather than a request, and an agent handed only context asks
+what you want — which nobody is there to answer. Say what to do, once, and leave
+what to do it with to the definition.
+
+Those values are the rest of the first prompt. `pluginDir` is among them because
+`executor.skill` values are relative to the plugin's `skills/migration/` directory and
+the subagent cannot locate that on its own; `agentId` is there for the same kind of
+reason — it is minted by the server and handed to *you*, so the agent has no way to
+obtain it, and every write it makes is refused without one. `snowflakeConnection`
+on the first-send is Cortex `sql_execute` `-c` and may be a **reader** account.
+MCP writes use the parent `configure(snowflake_connection=…)` connection, which
+can differ. Without `snowflakeConnection` the child omits `-c`
+and hits Cortex's default account; without `snowflakeDatabase` it qualifies SQL
+with the source catalog name.
+Do not tell the child to load `snowflake-migration:migration` — that is the
+interactive router; its contract is [`general-task`](../../../../agents/general-task.md).
+The agent definition is the contract — it already carries the loop, the fix-loop
+thresholds, the escalation test, and the return schema — and anything the prompt
+adds competes with it instead of replacing it. A prompt that invents a retry limit,
+names a field the tools do not return, or specifies its own return shape leaves the
+agent choosing between two sets of rules; a prompt that tells it not to escalate
+converts a decision that needed a human into a silent guess.
+
+**Later send** — you already have a `resume` id. The child finished a turn
+(`waiting` / `partial` / `stuck`) and this conversation still has the walk.
+Call `task` with `resume` set to that stored UUID, `description` set, and a
+prompt that is **only the new line**. Do not repeat `objectId` / `projectDir` /
+`pluginDir` / `snowflakeConnection` / `snowflakeDatabase` / the migrate-this-object line — they are
+already in that conversation. Do not pass `fork_conversation_history`.
+
+```
+relay_wake:
+```
+
+or, after a person answered:
+
+```
+guidance:
+```
+
+or, when the board says `ready` again after a `partial` / remediated `reset`:
+
+```
+Continue this object from the machine's next task.
+```
+
+Store the UUID this `task` call returned as the wait handle. Do not wait yet —
+finish every other first send and wake for this turn, then wait once in 2d.
+Keep resuming the stored id.
+
+If you have lost the `resume` id, this is a first send: full prompt, no
+`resume`. That is the killed-session path, not a wake.
+
+**One minted `agentId` per object, never shared across two live agents.** Two
+agents holding one id can write to each other's objects and the binding cannot
+tell them apart — the whole guarantee collapses to the one it replaced.
+Resuming the same conversation is the sequential reuse; it is the only reuse
+there is.
+
+**Never dispatch two agents for one object.** Because an agent walks the whole
+pipeline, an object it is mid-walk on will keep resolving to a *new* task each time
+you re-read the board. Track in-flight by **object id**, not by task: the object is
+the unit of work and the only safe key. Two agents on one object race on the same
+files and the same registry entries. A `resume` call is not a second agent.
+
+**Check `answered` before every send.** An answered escalation has already
+un-parked its object, so it comes back looking like ordinary work with no sign that
+a person decided anything. If `migration_status(mode="escalations")` lists the
+object under `answered`, the next send is a later send when you have its
+`resume` id (prompt is the `guidance:` line) and a first send when you do not
+— take that entry's minted `agentId` either way. An answered object is claimed
+by nobody, so a first send claims it like any other work. Dropping the guidance
+sends an agent to make a decision that was already made for it.
+
+`N` is the only dispatch limit you control. Do not inspect why a sibling is
+`blocked` or which task it is on.
+
+### 2d. Refill
+
+This session is headless: ending the turn exits the process and kills every
+child. Keep it alive with **one wait per turn**, and only after this turn has
+filled every free slot.
+
+**Top off, then wait.** Re-read the board (2a) and pull `next_objects` (2b)
+before every wait — after a child returns, after a wake, after empty
+`job_status` wakes. First-send every `leftover_claims` entry that is not a
+live child (same minted `agentId`), then spawn every other first send and
+every pending wake in this turn (background `task` calls). Then wait once.
+Do not `agent_output(wait=true)` on one live child while leftovers or free
+slots remain. A live Cortex child on one object does not block filling the
+other slots. Neither does a `waiting` object or an escalation.
+
+Wait once:
+- If a live object-walker remains: `agent_output(wait=true)` on the UUID
+ **that child's most recent `task` call returned** (the wait handle, which
+ equals the stored `resume` id only on a first send). Do not wait on a
+ nested test-writer or verifier UUID. Do not call it to peek at a running
+ child's tools or transcript.
+- If no live object-walker remains **and** 2b returned nothing (or
+ `free_slots` is 0): `job_status(wait=true, confirm=true, agent_id="0000",
+ cursor=)` instead of ending the turn. Without `confirm=true` the
+ tool returns a reminder and does not block — that is not a wake. Empty
+ `wakes` means go back to 2a/2b, not wait again with idle slots. Do not
+ call `job_status(wait=true)` while 2b would still return objects.
+
+Arm **one** Monitor for the wave: `orchestrator_watch.watch_command` from
+`configure` or `job_status(monitor=true)`, `persistent: true`. Each line is
+an instruction. Do not open the job, call `job_status` on it, or read the
+relay log.
+
+A wake looks like `wake up 86e1 and ask it to check new event from relay. …`.
+That is a later send: `task(resume=)` with
+the `relay_wake:` line. Do not spawn a new `general-task` for it. That later
+send is the live child; it occupies a slot only while it is live. Do not
+inspect the event.
+
+When `agent_output` returns, read only these keys from its result JSON:
+`objectId`, `tasksCompleted`, `result`, `reopenedCodeUnits`. Ignore `Recent Output`, `task`,
+`failed`, `blocked`, `evidence`, `asks`, and `notes`. Then 2a and 2b
+**before** you send a wake or wait again.
+
+Report one line per return (`dbo.Customers: done, 4 tasks`) and the running
+tally.
+
+| `result` | What you do |
+|---|---|
+| `completed` | Re-read the board. `done` → drop the `resume` id and the minted `agentId`. Anything else → the child died holding the claim. First send again with the **same** minted `agentId` (no `resume`, no fresh mint from 2b). |
+| `partial` | Re-read the board. `blocked` → leave it. `ready` → later send, same minted `agentId` (the machine routed recovery). `escalated` → 2e. |
+| `stuck` | 2e. Do not send again until a person answers. |
+| `waiting` | Keep the `resume` id; drop the slot. The object is on a relay job — its own migrate/validate, or a dependency the machine registered. Later send only on a wake for that minted `agentId`. |
+| `reopened` | Keep the waiter's `resume` id; drop the slot. First-send each id in `reopenedCodeUnits` that is not already in flight (2b mint, or the same minted `agentId` if that code unit already has one). Do not later-send the waiter until those walks finish. This is not a 2e interrupt. |
+
+Do not send again for a `partial` whose board bucket is still `blocked`.
+
+Reset only from evidence you already have — infrastructure now reports ready,
+or a shared job reached terminal — never from the child's `failed` payload:
+
+```
+transition_status(status="reset", task="", where="id = ''",
+ agent_id="0000")
+```
+
+Then a later send of the same object **with the same minted `agentId`**, once.
+Record the remediation or clearing evidence in your report. If nothing changed,
+leave an existing escalation open; if the object is not parked yet, park it with
+the exact failure and the repair needed before a rerun.
+
+Deterministic row mismatches, schema drift, invalid identifiers or compilation
+errors in generated SQL, and a tool invocation that fails the same way on repeat are
+not transient infrastructure failures. Park them rather than resetting or
+sending the unchanged task again. `reset` clears both the stamp and escalation
+marker, so using it before remediation erases the durable record of a failure the
+next run will reproduce.
+
+### 2e. Escalate
+
+Escalation is the only reason to interrupt the user. An escalated object does
+not occupy a slot — keep dispatching up to `N` live children while they read.
+
+**Read the queue, don't rely on what came back.** A subagent returning `stuck` is
+one source; the authoritative one is:
+
+```
+migration_status(mode="escalations")
+```
+
+Check it on every board read (2a), not only when an agent returns. It is
+project-wide, so it also surfaces escalations raised by another person's agents,
+and answers recorded in an earlier session — including one you were killed in the
+middle of.
+
+Present each **open** row: object, task, `causeClass` if set, and the numbered
+choices from `asks`. That is the only time you read inside an object. Batch
+them. Do not open `notes` or `overrideAcceptedCases`.
+
+**Record the decision with one call — the user's decision, never your own.** An
+escalation exists because the agent that raised it judged the choice not to be an
+agent's to make, and `answer` records *a person's* choice: the row names who
+decided. Do not settle one by reading the escalating agent's skill and writing
+guidance back yourself. If the queue cannot be answered without the user, the run
+terminates with it open — 2f allows that.
+
+The call closes the escalation *and* un-parks the object:
+
+```
+transition_status(status="answer", where="id IN (...)", task="",
+ resolution="guidance|decompose|needs_repair",
+ reason="", agent_id="0000")
+```
+
+`0000` is yours here, on a remediated `reset`, on `escalate`, and on `review`. It is the id that
+says a person decided — the row records which agent recorded the answer, so
+answering under a subagent's id would attribute the user's decision to the agent that
+asked.
+
+| The user says | `resolution` | Then you |
+|---|---|---|
+| Try again, here's how | `guidance` | Later send with their words as the `guidance:` line. `reason` is required. |
+| It's too big — split it | `decompose` | Decompose per [LONG_PROCEDURE.md](../migrate-object/references/LONG_PROCEDURE.md), then later send. |
+| Leave it for a human | `needs_repair` | Nothing. It stays parked but leaves the open queue, so it stops being re-offered every cycle. |
+| Apply an object-level outcome | — | Leave it parked. Putting an object out of scope or accepting its current state belongs to the interactive migration flow. |
+
+`skip` remains a human-interactive out-of-scope action. It is not an autonomous
+`answer` resolution; leave that choice parked for the interactive session.
+
+**Do not send again without answering first.** `error="human"` has no failure
+transition, so the object stays parked until the stamp is cleared: `next_task` keeps
+resolving `needsHuman`, and the agent you sent reads that and returns `stuck`
+having done nothing. `guidance` and `decompose` are the two resolutions that un-park;
+the response reports `unparked` so you can tell it happened.
+
+One thing parked here is not a decision to make: a transient failure whose condition
+has demonstrably cleared. The remediated `reset` in 2d un-parks it without recording
+that anybody chose anything. Every other way out of the queue is a person answering.
+
+A missing dependency (`reason: "missing"`) needs the register / stub / out-of-scope
+menu from [../SKILL.md](../SKILL.md) Step 2c. Register and stub are work you can
+dispatch. Keep choices that determine the object's scope or unmet requirements
+parked for the interactive migration flow.
+
+Not every escalation is a failure. An agent that stops on a design decision — no
+Snowflake equivalent, a definition missing from the source, two readings that
+differ in row count — has done the right thing and has nothing to show you but the
+ambiguity. Those arrive with no `causeClass`.
+
+**Stop the loop** is still an option at any point: let in-flight agents land, then
+Step 3.
+
+### 2f. Termination
+
+The loop ends when all four hold:
+
+- nothing is in flight (no live child, and no object `waiting` on a relay
+ job — that job is still the wave, even though it does not occupy a slot),
+- the board has no `ready` objects (`escalated` / `blocked` do not block
+ termination, and waiting for them to empty would hang the run on an
+ unanswered question),
+- `next_objects` is empty,
+- every remaining object is `escalated`, `blocked`, `done`, `errored`, or
+ out of scope.
+
+A minted `agentId` whose child is gone and whose object is not `done` is
+none of those: first-send that id (2d `completed`, or `leftover_claims` in
+2b). Do not treat `next_objects.objects` being empty as the wave being empty
+while `leftover_claims` is non-empty or you still hold those triples.
+Credential / OAuth expiry is not a human question — do not escalate it;
+the run cannot continue until the owner refreshes auth.
+
+A `ready` object with nothing in flight is none of those: dispatch it or park
+it, but never report around it. An object that returns `ready` again with an
+empty `tasksCompleted` must not be dispatched again until the board changes.
+If there is no concrete remediation or clearing evidence, park it:
+
+```
+transition_status(status="escalate", task="",
+ asks=[""],
+ cause="",
+ reason="", where="id = ''",
+ agent_id="0000")
+```
+
+It leaves `ready` because it is parked, which meets the fourth condition, and the
+user gets a question they can answer instead of an object no board read would have
+shown them.
+
+Then run Step 3. If the board still has `escalated` rows or `openCount` is
+non-zero at that point, say so in the report — the run finished, the wave did
+not.
+
+## Where the state lives
+
+You keep no ledger. Every question about the run has an authoritative answer
+somewhere else, and re-reading beats remembering:
+
+| Question | Source |
+|---|---|
+| What claimed work is ready? | `my_objects_board` → `bucket=ready` |
+| What unclaimed object can take a free slot? | `next_objects` |
+| What is parked on a person? | `my_objects_board` → `bucket=escalated`, and `escalations` for the asks |
+| What is claimed, by whom? | `my_objects_board` → `agentId` |
+| Who to wake for a relay event? | The wake line. Later send: `resume` that object's stored Cortex id. Do not read the job. |
+| Is an object done? | `bucket=done` — the machine closes a verified terminal (`isDone`) |
+| What is waiting on a human, and what did they decide? | `escalations` → `escalations` and `answered` |
+| Unreviewed judgments (count only, mid-loop) | `escalations` → `unreviewedCount` |
+
+Do not call `next_task`, `my_objects_details`, or `task_views`. You do not
+need where an object is in its pipeline or why it failed.
+
+One thing the server cannot tell you, so it stays in your reply text: which **object
+ids** you have agents on right now, the minted `agentId` each was dispatched with,
+and the Cortex `resume` id for that conversation. That is dispatcher bookkeeping,
+not migration state, and safe to lose — a re-read of the board plus the errored
+bucket rebuilds the objects, and a killed run's agents are gone anyway so their
+ids retire with them. Track object ids rather than tasks: an agent walks its
+object across several tasks, so a task-keyed ledger would show the same object as
+new work each time it advanced.
+
+Escalations survive the session — read them, don't remember them.
+
+## Step 3: Report
+
+Call `migration_status()` and report as [../SKILL.md](../SKILL.md) Step 3 does,
+plus what autonomous mode adds: objects finished without intervention, objects
+escalated (open asks, as in 2e), unreviewed notes as **object + task** only
+(do not pass `details=true`; do not quote SQL or choice text), then
+`transition_status(status="review", …)` in one batch with `agent_id="0000"`.
+Also: subagents dispatched, remediated tasks reset, and outcomes stamped by
+an agent because the machine could not observe them.
+
+A stage count is not a done count. Take the finished number from the
+`migration_status()` you already called — `objects_done` — and leave `stage_totals`
+out of it: an object can be deployed, data-migrated, and still not finished, so
+adding stage counts together counts one object several times. `objects_done` is
+project-wide, which is what a wave total should be; `doneCount` on
+`my_objects_board` counts only what you hold a claim on.
+
+Build every line the board can confirm from the board. A returning agent's `result`
+is a claim, not a finding: report what you cannot confirm as what that agent said,
+attributed to it, and never let one agent's `completed` become a report line of its
+own. Nothing counts remediated resets for you — take those from the board and
+what you recorded when you reset, and if you cannot tell how many there were,
+say what you saw instead of a number.
+
+Then tear down: `data_infrastructure(mode="down")`, or
+[../../data-infrastructure/teardown/SKILL.md](../../data-infrastructure/teardown/SKILL.md)
+when the project configured a `compute_pool` and data work ran.
+
+## Resuming an interrupted run
+
+A killed session loses your dispatch bookkeeping and nothing else. Claims live in
+Snowflake, task stamps in the registry, merges in git, escalations and their
+answers in Snowflake. Re-enter this skill: Step 0, then 2a, and the board shows
+exactly where the wave stands — including objects whose subagent died mid-task,
+which resolve back to that task as pending (`reclaimed_from_other_sessions` names
+the ones taken back from the dead session). Nothing needs manual cleanup.
+
+Read `migration_status(mode="escalations")` before dispatching anything. A question
+you asked before the session died is still open, and an answer the user gave is
+still waiting to be acted on — sending those objects again without answering them
+first just parks them again. The Cortex `resume` ids died with the session, so
+every object is a first send.
diff --git a/plugin/skills/migration/migrate-objects/baseline-capture/CAPTURE.md b/plugin/skills/migration/migrate-objects/baseline-capture/CAPTURE.md
index 555d169..5c046ac 100644
--- a/plugin/skills/migration/migrate-objects/baseline-capture/CAPTURE.md
+++ b/plugin/skills/migration/migrate-objects/baseline-capture/CAPTURE.md
@@ -6,7 +6,7 @@ After creating the test YAML files, capture baselines from the source database a
## Step 1: Capture Baselines from Source Database
-Baselines upload to the Snowflake stage `@.VALIDATION.BASELINES`; no copy is kept on the user's laptop (customer data residency).
+Baselines upload to the Snowflake stage `@.VALIDATION.BASELINES` — the database named by `testing_results_database` in `.scai/settings/test_config.yaml`; no copy is kept on the user's laptop (customer data residency).
```bash
scai test capture \
@@ -25,26 +25,26 @@ scai test capture \
List the stage, filtering server-side to just this object's baselines:
```bash
-snow stage list-files @.VALIDATION.BASELINES \
+snow stage list-files @.VALIDATION.BASELINES \
--pattern ".*\..*" \
-c
```
## Step 3 (BTEQ scripts only): mark capture complete
-For BTEQ scripts the baseline is uploaded to the stage and nothing is written to the test YAML, so the state machine cannot infer capture from the file — stamp the task explicitly:
+For BTEQ scripts the baseline is uploaded to the stage and `VALIDATION.BASELINE_METADATA` is not written, so the state machine cannot infer capture from Snowflake — stamp the task explicitly:
```
transition_status status=advance task=captureBaseline --where "id = ''"
```
-Procedures/functions skip this — their `captureBaseline` completes from the per-object test YAML.
+Procedures/functions skip this — their `captureBaseline` completes once `VALIDATION.BASELINE_METADATA` has a row for the object whose `ROW_COUNTS` sum to more than zero.
## CHECKPOINT
Confirm:
- [ ] Baselines captured for `` from source database
-- [ ] Baselines visible on Snowflake stage `@.VALIDATION.BASELINES`
+- [ ] Baselines visible on Snowflake stage `@.VALIDATION.BASELINES`
- [ ] At least 15-25 test cases for this object
## Next Steps
diff --git a/plugin/skills/migration/migrate-objects/baseline-capture/SWARM.md b/plugin/skills/migration/migrate-objects/baseline-capture/SWARM.md
index 1791d6b..9b59fee 100644
--- a/plugin/skills/migration/migrate-objects/baseline-capture/SWARM.md
+++ b/plugin/skills/migration/migrate-objects/baseline-capture/SWARM.md
@@ -115,36 +115,41 @@ Use the `files.source.path` from Step 2. Internalize:
| Complexity | Signals | Agents to spawn |
|---|---|---|
| **Simple** | 1–3 params, straightforward logic | 3 (one of each type) |
-| **Complex** | 4+ params, multiple branches, table lookups, OUT params | 6 (two of each type — see A/B split in each agent file) |
+| **Complex** | 4+ params, multiple branches, table lookups, OUT params | 6 (two of each type — pass `split: A` and `split: B`) |
### 5.3 — Spawn agents in parallel
-Use the Task tool. Each agent reads its own instruction file; do **not** paste instructions inline.
+Use the Task tool. Spawn each as its `subagent_type` with a facts-only
+prompt — the agent definition is the contract. Do not paste instructions
+inline and do not tell it to read a path.
-| Agent | Instruction file | Needs source DB | Focus |
+| Agent | `subagent_type` | Needs source DB | Focus |
|---|---|---|---|
-| **Data-Driven** (most important) | `agents/data_driven.md` | Yes (or testbed CSVs as fallback) | Real parameter values from actual data |
-| **Edge Cases & Boundaries** | `agents/edge_cases.md` | No | NULLs, zeros, type limits, overflow |
-| **Business Logic** | `agents/business_logic.md` | No | Branch coverage from source SQL analysis |
-
-Spawn prompt for each agent:
+| **Data-Driven** (most important) | [`data_driven`](../../../../agents/data_driven.md) | Yes (or testbed CSVs as fallback) | Real parameter values from actual data |
+| **Edge Cases & Boundaries** | [`edge_cases`](../../../../agents/edge_cases.md) | No | NULLs, zeros, type limits, overflow |
+| **Business Logic** | [`business_logic`](../../../../agents/business_logic.md) | No | Branch coverage from source SQL analysis |
```
-Read the instructions at /agents/.md
-then produce test_cases for .
-
-Object signature:
-Source code:
-Referenced tables: # data-driven only
-Source connection name: # data-driven only
-Project directory:
+Produce test_cases for this object, following your agent definition.
+
+object_name:
+signature:
+source_code:
+project_dir:
+referenced_tables: # data_driven only
+source_connection: # data_driven only
+split: A|B # complex only
```
-For **complex** objects spawn 2 agents per type — the agent files describe the A/B split.
+For **complex** objects spawn 2 of each type, one with `split: A` and
+one with `split: B`.
### 5.4 — Collect, dedupe, target 15–25 rows
-Each agent writes its rows to `/.scai/tmp/_