@msn-control/liftoff 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +11 -5
  2. package/assets/locks/frontend/package-lock.json +51 -51
  3. package/assets/locks/frontend/package.json +1 -1
  4. package/assets/locks/node-backend/package-lock.json +353 -12
  5. package/assets/locks/node-backend/package.json +3 -3
  6. package/assets/supported-stack.json +9 -9
  7. package/dist/args.js +7 -5
  8. package/dist/args.js.map +1 -1
  9. package/dist/artifact-lifecycle.d.ts +4 -0
  10. package/dist/artifact-lifecycle.js +38 -0
  11. package/dist/artifact-lifecycle.js.map +1 -0
  12. package/dist/commands.js +415 -77
  13. package/dist/commands.js.map +1 -1
  14. package/dist/file-system.d.ts +1 -1
  15. package/dist/file-system.js +171 -29
  16. package/dist/file-system.js.map +1 -1
  17. package/dist/framework-adapters.d.ts +1 -1
  18. package/dist/framework-adapters.js +9 -3
  19. package/dist/framework-adapters.js.map +1 -1
  20. package/dist/framework-validation.d.ts +1 -0
  21. package/dist/framework-validation.js +26 -0
  22. package/dist/framework-validation.js.map +1 -1
  23. package/dist/init-filesystem.js +2 -2
  24. package/dist/init-filesystem.js.map +1 -1
  25. package/dist/interactive.d.ts +13 -0
  26. package/dist/interactive.js +14 -0
  27. package/dist/interactive.js.map +1 -1
  28. package/dist/openspec-profile.d.ts +28 -0
  29. package/dist/openspec-profile.js +172 -0
  30. package/dist/openspec-profile.js.map +1 -0
  31. package/dist/planner.js +19 -1
  32. package/dist/planner.js.map +1 -1
  33. package/dist/power-apps-templates.js +15 -1
  34. package/dist/power-apps-templates.js.map +1 -1
  35. package/dist/reconcile.js +4 -10
  36. package/dist/reconcile.js.map +1 -1
  37. package/dist/repository-governance.js +4 -0
  38. package/dist/repository-governance.js.map +1 -1
  39. package/dist/templates.d.ts +5 -1
  40. package/dist/templates.js +126 -60
  41. package/dist/templates.js.map +1 -1
  42. package/dist/types.d.ts +27 -4
  43. package/docs/cli-reference.md +52 -25
  44. package/docs/configuration-and-manifests.md +39 -24
  45. package/docs/existing-repositories.md +38 -23
  46. package/docs/getting-started.md +17 -7
  47. package/docs/prerequisites.md +17 -0
  48. package/docs/project-structure.md +13 -1
  49. package/docs/repository-governance.md +2 -2
  50. package/docs/safety-and-consent.md +47 -30
  51. package/docs/spec-workflows-and-agents.md +47 -7
  52. package/docs/supported-stack.md +14 -17
  53. package/docs/troubleshooting.md +53 -8
  54. package/docs/workloads.md +9 -8
  55. package/package.json +1 -1
package/dist/commands.js CHANGED
@@ -5,14 +5,15 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat } from
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { getCommandHelp, getGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
8
- import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
8
+ import { getCodingAgent, getEnvironment, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
9
9
  import { probeCodeAppsPlugin } from './code-apps-plugin.js';
10
10
  import { initializeFramework } from './framework-adapters.js';
11
11
  import { validateFrameworkInstallation } from './framework-validation.js';
12
- import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, captureProjectFileSnapshot, findProjectRoot, loadManifest, manifestHadFilteredLegacySeedOwnership, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
12
+ import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, captureProjectFileSnapshot, findProjectRoot, loadManifest, manifestHadFilteredLegacyNonDurableOwnership, manifestDisplayPath, readProjectFile, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
13
13
  import { InteractiveCancelledError, InteractivePrompter } from './interactive.js';
14
14
  import { applyMergePreflight, assertSafeInitTarget, authorizeMergePreflight, buildMergePreflight, captureTreeState, discoverGitRoot, resolveInitTargetFromDiscovery, validateStagedTree, withStagingArea, writeStagedArtifacts } from './init-filesystem.js';
15
15
  import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
16
+ import { buildOpenSpecProfileWriteCommands, configureOpenSpecProfile, inspectOpenSpecProfile, OPEN_SPEC_DELIVERY, OPEN_SPEC_PROFILE, OPEN_SPEC_WORKFLOW_IDS } from './openspec-profile.js';
16
17
  import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
17
18
  import { buildDependencySetupPlan, dependencyResumeCommand, runDependencySetup, verifyPowerAppsPackageMetadata } from './project-dependencies.js';
18
19
  import { formatCommand, NodeCommandRunner } from './process-runner.js';
@@ -158,12 +159,17 @@ async function initCommand(parsed, context) {
158
159
  if (!readiness.ready) {
159
160
  return 1;
160
161
  }
162
+ const profileReadiness = await ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter);
163
+ if (!profileReadiness.ready) {
164
+ return 1;
165
+ }
161
166
  presentation.stage('Stage project files');
162
167
  const staged = await withStagingArea(async (area) => {
163
168
  const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
164
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
169
+ await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
165
170
  presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
166
171
  await initializeFramework(area, plan, runner, {
172
+ env: context.env,
167
173
  ...presentation.childStreams(),
168
174
  onCommand: (command) => presentation.command(command)
169
175
  });
@@ -206,6 +212,15 @@ async function initCommand(parsed, context) {
206
212
  `${plan.specWorkflow.label} ${plan.framework.version}`,
207
213
  ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
208
214
  ...readiness.pluginProbes.map((probe) => `Code Apps plugin for ${probe.agent.label}: ${probe.state}`),
215
+ ...(plan.specWorkflow.id === 'openspec'
216
+ ? [
217
+ `OpenSpec global profile: ${profileReadiness.changed ? 'configured' : 'verified'}; ` +
218
+ `${OPEN_SPEC_WORKFLOW_IDS.length} workflows; skills and commands`,
219
+ ...(plan.agents.some((agent) => agent.id === 'github-copilot')
220
+ ? [`GitHub Copilot cloud agent: ${plan.copilotCloud ? 'enabled' : 'disabled'}`]
221
+ : [])
222
+ ]
223
+ : []),
209
224
  plan.governanceProfile.id === 'none'
210
225
  ? 'Repository governance: disabled'
211
226
  : `Repository governance: ${plan.governanceProfile.label} policy ${plan.governanceProfile.policyVersion}; local handoff generated, live activation deferred`
@@ -240,7 +255,13 @@ async function planCommand(parsed, context) {
240
255
  const plan = buildProjectPlan(options, { requireProjectName: false });
241
256
  const artifacts = buildArtifacts(plan);
242
257
  presentation.definitions('Project decisions', projectPlanEntries(plan));
243
- presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Path'], artifacts.map((artifact) => [artifact.logicalName, artifact.pathParts.join('/')]));
258
+ presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Lifecycle', 'Path'], artifacts.map((artifact) => [
259
+ artifact.logicalName,
260
+ artifact.lifecycle === 'project'
261
+ ? `${artifact.lifecycle} (${artifact.provisioningGroup})`
262
+ : artifact.lifecycle,
263
+ artifact.pathParts.join('/')
264
+ ]));
244
265
  const workstationRows = selectWorkstationRequirements(plan).map((requirement) => [
245
266
  requirement.definition.label,
246
267
  requirement.exactVersion
@@ -374,6 +395,58 @@ async function ensureWorkstationReady(plan, options, context, runner, presentati
374
395
  ];
375
396
  return { ready: true, deferred, probes, pluginProbes };
376
397
  }
398
+ async function ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter, resumeInvocation = 'liftoff init') {
399
+ if (plan.specWorkflow.id !== 'openspec') {
400
+ return { ready: true, changed: false };
401
+ }
402
+ presentation.stage('Check OpenSpec global profile');
403
+ const inspection = await inspectOpenSpecProfile(plan.framework.executable, runner, {
404
+ cwd: context.cwd,
405
+ env: context.env
406
+ });
407
+ if (inspection.compatible) {
408
+ presentation.status('success', 'OpenSpec global profile', `${OPEN_SPEC_PROFILE}; ${OPEN_SPEC_DELIVERY}; ${OPEN_SPEC_WORKFLOW_IDS.length} workflows`);
409
+ return { ready: true, changed: false };
410
+ }
411
+ const commands = buildOpenSpecProfileWriteCommands(plan.framework.executable);
412
+ const authorized = options.configureOpenSpecProfile === true ||
413
+ (options.configureOpenSpecProfile === undefined &&
414
+ prompter !== undefined &&
415
+ await prompter.confirmOpenSpecProfileConfiguration({
416
+ observed: [
417
+ { label: 'Profile', value: inspection.state.profile },
418
+ { label: 'Delivery', value: inspection.state.delivery },
419
+ {
420
+ label: 'Workflows',
421
+ value: inspection.state.workflows.length > 0
422
+ ? inspection.state.workflows.join(', ')
423
+ : '(none)'
424
+ }
425
+ ],
426
+ required: [
427
+ { label: 'Profile', value: OPEN_SPEC_PROFILE },
428
+ { label: 'Delivery', value: OPEN_SPEC_DELIVERY },
429
+ { label: 'Workflows', value: OPEN_SPEC_WORKFLOW_IDS.join(', ') }
430
+ ],
431
+ differences: inspection.differences,
432
+ commands: commands.map((command) => formatCommand(command))
433
+ }));
434
+ if (!authorized) {
435
+ presentation.error('The global OpenSpec profile does not satisfy the Liftoff template contract.', `Run ${commands.map((command) => `\`${formatCommand(command)}\``).join(', then ')}, ` +
436
+ `then rerun \`${resumeInvocation}\`; or authorize those commands with ` +
437
+ '`--configure-openspec-profile`.');
438
+ return { ready: false, changed: false };
439
+ }
440
+ presentation.stage('Configure OpenSpec global profile');
441
+ await configureOpenSpecProfile(plan.framework.executable, runner, {
442
+ cwd: context.cwd,
443
+ env: context.env,
444
+ ...presentation.childStreams(),
445
+ onCommand: (command) => presentation.command(formatCommand(command))
446
+ });
447
+ presentation.status('success', 'OpenSpec global profile', `${OPEN_SPEC_PROFILE}; ${OPEN_SPEC_DELIVERY}; ${OPEN_SPEC_WORKFLOW_IDS.length} workflows (configured)`);
448
+ return { ready: true, changed: true };
449
+ }
377
450
  async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, presentation, prompter) {
378
451
  const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
379
452
  let installDependencies = options.installDependencies === true;
@@ -391,7 +464,7 @@ async function handleProjectDependencies(plan, projectRoot, options, probes, con
391
464
  if (plan.workload === 'power-apps-code-app') {
392
465
  const issues = await verifyPowerAppsPackageMetadata(projectRoot);
393
466
  if (issues.length > 0) {
394
- presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Restore package.json and package-lock.json or run \`liftoff update\`.`);
467
+ presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Repair the project-owned package.json and package-lock.json before installing dependencies.`);
395
468
  return { success: false, deferred: [] };
396
469
  }
397
470
  }
@@ -550,12 +623,14 @@ function migrationPlanArtifacts(plan, inventory) {
550
623
  {
551
624
  logicalName: 'migration-proposal',
552
625
  category: 'seed',
626
+ lifecycle: 'seed',
553
627
  pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'proposal.md'],
554
628
  content: renderMigrationProposal(plan, inventory)
555
629
  },
556
630
  {
557
631
  logicalName: 'migration-tasks',
558
632
  category: 'seed',
633
+ lifecycle: 'seed',
559
634
  pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'tasks.md'],
560
635
  content: renderMigrationTasks(groups)
561
636
  }
@@ -567,6 +642,7 @@ function migrationPlanArtifacts(plan, inventory) {
567
642
  artifacts: [{
568
643
  logicalName: 'migration-checklist',
569
644
  category: 'seed',
645
+ lifecycle: 'seed',
570
646
  pathParts: ['MIGRATION.md'],
571
647
  content: renderMigrationChecklist(plan, inventory, groups)
572
648
  }],
@@ -654,13 +730,18 @@ async function executeMigration(parsed, context, sourceRoot) {
654
730
  if (!readiness.ready) {
655
731
  return 1;
656
732
  }
733
+ const profileReadiness = await ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter, `liftoff migrate ${JSON.stringify(sourceRoot)}`);
734
+ if (!profileReadiness.ready) {
735
+ return 1;
736
+ }
657
737
  const migrationPlan = migrationPlanArtifacts(plan, inventory);
658
738
  presentation.stage('Stage fresh migration project');
659
739
  await withStagingArea(async (area) => {
660
740
  const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
661
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
741
+ await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
662
742
  presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
663
743
  await initializeFramework(area, plan, runner, {
744
+ env: context.env,
664
745
  ...presentation.childStreams(),
665
746
  onCommand: (command) => presentation.command(command)
666
747
  });
@@ -699,7 +780,16 @@ async function executeMigration(parsed, context, sourceRoot) {
699
780
  }
700
781
  presentation.bullets('Configured integrations', [
701
782
  `${plan.specWorkflow.label} ${plan.framework.version}`,
702
- ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
783
+ ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
784
+ ...(plan.specWorkflow.id === 'openspec'
785
+ ? [
786
+ `OpenSpec global profile: ${profileReadiness.changed ? 'configured' : 'verified'}; ` +
787
+ `${OPEN_SPEC_WORKFLOW_IDS.length} workflows; skills and commands`,
788
+ ...(plan.agents.some((agent) => agent.id === 'github-copilot')
789
+ ? [`GitHub Copilot cloud agent: ${plan.copilotCloud ? 'enabled' : 'disabled'}`]
790
+ : [])
791
+ ]
792
+ : [])
703
793
  ]);
704
794
  if (readiness.deferred.length > 0) {
705
795
  presentation.bullets('Deferred advisory checks', readiness.deferred);
@@ -748,7 +838,7 @@ async function migrateCommand(parsed, context) {
748
838
  return 1;
749
839
  }
750
840
  if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
751
- context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` instead.');
841
+ context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` for managed-core maintenance; in-place project template migration is not automated.');
752
842
  return 1;
753
843
  }
754
844
  context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
@@ -804,6 +894,151 @@ function isDirtyGitWorktree(projectRoot) {
804
894
  const result = spawnSync('git', ['status', '--porcelain'], { cwd: projectRoot, encoding: 'utf8' });
805
895
  return result.status === 0 && result.stdout.trim().length > 0;
806
896
  }
897
+ function requestedProvisioningGroups(manifest, plan) {
898
+ if (plan.workload === 'power-apps-code-app' ||
899
+ manifest.project.workload.kind === 'power-apps-code-app') {
900
+ return [];
901
+ }
902
+ const provisioned = new Set(manifest.projectArtifacts.map((artifact) => artifact.provisioningGroup));
903
+ const groups = [];
904
+ if (plan.includeFrontend &&
905
+ !manifest.project.workload.frontend &&
906
+ !provisioned.has('frontend')) {
907
+ groups.push('frontend');
908
+ }
909
+ const recordedEnvironments = new Set(manifest.project.workload.environments);
910
+ for (const environment of plan.environments) {
911
+ const group = `environment:${environment.id}`;
912
+ if (!recordedEnvironments.has(environment.id) &&
913
+ !provisioned.has(group)) {
914
+ groups.push(group);
915
+ }
916
+ }
917
+ return groups;
918
+ }
919
+ async function inspectProvisioningGroups(projectRoot, render, groups) {
920
+ const projectArtifacts = render.filter((artifact) => artifact.lifecycle === 'project');
921
+ const plans = [];
922
+ for (const group of groups) {
923
+ const artifacts = projectArtifacts.filter((artifact) => artifact.provisioningGroup === group);
924
+ if (artifacts.length === 0) {
925
+ throw new Error(`Provisioning group ${group} did not render any project artifacts.`);
926
+ }
927
+ const entries = await Promise.all(artifacts.map(async (artifact) => {
928
+ const disk = await readProjectFile(projectRoot, artifact.pathParts);
929
+ if (disk === undefined) {
930
+ return {
931
+ group,
932
+ status: 'create',
933
+ rendered: artifact,
934
+ reason: 'destination is absent'
935
+ };
936
+ }
937
+ if (disk.toString('utf8') === artifact.content) {
938
+ return {
939
+ group,
940
+ status: 'adopt',
941
+ rendered: artifact,
942
+ reason: 'destination already matches the selected component'
943
+ };
944
+ }
945
+ return {
946
+ group,
947
+ status: 'conflict',
948
+ rendered: artifact,
949
+ reason: 'destination contains project-owned bytes'
950
+ };
951
+ }));
952
+ plans.push({
953
+ group,
954
+ entries,
955
+ blocked: entries.some((entry) => entry.status === 'conflict')
956
+ });
957
+ }
958
+ return plans;
959
+ }
960
+ function provisioningJson(plans) {
961
+ return plans.map((plan) => ({
962
+ group: plan.group,
963
+ status: plan.blocked ? 'blocked' : 'ready',
964
+ entries: plan.entries.map((entry) => ({
965
+ status: entry.status,
966
+ path: manifestDisplayPath(entry.rendered.pathParts),
967
+ reason: entry.reason
968
+ }))
969
+ }));
970
+ }
971
+ function projectProvenanceFor(artifact) {
972
+ return {
973
+ logicalName: artifact.logicalName,
974
+ category: artifact.category,
975
+ pathParts: artifact.pathParts,
976
+ generatedBy: liftoffVersion,
977
+ generationHash: `sha256:${createHash('sha256').update(artifact.content, 'utf8').digest('hex')}`,
978
+ provisioningGroup: artifact.provisioningGroup
979
+ };
980
+ }
981
+ function appendProvisionedProjectArtifacts(existing, plans) {
982
+ const byName = new Map(existing.map((artifact) => [artifact.logicalName, artifact]));
983
+ for (const plan of plans) {
984
+ if (plan.blocked) {
985
+ continue;
986
+ }
987
+ for (const entry of plan.entries) {
988
+ if (!byName.has(entry.rendered.logicalName)) {
989
+ byName.set(entry.rendered.logicalName, projectProvenanceFor(entry.rendered));
990
+ }
991
+ }
992
+ }
993
+ return [...byName.values()];
994
+ }
995
+ function sameWorkloadIntent(left, right) {
996
+ if (left.kind !== right.kind) {
997
+ return false;
998
+ }
999
+ if (left.kind === 'power-apps-code-app' && right.kind === 'power-apps-code-app') {
1000
+ return left.codeAppsPlugin === right.codeAppsPlugin &&
1001
+ left.starter.repository === right.starter.repository &&
1002
+ left.starter.path === right.starter.path &&
1003
+ left.starter.commit === right.starter.commit;
1004
+ }
1005
+ if (left.kind === 'power-apps-code-app' || right.kind === 'power-apps-code-app') {
1006
+ return false;
1007
+ }
1008
+ return left.apiStack === right.apiStack &&
1009
+ left.cloud === right.cloud &&
1010
+ left.region === right.region &&
1011
+ left.frontend === right.frontend &&
1012
+ left.environments.length === right.environments.length &&
1013
+ left.environments.every((environment, index) => environment === right.environments[index]) &&
1014
+ (left.kind !== 'genai' || right.kind !== 'genai' || left.pattern === right.pattern);
1015
+ }
1016
+ function planWithBlockedProvisioning(plan, recordedWorkload, provisioningPlans) {
1017
+ if (plan.workload === 'power-apps-code-app' ||
1018
+ recordedWorkload.kind === 'power-apps-code-app') {
1019
+ return plan;
1020
+ }
1021
+ const blockedGroups = new Set(provisioningPlans
1022
+ .filter((group) => group.blocked)
1023
+ .map((group) => group.group));
1024
+ const hasBlockedEnvironment = [...blockedGroups].some((group) => group.startsWith('environment:'));
1025
+ const environments = hasBlockedEnvironment
1026
+ ? recordedWorkload.environments.map((id) => {
1027
+ const environment = getEnvironment(id);
1028
+ if (!environment) {
1029
+ throw new Error(`Recorded environment ${id} is no longer supported.`);
1030
+ }
1031
+ return environment;
1032
+ })
1033
+ : plan.environments;
1034
+ return {
1035
+ ...plan,
1036
+ includeFrontend: blockedGroups.has('frontend')
1037
+ ? recordedWorkload.frontend
1038
+ : plan.includeFrontend,
1039
+ environments
1040
+ };
1041
+ }
807
1042
  async function preflightUpdate(projectRoot, entries, force) {
808
1043
  for (const entry of entries) {
809
1044
  const writesDestination = entry.status === 'new' ||
@@ -828,10 +1063,8 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
828
1063
  const snapshots = new Map(initialSnapshots.map((snapshot) => [updateSnapshotKey(snapshot.pathParts), snapshot]));
829
1064
  const seenKeys = new Set(snapshots.keys());
830
1065
  const candidates = [
831
- ...manifest.artifacts.map((artifact) => artifact.pathParts),
832
- ...render
833
- .filter((artifact) => artifact.category !== 'seed' && artifact.category !== 'framework')
834
- .map((artifact) => artifact.pathParts)
1066
+ ...manifest.managedArtifacts.map((artifact) => artifact.pathParts),
1067
+ ...render.map((artifact) => artifact.pathParts)
835
1068
  ];
836
1069
  const uncaptured = [];
837
1070
  for (const pathParts of candidates) {
@@ -846,10 +1079,11 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
846
1079
  }
847
1080
  return [...snapshots.values()];
848
1081
  }
849
- function selectUpdatePreconditions(snapshots, entries, mutations) {
1082
+ function selectUpdatePreconditions(snapshots, entries, mutations, additionalPaths = []) {
850
1083
  const requiredKeys = new Set([
851
1084
  updateSnapshotKey(['liftoff.config.json']),
852
- ...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts))
1085
+ ...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts)),
1086
+ ...additionalPaths.map((pathParts) => updateSnapshotKey(pathParts))
853
1087
  ]);
854
1088
  for (const entry of entries) {
855
1089
  const adoptsExistingDestination = entry.status === 'moved' && entry.destinationMatches === true;
@@ -864,12 +1098,41 @@ function selectUpdatePreconditions(snapshots, entries, mutations) {
864
1098
  }
865
1099
  return snapshots.filter((snapshot) => requiredKeys.has(updateSnapshotKey(snapshot.pathParts)));
866
1100
  }
1101
+ function assertAuthorizedUpdateMutations(mutations, entries, provisioningPlans) {
1102
+ const authorized = new Set([
1103
+ updateSnapshotKey(['liftoff.manifest.json'])
1104
+ ]);
1105
+ for (const entry of entries) {
1106
+ if (entry.rendered && entry.rendered.lifecycle !== 'managed-core') {
1107
+ throw new Error(`Update reconciliation produced a non-core mutation candidate: ${entry.logicalName}`);
1108
+ }
1109
+ authorized.add(updateSnapshotKey(entry.pathParts));
1110
+ if (entry.previousPathParts) {
1111
+ authorized.add(updateSnapshotKey(entry.previousPathParts));
1112
+ }
1113
+ }
1114
+ for (const plan of provisioningPlans) {
1115
+ if (plan.blocked) {
1116
+ continue;
1117
+ }
1118
+ for (const entry of plan.entries) {
1119
+ if (entry.status === 'create') {
1120
+ authorized.add(updateSnapshotKey(entry.rendered.pathParts));
1121
+ }
1122
+ }
1123
+ }
1124
+ for (const mutation of mutations) {
1125
+ if (!authorized.has(updateSnapshotKey(mutation.pathParts))) {
1126
+ throw new Error(`Update mutation is outside managed-core or authorized provisioning scope: ${manifestDisplayPath(mutation.pathParts)}`);
1127
+ }
1128
+ }
1129
+ }
867
1130
  async function updateCommand(parsed, context) {
868
1131
  const { presentation } = context;
869
1132
  const check = readBooleanFlag(parsed.flags, 'check') ?? false;
870
1133
  const force = readBooleanFlag(parsed.flags, 'force') ?? false;
871
1134
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
872
- presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
1135
+ presentation.commandIdentity('update', 'Reconcile Liftoff-managed core files');
873
1136
  if (force && check) {
874
1137
  presentation.error('--force cannot be combined with --check.', 'Run `liftoff update --check` to inspect drift or `liftoff update --force` to overwrite conflicts.');
875
1138
  return 1;
@@ -899,7 +1162,7 @@ async function updateCommand(parsed, context) {
899
1162
  recordedWorkload.kind === 'power-apps-code-app';
900
1163
  presentation.error(`Project type changes (${recordedWorkload.kind} -> ${plan.workload}) are not supported by update.`, involvesPowerApps
901
1164
  ? 'Initialize a fresh project for the new workload.'
902
- : 'Run `liftoff migrate` instead.');
1165
+ : 'Initialize a fresh project and move production behavior through a reviewed project change.');
903
1166
  return 1;
904
1167
  }
905
1168
  if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
@@ -916,13 +1179,13 @@ async function updateCommand(parsed, context) {
916
1179
  }
917
1180
  else if (plan.workload !== 'power-apps-code-app' && recordedWorkload.kind !== 'power-apps-code-app') {
918
1181
  if (plan.apiStack.id !== recordedWorkload.apiStack) {
919
- presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
1182
+ presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Initialize a fresh project and move production behavior through a reviewed project change.');
920
1183
  return 1;
921
1184
  }
922
1185
  const recordedPattern = recordedWorkload.kind === 'genai' ? recordedWorkload.pattern : undefined;
923
1186
  const desiredPattern = plan.workload === 'genai' ? plan.pattern.id : undefined;
924
1187
  if (desiredPattern !== recordedPattern) {
925
- presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
1188
+ presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, 'Use a separately reviewed project migration; the existing migrate command only adopts non-Liftoff sources into a fresh target.');
926
1189
  return 1;
927
1190
  }
928
1191
  }
@@ -937,48 +1200,65 @@ async function updateCommand(parsed, context) {
937
1200
  presentation.error('AI agent or default-agent changes require official framework initialization and are not supported by liftoff update.', 'Restore liftoff.config.json to the integrations recorded in liftoff.manifest.json.');
938
1201
  return 1;
939
1202
  }
940
- const renderPlan = manifest.framework.state === 'legacy'
1203
+ let desiredRenderPlan = manifest.framework.state === 'legacy'
941
1204
  ? { ...plan, agents: [], defaultAgent: undefined }
942
1205
  : plan;
1206
+ if (desiredRenderPlan.workload === 'power-apps-code-app' &&
1207
+ recordedWorkload.kind === 'power-apps-code-app') {
1208
+ desiredRenderPlan = {
1209
+ ...desiredRenderPlan,
1210
+ starter: recordedWorkload.starter
1211
+ };
1212
+ }
1213
+ const desiredRender = buildArtifacts(desiredRenderPlan);
1214
+ const provisioningPlans = await inspectProvisioningGroups(projectRoot, desiredRender, requestedProvisioningGroups(manifest, desiredRenderPlan));
1215
+ const renderPlan = planWithBlockedProvisioning(desiredRenderPlan, recordedWorkload, provisioningPlans);
943
1216
  const render = buildArtifacts(renderPlan);
1217
+ const scopedRender = [
1218
+ ...render.filter((artifact) => artifact.lifecycle === 'managed-core'),
1219
+ ...desiredRender.filter((artifact) => artifact.lifecycle === 'project' &&
1220
+ provisioningPlans.some((group) => group.group === artifact.provisioningGroup))
1221
+ ];
944
1222
  const updateSnapshots = check
945
1223
  ? []
946
- : await captureUpdateSnapshots(projectRoot, manifest, render, initialUpdateSnapshots);
1224
+ : await captureUpdateSnapshots(projectRoot, manifest, scopedRender, initialUpdateSnapshots);
947
1225
  const entries = await reconcileProject(manifest, render, projectRoot);
948
1226
  const summary = summarizeEntries(entries);
949
- const manifestUpgradePending = manifest.artifactVersion !== 5;
950
- const starterUpgradePending = plan.workload === 'power-apps-code-app' &&
951
- recordedWorkload.kind === 'power-apps-code-app' &&
952
- recordedWorkload.starter.commit !== plan.starter.commit;
953
- const nonDurableNames = new Set(render
954
- .filter((artifact) => artifact.category === 'seed' || artifact.category === 'framework')
955
- .map((artifact) => artifact.logicalName));
956
- const manifestOwnershipCleanupPending = manifest.artifacts.some((artifact) => nonDurableNames.has(artifact.logicalName)) || manifestHadFilteredLegacySeedOwnership(manifest);
957
- const manifestRewritePending = manifestUpgradePending ||
958
- manifestOwnershipCleanupPending ||
959
- starterUpgradePending;
960
- const drift = hasDrift(entries) || manifestRewritePending;
1227
+ const ownershipMigrationPending = manifest.artifactVersion !== 6 ||
1228
+ manifestHadFilteredLegacyNonDurableOwnership(manifest);
1229
+ const plannedManifest = buildManifest(renderPlan, render, {
1230
+ frameworkState: manifest.framework.state,
1231
+ projectArtifacts: manifest.projectArtifacts
1232
+ });
1233
+ const workloadIntentChanged = !sameWorkloadIntent(plannedManifest.project.workload, manifest.project.workload);
1234
+ const manifestRewritePending = ownershipMigrationPending ||
1235
+ workloadIntentChanged;
1236
+ const drift = hasDrift(entries) ||
1237
+ manifestRewritePending ||
1238
+ provisioningPlans.length > 0;
961
1239
  const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
962
1240
  if (!drift) {
963
1241
  if (jsonMode) {
964
1242
  presentation.rawStdout(`${JSON.stringify(check ? {
965
- schemaVersion: 1,
1243
+ schemaVersion: 2,
966
1244
  mode: 'check',
1245
+ scope: 'managed-core',
967
1246
  cliVersion: liftoffVersion,
968
1247
  projectVersion: manifest.liftoffVersion,
969
1248
  entries: [],
970
- manifestUpgradePending,
971
- manifestOwnershipCleanupPending,
1249
+ provisioning: [],
1250
+ ownershipMigrationPending,
972
1251
  summary
973
1252
  } : {
974
- schemaVersion: 1,
1253
+ schemaVersion: 2,
975
1254
  mode: 'apply',
1255
+ scope: 'managed-core',
976
1256
  cliVersion: liftoffVersion,
977
1257
  projectVersion: manifest.liftoffVersion,
978
1258
  written: [],
979
1259
  skipped: [],
980
- manifestUpgradePending,
981
- manifestOwnershipCleanupPending,
1260
+ provisioning: [],
1261
+ ownershipMigrationPending,
982
1262
  summary
983
1263
  }, null, 2)}\n`);
984
1264
  return 0;
@@ -987,14 +1267,15 @@ async function updateCommand(parsed, context) {
987
1267
  { label: 'Liftoff CLI', value: liftoffVersion },
988
1268
  { label: 'Project generated by', value: manifest.liftoffVersion }
989
1269
  ]);
990
- presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
1270
+ presentation.status('success', 'Liftoff core is current', `${summary.unchanged} managed-core artifacts match; project files are not compared`);
991
1271
  return 0;
992
1272
  }
993
1273
  if (check) {
994
1274
  if (jsonMode) {
995
1275
  presentation.rawStdout(`${JSON.stringify({
996
- schemaVersion: 1,
1276
+ schemaVersion: 2,
997
1277
  mode: 'check',
1278
+ scope: 'managed-core',
998
1279
  cliVersion: liftoffVersion,
999
1280
  projectVersion: manifest.liftoffVersion,
1000
1281
  entries: visible.map((entry) => ({
@@ -1004,8 +1285,8 @@ async function updateCommand(parsed, context) {
1004
1285
  previousPath: entry.previousPathParts ? manifestDisplayPath(entry.previousPathParts) : undefined,
1005
1286
  reason: entry.reason
1006
1287
  })),
1007
- manifestUpgradePending,
1008
- manifestOwnershipCleanupPending,
1288
+ provisioning: provisioningJson(provisioningPlans),
1289
+ ownershipMigrationPending,
1009
1290
  summary
1010
1291
  }, null, 2)}\n`);
1011
1292
  return 2;
@@ -1016,19 +1297,21 @@ async function updateCommand(parsed, context) {
1016
1297
  ]);
1017
1298
  if (visible.length > 0) {
1018
1299
  if (presentation.stdout.layout === 'plain') {
1019
- presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
1300
+ presentation.section('Liftoff core drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
1020
1301
  }
1021
1302
  else {
1022
- presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
1303
+ presentation.table('Liftoff core drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
1023
1304
  }
1024
1305
  }
1306
+ for (const group of provisioningPlans) {
1307
+ presentation.bullets(`Project component provisioning: ${group.group}`, group.entries.map((entry) => `${entry.status} ${manifestDisplayPath(entry.rendered.pathParts)} ${entry.reason}`));
1308
+ }
1025
1309
  const manifestMaintenance = [
1026
- manifestUpgradePending ? 'upgrade liftoff.manifest.json to schema v5' : undefined,
1027
- manifestOwnershipCleanupPending
1028
- ? 'remove obsolete seed or framework entries from durable manifest ownership'
1310
+ ownershipMigrationPending
1311
+ ? 'release legacy project artifacts from Liftoff update authority in manifest schema v6; no production file will be written'
1029
1312
  : undefined,
1030
- starterUpgradePending
1031
- ? `record the current packaged Power Apps starter ${plan.starter.commit}`
1313
+ workloadIntentChanged
1314
+ ? 'record the requested project configuration intent after safe provisioning'
1032
1315
  : undefined
1033
1316
  ].filter((item) => item !== undefined);
1034
1317
  if (manifestMaintenance.length > 0) {
@@ -1039,8 +1322,11 @@ async function updateCommand(parsed, context) {
1039
1322
  summary.upgrade +
1040
1323
  summary.moved +
1041
1324
  summary.refresh +
1325
+ provisioningPlans.reduce((count, group) => count + (group.blocked
1326
+ ? 0
1327
+ : group.entries.filter((entry) => entry.status === 'create').length), 0) +
1042
1328
  (manifestRewritePending ? 1 : 0);
1043
- presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
1329
+ presentation.status('warning', 'Liftoff core maintenance available', `${toWrite} to write, ${summary.conflict} core conflict(s), ${summary.orphan} core orphan(s), ${summary.unchanged} core unchanged`);
1044
1330
  if (toWrite > 0) {
1045
1331
  presentation.command('liftoff update');
1046
1332
  }
@@ -1049,6 +1335,7 @@ async function updateCommand(parsed, context) {
1049
1335
  }
1050
1336
  return 2;
1051
1337
  }
1338
+ const blockedProvisioning = provisioningPlans.filter((group) => group.blocked);
1052
1339
  if (isDirtyGitWorktree(projectRoot)) {
1053
1340
  const warning = 'The project worktree has uncommitted changes; consider committing before applying.';
1054
1341
  if (jsonMode) {
@@ -1058,7 +1345,7 @@ async function updateCommand(parsed, context) {
1058
1345
  presentation.warning(warning);
1059
1346
  }
1060
1347
  }
1061
- presentation.stage('Apply safe template changes', projectRoot);
1348
+ presentation.stage('Apply safe Liftoff core changes', projectRoot);
1062
1349
  await preflightUpdate(projectRoot, entries, force);
1063
1350
  const written = [];
1064
1351
  const skipped = [];
@@ -1111,12 +1398,32 @@ async function updateCommand(parsed, context) {
1111
1398
  break;
1112
1399
  }
1113
1400
  }
1114
- const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
1401
+ const provisioned = [];
1402
+ for (const group of provisioningPlans) {
1403
+ if (group.blocked) {
1404
+ continue;
1405
+ }
1406
+ for (const entry of group.entries) {
1407
+ if (entry.status === 'create') {
1408
+ mutations.push({
1409
+ type: 'write',
1410
+ pathParts: entry.rendered.pathParts,
1411
+ content: entry.rendered.content
1412
+ });
1413
+ }
1414
+ provisioned.push(entry);
1415
+ }
1416
+ }
1417
+ const oldByName = new Map(manifest.managedArtifacts.map((artifact) => [artifact.logicalName, artifact]));
1115
1418
  const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
1116
1419
  const hasUnrecordedGovernanceConflict = skipped.some((entry) => entry.status === 'conflict' &&
1117
1420
  entry.rendered?.category === 'governance' &&
1118
1421
  !oldByName.has(entry.logicalName));
1119
- const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
1422
+ const nextProjectArtifacts = appendProvisionedProjectArtifacts(manifest.projectArtifacts, provisioningPlans);
1423
+ const nextManifest = buildManifest(renderPlan, render.filter((artifact) => artifact.logicalName !== 'manifest'), {
1424
+ frameworkState: manifest.framework.state,
1425
+ projectArtifacts: nextProjectArtifacts
1426
+ });
1120
1427
  nextManifest.framework = manifest.framework;
1121
1428
  nextManifest.project.specWorkflow = manifest.project.specWorkflow;
1122
1429
  nextManifest.project.agents = manifest.project.agents;
@@ -1130,11 +1437,7 @@ async function updateCommand(parsed, context) {
1130
1437
  nextManifest.governance.profile !== 'none') {
1131
1438
  nextManifest.governance.state = 'handoff-partial';
1132
1439
  }
1133
- nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
1134
- // config is user-owned after create: carry the recorded entry forward untouched
1135
- if (artifact.logicalName === 'liftoff-config') {
1136
- return [oldByName.get('liftoff-config') ?? artifact];
1137
- }
1440
+ nextManifest.managedArtifacts = nextManifest.managedArtifacts.flatMap((artifact) => {
1138
1441
  if (!skippedByName.has(artifact.logicalName)) {
1139
1442
  return [artifact];
1140
1443
  }
@@ -1151,7 +1454,7 @@ async function updateCommand(parsed, context) {
1151
1454
  previous.category === 'governance') {
1152
1455
  continue;
1153
1456
  }
1154
- nextManifest.artifacts.push(previous);
1457
+ nextManifest.managedArtifacts.push(previous);
1155
1458
  }
1156
1459
  }
1157
1460
  mutations.push({
@@ -1159,34 +1462,52 @@ async function updateCommand(parsed, context) {
1159
1462
  pathParts: ['liftoff.manifest.json'],
1160
1463
  content: `${JSON.stringify(nextManifest, null, 2)}\n`
1161
1464
  });
1465
+ assertAuthorizedUpdateMutations(mutations, entries, provisioningPlans);
1162
1466
  await applyProjectFileTransaction(projectRoot, mutations, {
1163
- preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations)
1467
+ preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations, provisioned.map((entry) => entry.rendered.pathParts))
1164
1468
  });
1165
1469
  if (jsonMode) {
1166
1470
  presentation.rawStdout(`${JSON.stringify({
1167
- schemaVersion: 1,
1471
+ schemaVersion: 2,
1168
1472
  mode: 'apply',
1473
+ scope: 'managed-core',
1474
+ status: blockedProvisioning.length > 0 ? 'partial' : 'applied',
1169
1475
  cliVersion: liftoffVersion,
1170
1476
  projectVersion: manifest.liftoffVersion,
1171
1477
  written: written.map((entry) => manifestDisplayPath(entry.pathParts)),
1172
1478
  skipped: skipped.map((entry) => ({ path: manifestDisplayPath(entry.pathParts), reason: entry.reason })),
1173
- manifestUpgradePending,
1174
- manifestOwnershipCleanupPending,
1479
+ provisioning: provisioningJson(provisioningPlans),
1480
+ ownershipMigrationPending,
1175
1481
  summary
1176
1482
  }, null, 2)}\n`);
1177
1483
  return 0;
1178
1484
  }
1179
1485
  if (written.length > 0) {
1180
- presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
1486
+ presentation.bullets('Applied Liftoff core changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
1487
+ }
1488
+ if (provisioned.length > 0) {
1489
+ presentation.bullets('Provisioned project components', provisioned.map((entry) => `${entry.status === 'create' ? 'wrote' : 'adopted'} ${manifestDisplayPath(entry.rendered.pathParts)}`));
1490
+ }
1491
+ if (blockedProvisioning.length > 0) {
1492
+ presentation.bullets('Blocked project component provisioning', blockedProvisioning.flatMap((group) => group.entries
1493
+ .filter((entry) => entry.status === 'conflict')
1494
+ .map((entry) => `${group.group}: preserved ${manifestDisplayPath(entry.rendered.pathParts)}; --force cannot overwrite project-owned bytes`)));
1181
1495
  }
1182
1496
  if (skipped.length > 0) {
1183
- presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --force to overwrite)'}`));
1497
+ presentation.bullets('Skipped Liftoff core conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --force to overwrite)'}`));
1184
1498
  }
1185
1499
  const orphans = entries.filter((entry) => entry.status === 'orphan');
1186
1500
  if (orphans.length > 0) {
1187
- presentation.bullets('Orphaned artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
1501
+ presentation.bullets('Orphaned Liftoff core artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
1188
1502
  }
1189
- presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
1503
+ presentation.completion('Updated project', [
1504
+ `${written.length} core written`,
1505
+ `${provisioned.length} project provisioned`,
1506
+ ...(blockedProvisioning.length > 0
1507
+ ? [`${blockedProvisioning.length} provisioning group(s) blocked`]
1508
+ : []),
1509
+ `${skipped.length} core skipped`
1510
+ ].join(', '), [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
1190
1511
  return 0;
1191
1512
  }
1192
1513
  function renderSelfUpgradeResult(value, presentation) {
@@ -1433,7 +1754,7 @@ function stackProjectCheck(projectRoot, apiStack) {
1433
1754
  label: `${apiStack} project`,
1434
1755
  severity: 'fail',
1435
1756
  detail: `missing locked dependency metadata: ${missingMetadata}`,
1436
- remedy: `restore ${missingMetadata} or run liftoff update`
1757
+ remedy: `restore or repair the project-owned ${missingMetadata}`
1437
1758
  };
1438
1759
  }
1439
1760
  if (apiStack === 'python-fastapi') {
@@ -1446,7 +1767,7 @@ function stackProjectCheck(projectRoot, apiStack) {
1446
1767
  label: `${apiStack} project`,
1447
1768
  severity: 'fail',
1448
1769
  detail: 'pyproject.toml and uv.lock do not identify the same project',
1449
- remedy: 'restore backend/pyproject.toml and backend/uv.lock or run liftoff update'
1770
+ remedy: 'repair the project-owned backend/pyproject.toml and backend/uv.lock'
1450
1771
  };
1451
1772
  }
1452
1773
  }
@@ -1498,7 +1819,7 @@ async function powerAppsProjectCheck(projectRoot) {
1498
1819
  severity: 'fail',
1499
1820
  state: 'unhealthy',
1500
1821
  detail: issues[0],
1501
- remedy: 'restore package.json and package-lock.json or run liftoff update'
1822
+ remedy: 'repair the project-owned package.json and package-lock.json'
1502
1823
  };
1503
1824
  }
1504
1825
  function runReadOnly(command, args, cwd) {
@@ -1579,11 +1900,15 @@ async function projectLayer(projectRoot, manifest) {
1579
1900
  label: 'manifest',
1580
1901
  severity: 'fail',
1581
1902
  detail: `${issues.length} issue(s): ${issues[0]}${issues.length > 1 ? ' ...' : ''}`,
1582
- remedy: 'restore missing artifacts or run liftoff update'
1903
+ remedy: 'restore the manifest or run liftoff update for managed-core repair'
1583
1904
  });
1584
1905
  }
1585
1906
  else {
1586
- checks.push({ label: 'manifest', severity: 'ok', detail: `valid, ${manifest.artifacts.length} artifacts present` });
1907
+ checks.push({
1908
+ label: 'manifest',
1909
+ severity: 'ok',
1910
+ detail: `valid, ${manifest.managedArtifacts.length} managed core and ${manifest.projectArtifacts.length} project provenance entries`
1911
+ });
1587
1912
  }
1588
1913
  if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
1589
1914
  checks.push({
@@ -1657,22 +1982,29 @@ async function projectLayer(projectRoot, manifest) {
1657
1982
  const plan = buildProjectPlan(config, { requireProjectName: true });
1658
1983
  const render = buildArtifacts(plan);
1659
1984
  const entries = await reconcileProject(manifest, render, projectRoot);
1660
- const driftCount = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash).length;
1985
+ const provisioningPlans = await inspectProvisioningGroups(projectRoot, render, requestedProvisioningGroups(manifest, plan));
1986
+ const driftCount = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash).length +
1987
+ provisioningPlans.length +
1988
+ (manifest.artifactVersion === 6 ? 0 : 1);
1661
1989
  if (driftCount > 0) {
1662
1990
  checks.push({
1663
- label: 'scaffold drift',
1991
+ label: 'managed core',
1664
1992
  severity: 'warn',
1665
- detail: `${driftCount} update(s) available`,
1993
+ detail: `${driftCount} core maintenance action(s) available`,
1666
1994
  remedy: 'run liftoff update'
1667
1995
  });
1668
1996
  }
1669
1997
  else {
1670
- checks.push({ label: 'scaffold drift', severity: 'ok', detail: 'project matches the current templates' });
1998
+ checks.push({
1999
+ label: 'managed core',
2000
+ severity: 'ok',
2001
+ detail: 'Liftoff core is current; project templates are not compared'
2002
+ });
1671
2003
  }
1672
2004
  }
1673
2005
  catch (error) {
1674
2006
  checks.push({
1675
- label: 'scaffold drift',
2007
+ label: 'managed core',
1676
2008
  severity: 'fail',
1677
2009
  detail: `liftoff.config.json could not be evaluated: ${error.message.split('\n')[0]}`,
1678
2010
  remedy: 'repair liftoff.config.json'
@@ -1939,6 +2271,8 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
1939
2271
  agents: readListFlag(parsed.flags, 'agents'),
1940
2272
  defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
1941
2273
  codeAppsPlugin: readBooleanFlag(parsed.flags, 'code-apps-plugin'),
2274
+ copilotCloud: readBooleanFlag(parsed.flags, 'copilot-cloud'),
2275
+ configureOpenSpecProfile: readBooleanFlag(parsed.flags, 'configure-openspec-profile'),
1942
2276
  governanceProfile: readStringFlag(parsed.flags, 'governance'),
1943
2277
  configPath,
1944
2278
  yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
@@ -1962,6 +2296,9 @@ function hasMissingInitInputs(options) {
1962
2296
  const missingDefaultAgent = options.specWorkflow === 'spec-kit' &&
1963
2297
  (options.agents?.length ?? 0) > 1 &&
1964
2298
  !options.defaultAgent;
2299
+ const missingCopilotCloud = options.specWorkflow === 'openspec' &&
2300
+ options.agents?.some((agent) => getCodingAgent(agent)?.id === 'github-copilot') === true &&
2301
+ options.copilotCloud === undefined;
1965
2302
  return !options.projectName ||
1966
2303
  missingTypeSpecific ||
1967
2304
  projectType !== 'power-apps-code-app' && !options.cloud ||
@@ -1970,6 +2307,7 @@ function hasMissingInitInputs(options) {
1970
2307
  !options.agents ||
1971
2308
  !options.governanceProfile ||
1972
2309
  missingDefaultAgent ||
2310
+ missingCopilotCloud ||
1973
2311
  projectType !== 'power-apps-code-app' && !options.environments;
1974
2312
  }
1975
2313
  function renderGeneralHelp(presentation) {