@aperant/framework 0.6.5 → 0.6.7

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.
Files changed (149) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +17 -251
  3. package/dist/cli/commands/check-version.d.mts.map +1 -1
  4. package/dist/cli/commands/check-version.mjs +76 -1
  5. package/dist/cli/commands/check-version.mjs.map +1 -1
  6. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  7. package/dist/cli/commands/ci-watch.mjs +34 -4
  8. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  9. package/dist/cli/commands/commit.d.mts.map +1 -1
  10. package/dist/cli/commands/commit.mjs +25 -8
  11. package/dist/cli/commands/commit.mjs.map +1 -1
  12. package/dist/cli/commands/event.d.mts.map +1 -1
  13. package/dist/cli/commands/event.mjs +66 -0
  14. package/dist/cli/commands/event.mjs.map +1 -1
  15. package/dist/cli/commands/init.d.mts +8 -1
  16. package/dist/cli/commands/init.d.mts.map +1 -1
  17. package/dist/cli/commands/init.mjs +91 -9
  18. package/dist/cli/commands/init.mjs.map +1 -1
  19. package/dist/cli/commands/modes.d.mts.map +1 -1
  20. package/dist/cli/commands/modes.mjs +2 -1
  21. package/dist/cli/commands/modes.mjs.map +1 -1
  22. package/dist/cli/commands/release-notes.d.mts +11 -0
  23. package/dist/cli/commands/release-notes.d.mts.map +1 -0
  24. package/dist/cli/commands/release-notes.mjs +173 -0
  25. package/dist/cli/commands/release-notes.mjs.map +1 -0
  26. package/dist/cli/commands/task.d.mts.map +1 -1
  27. package/dist/cli/commands/task.mjs +285 -139
  28. package/dist/cli/commands/task.mjs.map +1 -1
  29. package/dist/cli/commands/triage.d.mts.map +1 -1
  30. package/dist/cli/commands/triage.mjs +9 -5
  31. package/dist/cli/commands/triage.mjs.map +1 -1
  32. package/dist/cli/config/load.d.mts +14 -0
  33. package/dist/cli/config/load.d.mts.map +1 -1
  34. package/dist/cli/config/load.mjs +40 -0
  35. package/dist/cli/config/load.mjs.map +1 -1
  36. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  37. package/dist/cli/config/upgrade-gitignore.mjs +6 -0
  38. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  39. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  40. package/dist/cli/consistency/parse-review.mjs +4 -1
  41. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  42. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
  43. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
  44. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
  45. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
  46. package/dist/cli/coordination/event-schema.d.mts +16 -0
  47. package/dist/cli/coordination/event-schema.d.mts.map +1 -0
  48. package/dist/cli/coordination/event-schema.mjs +161 -0
  49. package/dist/cli/coordination/event-schema.mjs.map +1 -0
  50. package/dist/cli/coordination/store.d.mts +6 -0
  51. package/dist/cli/coordination/store.d.mts.map +1 -1
  52. package/dist/cli/coordination/store.mjs +14 -0
  53. package/dist/cli/coordination/store.mjs.map +1 -1
  54. package/dist/cli/dispatch.d.mts.map +1 -1
  55. package/dist/cli/dispatch.mjs +2 -0
  56. package/dist/cli/dispatch.mjs.map +1 -1
  57. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  58. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  59. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  60. package/dist/cli/help.mjs +2 -2
  61. package/dist/cli/help.mjs.map +1 -1
  62. package/dist/cli/install/update-chips.d.mts +23 -0
  63. package/dist/cli/install/update-chips.d.mts.map +1 -1
  64. package/dist/cli/install/update-chips.mjs +60 -0
  65. package/dist/cli/install/update-chips.mjs.map +1 -1
  66. package/dist/cli/release-notes/compile.d.mts +38 -0
  67. package/dist/cli/release-notes/compile.d.mts.map +1 -0
  68. package/dist/cli/release-notes/compile.mjs +244 -0
  69. package/dist/cli/release-notes/compile.mjs.map +1 -0
  70. package/dist/cli/release-notes/draft.d.mts +73 -0
  71. package/dist/cli/release-notes/draft.d.mts.map +1 -0
  72. package/dist/cli/release-notes/draft.mjs +120 -0
  73. package/dist/cli/release-notes/draft.mjs.map +1 -0
  74. package/dist/cli/release-notes/output-dir.d.mts +20 -0
  75. package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
  76. package/dist/cli/release-notes/output-dir.mjs +42 -0
  77. package/dist/cli/release-notes/output-dir.mjs.map +1 -0
  78. package/dist/cli/release-notes/persona-filter.d.mts +51 -0
  79. package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
  80. package/dist/cli/release-notes/persona-filter.mjs +127 -0
  81. package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
  82. package/dist/cli/release-notes/publish.d.mts +23 -0
  83. package/dist/cli/release-notes/publish.d.mts.map +1 -0
  84. package/dist/cli/release-notes/publish.mjs +125 -0
  85. package/dist/cli/release-notes/publish.mjs.map +1 -0
  86. package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
  87. package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
  88. package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
  89. package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
  90. package/dist/cli/roadmap/conductor-view.d.mts +13 -0
  91. package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
  92. package/dist/cli/roadmap/conductor-view.mjs +31 -0
  93. package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
  94. package/dist/cli/route/skill-discover.d.mts.map +1 -1
  95. package/dist/cli/route/skill-discover.mjs +2 -1
  96. package/dist/cli/route/skill-discover.mjs.map +1 -1
  97. package/dist/cli/task/ids.d.mts +7 -4
  98. package/dist/cli/task/ids.d.mts.map +1 -1
  99. package/dist/cli/task/ids.mjs +11 -10
  100. package/dist/cli/task/ids.mjs.map +1 -1
  101. package/dist/types/config.d.ts +11 -7
  102. package/dist/types/config.d.ts.map +1 -1
  103. package/package.json +9 -1
  104. package/prompts/conductor-status-check.md +23 -0
  105. package/prompts/conductor-sub-agent.md +57 -0
  106. package/prompts/conductor-system.md +172 -0
  107. package/skills/apt-close-task/SKILL.md +25 -0
  108. package/skills/apt-diagram/SKILL.md +45 -9
  109. package/skills/apt-plan/SKILL.md +12 -0
  110. package/skills/apt-plan/adapters/conductor.md +98 -0
  111. package/skills/apt-release-notes/SKILL.md +193 -0
  112. package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  113. package/skills/apt-setup/SKILL.md +148 -3
  114. package/skills/apt-ship/SKILL.md +74 -12
  115. package/skills/apt-spar/SKILL.md +290 -0
  116. package/skills/apt-update/SKILL.md +51 -9
  117. package/skills/apt-watch-ci/SKILL.md +2 -2
  118. package/src/cli/commands/check-version.mjs +73 -1
  119. package/src/cli/commands/ci-watch.mjs +35 -4
  120. package/src/cli/commands/commit.mjs +27 -8
  121. package/src/cli/commands/event.mjs +68 -0
  122. package/src/cli/commands/init.mjs +101 -9
  123. package/src/cli/commands/modes.mjs +2 -1
  124. package/src/cli/commands/release-notes.mjs +187 -0
  125. package/src/cli/commands/task.mjs +305 -152
  126. package/src/cli/commands/triage.mjs +14 -5
  127. package/src/cli/config/load.mjs +37 -0
  128. package/src/cli/config/upgrade-gitignore.mjs +6 -0
  129. package/src/cli/consistency/parse-review.mjs +3 -1
  130. package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
  131. package/src/cli/coordination/event-schema.d.ts +13 -0
  132. package/src/cli/coordination/event-schema.mjs +174 -0
  133. package/src/cli/coordination/store.mjs +14 -0
  134. package/src/cli/dispatch.mjs +2 -0
  135. package/src/cli/gate/gates/review-clean.mjs +4 -2
  136. package/src/cli/help.mjs +2 -2
  137. package/src/cli/install/update-chips.mjs +57 -0
  138. package/src/cli/release-notes/compile.mjs +261 -0
  139. package/src/cli/release-notes/draft.mjs +133 -0
  140. package/src/cli/release-notes/output-dir.mjs +52 -0
  141. package/src/cli/release-notes/persona-filter.mjs +126 -0
  142. package/src/cli/release-notes/publish.mjs +128 -0
  143. package/src/cli/release-notes/ship-autodraft.mjs +106 -0
  144. package/src/cli/roadmap/conductor-view.d.ts +10 -0
  145. package/src/cli/roadmap/conductor-view.mjs +31 -0
  146. package/src/cli/route/skill-discover.mjs +2 -1
  147. package/src/cli/task/ids.mjs +15 -13
  148. package/templates/config.json +28 -3
  149. package/workflows/docs.md +12 -0
@@ -13,7 +13,7 @@
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
- import { getTaskIsolationConfig } from '../config/load.mjs';
16
+ import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs';
17
17
  import { findMostRecentEventForTask } from '../coordination/event-log.mjs';
18
18
  import { createCoordinationStore } from '../coordination/store.mjs';
19
19
  import { getGitIdentity } from '../git/identity.mjs';
@@ -35,11 +35,12 @@ import { parseDuration } from '../util/duration.mjs';
35
35
  import { withFileLock } from '../util/fs-lock.mjs';
36
36
  import { atomicWriteJson } from '../util/io.mjs';
37
37
  import { err, exitWith, ok } from '../util/result.mjs';
38
+ import { cmdCiWatch } from './ci-watch.mjs';
38
39
  export function cmdTask(subcommand, projectDir, extraArgs) {
39
40
  if (!subcommand)
40
41
  return err('Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]');
41
42
  if (!projectDir)
42
- return err('Usage: apt-tools task ' + subcommand + ' <project-dir> [flags]');
43
+ return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`);
43
44
  const targetDir = resolve(projectDir);
44
45
  const statePath = join(targetDir, '.aperant', 'state.json');
45
46
  const flags = parseFlags(extraArgs);
@@ -116,151 +117,216 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
116
117
  // Load config for date format preference
117
118
  const configPath = join(targetDir, '.aperant', 'config.json');
118
119
  const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {};
119
- const taskId = flags.get('id') || generateTaskId(description, config);
120
- // Create task directory
121
- const taskDir = getTaskDir(targetDir, taskId);
122
- mkdirSync(taskDir, { recursive: true });
123
- for (const subdir of ['events', 'locks']) {
124
- mkdirSync(join(taskDir, subdir), { recursive: true });
120
+ // User-supplied --id bypasses the collision guard (caller owns the
121
+ // uniqueness contract). Generated ids run through a numeric-counter
122
+ // disambiguator that appends -2, -3, … when the candidate already
123
+ // exists in state or on disk. Bounded at 100 iterations as a
124
+ // defensive ceiling; same-day same-slug collisions are vanishingly
125
+ // rare so the loop should almost always terminate on the first try.
126
+ //
127
+ // BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
128
+ // state.active_tasks write MUST all happen inside a single
129
+ // withFileLock critical section. Two concurrent task create calls
130
+ // in the multi-agent environment can otherwise both compute the
131
+ // same candidate against the same pre-lock snapshot of state and
132
+ // race into a duplicate-id write that silently overwrites the
133
+ // first task's record.
134
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
135
+ const explicitIdRaw = flags.get('id');
136
+ if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
137
+ return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
138
+ command: 'task-create',
139
+ });
125
140
  }
126
- // Register in state.json
127
- if (existsSync(statePath)) {
128
- withFileLock(statePath, () => {
129
- const state = JSON.parse(readFileSync(statePath, 'utf-8'));
130
- // Migrate: if old format (active_task singular), convert
131
- if (state.active_task !== undefined && state.active_tasks === undefined) {
132
- state.active_tasks = {};
133
- if (state.active_task) {
134
- const oldId = state.active_task.id || 'legacy-task';
135
- state.active_tasks[oldId] = state.active_task;
141
+ // Secondary guard: ensure the resolved task directory stays inside the
142
+ // tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
143
+ // but allows `.` and `..` as valid characters. A lone `..` id would
144
+ // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
145
+ if (explicitIdRaw) {
146
+ const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'));
147
+ const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw));
148
+ if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
149
+ return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
150
+ command: 'task-create',
151
+ });
152
+ }
153
+ }
154
+ let taskId;
155
+ let taskDir;
156
+ let collisionOverflowErr = null;
157
+ withFileLock(statePath, () => {
158
+ // Read state under the lock so the collision scan, taskDir
159
+ // mkdir, and state.active_tasks write all observe the same
160
+ // snapshot. withFileLock seeds an empty file if statePath
161
+ // doesn't exist, so the JSON.parse fallback handles the
162
+ // first-ever task create cleanly.
163
+ const raw = readFileSync(statePath, 'utf-8').trim();
164
+ const state = raw ? JSON.parse(raw) : {};
165
+ // Migrate: if old format (active_task singular), convert
166
+ if (state.active_task !== undefined && state.active_tasks === undefined) {
167
+ state.active_tasks = {};
168
+ if (state.active_task) {
169
+ const oldId = state.active_task.id || 'legacy-task';
170
+ state.active_tasks[oldId] = state.active_task;
171
+ }
172
+ delete state.active_task;
173
+ }
174
+ if (!state.active_tasks)
175
+ state.active_tasks = {};
176
+ // Resolve taskId against the locked-in state snapshot.
177
+ if (explicitIdRaw) {
178
+ taskId = explicitIdRaw;
179
+ }
180
+ else {
181
+ const base = generateTaskId(description, config);
182
+ const activeIds = Object.keys(state.active_tasks);
183
+ const completedIds = Array.isArray(state.completed_tasks)
184
+ ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
185
+ : [];
186
+ const known = new Set([...activeIds, ...completedIds]);
187
+ const isTaken = (candidate) => known.has(candidate) || existsSync(getTaskDir(targetDir, candidate));
188
+ let candidate = base;
189
+ let counter = 2;
190
+ while (isTaken(candidate)) {
191
+ if (counter > 100) {
192
+ collisionOverflowErr = err(`task create: collision counter exceeded 100 for base id "${base}" — manual --id required`);
193
+ return;
136
194
  }
137
- delete state.active_task;
195
+ candidate = `${base}-${counter}`;
196
+ counter++;
138
197
  }
139
- if (!state.active_tasks)
140
- state.active_tasks = {};
141
- // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
142
- // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
143
- // R11 resolve the outputs allowlist now so it is pinned at
144
- // task creation time. If the planner edits the task record
145
- // later (e.g. narrows the allowlist), it overwrites this.
146
- const outputs = resolveDefaultOutputs({
147
- id: taskId,
148
- scope,
149
- milestone_id: milestoneId,
150
- phase_id: phaseIdForRoadmap,
151
- });
152
- state.active_tasks[taskId] = {
153
- id: taskId,
154
- description,
155
- track,
156
- autonomy,
157
- // C21 — execution_mode is omitted entirely when unset so legacy
158
- // readers (apt-plan SKILL.md 9a) continue to see `undefined`
159
- // rather than `null` and fall through to the autonomy heuristic.
160
- ...(executionMode ? { execution_mode: executionMode } : {}),
161
- scope,
162
- milestone_id: milestoneId,
163
- phase_id: phaseIdForRoadmap,
164
- roadmap_ref_url: roadmapRefUrl,
165
- depends_on: dependsOn,
166
- outputs,
167
- lifecycle_phase: 'planning',
168
- history: [],
169
- created_at: new Date().toISOString(),
170
- progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
171
- };
172
- // ── Task-level worktree hook ──────────────────────────────
173
- // When task_isolation.worktree_per_task is enabled AND we're
174
- // not already inside a worktree AND the host supports them,
175
- // fork a branch + worktree and record its location on the
176
- // task record. Any failure is downgraded to a warning so
177
- // task-create never aborts.
178
- const isolation = getTaskIsolationConfig(targetDir);
179
- const host = detectHost();
180
- if (isolation.worktree_per_task === true &&
181
- host.capabilities.worktrees === true &&
182
- !detectWorktree(targetDir).is_worktree) {
183
- try {
184
- const wtArgs = ['--task', taskId, '--scope', scope];
185
- if (isolation.push_on_create)
186
- wtArgs.push('--push');
187
- if (isolation.from_branch && isolation.from_branch !== 'current') {
188
- wtArgs.push('--from', isolation.from_branch);
198
+ taskId = candidate;
199
+ }
200
+ // Create task directory under the lock so a concurrent
201
+ // run can't observe (and then re-pick) a half-created id.
202
+ taskDir = getTaskDir(targetDir, taskId);
203
+ mkdirSync(taskDir, { recursive: true });
204
+ for (const subdir of ['events', 'locks']) {
205
+ mkdirSync(join(taskDir, subdir), { recursive: true });
206
+ }
207
+ // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
208
+ // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
209
+ // R11 — resolve the outputs allowlist now so it is pinned at
210
+ // task creation time. If the planner edits the task record
211
+ // later (e.g. narrows the allowlist), it overwrites this.
212
+ const outputs = resolveDefaultOutputs({
213
+ id: taskId,
214
+ scope,
215
+ milestone_id: milestoneId,
216
+ phase_id: phaseIdForRoadmap,
217
+ });
218
+ state.active_tasks[taskId] = {
219
+ id: taskId,
220
+ description,
221
+ track,
222
+ autonomy,
223
+ // C21 — execution_mode is omitted entirely when unset so legacy
224
+ // readers (apt-plan SKILL.md 9a) continue to see `undefined`
225
+ // rather than `null` and fall through to the autonomy heuristic.
226
+ ...(executionMode ? { execution_mode: executionMode } : {}),
227
+ scope,
228
+ milestone_id: milestoneId,
229
+ phase_id: phaseIdForRoadmap,
230
+ roadmap_ref_url: roadmapRefUrl,
231
+ depends_on: dependsOn,
232
+ outputs,
233
+ lifecycle_phase: 'planning',
234
+ history: [],
235
+ created_at: new Date().toISOString(),
236
+ progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
237
+ };
238
+ // ── Task-level worktree hook ──────────────────────────────
239
+ // When task_isolation.worktree_per_task is enabled AND we're
240
+ // not already inside a worktree AND the host supports them,
241
+ // fork a branch + worktree and record its location on the
242
+ // task record. Any failure is downgraded to a warning so
243
+ // task-create never aborts.
244
+ const isolation = getTaskIsolationConfig(targetDir);
245
+ const host = detectHost();
246
+ if (isolation.worktree_per_task === true &&
247
+ host.capabilities.worktrees === true &&
248
+ !detectWorktree(targetDir).is_worktree) {
249
+ try {
250
+ const wtArgs = ['--task', taskId, '--scope', scope];
251
+ if (isolation.push_on_create)
252
+ wtArgs.push('--push');
253
+ if (isolation.from_branch && isolation.from_branch !== 'current') {
254
+ wtArgs.push('--from', isolation.from_branch);
255
+ }
256
+ // Invoke inline so we capture the structured payload
257
+ // without shelling back out. We cheat by calling the
258
+ // worktree creator directly as a subprocess to keep
259
+ // its output discipline intact, then parse its JSON.
260
+ //
261
+ // D-03 — single source of truth for the apt-tools binary
262
+ // path (replaces the legacy process.argv[1] heuristic).
263
+ const aptToolsBin = resolveAptToolsBin(targetDir);
264
+ const createRes = spawnSync(process.execPath, [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs], { encoding: 'utf-8' });
265
+ if (createRes.status === 0 && createRes.stdout) {
266
+ const wtOut = JSON.parse(createRes.stdout);
267
+ state.active_tasks[taskId].worktree_path = wtOut.worktree_path;
268
+ state.active_tasks[taskId].branch = wtOut.branch;
269
+ state.active_tasks[taskId].base_branch = wtOut.base_branch;
270
+ state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking;
271
+ if (wtOut.scope) {
272
+ state.active_tasks[taskId].worktree_scope = wtOut.scope;
189
273
  }
190
- // Invoke inline so we capture the structured payload
191
- // without shelling back out. We cheat by calling the
192
- // worktree creator directly as a subprocess to keep
193
- // its output discipline intact, then parse its JSON.
194
- //
195
- // D-03 — single source of truth for the apt-tools binary
196
- // path (replaces the legacy process.argv[1] heuristic).
197
- const aptToolsBin = resolveAptToolsBin(targetDir);
198
- const createRes = spawnSync(process.execPath, [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs], { encoding: 'utf-8' });
199
- if (createRes.status === 0 && createRes.stdout) {
200
- const wtOut = JSON.parse(createRes.stdout);
201
- state.active_tasks[taskId].worktree_path = wtOut.worktree_path;
202
- state.active_tasks[taskId].branch = wtOut.branch;
203
- state.active_tasks[taskId].base_branch = wtOut.base_branch;
204
- state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking;
205
- if (wtOut.scope) {
206
- state.active_tasks[taskId].worktree_scope = wtOut.scope;
207
- }
208
- if (wtOut.push_warning) {
209
- state.active_tasks[taskId].push_warning = wtOut.push_warning;
210
- }
211
- // C54 — merge install_* fields surfaced by the
212
- // worktree-install hook in worktree.mjs. These fields are
213
- // absent on pre-C54 default-config paths, so readers that
214
- // don't know about install_* continue to work unchanged.
215
- if (wtOut.install && typeof wtOut.install === 'object') {
216
- const inst = wtOut.install;
217
- if (inst.install_status)
218
- state.active_tasks[taskId].install_status = inst.install_status;
219
- if (inst.install_log_path)
220
- state.active_tasks[taskId].install_log_path = inst.install_log_path;
221
- if (inst.install_pid !== undefined && inst.install_pid !== null)
222
- state.active_tasks[taskId].install_pid = inst.install_pid;
223
- if (Array.isArray(inst.install_ecosystems))
224
- state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems;
225
- if (inst.install_started_at)
226
- state.active_tasks[taskId].install_started_at = inst.install_started_at;
227
- if (inst.install_finished_at)
228
- state.active_tasks[taskId].install_finished_at = inst.install_finished_at;
229
- if (inst.install_warning)
230
- state.active_tasks[taskId].install_warning = inst.install_warning;
231
- // C54 review: persist the per-ecosystem breakdown and
232
- // pre-spawn scanner rejections. worktree-install already
233
- // wrote these directly via updateState; forwarding them
234
- // through the stdout merge keeps the exposed fields
235
- // consistent with the install envelope.
236
- if (inst.install_per_ecosystem &&
237
- typeof inst.install_per_ecosystem === 'object')
238
- state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem;
239
- if (Array.isArray(inst.install_rejections))
240
- state.active_tasks[taskId].install_rejections = inst.install_rejections;
241
- }
242
- if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
243
- state.active_tasks[taskId].install_warning = wtOut.install_warning;
244
- }
274
+ if (wtOut.push_warning) {
275
+ state.active_tasks[taskId].push_warning = wtOut.push_warning;
245
276
  }
246
- else {
247
- state.active_tasks[taskId].worktree_warning =
248
- 'worktree create failed: ' +
249
- (createRes.stderr || createRes.stdout || 'unknown')
250
- .toString()
251
- .trim()
252
- .split('\n')
253
- .pop();
277
+ // C54 — merge install_* fields surfaced by the
278
+ // worktree-install hook in worktree.mjs. These fields are
279
+ // absent on pre-C54 default-config paths, so readers that
280
+ // don't know about install_* continue to work unchanged.
281
+ if (wtOut.install && typeof wtOut.install === 'object') {
282
+ const inst = wtOut.install;
283
+ if (inst.install_status)
284
+ state.active_tasks[taskId].install_status = inst.install_status;
285
+ if (inst.install_log_path)
286
+ state.active_tasks[taskId].install_log_path = inst.install_log_path;
287
+ if (inst.install_pid !== undefined && inst.install_pid !== null)
288
+ state.active_tasks[taskId].install_pid = inst.install_pid;
289
+ if (Array.isArray(inst.install_ecosystems))
290
+ state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems;
291
+ if (inst.install_started_at)
292
+ state.active_tasks[taskId].install_started_at = inst.install_started_at;
293
+ if (inst.install_finished_at)
294
+ state.active_tasks[taskId].install_finished_at = inst.install_finished_at;
295
+ if (inst.install_warning)
296
+ state.active_tasks[taskId].install_warning = inst.install_warning;
297
+ // C54 review: persist the per-ecosystem breakdown and
298
+ // pre-spawn scanner rejections. worktree-install already
299
+ // wrote these directly via updateState; forwarding them
300
+ // through the stdout merge keeps the exposed fields
301
+ // consistent with the install envelope.
302
+ if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
303
+ state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem;
304
+ if (Array.isArray(inst.install_rejections))
305
+ state.active_tasks[taskId].install_rejections = inst.install_rejections;
306
+ }
307
+ if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
308
+ state.active_tasks[taskId].install_warning = wtOut.install_warning;
254
309
  }
255
310
  }
256
- catch (e) {
257
- state.active_tasks[taskId].worktree_warning = 'worktree hook error: ' + e.message;
311
+ else {
312
+ state.active_tasks[taskId].worktree_warning =
313
+ 'worktree create failed: ' +
314
+ (createRes.stderr || createRes.stdout || 'unknown')
315
+ .toString()
316
+ .trim()
317
+ .split('\n')
318
+ .pop();
258
319
  }
259
320
  }
260
- state.last_activity = new Date().toISOString();
261
- atomicWriteJson(statePath, state);
262
- }, { projectRoot: targetDir });
263
- }
321
+ catch (e) {
322
+ state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`;
323
+ }
324
+ }
325
+ state.last_activity = new Date().toISOString();
326
+ atomicWriteJson(statePath, state);
327
+ }, { projectRoot: targetDir });
328
+ if (collisionOverflowErr)
329
+ return collisionOverflowErr;
264
330
  regenerateTaskIndex(targetDir);
265
331
  // Team collaboration: register user + add to active board
266
332
  updateTeamMember(targetDir);
@@ -736,6 +802,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
736
802
  // This collapses the two-call apt:ship contract into a single
737
803
  // load-bearing call so orchestrators can't drop the lifecycle
738
804
  // transition. Idempotent / no-op on any other phase.
805
+ let ciWatchResult = {
806
+ ci_watch_armed: false,
807
+ ci_watch_skip_reason: null,
808
+ ci_watch: null,
809
+ };
810
+ let prUrlForCiWatch = null;
739
811
  if (flags.has('pr-url')) {
740
812
  const url = flags.get('pr-url');
741
813
  if (!url || typeof url !== 'string') {
@@ -769,6 +841,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
769
841
  },
770
842
  });
771
843
  }
844
+ prUrlForCiWatch = url;
772
845
  }
773
846
  if (flags.has('subtasks-total'))
774
847
  task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10);
@@ -823,6 +896,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
823
896
  }
824
897
  state.last_activity = new Date().toISOString();
825
898
  atomicWriteJson(statePath, state);
899
+ if (prUrlForCiWatch) {
900
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch);
901
+ }
826
902
  regenerateTaskIndex(targetDir);
827
903
  // Team collaboration: sync active board with updated state
828
904
  updateTeamMember(targetDir);
@@ -838,6 +914,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
838
914
  const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task };
839
915
  if (lifecycleTransition)
840
916
  updateOut.transition = lifecycleTransition;
917
+ if (flags.has('pr-url')) {
918
+ updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed;
919
+ updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason;
920
+ if (ciWatchResult.ci_watch)
921
+ updateOut.ci_watch = ciWatchResult.ci_watch;
922
+ }
841
923
  updateResult = ok(updateOut);
842
924
  }, { projectRoot: targetDir });
843
925
  return updateError ?? updateResult;
@@ -1321,7 +1403,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1321
1403
  const pruned = [];
1322
1404
  const skipped = [];
1323
1405
  for (const row of state.completed_tasks || []) {
1324
- if (!row || !row.id)
1406
+ if (!row?.id)
1325
1407
  continue;
1326
1408
  const rowScope = row.scope || 'project';
1327
1409
  if (scopeFilter && rowScope !== scopeFilter) {
@@ -1395,6 +1477,70 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1395
1477
  return err(`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`);
1396
1478
  }
1397
1479
  }
1480
+ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1481
+ const pr = extractPrNumber(prUrl);
1482
+ if (!pr) {
1483
+ return {
1484
+ ci_watch_armed: false,
1485
+ ci_watch_skip_reason: 'invalid-pr-url',
1486
+ ci_watch: null,
1487
+ };
1488
+ }
1489
+ const host = detectHost();
1490
+ const defaultMode = host.capabilities?.cron ? 'auto' : 'never';
1491
+ const config = loadProjectConfig(targetDir);
1492
+ const configuredMode = config?.ci_watch?.after_ship || defaultMode;
1493
+ const mode = configuredMode === 'ask' && Number(task.autonomy) === 3 ? 'auto' : configuredMode;
1494
+ if (mode === 'never') {
1495
+ return {
1496
+ ci_watch_armed: false,
1497
+ ci_watch_skip_reason: 'disabled',
1498
+ ci_watch: null,
1499
+ };
1500
+ }
1501
+ if (mode === 'ask') {
1502
+ return {
1503
+ ci_watch_armed: false,
1504
+ ci_watch_skip_reason: 'ask-deferred',
1505
+ ci_watch: null,
1506
+ };
1507
+ }
1508
+ if (mode !== 'auto') {
1509
+ return {
1510
+ ci_watch_armed: false,
1511
+ ci_watch_skip_reason: `invalid-config:${mode}`,
1512
+ ci_watch: null,
1513
+ };
1514
+ }
1515
+ const result = cmdCiWatch('start', targetDir, [
1516
+ '--pr',
1517
+ String(pr),
1518
+ '--task-id',
1519
+ task.id,
1520
+ '--host',
1521
+ host.cli || 'unknown',
1522
+ ]);
1523
+ const envelope = result?.envelope || {};
1524
+ if (envelope.status === 'ok') {
1525
+ return {
1526
+ ci_watch_armed: true,
1527
+ ci_watch_skip_reason: null,
1528
+ ci_watch: envelope,
1529
+ };
1530
+ }
1531
+ if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
1532
+ return {
1533
+ ci_watch_armed: true,
1534
+ ci_watch_skip_reason: 'already-armed',
1535
+ ci_watch: envelope,
1536
+ };
1537
+ }
1538
+ return {
1539
+ ci_watch_armed: false,
1540
+ ci_watch_skip_reason: envelope.reason || 'start-failed',
1541
+ ci_watch: envelope,
1542
+ };
1543
+ }
1398
1544
  /**
1399
1545
  * Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123
1400
1546
  * or a short form like `owner/repo#123`. Returns null if not parseable.