@aperant/framework 0.19.0 → 0.20.0

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/agents/apt-executor.md +6 -0
  3. package/agents/apt-planner.md +3 -2
  4. package/bin/features-reconcile-check.mjs +77 -0
  5. package/dist/cli/artifacts/self-stage.d.mts.map +1 -1
  6. package/dist/cli/artifacts/self-stage.mjs +2 -1
  7. package/dist/cli/artifacts/self-stage.mjs.map +1 -1
  8. package/dist/cli/cli-wrappers/features-reconcile.d.mts +2 -0
  9. package/dist/cli/cli-wrappers/features-reconcile.d.mts.map +1 -0
  10. package/dist/cli/cli-wrappers/features-reconcile.mjs +9 -0
  11. package/dist/cli/cli-wrappers/features-reconcile.mjs.map +1 -0
  12. package/dist/cli/commands/features-reconcile.d.mts +2 -0
  13. package/dist/cli/commands/features-reconcile.d.mts.map +1 -0
  14. package/dist/cli/commands/features-reconcile.mjs +143 -0
  15. package/dist/cli/commands/features-reconcile.mjs.map +1 -0
  16. package/dist/cli/commands/health-check.d.mts +2 -2
  17. package/dist/cli/commands/health-check.mjs +2 -2
  18. package/dist/cli/dispatch.mjs +2 -2
  19. package/dist/cli/dispatch.mjs.map +1 -1
  20. package/dist/cli/features/area-shape.d.mts +11 -0
  21. package/dist/cli/features/area-shape.d.mts.map +1 -0
  22. package/dist/cli/features/area-shape.mjs +141 -0
  23. package/dist/cli/features/area-shape.mjs.map +1 -0
  24. package/dist/cli/features/delta.d.mts +78 -0
  25. package/dist/cli/features/delta.d.mts.map +1 -0
  26. package/dist/cli/features/delta.mjs +591 -0
  27. package/dist/cli/features/delta.mjs.map +1 -0
  28. package/dist/cli/features/reconcile.d.mts +236 -0
  29. package/dist/cli/features/reconcile.d.mts.map +1 -0
  30. package/dist/cli/features/reconcile.mjs +1126 -0
  31. package/dist/cli/features/reconcile.mjs.map +1 -0
  32. package/dist/cli/features/surface-map.d.mts +63 -0
  33. package/dist/cli/features/surface-map.d.mts.map +1 -0
  34. package/dist/cli/features/surface-map.mjs +191 -0
  35. package/dist/cli/features/surface-map.mjs.map +1 -0
  36. package/dist/cli/{commands/features-audit.d.mts → features/write-root.d.mts} +4 -3
  37. package/dist/cli/features/write-root.d.mts.map +1 -0
  38. package/dist/cli/features/write-root.mjs +137 -0
  39. package/dist/cli/features/write-root.mjs.map +1 -0
  40. package/dist/cli/gate/gates/features-reconciled.d.mts +36 -0
  41. package/dist/cli/gate/gates/features-reconciled.d.mts.map +1 -0
  42. package/dist/cli/gate/gates/features-reconciled.mjs +67 -0
  43. package/dist/cli/gate/gates/features-reconciled.mjs.map +1 -0
  44. package/dist/cli/gate/gates/index.mjs +1 -0
  45. package/dist/cli/gate/gates/index.mjs.map +1 -1
  46. package/dist/cli/help.mjs +1 -1
  47. package/dist/cli/release-notes/ship-autodraft.mjs +2 -2
  48. package/dist/cli/variant/gallery.d.mts.map +1 -1
  49. package/dist/cli/variant/gallery.mjs +7 -3
  50. package/dist/cli/variant/gallery.mjs.map +1 -1
  51. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  52. package/dist/plugin/agents/apt-executor.md +6 -0
  53. package/dist/plugin/agents/apt-planner.md +3 -2
  54. package/dist/plugin/skills/apt-close-task/SKILL.md +2 -2
  55. package/dist/plugin/skills/apt-debug/SKILL.md +8 -0
  56. package/dist/plugin/skills/apt-execute/SKILL.md +35 -26
  57. package/dist/plugin/skills/apt-mockup/SKILL.md +1 -1
  58. package/dist/plugin/skills/apt-quick/SKILL.md +13 -11
  59. package/dist/plugin/skills/apt-review/SKILL.md +14 -11
  60. package/dist/plugin/skills/apt-run/SKILL.md +9 -3
  61. package/dist/plugin/skills/apt-ship/SKILL.md +27 -5
  62. package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -0
  63. package/dist/schemas/feature-registry.d.ts.map +1 -1
  64. package/dist/schemas/feature-registry.js +34 -3
  65. package/dist/schemas/feature-registry.js.map +1 -1
  66. package/dist/types/config.d.ts +8 -5
  67. package/dist/types/config.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/skills/apt-close-task/SKILL.md +2 -2
  70. package/skills/apt-debug/SKILL.md +8 -0
  71. package/skills/apt-execute/SKILL.md +35 -26
  72. package/skills/apt-mockup/SKILL.md +1 -1
  73. package/skills/apt-quick/SKILL.md +13 -11
  74. package/skills/apt-review/SKILL.md +14 -11
  75. package/skills/apt-run/SKILL.md +9 -3
  76. package/skills/apt-ship/SKILL.md +27 -5
  77. package/skills/apt-watch-ci/SKILL.md +2 -0
  78. package/src/cli/artifacts/self-stage.mjs +2 -1
  79. package/src/cli/cli-wrappers/features-reconcile.mjs +9 -0
  80. package/src/cli/commands/features-reconcile.mjs +157 -0
  81. package/src/cli/commands/health-check.mjs +2 -2
  82. package/src/cli/dispatch.mjs +2 -2
  83. package/src/cli/features/area-shape.mjs +140 -0
  84. package/src/cli/features/delta.mjs +625 -0
  85. package/src/cli/features/reconcile.mjs +1169 -0
  86. package/src/cli/features/surface-map.mjs +192 -0
  87. package/src/cli/features/write-root.mjs +140 -0
  88. package/src/cli/gate/gates/features-reconciled.mjs +70 -0
  89. package/src/cli/gate/gates/index.mjs +1 -0
  90. package/src/cli/help.mjs +1 -1
  91. package/src/cli/release-notes/ship-autodraft.mjs +2 -2
  92. package/src/cli/variant/gallery.mjs +7 -3
  93. package/templates/aperant-claude-md-appendix.md +2 -2
  94. package/workflows/scan-features.md +17 -11
  95. package/dist/cli/cli-wrappers/features-audit.d.mts +0 -2
  96. package/dist/cli/cli-wrappers/features-audit.d.mts.map +0 -1
  97. package/dist/cli/cli-wrappers/features-audit.mjs +0 -8
  98. package/dist/cli/cli-wrappers/features-audit.mjs.map +0 -1
  99. package/dist/cli/commands/features-audit.d.mts.map +0 -1
  100. package/dist/cli/commands/features-audit.mjs +0 -293
  101. package/dist/cli/commands/features-audit.mjs.map +0 -1
  102. package/dist/cli/features/registry-audit.d.mts +0 -56
  103. package/dist/cli/features/registry-audit.d.mts.map +0 -1
  104. package/dist/cli/features/registry-audit.mjs +0 -264
  105. package/dist/cli/features/registry-audit.mjs.map +0 -1
  106. package/src/cli/cli-wrappers/features-audit.mjs +0 -8
  107. package/src/cli/commands/features-audit.mjs +0 -302
  108. package/src/cli/features/registry-audit.mjs +0 -254
@@ -0,0 +1,192 @@
1
+ /**
2
+ * surface-map.mjs — feature-surface qualification + area suggestion for the
3
+ * features-reconcile delta ledger (replaces registry-audit.mjs's
4
+ * `detectFeatureKind`/`inferArea`).
5
+ *
6
+ * Dependency-free: node:-builtins only (in fact, zero imports) — this module
7
+ * sits inside the bare-node CI entry's import closure (ID-08).
8
+ *
9
+ * THE MAPPING TABLE (ID-06) — this header IS the contract:
10
+ *
11
+ * | Path family | kind | area resolution |
12
+ * |--------------------------------------------------------------------|------------------|----------------------------------------------------|
13
+ * | packages/ui/src/sections/<a>/** /*.tsx | ui-component | alias(<a>) — deterministic |
14
+ * | packages/ui/src/{components,design-system}/** /*.tsx | ui-component | 'chrome' (existing precedent in chrome.json) |
15
+ * | packages/ui/src/hooks/use-*.ts{,x} | hook | token-match against known areas, else null |
16
+ * | apps/desktop/src/renderer/src/** /*.tsx | ui-component | token-match (features/settings/** → settings), else null |
17
+ * | apps/web/src/app/<seg>/** | ui-component | alias(<seg>) when it names a known area, else null |
18
+ * | apps/desktop/src/main/ipc-handlers/*.ts | ipc-channel | token-match (inbox-*-handlers → inbox), else null |
19
+ * | packages/backend/convex/** /*.ts | backend-function | token-match, else null |
20
+ * | packages/core/src/{services,pipeline,workflows,providers,inbox,terminal,credentials}/** /*.ts | core-service | first path segment / token-match, else null |
21
+ *
22
+ * Exclusions (always, checked BEFORE any family match): test files
23
+ * (`\.(test|spec)\.`), `__tests__/` dirs, `.stories.` files, `_generated/`
24
+ * dirs.
25
+ *
26
+ * Area rules:
27
+ * - AREA_ALIASES = { terminal: 'terminals', conductor: 'showrunner' } is
28
+ * applied at EVERY resolution site.
29
+ * - `hooks` / `ipc` / `services` are NEVER emitted as areas (the junk-drawer
30
+ * failure mode is structurally impossible) — they are filtered even if a
31
+ * caller supplies them in `knownAreas`.
32
+ * - `area: null` means "the writer agent must assign it" — `--check` and
33
+ * `--write` fail closed on a null area.
34
+ * - Token-match compares path tokens (split on `/ - _ .`) against the
35
+ * caller-supplied `knownAreas` list, alias-normalized, with a simple
36
+ * plural tolerance (`competitor` matches `competitors`).
37
+ */
38
+
39
+ export const AREA_ALIASES = Object.freeze({
40
+ terminal: 'terminals',
41
+ conductor: 'showrunner',
42
+ })
43
+
44
+ /** Pseudo-areas that must never be suggested (retired junk drawers). */
45
+ const JUNK_AREAS = new Set(['hooks', 'ipc', 'services'])
46
+
47
+ /** Apply the alias map to a raw area token. */
48
+ export function applyAreaAlias(area) {
49
+ if (typeof area !== 'string') return area
50
+ return AREA_ALIASES[area] ?? area
51
+ }
52
+
53
+ /**
54
+ * Always-on exclusions: test files, __tests__ dirs, storybook stories,
55
+ * generated code.
56
+ */
57
+ export function isExcludedPath(norm) {
58
+ if (/\.(test|spec)\./.test(norm)) return true
59
+ if (/(^|\/)__tests__\//.test(norm)) return true
60
+ if (/\.stories\./.test(norm)) return true
61
+ if (/(^|\/)_generated\//.test(norm)) return true
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * Normalize the caller-supplied known-area list: strings only, junk areas
67
+ * dropped (they are never legal suggestions even if a stale file exists).
68
+ */
69
+ function normalizeKnownAreas(knownAreas) {
70
+ const out = new Set()
71
+ for (const a of knownAreas || []) {
72
+ if (typeof a !== 'string' || !a) continue
73
+ if (JUNK_AREAS.has(a)) continue
74
+ out.add(a)
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * Token-match a set of path tokens against the known-area set.
81
+ * A token `t` matches area `a` when alias(t) === a or alias(t) + 's' === a.
82
+ * Returns the first matching area (token order), or null.
83
+ */
84
+ function tokenMatchArea(tokens, knownSet) {
85
+ for (const raw of tokens) {
86
+ if (!raw) continue
87
+ const t = applyAreaAlias(raw.toLowerCase())
88
+ if (JUNK_AREAS.has(t)) continue
89
+ if (knownSet.has(t)) return t
90
+ if (knownSet.has(`${t}s`)) return `${t}s`
91
+ }
92
+ return null
93
+ }
94
+
95
+ /**
96
+ * Split a path fragment into candidate tokens: segments, kebab/underscore/dot
97
+ * parts, and camelCase words (convex files are camelCase — roadmapItems.ts).
98
+ */
99
+ function tokenize(fragment) {
100
+ const tokens = []
101
+ for (const seg of fragment.split('/')) {
102
+ if (!seg) continue
103
+ tokens.push(seg)
104
+ for (const part of seg.split(/[-_.]/)) {
105
+ if (!part) continue
106
+ if (part !== seg) tokens.push(part)
107
+ for (const camel of part.split(/(?=[A-Z])/)) {
108
+ if (camel && camel !== part) tokens.push(camel)
109
+ }
110
+ }
111
+ }
112
+ return tokens
113
+ }
114
+
115
+ /**
116
+ * Qualify a repo-relative file path as a feature surface.
117
+ *
118
+ * @param {string} filepath — repo-relative path (posix or win separators)
119
+ * @param {string[]} [knownAreas] — existing registry area names (callers pass
120
+ * the `.aperant/features/*.json` basenames); used only for token-match
121
+ * families. Deterministic families (sections, design-system) need none.
122
+ * @returns {{ kind: string, suggestedArea: string | null } | null}
123
+ * null when the path does not qualify at all.
124
+ */
125
+ export function qualifiesAsFeatureSurface(filepath, knownAreas = []) {
126
+ if (!filepath || typeof filepath !== 'string') return null
127
+ const norm = filepath.replace(/\\/g, '/').replace(/^\.\//, '')
128
+ if (isExcludedPath(norm)) return null
129
+ const knownSet = normalizeKnownAreas(knownAreas)
130
+
131
+ // packages/ui/src/sections/<a>/**/*.tsx → ui-component, alias(<a>) deterministic
132
+ let m = /^packages\/ui\/src\/sections\/([^/]+)\/.+\.tsx$/.exec(norm)
133
+ if (m) {
134
+ const area = applyAreaAlias(m[1])
135
+ return { kind: 'ui-component', suggestedArea: JUNK_AREAS.has(area) ? null : area }
136
+ }
137
+
138
+ // packages/ui/src/{components,design-system}/**/*.tsx → ui-component, chrome
139
+ if (/^packages\/ui\/src\/(components|design-system)\/.+\.tsx$/.test(norm)) {
140
+ return { kind: 'ui-component', suggestedArea: 'chrome' }
141
+ }
142
+
143
+ // packages/ui/src/hooks/use-*.ts{,x} → hook, token-match
144
+ m = /^packages\/ui\/src\/hooks\/(use-[A-Za-z0-9-]+)\.tsx?$/.exec(norm)
145
+ if (m) {
146
+ const tokens = tokenize(m[1]).filter((t) => t !== 'use')
147
+ return { kind: 'hook', suggestedArea: tokenMatchArea(tokens, knownSet) }
148
+ }
149
+
150
+ // apps/desktop/src/renderer/src/**/*.tsx → ui-component, token-match
151
+ m = /^apps\/desktop\/src\/renderer\/src\/(.+)\.tsx$/.exec(norm)
152
+ if (m) {
153
+ return { kind: 'ui-component', suggestedArea: tokenMatchArea(tokenize(m[1]), knownSet) }
154
+ }
155
+
156
+ // apps/web/src/app/<seg>/** → ui-component, alias(<seg>) when known
157
+ m = /^apps\/web\/src\/app\/([^/]+)\/.+\.tsx?$/.exec(norm)
158
+ if (m) {
159
+ const seg = applyAreaAlias(m[1])
160
+ const area = !JUNK_AREAS.has(seg) && knownSet.has(seg) ? seg : null
161
+ return { kind: 'ui-component', suggestedArea: area }
162
+ }
163
+
164
+ // apps/desktop/src/main/ipc-handlers/*.ts → ipc-channel, token-match
165
+ m = /^apps\/desktop\/src\/main\/ipc-handlers\/([^/]+)\.ts$/.exec(norm)
166
+ if (m) {
167
+ return { kind: 'ipc-channel', suggestedArea: tokenMatchArea(tokenize(m[1]), knownSet) }
168
+ }
169
+
170
+ // packages/backend/convex/**/*.ts → backend-function, token-match
171
+ m = /^packages\/backend\/convex\/(.+)\.ts$/.exec(norm)
172
+ if (m) {
173
+ return { kind: 'backend-function', suggestedArea: tokenMatchArea(tokenize(m[1]), knownSet) }
174
+ }
175
+
176
+ // packages/core/src/{services,pipeline,workflows,providers,inbox,terminal,credentials}/**/*.ts
177
+ // → core-service; first path segment / token-match.
178
+ m =
179
+ /^packages\/core\/src\/(services|pipeline|workflows|providers|inbox|terminal|credentials)\/(.+)\.ts$/.exec(
180
+ norm,
181
+ )
182
+ if (m) {
183
+ // The family segment itself is the first candidate token (pipeline →
184
+ // pipeline, terminal → terminals via alias, inbox → inbox); junk
185
+ // segments (services) and non-area segments (workflows, providers,
186
+ // credentials) fall through to token-match on the rest of the path.
187
+ const tokens = [m[1], ...tokenize(m[2])]
188
+ return { kind: 'core-service', suggestedArea: tokenMatchArea(tokens, knownSet) }
189
+ }
190
+
191
+ return null
192
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * write-root.mjs — BUG-025b worktree write-redirect resolution (ID-11).
3
+ *
4
+ * Moved verbatim from the retired legacy audit command so the mutating
5
+ * features-reconcile verbs (--draft / --write) keep the exact 4-layer
6
+ * behaviour: registry writes historically leaked generated `*.json` into main
7
+ * when the executor invoked the CLI from a worktree-isolated task. The fix
8
+ * applies a 4-step resolution order (see resolveWriteRoot below):
9
+ * (1) cwd IS a worktree root → write there (current behaviour)
10
+ * (2) --task-id maps to an active non-terminal worktree task →
11
+ * redirect writes to that worktree's `.aperant/features/`
12
+ * (3) multiple active worktree-isolated tasks + no --task-id → error
13
+ * (4) `--no-worktree-redirect` opt-out preserves layer (1) behaviour
14
+ */
15
+
16
+ import { spawnSync } from 'node:child_process'
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { join, resolve, sep } from 'node:path'
19
+ import { resolveMainRepoRoot } from '../util/state-paths.mjs'
20
+
21
+ const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged'])
22
+
23
+ /**
24
+ * BUG-025b — resolve where the mutating features-reconcile verbs should write.
25
+ * See module header for the 4-step order. Returns `{ writeRoot, redirect_notice }`
26
+ * on success or a synthetic error envelope (`{ error: { ... } }`) on the
27
+ * ambiguous-worktree case.
28
+ *
29
+ * @internal — exported for unit testing.
30
+ *
31
+ * @param {{projectDir: string, taskId?: string | null, noRedirect?: boolean}} args
32
+ * @returns {{writeRoot?: string, redirect_notice?: string | null, error?: {kind: string, message: string, active_worktree_tasks: string[]}}}
33
+ */
34
+ export function resolveWriteRoot({ projectDir, taskId, noRedirect }) {
35
+ const root = resolve(projectDir)
36
+ if (noRedirect === true) {
37
+ return { writeRoot: root, redirect_notice: null }
38
+ }
39
+ // Layer (1): cwd IS a worktree root. Worktrees have `git rev-parse
40
+ // --git-dir` != `git rev-parse --git-common-dir`. Falling through to
41
+ // the existing behaviour writes to this worktree's .aperant/features.
42
+ // Fast path: git sets GIT_DIR and GIT_COMMON_DIR in the environment
43
+ // when running inside a worktree hook context — if both are set and
44
+ // differ, we are in a worktree. This env-var check is ONLY valid at
45
+ // Layer (1) where we ask "is this process in a worktree?"; it must
46
+ // NOT be used to validate an untrusted external path (Layer 2 below).
47
+ const envDir = process.env.GIT_DIR
48
+ const envCommonDir = process.env.GIT_COMMON_DIR
49
+ if (envDir && envCommonDir) {
50
+ if (resolve(envDir) !== resolve(envCommonDir)) {
51
+ return { writeRoot: root, redirect_notice: null }
52
+ }
53
+ } else if (isWorktreeRoot(root)) {
54
+ return { writeRoot: root, redirect_notice: null }
55
+ }
56
+ // Layer (2): --task-id maps to an active non-terminal worktree-isolated
57
+ // task → redirect.
58
+ const stateActive = readActiveTasks(root)
59
+ if (taskId) {
60
+ const task = stateActive[taskId]
61
+ if (
62
+ task &&
63
+ !TERMINAL_PHASES.has(task.lifecycle_phase) &&
64
+ typeof task.worktree_path === 'string' &&
65
+ existsSync(task.worktree_path)
66
+ ) {
67
+ const resolvedWorktree = resolve(task.worktree_path)
68
+ const worktreesDir = resolve(root, '.aperant', 'worktrees') + sep
69
+ const isUnderProject = resolvedWorktree.startsWith(root + sep) || resolvedWorktree === root
70
+ const isUnderWorktrees = resolvedWorktree.startsWith(worktreesDir)
71
+ // Accept only paths under the project root, under .aperant/worktrees/,
72
+ // or confirmed as a real git worktree by git itself. Rejects arbitrary
73
+ // paths injected via a tampered state.json while preserving test
74
+ // fixtures that use ad-hoc worktree paths outside the project tree.
75
+ if (!isUnderProject && !isUnderWorktrees && !isWorktreeRoot(resolvedWorktree)) {
76
+ return { writeRoot: root, redirect_notice: null }
77
+ }
78
+ return {
79
+ writeRoot: resolvedWorktree,
80
+ redirect_notice: `features-reconcile redirected writes to worktree ${resolvedWorktree} (--task-id ${taskId})`,
81
+ }
82
+ }
83
+ }
84
+ // Layer (3): multiple active worktree-isolated tasks AND no --task-id
85
+ // → actionable error. Single-worktree case is OK to default to root
86
+ // (the executor will set up the worktree's registry on first scan).
87
+ const activeWorktreeTasks = Object.entries(stateActive).filter(([_id, t]) => {
88
+ return (
89
+ t &&
90
+ !TERMINAL_PHASES.has(t.lifecycle_phase) &&
91
+ typeof t.worktree_path === 'string' &&
92
+ existsSync(t.worktree_path)
93
+ )
94
+ })
95
+ if (!taskId && activeWorktreeTasks.length > 1) {
96
+ return {
97
+ error: {
98
+ kind: 'ambiguous-worktree-context',
99
+ message: 'ambiguous worktree context; pass --task-id <id> or --no-worktree-redirect',
100
+ active_worktree_tasks: activeWorktreeTasks.map(([id]) => id),
101
+ },
102
+ }
103
+ }
104
+ // Default: current behaviour (write into cwd).
105
+ return { writeRoot: root, redirect_notice: null }
106
+ }
107
+
108
+ export function isWorktreeRoot(projectDir) {
109
+ try {
110
+ const dirRes = spawnSync('git', ['rev-parse', '--git-dir'], {
111
+ cwd: projectDir,
112
+ encoding: 'utf-8',
113
+ stdio: ['ignore', 'pipe', 'ignore'],
114
+ })
115
+ const commonRes = spawnSync('git', ['rev-parse', '--git-common-dir'], {
116
+ cwd: projectDir,
117
+ encoding: 'utf-8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ })
120
+ if (dirRes.status !== 0 || commonRes.status !== 0) return false
121
+ return (dirRes.stdout || '').trim() !== (commonRes.stdout || '').trim()
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ export function readActiveTasks(projectDir) {
128
+ // state-fork-fix: read the AUTHORITATIVE main board — readActiveTasks finds
129
+ // each task's worktree_path for the BUG-025b feature-write redirect, so it
130
+ // MUST read main or the redirect-target lookup itself forks. (The feature
131
+ // WRITE path / resolveWriteRoot stays untouched.)
132
+ const statePath = join(resolveMainRepoRoot(projectDir), '.aperant', 'state.json')
133
+ if (!existsSync(statePath)) return {}
134
+ try {
135
+ const parsed = JSON.parse(readFileSync(statePath, 'utf-8'))
136
+ return parsed?.active_tasks ?? {}
137
+ } catch {
138
+ return {}
139
+ }
140
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @internal
3
+ * features-reconciled — blocking gate for `apt:ship` (ID-10).
4
+ *
5
+ * Runs the features-reconcile 4-clause checker IN-PROCESS (no shell-out)
6
+ * against the task's worktree (when present), verifying the write-then-verify
7
+ * ordering: apt-ship §0.0 materializes pending deltas BEFORE the §0 gate
8
+ * check reaches this gate.
9
+ *
10
+ * Deliberately has NO applies_to_profile — unlike verify-approved /
11
+ * review-clean, this gate runs under BOTH profiles so QUICK/DEBUG-shipped PRs
12
+ * are covered too (per gate/registry.mjs, omitting applies_to_profile means
13
+ * the gate applies universally).
14
+ *
15
+ * Deliberately returns NO inputs[] — the deltas/receipts/area dir contents
16
+ * are dynamic and the check is cheap, so the verdict is never cached.
17
+ */
18
+
19
+ import { existsSync } from 'node:fs'
20
+ import { checkReconciled, draftRepairCommand } from '../../features/reconcile.mjs'
21
+ import { registerGate } from '../registry.mjs'
22
+
23
+ const gate = {
24
+ id: 'features-reconciled',
25
+ description: 'feature-registry deltas reconciled (coverage + schema + integrity + no orphans)',
26
+ severity: 'blocking',
27
+ skills: ['ship'],
28
+ async check(ctx) {
29
+ const worktree = ctx.taskRecord?.worktree_path
30
+ const checkRoot =
31
+ typeof worktree === 'string' && worktree && existsSync(worktree) ? worktree : ctx.sharedRoot
32
+ const base = ctx.taskRecord?.base_branch ?? 'main'
33
+ let result
34
+ try {
35
+ result = checkReconciled({ root: checkRoot, base, taskId: ctx.taskId })
36
+ } catch (e) {
37
+ return {
38
+ status: 'fail',
39
+ reason: `features-reconcile check threw: ${e.message}`,
40
+ evidence: { checkRoot, base },
41
+ }
42
+ }
43
+ if (result.status === 'skip') {
44
+ return {
45
+ status: 'skip',
46
+ reason: `features-reconcile skipped: ${result.reason} (docs_policy opt-out)`,
47
+ evidence: { checkRoot, policy: result.policy ?? null },
48
+ }
49
+ }
50
+ if (result.status === 'ok') {
51
+ return {
52
+ status: 'pass',
53
+ reason: `feature registry reconciled (${result.qualifying_files?.length ?? 0} qualifying file(s) covered; schema valid)`,
54
+ evidence: { checkRoot, base: result.base },
55
+ }
56
+ }
57
+ const findings = result.findings ?? []
58
+ return {
59
+ status: 'fail',
60
+ reason:
61
+ `feature registry NOT reconciled — ${findings.length} finding(s). ` +
62
+ `Repair: ${draftRepairCommand(ctx.taskId)} (fill dispositions), then ` +
63
+ `node packages/framework/bin/apt-tools.mjs features-reconcile . --write --task-id ${ctx.taskId}`,
64
+ evidence: { checkRoot, base: result.base, findings },
65
+ }
66
+ },
67
+ }
68
+
69
+ registerGate(gate)
70
+ export default gate
@@ -18,6 +18,7 @@ import './verify-approved.mjs'
18
18
  import './review-clean.mjs'
19
19
  import './followups-adjudicated.mjs'
20
20
  import './gitignore-in-sync.mjs'
21
+ import './features-reconciled.mjs'
21
22
  import './consistency-check.mjs'
22
23
  import './tdd-iron-law.mjs'
23
24
  import './r-persona-alignment.mjs'
package/src/cli/help.mjs CHANGED
@@ -38,7 +38,7 @@ Commands:
38
38
  task close-merged <dir> [--task <id>] [--narrate-only] [--dry-run] Sweep shipped-pending-merge tasks for confirmed merges (--narrate-only returns pending_narration[] entries read-only without closing).
39
39
  task narration-drain <dir> --task <task-id> Remove a row from state.pending_narration[] under withFileLock (called by /apt:close-task --narrate-only after a successful narrator spawn)
40
40
  task complete-subtask <dir> --task-id <id> --subtask-id <sid|a+b+c> [--commit <sha>] [--notes <text>] Atomic plan ↔ build-progress sync
41
- features-audit <dir> [--task-id <id>] [--no-worktree-redirect] [--diff-files <csv>|--since <ref>] [--apply-stubs] [--format json|md] Audit features registry for unregistered feature-qualifying files (BUG-025b: --task-id redirects writes to active task's worktree)
41
+ features-reconcile <dir> (--draft|--write|--check|--gc) [--task-id <id>] [--subtask-id <id>] [--base <ref>] [--dry-run] [--warn-only] [--max-age-days <n>] [--no-worktree-redirect] Feature-registry delta ledger: --draft scaffolds a per-writer delta from the base..HEAD diff, --write materializes deltas into area JSONs + a receipt, --check verifies coverage/schema/integrity/orphans, --gc prunes merged receipts (BUG-025b: --task-id redirects writes to active task's worktree)
42
42
  docs-audit <dir> [--diff-files <csv>|--since <ref>|--dry-run] [--policy <path>] [--format json|md] Audit documented surfaces for docs-drift
43
43
 
44
44
  lock claim <dir> --subtask <id> Claim exclusive lock on a subtask
@@ -6,8 +6,8 @@
6
6
  * should grow a `Release note: …` line OR ship should block.
7
7
  *
8
8
  * This module is intentionally pure — the apt-ship SKILL.md body shells
9
- * `apt-tools features-audit . --diff-since <base> --json` and feeds the
10
- * envelope here. Keeping the decision pure makes TD-4 trivial: fixture in,
9
+ * `apt-tools features-reconcile . --draft --dry-run --base "$base"` and feeds
10
+ * the envelope here. Keeping the decision pure makes TD-4 trivial: fixture in,
11
11
  * decision out, no I/O.
12
12
  */
13
13
 
@@ -12,6 +12,10 @@
12
12
  * no privilege for that). Picking happens by running a terminal
13
13
  * command — the gallery's job is to make the letter obvious and the
14
14
  * command copy-pasteable.
15
+ * - Each card links to its variant file via a plain relative anchor
16
+ * with target="_blank", so the user can open any variant full-size
17
+ * in a new tab straight from the gallery (relative hrefs resolve
18
+ * against the gallery's own file:// URL — no server needed).
15
19
  * - Responsive grid: 1 column on narrow, 2 on medium, N on wide.
16
20
  * - Each variant iframe is rendered at full device-width-feel, not a
17
21
  * thumbnail, so the user can actually read the content.
@@ -80,11 +84,11 @@ export function renderGallery(input) {
80
84
  ` <div class="truncate text-xs text-slate-400">${description}</div>`,
81
85
  ' </div>',
82
86
  ' </div>',
83
- ` <code class="shrink-0 rounded bg-slate-800 px-2 py-1 text-xs text-slate-300">${filename}</code>`,
87
+ ` <a href="${filename}" target="_blank" rel="noopener" title="Open variant ${letter} in a new tab" class="shrink-0 rounded bg-slate-800 px-2 py-1 text-xs text-slate-300 hover:bg-slate-700 hover:text-slate-100"><code>${filename}</code> ↗</a>`,
84
88
  ' </header>',
85
89
  ` <iframe src="${filename}" title="Variant ${letter}" loading="lazy" class="block h-[720px] w-full bg-white"></iframe>`,
86
90
  ' <footer class="flex items-center justify-between gap-3 border-t border-slate-800 px-4 py-3">',
87
- ` <div class="text-xs text-slate-500">Pick this one:</div>`,
91
+ ` <a href="${filename}" target="_blank" rel="noopener" class="text-xs font-medium ${ACCENT} hover:underline">Open full size ↗</a>`,
88
92
  ` <code class="rounded bg-slate-800 px-2 py-1 text-xs text-slate-100">/apt:mockup --pick ${letter.toLowerCase()}</code>`,
89
93
  ' </footer>',
90
94
  ' </article>',
@@ -112,7 +116,7 @@ export function renderGallery(input) {
112
116
  ' <div>',
113
117
  ` <div class="text-xs uppercase tracking-[0.2em] text-slate-500">Variant gallery · iteration ${iterationEsc}</div>`,
114
118
  ` <h1 class="mt-1 text-2xl font-semibold text-slate-100">${featureEsc}</h1>`,
115
- ` <p class="mt-1 text-sm text-slate-400">${variants.length} parallel generations. Open this page via <code>file://</code>, scan the iframes, pick the letter that lands.</p>`,
119
+ ` <p class="mt-1 text-sm text-slate-400">${variants.length} parallel generations. Open this page via <code>file://</code>, scan the iframes, click “Open full size” to view any variant in its own tab, then pick the letter that lands.</p>`,
116
120
  ' </div>',
117
121
  ' <div class="flex flex-col gap-2 text-xs text-slate-400 sm:items-end">',
118
122
  ` <div>Directory: <code class="text-slate-300">${taskDirEsc}/mockups/</code></div>`,
@@ -23,9 +23,9 @@ The feature registry (`.aperant/features/`) is a living inventory of every inter
23
23
  feature in the app. It's the source of truth for what to verify and review.
24
24
 
25
25
  - **Created by:** `/apt:scan` (initial scan or full refresh)
26
- - **Updated by:** `/apt:execute` post-step (`apt-tools features-audit --apply-stubs` auto-registers new UI/core/hook/IPC files per the docs-coherence policy)
26
+ - **Updated by:** per-writer DELTAS committed with the work (`apt-tools features-reconcile --draft` at the executor/quick/debug commit step), materialized into the area JSONs + a durable receipt by `/apt:ship` §0.0 (`--write`)
27
27
  - **Consumed by:** `/apt:verify-proof` (generates test cases from registry)
28
- - **Checked by:** `/apt:review` Pass 6 (flags unregistered new components, applies stubs for auto-update policies)
28
+ - **Checked by:** the blocking `features-reconciled` ship gate, `/apt:review` Pass 6 (`features-reconcile --check`), and the always-on `features-reconcile` CI check on every PR
29
29
 
30
30
  Run `/apt:scan` after setting up Aperant on a brownfield project, or after major changes.
31
31
 
@@ -171,7 +171,7 @@ After all agents complete (or at the end of each wave), verify each expected out
171
171
  if (!result.success) { /* re-launch with error message */ }
172
172
  ```
173
173
  4. For orchestrators running in pure-markdown context (no TS runtime), perform a **lightweight structural check**: parse the JSON and verify the top-level keys include `area`, `route`, `components`, `last_scanned`, `features`, and that `features` is an array where each entry has `id`, `name`, `component`, `sub_features`. Flag deviations in the output report.
174
- 5. On validation failure, the orchestrator may: (a) re-launch the agent with the validation error as feedback, OR (b) mark the file as non-conforming and note it in REGISTRY.md. Prefer (a) up to 2 retries, then fall back to (b).
174
+ 5. On validation failure, re-launch the agent with the validation error as feedback, up to 2 retries. After 2 failed retries the orchestrator MUST mark the file as **must-fix** in REGISTRY.md (the `## Non-conforming files (must fix)` section) AND report it as a failure line in the final scan output — a non-conforming file is NEVER silently accepted (`features-reconcile --check` clause (b) will fail red on it until fixed).
175
175
  </step>
176
176
 
177
177
  <step name="synthesize_registry">
@@ -283,16 +283,22 @@ Next: /apt:verify-proof {area} — verify features with visual evidence
283
283
 
284
284
  ### How other skills update the registry
285
285
 
286
- **`/apt:execute` post-step:**
287
- After completing a task that adds or modifies UI features, the executor should:
288
- 1. Check if `.aperant/features/REGISTRY.md` exists
289
- 2. If yes: read the relevant `{area}.json` file
290
- 3. Add new entries for any features that were created
291
- 4. Update `wired` status for any features that were connected
292
- 5. Update `last_scanned` timestamp
293
-
294
- This is a lightweight update — NOT a full rescan. The executor reads its own diff
295
- to know what changed.
286
+ **`/apt:execute` post-step (delta-emit contract):**
287
+ After each subtask commit, the executor emits a per-writer registry DELTA
288
+ instead of editing area JSONs directly:
289
+ 1. `apt-tools features-reconcile . --draft --task-id <id> --subtask-id <sid>`
290
+ scaffolds `.aperant/features/deltas/<id>-<sid>.json` from the diff.
291
+ 2. The executor enriches the scaffold in-window: fills dispositions
292
+ (`upsert` / `remove` / `wiring-transition` / `no-semantic-change` + reason),
293
+ assigns any `area: null`, and writes real `sub_features` with
294
+ `wired`/`handler`/`test_hint`.
295
+ 3. The delta is committed with the work; `/apt:ship` §0.0 materializes all
296
+ pending deltas into the area JSONs + a durable receipt (`--write`), and the
297
+ `features-reconciled` ship gate + the always-on CI check verify coverage.
298
+
299
+ This is a lightweight capture — NOT a full rescan. The executor reads its own
300
+ diff to know what changed; area JSONs are only ever written by the
301
+ materializer (or this full-scan workflow).
296
302
 
297
303
  **`/apt:verify-proof` pre-step:**
298
304
  Before generating tests, verify-proof should:
@@ -1,2 +0,0 @@
1
- export { cmdFeaturesAudit as cliFeaturesAudit } from "../commands/features-audit.mjs";
2
- //# sourceMappingURL=features-audit.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"features-audit.d.mts","sourceRoot":"","sources":["../../../src/cli/cli-wrappers/features-audit.mjs"],"names":[],"mappings":""}
@@ -1,8 +0,0 @@
1
- /**
2
- * cli-wrappers/features-audit.mjs — Layer 2 wrapper for the features-audit cmd.
3
- *
4
- * Today this is a passthrough — DISPATCH calls wrap() directly.
5
- * Re-exporting keeps the file-per-cmd structural seam.
6
- */
7
- export { cmdFeaturesAudit as cliFeaturesAudit } from '../commands/features-audit.mjs';
8
- //# sourceMappingURL=features-audit.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"features-audit.mjs","sourceRoot":"","sources":["../../../src/cli/cli-wrappers/features-audit.mjs"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,IAAI,gBAAgB,EAAE,MAAM,gCAAgC,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"features-audit.d.mts","sourceRoot":"","sources":["../../../src/cli/commands/features-audit.mjs"],"names":[],"mappings":"AAoCA;;;;;;;;;;GAUG;AACH,qEAHW;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAC,GAChE;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,qBAAqB,EAAE,MAAM,EAAE,CAAA;KAAC,CAAA;CAAC,CA0E3I;AAoCD,8GAqFC"}