@yagni-app/code-staging 1.1.4-staging.1419.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.
- package/dist/extension/permission/approvedPrefixes.d.ts +65 -0
- package/dist/extension/permission/approvedPrefixes.js +138 -0
- package/dist/extension/permission/gate.js +96 -23
- package/dist/extension/permission/prefixInput.d.ts +58 -0
- package/dist/extension/permission/prefixInput.js +101 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.4-staging.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "651ed1d5bda0a76c0ce1257e0dc1a47769e2fe5c"
|
|
62
62
|
}
|