@aperant/framework 0.6.5 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/README.md +17 -251
- package/dist/cli/commands/check-version.d.mts.map +1 -1
- package/dist/cli/commands/check-version.mjs +76 -1
- package/dist/cli/commands/check-version.mjs.map +1 -1
- package/dist/cli/commands/ci-watch.d.mts.map +1 -1
- package/dist/cli/commands/ci-watch.mjs +34 -4
- package/dist/cli/commands/ci-watch.mjs.map +1 -1
- package/dist/cli/commands/commit.d.mts.map +1 -1
- package/dist/cli/commands/commit.mjs +25 -8
- package/dist/cli/commands/commit.mjs.map +1 -1
- package/dist/cli/commands/event.d.mts.map +1 -1
- package/dist/cli/commands/event.mjs +66 -0
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/init.d.mts +8 -1
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +91 -9
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts.map +1 -1
- package/dist/cli/commands/modes.mjs +2 -1
- package/dist/cli/commands/modes.mjs.map +1 -1
- package/dist/cli/commands/release-notes.d.mts +11 -0
- package/dist/cli/commands/release-notes.d.mts.map +1 -0
- package/dist/cli/commands/release-notes.mjs +173 -0
- package/dist/cli/commands/release-notes.mjs.map +1 -0
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +285 -139
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts.map +1 -1
- package/dist/cli/commands/triage.mjs +9 -5
- package/dist/cli/commands/triage.mjs.map +1 -1
- package/dist/cli/config/load.d.mts +14 -0
- package/dist/cli/config/load.d.mts.map +1 -1
- package/dist/cli/config/load.mjs +40 -0
- package/dist/cli/config/load.mjs.map +1 -1
- package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
- package/dist/cli/config/upgrade-gitignore.mjs +6 -0
- package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.d.mts.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +4 -1
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts +16 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.d.mts.map +1 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs +88 -0
- package/dist/cli/coordination/auto-emit-artifact-ready.mjs.map +1 -0
- package/dist/cli/coordination/event-schema.d.mts +16 -0
- package/dist/cli/coordination/event-schema.d.mts.map +1 -0
- package/dist/cli/coordination/event-schema.mjs +161 -0
- package/dist/cli/coordination/event-schema.mjs.map +1 -0
- package/dist/cli/coordination/store.d.mts +6 -0
- package/dist/cli/coordination/store.d.mts.map +1 -1
- package/dist/cli/coordination/store.mjs +14 -0
- package/dist/cli/coordination/store.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
- package/dist/cli/gate/gates/review-clean.mjs +4 -2
- package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
- package/dist/cli/help.mjs +2 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/install/update-chips.d.mts +23 -0
- package/dist/cli/install/update-chips.d.mts.map +1 -1
- package/dist/cli/install/update-chips.mjs +60 -0
- package/dist/cli/install/update-chips.mjs.map +1 -1
- package/dist/cli/release-notes/compile.d.mts +38 -0
- package/dist/cli/release-notes/compile.d.mts.map +1 -0
- package/dist/cli/release-notes/compile.mjs +244 -0
- package/dist/cli/release-notes/compile.mjs.map +1 -0
- package/dist/cli/release-notes/draft.d.mts +73 -0
- package/dist/cli/release-notes/draft.d.mts.map +1 -0
- package/dist/cli/release-notes/draft.mjs +120 -0
- package/dist/cli/release-notes/draft.mjs.map +1 -0
- package/dist/cli/release-notes/output-dir.d.mts +20 -0
- package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
- package/dist/cli/release-notes/output-dir.mjs +42 -0
- package/dist/cli/release-notes/output-dir.mjs.map +1 -0
- package/dist/cli/release-notes/persona-filter.d.mts +51 -0
- package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
- package/dist/cli/release-notes/persona-filter.mjs +127 -0
- package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
- package/dist/cli/release-notes/publish.d.mts +23 -0
- package/dist/cli/release-notes/publish.d.mts.map +1 -0
- package/dist/cli/release-notes/publish.mjs +125 -0
- package/dist/cli/release-notes/publish.mjs.map +1 -0
- package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
- package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
- package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
- package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
- package/dist/cli/roadmap/conductor-view.d.mts +13 -0
- package/dist/cli/roadmap/conductor-view.d.mts.map +1 -0
- package/dist/cli/roadmap/conductor-view.mjs +31 -0
- package/dist/cli/roadmap/conductor-view.mjs.map +1 -0
- package/dist/cli/route/skill-discover.d.mts.map +1 -1
- package/dist/cli/route/skill-discover.mjs +2 -1
- package/dist/cli/route/skill-discover.mjs.map +1 -1
- package/dist/cli/task/ids.d.mts +7 -4
- package/dist/cli/task/ids.d.mts.map +1 -1
- package/dist/cli/task/ids.mjs +11 -10
- package/dist/cli/task/ids.mjs.map +1 -1
- package/dist/types/config.d.ts +11 -7
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +9 -1
- package/prompts/conductor-status-check.md +23 -0
- package/prompts/conductor-sub-agent.md +57 -0
- package/prompts/conductor-system.md +172 -0
- package/skills/apt-close-task/SKILL.md +25 -0
- package/skills/apt-diagram/SKILL.md +45 -9
- package/skills/apt-plan/SKILL.md +12 -0
- package/skills/apt-plan/adapters/conductor.md +98 -0
- package/skills/apt-release-notes/SKILL.md +193 -0
- package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
- package/skills/apt-setup/SKILL.md +148 -3
- package/skills/apt-ship/SKILL.md +74 -12
- package/skills/apt-spar/SKILL.md +290 -0
- package/skills/apt-update/SKILL.md +51 -9
- package/skills/apt-watch-ci/SKILL.md +2 -2
- package/src/cli/commands/check-version.mjs +73 -1
- package/src/cli/commands/ci-watch.mjs +35 -4
- package/src/cli/commands/commit.mjs +27 -8
- package/src/cli/commands/event.mjs +68 -0
- package/src/cli/commands/init.mjs +101 -9
- package/src/cli/commands/modes.mjs +2 -1
- package/src/cli/commands/release-notes.mjs +187 -0
- package/src/cli/commands/task.mjs +305 -152
- package/src/cli/commands/triage.mjs +14 -5
- package/src/cli/config/load.mjs +37 -0
- package/src/cli/config/upgrade-gitignore.mjs +6 -0
- package/src/cli/consistency/parse-review.mjs +3 -1
- package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
- package/src/cli/coordination/event-schema.d.ts +13 -0
- package/src/cli/coordination/event-schema.mjs +174 -0
- package/src/cli/coordination/store.mjs +14 -0
- package/src/cli/dispatch.mjs +2 -0
- package/src/cli/gate/gates/review-clean.mjs +4 -2
- package/src/cli/help.mjs +2 -2
- package/src/cli/install/update-chips.mjs +57 -0
- package/src/cli/release-notes/compile.mjs +261 -0
- package/src/cli/release-notes/draft.mjs +133 -0
- package/src/cli/release-notes/output-dir.mjs +52 -0
- package/src/cli/release-notes/persona-filter.mjs +126 -0
- package/src/cli/release-notes/publish.mjs +128 -0
- package/src/cli/release-notes/ship-autodraft.mjs +106 -0
- package/src/cli/roadmap/conductor-view.d.ts +10 -0
- package/src/cli/roadmap/conductor-view.mjs +31 -0
- package/src/cli/route/skill-discover.mjs +2 -1
- package/src/cli/task/ids.mjs +15 -13
- package/templates/config.json +28 -3
- package/workflows/docs.md +12 -0
|
@@ -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(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
215
|
+
candidate = `${base}-${counter}`
|
|
216
|
+
counter++
|
|
156
217
|
}
|
|
157
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
package/src/cli/config/load.mjs
CHANGED
|
@@ -20,6 +20,43 @@ export function loadProjectConfig(targetDir) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Load `.aperant/config.json` deep-merged with `.aperant/config.local.json`.
|
|
25
|
+
* Local wins on key collision. Nested objects merge key-by-key; arrays and
|
|
26
|
+
* primitives replace wholesale.
|
|
27
|
+
*
|
|
28
|
+
* Returns null if config.json is missing/malformed (same as loadProjectConfig).
|
|
29
|
+
* Throws Error("config.local.json parse error: <msg>") if config.local.json
|
|
30
|
+
* exists but is malformed — this is a user-visible failure surface; silent
|
|
31
|
+
* fallback would mask the user's broken override. See FRAMEWORK-AUDIT-001.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} targetDir
|
|
34
|
+
* @returns {object | null}
|
|
35
|
+
*/
|
|
36
|
+
export function loadMergedProjectConfig(targetDir) {
|
|
37
|
+
const shared = loadProjectConfig(targetDir)
|
|
38
|
+
if (shared === null) return null
|
|
39
|
+
const localPath = join(targetDir, '.aperant', 'config.local.json')
|
|
40
|
+
if (!existsSync(localPath)) return shared
|
|
41
|
+
let local
|
|
42
|
+
try {
|
|
43
|
+
local = JSON.parse(readFileSync(localPath, 'utf-8'))
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw new Error(`config.local.json parse error: ${e.message}`)
|
|
46
|
+
}
|
|
47
|
+
return deepMerge(shared, local)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function deepMerge(a, b) {
|
|
51
|
+
if (a === null || typeof a !== 'object' || Array.isArray(a)) return b
|
|
52
|
+
if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
|
|
53
|
+
const out = { ...a }
|
|
54
|
+
for (const k of Object.keys(b)) {
|
|
55
|
+
out[k] = k in a ? deepMerge(a[k], b[k]) : b[k]
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
23
60
|
/**
|
|
24
61
|
* Return a normalized task_isolation config object with safe fallbacks.
|
|
25
62
|
* Always returns a concrete object even when the project has no config at all,
|
|
@@ -183,6 +183,12 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
|
|
|
183
183
|
lines.push('events/')
|
|
184
184
|
lines.push('locks/')
|
|
185
185
|
lines.push('team-status/')
|
|
186
|
+
// config.local.json — per-user override file (FRAMEWORK-AUDIT-001). The
|
|
187
|
+
// deny-by-default `*` rule above already ignores it because no allow line
|
|
188
|
+
// exists for it; this explicit deny is defense in depth so a future
|
|
189
|
+
// maintainer cannot accidentally un-ignore it with a broad `!config*.json`
|
|
190
|
+
// allow rule.
|
|
191
|
+
lines.push('config.local.json')
|
|
186
192
|
lines.push('')
|
|
187
193
|
lines.push('# ── Derived markdown indexes (regenerated from JSON) ──────────────')
|
|
188
194
|
lines.push('tasks/INDEX.md')
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* `warning` -> `major`. `suggestion|nitpick|note|info` are dropped.
|
|
11
11
|
*
|
|
12
12
|
* Status normalizes to `fixed | deferred | open` (default `open`).
|
|
13
|
-
* `resolved: true`
|
|
13
|
+
* Both `fixed: true` and `resolved: true` are treated as `status: 'fixed'`
|
|
14
|
+
* (the reviewer skill writes `fixed: true`; `resolved: true` is legacy).
|
|
14
15
|
*
|
|
15
16
|
* Output: {findings: [{id, severity, status, title, file}]}
|
|
16
17
|
*/
|
|
@@ -89,6 +90,7 @@ function normalizeSeverity(sev) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function normalizeStatus(f) {
|
|
93
|
+
if (f.fixed === true) return 'fixed'
|
|
92
94
|
if (f.resolved === true) return 'fixed'
|
|
93
95
|
if (typeof f.status === 'string') {
|
|
94
96
|
const s = f.status.toLowerCase().trim()
|