@aperant/framework 0.7.0 → 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 +180 -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 +133 -133
- 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 +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-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
|
@@ -89,8 +89,10 @@ For multi-model verification details:
|
|
|
89
89
|
|
|
90
90
|
## Config
|
|
91
91
|
|
|
92
|
-
Settings in `.aperant/config.json`
|
|
93
|
-
|
|
94
|
-
- `verification.
|
|
95
|
-
- `
|
|
96
|
-
- `multi_model.
|
|
92
|
+
Settings live in the MERGED Aperant config (shared `.aperant/config.json` + per-device `.aperant/config.local.json`; local wins). Run `/apt:setup` to configure. Read via `apt-tools config read .` or the framework's `loadMergedProjectConfig()` — NEVER raw `JSON.parse(readFileSync('.aperant/config.json'))`, which misses per-device overrides.
|
|
93
|
+
|
|
94
|
+
- `verification.evidence_capture` — shared. capture screenshots + terminal output (default: true)
|
|
95
|
+
- `verification.open_report` — **per-device (config.local.json)** per AUDIT-001. open HTML in browser when done (default: true)
|
|
96
|
+
- `multi_model.enabled` — shared. run multi-model verification (default: false)
|
|
97
|
+
- `multi_model.cross_verification.tools` — **per-device (config.local.json)** per AUDIT-001. which CLIs to cross-verify with.
|
|
98
|
+
- `multi_model.gemini.model` — shared. Gemini preset: default/thorough/fast
|
|
@@ -106,6 +106,23 @@ export function classify({ checks, state, prMetadata }) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// 6b. BUG-031 defensive wait branch — if no checks are actually
|
|
110
|
+
// failing (everything is success / skipped / in_progress / neutral),
|
|
111
|
+
// never fall through to the default-red 'fix' at the bottom. Placed
|
|
112
|
+
// AFTER reviewer-pause so CHANGES_REQUESTED + no-failures still
|
|
113
|
+
// produces 'reviewer-pause' (locks existing semantics), BEFORE the
|
|
114
|
+
// remaining checks (identical-log, flake-skip, infra-backoff, fix)
|
|
115
|
+
// which all assume at least one failing row. Pre-fix, a PR with a
|
|
116
|
+
// success + a SKIPPED conditional CI job fell through to 'fix' and
|
|
117
|
+
// burned a fix attempt every tick on a non-existent failure.
|
|
118
|
+
if (failed.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
action: 'wait',
|
|
121
|
+
counts_toward_budget: false,
|
|
122
|
+
reason: 'no failing checks; awaiting in-flight or skipped',
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
// 7. Identical-log → non-converging fixer.
|
|
110
127
|
// Compare aggregate fingerprints (same format as the write side in ci-watch.mjs).
|
|
111
128
|
if (attempts > 0 && st.last_status && failed.length > 0) {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* itself never shells out to gh.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { spawnSync } from 'node:child_process'
|
|
27
28
|
import { randomBytes } from 'node:crypto'
|
|
28
29
|
import { existsSync, readFileSync } from 'node:fs'
|
|
29
30
|
import { dirname, resolve } from 'node:path'
|
|
@@ -94,7 +95,7 @@ function validatePr(pr) {
|
|
|
94
95
|
*/
|
|
95
96
|
export function cmdCiWatch(subcommand, projectDir, extraArgs) {
|
|
96
97
|
if (!subcommand) {
|
|
97
|
-
return err('Usage: apt-tools ci-watch <start|stop|status|tick> <project-dir> [flags]')
|
|
98
|
+
return err('Usage: apt-tools ci-watch <start|stop|status|tick|sweep> <project-dir> [flags]')
|
|
98
99
|
}
|
|
99
100
|
if (!projectDir) {
|
|
100
101
|
return err(`Usage: apt-tools ci-watch ${subcommand} <project-dir> [flags]`)
|
|
@@ -112,9 +113,34 @@ export function cmdCiWatch(subcommand, projectDir, extraArgs) {
|
|
|
112
113
|
return doStatus(root, flags)
|
|
113
114
|
case 'tick':
|
|
114
115
|
return doTick(root, flags)
|
|
116
|
+
case 'sweep':
|
|
117
|
+
return err(
|
|
118
|
+
'sweep is async; call cmdCiWatchSweep directly (the apt-tools dispatcher routes it separately)',
|
|
119
|
+
)
|
|
115
120
|
default:
|
|
116
|
-
return err(
|
|
121
|
+
return err(
|
|
122
|
+
`Unknown ci-watch subcommand: ${subcommand}. Expected: start, stop, status, tick, sweep`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Async sibling for the `sweep` subcommand (the rate-limited orphaned-state
|
|
129
|
+
* walker). Split out from the sync `cmdCiWatch` dispatcher so the latter's
|
|
130
|
+
* return type stays `CommandResult` (not a `CommandResult | Promise` union)
|
|
131
|
+
* and the synchronous tests + callers don't need to be wrapped in `await`.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} projectDir
|
|
134
|
+
* @param {string[]} extraArgs
|
|
135
|
+
* @returns {Promise<CommandResult>}
|
|
136
|
+
*/
|
|
137
|
+
export async function cmdCiWatchSweep(projectDir, extraArgs) {
|
|
138
|
+
if (!projectDir) {
|
|
139
|
+
return err('Usage: apt-tools ci-watch sweep <project-dir> [flags]')
|
|
117
140
|
}
|
|
141
|
+
const root = resolve(projectDir)
|
|
142
|
+
const flags = parseFlags(extraArgs)
|
|
143
|
+
return await doSweep(root, flags)
|
|
118
144
|
}
|
|
119
145
|
|
|
120
146
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +271,91 @@ function doStop(root, flags) {
|
|
|
245
271
|
})
|
|
246
272
|
}
|
|
247
273
|
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// sweep — BUG-027 (c): manual GC for orphaned ci-watch state files.
|
|
276
|
+
//
|
|
277
|
+
// Enumerates all watcher state files via `listWatches(root)`, shells out
|
|
278
|
+
// to `gh pr view --json state` for each one (rate-limited to ≤5/sec),
|
|
279
|
+
// and removes the state files whose PR state is MERGED or CLOSED. Never
|
|
280
|
+
// auto-runs on startup — opt-in manual subcommand only, per brief.
|
|
281
|
+
// Honors APT_GH_FIXTURE_JSON for hermetic tests (returns the same body
|
|
282
|
+
// for every PR — fine since the test fixtures we ship cover each
|
|
283
|
+
// branch in isolation).
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
async function doSweep(root, flags) {
|
|
287
|
+
if (flags.get('orphaned') !== true) {
|
|
288
|
+
return err(
|
|
289
|
+
'ci-watch sweep currently supports only `--orphaned`. Usage: apt-tools ci-watch sweep <project-dir> --orphaned',
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
const dryRun = flags.get('dry-run') === true
|
|
293
|
+
const watches = listWatches(root)
|
|
294
|
+
const removed = []
|
|
295
|
+
const skipped = []
|
|
296
|
+
const ghFixture = process.env.APT_GH_FIXTURE_JSON
|
|
297
|
+
for (let i = 0; i < watches.length; i++) {
|
|
298
|
+
const watch = watches[i]
|
|
299
|
+
const pr = watch.pr
|
|
300
|
+
const prUrl = watch.pr_url ?? null
|
|
301
|
+
// Validate pr_url shape before passing to gh: must be an https:// URL
|
|
302
|
+
// or absent. An invalid value (e.g. a flag-injection string from a
|
|
303
|
+
// tampered state file) falls back to the integer pr number.
|
|
304
|
+
const safeUrl =
|
|
305
|
+
typeof prUrl === 'string' && /^https:\/\/.+/.test(prUrl) && !prUrl.startsWith('-')
|
|
306
|
+
? prUrl
|
|
307
|
+
: null
|
|
308
|
+
// Need a PR URL to call `gh pr view`; if absent, fall back to the
|
|
309
|
+
// integer pr arg which gh accepts for the active repo.
|
|
310
|
+
const target = safeUrl ?? String(pr)
|
|
311
|
+
let body = null
|
|
312
|
+
if (ghFixture) {
|
|
313
|
+
body = ghFixture
|
|
314
|
+
} else {
|
|
315
|
+
try {
|
|
316
|
+
const res = spawnSync('gh', ['pr', 'view', target, '--json', 'state'], {
|
|
317
|
+
cwd: root,
|
|
318
|
+
encoding: 'utf-8',
|
|
319
|
+
})
|
|
320
|
+
if (res.status === 0 && res.stdout) body = res.stdout
|
|
321
|
+
} catch {
|
|
322
|
+
/* gh transport error — skip this entry */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!body) {
|
|
326
|
+
skipped.push({ pr, reason: 'gh-unavailable' })
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
let parsed
|
|
330
|
+
try {
|
|
331
|
+
parsed = JSON.parse(body)
|
|
332
|
+
} catch {
|
|
333
|
+
skipped.push({ pr, reason: 'malformed-gh-json' })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const state = parsed?.state ?? null
|
|
337
|
+
if (state !== 'MERGED' && state !== 'CLOSED') {
|
|
338
|
+
skipped.push({ pr, reason: `pr-state-${state ?? 'unknown'}` })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (!dryRun) deleteState(root, pr)
|
|
342
|
+
removed.push({ pr, pr_state: state })
|
|
343
|
+
// Rate-limit ≤5/sec per brief — sleep 200ms between live gh calls.
|
|
344
|
+
// Use async setTimeout (non-blocking) rather than Atomics.wait.
|
|
345
|
+
if (!ghFixture && i < watches.length - 1) {
|
|
346
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return ok({
|
|
350
|
+
status: 'ok',
|
|
351
|
+
command: 'ci-watch-sweep',
|
|
352
|
+
dry_run: dryRun,
|
|
353
|
+
removed,
|
|
354
|
+
skipped,
|
|
355
|
+
total_watches: watches.length,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
248
359
|
// ---------------------------------------------------------------------------
|
|
249
360
|
// status
|
|
250
361
|
// ---------------------------------------------------------------------------
|
|
@@ -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 = {
|