gentle-pi 3.3.0 → 3.4.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.
Files changed (69) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +26 -2
  10. package/docs/readme-reference.md +32 -6
  11. package/extensions/ask-user-question.ts +210 -0
  12. package/extensions/gentle-agents.ts +8 -1
  13. package/extensions/gentle-ai.ts +59 -18
  14. package/extensions/gentle-shell.ts +99 -10
  15. package/extensions/quiet-tools.ts +28 -5
  16. package/extensions/startup-banner.ts +25 -10
  17. package/lib/animation-policy.ts +52 -0
  18. package/lib/background-cache-warming.ts +38 -0
  19. package/lib/command-palette-catalog.ts +1 -0
  20. package/lib/inprocess-reviewer.ts +38 -1
  21. package/lib/native-review-cli.ts +36 -10
  22. package/lib/questionnaire/questionnaire-view.ts +603 -0
  23. package/lib/questionnaire/schema.ts +82 -0
  24. package/lib/questionnaire/validate.ts +141 -0
  25. package/lib/review-candidate-view-owner.ts +20 -5
  26. package/lib/review-candidate-view.ts +9 -2
  27. package/lib/review-host-relay.ts +10 -0
  28. package/lib/review-integration-v2.ts +4 -1
  29. package/lib/shell-bar.ts +13 -0
  30. package/lib/shell-sidebar-layout.ts +10 -4
  31. package/lib/shell-usage-view.ts +5 -2
  32. package/lib/shell-usage.ts +120 -6
  33. package/package.json +1 -1
  34. package/runtime/native-review-cli.mjs +35 -9
  35. package/runtime/review-integration-v2.mjs +4 -1
  36. package/scripts/gentle-ai-installer.mjs +10 -10
  37. package/scripts/verify-package-files.mjs +2 -3
  38. package/tests/animation-policy.test.ts +42 -0
  39. package/tests/ask-user-question.test.ts +435 -0
  40. package/tests/background-cache-warming.test.ts +60 -0
  41. package/tests/background-subagents.test.ts +68 -0
  42. package/tests/command-palette.test.ts +9 -0
  43. package/tests/gentle-agents.test.ts +71 -2
  44. package/tests/gentle-ai-binary.test.ts +1 -1
  45. package/tests/gentle-ai-installer.test.ts +47 -47
  46. package/tests/gentle-ai.test.ts +56 -4
  47. package/tests/gentle-shell.test.ts +355 -2
  48. package/tests/inprocess-reviewer.test.ts +92 -0
  49. package/tests/native-review-capability-contract.test.ts +16 -1
  50. package/tests/native-review-parity.test.ts +19 -0
  51. package/tests/package-manifest.test.ts +6 -17
  52. package/tests/questionnaire-schema.test.ts +274 -0
  53. package/tests/questionnaire-view.test.ts +446 -0
  54. package/tests/rdd-status-line.test.ts +21 -4
  55. package/tests/review-candidate-owner-retry.test.ts +63 -0
  56. package/tests/review-candidate-view.test.ts +15 -0
  57. package/tests/review-controller-native-routing.test.ts +86 -0
  58. package/tests/review-host-relay.test.ts +21 -0
  59. package/tests/review-integration-v2.test.ts +30 -0
  60. package/tests/review-ledger-contract.test.ts +1 -2
  61. package/tests/review-relay-transport-agent.test.ts +107 -2
  62. package/tests/review-risk-assessment.test.ts +104 -0
  63. package/tests/shell-bar.test.ts +8 -0
  64. package/tests/shell-sidebar-layout.test.ts +60 -5
  65. package/tests/shell-usage.test.ts +129 -0
  66. package/tests/skill-collision-prefixes.test.ts +1 -1
  67. package/tests/startup-banner.test.ts +93 -2
  68. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  69. package/skills/release/SKILL.md +0 -137
@@ -706,7 +706,11 @@ function isNativeUntrackedPath(value ) {
706
706
  && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
707
707
  }
708
708
 
709
- function nativeUntrackedSelection(request ) {
709
+ export function nativeUntrackedSelection(request
710
+
711
+
712
+
713
+ ) {
710
714
  const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
711
715
  const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
712
716
  if (!declared) return {};
@@ -717,16 +721,19 @@ function nativeUntrackedSelection(request )
717
721
  ) {
718
722
  throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
719
723
  }
720
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
724
+ // The guard above establishes the array and element types for both typed
725
+ // native requests and untyped facade input.
726
+ const paths = intendedUntracked ;
727
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
721
728
  throw new TypeError("Native exclude untracked selection cannot include paths");
722
729
  }
723
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
730
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
724
731
  throw new TypeError("Native select untracked selection requires at least one path");
725
732
  }
726
733
  return {
727
734
  untrackedScope,
728
735
  expectedUntrackedInventory,
729
- intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
736
+ intendedUntracked: paths === undefined ? undefined : [...paths],
730
737
  };
731
738
  }
732
739
 
@@ -1019,6 +1026,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1019
1026
  // and hint remain dark because neither is proven to reach the negotiated
1020
1027
  // START path Pi consumes.
1021
1028
  "3.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1029
+ // v3.5.0 repeats 3.4.0: the published provider contract bundle is
1030
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1031
+ // with only build-identity differences, and no review-integration schema
1032
+ // changed. The v2 preflight failure identity fix does not change the
1033
+ // closed START/STATUS fields this row negotiates. riskEvidence and hint
1034
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1035
+ "3.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1022
1036
  });
1023
1037
 
1024
1038
 
@@ -1103,8 +1117,19 @@ function decodeSelectedLenses(value , riskLevel , lensesRequired
1103
1117
  function enumString(value , allowed ) { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
1104
1118
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
1105
1119
 
1106
- function sanitizeNativeDiagnosticText(value , limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT) {
1107
- const normalized = value
1120
+ function sanitizeNativeDiagnosticText(value , limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation ) {
1121
+ // ASSESS diagnostics are projected into a public verification plan. Retain
1122
+ // native guidance, not local paths or environment assignment values.
1123
+ const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
1124
+ ? value
1125
+ .replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
1126
+ .replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
1127
+ // Quoted paths have a clear boundary. For an unquoted path, the
1128
+ // remaining line is ambiguous (spaces may belong to the filename).
1129
+ // Redact that suffix rather than leak trailing path components.
1130
+ .replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
1131
+ : value;
1132
+ const normalized = input
1108
1133
  .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
1109
1134
  .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
1110
1135
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
@@ -1138,7 +1163,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value )
1138
1163
  timed_out: booleanValue(raw.timed_out),
1139
1164
  output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1140
1165
  ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint }),
1141
- ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1166
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
1142
1167
  };
1143
1168
  } catch { return undefined; }
1144
1169
  }
@@ -1155,7 +1180,7 @@ function nativeProcessDiagnostics(operation , code
1155
1180
  ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1156
1181
  ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1157
1182
  : {}),
1158
- ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
1183
+ ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
1159
1184
  };
1160
1185
  }
1161
1186
 
@@ -2536,6 +2561,7 @@ export class NativeReviewCliV216 {
2536
2561
  // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2537
2562
  // closed to `high`.
2538
2563
  async assess(request ) {
2564
+ const selection = nativeUntrackedSelection(request);
2539
2565
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2540
2566
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2541
2567
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
@@ -2543,7 +2569,7 @@ export class NativeReviewCliV216 {
2543
2569
  const execution = await this.invoke(
2544
2570
  NATIVE_REVIEW_OPERATION.ASSESS,
2545
2571
  cwd,
2546
- ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2572
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
2547
2573
  false,
2548
2574
  request.signal,
2549
2575
  this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),
@@ -340,6 +340,7 @@ const REQUIRED_MANDATORY_FEATURES = Object.freeze(FEATURE_NAMES.filter((name) =>
340
340
 
341
341
 
342
342
 
343
+
343
344
 
344
345
 
345
346
  const REPOSITORY_CONTEXT_OUTCOMES = ["applied", "pending", "blocked_conflict", "durability_limited"] ;
@@ -1151,7 +1152,8 @@ export function decodeReviewArtifactSubjectV2(value )
1151
1152
  }
1152
1153
 
1153
1154
  function decodeChangedPathEntry(value , label ) {
1154
- const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"]);
1155
+ const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"], ["generated"]);
1156
+ if (body.generated !== undefined && body.generated !== true) throw new TypeError(`${label}.generated must be true when present`);
1155
1157
  return {
1156
1158
  path: nonempty(body.path, `${label}.path`),
1157
1159
  status: enumeration(body.status, ["A", "D", "M", "T"] , `${label}.status`),
@@ -1161,6 +1163,7 @@ function decodeChangedPathEntry(value , label )
1161
1163
  typeChanged: boolean(body.type_changed, `${label}.type_changed`),
1162
1164
  modeOnly: boolean(body.mode_only, `${label}.mode_only`),
1163
1165
  intendedUntracked: boolean(body.intended_untracked, `${label}.intended_untracked`),
1166
+ ...(body.generated === undefined ? {} : { generated: true }),
1164
1167
  };
1165
1168
  }
1166
1169
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "3.4.0";
39
+ export const INSTALLER_VERSION = "3.5.0";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v3";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.4.0`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.5.0`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit 82a6de96, the published v3.4.0 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:bQOM+Xa3WN5+idTagEk15rgEEQQWVhZvq8BPF/FHtSs=";
50
+ // tag resolves to commit 0b335e43, the published v3.5.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:3y+Nb7CtgGB3ne3bSNxkzV6j19etkOnGDXIiuv7tMp8=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v3.4.0 digests are pinned from the published release:
70
+ // still holds it. The v3.5.0 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v3.4.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v3.5.0 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_3.4.0_darwin_amd64.tar.gz", "7d13fa45489098ff9e82dee2232204f72bf1c04ef998ceadfe1ea972697f2e56", "d9096b4bb13e56aae1251dfacd2487821a15c40b7c9397555ad615ede47e658a", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_3.4.0_darwin_arm64.tar.gz", "b9052d8dc02923663d4451a4b031c4ef7328cd866c0cb6729e8ff2b9853603f0", "0aca1239cafd87ce63b194ab69da90e762801d82a96418fd6b7b12ea3806dc07", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_3.4.0_linux_amd64.tar.gz", "c287289a514420381e890991bb3fbea4a2c36d7b4b1774fcf6bc4deb83378915", "309d9aafb48de5ef90ba0a212e8a98e8fdbb82d0852e24015e36a5ce0fe04c06", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_3.4.0_linux_arm64.tar.gz", "dfd1fe70acfe577d2a36e3a658895f481467518a2923b7200403dbcca0181399", "83ba7d27d250019115690be51664bd356e09085da8858761e362e6053c6290d3", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_3.5.0_darwin_amd64.tar.gz", "483b8363cc2b717d224ee7a15c834ed518854c31a1d79d84b6ea0551fd7905cf", "06e5ce2936f0b0579fa382b5cd59cf42c23b07404b162d9ff9a036dd107093a4", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_3.5.0_darwin_arm64.tar.gz", "d7a5c233ce23e8fc2ce917ed3e6a2e643655e6e8322fddb3c522324d4cf8ae52", "ec7757fe13f6cd5c9dffa6466d75c5cd68992eac44e684d330c838282c5e2e7e", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_3.5.0_linux_amd64.tar.gz", "3ea016622981f5e4f4a80632fb9c4673149c98f4e5cecc4b979b41bda758ba4a", "7a4202f75c8b90056f0ecd8fceaaef9efe407abc6f98bb42a3ee1e5ad69eb197", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_3.5.0_linux_arm64.tar.gz", "aca1997b3ae8439c17883cde39c3ba577ae38352ad6be1d1c4b2c6bc428c4505", "4d81f356f6e221bc5730a280bae049df8315232a2470089993200577e36e97cc", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -105,7 +105,6 @@ const requiredPaths = [
105
105
  "skills/issue-creation/SKILL.md",
106
106
  "skills/judgment-day/SKILL.md",
107
107
  "skills/rdd-defect-workflow/SKILL.md",
108
- "skills/release/SKILL.md",
109
108
  "skills/skill-creator/SKILL.md",
110
109
  "skills/skill-improver/SKILL.md",
111
110
  "skills/skill-registry/SKILL.md",
@@ -339,7 +338,7 @@ async function main() {
339
338
  });
340
339
 
341
340
  if (driftedContracts.length > 0) {
342
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.4.0 runtime's vendored Gentle AI contract artifacts:");
341
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.5.0 runtime's vendored Gentle AI contract artifacts:");
343
342
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
344
343
  process.exit(1);
345
344
  }
@@ -384,7 +383,7 @@ async function main() {
384
383
  process.exit(1);
385
384
  }
386
385
 
387
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.4.0 runtime).`);
386
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.5.0 runtime).`);
388
387
  }
389
388
 
390
389
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { ANIMATION_SCHEMA, parseAnimationPolicyFile, resolveAnimationPolicy, writeAnimationPolicy } from "../lib/animation-policy.ts";
7
+
8
+ test("animation policy strictly accepts only the v1 schema and three modes", () => {
9
+ for (const policy of ["quality", "performance", "potato"]) {
10
+ assert.equal(parseAnimationPolicyFile(JSON.stringify({ schema: ANIMATION_SCHEMA, policy })), policy);
11
+ }
12
+ for (const raw of ["bad", "null", "[]", '{}', '{"policy":"potato"}', ...[
13
+ { schema: "gentle-pi.animations/v2", policy: "potato" },
14
+ { schema: ANIMATION_SCHEMA, policy: "POTATO" },
15
+ { schema: ANIMATION_SCHEMA, policy: false },
16
+ { schema: ANIMATION_SCHEMA, policy: "quality", extra: true },
17
+ ].map((value) => JSON.stringify(value))]) assert.equal(parseAnimationPolicyFile(raw), undefined);
18
+ });
19
+
20
+ test("global policy defaults to quality, persists atomically, and attributes malformed input without rewriting", () => {
21
+ const home = mkdtempSync(join(tmpdir(), "gp-animations-"));
22
+ try {
23
+ const options = { gentlePiConfigHome: home };
24
+ assert.equal(resolveAnimationPolicy(options).policy, "quality");
25
+ assert.equal(resolveAnimationPolicy(options).source, "default");
26
+ for (const policy of ["potato", "performance", "quality"] as const) {
27
+ const path = writeAnimationPolicy(policy, options);
28
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: ANIMATION_SCHEMA, policy });
29
+ assert.equal(resolveAnimationPolicy(options).policy, policy);
30
+ assert.equal(resolveAnimationPolicy(options).source, "global_file");
31
+ assert.deepEqual(readdirSync(home), ["animations.json"]);
32
+ }
33
+ const path = join(home, "animations.json");
34
+ writeFileSync(path, "broken");
35
+ const result = resolveAnimationPolicy(options);
36
+ assert.equal(result.policy, "quality");
37
+ assert.equal(result.source, "global_file");
38
+ assert.equal(result.malformed, true);
39
+ assert.equal(result.globalFile, path);
40
+ assert.equal(readFileSync(path, "utf8"), "broken");
41
+ } finally { rmSync(home, { recursive: true, force: true }); }
42
+ });