@aperant/framework 0.6.7 → 0.7.3
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 +240 -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 +147 -3
- 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 +119 -3
- package/dist/cli/commands/health-check.mjs.map +1 -1
- package/dist/cli/commands/init.d.mts +19 -1
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +143 -8
- 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/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +37 -2
- 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 +132 -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 +8 -2
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/host/detect.d.mts +122 -9
- package/dist/cli/host/detect.d.mts.map +1 -1
- package/dist/cli/host/detect.mjs +132 -0
- package/dist/cli/host/detect.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts +38 -0
- package/dist/cli/install/legacy-paths.d.mts.map +1 -0
- package/dist/cli/install/legacy-paths.mjs +69 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -0
- 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/install/runtime-migrate.d.mts +84 -0
- package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
- package/dist/cli/install/runtime-migrate.mjs +244 -0
- package/dist/cli/install/runtime-migrate.mjs.map +1 -0
- package/dist/cli/route/drift-detect.d.mts +20 -0
- package/dist/cli/route/drift-detect.d.mts.map +1 -0
- package/dist/cli/route/drift-detect.mjs +107 -0
- package/dist/cli/route/drift-detect.mjs.map +1 -0
- 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/cli/util/aperant-section.d.mts +34 -0
- package/dist/cli/util/aperant-section.d.mts.map +1 -0
- package/dist/cli/util/aperant-section.mjs +127 -0
- package/dist/cli/util/aperant-section.mjs.map +1 -0
- package/dist/cli/util/copy.d.mts +28 -1
- package/dist/cli/util/copy.d.mts.map +1 -1
- package/dist/cli/util/copy.mjs +43 -55
- package/dist/cli/util/copy.mjs.map +1 -1
- package/dist/cli/util/semver.d.mts +17 -0
- package/dist/cli/util/semver.d.mts.map +1 -0
- package/dist/cli/util/semver.mjs +29 -0
- package/dist/cli/util/semver.mjs.map +1 -0
- package/dist/cli/util/skill-installs.d.mts +65 -9
- package/dist/cli/util/skill-installs.d.mts.map +1 -1
- package/dist/cli/util/skill-installs.mjs +130 -21
- package/dist/cli/util/skill-installs.mjs.map +1 -1
- package/dist/cli/util/version-preflight.d.mts +44 -0
- package/dist/cli/util/version-preflight.d.mts.map +1 -0
- package/dist/cli/util/version-preflight.mjs +66 -0
- package/dist/cli/util/version-preflight.mjs.map +1 -0
- 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 +133 -133
- package/prompts/conductor-framework-context.md +63 -0
- package/prompts/conductor-system.md +11 -0
- package/skills/apt-close-task/SKILL.md +6 -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 +11 -2
- 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-ship/SKILL.md +16 -4
- package/skills/apt-spar/SKILL.md +36 -11
- package/skills/apt-update/SKILL.md +26 -1
- package/skills/apt-verify-proof/SKILL.md +7 -5
- package/skills/apt-watch-ci/SKILL.md +4 -1
- package/src/cli/ci-watch/stop-matrix.mjs +17 -0
- package/src/cli/commands/ci-watch.mjs +152 -3
- package/src/cli/commands/features-audit.mjs +164 -5
- package/src/cli/commands/health-check.mjs +116 -3
- package/src/cli/commands/init.mjs +154 -6
- package/src/cli/commands/modes.mjs +11 -0
- package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
- package/src/cli/commands/route.mjs +38 -2
- package/src/cli/commands/task.mjs +132 -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 +8 -2
- package/src/cli/host/detect.mjs +135 -0
- package/src/cli/install/legacy-paths.mjs +69 -0
- package/src/cli/install/runtime-detect.mjs +9 -0
- package/src/cli/install/runtime-migrate.mjs +252 -0
- package/src/cli/route/drift-detect.mjs +107 -0
- package/src/cli/task/index-md.mjs +15 -2
- package/src/cli/util/aperant-section.mjs +136 -0
- package/src/cli/util/copy.mjs +43 -56
- package/src/cli/util/semver.mjs +28 -0
- package/src/cli/util/skill-installs.mjs +134 -21
- package/src/cli/util/version-preflight.mjs +65 -0
- package/templates/aperant-claude-md-appendix.md +37 -0
- 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)
|
|
@@ -140,7 +168,7 @@ export function cmdHealthCheck(projectDir) {
|
|
|
140
168
|
issues.push({
|
|
141
169
|
type: 'missing-skills',
|
|
142
170
|
severity: 'warning',
|
|
143
|
-
detail: 'No
|
|
171
|
+
detail: 'No apt install manifests or version stamps found for any supported runtime',
|
|
144
172
|
install_surfaces: installs,
|
|
145
173
|
})
|
|
146
174
|
}
|
|
@@ -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'
|
|
@@ -41,6 +46,7 @@ import {
|
|
|
41
46
|
writeLastInstallSummary,
|
|
42
47
|
} from '../install/pipeline.mjs'
|
|
43
48
|
import { RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
|
|
49
|
+
import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
|
|
44
50
|
import {
|
|
45
51
|
CLASS_A_SETTINGS,
|
|
46
52
|
CLASS_B_SETTINGS,
|
|
@@ -48,6 +54,7 @@ import {
|
|
|
48
54
|
parseClassFlags,
|
|
49
55
|
} from '../install/setting-class.mjs'
|
|
50
56
|
import { detectAgentRuntime } from '../util/agent-runtime.mjs'
|
|
57
|
+
import { buildAptSection } from '../util/aperant-section.mjs'
|
|
51
58
|
import { atomicWriteJson } from '../util/atomic-write.mjs'
|
|
52
59
|
import { aperantBanner } from '../util/banner.mjs'
|
|
53
60
|
import { copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
|
|
@@ -88,10 +95,22 @@ const TRANSFORM_LOADERS = new Map(
|
|
|
88
95
|
* additive: callers without the flag still get the no-op short-circuit
|
|
89
96
|
* (preserves `init-upgrade-drift.test.ts` case (a)).
|
|
90
97
|
*
|
|
98
|
+
* 0.7.0 — `--force-overwrite-catalog` (FRAMEWORK-BUG-023) bypasses the
|
|
99
|
+
* foreign-row defensive check in `injectInstructionFile`. Power-user
|
|
100
|
+
* flag only — no interview prompt. Without it, an instruction file
|
|
101
|
+
* carrying rows the generator didn't produce causes init to exit 2 with
|
|
102
|
+
* `code: 'instruction-file.foreign-rows'` so unattended upgrade flows
|
|
103
|
+
* surface the would-be data loss instead of silently clobbering it.
|
|
104
|
+
*
|
|
91
105
|
* @param {string[]} args
|
|
92
|
-
* @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 }}
|
|
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 }}
|
|
93
107
|
*/
|
|
94
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))
|
|
95
114
|
const flagSet = new Set(args.filter((a) => a.startsWith('--')))
|
|
96
115
|
let target = null
|
|
97
116
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -105,6 +124,14 @@ export function parseInstallFlags(args) {
|
|
|
105
124
|
// 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
|
|
106
125
|
// so /apt:update can re-stamp runtime files on a no-drift upgrade.
|
|
107
126
|
const forceRuntimes = flagSet.has('--force-runtimes')
|
|
127
|
+
// 0.7.0 (FRAMEWORK-BUG-023) — opt-in bypass of the foreign-row check
|
|
128
|
+
// in injectInstructionFile. Power-user flag only.
|
|
129
|
+
const forceOverwriteCatalog = flagSet.has('--force-overwrite-catalog')
|
|
130
|
+
// 0.7.0 (FRAMEWORK-BUG-021) — opt out of the codex legacy-tree cleanup
|
|
131
|
+
// that fires when `codex` is in the runtime set. Defaults to false so
|
|
132
|
+
// the cleanup runs by default; pass `--keep-legacy` to preserve the
|
|
133
|
+
// orphaned `.agents/skills/` tree on upgrades.
|
|
134
|
+
const keepLegacy = flagSet.has('--keep-legacy')
|
|
108
135
|
|
|
109
136
|
const classFlags = parseClassFlags(args)
|
|
110
137
|
|
|
@@ -112,6 +139,8 @@ export function parseInstallFlags(args) {
|
|
|
112
139
|
target,
|
|
113
140
|
skipBootstrap,
|
|
114
141
|
forceRuntimes,
|
|
142
|
+
forceOverwriteCatalog,
|
|
143
|
+
keepLegacy,
|
|
115
144
|
visibility: classFlags.visibility,
|
|
116
145
|
autonomy: classFlags.autonomy,
|
|
117
146
|
accept_new_defaults: classFlags.accept_new_defaults,
|
|
@@ -702,7 +731,11 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
|
|
|
702
731
|
if (suggested.size === 0) suggested.add('claude')
|
|
703
732
|
const sel = await clack.multiselect({
|
|
704
733
|
message: 'Pick the runtimes to install Aperant into',
|
|
705
|
-
options: RUNTIMES.map((r) => ({
|
|
734
|
+
options: RUNTIMES.map((r) => ({
|
|
735
|
+
value: r.id,
|
|
736
|
+
label: r.displayLabel ?? r.id,
|
|
737
|
+
hint: r.flag,
|
|
738
|
+
})),
|
|
706
739
|
initialValues: [...suggested],
|
|
707
740
|
required: true,
|
|
708
741
|
})
|
|
@@ -928,7 +961,7 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
|
|
|
928
961
|
* @param {Record<string, any>} config
|
|
929
962
|
* @param {InstallAnswers} answers
|
|
930
963
|
*/
|
|
931
|
-
function applyInterviewAnswers(config, answers) {
|
|
964
|
+
export function applyInterviewAnswers(config, answers, targetDir = null) {
|
|
932
965
|
if (config.autonomy) {
|
|
933
966
|
config.autonomy.default = answers.autonomyDefault
|
|
934
967
|
}
|
|
@@ -943,8 +976,22 @@ function applyInterviewAnswers(config, answers) {
|
|
|
943
976
|
}
|
|
944
977
|
if (config.multi_model?.cross_verification) {
|
|
945
978
|
const xverify = answers.crossVerificationTools
|
|
979
|
+
// `enabled` is project policy (is cross-verification on for this
|
|
980
|
+
// project?) — stays in shared `config.json`.
|
|
946
981
|
config.multi_model.cross_verification.enabled = xverify.length > 0
|
|
947
|
-
|
|
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
|
+
}
|
|
948
995
|
}
|
|
949
996
|
config.project_type =
|
|
950
997
|
answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
|
|
@@ -1164,6 +1211,27 @@ export async function cmdInit(projectDir, extraArgs = []) {
|
|
|
1164
1211
|
}
|
|
1165
1212
|
}
|
|
1166
1213
|
|
|
1214
|
+
// FRAMEWORK-BUG-021 — sweep the legacy `.agents/skills/` codex tree
|
|
1215
|
+
// BEFORE the install pipeline runs so the install can re-stamp
|
|
1216
|
+
// `.codex/` against a clean slate. Only fires when codex is in the
|
|
1217
|
+
// requested runtime set and `--keep-legacy` was not passed; the
|
|
1218
|
+
// helper itself is conservative (no-op on managed installs, no-op
|
|
1219
|
+
// when no legacy framework files are present).
|
|
1220
|
+
let legacyCleanup = null
|
|
1221
|
+
if (effectiveAnswers.runtimes.includes('codex')) {
|
|
1222
|
+
if (parsedFlags.keepLegacy) {
|
|
1223
|
+
legacyCleanup = { runtime: 'codex', ran: false, reason: '--keep-legacy' }
|
|
1224
|
+
} else {
|
|
1225
|
+
const cleanup = runLegacyCleanup({ targetDir: installTarget })
|
|
1226
|
+
legacyCleanup = { runtime: 'codex', ...cleanup }
|
|
1227
|
+
if (cleanup.ran && cleanup.retained.length > 0) {
|
|
1228
|
+
process.stderr.write(
|
|
1229
|
+
`[apt-tools] legacy_cleanup retained ${cleanup.retained.length} user-added path(s) under .agents/skills/: ${cleanup.retained.join(', ')}\n`,
|
|
1230
|
+
)
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1167
1235
|
const results = await installRuntimes(
|
|
1168
1236
|
effectiveAnswers.runtimes,
|
|
1169
1237
|
canonicalRoot,
|
|
@@ -1186,7 +1254,14 @@ export async function cmdInit(projectDir, extraArgs = []) {
|
|
|
1186
1254
|
effectiveAnswers,
|
|
1187
1255
|
interactiveTty,
|
|
1188
1256
|
kernelResult,
|
|
1257
|
+
parsedFlags.forceOverwriteCatalog === true,
|
|
1258
|
+
legacyCleanup,
|
|
1189
1259
|
)
|
|
1260
|
+
// FRAMEWORK-BUG-023 — finishInit signals the foreign-row defensive
|
|
1261
|
+
// abort by returning an early-exit sentinel envelope.
|
|
1262
|
+
if (envelope?.__earlyExit) {
|
|
1263
|
+
return envelope.__earlyExit
|
|
1264
|
+
}
|
|
1190
1265
|
if (interactiveTty) {
|
|
1191
1266
|
await renderOutro(effectiveAnswers, results, version, kernelResult)
|
|
1192
1267
|
// TTY mode: envelope was persisted to disk + suppressed from stdout
|
|
@@ -1365,6 +1440,19 @@ async function renderOutro(answers, results, version, kernelResult) {
|
|
|
1365
1440
|
* deps/` — surfaced in the envelope so
|
|
1366
1441
|
* downstream tooling can detect a
|
|
1367
1442
|
* degraded install.
|
|
1443
|
+
* @param {boolean} [forceOverwriteCatalog=false]
|
|
1444
|
+
* FRAMEWORK-BUG-023 — bypass the
|
|
1445
|
+
* foreign-row check in
|
|
1446
|
+
* injectInstructionFile when the user
|
|
1447
|
+
* explicitly opted in via
|
|
1448
|
+
* --force-overwrite-catalog.
|
|
1449
|
+
* @param {object|null} [legacyCleanup=null]
|
|
1450
|
+
* FRAMEWORK-BUG-021 — outcome of the
|
|
1451
|
+
* codex `.agents/skills/` cleanup
|
|
1452
|
+
* step. `null` when codex was not in
|
|
1453
|
+
* the runtime set; otherwise the
|
|
1454
|
+
* shape `{ runtime, ran, … }` from
|
|
1455
|
+
* `runLegacyCleanup`.
|
|
1368
1456
|
*/
|
|
1369
1457
|
function finishInit(
|
|
1370
1458
|
targetDir,
|
|
@@ -1376,6 +1464,8 @@ function finishInit(
|
|
|
1376
1464
|
answers,
|
|
1377
1465
|
suppressStdout = false,
|
|
1378
1466
|
kernelResult = null,
|
|
1467
|
+
forceOverwriteCatalog = false,
|
|
1468
|
+
legacyCleanup = null,
|
|
1379
1469
|
) {
|
|
1380
1470
|
writeLastInstallSummary(targetDir, runtimeResults, version)
|
|
1381
1471
|
|
|
@@ -1420,7 +1510,11 @@ function finishInit(
|
|
|
1420
1510
|
() => {
|
|
1421
1511
|
if (!existsSync(configDest)) {
|
|
1422
1512
|
const template = JSON.parse(readFileSync(configTemplate, 'utf-8'))
|
|
1423
|
-
|
|
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)
|
|
1424
1518
|
atomicWriteJson(configDest, template)
|
|
1425
1519
|
configCreated = true
|
|
1426
1520
|
}
|
|
@@ -1448,6 +1542,22 @@ function finishInit(
|
|
|
1448
1542
|
)
|
|
1449
1543
|
}
|
|
1450
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
|
+
|
|
1451
1561
|
const stateTemplate = resolve(canonicalRoot, 'templates', 'state.json')
|
|
1452
1562
|
const stateDest = join(aperantDir, 'state.json')
|
|
1453
1563
|
let stateCreated = false
|
|
@@ -1506,7 +1616,40 @@ function finishInit(
|
|
|
1506
1616
|
if (existsSync(claudeMdPath)) instructionFile = claudeMdPath
|
|
1507
1617
|
else if (existsSync(agentsMdPath)) instructionFile = agentsMdPath
|
|
1508
1618
|
else instructionFile = claudeMdPath
|
|
1509
|
-
|
|
1619
|
+
// FRAMEWORK-BUG-023 — generate the section from discoverSkills before
|
|
1620
|
+
// handing it to the writer. The writer also runs a foreign-row defensive
|
|
1621
|
+
// check; if rows present in the file aren't in the generated set we
|
|
1622
|
+
// signal exit-2 via the __earlyExit sentinel so cmdInit can route the
|
|
1623
|
+
// envelope through exitWith() without writing a malformed install.
|
|
1624
|
+
const aptSection = buildAptSection({
|
|
1625
|
+
canonicalRoot,
|
|
1626
|
+
targetDir,
|
|
1627
|
+
cli: detectAgentRuntime(),
|
|
1628
|
+
})
|
|
1629
|
+
const injectionOutcome = injectInstructionFile(instructionFile, {
|
|
1630
|
+
aptSection,
|
|
1631
|
+
force: forceOverwriteCatalog,
|
|
1632
|
+
})
|
|
1633
|
+
if (injectionOutcome.status === 'aborted-foreign-rows') {
|
|
1634
|
+
return {
|
|
1635
|
+
__earlyExit: exitWith(
|
|
1636
|
+
{
|
|
1637
|
+
ok: false,
|
|
1638
|
+
status: 'error',
|
|
1639
|
+
command: 'init',
|
|
1640
|
+
code: 'instruction-file.foreign-rows',
|
|
1641
|
+
message:
|
|
1642
|
+
`${basename(instructionFile)} contains skill rows not present in the installed framework: ` +
|
|
1643
|
+
`${injectionOutcome.foreignRowIds.join(', ')}. ` +
|
|
1644
|
+
'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
|
|
1645
|
+
foreignRowIds: injectionOutcome.foreignRowIds,
|
|
1646
|
+
instructionFile: basename(instructionFile),
|
|
1647
|
+
},
|
|
1648
|
+
2,
|
|
1649
|
+
),
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const injectionResult = injectionOutcome.status
|
|
1510
1653
|
|
|
1511
1654
|
const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
|
|
1512
1655
|
const precompactResult = installedClaude
|
|
@@ -1617,6 +1760,11 @@ function finishInit(
|
|
|
1617
1760
|
...(kernelResult.reason ? { reason: kernelResult.reason } : {}),
|
|
1618
1761
|
}
|
|
1619
1762
|
: null,
|
|
1763
|
+
// FRAMEWORK-BUG-021 — always-present envelope field. `null` when
|
|
1764
|
+
// codex was not in the runtime set; otherwise the cleanup outcome
|
|
1765
|
+
// (uses `{ ran: false, reason }` for every skip case so parsers
|
|
1766
|
+
// see a uniform shape).
|
|
1767
|
+
legacy_cleanup: legacyCleanup,
|
|
1620
1768
|
}
|
|
1621
1769
|
|
|
1622
1770
|
if (suppressStdout) {
|
|
@@ -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 = {
|