@miller-tech/uap 1.149.5 → 1.150.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.
@@ -0,0 +1,497 @@
1
+ # SPEC: P0 deliver hardening — anti-vacuous floor + no-op acceptance rail + autoroute default off
2
+
3
+ Exact, pre-reviewed operations. Apply VERBATIM. See
4
+ docs/plans/deliver-hardening-plan-2026-07-13.md (A2 + C1 + C2 + D2).
5
+
6
+ ---
7
+
8
+ ## File 1: `src/delivery/convergence-loop.ts`
9
+
10
+ ### Op 1.1 — REPLACE
11
+
12
+ ```ts
13
+ import type { GateRung, LadderResult, LadderOptions } from './verifier-ladder.js';
14
+ import { detectRungs, runLadder, tierOf } from './verifier-ladder.js';
15
+ ```
16
+
17
+ WITH
18
+
19
+ ```ts
20
+ import { execSync } from 'child_process';
21
+
22
+ import type { GateRung, LadderResult, LadderOptions } from './verifier-ladder.js';
23
+ import { detectRungs, runLadder, tierOf } from './verifier-ladder.js';
24
+ ```
25
+
26
+ ### Op 1.2 — REPLACE
27
+
28
+ ```ts
29
+ * applied" optimization permanently scores such turns 0%.
30
+ */
31
+ alwaysVerify?: boolean;
32
+ ```
33
+
34
+ WITH
35
+
36
+ ```ts
37
+ * applied" optimization permanently scores such turns 0%.
38
+ */
39
+ alwaysVerify?: boolean;
40
+ /**
41
+ * Withhold acceptance until the run has actually changed the tree
42
+ * (default true). An LLM acceptance judge must never be the only thing
43
+ * standing between a zero-diff run and "delivered" (2026-07-13 incident:
44
+ * a mission on a gates-green repo "delivered" after writing nothing).
45
+ * Change detection: files the applier wrote, else a git tree fingerprint
46
+ * (covers the direct-mutation agentic executor). Fail-closed when neither
47
+ * signal shows a change; skipped automatically on resume (prior-session
48
+ * turns may have written) and via --allow-noop for genuinely no-op
49
+ * missions.
50
+ */
51
+ requireDiffForAcceptance?: boolean;
52
+ ```
53
+
54
+ ### Op 1.3 — REPLACE
55
+
56
+ ```ts
57
+ private readonly acceptanceGate?: AcceptanceGate;
58
+
59
+ constructor(
60
+ ```
61
+
62
+ WITH
63
+
64
+ ```ts
65
+ private readonly acceptanceGate?: AcceptanceGate;
66
+ /** Every file the applier has written this run (union across turns). */
67
+ private appliedFilesTotal = new Set<string>();
68
+ /** Git tree fingerprint at run start; null when unavailable (non-git). */
69
+ private runStartTreeFingerprint: string | null = null;
70
+
71
+ constructor(
72
+ ```
73
+
74
+ ### Op 1.4 — REPLACE
75
+
76
+ ```ts
77
+ this.acceptanceGate = seams.acceptanceGate;
78
+ }
79
+ ```
80
+
81
+ WITH
82
+
83
+ ```ts
84
+ this.acceptanceGate = seams.acceptanceGate;
85
+ }
86
+
87
+ /**
88
+ * Cheap, stable fingerprint of the working tree: porcelain status (covers
89
+ * untracked adds/removes) + diff stat (covers content edits). Returns null
90
+ * outside a git repo or on any git failure.
91
+ */
92
+ private fingerprintTree(): string | null {
93
+ try {
94
+ const opts = {
95
+ cwd: this.config.projectRoot,
96
+ timeout: 10_000,
97
+ maxBuffer: 8 * 1024 * 1024,
98
+ } as const;
99
+ const status = execSync('git status --porcelain=v1 --untracked-files=all', opts).toString();
100
+ const diff = execSync('git diff HEAD --stat', opts).toString();
101
+ return `${status}\n---\n${diff}`;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Has this run changed the project tree yet? Applier-written files are
109
+ * authoritative; the git fingerprint covers direct-mutation executors
110
+ * (agentic write_file bypasses the applier). FAIL-CLOSED: when neither
111
+ * signal shows a change — including when git is unavailable — the answer
112
+ * is false. See requireDiffForAcceptance.
113
+ */
114
+ private hasAppliedChanges(): boolean {
115
+ if (this.appliedFilesTotal.size > 0) return true;
116
+ if (this.runStartTreeFingerprint !== null) {
117
+ const now = this.fingerprintTree();
118
+ if (now !== null) return now !== this.runStartTreeFingerprint;
119
+ }
120
+ return false;
121
+ }
122
+ ```
123
+
124
+ ### Op 1.5 — REPLACE
125
+
126
+ ```ts
127
+ private async judgeAcceptance(ladder: LadderResult): Promise<{ ladder: LadderResult; acceptanceMet?: number }> {
128
+ if (!this.acceptanceGate || !ladder.passed) return { ladder };
129
+ ```
130
+
131
+ WITH
132
+
133
+ ```ts
134
+ private async judgeAcceptance(
135
+ ladder: LadderResult,
136
+ opts: { atBaseline?: boolean } = {}
137
+ ): Promise<{ ladder: LadderResult; acceptanceMet?: number }> {
138
+ if (!this.acceptanceGate || !ladder.passed) return { ladder };
139
+ // Anti-no-op rail (P0, 2026-07-13): acceptance is deterministically
140
+ // withheld until the run has changed the tree — this also stops the
141
+ // baseline check from short-circuiting a coding mission as
142
+ // alreadyDelivered on a gates-green repo. At baseline nothing can have
143
+ // changed by definition. Skipped on resume (prior-session turns already
144
+ // wrote; their changes are invisible to this process's fingerprint).
145
+ const unchanged = opts.atBaseline ? true : !this.hasAppliedChanges();
146
+ if ((this.config.requireDiffForAcceptance ?? true) && !this.config.resumeFrom && unchanged) {
147
+ return {
148
+ ladder: {
149
+ ...ladder,
150
+ passed: false,
151
+ feedback:
152
+ `${ladder.feedback}\n\nAcceptance withheld: this run has not changed any files yet — a no-op cannot be "delivered". Apply the mission's changes (or re-run with --allow-noop if no change is genuinely required).`.trim(),
153
+ },
154
+ acceptanceMet: 0,
155
+ };
156
+ }
157
+ ```
158
+
159
+ ### Op 1.6 — REPLACE
160
+
161
+ ```ts
162
+ const baseline = (await this.judgeAcceptance(rawBaseline)).ladder;
163
+ ```
164
+
165
+ WITH
166
+
167
+ ```ts
168
+ const baseline = (await this.judgeAcceptance(rawBaseline, { atBaseline: true })).ladder;
169
+ ```
170
+
171
+ ### Op 1.7 — REPLACE
172
+
173
+ ```ts
174
+ // Gate-integrity snapshot, taken AFTER the baseline ladder run so files
175
+ ```
176
+
177
+ WITH
178
+
179
+ ```ts
180
+ // t0 tree fingerprint for the anti-no-op acceptance rail — taken AFTER
181
+ // the baseline ladder run so files the gates themselves create on first
182
+ // run (e.g. snapshots, lockfiles) don't read as mission changes.
183
+ this.runStartTreeFingerprint = this.fingerprintTree();
184
+
185
+ // Gate-integrity snapshot, taken AFTER the baseline ladder run so files
186
+ ```
187
+
188
+ ### Op 1.8 — REPLACE
189
+
190
+ ```ts
191
+ const outcome = explorerSettings
192
+ ? await this.runExplorerTurn(instruction, prompt, rungs, explorerSettings, executor, ladderRunner, applyOptions)
193
+ : await this.runSingleTurn(prompt, rungs, executor, ladderRunner, applyOptions);
194
+
195
+ // Acceptance: judge ONCE on this turn's committed verdict (single-turn
196
+ ```
197
+
198
+ WITH
199
+
200
+ ```ts
201
+ const outcome = explorerSettings
202
+ ? await this.runExplorerTurn(instruction, prompt, rungs, explorerSettings, executor, ladderRunner, applyOptions)
203
+ : await this.runSingleTurn(prompt, rungs, executor, ladderRunner, applyOptions);
204
+
205
+ // Anti-no-op rail bookkeeping: fold this turn's applier writes into the
206
+ // run-wide union BEFORE the acceptance judge consults it.
207
+ for (const f of outcome.filesApplied) this.appliedFilesTotal.add(f);
208
+
209
+ // Acceptance: judge ONCE on this turn's committed verdict (single-turn
210
+ ```
211
+
212
+ ---
213
+
214
+ ## File 2: `src/cli/deliver.ts`
215
+
216
+ ### Op 2.1 — REPLACE
217
+
218
+ ```ts
219
+ export function decideGateStrategy(opts: {
220
+ hasAcceptance: boolean;
221
+ noRealGates: boolean;
222
+ forceSelfGate: boolean;
223
+ selfGateAllowed: boolean;
224
+ }): { acceptancePrimary: boolean; needsSelfGate: boolean; noGatesError: boolean } {
225
+ const acceptancePrimary = opts.hasAcceptance && opts.noRealGates && !opts.forceSelfGate;
226
+ const needsSelfGate =
227
+ opts.selfGateAllowed && !acceptancePrimary && (opts.noRealGates || opts.forceSelfGate);
228
+ const noGatesError = opts.noRealGates && !needsSelfGate && !acceptancePrimary;
229
+ return { acceptancePrimary, needsSelfGate, noGatesError };
230
+ }
231
+ ```
232
+
233
+ WITH
234
+
235
+ ```ts
236
+ export function decideGateStrategy(opts: {
237
+ hasAcceptance: boolean;
238
+ noRealGates: boolean;
239
+ forceSelfGate: boolean;
240
+ selfGateAllowed: boolean;
241
+ /**
242
+ * Anti-vacuous floor (P0, 2026-07-13): every REQUIRED project gate passed a
243
+ * pre-run baseline probe. Gates that cannot fail are not a convergence
244
+ * target — "delivered" must mean "something that was red is now green" —
245
+ * so a mission self-gate is engaged exactly as if no gates were detected.
246
+ */
247
+ baselineAllGreen?: boolean;
248
+ }): { acceptancePrimary: boolean; needsSelfGate: boolean; noGatesError: boolean } {
249
+ const acceptancePrimary = opts.hasAcceptance && opts.noRealGates && !opts.forceSelfGate;
250
+ const needsSelfGate =
251
+ opts.selfGateAllowed &&
252
+ !acceptancePrimary &&
253
+ (opts.noRealGates || opts.forceSelfGate || opts.baselineAllGreen === true);
254
+ const noGatesError = opts.noRealGates && !needsSelfGate && !acceptancePrimary;
255
+ return { acceptancePrimary, needsSelfGate, noGatesError };
256
+ }
257
+ ```
258
+
259
+ ### Op 2.2 — REPLACE
260
+
261
+ ```ts
262
+ epics?: boolean;
263
+ dryRun?: boolean;
264
+ json?: boolean;
265
+ }
266
+ ```
267
+
268
+ WITH
269
+
270
+ ```ts
271
+ epics?: boolean;
272
+ /** `--allow-noop`: permit success without any tree change (disables the
273
+ * anti-no-op acceptance rail for missions that genuinely require none). */
274
+ allowNoop?: boolean;
275
+ dryRun?: boolean;
276
+ json?: boolean;
277
+ }
278
+ ```
279
+
280
+ ### Op 2.3 — REPLACE
281
+
282
+ ```ts
283
+ let autoPlan: AutoPlan | undefined;
284
+ if (options.auto !== false && process.env.UAP_DELIVER_AUTO !== '0' && !hasExplicitAidFlags(options)) {
285
+ autoPlan = planAutoOptimization(instruction);
286
+ applyAutoPlan(options, autoPlan);
287
+ if (!options.dryRun) {
288
+ console.log(chalk.cyan(`⚙ auto-optimize: ${autoPlan.summary}`));
289
+ }
290
+ }
291
+ ```
292
+
293
+ WITH
294
+
295
+ ```ts
296
+ let autoPlan: AutoPlan | undefined;
297
+ if (options.auto !== false && process.env.UAP_DELIVER_AUTO !== '0' && !hasExplicitAidFlags(options)) {
298
+ autoPlan = planAutoOptimization(instruction);
299
+ applyAutoPlan(options, autoPlan);
300
+ if (!options.dryRun) {
301
+ console.log(chalk.cyan(`⚙ auto-optimize: ${autoPlan.summary}`));
302
+ }
303
+ }
304
+ // Verification RAILS are independent of the optimization AIDS (P0,
305
+ // 2026-07-13): --no-auto / explicit aid flags stand down exploration,
306
+ // critic and ideation, but must not silently drop the acceptance judge —
307
+ // without it, a gates-green no-op run reads as delivered. When the
308
+ // auto-planner did not run, acceptance defaults ON; opt out explicitly
309
+ // with UAP_DELIVER_ACCEPTANCE=0.
310
+ if (!autoPlan && options.acceptance === undefined && process.env.UAP_DELIVER_ACCEPTANCE !== '0') {
311
+ options.acceptance = true;
312
+ if (!options.dryRun) {
313
+ console.log(chalk.cyan('⚖ acceptance judge on (verification rail; UAP_DELIVER_ACCEPTANCE=0 to disable)'));
314
+ }
315
+ }
316
+ ```
317
+
318
+ ### Op 2.4 — REPLACE
319
+
320
+ ```ts
321
+ const { acceptancePrimary, needsSelfGate, noGatesError } = decideGateStrategy({
322
+ hasAcceptance: Boolean(options.acceptance),
323
+ noRealGates,
324
+ forceSelfGate: options.forceSelfGate === true,
325
+ selfGateAllowed,
326
+ });
327
+ ```
328
+
329
+ WITH
330
+
331
+ ```ts
332
+ // Anti-vacuous floor (P0, 2026-07-13 incident): probe the REQUIRED rungs
333
+ // once before choosing the convergence target. If everything is already
334
+ // green, gate-satisfaction cannot measure this mission — a run on a
335
+ // gates-green repo would false-green as a no-op (observed live: a 6-file
336
+ // C++ mission "delivered" after writing nothing, because the only detected
337
+ // gates were unrelated npm web gates). UAP_DELIVER_VACUOUS_FLOOR=0 opts out.
338
+ let baselineAllGreen = false;
339
+ if (
340
+ !noRealGates &&
341
+ options.forceSelfGate !== true &&
342
+ selfGateAllowed &&
343
+ process.env.UAP_DELIVER_VACUOUS_FLOOR !== '0' &&
344
+ !options.dryRun
345
+ ) {
346
+ try {
347
+ const requiredRungs = rungs.filter((r) => r.required);
348
+ baselineAllGreen = requiredRungs.length > 0 && runLadder(requiredRungs, projectRoot).passed;
349
+ } catch {
350
+ baselineAllGreen = false; // probe is best-effort; never blocks a run
351
+ }
352
+ if (baselineAllGreen) {
353
+ console.log(
354
+ chalk.cyan(
355
+ '⚖ anti-vacuous floor: all required project gates are ALREADY green — authoring a mission self-gate so success requires real, verified change'
356
+ )
357
+ );
358
+ }
359
+ }
360
+
361
+ const { acceptancePrimary, needsSelfGate, noGatesError } = decideGateStrategy({
362
+ hasAcceptance: Boolean(options.acceptance),
363
+ noRealGates,
364
+ forceSelfGate: options.forceSelfGate === true,
365
+ selfGateAllowed,
366
+ baselineAllGreen,
367
+ });
368
+ ```
369
+
370
+ ### Op 2.5 — REPLACE
371
+
372
+ ```ts
373
+ if (sg.vacuous) {
374
+ console.log(
375
+ chalk.yellow(
376
+ ' ⚠ acceptance gate may be weak (could not force an initially-failing check); running multi-turn anyway.'
377
+ )
378
+ );
379
+ } else {
380
+ ```
381
+
382
+ WITH
383
+
384
+ ```ts
385
+ if (sg.vacuous) {
386
+ // P0 hard-fail: a REQUIRED self-gate that passes on the unsolved repo
387
+ // re-opens the false-green door — "delivered" would be meaningless.
388
+ if (process.env.UAP_DELIVER_ALLOW_WEAK_SELF_GATE !== '1') {
389
+ fail(
390
+ 'The self-gate is REQUIRED for this run (anti-vacuous floor) but stayed vacuous after retries — it passes on the unsolved repo. Add concrete, checkable ACCEPTANCE CRITERIA to the instruction, or set UAP_DELIVER_ALLOW_WEAK_SELF_GATE=1 to accept the risk.'
391
+ );
392
+ }
393
+ console.log(
394
+ chalk.yellow(
395
+ ' ⚠ acceptance gate may be weak (could not force an initially-failing check); UAP_DELIVER_ALLOW_WEAK_SELF_GATE=1 — running multi-turn anyway.'
396
+ )
397
+ );
398
+ } else {
399
+ ```
400
+
401
+ ### Op 2.6 — REPLACE
402
+
403
+ ```ts
404
+ // The agentic executor mutates the repo directly (no-op applier), so
405
+ // gates must run every turn regardless of applier file count.
406
+ alwaysVerify: agentic ? true : undefined,
407
+ ```
408
+
409
+ WITH
410
+
411
+ ```ts
412
+ // The agentic executor mutates the repo directly (no-op applier), so
413
+ // gates must run every turn regardless of applier file count.
414
+ alwaysVerify: agentic ? true : undefined,
415
+ // Anti-no-op acceptance rail (P0): success requires an actual tree
416
+ // change unless the caller explicitly allows a no-op mission.
417
+ requireDiffForAcceptance: options.allowNoop === true ? false : undefined,
418
+ ```
419
+
420
+ ---
421
+
422
+ ## File 3: `src/bin/cli.ts`
423
+
424
+ ### Op 3.1 — REPLACE
425
+
426
+ ```ts
427
+ .option('--force-self-gate', 'Author a task-specific acceptance gate even when project gates exist')
428
+ ```
429
+
430
+ WITH
431
+
432
+ ```ts
433
+ .option('--force-self-gate', 'Author a task-specific acceptance gate even when project gates exist')
434
+ .option('--allow-noop', 'Permit delivery without any tree change (disables the anti-no-op acceptance rail for missions that genuinely require none)')
435
+ ```
436
+
437
+ ---
438
+
439
+ ## File 4: `templates/hooks/deliver_autoroute.py`
440
+
441
+ ### Op 4.1 — REPLACE
442
+
443
+ ```python
444
+ def _autoroute_enabled() -> bool:
445
+ # Default ON: a blocked source edit auto-routes into `uap deliver` in the
446
+ # background instead of dead-ending the agent. Opt out with
447
+ # UAP_DELIVER_AUTOROUTE=0/off/false/no.
448
+ v = os.environ.get("UAP_DELIVER_AUTOROUTE", "on").lower()
449
+ return v not in {"0", "off", "false", "no"}
450
+ ```
451
+
452
+ WITH
453
+
454
+ ```python
455
+ def _autoroute_enabled() -> bool:
456
+ # Default OFF (P0, 2026-07-13): the auto-spawned deliver run carries only a
457
+ # vacuous "implement the intended change to <file>" hint — the blocked
458
+ # edit's actual content is not plumbed through yet (plan D1) — so a blind
459
+ # background model run is spawned per blocked file. Blind fan-out mangles
460
+ # shared worktrees; the recorded intent + block message let the agent run
461
+ # `uap deliver` itself with the real spec. Opt IN with
462
+ # UAP_DELIVER_AUTOROUTE=1/on/true/yes.
463
+ v = os.environ.get("UAP_DELIVER_AUTOROUTE", "off").lower()
464
+ return v in {"1", "on", "true", "yes"}
465
+ ```
466
+
467
+ ### Op 4.2 — REPLACE
468
+
469
+ ```python
470
+ intent = {"ts": int(time.time()), "tool": tool, "file_path": file_path, "hint": hint}
471
+ spawn = bool(autoroute_on and hint and file_path and file_path not in seen_files)
472
+ message = reason
473
+ if spawn:
474
+ message = reason + " [auto-routed to `uap deliver` — running in the background]"
475
+ ```
476
+
477
+ WITH
478
+
479
+ ```python
480
+ intent = {"ts": int(time.time()), "tool": tool, "file_path": file_path, "hint": hint}
481
+ spawn = bool(autoroute_on and hint and file_path and file_path not in seen_files)
482
+ message = reason
483
+ if spawn:
484
+ message = reason + " [auto-routed to `uap deliver` — running in the background]"
485
+ elif file_path:
486
+ message = reason + (
487
+ " [intent recorded to .uap/pending-deliver.jsonl — apply it yourself by running"
488
+ " `uap deliver` with the exact intended change as the instruction]"
489
+ )
490
+ ```
491
+
492
+ ---
493
+
494
+ END OF SPEC. After applying, `npm run build` and the vitest suites
495
+ `test/cli/acceptance-verdict.test.ts`, `test/delivery/convergence-loop.test.ts`,
496
+ and `test/hooks/deliver-autoroute-default.test.ts` MUST pass. If an anchor does
497
+ not match verbatim, STOP and report the mismatch instead of improvising.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.149.5",
3
+ "version": "1.150.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -384,11 +384,25 @@ def main() -> None:
384
384
  # auto-route the blocked change INTO `uap deliver` instead of leaving the
385
385
  # agent to flail. `route`/`deliverHint` are advisory extra fields; harness
386
386
  # adapters that understand them route, others just show `reason`.
387
+ # P1 (plan D1): carry the blocked edit's ACTUAL content so the harness
388
+ # can record a replayable intent — a vacuous "implement the intended
389
+ # change" hint spawns a blind model run that knows nothing (observed
390
+ # live 2026-07-13). Apply with `uap deliver --pending <file>`.
391
+ intent_payload = {
392
+ k: v
393
+ for k, v in (
394
+ ("old_string", args.get("old_string")),
395
+ ("new_string", args.get("new_string")),
396
+ ("content", args.get("content")),
397
+ )
398
+ if isinstance(v, str)
399
+ }
387
400
  emit(
388
401
  False,
389
402
  msg,
390
403
  route="deliver",
391
404
  deliverHint=f'implement the intended change to {rel_posix}',
405
+ editIntent=intent_payload or None,
392
406
  )
393
407
  return
394
408
 
@@ -30,11 +30,15 @@ UAP_DIR = ".uap"
30
30
 
31
31
 
32
32
  def _autoroute_enabled() -> bool:
33
- # Default ON: a blocked source edit auto-routes into `uap deliver` in the
34
- # background instead of dead-ending the agent. Opt out with
35
- # UAP_DELIVER_AUTOROUTE=0/off/false/no.
36
- v = os.environ.get("UAP_DELIVER_AUTOROUTE", "on").lower()
37
- return v not in {"0", "off", "false", "no"}
33
+ # Default OFF (P0, 2026-07-13): the auto-spawned deliver run carries only a
34
+ # vacuous "implement the intended change to <file>" hint — the blocked
35
+ # edit's actual content is not plumbed through yet (plan D1) — so a blind
36
+ # background model run is spawned per blocked file. Blind fan-out mangles
37
+ # shared worktrees; the recorded intent + block message let the agent run
38
+ # `uap deliver` itself with the real spec. Opt IN with
39
+ # UAP_DELIVER_AUTOROUTE=1/on/true/yes.
40
+ v = os.environ.get("UAP_DELIVER_AUTOROUTE", "off").lower()
41
+ return v in {"1", "on", "true", "yes"}
38
42
 
39
43
 
40
44
  def decide(out: dict, tool: str, args: dict, autoroute_on: bool, seen_files: set) -> dict:
@@ -69,12 +73,31 @@ def decide(out: dict, tool: str, args: dict, autoroute_on: bool, seen_files: set
69
73
  # were blocked and then silently dropped. The hint is what deliver actually
70
74
  # runs, so it is the correct spawn key.
71
75
  dedup_key = file_path or hint
76
+ # P1 (plan D1): persist the blocked edit's actual old/new content (from the
77
+ # enforcer's editIntent, falling back to the raw tool args) so the intent
78
+ # is REPLAYABLE via `uap deliver --pending <file>` instead of a blind hint.
79
+ edit_intent = out.get("editIntent") or {
80
+ k: v
81
+ for k, v in (
82
+ ("old_string", args.get("old_string")),
83
+ ("new_string", args.get("new_string")),
84
+ ("content", args.get("content")),
85
+ )
86
+ if isinstance(v, str)
87
+ }
88
+ if edit_intent:
89
+ intent["edit"] = edit_intent
72
90
  spawn = bool(autoroute_on and hint and dedup_key and dedup_key not in seen_files)
73
91
  message = reason
74
92
  if spawn:
75
93
  message = reason + " [auto-routed to `uap deliver` — running in the background]"
76
94
  elif autoroute_on and dedup_key and dedup_key in seen_files:
77
95
  message = reason + " [already auto-routed to `uap deliver` for this change — see .uap/autoroute.log / pending-deliver.jsonl]"
96
+ elif file_path:
97
+ message = reason + (
98
+ " [intent recorded to .uap/pending-deliver.jsonl — apply it yourself by running"
99
+ " `uap deliver` with the exact intended change as the instruction]"
100
+ )
78
101
  return {"message": message, "route": route, "spawn": spawn,
79
102
  "file_path": file_path, "hint": hint, "dedup_key": dedup_key, "intent": intent}
80
103