@yagni-app/code-staging 1.0.1-staging.1195.1 → 1.0.1-staging.1201.1

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.
@@ -12,7 +12,7 @@ export interface Citation {
12
12
  * AskStanding — mirrored locally by this extension's no-workspace-imports
13
13
  * convention, see costHud.ts).
14
14
  */
15
- export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position";
15
+ export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position" | "contested";
16
16
  /** One line the TUI shows above the answer, per standing. */
17
17
  export declare const STANDING_LINES: Record<AskStanding, string>;
18
18
  /** Options for {@link makeAskYagniTool}. */
@@ -10,6 +10,7 @@ export const STANDING_LINES = {
10
10
  asserted: "Grounded in a recorded assumption, not yet verified",
11
11
  inferred: "Inferred from workspace context, not a recorded decision",
12
12
  no_position: "No recorded position in this workspace",
13
+ contested: "Two recorded decisions conflict — escalate to a human",
13
14
  };
14
15
  function standingLine(value) {
15
16
  return typeof value === "string" && value in STANDING_LINES
@@ -141,11 +142,29 @@ export function makeAskYagniTool(opts) {
141
142
  text = `${text}\n\n${suggestion}`;
142
143
  }
143
144
  }
145
+ // P0 (Run 8 review): a contested answer must reach the MODEL, not just
146
+ // the TUI. The backend composes an ordinary answer and only overrides
147
+ // standing client-side, so without this the coding agent can act on one
148
+ // conflicting side without ever seeing the do-not-proceed instruction.
149
+ // The refusal + both positions are appended VERBATIM to tool content.
150
+ if (data.standing === "contested") {
151
+ const lines = [];
152
+ if (data.contestedInstruction)
153
+ lines.push(data.contestedInstruction);
154
+ for (const p of data.contestedPositions ?? []) {
155
+ lines.push(`- (${p.id}) ${p.question}: ${p.decision}`);
156
+ }
157
+ if (lines.length > 0) {
158
+ text = `${data.answer}\n\n${lines.join("\n")}`;
159
+ }
160
+ }
144
161
  return {
145
162
  content: [{ type: "text", text }],
146
163
  details: {
147
164
  citations: data.citations ?? [],
148
165
  ...(data.standing ? { standing: data.standing } : {}),
166
+ ...(data.contestedInstruction ? { contestedInstruction: data.contestedInstruction } : {}),
167
+ ...(data.contestedPositions ? { contestedPositions: data.contestedPositions } : {}),
149
168
  },
150
169
  };
151
170
  },
@@ -40,7 +40,7 @@ import { registerDecisionCommands } from "./decisions.js";
40
40
  import { makeDecisionCapture } from "./decisionCapture.js";
41
41
  import { registerAmbientRecall } from "./recall.js";
42
42
  import { resilientFetch } from "./resilientFetch.js";
43
- import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
43
+ import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
44
44
  import { flushSpool as defaultFlushSpool } from "./spool.js";
45
45
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
46
46
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
@@ -332,7 +332,10 @@ export async function registerYagni(pi, deps = {}) {
332
332
  const { event: _ignored, ...fields } = payload;
333
333
  logEvent({
334
334
  source: "guardian",
335
- level: event === "guardian_event_post_failed" ? "error" : "info",
335
+ // `malformed` is a real failure (this is the "unclear verdict" bug the
336
+ // whole capture exists to diagnose) — an error, not routine info. The
337
+ // failed-telemetry event is already an error.
338
+ level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
336
339
  event,
337
340
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
338
341
  fields,
@@ -357,7 +360,43 @@ export async function registerYagni(pi, deps = {}) {
357
360
  guardianTier,
358
361
  guardianDisabled,
359
362
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
360
- onGuardianReview: (ev) => guardianLogSink(ev),
363
+ onGuardianReview: (ev) => {
364
+ guardianLogSink(ev);
365
+ // A malformed verdict is a real failure on our side that must reach
366
+ // Sentry for EVERY workspace (independent of the opt-in storage tier).
367
+ // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
368
+ // suppressed under test/eval so a CI run never phones prod.
369
+ if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
370
+ void (async () => {
371
+ try {
372
+ const body = {
373
+ outcome: ev.outcome,
374
+ ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
375
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
376
+ ...(ev.tier ? { tier: ev.tier } : {}),
377
+ };
378
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-error`, {
379
+ method: "POST",
380
+ headers: {
381
+ "content-type": "application/json",
382
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
383
+ ...attributionHeaders(deps.env),
384
+ },
385
+ body: JSON.stringify(body),
386
+ }, {
387
+ fetchImpl: authedFetch,
388
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
389
+ });
390
+ if (!res.ok) {
391
+ guardianLogSink({ event: "guardian_error_post_failed", status: res.status });
392
+ }
393
+ }
394
+ catch {
395
+ guardianLogSink({ event: "guardian_error_post_failed", kind: "network" });
396
+ }
397
+ })();
398
+ }
399
+ },
361
400
  grants: sessionGrants,
362
401
  resolveRepoKey,
363
402
  persistGrant: (grant) => {
@@ -561,13 +561,14 @@ export function registerPermissionGate(pi, deps = {}) {
561
561
  ctx.ui.setStatus?.("yagni-guardian", undefined);
562
562
  }
563
563
  const durationMs = Date.now() - startMs;
564
- const emitDiag = (outcome, rationale) => {
564
+ const emitDiag = (outcome, rationale, rawOutput) => {
565
565
  if (!deps.onGuardianReview)
566
566
  return;
567
567
  void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
568
568
  durationMs,
569
569
  tier: guardianTier,
570
570
  ...(rationale ? { rationale } : {}),
571
+ ...(rawOutput ? { rawOutput } : {}),
571
572
  debug: isDebug(),
572
573
  }))).catch(() => { });
573
574
  };
@@ -734,7 +735,7 @@ export function registerPermissionGate(pi, deps = {}) {
734
735
  }
735
736
  // Guardian failed (timeout/malformed/network/empty/aborted).
736
737
  const error = reviewResult.error ?? "network";
737
- emitDiag(error);
738
+ emitDiag(error, undefined, reviewResult.rawOutput);
738
739
  if (error === "aborted" || ctx?.signal?.aborted) {
739
740
  // The user aborted mid-consult — silent block: no dialog, no
740
741
  // "Guardian unavailable" warning on a turn they deliberately
@@ -106,7 +106,20 @@ export interface ReviewResult {
106
106
  verdict: GuardianVerdict | null;
107
107
  error?: GuardianError;
108
108
  cost: number;
109
+ /**
110
+ * Scrubbed + capped copy of the model output when the verdict failed to
111
+ * parse (`error: "malformed"`). Present so the sink can capture the exact
112
+ * failure shape. Never contains the raw command unredacted: `scrubSecrets`
113
+ * removes secret-shaped values before this is stored.
114
+ */
115
+ rawOutput?: string;
109
116
  }
117
+ /**
118
+ * Cap on the malformed-output capture. The model's unparseable verdict is
119
+ * usually a single short JSON-ish blob; this bounds a pathological output so
120
+ * it cannot balloon a log line or a Sentry payload.
121
+ */
122
+ export declare const GUARDIAN_RAW_OUTPUT_CAP = 2048;
110
123
  export interface ReviewCommandDeps {
111
124
  runStage?: typeof defaultRunStage;
112
125
  cwd: string;
@@ -143,6 +156,12 @@ export interface GuardianDiagnosticEvent {
143
156
  commandHash?: string;
144
157
  /** Debug-only: the Guardian's rationale. */
145
158
  rationale?: string;
159
+ /**
160
+ * Scrubbed + capped copy of the unparseable model output, present only for
161
+ * `outcome: "malformed"`. Always-on (NOT debug-gated): it is already
162
+ * `scrubSecrets`-redacted and size-capped at the source.
163
+ */
164
+ rawOutput?: string;
146
165
  }
147
166
  /**
148
167
  * Create a sanitized diagnostic event. Never includes the raw command text
@@ -153,6 +172,7 @@ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | Guardian
153
172
  tier?: string;
154
173
  rationale?: string;
155
174
  commandHash?: string;
175
+ rawOutput?: string;
156
176
  debug?: boolean;
157
177
  }): GuardianDiagnosticEvent;
158
178
  //# sourceMappingURL=guardian.d.ts.map
@@ -28,6 +28,7 @@
28
28
  * alternating deny/ask.
29
29
  */
30
30
  import { runStage as defaultRunStage } from "../pipeline/runner.js";
31
+ import { scrubSecrets } from "../pipeline/scrubSecrets.js";
31
32
  export const DEFAULT_GUARDIAN_LIMITS = {
32
33
  maxReviews: 120,
33
34
  maxConsecutiveDenials: 3,
@@ -161,6 +162,12 @@ export function formatGuardianSubtotal(state, limits) {
161
162
  const plural = state.reviews === 1 ? "review" : "reviews";
162
163
  return `Guardian: ${state.reviews} ${plural}.`;
163
164
  }
165
+ /**
166
+ * Cap on the malformed-output capture. The model's unparseable verdict is
167
+ * usually a single short JSON-ish blob; this bounds a pathological output so
168
+ * it cannot balloon a log line or a Sentry payload.
169
+ */
170
+ export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
164
171
  /**
165
172
  * The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
166
173
  * (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
@@ -231,7 +238,14 @@ export async function reviewCommand(command, deps) {
231
238
  }
232
239
  const verdict = parseVerdict(output);
233
240
  if (!verdict) {
234
- return { verdict: null, error: "malformed", cost };
241
+ // Scrubbed + capped so the local sink and Sentry can see the exact
242
+ // failure shape without carrying a raw command or a secret it echoed.
243
+ return {
244
+ verdict: null,
245
+ error: "malformed",
246
+ cost,
247
+ rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
248
+ };
235
249
  }
236
250
  return { verdict, cost };
237
251
  }
@@ -261,6 +275,7 @@ export function buildDiagnosticEvent(outcome, opts) {
261
275
  outcome,
262
276
  ...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
263
277
  ...(opts.tier !== undefined ? { tier: opts.tier } : {}),
278
+ ...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
264
279
  };
265
280
  if (opts.debug) {
266
281
  if (opts.rationale)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.1-staging.1195.1",
3
+ "version": "1.0.1-staging.1201.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -41,5 +41,5 @@
41
41
  "turndown": "^7.2.4",
42
42
  "typebox": "^1.3.15"
43
43
  },
44
- "yagniSourceSha": "94552f071c671de30adbf48b73c34999e9c831d2"
44
+ "yagniSourceSha": "a096635384d1ff496d9cd4bd15611c0b2b1a6601"
45
45
  }