@ikon85/agent-workflow-kit 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.agents/skills/kit-update/SKILL.md +33 -1
  2. package/.agents/skills/setup-workflow/SKILL.md +34 -3
  3. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  4. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  5. package/.agents/skills/setup-workflow/worktree-lifecycle.md +31 -2
  6. package/.agents/skills/wrapup/SKILL.md +24 -0
  7. package/.claude/hooks/drift-guard.py +212 -21
  8. package/.claude/skills/kit-update/SKILL.md +33 -1
  9. package/.claude/skills/setup-workflow/SKILL.md +34 -3
  10. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  11. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  12. package/.claude/skills/setup-workflow/worktree-lifecycle.md +31 -2
  13. package/.claude/skills/wrapup/SKILL.md +24 -0
  14. package/README.md +37 -0
  15. package/agent-workflow-kit.package.json +35 -19
  16. package/package.json +1 -1
  17. package/scripts/board_bootstrap.py +367 -0
  18. package/scripts/kit-update-pr.mjs +20 -7
  19. package/scripts/kit-update-pr.test.mjs +29 -0
  20. package/scripts/profile_globs.py +347 -0
  21. package/scripts/test_board_bootstrap.py +348 -0
  22. package/scripts/test_drift_guard_diagnostics.py +295 -0
  23. package/scripts/test_profile_globs.py +280 -0
  24. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  25. package/scripts/test_worktree_wrapup_contract.py +588 -0
  26. package/scripts/workflow-advisories/core.py +29 -4
  27. package/scripts/worktree-lifecycle/README.md +35 -4
  28. package/scripts/worktree-lifecycle/core.py +211 -60
  29. package/scripts/wrapup-land.py +179 -34
  30. package/src/cli.mjs +32 -1
  31. package/src/commands/update.mjs +30 -21
  32. package/src/consumer-migrations.json +19 -0
  33. package/src/lib/bundle.mjs +10 -0
  34. package/src/lib/consumerMigrations.mjs +161 -0
@@ -71,6 +71,14 @@ INFRA_FAILURE_RE = re.compile(
71
71
  )
72
72
 
73
73
 
74
+ ABANDON_ATTEMPT_FLAG = "--abandon-unfinished-attempt"
75
+ RECOVER_CANONICAL_CLEANUP_FLAG = "--recover-canonical-cleanup"
76
+ CANONICAL_DRIFT_RECOVERY_HINT = (
77
+ f"rerun land with {RECOVER_CANONICAL_CLEANUP_FLAG} to revalidate the frozen "
78
+ "landing evidence against canonical policy and resume teardown"
79
+ )
80
+
81
+
74
82
  class Stop(Exception):
75
83
  def __init__(self, step: str, reason: str, detail: str = ""):
76
84
  super().__init__(reason)
@@ -461,9 +469,8 @@ def load_candidate_landing_profile(core, wt: str):
461
469
  return candidate
462
470
 
463
471
 
464
- def load_canonical_landing_profile(core, wt: str, main_tree: str):
465
- """Authorize deletion only after the candidate policy is canonical."""
466
- candidate = load_candidate_landing_profile(core, wt)
472
+ def load_merged_canonical_profile(core, main_tree: str):
473
+ """Read the merged canonical cleanup policy from origin/main."""
467
474
  result = core.run(
468
475
  [
469
476
  "git", "show",
@@ -482,6 +489,13 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
482
489
  raise core.LifecycleError(
483
490
  "merged canonical landing artifact policy is not configured"
484
491
  )
492
+ return canonical
493
+
494
+
495
+ def load_canonical_landing_profile(core, wt: str, main_tree: str):
496
+ """Authorize deletion only after the candidate policy is canonical."""
497
+ candidate = load_candidate_landing_profile(core, wt)
498
+ canonical = load_merged_canonical_profile(core, main_tree)
485
499
  if (
486
500
  candidate.landing_generated_artifact_patterns
487
501
  != canonical.landing_generated_artifact_patterns
@@ -489,11 +503,9 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
489
503
  ):
490
504
  raise core.LifecycleError(
491
505
  "worktree cleanup policy differs from merged canonical origin/main"
506
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
492
507
  )
493
- attempt_path = core.artifact_baseline_path(Path(wt)).with_name(
494
- core.LANDING_ATTEMPT_FILE
495
- )
496
- if not os.path.lexists(attempt_path):
508
+ if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
497
509
  raise core.LifecycleError(
498
510
  "landing attempt is missing before canonical cleanup authorization"
499
511
  )
@@ -504,17 +516,22 @@ def load_canonical_landing_profile(core, wt: str, main_tree: str):
504
516
  ):
505
517
  raise core.LifecycleError(
506
518
  "landing attempt cleanup policy differs from merged canonical origin/main"
519
+ f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
507
520
  )
508
521
  return canonical
509
522
 
510
523
 
511
- def landing_artifact_baseline_digest(wt: str, main_tree: str) -> str:
512
- core = load_worktree_cleanup_core()
513
- try:
514
- load_candidate_landing_profile(core, wt)
515
- return core.load_artifact_baseline(Path(wt)).digest
516
- except core.LifecycleError as error:
517
- raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
524
+ def load_canonical_recovery_profile(core, wt: str, main_tree: str):
525
+ """Authorize post-merge cleanup from canonical policy alone after drift.
526
+
527
+ The committed worktree candidate is deliberately never consulted here: after
528
+ canonical drift it is stale, and trusting it could only widen what may be
529
+ deleted. Every authority boundary re-validates the frozen evidence against
530
+ the merged canonical policy instead.
531
+ """
532
+ canonical = load_merged_canonical_profile(core, main_tree)
533
+ core.canonical_recovery_evidence(canonical, Path(wt))
534
+ return canonical
518
535
 
519
536
 
520
537
  def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
@@ -522,6 +539,10 @@ def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
522
539
  try:
523
540
  profile = load_candidate_landing_profile(core, wt)
524
541
  return core.landing_start_artifact_inventory(profile, Path(wt))
542
+ except core.LegacyLandingAttempt as error:
543
+ # Legacy, not damage: report the classification and the safe route out
544
+ # verbatim, without the corruption framing of a guard failure.
545
+ raise Stop("cleanup", str(error)) from error
525
546
  except core.LifecycleError as error:
526
547
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
527
548
 
@@ -581,6 +602,16 @@ def abandon_unfinished_landing_attempt(wt: str, main_tree: str) -> str:
581
602
  raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
582
603
 
583
604
 
605
+ def canonical_recovery_evidence(wt: str, main_tree: str) -> tuple[dict, ...]:
606
+ """Revalidate the frozen landing evidence against canonical policy alone."""
607
+ core = load_worktree_cleanup_core()
608
+ try:
609
+ canonical = load_merged_canonical_profile(core, main_tree)
610
+ return core.canonical_recovery_evidence(canonical, Path(wt))
611
+ except core.LifecycleError as error:
612
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
613
+
614
+
584
615
  def landing_verified_scratch_files(
585
616
  wt: str,
586
617
  main_tree: str,
@@ -605,10 +636,13 @@ def ensure_worktree_removable(
605
636
  *,
606
637
  verified_scratch_files: tuple[str, ...] = (),
607
638
  verified_scratch_evidence: tuple[dict, ...] = (),
639
+ profile_loader=None,
608
640
  ):
609
641
  core = load_worktree_cleanup_core()
642
+ # Resolved at call time so the module attribute stays the single seam.
643
+ loader = profile_loader or load_canonical_landing_profile
610
644
  try:
611
- profile = load_canonical_landing_profile(core, wt, main_tree)
645
+ profile = loader(core, wt, main_tree)
612
646
  kwargs = {"merge_target": "origin/main"}
613
647
  paths = (
614
648
  tuple(item["path"] for item in verified_scratch_evidence)
@@ -639,11 +673,13 @@ def remove_verified_worktree_scratch(
639
673
  *,
640
674
  verified_scratch_files: tuple[str, ...] = (),
641
675
  verified_scratch_evidence: tuple[dict, ...] = (),
676
+ profile_loader=None,
642
677
  ):
643
678
  """Re-verify and delete only assessed regular scratch files."""
644
679
  core = load_worktree_cleanup_core()
680
+ loader = profile_loader or load_canonical_landing_profile
645
681
  try:
646
- profile = load_canonical_landing_profile(core, wt, main_tree)
682
+ profile = loader(core, wt, main_tree)
647
683
  evidence = verified_scratch_evidence
648
684
  paths = tuple(item["path"] for item in evidence) or verified_scratch_files
649
685
  latest = core.cleanup_assessment(
@@ -920,6 +956,105 @@ def cmd_commit(args) -> dict:
920
956
  return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
921
957
 
922
958
 
959
+ def retire_local_branch(branch: str, main_tree: str, report: dict) -> None:
960
+ """Fast-forward main, then retire the merged local branch with `-d` only."""
961
+ git(["fetch", "origin", "--prune"], cwd=main_tree)
962
+ git(["checkout", "main"], cwd=main_tree)
963
+ p = git(["pull", "--ff-only"], cwd=main_tree)
964
+ if p.returncode != 0:
965
+ raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
966
+ (p.stderr or p.stdout).strip()[-1000:])
967
+ if git(["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
968
+ cwd=main_tree).returncode != 0:
969
+ report["branch_retired"] = "already absent"
970
+ return
971
+ if branch in worktree_map(main_tree)[1]:
972
+ report["branch_retired"] = "refused: still checked out"
973
+ report["warnings"].append(
974
+ f"branch -d {branch} refused (still checked out?) — never -D"
975
+ )
976
+ return
977
+ p = git(["branch", "-d", branch], cwd=main_tree)
978
+ report["branch_retired"] = p.returncode == 0
979
+ if p.returncode != 0:
980
+ report["warnings"].append(
981
+ f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}"
982
+ )
983
+
984
+
985
+ def recover_canonical_cleanup(
986
+ branch: str,
987
+ wt: str | None,
988
+ wt_exists: bool,
989
+ main_tree: str,
990
+ ) -> dict:
991
+ """Resume post-merge teardown after canonical cleanup-policy drift.
992
+
993
+ Canonical drift keeps the normal path fail-closed: the attempt is bound to
994
+ the policy that nominated its evidence, and that binding is never relaxed.
995
+ This route is the supported way out, not a bypass — it re-reads the merged
996
+ canonical policy, requires the branch to already be an ancestor of canonical
997
+ `origin/main`, and revalidates every frozen identity against that canonical
998
+ policy before the same shared assessment and removal primitives run. Nothing
999
+ outside the revalidated evidence can be deleted, and rerunning it after a
1000
+ successful teardown is a no-op.
1001
+ """
1002
+ report: dict = {
1003
+ "recovery": "canonical-cleanup",
1004
+ "branch": branch,
1005
+ "warnings": [],
1006
+ }
1007
+ git(["fetch", "origin", "main"], cwd=main_tree, check=True)
1008
+ if not wt_exists:
1009
+ report["worktree_removed"] = None
1010
+ report["cleanup_guard"] = None
1011
+ retire_local_branch(branch, main_tree, report)
1012
+ report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1013
+ check=True).stdout.strip()
1014
+ return report
1015
+ assert wt is not None
1016
+ if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
1017
+ raise Stop("recover-cleanup", "worktree dirty — run `commit` first", wt)
1018
+ if git(["merge-base", "--is-ancestor", branch, "origin/main"],
1019
+ cwd=main_tree).returncode != 0:
1020
+ raise Stop(
1021
+ "recover-cleanup",
1022
+ "branch is not merged into canonical origin/main — canonical cleanup "
1023
+ "recovery only resumes an already-merged teardown",
1024
+ branch,
1025
+ )
1026
+ evidence = canonical_recovery_evidence(wt, main_tree)
1027
+ assessment = ensure_worktree_removable(
1028
+ wt,
1029
+ main_tree,
1030
+ verified_scratch_evidence=evidence,
1031
+ profile_loader=load_canonical_recovery_profile,
1032
+ )
1033
+ report["cleanup_guard"] = {
1034
+ "assumptions_read": bool(assessment.assumptions),
1035
+ "landing_generated_files": [item["path"] for item in evidence],
1036
+ }
1037
+ report["killed_processes"] = kill_worktree_processes(wt)
1038
+ remove_verified_worktree_scratch(
1039
+ wt,
1040
+ main_tree,
1041
+ assessment,
1042
+ verified_scratch_evidence=evidence,
1043
+ profile_loader=load_canonical_recovery_profile,
1044
+ )
1045
+ p = git(["worktree", "remove", wt], cwd=main_tree)
1046
+ if p.returncode != 0:
1047
+ raise Stop("recover-cleanup", "git worktree remove refused — no --force; "
1048
+ "check for surviving processes (lsof/pgrep)",
1049
+ (p.stderr or p.stdout).strip()[-1000:])
1050
+ git(["worktree", "prune"], cwd=main_tree)
1051
+ report["worktree_removed"] = wt
1052
+ retire_local_branch(branch, main_tree, report)
1053
+ report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
1054
+ check=True).stdout.strip()
1055
+ return report
1056
+
1057
+
923
1058
  def cmd_land(args) -> dict:
924
1059
  report: dict = {"stops": [], "warnings": []}
925
1060
  main_tree, branches = worktree_map()
@@ -931,13 +1066,13 @@ def cmd_land(args) -> dict:
931
1066
  branch = args.branch
932
1067
  wt = branches.get(branch)
933
1068
  wt_exists = wt is not None and Path(wt).is_dir()
1069
+ if getattr(args, "recover_canonical_cleanup", False):
1070
+ return recover_canonical_cleanup(branch, wt, wt_exists, main_tree)
934
1071
  profile = load_profile()
935
1072
  default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
936
1073
 
937
1074
  # drift markers from the build-time log — mechanical, no gate
938
1075
  markers: list[dict] = []
939
- artifact_baseline_digest: str | None = None
940
- landing_start_files: tuple[str, ...] = ()
941
1076
  landing_attempt: dict | None = None
942
1077
  generated_evidence: tuple[dict, ...] = ()
943
1078
  if wt_exists:
@@ -959,16 +1094,18 @@ def cmd_land(args) -> dict:
959
1094
  raise Stop(
960
1095
  "cleanup",
961
1096
  "unfinished landing generator attempt has no frozen output evidence; "
962
- "classify its files, then rerun with --abandon-unfinished-attempt",
1097
+ f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
963
1098
  )
964
- landing_start_files = tuple(landing_attempt["generatedFiles"])
965
- if landing_start_files:
1099
+ # Retained structural backstop: a journal on the active contract always
1100
+ # carries an empty `generatedFiles`, because a landing-start blocker is
1101
+ # refused before the journal is written. A non-empty list therefore means
1102
+ # tampered or migrated evidence — never a path this run may claim.
1103
+ if landing_attempt["generatedFiles"]:
966
1104
  raise Stop(
967
1105
  "cleanup",
968
1106
  "landing-start generated paths are consumer-owned and protected: "
969
- + ", ".join(landing_start_files),
1107
+ + ", ".join(landing_attempt["generatedFiles"]),
970
1108
  )
971
- artifact_baseline_digest = landing_attempt["baselineDigest"]
972
1109
  annahmen = Path(wt) / "ANNAHMEN.md"
973
1110
  if annahmen.is_file():
974
1111
  markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
@@ -1077,18 +1214,16 @@ def cmd_land(args) -> dict:
1077
1214
  verified_scratch_evidence=generated_evidence,
1078
1215
  )
1079
1216
  report["cleanup_guard"] = {
1080
- "active": cleanup is not None,
1081
- "assumptions_read": bool(cleanup and cleanup.assumptions),
1217
+ "assumptions_read": bool(cleanup.assumptions),
1082
1218
  "landing_generated_files": list(generated),
1083
1219
  }
1084
1220
  report["killed_processes"] = kill_worktree_processes(wt)
1085
- if cleanup is not None:
1086
- cleanup = remove_verified_worktree_scratch(
1087
- wt,
1088
- main_tree,
1089
- cleanup,
1090
- verified_scratch_evidence=generated_evidence,
1091
- )
1221
+ remove_verified_worktree_scratch(
1222
+ wt,
1223
+ main_tree,
1224
+ cleanup,
1225
+ verified_scratch_evidence=generated_evidence,
1226
+ )
1092
1227
  p = git(["worktree", "remove", wt], cwd=main_tree)
1093
1228
  if p.returncode != 0:
1094
1229
  raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
@@ -1216,14 +1351,24 @@ def main() -> int:
1216
1351
  l.add_argument("--body-file", help="final PR body (create or overwrite)")
1217
1352
  l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
1218
1353
  l.add_argument("--skip-malformed-drift", action="store_true")
1219
- l.add_argument(
1220
- "--abandon-unfinished-attempt",
1354
+ recovery = l.add_mutually_exclusive_group()
1355
+ recovery.add_argument(
1356
+ ABANDON_ATTEMPT_FLAG,
1221
1357
  action="store_true",
1222
1358
  help=(
1223
1359
  "archive an interrupted pre-freeze landing attempt without deleting "
1224
1360
  "or claiming its ambiguous files"
1225
1361
  ),
1226
1362
  )
1363
+ recovery.add_argument(
1364
+ RECOVER_CANONICAL_CLEANUP_FLAG,
1365
+ action="store_true",
1366
+ help=(
1367
+ "after canonical cleanup-policy drift, revalidate the frozen landing "
1368
+ "evidence against canonical origin/main and resume the merged "
1369
+ "worktree's teardown idempotently"
1370
+ ),
1371
+ )
1227
1372
  args = ap.parse_args()
1228
1373
 
1229
1374
  try:
package/src/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './lib/contributionRouting.mjs';
17
17
  import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
18
18
  import { nonInteractiveUpdateDecision } from './lib/updateDecisions.mjs';
19
+ import { renderRequiredMigration } from './lib/consumerMigrations.mjs';
19
20
  import { currentAgentSurface } from './lib/agentSurfaceRegistry.mjs';
20
21
  import { createCommandAdapter } from '../scripts/release-state.mjs';
21
22
  import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
@@ -43,6 +44,8 @@ export async function runCli({
43
44
  const keepDeleted = args.includes('--keep-deleted');
44
45
  const restoreDeleted = args.includes('--restore-deleted');
45
46
  const ownershipState = args.find((arg) => arg.startsWith('--as='))?.slice('--as='.length);
47
+ // Machine-readable rendering of the same update record the interactive run prints.
48
+ const jsonReport = cmd === 'update' && args.includes('--json');
46
49
  let exitCode = 0;
47
50
 
48
51
  if (cmd === 'update' && keepDeleted && restoreDeleted) {
@@ -57,7 +60,7 @@ export async function runCli({
57
60
  return 1;
58
61
  }
59
62
 
60
- p.intro('agent-workflow-kit');
63
+ if (!jsonReport) p.intro('agent-workflow-kit');
61
64
 
62
65
  try {
63
66
  if (cmd === 'init') {
@@ -94,6 +97,11 @@ export async function runCli({
94
97
  releaseIdentities,
95
98
  routingProfile: routingProfileOptions(yes),
96
99
  });
100
+ if (jsonReport) {
101
+ process.stdout.write(`${JSON.stringify(updateDocument(r), null, 2)}\n`);
102
+ if (r.state === 'failed') return 1;
103
+ return r.state === 'conflicted' ? 2 : 0;
104
+ }
97
105
  printPlan(r);
98
106
  printRoutingProfile(r.routingProfile);
99
107
  for (const c of r.conflicts) p.note(c.diff || '(binary/!text)', `conflict (not applied): ${c.path}`);
@@ -183,12 +191,31 @@ export async function runCli({
183
191
  p.outro('');
184
192
  }
185
193
  } catch (err) {
194
+ if (jsonReport) {
195
+ process.stdout.write(`${JSON.stringify({
196
+ schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION, state: 'failed', error: err.message,
197
+ }, null, 2)}\n`);
198
+ return 1;
199
+ }
186
200
  p.cancel(`Error: ${err.message}`);
187
201
  return 1;
188
202
  }
189
203
  return exitCode;
190
204
  }
191
205
 
206
+ const UPDATE_DOCUMENT_SCHEMA_VERSION = 1;
207
+
208
+ /** The single structured update record; `printPlan` renders the same source. */
209
+ function updateDocument(r) {
210
+ return {
211
+ schemaVersion: UPDATE_DOCUMENT_SCHEMA_VERSION,
212
+ state: r.state,
213
+ status: r.status ?? null,
214
+ report: r.report,
215
+ ...(r.error ? { error: renderUpdateFailure(r) } : {}),
216
+ };
217
+ }
218
+
192
219
  const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
193
220
  if (invokedPath === fileURLToPath(import.meta.url)) {
194
221
  process.exitCode = await runCli();
@@ -211,6 +238,10 @@ function printPlan(r) {
211
238
  lines.push(`${label}: ${r.availability[key].join(', ') || 'none'}`);
212
239
  }
213
240
  }
241
+ for (const action of r.requiredMigrations ?? []) {
242
+ lines.push(renderRequiredMigration(action));
243
+ lines.push(` ${action.consequence} ${action.remediation}`);
244
+ }
214
245
  for (const owned of r.ownedDiffs ?? []) {
215
246
  lines.push(`${owned.state} ${owned.path}`);
216
247
  if (owned.binary) {
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  inspectRoutingProfile, reconcileRoutingProfile,
16
16
  } from '../lib/routingProfile.mjs';
17
+ import { evaluateConsumerMigrations } from '../lib/consumerMigrations.mjs';
17
18
 
18
19
  const RELEASE_NAME = '@ikon85/agent-workflow-kit';
19
20
 
@@ -75,7 +76,14 @@ async function updatePackage(options) {
75
76
  if (!decisions.has(key)) decisions.set(key, await decide(action, path));
76
77
  return decisions.get(key);
77
78
  };
79
+ // Required consumer migrations are decisions the consumer still owes; `update`
80
+ // never writes the consumer's project layer, so the pre-apply reading is also
81
+ // the post-apply state. Detected once, rendered by every surface.
82
+ const requiredMigrations = await evaluateConsumerMigrations({
83
+ consumerRoot, kitVersion: pkg.kitVersion,
84
+ });
78
85
  const preview = await reconcile({ kitRoot, consumerRoot, decide: choosePreview, dryRun: true });
86
+ preview.requiredMigrations = requiredMigrations;
79
87
  let previewFailure;
80
88
  try {
81
89
  Object.assign(preview, await previewReadinessAdoption({
@@ -92,7 +100,7 @@ async function updatePackage(options) {
92
100
  failure: { phase: 'staging', consumerState: 'unchanged' },
93
101
  };
94
102
  }
95
- if (dryRun) return { ...preview, state: 'preview', history };
103
+ if (dryRun) return { ...preview, state: 'preview', history, report: reportOf(preview) };
96
104
  if (preview.migrationConflicts?.length) {
97
105
  return terminal(preview, 'conflicted', history, transition);
98
106
  }
@@ -100,6 +108,7 @@ async function updatePackage(options) {
100
108
  const resolvedPreview = await resolvePreview({
101
109
  kitRoot, consumerRoot, preview, decisions, decide, transition,
102
110
  });
111
+ resolvedPreview.requiredMigrations = requiredMigrations;
103
112
  if (resolvedPreview.collisions.length) {
104
113
  return terminal(resolvedPreview, 'conflicted', history, transition);
105
114
  }
@@ -266,28 +275,28 @@ function hasUpstreamDelta(result) {
266
275
 
267
276
  async function terminal(result, state, history, transition) {
268
277
  await transition(state);
278
+ return { ...result, state, history, report: reportOf(result) };
279
+ }
280
+
281
+ function reportOf(result) {
269
282
  return {
270
- ...result,
271
- state,
272
- history,
273
- report: {
274
- unchanged: result.unchanged.length,
275
- added: result.added.length,
276
- updated: result.updated.length,
277
- deleted: result.deleted.length,
278
- localModified: result.userModified.length,
279
- conflicts: result.conflicts.length,
280
- keptDeleted: result.keptDeleted.length,
281
- paths: {
282
- added: result.added,
283
- updated: result.updated,
284
- deleted: result.deleted,
285
- localModified: result.userModified,
286
- conflicts: result.conflicts.map(({ path }) => path),
287
- keptDeleted: result.keptDeleted,
288
- },
289
- recommendation: updateRecommendation(result),
283
+ unchanged: result.unchanged.length,
284
+ added: result.added.length,
285
+ updated: result.updated.length,
286
+ deleted: result.deleted.length,
287
+ localModified: result.userModified.length,
288
+ conflicts: result.conflicts.length,
289
+ keptDeleted: result.keptDeleted.length,
290
+ paths: {
291
+ added: result.added,
292
+ updated: result.updated,
293
+ deleted: result.deleted,
294
+ localModified: result.userModified,
295
+ conflicts: result.conflicts.map(({ path }) => path),
296
+ keptDeleted: result.keptDeleted,
290
297
  },
298
+ recommendation: updateRecommendation(result),
299
+ requiredMigrations: result.requiredMigrations ?? [],
291
300
  };
292
301
  }
293
302
 
@@ -0,0 +1,19 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "migrations": [
4
+ {
5
+ "id": "wrapup-landing-artifact-policy",
6
+ "requiredFrom": "0.38.0",
7
+ "title": "Explicit landing-artifact policy",
8
+ "workflow": "setup-workflow",
9
+ "decision": "wrapup.landingGeneratedArtifactPatterns",
10
+ "detect": {
11
+ "type": "json-key",
12
+ "path": "docs/agents/workflow-capabilities.json",
13
+ "key": ["wrapup", "landingGeneratedArtifactPatterns"]
14
+ },
15
+ "consequence": "wrapup-land fails closed before landing until the decision is committed.",
16
+ "remediation": "Run setup-workflow, review the wrapup.landingGeneratedArtifactPatterns decision it writes into docs/agents/workflow-capabilities.json, and commit that profile change. kit-update never chooses cleanup patterns for you."
17
+ }
18
+ ]
19
+ }
@@ -16,6 +16,10 @@ export const HELPER_FILES = [
16
16
  // every board-specific value from docs/agents/board-sync.md through it, so it
17
17
  // MUST ship or they are broken-on-arrival. Library (imported, not run) → 0o644.
18
18
  { path: 'scripts/board_config.py', kind: 'script', mode: 0o644 },
19
+ // Offered board provisioning for /setup-workflow Section D: creates the
20
+ // project + Status/workflow fields, reads the opaque ids back, and writes the
21
+ // filled profile. Imports board_config (above). Invokable CLI → 0o755.
22
+ { path: 'scripts/board_bootstrap.py', kind: 'script', mode: 0o755 },
19
23
  // Pure Slices-table logic imported by board-sync.py for `anchor-sync` —
20
24
  // library (imported, not run) → 0o644. MUST ship or board-sync.py ImportErrors.
21
25
  { path: 'scripts/anchor_table.py', kind: 'script', mode: 0o644 },
@@ -127,6 +131,12 @@ export const HELPER_FILES = [
127
131
  { path: 'scripts/memory-lifecycle/setup.mjs', kind: 'script', mode: 0o644 },
128
132
  { path: 'assets/memory-templates/meta_decision_layer_choice.md', kind: 'template', mode: 0o644 },
129
133
  { path: 'assets/memory-templates/meta_memory_lifecycle.md', kind: 'template', mode: 0o644 },
134
+ // The one repository-relative glob dialect. Both capability cores below load
135
+ // it, so a consumer profile pattern selects the same paths for an advisory
136
+ // and for a cleanup decision. Also the reviewable migration check consumers
137
+ // run after setup or update, so a narrowed or widened pattern is named
138
+ // instead of silently changing deletion authority. Library + CLI → 0o755.
139
+ { path: 'scripts/profile_globs.py', kind: 'script', mode: 0o755 },
130
140
  // Profile-driven Worktree Lifecycle foundation. The setup adapter imports
131
141
  // core.py, while capabilities.json keeps the historical 8/8 denominator
132
142
  // explicit until the remaining hook and cleanup adapters are activated.