trackops 2.0.5 → 2.1.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/lib/control.js CHANGED
@@ -4,26 +4,28 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { spawnSync } = require("child_process");
6
6
 
7
- const config = require("./config");
8
- const env = require("./env");
9
- const { t, setLocale, getLocale } = require("./i18n");
10
-
11
- const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
12
- const STATUS_ORDER = ["in_progress", "in_review", "pending", "blocked", "completed", "cancelled"];
13
- const STATUS_ICONS = {
14
- pending: "\u23F3",
15
- in_progress: "\uD83D\uDEA7",
16
- in_review: "\uD83D\uDC40",
17
- blocked: "\u26D4",
18
- completed: "\u2705",
19
- cancelled: "\uD83D\uDDD1\uFE0F",
20
- };
21
- const CHECK_ICONS = {
22
- pass: "\u2705",
23
- warn: "\u26A0\uFE0F",
24
- fail: "\u274C",
25
- pending: "\u23F3",
26
- };
7
+ const config = require("./config");
8
+ const env = require("./env");
9
+ const { t, setLocale, getLocale } = require("./i18n");
10
+ const fmt = require("./cli-format");
11
+
12
+ const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
13
+ const STATUS_ORDER = ["in_progress", "in_review", "pending", "blocked", "completed", "cancelled"];
14
+ const STATUS_ICONS = {
15
+ pending: "\u23F3",
16
+ in_progress: "\uD83D\uDEA7",
17
+ in_review: "\uD83D\uDC40",
18
+ blocked: "\u26D4",
19
+ completed: "\u2705",
20
+ cancelled: "\uD83D\uDDD1\uFE0F",
21
+ };
22
+ const CHECK_ICONS = {
23
+ pass: "\u2705",
24
+ warn: "\u26A0\uFE0F",
25
+ fail: "\u274C",
26
+ pending: "\u23F3",
27
+ };
28
+ const AUTO_GENERATED_MARKER = "<!-- trackops:auto-generated -->";
27
29
 
28
30
  /* ── helpers ── */
29
31
 
@@ -39,29 +41,273 @@ function nowIso() {
39
41
  return new Date().toISOString();
40
42
  }
41
43
 
42
- function git(args, root) {
43
- const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
44
- if (result.error || result.status !== 0) return null;
45
- return result.stdout.replace(/\s+$/, "");
46
- }
47
-
48
- function statusLabel(status) {
49
- return t(`status.${status}`);
50
- }
51
-
52
- /* ── repo snapshot ── */
53
-
54
- function getRepoSnapshot(contextOrRoot) {
55
- const context = config.ensureContext(contextOrRoot);
56
- const repoRoot = context.workspaceRoot;
57
- const branch = git(["branch", "--show-current"], repoRoot) || "detached";
58
- const status = git(["status", "--short"], repoRoot) || "";
59
- const lines = status.split(/\r?\n/).filter(Boolean);
60
- const lastCommitRaw = git(["log", "-1", "--pretty=format:%H%n%cs%n%s"], repoRoot);
61
- const divergenceRaw = git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], repoRoot);
62
-
63
- let staged = 0;
64
- let unstaged = 0;
44
+ function git(args, root) {
45
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
46
+ if (result.error || result.status !== 0) return null;
47
+ return result.stdout.replace(/\s+$/, "");
48
+ }
49
+
50
+ function gitResult(args, root) {
51
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
52
+ return {
53
+ ok: !result.error && result.status === 0,
54
+ status: result.status,
55
+ stdout: String(result.stdout || "").replace(/\s+$/, ""),
56
+ stderr: String(result.stderr || "").replace(/\s+$/, ""),
57
+ error: result.error || null,
58
+ };
59
+ }
60
+
61
+ function statusLabel(status) {
62
+ return t(`status.${status}`);
63
+ }
64
+
65
+ function formatBootstrapStatus(status) {
66
+ return t(`bootstrap.status.${String(status || "").trim()}`) || status || t("locale.none");
67
+ }
68
+
69
+ function formatBootstrapMode(mode) {
70
+ return t(`bootstrap.mode.${String(mode || "").trim()}`) || mode || t("locale.none");
71
+ }
72
+
73
+ function formatBootstrapReason(reason) {
74
+ return t(`bootstrap.reason.${String(reason || "").trim()}`) || reason || t("locale.none");
75
+ }
76
+
77
+ function listOrNone(items) {
78
+ const values = (items || []).filter(Boolean);
79
+ return values.length ? values.join(", ") : t("locale.none");
80
+ }
81
+
82
+ function ensureAgentInbox(control) {
83
+ control.meta = control.meta || {};
84
+ control.meta.agentInbox = config.normalizeAgentInbox(control.meta.agentInbox);
85
+ return control.meta.agentInbox;
86
+ }
87
+
88
+ function makeInboxId(taskId, kind) {
89
+ return `ai-${String(taskId || "general").trim() || "general"}-${String(kind || "info").trim() || "info"}`;
90
+ }
91
+
92
+ function upsertAgentInstruction(control, payload = {}) {
93
+ const inbox = ensureAgentInbox(control);
94
+ const now = nowIso();
95
+ const instruction = config.normalizeAgentInbox({
96
+ pending: [{
97
+ id: payload.id || makeInboxId(payload.taskId, payload.kind),
98
+ taskId: payload.taskId || null,
99
+ kind: payload.kind || "verify_status",
100
+ message: payload.message || "",
101
+ status: "pending",
102
+ createdAt: payload.createdAt || now,
103
+ updatedAt: now,
104
+ createdBy: payload.createdBy || "system",
105
+ source: payload.source || null,
106
+ expectedStatus: payload.expectedStatus || null,
107
+ sessionId: payload.sessionId || null,
108
+ }],
109
+ }).pending[0];
110
+
111
+ const existingIndex = inbox.pending.findIndex((item) => item.id === instruction.id);
112
+ if (existingIndex >= 0) {
113
+ const existing = inbox.pending[existingIndex];
114
+ inbox.pending[existingIndex] = {
115
+ ...existing,
116
+ ...instruction,
117
+ createdAt: existing.createdAt || instruction.createdAt,
118
+ };
119
+ } else {
120
+ inbox.pending.unshift(instruction);
121
+ }
122
+ inbox.lastIssuedAt = now;
123
+ return instruction;
124
+ }
125
+
126
+ function resolveAgentInstructions(control, taskId, options = {}) {
127
+ const inbox = ensureAgentInbox(control);
128
+ const now = nowIso();
129
+ const kinds = Array.isArray(options.kinds) && options.kinds.length
130
+ ? new Set(options.kinds.map((kind) => String(kind || "").trim()))
131
+ : null;
132
+ const resolved = [];
133
+ const pending = [];
134
+
135
+ inbox.pending.forEach((item) => {
136
+ const matchesTask = !taskId || item.taskId === taskId;
137
+ const matchesKind = !kinds || kinds.has(item.kind);
138
+ if (matchesTask && matchesKind) {
139
+ const nextItem = {
140
+ ...item,
141
+ status: "resolved",
142
+ resolvedAt: now,
143
+ resolutionNote: String(options.note || "").trim() || null,
144
+ updatedAt: now,
145
+ };
146
+ inbox.history.unshift(nextItem);
147
+ resolved.push(nextItem);
148
+ } else {
149
+ pending.push(item);
150
+ }
151
+ });
152
+
153
+ inbox.pending = pending;
154
+ return resolved;
155
+ }
156
+
157
+ function ensureTaskExecution(task) {
158
+ task.execution = config.normalizeTaskExecution(task.execution);
159
+ return task.execution;
160
+ }
161
+
162
+ function setTaskExecutionContext(task, options = {}) {
163
+ const execution = ensureTaskExecution(task);
164
+ if (options.owner) execution.owner = config.normalizeExecutionOwner(options.owner);
165
+ if (options.actor) execution.lastActor = String(options.actor || "").trim() || execution.lastActor;
166
+ if (options.source) execution.lastSource = String(options.source || "").trim() || execution.lastSource;
167
+ if (Object.prototype.hasOwnProperty.call(options, "sessionId")) {
168
+ execution.currentSessionId = options.sessionId ? String(options.sessionId).trim() : null;
169
+ if (execution.currentSessionId) execution.lastSessionId = execution.currentSessionId;
170
+ }
171
+ if (Object.prototype.hasOwnProperty.call(options, "sessionStatus")) {
172
+ execution.lastSessionStatus = options.sessionStatus ? String(options.sessionStatus).trim() : null;
173
+ }
174
+ if (Object.prototype.hasOwnProperty.call(options, "awaitingUserConfirmation")) {
175
+ execution.awaitingUserConfirmation = options.awaitingUserConfirmation === true;
176
+ }
177
+ if (Object.prototype.hasOwnProperty.call(options, "verificationPending")) {
178
+ execution.verificationPending = options.verificationPending === true;
179
+ }
180
+ execution.updatedAt = nowIso();
181
+ return execution;
182
+ }
183
+
184
+ function buildUserExecutionInstruction(task, status, source) {
185
+ const sourceLabel = String(source || t("locale.none")).replace(/_/g, " ");
186
+ return t("agentInbox.awaitUser.message", {
187
+ taskId: task.id,
188
+ title: task.title,
189
+ status: statusLabel(status),
190
+ source: sourceLabel,
191
+ });
192
+ }
193
+
194
+ function buildVerificationInstruction(task, status, source) {
195
+ const sourceLabel = String(source || t("locale.none")).replace(/_/g, " ");
196
+ return t("agentInbox.verify.message", {
197
+ taskId: task.id,
198
+ title: task.title,
199
+ status: statusLabel(status),
200
+ source: sourceLabel,
201
+ });
202
+ }
203
+
204
+ function applyTaskStateTransition(control, task, transition, options = {}) {
205
+ const nextStatus = String(transition.status || "").trim();
206
+ const action = String(transition.action || "").trim();
207
+ const note = String(transition.note || "").trim();
208
+ const actor = String(options.actor || "system").trim() || "system";
209
+ const source = String(options.source || "system").trim() || "system";
210
+ const execution = setTaskExecutionContext(task, {
211
+ owner: options.owner || task.execution?.owner,
212
+ actor,
213
+ source,
214
+ sessionId: Object.prototype.hasOwnProperty.call(options, "sessionId") ? options.sessionId : undefined,
215
+ });
216
+
217
+ if (nextStatus && task.status !== nextStatus) {
218
+ task.status = nextStatus;
219
+ }
220
+ if (nextStatus === "blocked") {
221
+ task.blocker = note || task.blocker || t("cli.undocumentedBlocker");
222
+ } else if (nextStatus && nextStatus !== "blocked") {
223
+ delete task.blocker;
224
+ }
225
+
226
+ if (execution.owner === "user" && nextStatus === "in_progress") {
227
+ execution.awaitingUserConfirmation = true;
228
+ upsertAgentInstruction(control, {
229
+ taskId: task.id,
230
+ kind: "await_user_report",
231
+ message: buildUserExecutionInstruction(task, nextStatus, source),
232
+ createdBy: actor,
233
+ source,
234
+ expectedStatus: nextStatus,
235
+ sessionId: options.sessionId || null,
236
+ });
237
+ }
238
+
239
+ if (actor === "user" && nextStatus) {
240
+ execution.verificationPending = true;
241
+ upsertAgentInstruction(control, {
242
+ taskId: task.id,
243
+ kind: "verify_status",
244
+ message: buildVerificationInstruction(task, nextStatus, source),
245
+ createdBy: actor,
246
+ source,
247
+ expectedStatus: nextStatus,
248
+ sessionId: options.sessionId || null,
249
+ });
250
+ }
251
+
252
+ if (actor === "agent" || actor === "system") {
253
+ execution.verificationPending = false;
254
+ resolveAgentInstructions(control, task.id, {
255
+ kinds: ["verify_status"],
256
+ note: note || t("agentInbox.verify.resolved"),
257
+ });
258
+ }
259
+
260
+ if (nextStatus === "completed" || nextStatus === "blocked" || nextStatus === "cancelled") {
261
+ execution.awaitingUserConfirmation = false;
262
+ resolveAgentInstructions(control, task.id, {
263
+ kinds: ["await_user_report"],
264
+ note: note || t("agentInbox.awaitUser.resolved"),
265
+ });
266
+ }
267
+
268
+ if (action) {
269
+ task.history = task.history || [];
270
+ task.history.push({ at: nowIso(), action, note: note || "" });
271
+ }
272
+
273
+ return task;
274
+ }
275
+
276
+ /* ── repo snapshot ── */
277
+
278
+ function getRepoSnapshot(contextOrRoot) {
279
+ const context = config.ensureContext(contextOrRoot);
280
+ const repoRoot = context.workspaceRoot;
281
+ const insideWorkTree = gitResult(["rev-parse", "--is-inside-work-tree"], repoRoot);
282
+ if (!insideWorkTree.ok || insideWorkTree.stdout.trim() !== "true") {
283
+ return {
284
+ generatedAt: nowIso(),
285
+ available: false,
286
+ state: "not_initialized",
287
+ branch: null,
288
+ clean: null,
289
+ staged: 0,
290
+ unstaged: 0,
291
+ untracked: 0,
292
+ ahead: 0,
293
+ behind: 0,
294
+ hasUpstream: false,
295
+ lastCommit: null,
296
+ };
297
+ }
298
+
299
+ const branchResult = gitResult(["branch", "--show-current"], repoRoot);
300
+ const branch = branchResult.ok ? branchResult.stdout.trim() : "";
301
+ const status = git(["status", "--short"], repoRoot) || "";
302
+ const lines = status.split(/\r?\n/).filter(Boolean);
303
+ const lastCommitRaw = git(["log", "-1", "--pretty=format:%H%n%cs%n%s"], repoRoot);
304
+ const upstream = gitResult(["rev-parse", "--abbrev-ref", "@{upstream}"], repoRoot);
305
+ const divergenceRaw = upstream.ok
306
+ ? git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], repoRoot)
307
+ : null;
308
+
309
+ let staged = 0;
310
+ let unstaged = 0;
65
311
  let untracked = 0;
66
312
 
67
313
  lines.forEach((line) => {
@@ -78,14 +324,27 @@ function getRepoSnapshot(contextOrRoot) {
78
324
 
79
325
  let ahead = 0;
80
326
  let behind = 0;
81
- if (divergenceRaw) {
82
- const [left, right] = divergenceRaw.split(/\s+/).map(Number);
83
- behind = Number.isFinite(left) ? left : 0;
84
- ahead = Number.isFinite(right) ? right : 0;
85
- }
86
-
87
- return { generatedAt: nowIso(), branch, clean: lines.length === 0, staged, unstaged, untracked, ahead, behind, lastCommit };
88
- }
327
+ if (divergenceRaw) {
328
+ const [left, right] = divergenceRaw.split(/\s+/).map(Number);
329
+ behind = Number.isFinite(left) ? left : 0;
330
+ ahead = Number.isFinite(right) ? right : 0;
331
+ }
332
+
333
+ return {
334
+ generatedAt: nowIso(),
335
+ available: true,
336
+ state: branch ? "ready" : "detached",
337
+ branch: branch || null,
338
+ clean: lines.length === 0,
339
+ staged,
340
+ unstaged,
341
+ untracked,
342
+ ahead,
343
+ behind,
344
+ hasUpstream: upstream.ok,
345
+ lastCommit,
346
+ };
347
+ }
89
348
 
90
349
  function refreshRepoRuntime(root, options = {}) {
91
350
  const context = config.ensureContext(root);
@@ -101,86 +360,299 @@ function refreshRepoRuntime(root, options = {}) {
101
360
 
102
361
  /* ── derive ── */
103
362
 
104
- function getPhaseInfo(phaseId, phases) {
105
- return phases.find((p) => p.id === phaseId) || { id: phaseId, label: phaseId, index: 99 };
106
- }
107
-
108
- function compareTasks(a, b, phases) {
109
- const phaseDelta = getPhaseInfo(a.phase, phases).index - getPhaseInfo(b.phase, phases).index;
110
- if (phaseDelta !== 0) return phaseDelta;
111
- const priorityDelta = PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority);
112
- if (priorityDelta !== 0) return priorityDelta;
113
- const statusDelta = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
114
- if (statusDelta !== 0) return statusDelta;
115
- return a.title.localeCompare(b.title, getLocale());
116
- }
117
-
118
- function derive(control) {
119
- const phases = config.getPhases(control);
120
- const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
121
- const completedIds = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id));
122
- const closedStatuses = new Set(["completed", "cancelled"]);
123
-
124
- const readyTasks = tasks
125
- .filter((task) => {
126
- if (task.status !== "pending") return false;
127
- return (task.dependsOn || []).every((dep) => completedIds.has(dep));
128
- })
129
- .sort((a, b) => {
130
- const focusPhase = control.meta.focusPhase || "";
131
- const aFocused = a.phase === focusPhase ? 0 : 1;
132
- const bFocused = b.phase === focusPhase ? 0 : 1;
133
- if (aFocused !== bFocused) return aFocused - bFocused;
134
- return compareTasks(a, b, phases);
135
- });
136
-
137
- const blockers = tasks.filter((t) => t.status === "blocked");
138
- const activeTasks = tasks.filter((t) => t.status === "in_progress");
139
- const reviewTasks = tasks.filter((t) => t.status === "in_review");
140
- const openTasks = tasks.filter((t) => !["completed", "cancelled"].includes(t.status));
141
- const requiredOpenTasks = tasks.filter((t) => t.required !== false && !["completed", "cancelled"].includes(t.status));
142
-
143
- const activePhase =
144
- phases.find((p) => requiredOpenTasks.some((t) => t.phase === p.id)) ||
145
- phases[phases.length - 1];
146
-
147
- const phaseStats = phases.map((phase) => {
148
- const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
149
- const completed = phaseTasks.filter((t) => t.status === "completed").length;
150
- const closed = phaseTasks.filter((t) => closedStatuses.has(t.status)).length;
151
- return { ...phase, total: phaseTasks.length, completed, closed, remaining: phaseTasks.length - closed };
152
- });
153
-
154
- const nextTask = activeTasks[0] || readyTasks[0] || blockers[0] || openTasks[0] || null;
155
-
156
- return {
157
- tasks, blockers, activeTasks, reviewTasks, readyTasks, nextTask, activePhase, phaseStats,
158
- openFindings: (control.findings || []).filter((f) => f.status === "open"),
159
- resolvedFindings: (control.findings || []).filter((f) => f.status === "resolved"),
160
- totals: {
161
- all: tasks.length,
162
- completed: tasks.filter((t) => t.status === "completed").length,
163
- pending: tasks.filter((t) => t.status === "pending").length,
164
- inProgress: activeTasks.length,
165
- inReview: reviewTasks.length,
166
- blocked: blockers.length,
167
- cancelled: tasks.filter((t) => t.status === "cancelled").length,
168
- },
169
- };
170
- }
171
-
172
- /* ── render ── */
173
-
174
- function renderTask(task, phases) {
175
- const phase = getPhaseInfo(task.phase, phases);
176
- const detail = task.blocker || task.summary || "";
177
- const detailSuffix = detail ? ` — ${detail}` : "";
178
- return `- ${STATUS_ICONS[task.status]} \`${task.id}\` [${task.priority}] ${task.title} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
179
- }
180
-
181
- function renderTaskPlan(control) {
182
- const phases = config.getPhases(control);
183
- const state = derive(control);
363
+ function getPhaseInfo(phaseId, phases) {
364
+ return phases.find((p) => p.id === phaseId) || { id: phaseId, label: phaseId, index: 99 };
365
+ }
366
+
367
+ function compareTasks(a, b, phases) {
368
+ const aSequence = Number.isFinite(Number(a.sequence)) ? Number(a.sequence) : Number.POSITIVE_INFINITY;
369
+ const bSequence = Number.isFinite(Number(b.sequence)) ? Number(b.sequence) : Number.POSITIVE_INFINITY;
370
+ if (aSequence !== bSequence) return aSequence - bSequence;
371
+ const phaseDelta = getPhaseInfo(a.phase, phases).index - getPhaseInfo(b.phase, phases).index;
372
+ if (phaseDelta !== 0) return phaseDelta;
373
+ const priorityDelta = PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority);
374
+ if (priorityDelta !== 0) return priorityDelta;
375
+ const statusDelta = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
376
+ if (statusDelta !== 0) return statusDelta;
377
+ const titleDelta = a.title.localeCompare(b.title, getLocale());
378
+ if (titleDelta !== 0) return titleDelta;
379
+ return a.id.localeCompare(b.id, getLocale());
380
+ }
381
+
382
+ function detectCircularDeps(tasks) {
383
+ const taskMap = new Map(tasks.map((task) => [task.id, task]));
384
+ const visited = new Set();
385
+ const inStack = new Set();
386
+ const cycles = new Set();
387
+ function dfs(id) {
388
+ if (inStack.has(id)) { cycles.add(id); return; }
389
+ if (visited.has(id)) return;
390
+ visited.add(id);
391
+ inStack.add(id);
392
+ const task = taskMap.get(id);
393
+ if (task) (task.dependsOn || []).forEach((dep) => dfs(dep));
394
+ inStack.delete(id);
395
+ }
396
+ tasks.forEach((t) => dfs(t.id));
397
+ return [...cycles];
398
+ }
399
+
400
+ function detectHierarchyIssues(tasks) {
401
+ const taskMap = new Map(tasks.map((task) => [task.id, task]));
402
+ const hierarchyCycles = new Set();
403
+ const phantomParents = [];
404
+ const visiting = new Set();
405
+ const visited = new Set();
406
+
407
+ function visit(task) {
408
+ if (!task || visited.has(task.id)) return;
409
+ if (visiting.has(task.id)) {
410
+ hierarchyCycles.add(task.id);
411
+ return;
412
+ }
413
+ visiting.add(task.id);
414
+ if (task.parentId) {
415
+ if (task.parentId === task.id) {
416
+ hierarchyCycles.add(task.id);
417
+ } else if (!taskMap.has(task.parentId)) {
418
+ phantomParents.push({ taskId: task.id, missingParentId: task.parentId });
419
+ } else {
420
+ visit(taskMap.get(task.parentId));
421
+ if (hierarchyCycles.has(task.parentId)) hierarchyCycles.add(task.id);
422
+ }
423
+ }
424
+ visiting.delete(task.id);
425
+ visited.add(task.id);
426
+ }
427
+
428
+ tasks.forEach((task) => visit(task));
429
+ return { hierarchyCycles: [...hierarchyCycles], phantomParents };
430
+ }
431
+
432
+ function derive(control) {
433
+ const phases = config.getPhases(control);
434
+ const baseTasks = (control.tasks || []).map((task) => config.normalizeTaskShape(task));
435
+ const taskMap = new Map(baseTasks.map((task) => [task.id, { ...task, rawStatus: task.status }]));
436
+ const { hierarchyCycles, phantomParents } = detectHierarchyIssues(baseTasks);
437
+ const cycleSet = new Set(hierarchyCycles);
438
+ const childrenByParent = new Map();
439
+
440
+ taskMap.forEach((task) => {
441
+ const declaredParentId = task.parentId;
442
+ task.declaredParentId = declaredParentId;
443
+ if (!declaredParentId || declaredParentId === task.id || cycleSet.has(task.id) || !taskMap.has(declaredParentId)) {
444
+ task.parentId = null;
445
+ return;
446
+ }
447
+ if (!childrenByParent.has(declaredParentId)) childrenByParent.set(declaredParentId, []);
448
+ childrenByParent.get(declaredParentId).push(task);
449
+ });
450
+
451
+ childrenByParent.forEach((children) => children.sort((a, b) => compareTasks(a, b, phases)));
452
+
453
+ const leafCache = new Map();
454
+ const statusCache = new Map();
455
+ const visitedTree = new Set();
456
+ const closedStatuses = new Set(["completed", "cancelled"]);
457
+
458
+ function getLeafDescendants(taskId) {
459
+ if (leafCache.has(taskId)) return leafCache.get(taskId);
460
+ const children = childrenByParent.get(taskId) || [];
461
+ if (!children.length) {
462
+ const leaves = [taskMap.get(taskId)].filter(Boolean);
463
+ leafCache.set(taskId, leaves);
464
+ return leaves;
465
+ }
466
+ const leaves = children.flatMap((child) => getLeafDescendants(child.id));
467
+ leafCache.set(taskId, leaves);
468
+ return leaves;
469
+ }
470
+
471
+ function computeStatus(taskId) {
472
+ if (statusCache.has(taskId)) return statusCache.get(taskId);
473
+ const task = taskMap.get(taskId);
474
+ if (!task) return "pending";
475
+ const children = childrenByParent.get(taskId) || [];
476
+ if (!children.length) {
477
+ statusCache.set(taskId, task.rawStatus);
478
+ return task.rawStatus;
479
+ }
480
+ const requiredLeaves = getLeafDescendants(taskId).filter((leaf) => leaf.required !== false);
481
+ let nextStatus = "pending";
482
+ if (requiredLeaves.some((leaf) => leaf.rawStatus === "blocked")) nextStatus = "blocked";
483
+ else if (requiredLeaves.some((leaf) => leaf.rawStatus === "in_review")) nextStatus = "in_review";
484
+ else if (requiredLeaves.some((leaf) => leaf.rawStatus === "in_progress")) nextStatus = "in_progress";
485
+ else if (requiredLeaves.length && requiredLeaves.every((leaf) => closedStatuses.has(leaf.rawStatus)) && requiredLeaves.some((leaf) => leaf.rawStatus === "completed")) nextStatus = "completed";
486
+ statusCache.set(taskId, nextStatus);
487
+ return nextStatus;
488
+ }
489
+
490
+ const roots = [...taskMap.values()]
491
+ .filter((task) => !task.parentId)
492
+ .sort((a, b) => compareTasks(a, b, phases));
493
+ const treeTasks = [];
494
+
495
+ function flatten(task, depth = 0, rootId = task.id, rootTitle = task.title) {
496
+ if (!task || visitedTree.has(task.id)) return;
497
+ visitedTree.add(task.id);
498
+ const children = childrenByParent.get(task.id) || [];
499
+ const effectiveStatus = computeStatus(task.id);
500
+ const execution = config.normalizeTaskExecution(task.execution);
501
+ const derivedTask = {
502
+ ...task,
503
+ status: effectiveStatus,
504
+ effectiveStatus,
505
+ isParent: children.length > 0,
506
+ isLeaf: children.length === 0,
507
+ childrenIds: children.map((child) => child.id),
508
+ childrenCount: children.length,
509
+ depth,
510
+ rootId,
511
+ rootTitle,
512
+ sourceId: task.origin?.sourceId || null,
513
+ detached: task.origin?.detached === true,
514
+ execution,
515
+ executionOwner: execution.owner,
516
+ awaitingUserConfirmation: execution.awaitingUserConfirmation === true,
517
+ verificationPending: execution.verificationPending === true,
518
+ currentSessionId: execution.currentSessionId || null,
519
+ };
520
+ treeTasks.push(derivedTask);
521
+ children.forEach((child) => flatten(child, depth + 1, rootId, rootTitle));
522
+ }
523
+
524
+ roots.forEach((task) => flatten(task));
525
+ [...taskMap.values()].filter((task) => !visitedTree.has(task.id)).sort((a, b) => compareTasks(a, b, phases)).forEach((task) => flatten(task));
526
+
527
+ const allIds = new Set(treeTasks.map((task) => task.id));
528
+ const completedIds = new Set(treeTasks.filter((task) => task.status === "completed").map((task) => task.id));
529
+ const phantomDeps = [];
530
+ const actionableTasks = treeTasks.filter((task) => task.isLeaf);
531
+ const focusPhase = control.meta.focusPhase || "";
532
+
533
+ const readyTasks = actionableTasks
534
+ .filter((task) => {
535
+ if (task.status !== "pending") return false;
536
+ const validDeps = (task.dependsOn || []).filter((dep) => {
537
+ if (!allIds.has(dep)) {
538
+ phantomDeps.push({ taskId: task.id, missingDep: dep });
539
+ return false;
540
+ }
541
+ return true;
542
+ });
543
+ return validDeps.every((dep) => completedIds.has(dep));
544
+ })
545
+ .sort((a, b) => {
546
+ const aFocused = a.phase === focusPhase ? 0 : 1;
547
+ const bFocused = b.phase === focusPhase ? 0 : 1;
548
+ if (aFocused !== bFocused) return aFocused - bFocused;
549
+ return compareTasks(a, b, phases);
550
+ });
551
+
552
+ const blockers = actionableTasks.filter((task) => task.status === "blocked");
553
+ const activeTasks = actionableTasks.filter((task) => task.status === "in_progress");
554
+ const reviewTasks = actionableTasks.filter((task) => task.status === "in_review");
555
+ const awaitingUserTasks = actionableTasks.filter((task) => task.awaitingUserConfirmation);
556
+ const verificationPendingTasks = actionableTasks.filter((task) => task.verificationPending);
557
+ const openTasks = actionableTasks.filter((task) => !closedStatuses.has(task.status));
558
+ const requiredOpenTasks = actionableTasks.filter((task) => task.required !== false && !closedStatuses.has(task.status));
559
+ const projectCompleted = requiredOpenTasks.length === 0 && actionableTasks.length > 0;
560
+ const agentInbox = config.normalizeAgentInbox(control.meta?.agentInbox);
561
+
562
+ const activePhase =
563
+ phases.find((phase) => requiredOpenTasks.some((task) => task.phase === phase.id)) ||
564
+ (projectCompleted ? phases[phases.length - 1] : phases[0]);
565
+
566
+ const phaseStats = phases.map((phase) => {
567
+ const phaseTasks = actionableTasks.filter((task) => task.phase === phase.id && task.required !== false);
568
+ const completed = phaseTasks.filter((task) => task.status === "completed").length;
569
+ const closed = phaseTasks.filter((task) => closedStatuses.has(task.status)).length;
570
+ return { ...phase, total: phaseTasks.length, completed, closed, remaining: phaseTasks.length - closed };
571
+ });
572
+
573
+ const nextTask = activeTasks[0] || readyTasks[0] || blockers[0] || openTasks[0] || null;
574
+ const circularDeps = detectCircularDeps(treeTasks);
575
+
576
+ return {
577
+ tasks: treeTasks,
578
+ roots: roots.map((task) => task.id),
579
+ taskMap: Object.fromEntries(treeTasks.map((task) => [task.id, task])),
580
+ blockers,
581
+ activeTasks,
582
+ reviewTasks,
583
+ awaitingUserTasks,
584
+ verificationPendingTasks,
585
+ agentInbox,
586
+ readyTasks,
587
+ actionableTasks,
588
+ nextTask,
589
+ activePhase,
590
+ phaseStats,
591
+ projectCompleted,
592
+ circularDeps,
593
+ hierarchyCycles,
594
+ phantomDeps,
595
+ phantomParents,
596
+ openFindings: (control.findings || []).filter((finding) => finding.status === "open"),
597
+ resolvedFindings: (control.findings || []).filter((finding) => finding.status === "resolved"),
598
+ totals: {
599
+ all: actionableTasks.length,
600
+ completed: actionableTasks.filter((task) => task.status === "completed").length,
601
+ pending: actionableTasks.filter((task) => task.status === "pending").length,
602
+ inProgress: activeTasks.length,
603
+ inReview: reviewTasks.length,
604
+ blocked: blockers.length,
605
+ cancelled: actionableTasks.filter((task) => task.status === "cancelled").length,
606
+ parents: treeTasks.filter((task) => task.isParent).length,
607
+ awaitingUser: awaitingUserTasks.length,
608
+ verificationPending: verificationPendingTasks.length,
609
+ agentInboxPending: agentInbox.pending.length,
610
+ },
611
+ };
612
+ }
613
+
614
+ /* ── render ── */
615
+
616
+ function renderTask(task, phases, options = {}) {
617
+ const phase = getPhaseInfo(task.phase, phases);
618
+ const detail = task.blocker || task.summary || "";
619
+ const detailSuffix = detail ? ` — ${detail}` : "";
620
+ const token = options.cli ? fmt.statusToken(task.status) : STATUS_ICONS[task.status];
621
+ const indent = options.hierarchy ? " ".repeat(task.depth || 0) : "";
622
+ const sourceSuffix = task.sourceId ? ` [plan:${task.sourceId}]` : "";
623
+ const detachedSuffix = task.detached ? " [detached]" : "";
624
+ const parentSuffix = task.isParent ? ` [${task.childrenCount} child${task.childrenCount === 1 ? "" : "ren"}]` : "";
625
+ const executionSuffix = task.executionOwner ? ` [exec:${task.executionOwner}]` : "";
626
+ const awaitingSuffix = task.awaitingUserConfirmation ? " [await-user]" : "";
627
+ const verifySuffix = task.verificationPending ? " [verify]" : "";
628
+ return `${indent}- ${token} \`${task.id}\` [${task.priority}] ${task.title}${sourceSuffix}${detachedSuffix}${parentSuffix}${executionSuffix}${awaitingSuffix}${verifySuffix} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
629
+ }
630
+
631
+ function renderPlanSummary(control) {
632
+ const sources = control.meta?.plans?.sources || [];
633
+ if (!sources.length) return `- No imported plans.`;
634
+ return sources
635
+ .map((source) => `- [plan:${source.id}] ${source.title} (${source.adapter}) · status=${source.status} · warnings=${source.warnings} · conflicts=${source.conflicts}`)
636
+ .join("\n");
637
+ }
638
+
639
+ function renderAgentInboxSummary(state) {
640
+ const pending = state.agentInbox?.pending || [];
641
+ if (!pending.length) return `- ${t("doc.label.noAgentInbox")}`;
642
+ return pending
643
+ .slice(0, 8)
644
+ .map((item) => `- [${item.kind}] \`${item.taskId || "general"}\` ${item.message}`)
645
+ .join("\n");
646
+ }
647
+
648
+ function renderHierarchyOverview(state, phases) {
649
+ if (!state.tasks.length) return `- ${t("doc.label.noTasks")}`;
650
+ return state.tasks.map((task) => renderTask(task, phases, { hierarchy: true })).join("\n");
651
+ }
652
+
653
+ function renderTaskPlan(control) {
654
+ const phases = config.getPhases(control);
655
+ const state = derive(control);
184
656
  const blockersLabel = state.blockers.length
185
657
  ? state.blockers.map((t) => t.title).join("; ")
186
658
  : t("doc.label.noBlockers");
@@ -189,17 +661,21 @@ function renderTaskPlan(control) {
189
661
  const externalDecisions = (control.decisionsPending || []).length
190
662
  ? control.decisionsPending.map((d) => `- [${d.owner}] ${d.title} — ${d.impact}`).join("\n")
191
663
  : `- ${t("doc.label.noDecisions")}`;
192
-
193
- const readyTasks = state.readyTasks.length
194
- ? state.readyTasks.slice(0, 6).map((task) => renderTask(task, phases)).join("\n")
195
- : `- ${t("doc.label.noReadyTasks")}`;
196
-
197
- const phaseBlocks = phases.map((phase) => {
198
- const phaseTasks = state.tasks.filter((task) => task.phase === phase.id);
199
- const stats = state.phaseStats.find((s) => s.id === phase.id);
200
- const lines = phaseTasks.length
201
- ? phaseTasks.map((task) => renderTask(task, phases)).join("\n")
202
- : `- ${t("doc.label.noTasks")}`;
664
+
665
+ const readyTasks = state.readyTasks.length
666
+ ? state.readyTasks.slice(0, 6).map((task) => renderTask(task, phases)).join("\n")
667
+ : `- ${t("doc.label.noReadyTasks")}`;
668
+
669
+ const hierarchyOverview = renderHierarchyOverview(state, phases);
670
+ const planSummary = renderPlanSummary(control);
671
+ const agentInboxSummary = renderAgentInboxSummary(state);
672
+
673
+ const phaseBlocks = phases.map((phase) => {
674
+ const phaseTasks = state.actionableTasks.filter((task) => task.phase === phase.id);
675
+ const stats = state.phaseStats.find((s) => s.id === phase.id);
676
+ const lines = phaseTasks.length
677
+ ? phaseTasks.map((task) => renderTask(task, phases)).join("\n")
678
+ : `- ${t("doc.label.noTasks")}`;
203
679
 
204
680
  const phaseStatus = phase.id === state.activePhase.id
205
681
  ? t("doc.label.phaseActive")
@@ -216,29 +692,44 @@ function renderTaskPlan(control) {
216
692
  ].join("\n");
217
693
  }).join("\n\n---\n\n");
218
694
 
219
- return [
220
- `# ${t("doc.header.taskPlan", { projectName: control.meta.projectName })}`,
221
- "",
222
- `> ${t("doc.autogenerated")}`,
223
- "",
695
+ return [
696
+ `# ${t("doc.header.taskPlan", { projectName: control.meta.projectName })}`,
697
+ "",
698
+ AUTO_GENERATED_MARKER,
699
+ "",
700
+ `> ${t("doc.autogenerated")}`,
701
+ "",
224
702
  `## ${t("doc.section.operativeState")}`,
225
703
  `- ${t("doc.label.activePhase")}: ${state.activePhase.id} — ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
226
704
  `- ${t("doc.label.currentFocus")}: ${control.meta.currentFocus}`,
227
- `- ${t("doc.label.deliveryTarget")}: ${control.meta.deliveryTarget}`,
228
- `- ${t("doc.label.blockers")}: ${blockersLabel}`,
229
- `- ${t("doc.label.nextStep")}: ${nextStep}`,
230
- "",
231
- `### ${t("doc.section.externalDecisions")}`,
232
- externalDecisions,
233
- "",
234
- `### ${t("doc.section.readyTasks")}`,
235
- readyTasks,
236
- "",
237
- "---",
238
- "",
239
- phaseBlocks,
240
- ].join("\n");
241
- }
705
+ `- ${t("doc.label.deliveryTarget")}: ${control.meta.deliveryTarget}`,
706
+ `- ${t("doc.label.blockers")}: ${blockersLabel}`,
707
+ `- ${t("doc.label.nextStep")}: ${nextStep}`,
708
+ `- Imported plans: ${(control.meta?.plans?.sources || []).length}`,
709
+ `- Unresolved plan conflicts: ${control.meta?.plans?.unresolvedConflicts || 0}`,
710
+ `- Agent inbox pending: ${state.totals.agentInboxPending}`,
711
+ `- Awaiting user confirmation: ${state.totals.awaitingUser}`,
712
+ "",
713
+ "### Plan Sources",
714
+ planSummary,
715
+ "",
716
+ "### Agent Coordination",
717
+ agentInboxSummary,
718
+ "",
719
+ `### ${t("doc.section.externalDecisions")}`,
720
+ externalDecisions,
721
+ "",
722
+ `### ${t("doc.section.readyTasks")}`,
723
+ readyTasks,
724
+ "",
725
+ "### Hierarchy",
726
+ hierarchyOverview,
727
+ "",
728
+ "---",
729
+ "",
730
+ phaseBlocks,
731
+ ].join("\n");
732
+ }
242
733
 
243
734
  function renderProgress(control) {
244
735
  const phases = config.getPhases(control);
@@ -248,14 +739,17 @@ function renderProgress(control) {
248
739
  : t("doc.label.noBlockers");
249
740
  const nextStep = state.nextTask ? state.nextTask.title : t("doc.label.noOpenTasks");
250
741
  const lastTest = (control.checks || {}).lastTest || { status: "pending" };
251
- const latestHistory = state.tasks
252
- .flatMap((task) => (task.history || []).map((entry) => ({ ...entry, taskId: task.id, taskTitle: task.title })))
253
- .sort((a, b) => (a.at < b.at ? 1 : -1))
254
- .slice(0, 8);
255
-
256
- const activeLines = state.activeTasks.length
257
- ? state.activeTasks.map((task) => renderTask(task, phases)).join("\n")
258
- : `- ${t("doc.label.noActiveTasks")}`;
742
+ const latestHistory = state.tasks
743
+ .flatMap((task) => (task.history || []).map((entry) => ({ ...entry, taskId: task.id, taskTitle: task.title })))
744
+ .sort((a, b) => (a.at < b.at ? 1 : -1))
745
+ .slice(0, 8);
746
+ const planActivity = latestHistory
747
+ .filter((entry) => String(entry.action || "").startsWith("plan_") || entry.action === "detach_from_plan")
748
+ .slice(0, 5);
749
+
750
+ const activeLines = state.activeTasks.length
751
+ ? state.activeTasks.map((task) => renderTask(task, phases)).join("\n")
752
+ : `- ${t("doc.label.noActiveTasks")}`;
259
753
 
260
754
  const reviewLines = state.reviewTasks.length
261
755
  ? state.reviewTasks.map((task) => renderTask(task, phases)).join("\n")
@@ -264,27 +758,33 @@ function renderProgress(control) {
264
758
  const blockerLines = state.blockers.length
265
759
  ? state.blockers.map((task) => renderTask(task, phases)).join("\n")
266
760
  : `- ${t("doc.label.noActiveBlockers")}`;
267
-
268
- const historyLines = latestHistory.length
269
- ? latestHistory.map((e) => `- [${e.at.slice(0, 10)}] \`${e.taskId}\` ${e.action}${e.note ? ` — ${e.note}` : ""}`).join("\n")
270
- : `- ${t("doc.label.noHistory")}`;
271
-
272
- const milestoneLines = (control.milestones || [])
273
- .map((m) => {
274
- const items = m.items.map((item) => `- ${item}`).join("\n");
275
- return [`### [${m.date}] — ${m.title}`, items].join("\n");
761
+
762
+ const historyLines = latestHistory.length
763
+ ? latestHistory.map((e) => `- [${e.at.slice(0, 10)}] \`${e.taskId}\` ${e.action}${e.note ? ` — ${e.note}` : ""}`).join("\n")
764
+ : `- ${t("doc.label.noHistory")}`;
765
+ const planActivityLines = planActivity.length
766
+ ? planActivity.map((e) => `- [${e.at.slice(0, 10)}] \`${e.taskId}\` ${e.action}${e.note ? ` — ${e.note}` : ""}`).join("\n")
767
+ : "- No plan imports yet.";
768
+ const agentInboxLines = renderAgentInboxSummary(state);
769
+
770
+ const milestoneLines = (control.milestones || [])
771
+ .map((m) => {
772
+ const items = m.items.map((item) => `- ${item}`).join("\n");
773
+ return [`### [${m.date}] — ${m.title}`, items].join("\n");
276
774
  })
277
775
  .join("\n\n");
278
776
 
279
- return [
280
- `# ${t("doc.header.progress", { projectName: control.meta.projectName })}`,
281
- "",
282
- `> ${t("doc.autogenerated")}`,
283
- "",
777
+ return [
778
+ `# ${t("doc.header.progress", { projectName: control.meta.projectName })}`,
779
+ "",
780
+ AUTO_GENERATED_MARKER,
781
+ "",
782
+ `> ${t("doc.autogenerated")}`,
783
+ "",
284
784
  `## ${t("doc.section.currentState")}`,
285
785
  `- ${t("doc.label.phase")}: ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
286
786
  `- ${t("doc.label.blockers")}: ${blockersLabel}`,
287
- `- ${t("doc.label.lastTest")}: ${CHECK_ICONS[lastTest.status]} ${lastTest.date || t("status.pending")}${lastTest.note ? ` — ${lastTest.note}` : ""}`,
787
+ `- ${t("doc.label.lastTest")}: ${CHECK_ICONS[lastTest.status]} ${lastTest.date || t("status.pending")}${lastTest.note ? ` — ${lastTest.note}` : ""}`,
288
788
  `- ${t("doc.label.nextStepShort")}: ${nextStep}`,
289
789
  `- ${t("doc.label.lastUpdate")}: ${(control.meta.updatedAt || "").slice(0, 10)}`,
290
790
  "",
@@ -295,37 +795,72 @@ function renderProgress(control) {
295
795
  `- ${t("doc.label.completedTasks")}: ${state.totals.completed}`,
296
796
  `- ${t("doc.label.inProgressTasks")}: ${state.totals.inProgress}`,
297
797
  `- ${t("doc.label.inReviewTasks")}: ${state.totals.inReview}`,
298
- `- ${t("doc.label.pendingTasks")}: ${state.totals.pending}`,
299
- `- ${t("doc.label.blockedTasks")}: ${state.totals.blocked}`,
300
- "",
301
- `### ${t("doc.section.activeTasks")}`,
302
- activeLines,
798
+ `- ${t("doc.label.pendingTasks")}: ${state.totals.pending}`,
799
+ `- ${t("doc.label.blockedTasks")}: ${state.totals.blocked}`,
800
+ `- ${t("doc.label.awaitingUserTasks")}: ${state.totals.awaitingUser}`,
801
+ `- ${t("doc.label.agentInboxPending")}: ${state.totals.agentInboxPending}`,
802
+ "",
803
+ `### ${t("doc.section.activeTasks")}`,
804
+ activeLines,
303
805
  "",
304
806
  `### ${t("doc.section.reviewTasks")}`,
305
807
  reviewLines,
306
808
  "",
307
809
  `### ${t("doc.section.activeBlockers")}`,
308
810
  blockerLines,
309
- "",
310
- `### ${t("doc.section.recentActivity")}`,
311
- historyLines,
312
- "",
313
- "---",
314
- "",
315
- `## ${t("doc.section.milestones")}`,
316
- "",
317
- milestoneLines,
811
+ "",
812
+ `### ${t("doc.section.recentActivity")}`,
813
+ historyLines,
814
+ "",
815
+ "### Agent Coordination",
816
+ agentInboxLines,
817
+ "",
818
+ "### Plan Activity",
819
+ planActivityLines,
820
+ "",
821
+ "---",
822
+ "",
823
+ `## ${t("doc.section.milestones")}`,
824
+ "",
825
+ milestoneLines,
318
826
  ].join("\n");
319
827
  }
320
828
 
321
- function renderFindings(control) {
322
- const state = derive(control);
323
- const openLines = state.openFindings.length
324
- ? state.openFindings
325
- .map((f) =>
326
- `### [${f.severity.toUpperCase()}] ${f.title}\n- ${t("doc.label.findingStatus")}: ${t("doc.label.findingOpen")}\n- ${t("doc.label.findingDetail")}: ${f.detail}\n- ${t("doc.label.findingImpact")}: ${f.impact}`
327
- )
328
- .join("\n\n")
829
+ function renderFindings(control) {
830
+ const state = derive(control);
831
+ const planFindings = [];
832
+ if (state.hierarchyCycles.length) {
833
+ planFindings.push({
834
+ severity: "high",
835
+ title: "Hierarchy cycle detected",
836
+ detail: `Tasks with parent cycles: ${state.hierarchyCycles.join(", ")}`,
837
+ impact: "Parent rollups and tree rendering are degraded until hierarchy is corrected.",
838
+ });
839
+ }
840
+ if (state.phantomParents.length) {
841
+ planFindings.push({
842
+ severity: "medium",
843
+ title: "Tasks with missing parents",
844
+ detail: state.phantomParents.map((entry) => `${entry.taskId} -> ${entry.missingParentId}`).join("; "),
845
+ impact: "Affected tasks are promoted to roots in the derived tree.",
846
+ });
847
+ }
848
+ if (control.meta?.plans?.unresolvedConflicts) {
849
+ planFindings.push({
850
+ severity: "medium",
851
+ title: "Imported plans have unresolved conflicts",
852
+ detail: `${control.meta.plans.unresolvedConflicts} linked plan conflict(s) remain unresolved.`,
853
+ impact: "Applying previews with conflict policy 'abort' will be blocked.",
854
+ });
855
+ }
856
+
857
+ const openFindings = [...planFindings, ...state.openFindings];
858
+ const openLines = openFindings.length
859
+ ? openFindings
860
+ .map((f) =>
861
+ `### [${f.severity.toUpperCase()}] ${f.title}\n- ${t("doc.label.findingStatus")}: ${t("doc.label.findingOpen")}\n- ${t("doc.label.findingDetail")}: ${f.detail}\n- ${t("doc.label.findingImpact")}: ${f.impact}`
862
+ )
863
+ .join("\n\n")
329
864
  : t("doc.label.noFindings");
330
865
 
331
866
  const resolvedLines = state.resolvedFindings.length
@@ -336,11 +871,13 @@ function renderFindings(control) {
336
871
  .join("\n\n")
337
872
  : t("doc.label.noResolvedFindings");
338
873
 
339
- return [
340
- `# ${t("doc.header.findings", { projectName: control.meta.projectName })}`,
341
- "",
342
- `> ${t("doc.autogenerated")}`,
343
- "",
874
+ return [
875
+ `# ${t("doc.header.findings", { projectName: control.meta.projectName })}`,
876
+ "",
877
+ AUTO_GENERATED_MARKER,
878
+ "",
879
+ `> ${t("doc.autogenerated")}`,
880
+ "",
344
881
  `## ${t("doc.section.openFindings")}`,
345
882
  "",
346
883
  openLines,
@@ -371,44 +908,111 @@ function getDocDrift(root, control) {
371
908
  .map(([name]) => name);
372
909
  }
373
910
 
374
- function syncDocs(root, control) {
911
+ function syncDocs(root, control, options = {}) {
375
912
  const context = config.ensureContext(root);
376
913
  const docs = buildDocMap(control);
377
914
  const docFiles = config.docFilePaths(context);
378
- writeText(docFiles.taskPlan, `${docs.taskPlan}\n`);
379
- writeText(docFiles.progress, `${docs.progress}\n`);
380
- writeText(docFiles.findings, `${docs.findings}\n`);
381
- }
382
-
383
- /* ── task management ── */
384
-
385
- function updateTask(root, control, action, taskId, note) {
386
- const context = config.ensureContext(root);
387
- const task = control.tasks.find((item) => item.id === taskId);
388
- if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
389
-
390
- const actionMap = { start: "in_progress", review: "in_review", complete: "completed", done: "completed", block: "blocked", pending: "pending", cancel: "cancelled" };
391
-
392
- if (action === "note") {
393
- task.history = task.history || [];
394
- task.history.push({ at: nowIso(), action: "note", note: note || t("cli.emptyNote") });
395
- } else {
396
- const nextStatus = actionMap[action];
397
- if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action }));
398
-
399
- task.status = nextStatus;
400
- if (nextStatus === "blocked") {
401
- task.blocker = note || task.blocker || t("cli.undocumentedBlocker");
402
- } else {
403
- delete task.blocker;
404
- }
405
- task.history = task.history || [];
406
- task.history.push({ at: nowIso(), action, note: note || "" });
915
+ const pairs = [
916
+ [docFiles.taskPlan, docs.taskPlan],
917
+ [docFiles.progress, docs.progress],
918
+ [docFiles.findings, docs.findings],
919
+ ];
920
+ for (const [filePath, content] of pairs) {
921
+ if (!options.force && fs.existsSync(filePath)) {
922
+ const existing = fs.readFileSync(filePath, "utf8");
923
+ const isAutoGenerated = existing.includes(AUTO_GENERATED_MARKER) || existing.includes(t("doc.autogenerated"));
924
+ if (!isAutoGenerated && existing.trim()) {
925
+ console.error(t("control.docsOverwriteWarning", { file: path.basename(filePath) }));
926
+ continue;
927
+ }
928
+ }
929
+ writeText(filePath, `${content}\n`);
407
930
  }
931
+ }
408
932
 
409
- config.saveControl(context, control);
410
- syncDocs(context, control);
411
- refreshRepoRuntime(context, { quiet: true });
933
+ /* ── task management ── */
934
+
935
+ function taskHasChildren(controlState, taskId) {
936
+ return (controlState.tasks || []).some((task) => String(task.parentId || "").trim() === taskId);
937
+ }
938
+
939
+ function setTaskStatus(root, control, taskId, status, note, options = {}) {
940
+ const context = config.ensureContext(root);
941
+ const task = control.tasks.find((item) => item.id === taskId);
942
+ if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
943
+ if (taskHasChildren(control, taskId)) {
944
+ throw new Error(`Task '${taskId}' is a parent task. Update its leaf tasks instead; parent status is rolled up automatically.`);
945
+ }
946
+ const nextStatus = String(status || "").trim();
947
+ if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action: status }));
948
+ if (task.status === nextStatus) {
949
+ setTaskExecutionContext(task, {
950
+ owner: options.owner,
951
+ actor: options.actor || "system",
952
+ source: options.source || "system",
953
+ sessionId: Object.prototype.hasOwnProperty.call(options, "sessionId") ? options.sessionId : undefined,
954
+ verificationPending: task.execution?.verificationPending,
955
+ awaitingUserConfirmation: task.execution?.awaitingUserConfirmation,
956
+ });
957
+ config.saveControl(context, control);
958
+ syncDocs(context, control);
959
+ refreshRepoRuntime(context, { quiet: true });
960
+ return task;
961
+ }
962
+
963
+ applyTaskStateTransition(control, task, {
964
+ status: nextStatus,
965
+ action: options.historyAction || "edit",
966
+ note,
967
+ }, options);
968
+
969
+ config.saveControl(context, control);
970
+ syncDocs(context, control);
971
+ refreshRepoRuntime(context, { quiet: true });
972
+ return task;
973
+ }
974
+
975
+ function updateTask(root, control, action, taskId, note, options = {}) {
976
+ const context = config.ensureContext(root);
977
+ const task = control.tasks.find((item) => item.id === taskId);
978
+ if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
979
+
980
+ const actionMap = { start: "in_progress", review: "in_review", complete: "completed", done: "completed", block: "blocked", pending: "pending", cancel: "cancelled" };
981
+
982
+ if (action === "note") {
983
+ task.history = task.history || [];
984
+ task.history.push({ at: nowIso(), action: "note", note: note || t("cli.emptyNote") });
985
+ setTaskExecutionContext(task, {
986
+ owner: options.owner,
987
+ actor: options.actor || "agent",
988
+ source: options.source || "cli",
989
+ sessionId: Object.prototype.hasOwnProperty.call(options, "sessionId") ? options.sessionId : undefined,
990
+ });
991
+ } else {
992
+ const nextStatus = actionMap[action];
993
+ if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action }));
994
+ if (taskHasChildren(control, taskId)) {
995
+ throw new Error(`Task '${taskId}' is a parent task. Update its leaf tasks instead; parent status is rolled up automatically.`);
996
+ }
997
+
998
+ if (task.status === nextStatus) {
999
+ console.log(t("control.taskAlreadyStatus", { taskId, status: nextStatus }));
1000
+ return;
1001
+ }
1002
+ applyTaskStateTransition(control, task, {
1003
+ status: nextStatus,
1004
+ action,
1005
+ note,
1006
+ }, {
1007
+ ...options,
1008
+ actor: options.actor || "agent",
1009
+ source: options.source || "cli",
1010
+ });
1011
+ }
1012
+
1013
+ config.saveControl(context, control);
1014
+ syncDocs(context, control);
1015
+ refreshRepoRuntime(context, { quiet: true });
412
1016
  }
413
1017
 
414
1018
  /* ── CLI commands ── */
@@ -422,103 +1026,190 @@ function initLocale(root) {
422
1026
  }
423
1027
  }
424
1028
 
425
- function cmdStatus(root) {
426
- const context = config.ensureContext(root);
427
- initLocale(context);
428
- const control = config.loadControl(context);
429
- const state = derive(control);
1029
+ function cmdStatus(root) {
1030
+ const context = config.ensureContext(root);
1031
+ initLocale(context);
1032
+ const control = config.loadControl(context);
1033
+ const state = derive(control);
430
1034
  const phases = config.getPhases(control);
431
1035
  const repo = refreshRepoRuntime(context, { quiet: true });
432
1036
  const drift = getDocDrift(context, control);
433
1037
  const envAudit = env.auditEnvironment(context, control);
434
1038
 
435
- console.log(t("cli.status.title", { projectName: control.meta.projectName }));
436
- console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
437
- console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
438
- console.log(`Layout: ${context.layout} | Workspace: ${context.workspaceRoot}`);
439
- if (context.layout === "split") {
440
- console.log(`App: ${context.appRoot}`);
441
- console.log(`Ops: ${context.opsRoot}`);
442
- }
443
- if (control.meta?.opera?.bootstrap?.status) {
444
- console.log(t("cli.status.bootstrap", { status: control.meta.opera.bootstrap.status, locale: config.getLocale(control) }));
445
- if (control.meta.opera.bootstrap.mode) {
446
- console.log(`Bootstrap mode: ${control.meta.opera.bootstrap.mode}`);
447
- }
448
- if (control.meta.opera.bootstrap.routeReason) {
449
- console.log(`Bootstrap route: ${control.meta.opera.bootstrap.routeReason}`);
450
- }
451
- }
1039
+ console.log(t("cli.status.title", { projectName: control.meta.projectName }));
1040
+ console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
1041
+ console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
1042
+ console.log(t("cli.status.layout", { layout: context.layout, workspace: context.workspaceRoot }));
1043
+ if (context.layout === "split") {
1044
+ console.log(t("cli.status.appRoot", { path: context.appRoot }));
1045
+ console.log(t("cli.status.opsRoot", { path: context.opsRoot }));
1046
+ }
1047
+ if (control.meta?.opera?.bootstrap?.status) {
1048
+ console.log(t("cli.status.bootstrap", {
1049
+ status: formatBootstrapStatus(control.meta.opera.bootstrap.status),
1050
+ locale: config.getLocale(control),
1051
+ }));
1052
+ if (control.meta.opera.bootstrap.mode) {
1053
+ console.log(t("cli.status.bootstrapMode", { value: formatBootstrapMode(control.meta.opera.bootstrap.mode) }));
1054
+ }
1055
+ if (control.meta.opera.bootstrap.routeReason) {
1056
+ console.log(t("cli.status.bootstrapReason", { value: formatBootstrapReason(control.meta.opera.bootstrap.routeReason) }));
1057
+ }
1058
+ }
452
1059
  console.log(t("cli.status.tasks", {
453
1060
  completed: state.totals.completed, inProgress: state.totals.inProgress,
454
1061
  inReview: state.totals.inReview, pending: state.totals.pending, blocked: state.totals.blocked,
455
1062
  }));
456
1063
  console.log("");
457
- console.log(t("cli.status.readyTasks"));
458
- if (state.readyTasks.length) {
459
- state.readyTasks.slice(0, 5).forEach((task) => console.log(renderTask(task, phases)));
460
- } else {
461
- console.log(t("cli.status.noReadyTasks"));
462
- }
463
- console.log("");
464
- console.log(t("cli.status.blockers"));
465
- if (state.blockers.length) {
466
- state.blockers.forEach((task) => console.log(renderTask(task, phases)));
467
- } else {
468
- console.log(t("cli.status.noBlockers"));
469
- }
470
- console.log("");
471
- console.log(t("cli.status.decisions"));
472
- if ((control.decisionsPending || []).length) {
473
- control.decisionsPending.forEach((d) => console.log(`- ${d.title} (${d.owner}) — ${d.impact}`));
474
- } else {
475
- console.log(`- ${t("cli.status.noDecisions")}`);
476
- }
477
- console.log("");
478
- console.log(t("cli.status.repo"));
479
- const treeStatus = repo.clean
480
- ? t("cli.status.treeClean")
481
- : t("cli.status.treeDirty", { staged: repo.staged, unstaged: repo.unstaged, untracked: repo.untracked });
482
- console.log(`- ${t("cli.status.branch", { branch: repo.branch, treeStatus })}`);
483
- if (repo.lastCommit) {
484
- console.log(`- ${t("cli.status.lastCommit", { hash: repo.lastCommit.shortHash, subject: repo.lastCommit.subject })}`);
485
- }
486
- if (repo.ahead || repo.behind) {
487
- console.log(`- ${t("cli.status.divergence", { ahead: repo.ahead, behind: repo.behind })}`);
488
- }
489
- console.log(`- ${t("cli.status.runtime", { path: path.relative(context.workspaceRoot, config.runtimeFilePath(context)) })}`);
490
- console.log(`- Env present: ${envAudit.presentKeys.length ? envAudit.presentKeys.join(", ") : "none"}`);
491
- console.log(`- Env missing: ${envAudit.missingKeys.length ? envAudit.missingKeys.join(", ") : "none"}`);
1064
+ console.log(t("cli.status.readyTasks"));
1065
+ if (state.readyTasks.length) {
1066
+ state.readyTasks.slice(0, 5).forEach((task) => console.log(renderTask(task, phases, { cli: true })));
1067
+ } else {
1068
+ console.log(t("cli.status.noReadyTasks"));
1069
+ }
492
1070
  console.log("");
493
- const syncStatus = drift.length
494
- ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
495
- : t("cli.status.docsSyncedYes");
496
- console.log(t("cli.status.docsSynced", { status: syncStatus }));
497
- }
498
-
499
- function cmdNext(root) {
500
- const context = config.ensureContext(root);
501
- initLocale(context);
502
- const control = config.loadControl(context);
503
- const ready = derive(control).readyTasks.slice(0, 10);
504
- if (!ready.length) {
505
- console.log(t("cli.noReadyTasks"));
506
- return;
1071
+ console.log(t("cli.status.blockers"));
1072
+ if (state.blockers.length) {
1073
+ state.blockers.forEach((task) => console.log(renderTask(task, phases, { cli: true })));
1074
+ } else {
1075
+ console.log(t("cli.status.noBlockers"));
1076
+ }
1077
+ console.log("");
1078
+ console.log(t("cli.status.decisions"));
1079
+ if ((control.decisionsPending || []).length) {
1080
+ control.decisionsPending.forEach((d) => console.log(`- ${d.title} (${d.owner}) — ${d.impact}`));
1081
+ } else {
1082
+ console.log(`- ${t("cli.status.noDecisions")}`);
1083
+ }
1084
+ console.log("");
1085
+ console.log(t("cli.status.agentInbox"));
1086
+ if (state.agentInbox.pending.length) {
1087
+ state.agentInbox.pending.slice(0, 5).forEach((item) => console.log(`- [${item.kind}] ${item.taskId || "general"} — ${item.message}`));
1088
+ } else {
1089
+ console.log(`- ${t("cli.status.noAgentInbox")}`);
1090
+ }
1091
+ console.log("");
1092
+ console.log(t("cli.status.repo"));
1093
+ if (!repo.available) {
1094
+ console.log(`- ${t("cli.status.gitState", { state: t("cli.status.gitNotInitialized") })}`);
1095
+ console.log(`- ${t("cli.status.gitAction")}`);
1096
+ } else {
1097
+ const branchLabel = repo.state === "detached" ? t("cli.status.branchDetached") : repo.branch;
1098
+ const treeStatus = repo.clean
1099
+ ? t("cli.status.treeClean")
1100
+ : t("cli.status.treeDirty", { staged: repo.staged, unstaged: repo.unstaged, untracked: repo.untracked });
1101
+ console.log(`- ${t("cli.status.branch", { branch: branchLabel, treeStatus })}`);
1102
+ if (repo.lastCommit) {
1103
+ console.log(`- ${t("cli.status.lastCommit", { hash: repo.lastCommit.shortHash, subject: repo.lastCommit.subject })}`);
1104
+ }
1105
+ if (!repo.hasUpstream) {
1106
+ console.log(`- ${t("cli.status.noUpstream")}`);
1107
+ } else if (repo.ahead || repo.behind) {
1108
+ console.log(`- ${t("cli.status.divergence", { ahead: repo.ahead, behind: repo.behind })}`);
1109
+ }
1110
+ }
1111
+ console.log(`- ${t("cli.status.runtime", { path: path.relative(context.workspaceRoot, config.runtimeFilePath(context)) })}`);
1112
+ console.log(`- ${t("cli.status.envPresent", { value: listOrNone(envAudit.presentKeys) })}`);
1113
+ console.log(`- ${t("cli.status.envMissing", { value: listOrNone(envAudit.missingKeys) })}`);
1114
+ console.log("");
1115
+ const syncStatus = drift.length
1116
+ ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
1117
+ : t("cli.status.docsSyncedYes");
1118
+ console.log(t("cli.status.docsSynced", { status: syncStatus }));
1119
+ try {
1120
+ const quality = require("./quality");
1121
+ const snapshot = quality.buildQualitySnapshot(context, control);
1122
+ console.log("");
1123
+ console.log(t("quality.statusBlock.title"));
1124
+ console.log(`- ${t("quality.statusBlock.status")}: ${quality.formatQualityStatus(snapshot.report.summary.overallStatus)}`);
1125
+ console.log(`- ${t("quality.statusBlock.phaseReadiness")}: ${quality.formatQualityStatus(snapshot.phaseReadiness.status)}`);
1126
+ console.log(`- ${t("quality.statusBlock.releaseReadiness")}: ${quality.formatQualityStatus(snapshot.releaseReadiness.status)}`);
1127
+ if (snapshot.releaseReadiness.blockers.length) {
1128
+ snapshot.releaseReadiness.blockers.slice(0, 3).forEach((item) => {
1129
+ console.log(`- ${t("quality.statusBlock.blocker")}: ${item.id} — ${item.message}`);
1130
+ });
1131
+ }
1132
+ } catch (_error) {
1133
+ // Quality is complementary; status should still work if it cannot be computed.
1134
+ }
1135
+ }
1136
+
1137
+ function cmdNext(root) {
1138
+ const context = config.ensureContext(root);
1139
+ initLocale(context);
1140
+ const control = config.loadControl(context);
1141
+ const state = derive(control);
1142
+ const printTasks = (tasks) => {
1143
+ tasks.forEach((task, i) => {
1144
+ console.log(`${i + 1}. ${task.title}`);
1145
+ console.log(` id: ${task.id}`);
1146
+ console.log(` ${t("cli.next.phase")}: ${task.phase} · ${t("cli.next.priority")}: ${task.priority} · ${t("cli.next.stream")}: ${task.stream}`);
1147
+ if (task.summary) console.log(` ${t("cli.next.summary")}: ${task.summary}`);
1148
+ });
1149
+ };
1150
+
1151
+ if (state.circularDeps.length) {
1152
+ console.log(t("control.circularDependency", { taskIds: state.circularDeps.join(", ") }));
1153
+ }
1154
+ const active = state.activeTasks.slice(0, 5);
1155
+ const ready = state.readyTasks.slice(0, 10);
1156
+ if (active.length) {
1157
+ console.log(t("cli.next.activeTasks"));
1158
+ printTasks(active);
1159
+ if (ready.length) {
1160
+ console.log("");
1161
+ console.log(t("cli.next.readyQueue"));
1162
+ printTasks(ready.slice(0, 5));
1163
+ }
1164
+ return;
1165
+ }
1166
+ if (!ready.length) {
1167
+ if (state.projectCompleted) {
1168
+ console.log(t("cli.noReadyTasks.allDone"));
1169
+ } else if (state.blockers.length) {
1170
+ console.log(t("cli.noReadyTasks.blocked", { count: state.blockers.length }));
1171
+ for (const task of state.blockers.slice(0, 5)) {
1172
+ console.log(` - ${task.id}: ${task.blocker || task.title}`);
1173
+ }
1174
+ } else {
1175
+ console.log(t("cli.noReadyTasks"));
1176
+ }
1177
+ return;
1178
+ }
1179
+ printTasks(ready);
1180
+ }
1181
+
1182
+ function cmdSync(root, args) {
1183
+ const context = config.ensureContext(root);
1184
+ initLocale(context);
1185
+ const control = config.loadControl(context);
1186
+ if (control.__trackopsMigrated) {
1187
+ config.saveControl(context, control);
1188
+ }
1189
+ if ((args || []).includes("--dry-run")) {
1190
+ const drift = getDocDrift(context, control);
1191
+ if (drift.length) {
1192
+ console.log(t("cli.sync.dryRunWouldUpdate", { files: drift.join(", ") }));
1193
+ } else {
1194
+ console.log(t("cli.sync.dryRunInSync"));
1195
+ }
1196
+ return;
1197
+ }
1198
+ const state = derive(control);
1199
+ if (state.activePhase && control.meta.focusPhase !== state.activePhase.id) {
1200
+ control.meta.focusPhase = state.activePhase.id;
1201
+ config.saveControl(context, control);
507
1202
  }
508
- ready.forEach((task, i) => {
509
- console.log(`${i + 1}. ${task.title}`);
510
- console.log(` id: ${task.id}`);
511
- console.log(` ${t("cli.next.phase")}: ${task.phase} · ${t("cli.next.priority")}: ${task.priority} · ${t("cli.next.stream")}: ${task.stream}`);
512
- if (task.summary) console.log(` ${t("cli.next.summary")}: ${task.summary}`);
513
- });
514
- }
515
-
516
- function cmdSync(root) {
517
- const context = config.ensureContext(root);
518
- initLocale(context);
519
- const control = config.loadControl(context);
520
1203
  env.syncEnvironment(context, control);
521
- syncDocs(context, control);
1204
+ if (config.isOperaInstalled(control)) {
1205
+ const bootstrap = require("./opera-bootstrap");
1206
+ const result = bootstrap.revalidateContract(context, control);
1207
+ if (result.changed) {
1208
+ console.log(t("control.contractStale"));
1209
+ }
1210
+ }
1211
+ const force = (args || []).includes("--force");
1212
+ syncDocs(context, control, { force });
522
1213
  refreshRepoRuntime(context, { quiet: true });
523
1214
  console.log(t("cli.docsSynced"));
524
1215
  }
@@ -530,12 +1221,12 @@ function cmdRefreshRepo(root, args) {
530
1221
  function cmdTask(root, args) {
531
1222
  const context = config.ensureContext(root);
532
1223
  initLocale(context);
533
- const [action, taskId, ...noteParts] = args || [];
534
- if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
535
- const control = config.loadControl(context);
536
- updateTask(context, control, action, taskId, noteParts.join(" ").trim());
537
- console.log(t("cli.taskUpdated", { taskId, action }));
538
- }
1224
+ const [action, taskId, ...noteParts] = args || [];
1225
+ if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
1226
+ const control = config.loadControl(context);
1227
+ updateTask(context, control, action, taskId, noteParts.join(" ").trim(), { actor: "agent", source: "cli" });
1228
+ console.log(t("cli.taskUpdated", { taskId, action }));
1229
+ }
539
1230
 
540
1231
  function cmdInstallHooks(root) {
541
1232
  const context = config.ensureContext(root);
@@ -581,10 +1272,14 @@ function cmdHelp() {
581
1272
  console.log(` ${t("cli.help.register.desc")}`);
582
1273
  console.log(" projects");
583
1274
  console.log(` ${t("cli.help.projects.desc")}`);
584
- console.log(" task <action> <id> [note]");
585
- console.log(` ${t("cli.help.task.desc")}`);
586
- console.log(" opera install|bootstrap|handoff|status|configure|upgrade");
587
- console.log(` ${t("cli.help.opera.desc")}`);
1275
+ console.log(" task <action> <id> [note]");
1276
+ console.log(` ${t("cli.help.task.desc")}`);
1277
+ console.log(" plan scan|import|show|apply|list|unlink");
1278
+ console.log(` ${t("cli.help.plan.desc")}`);
1279
+ console.log(" quality status|verify|phase-readiness|release-readiness|promote-readiness|waiver");
1280
+ console.log(` ${t("cli.help.quality.desc")}`);
1281
+ console.log(" opera install|bootstrap|handoff|status|configure|upgrade");
1282
+ console.log(` ${t("cli.help.opera.desc")}`);
588
1283
  console.log(` ${t("cli.help.opera.upgradeHint")}`);
589
1284
  console.log(" locale get|set [es|en]");
590
1285
  console.log(` ${t("cli.help.locale.desc")}`);
@@ -593,11 +1288,14 @@ function cmdHelp() {
593
1288
  console.log(" skill install|list|remove|catalog <name>");
594
1289
  console.log(` ${t("cli.help.skill.desc")}`);
595
1290
  console.log(" help");
596
- console.log(` ${t("cli.help.help.desc")}`);
597
- console.log("");
598
- console.log(t("cli.help.globalWorkflow"));
599
- console.log(` ${t("cli.help.globalWorkflow.line1")}`);
600
- console.log(` ${t("cli.help.globalWorkflow.line2")}`);
1291
+ console.log(` ${t("cli.help.help.desc")}`);
1292
+ console.log("");
1293
+ console.log(t("cli.help.globalFlags"));
1294
+ console.log(` ${t("cli.help.globalFlags.line1")}`);
1295
+ console.log("");
1296
+ console.log(t("cli.help.globalWorkflow"));
1297
+ console.log(` ${t("cli.help.globalWorkflow.line1")}`);
1298
+ console.log(` ${t("cli.help.globalWorkflow.line2")}`);
601
1299
  }
602
1300
 
603
1301
  /* ── project-scoped API (used by server) ── */
@@ -611,20 +1309,22 @@ function forProject(root) {
611
1309
  derive,
612
1310
  buildDocMap,
613
1311
  getDocDrift: (ctrl) => getDocDrift(context, ctrl),
614
- syncDocs: (ctrl) => syncDocs(context, ctrl),
615
- updateTask: (ctrl, action, id, note) => updateTask(context, ctrl, action, id, note),
616
- getRepoSnapshot: () => getRepoSnapshot(context),
617
- refreshRepoRuntime: (opts) => refreshRepoRuntime(context, opts),
618
- getPhases: (ctrl) => config.getPhases(ctrl),
1312
+ syncDocs: (ctrl) => syncDocs(context, ctrl),
1313
+ updateTask: (ctrl, action, id, note, options) => updateTask(context, ctrl, action, id, note, options),
1314
+ setTaskStatus: (ctrl, id, status, note, options) => setTaskStatus(context, ctrl, id, status, note, options),
1315
+ getRepoSnapshot: () => getRepoSnapshot(context),
1316
+ refreshRepoRuntime: (opts) => refreshRepoRuntime(context, opts),
1317
+ getPhases: (ctrl) => config.getPhases(ctrl),
619
1318
  getLocale: (ctrl) => config.getLocale(ctrl),
620
1319
  statusLabel,
621
1320
  context,
622
1321
  };
623
1322
  }
624
1323
 
625
- module.exports = {
626
- buildDocMap, derive, getDocDrift, getRepoSnapshot, refreshRepoRuntime, syncDocs, updateTask,
627
- forProject, statusLabel, renderTask, getPhaseInfo,
1324
+ module.exports = {
1325
+ buildDocMap, derive, getDocDrift, getRepoSnapshot, refreshRepoRuntime, syncDocs, updateTask,
1326
+ setTaskStatus, ensureAgentInbox, upsertAgentInstruction, resolveAgentInstructions,
1327
+ forProject, statusLabel, renderTask, getPhaseInfo,
628
1328
  cmdStatus, cmdNext, cmdSync, cmdRefreshRepo, cmdTask, cmdInstallHooks, cmdHelp,
629
1329
  PRIORITY_ORDER, STATUS_ORDER, STATUS_ICONS, CHECK_ICONS,
630
1330
  };