@zq-silk/yui 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -294,6 +294,49 @@ files as dirty, preserves ignored files, and refuses missing remotes or refs and
294
294
  When the configured branch is `HEAD`, refresh resolves the remote's symbolic default branch for that
295
295
  operation and requires the checkout to be on that branch; detached or mismatched checkouts fail.
296
296
 
297
+ ### Project lifecycle
298
+
299
+ Divergence and end-of-life are explicit, Operator-authority operations with fail-closed gates.
300
+ Every destructive command refuses a managed Task Session (run it from an Operator or user
301
+ terminal), an active Task binding, a dirty checkout, and an unreachable or unverified remote.
302
+
303
+ ```sh
304
+ yui project diagnose app
305
+ yui project reset app
306
+ yui project reset app --discard-local
307
+ yui project replace app --discard-local
308
+ yui project retire app --reason "superseded by app-ng"
309
+ yui project delete app --confirm app
310
+ yui project delete app --checkout --confirm app
311
+ ```
312
+
313
+ `project reset` handles the divergence `project refresh` refuses. Without `--discard-local` it is
314
+ a dry run: it fetches and verifies the remote baseline, and when the checkout has diverged it
315
+ refuses while listing the exact local commits that would be discarded. With `--discard-local` it
316
+ hard-resets the clean checkout to the verified remote commit (a plain fast-forward when the
317
+ checkout is merely behind). `project replace` goes further for Home-managed checkouts: it clones
318
+ the remote into a staging directory, verifies both branches, copies the Yui-local refs
319
+ (`refs/heads/yui/`, `refs/yui/archive/`) so historical evidence keeps resolving, then swaps the
320
+ checkout on disk while the catalog record keeps its path. Replace refuses linked worktrees (Task
321
+ or Integration workspaces) and dirty checkouts, and requires `--discard-local`. The swap is
322
+ recoverable: the previous checkout is parked at a backup path and restored on any failure, a
323
+ catalog refusal rolls the swap back, and a crash mid-swap is healed on the next run (a crash
324
+ before the swap leaves only a removable staging clone).
325
+
326
+ `project retire` is the auditable soft deprecation: it records who retired the Project, when, and
327
+ why, while retaining the catalog record, checkout, and every historical
328
+ Task/Run/Review/Integration/Publication reference. A retired Project cannot be refreshed,
329
+ updated, migrated, reset, replaced, maintained through Knowledge writes (add/retire/propose/
330
+ accept/reject), or bound to new Tasks, WorkItems, or Integrations; Knowledge reads (`list`,
331
+ `show`, `proposals list/show`) stay open so the evidence stays auditable.
332
+ `project delete` is the separate hard-removal decision: it requires a retired Project, an exact
333
+ `--confirm <project-id>` acknowledgment, and fails closed while any Task record references the
334
+ Project. `--checkout` additionally removes the Home-managed checkout (external checkouts are
335
+ user-owned and must be removed manually): it first refuses linked worktrees and dirty checkouts,
336
+ then moves the checkout to a tombstone before removing the catalog record, restoring it on any
337
+ failure so the catalog and checkout never disagree unrecoverably. `project show` and
338
+ `project list` display the lifecycle status and retirement record.
339
+
297
340
  Use `task context` as the first detailed read of an existing Task. It combines the Task, Brief, active Decisions, recent Milestones, Roles, current and recent WorkItems with their Runs, recent Messages, open and resolved InputRequests, and recent Events. Terminal output keeps histories and long text compact; `yui --json task context <task-id>` returns the complete records in the top-level `data` field.
298
341
 
299
342
  Leader wakeups stay deliberately small: the wake envelope carries only the
@@ -1011,6 +1054,7 @@ yui config completion [bash|zsh|fish]
1011
1054
  yui session enter|record|replace|reconcile
1012
1055
  yui session stop --all
1013
1056
  yui project add|clone|refresh|update|discover|list|show|knowledge
1057
+ yui project reset|replace|retire|delete
1014
1058
  ```
1015
1059
 
1016
1060
  `yui update` stages the newly published package **side by side** — it never
@@ -1260,7 +1260,10 @@ export const ROOT_COMMAND = buildNode({
1260
1260
  {
1261
1261
  name: "project",
1262
1262
  summary: "Manage Projects, stable checkouts, branches, and Yui knowledge.",
1263
- sections: [{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "diagnose", "migrate", "update", "discover", "list", "show", "knowledge"] }],
1263
+ sections: [
1264
+ { id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "diagnose", "migrate", "update", "discover", "list", "show", "knowledge"] },
1265
+ { id: "lifecycle", title: "Lifecycle (Operator authority)", entries: ["reset", "replace", "retire", "delete"] }
1266
+ ],
1264
1267
  children: [
1265
1268
  {
1266
1269
  name: "add",
@@ -1291,6 +1294,30 @@ export const ROOT_COMMAND = buildNode({
1291
1294
  usage: "yui project migrate <project> [--preflight]",
1292
1295
  options: ["--preflight"]
1293
1296
  },
1297
+ {
1298
+ name: "reset",
1299
+ summary: "Hard-reset a canonical checkout to its verified remote baseline (Operator authority).",
1300
+ usage: "yui project reset <project> [--discard-local]",
1301
+ options: ["--discard-local"]
1302
+ },
1303
+ {
1304
+ name: "replace",
1305
+ summary: "Re-clone a Home-managed checkout from its remote, preserving Yui refs (Operator authority).",
1306
+ usage: "yui project replace <project> --discard-local",
1307
+ options: ["--discard-local"]
1308
+ },
1309
+ {
1310
+ name: "retire",
1311
+ summary: "Soft-deprecate a Project; record and evidence are retained (Operator authority).",
1312
+ usage: "yui project retire <project> --reason <text>",
1313
+ options: ["--reason"]
1314
+ },
1315
+ {
1316
+ name: "delete",
1317
+ summary: "Remove a retired Project's catalog record and optionally its checkout (Operator authority).",
1318
+ usage: "yui project delete <project> [--checkout] --confirm <project-id>",
1319
+ options: ["--checkout", "--confirm"]
1320
+ },
1294
1321
  {
1295
1322
  name: "update",
1296
1323
  summary: "Update a bound Project's aliases, remote, or branch refs.",
@@ -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
+ }
@@ -40,7 +40,7 @@ import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createT
40
40
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
41
41
  import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
42
42
  import { projectCompletionReadiness } from "../task/completionReadiness.js";
43
- import { resolveProject } from "../repository/project.js";
43
+ import { assertProjectActive, resolveProject } from "../repository/project.js";
44
44
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
45
45
  import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, planWorkItemExplorationStage, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
46
46
  import { addExecutionLane, createExecutionGroup, queueExecutionLaneRetry, resolveExecutionGroup, restartExecutionLane, skipPendingExecutionLanes, updateExecutionLane, WORK_ITEM_EXECUTION_MODES } from "../execution/executionGroup.js";
@@ -385,6 +385,7 @@ function taskProjectCommand(args, store, options) {
385
385
  const project = resolveProject(tx.listProjects(), parsed.positionals[1]);
386
386
  if (project === null)
387
387
  throw usageError(`Project not found: ${parsed.positionals[1]}.`);
388
+ assertProjectActive(project, "bind to a Task");
388
389
  const next = addTaskProjectBinding(task, {
389
390
  projectId: project.id,
390
391
  directory: parsed.options.get("--directory") ?? project.name,
@@ -532,6 +533,7 @@ function parseTaskCreation(args, store) {
532
533
  const project = resolveProject(store.listProjects(), reference);
533
534
  if (project === null)
534
535
  throw usageError(`Project not found: ${reference}.`);
536
+ assertProjectActive(project, "bind to a Task");
535
537
  return project;
536
538
  });
537
539
  if (new Set(projects.map(({ id }) => id)).size !== projects.length) {
@@ -1857,6 +1859,7 @@ function createWork(args, store, options) {
1857
1859
  const project = resolveProject(task.projectBindings.map(({ projectId }) => requireProject(tx, projectId)), reference);
1858
1860
  if (project === null)
1859
1861
  throw usageError(`Task Project not found: ${reference}.`);
1862
+ assertProjectActive(project, "scope a Work Item");
1860
1863
  return project.id;
1861
1864
  });
1862
1865
  const baseRefs = parsed.baseRefs.map(({ project, baseRef }) => {
@@ -1914,6 +1917,7 @@ function updateWorkScope(args, store, options) {
1914
1917
  const project = resolveProject(task.projectBindings.map(({ projectId }) => requireProject(tx, projectId)), reference);
1915
1918
  if (project === null)
1916
1919
  throw usageError(`Task Project not found: ${reference}.`);
1920
+ assertProjectActive(project, "scope a Work Item");
1917
1921
  return project.id;
1918
1922
  });
1919
1923
  const requested = new Set(requestedProjectIds);
@@ -1,5 +1,5 @@
1
1
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
2
- import { resolveProject } from "../repository/project.js";
2
+ import { assertProjectActive, resolveProject } from "../repository/project.js";
3
3
  import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
4
4
  import { usageError } from "../errors/cliError.js";
5
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
@@ -114,6 +114,7 @@ async function start(args, store, home, now, options) {
114
114
  : resolveProject(store.listProjects(), requestedProject);
115
115
  if (project === null)
116
116
  throw usageError(`Project not found: ${requestedProject ?? projectIds[0]}.`);
117
+ assertProjectActive(project, "start an Integration");
117
118
  if (project.id !== projectIds[0]) {
118
119
  throw usageError(`ChangeSets belong to another Project: ${projectIds[0]}.`);
119
120
  }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rename as defaultRename, rm as defaultRm } from "node:fs/promises";
3
+ const realPorts = {
4
+ rename: (sourcePath, targetPath) => defaultRename(sourcePath, targetPath),
5
+ remove: (path) => defaultRm(path, { recursive: true, force: true })
6
+ };
7
+ /**
8
+ * Heal the on-disk state of a crashed earlier swap before any new destructive
9
+ * step runs. A backup next to a live checkout is a leftover from a completed
10
+ * swap whose cleanup crashed and is removed; a backup without a live checkout
11
+ * means the crash happened mid-swap, so the previous checkout is restored
12
+ * intact. With no backup there is nothing to heal.
13
+ */
14
+ export async function healCheckoutSwap(request, ports = realPorts) {
15
+ if (!existsSync(request.backupPath))
16
+ return;
17
+ if (existsSync(request.currentPath)) {
18
+ await ports.remove(request.backupPath);
19
+ return;
20
+ }
21
+ await ports.rename(request.backupPath, request.currentPath);
22
+ }
23
+ /**
24
+ * Promote a prepared staging checkout into the registered path without ever
25
+ * leaving the registered path without a valid repository. The previous
26
+ * checkout is moved to the backup path first; if the promotion fails it is
27
+ * restored and the staging clone is removed. A crash between the two renames
28
+ * is healed by {@link healCheckoutSwap} on the next run.
29
+ */
30
+ export async function swapManagedCheckout(request, ports = realPorts) {
31
+ await healCheckoutSwap(request, ports);
32
+ await ports.rename(request.currentPath, request.backupPath);
33
+ try {
34
+ await ports.rename(request.stagingPath, request.currentPath);
35
+ }
36
+ catch (error) {
37
+ try {
38
+ await ports.rename(request.backupPath, request.currentPath);
39
+ }
40
+ catch (rollbackError) {
41
+ throw new Error(`Checkout swap failed and rollback was incomplete: ${messageOf(error)}; `
42
+ + `rollback failed: ${messageOf(rollbackError)}. `
43
+ + `The previous checkout remains parked at ${request.backupPath}.`);
44
+ }
45
+ await ports.remove(request.stagingPath).catch(() => { });
46
+ throw error;
47
+ }
48
+ }
49
+ /**
50
+ * Restore the parked previous checkout after a committed swap had to be undone
51
+ * (for example because the catalog transaction refused the change). The
52
+ * promoted clone is discarded first; if the restore fails the parked checkout
53
+ * stays recoverable through {@link healCheckoutSwap}.
54
+ */
55
+ export async function restoreCheckoutSwap(request, ports = realPorts) {
56
+ await ports.remove(request.currentPath);
57
+ await ports.rename(request.backupPath, request.currentPath);
58
+ }
59
+ function messageOf(error) {
60
+ return error instanceof Error ? error.message : String(error);
61
+ }
@@ -694,6 +694,42 @@ export class NodeGitWorkspace {
694
694
  throw new Error(`Git reset compensation failed for ${target}; Candidate materialization remains retained for diagnosis.`, { cause: error });
695
695
  }
696
696
  }
697
+ async resetToCommit(input) {
698
+ const root = await canonicalDirectory(input.repositoryPath, "Project");
699
+ const expectedHead = requireText(input.expectedHead, "Expected head");
700
+ const targetCommit = requireText(input.targetCommit, "Target commit");
701
+ if (!await this.isClean(root)) {
702
+ throw new Error(`Project checkout must be clean before reset: ${root}.`);
703
+ }
704
+ const currentHead = (await this.inspect(root, "HEAD")).baseCommit;
705
+ if (currentHead.toLowerCase() !== expectedHead.toLowerCase()) {
706
+ throw new Error(`Project checkout changed before reset: ${root} (${currentHead}).`);
707
+ }
708
+ // Verify the target commit resolves before moving the branch.
709
+ await gitLine(["-C", root, "rev-parse", "--verify", "--end-of-options", `${targetCommit}^{commit}`]);
710
+ await git(["-C", root, "reset", "--hard", targetCommit]);
711
+ const restoredHead = (await this.inspect(root, "HEAD")).baseCommit;
712
+ if (restoredHead.toLowerCase() !== targetCommit.toLowerCase() || !await this.isClean(root)) {
713
+ throw new Error(`Project checkout did not reset to the exact target commit: ${root}.`);
714
+ }
715
+ }
716
+ async listWorktrees(repositoryPath) {
717
+ const root = await canonicalDirectory(repositoryPath, "Project");
718
+ const output = await git(["-C", root, "worktree", "list", "--porcelain"]);
719
+ return output
720
+ .split(/\r?\n/u)
721
+ .filter((line) => line.startsWith("worktree "))
722
+ .map((line) => line.slice("worktree ".length).trim());
723
+ }
724
+ async listCommitsBetween(input) {
725
+ const root = await canonicalDirectory(input.repositoryPath, "Project");
726
+ const range = `${requireText(input.baseCommit, "Base commit")}..${requireText(input.headCommit, "Head commit")}`;
727
+ const output = await git(["-C", root, "log", "--oneline", range]);
728
+ return output
729
+ .split(/\r?\n/u)
730
+ .map((line) => line.trim())
731
+ .filter((line) => line.length > 0);
732
+ }
697
733
  async #assertRefreshCheckout(repositoryPath, stableRef, expectedCommit) {
698
734
  if (stableRef !== "HEAD") {
699
735
  const currentBranch = await this.headRef(repositoryPath);
@@ -8,7 +8,7 @@ export function managedProjectPath(home, projectId) {
8
8
  export function createProject(id, name, path, branches, now, metadata = {}) {
9
9
  const timestamp = now.toISOString();
10
10
  return validateProject({
11
- schemaVersion: 4,
11
+ schemaVersion: 5,
12
12
  id: requireIdentity(id, "Project id"),
13
13
  name: validateProjectName(name),
14
14
  aliases: normalizeAliases(metadata.aliases ?? [], name),
@@ -19,6 +19,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
19
19
  : { remoteUrl: requireText(metadata.remoteUrl, "Project remote URL") }),
20
20
  stableBranch: requireGitRef(branches.stable, "Project stable branch"),
21
21
  developmentBranch: requireGitRef(branches.development, "Project development branch"),
22
+ status: "active",
22
23
  knowledge: [],
23
24
  knowledgeProposals: [],
24
25
  createdAt: timestamp,
@@ -28,6 +29,38 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
28
29
  export function validateProjectName(value) {
29
30
  return requireIdentity(value, "Project name");
30
31
  }
32
+ /**
33
+ * Soft-retire a Project: mark it retired and record the audit trail. The
34
+ * catalog record, checkout, and every historical Task/Run/Review/Integration/
35
+ * Publication reference are retained. Retiring an already-retired Project
36
+ * fails closed; a retired Project must be deleted (or kept) as-is.
37
+ */
38
+ export function retireProject(project, reason, by, now) {
39
+ if (project.status === "retired") {
40
+ throw new Error(`Project is already retired: ${project.id}.`);
41
+ }
42
+ const timestamp = now.toISOString();
43
+ return validateProject({
44
+ ...project,
45
+ status: "retired",
46
+ retirement: {
47
+ reason: requireText(reason, "Project retirement reason"),
48
+ retiredBy: by,
49
+ retiredAt: timestamp
50
+ },
51
+ updatedAt: timestamp
52
+ });
53
+ }
54
+ /**
55
+ * Refuse a mutation or new binding against a retired Project. Read-only
56
+ * inspection (`project show`, `project diagnose`, knowledge reads) remains
57
+ * allowed, so historical evidence stays auditable.
58
+ */
59
+ export function assertProjectActive(project, action) {
60
+ if (project.status === "retired") {
61
+ throw new Error(`Project is retired and cannot ${action}: ${project.id}.`);
62
+ }
63
+ }
31
64
  export function addProjectKnowledge(project, id, title, body, now, provenance) {
32
65
  const timestamp = now.toISOString();
33
66
  const knowledge = {
@@ -243,8 +276,8 @@ export function assertProjectCatalog(projects) {
243
276
  }
244
277
  }
245
278
  export function validateProject(project) {
246
- if (project.schemaVersion !== 4) {
247
- throw new Error("Project must use schemaVersion 4.");
279
+ if (project.schemaVersion !== 5) {
280
+ throw new Error("Project must use schemaVersion 5.");
248
281
  }
249
282
  requireIdentity(project.id, "Project id");
250
283
  requireIdentity(project.name, "Project name");
@@ -252,6 +285,23 @@ export function validateProject(project) {
252
285
  if (project.ownership !== "managed" && project.ownership !== "external") {
253
286
  throw new Error(`Project ownership is invalid: ${String(project.ownership)}.`);
254
287
  }
288
+ if (project.status !== "active" && project.status !== "retired") {
289
+ throw new Error(`Project status is invalid: ${String(project.status)}.`);
290
+ }
291
+ if (project.status === "retired") {
292
+ const retirement = project.retirement;
293
+ if (retirement === undefined) {
294
+ throw new Error(`Retired Project must carry its retirement record: ${project.id}.`);
295
+ }
296
+ requireText(retirement.reason, "Project retirement reason");
297
+ if (retirement.retiredBy !== "user" && retirement.retiredBy !== "operator") {
298
+ throw new Error("Project retirement actor is invalid.");
299
+ }
300
+ requireTimestamp(retirement.retiredAt, "Project retirement retiredAt");
301
+ }
302
+ else if (project.retirement !== undefined) {
303
+ throw new Error(`Active Project must not carry a retirement record: ${project.id}.`);
304
+ }
255
305
  const references = new Set();
256
306
  for (const reference of [project.id, project.name, ...project.aliases]) {
257
307
  const folded = reference.toLocaleLowerCase();
@@ -18,6 +18,8 @@ const PROJECT_FROM_VERSION = 2;
18
18
  const PROJECT_TO_VERSION = 3;
19
19
  const PROJECT_KNOWLEDGE_PROPOSALS_FROM_VERSION = 3;
20
20
  const PROJECT_KNOWLEDGE_PROPOSALS_TO_VERSION = 4;
21
+ const PROJECT_LIFECYCLE_FROM_VERSION = 4;
22
+ const PROJECT_LIFECYCLE_TO_VERSION = 5;
21
23
  const TASK_FROM_VERSION = 3;
22
24
  const TASK_TO_VERSION = 4;
23
25
  const TASK_INTENT_FROM_VERSION = 4;
@@ -145,6 +147,7 @@ export function createProductionStorageRegistry() {
145
147
  .registerOfflineMigration(projectOwnershipStep())
146
148
  .registerOfflineMigration(configV2Step())
147
149
  .registerCompatible(projectKnowledgeProposalsStep())
150
+ .registerCompatible(projectLifecycleStep())
148
151
  .registerOfflineMigration(taskWorkspaceIdentityStep())
149
152
  .registerOfflineMigration(taskIntentStep())
150
153
  .registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
@@ -2761,6 +2764,91 @@ function normalizeProjectV3ToV4(snapshot) {
2761
2764
  state: { ...snapshot.state, projects: nextProjects }
2762
2765
  };
2763
2766
  }
2767
+ /**
2768
+ * Project v5 adds the lifecycle status (`active`/`retired`) plus the
2769
+ * retirement audit record. Historical Projects are all active; the step only
2770
+ * defaults the new field, so it is a compatible adjacent migration.
2771
+ */
2772
+ function projectLifecycleStep() {
2773
+ return {
2774
+ kind: "compatible",
2775
+ axis: "record",
2776
+ recordKind: "project",
2777
+ fromVersion: PROJECT_LIFECYCLE_FROM_VERSION,
2778
+ toVersion: PROJECT_LIFECYCLE_TO_VERSION,
2779
+ defaults: [
2780
+ "status defaults to active on every Project"
2781
+ ],
2782
+ validateSource: (snapshot) => requireProjectV4Shape(snapshot),
2783
+ normalize: (snapshot) => normalizeProjectV4ToV5(snapshot)
2784
+ };
2785
+ }
2786
+ function requireProjectV4Shape(snapshot) {
2787
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2788
+ if (manifestVersions.project !== PROJECT_LIFECYCLE_FROM_VERSION) {
2789
+ throw new Error(`Record project compatible step requires manifest version ${PROJECT_LIFECYCLE_FROM_VERSION}.`);
2790
+ }
2791
+ if (snapshot.state === null)
2792
+ return;
2793
+ const projects = snapshot.state.projects;
2794
+ if (projects === undefined)
2795
+ return;
2796
+ const map = asObject(projects, "project map");
2797
+ const allowed = new Set(PROJECT_V4_FIELDS);
2798
+ for (const [projectId, rawProject] of Object.entries(map)) {
2799
+ const project = asObject(rawProject, `Project ${projectId}`);
2800
+ if (project.schemaVersion !== PROJECT_LIFECYCLE_FROM_VERSION) {
2801
+ throw new Error(`Project ${projectId} must use schemaVersion ${PROJECT_LIFECYCLE_FROM_VERSION}.`);
2802
+ }
2803
+ const unknown = Object.keys(project).find((key) => !allowed.has(key));
2804
+ if (unknown !== undefined) {
2805
+ throw new Error(`Project ${projectId} has an unknown v4 field: ${unknown}.`);
2806
+ }
2807
+ }
2808
+ }
2809
+ function normalizeProjectV4ToV5(snapshot) {
2810
+ requireProjectV4Shape(snapshot);
2811
+ const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
2812
+ const schemaManifest = {
2813
+ ...snapshot.schemaManifest,
2814
+ recordVersions: { ...manifestVersions, project: PROJECT_LIFECYCLE_TO_VERSION }
2815
+ };
2816
+ if (snapshot.state === null)
2817
+ return { schemaManifest, state: null };
2818
+ const projects = snapshot.state.projects;
2819
+ if (projects === undefined) {
2820
+ return { schemaManifest, state: { ...snapshot.state } };
2821
+ }
2822
+ const map = asObject(projects, "project map");
2823
+ const nextProjects = {};
2824
+ for (const [projectId, rawProject] of Object.entries(map)) {
2825
+ const project = asObject(rawProject, `Project ${projectId}`);
2826
+ nextProjects[projectId] = {
2827
+ ...project,
2828
+ schemaVersion: PROJECT_LIFECYCLE_TO_VERSION,
2829
+ status: "active"
2830
+ };
2831
+ }
2832
+ return {
2833
+ schemaManifest,
2834
+ state: { ...snapshot.state, projects: nextProjects }
2835
+ };
2836
+ }
2837
+ const PROJECT_V4_FIELDS = [
2838
+ "schemaVersion",
2839
+ "id",
2840
+ "name",
2841
+ "aliases",
2842
+ "path",
2843
+ "ownership",
2844
+ "remoteUrl",
2845
+ "stableBranch",
2846
+ "developmentBranch",
2847
+ "knowledge",
2848
+ "knowledgeProposals",
2849
+ "createdAt",
2850
+ "updatedAt"
2851
+ ];
2764
2852
  /**
2765
2853
  * Task v4 adds the optional durable workspace identity. Historical Tasks have
2766
2854
  * no identity; they keep working against their existing (legacy) refs until the
@@ -596,7 +596,43 @@ export class SqliteTaskStore {
596
596
  return this.#getPayload("projects", "id = ?", [id]);
597
597
  }
598
598
  removeProject(id) {
599
- return this.#mutate(() => this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0);
599
+ return this.#mutate(() => {
600
+ if (this.listTasks().some((task) => task.projectBindings.some((binding) => binding.projectId === id))) {
601
+ throw new StorageRecordError(`Project is still used by a Task: ${id}`);
602
+ }
603
+ return this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0;
604
+ });
605
+ }
606
+ summarizeProjectReferences(projectId) {
607
+ const boundTasks = this.listTasks().filter((task) => task.projectBindings.some((binding) => binding.projectId === projectId));
608
+ const activeTasks = boundTasks.filter((task) => task.status === "active");
609
+ const unresolvedWorkItemRefs = [];
610
+ const activeRunRefs = [];
611
+ const unresolvedIntegrationRefs = [];
612
+ for (const task of activeTasks) {
613
+ for (const workItem of this.listWorkItems(task.id)) {
614
+ if (workItem.status !== "completed" && workItem.status !== "retired") {
615
+ unresolvedWorkItemRefs.push(`${task.id}/${workItem.id}`);
616
+ }
617
+ }
618
+ for (const run of this.listAgentRuns(task.id)) {
619
+ if (run.status === "active")
620
+ activeRunRefs.push(`${task.id}/${run.id}`);
621
+ }
622
+ for (const attempt of this.listIntegrationAttempts(task.id)) {
623
+ if (attempt.status === "running" || attempt.status === "blocked") {
624
+ unresolvedIntegrationRefs.push(`${task.id}/${attempt.id}`);
625
+ }
626
+ }
627
+ }
628
+ return {
629
+ projectId,
630
+ boundTaskIds: boundTasks.map(({ id }) => id),
631
+ activeTaskIds: activeTasks.map(({ id }) => id),
632
+ unresolvedWorkItemRefs,
633
+ activeRunRefs,
634
+ unresolvedIntegrationRefs
635
+ };
600
636
  }
601
637
  // -- agent profiles ---------------------------------------------------------
602
638
  saveAgentProfile(profile) {
@@ -1485,9 +1521,10 @@ export class SqliteTaskStore {
1485
1521
  if (round === null) {
1486
1522
  throw new StorageRecordError(`Agent run ReviewRound not found: ${run.reviewRoundId}.`);
1487
1523
  }
1524
+ const roundWorkItemId = round.workItemId ?? round.legacyAnchor?.workItemId;
1488
1525
  const laneRole = round.executionGroup?.lanes
1489
1526
  .find(({ id }) => id === run.executionLaneId)?.roleName;
1490
- if (round.workItemId !== run.workItemId
1527
+ if (roundWorkItemId !== run.workItemId
1491
1528
  || (round.reviewerRoleName !== run.roleName && laneRole !== run.roleName)) {
1492
1529
  throw new StorageRecordError(`Agent run does not match ReviewRound: ${run.id}.`);
1493
1530
  }
@@ -58,7 +58,7 @@ export const CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION = 3;
58
58
  * below, but are not independent record-axis families.
59
59
  */
60
60
  export const CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION = 2;
61
- export const CURRENT_PROJECT_SCHEMA_VERSION = 4;
61
+ export const CURRENT_PROJECT_SCHEMA_VERSION = 5;
62
62
  export const CURRENT_AGENT_PROFILE_SCHEMA_VERSION = 2;
63
63
  export const CURRENT_GLOBAL_ROLE_SCHEMA_VERSION = 3;
64
64
  export const CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION = 3;
@@ -279,6 +279,37 @@ export class FileTaskStore {
279
279
  return this.#remove((state) => state.projects, id);
280
280
  });
281
281
  }
282
+ summarizeProjectReferences(projectId) {
283
+ const boundTasks = this.listTasks().filter((task) => task.projectBindings.some((binding) => binding.projectId === projectId));
284
+ const activeTasks = boundTasks.filter((task) => task.status === "active");
285
+ const unresolvedWorkItemRefs = [];
286
+ const activeRunRefs = [];
287
+ const unresolvedIntegrationRefs = [];
288
+ for (const task of activeTasks) {
289
+ for (const workItem of this.listWorkItems(task.id)) {
290
+ if (workItem.status !== "completed" && workItem.status !== "retired") {
291
+ unresolvedWorkItemRefs.push(`${task.id}/${workItem.id}`);
292
+ }
293
+ }
294
+ for (const run of this.listAgentRuns(task.id)) {
295
+ if (run.status === "active")
296
+ activeRunRefs.push(`${task.id}/${run.id}`);
297
+ }
298
+ for (const attempt of this.listIntegrationAttempts(task.id)) {
299
+ if (attempt.status === "running" || attempt.status === "blocked") {
300
+ unresolvedIntegrationRefs.push(`${task.id}/${attempt.id}`);
301
+ }
302
+ }
303
+ }
304
+ return {
305
+ projectId,
306
+ boundTaskIds: boundTasks.map(({ id }) => id),
307
+ activeTaskIds: activeTasks.map(({ id }) => id),
308
+ unresolvedWorkItemRefs,
309
+ activeRunRefs,
310
+ unresolvedIntegrationRefs
311
+ };
312
+ }
282
313
  saveAgentProfile(profile) {
283
314
  const stored = identified(profile, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", profile.id, "Agent Profile");
284
315
  validateAgentProfile(stored);
@@ -998,8 +1029,9 @@ export class FileTaskStore {
998
1029
  if (round === undefined) {
999
1030
  throw new StorageRecordError(`Agent run ReviewRound not found: ${stored.reviewRoundId}.`);
1000
1031
  }
1032
+ const roundWorkItemId = round.workItemId ?? round.legacyAnchor?.workItemId;
1001
1033
  const laneRole = round.executionGroup?.lanes.find(({ id }) => id === stored.executionLaneId)?.roleName;
1002
- if (round.workItemId !== stored.workItemId
1034
+ if (roundWorkItemId !== stored.workItemId
1003
1035
  || (round.reviewerRoleName !== stored.roleName && laneRole !== stored.roleName)) {
1004
1036
  throw new StorageRecordError(`Agent run does not match ReviewRound: ${stored.id}.`);
1005
1037
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,