@aperant/framework 0.17.0 → 0.19.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.
Files changed (152) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/cli/commands/ci-watch.mjs +49 -2
  3. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  4. package/dist/cli/commands/commit.mjs +3 -3
  5. package/dist/cli/commands/commit.mjs.map +1 -1
  6. package/dist/cli/commands/event.mjs +16 -16
  7. package/dist/cli/commands/event.mjs.map +1 -1
  8. package/dist/cli/commands/merge-integrate.mjs +3 -3
  9. package/dist/cli/commands/merge-integrate.mjs.map +1 -1
  10. package/dist/cli/commands/produce.d.mts +9 -0
  11. package/dist/cli/commands/produce.d.mts.map +1 -0
  12. package/dist/cli/commands/produce.mjs +1345 -0
  13. package/dist/cli/commands/produce.mjs.map +1 -0
  14. package/dist/cli/commands/task.d.mts +16 -1
  15. package/dist/cli/commands/task.d.mts.map +1 -1
  16. package/dist/cli/commands/task.mjs +434 -266
  17. package/dist/cli/commands/task.mjs.map +1 -1
  18. package/dist/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  19. package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -1
  20. package/dist/cli/coordination/event-schema.d.mts +5 -3
  21. package/dist/cli/coordination/event-schema.d.mts.map +1 -1
  22. package/dist/cli/coordination/event-schema.mjs +245 -21
  23. package/dist/cli/coordination/event-schema.mjs.map +1 -1
  24. package/dist/cli/coordination/store.d.mts +2 -2
  25. package/dist/cli/coordination/store.mjs +4 -4
  26. package/dist/cli/coordination/store.mjs.map +1 -1
  27. package/dist/cli/dispatch.d.mts.map +1 -1
  28. package/dist/cli/dispatch.mjs +2 -0
  29. package/dist/cli/dispatch.mjs.map +1 -1
  30. package/dist/cli/help.d.mts.map +1 -1
  31. package/dist/cli/help.mjs +30 -0
  32. package/dist/cli/help.mjs.map +1 -1
  33. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  34. package/dist/cli/install/legacy-paths.mjs +2 -0
  35. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  36. package/dist/cli/produce/blind-probe.d.mts +85 -0
  37. package/dist/cli/produce/blind-probe.d.mts.map +1 -0
  38. package/dist/cli/produce/blind-probe.mjs +217 -0
  39. package/dist/cli/produce/blind-probe.mjs.map +1 -0
  40. package/dist/cli/produce/claim.d.mts +188 -0
  41. package/dist/cli/produce/claim.d.mts.map +1 -0
  42. package/dist/cli/produce/claim.mjs +518 -0
  43. package/dist/cli/produce/claim.mjs.map +1 -0
  44. package/dist/cli/produce/done-gate.d.mts +87 -0
  45. package/dist/cli/produce/done-gate.d.mts.map +1 -0
  46. package/dist/cli/produce/done-gate.mjs +200 -0
  47. package/dist/cli/produce/done-gate.mjs.map +1 -0
  48. package/dist/cli/produce/events.d.mts +77 -0
  49. package/dist/cli/produce/events.d.mts.map +1 -0
  50. package/dist/cli/produce/events.mjs +126 -0
  51. package/dist/cli/produce/events.mjs.map +1 -0
  52. package/dist/cli/produce/evidence-oracle.d.mts +63 -0
  53. package/dist/cli/produce/evidence-oracle.d.mts.map +1 -0
  54. package/dist/cli/produce/evidence-oracle.mjs +122 -0
  55. package/dist/cli/produce/evidence-oracle.mjs.map +1 -0
  56. package/dist/cli/produce/ledger.d.mts +140 -0
  57. package/dist/cli/produce/ledger.d.mts.map +1 -0
  58. package/dist/cli/produce/ledger.mjs +272 -0
  59. package/dist/cli/produce/ledger.mjs.map +1 -0
  60. package/dist/cli/produce/probe-family.d.mts +53 -0
  61. package/dist/cli/produce/probe-family.d.mts.map +1 -0
  62. package/dist/cli/produce/probe-family.mjs +160 -0
  63. package/dist/cli/produce/probe-family.mjs.map +1 -0
  64. package/dist/cli/produce/projection.d.mts +55 -0
  65. package/dist/cli/produce/projection.d.mts.map +1 -0
  66. package/dist/cli/produce/projection.mjs +97 -0
  67. package/dist/cli/produce/projection.mjs.map +1 -0
  68. package/dist/cli/produce/run-id.d.mts +42 -0
  69. package/dist/cli/produce/run-id.d.mts.map +1 -0
  70. package/dist/cli/produce/run-id.mjs +79 -0
  71. package/dist/cli/produce/run-id.mjs.map +1 -0
  72. package/dist/cli/produce/saga.d.mts +180 -0
  73. package/dist/cli/produce/saga.d.mts.map +1 -0
  74. package/dist/cli/produce/saga.mjs +290 -0
  75. package/dist/cli/produce/saga.mjs.map +1 -0
  76. package/dist/cli/produce/scheduler.d.mts +165 -0
  77. package/dist/cli/produce/scheduler.d.mts.map +1 -0
  78. package/dist/cli/produce/scheduler.mjs +399 -0
  79. package/dist/cli/produce/scheduler.mjs.map +1 -0
  80. package/dist/cli/produce/setpoint.d.mts +52 -0
  81. package/dist/cli/produce/setpoint.d.mts.map +1 -0
  82. package/dist/cli/produce/setpoint.mjs +113 -0
  83. package/dist/cli/produce/setpoint.mjs.map +1 -0
  84. package/dist/cli/produce/verification-ttl.d.mts +75 -0
  85. package/dist/cli/produce/verification-ttl.d.mts.map +1 -0
  86. package/dist/cli/produce/verification-ttl.mjs +169 -0
  87. package/dist/cli/produce/verification-ttl.mjs.map +1 -0
  88. package/dist/cli/roadmap/{conductor-view.d.mts → showrunner-view.d.mts} +3 -3
  89. package/dist/cli/roadmap/showrunner-view.d.mts.map +1 -0
  90. package/dist/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +7 -7
  91. package/dist/cli/roadmap/showrunner-view.mjs.map +1 -0
  92. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  93. package/dist/plugin/skills/apt/SKILL.md +112 -38
  94. package/dist/plugin/skills/apt-debug/SKILL.md +14 -24
  95. package/dist/plugin/skills/apt-fan-out/SKILL.md +4 -4
  96. package/dist/plugin/skills/apt-handoff/SKILL.md +1 -1
  97. package/dist/plugin/skills/apt-plan/SKILL.md +5 -5
  98. package/dist/plugin/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  99. package/dist/plugin/skills/apt-produce/SKILL.md +606 -0
  100. package/dist/plugin/skills/apt-quick/SKILL.md +14 -22
  101. package/dist/plugin/skills/apt-run/SKILL.md +126 -3
  102. package/dist/plugin/skills/apt-ship/SKILL.md +2 -0
  103. package/dist/plugin/skills/apt-spar/SKILL.md +5 -3
  104. package/dist/plugin/skills/apt-watch-ci/SKILL.md +4 -4
  105. package/package.json +4 -4
  106. package/prompts/{conductor-framework-context.md → showrunner-framework-context.md} +1 -1
  107. package/prompts/{conductor-status-check.md → showrunner-status-check.md} +1 -1
  108. package/prompts/{conductor-sub-agent.md → showrunner-sub-agent.md} +6 -6
  109. package/prompts/{conductor-system.md → showrunner-system.md} +8 -8
  110. package/skills/apt/SKILL.md +112 -38
  111. package/skills/apt-debug/SKILL.md +14 -24
  112. package/skills/apt-fan-out/SKILL.md +4 -4
  113. package/skills/apt-handoff/SKILL.md +1 -1
  114. package/skills/apt-plan/SKILL.md +5 -5
  115. package/skills/apt-plan/adapters/{conductor.md → showrunner.md} +16 -16
  116. package/skills/apt-produce/SKILL.md +606 -0
  117. package/skills/apt-quick/SKILL.md +14 -22
  118. package/skills/apt-run/SKILL.md +126 -3
  119. package/skills/apt-ship/SKILL.md +2 -0
  120. package/skills/apt-spar/SKILL.md +5 -3
  121. package/skills/apt-watch-ci/SKILL.md +4 -4
  122. package/src/cli/commands/ci-watch.mjs +51 -2
  123. package/src/cli/commands/commit.mjs +3 -3
  124. package/src/cli/commands/event.mjs +16 -16
  125. package/src/cli/commands/merge-integrate.mjs +3 -3
  126. package/src/cli/commands/produce.mjs +1466 -0
  127. package/src/cli/commands/task.mjs +482 -285
  128. package/src/cli/coordination/auto-emit-artifact-ready.mjs +5 -5
  129. package/src/cli/coordination/event-schema.d.ts +4 -2
  130. package/src/cli/coordination/event-schema.mjs +276 -21
  131. package/src/cli/coordination/store.mjs +4 -4
  132. package/src/cli/dispatch.mjs +2 -0
  133. package/src/cli/help.mjs +30 -0
  134. package/src/cli/install/legacy-paths.mjs +2 -0
  135. package/src/cli/produce/blind-probe.mjs +245 -0
  136. package/src/cli/produce/claim.mjs +543 -0
  137. package/src/cli/produce/done-gate.mjs +238 -0
  138. package/src/cli/produce/events.mjs +131 -0
  139. package/src/cli/produce/evidence-oracle.mjs +133 -0
  140. package/src/cli/produce/ledger.mjs +284 -0
  141. package/src/cli/produce/probe-family.mjs +168 -0
  142. package/src/cli/produce/projection.mjs +105 -0
  143. package/src/cli/produce/run-id.mjs +84 -0
  144. package/src/cli/produce/saga.mjs +303 -0
  145. package/src/cli/produce/scheduler.mjs +423 -0
  146. package/src/cli/produce/setpoint.mjs +122 -0
  147. package/src/cli/produce/verification-ttl.mjs +191 -0
  148. package/src/cli/roadmap/showrunner-view.d.ts +10 -0
  149. package/src/cli/roadmap/{conductor-view.mjs → showrunner-view.mjs} +6 -6
  150. package/dist/cli/roadmap/conductor-view.d.mts.map +0 -1
  151. package/dist/cli/roadmap/conductor-view.mjs.map +0 -1
  152. package/src/cli/roadmap/conductor-view.d.ts +0 -10
@@ -48,6 +48,319 @@ import { err, exitWith, ok } from '../util/result.mjs'
48
48
  import { resolveMainRepoRoot } from '../util/state-paths.mjs'
49
49
  import { cmdCiWatch } from './ci-watch.mjs'
50
50
 
51
+ /**
52
+ * Resolve the effective autonomy for a `task create` / `task ensure`
53
+ * invocation (ID-04). Layered fallback mirroring
54
+ * worktree.mjs:resolveCreateAutonomy — a `--autonomy <n>` flag wins; else the
55
+ * project config's `autonomy.default`; else 1 (the conservative
56
+ * confirm-by-default level). A non-numeric / out-of-range flag or config value
57
+ * is ignored. This kills the hardcoded-1 footgun: a caller that omits
58
+ * --autonomy now inherits config.autonomy.default instead of silently
59
+ * forwarding 1 to the worktree hook (which would spuriously trip the
60
+ * non-default-base deferral).
61
+ * @param {Map<string, string>} flags
62
+ * @param {Record<string, unknown>} config already-loaded merged project config
63
+ * @returns {number}
64
+ */
65
+ export function resolveEffectiveAutonomy(flags, config) {
66
+ const raw = flags.get('autonomy')
67
+ if (raw !== undefined && raw !== null && raw !== '') {
68
+ const n = Number.parseInt(String(raw), 10)
69
+ if (Number.isFinite(n) && n >= 0 && n <= 3) return n
70
+ }
71
+ const def = config?.autonomy?.default
72
+ if (typeof def === 'number' && Number.isFinite(def) && def >= 0 && def <= 3) return def
73
+ return 1
74
+ }
75
+
76
+ /**
77
+ * Shared create-new allocation routine for `task create` and `task ensure`
78
+ * (ID-02). Owns the explicit-id safety checks, the withFileLock critical
79
+ * section (collision disambiguation + taskDir mkdir + task-record write +
80
+ * worktree hook), and the post-lock side effects (index regen + team status +
81
+ * task.created event + re-read of the created record).
82
+ *
83
+ * When `ensureKey` is set (the idempotent `task ensure --intent create-new`
84
+ * path, ID-03), the lock first scans active_tasks for a record already
85
+ * carrying that ensure_key (or the deterministic id with a matching
86
+ * track+scope) and, on a hit, RETURNS the existing record + worktree WITHOUT
87
+ * creating a second task/worktree (reused:true) — short-circuiting the
88
+ * collision disambiguator. On a miss it creates and stamps ensure_key.
89
+ *
90
+ * @returns {{ error?: any, taskId?: string, taskDir?: string, createdTask?: any, reused?: boolean }}
91
+ */
92
+ function internalCreateTask(ctx) {
93
+ const {
94
+ stateRoot,
95
+ statePath,
96
+ targetDir,
97
+ store,
98
+ config,
99
+ description,
100
+ track,
101
+ autonomy,
102
+ executionMode,
103
+ scope,
104
+ milestoneId,
105
+ phaseIdForRoadmap,
106
+ roadmapRefUrl,
107
+ dependsOn,
108
+ explicitIdRaw,
109
+ assignedTo,
110
+ ensureKey,
111
+ } = ctx
112
+
113
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
114
+ if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
115
+ return {
116
+ error: err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
117
+ command: 'task-create',
118
+ }),
119
+ }
120
+ }
121
+ // Secondary guard: the resolved task directory must stay inside the tasks
122
+ // base (SAFE_ID_PATTERN allows '.'/'..').
123
+ if (explicitIdRaw) {
124
+ const tasksBase = resolve(join(stateRoot, '.aperant', 'tasks'))
125
+ const candidateDir = resolve(getTaskDir(stateRoot, explicitIdRaw))
126
+ if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
127
+ return {
128
+ error: err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
129
+ command: 'task-create',
130
+ }),
131
+ }
132
+ }
133
+ }
134
+
135
+ let taskId
136
+ let taskDir
137
+ let collisionOverflowErr = null
138
+ let reused = false
139
+
140
+ withFileLock(
141
+ statePath,
142
+ () => {
143
+ const raw = readFileSync(statePath, 'utf-8').trim()
144
+ const state = raw ? JSON.parse(raw) : {}
145
+
146
+ if (state.active_task !== undefined && state.active_tasks === undefined) {
147
+ state.active_tasks = {}
148
+ if (state.active_task) {
149
+ const oldId = state.active_task.id || 'legacy-task'
150
+ state.active_tasks[oldId] = state.active_task
151
+ }
152
+ delete state.active_task
153
+ }
154
+ if (!state.active_tasks) state.active_tasks = {}
155
+
156
+ // ID-03 — idempotent reuse for `task ensure --intent create-new`. A
157
+ // record already carrying this ensure_key (or the deterministic id with
158
+ // a matching track+scope) short-circuits the collision disambiguator and
159
+ // returns the existing task + worktree.
160
+ if (ensureKey) {
161
+ const candidateId = explicitIdRaw || generateTaskId(description, config)
162
+ const existing = Object.values(state.active_tasks).find(
163
+ (t) =>
164
+ t &&
165
+ (t.ensure_key === ensureKey ||
166
+ (t.id === candidateId &&
167
+ (t.track || 'STANDARD') === track &&
168
+ (t.scope || 'project') === scope)),
169
+ )
170
+ if (existing) {
171
+ taskId = existing.id
172
+ taskDir = getTaskDir(stateRoot, taskId)
173
+ reused = true
174
+ return
175
+ }
176
+ }
177
+
178
+ // Resolve taskId against the locked-in state snapshot.
179
+ if (explicitIdRaw) {
180
+ taskId = explicitIdRaw
181
+ } else {
182
+ const base = generateTaskId(description, config)
183
+ const activeIds = Object.keys(state.active_tasks)
184
+ const completedIds = Array.isArray(state.completed_tasks)
185
+ ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
186
+ : []
187
+ const known = new Set([...activeIds, ...completedIds])
188
+ const isTaken = (candidate) =>
189
+ known.has(candidate) || existsSync(getTaskDir(stateRoot, candidate))
190
+ let candidate = base
191
+ let counter = 2
192
+ while (isTaken(candidate)) {
193
+ if (counter > 100) {
194
+ collisionOverflowErr = err(
195
+ `task create: collision counter exceeded 100 for base id "${base}" — manual --id required`,
196
+ )
197
+ return
198
+ }
199
+ candidate = `${base}-${counter}`
200
+ counter++
201
+ }
202
+ taskId = candidate
203
+ }
204
+
205
+ taskDir = getTaskDir(stateRoot, taskId)
206
+ mkdirSync(taskDir, { recursive: true })
207
+ for (const subdir of ['events', 'locks']) {
208
+ mkdirSync(join(taskDir, subdir), { recursive: true })
209
+ }
210
+
211
+ const outputs = resolveDefaultOutputs({
212
+ id: taskId,
213
+ scope,
214
+ milestone_id: milestoneId,
215
+ phase_id: phaseIdForRoadmap,
216
+ })
217
+
218
+ state.active_tasks[taskId] = {
219
+ id: taskId,
220
+ description,
221
+ track,
222
+ autonomy,
223
+ ...(executionMode ? { execution_mode: executionMode } : {}),
224
+ // ID-03 — stamp the ensure_key so a re-run can short-circuit.
225
+ ...(ensureKey ? { ensure_key: ensureKey } : {}),
226
+ scope,
227
+ milestone_id: milestoneId,
228
+ phase_id: phaseIdForRoadmap,
229
+ roadmap_ref_url: roadmapRefUrl,
230
+ depends_on: dependsOn,
231
+ outputs,
232
+ assigned_to: assignedTo ?? null,
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
+
239
+ // ── Task-level worktree hook ──────────────────────────────
240
+ const isolation = getTaskIsolationConfig(targetDir)
241
+ const host = detectHost()
242
+ if (
243
+ isolation.worktree_per_task === true &&
244
+ host.capabilities.worktrees === true &&
245
+ !detectWorktree(targetDir).is_worktree
246
+ ) {
247
+ try {
248
+ const wtArgs = ['--task', taskId, '--scope', scope]
249
+ if (isolation.push_on_create) wtArgs.push('--push')
250
+ if (isolation.from_branch && isolation.from_branch !== 'current') {
251
+ wtArgs.push('--from', isolation.from_branch)
252
+ }
253
+ // ID-04 — forward the EFFECTIVE autonomy (already resolved from
254
+ // config.autonomy.default) so a non-default from_branch:current base
255
+ // does not spuriously trip the worktree.mjs autonomy<=1 deferral.
256
+ wtArgs.push('--autonomy', String(autonomy))
257
+ const aptToolsBin = resolveAptToolsBin(targetDir)
258
+ const createRes = spawnSync(
259
+ process.execPath,
260
+ [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
261
+ { encoding: 'utf-8' },
262
+ )
263
+ if (createRes.status === 0 && createRes.stdout) {
264
+ const wtOut = JSON.parse(createRes.stdout)
265
+ if (wtOut.status === 'requires_input' || wtOut.reason === 'non_default_base') {
266
+ state.active_tasks[taskId].worktree_warning =
267
+ `worktree deferred: non-default base — re-run worktree create with explicit --from (base would be '${wtOut.base_branch}', default '${wtOut.default_branch}')`
268
+ state.last_activity = new Date().toISOString()
269
+ atomicWriteJson(statePath, state)
270
+ return
271
+ }
272
+ state.active_tasks[taskId].worktree_path = wtOut.worktree_path
273
+ state.active_tasks[taskId].branch = wtOut.branch
274
+ state.active_tasks[taskId].base_branch = wtOut.base_branch
275
+ state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
276
+ if (wtOut.scope) {
277
+ state.active_tasks[taskId].worktree_scope = wtOut.scope
278
+ }
279
+ if (wtOut.push_warning) {
280
+ state.active_tasks[taskId].push_warning = wtOut.push_warning
281
+ }
282
+ if (wtOut.install && typeof wtOut.install === 'object') {
283
+ const inst = wtOut.install
284
+ if (inst.install_status)
285
+ state.active_tasks[taskId].install_status = inst.install_status
286
+ if (inst.install_log_path)
287
+ state.active_tasks[taskId].install_log_path = inst.install_log_path
288
+ if (inst.install_pid !== undefined && inst.install_pid !== null)
289
+ state.active_tasks[taskId].install_pid = inst.install_pid
290
+ if (Array.isArray(inst.install_ecosystems))
291
+ state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
292
+ if (inst.install_started_at)
293
+ state.active_tasks[taskId].install_started_at = inst.install_started_at
294
+ if (inst.install_finished_at)
295
+ state.active_tasks[taskId].install_finished_at = inst.install_finished_at
296
+ if (inst.install_warning)
297
+ state.active_tasks[taskId].install_warning = inst.install_warning
298
+ if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
299
+ state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
300
+ if (Array.isArray(inst.install_rejections))
301
+ state.active_tasks[taskId].install_rejections = inst.install_rejections
302
+ }
303
+ if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
304
+ state.active_tasks[taskId].install_warning = wtOut.install_warning
305
+ }
306
+ } else {
307
+ state.active_tasks[taskId].worktree_warning =
308
+ 'worktree create failed: ' +
309
+ (createRes.stderr || createRes.stdout || 'unknown')
310
+ .toString()
311
+ .trim()
312
+ .split('\n')
313
+ .pop()
314
+ }
315
+ } catch (e) {
316
+ state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
317
+ }
318
+ }
319
+
320
+ state.last_activity = new Date().toISOString()
321
+ atomicWriteJson(statePath, state)
322
+ },
323
+ { projectRoot: stateRoot },
324
+ )
325
+
326
+ if (collisionOverflowErr) return { error: collisionOverflowErr }
327
+
328
+ // Post-lock side-effects only run for a genuinely NEW task — a reused
329
+ // ensure hit must not re-emit task.created or re-register team status.
330
+ if (!reused) {
331
+ regenerateTaskIndex(stateRoot)
332
+ updateTeamMember(targetDir)
333
+ store.upsertTeamStatus(taskId, {
334
+ description,
335
+ lifecycle_phase: 'planning',
336
+ scope,
337
+ })
338
+ store.appendEvent({
339
+ op: 'task.created',
340
+ task: taskId,
341
+ data: {
342
+ description,
343
+ track,
344
+ scope,
345
+ milestone_id: milestoneId,
346
+ phase_id: phaseIdForRoadmap,
347
+ },
348
+ })
349
+ }
350
+
351
+ let createdTask = null
352
+ if (existsSync(statePath)) {
353
+ try {
354
+ const s = JSON.parse(readFileSync(statePath, 'utf-8'))
355
+ createdTask = s.active_tasks?.[taskId] || null
356
+ } catch {
357
+ /* best-effort */
358
+ }
359
+ }
360
+
361
+ return { taskId, taskDir, createdTask, reused }
362
+ }
363
+
51
364
  export function cmdTask(subcommand, projectDir, extraArgs) {
52
365
  if (!subcommand)
53
366
  return err(
@@ -77,7 +390,6 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
77
390
  if (!description) return err('Required: --description "task description"')
78
391
 
79
392
  const track = flags.get('track') || 'STANDARD'
80
- const autonomy = parseInt(flags.get('autonomy') ?? '1', 10)
81
393
 
82
394
  // C21 — first-class execution_mode field. Accepts the same values as
83
395
  // the router's `--execution-mode` flag (validation lives in
@@ -150,293 +462,32 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
150
462
  } catch {
151
463
  config = {}
152
464
  }
153
- // User-supplied --id bypasses the collision guard (caller owns the
154
- // uniqueness contract). Generated ids run through a numeric-counter
155
- // disambiguator that appends -2, -3, when the candidate already
156
- // exists in state or on disk. Bounded at 100 iterations as a
157
- // defensive ceiling; same-day same-slug collisions are vanishingly
158
- // rare so the loop should almost always terminate on the first try.
159
- //
160
- // BUG-2 fix: the candidate-selection scan, taskDir mkdir, and
161
- // state.active_tasks write MUST all happen inside a single
162
- // withFileLock critical section. Two concurrent task create calls
163
- // in the multi-agent environment can otherwise both compute the
164
- // same candidate against the same pre-lock snapshot of state and
165
- // race into a duplicate-id write that silently overwrites the
166
- // first task's record.
167
- const SAFE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
465
+ // ID-04 resolve effective autonomy from the loaded config AFTER it is
466
+ // available: --autonomy flag config.autonomy.default 1. Replaces the
467
+ // former hardcoded `?? '1'` so a caller omitting --autonomy inherits the
468
+ // project default instead of silently forwarding 1 to the worktree hook.
469
+ const autonomy = resolveEffectiveAutonomy(flags, config)
168
470
  const explicitIdRaw = flags.get('id')
169
- if (explicitIdRaw && !SAFE_ID_PATTERN.test(explicitIdRaw)) {
170
- return err('[apt-tools] Invalid task id: must match /^[A-Za-z0-9._-]+$/', 1, {
171
- command: 'task-create',
172
- })
173
- }
174
- // Secondary guard: ensure the resolved task directory stays inside the
175
- // tasks base. The SAFE_ID_PATTERN above blocks `/` and most traversal
176
- // but allows `.` and `..` as valid characters. A lone `..` id would
177
- // escape via getTaskDir's join: `tasks/..` resolves to `.aperant/`.
178
- if (explicitIdRaw) {
179
- const tasksBase = resolve(join(stateRoot, '.aperant', 'tasks'))
180
- const candidateDir = resolve(getTaskDir(stateRoot, explicitIdRaw))
181
- if (!candidateDir.startsWith(`${tasksBase}/`) && candidateDir !== tasksBase) {
182
- return err('[apt-tools] Invalid task id: path escapes the tasks directory', 1, {
183
- command: 'task-create',
184
- })
185
- }
186
- }
187
-
188
- let taskId
189
- let taskDir
190
- let collisionOverflowErr = null
191
-
192
- withFileLock(
471
+ const created = internalCreateTask({
472
+ stateRoot,
193
473
  statePath,
194
- () => {
195
- // Read state under the lock so the collision scan, taskDir
196
- // mkdir, and state.active_tasks write all observe the same
197
- // snapshot. withFileLock seeds an empty file if statePath
198
- // doesn't exist, so the JSON.parse fallback handles the
199
- // first-ever task create cleanly.
200
- const raw = readFileSync(statePath, 'utf-8').trim()
201
- const state = raw ? JSON.parse(raw) : {}
202
-
203
- // Migrate: if old format (active_task singular), convert
204
- if (state.active_task !== undefined && state.active_tasks === undefined) {
205
- state.active_tasks = {}
206
- if (state.active_task) {
207
- const oldId = state.active_task.id || 'legacy-task'
208
- state.active_tasks[oldId] = state.active_task
209
- }
210
- delete state.active_task
211
- }
212
- if (!state.active_tasks) state.active_tasks = {}
213
-
214
- // Resolve taskId against the locked-in state snapshot.
215
- if (explicitIdRaw) {
216
- taskId = explicitIdRaw
217
- } else {
218
- const base = generateTaskId(description, config)
219
- const activeIds = Object.keys(state.active_tasks)
220
- const completedIds = Array.isArray(state.completed_tasks)
221
- ? state.completed_tasks.map((row) => row?.id).filter(Boolean)
222
- : []
223
- const known = new Set([...activeIds, ...completedIds])
224
- const isTaken = (candidate) =>
225
- known.has(candidate) || existsSync(getTaskDir(stateRoot, candidate))
226
- let candidate = base
227
- let counter = 2
228
- while (isTaken(candidate)) {
229
- if (counter > 100) {
230
- collisionOverflowErr = err(
231
- `task create: collision counter exceeded 100 for base id "${base}" — manual --id required`,
232
- )
233
- return
234
- }
235
- candidate = `${base}-${counter}`
236
- counter++
237
- }
238
- taskId = candidate
239
- }
240
-
241
- // Create task directory under the lock so a concurrent
242
- // run can't observe (and then re-pick) a half-created id.
243
- taskDir = getTaskDir(stateRoot, taskId)
244
- mkdirSync(taskDir, { recursive: true })
245
- for (const subdir of ['events', 'locks']) {
246
- mkdirSync(join(taskDir, subdir), { recursive: true })
247
- }
248
-
249
- // Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
250
- // C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
251
- // R11 — resolve the outputs allowlist now so it is pinned at
252
- // task creation time. If the planner edits the task record
253
- // later (e.g. narrows the allowlist), it overwrites this.
254
- const outputs = resolveDefaultOutputs({
255
- id: taskId,
256
- scope,
257
- milestone_id: milestoneId,
258
- phase_id: phaseIdForRoadmap,
259
- })
260
-
261
- state.active_tasks[taskId] = {
262
- id: taskId,
263
- description,
264
- track,
265
- autonomy,
266
- // C21 — execution_mode is omitted entirely when unset so legacy
267
- // readers (apt-plan SKILL.md 9a) continue to see `undefined`
268
- // rather than `null` and fall through to the autonomy heuristic.
269
- ...(executionMode ? { execution_mode: executionMode } : {}),
270
- scope,
271
- milestone_id: milestoneId,
272
- phase_id: phaseIdForRoadmap,
273
- roadmap_ref_url: roadmapRefUrl,
274
- depends_on: dependsOn,
275
- outputs,
276
- // FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
277
- // Defaults to null at create time; fan-out workers (or the
278
- // `task update --assigned-to <agent-id>` path) populate it
279
- // when they claim the task. Persists across short-lived CLI
280
- // invocations unlike team-status/{agentId}.json (pid-keyed).
281
- assigned_to: flags.has('assigned-to') ? flags.get('assigned-to') : null,
282
- lifecycle_phase: 'planning',
283
- history: [],
284
- created_at: new Date().toISOString(),
285
- progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
286
- }
287
-
288
- // ── Task-level worktree hook ──────────────────────────────
289
- // When task_isolation.worktree_per_task is enabled AND we're
290
- // not already inside a worktree AND the host supports them,
291
- // fork a branch + worktree and record its location on the
292
- // task record. Any failure is downgraded to a warning so
293
- // task-create never aborts.
294
- const isolation = getTaskIsolationConfig(targetDir)
295
- const host = detectHost()
296
- if (
297
- isolation.worktree_per_task === true &&
298
- host.capabilities.worktrees === true &&
299
- !detectWorktree(targetDir).is_worktree
300
- ) {
301
- try {
302
- const wtArgs = ['--task', taskId, '--scope', scope]
303
- if (isolation.push_on_create) wtArgs.push('--push')
304
- if (isolation.from_branch && isolation.from_branch !== 'current') {
305
- wtArgs.push('--from', isolation.from_branch)
306
- }
307
- // state-fork-fix ID-09b: forward the task's effective autonomy
308
- // so `worktree create` can pick the 0-1 confirm vs. 2-3 log
309
- // branch for a non-default `from_branch: current` base.
310
- wtArgs.push('--autonomy', String(autonomy))
311
- // Invoke inline so we capture the structured payload
312
- // without shelling back out. We cheat by calling the
313
- // worktree creator directly as a subprocess to keep
314
- // its output discipline intact, then parse its JSON.
315
- //
316
- // D-03 — single source of truth for the apt-tools binary
317
- // path (replaces the legacy process.argv[1] heuristic).
318
- const aptToolsBin = resolveAptToolsBin(targetDir)
319
- const createRes = spawnSync(
320
- process.execPath,
321
- [aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
322
- { encoding: 'utf-8' },
323
- )
324
- if (createRes.status === 0 && createRes.stdout) {
325
- const wtOut = JSON.parse(createRes.stdout)
326
- // state-fork-fix ID-09b: a requires_input non-default-base
327
- // return (autonomy <= 1) is NOT a created worktree — degrade
328
- // it to a worktree_warning so task-create still SUCCEEDS
329
- // (task created, worktree deferred) instead of recording a
330
- // bogus undefined worktree_path. The operator re-runs
331
- // `worktree create` with an explicit --from.
332
- if (wtOut.status === 'requires_input' || wtOut.reason === 'non_default_base') {
333
- state.active_tasks[taskId].worktree_warning =
334
- `worktree deferred: non-default base — re-run worktree create with explicit --from (base would be '${wtOut.base_branch}', default '${wtOut.default_branch}')`
335
- state.last_activity = new Date().toISOString()
336
- atomicWriteJson(statePath, state)
337
- return
338
- }
339
- state.active_tasks[taskId].worktree_path = wtOut.worktree_path
340
- state.active_tasks[taskId].branch = wtOut.branch
341
- state.active_tasks[taskId].base_branch = wtOut.base_branch
342
- state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
343
- if (wtOut.scope) {
344
- state.active_tasks[taskId].worktree_scope = wtOut.scope
345
- }
346
- if (wtOut.push_warning) {
347
- state.active_tasks[taskId].push_warning = wtOut.push_warning
348
- }
349
- // C54 — merge install_* fields surfaced by the
350
- // worktree-install hook in worktree.mjs. These fields are
351
- // absent on pre-C54 default-config paths, so readers that
352
- // don't know about install_* continue to work unchanged.
353
- if (wtOut.install && typeof wtOut.install === 'object') {
354
- const inst = wtOut.install
355
- if (inst.install_status)
356
- state.active_tasks[taskId].install_status = inst.install_status
357
- if (inst.install_log_path)
358
- state.active_tasks[taskId].install_log_path = inst.install_log_path
359
- if (inst.install_pid !== undefined && inst.install_pid !== null)
360
- state.active_tasks[taskId].install_pid = inst.install_pid
361
- if (Array.isArray(inst.install_ecosystems))
362
- state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
363
- if (inst.install_started_at)
364
- state.active_tasks[taskId].install_started_at = inst.install_started_at
365
- if (inst.install_finished_at)
366
- state.active_tasks[taskId].install_finished_at = inst.install_finished_at
367
- if (inst.install_warning)
368
- state.active_tasks[taskId].install_warning = inst.install_warning
369
- // C54 review: persist the per-ecosystem breakdown and
370
- // pre-spawn scanner rejections. worktree-install already
371
- // wrote these directly via updateState; forwarding them
372
- // through the stdout merge keeps the exposed fields
373
- // consistent with the install envelope.
374
- if (inst.install_per_ecosystem && typeof inst.install_per_ecosystem === 'object')
375
- state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
376
- if (Array.isArray(inst.install_rejections))
377
- state.active_tasks[taskId].install_rejections = inst.install_rejections
378
- }
379
- if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
380
- state.active_tasks[taskId].install_warning = wtOut.install_warning
381
- }
382
- } else {
383
- state.active_tasks[taskId].worktree_warning =
384
- 'worktree create failed: ' +
385
- (createRes.stderr || createRes.stdout || 'unknown')
386
- .toString()
387
- .trim()
388
- .split('\n')
389
- .pop()
390
- }
391
- } catch (e) {
392
- state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
393
- }
394
- }
395
-
396
- state.last_activity = new Date().toISOString()
397
- atomicWriteJson(statePath, state)
398
- },
399
- { projectRoot: stateRoot },
400
- )
401
-
402
- if (collisionOverflowErr) return collisionOverflowErr
403
-
404
- regenerateTaskIndex(stateRoot)
405
-
406
- // Team collaboration: register user + add to active board
407
- updateTeamMember(targetDir)
408
- store.upsertTeamStatus(taskId, {
474
+ targetDir,
475
+ store,
476
+ config,
409
477
  description,
410
- lifecycle_phase: 'planning',
478
+ track,
479
+ autonomy,
480
+ executionMode,
411
481
  scope,
482
+ milestoneId,
483
+ phaseIdForRoadmap,
484
+ roadmapRefUrl,
485
+ dependsOn,
486
+ explicitIdRaw,
487
+ assignedTo: flags.has('assigned-to') ? flags.get('assigned-to') : null,
412
488
  })
413
-
414
- // R10 forensic trail: emit task.created to the append-only
415
- // JSONL log so operators can reconstruct history even when
416
- // ephemeral state.json / team-status have been rotated.
417
- store.appendEvent({
418
- op: 'task.created',
419
- task: taskId,
420
- data: {
421
- description,
422
- track,
423
- scope,
424
- milestone_id: milestoneId,
425
- phase_id: phaseIdForRoadmap,
426
- },
427
- })
428
-
429
- // Re-read task record so we can surface the worktree block to the
430
- // router skill without duplicating state.
431
- let createdTask = null
432
- if (existsSync(statePath)) {
433
- try {
434
- const s = JSON.parse(readFileSync(statePath, 'utf-8'))
435
- createdTask = s.active_tasks?.[taskId] || null
436
- } catch {
437
- /* best-effort */
438
- }
439
- }
489
+ if (created.error) return created.error
490
+ const { taskId, taskDir, createdTask } = created
440
491
 
441
492
  const createOut = {
442
493
  status: 'ok',
@@ -462,6 +513,152 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
462
513
  return ok(createOut)
463
514
  }
464
515
 
516
+ case 'ensure': {
517
+ // ID-02 — deterministic, idempotent allocator invoked by the /apt
518
+ // dispatch layer. The host emits a single schema-bounded
519
+ // allocation_intent bit; this command provisions (or no-ops)
520
+ // deterministically so worktree creation never depends on LLM prose.
521
+ const intent = flags.get('intent')
522
+ const VALID_INTENTS = ['create-new', 'require-existing', 'self-managed', 'none']
523
+ if (!intent || !VALID_INTENTS.includes(intent)) {
524
+ return err(`Required: --intent <${VALID_INTENTS.join('|')}>`, 1, { command: 'task-ensure' })
525
+ }
526
+
527
+ // none / self-managed — allocate nothing.
528
+ if (intent === 'none' || intent === 'self-managed') {
529
+ return ok({
530
+ status: 'ok',
531
+ command: 'task-ensure',
532
+ intent,
533
+ allocated: false,
534
+ reason:
535
+ intent === 'none'
536
+ ? 'allocation_intent=none — brainstorm/investigation/status/help allocates no task or worktree'
537
+ : 'allocation_intent=self-managed — the skill owns its own task/worktree lifecycle',
538
+ })
539
+ }
540
+
541
+ // require-existing — resolve the active task; NEVER create one.
542
+ if (intent === 'require-existing') {
543
+ if (!existsSync(statePath)) {
544
+ return ok({
545
+ status: 'requires_input',
546
+ command: 'task-ensure',
547
+ intent,
548
+ allocated: false,
549
+ reason: 'no active tasks — require-existing cannot mint a new task',
550
+ })
551
+ }
552
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'))
553
+ const active =
554
+ state.active_tasks && typeof state.active_tasks === 'object'
555
+ ? Object.values(state.active_tasks)
556
+ : []
557
+ if (active.length === 1) {
558
+ const task = active[0]
559
+ const ensureOut = {
560
+ status: 'ok',
561
+ command: 'task-ensure',
562
+ intent,
563
+ allocated: false,
564
+ reused: true,
565
+ task_id: task.id,
566
+ task_dir: getTaskDir(stateRoot, task.id),
567
+ description: task.description,
568
+ track: task.track,
569
+ }
570
+ if (task.worktree_path) {
571
+ ensureOut.worktree = {
572
+ worktree_path: task.worktree_path,
573
+ branch: task.branch,
574
+ base_branch: task.base_branch,
575
+ remote_tracking: !!task.remote_tracking,
576
+ }
577
+ }
578
+ return ok(ensureOut)
579
+ }
580
+ return ok({
581
+ status: 'requires_input',
582
+ command: 'task-ensure',
583
+ intent,
584
+ allocated: false,
585
+ reason:
586
+ active.length === 0
587
+ ? 'no active tasks — require-existing cannot mint a new task'
588
+ : `${active.length} active tasks — disambiguate before require-existing can resolve`,
589
+ })
590
+ }
591
+
592
+ // create-new — idempotent allocation via the shared helper (ID-03/ID-04).
593
+ const description = flags.get('description') || flags.get('desc')
594
+ if (!description) return err('Required: --description "task description"')
595
+
596
+ const track = flags.get('track') || 'STANDARD'
597
+ const scope = (flags.get('scope') || 'project').trim() || 'project'
598
+
599
+ let config
600
+ try {
601
+ config = loadMergedProjectConfig(targetDir) ?? {}
602
+ } catch {
603
+ config = {}
604
+ }
605
+ // ID-04 — resolve effective autonomy BEFORE forwarding to the worktree
606
+ // hook so a non-default from_branch base does not spuriously defer.
607
+ const autonomy = resolveEffectiveAutonomy(flags, config)
608
+
609
+ // ID-03 — ensure-key keyed on normalized description + scope + intent.
610
+ const normalizedDescription = description.trim().toLowerCase().replace(/\s+/g, ' ')
611
+ const ensureKey = `${normalizedDescription}|${scope}|create-new`
612
+
613
+ const allocated = internalCreateTask({
614
+ stateRoot,
615
+ statePath,
616
+ targetDir,
617
+ store,
618
+ config,
619
+ description,
620
+ track,
621
+ autonomy,
622
+ executionMode: null,
623
+ scope,
624
+ milestoneId: null,
625
+ phaseIdForRoadmap: null,
626
+ roadmapRefUrl: null,
627
+ dependsOn: [],
628
+ explicitIdRaw: flags.get('id'),
629
+ assignedTo: flags.has('assigned-to') ? flags.get('assigned-to') : null,
630
+ ensureKey,
631
+ })
632
+ if (allocated.error) return allocated.error
633
+
634
+ const ensureOut = {
635
+ status: 'ok',
636
+ command: 'task-ensure',
637
+ intent,
638
+ allocated: true,
639
+ reused: !!allocated.reused,
640
+ task_id: allocated.taskId,
641
+ task_dir: allocated.taskDir,
642
+ description,
643
+ track,
644
+ autonomy,
645
+ }
646
+ const t = allocated.createdTask
647
+ if (t?.worktree_path) {
648
+ ensureOut.worktree = {
649
+ worktree_path: t.worktree_path,
650
+ branch: t.branch,
651
+ base_branch: t.base_branch,
652
+ remote_tracking: !!t.remote_tracking,
653
+ }
654
+ if (t.push_warning) ensureOut.worktree.push_warning = t.push_warning
655
+ }
656
+ if (t?.worktree_warning) {
657
+ ensureOut.worktree_warning = t.worktree_warning
658
+ }
659
+ return ok(ensureOut)
660
+ }
661
+
465
662
  case 'list': {
466
663
  if (!existsSync(statePath)) {
467
664
  return ok({ status: 'ok', command: 'task-list', tasks: [], count: 0 })
@@ -1051,9 +1248,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1051
1248
  }
1052
1249
  // FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
1053
1250
  // Mutable owner field; fan-out workers write
1054
- // `fanout-${batch}-${task}` on claim, conductor reads to
1251
+ // `fanout-${batch}-${task}` on claim, showrunner reads to
1055
1252
  // resolve crash recovery (which worker owned T1 when the
1056
- // conductor died?).
1253
+ // showrunner died?).
1057
1254
  if (flags.has('assigned-to')) {
1058
1255
  task.assigned_to = flags.get('assigned-to')
1059
1256
  }