@gajae-code/coding-agent 0.12.12 → 0.12.13

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 (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli.d.ts +10 -0
  4. package/dist/types/config/file-lock-gc.d.ts +13 -1
  5. package/dist/types/config/model-registry.d.ts +6 -0
  6. package/dist/types/config/models-config-schema.d.ts +99 -0
  7. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  8. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  9. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  10. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  11. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -2
  12. package/dist/types/sdk/bus/telegram-daemon.d.ts +15 -1
  13. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -0
  14. package/dist/types/session/agent-session.d.ts +0 -2
  15. package/dist/types/session/session-manager.d.ts +17 -1
  16. package/dist/types/tools/index.d.ts +7 -0
  17. package/package.json +8 -8
  18. package/src/cli/fast-help.ts +1 -0
  19. package/src/cli/marketplace-hint.ts +39 -0
  20. package/src/cli/plugin-cli.ts +10 -0
  21. package/src/cli.ts +23 -0
  22. package/src/config/file-lock-gc.ts +47 -26
  23. package/src/config/model-profiles.ts +18 -18
  24. package/src/config/model-registry.ts +17 -5
  25. package/src/config/models-config-schema.ts +9 -4
  26. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  27. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  28. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  29. package/src/gjc-runtime/gc-render.ts +8 -1
  30. package/src/gjc-runtime/gc-runtime.ts +15 -1
  31. package/src/internal-urls/docs-index.generated.ts +4 -4
  32. package/src/main.ts +11 -3
  33. package/src/modes/acp/acp-agent.ts +123 -7
  34. package/src/modes/components/model-selector.ts +52 -5
  35. package/src/modes/print-mode.ts +10 -0
  36. package/src/prompts/system/project-prompt.md +19 -0
  37. package/src/sdk/bus/html-format.ts +19 -3
  38. package/src/sdk/bus/index.ts +21 -12
  39. package/src/sdk/bus/telegram-daemon-contract.ts +6 -2
  40. package/src/sdk/bus/telegram-daemon.ts +50 -16
  41. package/src/sdk/bus/telegram-reference.ts +72 -9
  42. package/src/sdk/session.ts +5 -1
  43. package/src/session/agent-session.ts +0 -4
  44. package/src/session/internal/managed-session-storage.ts +24 -2
  45. package/src/session/session-manager.ts +77 -22
  46. package/src/setup/provider-presets.json +3 -3
  47. package/src/thinking-metadata.ts +2 -1
  48. package/src/tools/ask.ts +10 -0
  49. package/src/tools/index.ts +7 -0
  50. package/src/tools/todo-write.ts +4 -4
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Install-failure hints for marketplace-shaped plugin names.
3
+ *
4
+ * A bare spec (no `@marketplace`) is always classified as npm, so a user who
5
+ * copies a plugin name out of `plugin discover` gets an npm resolution failure
6
+ * with no hint that the same name is offered by a registered marketplace.
7
+ *
8
+ * Kept dependency-free — the marketplace barrel loads native addons — so this
9
+ * takes the narrow catalog surface it actually reads instead of the manager.
10
+ */
11
+
12
+ /** The catalog lookups this module needs; `MarketplaceManager` satisfies it. */
13
+ export interface MarketplaceCatalogLookup {
14
+ listMarketplaces(): Promise<ReadonlyArray<{ name: string }>>;
15
+ getPluginInfo(name: string, marketplace: string): Promise<unknown | null>;
16
+ }
17
+
18
+ /**
19
+ * A bare name carries no `@scope`, version specifier, or path separator, so it
20
+ * is the only install spec shape that is safe to echo back in an error.
21
+ */
22
+ export function isBareInstallName(spec: string): boolean {
23
+ return spec.length > 0 && !spec.includes("@") && !spec.includes("/") && !spec.includes("\\");
24
+ }
25
+
26
+ /**
27
+ * Names of registered marketplaces whose catalog offers a plugin under exactly
28
+ * `name`. Only marketplace names are returned: they are user-chosen registry
29
+ * labels, never the plugin source, so they carry no credentials or home paths.
30
+ */
31
+ export async function findMarketplacesOffering(catalogs: MarketplaceCatalogLookup, name: string): Promise<string[]> {
32
+ const marketplaces = await catalogs.listMarketplaces();
33
+ const offering: string[] = [];
34
+ for (const marketplace of marketplaces) {
35
+ const info = await catalogs.getPluginInfo(name, marketplace.name).catch(() => null);
36
+ if (info) offering.push(marketplace.name);
37
+ }
38
+ return offering;
39
+ }
@@ -155,6 +155,7 @@ export function parsePluginArgs(args: string[]): PluginCommandArgs | undefined {
155
155
  }
156
156
 
157
157
  import { classifyInstallTarget } from "./classify-install-target";
158
+ import { findMarketplacesOffering, isBareInstallName } from "./marketplace-hint";
158
159
 
159
160
  export { classifyInstallTarget } from "./classify-install-target";
160
161
 
@@ -609,6 +610,15 @@ async function handleInstall(
609
610
  // The spec can carry credentials, a query string, or an absolute home
610
611
  // path, so report the failure without echoing it or the raw cause.
611
612
  console.error(chalk.red(`${theme.status.error} Failed to install plugin (${describeInstallFailure(err)})`));
613
+ // A bare name (no `@scope`, no version, no path separator) is safe to
614
+ // echo, and it is exactly the shape a user copies out of
615
+ // `plugin discover`. Point at the qualified spec that would work.
616
+ if (isBareInstallName(spec)) {
617
+ const offering = await findMarketplacesOffering(mktMgr, spec).catch(() => []);
618
+ for (const marketplace of offering) {
619
+ console.error(chalk.dim(` Try: ${APP_NAME} plugin install ${spec}@${marketplace}`));
620
+ }
621
+ }
612
622
  process.exit(1);
613
623
  }
614
624
  }
package/src/cli.ts CHANGED
@@ -389,11 +389,34 @@ function routeLegacyRootArgv(argv: readonly string[]): string[] | undefined {
389
389
  return ["team", size, ...remaining];
390
390
  }
391
391
 
392
+ /**
393
+ * Map the common mistaken `models` subcommand spelling to non-agent listing.
394
+ *
395
+ * Agents frequently run `gjc models` from the bash tool expecting a catalog.
396
+ * Without this route, `models` was a positional launch prompt and nested agents
397
+ * re-invoked `gjc models`, spawning an unbounded process chain (#3857).
398
+ * Always rewrite to `launch --list-models` so the invocation exits after a
399
+ * bounded listing and never starts an interactive agent session.
400
+ */
401
+ export function routeModelsAlias(argv: readonly string[]): string[] | undefined {
402
+ if (argv[0] !== "models") return undefined;
403
+ const rest = argv.slice(1);
404
+ if (rest.length === 0) return ["launch", "--list-models"];
405
+ // Pure search tokens become a single fuzzy pattern (matches --list-models).
406
+ if (rest.every(token => !token.startsWith("-") && !token.startsWith("@"))) {
407
+ return ["launch", "--list-models", rest.join(" ")];
408
+ }
409
+ // Mixed flags still go through list-models first so "models" is never a prompt.
410
+ return ["launch", "--list-models", ...rest];
411
+ }
412
+
392
413
  /** Apply the same default-launch routing used by runCli after root fast paths. */
393
414
  export function routeRootArgv(argv: readonly string[]): string[] {
394
415
  const normalizedArgv = normalizeResumeAlias(argv);
395
416
  const legacyArgv = routeLegacyRootArgv(normalizedArgv);
396
417
  if (legacyArgv) return legacyArgv;
418
+ const modelsArgv = routeModelsAlias(normalizedArgv);
419
+ if (modelsArgv) return modelsArgv;
397
420
  const first = normalizedArgv[0];
398
421
  return first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help"
399
422
  ? normalizedArgv
@@ -13,13 +13,15 @@ import type {
13
13
  GcPruneOutcome,
14
14
  GcRecord,
15
15
  GcStoreAdapter,
16
+ GcWarning,
16
17
  } from "../gjc-runtime/gc-runtime";
17
18
  import { gcPidStatusLabel } from "../gjc-runtime/gc-runtime";
18
19
  import { resolveReceiptSpoolDir } from "../harness-control-plane/receipt-spool";
19
20
  import { readFileLockInfoForGc, removeFileLockDirForGc } from "./file-lock";
20
21
 
21
22
  const MAX_WALK_DEPTH = 6;
22
- const MAX_WALK_ENTRIES = 20_000;
23
+ /** Default per-root walk budget. Truncation is a warning, not a hard error. */
24
+ export const FILE_LOCK_GC_MAX_WALK_ENTRIES = 20_000;
23
25
 
24
26
  // High-cardinality, lock-free subtrees we never descend into. `.lock` dirs are
25
27
  // created next to config files, never inside these.
@@ -30,6 +32,11 @@ interface WalkState {
30
32
  truncated: boolean;
31
33
  }
32
34
 
35
+ export interface FileLocksGcCollectOptions {
36
+ /** Override per-root entry budget (tests). Defaults to {@link FILE_LOCK_GC_MAX_WALK_ENTRIES}. */
37
+ maxWalkEntries?: number;
38
+ }
39
+
33
40
  // Global, env-aware GJC lock roots. Per the approved scope this covers the
34
41
  // user config root, the agent dir (honors GJC_CODING_AGENT_DIR), and the
35
42
  // configured receipt-spool dir — NOT the invocation cwd's project `.gjc`.
@@ -102,8 +109,9 @@ async function walkForLockDirs(
102
109
  state: WalkState,
103
110
  lockDirs: Set<string>,
104
111
  errors: GcError[],
112
+ maxWalkEntries: number,
105
113
  ): Promise<void> {
106
- if (state.entries >= MAX_WALK_ENTRIES) {
114
+ if (state.entries >= maxWalkEntries) {
107
115
  state.truncated = true;
108
116
  return;
109
117
  }
@@ -137,45 +145,58 @@ async function walkForLockDirs(
137
145
  }
138
146
 
139
147
  for (const entry of entries) {
140
- if (state.entries >= MAX_WALK_ENTRIES) {
148
+ if (state.entries >= maxWalkEntries) {
141
149
  state.truncated = true;
142
150
  return;
143
151
  }
144
152
  if (PRUNED_DIR_NAMES.has(entry)) continue;
145
- await walkForLockDirs(path.join(dir, entry), depth + 1, state, lockDirs, errors);
153
+ await walkForLockDirs(path.join(dir, entry), depth + 1, state, lockDirs, errors, maxWalkEntries);
146
154
  }
147
155
  }
148
156
 
149
- export const fileLocksGcAdapter: GcStoreAdapter = {
150
- store: "file_locks",
151
- async collect(ctx: GcContext): Promise<GcCollectResult> {
152
- const records: GcRecord[] = [];
153
- const errors: GcError[] = [];
154
- const lockDirs = new Set<string>();
157
+ /**
158
+ * Discover + classify file-lock records. Each known lock root gets its own walk
159
+ * budget so truncating one root never skips the others. Caps surface as
160
+ * warnings (partial results), not hard discovery errors.
161
+ */
162
+ export async function collectFileLocksForGc(
163
+ ctx: GcContext,
164
+ options: FileLocksGcCollectOptions = {},
165
+ ): Promise<GcCollectResult> {
166
+ const maxWalkEntries = options.maxWalkEntries ?? FILE_LOCK_GC_MAX_WALK_ENTRIES;
167
+ const records: GcRecord[] = [];
168
+ const errors: GcError[] = [];
169
+ const warnings: GcWarning[] = [];
170
+ const lockDirs = new Set<string>();
171
+
172
+ for (const root of knownFileLockRoots(ctx)) {
173
+ // Fresh budget per root so a huge agent dir cannot starve config/spool.
155
174
  const state: WalkState = { entries: 0, truncated: false };
156
-
157
- for (const root of knownFileLockRoots(ctx)) {
158
- await walkForLockDirs(root, 0, state, lockDirs, errors);
159
- if (state.truncated) break;
160
- }
161
-
175
+ await walkForLockDirs(root, 0, state, lockDirs, errors, maxWalkEntries);
162
176
  if (state.truncated) {
163
- errors.push({
177
+ warnings.push({
164
178
  store: "file_locks",
165
- scope: "discovery",
166
- message: `file lock discovery capped at ${MAX_WALK_ENTRIES} entries`,
179
+ scope: root,
180
+ message: `file lock discovery capped at ${maxWalkEntries} entries for root ${root} (scanned ${state.entries})`,
167
181
  });
168
182
  }
183
+ }
169
184
 
170
- for (const lockDir of lockDirs) {
171
- try {
172
- records.push(await collectLockRecord(lockDir, ctx));
173
- } catch (error) {
174
- errors.push({ store: "file_locks", scope: lockDir, message: errorMessage(error) });
175
- }
185
+ for (const lockDir of lockDirs) {
186
+ try {
187
+ records.push(await collectLockRecord(lockDir, ctx));
188
+ } catch (error) {
189
+ errors.push({ store: "file_locks", scope: lockDir, message: errorMessage(error) });
176
190
  }
191
+ }
177
192
 
178
- return { records, errors };
193
+ return { records, errors, warnings };
194
+ }
195
+
196
+ export const fileLocksGcAdapter: GcStoreAdapter = {
197
+ store: "file_locks",
198
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
199
+ return collectFileLocksForGc(ctx);
179
200
  },
180
201
  async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
181
202
  const lockDir = record.path ?? record.id;
@@ -264,25 +264,25 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
264
264
  architect: "cursor/composer-2.5-fast",
265
265
  }),
266
266
  profile("minimax-eco", ["minimax-code"], {
267
- default: "minimax-code/minimax-m3:low",
268
- executor: "minimax-code/minimax-m3:minimal",
269
- planner: "minimax-code/minimax-m3:low",
270
- critic: "minimax-code/minimax-m3:medium",
271
- architect: "minimax-code/minimax-m3:high",
267
+ default: "minimax-code/MiniMax-M3:low",
268
+ executor: "minimax-code/MiniMax-M3:minimal",
269
+ planner: "minimax-code/MiniMax-M3:low",
270
+ critic: "minimax-code/MiniMax-M3:medium",
271
+ architect: "minimax-code/MiniMax-M3:high",
272
272
  }),
273
273
  profile("minimax-medium", ["minimax-code"], {
274
- default: "minimax-code/minimax-m3:medium",
275
- executor: "minimax-code/minimax-m3:low",
276
- planner: "minimax-code/minimax-m3:medium",
277
- critic: "minimax-code/minimax-m3:high",
278
- architect: "minimax-code/minimax-m3:xhigh",
274
+ default: "minimax-code/MiniMax-M3:medium",
275
+ executor: "minimax-code/MiniMax-M3:low",
276
+ planner: "minimax-code/MiniMax-M3:medium",
277
+ critic: "minimax-code/MiniMax-M3:high",
278
+ architect: "minimax-code/MiniMax-M3:xhigh",
279
279
  }),
280
280
  profile("minimax-pro", ["minimax-code"], {
281
- default: "minimax-code/minimax-m3:xhigh",
282
- executor: "minimax-code/minimax-m3:medium",
283
- planner: "minimax-code/minimax-m3:high",
284
- critic: "minimax-code/minimax-m3:xhigh",
285
- architect: "minimax-code/minimax-m3:xhigh",
281
+ default: "minimax-code/MiniMax-M3:xhigh",
282
+ executor: "minimax-code/MiniMax-M3:medium",
283
+ planner: "minimax-code/MiniMax-M3:high",
284
+ critic: "minimax-code/MiniMax-M3:xhigh",
285
+ architect: "minimax-code/MiniMax-M3:xhigh",
286
286
  }),
287
287
  profile("alibaba-token-plan-balanced", ["alibaba-token-plan"], {
288
288
  default: "alibaba-token-plan/qwen3.8-max-preview:medium",
@@ -306,11 +306,11 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
306
306
  critic: "alibaba-token-plan/qwen3.8-max-preview:xhigh",
307
307
  }),
308
308
  profile("alibaba-token-plan-qwen-deepseek", ["alibaba-token-plan"], {
309
- default: "alibaba-token-plan/qwen-3.8-max:high",
309
+ default: "alibaba-token-plan/qwen3.8-max:high",
310
310
  executor: "alibaba-token-plan/deepseek-v4-flash-0731:high",
311
311
  planner: "alibaba-token-plan/deepseek-v4-flash-0731:max",
312
- architect: "alibaba-token-plan/qwen-3.8-max:xhigh",
313
- critic: "alibaba-token-plan/qwen-3.8-max:xhigh",
312
+ architect: "alibaba-token-plan/qwen3.8-max:xhigh",
313
+ critic: "alibaba-token-plan/qwen3.8-max:xhigh",
314
314
  }),
315
315
  profile("alibaba-token-plan-glm-deepseek", ["alibaba-token-plan"], {
316
316
  default: "alibaba-token-plan/glm-5.2:high",
@@ -926,12 +926,24 @@ const customReferenceMap = buildCustomReferenceMap();
926
926
 
927
927
  function getCustomReferenceCandidateIds(modelId: string): string[] {
928
928
  const candidates = new Set<string>();
929
- const minimaxM = /^minimax-m(\d+(?:\.\d+)*)$/i.exec(modelId.trim());
930
- const queue = minimaxM ? [`MiniMax-M${minimaxM[1]}`, modelId] : [modelId];
929
+ const trimmedId = modelId.trim();
930
+ const minimaxM = /^minimax-m(\d+(?:\.\d+)*)$/i.exec(trimmedId);
931
+ const queue = minimaxM ? [`MiniMax-M${minimaxM[1]}`, trimmedId] : [trimmedId];
931
932
  if (minimaxM) {
932
- // MiniMax catalogs include lowercase wire ids plus display-cased aliases.
933
- // Custom providers should keep the lowercase wire id while inheriting the
934
- // canonical display casing when the alias exists.
933
+ // First-class MiniMax catalogs expose canonical `MiniMax-M*` ids only,
934
+ // but custom providers may still use lowercase wire ids. Normalize to
935
+ // the canonical display casing so metadata inheritance keeps working.
936
+ }
937
+ // Namespaced wire IDs (e.g. `cline-pass/deepseek-v4-flash`) keep the full id for
938
+ // the API request, but should still try the leaf segment against bundled
939
+ // references so capability metadata is not silently replaced by 128K/16K defaults.
940
+ // Only an exact leaf match in the reference map inherits; unknown leaves stay defaulted.
941
+ const slashIndex = trimmedId.lastIndexOf("/");
942
+ if (slashIndex >= 0 && slashIndex < trimmedId.length - 1) {
943
+ const leafId = trimmedId.slice(slashIndex + 1).trim();
944
+ if (leafId && leafId !== trimmedId) {
945
+ queue.push(leafId);
946
+ }
935
947
  }
936
948
  for (let index = 0; index < queue.length; index += 1) {
937
949
  const candidate = queue[index]?.trim();
@@ -20,7 +20,7 @@ const ReasoningEffortMapSchema = z.object({
20
20
  max: z.string().optional(),
21
21
  });
22
22
 
23
- export const OpenAICompatSchema = z.object({
23
+ export const ModelCompatSchema = z.object({
24
24
  supportsStore: z.boolean().optional(),
25
25
  supportsDeveloperRole: z.boolean().optional(),
26
26
  sendSessionHeaders: z.boolean().optional(),
@@ -48,8 +48,13 @@ export const OpenAICompatSchema = z.object({
48
48
  extraBody: z.record(z.string(), z.unknown()).optional(),
49
49
  supportsStrictMode: z.boolean().optional(),
50
50
  toolStrictMode: z.enum(["all_strict", "none"]).optional(),
51
+ supportsLongCacheRetention: z.boolean().optional(),
52
+ promptCacheMode: z.enum(["none", "explicit", "automatic"]).optional(),
51
53
  });
52
54
 
55
+ // Backward-compatible export for callers that imported the original schema name.
56
+ export const OpenAICompatSchema = ModelCompatSchema;
57
+
53
58
  export const GJC_MODEL_EFFORT_IDS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
54
59
  export const GJC_MODEL_ASSIGNMENT_TARGET_IDS = ["default", "executor", "architect", "planner", "critic"] as const;
55
60
  export const EffortSchema = z.enum(GJC_MODEL_EFFORT_IDS);
@@ -146,7 +151,7 @@ const ModelDefinitionSchema = z
146
151
  contextWindow: z.number().optional(),
147
152
  maxTokens: z.number().optional(),
148
153
  headers: z.record(z.string(), z.string()).optional(),
149
- compat: OpenAICompatSchema.optional(),
154
+ compat: ModelCompatSchema.optional(),
150
155
  contextPromotionTarget: z.string().min(1).optional(),
151
156
  wireModelId: z.string().min(1).optional(),
152
157
  requestTransform: RequestTransformSchema.optional(),
@@ -173,7 +178,7 @@ export const ModelOverrideSchema = z
173
178
  contextWindow: z.number().optional(),
174
179
  maxTokens: z.number().optional(),
175
180
  headers: z.record(z.string(), z.string()).optional(),
176
- compat: OpenAICompatSchema.optional(),
181
+ compat: ModelCompatSchema.optional(),
177
182
  contextPromotionTarget: z.string().min(1).optional(),
178
183
  wireModelId: z.string().min(1).optional(),
179
184
  requestTransform: RequestTransformSchema.optional(),
@@ -221,7 +226,7 @@ const ProviderConfigSchema = z
221
226
  ])
222
227
  .optional(),
223
228
  headers: z.record(z.string(), z.string()).optional(),
224
- compat: OpenAICompatSchema.optional(),
229
+ compat: ModelCompatSchema.optional(),
225
230
  webSearch: z.enum(["on", "off", "auto"]).optional(),
226
231
  authHeader: z.boolean().optional(),
227
232
  auth: ProviderAuthSchema.optional(),
@@ -122,7 +122,7 @@ Deep Interview threshold: <resolvedThresholdPercent> (source: <resolvedThreshold
122
122
  - Substitute `<resolvedThreshold>`, `<resolvedThresholdPercent>`, and `<resolvedThresholdSource>` throughout the remaining instructions before continuing.
123
123
  - Include `threshold_source` in the first `gjc deep-interview write` payload and preserve it on later state updates; do not edit `.gjc/_session-{sessionid}/state` files directly unless an explicit force override is active.
124
124
  - Include both threshold and source in the final spec metadata.
125
- - Read any `language` object from active deep-interview state and carry `language.instruction` forward mechanically. If absent, default to English unless `{{ARGUMENTS}}` makes another user/session language obvious or the user explicitly requests another language. Do not add language-specific special cases.
125
+ - Read any `language` object from active deep-interview state and carry `language.instruction` forward mechanically. If absent, default to English unless the appended `User:` request makes another user/session language obvious or the user explicitly requests another language. Do not add language-specific special cases.
126
126
 
127
127
  ## Phase 0.5: Suitability Gate
128
128
 
@@ -11,6 +11,7 @@ import {
11
11
  assertDeepInterviewInputWithinLimit,
12
12
  assertDeepInterviewIntentManifest,
13
13
  assertDeepInterviewStructuredResponseWithinLimit,
14
+ canonicalizeDeepInterviewText,
14
15
  createDeepInterviewIntentManifest,
15
16
  type DeepInterviewEstablishedFact,
16
17
  type DeepInterviewIntentItem,
@@ -106,16 +107,19 @@ export function buildAnswerShell(
106
107
  input: DeepInterviewAnswerInput,
107
108
  now: string = new Date().toISOString(),
108
109
  ): DeepInterviewRoundRecord {
110
+ const questionText = canonicalizeDeepInterviewText(input.questionText);
111
+ const selectedOptions = input.selectedOptions?.map(canonicalizeDeepInterviewText);
112
+ const customInput = input.customInput === undefined ? undefined : canonicalizeDeepInterviewText(input.customInput);
109
113
  return {
110
114
  round_key: deriveRoundKey(input.interviewId, input),
111
115
  round_id: input.round_id,
112
116
  round: input.round,
113
117
  question_id: input.questionId,
114
- question_text: input.questionText,
115
- question_hash: questionHash(input.questionText),
116
- answer_hash: answerHash(input.selectedOptions, input.customInput),
117
- selected_options: input.selectedOptions,
118
- custom_input: input.customInput,
118
+ question_text: questionText,
119
+ question_hash: questionHash(questionText),
120
+ answer_hash: answerHash(selectedOptions, customInput),
121
+ selected_options: selectedOptions,
122
+ custom_input: customInput,
119
123
  component: input.component,
120
124
  dimension: input.dimension,
121
125
  ambiguity_at_ask: input.ambiguity,
@@ -97,13 +97,34 @@ export function hashContent(value: string): string {
97
97
  return createHash("sha256").update(value).digest("hex").slice(0, 32);
98
98
  }
99
99
 
100
+ /**
101
+ * Canonicalize interview prose to NFC.
102
+ *
103
+ * Hangul, unlike most Latin prose, routinely reaches the interview in both
104
+ * composed and decomposed form (macOS-sourced pastes and some IME/clipboard
105
+ * paths emit NFD), and the two forms are the same text for the user while
106
+ * being different JavaScript strings. Canonicalizing at the identity and
107
+ * length boundaries keeps one round per answer and keeps the character caps
108
+ * script-fair: decomposed Hangul otherwise costs two to three code points per
109
+ * syllable against the same budget.
110
+ */
111
+ export function canonicalizeDeepInterviewText(value: string): string {
112
+ return value.normalize("NFC");
113
+ }
114
+
100
115
  export function questionHash(questionText: string): string {
101
- return hashContent(questionText);
116
+ return hashContent(canonicalizeDeepInterviewText(questionText));
102
117
  }
103
118
 
104
119
  export function answerHash(selectedOptions: string[] | undefined, customInput: string | undefined): string {
105
120
  return createHash("sha256")
106
- .update(JSON.stringify({ selected: selectedOptions ?? [], custom: customInput ?? null }))
121
+ .update(
122
+ JSON.stringify({
123
+ selected: (selectedOptions ?? []).map(canonicalizeDeepInterviewText),
124
+ custom:
125
+ customInput === undefined || customInput === null ? null : canonicalizeDeepInterviewText(customInput),
126
+ }),
127
+ )
107
128
  .digest("hex");
108
129
  }
109
130
 
@@ -471,10 +492,13 @@ export function assertDeepInterviewStructuredResponseWithinLimit(value: unknown)
471
492
  /**
472
493
  * Assert a free-text input is within its size cap. Never inspects content for shell
473
494
  * metacharacters — free-text fields accept prose verbatim; this only bounds length.
495
+ * The cap is measured on the NFC form so decomposed Hangul is charged the same
496
+ * budget as the identical composed text.
474
497
  */
475
498
  export function assertDeepInterviewInputWithinLimit(value: string, max: number, fieldName = "input"): void {
476
499
  if (typeof value !== "string") throw new Error(`${fieldName} must be a string`);
477
- if (deepInterviewCharacterCount(value) > max) throw new Error(`${fieldName} exceeds max length ${max}`);
500
+ if (deepInterviewCharacterCount(canonicalizeDeepInterviewText(value)) > max)
501
+ throw new Error(`${fieldName} exceeds max length ${max}`);
478
502
  }
479
503
 
480
504
  /** Validate user-supplied deep-interview prose before an envelope is persisted. */
@@ -72,6 +72,12 @@ export function buildGcReportText(report: GcReport): string {
72
72
  lines.push("");
73
73
  }
74
74
 
75
+ if (report.warnings.length > 0) {
76
+ lines.push(`Warnings (${report.warnings.length})`);
77
+ for (const warning of report.warnings) lines.push(` [${warning.store}/${warning.scope}] ${warning.message}`);
78
+ lines.push("");
79
+ }
80
+
75
81
  if (report.errors.length > 0) {
76
82
  lines.push(`Errors (${report.errors.length})`);
77
83
  for (const err of report.errors) lines.push(` [${err.store}/${err.scope}] ${err.message}`);
@@ -82,7 +88,8 @@ export function buildGcReportText(report: GcReport): string {
82
88
  lines.push(
83
89
  `Summary: discovered=${c.discovered} stale=${c.stale} alive=${c.alive} eperm=${c.eperm} unknown=${c.unknown} ` +
84
90
  `terminal_lifecycle=${c.terminal_lifecycle} unclassified=${c.unclassified} ` +
85
- `${report.dry_run ? `would_remove=${c.would_remove}` : `removed=${c.removed} failed=${c.failed}`} errors=${c.errors}`,
91
+ `${report.dry_run ? `would_remove=${c.would_remove}` : `removed=${c.removed} failed=${c.failed}`} ` +
92
+ `errors=${c.errors} warnings=${report.warnings.length}`,
86
93
  );
87
94
  lines.push("");
88
95
  return `${lines.join("\n")}`;
@@ -78,9 +78,18 @@ export interface GcError {
78
78
  message: string;
79
79
  }
80
80
 
81
+ /** Non-fatal discovery partials (e.g. traversal caps). Does not affect exit code. */
82
+ export interface GcWarning {
83
+ store: GcStore;
84
+ scope: string;
85
+ message: string;
86
+ }
87
+
81
88
  export interface GcCollectResult {
82
89
  records: GcRecord[];
83
90
  errors: GcError[];
91
+ /** Optional partial-result notices; omitted by adapters that have none. */
92
+ warnings?: GcWarning[];
84
93
  }
85
94
 
86
95
  export interface GcPruneOutcome {
@@ -140,6 +149,8 @@ export interface GcReport {
140
149
  stores: Record<GcStore, GcRecord[]>;
141
150
  counts: GcCounts;
142
151
  errors: GcError[];
152
+ /** Partial-result notices that do not fail the run (e.g. walk caps). */
153
+ warnings: GcWarning[];
143
154
  session_index?: GcSessionIndexHealth;
144
155
  }
145
156
 
@@ -298,12 +309,14 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
298
309
  export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext, prune: boolean): Promise<GcReport> {
299
310
  const stores = emptyStores();
300
311
  const errors: GcError[] = [];
312
+ const warnings: GcWarning[] = [];
301
313
 
302
314
  for (const adapter of adapters) {
303
315
  try {
304
316
  const result = await adapter.collect(ctx);
305
317
  stores[adapter.store].push(...result.records);
306
318
  errors.push(...result.errors);
319
+ if (result.warnings) warnings.push(...result.warnings);
307
320
  } catch (error) {
308
321
  errors.push({
309
322
  store: adapter.store,
@@ -350,7 +363,7 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
350
363
  }
351
364
  }
352
365
 
353
- return { dry_run: !prune, stores, counts: computeCounts(stores, errors), errors };
366
+ return { dry_run: !prune, stores, counts: computeCounts(stores, errors), errors, warnings };
354
367
  }
355
368
 
356
369
  /**
@@ -358,6 +371,7 @@ export async function collectGcReport(adapters: GcStoreAdapter[], ctx: GcContext
358
371
  * - usage/parse error => 2
359
372
  * - hard discovery errors => 1 (both modes)
360
373
  * - prune mode with a failed intended removal => 1
374
+ * - warnings alone never fail the run
361
375
  * - otherwise => 0
362
376
  */
363
377
  export function computeExitCode(report: GcReport): number {