@zq-silk/yui 0.11.2 → 0.12.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 (91) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +80 -13
  3. package/dist/cli/commandCatalog.js +30 -5
  4. package/dist/cli.js +105 -43
  5. package/dist/commands/configCommands.js +12 -45
  6. package/dist/commands/executionAuditCommands.js +1 -1
  7. package/dist/commands/jobCommands.js +4 -10
  8. package/dist/commands/projectCommands.js +581 -2
  9. package/dist/commands/taskCommands.js +159 -172
  10. package/dist/commands/taskCompletionGate.js +29 -54
  11. package/dist/commands/taskContextCommand.js +55 -11
  12. package/dist/commands/taskInputCommands.js +8 -5
  13. package/dist/commands/taskIntegrationCommands.js +2 -1
  14. package/dist/commands/taskNextActionCommand.js +56 -6
  15. package/dist/commands/taskOverviewCommand.js +17 -31
  16. package/dist/commands/taskRoleRuntimeStatus.js +24 -0
  17. package/dist/commands/taskWorkspaceCommands.js +5 -2
  18. package/dist/config/configCatalog.js +2 -2
  19. package/dist/config/yuiConfig.js +3 -7
  20. package/dist/context/wakeNotification.js +20 -5
  21. package/dist/controller/agentRuntimeObserver.js +247 -51
  22. package/dist/controller/clientRuntime.js +1 -1
  23. package/dist/controller/controller.js +8 -12
  24. package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
  25. package/dist/controller/runtime.js +3 -3
  26. package/dist/controller/runtimeEventInbox.js +17 -7
  27. package/dist/controller/runtimeEventProcessor.js +12 -19
  28. package/dist/controller/runtimeObservationHook.js +45 -0
  29. package/dist/execution/resourceBroker.js +5 -4
  30. package/dist/executor/agentExecutor.js +4 -4
  31. package/dist/executor/effectiveLaunch.js +8 -48
  32. package/dist/executor/fileRoleLaunchPlanner.js +36 -16
  33. package/dist/executor/workspacePreflightClassification.js +23 -2
  34. package/dist/interaction/operatorPresentation.js +33 -89
  35. package/dist/lifecycle/exactRunTerminalization.js +1 -2
  36. package/dist/lifecycle/taskRoleSessionReset.js +5 -13
  37. package/dist/observability/orchestrationMetrics.js +8 -3
  38. package/dist/profile/agentProfile.js +1 -1
  39. package/dist/release/cliHomeReleaseFence.js +123 -0
  40. package/dist/repository/checkoutSwap.js +61 -0
  41. package/dist/repository/gitWorkspace.js +36 -0
  42. package/dist/repository/project.js +53 -3
  43. package/dist/repository/taskWorkspacePreparer.js +176 -60
  44. package/dist/resources/sqliteResourceRegistry.js +1 -1
  45. package/dist/review/deltaRecheck.js +12 -51
  46. package/dist/review/reviewAcceptance.js +26 -0
  47. package/dist/review/reviewConfig.js +0 -31
  48. package/dist/review/reviewDecision.js +113 -0
  49. package/dist/review/reviewOutcomeClassifier.js +1 -1
  50. package/dist/review/reviewRound.js +1 -1
  51. package/dist/review/reviewerAvailability.js +69 -0
  52. package/dist/runtime/agentDriverObservation.js +24 -10
  53. package/dist/runtime/builtinAgentDrivers.js +4 -3
  54. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  55. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  56. package/dist/runtime/index.js +1 -0
  57. package/dist/runtime/runtimeObservation.js +13 -0
  58. package/dist/runtime/runtimeProjection.js +16 -25
  59. package/dist/runtime/sessionTokenMetrics.js +181 -0
  60. package/dist/runtime/structuredProviderHost.js +7 -1
  61. package/dist/scheduler/activeRoleRunDelivery.js +4 -4
  62. package/dist/scheduler/activeTaskProgress.js +7 -6
  63. package/dist/scheduler/leaderWakeupProcessor.js +3 -5
  64. package/dist/scheduler/operatorEvent.js +34 -0
  65. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  66. package/dist/scheduler/roleRunStall.js +0 -14
  67. package/dist/scheduler/taskExecutionProjection.js +97 -76
  68. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  69. package/dist/storage/migration/productionRegistry.js +467 -0
  70. package/dist/storage/sqliteSchema.js +18 -8
  71. package/dist/storage/sqliteStore.js +61 -21
  72. package/dist/storage/storeRpc.js +0 -1
  73. package/dist/storage/taskStore.js +47 -31
  74. package/dist/storage/upgrade/recordVersions.js +3 -2
  75. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  76. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  77. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  78. package/dist/task/completionReadiness.js +10 -6
  79. package/dist/task/nextAction.js +82 -24
  80. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  81. package/dist/web/assets/client/components.js +13 -3
  82. package/dist/web/assets/client/i18n.js +6 -2
  83. package/dist/web/webSnapshot.js +4 -0
  84. package/i18n/README.zh-CN.md +2 -2
  85. package/package.json +1 -1
  86. package/skills/yui-leader/SKILL.md +53 -16
  87. package/skills/yui-operator/SKILL.md +9 -0
  88. package/skills/yui-reviewer/SKILL.md +28 -7
  89. package/dist/context/sessionContextBudget.js +0 -71
  90. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  91. package/dist/scheduler/operatorNotification.js +0 -59
@@ -1,11 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readdir, rm } from "node:fs/promises";
2
+ import { readdir, rename, rm } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { usageError } from "../errors/cliError.js";
5
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
6
+ import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../repository/checkoutSwap.js";
6
7
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
7
8
  import { acquireProjectMaintenanceLock } from "../repository/projectMaintenanceLock.js";
8
- import { addProjectKnowledge, addKnowledgeProposal, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
9
+ import { addProjectKnowledge, addKnowledgeProposal, assertProjectActive, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
9
10
  import { projectActor } from "./taskActor.js";
10
11
  export async function runProjectCommand(args, store, options = {}) {
11
12
  const [command, ...rest] = args;
@@ -46,6 +47,18 @@ export async function runProjectCommand(args, store, options = {}) {
46
47
  data: { project }
47
48
  };
48
49
  }
50
+ if (command === "reset") {
51
+ return resetProject(rest, store, options);
52
+ }
53
+ if (command === "replace") {
54
+ return replaceProject(rest, store, options);
55
+ }
56
+ if (command === "retire") {
57
+ return retireProjectCommand(rest, store, options);
58
+ }
59
+ if (command === "delete") {
60
+ return deleteProjectCommand(rest, store, options);
61
+ }
49
62
  if (command === "discover") {
50
63
  const discovered = await discoverProjects(rest, store, options);
51
64
  return { output: renderDiscoveredProjects(discovered), data: { projects: discovered } };
@@ -77,6 +90,7 @@ async function refreshProject(args, store, options) {
77
90
  throw usageError("Project refresh usage: yui project refresh <project>.");
78
91
  }
79
92
  const project = requireProject(store, args[0]);
93
+ assertProjectActive(project, "refresh");
80
94
  if (project.remoteUrl === undefined) {
81
95
  throw usageError(`Project refresh requires a remote URL: ${project.id}.`);
82
96
  }
@@ -280,6 +294,7 @@ async function migrateProject(args, store, options) {
280
294
  const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
281
295
  const parsed = parseMigrateArguments(args, usage);
282
296
  const project = requireProject(store, parsed.project);
297
+ assertProjectActive(project, "migrate");
283
298
  if (project.ownership === "managed") {
284
299
  throw usageError(`Project is already Home-managed: ${project.id}.`);
285
300
  }
@@ -475,6 +490,7 @@ async function updateProject(args, store, options) {
475
490
  const usage = "Project update usage: yui project update <project> [--alias <name> ...|--clear-aliases] [--remote <url>|--clear-remote] [--stable <ref>] [--development <ref>].";
476
491
  const parsed = parseProjectUpdateArguments(args, usage);
477
492
  const current = requireProject(store, parsed.reference);
493
+ assertProjectActive(current, "update");
478
494
  const now = (options.now ?? (() => new Date()))();
479
495
  const patch = {
480
496
  ...(parsed.aliases === undefined ? {} : { aliases: parsed.aliases }),
@@ -514,6 +530,552 @@ async function updateProject(args, store, options) {
514
530
  return updated;
515
531
  });
516
532
  }
533
+ /**
534
+ * Lifecycle commands (reset/replace/retire/delete) are Operator authority:
535
+ * they discard commits, replace checkouts, or remove catalog records, so a
536
+ * managed Task Session may never drive them. A plain terminal is the human
537
+ * Operator; a managed global Session must be the Operator role.
538
+ */
539
+ function assertProjectOperator(options, action) {
540
+ const actor = projectActor(options.environment);
541
+ if (actor === "agent") {
542
+ throw usageError(`Project lifecycle commands are Operator authority; a managed Task Session cannot ${action} a Project. `
543
+ + "Run this command from an Operator or user terminal.");
544
+ }
545
+ }
546
+ /**
547
+ * Fail-closed gate for the destructive lifecycle commands: an active Task
548
+ * binding means the canonical checkout and catalog record are in active use,
549
+ * so reset/replace/retire refuse until the Task is settled. Historical
550
+ * (completed/retired/archived) bindings are reported but never block — their
551
+ * evidence lives in the Task records themselves.
552
+ */
553
+ function assertNoActiveTaskBinding(store, project, action) {
554
+ const references = store.summarizeProjectReferences(project.id);
555
+ if (references.activeTaskIds.length > 0) {
556
+ const delivery = [
557
+ ...references.unresolvedWorkItemRefs,
558
+ ...references.activeRunRefs,
559
+ ...references.unresolvedIntegrationRefs
560
+ ];
561
+ throw usageError(`Project cannot be ${action} while an active Task binds it: ${project.id}. `
562
+ + `Active Tasks: ${references.activeTaskIds.join(", ")}.`
563
+ + (delivery.length === 0 ? "" : ` Unresolved delivery: ${delivery.join(", ")}.`)
564
+ + " Complete, retire, or archive those Tasks first.");
565
+ }
566
+ return references;
567
+ }
568
+ /**
569
+ * Controlled reset of a canonical Project checkout to its verified remote
570
+ * baseline. Handles the local/remote divergence that `project refresh`
571
+ * (clean fast-forward only) refuses: with `--discard-local` the checkout is
572
+ * hard-reset to the fetched remote commit, explicitly discarding the local
573
+ * commits listed in the refusal. Every destructive path is fail-closed:
574
+ * Operator authority, no active Task binding, clean checkout on its stable
575
+ * branch, reachable verified remote, and the per-Project maintenance fence.
576
+ */
577
+ async function resetProject(args, store, options) {
578
+ const usage = "Project reset usage: yui project reset <project> [--discard-local].";
579
+ const parsed = parseResetArguments(args, usage);
580
+ const project = requireProject(store, parsed.project);
581
+ assertProjectOperator(options, "reset");
582
+ assertProjectActive(project, "reset");
583
+ if (project.remoteUrl === undefined) {
584
+ throw usageError(`Project reset requires a remote URL: ${project.id}.`);
585
+ }
586
+ if (project.stableBranch !== project.developmentBranch) {
587
+ throw usageError(`Project reset requires matching stable and development branches: ${project.id}.`);
588
+ }
589
+ const git = options.git ?? new NodeGitWorkspace();
590
+ const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
591
+ try {
592
+ // Re-read under the fence so a concurrent catalog change can never drive
593
+ // a destructive Git effect from a stale snapshot.
594
+ const current = requireProject(store, project.id);
595
+ assertProjectActive(current, "reset");
596
+ if (current.path !== project.path
597
+ || current.remoteUrl !== project.remoteUrl
598
+ || current.stableBranch !== project.stableBranch
599
+ || current.developmentBranch !== project.developmentBranch) {
600
+ throw new Error(`Project changed while resetting: ${project.id}.`);
601
+ }
602
+ assertNoActiveTaskBinding(store, current, "reset");
603
+ const head = await git.inspect(current.path, "HEAD");
604
+ if (!await git.isClean(head.root)) {
605
+ throw usageError(`Project checkout must be clean before reset: ${current.id}. `
606
+ + "Commit, stash, or discard uncommitted changes first.");
607
+ }
608
+ const branch = await git.headRef(head.root);
609
+ if (branch !== current.stableBranch) {
610
+ const found = branch === "HEAD" ? "detached HEAD" : branch;
611
+ throw usageError(`Project checkout must be on its stable branch ${current.stableBranch} before reset (found ${found}).`);
612
+ }
613
+ // Fetch and verify the advertised remote baseline. An unreachable remote
614
+ // or a ref that moves mid-fetch fails closed here, before any local effect.
615
+ const remote = await git.resolveRemoteBaseline({
616
+ repositoryPath: head.root,
617
+ remoteUrl: current.remoteUrl,
618
+ developmentRef: current.stableBranch
619
+ });
620
+ const localCommit = head.baseCommit.toLowerCase();
621
+ const remoteCommit = remote.commit.toLowerCase();
622
+ if (localCommit === remoteCommit) {
623
+ return {
624
+ output: `Project ${current.id} is already at the remote baseline ${remoteCommit}.\n`,
625
+ data: {
626
+ project: current,
627
+ fromCommit: localCommit,
628
+ toCommit: remoteCommit,
629
+ changed: false,
630
+ discarded: []
631
+ }
632
+ };
633
+ }
634
+ const isAncestor = await git.isAncestor(head.root, localCommit, remoteCommit);
635
+ if (!isAncestor) {
636
+ const discarded = await git.listCommitsBetween({
637
+ repositoryPath: head.root,
638
+ baseCommit: remoteCommit,
639
+ headCommit: localCommit
640
+ });
641
+ if (!parsed.discardLocal) {
642
+ throw usageError(`Project ${current.id} has diverged from ${current.remoteUrl}: local ${localCommit} `
643
+ + `is not an ancestor of remote ${remoteCommit}.\n`
644
+ + "Local commits that would be discarded:\n"
645
+ + discarded.map((entry) => ` ${entry}`).join("\n")
646
+ + "\nRe-run with --discard-local to hard-reset the checkout to the remote baseline.");
647
+ }
648
+ await git.resetToCommit({
649
+ repositoryPath: head.root,
650
+ expectedHead: localCommit,
651
+ targetCommit: remoteCommit
652
+ });
653
+ return {
654
+ output: `Reset project ${current.id}: ${localCommit} -> ${remoteCommit} `
655
+ + `(discarded ${discarded.length} local commit(s)).\n`,
656
+ data: {
657
+ project: current,
658
+ fromCommit: localCommit,
659
+ toCommit: remoteCommit,
660
+ changed: true,
661
+ discarded
662
+ }
663
+ };
664
+ }
665
+ // Behind only: the verified reset is a clean fast-forward.
666
+ await git.resetToCommit({
667
+ repositoryPath: head.root,
668
+ expectedHead: localCommit,
669
+ targetCommit: remoteCommit
670
+ });
671
+ return {
672
+ output: `Reset project ${current.id}: ${localCommit} -> ${remoteCommit} (fast-forward).\n`,
673
+ data: {
674
+ project: current,
675
+ fromCommit: localCommit,
676
+ toCommit: remoteCommit,
677
+ changed: true,
678
+ discarded: []
679
+ }
680
+ };
681
+ }
682
+ finally {
683
+ releaseMaintenance();
684
+ }
685
+ }
686
+ /**
687
+ * Replace a Home-managed canonical checkout with a fresh clone from its
688
+ * remote, preserving the catalog binding and Yui-local refs. For checkouts
689
+ * whose divergence or corruption is too severe for `project reset`. The
690
+ * discard is explicit (`--discard-local`), the remote is verified before the
691
+ * swap, and Task/Integration worktrees are protected by a linked-worktree
692
+ * gate. The swap itself is recoverable: the previous checkout is parked at a
693
+ * backup path and restored on any failure, and a crash mid-swap is healed on
694
+ * the next run (a crash before the swap leaves only a removable staging
695
+ * clone).
696
+ */
697
+ async function replaceProject(args, store, options) {
698
+ const usage = "Project replace usage: yui project replace <project> --discard-local.";
699
+ const parsed = parseReplaceArguments(args, usage);
700
+ const project = requireProject(store, parsed.project);
701
+ assertProjectOperator(options, "replace");
702
+ assertProjectActive(project, "replace");
703
+ if (project.ownership !== "managed") {
704
+ throw usageError(`Project replace only applies to Home-managed checkouts: ${project.id}. `
705
+ + "External checkouts are user-owned; re-register or repair them with `yui project update`/`yui project add`.");
706
+ }
707
+ if (project.remoteUrl === undefined) {
708
+ throw usageError(`Project replace requires a remote URL: ${project.id}.`);
709
+ }
710
+ if (!parsed.discardLocal) {
711
+ throw usageError(`Project replace discards the current checkout and re-clones from ${project.remoteUrl}. `
712
+ + "Re-run with --discard-local to acknowledge that the checkout and any uncommitted state will be discarded.");
713
+ }
714
+ const git = options.git ?? new NodeGitWorkspace();
715
+ const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
716
+ try {
717
+ const current = requireProject(store, project.id);
718
+ assertProjectActive(current, "replace");
719
+ if (current.path !== project.path
720
+ || current.remoteUrl !== project.remoteUrl
721
+ || current.ownership !== project.ownership
722
+ || current.stableBranch !== project.stableBranch
723
+ || current.developmentBranch !== project.developmentBranch) {
724
+ throw new Error(`Project changed while replacing: ${project.id}.`);
725
+ }
726
+ assertNoActiveTaskBinding(store, current, "replace");
727
+ const backup = join(store.rootDirectory(), "projects", `.replace-backup-${current.id}`);
728
+ // Heal a crashed earlier swap before any new destructive step, so the
729
+ // prechecks below always see a valid checkout.
730
+ await healCheckoutSwap({ currentPath: current.path, backupPath: backup });
731
+ // A linked worktree (Task/Integration workspace) shares this repository's
732
+ // object store and would be broken by a wholesale checkout replacement.
733
+ const worktrees = await git.listWorktrees(current.path);
734
+ const linked = worktrees.filter((path) => path !== current.path);
735
+ if (linked.length > 0) {
736
+ throw usageError(`Project checkout has linked worktrees that would break: ${current.id}.\n`
737
+ + linked.map((path) => ` ${path}`).join("\n")
738
+ + "\nClean up Task/Integration workspaces first (e.g. `yui task work cleanup`, `yui task integration cleanup`).");
739
+ }
740
+ if (!await git.isClean(current.path)) {
741
+ throw usageError(`Project checkout must be clean before replace: ${current.id}. `
742
+ + "Commit, stash, or discard uncommitted changes first.");
743
+ }
744
+ const staging = join(store.rootDirectory(), "projects", `.replace-${current.id}`);
745
+ if (existsSync(staging)) {
746
+ // A crashed earlier attempt can only leave its own staging clone behind.
747
+ await rm(staging, { recursive: true, force: true });
748
+ }
749
+ let prepared = false;
750
+ try {
751
+ const head = await git.clone({
752
+ remoteUrl: current.remoteUrl,
753
+ destination: staging,
754
+ ...(current.stableBranch === "HEAD" ? {} : { branch: current.stableBranch })
755
+ });
756
+ prepared = true;
757
+ const stable = current.stableBranch === "HEAD"
758
+ ? head
759
+ : await git.inspect(staging, current.stableBranch);
760
+ if (head.baseCommit !== stable.baseCommit) {
761
+ throw new Error(`Project checkout is not on its stable ref: ${current.stableBranch}.`);
762
+ }
763
+ if (current.developmentBranch !== current.stableBranch) {
764
+ await git.ensureLocalBranch(staging, current.developmentBranch);
765
+ }
766
+ await assertRemoteBranchesVerified(git, staging, current.remoteUrl, [
767
+ { ref: current.stableBranch, localCommit: head.baseCommit },
768
+ ...(current.developmentBranch === current.stableBranch
769
+ ? []
770
+ : [{
771
+ ref: current.developmentBranch,
772
+ localCommit: (await git.inspect(staging, current.developmentBranch)).baseCommit
773
+ }])
774
+ ]);
775
+ // Preserve Yui-local refs (Task branches, archive refs) so historical
776
+ // evidence keeps resolving after the checkout is replaced.
777
+ const copyRefs = git.copyRefs;
778
+ if (typeof copyRefs !== "function") {
779
+ throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
780
+ }
781
+ await copyRefs.call(git, {
782
+ sourceRepositoryPath: current.path,
783
+ destinationRepositoryPath: staging,
784
+ patterns: ["refs/heads/yui/", "refs/yui/archive/"]
785
+ });
786
+ const oldHead = (await git.inspect(current.path, "HEAD")).baseCommit;
787
+ // Swap the checkout on disk without ever leaving the registered path
788
+ // without a repository: the previous checkout is parked at the backup
789
+ // path first and restored on any failure. The backup is removed only
790
+ // after the catalog transaction commits, so a crash in between stays
791
+ // recoverable (healed at the top of the next run).
792
+ await swapManagedCheckout({ currentPath: current.path, stagingPath: staging, backupPath: backup });
793
+ let switched;
794
+ try {
795
+ switched = store.transaction((tx) => {
796
+ const latest = requireProject(tx, current.id);
797
+ if (latest.path !== current.path || latest.ownership !== current.ownership) {
798
+ throw new Error(`Project changed while replacing: ${current.id}.`);
799
+ }
800
+ const next = validateProject({
801
+ ...latest,
802
+ updatedAt: (options.now ?? (() => new Date()))().toISOString()
803
+ });
804
+ tx.saveProject(next);
805
+ return next;
806
+ });
807
+ }
808
+ catch (error) {
809
+ try {
810
+ await restoreCheckoutSwap({ currentPath: current.path, backupPath: backup });
811
+ }
812
+ catch (rollbackError) {
813
+ throw new Error(`Project replace failed and checkout rollback was incomplete: ${messageOf(error)}; `
814
+ + `rollback failed: ${messageOf(rollbackError)}. `
815
+ + `The previous checkout remains parked at ${backup}.`);
816
+ }
817
+ throw error;
818
+ }
819
+ let backupLeftover = false;
820
+ try {
821
+ await rm(backup, { recursive: true, force: true });
822
+ }
823
+ catch {
824
+ backupLeftover = true;
825
+ }
826
+ const newHead = (await git.inspect(current.path, "HEAD")).baseCommit;
827
+ return {
828
+ output: `Replaced project ${current.id} checkout: ${oldHead} -> ${newHead} `
829
+ + `(re-cloned from ${current.remoteUrl}).\n`
830
+ + (backupLeftover
831
+ ? `The previous checkout could not be removed; delete it manually: ${backup}.\n`
832
+ : ""),
833
+ data: { project: switched, fromCommit: oldHead, toCommit: newHead }
834
+ };
835
+ }
836
+ catch (error) {
837
+ if (prepared && existsSync(staging)) {
838
+ await rm(staging, { recursive: true, force: true });
839
+ }
840
+ throw error;
841
+ }
842
+ }
843
+ finally {
844
+ releaseMaintenance();
845
+ }
846
+ }
847
+ /**
848
+ * Auditable soft deprecation. The catalog record, checkout, and every
849
+ * historical Task/Run/Review/Integration/Publication reference are retained;
850
+ * the Project can no longer be bound to new work or maintained. Hard removal
851
+ * is a separate `project delete` decision.
852
+ */
853
+ function retireProjectCommand(args, store, options) {
854
+ const usage = "Project retire usage: yui project retire <project> --reason <text>.";
855
+ const parsed = parseRetireArguments(args, usage);
856
+ const project = requireProject(store, parsed.project);
857
+ assertProjectOperator(options, "retire");
858
+ assertProjectActive(project, "retire");
859
+ const actor = projectActor(options.environment);
860
+ const retiredBy = actor === "operator" ? "operator" : "user";
861
+ const now = (options.now ?? (() => new Date()))();
862
+ const retired = store.transaction((tx) => {
863
+ const latest = requireProject(tx, project.id);
864
+ assertProjectActive(latest, "retire");
865
+ assertNoActiveTaskBinding(tx, latest, "retire");
866
+ const next = retireProject(latest, parsed.reason, retiredBy, now);
867
+ tx.saveProject(next);
868
+ return { project: next, references: tx.summarizeProjectReferences(next.id) };
869
+ });
870
+ const lines = [
871
+ `Retired project ${retired.project.id} (${retired.project.name})`,
872
+ `Reason: ${parsed.reason}`,
873
+ `Retired by: ${retiredBy} at ${retired.project.retirement?.retiredAt ?? "?"}`,
874
+ "The catalog record, checkout, and historical Task/Run/Review/Integration/Publication evidence are retained.",
875
+ ...(retired.references.boundTaskIds.length === 0
876
+ ? []
877
+ : [`Historical Task bindings retained: ${retired.references.boundTaskIds.join(", ")}.`]),
878
+ "Use `yui project delete` to remove the catalog record once no Task references it."
879
+ ];
880
+ return {
881
+ output: lines.join("\n").concat("\n"),
882
+ data: { project: retired.project, references: retired.references }
883
+ };
884
+ }
885
+ /**
886
+ * Hard removal of a retired Project catalog record, optionally including its
887
+ * Home-managed checkout. Deletion is deliberately two-phase (retire first)
888
+ * and doubly acknowledged (--confirm with the exact Project id), and it fails
889
+ * closed while any Task record references the Project so historical evidence
890
+ * stays resolvable. With `--checkout`, every fail-closed precheck (linked
891
+ * worktrees, dirty checkout) runs before any destruction, and the checkout is
892
+ * moved to a tombstone before the catalog record is removed; any failure
893
+ * restores it, so catalog and checkout never disagree unrecoverably.
894
+ */
895
+ async function deleteProjectCommand(args, store, options) {
896
+ const usage = "Project delete usage: yui project delete <project> [--checkout] --confirm <project-id>.";
897
+ const parsed = parseDeleteArguments(args, usage);
898
+ const project = requireProject(store, parsed.project);
899
+ assertProjectOperator(options, "delete");
900
+ if (project.status !== "retired") {
901
+ throw usageError(`Project must be retired before it can be deleted: ${project.id}. `
902
+ + "Use `yui project retire <project> --reason <text>` first; retirement keeps the audit trail.");
903
+ }
904
+ if (parsed.confirm !== project.id) {
905
+ throw usageError(`Project delete requires --confirm ${project.id} to acknowledge removal of the catalog record`
906
+ + `${parsed.checkout ? " and the checkout" : ""}.`);
907
+ }
908
+ if (parsed.checkout && project.ownership !== "managed") {
909
+ throw usageError(`Project delete --checkout only applies to Home-managed checkouts: ${project.id}. `
910
+ + "External checkouts are user-owned; remove them manually after deleting the binding.");
911
+ }
912
+ if (!parsed.checkout) {
913
+ const removed = removeRetiredProjectRecord(store, project.id);
914
+ return {
915
+ output: `Deleted project ${removed.id} (catalog record; checkout retained at ${removed.path}).\n`,
916
+ data: { project: removed, checkoutRemoved: false }
917
+ };
918
+ }
919
+ // --checkout: prechecks first, then a rename/tombstone two-phase flow. The
920
+ // checkout moves to a tombstone before the catalog record is removed, and
921
+ // every failure restores it, so the catalog never loses its recoverable
922
+ // entry while a live checkout (or its failure) is still in play.
923
+ const tombstone = join(store.rootDirectory(), "projects", `.delete-${project.id}`);
924
+ const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
925
+ try {
926
+ // Heal a crashed earlier attempt before the prechecks run.
927
+ await healCheckoutSwap({ currentPath: project.path, backupPath: tombstone });
928
+ if (existsSync(project.path)) {
929
+ const git = options.git ?? new NodeGitWorkspace();
930
+ const worktrees = await git.listWorktrees(project.path);
931
+ const linked = worktrees.filter((path) => path !== project.path);
932
+ if (linked.length > 0) {
933
+ throw usageError(`Project checkout has linked worktrees that would break: ${project.id}.\n`
934
+ + linked.map((path) => ` ${path}`).join("\n")
935
+ + "\nClean up Task/Integration workspaces first (e.g. `yui task work cleanup`, `yui task integration cleanup`).");
936
+ }
937
+ if (!await git.isClean(project.path)) {
938
+ throw usageError(`Project checkout must be clean before delete: ${project.id}. `
939
+ + "Commit, stash, or discard uncommitted changes first, or delete the catalog record only without --checkout.");
940
+ }
941
+ // Phase 1: park the checkout. The catalog record still exists, so
942
+ // phase 2 must complete or roll the rename back.
943
+ await rename(project.path, tombstone);
944
+ }
945
+ // Phase 2: remove the catalog record, restoring the checkout on failure.
946
+ let removed;
947
+ try {
948
+ removed = removeRetiredProjectRecord(store, project.id);
949
+ }
950
+ catch (error) {
951
+ if (existsSync(tombstone) && !existsSync(project.path)) {
952
+ try {
953
+ await rename(tombstone, project.path);
954
+ }
955
+ catch (rollbackError) {
956
+ throw new Error(`Project delete failed and checkout rollback was incomplete: ${messageOf(error)}; `
957
+ + `rollback failed: ${messageOf(rollbackError)}. `
958
+ + `The checkout remains parked at ${tombstone}.`);
959
+ }
960
+ }
961
+ throw error;
962
+ }
963
+ // Phase 3: the catalog record is gone, so the tombstone is unreferenced
964
+ // garbage. Removal is best-effort; a leftover is reported, never silent.
965
+ let tombstoneLeftover = false;
966
+ if (existsSync(tombstone)) {
967
+ try {
968
+ await rm(tombstone, { recursive: true, force: true });
969
+ }
970
+ catch {
971
+ tombstoneLeftover = true;
972
+ }
973
+ }
974
+ return {
975
+ output: `Deleted project ${removed.id} and its checkout at ${removed.path}.\n`
976
+ + (tombstoneLeftover
977
+ ? `The checkout tombstone could not be removed; delete it manually: ${tombstone}.\n`
978
+ : ""),
979
+ data: { project: removed, checkoutRemoved: true }
980
+ };
981
+ }
982
+ finally {
983
+ releaseMaintenance();
984
+ }
985
+ }
986
+ /**
987
+ * The catalog side of `project delete`: re-read under a transaction, re-assert
988
+ * the retired state and the absence of any Task reference, then remove the
989
+ * record. Fails closed with the record intact on every refusal.
990
+ */
991
+ function removeRetiredProjectRecord(store, projectId) {
992
+ return store.transaction((tx) => {
993
+ const latest = requireProject(tx, projectId);
994
+ if (latest.status !== "retired") {
995
+ throw new Error(`Project changed while deleting: ${projectId}.`);
996
+ }
997
+ const references = tx.summarizeProjectReferences(latest.id);
998
+ if (references.boundTaskIds.length > 0) {
999
+ throw usageError(`Project cannot be deleted while Task records reference it: ${latest.id}. `
1000
+ + `Bound Tasks: ${references.boundTaskIds.join(", ")}. `
1001
+ + "Historical Task/Run/Review/Integration/Publication evidence must stay resolvable; keep the Project retired instead.");
1002
+ }
1003
+ if (!tx.removeProject(latest.id)) {
1004
+ throw new Error(`Project was already removed: ${latest.id}.`);
1005
+ }
1006
+ return latest;
1007
+ });
1008
+ }
1009
+ function parseResetArguments(args, usage) {
1010
+ const positionals = [];
1011
+ let discardLocal = false;
1012
+ for (const value of args) {
1013
+ if (value === "--discard-local") {
1014
+ discardLocal = true;
1015
+ continue;
1016
+ }
1017
+ if (value.startsWith("--"))
1018
+ throw usageError(`Unknown option: ${value}. ${usage}`);
1019
+ positionals.push(value);
1020
+ }
1021
+ if (positionals.length !== 1)
1022
+ throw usageError(usage);
1023
+ return { project: requireText(positionals[0], "Project reference"), discardLocal };
1024
+ }
1025
+ function parseReplaceArguments(args, usage) {
1026
+ return parseResetArguments(args, usage);
1027
+ }
1028
+ function parseRetireArguments(args, usage) {
1029
+ const positionals = [];
1030
+ let reason;
1031
+ for (let index = 0; index < args.length; index += 1) {
1032
+ const value = args[index];
1033
+ if (value === "--reason") {
1034
+ const next = args[index + 1];
1035
+ if (next === undefined || next.startsWith("--"))
1036
+ throw usageError(`--reason is required. ${usage}`);
1037
+ reason = requireText(next, "--reason");
1038
+ index += 1;
1039
+ continue;
1040
+ }
1041
+ if (value.startsWith("--"))
1042
+ throw usageError(`Unknown option: ${value}. ${usage}`);
1043
+ positionals.push(value);
1044
+ }
1045
+ if (positionals.length !== 1)
1046
+ throw usageError(usage);
1047
+ if (reason === undefined)
1048
+ throw usageError(`--reason is required. ${usage}`);
1049
+ return { project: requireText(positionals[0], "Project reference"), reason };
1050
+ }
1051
+ function parseDeleteArguments(args, usage) {
1052
+ const positionals = [];
1053
+ let checkout = false;
1054
+ let confirm;
1055
+ for (let index = 0; index < args.length; index += 1) {
1056
+ const value = args[index];
1057
+ if (value === "--checkout") {
1058
+ checkout = true;
1059
+ continue;
1060
+ }
1061
+ if (value === "--confirm") {
1062
+ const next = args[index + 1];
1063
+ if (next === undefined || next.startsWith("--"))
1064
+ throw usageError(`--confirm is required. ${usage}`);
1065
+ confirm = requireText(next, "--confirm");
1066
+ index += 1;
1067
+ continue;
1068
+ }
1069
+ if (value.startsWith("--"))
1070
+ throw usageError(`Unknown option: ${value}. ${usage}`);
1071
+ positionals.push(value);
1072
+ }
1073
+ if (positionals.length !== 1)
1074
+ throw usageError(usage);
1075
+ if (confirm === undefined)
1076
+ throw usageError(`--confirm is required. ${usage}`);
1077
+ return { project: requireText(positionals[0], "Project reference"), checkout, confirm };
1078
+ }
517
1079
  function renderAddedProject(created) {
518
1080
  return [
519
1081
  `Added project ${created.id}`,
@@ -559,6 +1121,7 @@ function listProjects(args, store) {
559
1121
  return `${renderTable("Projects", [
560
1122
  { header: "Project", minWidth: 8, maxWidth: 24 },
561
1123
  { header: "Name", minWidth: 4, maxWidth: 28 },
1124
+ { header: "Status", minWidth: 6, maxWidth: 8 },
562
1125
  { header: "Ownership", minWidth: 9, maxWidth: 10 },
563
1126
  { header: "Path", minWidth: 8, maxWidth: 64 },
564
1127
  { header: "Stable", minWidth: 6, maxWidth: 24 },
@@ -566,6 +1129,7 @@ function listProjects(args, store) {
566
1129
  ], projects.map((project) => [
567
1130
  project.id,
568
1131
  project.name,
1132
+ project.status,
569
1133
  project.ownership,
570
1134
  project.path,
571
1135
  project.stableBranch,
@@ -579,6 +1143,13 @@ function showProject(args, store) {
579
1143
  return [
580
1144
  `Project: ${project.id}`,
581
1145
  `Name: ${project.name}`,
1146
+ `Status: ${project.status}`,
1147
+ ...(project.retirement === undefined
1148
+ ? []
1149
+ : [
1150
+ `Retired by: ${project.retirement.retiredBy} at ${project.retirement.retiredAt}`,
1151
+ `Retirement reason: ${project.retirement.reason}`
1152
+ ]),
582
1153
  `Ownership: ${project.ownership}`,
583
1154
  `Aliases: ${project.aliases.length === 0 ? "-" : project.aliases.join(", ")}`,
584
1155
  `Path: ${project.path}`,
@@ -599,6 +1170,7 @@ function projectKnowledge(args, store, options) {
599
1170
  assertKnowledgeOperator(options, "add");
600
1171
  const added = store.transaction((tx) => {
601
1172
  const project = requireProject(tx, parsed.positionals[0]);
1173
+ assertProjectActive(project, "add project knowledge");
602
1174
  const id = nextKnowledgeId(project);
603
1175
  tx.saveProject(addProjectKnowledge(project, id, parsed.positionals[1], parsed.value, (options.now ?? (() => new Date()))()));
604
1176
  return { id, projectId: project.id };
@@ -685,6 +1257,7 @@ function projectKnowledge(args, store, options) {
685
1257
  assertKnowledgeOperator(options, "retire");
686
1258
  const updated = store.transaction((tx) => {
687
1259
  const project = requireProject(tx, rest[0]);
1260
+ assertProjectActive(project, "retire project knowledge");
688
1261
  const next = retireProjectKnowledge(project, rest[1], (options.now ?? (() => new Date()))());
689
1262
  tx.saveProject(next);
690
1263
  return next;
@@ -823,6 +1396,7 @@ function proposeKnowledge(args, store, options) {
823
1396
  const now = (options.now ?? (() => new Date()))();
824
1397
  const result = store.transaction((tx) => {
825
1398
  const project = requireProject(tx, parsed.project);
1399
+ assertProjectActive(project, "propose project knowledge");
826
1400
  const source = requireProposalEvidence(tx, parsed);
827
1401
  const fingerprint = knowledgeProposalFingerprint({
828
1402
  projectId: project.id,
@@ -1056,6 +1630,7 @@ function acceptKnowledgeProposal(args, store, options) {
1056
1630
  const now = (options.now ?? (() => new Date()))();
1057
1631
  const accepted = store.transaction((tx) => {
1058
1632
  const project = requireProject(tx, parsed.project);
1633
+ assertProjectActive(project, "accept project knowledge");
1059
1634
  const proposal = findKnowledgeProposal(project, parsed.proposal);
1060
1635
  if (proposal === null) {
1061
1636
  throw usageError(`Knowledge proposal not found: ${parsed.proposal}.`);
@@ -1143,6 +1718,7 @@ function rejectKnowledgeProposal(args, store, options) {
1143
1718
  const decidedBy = actor === "operator" ? "operator" : "user";
1144
1719
  const rejected = store.transaction((tx) => {
1145
1720
  const project = requireProject(tx, parsed.positionals[0]);
1721
+ assertProjectActive(project, "reject project knowledge");
1146
1722
  const proposal = findKnowledgeProposal(project, parsed.positionals[1]);
1147
1723
  if (proposal === null) {
1148
1724
  throw usageError(`Knowledge proposal not found: ${parsed.positionals[1]}.`);
@@ -1412,3 +1988,6 @@ function requireText(value, label) {
1412
1988
  }
1413
1989
  return normalized;
1414
1990
  }
1991
+ function messageOf(error) {
1992
+ return error instanceof Error ? error.message : String(error);
1993
+ }