@aperant/framework 0.7.0 → 0.7.4
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 +205 -0
- package/agents/apt-planner.md +12 -0
- package/agents/apt-pr-review-fixer.md +13 -9
- package/bin/apt-tools.mjs +7 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
- package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
- package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
- package/dist/cli/commands/ci-watch.d.mts +11 -0
- package/dist/cli/commands/ci-watch.d.mts.map +1 -1
- package/dist/cli/commands/ci-watch.mjs +108 -2
- package/dist/cli/commands/ci-watch.mjs.map +1 -1
- package/dist/cli/commands/features-audit.d.mts +24 -0
- package/dist/cli/commands/features-audit.d.mts.map +1 -1
- package/dist/cli/commands/features-audit.mjs +159 -5
- package/dist/cli/commands/features-audit.mjs.map +1 -1
- package/dist/cli/commands/health-check.d.mts +16 -0
- package/dist/cli/commands/health-check.d.mts.map +1 -1
- package/dist/cli/commands/health-check.mjs +118 -2
- package/dist/cli/commands/health-check.mjs.map +1 -1
- package/dist/cli/commands/init.d.mts +9 -0
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +49 -4
- 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 +11 -0
- package/dist/cli/commands/modes.mjs.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
- package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
- package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +84 -5
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/validate-evidence.d.mts +24 -2
- package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
- package/dist/cli/commands/validate-evidence.mjs +154 -17
- package/dist/cli/commands/validate-evidence.mjs.map +1 -1
- package/dist/cli/commands/vitest-doctor.d.mts +2 -0
- package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
- package/dist/cli/commands/vitest-doctor.mjs +168 -0
- package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
- package/dist/cli/config/gitignore-drift.d.mts +23 -1
- package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
- package/dist/cli/config/gitignore-drift.mjs +81 -3
- package/dist/cli/config/gitignore-drift.mjs.map +1 -1
- package/dist/cli/config/load.d.mts +56 -2
- package/dist/cli/config/load.d.mts.map +1 -1
- package/dist/cli/config/load.mjs +192 -2
- package/dist/cli/config/load.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +7 -0
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +24 -2
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
- package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
- package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
- package/dist/cli/gate/gates/review-clean.d.mts +5 -1
- package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
- package/dist/cli/gate/gates/review-clean.mjs +23 -18
- package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
- package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
- package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
- package/dist/cli/gate/gates/verify-approved.mjs +93 -14
- package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +7 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/install/runtime-detect.d.mts +13 -0
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +9 -0
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/task/index-md.d.mts.map +1 -1
- package/dist/cli/task/index-md.mjs +14 -2
- package/dist/cli/task/index-md.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +11 -2
- package/dist/plugin/agents/apt-improver.md +99 -0
- package/dist/plugin/agents/apt-planner.md +127 -10
- package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
- package/dist/plugin/skills/apt/SKILL.md +1 -0
- package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
- package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
- package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
- package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
- package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
- package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
- package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
- package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
- package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
- package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
- package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
- package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
- package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
- package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
- package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
- package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
- package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
- package/dist/plugin/skills/apt-prototype/UI.md +90 -0
- package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
- package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
- package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
- package/dist/plugin/skills/apt-review/SKILL.md +2 -0
- package/dist/plugin/skills/apt-run/SKILL.md +32 -4
- package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
- package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
- package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
- package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
- package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
- package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
- package/dist/plugin/skills/apt-update/SKILL.md +77 -10
- package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
- package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
- package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
- package/package.json +1 -1
- package/prompts/conductor-framework-context.md +63 -0
- package/prompts/conductor-system.md +11 -0
- package/skills/apt-close-task/SKILL.md +1 -0
- package/skills/apt-discuss/SKILL.md +47 -5
- package/skills/apt-execute/SKILL.md +9 -0
- package/skills/apt-plan/SKILL.md +12 -0
- package/skills/apt-pr-review/SKILL.md +19 -3
- package/skills/apt-quick/SKILL.md +19 -8
- package/skills/apt-researcher.md +1 -0
- package/skills/apt-setup/SKILL.md +33 -2
- package/skills/apt-verify-proof/SKILL.md +7 -5
- package/src/cli/ci-watch/stop-matrix.mjs +17 -0
- package/src/cli/commands/ci-watch.mjs +113 -2
- package/src/cli/commands/features-audit.mjs +164 -5
- package/src/cli/commands/health-check.mjs +115 -2
- package/src/cli/commands/init.mjs +52 -4
- package/src/cli/commands/modes.mjs +11 -0
- package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
- package/src/cli/commands/task.mjs +83 -5
- package/src/cli/commands/validate-evidence.mjs +158 -17
- package/src/cli/commands/vitest-doctor.mjs +173 -0
- package/src/cli/config/gitignore-drift.mjs +74 -3
- package/src/cli/config/load.mjs +188 -2
- package/src/cli/consistency/parse-review.mjs +6 -0
- package/src/cli/dispatch.mjs +23 -2
- package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
- package/src/cli/gate/gates/review-clean.mjs +24 -19
- package/src/cli/gate/gates/verify-approved.mjs +97 -14
- package/src/cli/help.mjs +7 -2
- package/src/cli/install/runtime-detect.mjs +9 -0
- package/src/cli/task/index-md.mjs +15 -2
- package/templates/config.json +2 -7
- package/workflows/verify-proof.md +8 -3
|
@@ -11,25 +11,174 @@
|
|
|
11
11
|
* 1. `--diff-files <a,b,c>` explicit CSV (highest priority)
|
|
12
12
|
* 2. `--since <ref>` → runs `git diff --name-only <ref>..HEAD`
|
|
13
13
|
* 3. fallback: `git diff HEAD --name-only` (current WIP)
|
|
14
|
+
*
|
|
15
|
+
* BUG-025b worktree-redirect: features-audit historically wrote to the
|
|
16
|
+
* invoking cwd's `.aperant/features/`, leaking generated `*.json` into
|
|
17
|
+
* main when the executor invoked it from a worktree-isolated task. The
|
|
18
|
+
* fix applies a 4-step resolution order (see resolveWriteRoot below):
|
|
19
|
+
* (1) cwd IS a worktree root → write there (current behaviour)
|
|
20
|
+
* (2) --task-id maps to an active non-terminal worktree task →
|
|
21
|
+
* redirect writes to that worktree's `.aperant/features/`
|
|
22
|
+
* (3) multiple active worktree-isolated tasks + no --task-id → error
|
|
23
|
+
* (4) `--no-worktree-redirect` opt-out preserves layer (1) behaviour
|
|
14
24
|
*/
|
|
15
25
|
|
|
16
|
-
import { execFileSync } from 'node:child_process'
|
|
17
|
-
import {
|
|
26
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
28
|
+
import { join, resolve, sep } from 'node:path'
|
|
18
29
|
import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
|
|
19
30
|
import { applyStubPatches, auditFeatureRegistry } from '../features/registry-audit.mjs'
|
|
20
31
|
import { parseFlags } from '../util/args.mjs'
|
|
21
32
|
import { err, ok } from '../util/result.mjs'
|
|
22
33
|
|
|
34
|
+
const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged'])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* BUG-025b — resolve where features-audit should write `*.json`.
|
|
38
|
+
* See module header for the 4-step order. Returns `{ writeRoot, redirect_notice }`
|
|
39
|
+
* on success or a synthetic error envelope (`{ error: { ... } }`) on the
|
|
40
|
+
* ambiguous-worktree case.
|
|
41
|
+
*
|
|
42
|
+
* @internal — exported for unit testing.
|
|
43
|
+
*
|
|
44
|
+
* @param {{projectDir: string, taskId?: string | null, noRedirect?: boolean}} args
|
|
45
|
+
* @returns {{writeRoot?: string, redirect_notice?: string | null, error?: {kind: string, message: string, active_worktree_tasks: string[]}}}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveWriteRoot({ projectDir, taskId, noRedirect }) {
|
|
48
|
+
const root = resolve(projectDir)
|
|
49
|
+
if (noRedirect === true) {
|
|
50
|
+
return { writeRoot: root, redirect_notice: null }
|
|
51
|
+
}
|
|
52
|
+
// Layer (1): cwd IS a worktree root. Worktrees have `git rev-parse
|
|
53
|
+
// --git-dir` != `git rev-parse --git-common-dir`. Falling through to
|
|
54
|
+
// the existing behaviour writes to this worktree's .aperant/features.
|
|
55
|
+
// Fast path: git sets GIT_DIR and GIT_COMMON_DIR in the environment
|
|
56
|
+
// when running inside a worktree hook context — if both are set and
|
|
57
|
+
// differ, we are in a worktree. This env-var check is ONLY valid at
|
|
58
|
+
// Layer (1) where we ask "is this process in a worktree?"; it must
|
|
59
|
+
// NOT be used to validate an untrusted external path (Layer 2 below).
|
|
60
|
+
const envDir = process.env.GIT_DIR
|
|
61
|
+
const envCommonDir = process.env.GIT_COMMON_DIR
|
|
62
|
+
if (envDir && envCommonDir) {
|
|
63
|
+
if (resolve(envDir) !== resolve(envCommonDir)) {
|
|
64
|
+
return { writeRoot: root, redirect_notice: null }
|
|
65
|
+
}
|
|
66
|
+
} else if (isWorktreeRoot(root)) {
|
|
67
|
+
return { writeRoot: root, redirect_notice: null }
|
|
68
|
+
}
|
|
69
|
+
// Layer (2): --task-id maps to an active non-terminal worktree-isolated
|
|
70
|
+
// task → redirect.
|
|
71
|
+
const stateActive = readActiveTasks(root)
|
|
72
|
+
if (taskId) {
|
|
73
|
+
const task = stateActive[taskId]
|
|
74
|
+
if (
|
|
75
|
+
task &&
|
|
76
|
+
!TERMINAL_PHASES.has(task.lifecycle_phase) &&
|
|
77
|
+
typeof task.worktree_path === 'string' &&
|
|
78
|
+
existsSync(task.worktree_path)
|
|
79
|
+
) {
|
|
80
|
+
const resolvedWorktree = resolve(task.worktree_path)
|
|
81
|
+
const worktreesDir = resolve(root, '.aperant', 'worktrees') + sep
|
|
82
|
+
const isUnderProject = resolvedWorktree.startsWith(root + sep) || resolvedWorktree === root
|
|
83
|
+
const isUnderWorktrees = resolvedWorktree.startsWith(worktreesDir)
|
|
84
|
+
// Accept only paths under the project root, under .aperant/worktrees/,
|
|
85
|
+
// or confirmed as a real git worktree by git itself. Rejects arbitrary
|
|
86
|
+
// paths injected via a tampered state.json while preserving test
|
|
87
|
+
// fixtures that use ad-hoc worktree paths outside the project tree.
|
|
88
|
+
if (!isUnderProject && !isUnderWorktrees && !isWorktreeRoot(resolvedWorktree)) {
|
|
89
|
+
return { writeRoot: root, redirect_notice: null }
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
writeRoot: resolvedWorktree,
|
|
93
|
+
redirect_notice: `features-audit redirected writes to worktree ${resolvedWorktree} (--task-id ${taskId})`,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Layer (3): multiple active worktree-isolated tasks AND no --task-id
|
|
98
|
+
// → actionable error. Single-worktree case is OK to default to root
|
|
99
|
+
// (the executor will set up the worktree's registry on first scan).
|
|
100
|
+
const activeWorktreeTasks = Object.entries(stateActive).filter(([_id, t]) => {
|
|
101
|
+
return (
|
|
102
|
+
t &&
|
|
103
|
+
!TERMINAL_PHASES.has(t.lifecycle_phase) &&
|
|
104
|
+
typeof t.worktree_path === 'string' &&
|
|
105
|
+
existsSync(t.worktree_path)
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
if (!taskId && activeWorktreeTasks.length > 1) {
|
|
109
|
+
return {
|
|
110
|
+
error: {
|
|
111
|
+
kind: 'ambiguous-worktree-context',
|
|
112
|
+
message: 'ambiguous worktree context; pass --task-id <id> or --no-worktree-redirect',
|
|
113
|
+
active_worktree_tasks: activeWorktreeTasks.map(([id]) => id),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Default: current behaviour (write into cwd).
|
|
118
|
+
return { writeRoot: root, redirect_notice: null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isWorktreeRoot(projectDir) {
|
|
122
|
+
try {
|
|
123
|
+
const dirRes = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
124
|
+
cwd: projectDir,
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
127
|
+
})
|
|
128
|
+
const commonRes = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
129
|
+
cwd: projectDir,
|
|
130
|
+
encoding: 'utf-8',
|
|
131
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
132
|
+
})
|
|
133
|
+
if (dirRes.status !== 0 || commonRes.status !== 0) return false
|
|
134
|
+
return (dirRes.stdout || '').trim() !== (commonRes.stdout || '').trim()
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readActiveTasks(projectDir) {
|
|
141
|
+
const statePath = join(projectDir, '.aperant', 'state.json')
|
|
142
|
+
if (!existsSync(statePath)) return {}
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
145
|
+
return parsed?.active_tasks ?? {}
|
|
146
|
+
} catch {
|
|
147
|
+
return {}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
23
151
|
export function cmdFeaturesAudit(projectDir, extraArgs) {
|
|
24
152
|
if (!projectDir) return err('Usage: apt-tools features-audit <project-dir> [flags]')
|
|
25
153
|
const root = resolve(projectDir)
|
|
26
154
|
const flags = parseFlags(extraArgs)
|
|
27
155
|
|
|
156
|
+
// BUG-025b: redirect writes to the active task's worktree when the
|
|
157
|
+
// caller is in a non-worktree cwd. See resolveWriteRoot for the
|
|
158
|
+
// 4-step resolution order.
|
|
159
|
+
const taskIdFlag = flags.get('task-id')
|
|
160
|
+
const taskId = typeof taskIdFlag === 'string' && taskIdFlag.trim() ? taskIdFlag.trim() : null
|
|
161
|
+
const noRedirect = flags.get('no-worktree-redirect') === true
|
|
162
|
+
const writeRootResult = resolveWriteRoot({ projectDir: root, taskId, noRedirect })
|
|
163
|
+
if (writeRootResult.error) {
|
|
164
|
+
return err(writeRootResult.error.message, 1, {
|
|
165
|
+
kind: writeRootResult.error.kind,
|
|
166
|
+
active_worktree_tasks: writeRootResult.error.active_worktree_tasks,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const writeRoot = writeRootResult.writeRoot
|
|
170
|
+
const redirectNotice = writeRootResult.redirect_notice
|
|
171
|
+
if (redirectNotice) {
|
|
172
|
+
// Surface the redirect once on stderr so the executor's log carries
|
|
173
|
+
// the audit trail without polluting stdout JSON.
|
|
174
|
+
process.stderr.write(`[features-audit] ${redirectNotice}\n`)
|
|
175
|
+
}
|
|
176
|
+
|
|
28
177
|
const diffFiles = resolveDiffFiles(root, flags)
|
|
29
178
|
const applyStubs = flags.has('apply-stubs')
|
|
30
179
|
const format = (flags.get('format') || 'json').toLowerCase()
|
|
31
180
|
|
|
32
|
-
const result = auditFeatureRegistry({ projectDir:
|
|
181
|
+
const result = auditFeatureRegistry({ projectDir: writeRoot, diffFiles })
|
|
33
182
|
|
|
34
183
|
// Honor `.aperant/config.json.docs_policy`: only apply stubs for targets
|
|
35
184
|
// whose resolved policy is `auto-update`. This closes the skill-vs-code
|
|
@@ -52,7 +201,9 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
|
|
|
52
201
|
}
|
|
53
202
|
}
|
|
54
203
|
if (eligible.length > 0) {
|
|
55
|
-
|
|
204
|
+
// BUG-025b: stubs are written to the resolved `writeRoot` (which
|
|
205
|
+
// may be the active task's worktree) rather than the invoking cwd.
|
|
206
|
+
applied_patches = applyStubPatches(writeRoot, eligible)
|
|
56
207
|
}
|
|
57
208
|
if (skipped.length > 0) {
|
|
58
209
|
skipped_patches = skipped
|
|
@@ -65,6 +216,8 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
|
|
|
65
216
|
command: 'features-audit',
|
|
66
217
|
format: 'md',
|
|
67
218
|
markdown: renderMarkdown(result, applied_patches, skipped_patches),
|
|
219
|
+
write_root: writeRoot,
|
|
220
|
+
...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
|
|
68
221
|
...result,
|
|
69
222
|
...(applied_patches ? { applied_patches } : {}),
|
|
70
223
|
...(skipped_patches ? { skipped_patches } : {}),
|
|
@@ -74,6 +227,8 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
|
|
|
74
227
|
return ok({
|
|
75
228
|
status: 'ok',
|
|
76
229
|
command: 'features-audit',
|
|
230
|
+
write_root: writeRoot,
|
|
231
|
+
...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
|
|
77
232
|
...result,
|
|
78
233
|
...(applied_patches ? { applied_patches } : {}),
|
|
79
234
|
...(skipped_patches ? { skipped_patches } : {}),
|
|
@@ -88,7 +243,11 @@ function resolveDiffFiles(root, flags) {
|
|
|
88
243
|
.map((s) => s.trim())
|
|
89
244
|
.filter(Boolean)
|
|
90
245
|
}
|
|
91
|
-
const
|
|
246
|
+
const sinceRaw = flags.get('since')
|
|
247
|
+
const since =
|
|
248
|
+
typeof sinceRaw === 'string' && /^[A-Za-z0-9/_.-]+$/.test(sinceRaw) && !sinceRaw.startsWith('-')
|
|
249
|
+
? sinceRaw
|
|
250
|
+
: null
|
|
92
251
|
try {
|
|
93
252
|
const args = since ? ['diff', '--name-only', `${since}..HEAD`] : ['diff', 'HEAD', '--name-only']
|
|
94
253
|
const out = execFileSync('git', args, {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* commands/health-check.mjs — health-check command (C29).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
7
7
|
import { dirname, join, resolve } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
@@ -13,6 +13,33 @@ import { readSkillInstallState } from '../util/skill-installs.mjs'
|
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url)
|
|
14
14
|
const __dirname = dirname(__filename)
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* SECURITY: validate a component path string from `.aperant/features/*.json`
|
|
18
|
+
* before passing it to git. The registry is on-disk JSON authored by
|
|
19
|
+
* features-audit; pre-fix the orphan walker spliced this string into a
|
|
20
|
+
* shell command (`execSync(\`git rev-parse HEAD:${comp} 2>/dev/null\`)`),
|
|
21
|
+
* making a malicious entry like `foo.ts; rm -rf $HOME` a command-injection
|
|
22
|
+
* vector against any user who ran `apt-tools health-check`. The fix
|
|
23
|
+
* combines `execFileSync` (argv-form, no shell) with a strict path
|
|
24
|
+
* whitelist: relative posix paths only, no NUL / control bytes / shell
|
|
25
|
+
* metacharacters / leading `/` / `..` segments.
|
|
26
|
+
*
|
|
27
|
+
* @internal — exported for unit testing.
|
|
28
|
+
* @param {unknown} value
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function isSafeComponentPath(value) {
|
|
32
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 512) return false
|
|
33
|
+
// Reject NUL, control bytes, and shell metacharacters that could escape
|
|
34
|
+
// argv interpretation if a downstream caller ever reverts to shell mode.
|
|
35
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control bytes IS the security guard — see isSafeComponentPath docstring + test.
|
|
36
|
+
if (/[\x00-\x1f;&|`$<>"\\]/.test(value)) return false
|
|
37
|
+
if (value.startsWith('/')) return false
|
|
38
|
+
if (value.startsWith('-')) return false // would be parsed as a flag
|
|
39
|
+
if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
export function cmdHealthCheck(projectDir) {
|
|
17
44
|
if (!projectDir) return err('Usage: apt-tools health-check <project-dir>')
|
|
18
45
|
const targetDir = resolve(projectDir)
|
|
@@ -22,9 +49,10 @@ export function cmdHealthCheck(projectDir) {
|
|
|
22
49
|
// 1. Check for uncommitted changes in working tree
|
|
23
50
|
let dirtyFiles = []
|
|
24
51
|
try {
|
|
25
|
-
const status =
|
|
52
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
26
53
|
cwd: targetDir,
|
|
27
54
|
encoding: 'utf-8',
|
|
55
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
56
|
}).trim()
|
|
29
57
|
if (status) {
|
|
30
58
|
dirtyFiles = status.split('\n').filter(Boolean)
|
|
@@ -156,6 +184,91 @@ export function cmdHealthCheck(projectDir) {
|
|
|
156
184
|
}
|
|
157
185
|
}
|
|
158
186
|
|
|
187
|
+
// 6. BUG-025b — orphaned-feature-component warning. Walk
|
|
188
|
+
// `.aperant/features/*.json`; for each cited component path, verify
|
|
189
|
+
// `git rev-parse HEAD:<path>` succeeds on the main repo's HEAD. A path
|
|
190
|
+
// that resolves on a worktree branch but is missing on main is a
|
|
191
|
+
// candidate orphan (the registry entry leaked from a worktree-isolated
|
|
192
|
+
// task and was never followed by the actual component landing on main).
|
|
193
|
+
// Severity `warn` — the registry is best-effort metadata, not a build
|
|
194
|
+
// blocker. Only fires on the main repo (skipped when health-check is
|
|
195
|
+
// invoked from a worktree).
|
|
196
|
+
try {
|
|
197
|
+
const featuresDir = join(aperantDir, 'features')
|
|
198
|
+
if (existsSync(featuresDir)) {
|
|
199
|
+
// SECURITY: argv-form (no shell) — see isSafeComponentPath comment.
|
|
200
|
+
const dirRes = execFileSync('git', ['rev-parse', '--git-dir'], {
|
|
201
|
+
cwd: targetDir,
|
|
202
|
+
encoding: 'utf-8',
|
|
203
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
204
|
+
}).trim()
|
|
205
|
+
const commonRes = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
206
|
+
cwd: targetDir,
|
|
207
|
+
encoding: 'utf-8',
|
|
208
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
209
|
+
}).trim()
|
|
210
|
+
if (dirRes === commonRes) {
|
|
211
|
+
const orphans = []
|
|
212
|
+
// PRF-001: one subprocess total instead of one per component.
|
|
213
|
+
// Build a Set of all tracked paths from a single ls-tree call.
|
|
214
|
+
const lsOut = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], {
|
|
215
|
+
cwd: targetDir,
|
|
216
|
+
encoding: 'utf-8',
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
218
|
+
}).trim()
|
|
219
|
+
const trackedPaths = new Set(
|
|
220
|
+
lsOut
|
|
221
|
+
.split('\n')
|
|
222
|
+
.map((s) => s.trim())
|
|
223
|
+
.filter(Boolean),
|
|
224
|
+
)
|
|
225
|
+
const files = readdirSync(featuresDir).filter(
|
|
226
|
+
(f) => f.endsWith('.json') && f !== 'REGISTRY.md',
|
|
227
|
+
)
|
|
228
|
+
// QUA-006: cap total component iterations to guard against
|
|
229
|
+
// adversarially large or malformed registry files.
|
|
230
|
+
const MAX_ORPHAN_ITERATIONS = 10_000
|
|
231
|
+
let iterCount = 0
|
|
232
|
+
let cappedAt = null
|
|
233
|
+
outer: for (const f of files) {
|
|
234
|
+
let area
|
|
235
|
+
try {
|
|
236
|
+
area = JSON.parse(readFileSync(join(featuresDir, f), 'utf-8'))
|
|
237
|
+
} catch {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
const components = Array.isArray(area?.components) ? area.components : []
|
|
241
|
+
for (const comp of components) {
|
|
242
|
+
if (iterCount >= MAX_ORPHAN_ITERATIONS) {
|
|
243
|
+
cappedAt = f
|
|
244
|
+
break outer
|
|
245
|
+
}
|
|
246
|
+
iterCount++
|
|
247
|
+
// SECURITY: reject obviously-unsafe component strings.
|
|
248
|
+
if (!isSafeComponentPath(comp)) continue
|
|
249
|
+
if (!trackedPaths.has(comp)) orphans.push({ area_file: f, component: comp })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (cappedAt !== null) {
|
|
253
|
+
orphans.push({
|
|
254
|
+
area_file: cappedAt,
|
|
255
|
+
component: `(orphan scan capped at ${MAX_ORPHAN_ITERATIONS} entries — remaining components skipped)`,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
if (orphans.length > 0) {
|
|
259
|
+
issues.push({
|
|
260
|
+
type: 'orphan-feature-components',
|
|
261
|
+
severity: 'warning',
|
|
262
|
+
detail: `${orphans.length} feature component(s) cited in .aperant/features/*.json are missing on HEAD — candidates leaked from worktree-isolated tasks`,
|
|
263
|
+
orphans: orphans.slice(0, 20),
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
/* git not available or worktree — skip silently */
|
|
270
|
+
}
|
|
271
|
+
|
|
159
272
|
return ok({
|
|
160
273
|
status: 'ok',
|
|
161
274
|
command: 'health-check',
|
|
@@ -29,6 +29,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
29
29
|
import { detectMode } from '../bootstrap/detect-mode.mjs'
|
|
30
30
|
import { deriveSidecar } from '../bootstrap/product-context-sidecar.mjs'
|
|
31
31
|
import { assertConfigWriteAllowed } from '../config/guard.mjs'
|
|
32
|
+
import {
|
|
33
|
+
AUDIT_001_LOCAL_FIELDS,
|
|
34
|
+
migrateDeferredFieldsToLocalConfig,
|
|
35
|
+
writeLocalConfigField,
|
|
36
|
+
} from '../config/load.mjs'
|
|
32
37
|
import { migrateConfigSchema, setAt } from '../config/schema-migrate.mjs'
|
|
33
38
|
import { resolveSharePolicy } from '../config/share-policy.mjs'
|
|
34
39
|
import { buildAperantGitignoreTemplate } from '../config/upgrade-gitignore.mjs'
|
|
@@ -101,6 +106,11 @@ const TRANSFORM_LOADERS = new Map(
|
|
|
101
106
|
* @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean, forceOverwriteCatalog: boolean, keepLegacy: boolean }}
|
|
102
107
|
*/
|
|
103
108
|
export function parseInstallFlags(args) {
|
|
109
|
+
// C58 (0.7.2): `--kilocode` is a deprecated alias of `--kilo`. Matches
|
|
110
|
+
// the upstream package + app branding so users typing what they see on
|
|
111
|
+
// kilocode.ai get the same install as the canonical `--kilo` flag.
|
|
112
|
+
// Internal runtime id stays `kilo` (no migration — per spec ID-01).
|
|
113
|
+
args = args.map((a) => (a === '--kilocode' ? '--kilo' : a))
|
|
104
114
|
const flagSet = new Set(args.filter((a) => a.startsWith('--')))
|
|
105
115
|
let target = null
|
|
106
116
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -721,7 +731,11 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
|
|
|
721
731
|
if (suggested.size === 0) suggested.add('claude')
|
|
722
732
|
const sel = await clack.multiselect({
|
|
723
733
|
message: 'Pick the runtimes to install Aperant into',
|
|
724
|
-
options: RUNTIMES.map((r) => ({
|
|
734
|
+
options: RUNTIMES.map((r) => ({
|
|
735
|
+
value: r.id,
|
|
736
|
+
label: r.displayLabel ?? r.id,
|
|
737
|
+
hint: r.flag,
|
|
738
|
+
})),
|
|
725
739
|
initialValues: [...suggested],
|
|
726
740
|
required: true,
|
|
727
741
|
})
|
|
@@ -947,7 +961,7 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
|
|
|
947
961
|
* @param {Record<string, any>} config
|
|
948
962
|
* @param {InstallAnswers} answers
|
|
949
963
|
*/
|
|
950
|
-
function applyInterviewAnswers(config, answers) {
|
|
964
|
+
export function applyInterviewAnswers(config, answers, targetDir = null) {
|
|
951
965
|
if (config.autonomy) {
|
|
952
966
|
config.autonomy.default = answers.autonomyDefault
|
|
953
967
|
}
|
|
@@ -962,8 +976,22 @@ function applyInterviewAnswers(config, answers) {
|
|
|
962
976
|
}
|
|
963
977
|
if (config.multi_model?.cross_verification) {
|
|
964
978
|
const xverify = answers.crossVerificationTools
|
|
979
|
+
// `enabled` is project policy (is cross-verification on for this
|
|
980
|
+
// project?) — stays in shared `config.json`.
|
|
965
981
|
config.multi_model.cross_verification.enabled = xverify.length > 0
|
|
966
|
-
|
|
982
|
+
// AUDIT-001 (0.7.2 S1) — `tools` is per-device (which CLIs are installed
|
|
983
|
+
// HERE) and MUST land in `config.local.json`, NOT shared. Strip from
|
|
984
|
+
// the template to keep the shared file clean. When `targetDir` is null
|
|
985
|
+
// (legacy callers / tests that don't go through finishInit), we leave
|
|
986
|
+
// the tools array on the shared template — backward-compatible fallback
|
|
987
|
+
// — but the writer path always passes targetDir so production lands
|
|
988
|
+
// the value in local.
|
|
989
|
+
if (targetDir) {
|
|
990
|
+
delete config.multi_model.cross_verification.tools
|
|
991
|
+
writeLocalConfigField(targetDir, 'multi_model.cross_verification.tools', xverify)
|
|
992
|
+
} else {
|
|
993
|
+
config.multi_model.cross_verification.tools = xverify
|
|
994
|
+
}
|
|
967
995
|
}
|
|
968
996
|
config.project_type =
|
|
969
997
|
answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
|
|
@@ -1482,7 +1510,11 @@ function finishInit(
|
|
|
1482
1510
|
() => {
|
|
1483
1511
|
if (!existsSync(configDest)) {
|
|
1484
1512
|
const template = JSON.parse(readFileSync(configTemplate, 'utf-8'))
|
|
1485
|
-
|
|
1513
|
+
// AUDIT-001 (0.7.2 S1) — pass targetDir so the cross_verification.tools
|
|
1514
|
+
// per-device field lands in `config.local.json`, not the shared
|
|
1515
|
+
// `config.json`. Matches the upgrade-path migrator which already
|
|
1516
|
+
// moves the same field via migrateDeferredFieldsToLocalConfig.
|
|
1517
|
+
applyInterviewAnswers(template, answers, targetDir)
|
|
1486
1518
|
atomicWriteJson(configDest, template)
|
|
1487
1519
|
configCreated = true
|
|
1488
1520
|
}
|
|
@@ -1510,6 +1542,22 @@ function finishInit(
|
|
|
1510
1542
|
)
|
|
1511
1543
|
}
|
|
1512
1544
|
|
|
1545
|
+
// AUDIT-001 — UPGRADE-only migrator. Moves the 5 deferred fields out
|
|
1546
|
+
// of `config.json` into `config.local.json` on real upgrade paths
|
|
1547
|
+
// (configMigrated === true), NOT on fresh seeds — the freshly-seeded
|
|
1548
|
+
// template config.json IS the post-migration state, so the migrator
|
|
1549
|
+
// would just round-trip with no observable benefit AND would trip
|
|
1550
|
+
// the next init invocation's no-drift detection. Idempotent on
|
|
1551
|
+
// re-run. Per-field independence per ID-08 — one failure does NOT
|
|
1552
|
+
// block the others.
|
|
1553
|
+
if (configMigrated && existsSync(configDest)) {
|
|
1554
|
+
try {
|
|
1555
|
+
migrateDeferredFieldsToLocalConfig(targetDir, AUDIT_001_LOCAL_FIELDS)
|
|
1556
|
+
} catch {
|
|
1557
|
+
/* never block init on telemetry-only migrator */
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1513
1561
|
const stateTemplate = resolve(canonicalRoot, 'templates', 'state.json')
|
|
1514
1562
|
const stateDest = join(aperantDir, 'state.json')
|
|
1515
1563
|
let stateCreated = false
|
|
@@ -41,6 +41,17 @@ const FRONTMATTER_RE = /^---\s*([\s\S]*?)\s*---/
|
|
|
41
41
|
* which config keys a skill cares about. Skills that don't touch
|
|
42
42
|
* config are omitted (their config_keys is `[]`).
|
|
43
43
|
*
|
|
44
|
+
* AUDIT-001 (0.7.2): all dotted paths below resolve against the MERGED
|
|
45
|
+
* config (`loadMergedProjectConfig()` — shared `config.json` + local
|
|
46
|
+
* `config.local.json` overlay). The five per-device fields enumerated by
|
|
47
|
+
* `AUDIT_001_LOCAL_FIELDS` in `cli/config/load.mjs` (currently
|
|
48
|
+
* `verification.open_report`, `multi_model.cross_verification.tools`,
|
|
49
|
+
* `preferences.{date_format,time_format,quick_task_post_verify}`)
|
|
50
|
+
* physically live in `config.local.json`; the wildcard `'*'` surface for
|
|
51
|
+
* `apt:setup` covers both files. Consumers MUST go through the merged
|
|
52
|
+
* loader — never raw `JSON.parse(readFileSync('.aperant/config.json'))` —
|
|
53
|
+
* to honor per-device overrides correctly.
|
|
54
|
+
*
|
|
44
55
|
* @type {Record<string, string[]>}
|
|
45
56
|
*/
|
|
46
57
|
const SKILL_CONFIG_KEYS = {
|
|
@@ -668,10 +668,14 @@ export function decideVerdict({ statusLine, fixesApplied, diffFiles }) {
|
|
|
668
668
|
}
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
-
function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
672
|
-
//
|
|
673
|
-
// fixer-
|
|
674
|
-
//
|
|
671
|
+
export function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
672
|
+
// BUG-030: strict-ordered candidate list. Parallel fixers write to
|
|
673
|
+
// distinct fixer-named markdowns (fixes-applied-fixer-{N}.md AND/OR
|
|
674
|
+
// fixes-applied-{fixer}.md); the legacy group-named form remains for
|
|
675
|
+
// backward-compat with reviews started before this fix. fixes-applied.md
|
|
676
|
+
// (unscoped) stays last as the legacy single-fixer fallback. NO broad
|
|
677
|
+
// glob — false-positives on the wrong fixer's file are what caused the
|
|
678
|
+
// `hallucinated` flake (4 sightings in 3 days per spec-gaps).
|
|
675
679
|
const iterDir = join(reviewDir, 'iterations', String(iteration))
|
|
676
680
|
const candidates = []
|
|
677
681
|
if (fixer === 'self-reviewer') {
|
|
@@ -681,7 +685,9 @@ function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
|
|
|
681
685
|
const match = /^fixer-(\d+)$/.exec(fixer)
|
|
682
686
|
if (match) {
|
|
683
687
|
candidates.push(join(iterDir, `fixes-applied-group${match[1]}.md`))
|
|
688
|
+
candidates.push(join(iterDir, `fixes-applied-fixer-${match[1]}.md`))
|
|
684
689
|
}
|
|
690
|
+
candidates.push(join(iterDir, `fixes-applied-${fixer}.md`))
|
|
685
691
|
candidates.push(join(iterDir, 'fixes-applied.md'))
|
|
686
692
|
}
|
|
687
693
|
for (const c of candidates) {
|
|
@@ -802,7 +808,14 @@ export function cmdPrReviewAuditFixer(projectDir, extraArgs) {
|
|
|
802
808
|
|
|
803
809
|
const worktreeArg = flags.get('worktree')
|
|
804
810
|
if (!worktreeArg) return err('Required: --worktree <path>')
|
|
805
|
-
|
|
811
|
+
// BUG-015: resolve relative to the passed projectDir (not process.cwd()).
|
|
812
|
+
// Callers invoke audit-fixer from a non-worktree cwd (the main repo) with
|
|
813
|
+
// a relative --worktree like `../worktrees/foo`; resolving against cwd
|
|
814
|
+
// returned a bogus path that didn't exist, causing the audit to see zero
|
|
815
|
+
// staged files and false-positive `hallucinated`. The existing `cwd:
|
|
816
|
+
// worktree` at the spawn site (line 437-473) already scopes git
|
|
817
|
+
// invocations correctly, so no `git -C` change is needed.
|
|
818
|
+
const worktree = resolve(projectDir, worktreeArg)
|
|
806
819
|
|
|
807
820
|
const statusLineArg = flags.get('status-line')
|
|
808
821
|
// Missing / empty status line is fail-closed to hallucinated, not a
|
|
@@ -14,7 +14,11 @@
|
|
|
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 {
|
|
17
|
+
import {
|
|
18
|
+
getTaskIsolationConfig,
|
|
19
|
+
loadMergedProjectConfig,
|
|
20
|
+
loadProjectConfig,
|
|
21
|
+
} from '../config/load.mjs'
|
|
18
22
|
import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
|
|
19
23
|
import { createCoordinationStore } from '../coordination/store.mjs'
|
|
20
24
|
import { getGitIdentity } from '../git/identity.mjs'
|
|
@@ -127,9 +131,16 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
127
131
|
// a later roadmap add-milestone/add-phase may populate it.
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
// AUDIT-001: read through the merged loader so local-only
|
|
135
|
+
// overrides for preferences.date_format / preferences.time_format
|
|
136
|
+
// win over the shared config.json fallback.
|
|
137
|
+
// Guard: malformed config.local.json must not crash task creation.
|
|
138
|
+
let config
|
|
139
|
+
try {
|
|
140
|
+
config = loadMergedProjectConfig(targetDir) ?? {}
|
|
141
|
+
} catch {
|
|
142
|
+
config = {}
|
|
143
|
+
}
|
|
133
144
|
// User-supplied --id bypasses the collision guard (caller owns the
|
|
134
145
|
// uniqueness contract). Generated ids run through a numeric-counter
|
|
135
146
|
// disambiguator that appends -2, -3, … when the candidate already
|
|
@@ -982,7 +993,50 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
982
993
|
state.last_activity = new Date().toISOString()
|
|
983
994
|
atomicWriteJson(statePath, state)
|
|
984
995
|
if (prUrlForCiWatch) {
|
|
985
|
-
|
|
996
|
+
// BUG-027 (a): pre-arm gh check. If the PR is already
|
|
997
|
+
// MERGED/CLOSED, skip arming so we don't leak a new
|
|
998
|
+
// watcher state file we'll immediately have to GC. gh
|
|
999
|
+
// rate-limit / offline / ENOENT falls back to arm (mirror
|
|
1000
|
+
// the existing rate_limited/offline patterns at lines
|
|
1001
|
+
// 1322/1326) — non-fatal so a transient gh failure
|
|
1002
|
+
// doesn't prevent legitimate watcher arming.
|
|
1003
|
+
const ghFixture = process.env.APT_GH_FIXTURE_JSON
|
|
1004
|
+
let terminalState = null
|
|
1005
|
+
if (ghFixture) {
|
|
1006
|
+
try {
|
|
1007
|
+
const parsed = JSON.parse(ghFixture)
|
|
1008
|
+
if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
|
|
1009
|
+
terminalState = parsed.state
|
|
1010
|
+
}
|
|
1011
|
+
} catch {
|
|
1012
|
+
/* malformed fixture → fall through to arm */
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
try {
|
|
1016
|
+
const res = spawnSync('gh', ['pr', 'view', prUrlForCiWatch, '--json', 'state'], {
|
|
1017
|
+
cwd: targetDir,
|
|
1018
|
+
encoding: 'utf-8',
|
|
1019
|
+
})
|
|
1020
|
+
if (res.status === 0 && res.stdout) {
|
|
1021
|
+
const parsed = JSON.parse(res.stdout)
|
|
1022
|
+
if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
|
|
1023
|
+
terminalState = parsed.state
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/* non-zero exit / ENOENT / parse error → fall through to arm */
|
|
1027
|
+
} catch {
|
|
1028
|
+
/* never block arming on gh transport error */
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (terminalState) {
|
|
1032
|
+
ciWatchResult = {
|
|
1033
|
+
ci_watch_armed: false,
|
|
1034
|
+
ci_watch_skip_reason: 'pr-terminal-state',
|
|
1035
|
+
ci_watch: null,
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
|
|
1039
|
+
}
|
|
986
1040
|
}
|
|
987
1041
|
|
|
988
1042
|
regenerateTaskIndex(targetDir)
|
|
@@ -1479,6 +1533,30 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
1479
1533
|
mergedAt: parsed.mergedAt ?? null,
|
|
1480
1534
|
})
|
|
1481
1535
|
|
|
1536
|
+
// BUG-027 (b): tear down any armed ci-watch state for this PR.
|
|
1537
|
+
// `doStop` is idempotent (returns `not-found` for missing state
|
|
1538
|
+
// files), so calling unconditionally here is safe. Without this
|
|
1539
|
+
// the watcher state file leaks past PR-terminal-state and
|
|
1540
|
+
// downstream operations (ci-watch sweep, health-check) flag it
|
|
1541
|
+
// as orphaned. Failure is logged into errors[] as a warning;
|
|
1542
|
+
// the merge close itself has already succeeded.
|
|
1543
|
+
try {
|
|
1544
|
+
const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
|
|
1545
|
+
if (prNumber !== null) {
|
|
1546
|
+
cmdCiWatch('stop', targetDir, [
|
|
1547
|
+
'--pr',
|
|
1548
|
+
String(prNumber),
|
|
1549
|
+
'--reason',
|
|
1550
|
+
'task-closed-merged',
|
|
1551
|
+
])
|
|
1552
|
+
}
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
errors.push({
|
|
1555
|
+
id,
|
|
1556
|
+
reason: `ci-watch-stop-failed: ${e?.message ?? String(e)}`,
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1482
1560
|
// Append a pending_narration[] ledger entry on successful merged close
|
|
1483
1561
|
// so /apt:close-task --narrate-only can drain it later. The ledger is
|
|
1484
1562
|
// additive-only; failure to write must never undo the close above
|