gentle-pi 4.1.0 → 4.2.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.
- package/README.md +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
2
|
-
import { recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
2
|
+
import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
3
3
|
import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts";
|
|
4
4
|
import { blockChildDestructiveCommand } from "./child-safety.ts";
|
|
5
5
|
import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
|
|
@@ -1098,9 +1098,11 @@ function homeRootedRepository(cwd: string): string | undefined {
|
|
|
1098
1098
|
* `NativeReviewModeStatus`) that the `/gentle:review-mode` command also
|
|
1099
1099
|
* calls. Never throws and never hangs past `signal`'s deadline when one is
|
|
1100
1100
|
* given: an absent binary, a timed-out/aborted process, or a native CLI
|
|
1101
|
-
* failure all resolve to `undefined
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1101
|
+
* failure all resolve to `undefined` unless an earlier read for the same cwd
|
|
1102
|
+
* succeeded, in which case that last known status is kept (gentle-shell#1029:
|
|
1103
|
+
* the rendered line sits in the cached prompt prefix). `ctx` is optional and
|
|
1104
|
+
* used only for a one-shot (per process) UI notice when the read is
|
|
1105
|
+
* swallowed, so a sustained native outage is observable beyond the prompt.
|
|
1104
1106
|
*/
|
|
1105
1107
|
async function resolveRddModeStatus(
|
|
1106
1108
|
nativeReviewCli: Pick<NativeReviewCli, "reviewMode"> | null | undefined,
|
|
@@ -1112,13 +1114,19 @@ async function resolveRddModeStatus(
|
|
|
1112
1114
|
const nowMs = now();
|
|
1113
1115
|
const cached = rddStatusMemo.get(cwd);
|
|
1114
1116
|
if (cached !== undefined && cached.expiresAt > nowMs) return cached.status;
|
|
1115
|
-
|
|
1117
|
+
// gentle-shell#1029: the rendered line is part of the cached system-prompt
|
|
1118
|
+
// prefix. A re-read that fails or times out keeps the last successfully
|
|
1119
|
+
// read status instead of flipping the prompt to "unknown" and back, which
|
|
1120
|
+
// would invalidate the provider's prefix cache for the whole conversation.
|
|
1121
|
+
// Only a status the native reader actually reports replaces a known one.
|
|
1122
|
+
const read = await readRddModeStatusOnce(nativeReviewCli, cwd, signal);
|
|
1123
|
+
const status = read ?? cached?.status;
|
|
1116
1124
|
rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS });
|
|
1117
|
-
if (
|
|
1125
|
+
if (read === undefined && !rddStatusUnavailableWarned) {
|
|
1118
1126
|
rddStatusUnavailableWarned = true;
|
|
1119
1127
|
if (ctx?.hasUI) {
|
|
1120
1128
|
ctx.ui.notify(
|
|
1121
|
-
"Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\"
|
|
1129
|
+
"Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt keeps the last known status, or renders \"unknown\" when none was read; this notice will not repeat this session.",
|
|
1122
1130
|
"warning",
|
|
1123
1131
|
);
|
|
1124
1132
|
}
|
|
@@ -1306,6 +1314,14 @@ ${personaPrompt}
|
|
|
1306
1314
|
|
|
1307
1315
|
${languageBoundary}
|
|
1308
1316
|
|
|
1317
|
+
## Authorized work continuity
|
|
1318
|
+
|
|
1319
|
+
An authorized outcome includes its necessary in-scope implementation, documentation and verification steps. Continue those steps without asking the human to approve each transition. Never end a turn merely announcing an immediately executable authorized next step; execute it instead. A progress update is not a request for permission.
|
|
1320
|
+
|
|
1321
|
+
Stop only for an unresolved consequential scope/product decision, required explicit consent, a denied action, unavailable required proof, or the human's explicit stop. Do not infer permission for publishing, destructive actions, unrelated scope or native consent from this continuity rule. Report a concrete blocker and the exact decision needed rather than requesting a generic "continue".
|
|
1322
|
+
|
|
1323
|
+
For background work, launch the bounded action and end the turn when no independent in-scope work remains. Retain its identity and resume the already-authorized continuation automatically on its completion event; do not ask the human to say "continue", poll, or claim completion while checks remain pending.
|
|
1324
|
+
|
|
1309
1325
|
Default workflow: Organic Driven Development (MANDATORY)
|
|
1310
1326
|
Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request:
|
|
1311
1327
|
1. **Authorize.** Investigation, explanation, review, comparison, and proposal-only requests stay read-only: no writer, apply, or implementation artifacts. Ambiguous or conditional change intent (unclear whether a change is authorized at all) gets one clarification; stop and wait. A user saying they may stop you or resume later asks for notes and separate commits, not a stop.
|
|
@@ -1333,7 +1349,9 @@ ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine, hostMode)}`;
|
|
|
1333
1349
|
|
|
1334
1350
|
// Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
|
|
1335
1351
|
// between `git` and the subcommand. Short flags may be followed by a separate value token.
|
|
1336
|
-
|
|
1352
|
+
// gentle-shell#2172: `\s+` rather than a literal space, so a tab-separated
|
|
1353
|
+
// `git\tpush` is guarded like any other spelling.
|
|
1354
|
+
const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)*\s+`;
|
|
1337
1355
|
const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`);
|
|
1338
1356
|
|
|
1339
1357
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
@@ -1399,7 +1417,7 @@ interface LoadGuardrailsOptions {
|
|
|
1399
1417
|
|
|
1400
1418
|
const GUARDED_KEY_PATTERNS: Record<GuardedCommandKey, RegExp> = {
|
|
1401
1419
|
gitPush: GIT_PUSH_RE,
|
|
1402
|
-
gitRebase:
|
|
1420
|
+
gitRebase: new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(rebase)\b`),
|
|
1403
1421
|
gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/,
|
|
1404
1422
|
npmPublish: /\bnpm\s+(publish)\b/,
|
|
1405
1423
|
piRemove: /\bpi\s+(remove)\b/,
|
|
@@ -1441,12 +1459,15 @@ function collectGuardedMatches(
|
|
|
1441
1459
|
config: RuntimeGuardrailsConfig,
|
|
1442
1460
|
): GuardMatch[] {
|
|
1443
1461
|
const matches: GuardMatch[] = [];
|
|
1462
|
+
const dataRanges = dataArgumentRanges(command);
|
|
1444
1463
|
for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [
|
|
1445
1464
|
GuardedCommandKey,
|
|
1446
1465
|
RegExp,
|
|
1447
1466
|
][]) {
|
|
1448
1467
|
const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`);
|
|
1449
1468
|
for (const match of command.matchAll(globalPattern)) {
|
|
1469
|
+
// gentle-shell#2174: text inside a data command's argument is not an operation.
|
|
1470
|
+
if (inDataArgument(dataRanges, match.index)) continue;
|
|
1450
1471
|
const action = config.autonomousMode
|
|
1451
1472
|
? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key])
|
|
1452
1473
|
: "confirm";
|
|
@@ -1460,6 +1481,10 @@ function collectGuardedMatches(
|
|
|
1460
1481
|
return matches.sort((left, right) => left.triggerIndex - right.triggerIndex);
|
|
1461
1482
|
}
|
|
1462
1483
|
|
|
1484
|
+
function inDataArgument(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
|
|
1485
|
+
return ranges.some(([start, end]) => index >= start && index < end);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1463
1488
|
function evaluateGuardedCommand(
|
|
1464
1489
|
command: string,
|
|
1465
1490
|
config: RuntimeGuardrailsConfig,
|
|
@@ -1470,8 +1495,10 @@ function evaluateGuardedCommand(
|
|
|
1470
1495
|
if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches };
|
|
1471
1496
|
|
|
1472
1497
|
// Hard denies override every configured action across the complete command.
|
|
1498
|
+
const dataRanges = dataArgumentRanges(command);
|
|
1473
1499
|
for (const pattern of DENIED_BASH_PATTERNS) {
|
|
1474
|
-
const denied = pattern.
|
|
1500
|
+
const denied = [...command.matchAll(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))]
|
|
1501
|
+
.find((candidate) => !inDataArgument(dataRanges, candidate.index));
|
|
1475
1502
|
if (!denied) continue;
|
|
1476
1503
|
const matchedAction = matches.find((match) =>
|
|
1477
1504
|
match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length,
|
|
@@ -1817,14 +1844,60 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr
|
|
|
1817
1844
|
};
|
|
1818
1845
|
}
|
|
1819
1846
|
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1847
|
+
// gentle-shell#2172: YOLO waives reversible delivery, not only one bare push.
|
|
1848
|
+
// A guarded segment is judged from its trigger word (`push`/`rebase`) to the
|
|
1849
|
+
// next command separator; a plain token or allow-listed option list is the
|
|
1850
|
+
// whole eligible grammar, so quotes, wrappers and unknown options fall back
|
|
1851
|
+
// to the ordinary confirmation. Everything that deletes or rewrites a remote
|
|
1852
|
+
// ref, publishes, or runs arbitrary commands is deliberately absent:
|
|
1853
|
+
// `--delete`/`-d`, `:dst` and `+src` refspecs, `--mirror`, `--prune`,
|
|
1854
|
+
// `--all`, every force spelling, `--receive-pack`/`--exec`, `--repo`, `-o`.
|
|
1855
|
+
const YOLO_WAIVABLE_KEYS: ReadonlySet<GuardedCommandKey> = new Set(["gitPush", "gitRebase"]);
|
|
1856
|
+
const YOLO_SEGMENT_END_RE = /&&|\|\||;|\||\n|\r/;
|
|
1857
|
+
// Text between the segment start and the `push`/`rebase` word: optional
|
|
1858
|
+
// variable assignments, `env`/`command` prefixes and the `git` word with at
|
|
1859
|
+
// most a `-C <path>`. Other global options (`-c key=value`, `--exec-path`,
|
|
1860
|
+
// `--git-dir`) can run or redirect code, so they keep the confirmation.
|
|
1861
|
+
const YOLO_GIT_PREFIX_RE = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s'"`$]*\s+|env\s+(?:-i\s+)?|command\s+)*git(?:\s+-C\s+[^\s'"`$;&|]+)?\s+$/;
|
|
1862
|
+
const YOLO_PUSH_SEGMENT_RE = /^push(?:\s+(?:-u|--set-upstream|-q|--quiet|-v|--verbose|--tags|--follow-tags|--no-verify|--porcelain|[A-Za-z0-9_][A-Za-z0-9_./-]*(?::[A-Za-z0-9_][A-Za-z0-9_./-]*)?))*\s*$/;
|
|
1863
|
+
const YOLO_REBASE_SEGMENT_RE = /^rebase(?:\s+(?:--onto\s+[A-Za-z0-9_][A-Za-z0-9_./~^-]*|--continue|--abort|--skip|--autostash|--no-autostash|[A-Za-z0-9_][A-Za-z0-9_./~^-]*))*\s*$/;
|
|
1864
|
+
|
|
1865
|
+
function yoloGuardedSegment(command: string, triggerIndex: number): string {
|
|
1866
|
+
const rest = command.slice(triggerIndex);
|
|
1867
|
+
const end = rest.search(YOLO_SEGMENT_END_RE);
|
|
1868
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function yoloSegmentPrefix(command: string, triggerIndex: number): string {
|
|
1872
|
+
const head = command.slice(0, triggerIndex);
|
|
1873
|
+
const separators = [...head.matchAll(new RegExp(YOLO_SEGMENT_END_RE.source, "g"))];
|
|
1874
|
+
const last = separators.at(-1);
|
|
1875
|
+
return last === undefined ? head : head.slice(last.index + last[0].length);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** Every guarded match and every recognized Git side effect must be a reversible push or non-interactive rebase. */
|
|
1879
|
+
function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation): boolean {
|
|
1880
|
+
if (evaluation.action !== "confirm" || evaluation.dataLoss || evaluation.matches.length === 0) return false;
|
|
1881
|
+
for (const match of evaluation.matches) {
|
|
1882
|
+
if (!YOLO_WAIVABLE_KEYS.has(match.key)) return false;
|
|
1883
|
+
if (!YOLO_GIT_PREFIX_RE.test(yoloSegmentPrefix(command, match.triggerIndex))) return false;
|
|
1884
|
+
const segment = yoloGuardedSegment(command, match.triggerIndex);
|
|
1885
|
+
if (!(match.key === "gitPush" ? YOLO_PUSH_SEGMENT_RE : YOLO_REBASE_SEGMENT_RE).test(segment)) return false;
|
|
1886
|
+
}
|
|
1887
|
+
// The data-loss recognizer leaves non-hard Git effects (reset, stash drop,
|
|
1888
|
+
// restore, checkout --) to the primary policy; under YOLO only a rebase that
|
|
1889
|
+
// a guarded match already vetted may remain.
|
|
1890
|
+
const rebaseTriggers = new Set(evaluation.matches.filter((match) => match.key === "gitRebase").map((match) => match.triggerIndex));
|
|
1891
|
+
for (const effect of recognizeDestructiveCommands(command)) {
|
|
1892
|
+
if (effect.hardDeny || effect.kind !== "git") return false;
|
|
1893
|
+
const rebase = /^git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(rebase)\b/.exec(command.slice(effect.triggerIndex));
|
|
1894
|
+
if (rebase === null || !rebaseTriggers.has(effect.triggerIndex + rebase.index + rebase[0].lastIndexOf("rebase"))) return false;
|
|
1895
|
+
}
|
|
1896
|
+
return true;
|
|
1824
1897
|
}
|
|
1825
1898
|
|
|
1826
|
-
/** Preserve explicitly configured confirmations even when legacy env/autonomy resolution ignores them. */
|
|
1827
|
-
function
|
|
1899
|
+
/** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */
|
|
1900
|
+
function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined {
|
|
1828
1901
|
let restriction: GuardAction | undefined;
|
|
1829
1902
|
for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) {
|
|
1830
1903
|
try {
|
|
@@ -1834,9 +1907,11 @@ function yoloPushConfiguredRestriction(cwd: string, options: LoadGuardrailsOptio
|
|
|
1834
1907
|
restriction ??= "confirm";
|
|
1835
1908
|
continue;
|
|
1836
1909
|
}
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1910
|
+
for (const key of keys) {
|
|
1911
|
+
const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands[key] : undefined;
|
|
1912
|
+
if (action === "block") return "block";
|
|
1913
|
+
if (action !== undefined && action !== "allow") restriction = "confirm";
|
|
1914
|
+
}
|
|
1840
1915
|
} catch { restriction ??= "confirm"; }
|
|
1841
1916
|
}
|
|
1842
1917
|
return restriction;
|
|
@@ -1855,8 +1930,9 @@ async function confirmCommand(
|
|
|
1855
1930
|
// Configuration provenance is distinct from legacy autonomy resolution, which
|
|
1856
1931
|
// can ignore confirmations when an environment override is present. Activation
|
|
1857
1932
|
// cannot waive a restriction from either user or project configuration layer.
|
|
1858
|
-
const
|
|
1859
|
-
|
|
1933
|
+
const waivableKeys = evaluation.matches.map((match) => match.key).filter((key) => YOLO_WAIVABLE_KEYS.has(key));
|
|
1934
|
+
const configuredRestriction = yoloActive && waivableKeys.length > 0
|
|
1935
|
+
? yoloConfiguredRestriction(ctx.cwd, waivableKeys) : undefined;
|
|
1860
1936
|
const classification = evaluation.action === "block" || configuredRestriction === "block"
|
|
1861
1937
|
? "block" : configuredRestriction ?? evaluation.action;
|
|
1862
1938
|
|
|
@@ -1892,7 +1968,7 @@ async function confirmCommand(
|
|
|
1892
1968
|
}
|
|
1893
1969
|
|
|
1894
1970
|
// Full T1 evaluation has already run. YOLO never changes persistent autonomy.
|
|
1895
|
-
if (yoloActive && configuredRestriction === undefined &&
|
|
1971
|
+
if (yoloActive && configuredRestriction === undefined && isReversibleYoloCommand(command, evaluation)) return undefined;
|
|
1896
1972
|
|
|
1897
1973
|
// classification === "confirm"
|
|
1898
1974
|
if (!ctx.hasUI) {
|
|
@@ -9384,8 +9460,8 @@ export const __testing = {
|
|
|
9384
9460
|
guardedCommandPreview,
|
|
9385
9461
|
guardedCommandTitle,
|
|
9386
9462
|
loadRuntimeGuardrailsConfig,
|
|
9387
|
-
|
|
9388
|
-
|
|
9463
|
+
isReversibleYoloCommand,
|
|
9464
|
+
yoloConfiguredRestriction,
|
|
9389
9465
|
buildGentlePrompt,
|
|
9390
9466
|
nativeStatusUnsupported,
|
|
9391
9467
|
nativeStartRejection,
|
|
@@ -9487,6 +9563,9 @@ function createGentleAiExtensionForTesting(
|
|
|
9487
9563
|
cancel: () => classifier?.cancel(),
|
|
9488
9564
|
}, { configHome: gentlePiConfigHome(permissionEnvironment) });
|
|
9489
9565
|
const auto = autoPermissions;
|
|
9566
|
+
pi.on("tool_result", (event, ctx) => {
|
|
9567
|
+
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx);
|
|
9568
|
+
});
|
|
9490
9569
|
pi.on("input", (event, ctx) => {
|
|
9491
9570
|
if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.input(event, ctx);
|
|
9492
9571
|
return { action: "continue" };
|
|
@@ -10051,7 +10130,8 @@ function createGentleAiExtensionForTesting(
|
|
|
10051
10130
|
// this branch, so no line is resolved or computed for them.
|
|
10052
10131
|
// resolveRddStatusLine never throws and never hangs past
|
|
10053
10132
|
// RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native
|
|
10054
|
-
// binary
|
|
10133
|
+
// binary keeps the last known line for this cwd, or renders the
|
|
10134
|
+
// fail-closed "unknown" line when none was ever read (gentle-shell#1029).
|
|
10055
10135
|
const rddStatusSignal = AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS);
|
|
10056
10136
|
// A startup read still in flight fills the memo: wait for it within the
|
|
10057
10137
|
// same deadline instead of spawning the same STATUS a second time.
|
|
@@ -6,8 +6,7 @@ import { existsSync, realpathSync, statSync } from "node:fs";
|
|
|
6
6
|
import { lstat, readFile } from "node:fs/promises";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
9
|
-
import {
|
|
10
|
-
import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
|
|
9
|
+
import { resolveSessionProfile, sessionProfileLabel, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts";
|
|
11
10
|
import * as os from "node:os";
|
|
12
11
|
import { dirname, join, resolve } from "node:path";
|
|
13
12
|
import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
@@ -208,23 +207,18 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
|
|
|
208
207
|
const reader = (() => bound ? effective : global()) as ActiveProfileReader;
|
|
209
208
|
reader.refresh = () => {
|
|
210
209
|
if (!bound) return false;
|
|
211
|
-
// gentle-shell#1064
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const changed = next !== effective;
|
|
219
|
-
effective = next;
|
|
220
|
-
return changed;
|
|
221
|
-
}
|
|
222
|
-
const pin = identity && cwd ? resolveProfilePin({
|
|
210
|
+
// gentle-shell#1064: the status shows the profile launches use, through
|
|
211
|
+
// the same single rule (explicit Enter binding → profile frozen at
|
|
212
|
+
// startup → live p → P → G only in follow mode), with the existing
|
|
213
|
+
// spelling: "name (session)", "name (local)", "name (repo)", or the bare
|
|
214
|
+
// global name.
|
|
215
|
+
const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({
|
|
216
|
+
sessionId,
|
|
223
217
|
cwd,
|
|
224
218
|
configHome: bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
|
|
225
|
-
resolveWorktree: () => identity
|
|
226
|
-
|
|
227
|
-
|
|
219
|
+
resolveWorktree: () => identity,
|
|
220
|
+
env,
|
|
221
|
+
}));
|
|
228
222
|
const changed = next !== effective;
|
|
229
223
|
effective = next;
|
|
230
224
|
return changed;
|
|
@@ -1578,19 +1572,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1578
1572
|
// store and the profile reader cannot drift onto two different stores.
|
|
1579
1573
|
const usageConfigHome = gentlePiConfigHome(env);
|
|
1580
1574
|
const usageFetchTimeout = usageFetchTimeoutMs(env);
|
|
1581
|
-
// The subagent routing in force for this session
|
|
1582
|
-
// (gentle-shell#1064
|
|
1583
|
-
// profile
|
|
1584
|
-
//
|
|
1575
|
+
// The subagent routing in force for this session, through the same single
|
|
1576
|
+
// rule launches use (gentle-shell#1064): explicit Enter binding, then the
|
|
1577
|
+
// profile frozen at startup, then the live p → P → G only in follow mode.
|
|
1578
|
+
// Only the profile's own role entries count; the reserved orchestrator key
|
|
1579
|
+
// is not a route.
|
|
1585
1580
|
const activeRoutingModels = (ctx: ExtensionContext): Array<string | undefined> => {
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
return
|
|
1581
|
+
const config = sessionProfileModelProfiles(resolveSessionProfile({
|
|
1582
|
+
sessionId: ctx.sessionManager?.getSessionId?.(),
|
|
1583
|
+
cwd: ctx.cwd,
|
|
1584
|
+
configHome: usageConfigHome,
|
|
1585
|
+
resolveWorktree: deps.resolveWorktree,
|
|
1586
|
+
env,
|
|
1587
|
+
}));
|
|
1588
|
+
return config ? profileRoleEntries(config).map(([, entry]) => entry.model) : [];
|
|
1594
1589
|
};
|
|
1595
1590
|
// A bare model id names a provider only when exactly one provider in the
|
|
1596
1591
|
// registry carries that id; anything else stays untargeted rather than guessed.
|
|
@@ -2261,7 +2256,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2261
2256
|
});
|
|
2262
2257
|
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2263
2258
|
category,
|
|
2264
|
-
label: () => `Session permissions: ${mode.toUpperCase()}${permissionDisplay === mode ? " (current)" : ""}`,
|
|
2259
|
+
label: () => `Session permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDisplay === mode ? " (current)" : ""}`,
|
|
2265
2260
|
preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
|
|
2266
2261
|
? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
|
|
2267
2262
|
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain" : "normal checks · overrides an elevated global default" }),
|
|
@@ -2273,7 +2268,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2273
2268
|
});
|
|
2274
2269
|
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2275
2270
|
category,
|
|
2276
|
-
label: () => `Default permissions: ${mode.toUpperCase()}${permissionDefault === mode ? " (saved)" : ""}`,
|
|
2271
|
+
label: () => `Default permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDefault === mode ? " (saved)" : ""}`,
|
|
2277
2272
|
preview: () => ({ title: "Permissions · user-global default", sample: `New primary sessions start in ${mode.toUpperCase()}; current session stays unchanged. ${mode === "auto" ? "Additional classifier inference uses the session model; usage is separate." : "Independent safety and consent remain."}` }),
|
|
2278
2273
|
action: async () => {
|
|
2279
2274
|
if (closed) return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
TODO_TOOL_NAME,
|
|
17
17
|
todoCardTone,
|
|
18
18
|
todoPromptBlock,
|
|
19
|
+
todoStateMessage,
|
|
19
20
|
todoSummary,
|
|
20
21
|
type TodoParams,
|
|
21
22
|
type TodoState,
|
|
@@ -24,10 +25,12 @@ import {
|
|
|
24
25
|
// Gentle Todo: the task list the model keeps while it works, drawn as a
|
|
25
26
|
// Gentle Shell card above the editor. Three things keep it current that a
|
|
26
27
|
// static tool description cannot: `write` replaces the whole list in one
|
|
27
|
-
// call, each run's system prompt carries
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// call, each run's system prompt carries a byte-stable rules block while the
|
|
29
|
+
// open tasks reach the model as a request-local trailing message (so list
|
|
30
|
+
// changes never invalidate the cached prompt prefix, gentle-shell#1662), and
|
|
31
|
+
// a list that goes untouched while tasks stay open is marked stale for the
|
|
32
|
+
// human. Long runs also receive one request-local reminder after several
|
|
33
|
+
// tool-use turns, without waking the agent or changing tasks.
|
|
31
34
|
|
|
32
35
|
const WIDGET_KEY = "gentle-todo";
|
|
33
36
|
const STATUS_ENUM = ["pending", "in_progress", "blocked", "done", "dropped"];
|
|
@@ -281,7 +284,9 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
281
284
|
});
|
|
282
285
|
}
|
|
283
286
|
|
|
284
|
-
|
|
287
|
+
// Pi binds ctrl+shift+up/down to prompt navigation (tui.altScreen.*Prompt) and
|
|
288
|
+
// alt+up/down to dequeue and model reordering; alt+shift+up/down are free.
|
|
289
|
+
for (const [key, lines] of [["alt+shift+up", -3], ["alt+shift+down", 3]] as const) {
|
|
285
290
|
pi.registerShortcut(key, {
|
|
286
291
|
description: `Scroll the Todo list ${lines < 0 ? "up" : "down"}`,
|
|
287
292
|
handler: async (ctx) => session(ctx).scrollTodo?.(lines),
|
|
@@ -315,11 +320,10 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
315
320
|
current.clearOnNextTurn = false;
|
|
316
321
|
show(current);
|
|
317
322
|
}
|
|
318
|
-
const block = todoPromptBlock(current.state, staleTurns(current.state, current.turn));
|
|
319
|
-
if (!block) return undefined;
|
|
320
323
|
// gentle-shell#1485: pi-claude-bridge drops a handler-returned
|
|
321
|
-
// systemPrompt, so the
|
|
322
|
-
|
|
324
|
+
// systemPrompt, so the rules block goes through appendSystemPrompt. It is
|
|
325
|
+
// the same bytes on every turn, list or no list (gentle-shell#1662).
|
|
326
|
+
appendSystemPromptOnce(event.systemPromptOptions, todoPromptBlock());
|
|
323
327
|
return undefined;
|
|
324
328
|
});
|
|
325
329
|
|
|
@@ -335,14 +339,20 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
335
339
|
|
|
336
340
|
pi.on("context", (event, ctx) => {
|
|
337
341
|
const current = session(ctx);
|
|
338
|
-
if (current.reminded || current.toolTurnsSinceWrite < REMIND_AFTER_TOOL_TURNS) return undefined;
|
|
339
|
-
if (!current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress")) return undefined;
|
|
340
|
-
current.reminded = true;
|
|
341
342
|
// Context transforms are request-local: no transcript entry, UI
|
|
342
|
-
// notification, continuation or provider-specific wake is needed.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
};
|
|
343
|
+
// notification, continuation or provider-specific wake is needed. The
|
|
344
|
+
// current list trails the transcript (gentle-shell#1662), then the
|
|
345
|
+
// one-shot reminder when it is due.
|
|
346
|
+
const trailing: Array<{ role: "custom"; customType: string; content: string; display: false; timestamp: number }> = [];
|
|
347
|
+
const state = todoStateMessage(current.state);
|
|
348
|
+
if (state !== undefined) trailing.push({ role: "custom", customType: "gentle-todo-state", content: state, display: false, timestamp: Date.now() });
|
|
349
|
+
const actionable = current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
|
|
350
|
+
if (!current.reminded && current.toolTurnsSinceWrite >= REMIND_AFTER_TOOL_TURNS && actionable) {
|
|
351
|
+
current.reminded = true;
|
|
352
|
+
trailing.push({ role: "custom", customType: "gentle-todo-reminder", content: TODO_REMINDER, display: false, timestamp: Date.now() });
|
|
353
|
+
}
|
|
354
|
+
if (trailing.length === 0) return undefined;
|
|
355
|
+
return { messages: [...event.messages, ...trailing] };
|
|
346
356
|
});
|
|
347
357
|
|
|
348
358
|
pi.on("tool_execution_end", (event, ctx) => {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { QuestionParams } from "./questionnaire/schema.ts";
|
|
3
|
+
import type { AnswerRow } from "./questionnaire/questionnaire-view.ts";
|
|
4
|
+
import { isInteractiveMode } from "./rpc-host.ts";
|
|
5
|
+
|
|
6
|
+
type AnswerContext = Pick<ExtensionContext, "sessionManager" | "cwd" | "mode" | "hasUI">;
|
|
7
|
+
export type HumanAnswer =
|
|
8
|
+
| { readonly kind: "option" | "custom"; readonly text: string; readonly selected?: readonly string[] }
|
|
9
|
+
| { readonly kind: "multi"; readonly selected: readonly string[] };
|
|
10
|
+
export interface HumanAnswerReceipt {
|
|
11
|
+
readonly sessionManager: ExtensionContext["sessionManager"];
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
readonly branchAnchor: string | null;
|
|
15
|
+
readonly toolName: "ask_user_choice" | "ask_user_question";
|
|
16
|
+
readonly toolCallId: string;
|
|
17
|
+
readonly questionIndex: number;
|
|
18
|
+
/** Untrusted referent, never an instruction or an authorization. */
|
|
19
|
+
readonly question: string;
|
|
20
|
+
/** Full original prompt context, untrusted and inseparable from its answer. */
|
|
21
|
+
readonly prompt: string;
|
|
22
|
+
/** Authenticated human response; opaque tokens and previews are excluded. */
|
|
23
|
+
readonly answer: HumanAnswer;
|
|
24
|
+
}
|
|
25
|
+
interface Subscription {
|
|
26
|
+
ctx: AnswerContext;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
callback: (receipt: HumanAnswerReceipt) => void;
|
|
30
|
+
}
|
|
31
|
+
const subscriptions = new WeakMap<object, Set<Subscription>>();
|
|
32
|
+
|
|
33
|
+
/** Subscribe from a trusted host context; dispose on session shutdown/replacement. */
|
|
34
|
+
export function subscribeHumanAnswers(ctx: AnswerContext, callback: (receipt: HumanAnswerReceipt) => void): () => void {
|
|
35
|
+
if (!ctx.sessionManager || !isInteractiveMode(ctx.mode, process.env) || ctx.hasUI === false) return () => {};
|
|
36
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
37
|
+
if (!sessionId || !ctx.cwd) return () => {};
|
|
38
|
+
let listeners = subscriptions.get(ctx.sessionManager);
|
|
39
|
+
if (!listeners) subscriptions.set(ctx.sessionManager, listeners = new Set());
|
|
40
|
+
const subscription = { ctx, sessionId, cwd: ctx.cwd, callback };
|
|
41
|
+
listeners.add(subscription);
|
|
42
|
+
return () => { listeners.delete(subscription); };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host-only capture seam. Call before mounting UI, never from tool_result. */
|
|
46
|
+
function beginCapture(ctx: AnswerContext, toolName: HumanAnswerReceipt["toolName"], toolCallId: string, questions: ReadonlyArray<{ question: string; prompt: string }>, signal?: AbortSignal) {
|
|
47
|
+
const manager = ctx.sessionManager;
|
|
48
|
+
const sessionId = manager?.getSessionId();
|
|
49
|
+
const cwd = ctx.cwd;
|
|
50
|
+
const branchAnchor = manager?.getLeafId?.();
|
|
51
|
+
const listeners = manager ? subscriptions.get(manager) : undefined;
|
|
52
|
+
const eligible = [...(listeners ?? [])].filter((entry) => entry.sessionId === sessionId && entry.cwd === cwd);
|
|
53
|
+
const interactive = isInteractiveMode(ctx.mode, process.env) && ctx.hasUI !== false;
|
|
54
|
+
let finished = false;
|
|
55
|
+
return (answers: ReadonlyArray<{ questionIndex: number; answer: HumanAnswer }>) => {
|
|
56
|
+
if (finished) return;
|
|
57
|
+
finished = true;
|
|
58
|
+
if (!interactive || !isInteractiveMode(ctx.mode, process.env) || ctx.hasUI === false ||
|
|
59
|
+
!manager || !sessionId || signal?.aborted || ctx.sessionManager !== manager ||
|
|
60
|
+
manager.getSessionId() !== sessionId || ctx.cwd !== cwd || branchAnchor === undefined || manager.getLeafId() !== branchAnchor) return;
|
|
61
|
+
for (const { questionIndex, answer } of answers) {
|
|
62
|
+
const referent = questions[questionIndex];
|
|
63
|
+
if (referent === undefined) continue;
|
|
64
|
+
const { question, prompt } = referent;
|
|
65
|
+
const receipt = Object.freeze({ sessionManager: manager, sessionId, cwd, branchAnchor, toolName, toolCallId, questionIndex, question, prompt,
|
|
66
|
+
answer: Object.freeze({ ...answer, ...(answer.selected ? { selected: Object.freeze([...answer.selected]) } : {}) }) });
|
|
67
|
+
for (const entry of eligible) {
|
|
68
|
+
if (!listeners?.has(entry) || !isInteractiveMode(entry.ctx.mode, process.env) || entry.ctx.hasUI === false ||
|
|
69
|
+
entry.ctx.sessionManager !== manager || entry.ctx.cwd !== cwd ||
|
|
70
|
+
manager.getSessionId() !== sessionId || manager.getLeafId() !== branchAnchor || signal?.aborted) continue;
|
|
71
|
+
// Observers must not change the existing public tool outcome.
|
|
72
|
+
try { entry.callback(receipt); } catch { /* Isolate host observer failures. */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ChoicePrompt {
|
|
79
|
+
question: string;
|
|
80
|
+
options: Array<{ label: string; description?: string; value: string }>;
|
|
81
|
+
allowCustomResponse?: boolean;
|
|
82
|
+
}
|
|
83
|
+
export function captureChoiceAnswer(ctx: AnswerContext, toolCallId: string, params: ChoicePrompt, signal?: AbortSignal) {
|
|
84
|
+
const options = params.options.map((option) => ({ ...option }));
|
|
85
|
+
const allowCustom = params.allowCustomResponse === true;
|
|
86
|
+
const prompt = JSON.stringify({ question: params.question,
|
|
87
|
+
options: options.map(({ label, description }) => ({ label, description })), allowCustomResponse: allowCustom });
|
|
88
|
+
const publish = beginCapture(ctx, "ask_user_choice", toolCallId, [{ question: params.question, prompt }], signal);
|
|
89
|
+
return (selection: { value: string; label: string; index: number } | { customResponse: string } | undefined) => {
|
|
90
|
+
if (!selection) return;
|
|
91
|
+
if ("customResponse" in selection) {
|
|
92
|
+
if (allowCustom && typeof selection.customResponse === "string" && selection.customResponse.trim())
|
|
93
|
+
publish([{ questionIndex: 0, answer: { kind: "custom", text: selection.customResponse } }]);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const option = options[selection.index - 1];
|
|
97
|
+
if (Number.isInteger(selection.index) && option && selection.label === option.label && selection.value === option.value)
|
|
98
|
+
publish([{ questionIndex: 0, answer: { kind: "option", text: option.label } }]);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function captureQuestionnaireAnswers(ctx: AnswerContext, toolCallId: string, params: QuestionParams, signal?: AbortSignal) {
|
|
103
|
+
const questions = params.questions.map((question) => ({ ...question, options: question.options.map((option) => ({ ...option })) }));
|
|
104
|
+
const referents = questions.map((question) => ({ question: question.question, prompt: JSON.stringify({
|
|
105
|
+
question: question.question, header: question.header, multiSelect: question.multiSelect === true,
|
|
106
|
+
options: question.options.map(({ label, description, preview }) => ({ label, description,
|
|
107
|
+
...(ctx.mode === "tui" && preview !== undefined ? { preview } : {}) })),
|
|
108
|
+
}) }));
|
|
109
|
+
const publish = beginCapture(ctx, "ask_user_question", toolCallId, referents, signal);
|
|
110
|
+
return (rows: AnswerRow[] | undefined) => {
|
|
111
|
+
if (!rows?.length) return;
|
|
112
|
+
const seen = new Set<number>();
|
|
113
|
+
const answers: Array<{ questionIndex: number; answer: HumanAnswer }> = [];
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
const question = questions[row.questionIndex];
|
|
116
|
+
if (!Number.isInteger(row.questionIndex) || !question || seen.has(row.questionIndex) || row.question !== question.question) return;
|
|
117
|
+
seen.add(row.questionIndex);
|
|
118
|
+
const labels = question.options.map((option) => option.label);
|
|
119
|
+
const selected = row.selected;
|
|
120
|
+
if (selected && (!question.multiSelect || new Set(selected).size !== selected.length || selected.some((label) => !labels.includes(label)))) return;
|
|
121
|
+
let answer: HumanAnswer;
|
|
122
|
+
if (row.kind === "option" && typeof row.answer === "string" && labels.includes(row.answer) && !question.multiSelect && !selected)
|
|
123
|
+
answer = { kind: "option", text: row.answer };
|
|
124
|
+
else if (row.kind === "custom" && typeof row.answer === "string" && row.answer.trim())
|
|
125
|
+
answer = { kind: "custom", text: row.answer, ...(selected ? { selected } : {}) };
|
|
126
|
+
else if (row.kind === "multi" && question.multiSelect && selected && row.answer === null)
|
|
127
|
+
answer = { kind: "multi", selected };
|
|
128
|
+
else return;
|
|
129
|
+
answers.push({ questionIndex: row.questionIndex, answer });
|
|
130
|
+
}
|
|
131
|
+
publish(answers);
|
|
132
|
+
};
|
|
133
|
+
}
|