@aperant/framework 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +54 -234
- package/agents/apt-improver.md +99 -0
- package/agents/apt-planner.md +115 -10
- package/dist/__test-helpers/run-cmd.d.mts +4 -2
- package/dist/__test-helpers/run-cmd.d.mts.map +1 -1
- package/dist/__test-helpers/run-cmd.mjs +56 -15
- package/dist/__test-helpers/run-cmd.mjs.map +1 -1
- package/dist/cli/ci-watch/lock.d.mts +53 -0
- package/dist/cli/ci-watch/lock.d.mts.map +1 -0
- package/dist/cli/ci-watch/lock.mjs +172 -0
- package/dist/cli/ci-watch/lock.mjs.map +1 -0
- package/dist/cli/ci-watch/state.d.mts +36 -0
- package/dist/cli/ci-watch/state.d.mts.map +1 -0
- package/dist/cli/ci-watch/state.mjs +103 -0
- package/dist/cli/ci-watch/state.mjs.map +1 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts +58 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -0
- package/dist/cli/ci-watch/stop-matrix.mjs +164 -0
- package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -0
- package/dist/cli/cli-wrappers/ci-watch.d.mts +2 -0
- package/dist/cli/cli-wrappers/ci-watch.d.mts.map +1 -0
- package/dist/cli/cli-wrappers/ci-watch.mjs +9 -0
- package/dist/cli/cli-wrappers/ci-watch.mjs.map +1 -0
- package/dist/cli/commands/adr.d.mts +5 -0
- package/dist/cli/commands/adr.d.mts.map +1 -0
- package/dist/cli/commands/adr.mjs +228 -0
- package/dist/cli/commands/adr.mjs.map +1 -0
- package/dist/cli/commands/ci-watch.d.mts +7 -0
- package/dist/cli/commands/ci-watch.d.mts.map +1 -0
- package/dist/cli/commands/ci-watch.mjs +465 -0
- package/dist/cli/commands/ci-watch.mjs.map +1 -0
- 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/context.d.mts +7 -0
- package/dist/cli/commands/context.d.mts.map +1 -0
- package/dist/cli/commands/context.mjs +224 -0
- package/dist/cli/commands/context.mjs.map +1 -0
- package/dist/cli/commands/event.d.mts.map +1 -1
- package/dist/cli/commands/event.mjs +125 -24
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/host-detect.d.mts +1 -1
- package/dist/cli/commands/host-detect.d.mts.map +1 -1
- package/dist/cli/commands/host-detect.mjs +30 -3
- package/dist/cli/commands/host-detect.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts +13 -0
- package/dist/cli/commands/modes.d.mts.map +1 -0
- package/dist/cli/commands/modes.mjs +221 -0
- package/dist/cli/commands/modes.mjs.map +1 -0
- package/dist/cli/commands/route.mjs +1 -1
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +393 -145
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/tokens.d.mts.map +1 -1
- package/dist/cli/commands/tokens.mjs +150 -6
- package/dist/cli/commands/tokens.mjs.map +1 -1
- package/dist/cli/commands/triage.d.mts +8 -0
- package/dist/cli/commands/triage.d.mts.map +1 -0
- package/dist/cli/commands/triage.mjs +263 -0
- package/dist/cli/commands/triage.mjs.map +1 -0
- package/dist/cli/config/post-merge-sweep.d.mts +18 -0
- package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
- package/dist/cli/config/post-merge-sweep.mjs +48 -3
- package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
- package/dist/cli/consistency/registry.d.mts +11 -0
- package/dist/cli/consistency/registry.d.mts.map +1 -1
- package/dist/cli/consistency/registry.mjs +13 -0
- package/dist/cli/consistency/registry.mjs.map +1 -1
- package/dist/cli/coordination/agent-identity.d.mts +9 -0
- package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
- package/dist/cli/coordination/agent-identity.mjs +11 -0
- package/dist/cli/coordination/agent-identity.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-log.d.mts +27 -0
- package/dist/cli/coordination/event-log.d.mts.map +1 -1
- package/dist/cli/coordination/event-log.mjs +72 -2
- package/dist/cli/coordination/event-log.mjs.map +1 -1
- 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 +14 -1
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/registry.d.mts +11 -0
- package/dist/cli/gate/registry.d.mts.map +1 -1
- package/dist/cli/gate/registry.mjs +13 -0
- package/dist/cli/gate/registry.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +3 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/host/detect.d.mts +1 -0
- package/dist/cli/host/detect.d.mts.map +1 -1
- package/dist/cli/host/detect.mjs +5 -0
- package/dist/cli/host/detect.mjs.map +1 -1
- 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/envelope.d.mts +68 -4
- package/dist/cli/route/envelope.d.mts.map +1 -1
- package/dist/cli/route/envelope.mjs +140 -103
- package/dist/cli/route/envelope.mjs.map +1 -1
- package/dist/cli/route/skill-discover.d.mts +9 -0
- package/dist/cli/route/skill-discover.d.mts.map +1 -1
- package/dist/cli/route/skill-discover.mjs +13 -1
- package/dist/cli/route/skill-discover.mjs.map +1 -1
- package/dist/cli/skill-author/contract.d.mts +1 -0
- package/dist/cli/skill-author/contract.d.mts.map +1 -1
- package/dist/cli/skill-author/contract.mjs +7 -0
- package/dist/cli/skill-author/contract.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/cli/task/worktree-cleanup.d.mts +9 -1
- package/dist/cli/task/worktree-cleanup.d.mts.map +1 -1
- package/dist/cli/task/worktree-cleanup.mjs +190 -9
- package/dist/cli/task/worktree-cleanup.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-planner.md +1 -1
- package/dist/plugin/skills/apt/SKILL.md +111 -5
- package/dist/plugin/skills/apt-author-skill/SKILL.md +11 -0
- package/dist/plugin/skills/apt-bootstrap/SKILL.md +1 -0
- package/dist/plugin/skills/apt-classify/SKILL.md +1 -0
- package/dist/plugin/skills/apt-close-task/SKILL.md +1 -0
- package/dist/plugin/skills/apt-create-docs/SKILL.md +1 -0
- package/dist/plugin/skills/apt-debug/SKILL.md +2 -0
- package/dist/plugin/skills/apt-design/SKILL.md +2 -0
- package/dist/plugin/skills/apt-discuss/SKILL.md +2 -0
- package/dist/plugin/skills/apt-docs/SKILL.md +2 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +1 -0
- package/dist/plugin/skills/apt-mockup/SKILL.md +2 -0
- package/dist/plugin/skills/apt-pause/SKILL.md +1 -0
- package/dist/plugin/skills/apt-personas/SKILL.md +1 -0
- package/dist/plugin/skills/apt-plan/SKILL.md +2 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +1 -0
- package/dist/plugin/skills/apt-quick/SKILL.md +2 -0
- package/dist/plugin/skills/apt-resume/SKILL.md +1 -0
- package/dist/plugin/skills/apt-review/SKILL.md +1 -0
- package/dist/plugin/skills/apt-roadmap/SKILL.md +1 -0
- package/dist/plugin/skills/apt-roundtable/SKILL.md +2 -0
- package/dist/plugin/skills/apt-run/SKILL.md +1 -0
- package/dist/plugin/skills/apt-scan/SKILL.md +1 -0
- package/dist/plugin/skills/apt-setup/SKILL.md +1 -0
- package/dist/plugin/skills/apt-ship/SKILL.md +6 -5
- package/dist/plugin/skills/apt-stress-test/SKILL.md +1 -0
- package/dist/plugin/skills/apt-terminal/SKILL.md +1 -0
- package/dist/plugin/skills/apt-update/SKILL.md +5 -0
- package/dist/plugin/skills/apt-verify/SKILL.md +1 -0
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +1 -0
- package/dist/types/config.d.ts +85 -0
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +9 -1
- package/prompts/coder.md +2 -0
- package/prompts/conductor-status-check.md +23 -0
- package/prompts/conductor-sub-agent.md +57 -0
- package/prompts/conductor-system.md +172 -0
- package/prompts/planner.md +12 -0
- package/prompts/spec_writer.md +9 -0
- package/skills/apt/SKILL.md +1 -0
- package/skills/apt-close-task/SKILL.md +32 -1
- package/skills/apt-debug/SKILL.md +39 -6
- package/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
- package/skills/apt-diagram/SKILL.md +342 -0
- package/skills/apt-diagram/appendices/design-discipline.md +97 -0
- package/skills/apt-discuss/SKILL.md +25 -0
- package/skills/apt-discuss/appendices/grill-discipline.md +104 -0
- package/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
- package/skills/apt-execute/SKILL.md +48 -5
- package/skills/apt-execute/appendices/tdd-mode.md +107 -0
- package/skills/apt-improve/DEEPENING.md +84 -0
- package/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
- package/skills/apt-improve/LANGUAGE.md +104 -0
- package/skills/apt-improve/SKILL.md +141 -0
- package/skills/apt-plan/SKILL.md +159 -4
- package/skills/apt-plan/adapters/conductor.md +98 -0
- package/skills/apt-planner.md +42 -1
- package/skills/apt-pr-review/SKILL.md +46 -16
- package/skills/apt-prototype/LOGIC.md +109 -0
- package/skills/apt-prototype/SKILL.md +143 -0
- package/skills/apt-prototype/UI.md +90 -0
- package/skills/apt-quick/SKILL.md +30 -0
- package/skills/apt-review/SKILL.md +2 -0
- package/skills/apt-run/SKILL.md +32 -4
- package/skills/apt-setup/SKILL.md +130 -2
- package/skills/apt-ship/SKILL.md +63 -1
- package/skills/apt-spar/SKILL.md +290 -0
- package/skills/apt-triage/AGENT-BRIEF.md +84 -0
- package/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
- package/skills/apt-triage/SKILL.md +169 -0
- package/skills/apt-verify/SKILL.md +3 -0
- package/skills/apt-verify-proof/SKILL.md +3 -0
- package/skills/apt-watch-ci/SKILL.md +163 -0
- package/skills/apt-zoom-out/SKILL.md +130 -0
- package/src/cli/ci-watch/lock.mjs +178 -0
- package/src/cli/ci-watch/state.mjs +103 -0
- package/src/cli/ci-watch/stop-matrix.mjs +181 -0
- package/src/cli/cli-wrappers/ci-watch.mjs +9 -0
- package/src/cli/commands/adr.mjs +243 -0
- package/src/cli/commands/ci-watch.mjs +503 -0
- package/src/cli/commands/commit.mjs +27 -8
- package/src/cli/commands/context.mjs +244 -0
- package/src/cli/commands/event.mjs +131 -24
- package/src/cli/commands/host-detect.mjs +33 -7
- package/src/cli/commands/modes.mjs +216 -0
- package/src/cli/commands/route.mjs +1 -1
- package/src/cli/commands/task.mjs +430 -170
- package/src/cli/commands/tokens.mjs +157 -6
- package/src/cli/commands/triage.mjs +286 -0
- package/src/cli/config/post-merge-sweep.mjs +49 -3
- package/src/cli/consistency/registry.mjs +14 -0
- package/src/cli/coordination/agent-identity.mjs +12 -0
- package/src/cli/coordination/auto-emit-artifact-ready.mjs +96 -0
- package/src/cli/coordination/event-log.mjs +73 -2
- 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 +15 -1
- package/src/cli/gate/registry.mjs +14 -0
- package/src/cli/help.mjs +3 -2
- package/src/cli/host/detect.mjs +5 -0
- package/src/cli/roadmap/conductor-view.d.ts +10 -0
- package/src/cli/roadmap/conductor-view.mjs +31 -0
- package/src/cli/route/envelope.mjs +140 -106
- package/src/cli/route/skill-discover.mjs +14 -1
- package/src/cli/skill-author/contract.mjs +7 -0
- package/src/cli/task/ids.mjs +15 -13
- package/src/cli/task/worktree-cleanup.mjs +191 -9
- package/templates/adr-format.md +56 -0
- package/templates/config.json +4 -0
- package/templates/context-format.md +34 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* commands/task.mjs — per-task isolated directories for multi-agent concurrency (C29).
|
|
3
3
|
*
|
|
4
|
-
* task subcommand: create | list | get | close | update | move | complete-subtask | prune
|
|
4
|
+
* task subcommand: create | list | get | close | update | move | complete-subtask | narration-drain | prune
|
|
5
5
|
*
|
|
6
6
|
* Layer 1 (post-CLI-layering refactor, subtask 9). cmdTask returns
|
|
7
7
|
* { envelope, exitCode } — it MUST NOT call process.exit, output(), or fail()
|
|
@@ -14,7 +14,8 @@
|
|
|
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
|
+
import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
|
|
18
19
|
import { createCoordinationStore } from '../coordination/store.mjs'
|
|
19
20
|
import { getGitIdentity } from '../git/identity.mjs'
|
|
20
21
|
import { detectWorktree } from '../git/worktree-detect.mjs'
|
|
@@ -39,13 +40,14 @@ import { parseDuration } from '../util/duration.mjs'
|
|
|
39
40
|
import { withFileLock } from '../util/fs-lock.mjs'
|
|
40
41
|
import { atomicWriteJson } from '../util/io.mjs'
|
|
41
42
|
import { err, exitWith, ok } from '../util/result.mjs'
|
|
43
|
+
import { cmdCiWatch } from './ci-watch.mjs'
|
|
42
44
|
|
|
43
45
|
export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
44
46
|
if (!subcommand)
|
|
45
47
|
return err(
|
|
46
|
-
'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|prune> <project-dir> [flags]',
|
|
48
|
+
'Usage: apt-tools task <create|list|get|close|update|move|complete-subtask|narration-drain|prune> <project-dir> [flags]',
|
|
47
49
|
)
|
|
48
|
-
if (!projectDir) return err(
|
|
50
|
+
if (!projectDir) return err(`Usage: apt-tools task ${subcommand} <project-dir> [flags]`)
|
|
49
51
|
|
|
50
52
|
const targetDir = resolve(projectDir)
|
|
51
53
|
const statePath = join(targetDir, '.aperant', 'state.json')
|
|
@@ -128,166 +130,233 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
128
130
|
// Load config for date format preference
|
|
129
131
|
const configPath = join(targetDir, '.aperant', 'config.json')
|
|
130
132
|
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
166
|
}
|
|
139
167
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
153
214
|
}
|
|
154
|
-
|
|
215
|
+
candidate = `${base}-${counter}`
|
|
216
|
+
counter++
|
|
155
217
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Task-record lifecycle phase (NOT roadmap phase, NOT workflow phase).
|
|
159
|
-
// C28 D-02/D-03/D-08: scope + linkage + dependency-graph fields.
|
|
160
|
-
// R11 — resolve the outputs allowlist now so it is pinned at
|
|
161
|
-
// task creation time. If the planner edits the task record
|
|
162
|
-
// later (e.g. narrows the allowlist), it overwrites this.
|
|
163
|
-
const outputs = resolveDefaultOutputs({
|
|
164
|
-
id: taskId,
|
|
165
|
-
scope,
|
|
166
|
-
milestone_id: milestoneId,
|
|
167
|
-
phase_id: phaseIdForRoadmap,
|
|
168
|
-
})
|
|
218
|
+
taskId = candidate
|
|
219
|
+
}
|
|
169
220
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// rather than `null` and fall through to the autonomy heuristic.
|
|
178
|
-
...(executionMode ? { execution_mode: executionMode } : {}),
|
|
179
|
-
scope,
|
|
180
|
-
milestone_id: milestoneId,
|
|
181
|
-
phase_id: phaseIdForRoadmap,
|
|
182
|
-
roadmap_ref_url: roadmapRefUrl,
|
|
183
|
-
depends_on: dependsOn,
|
|
184
|
-
outputs,
|
|
185
|
-
lifecycle_phase: 'planning',
|
|
186
|
-
history: [],
|
|
187
|
-
created_at: new Date().toISOString(),
|
|
188
|
-
progress: { subtasks_total: 0, subtasks_completed: 0, current_subtask: null },
|
|
189
|
-
}
|
|
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
|
+
}
|
|
190
228
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
209
302
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// worktree creator directly as a subprocess to keep
|
|
213
|
-
// its output discipline intact, then parse its JSON.
|
|
214
|
-
//
|
|
215
|
-
// D-03 — single source of truth for the apt-tools binary
|
|
216
|
-
// path (replaces the legacy process.argv[1] heuristic).
|
|
217
|
-
const aptToolsBin = resolveAptToolsBin(targetDir)
|
|
218
|
-
const createRes = spawnSync(
|
|
219
|
-
process.execPath,
|
|
220
|
-
[aptToolsBin, 'worktree', 'create', targetDir, ...wtArgs],
|
|
221
|
-
{ encoding: 'utf-8' },
|
|
222
|
-
)
|
|
223
|
-
if (createRes.status === 0 && createRes.stdout) {
|
|
224
|
-
const wtOut = JSON.parse(createRes.stdout)
|
|
225
|
-
state.active_tasks[taskId].worktree_path = wtOut.worktree_path
|
|
226
|
-
state.active_tasks[taskId].branch = wtOut.branch
|
|
227
|
-
state.active_tasks[taskId].base_branch = wtOut.base_branch
|
|
228
|
-
state.active_tasks[taskId].remote_tracking = !!wtOut.remote_tracking
|
|
229
|
-
if (wtOut.scope) {
|
|
230
|
-
state.active_tasks[taskId].worktree_scope = wtOut.scope
|
|
231
|
-
}
|
|
232
|
-
if (wtOut.push_warning) {
|
|
233
|
-
state.active_tasks[taskId].push_warning = wtOut.push_warning
|
|
234
|
-
}
|
|
235
|
-
// C54 — merge install_* fields surfaced by the
|
|
236
|
-
// worktree-install hook in worktree.mjs. These fields are
|
|
237
|
-
// absent on pre-C54 default-config paths, so readers that
|
|
238
|
-
// don't know about install_* continue to work unchanged.
|
|
239
|
-
if (wtOut.install && typeof wtOut.install === 'object') {
|
|
240
|
-
const inst = wtOut.install
|
|
241
|
-
if (inst.install_status)
|
|
242
|
-
state.active_tasks[taskId].install_status = inst.install_status
|
|
243
|
-
if (inst.install_log_path)
|
|
244
|
-
state.active_tasks[taskId].install_log_path = inst.install_log_path
|
|
245
|
-
if (inst.install_pid !== undefined && inst.install_pid !== null)
|
|
246
|
-
state.active_tasks[taskId].install_pid = inst.install_pid
|
|
247
|
-
if (Array.isArray(inst.install_ecosystems))
|
|
248
|
-
state.active_tasks[taskId].install_ecosystems = inst.install_ecosystems
|
|
249
|
-
if (inst.install_started_at)
|
|
250
|
-
state.active_tasks[taskId].install_started_at = inst.install_started_at
|
|
251
|
-
if (inst.install_finished_at)
|
|
252
|
-
state.active_tasks[taskId].install_finished_at = inst.install_finished_at
|
|
253
|
-
if (inst.install_warning)
|
|
254
|
-
state.active_tasks[taskId].install_warning = inst.install_warning
|
|
255
|
-
// C54 review: persist the per-ecosystem breakdown and
|
|
256
|
-
// pre-spawn scanner rejections. worktree-install already
|
|
257
|
-
// wrote these directly via updateState; forwarding them
|
|
258
|
-
// through the stdout merge keeps the exposed fields
|
|
259
|
-
// consistent with the install envelope.
|
|
260
|
-
if (
|
|
261
|
-
inst.install_per_ecosystem &&
|
|
262
|
-
typeof inst.install_per_ecosystem === 'object'
|
|
263
|
-
)
|
|
264
|
-
state.active_tasks[taskId].install_per_ecosystem = inst.install_per_ecosystem
|
|
265
|
-
if (Array.isArray(inst.install_rejections))
|
|
266
|
-
state.active_tasks[taskId].install_rejections = inst.install_rejections
|
|
267
|
-
}
|
|
268
|
-
if (wtOut.install_warning && !state.active_tasks[taskId].install_warning) {
|
|
269
|
-
state.active_tasks[taskId].install_warning = wtOut.install_warning
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
272
|
-
state.active_tasks[taskId].worktree_warning =
|
|
273
|
-
'worktree create failed: ' +
|
|
274
|
-
(createRes.stderr || createRes.stdout || 'unknown')
|
|
275
|
-
.toString()
|
|
276
|
-
.trim()
|
|
277
|
-
.split('\n')
|
|
278
|
-
.pop()
|
|
303
|
+
if (wtOut.push_warning) {
|
|
304
|
+
state.active_tasks[taskId].push_warning = wtOut.push_warning
|
|
279
305
|
}
|
|
280
|
-
|
|
281
|
-
|
|
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()
|
|
282
347
|
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
state.active_tasks[taskId].worktree_warning = `worktree hook error: ${e.message}`
|
|
283
350
|
}
|
|
351
|
+
}
|
|
284
352
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
353
|
+
state.last_activity = new Date().toISOString()
|
|
354
|
+
atomicWriteJson(statePath, state)
|
|
355
|
+
},
|
|
356
|
+
{ projectRoot: targetDir },
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if (collisionOverflowErr) return collisionOverflowErr
|
|
291
360
|
|
|
292
361
|
regenerateTaskIndex(targetDir)
|
|
293
362
|
|
|
@@ -457,6 +526,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
457
526
|
const taskId = flags.get('id')
|
|
458
527
|
if (!taskId) return err('Required: --id <task-id>')
|
|
459
528
|
const verdict = flags.get('verdict') || 'completed'
|
|
529
|
+
// Internal flag set by `task close-merged` when
|
|
530
|
+
// GitHub confirmed state === 'MERGED'. Threads through to
|
|
531
|
+
// computeWorktreeCleanup so cleanup skips local merge-back and
|
|
532
|
+
// runs fetch + ff-only instead.
|
|
533
|
+
const prMerged = flags.has('pr-merged-remotely')
|
|
460
534
|
|
|
461
535
|
if (!existsSync(statePath)) return err('No state.json found')
|
|
462
536
|
|
|
@@ -521,11 +595,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
521
595
|
|
|
522
596
|
// Worktree cleanup hook — honors task_isolation.cleanup_on_close
|
|
523
597
|
// (never | ask | auto). See src/cli/task/worktree-cleanup.mjs.
|
|
598
|
+
// prMerged threads from `task close-merged`
|
|
599
|
+
// via --pr-merged-remotely; when true, cleanup runs fetch +
|
|
600
|
+
// ff-only instead of local merge-back.
|
|
524
601
|
let cleanup = null
|
|
525
602
|
if (task.worktree_path) {
|
|
526
603
|
const isolation = getTaskIsolationConfig(targetDir)
|
|
527
604
|
const mode = isolation.cleanup_on_close || 'ask'
|
|
528
|
-
cleanup = computeWorktreeCleanup(task, mode, targetDir)
|
|
605
|
+
cleanup = computeWorktreeCleanup(task, mode, targetDir, { prMerged })
|
|
529
606
|
}
|
|
530
607
|
|
|
531
608
|
// Best-effort team sync: commit shared bookkeeping so teammates
|
|
@@ -721,6 +798,21 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
721
798
|
let lifecycleTransition = null
|
|
722
799
|
if (flags.has('lifecycle-phase')) {
|
|
723
800
|
task.lifecycle_phase = flags.get('lifecycle-phase')
|
|
801
|
+
// R6 (roundtable 2026-05-14) — compute elapsed_ms since the
|
|
802
|
+
// previous LIFECYCLE event (or task.created) for phase-boundary
|
|
803
|
+
// telemetry. The 'task.lifecycle.' opPrefix is critical: without
|
|
804
|
+
// it, the most-recent event lookup picks up high-frequency events
|
|
805
|
+
// like task.tokens.recorded, which would silently corrupt
|
|
806
|
+
// elapsed_ms attribution (PR #115 review finding LOG-001).
|
|
807
|
+
const prev = findMostRecentEventForTask(targetDir, taskId, 'task.lifecycle.')
|
|
808
|
+
let prevTime = prev ? new Date(prev.t).getTime() : null
|
|
809
|
+
// LOG-003: when no prior lifecycle event exists (first transition),
|
|
810
|
+
// fall back to task.created_at so planning phase duration is captured.
|
|
811
|
+
if (!prevTime && task.created_at) {
|
|
812
|
+
const createdMs = new Date(task.created_at).getTime()
|
|
813
|
+
if (Number.isFinite(createdMs)) prevTime = createdMs
|
|
814
|
+
}
|
|
815
|
+
const elapsed_ms = prevTime ? Date.now() - prevTime : null
|
|
724
816
|
// R10 — emit the lifecycle transition event even when the
|
|
725
817
|
// side-effect helper decides the phase spec is a no-op; the
|
|
726
818
|
// caller still changed task.lifecycle_phase.
|
|
@@ -732,6 +824,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
732
824
|
scope: task.scope || 'project',
|
|
733
825
|
milestone_id: task.milestone_id ?? null,
|
|
734
826
|
phase_id: task.phase_id ?? null,
|
|
827
|
+
elapsed_ms,
|
|
735
828
|
},
|
|
736
829
|
})
|
|
737
830
|
// C56 B5 — exhaustive lifecycle→roadmap-phase map. `null`
|
|
@@ -786,6 +879,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
786
879
|
// This collapses the two-call apt:ship contract into a single
|
|
787
880
|
// load-bearing call so orchestrators can't drop the lifecycle
|
|
788
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
|
|
789
888
|
if (flags.has('pr-url')) {
|
|
790
889
|
const url = flags.get('pr-url')
|
|
791
890
|
if (!url || typeof url !== 'string') {
|
|
@@ -821,6 +920,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
821
920
|
},
|
|
822
921
|
})
|
|
823
922
|
}
|
|
923
|
+
prUrlForCiWatch = url
|
|
824
924
|
}
|
|
825
925
|
if (flags.has('subtasks-total'))
|
|
826
926
|
task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10)
|
|
@@ -881,6 +981,9 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
881
981
|
|
|
882
982
|
state.last_activity = new Date().toISOString()
|
|
883
983
|
atomicWriteJson(statePath, state)
|
|
984
|
+
if (prUrlForCiWatch) {
|
|
985
|
+
ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
|
|
986
|
+
}
|
|
884
987
|
|
|
885
988
|
regenerateTaskIndex(targetDir)
|
|
886
989
|
|
|
@@ -899,6 +1002,11 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
899
1002
|
|
|
900
1003
|
const updateOut = { status: 'ok', command: 'task-update', task_id: taskId, task }
|
|
901
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
|
+
}
|
|
902
1010
|
updateResult = ok(updateOut)
|
|
903
1011
|
},
|
|
904
1012
|
{ projectRoot: targetDir },
|
|
@@ -1253,20 +1361,27 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1253
1361
|
// Drive the full close path via an in-process CLI call so all of
|
|
1254
1362
|
// task close's side effects (team-sync, worktree cleanup, phase flip
|
|
1255
1363
|
// when auto_close_phase is on, event emission, INDEX regen) run.
|
|
1256
|
-
const
|
|
1257
|
-
'
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
)
|
|
1364
|
+
const closeArgs = [
|
|
1365
|
+
resolve(import.meta.dirname ?? '.', '..', '..', '..', 'bin', 'apt-tools.mjs'),
|
|
1366
|
+
'task',
|
|
1367
|
+
'close',
|
|
1368
|
+
targetDir,
|
|
1369
|
+
'--id',
|
|
1370
|
+
id,
|
|
1371
|
+
'--verdict',
|
|
1372
|
+
closeVerdict,
|
|
1373
|
+
]
|
|
1374
|
+
// Only signal pr_merged_remotely when GitHub
|
|
1375
|
+
// confirmed MERGED. CLOSED-not-merged is a rejection path; leaving
|
|
1376
|
+
// cleanup behavior unchanged there is intentional (spec §11 R3).
|
|
1377
|
+
if (parsed.state === 'MERGED') {
|
|
1378
|
+
closeArgs.push('--pr-merged-remotely')
|
|
1379
|
+
}
|
|
1380
|
+
const closeRes = spawnSync('node', closeArgs, {
|
|
1381
|
+
cwd: targetDir,
|
|
1382
|
+
encoding: 'utf-8',
|
|
1383
|
+
env: { ...process.env },
|
|
1384
|
+
})
|
|
1270
1385
|
if (closeRes.status !== 0) {
|
|
1271
1386
|
errors.push({
|
|
1272
1387
|
id,
|
|
@@ -1317,6 +1432,36 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1317
1432
|
verdict: closeVerdict,
|
|
1318
1433
|
mergedAt: parsed.mergedAt ?? null,
|
|
1319
1434
|
})
|
|
1435
|
+
|
|
1436
|
+
// Append a pending_narration[] ledger entry on successful merged close
|
|
1437
|
+
// so /apt:close-task --narrate-only can drain it later. The ledger is
|
|
1438
|
+
// additive-only; failure to write must never undo the close above
|
|
1439
|
+
// (R4 ledger leak — caller can manually invoke the narrator).
|
|
1440
|
+
if (closeVerdict === 'approved') {
|
|
1441
|
+
try {
|
|
1442
|
+
withFileLock(
|
|
1443
|
+
statePath,
|
|
1444
|
+
() => {
|
|
1445
|
+
const fresh = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
1446
|
+
if (!Array.isArray(fresh.pending_narration)) fresh.pending_narration = []
|
|
1447
|
+
fresh.pending_narration.push({
|
|
1448
|
+
task_id: id,
|
|
1449
|
+
scope: task.scope || 'project',
|
|
1450
|
+
phase_id: task.phase_id ?? null,
|
|
1451
|
+
closed_at: new Date().toISOString(),
|
|
1452
|
+
pr_number: parsed.number ?? null,
|
|
1453
|
+
})
|
|
1454
|
+
atomicWriteJson(statePath, fresh)
|
|
1455
|
+
},
|
|
1456
|
+
{ projectRoot: targetDir },
|
|
1457
|
+
)
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
errors.push({
|
|
1460
|
+
id,
|
|
1461
|
+
reason: `ledger-write-failed: ${e.message ?? String(e)}`,
|
|
1462
|
+
})
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1320
1465
|
}
|
|
1321
1466
|
|
|
1322
1467
|
return ok({
|
|
@@ -1329,6 +1474,51 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1329
1474
|
})
|
|
1330
1475
|
}
|
|
1331
1476
|
|
|
1477
|
+
case 'narration-drain': {
|
|
1478
|
+
// Remove a single pending_narration[] row under the same withFileLock
|
|
1479
|
+
// guard task close uses. Caller (apt:close-task --narrate-only) invokes
|
|
1480
|
+
// this once per drained entry AFTER its background narrator spawn
|
|
1481
|
+
// returns — so a spawn failure leaves the row in place for retry.
|
|
1482
|
+
const taskId = flags.get('task')
|
|
1483
|
+
if (!taskId) return err('Required: --task <task-id>')
|
|
1484
|
+
|
|
1485
|
+
if (!existsSync(statePath)) {
|
|
1486
|
+
return ok({
|
|
1487
|
+
status: 'ok',
|
|
1488
|
+
command: 'task-narration-drain',
|
|
1489
|
+
removed: null,
|
|
1490
|
+
remaining: 0,
|
|
1491
|
+
reason: 'no-state',
|
|
1492
|
+
})
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
let removed = null
|
|
1496
|
+
let remaining = 0
|
|
1497
|
+
withFileLock(
|
|
1498
|
+
statePath,
|
|
1499
|
+
() => {
|
|
1500
|
+
const state = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
1501
|
+
const ledger = Array.isArray(state.pending_narration) ? state.pending_narration : []
|
|
1502
|
+
const idx = ledger.findIndex((row) => row && row.task_id === taskId)
|
|
1503
|
+
if (idx >= 0) {
|
|
1504
|
+
removed = ledger[idx]
|
|
1505
|
+
ledger.splice(idx, 1)
|
|
1506
|
+
state.pending_narration = ledger
|
|
1507
|
+
atomicWriteJson(statePath, state)
|
|
1508
|
+
}
|
|
1509
|
+
remaining = ledger.length
|
|
1510
|
+
},
|
|
1511
|
+
{ projectRoot: targetDir },
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
return ok({
|
|
1515
|
+
status: 'ok',
|
|
1516
|
+
command: 'task-narration-drain',
|
|
1517
|
+
removed,
|
|
1518
|
+
remaining,
|
|
1519
|
+
})
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1332
1522
|
case 'prune': {
|
|
1333
1523
|
// C56 A4 — retention verb. Removes `.aperant/tasks/{id}/` directories
|
|
1334
1524
|
// whose task row is in `state.completed_tasks[]` AND whose completion
|
|
@@ -1354,7 +1544,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1354
1544
|
const skipped = []
|
|
1355
1545
|
|
|
1356
1546
|
for (const row of state.completed_tasks || []) {
|
|
1357
|
-
if (!row
|
|
1547
|
+
if (!row?.id) continue
|
|
1358
1548
|
const rowScope = row.scope || 'project'
|
|
1359
1549
|
if (scopeFilter && rowScope !== scopeFilter) {
|
|
1360
1550
|
skipped.push({ id: row.id, reason: 'scope-mismatch' })
|
|
@@ -1426,11 +1616,81 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1426
1616
|
|
|
1427
1617
|
default:
|
|
1428
1618
|
return err(
|
|
1429
|
-
`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, prune`,
|
|
1619
|
+
`Unknown task subcommand: ${subcommand}. Expected: create, list, get, close, update, move, complete-subtask, close-merged, narration-drain, prune`,
|
|
1430
1620
|
)
|
|
1431
1621
|
}
|
|
1432
1622
|
}
|
|
1433
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
|
+
|
|
1434
1694
|
/**
|
|
1435
1695
|
* Extract a PR number from a GitHub URL like https://github.com/owner/repo/pull/123
|
|
1436
1696
|
* or a short form like `owner/repo#123`. Returns null if not parseable.
|