trackops 2.0.6 → 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
@@ -20,11 +20,12 @@ const STATUS_ICONS = {
20
20
  cancelled: "\uD83D\uDDD1\uFE0F",
21
21
  };
22
22
  const CHECK_ICONS = {
23
- pass: "\u2705",
24
- warn: "\u26A0\uFE0F",
25
- fail: "\u274C",
26
- pending: "\u23F3",
27
- };
23
+ pass: "\u2705",
24
+ warn: "\u26A0\uFE0F",
25
+ fail: "\u274C",
26
+ pending: "\u23F3",
27
+ };
28
+ const AUTO_GENERATED_MARKER = "<!-- trackops:auto-generated -->";
28
29
 
29
30
  /* ── helpers ── */
30
31
 
@@ -78,6 +79,200 @@ function listOrNone(items) {
78
79
  return values.length ? values.join(", ") : t("locale.none");
79
80
  }
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
+
81
276
  /* ── repo snapshot ── */
82
277
 
83
278
  function getRepoSnapshot(contextOrRoot) {
@@ -165,118 +360,299 @@ function refreshRepoRuntime(root, options = {}) {
165
360
 
166
361
  /* ── derive ── */
167
362
 
168
- function getPhaseInfo(phaseId, phases) {
169
- return phases.find((p) => p.id === phaseId) || { id: phaseId, label: phaseId, index: 99 };
170
- }
171
-
172
- function compareTasks(a, b, phases) {
173
- const phaseDelta = getPhaseInfo(a.phase, phases).index - getPhaseInfo(b.phase, phases).index;
174
- if (phaseDelta !== 0) return phaseDelta;
175
- const priorityDelta = PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority);
176
- if (priorityDelta !== 0) return priorityDelta;
177
- const statusDelta = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
178
- if (statusDelta !== 0) return statusDelta;
179
- return a.title.localeCompare(b.title, getLocale());
180
- }
181
-
182
- function detectCircularDeps(tasks) {
183
- const visited = new Set();
184
- const inStack = new Set();
185
- const cycles = [];
186
- function dfs(id) {
187
- if (inStack.has(id)) { cycles.push(id); return; }
188
- if (visited.has(id)) return;
189
- visited.add(id);
190
- inStack.add(id);
191
- const task = tasks.find((t) => t.id === id);
192
- if (task) (task.dependsOn || []).forEach((dep) => dfs(dep));
193
- inStack.delete(id);
194
- }
195
- tasks.forEach((t) => dfs(t.id));
196
- return cycles;
197
- }
198
-
199
- function derive(control) {
200
- const phases = config.getPhases(control);
201
- const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
202
- const completedIds = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id));
203
- const allIds = new Set(tasks.map((t) => t.id));
204
- const closedStatuses = new Set(["completed", "cancelled"]);
205
- const phantomDeps = [];
206
-
207
- const readyTasks = tasks
208
- .filter((task) => {
209
- if (task.status !== "pending") return false;
210
- const validDeps = (task.dependsOn || []).filter((dep) => {
211
- if (!allIds.has(dep)) {
212
- phantomDeps.push({ taskId: task.id, missingDep: dep });
213
- return false;
214
- }
215
- return true;
216
- });
217
- return validDeps.every((dep) => completedIds.has(dep));
218
- })
219
- .sort((a, b) => {
220
- const focusPhase = control.meta.focusPhase || "";
221
- const aFocused = a.phase === focusPhase ? 0 : 1;
222
- const bFocused = b.phase === focusPhase ? 0 : 1;
223
- if (aFocused !== bFocused) return aFocused - bFocused;
224
- return compareTasks(a, b, phases);
225
- });
226
-
227
- const blockers = tasks.filter((t) => t.status === "blocked");
228
- const activeTasks = tasks.filter((t) => t.status === "in_progress");
229
- const reviewTasks = tasks.filter((t) => t.status === "in_review");
230
- const openTasks = tasks.filter((t) => !["completed", "cancelled"].includes(t.status));
231
- const requiredOpenTasks = tasks.filter((t) => t.required !== false && !["completed", "cancelled"].includes(t.status));
232
- const projectCompleted = requiredOpenTasks.length === 0 && tasks.length > 0;
233
-
234
- const activePhase =
235
- phases.find((p) => requiredOpenTasks.some((t) => t.phase === p.id)) ||
236
- (projectCompleted ? phases[phases.length - 1] : phases[0]);
237
-
238
- const phaseStats = phases.map((phase) => {
239
- const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
240
- const completed = phaseTasks.filter((t) => t.status === "completed").length;
241
- const closed = phaseTasks.filter((t) => closedStatuses.has(t.status)).length;
242
- return { ...phase, total: phaseTasks.length, completed, closed, remaining: phaseTasks.length - closed };
243
- });
244
-
245
- const nextTask = activeTasks[0] || readyTasks[0] || blockers[0] || openTasks[0] || null;
246
- const circularDeps = detectCircularDeps(tasks);
247
-
248
- return {
249
- tasks, blockers, activeTasks, reviewTasks, readyTasks, nextTask, activePhase, phaseStats,
250
- projectCompleted,
251
- circularDeps,
252
- phantomDeps,
253
- openFindings: (control.findings || []).filter((f) => f.status === "open"),
254
- resolvedFindings: (control.findings || []).filter((f) => f.status === "resolved"),
255
- totals: {
256
- all: tasks.length,
257
- completed: tasks.filter((t) => t.status === "completed").length,
258
- pending: tasks.filter((t) => t.status === "pending").length,
259
- inProgress: activeTasks.length,
260
- inReview: reviewTasks.length,
261
- blocked: blockers.length,
262
- cancelled: tasks.filter((t) => t.status === "cancelled").length,
263
- },
264
- };
265
- }
266
-
267
- /* ── render ── */
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
+ }
268
613
 
614
+ /* ── render ── */
615
+
269
616
  function renderTask(task, phases, options = {}) {
270
617
  const phase = getPhaseInfo(task.phase, phases);
271
618
  const detail = task.blocker || task.summary || "";
272
619
  const detailSuffix = detail ? ` — ${detail}` : "";
273
620
  const token = options.cli ? fmt.statusToken(task.status) : STATUS_ICONS[task.status];
274
- return `- ${token} \`${task.id}\` [${task.priority}] ${task.title} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
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}`;
275
629
  }
276
-
277
- function renderTaskPlan(control) {
278
- const phases = config.getPhases(control);
279
- const state = derive(control);
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);
280
656
  const blockersLabel = state.blockers.length
281
657
  ? state.blockers.map((t) => t.title).join("; ")
282
658
  : t("doc.label.noBlockers");
@@ -285,17 +661,21 @@ function renderTaskPlan(control) {
285
661
  const externalDecisions = (control.decisionsPending || []).length
286
662
  ? control.decisionsPending.map((d) => `- [${d.owner}] ${d.title} — ${d.impact}`).join("\n")
287
663
  : `- ${t("doc.label.noDecisions")}`;
288
-
289
- const readyTasks = state.readyTasks.length
290
- ? state.readyTasks.slice(0, 6).map((task) => renderTask(task, phases)).join("\n")
291
- : `- ${t("doc.label.noReadyTasks")}`;
292
-
293
- const phaseBlocks = phases.map((phase) => {
294
- const phaseTasks = state.tasks.filter((task) => task.phase === phase.id);
295
- const stats = state.phaseStats.find((s) => s.id === phase.id);
296
- const lines = phaseTasks.length
297
- ? phaseTasks.map((task) => renderTask(task, phases)).join("\n")
298
- : `- ${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")}`;
299
679
 
300
680
  const phaseStatus = phase.id === state.activePhase.id
301
681
  ? t("doc.label.phaseActive")
@@ -312,29 +692,44 @@ function renderTaskPlan(control) {
312
692
  ].join("\n");
313
693
  }).join("\n\n---\n\n");
314
694
 
315
- return [
316
- `# ${t("doc.header.taskPlan", { projectName: control.meta.projectName })}`,
317
- "",
318
- `> ${t("doc.autogenerated")}`,
319
- "",
695
+ return [
696
+ `# ${t("doc.header.taskPlan", { projectName: control.meta.projectName })}`,
697
+ "",
698
+ AUTO_GENERATED_MARKER,
699
+ "",
700
+ `> ${t("doc.autogenerated")}`,
701
+ "",
320
702
  `## ${t("doc.section.operativeState")}`,
321
703
  `- ${t("doc.label.activePhase")}: ${state.activePhase.id} — ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
322
704
  `- ${t("doc.label.currentFocus")}: ${control.meta.currentFocus}`,
323
- `- ${t("doc.label.deliveryTarget")}: ${control.meta.deliveryTarget}`,
324
- `- ${t("doc.label.blockers")}: ${blockersLabel}`,
325
- `- ${t("doc.label.nextStep")}: ${nextStep}`,
326
- "",
327
- `### ${t("doc.section.externalDecisions")}`,
328
- externalDecisions,
329
- "",
330
- `### ${t("doc.section.readyTasks")}`,
331
- readyTasks,
332
- "",
333
- "---",
334
- "",
335
- phaseBlocks,
336
- ].join("\n");
337
- }
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
+ }
338
733
 
339
734
  function renderProgress(control) {
340
735
  const phases = config.getPhases(control);
@@ -344,14 +739,17 @@ function renderProgress(control) {
344
739
  : t("doc.label.noBlockers");
345
740
  const nextStep = state.nextTask ? state.nextTask.title : t("doc.label.noOpenTasks");
346
741
  const lastTest = (control.checks || {}).lastTest || { status: "pending" };
347
- const latestHistory = state.tasks
348
- .flatMap((task) => (task.history || []).map((entry) => ({ ...entry, taskId: task.id, taskTitle: task.title })))
349
- .sort((a, b) => (a.at < b.at ? 1 : -1))
350
- .slice(0, 8);
351
-
352
- const activeLines = state.activeTasks.length
353
- ? state.activeTasks.map((task) => renderTask(task, phases)).join("\n")
354
- : `- ${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")}`;
355
753
 
356
754
  const reviewLines = state.reviewTasks.length
357
755
  ? state.reviewTasks.map((task) => renderTask(task, phases)).join("\n")
@@ -360,23 +758,29 @@ function renderProgress(control) {
360
758
  const blockerLines = state.blockers.length
361
759
  ? state.blockers.map((task) => renderTask(task, phases)).join("\n")
362
760
  : `- ${t("doc.label.noActiveBlockers")}`;
363
-
364
- const historyLines = latestHistory.length
365
- ? latestHistory.map((e) => `- [${e.at.slice(0, 10)}] \`${e.taskId}\` ${e.action}${e.note ? ` — ${e.note}` : ""}`).join("\n")
366
- : `- ${t("doc.label.noHistory")}`;
367
-
368
- const milestoneLines = (control.milestones || [])
369
- .map((m) => {
370
- const items = m.items.map((item) => `- ${item}`).join("\n");
371
- 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");
372
774
  })
373
775
  .join("\n\n");
374
776
 
375
- return [
376
- `# ${t("doc.header.progress", { projectName: control.meta.projectName })}`,
377
- "",
378
- `> ${t("doc.autogenerated")}`,
379
- "",
777
+ return [
778
+ `# ${t("doc.header.progress", { projectName: control.meta.projectName })}`,
779
+ "",
780
+ AUTO_GENERATED_MARKER,
781
+ "",
782
+ `> ${t("doc.autogenerated")}`,
783
+ "",
380
784
  `## ${t("doc.section.currentState")}`,
381
785
  `- ${t("doc.label.phase")}: ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
382
786
  `- ${t("doc.label.blockers")}: ${blockersLabel}`,
@@ -391,37 +795,72 @@ function renderProgress(control) {
391
795
  `- ${t("doc.label.completedTasks")}: ${state.totals.completed}`,
392
796
  `- ${t("doc.label.inProgressTasks")}: ${state.totals.inProgress}`,
393
797
  `- ${t("doc.label.inReviewTasks")}: ${state.totals.inReview}`,
394
- `- ${t("doc.label.pendingTasks")}: ${state.totals.pending}`,
395
- `- ${t("doc.label.blockedTasks")}: ${state.totals.blocked}`,
396
- "",
397
- `### ${t("doc.section.activeTasks")}`,
398
- 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,
399
805
  "",
400
806
  `### ${t("doc.section.reviewTasks")}`,
401
807
  reviewLines,
402
808
  "",
403
809
  `### ${t("doc.section.activeBlockers")}`,
404
810
  blockerLines,
405
- "",
406
- `### ${t("doc.section.recentActivity")}`,
407
- historyLines,
408
- "",
409
- "---",
410
- "",
411
- `## ${t("doc.section.milestones")}`,
412
- "",
413
- 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,
414
826
  ].join("\n");
415
827
  }
416
828
 
417
- function renderFindings(control) {
418
- const state = derive(control);
419
- const openLines = state.openFindings.length
420
- ? state.openFindings
421
- .map((f) =>
422
- `### [${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}`
423
- )
424
- .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")
425
864
  : t("doc.label.noFindings");
426
865
 
427
866
  const resolvedLines = state.resolvedFindings.length
@@ -432,11 +871,13 @@ function renderFindings(control) {
432
871
  .join("\n\n")
433
872
  : t("doc.label.noResolvedFindings");
434
873
 
435
- return [
436
- `# ${t("doc.header.findings", { projectName: control.meta.projectName })}`,
437
- "",
438
- `> ${t("doc.autogenerated")}`,
439
- "",
874
+ return [
875
+ `# ${t("doc.header.findings", { projectName: control.meta.projectName })}`,
876
+ "",
877
+ AUTO_GENERATED_MARKER,
878
+ "",
879
+ `> ${t("doc.autogenerated")}`,
880
+ "",
440
881
  `## ${t("doc.section.openFindings")}`,
441
882
  "",
442
883
  openLines,
@@ -477,51 +918,101 @@ function syncDocs(root, control, options = {}) {
477
918
  [docFiles.findings, docs.findings],
478
919
  ];
479
920
  for (const [filePath, content] of pairs) {
480
- if (!options.force && fs.existsSync(filePath)) {
481
- const existing = fs.readFileSync(filePath, "utf8");
482
- const isAutoGenerated = existing.includes(t("doc.autogenerated"));
483
- if (!isAutoGenerated && existing.trim()) {
484
- console.log(t("control.docsOverwriteWarning", { file: path.basename(filePath) }));
485
- continue;
486
- }
487
- }
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
+ }
488
929
  writeText(filePath, `${content}\n`);
489
930
  }
490
931
  }
491
932
 
492
- /* ── task management ── */
493
-
494
- function updateTask(root, control, action, taskId, note) {
495
- const context = config.ensureContext(root);
496
- const task = control.tasks.find((item) => item.id === taskId);
497
- if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
498
-
499
- const actionMap = { start: "in_progress", review: "in_review", complete: "completed", done: "completed", block: "blocked", pending: "pending", cancel: "cancelled" };
500
-
501
- if (action === "note") {
502
- task.history = task.history || [];
503
- task.history.push({ at: nowIso(), action: "note", note: note || t("cli.emptyNote") });
504
- } else {
505
- const nextStatus = actionMap[action];
506
- if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action }));
507
-
508
- if (task.status === nextStatus) {
509
- console.log(t("control.taskAlreadyStatus", { taskId, status: nextStatus }));
510
- return;
511
- }
512
- task.status = nextStatus;
513
- if (nextStatus === "blocked") {
514
- task.blocker = note || task.blocker || t("cli.undocumentedBlocker");
515
- } else {
516
- delete task.blocker;
517
- }
518
- task.history = task.history || [];
519
- task.history.push({ at: nowIso(), action, note: note || "" });
520
- }
521
-
522
- config.saveControl(context, control);
523
- syncDocs(context, control);
524
- 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 });
525
1016
  }
526
1017
 
527
1018
  /* ── CLI commands ── */
@@ -535,11 +1026,11 @@ function initLocale(root) {
535
1026
  }
536
1027
  }
537
1028
 
538
- function cmdStatus(root) {
539
- const context = config.ensureContext(root);
540
- initLocale(context);
541
- const control = config.loadControl(context);
542
- 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);
543
1034
  const phases = config.getPhases(control);
544
1035
  const repo = refreshRepoRuntime(context, { quiet: true });
545
1036
  const drift = getDocDrift(context, control);
@@ -583,12 +1074,19 @@ function cmdStatus(root) {
583
1074
  } else {
584
1075
  console.log(t("cli.status.noBlockers"));
585
1076
  }
586
- console.log("");
587
- console.log(t("cli.status.decisions"));
588
- if ((control.decisionsPending || []).length) {
589
- control.decisionsPending.forEach((d) => console.log(`- ${d.title} (${d.owner}) — ${d.impact}`));
590
- } else {
591
- console.log(`- ${t("cli.status.noDecisions")}`);
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")}`);
592
1090
  }
593
1091
  console.log("");
594
1092
  console.log(t("cli.status.repo"));
@@ -616,44 +1114,78 @@ function cmdStatus(root) {
616
1114
  console.log("");
617
1115
  const syncStatus = drift.length
618
1116
  ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
619
- : t("cli.status.docsSyncedYes");
620
- console.log(t("cli.status.docsSynced", { status: syncStatus }));
621
- }
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
+ }
622
1136
 
623
- function cmdNext(root) {
624
- const context = config.ensureContext(root);
625
- initLocale(context);
626
- const control = config.loadControl(context);
627
- const state = derive(control);
628
- if (state.circularDeps.length) {
629
- console.log(t("control.circularDependency", { taskIds: state.circularDeps.join(", ") }));
630
- }
631
- const ready = state.readyTasks.slice(0, 10);
632
- if (!ready.length) {
633
- if (state.projectCompleted) {
634
- console.log(t("cli.noReadyTasks.allDone"));
635
- } else if (state.blockers.length) {
636
- console.log(t("cli.noReadyTasks.blocked", { count: state.blockers.length }));
637
- for (const task of state.blockers.slice(0, 5)) {
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)) {
638
1172
  console.log(` - ${task.id}: ${task.blocker || task.title}`);
639
1173
  }
640
1174
  } else {
641
1175
  console.log(t("cli.noReadyTasks"));
642
- }
643
- return;
644
- }
645
- ready.forEach((task, i) => {
646
- console.log(`${i + 1}. ${task.title}`);
647
- console.log(` id: ${task.id}`);
648
- console.log(` ${t("cli.next.phase")}: ${task.phase} · ${t("cli.next.priority")}: ${task.priority} · ${t("cli.next.stream")}: ${task.stream}`);
649
- if (task.summary) console.log(` ${t("cli.next.summary")}: ${task.summary}`);
650
- });
651
- }
1176
+ }
1177
+ return;
1178
+ }
1179
+ printTasks(ready);
1180
+ }
652
1181
 
653
- function cmdSync(root, args) {
654
- const context = config.ensureContext(root);
655
- initLocale(context);
656
- const control = config.loadControl(context);
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
+ }
657
1189
  if ((args || []).includes("--dry-run")) {
658
1190
  const drift = getDocDrift(context, control);
659
1191
  if (drift.length) {
@@ -689,12 +1221,12 @@ function cmdRefreshRepo(root, args) {
689
1221
  function cmdTask(root, args) {
690
1222
  const context = config.ensureContext(root);
691
1223
  initLocale(context);
692
- const [action, taskId, ...noteParts] = args || [];
693
- if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
694
- const control = config.loadControl(context);
695
- updateTask(context, control, action, taskId, noteParts.join(" ").trim());
696
- console.log(t("cli.taskUpdated", { taskId, action }));
697
- }
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
+ }
698
1230
 
699
1231
  function cmdInstallHooks(root) {
700
1232
  const context = config.ensureContext(root);
@@ -740,10 +1272,14 @@ function cmdHelp() {
740
1272
  console.log(` ${t("cli.help.register.desc")}`);
741
1273
  console.log(" projects");
742
1274
  console.log(` ${t("cli.help.projects.desc")}`);
743
- console.log(" task <action> <id> [note]");
744
- console.log(` ${t("cli.help.task.desc")}`);
745
- console.log(" opera install|bootstrap|handoff|status|configure|upgrade");
746
- 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")}`);
747
1283
  console.log(` ${t("cli.help.opera.upgradeHint")}`);
748
1284
  console.log(" locale get|set [es|en]");
749
1285
  console.log(` ${t("cli.help.locale.desc")}`);
@@ -773,20 +1309,22 @@ function forProject(root) {
773
1309
  derive,
774
1310
  buildDocMap,
775
1311
  getDocDrift: (ctrl) => getDocDrift(context, ctrl),
776
- syncDocs: (ctrl) => syncDocs(context, ctrl),
777
- updateTask: (ctrl, action, id, note) => updateTask(context, ctrl, action, id, note),
778
- getRepoSnapshot: () => getRepoSnapshot(context),
779
- refreshRepoRuntime: (opts) => refreshRepoRuntime(context, opts),
780
- 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),
781
1318
  getLocale: (ctrl) => config.getLocale(ctrl),
782
1319
  statusLabel,
783
1320
  context,
784
1321
  };
785
1322
  }
786
1323
 
787
- module.exports = {
788
- buildDocMap, derive, getDocDrift, getRepoSnapshot, refreshRepoRuntime, syncDocs, updateTask,
789
- 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,
790
1328
  cmdStatus, cmdNext, cmdSync, cmdRefreshRepo, cmdTask, cmdInstallHooks, cmdHelp,
791
1329
  PRIORITY_ORDER, STATUS_ORDER, STATUS_ICONS, CHECK_ICONS,
792
1330
  };