@yagni-app/code-staging 1.1.4-staging.1416.1 → 1.1.4-staging.1420.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.
@@ -17,7 +17,7 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
17
17
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
18
18
  import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_DRIVER_GROUNDING_FREE, YAGNI_IDENTITY_GROUNDING_FREE, YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_ULTRA_GROUNDING_FREE } from "./branding.js";
19
19
  import { claudeRulesSection } from "./claudeRules.js";
20
- import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
20
+ import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadOwnerRootFor, scratchpadSection } from "./scratchpad.js";
21
21
  import { registerCostCommand } from "./costHud.js";
22
22
  import { isDebug } from "./diagnostics.js";
23
23
  import { logEvent } from "./errorSink.js";
@@ -1339,24 +1339,69 @@ export async function registerYagni(pi, deps = {}) {
1339
1339
  // so the very first turn already carries the section. Fails closed: no
1340
1340
  // sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
1341
1341
  // failure is logged so a missing scratchpad is not silent.
1342
+ const scratchpadTmpRoot = env[SCRATCHPAD_TMPDIR_ENV] || undefined;
1342
1343
  const scratchpadDirPath = scratchpadDirFor({
1343
1344
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
1344
1345
  cwd: process.cwd(),
1345
- tmp: env[SCRATCHPAD_TMPDIR_ENV] || undefined,
1346
+ tmp: scratchpadTmpRoot,
1346
1347
  });
1347
1348
  let scratchpadSectionText;
1348
1349
  if (scratchpadDirPath) {
1349
- const ensured = ensureScratchpadDir(scratchpadDirPath);
1350
+ const ensured = ensureScratchpadDir(scratchpadDirPath, undefined, {
1351
+ onError: (errorCode) => {
1352
+ logEvent({
1353
+ source: "scratchpad",
1354
+ level: "error",
1355
+ event: "scratchpad_mkdir_failed",
1356
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
1357
+ fields: { path: scratchpadDirPath, errorCode },
1358
+ });
1359
+ },
1360
+ });
1350
1361
  if (ensured) {
1351
1362
  scratchpadSectionText = scratchpadSection(ensured);
1352
- }
1353
- else {
1354
- logEvent({
1355
- source: "scratchpad",
1356
- level: "error",
1357
- event: "scratchpad_mkdir_failed",
1358
- sessionId: env.YAGNI_SESSION_ID ?? undefined,
1359
- });
1363
+ // Claude Code parity: srt reads CLAUDE_CODE_TMPDIR from THIS process's
1364
+ // env when wrapping a sandboxed bash command and forces the child's
1365
+ // TMPDIR onto it. Point it at the per-user scratchpad OWNER ROOT (not
1366
+ // the scratchpad itself) so sandboxed-bash temp files land in the same
1367
+ // per-user tree the scratchpad lives in, under a path SHORT enough for
1368
+ // named unix-socket binds (the test-runner IPC pattern — a deep
1369
+ // scratchpad TMPDIR trips macOS's 104-char sun_path limit with EINVAL).
1370
+ // The scratchpad remains the sanctioned staging place by PRIORITY (the
1371
+ // prompt section outranks $TMPDIR), not by path convergence.
1372
+ //
1373
+ // Production note: children (subagents, /go stages) inherit this env
1374
+ // because `env` IS process.env there and the runner spawns with it. A
1375
+ // harness injecting deps.env sees the write only in that object —
1376
+ // spawned children of such a harness do not, by construction of the
1377
+ // runner's own env seam.
1378
+ //
1379
+ // Unset when there is no scratchpad (no session id, or the mkdir
1380
+ // failed) — srt keeps its /tmp/claude default and nothing changes for
1381
+ // those sessions.
1382
+ if (!env.CLAUDE_CODE_TMPDIR) {
1383
+ const ownerRoot = scratchpadOwnerRootFor({ tmp: scratchpadTmpRoot });
1384
+ env.CLAUDE_CODE_TMPDIR = ownerRoot;
1385
+ logEvent({
1386
+ source: "scratchpad",
1387
+ level: "info",
1388
+ event: "scratchpad_tmpdir_pinned",
1389
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
1390
+ fields: { path: ownerRoot },
1391
+ });
1392
+ }
1393
+ else {
1394
+ // The trail must distinguish "pinned" from "respected an operator
1395
+ // override" — no path value here (the explicit value is the
1396
+ // operator's, not ours to echo).
1397
+ logEvent({
1398
+ source: "scratchpad",
1399
+ level: "info",
1400
+ event: "scratchpad_tmpdir_pin_skipped",
1401
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
1402
+ fields: {},
1403
+ });
1404
+ }
1360
1405
  }
1361
1406
  }
1362
1407
  // The condensed Claude Code-style transcript: the seven built-ins re-render
@@ -151,6 +151,71 @@ export declare function matchesCompoundGrants(command: string, grants: readonly
151
151
  export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
152
152
  /** Human label for the remember option: "git push …". */
153
153
  export declare function describePrefix(pattern: string[]): string;
154
+ /**
155
+ * The custom-prefix field's seed (the editable input's initial value —
156
+ * Claude's initialValue): the prefix the user most plausibly wants to
157
+ * narrow. Single command → its own derived prefix; compound → the first
158
+ * PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
159
+ * suggestions name only the segments needing approval — a `cd`/read-only
160
+ * rider is not what the user is being asked about). Falls back to the raw
161
+ * command only when nothing derivable exists (Claude's editable-field
162
+ * final fallback — the user narrows from the full text). NEVER the
163
+ * multi-seed join: "cd git push grep" is not a prefix of anything and
164
+ * editing it from scratch is the dead-end the field exists to prevent.
165
+ * PURE — a throwing classify counts as seedable (the LADDER fences, the
166
+ * seed does not).
167
+ */
168
+ export declare function describeSeedPrefix(command: string, uncoveredSegments: readonly string[], policy: ExecPolicy): string;
169
+ /**
170
+ * The custom-prefix acceptance unit (the decision-(a) fill): given the
171
+ * user's typed word (as a grant) and the command it was typed for, return
172
+ * the complete remember unit — the word's grant PLUS the ladder's
173
+ * self-match-proved seeds for every REMAINING uncovered segment — or null
174
+ * when the word covers nothing, a segment stays unfillable, or the filled
175
+ * set cannot cover the whole compound. PURE — extracted from the gate so
176
+ * every exclusion branch is directly testable. The unit is ONE decision
177
+ * (saved atomically at the gate); persistence itself is per-grant
178
+ * fail-soft, the same pattern as the multi-seed remember path.
179
+ *
180
+ * The user-typed grant must already carry repoKey (it matches at evaluation
181
+ * time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
182
+ * to persist.
183
+ */
184
+ export declare function computeCustomPrefixUnit(args: {
185
+ command: string;
186
+ customGrant: ApprovedPrefixGrant;
187
+ existingGrants: readonly ApprovedPrefixGrant[];
188
+ uncoveredCount: number;
189
+ policy: ExecPolicy;
190
+ repoKey: string;
191
+ cwd: string;
192
+ /** The ladder's fill preview for this command (validateGrantForEscape's
193
+ * token seeds, repoKey/addedAt/cwd already filled) — the SAME preview the
194
+ * gate uses for the title disclosure, passed in so the ladder runs ONCE
195
+ * per ask and disclosure/save agreement is structural, not incidental.
196
+ * Absent → computed here (tests can drive the seam standalone). */
197
+ fillSeeds?: readonly ApprovedPrefixGrant[];
198
+ }): ApprovedPrefixGrant[] | null;
199
+ /**
200
+ * The custom-prefix dialog's TITLE, derived from the SEED word (the
201
+ * overwhelmingly common case: the field opens pre-filled, and the user
202
+ * keeps or edits around it). The disclosure predicts the actual unit for
203
+ * the seed — running the same acceptance seam (computeCustomPrefixUnit)
204
+ * the save will run — so the title advertises exactly what persisting the
205
+ * seed would save, minus the seed's own grant. A user who types a wildly
206
+ * different word gets the after-the-fact save-notify naming the real unit.
207
+ * Returns null when no fill rides along (single commands, or the seed
208
+ * already covers everything) — the plain title.
209
+ * PURE.
210
+ */
211
+ export declare function describeCustomPrefixFill(command: string, seed: string, args: {
212
+ existingGrants: readonly ApprovedPrefixGrant[];
213
+ uncoveredCount: number;
214
+ policy: ExecPolicy;
215
+ repoKey: string;
216
+ cwd: string;
217
+ fillSeeds: readonly ApprovedPrefixGrant[];
218
+ }): string[] | null;
154
219
  /** Preserve the command and output target before a heredoc marker. */
155
220
  export declare function heredocPrefix(command: string): string | null;
156
221
  /**
@@ -441,6 +441,144 @@ export function validateGrant(command, policy, repoKey) {
441
441
  export function describePrefix(pattern) {
442
442
  return `${pattern.join(" ")} …`;
443
443
  }
444
+ /**
445
+ * The custom-prefix field's seed (the editable input's initial value —
446
+ * Claude's initialValue): the prefix the user most plausibly wants to
447
+ * narrow. Single command → its own derived prefix; compound → the first
448
+ * PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
449
+ * suggestions name only the segments needing approval — a `cd`/read-only
450
+ * rider is not what the user is being asked about). Falls back to the raw
451
+ * command only when nothing derivable exists (Claude's editable-field
452
+ * final fallback — the user narrows from the full text). NEVER the
453
+ * multi-seed join: "cd git push grep" is not a prefix of anything and
454
+ * editing it from scratch is the dead-end the field exists to prevent.
455
+ * PURE — a throwing classify counts as seedable (the LADDER fences, the
456
+ * seed does not).
457
+ */
458
+ export function describeSeedPrefix(command, uncoveredSegments, policy) {
459
+ if (isSinglePlainCommand(command)) {
460
+ const pattern = derivePrefix(command);
461
+ if (pattern)
462
+ return pattern.join(" ");
463
+ }
464
+ // Compound (or a non-derivable single): the first prompt-band uncovered
465
+ // segment's prefix, skipping read-only riders (cd, cat, ls — the
466
+ // allow-classified segments the user is not being asked about). Each
467
+ // segment is CANONICALIZED first (the ladder's own posture): the raw
468
+ // segments arrive quote-preserving, so `git push 2>&1` reads as
469
+ // `git push 2>'&1'` — an operator-carrying string that neither
470
+ // classifies nor derives; canonicalization strips the safe redirect the
471
+ // same way matching will.
472
+ for (const seg of uncoveredSegments) {
473
+ const canonical = canonicalizeForGrants(seg);
474
+ if (!canonical || !isSinglePlainCommand(canonical))
475
+ continue;
476
+ let readonlySegment = false;
477
+ try {
478
+ readonlySegment = classifyCommand(canonical, policy).decision === "allow";
479
+ }
480
+ catch {
481
+ // classify-error counts as seedable — the ladder fences.
482
+ }
483
+ if (readonlySegment)
484
+ continue;
485
+ const pattern = derivePrefix(canonical);
486
+ if (pattern)
487
+ return pattern.join(" ");
488
+ }
489
+ return command;
490
+ }
491
+ /**
492
+ * The custom-prefix acceptance unit (the decision-(a) fill): given the
493
+ * user's typed word (as a grant) and the command it was typed for, return
494
+ * the complete remember unit — the word's grant PLUS the ladder's
495
+ * self-match-proved seeds for every REMAINING uncovered segment — or null
496
+ * when the word covers nothing, a segment stays unfillable, or the filled
497
+ * set cannot cover the whole compound. PURE — extracted from the gate so
498
+ * every exclusion branch is directly testable. The unit is ONE decision
499
+ * (saved atomically at the gate); persistence itself is per-grant
500
+ * fail-soft, the same pattern as the multi-seed remember path.
501
+ *
502
+ * The user-typed grant must already carry repoKey (it matches at evaluation
503
+ * time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
504
+ * to persist.
505
+ */
506
+ export function computeCustomPrefixUnit(args) {
507
+ const { command, customGrant, existingGrants, uncoveredCount, policy, repoKey, cwd } = args;
508
+ // Non-token (literal) custom inputs keep the single-grant proof.
509
+ if (customGrant.pattern.length === 0) {
510
+ return matchesGrant(command, [customGrant], repoKey) ? [customGrant] : null;
511
+ }
512
+ const ev = evaluateCompoundForEscape(command, [...existingGrants, customGrant], repoKey, policy);
513
+ if (ev.forbidden)
514
+ return null;
515
+ if (ev.uncovered.length === 0)
516
+ return [customGrant];
517
+ // The word must cover AT LEAST ONE segment of this command — a word
518
+ // unrelated to every segment (typed "zzz" for a pnpm compound) is a dead
519
+ // grant no fill can legitimize; refuse it. The fill exists to cover the
520
+ // SIBLINGS of the user's word, never to smuggle it in.
521
+ if (ev.uncovered.length >= uncoveredCount)
522
+ return null;
523
+ // Fill from the ladder: seeds for the remaining uncovered segments. The
524
+ // ladder's seeds are hypothetical (empty repoKey) — fill repoKey/addedAt/
525
+ // cwd BEFORE the recheck so grant matching sees them as real grants for
526
+ // THIS repo, exactly as the remember resolution does.
527
+ const fillSeeds = args.fillSeeds ?? (validateGrantForEscape(command, policy, repoKey)?.seeds ?? [])
528
+ .filter((sd) => sd.pattern.length > 0)
529
+ .map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
530
+ // A fill with no token seeds cannot complete the unit — refuse (the
531
+ // ladder's literal rung is not a token fill; the shapes it covers are
532
+ // the heredoc class, which the honest warn + re-offer handles).
533
+ if (fillSeeds.length === 0)
534
+ return null;
535
+ const recheck = evaluateCompoundForEscape(command, [...existingGrants, customGrant, ...fillSeeds], repoKey, policy);
536
+ if (recheck.forbidden || recheck.uncovered.length > 0)
537
+ return null;
538
+ // Dedupe: when the typed word is itself the derivable prefix of its
539
+ // segment (the common case — typing "git push"), the fill re-derives the
540
+ // same pattern. One grant per distinct pattern.
541
+ const seen = new Set();
542
+ const unit = [];
543
+ for (const grant of [customGrant, ...fillSeeds]) {
544
+ const key = grant.pattern.join(" ");
545
+ if (seen.has(key))
546
+ continue;
547
+ seen.add(key);
548
+ unit.push(grant);
549
+ }
550
+ return unit;
551
+ }
552
+ /**
553
+ * The custom-prefix dialog's TITLE, derived from the SEED word (the
554
+ * overwhelmingly common case: the field opens pre-filled, and the user
555
+ * keeps or edits around it). The disclosure predicts the actual unit for
556
+ * the seed — running the same acceptance seam (computeCustomPrefixUnit)
557
+ * the save will run — so the title advertises exactly what persisting the
558
+ * seed would save, minus the seed's own grant. A user who types a wildly
559
+ * different word gets the after-the-fact save-notify naming the real unit.
560
+ * Returns null when no fill rides along (single commands, or the seed
561
+ * already covers everything) — the plain title.
562
+ * PURE.
563
+ */
564
+ export function describeCustomPrefixFill(command, seed, args) {
565
+ const seedPattern = derivePrefix(seed);
566
+ if (!seedPattern)
567
+ return null;
568
+ const seedGrant = {
569
+ pattern: seedPattern,
570
+ repoKey: args.repoKey,
571
+ addedAt: new Date().toISOString(),
572
+ cwd: args.cwd,
573
+ };
574
+ const unit = computeCustomPrefixUnit({ command, customGrant: seedGrant, ...args });
575
+ if (!unit)
576
+ return null;
577
+ const fill = unit
578
+ .filter((g) => g.pattern.join(" ") !== seedPattern.join(" "))
579
+ .map((g) => g.pattern.join(" "));
580
+ return fill.length > 0 ? fill : null;
581
+ }
444
582
  // --- Escape-flow derivation (the unsandboxed-retry consent ladder) ---
445
583
  /** Preserve the command and output target before a heredoc marker. */
446
584
  export function heredocPrefix(command) {
@@ -26,7 +26,7 @@
26
26
  * When the mode leaves plan, stale plan-context messages are filtered out of
27
27
  * the context so the model doesn't keep believing it is restricted.
28
28
  */
29
- import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
29
+ import { derivePrefix, describePrefix, describeSeedPrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
30
30
  import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
31
31
  import { logEvent } from "../errorSink.js";
32
32
  import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
@@ -34,6 +34,8 @@ import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
34
34
  import { evaluateRules } from "../permissionRules/engine.js";
35
35
  import { isDebug } from "../diagnostics.js";
36
36
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
37
+ import { askCustomPrefix, CUSTOM_PREFIX_CANCELLED } from "./prefixInput.js";
38
+ import { computeCustomPrefixUnit, describeCustomPrefixFill } from "./approvedPrefixes.js";
37
39
  export function createModeHolder(initial = "auto") {
38
40
  let current = initial;
39
41
  const listeners = new Set();
@@ -1865,18 +1867,63 @@ export function registerPermissionGate(pi, deps = {}) {
1865
1867
  // dismissed/user_reject.
1866
1868
  let retryErrored = false;
1867
1869
  if (!selectThrew && choice === customLabel) {
1868
- const seedText = seeds
1869
- ? (seeds.seeds[0]?.literal ?? seeds.seeds.map((s) => s.pattern.join(" ")).join(" "))
1870
- : command;
1870
+ // The seed is the honest single suggestion (Claude's initialValue):
1871
+ // the derived prefix for a single command, the first prompt-band
1872
+ // segment's prefix for a compound — never the multi-seed join
1873
+ // ("cd git push grep" is a prefix of nothing; the join made the
1874
+ // field unrecognizable AND uneditable from a useful base).
1875
+ const seedText = describeSeedPrefix(command, compound.uncovered, execPolicy);
1876
+ // The title DISCLOSES the fill up front (informed consent, no extra
1877
+ // click) — derived from the SEED word: the field opens pre-filled,
1878
+ // so the disclosure predicts the actual unit the acceptance seam
1879
+ // would save for that seed (sibling families only, minus the seed's
1880
+ // own grant). A user who types a wildly different word still gets the
1881
+ // after-the-fact save-notify naming the real unit. ONE ladder preview
1882
+ // feeds both the title and the seam's fill (the gate computes it;
1883
+ // computeCustomPrefixUnit consumes it) — the ladder runs once per
1884
+ // ask, and disclosure/save agreement is structural.
1885
+ const fillPreview = (validateGrantForEscape(command, execPolicy, repoKey)?.seeds ?? [])
1886
+ .filter((sd) => sd.pattern.length > 0)
1887
+ .map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
1888
+ const fillLabels = describeCustomPrefixFill(command, seedText, {
1889
+ existingGrants: grants,
1890
+ uncoveredCount: compound.uncovered.length,
1891
+ policy: execPolicy,
1892
+ repoKey,
1893
+ cwd,
1894
+ fillSeeds: fillPreview,
1895
+ });
1896
+ const unitTitle = fillLabels !== null
1897
+ ? `Don't ask again for commands starting with (also saving: ${fillLabels.join(", ")})`
1898
+ : "Don't ask again for commands starting with";
1871
1899
  let custom;
1900
+ let cancelledInput = false;
1872
1901
  let inputThrew = false;
1873
1902
  try {
1874
- custom = await ctx.ui.input("Don't ask again for commands starting with", seedText, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
1903
+ const resolution = await askCustomPrefix(ctx, unitTitle, seedText);
1904
+ if (resolution === CUSTOM_PREFIX_CANCELLED)
1905
+ cancelledInput = true;
1906
+ else if (resolution !== undefined)
1907
+ custom = resolution;
1875
1908
  }
1876
- catch {
1909
+ catch (err) {
1877
1910
  inputThrew = true;
1911
+ // Same discipline as every sibling dialog failure in this flow —
1912
+ // a thrown ask (the fallback ctx.ui.input included) must leave a
1913
+ // trail, error class only (never the thrown message). The phase
1914
+ // discriminator is a stable non-message field so multiple ask
1915
+ // failure lines in one turn are tellable apart.
1916
+ logEvent({
1917
+ source: "permission-rules",
1918
+ level: "warn",
1919
+ event: "gate_dialog_error",
1920
+ fields: {
1921
+ error: err instanceof Error ? err.constructor.name : typeof err,
1922
+ phase: "custom-ask",
1923
+ },
1924
+ });
1878
1925
  }
1879
- if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
1926
+ if (!inputThrew && !cancelledInput && custom !== undefined && custom.trim().length > 0) {
1880
1927
  const trimmedCustom = custom.trim();
1881
1928
  // The custom field accepts two shapes:
1882
1929
  // (a) a derivable token pattern — a clean command word (`npx`,
@@ -1897,24 +1944,50 @@ export function registerPermissionGate(pi, deps = {}) {
1897
1944
  const customGrant = tokenPattern
1898
1945
  ? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
1899
1946
  : { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
1900
- // Validation must be compound-aware: the escaped command is often a
1901
- // compound (`S=…; curl && echo done`), and matchesGrant alone can
1902
- // never match a single token grant against a compound. A bare-word
1903
- // token grant is valid when EVERY segment of the compound is covered
1904
- // by the token grant or an existing grant — exactly the ladder's
1905
- // rung-1 self-match, applied to the user's custom word.
1906
- const customValid = tokenPattern
1907
- ? (() => {
1908
- const evalPolicy = deps.policy?.execPolicy ?? DEFAULT_EXEC_POLICY;
1909
- const ev = evaluateCompoundForEscape(command, [...grants, customGrant], repoKey, evalPolicy);
1910
- return !ev.forbidden && ev.uncovered.length === 0;
1911
- })()
1912
- : matchesGrant(command, [customGrant], repoKey);
1913
- if (customValid) {
1914
- grants.push(customGrant);
1915
- persistGrantFailSoft(customGrant);
1947
+ // Compound acceptance is FILLING, not all-or-nothing (Claude
1948
+ // parity their compound per-subcommand rules save as one
1949
+ // decision; our analog: the custom word is the grant for the
1950
+ // segment it covers, and the ladder's own self-match-proved seeds
1951
+ // fill every REMAINING uncovered segment). Under the old
1952
+ // all-or-nothing validation a bare word could NEVER satisfy a
1953
+ // compound (the sibling segments stayed uncovered), so an
1954
+ // honestly-answered field was rejected and the ask re-fired
1955
+ // forever the live failure this flow fixes. The unit is ONE
1956
+ // decision saved here; persistence itself is per-grant fail-soft,
1957
+ // the same pattern as the multi-seed remember path (a mid-loop
1958
+ // persist failure leaves earlier grants saved — the warn trail
1959
+ // records it). The unit computation is PURE and every exclusion
1960
+ // branch is unit-tested directly (computeCustomPrefixUnit); a
1961
+ // segment the ladder cannot seed (heredoc/quoted shapes) still
1962
+ // refuses: fail closed, warn honestly, one re-offer (below).
1963
+ const customUnit = computeCustomPrefixUnit({
1964
+ command,
1965
+ customGrant,
1966
+ existingGrants: grants,
1967
+ uncoveredCount: compound.uncovered.length,
1968
+ policy: execPolicy,
1969
+ repoKey,
1970
+ cwd,
1971
+ fillSeeds: fillPreview,
1972
+ });
1973
+ if (customUnit) {
1974
+ for (const grantRecord of customUnit) {
1975
+ grants.push(grantRecord);
1976
+ persistGrantFailSoft(grantRecord);
1977
+ }
1916
1978
  rememberApproved(cwd, command);
1917
1979
  emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
1980
+ // Save-confirmation: the old flow's only success feedback was
1981
+ // silence, leaving the user asking "did it work?". Name what was
1982
+ // saved — the unit (custom word + filled siblings) — one line,
1983
+ // info tier, fail-soft.
1984
+ if (ctx.hasUI) {
1985
+ try {
1986
+ const savedLabels = customUnit.map((g) => g.pattern.length > 0 ? g.pattern.join(" ") : (g.literal ?? ""));
1987
+ ctx.ui.notify(`Saved "don't ask again" for: ${savedLabels.join(", ")}`, "info");
1988
+ }
1989
+ catch { /* notify must never gate */ }
1990
+ }
1918
1991
  return {};
1919
1992
  }
1920
1993
  // A custom prefix that does not cover this command is a dead rule —
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The custom-prefix field (the escape consent flow's editable input):
3
+ * pi's OWN shipped ExtensionInputComponent with exactly one delta — the
4
+ * input is pre-filled with the derived seed (Claude's initialValue; pi's
5
+ * stock component drops its placeholder, so the old `ctx.ui.input` dialog
6
+ * arrived empty and unlabeled while the turn's working indicator kept
7
+ * animating — a blocked turn cosplaying as a live one, the ticket's
8
+ * "the model went back to work" symptom).
9
+ *
10
+ * The gate's askCustomPrefix seam: TUI mounts the seeded surface via
11
+ * ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
12
+ * falls back to plain ctx.ui.input, where the corrected seed rides the
13
+ * placeholder (desktop's DialogCard renders it). ctx.mode is a hard
14
+ * guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
15
+ * so an unguarded call would read as a cancelled input. A THROWN or
16
+ * no-mount custom() falls back to the plain input dialog WITH a
17
+ * gate_dialog_error trail line (error class only) — a broken mount must
18
+ * degrade visibly, not silently.
19
+ */
20
+ import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
21
+ import type { Component } from "@earendil-works/pi-tui";
22
+ /** pi's input dialog, seeded. Subclass — not a reimplementation. */
23
+ export declare class SeededExtensionInput extends ExtensionInputComponent {
24
+ constructor(title: string, seed: string, onSubmit: (value: string) => void, onCancel: () => void);
25
+ }
26
+ /**
27
+ * The mount's resolution. A plain string is the typed prefix; CANCELLED is
28
+ * the user's Esc — DISTINCT from undefined, which pi's custom() resolves
29
+ * with when the harness has no custom mount (a bare `return undefined`
30
+ * path in rpc/custom-less hosts). Conflating the two made cancel re-open
31
+ * the plain input dialog (the cancel-twice bug).
32
+ */
33
+ export declare const CUSTOM_PREFIX_CANCELLED: unique symbol;
34
+ export type CustomPrefixResolution = string | typeof CUSTOM_PREFIX_CANCELLED | undefined;
35
+ /**
36
+ * The PRODUCTION submit mapping (shared, not duplicated): pi's component
37
+ * submits the raw field; this wrapper trims and maps an emptied field to
38
+ * the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
39
+ * gate's askCustomPrefix mount and the live-mount test install the SAME
40
+ * function — a production regression breaks both, per the review round.
41
+ */
42
+ export declare function mapCustomPrefixSubmit(value: string): string | typeof CUSTOM_PREFIX_CANCELLED;
43
+ type CustomMount = <T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (r: T) => void) => Component) => Promise<T>;
44
+ interface AskCustomPrefixCtx {
45
+ mode?: string;
46
+ hasUI?: boolean;
47
+ signal?: AbortSignal;
48
+ ui: {
49
+ input: (title: string, placeholder?: string, opts?: {
50
+ signal?: AbortSignal;
51
+ }) => Promise<string | undefined>;
52
+ custom?: CustomMount;
53
+ setWorkingVisible?: (visible: boolean) => void;
54
+ };
55
+ }
56
+ export declare function askCustomPrefix(ctx: AskCustomPrefixCtx, title: string, seed: string): Promise<CustomPrefixResolution>;
57
+ export {};
58
+ //# sourceMappingURL=prefixInput.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The custom-prefix field (the escape consent flow's editable input):
3
+ * pi's OWN shipped ExtensionInputComponent with exactly one delta — the
4
+ * input is pre-filled with the derived seed (Claude's initialValue; pi's
5
+ * stock component drops its placeholder, so the old `ctx.ui.input` dialog
6
+ * arrived empty and unlabeled while the turn's working indicator kept
7
+ * animating — a blocked turn cosplaying as a live one, the ticket's
8
+ * "the model went back to work" symptom).
9
+ *
10
+ * The gate's askCustomPrefix seam: TUI mounts the seeded surface via
11
+ * ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
12
+ * falls back to plain ctx.ui.input, where the corrected seed rides the
13
+ * placeholder (desktop's DialogCard renders it). ctx.mode is a hard
14
+ * guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
15
+ * so an unguarded call would read as a cancelled input. A THROWN or
16
+ * no-mount custom() falls back to the plain input dialog WITH a
17
+ * gate_dialog_error trail line (error class only) — a broken mount must
18
+ * degrade visibly, not silently.
19
+ */
20
+ import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
21
+ import { logEvent } from "../errorSink.js";
22
+ /** pi's input dialog, seeded. Subclass — not a reimplementation. */
23
+ export class SeededExtensionInput extends ExtensionInputComponent {
24
+ constructor(title, seed, onSubmit, onCancel) {
25
+ super(title, undefined, onSubmit, onCancel, undefined);
26
+ // The seed is the EDITABLE INITIAL VALUE (Claude's initialValue), not a
27
+ // placeholder: the user narrows from a real suggestion. The only delta
28
+ // from pi's component.
29
+ this.input.setValue(seed);
30
+ }
31
+ }
32
+ /**
33
+ * The mount's resolution. A plain string is the typed prefix; CANCELLED is
34
+ * the user's Esc — DISTINCT from undefined, which pi's custom() resolves
35
+ * with when the harness has no custom mount (a bare `return undefined`
36
+ * path in rpc/custom-less hosts). Conflating the two made cancel re-open
37
+ * the plain input dialog (the cancel-twice bug).
38
+ */
39
+ export const CUSTOM_PREFIX_CANCELLED = Symbol("custom-prefix-cancelled");
40
+ /**
41
+ * The PRODUCTION submit mapping (shared, not duplicated): pi's component
42
+ * submits the raw field; this wrapper trims and maps an emptied field to
43
+ * the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
44
+ * gate's askCustomPrefix mount and the live-mount test install the SAME
45
+ * function — a production regression breaks both, per the review round.
46
+ */
47
+ export function mapCustomPrefixSubmit(value) {
48
+ const trimmed = value.trim();
49
+ return trimmed.length > 0 ? trimmed : CUSTOM_PREFIX_CANCELLED;
50
+ }
51
+ export async function askCustomPrefix(ctx, title, seed) {
52
+ const fallbackInput = () => ctx.ui.input(title, seed, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
53
+ // A dialog waiting on the human must not animate a working spinner —
54
+ // the ticket's "turn resumed" illusion. Suppress while the dialog is
55
+ // up; restore ONLY if the suppression call actually exists and ran
56
+ // (a host without the API — RPC — has no spinner to restore). Fail-soft
57
+ // both ways: UI calls never gate the consent flow.
58
+ let spinnerSuppressed = false;
59
+ try {
60
+ ctx.ui.setWorkingVisible?.(false);
61
+ spinnerSuppressed = true;
62
+ }
63
+ catch { /* UI must never gate */ }
64
+ try {
65
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
66
+ try {
67
+ const result = await ctx.ui.custom((_tui, _theme, _kb, done) => {
68
+ return new SeededExtensionInput(title, seed, (value) => done(mapCustomPrefixSubmit(value)), () => done(CUSTOM_PREFIX_CANCELLED));
69
+ });
70
+ // undefined = the harness has NO custom mount (pi resolves
71
+ // custom() with undefined there) — fall back to the plain input.
72
+ // A real cancel arrives as the sentinel, never undefined.
73
+ if (result !== undefined)
74
+ return result;
75
+ return await fallbackInput();
76
+ }
77
+ catch (err) {
78
+ // A THROWN custom() is a broken mount degrading every escape ask —
79
+ // never silent. Error class only (never the thrown message; it can
80
+ // carry dialog-layer content), mirroring the gate's gate_dialog_error.
81
+ logEvent({
82
+ source: "permission-rules",
83
+ level: "warn",
84
+ event: "gate_dialog_error",
85
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err, phase: "custom-mount" },
86
+ });
87
+ return await fallbackInput();
88
+ }
89
+ }
90
+ return await fallbackInput();
91
+ }
92
+ finally {
93
+ if (spinnerSuppressed) {
94
+ try {
95
+ ctx.ui.setWorkingVisible?.(true);
96
+ }
97
+ catch { /* UI must never gate */ }
98
+ }
99
+ }
100
+ }
101
+ //# sourceMappingURL=prefixInput.js.map
@@ -118,8 +118,10 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
118
118
  const heredocNote = "Caveat: heredocs (<<EOF) write temp files relative to the current directory on macOS bash 3.2 — from an unwritable cwd they fail; prefer printf or redirect from a file in the working directory.";
119
119
  // $TMPDIR guidance (Claude Code parity, always-on): the sandbox sets
120
120
  // TMPDIR to the per-user sandbox-writable temp dir; /tmp itself is NOT
121
- // writable.
122
- const tmpdirNote = "For temporary files, always use the $TMPDIR environment variable. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use /tmp directly - use $TMPDIR instead.";
121
+ // writable. When the system prompt names a scratchpad directory, that
122
+ // directory takes priority for temporary files stated here so the two
123
+ // guidance lines never read as mutually exclusive always-rules.
124
+ const tmpdirNote = "For temporary files, use the $TMPDIR environment variable unless your system prompt names a scratchpad directory — the scratchpad takes priority for temporary files. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use /tmp directly - use $TMPDIR instead.";
123
125
  const wrapped = {
124
126
  ...def,
125
127
  description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}`,
@@ -48,6 +48,18 @@ export declare function scratchpadDir(opts?: {
48
48
  uid?: number;
49
49
  tmp?: string;
50
50
  }): string | null;
51
+ /**
52
+ * PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
53
+ * `scratchpadDir` resolves from (never derived by stripping segments off a
54
+ * built path — a layout change would silently shift a stripped spelling).
55
+ * The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
56
+ * across sessions, and short enough for named unix-socket binds where the
57
+ * deep scratchpad path would trip macOS's 104-char sun_path limit.
58
+ */
59
+ export declare function scratchpadOwnerRootFor(opts?: {
60
+ uid?: number;
61
+ tmp?: string;
62
+ }): string;
51
63
  /**
52
64
  * IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
53
65
  * the path on success and null on failure — a null result means "no scratchpad
@@ -56,7 +68,9 @@ export declare function scratchpadDir(opts?: {
56
68
  export declare function ensureScratchpadDir(path: string, mkdir?: (p: string, o: {
57
69
  mode: number;
58
70
  recursive: boolean;
59
- }) => void): string | null;
71
+ }) => void, opts?: {
72
+ onError?: (errorCode: string) => void;
73
+ }): string | null;
60
74
  /**
61
75
  * PURE: the prompt section naming the scratchpad. Gated by the caller on the
62
76
  * dir existing; when present, it tells the agent where to put intermediate
@@ -61,17 +61,39 @@ export function scratchpadDir(opts = {}) {
61
61
  const cwd = sanitizeCwdSegment(opts.cwd ?? ".");
62
62
  return join(tmp, scratchpadOwnerDir(uid), cwd, sessionId, "scratchpad");
63
63
  }
64
+ /**
65
+ * PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
66
+ * `scratchpadDir` resolves from (never derived by stripping segments off a
67
+ * built path — a layout change would silently shift a stripped spelling).
68
+ * The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
69
+ * across sessions, and short enough for named unix-socket binds where the
70
+ * deep scratchpad path would trip macOS's 104-char sun_path limit.
71
+ */
72
+ export function scratchpadOwnerRootFor(opts = {}) {
73
+ const tmp = opts.tmp ?? tmpdir();
74
+ const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() ?? 0 : 0);
75
+ return join(tmp, scratchpadOwnerDir(uid));
76
+ }
64
77
  /**
65
78
  * IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
66
79
  * the path on success and null on failure — a null result means "no scratchpad
67
80
  * this session", which the caller turns into an omitted prompt section.
68
81
  */
69
- export function ensureScratchpadDir(path, mkdir = mkdirSync) {
82
+ export function ensureScratchpadDir(path, mkdir = mkdirSync, opts = {}) {
70
83
  try {
71
84
  mkdir(path, { recursive: true, mode: 0o700 });
72
85
  return path;
73
86
  }
74
- catch {
87
+ catch (err) {
88
+ // The errno CODE (never the thrown message — the message can carry a
89
+ // path or system detail, and the caller forwards this to the sink).
90
+ // fs errors are plain Error instances whose distinguishing value is
91
+ // .code (ENOTDIR vs EACCES vs EPERM...); the constructor name would
92
+ // always read "Error" and distinguish nothing. The fallback keeps the
93
+ // class name for codeless throws (a non-fs throw), so the callback's
94
+ // value is "errno code, or class name when there is no code".
95
+ const code = err?.code;
96
+ opts.onError?.(code ?? (err instanceof Error ? err.constructor.name : typeof err));
75
97
  return null;
76
98
  }
77
99
  }
@@ -82,12 +104,13 @@ export function ensureScratchpadDir(path, mkdir = mkdirSync) {
82
104
  */
83
105
  export function scratchpadSection(dir) {
84
106
  return ("# Scratchpad directory\n\n" +
85
- `Use this session scratchpad directory for files that do not belong in the user's project:\n` +
107
+ `IMPORTANT: Always use this scratchpad directory for temporary files instead of /tmp or other system temp directories (including $TMPDIR in sandboxed bash):\n` +
86
108
  `${dir}\n\n` +
109
+ "Use this directory for ALL temporary file needs:\n" +
87
110
  "- Store intermediate results or data during multi-step tasks.\n" +
88
111
  "- Write temporary scripts or configuration files.\n" +
89
112
  "- Save outputs that don't belong in the user's project.\n" +
90
- "- Anything that would otherwise go to /tmp.\n\n" +
113
+ "- Staging files for subagents: paths in this directory are readable by every subagent of this session, so hand subagents scratchpad paths rather than $TMPDIR or /tmp paths.\n\n" +
91
114
  "The directory is session-specific and isolated from the user's project.");
92
115
  }
93
116
  //# sourceMappingURL=scratchpad.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.4-staging.1416.1",
3
+ "version": "1.1.4-staging.1420.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)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "f17aa2f61573701f8a9b62da1d99ca022b96a7bd"
61
+ "yagniSourceSha": "651ed1d5bda0a76c0ce1257e0dc1a47769e2fe5c"
62
62
  }