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