@zq-silk/yui 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -226,6 +226,36 @@ const taskChildren = [
226
226
  options: ["--integrated", "--abandon"]
227
227
  },
228
228
  { name: "reconcile", summary: "Run one immediate Controller reconciliation.", usage: "yui task reconcile <id>" },
229
+ {
230
+ name: "rebuild",
231
+ summary: "Rebuild a legacy Task workspace under its canonical identity.",
232
+ usage: "yui task rebuild <task>",
233
+ options: []
234
+ },
235
+ {
236
+ name: "history",
237
+ summary: "Inspect and archive legacy Task refs in the Home repository.",
238
+ sections: [{ id: "manage", title: "Commands", entries: ["list", "archive"] }],
239
+ children: [
240
+ {
241
+ name: "list",
242
+ summary: "List legacy Task refs and their live owners.",
243
+ usage: "yui task history list [--task <task>]"
244
+ },
245
+ {
246
+ name: "archive",
247
+ summary: "Archive legacy Task refs without a live owner.",
248
+ usage: "yui task history archive [<task>] [--force]",
249
+ options: ["--force"]
250
+ }
251
+ ]
252
+ },
253
+ {
254
+ name: "replace",
255
+ summary: "Create a draft successor for a terminal Task.",
256
+ usage: "yui task replace <task> [--title <text>]",
257
+ options: ["--title"]
258
+ },
229
259
  {
230
260
  name: "message",
231
261
  summary: "Manage durable Task messages.",
@@ -755,7 +785,7 @@ export const ROOT_COMMAND = buildNode({
755
785
  {
756
786
  name: "project",
757
787
  summary: "Manage Projects, stable checkouts, branches, and Yui knowledge.",
758
- sections: [{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "update", "discover", "list", "show", "knowledge"] }],
788
+ sections: [{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "migrate", "update", "discover", "list", "show", "knowledge"] }],
759
789
  children: [
760
790
  {
761
791
  name: "add",
@@ -775,6 +805,12 @@ export const ROOT_COMMAND = buildNode({
775
805
  summary: "Fast-forward a clean stable checkout from its configured remote.",
776
806
  usage: "yui project refresh <project>"
777
807
  },
808
+ {
809
+ name: "migrate",
810
+ summary: "Move an external Project into a Home-managed repository.",
811
+ usage: "yui project migrate <project> [--preflight]",
812
+ options: ["--preflight"]
813
+ },
778
814
  {
779
815
  name: "update",
780
816
  summary: "Update a bound Project's aliases, remote, or branch refs.",
@@ -860,7 +896,7 @@ export const ROOT_COMMAND = buildNode({
860
896
  name: "task",
861
897
  summary: "Manage Tasks, WorkItems, Agent Runs, and integration.",
862
898
  sections: [
863
- { id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "reconcile"] },
899
+ { id: "lifecycle", title: "Lifecycle", entries: ["create", "project", "update", "activate", "complete", "reopen", "retire", "list", "show", "context", "archive", "rebuild", "history", "replace", "reconcile"] },
864
900
  { id: "collaboration", title: "Collaboration", entries: ["message", "input", "work", "run", "review", "integration", "role", "enter"] },
865
901
  { id: "knowledge", title: "Task Knowledge", entries: ["brief", "decision", "milestone", "event"] }
866
902
  ],
@@ -18,6 +18,18 @@ export const INTERACTION_POLICIES = Object.freeze([
18
18
  actionTarget: true
19
19
  }]
20
20
  },
21
+ {
22
+ commandPath: ["project", "migrate"],
23
+ selectors: [{
24
+ argumentIndex: 2,
25
+ entity: "project",
26
+ provider: "projects",
27
+ actionTarget: true
28
+ }],
29
+ trailingOptions: {
30
+ "--preflight": "flag"
31
+ }
32
+ },
21
33
  {
22
34
  commandPath: ["project", "update"],
23
35
  selectors: [{
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ import { runProfileCommand } from "./commands/profileCommands.js";
28
28
  import { dispatchPreparedReviewRound, failPendingReviewRound, RESUMED_PENDING_FINAL_REVIEW, TERMINALIZED_LEADER_BEFORE_FINAL_REVIEW, TaskFinalReviewDispatchDriftError, preserveReviewRoundWorkspace, parseTaskCompletionRequest, preflightTaskCompletion, runTaskCommand, normalizedExecutionLanePlan, validateTaskArchiveRequest } from "./commands/taskCommands.js";
29
29
  import { taskActor } from "./commands/taskActor.js";
30
30
  import { runTaskIntegrationCommand } from "./commands/taskIntegrationCommands.js";
31
+ import { runTaskWorkspaceCommand } from "./commands/taskWorkspaceCommands.js";
31
32
  import { reconcileTaskRemoteBaselines } from "./commands/taskCompletionGate.js";
32
33
  import { FileCompletionManager, resolveCliIdentity } from "./completion/fileCompletionManager.js";
33
34
  import { assertFileTaskControllerStorageCompatible, ensureFileTaskController, FileTaskWorkflowRuntime, refreshRunningFileTaskControllerConfiguration, refreshRunningFileTaskControllerEnvironment, restartFileTaskController, stopFileTaskController } from "./controller/clientRuntime.js";
@@ -446,6 +447,13 @@ export async function main() {
446
447
  emit(result.output, false, result.data);
447
448
  return;
448
449
  }
450
+ if (resolved[1] === "rebuild"
451
+ || resolved[1] === "history"
452
+ || resolved[1] === "replace") {
453
+ const result = await runTaskWorkspaceCommand(resolved.slice(1), store, workspacePreparer);
454
+ emit(result.output, false, result.data);
455
+ return;
456
+ }
449
457
  const enteringTask = (resolved[1] === "enter")
450
458
  || (resolved[1] === "role" && resolved[2] === "enter");
451
459
  if (enteringTask) {
@@ -1,9 +1,10 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { readdir, rm } from "node:fs/promises";
2
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
4
  import { usageError } from "../errors/cliError.js";
4
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
5
6
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
6
- import { addProjectKnowledge, createProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProjectName } from "../repository/project.js";
7
+ import { addProjectKnowledge, createProject, managedProjectPath, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
7
8
  export async function runProjectCommand(args, store, options = {}) {
8
9
  const [command, ...rest] = args;
9
10
  if (command === "add" || command === "clone") {
@@ -24,6 +25,15 @@ export async function runProjectCommand(args, store, options = {}) {
24
25
  data: refreshed
25
26
  };
26
27
  }
28
+ if (command === "migrate") {
29
+ const migrated = await migrateProject(rest, store, options);
30
+ return {
31
+ output: migrated.preflight
32
+ ? `Project ${migrated.project.id} can be migrated to a Home-managed repository at ${migrated.path}\n`
33
+ : `Migrated project ${migrated.project.id} to a Home-managed repository at ${migrated.path}\n`,
34
+ data: migrated
35
+ };
36
+ }
27
37
  if (command === "update") {
28
38
  const project = await updateProject(rest, store, options);
29
39
  return {
@@ -123,24 +133,37 @@ function renderDiscoveredProjects(projects) {
123
133
  ]), defaultTableWidth())}\n`;
124
134
  }
125
135
  async function cloneProject(args, store, options) {
126
- const usage = "Project clone usage: yui project clone <name> <remote> [--alias <name> ...] [--stable <ref>] [--development <ref>].";
136
+ const usage = "Project clone usage: yui project clone <name> <remote> [--alias <name> ...] [--stable <ref>] [--development <ref>] [--external].";
127
137
  const parsed = parseCloneArguments(args, usage);
128
- const workspace = store.getConfig().defaultWorkspace;
129
- if (workspace === undefined)
130
- throw usageError("No default workspace is configured; run yui setup.");
131
138
  const git = options.git ?? new NodeGitWorkspace();
132
139
  const name = validateProjectName(parsed.name);
133
- const workspaceRoot = resolve(workspace);
134
- const destination = resolve(workspaceRoot, name);
135
- if (dirname(destination) !== workspaceRoot) {
136
- throw usageError("Project clone destination must be directly inside the configured workspace.");
137
- }
138
- assertOutsideManagedWorktrees(destination, workspace);
139
140
  const stableRef = parsed.stable ?? "HEAD";
140
141
  const developmentRef = parsed.development ?? stableRef;
141
142
  const projectId = store.nextProjectId();
142
143
  const now = (options.now ?? (() => new Date()))();
143
- const candidate = createProject(projectId, name, destination, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote });
144
+ // A remote-URL binding is Home-managed by default: its canonical repository
145
+ // lives below the persistent Home, so the runtime never depends on a
146
+ // user-controlled checkout path. --external is an explicit opt-in to the
147
+ // legacy clone-inside-the-workspace mode.
148
+ let destination;
149
+ let ownership;
150
+ if (parsed.external) {
151
+ const workspace = store.getConfig().defaultWorkspace;
152
+ if (workspace === undefined)
153
+ throw usageError("No default workspace is configured; run yui setup.");
154
+ const workspaceRoot = resolve(workspace);
155
+ destination = resolve(workspaceRoot, name);
156
+ if (dirname(destination) !== workspaceRoot) {
157
+ throw usageError("Project clone destination must be directly inside the configured workspace.");
158
+ }
159
+ assertOutsideManagedWorktrees(destination, workspace);
160
+ ownership = "external";
161
+ }
162
+ else {
163
+ destination = managedProjectPath(store.rootDirectory(), projectId);
164
+ ownership = "managed";
165
+ }
166
+ const candidate = createProject(projectId, name, destination, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote, ownership });
144
167
  assertProjectAvailable(store, candidate);
145
168
  let cloned = false;
146
169
  try {
@@ -157,7 +180,16 @@ async function cloneProject(args, store, options) {
157
180
  if (developmentRef !== stableRef) {
158
181
  await git.ensureLocalBranch(destination, developmentRef);
159
182
  }
160
- const project = createProject(projectId, name, head.root, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote });
183
+ // Verify both configured branches against the SHAs the remote advertises
184
+ // now. A network race or a ref that moved during the clone fails closed
185
+ // before any binding is persisted.
186
+ await assertRemoteBranchesVerified(git, destination, parsed.remote, [
187
+ { ref: stableRef, localCommit: head.baseCommit },
188
+ ...(developmentRef === stableRef
189
+ ? []
190
+ : [{ ref: developmentRef, localCommit: (await git.inspect(destination, developmentRef)).baseCommit }])
191
+ ]);
192
+ const project = createProject(projectId, name, head.root, { stable: stableRef, development: developmentRef }, now, { aliases: parsed.aliases, remoteUrl: parsed.remote, ownership });
161
193
  assertProjectAvailable(store, project);
162
194
  const created = store.createProjectIfAbsent(project);
163
195
  if (created === null)
@@ -171,6 +203,135 @@ async function cloneProject(args, store, options) {
171
203
  throw error;
172
204
  }
173
205
  }
206
+ /**
207
+ * Migrate an external Project binding to a Home-managed repository. The
208
+ * remote is cloned and both configured branches are verified against the
209
+ * advertised remote SHAs before the catalog record switches; the old
210
+ * checkout is never touched and stays usable until the switch commits. A
211
+ * failed migration leaves no partial state: the unfinished managed clone is
212
+ * removed so the command can be retried cleanly.
213
+ */
214
+ async function migrateProject(args, store, options) {
215
+ const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
216
+ const parsed = parseMigrateArguments(args, usage);
217
+ const project = requireProject(store, parsed.project);
218
+ if (project.ownership === "managed") {
219
+ throw usageError(`Project is already Home-managed: ${project.id}.`);
220
+ }
221
+ if (project.remoteUrl === undefined) {
222
+ throw usageError(`Project migrate requires a remote URL: ${project.id}.`);
223
+ }
224
+ const git = options.git ?? new NodeGitWorkspace();
225
+ const destination = managedProjectPath(store.rootDirectory(), project.id);
226
+ // Preflight verifies the remote into a throwaway clone: it changes neither
227
+ // the catalog nor the persistent projects directory.
228
+ const verifyRoot = parsed.preflight
229
+ ? join(store.rootDirectory(), "projects", `.preflight-${project.id}`)
230
+ : destination;
231
+ if (!parsed.preflight) {
232
+ // A crashed earlier attempt can leave an unreferenced managed clone
233
+ // behind. It is safe to remove only because no catalog record points at
234
+ // it; a registered path fails closed instead.
235
+ await removeUnreferencedClone(store, destination, project.id);
236
+ }
237
+ else if (existsSync(verifyRoot)) {
238
+ // A crashed preflight can only leave its own throwaway clone behind.
239
+ await rm(verifyRoot, { recursive: true, force: true });
240
+ }
241
+ let prepared = false;
242
+ try {
243
+ const head = await git.clone({
244
+ remoteUrl: project.remoteUrl,
245
+ destination: verifyRoot,
246
+ ...(project.stableBranch === "HEAD" ? {} : { branch: project.stableBranch })
247
+ });
248
+ prepared = true;
249
+ const stable = project.stableBranch === "HEAD"
250
+ ? head
251
+ : await git.inspect(verifyRoot, project.stableBranch);
252
+ if (head.baseCommit !== stable.baseCommit) {
253
+ throw new Error(`Project checkout is not on its stable ref: ${project.stableBranch}.`);
254
+ }
255
+ if (project.developmentBranch !== project.stableBranch) {
256
+ await git.ensureLocalBranch(verifyRoot, project.developmentBranch);
257
+ }
258
+ await assertRemoteBranchesVerified(git, verifyRoot, project.remoteUrl, [
259
+ { ref: project.stableBranch, localCommit: head.baseCommit },
260
+ ...(project.developmentBranch === project.stableBranch
261
+ ? []
262
+ : [{
263
+ ref: project.developmentBranch,
264
+ localCommit: (await git.inspect(verifyRoot, project.developmentBranch)).baseCommit
265
+ }])
266
+ ]);
267
+ if (parsed.preflight) {
268
+ return { project, path: destination, preflight: true };
269
+ }
270
+ const switched = store.transaction((tx) => {
271
+ const latest = requireProject(tx, project.id);
272
+ if (latest.ownership !== "external"
273
+ || latest.path !== project.path
274
+ || latest.remoteUrl !== project.remoteUrl) {
275
+ throw new Error(`Project changed while migrating: ${project.id}.`);
276
+ }
277
+ // The switch only changes ownership and path; every other field is
278
+ // frozen, and the catalog validator re-checks the whole record.
279
+ const next = validateProject({
280
+ ...latest,
281
+ path: destination,
282
+ ownership: "managed",
283
+ updatedAt: (options.now ?? (() => new Date()))().toISOString()
284
+ });
285
+ assertProjectAvailable(tx, next, latest.id);
286
+ tx.saveProject(next);
287
+ return next;
288
+ });
289
+ return { project: switched, path: switched.path, preflight: false };
290
+ }
291
+ catch (error) {
292
+ if (prepared && !parsed.preflight
293
+ && !store.listProjects().some(({ path }) => path === destination)) {
294
+ await rm(destination, { recursive: true, force: true });
295
+ }
296
+ throw error;
297
+ }
298
+ finally {
299
+ if (parsed.preflight) {
300
+ await rm(verifyRoot, { recursive: true, force: true });
301
+ }
302
+ }
303
+ }
304
+ /**
305
+ * Remove a managed clone left behind by a crashed migration attempt. Such a
306
+ * directory is unreferenced garbage; a path any catalog record points at is
307
+ * never deleted.
308
+ */
309
+ async function removeUnreferencedClone(store, destination, migratingProjectId) {
310
+ if (!existsSync(destination))
311
+ return;
312
+ const registered = store.listProjects().find(({ path }) => path === destination);
313
+ if (registered !== undefined && registered.id !== migratingProjectId) {
314
+ throw new Error(`Managed Project path is already registered: ${destination}.`);
315
+ }
316
+ await rm(destination, { recursive: true, force: true });
317
+ }
318
+ /**
319
+ * Verify that the local SHAs of the configured branches exactly match the
320
+ * commits the remote advertises right now. Any mismatch fails closed.
321
+ */
322
+ async function assertRemoteBranchesVerified(git, repositoryPath, remoteUrl, branches) {
323
+ for (const branch of branches) {
324
+ const resolved = await git.resolveRemoteBaseline({
325
+ repositoryPath,
326
+ remoteUrl,
327
+ developmentRef: branch.ref
328
+ });
329
+ if (resolved.commit.toLowerCase() !== branch.localCommit.toLowerCase()) {
330
+ throw new Error(`Project remote branch ${resolved.branch} moved while it was fetched: `
331
+ + `advertised ${resolved.commit}, local ${branch.localCommit}.`);
332
+ }
333
+ }
334
+ }
174
335
  async function addProject(args, store, options) {
175
336
  const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
176
337
  const parsed = parseAddArguments(args, usage);
@@ -246,6 +407,7 @@ function renderAddedProject(created) {
246
407
  return [
247
408
  `Added project ${created.id}`,
248
409
  `Name: ${created.name}`,
410
+ `Ownership: ${created.ownership}`,
249
411
  `Path: ${created.path}`,
250
412
  `Stable: ${created.stableBranch}`,
251
413
  `Development: ${created.developmentBranch}`
@@ -286,11 +448,17 @@ function listProjects(args, store) {
286
448
  return `${renderTable("Projects", [
287
449
  { header: "Project", minWidth: 8, maxWidth: 24 },
288
450
  { header: "Name", minWidth: 4, maxWidth: 28 },
451
+ { header: "Ownership", minWidth: 9, maxWidth: 10 },
289
452
  { header: "Path", minWidth: 8, maxWidth: 64 },
290
453
  { header: "Stable", minWidth: 6, maxWidth: 24 },
291
454
  { header: "Development", minWidth: 11, maxWidth: 24 }
292
455
  ], projects.map((project) => [
293
- project.id, project.name, project.path, project.stableBranch, project.developmentBranch
456
+ project.id,
457
+ project.name,
458
+ project.ownership,
459
+ project.path,
460
+ project.stableBranch,
461
+ project.developmentBranch
294
462
  ]), defaultTableWidth())}\n`;
295
463
  }
296
464
  function showProject(args, store) {
@@ -300,6 +468,7 @@ function showProject(args, store) {
300
468
  return [
301
469
  `Project: ${project.id}`,
302
470
  `Name: ${project.name}`,
471
+ `Ownership: ${project.ownership}`,
303
472
  `Aliases: ${project.aliases.length === 0 ? "-" : project.aliases.join(", ")}`,
304
473
  `Path: ${project.path}`,
305
474
  ...(project.remoteUrl === undefined ? [] : [`Remote: ${project.remoteUrl}`]),
@@ -579,8 +748,13 @@ function parseCloneArguments(args, usage) {
579
748
  const aliases = [];
580
749
  let stable;
581
750
  let development;
751
+ let external = false;
582
752
  for (let index = 0; index < args.length; index += 1) {
583
753
  const value = args[index];
754
+ if (value === "--external") {
755
+ external = true;
756
+ continue;
757
+ }
584
758
  if (["--alias", "--stable", "--development"].includes(value)) {
585
759
  const next = args[index + 1];
586
760
  if (next === undefined || next.startsWith("--")) {
@@ -613,9 +787,26 @@ function parseCloneArguments(args, usage) {
613
787
  remote: requireText(positionals[1], "Project remote"),
614
788
  aliases,
615
789
  ...(stable === undefined ? {} : { stable }),
616
- ...(development === undefined ? {} : { development })
790
+ ...(development === undefined ? {} : { development }),
791
+ external
617
792
  };
618
793
  }
794
+ function parseMigrateArguments(args, usage) {
795
+ const positionals = [];
796
+ let preflight = false;
797
+ for (const value of args) {
798
+ if (value === "--preflight") {
799
+ preflight = true;
800
+ continue;
801
+ }
802
+ if (value.startsWith("--"))
803
+ throw usageError(`Unknown option: ${value}. ${usage}`);
804
+ positionals.push(value);
805
+ }
806
+ if (positionals.length !== 1)
807
+ throw usageError(usage);
808
+ return { project: requireText(positionals[0], "Project reference"), preflight };
809
+ }
619
810
  function parseSingleOption(args, option, usage) {
620
811
  const positionals = [];
621
812
  let found;
@@ -0,0 +1,135 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ import { createMilestone } from "../milestone/milestone.js";
3
+ import { defaultTableWidth, renderTable } from "../output/table.js";
4
+ import { runTaskCommand } from "./taskCommands.js";
5
+ /**
6
+ * Task workspace lifecycle commands that sit outside the sync task command
7
+ * surface because they drive Git: controlled rebuild of a legacy Task
8
+ * workspace, legacy ref history inspection/archival, and the
9
+ * replacement-creating path for terminal Tasks.
10
+ */
11
+ export async function runTaskWorkspaceCommand(args, store, preparer, options = {}) {
12
+ const [command, ...rest] = args;
13
+ if (command === "rebuild")
14
+ return rebuildTaskCommand(rest, preparer);
15
+ if (command === "history")
16
+ return historyCommand(rest, preparer);
17
+ if (command === "replace")
18
+ return replaceTaskCommand(rest, store, options);
19
+ throw usageError("Unknown task workspace command. Available: yui task rebuild <task>, "
20
+ + "yui task history list|archive [task], yui task replace <task>.");
21
+ }
22
+ async function rebuildTaskCommand(args, preparer) {
23
+ const usage = "Task rebuild usage: yui task rebuild <task>.";
24
+ const taskId = args[0];
25
+ if (taskId === undefined || args.length !== 1)
26
+ throw usageError(usage);
27
+ const result = await preparer.rebuildTaskWorkspace(taskId);
28
+ const archived = result.archived.length === 0
29
+ ? "no legacy refs"
30
+ : `${result.archived.length} legacy ref(s) archived`;
31
+ const resumed = result.resumed ? " (resumed pending cleanup)" : "";
32
+ return {
33
+ output: `Rebuilt Task workspace ${result.task.id} at ${result.task.cwd ?? "its configured workspace"}: `
34
+ + `${archived}${resumed}.`,
35
+ data: {
36
+ taskId: result.task.id,
37
+ archived: result.archived,
38
+ resumed: result.resumed
39
+ }
40
+ };
41
+ }
42
+ async function historyCommand(args, preparer) {
43
+ const usage = "Task history usage: yui task history list|archive [task].";
44
+ const action = args[0];
45
+ if (action !== "list" && action !== "archive")
46
+ throw usageError(usage);
47
+ const taskId = args[1];
48
+ if (args.length > 2)
49
+ throw usageError(usage);
50
+ if (action === "list") {
51
+ const refs = await preparer.listLegacyTaskRefs(taskId);
52
+ if (refs.length === 0) {
53
+ return { output: "No legacy Task refs.", data: { refs: [] } };
54
+ }
55
+ return {
56
+ output: renderTable("Legacy Task refs", [
57
+ { header: "Project", minWidth: 8, maxWidth: 24 },
58
+ { header: "Task", minWidth: 8, maxWidth: 16 },
59
+ { header: "Legacy ref", minWidth: 16, maxWidth: 64 }
60
+ ], refs.map((entry) => [entry.projectId, entry.taskId, entry.ref]), defaultTableWidth()),
61
+ data: { refs }
62
+ };
63
+ }
64
+ const result = await preparer.archiveLegacyTaskRefs(taskId);
65
+ const lines = [];
66
+ if (result.archived.length > 0) {
67
+ lines.push(`Archived ${result.archived.length} legacy ref(s):`);
68
+ lines.push(...result.archived.map((entry) => ` ${entry}`));
69
+ }
70
+ if (result.refused.length > 0) {
71
+ lines.push(`Refused ${result.refused.length} live ref(s) owned by an open Task:`);
72
+ lines.push(...result.refused.map((entry) => ` ${entry}`));
73
+ }
74
+ if (lines.length === 0)
75
+ lines.push("No legacy Task refs to archive.");
76
+ return {
77
+ output: lines.join("\n"),
78
+ data: { archived: result.archived, refused: result.refused }
79
+ };
80
+ }
81
+ /**
82
+ * A terminal (retired/completed/archived) Task is never rewritten in place.
83
+ * This creates a fresh draft Task bound to the same Projects at the same
84
+ * bases and records the relationship as a milestone on the replacement, so
85
+ * the original Task, its refs, and its evidence stay untouched.
86
+ */
87
+ function replaceTaskCommand(args, store, options) {
88
+ const usage = "Task replace usage: yui task replace <task> [--title <text>].";
89
+ const oldId = args[0];
90
+ if (oldId === undefined)
91
+ throw usageError(usage);
92
+ const old = store.getTask(oldId);
93
+ if (old === null)
94
+ throw usageError(`Task not found: ${oldId}.`);
95
+ if (old.status === "draft" || old.status === "active") {
96
+ throw usageError(`Task is open and can be rebuilt instead of replaced: ${old.id}.`);
97
+ }
98
+ let title = `Replaces ${old.id}: ${old.title}`;
99
+ for (let index = 1; index < args.length; index += 1) {
100
+ if (args[index] === "--title") {
101
+ const value = args[index + 1];
102
+ if (value === undefined || value.startsWith("--"))
103
+ throw usageError(usage);
104
+ title = value;
105
+ index += 1;
106
+ continue;
107
+ }
108
+ throw usageError(usage);
109
+ }
110
+ const createArgs = ["create", title];
111
+ for (const binding of old.projectBindings) {
112
+ createArgs.push("--project", binding.projectId);
113
+ createArgs.push("--base", `${binding.projectId}=${binding.baseRef}`);
114
+ }
115
+ if (old.requireIntegration)
116
+ createArgs.push("--require-integration");
117
+ const created = runTaskCommand(createArgs, store);
118
+ if (created.kind !== "output") {
119
+ throw new Error(`Task replacement could not be created for ${old.id}.`);
120
+ }
121
+ const data = created.data;
122
+ const replacement = data?.task;
123
+ if (replacement === undefined) {
124
+ throw new Error(`Task replacement result did not name the new Task for ${old.id}.`);
125
+ }
126
+ const now = options.now?.() ?? new Date();
127
+ const milestone = createMilestone(store.nextMilestoneId(replacement.id), replacement.id, `Replaces ${old.id}`, `Replacement Task for ${old.id} (${old.status}); the original Task, its refs, `
128
+ + "and its evidence are preserved and were not rewritten.", now);
129
+ store.saveMilestone(replacement.id, milestone);
130
+ return {
131
+ output: `${created.output}\nRecorded replacement of ${old.id} on ${replacement.id} `
132
+ + `(milestone ${milestone.id}).`,
133
+ data: { task: replacement, replaces: old.id, milestone: milestone.id }
134
+ };
135
+ }
@@ -7,6 +7,7 @@ import { selectEnvironment } from "../agent/launchEnvironment.js";
7
7
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
8
8
  import { NodeGitWorkspace, RemoteBaselineConflictError } from "../repository/gitWorkspace.js";
9
9
  import { resolveWorktreeRoot } from "../repository/taskWorkspacePreparer.js";
10
+ import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
10
11
  import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
11
12
  import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
12
13
  import { requireLeaderDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
@@ -78,7 +79,7 @@ export class GitIntegrationService {
78
79
  prepared = await this.git.ensureIntegrationWorktree({
79
80
  repositoryPath: project.path,
80
81
  container: join(this.worktreeRoot, project.name),
81
- taskId: task.id,
82
+ taskSegment: taskWorkspaceRefSegment(task),
82
83
  integrationId: initial.id,
83
84
  baseRef: initial.expectedHead
84
85
  });
@@ -212,7 +213,7 @@ export class GitIntegrationService {
212
213
  const result = await this.git.removeIntegrationWorktree({
213
214
  repositoryPath: project.path,
214
215
  container: join(this.worktreeRoot, project.name),
215
- taskId: task.id,
216
+ taskSegment: taskWorkspaceRefSegment(task),
216
217
  integrationId: integration.id,
217
218
  discardChanges: integration.status === "failed"
218
219
  });