@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.
- package/CHANGELOG.md +54 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli/customize-doctor.d.ts +22 -0
- package/dist/types/config/file-lock.d.ts +31 -3
- package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +7 -0
- package/dist/types/discovery/helpers.d.ts +33 -0
- package/dist/types/exec/bash-executor.d.ts +17 -1
- package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
- package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-signals.d.ts +2 -0
- package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
- package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
- package/dist/types/exec/bash-shell-worker.d.ts +1 -0
- package/dist/types/exec/isolated-shell.d.ts +19 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
- package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +46 -6
- package/dist/types/extensibility/extensions/types.d.ts +16 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/hooks/types.d.ts +4 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
- package/dist/types/extensibility/skill-management.d.ts +9 -4
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
- package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
- package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/modes/components/bash-execution.d.ts +1 -0
- package/dist/types/modes/components/execution-shared.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/broker/ensure.d.ts +32 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
- package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
- package/dist/types/sdk/client/client.d.ts +6 -0
- package/dist/types/sdk/host/host.d.ts +3 -1
- package/dist/types/sdk/host/session-runtime.d.ts +4 -2
- package/dist/types/sdk/lifecycle/service.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +10 -2
- package/dist/types/sdk/session.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/setup/paseo/announce.d.ts +4 -3
- package/dist/types/skill-state/canonical-skills.d.ts +4 -0
- package/dist/types/system-prompt.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/github-cache.d.ts +2 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/package.json +9 -7
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/capability/index.ts +14 -2
- package/src/capability/types.ts +4 -0
- package/src/cli/customize-doctor.ts +155 -12
- package/src/cli.ts +22 -0
- package/src/commands/sdk.ts +74 -31
- package/src/config/file-lock-gc.ts +40 -2
- package/src/config/file-lock.ts +198 -14
- package/src/config/model-preset-registry.ts +115 -16
- package/src/config/model-registry.ts +16 -12
- package/src/coordinator-mcp/question-state.ts +33 -2
- package/src/coordinator-mcp/server.ts +477 -101
- package/src/crash/sanitize.ts +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
- package/src/discovery/builtin.ts +50 -62
- package/src/discovery/helpers.ts +297 -32
- package/src/exec/bash-executor.ts +330 -50
- package/src/exec/bash-shell-guardian.ts +388 -0
- package/src/exec/bash-shell-runtime-entry.ts +5 -0
- package/src/exec/bash-shell-signals.ts +29 -0
- package/src/exec/bash-shell-supervisor-entry.ts +3 -0
- package/src/exec/bash-shell-supervisor.ts +359 -0
- package/src/exec/bash-shell-worker-entry.ts +5 -0
- package/src/exec/bash-shell-worker-protocol.ts +36 -0
- package/src/exec/bash-shell-worker.ts +116 -0
- package/src/exec/isolated-shell.ts +393 -0
- package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
- package/src/extensibility/extensions/function-hooks.ts +959 -0
- package/src/extensibility/extensions/index.ts +22 -0
- package/src/extensibility/extensions/loader.ts +53 -2
- package/src/extensibility/extensions/runner.ts +895 -264
- package/src/extensibility/extensions/types.ts +42 -0
- package/src/extensibility/extensions/wrapper.ts +56 -15
- package/src/extensibility/gjc-plugins/compiler.ts +20 -2
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
- package/src/extensibility/gjc-plugins/registry.ts +113 -1
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
- package/src/extensibility/gjc-plugins/schema.ts +121 -4
- package/src/extensibility/gjc-plugins/types.ts +25 -0
- package/src/extensibility/hooks/loader.ts +133 -16
- package/src/extensibility/hooks/types.ts +28 -0
- package/src/extensibility/runtime-skill-discovery.ts +80 -47
- package/src/extensibility/skill-management.ts +200 -52
- package/src/extensibility/skills.ts +29 -9
- package/src/gjc-runtime/empty-delete-gc.ts +94 -4
- package/src/gjc-runtime/gc-runtime.ts +29 -0
- package/src/gjc-runtime/linux-proc.ts +14 -0
- package/src/gjc-runtime/session-state-lock.ts +51 -8
- package/src/gjc-runtime/session-state-sidecar.ts +27 -11
- package/src/gjc-runtime/state-writer.ts +30 -29
- package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
- package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -0
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/config.ts +10 -3
- package/src/lsp/index.ts +1 -1
- package/src/lsp/path-trust.ts +69 -14
- package/src/main.ts +6 -1
- package/src/modes/acp/acp-agent.ts +16 -3
- package/src/modes/components/bash-execution.ts +4 -1
- package/src/modes/components/execution-shared.ts +4 -1
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/tool-status-header.ts +3 -1
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +6 -3
- package/src/modes/interactive-mode.ts +6 -5
- package/src/modes/print-mode.ts +5 -1
- package/src/modes/prompt-action-autocomplete.ts +54 -27
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/types.ts +16 -3
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/subagent.md +1 -0
- package/src/runtime-mcp/manager.ts +20 -2
- package/src/runtime-mcp/pool.test.ts +20 -11
- package/src/sdk/broker/broker.ts +119 -28
- package/src/sdk/broker/ensure.ts +248 -41
- package/src/sdk/broker/lifecycle-ledger.ts +7 -17
- package/src/sdk/broker/lifecycle.ts +56 -6
- package/src/sdk/broker/startup-failure.ts +15 -6
- package/src/sdk/bus/chat-daemon-control.ts +3 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
- package/src/sdk/bus/chat-effect-journal.ts +132 -7
- package/src/sdk/bus/conversation-store.ts +29 -0
- package/src/sdk/bus/lifecycle-commands.ts +17 -11
- package/src/sdk/bus/slack-conversation.ts +1 -0
- package/src/sdk/bus/slack-daemon.ts +387 -48
- package/src/sdk/bus/telegram-daemon.ts +16 -2
- package/src/sdk/bus/topic-registry.ts +19 -0
- package/src/sdk/cli/master-cli.ts +1 -1
- package/src/sdk/cli/session-cli.ts +2 -2
- package/src/sdk/client/client.ts +66 -17
- package/src/sdk/host/host.ts +29 -9
- package/src/sdk/host/query/revision-store.ts +19 -13
- package/src/sdk/host/reverse-leases.ts +7 -4
- package/src/sdk/host/session-runtime.test.ts +50 -8
- package/src/sdk/host/session-runtime.ts +158 -51
- package/src/sdk/lifecycle/service.ts +4 -0
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/router/session-router.ts +89 -38
- package/src/sdk/session.ts +155 -26
- package/src/sdk/transport/relay.ts +35 -13
- package/src/session/agent-session.ts +117 -52
- package/src/session/fallback-chain-controller.ts +38 -0
- package/src/session/internal/managed-session-storage.ts +19 -5
- package/src/session/messages.ts +5 -0
- package/src/session/session-manager.ts +29 -5
- package/src/session-import/redact.ts +5 -1
- package/src/setup/paseo/announce.ts +49 -43
- package/src/setup/paseo/setup-deps.ts +1 -1
- package/src/skill-state/canonical-skills.ts +12 -0
- package/src/skills/skills.test.ts +41 -21
- package/src/system-prompt.ts +25 -3
- package/src/task/executor.ts +5 -0
- package/src/task/index.ts +4 -0
- package/src/tools/bash.ts +28 -11
- package/src/tools/gh.ts +26 -3
- package/src/tools/github-cache.ts +68 -16
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +13 -2
- package/src/tools/read.ts +14 -2
- package/src/tools/render-utils.ts +1 -3
- package/src/tools/resource-gc.ts +28 -8
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +15 -0
- package/src/tools/subagent.ts +3 -2
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/tools/ultragoal-ask-guard.ts +2 -2
package/src/crash/sanitize.ts
CHANGED
|
@@ -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 =
|
|
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:
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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)
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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:
|
|
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
|
|
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
|
|
942
|
-
const userContent = await
|
|
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);
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|