@aperant/framework 0.6.5 → 0.6.6

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 (64) hide show
  1. package/README.md +17 -251
  2. package/dist/cli/commands/commit.d.mts.map +1 -1
  3. package/dist/cli/commands/commit.mjs +25 -8
  4. package/dist/cli/commands/commit.mjs.map +1 -1
  5. package/dist/cli/commands/event.d.mts.map +1 -1
  6. package/dist/cli/commands/event.mjs +66 -0
  7. package/dist/cli/commands/event.mjs.map +1 -1
  8. package/dist/cli/commands/modes.d.mts.map +1 -1
  9. package/dist/cli/commands/modes.mjs +2 -1
  10. package/dist/cli/commands/modes.mjs.map +1 -1
  11. package/dist/cli/commands/task.d.mts.map +1 -1
  12. package/dist/cli/commands/task.mjs +285 -139
  13. package/dist/cli/commands/task.mjs.map +1 -1
  14. package/dist/cli/commands/triage.d.mts.map +1 -1
  15. package/dist/cli/commands/triage.mjs +9 -5
  16. package/dist/cli/commands/triage.mjs.map +1 -1
  17. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
  18. package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
  19. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
  20. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
  21. package/dist/cli/coordination/event-schema.d.mts +16 -0
  22. package/dist/cli/coordination/event-schema.d.mts.map +1 -0
  23. package/dist/cli/coordination/event-schema.mjs +161 -0
  24. package/dist/cli/coordination/event-schema.mjs.map +1 -0
  25. package/dist/cli/coordination/store.d.mts +6 -0
  26. package/dist/cli/coordination/store.d.mts.map +1 -1
  27. package/dist/cli/coordination/store.mjs +14 -0
  28. package/dist/cli/coordination/store.mjs.map +1 -1
  29. package/dist/cli/help.mjs +2 -2
  30. package/dist/cli/help.mjs.map +1 -1
  31. package/dist/cli/roadmap/conductor-view.d.mts +13 -0
  32. package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
  33. package/dist/cli/roadmap/conductor-view.mjs +31 -0
  34. package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
  35. package/dist/cli/route/skill-discover.d.mts.map +1 -1
  36. package/dist/cli/route/skill-discover.mjs +2 -1
  37. package/dist/cli/route/skill-discover.mjs.map +1 -1
  38. package/dist/cli/task/ids.d.mts +7 -4
  39. package/dist/cli/task/ids.d.mts.map +1 -1
  40. package/dist/cli/task/ids.mjs +11 -10
  41. package/dist/cli/task/ids.mjs.map +1 -1
  42. package/package.json +133 -125
  43. package/prompts/conductor-status-check.md +23 -0
  44. package/prompts/conductor-sub-agent.md +57 -0
  45. package/prompts/conductor-system.md +172 -0
  46. package/skills/apt-plan/SKILL.md +12 -0
  47. package/skills/apt-plan/adapters/conductor.md +98 -0
  48. package/skills/apt-setup/SKILL.md +2 -0
  49. package/skills/apt-ship/SKILL.md +27 -12
  50. package/skills/apt-spar/SKILL.md +290 -0
  51. package/src/cli/commands/commit.mjs +27 -8
  52. package/src/cli/commands/event.mjs +68 -0
  53. package/src/cli/commands/modes.mjs +2 -1
  54. package/src/cli/commands/task.mjs +305 -152
  55. package/src/cli/commands/triage.mjs +14 -5
  56. package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
  57. package/src/cli/coordination/event-schema.d.ts +13 -0
  58. package/src/cli/coordination/event-schema.mjs +174 -0
  59. package/src/cli/coordination/store.mjs +14 -0
  60. package/src/cli/help.mjs +2 -2
  61. package/src/cli/roadmap/conductor-view.d.ts +10 -0
  62. package/src/cli/roadmap/conductor-view.mjs +31 -0
  63. package/src/cli/route/skill-discover.mjs +2 -1
  64. package/src/cli/task/ids.mjs +15 -13
@@ -14,7 +14,7 @@
14
14
  import { spawnSync } from 'node:child_process'
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
16
16
  import { join, resolve } from 'node:path'
17
- import { getTaskIsolationConfig } from '../config/load.mjs'
17
+ import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs'
18
18
  import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
19
19
  import { createCoordinationStore } from '../coordination/store.mjs'
20
20
  import { getGitIdentity } from '../git/identity.mjs'
@@ -40,13 +40,14 @@ import { parseDuration } from '../util/duration.mjs'
40
40
  import { withFileLock } from '../util/fs-lock.mjs'
41
41
  import { atomicWriteJson } from '../util/io.mjs'
42
42
  import { err, exitWith, ok } from '../util/result.mjs'
43
+ import { cmdCiWatch } from './ci-watch.mjs'
43
44
 
44
45
  export function cmdTask(subcommand, projectDir, extraArgs) {
45
46
  if (!subcommand)
46
47
  return err(
47
48
  'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]',
48
49
  )
49
- if (!projectDir) return err('Usage: apt-tools task ' + subcommand + ' <project-dir> [flags]')
50
+ if (!projectDir) return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`)
50
51
 
51
52
  const targetDir = resolve(projectDir)
52
53
  const statePath = join(targetDir, '.aperant', 'state.json')
@@ -129,166 +130,233 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
129
130
  // Load config for date format preference
130
131
  const configPath = join(targetDir, '.aperant', 'config.json')
131
132
  const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
132
- const taskId = flags.get('id') || generateTaskId(description, config)
133
-
134
- // Create task directory
135
- const taskDir = getTaskDir(targetDir, taskId)
136
- mkdirSync(taskDir, { recursive: true })
137
- for (const subdir of ['events', 'locks']) {
138
- mkdirSync(join(taskDir, subdir), { recursive: true })
133
+ // User-supplied --id bypasses the collision guard (caller owns the
134
+ // uniqueness contract). Generated ids run through a numeric-counter
135
+ // disambiguator that appends -2, -3, … when the candidate already
136
+ // exists in state or on disk. Bounded at 100 iterations as a
137
+ // defensive ceiling; same-day same-slug collisions are vanishingly
138
+ // rare so the loop should almost always terminate on the first try.
139
+ //
140
+ // BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
141
+ // state.active_tasks write MUST all happen inside a single
142
+ // withFileLock critical section. Two concurrent task create calls
143
+ // in the multi-agent environment can otherwise both compute the
144
+ // same candidate against the same pre-lock snapshot of state and
145
+ // race into a duplicate-id write that silently overwrites the
146
+ // first task's record.
147
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
148
+ const explicitIdRaw = flags.get('id')
149
+ if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
150
+ return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
151
+ command: 'task-create',
152
+ })
153
+ }
154
+ // Secondary guard: ensure the resolved task directory stays inside the
155
+ // tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
156
+ // but allows `.` and `..` as valid characters. A lone `..` id would
157
+ // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
158
+ if (explicitIdRaw) {
159
+ const tasksBase = resolve(join(targetDir, '.aperant', 'tasks'))
160
+ const candidateDir = resolve(getTaskDir(targetDir, explicitIdRaw))
161
+ if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
162
+ return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
163
+ command: 'task-create',
164
+ })
165
+ }
139
166
  }
140
167
 
141
- // Register in state.json
142
- if (existsSync(statePath)) {
143
- withFileLock(
144
- statePath,
145
- () => {
146
- const state = JSON.parse(readFileSync(statePath, 'utf-8'))
147
-
148
- // Migrate: if old format (active_task singular), convert
149
- if (state.active_task !== undefined && state.active_tasks === undefined) {
150
- state.active_tasks = {}
151
- if (state.active_task) {
152
- const oldId = state.active_task.id || 'legacy-task'
153
- state.active_tasks[oldId] = state.active_task
168
+ let taskId
169
+ let taskDir
170
+ let collisionOverflowErr = null
171
+
172
+ withFileLock(
173
+ statePath,
174
+ () => {
175
+ // Read state under the lock so the collision scan, taskDir
176
+ // mkdir, and state.active_tasks write all observe the same
177
+ // snapshot. withFileLock seeds an empty file if statePath
178
+ // doesn't exist, so the JSON.parse fallback handles the
179
+ // first-ever task create cleanly.
180
+ const raw = readFileSync(statePath, 'utf-8').trim()
181
+ const state = raw ? JSON.parse(raw) : {}
182
+
183
+ // Migrate: if old format (active_task singular), convert
184
+ if (state.active_task !== undefined && state.active_tasks === undefined) {
185
+ state.active_tasks = {}
186
+ if (state.active_task) {
187
+ const oldId = state.active_task.id || 'legacy-task'
188
+ state.active_tasks[oldId] = state.active_task
189
+ }
190
+ delete state.active_task
191
+ }
192
+ if (!state.active_tasks) state.active_tasks = {}
193
+
194
+ // Resolve taskId against the locked-in state snapshot.
195
+ if (explicitIdRaw) {
196
+ taskId = explicitIdRaw
197
+ } else {
198
+ const base = generateTaskId(description, config)
199
+ const activeIds = Object.keys(state.active_tasks)
200
+ const completedIds = Array.isArray(state.completed_tasks)
201
+ ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
202
+ : []
203
+ const known = new Set([...activeIds, ...completedIds])
204
+ const isTaken = (candidate) =>
205
+ known.has(candidate) || existsSync(getTaskDir(targetDir, candidate))
206
+ let candidate = base
207
+ let counter = 2
208
+ while (isTaken(candidate)) {
209
+ if (counter > 100) {
210
+ collisionOverflowErr = err(
211
+ `task create: collision counter exceeded 100 for base id "${base}" — manual --id required`,
212
+ )
213
+ return
154
214
  }
155
- delete state.active_task
215
+ candidate = `${base}-${counter}`
216
+ counter++
156
217
  }
157
- if (!state.active_tasks) state.active_tasks = {}
158
-
159
- // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
160
- // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
161
- // R11 — resolve the outputs allowlist now so it is pinned at
162
- // task creation time. If the planner edits the task record
163
- // later (e.g. narrows the allowlist), it overwrites this.
164
- const outputs = resolveDefaultOutputs({
165
- id: taskId,
166
- scope,
167
- milestone_id: milestoneId,
168
- phase_id: phaseIdForRoadmap,
169
- })
218
+ taskId = candidate
219
+ }
170
220
 
171
- state.active_tasks[taskId] = {
172
- id: taskId,
173
- description,
174
- track,
175
- autonomy,
176
- // C21 execution_mode is omitted entirely when unset so legacy
177
- // readers (apt-plan SKILL.md 9a) continue to see `undefined`
178
- // rather than `null` and fall through to the autonomy heuristic.
179
- ...(executionMode ? { execution_mode: executionMode } : {}),
180
- scope,
181
- milestone_id: milestoneId,
182
- phase_id: phaseIdForRoadmap,
183
- roadmap_ref_url: roadmapRefUrl,
184
- depends_on: dependsOn,
185
- outputs,
186
- lifecycle_phase: 'planning',
187
- history: [],
188
- created_at: new Date().toISOString(),
189
- progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
190
- }
221
+ // Create task directory under the lock so a concurrent
222
+ // run can't observe (and then re-pick) a half-created id.
223
+ taskDir = getTaskDir(targetDir, taskId)
224
+ mkdirSync(taskDir, { recursive: true })
225
+ for (const subdir of ['events', 'locks']) {
226
+ mkdirSync(join(taskDir, subdir), { recursive: true })
227
+ }
191
228
 
192
- // ── Task-level worktree hook ──────────────────────────────
193
- // When task_isolation.worktree_per_task is enabled AND we're
194
- // not already inside a worktree AND the host supports them,
195
- // fork a branch + worktree and record its location on the
196
- // task record. Any failure is downgraded to a warning so
197
- // task-create never aborts.
198
- const isolation = getTaskIsolationConfig(targetDir)
199
- const host = detectHost()
200
- if (
201
- isolation.worktree_per_task === true &&
202
- host.capabilities.worktrees === true &&
203
- !detectWorktree(targetDir).is_worktree
204
- ) {
205
- try {
206
- const wtArgs = ['--task', taskId, '--scope', scope]
207
- if (isolation.push_on_create) wtArgs.push('--push')
208
- if (isolation.from_branch && isolation.from_branch !== 'current') {
209
- wtArgs.push('--from', isolation.from_branch)
229
+ // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
230
+ // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
231
+ // R11 resolve the outputs allowlist now so it is pinned at
232
+ // task creation time. If the planner edits the task record
233
+ // later (e.g. narrows the allowlist), it overwrites this.
234
+ const outputs = resolveDefaultOutputs({
235
+ id: taskId,
236
+ scope,
237
+ milestone_id: milestoneId,
238
+ phase_id: phaseIdForRoadmap,
239
+ })
240
+
241
+ state.active_tasks[taskId] = {
242
+ id: taskId,
243
+ description,
244
+ track,
245
+ autonomy,
246
+ // C21 — execution_mode is omitted entirely when unset so legacy
247
+ // readers (apt-plan SKILL.md 9a) continue to see `undefined`
248
+ // rather than `null` and fall through to the autonomy heuristic.
249
+ ...(executionMode ? { execution_mode: executionMode } : {}),
250
+ scope,
251
+ milestone_id: milestoneId,
252
+ phase_id: phaseIdForRoadmap,
253
+ roadmap_ref_url: roadmapRefUrl,
254
+ depends_on: dependsOn,
255
+ outputs,
256
+ lifecycle_phase: 'planning',
257
+ history: [],
258
+ created_at: new Date().toISOString(),
259
+ progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
260
+ }
261
+
262
+ // ── Task-level worktree hook ──────────────────────────────
263
+ // When task_isolation.worktree_per_task is enabled AND we're
264
+ // not already inside a worktree AND the host supports them,
265
+ // fork a branch + worktree and record its location on the
266
+ // task record. Any failure is downgraded to a warning so
267
+ // task-create never aborts.
268
+ const isolation = getTaskIsolationConfig(targetDir)
269
+ const host = detectHost()
270
+ if (
271
+ isolation.worktree_per_task === true &&
272
+ host.capabilities.worktrees === true &&
273
+ !detectWorktree(targetDir).is_worktree
274
+ ) {
275
+ try {
276
+ const wtArgs = ['--task', taskId, '--scope', scope]
277
+ if (isolation.push_on_create) wtArgs.push('--push')
278
+ if (isolation.from_branch && isolation.from_branch !== 'current') {
279
+ wtArgs.push('--from', isolation.from_branch)
280
+ }
281
+ // Invoke inline so we capture the structured payload
282
+ // without shelling back out. We cheat by calling the
283
+ // worktree creator directly as a subprocess to keep
284
+ // its output discipline intact, then parse its JSON.
285
+ //
286
+ // D-03 — single source of truth for the apt-tools binary
287
+ // path (replaces the legacy process.argv[1] heuristic).
288
+ const aptToolsBin = resolveAptToolsBin(targetDir)
289
+ const createRes = spawnSync(
290
+ process.execPath,
291
+ [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
292
+ { encoding: 'utf-8' },
293
+ )
294
+ if (createRes.status === 0 && createRes.stdout) {
295
+ const wtOut = JSON.parse(createRes.stdout)
296
+ state.active_tasks[taskId].worktree_path = wtOut.worktree_path
297
+ state.active_tasks[taskId].branch = wtOut.branch
298
+ state.active_tasks[taskId].base_branch = wtOut.base_branch
299
+ state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
300
+ if (wtOut.scope) {
301
+ state.active_tasks[taskId].worktree_scope = wtOut.scope
210
302
  }
211
- // Invoke inline so we capture the structured payload
212
- // without shelling back out. We cheat by calling the
213
- // worktree creator directly as a subprocess to keep
214
- // its output discipline intact, then parse its JSON.
215
- //
216
- // D-03 — single source of truth for the apt-tools binary
217
- // path (replaces the legacy process.argv[1] heuristic).
218
- const aptToolsBin = resolveAptToolsBin(targetDir)
219
- const createRes = spawnSync(
220
- process.execPath,
221
- [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
222
- { encoding: 'utf-8' },
223
- )
224
- if (createRes.status === 0 && createRes.stdout) {
225
- const wtOut = JSON.parse(createRes.stdout)
226
- state.active_tasks[taskId].worktree_path = wtOut.worktree_path
227
- state.active_tasks[taskId].branch = wtOut.branch
228
- state.active_tasks[taskId].base_branch = wtOut.base_branch
229
- state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
230
- if (wtOut.scope) {
231
- state.active_tasks[taskId].worktree_scope = wtOut.scope
232
- }
233
- if (wtOut.push_warning) {
234
- state.active_tasks[taskId].push_warning = wtOut.push_warning
235
- }
236
- // C54 — merge install_* fields surfaced by the
237
- // worktree-install hook in worktree.mjs. These fields are
238
- // absent on pre-C54 default-config paths, so readers that
239
- // don't know about install_* continue to work unchanged.
240
- if (wtOut.install && typeof wtOut.install === 'object') {
241
- const inst = wtOut.install
242
- if (inst.install_status)
243
- state.active_tasks[taskId].install_status = inst.install_status
244
- if (inst.install_log_path)
245
- state.active_tasks[taskId].install_log_path = inst.install_log_path
246
- if (inst.install_pid !== undefined && inst.install_pid !== null)
247
- state.active_tasks[taskId].install_pid = inst.install_pid
248
- if (Array.isArray(inst.install_ecosystems))
249
- state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
250
- if (inst.install_started_at)
251
- state.active_tasks[taskId].install_started_at = inst.install_started_at
252
- if (inst.install_finished_at)
253
- state.active_tasks[taskId].install_finished_at = inst.install_finished_at
254
- if (inst.install_warning)
255
- state.active_tasks[taskId].install_warning = inst.install_warning
256
- // C54 review: persist the per-ecosystem breakdown and
257
- // pre-spawn scanner rejections. worktree-install already
258
- // wrote these directly via updateState; forwarding them
259
- // through the stdout merge keeps the exposed fields
260
- // consistent with the install envelope.
261
- if (
262
- inst.install_per_ecosystem &&
263
- typeof inst.install_per_ecosystem === 'object'
264
- )
265
- state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
266
- if (Array.isArray(inst.install_rejections))
267
- state.active_tasks[taskId].install_rejections = inst.install_rejections
268
- }
269
- if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
270
- state.active_tasks[taskId].install_warning = wtOut.install_warning
271
- }
272
- } else {
273
- state.active_tasks[taskId].worktree_warning =
274
- 'worktree create failed: ' +
275
- (createRes.stderr || createRes.stdout || 'unknown')
276
- .toString()
277
- .trim()
278
- .split('\n')
279
- .pop()
303
+ if (wtOut.push_warning) {
304
+ state.active_tasks[taskId].push_warning = wtOut.push_warning
280
305
  }
281
- } catch (e) {
282
- state.active_tasks[taskId].worktree_warning = 'worktree hook error: ' + e.message
306
+ // C54 merge install_* fields surfaced by the
307
+ // worktree-install hook in worktree.mjs. These fields are
308
+ // absent on pre-C54 default-config paths, so readers that
309
+ // don't know about install_* continue to work unchanged.
310
+ if (wtOut.install && typeof wtOut.install === 'object') {
311
+ const inst = wtOut.install
312
+ if (inst.install_status)
313
+ state.active_tasks[taskId].install_status = inst.install_status
314
+ if (inst.install_log_path)
315
+ state.active_tasks[taskId].install_log_path = inst.install_log_path
316
+ if (inst.install_pid !== undefined && inst.install_pid !== null)
317
+ state.active_tasks[taskId].install_pid = inst.install_pid
318
+ if (Array.isArray(inst.install_ecosystems))
319
+ state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
320
+ if (inst.install_started_at)
321
+ state.active_tasks[taskId].install_started_at = inst.install_started_at
322
+ if (inst.install_finished_at)
323
+ state.active_tasks[taskId].install_finished_at = inst.install_finished_at
324
+ if (inst.install_warning)
325
+ state.active_tasks[taskId].install_warning = inst.install_warning
326
+ // C54 review: persist the per-ecosystem breakdown and
327
+ // pre-spawn scanner rejections. worktree-install already
328
+ // wrote these directly via updateState; forwarding them
329
+ // through the stdout merge keeps the exposed fields
330
+ // consistent with the install envelope.
331
+ if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
332
+ state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
333
+ if (Array.isArray(inst.install_rejections))
334
+ state.active_tasks[taskId].install_rejections = inst.install_rejections
335
+ }
336
+ if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
337
+ state.active_tasks[taskId].install_warning = wtOut.install_warning
338
+ }
339
+ } else {
340
+ state.active_tasks[taskId].worktree_warning =
341
+ 'worktree create failed: ' +
342
+ (createRes.stderr || createRes.stdout || 'unknown')
343
+ .toString()
344
+ .trim()
345
+ .split('\n')
346
+ .pop()
283
347
  }
348
+ } catch (e) {
349
+ state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
284
350
  }
351
+ }
285
352
 
286
- state.last_activity = new Date().toISOString()
287
- atomicWriteJson(statePath, state)
288
- },
289
- { projectRoot: targetDir },
290
- )
291
- }
353
+ state.last_activity = new Date().toISOString()
354
+ atomicWriteJson(statePath, state)
355
+ },
356
+ { projectRoot: targetDir },
357
+ )
358
+
359
+ if (collisionOverflowErr) return collisionOverflowErr
292
360
 
293
361
  regenerateTaskIndex(targetDir)
294
362
 
@@ -811,6 +879,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
811
879
  // This collapses the two-call apt:ship contract into a single
812
880
  // load-bearing call so orchestrators can't drop the lifecycle
813
881
  // transition. Idempotent / no-op on any other phase.
882
+ let ciWatchResult = {
883
+ ci_watch_armed: false,
884
+ ci_watch_skip_reason: null,
885
+ ci_watch: null,
886
+ }
887
+ let prUrlForCiWatch = null
814
888
  if (flags.has('pr-url')) {
815
889
  const url = flags.get('pr-url')
816
890
  if (!url || typeof url !== 'string') {
@@ -846,6 +920,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
846
920
  },
847
921
  })
848
922
  }
923
+ prUrlForCiWatch = url
849
924
  }
850
925
  if (flags.has('subtasks-total'))
851
926
  task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10)
@@ -906,6 +981,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
906
981
 
907
982
  state.last_activity = new Date().toISOString()
908
983
  atomicWriteJson(statePath, state)
984
+ if (prUrlForCiWatch) {
985
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
986
+ }
909
987
 
910
988
  regenerateTaskIndex(targetDir)
911
989
 
@@ -924,6 +1002,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
924
1002
 
925
1003
  const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task }
926
1004
  if (lifecycleTransition) updateOut.transition = lifecycleTransition
1005
+ if (flags.has('pr-url')) {
1006
+ updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
1007
+ updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
1008
+ if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
1009
+ }
927
1010
  updateResult = ok(updateOut)
928
1011
  },
929
1012
  { projectRoot: targetDir },
@@ -1461,7 +1544,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1461
1544
  const skipped = []
1462
1545
 
1463
1546
  for (const row of state.completed_tasks || []) {
1464
- if (!row || !row.id) continue
1547
+ if (!row?.id) continue
1465
1548
  const rowScope = row.scope || 'project'
1466
1549
  if (scopeFilter && rowScope !== scopeFilter) {
1467
1550
  skipped.push({ id: row.id, reason: 'scope-mismatch' })
@@ -1538,6 +1621,76 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1538
1621
  }
1539
1622
  }
1540
1623
 
1624
+ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1625
+ const pr = extractPrNumber(prUrl)
1626
+ if (!pr) {
1627
+ return {
1628
+ ci_watch_armed: false,
1629
+ ci_watch_skip_reason: 'invalid-pr-url',
1630
+ ci_watch: null,
1631
+ }
1632
+ }
1633
+
1634
+ const host = detectHost()
1635
+ const defaultMode = host.capabilities?.cron ? 'auto' : 'never'
1636
+ const config = loadProjectConfig(targetDir)
1637
+ const configuredMode = config?.ci_watch?.after_ship || defaultMode
1638
+ const mode = configuredMode === 'ask' && Number(task.autonomy) === 3 ? 'auto' : configuredMode
1639
+
1640
+ if (mode === 'never') {
1641
+ return {
1642
+ ci_watch_armed: false,
1643
+ ci_watch_skip_reason: 'disabled',
1644
+ ci_watch: null,
1645
+ }
1646
+ }
1647
+
1648
+ if (mode === 'ask') {
1649
+ return {
1650
+ ci_watch_armed: false,
1651
+ ci_watch_skip_reason: 'ask-deferred',
1652
+ ci_watch: null,
1653
+ }
1654
+ }
1655
+
1656
+ if (mode !== 'auto') {
1657
+ return {
1658
+ ci_watch_armed: false,
1659
+ ci_watch_skip_reason: `invalid-config:${mode}`,
1660
+ ci_watch: null,
1661
+ }
1662
+ }
1663
+
1664
+ const result = cmdCiWatch('start', targetDir, [
1665
+ '--pr',
1666
+ String(pr),
1667
+ '--task-id',
1668
+ task.id,
1669
+ '--host',
1670
+ host.cli || 'unknown',
1671
+ ])
1672
+ const envelope = result?.envelope || {}
1673
+ if (envelope.status === 'ok') {
1674
+ return {
1675
+ ci_watch_armed: true,
1676
+ ci_watch_skip_reason: null,
1677
+ ci_watch: envelope,
1678
+ }
1679
+ }
1680
+ if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
1681
+ return {
1682
+ ci_watch_armed: true,
1683
+ ci_watch_skip_reason: 'already-armed',
1684
+ ci_watch: envelope,
1685
+ }
1686
+ }
1687
+ return {
1688
+ ci_watch_armed: false,
1689
+ ci_watch_skip_reason: envelope.reason || 'start-failed',
1690
+ ci_watch: envelope,
1691
+ }
1692
+ }
1693
+
1541
1694
  /**
1542
1695
  * Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123
1543
1696
  * or a short form like `owner/repo#123`. Returns null if not parseable.
@@ -43,6 +43,14 @@ const VALID_TRANSITIONS = new Set([
43
43
 
44
44
  const BACKENDS = new Set(['local-only', 'github-issues', 'app-inbox'])
45
45
 
46
+ // Two task-id shapes coexist:
47
+ // - Legacy date-first: YY-MM-DD_slug-XXXX (preserved so the ~159
48
+ // pre-slug-first tasks already on disk keep resolving).
49
+ // - Current slug-first: slug_YY-MM-DD with optional -N counter
50
+ // emitted by generateTaskId since the slug-first rename.
51
+ const TASK_ID_RE_LEGACY = /^[0-9]{2}-[0-9]{2}-[0-9]{2}_[a-z0-9-]+-[a-z0-9]{4}$/
52
+ const TASK_ID_RE_SLUG_FIRST = /^[a-z0-9][a-z0-9-]*_[0-9]{2}-[0-9]{2}-[0-9]{2}(-\d+)?$/
53
+
46
54
  /**
47
55
  * Read .aperant/config.json and extract task_tracking.backend.
48
56
  * Defaults to 'local-only' if the file or the key is missing.
@@ -145,11 +153,12 @@ export function cmdTriage(subcommand, projectDir, extraArgs) {
145
153
  if (typeof taskId !== 'string' || taskId.trim() === '') {
146
154
  return err('--id <task-id> is required', 1, { command: `triage-${subcommand}` })
147
155
  }
148
- const TASK_ID_RE = /^[0-9]{2}-[0-9]{2}-[0-9]{2}_[a-z0-9-]+-[a-z0-9]{4}$/
149
- if (!TASK_ID_RE.test(taskId)) {
150
- return err(`--id must match pattern <YY-MM-DD_slug-XXXX> (e.g. 24-01-15_fix-login-a3b2)`, 1, {
151
- command: `triage-${subcommand}`,
152
- })
156
+ if (!TASK_ID_RE_LEGACY.test(taskId) && !TASK_ID_RE_SLUG_FIRST.test(taskId)) {
157
+ return err(
158
+ `--id must match pattern <slug_YY-MM-DD[-N]> (e.g. fix-login_24-01-15) or legacy <YY-MM-DD_slug-XXXX>`,
159
+ 1,
160
+ { command: `triage-${subcommand}` },
161
+ )
153
162
  }
154
163
  const backend = readBackend(absProjectDir)
155
164
 
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @internal
3
+ * auto-emit-artifact-ready.mjs — code-enforced plan-ready signal.
4
+ *
5
+ * Promotes the `artifact.ready{kind:'plan'}` emit from a markdown contract
6
+ * in `skills/apt-plan/adapters/conductor.md` to a framework-level
7
+ * postcondition of `apt-tools commit`. This closes the Conductor v2 gap
8
+ * exposed during live verification 2026-05-15: the planner's QUICK /
9
+ * SIMPLE track is hard-exempt from "new interaction discipline"
10
+ * (Pocock Fast Path Guarantee ID-05), so the markdown emit step was
11
+ * never executed for tasks router-classified as simple. The fleet
12
+ * never saw artifact.ready, the PlanReadyCard never auto-promoted.
13
+ *
14
+ * The fix lives in `framework` rather than the skill so:
15
+ * - it works for QUICK + STANDARD + DEEP + COMPLEX uniformly
16
+ * - it doesn't violate the Fast Path Guarantee (a passive log emit
17
+ * is not interaction discipline)
18
+ * - it's the same code path every CLI consumer (Claude Code, Gemini,
19
+ * OpenCode, Codex) traverses via `apt-tools commit`
20
+ *
21
+ * Conditions for emit (ALL must hold — otherwise no-op):
22
+ * 1. `APERANT_TERMINAL_ID` is set in process.env (Conductor PTY context).
23
+ * 2. The committed files include a path matching
24
+ * `.aperant/tasks/{task-id}/implementation_plan.json`.
25
+ *
26
+ * The emit re-uses the same `event-schema` validator and JSONL writer
27
+ * `commands/event.mjs` already uses, so the envelope structure stays
28
+ * byte-identical with manually-emitted ones.
29
+ */
30
+
31
+ import { createHash } from 'node:crypto'
32
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
33
+ import { hostname } from 'node:os'
34
+ import { join } from 'node:path'
35
+ import { validateConductorEvent } from './event-schema.mjs'
36
+
37
+ const PLAN_PATH_RE = /^(?:\.\/)?\.aperant\/tasks\/([^/]+)\/implementation_plan\.json$/
38
+
39
+ let _incarnationCounter = 0
40
+
41
+ /**
42
+ * @param {{ files: string[], projectRoot: string }} input
43
+ * @returns {{ emitted: false, reason: string } | { emitted: true, envelope: object, file: string }}
44
+ */
45
+ export function maybeAutoEmitPlanReady({ files, projectRoot }) {
46
+ const terminalId = process.env.APERANT_TERMINAL_ID
47
+ if (!terminalId) return { emitted: false, reason: 'APERANT_TERMINAL_ID unset' }
48
+
49
+ const match = files.map((f) => f.replace(/\\/g, '/').match(PLAN_PATH_RE)).find((m) => m !== null)
50
+ if (!match) return { emitted: false, reason: 'no implementation_plan.json in committed files' }
51
+ const taskId = match[1]
52
+
53
+ const planRelPath = `.aperant/tasks/${taskId}/implementation_plan.json`
54
+ const absolutePath = join(projectRoot, planRelPath)
55
+
56
+ let hash
57
+ try {
58
+ const body = readFileSync(absolutePath)
59
+ hash = createHash('sha256').update(body).digest('hex')
60
+ } catch (err) {
61
+ return { emitted: false, reason: `plan file unreadable: ${(err && err.message) || err}` }
62
+ }
63
+
64
+ const data = {
65
+ kind: 'plan',
66
+ path: planRelPath,
67
+ hash,
68
+ request_id: `plan-${taskId}-${Date.now()}`,
69
+ task_id: taskId,
70
+ terminal_id: terminalId,
71
+ }
72
+
73
+ const validation = validateConductorEvent('artifact.ready', data)
74
+ if (!validation.ok) {
75
+ return { emitted: false, reason: `schema violation: ${validation.error}` }
76
+ }
77
+
78
+ const envelope = {
79
+ t: new Date().toISOString(),
80
+ agent: `${hostname()}-${process.pid}`,
81
+ pid: process.pid,
82
+ hostname: hostname(),
83
+ incarnation: ++_incarnationCounter,
84
+ op: 'artifact.ready',
85
+ task: taskId,
86
+ data,
87
+ }
88
+
89
+ const eventsDir = join(projectRoot, '.aperant', 'events')
90
+ mkdirSync(eventsDir, { recursive: true })
91
+ const dayFile = `${new Date().toISOString().slice(0, 10)}.jsonl`
92
+ const fullPath = join(eventsDir, dayFile)
93
+ appendFileSync(fullPath, `${JSON.stringify(envelope)}\n`, 'utf-8')
94
+
95
+ return { emitted: true, envelope, file: dayFile }
96
+ }