iterate-plugin 2.9.0 → 2.9.2

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.
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join, resolve, sep } from 'node:path';
3
4
  import yaml from 'js-yaml';
4
5
  /**
@@ -168,9 +169,46 @@ export function validateConfig(config) {
168
169
  */
169
170
  export function resolveProjectRoot(input) {
170
171
  const raw = (input ?? '').trim();
171
- const root = raw ? resolve(raw) : resolve(process.cwd());
172
+ const root = raw ? resolve(raw) : resolve(effectiveCwd());
172
173
  if (!root || root === sep) {
173
174
  return { ok: false, reason: 'Refusing filesystem root as project root.' };
174
175
  }
175
176
  return { ok: true, root };
176
177
  }
178
+ /**
179
+ * Resolve the default working directory for tools invoked without an explicit
180
+ * `path`. Prefers the process cwd, but a daemon-managed web server can start
181
+ * with cwd = `/` (e.g. launchd), which is not a usable project root. In that
182
+ * case fall back to the session workspace encoded in `DSH_SESSION_JSONL`
183
+ * (`…/sessions/<encoded-workspace>/<session-id>/session.jsonl.zstd`), where
184
+ * the workspace directory is `--`-wrapped with `/` → `-` and percent-encoded
185
+ * bytes spelled as `~<hex>` (e.g. `/Volumes/Eng-Dev/iterate-skill` →
186
+ * `--Volumes-Eng-Dev-iterate-skill--`).
187
+ */
188
+ function effectiveCwd() {
189
+ let cwd = '';
190
+ try {
191
+ cwd = process.cwd();
192
+ }
193
+ catch {
194
+ // cwd may be unreadable (deleted dir) — fall through to session workspace
195
+ }
196
+ if (cwd && cwd !== sep && cwd !== homedir())
197
+ return cwd;
198
+ const session = process.env.DSH_SESSION_JSONL;
199
+ if (session) {
200
+ const m = session.match(/\/sessions\/([^/]+)\//);
201
+ const encoded = m ? m[1] : undefined;
202
+ if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
203
+ try {
204
+ const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'));
205
+ if (decoded && decoded.startsWith(sep))
206
+ return decoded;
207
+ }
208
+ catch {
209
+ // malformed encoding — fall through to cwd
210
+ }
211
+ }
212
+ }
213
+ return cwd || sep;
214
+ }
@@ -90,7 +90,7 @@ for (let r = 1; r <= maxRounds; r++) {
90
90
  : ''
91
91
  const raw = await parallel(dims.map(dim => () => agent(
92
92
  'Review dimension "' + dim + '".' +
93
- (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
93
+ (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
94
94
  ' Already-known findings (do NOT re-report): ' +
95
95
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
96
96
  Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
@@ -190,11 +190,13 @@ const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
190
190
  const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
191
191
  // Track how many times this checkpoint has already been resumed (interruption recovery).
192
192
  const resumeCount = (checkpoint && typeof checkpoint.resumeCount === 'number') ? checkpoint.resumeCount : 0
193
+ // The effective count AFTER this recovery: this run counts as one more resume.
194
+ const effectiveResumeCount = checkpoint ? resumeCount + 1 : 0
193
195
  if (checkpoint) {
194
196
  // A previous run left a checkpoint — record the recovery so the decision log
195
197
  // shows the resume, then continue where it left off.
196
198
  await agent(
197
- 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + (resumeCount + 1) + '}})',
199
+ 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + effectiveResumeCount + '}})',
198
200
  Object.assign({ label: 'log:resume' }, backend)
199
201
  )
200
202
  }
@@ -216,7 +218,8 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
216
218
  const dims = plan.dimensions.map(d => d.id)
217
219
  const maxRounds = plan.maxReviewRounds
218
220
  const rounds = [] // findings per review round (each on the then-current code state)
219
- const architectural = [] // findings deliberately left unfixed (reported at the end)
221
+ // Restore previously-unfixed architectural findings when resuming an interrupted run.
222
+ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? checkpoint.findings : [] // findings deliberately left unfixed (reported at the end)
220
223
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
221
224
  let converged = false
222
225
  let abortedByValidation = false
@@ -235,7 +238,7 @@ for (let r = startRound; r <= maxRounds; r++) {
235
238
  : ''
236
239
  const raw = await parallel(dims.map(dim => () => agent(
237
240
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
238
- (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
241
+ (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
239
242
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
240
243
  Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
241
244
  )))
@@ -341,7 +344,7 @@ for (let r = startRound; r <= maxRounds; r++) {
341
344
 
342
345
  // Persist progress so an interrupted run can resume from the next round.
343
346
  await agent(
344
- 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + resumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
347
+ 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + effectiveResumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
345
348
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
346
349
  )
347
350
 
@@ -149,7 +149,7 @@ export function registerCheckpointTool(ctx) {
149
149
  const projectRoot = resolved.root;
150
150
  if (args.operation === 'load') {
151
151
  const checkpoint = readCheckpoint(projectRoot);
152
- return { operation: 'load', ok: true, checkpoint: checkpoint ?? undefined };
152
+ return { operation: 'load', ok: true, checkpoint: checkpoint };
153
153
  }
154
154
  if (args.operation === 'clear') {
155
155
  const existed = existsSync(checkpointPath(projectRoot));
@@ -217,7 +217,7 @@ export function registerStatusTool(ctx) {
217
217
  additionalProperties: false,
218
218
  properties: {
219
219
  ok: { type: 'boolean', required: true },
220
- mode: { type: 'string' },
220
+ mode: { oneOf: [{ type: 'string' }, { type: 'null' }] },
221
221
  currentRound: { type: 'integer' },
222
222
  totalRounds: { type: 'integer' },
223
223
  fixedCount: { type: 'integer' },
@@ -227,7 +227,7 @@ export function registerStatusTool(ctx) {
227
227
  hasCheckpoint: { type: 'boolean' },
228
228
  interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
229
229
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
230
- lastUpdated: { type: 'string' },
230
+ lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
231
231
  error: { type: 'string' },
232
232
  },
233
233
  },
@@ -258,7 +258,7 @@ export function registerStatusTool(ctx) {
258
258
  });
259
259
  return {
260
260
  ok: true,
261
- mode: status.mode ?? undefined,
261
+ mode: status.mode ?? null,
262
262
  currentRound: status.currentRound,
263
263
  totalRounds: status.totalRounds,
264
264
  fixedCount: status.fixedCount,
@@ -268,7 +268,7 @@ export function registerStatusTool(ctx) {
268
268
  hasCheckpoint: status.hasCheckpoint,
269
269
  interrupted: status.interrupted,
270
270
  resumeCount: status.resumeCount,
271
- lastUpdated: status.lastUpdated ?? undefined,
271
+ lastUpdated: status.lastUpdated ?? null,
272
272
  };
273
273
  },
274
274
  }));
@@ -47,14 +47,14 @@ export function registerConfigTool(ctx) {
47
47
  properties: {
48
48
  found: { type: 'boolean', required: true },
49
49
  valid: { type: 'boolean' },
50
- errors: { type: 'array', items: { type: 'string' } },
50
+ errors: { oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }] },
51
51
  section: { type: 'string' },
52
52
  data: { type: 'json' },
53
53
  config: { type: 'json' },
54
54
  availableSections: { type: 'array', items: { type: 'string' } },
55
55
  operation: { type: 'string' },
56
56
  ok: { type: 'boolean' },
57
- backupPath: { type: 'string' },
57
+ backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
58
58
  error: { type: 'string' },
59
59
  },
60
60
  },
@@ -100,7 +100,7 @@ export function registerConfigTool(ctx) {
100
100
  operation: 'write',
101
101
  ok: true,
102
102
  found: true,
103
- backupPath: result.backupPath ?? undefined,
103
+ backupPath: result.backupPath ?? null,
104
104
  config: config,
105
105
  };
106
106
  }
@@ -112,7 +112,7 @@ export function registerConfigTool(ctx) {
112
112
  return {
113
113
  found: hasOverride,
114
114
  valid: errors.length === 0,
115
- errors: errors.length > 0 ? errors : undefined,
115
+ errors: errors.length > 0 ? errors : null,
116
116
  section: 'validation_report',
117
117
  };
118
118
  }
@@ -7,6 +7,8 @@ import { resolveProjectRoot } from "../config-loader.js";
7
7
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12;
8
8
  /** Maximum number of image attachments relayed into the context in one call. */
9
9
  const MAX_ATTACHMENTS = 8;
10
+ /** Maximum intrinsic width/height (px) accepted for an attached image. */
11
+ const MAX_ATTACHMENT_DIMENSION = 16384;
10
12
  /**
11
13
  * Validate and normalize one raw image-attachment entry passed by the
12
14
  * orchestrator. The top-level model observes user-attached images in its own
@@ -33,8 +35,8 @@ export function normalizeAttachment(raw) {
33
35
  }
34
36
  for (const dim of ['width', 'height']) {
35
37
  if (entry[dim] !== undefined) {
36
- if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > 16384) {
37
- return { ok: false, error: `attachment.${dim} must be an integer in [0, 16384]` };
38
+ if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > MAX_ATTACHMENT_DIMENSION) {
39
+ return { ok: false, error: `attachment.${dim} must be an integer in [0, ${MAX_ATTACHMENT_DIMENSION}]` };
38
40
  }
39
41
  out[dim] = entry[dim];
40
42
  }
@@ -182,7 +182,7 @@ export function registerReviewTool(ctx) {
182
182
  operation: 'aggregate',
183
183
  mode,
184
184
  report: report,
185
- schemaValidation: schemaValidation,
185
+ schemaValidation: (schemaValidation ?? null),
186
186
  };
187
187
  }
188
188
  if (args.operation === 'meta-review') {
@@ -262,7 +262,7 @@ export function registerTriageTool(ctx) {
262
262
  skipped: { type: 'integer' },
263
263
  count: { type: 'integer' },
264
264
  path: { type: 'string' },
265
- backupPath: { type: 'string' },
265
+ backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
266
266
  entries: { type: 'json' },
267
267
  errors: { type: 'array', items: { type: 'string' } },
268
268
  error: { type: 'string' },
@@ -321,7 +321,7 @@ export function registerTriageTool(ctx) {
321
321
  skipped: result.skipped,
322
322
  count: result.count,
323
323
  path: result.configPath,
324
- backupPath: result.backupPath ?? undefined,
324
+ backupPath: result.backupPath ?? null,
325
325
  };
326
326
  }
327
327
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
2
3
  import { join, resolve, sep } from 'node:path'
3
4
  import yaml from 'js-yaml'
4
5
  import type { IterateConfig } from './types.ts'
@@ -185,9 +186,43 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
185
186
  */
186
187
  export function resolveProjectRoot(input?: string): ProjectRootResult {
187
188
  const raw = (input ?? '').trim()
188
- const root = raw ? resolve(raw) : resolve(process.cwd())
189
+ const root = raw ? resolve(raw) : resolve(effectiveCwd())
189
190
  if (!root || root === sep) {
190
191
  return { ok: false, reason: 'Refusing filesystem root as project root.' }
191
192
  }
192
193
  return { ok: true, root }
194
+ }
195
+
196
+ /**
197
+ * Resolve the default working directory for tools invoked without an explicit
198
+ * `path`. Prefers the process cwd, but a daemon-managed web server can start
199
+ * with cwd = `/` (e.g. launchd), which is not a usable project root. In that
200
+ * case fall back to the session workspace encoded in `DSH_SESSION_JSONL`
201
+ * (`…/sessions/<encoded-workspace>/<session-id>/session.jsonl.zstd`), where
202
+ * the workspace directory is `--`-wrapped with `/` → `-` and percent-encoded
203
+ * bytes spelled as `~<hex>` (e.g. `/Volumes/Eng-Dev/iterate-skill` →
204
+ * `--Volumes-Eng-Dev-iterate-skill--`).
205
+ */
206
+ function effectiveCwd(): string {
207
+ let cwd = ''
208
+ try {
209
+ cwd = process.cwd()
210
+ } catch {
211
+ // cwd may be unreadable (deleted dir) — fall through to session workspace
212
+ }
213
+ if (cwd && cwd !== sep && cwd !== homedir()) return cwd
214
+ const session = process.env.DSH_SESSION_JSONL
215
+ if (session) {
216
+ const m = session.match(/\/sessions\/([^/]+)\//)
217
+ const encoded = m ? m[1] : undefined
218
+ if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
219
+ try {
220
+ const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'))
221
+ if (decoded && decoded.startsWith(sep)) return decoded
222
+ } catch {
223
+ // malformed encoding — fall through to cwd
224
+ }
225
+ }
226
+ }
227
+ return cwd || sep
193
228
  }
@@ -91,7 +91,7 @@ for (let r = 1; r <= maxRounds; r++) {
91
91
  : ''
92
92
  const raw = await parallel(dims.map(dim => () => agent(
93
93
  'Review dimension "' + dim + '".' +
94
- (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
94
+ (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
95
95
  ' Already-known findings (do NOT re-report): ' +
96
96
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
97
97
  Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
@@ -191,11 +191,13 @@ const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
191
191
  const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
192
192
  // Track how many times this checkpoint has already been resumed (interruption recovery).
193
193
  const resumeCount = (checkpoint && typeof checkpoint.resumeCount === 'number') ? checkpoint.resumeCount : 0
194
+ // The effective count AFTER this recovery: this run counts as one more resume.
195
+ const effectiveResumeCount = checkpoint ? resumeCount + 1 : 0
194
196
  if (checkpoint) {
195
197
  // A previous run left a checkpoint — record the recovery so the decision log
196
198
  // shows the resume, then continue where it left off.
197
199
  await agent(
198
- 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + (resumeCount + 1) + '}})',
200
+ 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + effectiveResumeCount + '}})',
199
201
  Object.assign({ label: 'log:resume' }, backend)
200
202
  )
201
203
  }
@@ -217,7 +219,8 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
217
219
  const dims = plan.dimensions.map(d => d.id)
218
220
  const maxRounds = plan.maxReviewRounds
219
221
  const rounds = [] // findings per review round (each on the then-current code state)
220
- const architectural = [] // findings deliberately left unfixed (reported at the end)
222
+ // Restore previously-unfixed architectural findings when resuming an interrupted run.
223
+ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? checkpoint.findings : [] // findings deliberately left unfixed (reported at the end)
221
224
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
222
225
  let converged = false
223
226
  let abortedByValidation = false
@@ -236,7 +239,7 @@ for (let r = startRound; r <= maxRounds; r++) {
236
239
  : ''
237
240
  const raw = await parallel(dims.map(dim => () => agent(
238
241
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
239
- (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
242
+ (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
240
243
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
241
244
  Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
242
245
  )))
@@ -342,7 +345,7 @@ for (let r = startRound; r <= maxRounds; r++) {
342
345
 
343
346
  // Persist progress so an interrupted run can resume from the next round.
344
347
  await agent(
345
- 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + resumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
348
+ 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + effectiveResumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
346
349
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
347
350
  )
348
351
 
@@ -173,7 +173,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
173
173
 
174
174
  if (args.operation === 'load') {
175
175
  const checkpoint = readCheckpoint(projectRoot)
176
- return { operation: 'load', ok: true, checkpoint: (checkpoint as unknown as JsonValue | null) ?? undefined }
176
+ return { operation: 'load', ok: true, checkpoint: checkpoint as unknown as JsonValue | null }
177
177
  }
178
178
 
179
179
  if (args.operation === 'clear') {
@@ -245,7 +245,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
245
245
  additionalProperties: false,
246
246
  properties: {
247
247
  ok: { type: 'boolean', required: true },
248
- mode: { type: 'string' },
248
+ mode: { oneOf: [{ type: 'string' }, { type: 'null' }] },
249
249
  currentRound: { type: 'integer' },
250
250
  totalRounds: { type: 'integer' },
251
251
  fixedCount: { type: 'integer' },
@@ -255,7 +255,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
255
255
  hasCheckpoint: { type: 'boolean' },
256
256
  interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
257
257
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
258
- lastUpdated: { type: 'string' },
258
+ lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
259
259
  error: { type: 'string' },
260
260
  },
261
261
  },
@@ -285,7 +285,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
285
285
  })
286
286
  return {
287
287
  ok: true,
288
- mode: status.mode ?? undefined,
288
+ mode: status.mode ?? null,
289
289
  currentRound: status.currentRound,
290
290
  totalRounds: status.totalRounds,
291
291
  fixedCount: status.fixedCount,
@@ -295,7 +295,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
295
295
  hasCheckpoint: status.hasCheckpoint,
296
296
  interrupted: status.interrupted,
297
297
  resumeCount: status.resumeCount,
298
- lastUpdated: status.lastUpdated ?? undefined,
298
+ lastUpdated: status.lastUpdated ?? null,
299
299
  }
300
300
  },
301
301
  }),
@@ -60,14 +60,14 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
60
60
  properties: {
61
61
  found: { type: 'boolean', required: true },
62
62
  valid: { type: 'boolean' },
63
- errors: { type: 'array', items: { type: 'string' } },
63
+ errors: { oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }] },
64
64
  section: { type: 'string' },
65
65
  data: { type: 'json' },
66
66
  config: { type: 'json' },
67
67
  availableSections: { type: 'array', items: { type: 'string' } },
68
68
  operation: { type: 'string' },
69
69
  ok: { type: 'boolean' },
70
- backupPath: { type: 'string' },
70
+ backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
71
71
  error: { type: 'string' },
72
72
  },
73
73
  },
@@ -113,7 +113,7 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
113
113
  operation: 'write',
114
114
  ok: true,
115
115
  found: true,
116
- backupPath: result.backupPath ?? undefined,
116
+ backupPath: result.backupPath ?? null,
117
117
  config: config as unknown as JsonValue,
118
118
  }
119
119
  }
@@ -127,7 +127,7 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
127
127
  return {
128
128
  found: hasOverride,
129
129
  valid: errors.length === 0,
130
- errors: errors.length > 0 ? errors : undefined,
130
+ errors: errors.length > 0 ? errors : null,
131
131
  section: 'validation_report',
132
132
  }
133
133
  }
@@ -10,6 +10,9 @@ const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
10
10
  /** Maximum number of image attachments relayed into the context in one call. */
11
11
  const MAX_ATTACHMENTS = 8
12
12
 
13
+ /** Maximum intrinsic width/height (px) accepted for an attached image. */
14
+ const MAX_ATTACHMENT_DIMENSION = 16384
15
+
13
16
  /** A validated, normalized image-attachment entry carried into review context. */
14
17
  export interface NormalizedAttachment {
15
18
  name?: string
@@ -50,8 +53,8 @@ export function normalizeAttachment(raw: unknown): AttachmentValidationResult {
50
53
  }
51
54
  for (const dim of ['width', 'height'] as const) {
52
55
  if (entry[dim] !== undefined) {
53
- if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > 16384) {
54
- return { ok: false, error: `attachment.${dim} must be an integer in [0, 16384]` }
56
+ if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > MAX_ATTACHMENT_DIMENSION) {
57
+ return { ok: false, error: `attachment.${dim} must be an integer in [0, ${MAX_ATTACHMENT_DIMENSION}]` }
55
58
  }
56
59
  out[dim] = entry[dim]
57
60
  }
@@ -213,7 +213,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
213
213
  operation: 'aggregate',
214
214
  mode,
215
215
  report: report as unknown as JsonValue,
216
- schemaValidation: schemaValidation as unknown as JsonValue | undefined,
216
+ schemaValidation: (schemaValidation ?? null) as unknown as JsonValue | null,
217
217
  }
218
218
  }
219
219
 
@@ -302,7 +302,7 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
302
302
  skipped: { type: 'integer' },
303
303
  count: { type: 'integer' },
304
304
  path: { type: 'string' },
305
- backupPath: { type: 'string' },
305
+ backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
306
306
  entries: { type: 'json' },
307
307
  errors: { type: 'array', items: { type: 'string' } },
308
308
  error: { type: 'string' },
@@ -363,7 +363,7 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
363
363
  skipped: result.skipped,
364
364
  count: result.count,
365
365
  path: result.configPath,
366
- backupPath: result.backupPath ?? undefined,
366
+ backupPath: result.backupPath ?? null,
367
367
  }
368
368
  }
369
369