@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.
- package/ARCHITECTURE.md +23 -4
- package/README.md +80 -13
- package/dist/cli/commandCatalog.js +30 -5
- package/dist/cli.js +105 -43
- package/dist/commands/configCommands.js +12 -45
- package/dist/commands/executionAuditCommands.js +1 -1
- package/dist/commands/jobCommands.js +4 -10
- package/dist/commands/projectCommands.js +581 -2
- package/dist/commands/taskCommands.js +159 -172
- package/dist/commands/taskCompletionGate.js +29 -54
- package/dist/commands/taskContextCommand.js +55 -11
- package/dist/commands/taskInputCommands.js +8 -5
- package/dist/commands/taskIntegrationCommands.js +2 -1
- package/dist/commands/taskNextActionCommand.js +56 -6
- package/dist/commands/taskOverviewCommand.js +17 -31
- package/dist/commands/taskRoleRuntimeStatus.js +24 -0
- package/dist/commands/taskWorkspaceCommands.js +5 -2
- package/dist/config/configCatalog.js +2 -2
- package/dist/config/yuiConfig.js +3 -7
- package/dist/context/wakeNotification.js +20 -5
- package/dist/controller/agentRuntimeObserver.js +247 -51
- package/dist/controller/clientRuntime.js +1 -1
- package/dist/controller/controller.js +8 -12
- package/dist/controller/fileSchedulerStoreAdapter.js +146 -198
- package/dist/controller/runtime.js +3 -3
- package/dist/controller/runtimeEventInbox.js +17 -7
- package/dist/controller/runtimeEventProcessor.js +12 -19
- package/dist/controller/runtimeObservationHook.js +45 -0
- package/dist/execution/resourceBroker.js +5 -4
- package/dist/executor/agentExecutor.js +4 -4
- package/dist/executor/effectiveLaunch.js +8 -48
- package/dist/executor/fileRoleLaunchPlanner.js +36 -16
- package/dist/executor/workspacePreflightClassification.js +23 -2
- package/dist/interaction/operatorPresentation.js +33 -89
- package/dist/lifecycle/exactRunTerminalization.js +1 -2
- package/dist/lifecycle/taskRoleSessionReset.js +5 -13
- package/dist/observability/orchestrationMetrics.js +8 -3
- package/dist/profile/agentProfile.js +1 -1
- package/dist/release/cliHomeReleaseFence.js +123 -0
- package/dist/repository/checkoutSwap.js +61 -0
- package/dist/repository/gitWorkspace.js +36 -0
- package/dist/repository/project.js +53 -3
- package/dist/repository/taskWorkspacePreparer.js +176 -60
- package/dist/resources/sqliteResourceRegistry.js +1 -1
- package/dist/review/deltaRecheck.js +12 -51
- package/dist/review/reviewAcceptance.js +26 -0
- package/dist/review/reviewConfig.js +0 -31
- package/dist/review/reviewDecision.js +113 -0
- package/dist/review/reviewOutcomeClassifier.js +1 -1
- package/dist/review/reviewRound.js +1 -1
- package/dist/review/reviewerAvailability.js +69 -0
- package/dist/runtime/agentDriverObservation.js +24 -10
- package/dist/runtime/builtinAgentDrivers.js +4 -3
- package/dist/runtime/builtinTranscriptObserver.js +301 -64
- package/dist/runtime/builtinTranscriptUsage.js +9 -7
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runtimeObservation.js +13 -0
- package/dist/runtime/runtimeProjection.js +16 -25
- package/dist/runtime/sessionTokenMetrics.js +181 -0
- package/dist/runtime/structuredProviderHost.js +7 -1
- package/dist/scheduler/activeRoleRunDelivery.js +4 -4
- package/dist/scheduler/activeTaskProgress.js +7 -6
- package/dist/scheduler/leaderWakeupProcessor.js +3 -5
- package/dist/scheduler/operatorEvent.js +34 -0
- package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
- package/dist/scheduler/roleRunStall.js +0 -14
- package/dist/scheduler/taskExecutionProjection.js +97 -76
- package/dist/scheduler/taskObservabilityProjection.js +10 -11
- package/dist/storage/migration/productionRegistry.js +467 -0
- package/dist/storage/sqliteSchema.js +18 -8
- package/dist/storage/sqliteStore.js +61 -21
- package/dist/storage/storeRpc.js +0 -1
- package/dist/storage/taskStore.js +47 -31
- package/dist/storage/upgrade/recordVersions.js +3 -2
- package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
- package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
- package/dist/task/completionReadiness.js +10 -6
- package/dist/task/nextAction.js +82 -24
- package/dist/telemetry/sqliteTelemetryStore.js +1 -1
- package/dist/web/assets/client/components.js +13 -3
- package/dist/web/assets/client/i18n.js +6 -2
- package/dist/web/webSnapshot.js +4 -0
- package/i18n/README.zh-CN.md +2 -2
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +53 -16
- package/skills/yui-operator/SKILL.md +9 -0
- package/skills/yui-reviewer/SKILL.md +28 -7
- package/dist/context/sessionContextBudget.js +0 -71
- package/dist/lifecycle/contextBudgetRollover.js +0 -81
- 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
|
+
}
|