@gmickel/gno 1.25.1 → 1.26.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 (56) hide show
  1. package/README.md +10 -4
  2. package/assets/skill/SKILL.md +29 -17
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.25.1.zip → gno-browser-clipper-v1.26.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +1 -1
  7. package/spec/cli.md +146 -4
  8. package/spec/db/schema.sql +1 -1
  9. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  10. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  11. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  12. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  13. package/spec/project-profile.schema.json +301 -0
  14. package/src/cli/commands/collection/add.ts +39 -45
  15. package/src/cli/commands/collection/remove.ts +28 -28
  16. package/src/cli/commands/collection/rename.ts +55 -73
  17. package/src/cli/commands/context/add.ts +37 -26
  18. package/src/cli/commands/context/rm.ts +47 -20
  19. package/src/cli/commands/init.ts +55 -125
  20. package/src/cli/commands/models/use.ts +43 -38
  21. package/src/cli/commands/profile-apply.ts +334 -0
  22. package/src/cli/commands/profile.ts +409 -0
  23. package/src/cli/commands/setup-activation.ts +205 -54
  24. package/src/cli/commands/setup-profile.ts +223 -0
  25. package/src/cli/commands/setup.ts +3 -0
  26. package/src/cli/program.ts +110 -9
  27. package/src/config/index.ts +3 -0
  28. package/src/config/project-profile.ts +367 -0
  29. package/src/config/saver.ts +16 -7
  30. package/src/config/types.ts +42 -0
  31. package/src/core/config-mutation.ts +138 -76
  32. package/src/core/config-write-lock.ts +89 -0
  33. package/src/core/context-identity.ts +16 -0
  34. package/src/core/context-resolver.ts +2 -12
  35. package/src/core/folder-setup-planning.ts +6 -21
  36. package/src/core/folder-setup.ts +30 -2
  37. package/src/core/path-rules.ts +53 -0
  38. package/src/core/project-affinity-surface.ts +102 -7
  39. package/src/core/project-profile-apply-state.ts +268 -0
  40. package/src/core/project-profile-apply-validation.ts +95 -0
  41. package/src/core/project-profile-apply.ts +408 -0
  42. package/src/core/project-profile-canonical.ts +71 -0
  43. package/src/core/project-profile-diff.ts +302 -0
  44. package/src/core/project-profile-discovery.ts +519 -0
  45. package/src/core/project-profile-file.ts +37 -0
  46. package/src/core/project-profile-parser.ts +98 -0
  47. package/src/core/project-profile.ts +490 -0
  48. package/src/ingestion/walker.ts +85 -44
  49. package/src/llm/cache.ts +21 -0
  50. package/src/serve/config-sync.ts +2 -2
  51. package/src/serve/resident-runtime.ts +1 -0
  52. package/src/serve/routes/api.ts +1 -0
  53. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  54. package/src/store/migrations/index.ts +2 -0
  55. package/src/store/sqlite/adapter.ts +83 -0
  56. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +0 -1
@@ -1291,6 +1291,77 @@ function wireSearchCommands(program: Command): void {
1291
1291
  // ─────────────────────────────────────────────────────────────────────────────
1292
1292
 
1293
1293
  function wireOnboardingCommands(program: Command): void {
1294
+ const profileCmd = program
1295
+ .command("profile")
1296
+ .description("Inspect a project-local retrieval profile");
1297
+
1298
+ const wireProfileRead = (
1299
+ command: "check" | "show" | "diff",
1300
+ description: string
1301
+ ): void => {
1302
+ profileCmd
1303
+ .command(`${command} [path]`)
1304
+ .description(description)
1305
+ .option("--json", "JSON output")
1306
+ .action(
1307
+ async (path: string | undefined, cmdOpts: Record<string, unknown>) => {
1308
+ const globals = getGlobals();
1309
+ const json = Boolean(cmdOpts.json) || globals.json;
1310
+ const { formatProjectProfileResult, runProjectProfileCommand } =
1311
+ await import("./commands/profile");
1312
+ const outcome = await runProjectProfileCommand({
1313
+ command,
1314
+ path,
1315
+ configPath: globals.config,
1316
+ offline: globals.offline,
1317
+ });
1318
+ process.stdout.write(
1319
+ `${formatProjectProfileResult(outcome.result, { json })}\n`
1320
+ );
1321
+ if (outcome.exitCode !== 0) {
1322
+ throw new CliError(
1323
+ outcome.exitCode === 1 ? "VALIDATION" : "RUNTIME",
1324
+ "Project profile inspection failed",
1325
+ { silent: true }
1326
+ );
1327
+ }
1328
+ }
1329
+ );
1330
+ };
1331
+
1332
+ wireProfileRead("check", "Validate a project-local retrieval profile");
1333
+ wireProfileRead("show", "Show normalized project-local retrieval state");
1334
+ wireProfileRead("diff", "Compare a project profile with local config");
1335
+ profileCmd
1336
+ .command("apply [path]")
1337
+ .description("Apply a project profile without implicit deletion")
1338
+ .option("--json", "JSON output")
1339
+ .action(
1340
+ async (path: string | undefined, cmdOpts: Record<string, unknown>) => {
1341
+ const globals = getGlobals();
1342
+ const json = Boolean(cmdOpts.json) || globals.json;
1343
+ const {
1344
+ formatProjectProfileApplyResult,
1345
+ runProjectProfileApplyCommand,
1346
+ } = await import("./commands/profile-apply");
1347
+ const outcome = await runProjectProfileApplyCommand({
1348
+ path,
1349
+ configPath: globals.config,
1350
+ indexName: globals.index,
1351
+ });
1352
+ process.stdout.write(
1353
+ `${formatProjectProfileApplyResult(outcome.result, { json })}\n`
1354
+ );
1355
+ if (outcome.exitCode !== 0) {
1356
+ throw new CliError(
1357
+ outcome.exitCode === 1 ? "VALIDATION" : "RUNTIME",
1358
+ "Project profile apply failed",
1359
+ { silent: true }
1360
+ );
1361
+ }
1362
+ }
1363
+ );
1364
+
1294
1365
  // init - Initialize GNO
1295
1366
  program
1296
1367
  .command("init [path]")
@@ -1320,6 +1391,7 @@ function wireOnboardingCommands(program: Command): void {
1320
1391
  | undefined,
1321
1392
  language: cmdOpts.language as string | undefined,
1322
1393
  yes: globals.yes,
1394
+ configPath: globals.config,
1323
1395
  });
1324
1396
 
1325
1397
  if (!result.success) {
@@ -1367,13 +1439,20 @@ function wireOnboardingCommands(program: Command): void {
1367
1439
  collectRepeatableValue,
1368
1440
  []
1369
1441
  )
1442
+ .option(
1443
+ "--apply-profile",
1444
+ "apply a valid project profile before lexical setup"
1445
+ )
1370
1446
  .option("--no-semantic", "skip background semantic indexing")
1371
1447
  .option("--json", "JSON output")
1372
1448
  .action(async (folder: string, cmdOpts: Record<string, unknown>) => {
1373
1449
  const globals = getGlobals();
1374
1450
  const json = Boolean(cmdOpts.json) || globals.json;
1375
- const { formatSetupOutputResult, setupWithActivation } =
1376
- await import("./commands/setup-activation");
1451
+ const {
1452
+ formatSetupOutputResult,
1453
+ formatSetupProfileAdvisory,
1454
+ setupWithActivation,
1455
+ } = await import("./commands/setup-activation");
1377
1456
  const exclusions = cmdOpts.exclude as string[];
1378
1457
  const outcome = await setupWithActivation({
1379
1458
  folder,
@@ -1381,6 +1460,7 @@ function wireOnboardingCommands(program: Command): void {
1381
1460
  exclude: exclusions.length > 0 ? exclusions : undefined,
1382
1461
  authorizeSecretRisk: Boolean(cmdOpts.authorizeSecretRisk),
1383
1462
  connectorIds: cmdOpts.connector as string[],
1463
+ applyProfile: Boolean(cmdOpts.applyProfile),
1384
1464
  semantic: cmdOpts.semantic !== false,
1385
1465
  indexName: globals.index,
1386
1466
  configPath: globals.config,
@@ -1391,6 +1471,17 @@ function wireOnboardingCommands(program: Command): void {
1391
1471
  progress: (stage) => {
1392
1472
  process.stderr.write(`setup: ${stage}\n`);
1393
1473
  },
1474
+ onProfileAdvisory: (profile) => {
1475
+ if (json || globals.quiet) return;
1476
+ const advisory = formatSetupProfileAdvisory(profile, {
1477
+ applyRequested: Boolean(cmdOpts.applyProfile),
1478
+ });
1479
+ if (advisory) process.stderr.write(`${advisory}\n`);
1480
+ },
1481
+ onProfileApply: (profile) => {
1482
+ if (json || globals.quiet) return;
1483
+ process.stderr.write(`setup: project profile ${profile.status}\n`);
1484
+ },
1394
1485
  });
1395
1486
  const output = formatSetupOutputResult(outcome.result, { json });
1396
1487
  if (json || outcome.exitCode === 0) {
@@ -1958,6 +2049,7 @@ function wireManagementCommands(program: Command): void {
1958
2049
  include: cmdOpts.include as string | undefined,
1959
2050
  exclude: cmdOpts.exclude as string | undefined,
1960
2051
  update: cmdOpts.update as string | undefined,
2052
+ configPath: getGlobals().config,
1961
2053
  });
1962
2054
  });
1963
2055
 
@@ -1982,7 +2074,7 @@ function wireManagementCommands(program: Command): void {
1982
2074
  .description("Remove a collection")
1983
2075
  .action(async (name: string) => {
1984
2076
  const { collectionRemove } = await import("./commands/collection");
1985
- await collectionRemove(name);
2077
+ await collectionRemove(name, { configPath: getGlobals().config });
1986
2078
  });
1987
2079
 
1988
2080
  collectionCmd
@@ -1990,7 +2082,9 @@ function wireManagementCommands(program: Command): void {
1990
2082
  .description("Rename a collection")
1991
2083
  .action(async (oldName: string, newName: string) => {
1992
2084
  const { collectionRename } = await import("./commands/collection");
1993
- await collectionRename(oldName, newName);
2085
+ await collectionRename(oldName, newName, {
2086
+ configPath: getGlobals().config,
2087
+ });
1994
2088
  });
1995
2089
 
1996
2090
  collectionCmd
@@ -2017,9 +2111,11 @@ function wireManagementCommands(program: Command): void {
2017
2111
  .description("Add context metadata for a scope")
2018
2112
  .action(async (scope: string, text: string) => {
2019
2113
  const { contextAdd } = await import("./commands/context");
2020
- const exitCode = await contextAdd(scope, text);
2114
+ const exitCode = await contextAdd(scope, text, {
2115
+ configPath: getGlobals().config,
2116
+ });
2021
2117
  if (exitCode !== 0) {
2022
- throw new CliError("RUNTIME", "Failed to add context");
2118
+ throw new CliError("VALIDATION", "Failed to add context");
2023
2119
  }
2024
2120
  });
2025
2121
 
@@ -2301,11 +2397,16 @@ function wireManagementCommands(program: Command): void {
2301
2397
  });
2302
2398
 
2303
2399
  contextCmd
2304
- .command("rm <uri>")
2400
+ .command("rm <scope> [text]")
2305
2401
  .description("Remove context item")
2306
- .action(async (uri: string) => {
2402
+ .action(async (scope: string, text?: string) => {
2307
2403
  const { contextRm } = await import("./commands/context");
2308
- await contextRm(uri);
2404
+ const exitCode = await contextRm(scope, text, {
2405
+ configPath: getGlobals().config,
2406
+ });
2407
+ if (exitCode !== 0) {
2408
+ throw new CliError("VALIDATION", "Failed to remove context");
2409
+ }
2309
2410
  });
2310
2411
 
2311
2412
  // models subcommands
@@ -42,6 +42,7 @@ export {
42
42
  type SaveResult,
43
43
  saveConfig,
44
44
  saveConfigToPath,
45
+ saveTextToPath,
45
46
  } from "./saver";
46
47
  // Types and schemas
47
48
  export {
@@ -66,6 +67,8 @@ export {
66
67
  getCollectionFromScope,
67
68
  isValidLanguageHint,
68
69
  parseScope,
70
+ type ProjectProfileBinding,
71
+ ProjectProfileBindingSchema,
69
72
  type ScopeType,
70
73
  ScopeTypeSchema,
71
74
  } from "./types";
@@ -0,0 +1,367 @@
1
+ import { z } from "zod";
2
+
3
+ import { NOTE_PRESETS } from "../core/note-presets";
4
+ import { hasLikelySecretPath } from "../core/path-rules";
5
+ import {
6
+ isValidLanguageHint,
7
+ PROJECT_AFFINITY_MAX_CONTRIBUTION,
8
+ } from "./types";
9
+
10
+ export const PROJECT_PROFILE_SCHEMA_VERSION = "1.0" as const;
11
+ export const PROJECT_PROFILE_FINGERPRINT_DOMAIN = "gno-project-profile\0v1.0\0";
12
+ export const PROJECT_PROFILE_FORCED_EXCLUDES = [".gno"] as const;
13
+
14
+ const COLLECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
15
+ const REFERENCE_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
16
+ const CAPABILITY_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
17
+ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-z]:/i;
18
+ const WINDOWS_UNC_PATH_PATTERN = /^(?:\\\\|\/\/)/;
19
+ const ENV_EXPANSION_PATTERN =
20
+ /(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%)/;
21
+ const GLOB_META_PATTERN = /[*?[\]{}]/;
22
+ const WINDOWS_INVALID_COMPONENT_PATTERN = /[<>:"|]/;
23
+ const WINDOWS_RESERVED_COMPONENT_PATTERN =
24
+ /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
25
+ const RUNTIME_PATH_PATTERN =
26
+ /(?:^|\/)\.gno(?:\/|$)|\.(?:db|sqlite|sqlite3|gguf|lock)$/i;
27
+ const NOTE_PRESET_IDS = new Set<string>(
28
+ NOTE_PRESETS.map((preset) => preset.id)
29
+ );
30
+
31
+ const globStructureIssue = (value: string): string | null => {
32
+ if (value.startsWith("!")) {
33
+ return "must not use whole-pattern negation";
34
+ }
35
+ let braceDepth = 0;
36
+ let bracketDepth = 0;
37
+ for (const character of value) {
38
+ if (character === "{") braceDepth += 1;
39
+ else if (character === "}") {
40
+ braceDepth -= 1;
41
+ if (braceDepth < 0) return "contains an unmatched closing brace";
42
+ } else if (character === "[") bracketDepth += 1;
43
+ else if (character === "]") {
44
+ bracketDepth -= 1;
45
+ if (bracketDepth < 0) return "contains an unmatched closing bracket";
46
+ }
47
+ if (braceDepth > 16 || bracketDepth > 16) {
48
+ return "contains excessively nested glob groups";
49
+ }
50
+ }
51
+ if (braceDepth !== 0) return "contains an unmatched opening brace";
52
+ if (bracketDepth !== 0) return "contains an unmatched opening bracket";
53
+ return null;
54
+ };
55
+
56
+ const windowsComponentIssue = (
57
+ normalized: string,
58
+ allowGlob: boolean
59
+ ): string | null => {
60
+ const components: string[] = [];
61
+ let component = "";
62
+ let braceDepth = 0;
63
+ const pushComponent = (): void => {
64
+ if (component !== "") components.push(component);
65
+ component = "";
66
+ };
67
+ for (const character of normalized) {
68
+ const isBraceSeparator =
69
+ character === "{" ||
70
+ character === "}" ||
71
+ (character === "," && braceDepth > 0);
72
+ if (character === "/" || isBraceSeparator) {
73
+ pushComponent();
74
+ if (character === "{") braceDepth += 1;
75
+ else if (character === "}") braceDepth -= 1;
76
+ continue;
77
+ }
78
+ component += character;
79
+ }
80
+ pushComponent();
81
+
82
+ for (const component of components) {
83
+ if (component === "." || component === "") continue;
84
+ let hasControlCharacter = false;
85
+ for (const character of component) {
86
+ if ((character.codePointAt(0) ?? 0) < 32) {
87
+ hasControlCharacter = true;
88
+ break;
89
+ }
90
+ }
91
+ if (
92
+ hasControlCharacter ||
93
+ WINDOWS_INVALID_COMPONENT_PATTERN.test(component)
94
+ ) {
95
+ return "contains characters invalid in Windows path components";
96
+ }
97
+ if (component.endsWith(".") || component.endsWith(" ")) {
98
+ return "contains a component ending in a dot or space";
99
+ }
100
+ if (
101
+ (!allowGlob || !GLOB_META_PATTERN.test(component)) &&
102
+ WINDOWS_RESERVED_COMPONENT_PATTERN.test(component)
103
+ ) {
104
+ return "contains a Windows-reserved path component";
105
+ }
106
+ }
107
+ return null;
108
+ };
109
+
110
+ const portablePathIssue = (
111
+ value: string,
112
+ options: { allowGlob: boolean; allowRuntimeExclusion: boolean }
113
+ ): string | null => {
114
+ if (value.includes("\0")) return "contains a NUL byte";
115
+ if (
116
+ value.startsWith("/") ||
117
+ value.startsWith("\\") ||
118
+ WINDOWS_UNC_PATH_PATTERN.test(value) ||
119
+ WINDOWS_DRIVE_PATH_PATTERN.test(value) ||
120
+ value.startsWith("~")
121
+ ) {
122
+ return "must be repository-relative on every supported platform";
123
+ }
124
+ if (ENV_EXPANSION_PATTERN.test(value)) {
125
+ return "must not contain environment expansion";
126
+ }
127
+
128
+ const normalized = value.replaceAll("\\", "/");
129
+ if (normalized.split("/").includes("..")) {
130
+ return "must not contain traversal segments";
131
+ }
132
+ const windowsIssue = windowsComponentIssue(normalized, options.allowGlob);
133
+ if (windowsIssue) return windowsIssue;
134
+ if (!options.allowGlob && GLOB_META_PATTERN.test(normalized)) {
135
+ return "must not contain glob metacharacters";
136
+ }
137
+ if (options.allowGlob) {
138
+ const globIssue = globStructureIssue(normalized);
139
+ if (globIssue) return globIssue;
140
+ if (normalized.includes("{") || normalized.includes("}")) {
141
+ return "must not use brace alternatives; use separate include or exclude entries";
142
+ }
143
+ }
144
+ if (!options.allowRuntimeExclusion && RUNTIME_PATH_PATTERN.test(normalized)) {
145
+ return "must not reference GNO runtime state";
146
+ }
147
+ return null;
148
+ };
149
+
150
+ const portablePathSchema = (
151
+ options: { allowGlob?: boolean; allowRuntimeExclusion?: boolean } = {}
152
+ ) =>
153
+ z
154
+ .string()
155
+ .min(1)
156
+ .max(512)
157
+ .superRefine((value, context) => {
158
+ const issue = portablePathIssue(value, {
159
+ allowGlob: options.allowGlob ?? false,
160
+ allowRuntimeExclusion: options.allowRuntimeExclusion ?? false,
161
+ });
162
+ if (issue) {
163
+ context.addIssue({
164
+ code: "custom",
165
+ message: `UNSAFE_PATH: ${issue}`,
166
+ });
167
+ }
168
+ });
169
+
170
+ export const ProjectProfileCollectionSchema = z
171
+ .object({
172
+ name: z.string().regex(COLLECTION_NAME_PATTERN),
173
+ root: portablePathSchema().default("."),
174
+ include: z
175
+ .array(portablePathSchema({ allowGlob: true }))
176
+ .min(1)
177
+ .max(128)
178
+ .superRefine((values, context) => {
179
+ const seen = new Set<string>();
180
+ for (const [index, value] of values.entries()) {
181
+ if (seen.has(value)) {
182
+ context.addIssue({
183
+ code: "custom",
184
+ path: [index],
185
+ message: "Duplicate include rule",
186
+ });
187
+ }
188
+ seen.add(value);
189
+ }
190
+ })
191
+ .default(["**/*"]),
192
+ exclude: z
193
+ .array(
194
+ portablePathSchema({
195
+ allowGlob: true,
196
+ allowRuntimeExclusion: true,
197
+ })
198
+ )
199
+ .max(128)
200
+ .superRefine((values, context) => {
201
+ const seen = new Set<string>();
202
+ for (const [index, value] of values.entries()) {
203
+ if (seen.has(value)) {
204
+ context.addIssue({
205
+ code: "custom",
206
+ path: [index],
207
+ message: "Duplicate exclude rule",
208
+ });
209
+ }
210
+ seen.add(value);
211
+ }
212
+ })
213
+ .default([]),
214
+ languageHint: z
215
+ .string()
216
+ .refine(isValidLanguageHint, "Invalid BCP-47 language hint")
217
+ .optional(),
218
+ modelPreset: z.string().regex(REFERENCE_PATTERN).optional(),
219
+ })
220
+ .strict();
221
+
222
+ const InlineProjectProfileContextSchema = z
223
+ .object({
224
+ text: z.string().min(1).max(65_536),
225
+ })
226
+ .strict();
227
+
228
+ const FileProjectProfileContextSchema = z
229
+ .object({
230
+ file: portablePathSchema().superRefine((value, context) => {
231
+ if (hasLikelySecretPath(value)) {
232
+ context.addIssue({
233
+ code: "custom",
234
+ message:
235
+ "UNSAFE_PATH: likely credential and secret files cannot be profile contexts",
236
+ });
237
+ }
238
+ }),
239
+ })
240
+ .strict();
241
+
242
+ export const ProjectProfileContextSchema = z.union([
243
+ InlineProjectProfileContextSchema,
244
+ FileProjectProfileContextSchema,
245
+ ]);
246
+
247
+ export const ProjectProfileContentTypeSchema = z
248
+ .object({
249
+ prefixes: z
250
+ .array(portablePathSchema())
251
+ .min(1)
252
+ .max(64)
253
+ .superRefine((values, context) => {
254
+ const seen = new Set<string>();
255
+ for (const [index, value] of values.entries()) {
256
+ if (seen.has(value)) {
257
+ context.addIssue({
258
+ code: "custom",
259
+ path: [index],
260
+ message: "Duplicate content type prefix",
261
+ });
262
+ }
263
+ seen.add(value);
264
+ }
265
+ }),
266
+ preset: z
267
+ .string()
268
+ .min(1)
269
+ .refine((value) => NOTE_PRESET_IDS.has(value), "Unknown note preset"),
270
+ graphHints: z
271
+ .array(z.string().min(1).max(64))
272
+ .max(16)
273
+ .superRefine((values, context) => {
274
+ const seen = new Set<string>();
275
+ for (const [index, value] of values.entries()) {
276
+ if (seen.has(value)) {
277
+ context.addIssue({
278
+ code: "custom",
279
+ path: [index],
280
+ message: "Duplicate graph hint",
281
+ });
282
+ }
283
+ seen.add(value);
284
+ }
285
+ })
286
+ .optional(),
287
+ searchBoost: z.number().finite().optional(),
288
+ temporal: z.boolean().optional(),
289
+ })
290
+ .strict();
291
+
292
+ export const ProjectProfileAffinityDefaultsSchema = z
293
+ .object({
294
+ enabled: z.boolean().default(true),
295
+ contribution: z
296
+ .number()
297
+ .finite()
298
+ .min(0)
299
+ .max(PROJECT_AFFINITY_MAX_CONTRIBUTION)
300
+ .default(PROJECT_AFFINITY_MAX_CONTRIBUTION),
301
+ })
302
+ .strict();
303
+
304
+ export const ProjectProfileSchema = z
305
+ .object({
306
+ schemaVersion: z.literal(PROJECT_PROFILE_SCHEMA_VERSION),
307
+ collection: ProjectProfileCollectionSchema,
308
+ contexts: z
309
+ .array(ProjectProfileContextSchema)
310
+ .max(64)
311
+ .superRefine((values, context) => {
312
+ const seen = new Set<string>();
313
+ for (const [index, value] of values.entries()) {
314
+ const key = JSON.stringify(value);
315
+ if (seen.has(key)) {
316
+ context.addIssue({
317
+ code: "custom",
318
+ path: [index],
319
+ message: "Duplicate context declaration",
320
+ });
321
+ }
322
+ seen.add(key);
323
+ }
324
+ })
325
+ .default([]),
326
+ contentTypes: z
327
+ .record(
328
+ z.string().regex(REFERENCE_PATTERN),
329
+ ProjectProfileContentTypeSchema
330
+ )
331
+ .superRefine((rules, context) => {
332
+ if (Object.keys(rules).length > 64) {
333
+ context.addIssue({
334
+ code: "custom",
335
+ message: "Too many content type rules; maximum is 64",
336
+ });
337
+ }
338
+ })
339
+ .default({}),
340
+ affinityDefaults: ProjectProfileAffinityDefaultsSchema.default({
341
+ enabled: true,
342
+ contribution: PROJECT_AFFINITY_MAX_CONTRIBUTION,
343
+ }),
344
+ recommendedCapabilities: z
345
+ .array(z.string().regex(CAPABILITY_PATTERN))
346
+ .max(32)
347
+ .superRefine((values, context) => {
348
+ const seen = new Set<string>();
349
+ for (const [index, value] of values.entries()) {
350
+ if (seen.has(value)) {
351
+ context.addIssue({
352
+ code: "custom",
353
+ path: [index],
354
+ message: "Duplicate recommended capability",
355
+ });
356
+ }
357
+ seen.add(value);
358
+ }
359
+ })
360
+ .default([]),
361
+ })
362
+ .strict();
363
+
364
+ export type ProjectProfile = z.infer<typeof ProjectProfileSchema>;
365
+ export type ProjectProfileContentType = z.infer<
366
+ typeof ProjectProfileContentTypeSchema
367
+ >;
@@ -62,9 +62,18 @@ export async function saveConfigToPath(
62
62
  };
63
63
  }
64
64
 
65
- // Convert to YAML
66
- const yamlContent = Bun.YAML.stringify(config);
65
+ return saveTextToPath(Bun.YAML.stringify(config), filePath);
66
+ }
67
67
 
68
+ /**
69
+ * Atomically save deterministic text outside the config schema.
70
+ * Used for runtime receipts that need the same crash-safe write discipline as
71
+ * the user config.
72
+ */
73
+ export async function saveTextToPath(
74
+ content: string,
75
+ filePath: string
76
+ ): Promise<SaveResult> {
68
77
  // Ensure parent directory exists
69
78
  const dir = dirname(filePath);
70
79
  try {
@@ -88,7 +97,7 @@ export async function saveConfigToPath(
88
97
  );
89
98
 
90
99
  try {
91
- await Bun.write(tempPath, yamlContent);
100
+ await Bun.write(tempPath, content);
92
101
  } catch (cause) {
93
102
  return {
94
103
  ok: false,
@@ -102,10 +111,10 @@ export async function saveConfigToPath(
102
111
 
103
112
  // Rename temp to target (atomic on POSIX, needs unlink on Windows)
104
113
  try {
105
- // Windows: rename fails if dest exists, so unlink first (ignore if not exists)
106
- await unlink(filePath).catch(() => {
107
- /* ENOENT ok */
108
- });
114
+ if (process.platform === "win32") {
115
+ // Windows rename fails if dest exists, so unlink first (ignore ENOENT).
116
+ await unlink(filePath).catch(() => undefined);
117
+ }
109
118
  await rename(tempPath, filePath);
110
119
  } catch (cause) {
111
120
  // Clean up temp file on rename failure
@@ -5,6 +5,8 @@
5
5
  * @module src/config/types
6
6
  */
7
7
 
8
+ // node:path provides platform-correct absolute-path validation; Bun has no path utilities.
9
+ import { isAbsolute } from "node:path";
8
10
  import { z } from "zod";
9
11
 
10
12
  import { URI_PREFIX } from "../app/constants";
@@ -123,6 +125,7 @@ export const TrustedProjectRootSourceSchema = z.enum([
123
125
  "cli_cwd",
124
126
  "cli_explicit",
125
127
  "cli_worktree",
128
+ "project_profile",
126
129
  ]);
127
130
  export type TrustedProjectRootSource = z.infer<
128
131
  typeof TrustedProjectRootSourceSchema
@@ -183,6 +186,26 @@ export const ProjectAffinityConfigSchema = z.object({
183
186
  });
184
187
  export type ProjectAffinityConfig = z.infer<typeof ProjectAffinityConfigSchema>;
185
188
 
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // Local Project Profile Bindings
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+
193
+ const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
194
+
195
+ /**
196
+ * Machine-local provenance for a project profile projected into this config.
197
+ * Apply canonicalizes `path`; the schema rejects relative paths and malformed
198
+ * fingerprints without making the tracked profile machine-specific.
199
+ */
200
+ export const ProjectProfileBindingSchema = z.object({
201
+ path: z.string().min(1).refine(isAbsolute, {
202
+ message: "Project profile binding path must be absolute",
203
+ }),
204
+ fingerprint: z.string().regex(SHA256_HEX_REGEX),
205
+ collection: z.string().regex(COLLECTION_NAME_REGEX),
206
+ });
207
+ export type ProjectProfileBinding = z.infer<typeof ProjectProfileBindingSchema>;
208
+
186
209
  // ─────────────────────────────────────────────────────────────────────────────
187
210
  // Context Schema
188
211
  // ─────────────────────────────────────────────────────────────────────────────
@@ -413,6 +436,25 @@ export const ConfigSchema = z.object({
413
436
 
414
437
  /** Bounded project-aware retrieval affinity. */
415
438
  projectAffinity: ProjectAffinityConfigSchema.optional(),
439
+
440
+ /** Machine-local, timestamp-free project profile provenance. */
441
+ projectProfileBindings: z
442
+ .array(ProjectProfileBindingSchema)
443
+ .max(256)
444
+ .superRefine((bindings, ctx) => {
445
+ const paths = new Set<string>();
446
+ for (const [index, binding] of bindings.entries()) {
447
+ if (paths.has(binding.path)) {
448
+ ctx.addIssue({
449
+ code: "custom",
450
+ message: "Project profile binding paths must be unique",
451
+ path: [index, "path"],
452
+ });
453
+ }
454
+ paths.add(binding.path);
455
+ }
456
+ })
457
+ .optional(),
416
458
  });
417
459
 
418
460
  export type Config = Omit<z.infer<typeof ConfigSchema>, "contentTypes"> & {