@gajae-code/coding-agent 0.16.4 → 0.16.6

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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -40,7 +40,7 @@ const INVISIBLE_PATTERN = new RegExp(INVISIBLE_CLASS, "g");
40
40
  // residual scanner that silently alternates verdicts is worse than none.
41
41
  const CONTROL_PROBE = new RegExp(CONTROL_CLASS);
42
42
  const INVISIBLE_PROBE = new RegExp(INVISIBLE_CLASS);
43
- const URL_PATTERN = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s<>"'`)\]]+/g;
43
+ const URL_PATTERN = /(?<![A-Za-z0-9+.-])[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s<>"'`)\]]+/g;
44
44
  /** Anything still matching these after sanitization means the scanner is not certain. */
45
45
  const RESIDUAL_PATTERNS: readonly RegExp[] = [
46
46
  /\bsk-[A-Za-z0-9_-]{8,}/,
@@ -442,6 +442,28 @@ Receipts are freshness-scoped:
442
442
  - Appending required goals or changing final required-goal state stales final aggregate receipts. Final aggregate completion requires a fresh final aggregate receipt proving no incomplete, blocked, or `review_blocked` required goals remain.
443
443
  - Deferred per-goal receipts (validation-batch members) are incomplete until a matching fresh batch-close receipt exists on the batch's `finalGoalId`; a story-scope query for a deferred member stays blocked until that close, and mutating a member after close stales the batch-close and final aggregate receipts.
444
444
 
445
+ ## Cross-repository succession
446
+
447
+ When an approved run was planned in one repository but the implementation belongs in another, the repository-binding guard correctly refuses to execute the plan from the second repository. Never edit `repositoryBinding` and never copy the runtime directory: both defeat the boundary and launder verification provenance. Use the explicit successor path instead.
448
+
449
+ ```
450
+ gjc ultragoal succession offer --target-repo <path> --goal-id G002 --goal-id G003 --authorize "<who approved what, for which source, target and goals>" --authorized-by <identity> --json
451
+ gjc ultragoal succession adopt --offer <offer path inside the source worktree> --json
452
+ gjc ultragoal succession status --json
453
+ ```
454
+
455
+ `offer` runs in the source repository. It records an offer document plus a durable outgoing ownership fence, and writes nothing to `brief.md`, `goals.json` or `ledger.jsonl`. From that moment the source run refuses to schedule or checkpoint the fenced goals; goals outside the selection stay fully schedulable. Ownership therefore leaves the source at the offer, not when the target happens to adopt, so no interval exists in which both runs may resume the same goal. There is deliberately no `revoke` verb: any read-then-delete of the fence races a concurrent adoption and could revive the source while the target is starting, so an unadopted offer keeps its goals fenced.
456
+
457
+ Because unselected goals stay schedulable, ordinary source progress moves whole-file digests. Adoption therefore compares a **snapshot of the offered work** — the brief plus each selected goal's record and unresolved obligations — instead of whole-file equality, so unrelated progress cannot permanently strand the handed-off work. Whole-file digests are still recorded as provenance and any drift is written into the adoption record.
458
+
459
+ `adopt` runs in the target repository and starts a genuinely fresh run: new pending goals, fresh timestamps, the target repository's own binding, and no inherited receipts, quality gates, validation batches, steering or activated objective. The source brief and every selected objective are carried verbatim, together with the unresolved obligations recorded against those goals — requirements are carried forward in full, never summarized down. Dependency groups travel as explicit obligations with source-to-target id mapping: the source's batch metadata and receipts are not inherited, but the requirement to validate those goals together is.
460
+
461
+ Selection and authorization must be explicit. Only unfinished goals (`pending`, `failed`, `blocked`, `review_blocked`) may be handed off; the source run must be quiescent with no `active` goal; there is no implicit or wildcard selection; and a selection that takes only part of a validation batch, or splits a review blocker from the goal it still blocks, is rejected rather than silently dropping that obligation. Adoption fails closed on changed offered work, an altered or unfenced offer, an offer path that escapes the source worktree, a target the offer does not name, a linked worktree that is not the exact named target, an occupied target session, and any duplicate or simultaneous adoption anywhere in the target repository. A retry reconciles the exact recorded operation or fails closed — a matching operation id is never authority to overwrite target work it did not write, and a published plan that has disappeared is never reconstructed.
462
+
463
+ Until publication completes, the target refuses to execute: a visible `goals.json` is not admission evidence, the adoption record is. `start`/`complete-goals` and `checkpoint` reject while an adoption remains pending.
464
+
465
+ Integrity digests are evidence, not authorization: a matching digest set with no fence, no bounded authorization, or the wrong target is still rejected. Historical source statuses, receipts, reviews and approvals travel as provenance only and never satisfy a target gate.
466
+
445
467
  ## Handoff back to planning
446
468
 
447
469
  When the aggregate ultragoal is complete OR the user requests return to planning/clarification, mark ultragoal ready for handoff so the skill tool's chain guard permits the backward transition:
@@ -28,8 +28,11 @@ import {
28
28
  createSourceMeta,
29
29
  discoverExtensionModulePaths,
30
30
  expandEnvVarsDeep,
31
+ getAncestorDirs,
31
32
  getExtensionNameFromPath,
33
+ getUserSkillScanDirs,
32
34
  loadFilesFromDir,
35
+ readContainedFile,
33
36
  SOURCE_PATHS,
34
37
  scanSkillsFromDir,
35
38
  } from "./helpers";
@@ -41,10 +44,6 @@ const PRIORITY = 100;
41
44
 
42
45
  const PATHS = SOURCE_PATHS.native;
43
46
 
44
- function getUserAgentDirs(): string[] {
45
- return [PATHS.userAgent];
46
- }
47
-
48
47
  /**
49
48
  * GJC's user-scope config directory.
50
49
  *
@@ -92,26 +91,12 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
92
91
  return result;
93
92
  }
94
93
 
95
- function getAncestorDirs(cwd: string, stopAt?: string | null): Array<{ dir: string; depth: number }> {
96
- const ancestors: Array<{ dir: string; depth: number }> = [];
97
- let current = cwd;
98
- let depth = 0;
99
- while (true) {
100
- ancestors.push({ dir: current, depth });
101
- if (stopAt && current === stopAt) break;
102
- const parent = path.dirname(current);
103
- if (parent === current) break;
104
- current = parent;
105
- depth++;
106
- }
107
- return ancestors;
108
- }
109
-
110
94
  async function findNearestProjectConfigDir(
111
95
  cwd: string,
112
96
  repoRoot?: string | null,
97
+ home?: string,
113
98
  ): Promise<{ dir: string; depth: number } | null> {
114
- for (const ancestor of getAncestorDirs(cwd, repoRoot)) {
99
+ for (const ancestor of getAncestorDirs(cwd, repoRoot ?? home, home)) {
115
100
  for (const projectConfigDir of getProjectConfigDirs()) {
116
101
  const configDir = await ifNonEmptyDir(ancestor.dir, projectConfigDir);
117
102
  if (configDir) return { dir: configDir, depth: ancestor.depth };
@@ -280,20 +265,21 @@ registerProvider<MCPServer>(mcpCapability.id, {
280
265
  async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
281
266
  const items: SystemPrompt[] = [];
282
267
 
283
- for (const userAgentDir of getUserAgentDirs()) {
284
- const userPath = path.join(ctx.home, userAgentDir, "SYSTEM.md");
285
- const userContent = await readFile(userPath);
286
- if (userContent) {
287
- items.push({
288
- path: userPath,
289
- content: userContent,
290
- level: "user",
291
- _source: createSourceMeta(PROVIDER_ID, userPath, "user"),
292
- });
293
- }
268
+ // User scope is the agent directory — the directory `gjc config path` prints
269
+ // and the only user-scope seam. A profile is a separate scope; the default
270
+ // profile's home-relative SYSTEM.md is not read under one.
271
+ const userPath = path.join(resolveUserAgentDir(ctx), "SYSTEM.md");
272
+ const userContent = await readContainedFile(resolveUserAgentDir(ctx), userPath);
273
+ if (userContent) {
274
+ items.push({
275
+ path: userPath,
276
+ content: userContent,
277
+ level: "user",
278
+ _source: createSourceMeta(PROVIDER_ID, userPath, "user"),
279
+ });
294
280
  }
295
281
 
296
- const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
282
+ const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot, ctx.home);
297
283
  if (nearestProjectConfigDir) {
298
284
  const projectPath = path.join(nearestProjectConfigDir.dir, "SYSTEM.md");
299
285
  const projectContent = await readFile(projectPath);
@@ -321,9 +307,7 @@ registerProvider<SystemPrompt>(systemPromptCapability.id, {
321
307
  // Skills
322
308
  async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
323
309
  // Walk up from cwd finding .gjc/skills/ in ancestors (closest first)
324
- const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home).filter(
325
- ({ dir }) => path.resolve(dir) !== path.resolve(ctx.home),
326
- );
310
+ const ancestors = getAncestorDirs(ctx.cwd, ctx.repoRoot ?? ctx.home, ctx.home);
327
311
  const projectScans = ancestors.flatMap(({ dir }) =>
328
312
  getProjectConfigDirs().map(projectConfigDir =>
329
313
  scanSkillsFromDir(ctx, {
@@ -335,17 +319,13 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
335
319
  ),
336
320
  );
337
321
 
338
- // User-level scan from the active agent-directory profile.
339
- const userSkillDirs = [
340
- ...new Set([
341
- path.join(resolveUserAgentDir(ctx), "skills"),
342
- path.join(ctx.home, PATHS.userBase, "skills"),
343
- path.join(ctx.home, ".gjc", "skills"),
344
- ]),
345
- ];
346
- const userScans = userSkillDirs.map(dir =>
322
+ // User-level scan from the selected agent-directory profile plus documented
323
+ // default-profile legacy scan roots.
324
+ const userAgentDir = resolveUserAgentDir(ctx);
325
+ const userScans = getUserSkillScanDirs(ctx.home, userAgentDir, ctx.profileAuthority).map(dir =>
347
326
  scanSkillsFromDir(ctx, {
348
327
  dir,
328
+ authorityRoot: path.resolve(dir) === path.resolve(userAgentDir, "skills") ? userAgentDir : undefined,
349
329
  providerId: PROVIDER_ID,
350
330
  level: "user",
351
331
  requireDescription: true,
@@ -404,28 +384,36 @@ registerProvider<SlashCommand>(slashCommandCapability.id, {
404
384
  async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
405
385
  const items: Rule[] = [];
406
386
  const warnings: string[] = [];
407
-
408
- for (const { dir, level } of await getConfigDirs(ctx)) {
409
- const rulesDir = path.join(dir, "rules");
410
- const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
411
- extensions: ["md", "mdc"],
412
- transform: (name, content, path, source) =>
413
- buildRuleFromMarkdown(name, content, path, source, { stripNamePattern: /\.(md|mdc)$/ }),
414
- });
415
- items.push(...result.items);
416
- if (result.warnings) warnings.push(...result.warnings);
417
- }
387
+ const configDirs = await getConfigDirs(ctx);
388
+ const projectConfigDirs = configDirs.filter(({ level }) => level === "project");
389
+ const userConfigDirs = configDirs.filter(({ level }) => level === "user");
390
+
391
+ const loadRulesFromDirs = async (dirs: Array<{ dir: string; level: "user" | "project" }>) => {
392
+ for (const { dir, level } of dirs) {
393
+ const rulesDir = path.join(dir, "rules");
394
+ const result = await loadFilesFromDir<Rule>(ctx, rulesDir, PROVIDER_ID, level, {
395
+ extensions: ["md", "mdc"],
396
+ transform: (name, content, path, source) =>
397
+ buildRuleFromMarkdown(name, content, path, source, { stripNamePattern: /\.(md|mdc)$/ }),
398
+ });
399
+ items.push(...result.items);
400
+ if (result.warnings) warnings.push(...result.warnings);
401
+ }
402
+ };
418
403
 
419
404
  // Top-level RULES.md is a sticky always-apply rule. The context-file
420
405
  // discovery contract treats it as the file "re-injected near the current
421
406
  // turn so they keep hold across long conversations".
422
- // User scope: ~/.gjc/agent/RULES.md
407
+ // User scope: <agentDir>/RULES.md (a profile is a separate user scope;
408
+ // the default profile's home-relative copy is not read)
423
409
  // Project scope: nearest .gjc/RULES.md walking up from cwd to repoRoot
410
+ await loadRulesFromDirs([...projectConfigDirs, ...userConfigDirs]);
411
+
424
412
  const userRulesFile = path.join(resolveUserAgentDir(ctx), "RULES.md");
425
413
  const userRule = await loadStickyRulesFile(userRulesFile, "user");
426
414
  if (userRule) items.push(userRule);
427
415
 
428
- const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
416
+ const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot, ctx.home);
429
417
  if (nearestProjectConfigDir) {
430
418
  const projectRulesFile = path.join(nearestProjectConfigDir.dir, "RULES.md");
431
419
  const projectRule = await loadStickyRulesFile(projectRulesFile, "project");
@@ -440,7 +428,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
440
428
  * Returns null when the file is absent or empty so callers can short-circuit.
441
429
  */
442
430
  async function loadStickyRulesFile(filePath: string, level: "user" | "project"): Promise<Rule | null> {
443
- const content = await readFile(filePath);
431
+ const content = await readContainedFile(path.dirname(filePath), filePath);
444
432
  if (!content) return null;
445
433
  const source = createSourceMeta(PROVIDER_ID, filePath, level);
446
434
  const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName: "RULES" });
@@ -728,7 +716,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
728
716
  const { typeDir, hookType, level } = typeDirRequests[i];
729
717
  const typeEntries = typeEntriesResults[i];
730
718
 
731
- for (const entry of typeEntries) {
719
+ for (const entry of [...typeEntries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
732
720
  if (entry.name.startsWith(".")) continue;
733
721
  if (!entry.isFile()) continue;
734
722
 
@@ -938,8 +926,8 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
938
926
  const items: ContextFile[] = [];
939
927
  const warnings: string[] = [];
940
928
 
941
- const userPath = path.join(ctx.home, PATHS.userAgent, "AGENTS.md");
942
- const userContent = await readFile(userPath);
929
+ const userPath = path.join(resolveUserAgentDir(ctx), "AGENTS.md");
930
+ const userContent = await readContainedFile(resolveUserAgentDir(ctx), userPath);
943
931
  if (userContent) {
944
932
  items.push({
945
933
  path: userPath,
@@ -949,7 +937,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
949
937
  });
950
938
  }
951
939
 
952
- const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot);
940
+ const nearestProjectConfigDir = await findNearestProjectConfigDir(ctx.cwd, ctx.repoRoot, ctx.home);
953
941
  if (nearestProjectConfigDir) {
954
942
  const projectPath = path.join(nearestProjectConfigDir.dir, "AGENTS.md");
955
943
  const projectContent = await readFile(projectPath);
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import type { FileHandle } from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import type { ThinkingLevel } from "@gajae-code/agent-core";
4
5
  import type { FileType as FileTypeEnum, glob as globFn } from "@gajae-code/natives";
@@ -9,6 +10,7 @@ import {
9
10
  getProjectDir,
10
11
  getTrustedHomeDir,
11
12
  logger,
13
+ normalizePathForComparison,
12
14
  parseFrontmatter,
13
15
  tryParseJson,
14
16
  } from "@gajae-code/utils";
@@ -99,7 +101,32 @@ export const SOURCE_PATHS = {
99
101
  },
100
102
  } as const;
101
103
 
104
+ /** Ancestors from `cwd` through `stopAt`, excluding one normalized authority boundary. */
105
+ export function getAncestorDirs(
106
+ cwd: string,
107
+ stopAt?: string | null,
108
+ excludeDir?: string,
109
+ ): Array<{ dir: string; depth: number }> {
110
+ const ancestors: Array<{ dir: string; depth: number }> = [];
111
+ let current = path.resolve(cwd);
112
+ let depth = 0;
113
+ let normalizedCurrent = normalizePathForComparison(current);
114
+ const normalizedStop = stopAt ? normalizePathForComparison(path.resolve(stopAt)) : undefined;
115
+ const normalizedExclude = excludeDir ? normalizePathForComparison(path.resolve(excludeDir)) : undefined;
116
+ while (true) {
117
+ if (normalizedCurrent !== normalizedExclude) ancestors.push({ dir: current, depth });
118
+ if (normalizedStop && normalizedCurrent === normalizedStop) break;
119
+ const parent = path.dirname(current);
120
+ if (parent === current) break;
121
+ current = parent;
122
+ normalizedCurrent = normalizePathForComparison(current);
123
+ depth++;
124
+ }
125
+ return ancestors;
126
+ }
127
+
102
128
  export type SourceId = keyof typeof SOURCE_PATHS;
129
+ export type ProfileAuthority = "default" | "custom";
103
130
 
104
131
  /**
105
132
  * Get user-level path for a source.
@@ -110,6 +137,49 @@ export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string)
110
137
  return path.join(ctx.home, paths.userAgent, subpath);
111
138
  }
112
139
 
140
+ /**
141
+ * Deterministic user-scope skill scan directories, highest precedence first.
142
+ *
143
+ * The canonical root is the agent directory (`getAgentDir()`, moved by
144
+ * `--agent-dir` / `GJC_CODING_AGENT_DIR` / `setAgentDir()`) — the target of
145
+ * every user-scope skill writer (`gjc migrate`, `gjc skill`). An agent-directory
146
+ * profile is a *separate* user scope, the same contract as MCP user config
147
+ * (#4768): its legacy home-relative roots are not scanned, so a profile cannot
148
+ * pick up the default profile's skills (and vice versa). The resolver-owned
149
+ * `profileAuthority` classification is authoritative when supplied, so a
150
+ * custom profile remains isolated even if a later HOME/config-root refresh
151
+ * makes its path look like the current default. In the default profile the
152
+ * agent directory is `<home>/<configDir>/agent` and the configured legacy roots
153
+ * below it are still honored, exactly as before.
154
+ */
155
+ export function resolveUserAgentDir(home: string, userAgentDir?: string): string {
156
+ return path.resolve(userAgentDir ?? path.join(home, SOURCE_PATHS.native.userAgent));
157
+ }
158
+
159
+ export function getUserSkillScanDirs(
160
+ home: string,
161
+ userAgentDir?: string,
162
+ profileAuthority?: ProfileAuthority,
163
+ ): string[] {
164
+ const resolvedAgentDir = resolveUserAgentDir(home, userAgentDir);
165
+ const defaultAgentDir = path.join(home, SOURCE_PATHS.native.userAgent);
166
+ const authority =
167
+ profileAuthority ??
168
+ (normalizePathForComparison(resolvedAgentDir) === normalizePathForComparison(defaultAgentDir)
169
+ ? "default"
170
+ : "custom");
171
+ if (authority === "custom") {
172
+ return [path.join(resolvedAgentDir, "skills")];
173
+ }
174
+ return [
175
+ ...new Set([
176
+ path.join(resolvedAgentDir, "skills"),
177
+ path.join(home, SOURCE_PATHS.native.userBase, "skills"),
178
+ path.join(home, ".gjc", "skills"),
179
+ ]),
180
+ ];
181
+ }
182
+
113
183
  /**
114
184
  * Get project-level path for a source (cwd only).
115
185
  */
@@ -345,6 +415,8 @@ async function globIf(
345
415
 
346
416
  export interface ScanSkillsFromDirOptions {
347
417
  dir: string;
418
+ /** Selected authority root whose own identity must not be a symlink. */
419
+ authorityRoot?: string;
348
420
  providerId: string;
349
421
  level: "user" | "project";
350
422
  requireDescription?: boolean;
@@ -365,30 +437,40 @@ export const SKILL_FRONTMATTER_SCAN_BYTES = 4 * 1024;
365
437
  /** Maximum total bytes read while seeking the frontmatter closing delimiter. */
366
438
  export const SKILL_FRONTMATTER_SCAN_TOTAL_BYTES = 64 * 1024;
367
439
 
368
- async function readSkillFrontmatter(skillPath: string): Promise<SkillFrontmatter | null> {
369
- const file = Bun.file(skillPath);
370
- const size = (await fs.promises.stat(skillPath)).size;
371
- const scanLimit = Math.min(size, SKILL_FRONTMATTER_SCAN_TOTAL_BYTES);
372
- let offset = 0;
373
- let prefix = "";
374
- const decoder = new TextDecoder();
375
- while (offset < scanLimit) {
376
- const end = Math.min(offset + SKILL_FRONTMATTER_SCAN_BYTES, scanLimit);
377
- const bytes = new Uint8Array(await file.slice(offset, end).arrayBuffer());
378
- const chunk = decoder.decode(bytes, { stream: end < scanLimit });
379
- if (!chunk) break;
380
- prefix += chunk;
381
- offset = end;
382
-
383
- const opening = prefix.match(/^---[ \t]*(?:\r?\n|$)/);
384
- if (!opening) return null;
385
- const afterOpening = prefix.slice(opening[0].length);
386
- const closing = afterOpening.match(/\r?\n---[ \t]*(?:\r?\n|$)/);
387
- if (!closing || closing.index === undefined) continue;
388
- const bounded = prefix.slice(0, opening[0].length + closing.index + closing[0].length);
389
- return parseFrontmatter(bounded, { source: skillPath }).frontmatter as SkillFrontmatter;
440
+ export const SkillDiscoveryTestHooks: {
441
+ afterCandidateValidated?: (candidatePath: string) => void | Promise<void>;
442
+ afterAuthorityRootValidated?: (root: string) => void | Promise<void>;
443
+ afterAuthorityRootMissing?: (root: string) => void | Promise<void>;
444
+ afterContainedRootValidated?: (root: string) => void | Promise<void>;
445
+ } = {};
446
+
447
+ interface DirectoryIdentity {
448
+ dev: bigint;
449
+ ino: bigint;
450
+ realPath: string;
451
+ }
452
+
453
+ async function captureDirectoryIdentity(root: string): Promise<DirectoryIdentity | null> {
454
+ const observed = await fs.promises.lstat(root, { bigint: true });
455
+ if (observed.isSymbolicLink() || !observed.isDirectory()) return null;
456
+ const realPath = await fs.promises.realpath(root);
457
+ const resolved = await fs.promises.stat(realPath, { bigint: true });
458
+ if (!resolved.isDirectory() || observed.dev !== resolved.dev || observed.ino !== resolved.ino) return null;
459
+ return { dev: observed.dev, ino: observed.ino, realPath };
460
+ }
461
+
462
+ async function directoryIdentityIsCurrent(root: string, expected: DirectoryIdentity): Promise<boolean> {
463
+ try {
464
+ const current = await captureDirectoryIdentity(root);
465
+ return (
466
+ current !== null &&
467
+ current.dev === expected.dev &&
468
+ current.ino === expected.ino &&
469
+ normalizePathForComparison(current.realPath) === normalizePathForComparison(expected.realPath)
470
+ );
471
+ } catch {
472
+ return false;
390
473
  }
391
- return null;
392
474
  }
393
475
 
394
476
  export async function scanSkillsFromDir(
@@ -400,19 +482,150 @@ export async function scanSkillsFromDir(
400
482
  const { dir, level, providerId, requireDescription = false } = options;
401
483
 
402
484
  let entries: fs.Dirent[];
485
+ let realRoot: string;
486
+ let authorityIdentity: DirectoryIdentity | null = null;
487
+ let scanRootIdentity: DirectoryIdentity;
403
488
  try {
489
+ if (options.authorityRoot) {
490
+ try {
491
+ authorityIdentity = await captureDirectoryIdentity(options.authorityRoot);
492
+ if (!authorityIdentity) {
493
+ warnings.push(`Refusing unsafe skill authority root: ${options.authorityRoot}`);
494
+ return { items, warnings };
495
+ }
496
+ await SkillDiscoveryTestHooks.afterAuthorityRootValidated?.(options.authorityRoot);
497
+ if (!(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity))) {
498
+ warnings.push(`Refusing changed skill authority root: ${options.authorityRoot}`);
499
+ return { items, warnings };
500
+ }
501
+ } catch (error) {
502
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
503
+ await SkillDiscoveryTestHooks.afterAuthorityRootMissing?.(options.authorityRoot);
504
+ return { items, warnings };
505
+ }
506
+ }
507
+ const capturedScanRoot = await captureDirectoryIdentity(dir);
508
+ if (!capturedScanRoot) {
509
+ warnings.push(`Refusing unsafe skills directory: ${dir}`);
510
+ return { items, warnings };
511
+ }
512
+ scanRootIdentity = capturedScanRoot;
513
+ realRoot = scanRootIdentity.realPath;
404
514
  entries = await fs.promises.readdir(dir, { withFileTypes: true });
515
+ if (!(await directoryIdentityIsCurrent(dir, scanRootIdentity))) {
516
+ warnings.push(`Refusing changed skills directory: ${dir}`);
517
+ return { items, warnings };
518
+ }
405
519
  } catch (error) {
406
520
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
407
521
  warnings.push(`Failed to read skills directory: ${dir} (${String(error)})`);
408
522
  }
409
523
  return { items, warnings };
410
524
  }
411
- const loadSkill = async (skillPath: string) => {
525
+ const isWithinRoot = (candidate: string): boolean => {
526
+ const relative = path.relative(realRoot, candidate);
527
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
528
+ };
529
+ const openSkillFileSafely = async (skillPath: string): Promise<FileHandle> => {
530
+ if (
531
+ !(await directoryIdentityIsCurrent(dir, scanRootIdentity)) ||
532
+ (options.authorityRoot &&
533
+ authorityIdentity &&
534
+ !(await directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)))
535
+ ) {
536
+ throw new Error(`Unsafe skill authority root for: ${skillPath}`);
537
+ }
538
+ const flags =
539
+ fs.constants.O_RDONLY |
540
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
541
+ const handle = await fs.promises.open(skillPath, flags);
412
542
  try {
413
- const frontmatter = await readSkillFrontmatter(skillPath);
543
+ const [opened, observed, currentPath, currentScanRoot, currentAuthorityRoot] = await Promise.all([
544
+ handle.stat({ bigint: true }),
545
+ fs.promises.lstat(skillPath, { bigint: true }),
546
+ fs.promises.realpath(skillPath),
547
+ directoryIdentityIsCurrent(dir, scanRootIdentity),
548
+ options.authorityRoot && authorityIdentity
549
+ ? directoryIdentityIsCurrent(options.authorityRoot, authorityIdentity)
550
+ : true,
551
+ ]);
552
+ if (
553
+ !opened.isFile() ||
554
+ opened.nlink !== 1n ||
555
+ observed.isSymbolicLink() ||
556
+ !observed.isFile() ||
557
+ opened.dev !== observed.dev ||
558
+ opened.ino !== observed.ino ||
559
+ !isWithinRoot(currentPath) ||
560
+ !currentScanRoot ||
561
+ !currentAuthorityRoot
562
+ ) {
563
+ throw new Error(`Unsafe skill file: ${skillPath}`);
564
+ }
565
+ return handle;
566
+ } catch (error) {
567
+ await handle.close();
568
+ throw error;
569
+ }
570
+ };
571
+ const readSkillContentSafely = async (skillPath: string): Promise<string> => {
572
+ const handle = await openSkillFileSafely(skillPath);
573
+ try {
574
+ return await handle.readFile({ encoding: "utf8" });
575
+ } finally {
576
+ await handle.close();
577
+ }
578
+ };
579
+ const readSkillFrontmatterSafely = async (
580
+ skillPath: string,
581
+ ): Promise<{ frontmatter: SkillFrontmatter | null; size: number }> => {
582
+ const handle = await openSkillFileSafely(skillPath);
583
+ try {
584
+ const size = Number((await handle.stat({ bigint: true })).size);
585
+ const scanLimit = Math.min(size, SKILL_FRONTMATTER_SCAN_TOTAL_BYTES);
586
+ let offset = 0;
587
+ let prefix = "";
588
+ const decoder = new TextDecoder();
589
+ while (offset < scanLimit) {
590
+ const length = Math.min(SKILL_FRONTMATTER_SCAN_BYTES, scanLimit - offset);
591
+ const bytes = new Uint8Array(length);
592
+ const { bytesRead } = await handle.read(bytes, 0, length, offset);
593
+ if (bytesRead === 0) break;
594
+ offset += bytesRead;
595
+ prefix += decoder.decode(bytes.subarray(0, bytesRead), { stream: offset < scanLimit });
596
+
597
+ const opening = prefix.match(/^---[ \t]*(?:\r?\n|$)/);
598
+ if (!opening) return { frontmatter: null, size };
599
+ const afterOpening = prefix.slice(opening[0].length);
600
+ const closing = afterOpening.match(/\r?\n---[ \t]*(?:\r?\n|$)/);
601
+ if (!closing || closing.index === undefined) continue;
602
+ const bounded = prefix.slice(0, opening[0].length + closing.index + closing[0].length);
603
+ return {
604
+ frontmatter: parseFrontmatter(bounded, { source: skillPath }).frontmatter as SkillFrontmatter,
605
+ size,
606
+ };
607
+ }
608
+ return { frontmatter: null, size };
609
+ } finally {
610
+ await handle.close();
611
+ }
612
+ };
613
+ const loadSkill = async (candidatePath: string) => {
614
+ try {
615
+ const skillPath = await fs.promises.realpath(candidatePath);
616
+ if (!isWithinRoot(skillPath)) {
617
+ warnings.push(`Refusing skill path outside scan root: ${candidatePath}`);
618
+ return;
619
+ }
620
+ const stat = await fs.promises.stat(skillPath);
621
+ if (!stat.isFile() || stat.nlink !== 1) {
622
+ warnings.push(`Skill path is not a regular file: ${candidatePath}`);
623
+ return;
624
+ }
625
+ await SkillDiscoveryTestHooks.afterCandidateValidated?.(candidatePath);
626
+ const { frontmatter, size } = await readSkillFrontmatterSafely(skillPath);
414
627
  if (!frontmatter) {
415
- if (fs.statSync(skillPath).size > SKILL_FRONTMATTER_SCAN_TOTAL_BYTES) {
628
+ if (size > SKILL_FRONTMATTER_SCAN_TOTAL_BYTES) {
416
629
  warnings.push(
417
630
  `Skill frontmatter exceeded ${SKILL_FRONTMATTER_SCAN_TOTAL_BYTES} byte scan cap: ${skillPath}`,
418
631
  );
@@ -435,15 +648,16 @@ export async function scanSkillsFromDir(
435
648
  name,
436
649
  path: skillPath,
437
650
  loadContent: async () => {
438
- const content = await Bun.file(skillPath).text();
651
+ const content = await readSkillContentSafely(skillPath);
439
652
  return parseFrontmatter(content, { source: skillPath }).body;
440
653
  },
441
654
  frontmatter: frontmatter as SkillFrontmatter,
442
655
  level,
443
656
  _source: createSourceMeta(providerId, skillPath, level),
444
657
  });
445
- } catch {
446
- warnings.push(`Failed to read skill file: ${skillPath}`);
658
+ } catch (error) {
659
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
660
+ warnings.push(`Failed to read skill file: ${candidatePath}`);
447
661
  }
448
662
  };
449
663
 
@@ -452,9 +666,7 @@ export async function scanSkillsFromDir(
452
666
  if (entry.name.startsWith(".")) continue;
453
667
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
454
668
  const skillPath = path.join(dir, entry.name, "SKILL.md");
455
- if (fs.existsSync(skillPath)) {
456
- work.push(loadSkill(skillPath));
457
- }
669
+ work.push(loadSkill(skillPath));
458
670
  }
459
671
  await Promise.all(work);
460
672
 
@@ -464,6 +676,59 @@ export async function scanSkillsFromDir(
464
676
  return { items, warnings };
465
677
  }
466
678
 
679
+ /** Read a regular single-link file that remains contained by its configured root. */
680
+ export async function readContainedFile(root: string, filePath: string): Promise<string | null> {
681
+ let rootIdentity: DirectoryIdentity;
682
+ try {
683
+ const capturedRoot = await captureDirectoryIdentity(root);
684
+ if (!capturedRoot) return null;
685
+ rootIdentity = capturedRoot;
686
+ await SkillDiscoveryTestHooks.afterContainedRootValidated?.(root);
687
+ if (!(await directoryIdentityIsCurrent(root, rootIdentity))) return null;
688
+ } catch (error) {
689
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
690
+ throw error;
691
+ }
692
+ const relative = path.relative(root, filePath);
693
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return null;
694
+ const flags =
695
+ fs.constants.O_RDONLY |
696
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
697
+ let handle: FileHandle;
698
+ try {
699
+ handle = await fs.promises.open(filePath, flags);
700
+ } catch (error) {
701
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
702
+ return null;
703
+ }
704
+ try {
705
+ const [opened, observed, currentPath, currentRoot] = await Promise.all([
706
+ handle.stat({ bigint: true }),
707
+ fs.promises.lstat(filePath, { bigint: true }),
708
+ fs.promises.realpath(filePath),
709
+ directoryIdentityIsCurrent(root, rootIdentity),
710
+ ]);
711
+ const currentRelative = path.relative(rootIdentity.realPath, currentPath);
712
+ if (
713
+ !opened.isFile() ||
714
+ opened.nlink !== 1n ||
715
+ observed.isSymbolicLink() ||
716
+ !observed.isFile() ||
717
+ opened.dev !== observed.dev ||
718
+ opened.ino !== observed.ino ||
719
+ !currentRoot ||
720
+ currentRelative.startsWith("..") ||
721
+ path.isAbsolute(currentRelative)
722
+ ) {
723
+ return null;
724
+ }
725
+ const content = await handle.readFile({ encoding: "utf8" });
726
+ return (await directoryIdentityIsCurrent(root, rootIdentity)) ? content : null;
727
+ } finally {
728
+ await handle.close();
729
+ }
730
+ }
731
+
467
732
  /**
468
733
  * Expand environment variables in a string.
469
734
  * Supports ${VAR} and ${VAR:-default} syntax.