wizz-method 2.1.1 → 2.2.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 (22) hide show
  1. package/package.json +1 -1
  2. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +2 -2
  3. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +102 -8
  4. package/src/bmm-skills/4-implementation/wizz-code-review/references/review-economy.md +75 -0
  5. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +29 -15
  6. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +13 -10
  7. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +11 -5
  8. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +4 -0
  9. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +103 -7
  10. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/review-economy.md +75 -0
  11. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +43 -18
  12. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-05-present.md +4 -0
  13. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-oneshot.md +2 -2
  14. package/src/modules/wizz/_shared/model-ladder.md +1 -0
  15. package/src/modules/wizz/_shared/token-economy.md +1 -1
  16. package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +2 -1
  17. package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +2 -1
  18. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -1
  19. package/src/modules/wizz/subagents/spec.yaml +2 -1
  20. package/src/modules/wizz/subagents/wizz-exec-review.md +2 -1
  21. package/tools/installer/core/resolved-customizations.js +58 -10
  22. package/tools/installer/core/resolved-schema.js +116 -18
@@ -78,11 +78,35 @@ function detectKeyedMergeField(items) {
78
78
  return null;
79
79
  }
80
80
 
81
+ /**
82
+ * Warn when a team/user override REPLACES a base review layer (matched by
83
+ * `id`/`code`) but omits `tiers` or `max_calls` the base layer had.
84
+ * `mergeByKey` replaces the whole matched item — it never merges an
85
+ * override item field-by-field against its base counterpart (Python
86
+ * parity) — so dropping those fields silently loses the base layer's
87
+ * scaling: the layer now runs on every tier and/or with no call cap.
88
+ * No-op when `opts.warnings`/`opts.layerLabel` are not supplied, so callers
89
+ * that don't care about this (e.g. direct `deepMerge` calls in tests) pay
90
+ * nothing for it.
91
+ */
92
+ function warnOverrideDropsLayerScaling({ warnings, layerLabel, key, matchedBase, item }) {
93
+ if (!warnings || !layerLabel) return;
94
+ const missing = ['tiers', 'max_calls'].filter((field) => hasOwn(matchedBase, field) && !hasOwn(item, field));
95
+ if (missing.length === 0) return;
96
+ const consequence = missing.map((field) => (field === 'tiers' ? 'every tier' : 'uncapped')).join(' and ');
97
+ warnings.push(
98
+ `${layerLabel} override: "${key}" replaces the whole base layer and omits ${missing.join(' and ')} — it now runs on ${consequence}`,
99
+ );
100
+ }
101
+
81
102
  /**
82
103
  * Keyed merge: matching identifiers replace in place, new ones append.
83
104
  * Non-table items in the override are appended (Python parity).
105
+ * @param {object} [opts] - `{ warnings, layerLabel }` to collect the
106
+ * dropped-scaling warning above; omit for warning-free merges (tests,
107
+ * internal recomputation).
84
108
  */
85
- function mergeByKey(base, override, keyName) {
109
+ function mergeByKey(base, override, keyName, opts) {
86
110
  const result = [];
87
111
  const indexByKey = new Map();
88
112
  for (const item of base) {
@@ -99,7 +123,9 @@ function mergeByKey(base, override, keyName) {
99
123
  }
100
124
  const key = item[keyName];
101
125
  if (key !== undefined && key !== null && indexByKey.has(key)) {
102
- result[indexByKey.get(key)] = { ...item };
126
+ const matchedIndex = indexByKey.get(key);
127
+ if (opts) warnOverrideDropsLayerScaling({ ...opts, key, matchedBase: result[matchedIndex], item });
128
+ result[matchedIndex] = { ...item };
103
129
  } else {
104
130
  if (key !== undefined && key !== null) indexByKey.set(key, result.length);
105
131
  result.push({ ...item });
@@ -112,30 +138,33 @@ function mergeByKey(base, override, keyName) {
112
138
  * Shape-aware array merge: keyed merge when every item (base + override)
113
139
  * shares `code` or `id`, else append.
114
140
  */
115
- function mergeArrays(base, override) {
141
+ function mergeArrays(base, override, opts) {
116
142
  const baseArr = Array.isArray(base) ? base : [];
117
143
  const overrideArr = Array.isArray(override) ? override : [];
118
144
  const keyedField = detectKeyedMergeField([...baseArr, ...overrideArr]);
119
- if (keyedField) return mergeByKey(baseArr, overrideArr, keyedField);
145
+ if (keyedField) return mergeByKey(baseArr, overrideArr, keyedField, opts);
120
146
  return [...baseArr, ...overrideArr];
121
147
  }
122
148
 
123
149
  /**
124
150
  * Structural deep merge. Tables deep-merge, arrays shape-merge,
125
151
  * everything else: override wins. Python `deep_merge` parity.
152
+ * @param {object} [opts] - `{ warnings, layerLabel }`, threaded down to
153
+ * every `mergeByKey` call reached while walking `base`/`override`; see
154
+ * `warnOverrideDropsLayerScaling`.
126
155
  */
127
- function deepMerge(base, override) {
156
+ function deepMerge(base, override, opts) {
128
157
  if (isPlainObject(base) && isPlainObject(override)) {
129
158
  const result = { ...base };
130
159
  for (const [key, overVal] of Object.entries(override)) {
131
160
  // hasOwn, not `in`: `'constructor' in result` is true via the
132
161
  // prototype chain even when the base layer never declared it, which
133
162
  // would deep-merge the override into Object's constructor function.
134
- safeSet(result, key, hasOwn(result, key) ? deepMerge(result[key], overVal) : overVal);
163
+ safeSet(result, key, hasOwn(result, key) ? deepMerge(result[key], overVal, opts) : overVal);
135
164
  }
136
165
  return result;
137
166
  }
138
- if (Array.isArray(base) && Array.isArray(override)) return mergeArrays(base, override);
167
+ if (Array.isArray(base) && Array.isArray(override)) return mergeArrays(base, override, opts);
139
168
  return override;
140
169
  }
141
170
 
@@ -455,16 +484,35 @@ async function resolveSkillSnapshot({ customizePath, customDir, resolvedDir, pro
455
484
  // layer is exempt (it owns the LLM-only fields and new experimental
456
485
  // keys); a typo here is exactly the silent-merge the audit flagged.
457
486
  // Warnings, never errors — the snapshot is still written (fail-open).
487
+ // findUnknownKeys() returns structured { path, kind, detail? } entries:
488
+ // `kind` says "unknown key" vs "invalid value" directly, so this loop
489
+ // never has to sniff the rendered path text (a quoted TOML key can itself
490
+ // contain a space, which broke the old `includes(' ')` heuristic).
458
491
  for (const [layerName, layer] of [
459
492
  ['team', teamRaw],
460
493
  ['user', userRaw],
461
494
  ]) {
462
- for (const unknown of findUnknownKeys(layer)) {
463
- warnings.push(`${layerName} override: unknown key "${unknown}" (not a recognized workflow field — typo? check customize.toml docs)`);
495
+ for (const { path: keyPath, kind, detail } of findUnknownKeys(layer)) {
496
+ warnings.push(
497
+ kind === 'invalid'
498
+ ? `${layerName} override: invalid ${keyPath}${detail ? ` (${detail})` : ''} — check customize.toml docs`
499
+ : `${layerName} override: unknown key "${keyPath}" (not a recognized workflow field — typo? check customize.toml docs)`,
500
+ );
464
501
  }
465
502
  }
466
503
 
467
- const merged = deepMerge(deepMerge(defaults, team), user);
504
+ // Audit item 24 follow-up: a team/user override that REPLACES a base
505
+ // review layer (matched by id/code) but drops the base's `tiers`/
506
+ // `max_calls` silently loses that scaling — see
507
+ // warnOverrideDropsLayerScaling. Two separate deepMerge calls (rather
508
+ // than the previous deepMerge(deepMerge(defaults, team), user)) so each
509
+ // one can be labeled with the layer that is doing the overriding; a user
510
+ // override is checked against the team-adjusted result, which is the
511
+ // layer it actually replaces.
512
+ const overrideWarnings = [];
513
+ const afterTeam = deepMerge(defaults, team, { warnings: overrideWarnings, layerLabel: 'team' });
514
+ const merged = deepMerge(afterTeam, user, { warnings: overrideWarnings, layerLabel: 'user' });
515
+ warnings.push(...overrideWarnings);
468
516
  const body = `${JSON.stringify(merged, null, 2)}\n`;
469
517
  // Atomic write via fs-native (tmp + rename), consistent with every other
470
518
  // config file the installer writes.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Extracted from `resolved-customizations.js` (2026-09-11) to keep that file
5
5
  * under the 800-line cap. Validation only: nothing here writes, parses or
6
- * merges — it takes ONE parsed layer and reports the keys no code consumes.
6
+ * merges — it takes ONE parsed layer and reports the keys/values no code
7
+ * consumes or that are structurally invalid.
7
8
  *
8
9
  * "additionalProperties: false" in spirit. The BASE layer (shipped,
9
10
  * code-reviewed) is exempt: it may legitimately carry LLM-only fields
@@ -14,6 +15,12 @@
14
15
  * is a warning (not an error: fail-open, the snapshot is still written).
15
16
  * `include` is the documented extension point and is validated by
16
17
  * `resolveIncludes`.
18
+ *
19
+ * Findings are structured `{ path, kind: 'unknown' | 'invalid', detail? }`
20
+ * entries — never plain strings. `kind` is how a caller tells "this key
21
+ * doesn't exist" from "this key exists but its value is wrong"; sniffing the
22
+ * rendered path text (e.g. for a space) breaks the moment a dotted path or a
23
+ * quoted TOML key legitimately contains one.
17
24
  */
18
25
 
19
26
  const { isPlainObject } = require('./resolved-includes');
@@ -72,31 +79,56 @@ const WORKFLOW_KEYS = new Set([
72
79
  // item keys are validated against REVIEW_LAYER_KEYS below.
73
80
  'review_layers',
74
81
  'oneshot_review_layers',
82
+ // review economy (review-economy.md): diff cleaning excludes/critical paths.
83
+ 'review_exclude',
84
+ 'review_critical_paths',
75
85
  ]);
76
86
  const PARTY_MEMBER_KEYS = new Set(['code', 'name', 'icon', 'title', 'persona', 'capabilities', 'model']);
77
87
  const PARTY_GROUP_KEYS = new Set(['id', 'name', 'scene', 'members', 'memory']);
78
- // Exactly the keys the 8 shipped `[[workflow.*review_layers]]` entries use
79
- // (src/bmm-skills/4-implementation/wizz-code-review + wizz-quick-dev).
80
- const REVIEW_LAYER_KEYS = new Set(['id', 'name', 'instruction', 'when']);
88
+ // Exactly the keys the shipped `[[workflow.*review_layers]]` entries use
89
+ // (src/bmm-skills/4-implementation/wizz-code-review + wizz-quick-dev), plus
90
+ // `tiers`/`max_calls` for the review-economy tier scaling (review-economy.md).
91
+ const REVIEW_LAYER_KEYS = new Set(['id', 'name', 'instruction', 'when', 'tiers', 'max_calls']);
92
+ // The only valid values for a review_layers/oneshot_review_layers `tiers`
93
+ // entry (review-economy.md tier scaling). A typo (`"crtical"`) used to load
94
+ // silently and drop the layer on every tier — nothing ever matched it.
95
+ const VALID_REVIEW_TIERS = new Set(['small', 'normal', 'critical']);
81
96
  // The only top-level keys any of the 47 shipped customize.toml carry. A
82
97
  // top-level typo (`[workflw]`) or a stray scalar merged into silence before.
83
98
  const TOP_LEVEL_KEYS = new Set(['workflow', 'agent', 'include']);
84
99
 
100
+ /** `value` is an array whose every element is a string. */
101
+ function isArrayOfStrings(value) {
102
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
103
+ }
104
+
85
105
  /**
86
- * Collect unknown keys under `[workflow]`: unknown fields by name, and for
87
- * the arrays-of-tables we own (party members/groups, review layers) the
88
- * unknown keys of each item.
89
- * @returns {string[]} dotted paths
106
+ * Collect unknown/invalid entries under `[workflow]`: unknown fields by
107
+ * name, invalid values for the two review-economy string-array fields, and
108
+ * — for the arrays-of-tables we own (party members/groups, review layers) —
109
+ * the unknown keys and invalid values of each item.
110
+ *
111
+ * Every array-of-tables item is validated on its own, even when the array
112
+ * also holds non-object elements: a non-object item is flagged (invalid)
113
+ * instead of skipping validation for the whole array, which used to let a
114
+ * stray non-table entry hide typos in every OTHER entry.
115
+ * @returns {Array<{path: string, kind: 'unknown'|'invalid', detail?: string}>}
90
116
  */
91
117
  function findUnknownWorkflowKeys(workflow) {
92
118
  const unknown = [];
93
119
  for (const [k2, v2] of Object.entries(workflow)) {
94
120
  const wfPath = `workflow.${k2}`;
95
121
  if (!WORKFLOW_KEYS.has(k2)) {
96
- unknown.push(wfPath);
122
+ unknown.push({ path: wfPath, kind: 'unknown' });
97
123
  continue;
98
124
  }
99
- if (!Array.isArray(v2) || v2.length === 0 || !v2.every((it) => isPlainObject(it))) continue;
125
+ if (k2 === 'review_exclude' || k2 === 'review_critical_paths') {
126
+ if (!isArrayOfStrings(v2)) {
127
+ unknown.push({ path: wfPath, kind: 'invalid', detail: `expected an array of strings, got ${JSON.stringify(v2)}` });
128
+ }
129
+ continue;
130
+ }
131
+ if (!Array.isArray(v2) || v2.length === 0) continue;
100
132
  const allowed =
101
133
  k2 === 'party_members'
102
134
  ? PARTY_MEMBER_KEYS
@@ -106,30 +138,93 @@ function findUnknownWorkflowKeys(workflow) {
106
138
  ? REVIEW_LAYER_KEYS
107
139
  : null;
108
140
  if (!allowed) continue;
141
+ const isOneshot = k2 === 'oneshot_review_layers';
109
142
  for (const item of v2) {
143
+ if (!isPlainObject(item)) {
144
+ unknown.push({ path: `${wfPath}[]`, kind: 'invalid', detail: `expected a table, got ${JSON.stringify(item)}` });
145
+ continue;
146
+ }
110
147
  for (const k3 of Object.keys(item)) {
111
- if (!allowed.has(k3)) unknown.push(`${wfPath}[].${k3}`);
148
+ if (!allowed.has(k3)) unknown.push({ path: `${wfPath}[].${k3}`, kind: 'unknown' });
112
149
  }
150
+ if (allowed === REVIEW_LAYER_KEYS) unknown.push(...findInvalidReviewLayerValues(item, wfPath, isOneshot));
113
151
  }
114
152
  }
115
153
  return unknown;
116
154
  }
117
155
 
118
156
  /**
119
- * Walk a loaded TOML layer and collect unknown keys: unknown TOP-level
120
- * tables/scalars (anything outside `workflow`, `agent`, `include`) plus the
121
- * `[workflow]` fields and array-of-table item keys. `[agent]` is prose the
122
- * LLM reads, not a code-consumed schema, so its keys are open by design;
123
- * `include` is validated by resolveIncludes.
157
+ * Validate the VALUES of a review_layers/oneshot_review_layers item (item
158
+ * keys themselves are already checked by the caller against
159
+ * REVIEW_LAYER_KEYS). A typo in `tiers` (`"crtical"`), an empty `tiers`
160
+ * array, a non-positive `max_calls`, or a `tiers` entry on an
161
+ * `oneshot_review_layers` item (that step ignores tiers — it always runs)
162
+ * all load silently today: deepMerge applies them, nothing catches the
163
+ * mistake, the layer misbehaves. Warnings, same shape as unknown keys —
164
+ * fail-open, the snapshot is still written.
165
+ * @param {object} item
166
+ * @param {string} wfPath
167
+ * @param {boolean} isOneshot - true for an `oneshot_review_layers` item
168
+ * @returns {Array<{path: string, kind: 'invalid', detail: string}>}
169
+ */
170
+ function findInvalidReviewLayerValues(item, wfPath, isOneshot) {
171
+ const invalid = [];
172
+ if ('tiers' in item) {
173
+ if (isOneshot) {
174
+ invalid.push({
175
+ path: `${wfPath}[].tiers`,
176
+ kind: 'invalid',
177
+ detail: 'oneshot_review_layers always runs regardless of tier — tiers has no effect here',
178
+ });
179
+ } else if (!Array.isArray(item.tiers)) {
180
+ invalid.push({
181
+ path: `${wfPath}[].tiers`,
182
+ kind: 'invalid',
183
+ detail: `expected an array of "small"/"normal"/"critical", got ${JSON.stringify(item.tiers)}`,
184
+ });
185
+ } else if (item.tiers.length === 0) {
186
+ invalid.push({
187
+ path: `${wfPath}[].tiers`,
188
+ kind: 'invalid',
189
+ detail: 'empty — the layer would never run on any tier',
190
+ });
191
+ } else {
192
+ for (const tier of item.tiers) {
193
+ if (!VALID_REVIEW_TIERS.has(tier)) {
194
+ invalid.push({
195
+ path: `${wfPath}[].tiers[]`,
196
+ kind: 'invalid',
197
+ detail: `invalid tier "${tier}" — expected one of "small", "normal", "critical"`,
198
+ });
199
+ }
200
+ }
201
+ }
202
+ }
203
+ if ('max_calls' in item && !(Number.isInteger(item.max_calls) && item.max_calls > 0)) {
204
+ invalid.push({
205
+ path: `${wfPath}[].max_calls`,
206
+ kind: 'invalid',
207
+ detail: `expected a positive integer, got ${JSON.stringify(item.max_calls)}`,
208
+ });
209
+ }
210
+ return invalid;
211
+ }
212
+
213
+ /**
214
+ * Walk a loaded TOML layer and collect unknown/invalid entries: unknown
215
+ * TOP-level tables/scalars (anything outside `workflow`, `agent`, `include`)
216
+ * plus the `[workflow]` fields and array-of-table item keys/values.
217
+ * `[agent]` is prose the LLM reads, not a code-consumed schema, so its keys
218
+ * are open by design; `include` is validated by resolveIncludes.
124
219
  * @param {object} data - a single layer, as parsed (never a merged tree)
125
- * @returns {string[]} dotted paths (caller prefixes with layer info)
220
+ * @returns {Array<{path: string, kind: 'unknown'|'invalid', detail?: string}>} caller prefixes with layer info
126
221
  */
127
222
  function findUnknownKeys(data) {
128
223
  const unknown = [];
129
224
  if (!isPlainObject(data)) return unknown;
130
225
  for (const [key, value] of Object.entries(data)) {
131
226
  if (!TOP_LEVEL_KEYS.has(key)) {
132
- unknown.push(key);
227
+ unknown.push({ path: key, kind: 'unknown' });
133
228
  continue;
134
229
  }
135
230
  if (key === 'workflow' && isPlainObject(value)) unknown.push(...findUnknownWorkflowKeys(value));
@@ -140,9 +235,12 @@ function findUnknownKeys(data) {
140
235
  module.exports = {
141
236
  findUnknownKeys,
142
237
  findUnknownWorkflowKeys,
238
+ findInvalidReviewLayerValues,
239
+ isArrayOfStrings,
143
240
  WORKFLOW_KEYS,
144
241
  PARTY_MEMBER_KEYS,
145
242
  PARTY_GROUP_KEYS,
146
243
  REVIEW_LAYER_KEYS,
244
+ VALID_REVIEW_TIERS,
147
245
  TOP_LEVEL_KEYS,
148
246
  };