@moreih29/nexus-core 0.12.0 → 0.13.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.
- package/README.md +48 -63
- package/assets/agents/architect/body.ko.md +177 -0
- package/{agents → assets/agents}/architect/body.md +16 -0
- package/assets/agents/designer/body.ko.md +125 -0
- package/{agents → assets/agents}/designer/body.md +16 -0
- package/assets/agents/engineer/body.ko.md +106 -0
- package/{agents → assets/agents}/engineer/body.md +14 -0
- package/assets/agents/lead/body.ko.md +70 -0
- package/assets/agents/lead/body.md +70 -0
- package/assets/agents/postdoc/body.ko.md +122 -0
- package/{agents → assets/agents}/postdoc/body.md +16 -0
- package/assets/agents/researcher/body.ko.md +137 -0
- package/{agents → assets/agents}/researcher/body.md +15 -0
- package/assets/agents/reviewer/body.ko.md +138 -0
- package/{agents → assets/agents}/reviewer/body.md +15 -0
- package/assets/agents/strategist/body.ko.md +116 -0
- package/{agents → assets/agents}/strategist/body.md +16 -0
- package/assets/agents/tester/body.ko.md +195 -0
- package/{agents → assets/agents}/tester/body.md +15 -0
- package/assets/agents/writer/body.ko.md +122 -0
- package/{agents → assets/agents}/writer/body.md +14 -0
- package/assets/capability-matrix.yml +198 -0
- package/assets/hooks/agent-bootstrap/handler.test.ts +368 -0
- package/assets/hooks/agent-bootstrap/handler.ts +119 -0
- package/assets/hooks/agent-bootstrap/meta.yml +10 -0
- package/assets/hooks/agent-finalize/handler.test.ts +368 -0
- package/assets/hooks/agent-finalize/handler.ts +76 -0
- package/assets/hooks/agent-finalize/meta.yml +10 -0
- package/assets/hooks/capability-matrix.yml +313 -0
- package/assets/hooks/post-tool-telemetry/handler.test.ts +302 -0
- package/assets/hooks/post-tool-telemetry/handler.ts +49 -0
- package/assets/hooks/post-tool-telemetry/meta.yml +11 -0
- package/assets/hooks/prompt-router/handler.test.ts +801 -0
- package/assets/hooks/prompt-router/handler.ts +261 -0
- package/assets/hooks/prompt-router/meta.yml +11 -0
- package/assets/hooks/session-init/handler.test.ts +274 -0
- package/assets/hooks/session-init/handler.ts +30 -0
- package/assets/hooks/session-init/meta.yml +9 -0
- package/assets/lsp-servers.json +55 -0
- package/assets/schema/lsp-servers.schema.json +67 -0
- package/assets/skills/nx-init/body.ko.md +197 -0
- package/{skills → assets/skills}/nx-init/body.md +11 -0
- package/assets/skills/nx-plan/body.ko.md +361 -0
- package/{skills → assets/skills}/nx-plan/body.md +13 -0
- package/assets/skills/nx-run/body.ko.md +161 -0
- package/{skills → assets/skills}/nx-run/body.md +11 -0
- package/assets/skills/nx-sync/body.ko.md +92 -0
- package/{skills → assets/skills}/nx-sync/body.md +10 -0
- package/assets/tools/tool-name-map.yml +353 -0
- package/dist/hooks/opencode-mount.d.ts +35 -0
- package/dist/hooks/opencode-mount.d.ts.map +1 -0
- package/dist/hooks/opencode-mount.js +332 -0
- package/dist/hooks/opencode-mount.js.map +1 -0
- package/dist/hooks/runtime.d.ts +37 -0
- package/dist/hooks/runtime.d.ts.map +1 -0
- package/dist/hooks/runtime.js +274 -0
- package/dist/hooks/runtime.js.map +1 -0
- package/dist/hooks/types.d.ts +196 -0
- package/dist/hooks/types.d.ts.map +1 -0
- package/dist/hooks/types.js +85 -0
- package/dist/hooks/types.js.map +1 -0
- package/dist/lsp/cache.d.ts +9 -0
- package/dist/lsp/cache.d.ts.map +1 -0
- package/dist/lsp/cache.js +216 -0
- package/dist/lsp/cache.js.map +1 -0
- package/dist/lsp/client.d.ts +24 -0
- package/dist/lsp/client.d.ts.map +1 -0
- package/dist/lsp/client.js +166 -0
- package/dist/lsp/client.js.map +1 -0
- package/dist/lsp/detect.d.ts +77 -0
- package/dist/lsp/detect.d.ts.map +1 -0
- package/dist/lsp/detect.js +116 -0
- package/dist/lsp/detect.js.map +1 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +34 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/artifact.d.ts +4 -0
- package/dist/mcp/tools/artifact.d.ts.map +1 -0
- package/dist/mcp/tools/artifact.js +36 -0
- package/dist/mcp/tools/artifact.js.map +1 -0
- package/dist/mcp/tools/history.d.ts +3 -0
- package/dist/mcp/tools/history.d.ts.map +1 -0
- package/dist/mcp/tools/history.js +29 -0
- package/dist/mcp/tools/history.js.map +1 -0
- package/dist/mcp/tools/lsp.d.ts +13 -0
- package/dist/mcp/tools/lsp.d.ts.map +1 -0
- package/dist/mcp/tools/lsp.js +225 -0
- package/dist/mcp/tools/lsp.js.map +1 -0
- package/dist/mcp/tools/plan.d.ts +3 -0
- package/dist/mcp/tools/plan.d.ts.map +1 -0
- package/dist/mcp/tools/plan.js +317 -0
- package/dist/mcp/tools/plan.js.map +1 -0
- package/dist/mcp/tools/task.d.ts +3 -0
- package/dist/mcp/tools/task.d.ts.map +1 -0
- package/dist/mcp/tools/task.js +252 -0
- package/dist/mcp/tools/task.js.map +1 -0
- package/dist/shared/invocations.d.ts +74 -0
- package/dist/shared/invocations.d.ts.map +1 -0
- package/dist/shared/invocations.js +247 -0
- package/dist/shared/invocations.js.map +1 -0
- package/dist/shared/json-store.d.ts +37 -0
- package/dist/shared/json-store.d.ts.map +1 -0
- package/dist/shared/json-store.js +163 -0
- package/dist/shared/json-store.js.map +1 -0
- package/dist/shared/mcp-utils.d.ts +3 -0
- package/dist/shared/mcp-utils.d.ts.map +1 -0
- package/dist/shared/mcp-utils.js +6 -0
- package/dist/shared/mcp-utils.js.map +1 -0
- package/dist/shared/paths.d.ts +21 -0
- package/dist/shared/paths.d.ts.map +1 -0
- package/dist/shared/paths.js +81 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/tool-log.d.ts +8 -0
- package/dist/shared/tool-log.d.ts.map +1 -0
- package/dist/shared/tool-log.js +22 -0
- package/dist/shared/tool-log.js.map +1 -0
- package/dist/types/state.d.ts +862 -0
- package/dist/types/state.d.ts.map +1 -0
- package/dist/types/state.js +66 -0
- package/dist/types/state.js.map +1 -0
- package/docs/consuming/codex-lead-merge.md +106 -0
- package/docs/plugin-guide.md +360 -0
- package/docs/plugin-template/claude/.github/workflows/build.yml +60 -0
- package/docs/plugin-template/claude/README.md +110 -0
- package/docs/plugin-template/claude/package.json +16 -0
- package/docs/plugin-template/codex/.github/workflows/build.yml +51 -0
- package/docs/plugin-template/codex/README.md +147 -0
- package/docs/plugin-template/codex/package.json +17 -0
- package/docs/plugin-template/opencode/.github/workflows/build.yml +61 -0
- package/docs/plugin-template/opencode/README.md +121 -0
- package/docs/plugin-template/opencode/package.json +25 -0
- package/package.json +21 -21
- package/scripts/build-agents.test.ts +1279 -0
- package/scripts/build-agents.ts +978 -0
- package/scripts/build-hooks.test.ts +1385 -0
- package/scripts/build-hooks.ts +584 -0
- package/scripts/cli.test.ts +367 -0
- package/scripts/cli.ts +547 -0
- package/agents/architect/meta.yml +0 -13
- package/agents/designer/meta.yml +0 -13
- package/agents/engineer/meta.yml +0 -11
- package/agents/postdoc/meta.yml +0 -13
- package/agents/researcher/meta.yml +0 -12
- package/agents/reviewer/meta.yml +0 -12
- package/agents/strategist/meta.yml +0 -13
- package/agents/tester/meta.yml +0 -12
- package/agents/writer/meta.yml +0 -11
- package/conformance/README.md +0 -311
- package/conformance/examples/plan.extension.schema.example.json +0 -25
- package/conformance/lifecycle/README.md +0 -48
- package/conformance/lifecycle/agent-complete.json +0 -44
- package/conformance/lifecycle/agent-resume.json +0 -43
- package/conformance/lifecycle/agent-spawn.json +0 -36
- package/conformance/lifecycle/memory-access-record.json +0 -27
- package/conformance/lifecycle/session-end.json +0 -48
- package/conformance/scenarios/full-plan-cycle.json +0 -147
- package/conformance/scenarios/task-deps-ordering.json +0 -95
- package/conformance/schema/fixture.schema.json +0 -354
- package/conformance/state-schemas/agent-tracker.schema.json +0 -63
- package/conformance/state-schemas/history.schema.json +0 -134
- package/conformance/state-schemas/memory-access.schema.json +0 -36
- package/conformance/state-schemas/plan.schema.json +0 -77
- package/conformance/state-schemas/tasks.schema.json +0 -98
- package/conformance/tools/artifact-write.json +0 -97
- package/conformance/tools/context.json +0 -172
- package/conformance/tools/history-search.json +0 -219
- package/conformance/tools/plan-decide.json +0 -139
- package/conformance/tools/plan-start.json +0 -81
- package/conformance/tools/plan-status.json +0 -127
- package/conformance/tools/plan-update.json +0 -341
- package/conformance/tools/task-add.json +0 -156
- package/conformance/tools/task-close.json +0 -161
- package/conformance/tools/task-list.json +0 -177
- package/conformance/tools/task-update.json +0 -167
- package/docs/behavioral-contracts.md +0 -145
- package/docs/consumer-implementation-guide.md +0 -840
- package/docs/memory-lifecycle-contract.md +0 -119
- package/docs/nexus-layout.md +0 -224
- package/docs/nexus-outputs-contract.md +0 -344
- package/docs/nexus-state-overview.md +0 -170
- package/docs/nexus-tools-contract.md +0 -438
- package/manifest.json +0 -448
- package/schema/README.md +0 -69
- package/schema/agent.schema.json +0 -23
- package/schema/common.schema.json +0 -17
- package/schema/manifest.schema.json +0 -78
- package/schema/memory-policy.schema.json +0 -98
- package/schema/skill.schema.json +0 -54
- package/schema/task-exceptions.schema.json +0 -40
- package/schema/vocabulary.schema.json +0 -167
- package/scripts/.gitkeep +0 -0
- package/scripts/conformance-coverage.ts +0 -466
- package/scripts/import-from-claude-nexus.ts +0 -403
- package/scripts/lib/frontmatter.ts +0 -71
- package/scripts/lib/lint.ts +0 -348
- package/scripts/lib/structure.ts +0 -159
- package/scripts/lib/validate.ts +0 -796
- package/scripts/validate.ts +0 -90
- package/skills/nx-init/meta.yml +0 -8
- package/skills/nx-plan/meta.yml +0 -10
- package/skills/nx-run/meta.yml +0 -8
- package/skills/nx-sync/meta.yml +0 -7
- package/vocabulary/capabilities.yml +0 -65
- package/vocabulary/categories.yml +0 -11
- package/vocabulary/invocations.yml +0 -147
- package/vocabulary/memory_policy.yml +0 -88
- package/vocabulary/resume-tiers.yml +0 -11
- package/vocabulary/tags.yml +0 -60
- package/vocabulary/task-exceptions.yml +0 -29
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { HookHandler, NexusHookOutput } from "../../../src/hooks/types.js";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join, resolve, dirname } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import {
|
|
6
|
+
expandInvocations,
|
|
7
|
+
type InvocationsMap,
|
|
8
|
+
type Harness,
|
|
9
|
+
} from "../../../src/shared/invocations.js";
|
|
10
|
+
|
|
11
|
+
// Tag priority: specific variants first (m:gc > m, rule:name > rule, plan:auto > plan, init:reset > init)
|
|
12
|
+
const TAG_PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
|
13
|
+
{ name: "plan:auto", regex: /\[plan:auto\]/ },
|
|
14
|
+
{ name: "plan", regex: /\[plan\](?!\w)/ },
|
|
15
|
+
{ name: "run", regex: /\[run\](?!\w)/ },
|
|
16
|
+
{ name: "d", regex: /\[d\](?!\w)/ },
|
|
17
|
+
{ name: "m:gc", regex: /\[m:gc\]/ },
|
|
18
|
+
{ name: "m", regex: /\[m\](?!\w)/ },
|
|
19
|
+
{ name: "rule:name", regex: /\[rule:([a-zA-Z0-9_-]+)\]/ },
|
|
20
|
+
{ name: "rule", regex: /\[rule\](?!\w)/ },
|
|
21
|
+
{ name: "sync", regex: /\[sync\](?!\w)/ },
|
|
22
|
+
{ name: "init:reset", regex: /\[init:reset\]/ },
|
|
23
|
+
{ name: "init", regex: /\[init\](?!\w)/ },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Invocations loader — cached per process
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
let _invocationsCache: InvocationsMap | null = null;
|
|
31
|
+
|
|
32
|
+
function loadInvocations(): InvocationsMap {
|
|
33
|
+
if (_invocationsCache) return _invocationsCache;
|
|
34
|
+
|
|
35
|
+
const selfDir = new URL(".", import.meta.url).pathname;
|
|
36
|
+
// Walk up from handler directory to find assets/tools/tool-name-map.yml
|
|
37
|
+
let dir = selfDir;
|
|
38
|
+
while (dir !== "/") {
|
|
39
|
+
const candidate = resolve(dir, "assets/tools/tool-name-map.yml");
|
|
40
|
+
if (existsSync(candidate)) {
|
|
41
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
42
|
+
const parsed = parseYaml(raw) as { invocations?: InvocationsMap };
|
|
43
|
+
if (!parsed.invocations) {
|
|
44
|
+
throw new Error("[prompt-router] tool-name-map.yml missing 'invocations' section");
|
|
45
|
+
}
|
|
46
|
+
_invocationsCache = parsed.invocations;
|
|
47
|
+
return _invocationsCache;
|
|
48
|
+
}
|
|
49
|
+
const parent = dirname(dir);
|
|
50
|
+
if (parent === dir) break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(`[prompt-router] Cannot locate assets/tools/tool-name-map.yml from ${selfDir}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Harness resolution
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function resolveHarness(): Harness {
|
|
62
|
+
const h = process.env["NEXUS_HARNESS"];
|
|
63
|
+
if (h === "claude" || h === "opencode" || h === "codex") return h;
|
|
64
|
+
if (h) {
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`[prompt-router] Unknown NEXUS_HARNESS="${h}", falling back to "claude"\n`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return "claude";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Invocation expansion helper
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function expand(template: string, harness: Harness): string {
|
|
77
|
+
return expandInvocations(template, harness, loadInvocations());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Rule target loader
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function loadValidRuleTargets(cwd: string): string[] {
|
|
85
|
+
const targets: string[] = [];
|
|
86
|
+
for (const dir of ["assets/agents", "assets/skills"]) {
|
|
87
|
+
const absDir = join(cwd, dir);
|
|
88
|
+
if (!existsSync(absDir)) continue;
|
|
89
|
+
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
|
90
|
+
if (entry.isDirectory()) targets.push(entry.name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return targets;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Handler
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const handler: HookHandler = async (input): Promise<NexusHookOutput | void> => {
|
|
101
|
+
if (input.hook_event_name !== "UserPromptSubmit") return;
|
|
102
|
+
|
|
103
|
+
const prompt = input.prompt;
|
|
104
|
+
const detected: Array<{ name: string; arg?: string }> = [];
|
|
105
|
+
|
|
106
|
+
// Detect all tags — use seen Set keyed on base tag name to prevent duplicates
|
|
107
|
+
// (e.g. plan:auto and plan share base "plan"; whichever appears first wins)
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
for (const { name, regex } of TAG_PATTERNS) {
|
|
110
|
+
const m = regex.exec(prompt);
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
const base = name.split(":")[0];
|
|
113
|
+
if (seen.has(base)) continue;
|
|
114
|
+
seen.add(base);
|
|
115
|
+
detected.push({ name, arg: m[1] });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sessionDir = join(input.cwd, ".nexus/state", input.session_id);
|
|
119
|
+
const planPath = join(sessionDir, "plan.json");
|
|
120
|
+
const tasksPath = join(sessionDir, "tasks.json");
|
|
121
|
+
const hasPlan = existsSync(planPath);
|
|
122
|
+
const hasTasks = existsSync(tasksPath);
|
|
123
|
+
|
|
124
|
+
const harness = resolveHarness();
|
|
125
|
+
|
|
126
|
+
const notices: string[] = [];
|
|
127
|
+
let decision: "block" | undefined;
|
|
128
|
+
let block_reason: string | undefined;
|
|
129
|
+
|
|
130
|
+
for (const tag of detected) {
|
|
131
|
+
switch (tag.name) {
|
|
132
|
+
case "plan":
|
|
133
|
+
notices.push(
|
|
134
|
+
`<system-notice>[plan] tag detected. ${expand('{{skill_activation skill="nx-plan"}}', harness)} for structured planning.</system-notice>`
|
|
135
|
+
);
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "plan:auto":
|
|
139
|
+
notices.push(
|
|
140
|
+
`<system-notice>[plan:auto] tag detected. ${expand('{{skill_activation skill="nx-plan" mode="auto"}}', harness)} for structured planning.</system-notice>`
|
|
141
|
+
);
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case "run":
|
|
145
|
+
if (!hasTasks) {
|
|
146
|
+
notices.push(
|
|
147
|
+
`<system-notice>[run] tag detected but no tasks.json. ${expand('{{skill_activation skill="nx-plan"}}', harness)} with args "auto" first to generate tasks, then run.</system-notice>`
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
notices.push(
|
|
151
|
+
`<system-notice>[run] tag detected. ${expand('{{skill_activation skill="nx-run"}}', harness)} to execute tasks.</system-notice>`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case "d":
|
|
157
|
+
if (!hasPlan) {
|
|
158
|
+
decision = "block";
|
|
159
|
+
block_reason =
|
|
160
|
+
`[d] tag requires an active plan session. ${expand('{{skill_activation skill="nx-plan"}}', harness)} first.`;
|
|
161
|
+
} else {
|
|
162
|
+
notices.push(
|
|
163
|
+
`<system-notice>[d] tag detected. Record decision via \`nx_plan_decide(issue_id, summary)\` MCP tool.</system-notice>`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
|
|
168
|
+
case "m":
|
|
169
|
+
notices.push(
|
|
170
|
+
`<system-notice>[m] tag detected. Save a memory note to \`.nexus/memory/<prefix>-<name>.md\`. Prefix: empirical-, external-, or pattern- (see architecture.md §2-1).</system-notice>`
|
|
171
|
+
);
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case "m:gc":
|
|
175
|
+
notices.push(
|
|
176
|
+
`<system-notice>[m:gc] tag detected. Review \`.nexus/memory/\` for stale or duplicate entries and consolidate.</system-notice>`
|
|
177
|
+
);
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
case "rule": {
|
|
181
|
+
const valid = loadValidRuleTargets(input.cwd);
|
|
182
|
+
notices.push(
|
|
183
|
+
`<system-notice>[rule] tag detected. Determine target from intent. Valid targets: ${valid.join(", ")}. Update \`.nexus/rules/<target>.md\`.</system-notice>`
|
|
184
|
+
);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "rule:name": {
|
|
189
|
+
const valid = loadValidRuleTargets(input.cwd);
|
|
190
|
+
const name = tag.arg ?? "";
|
|
191
|
+
if (!valid.includes(name)) {
|
|
192
|
+
decision = "block";
|
|
193
|
+
block_reason = `[rule:${name}] invalid — must be one of: ${valid.join(", ")}`;
|
|
194
|
+
} else {
|
|
195
|
+
notices.push(
|
|
196
|
+
`<system-notice>[rule:${name}] tag detected. Update \`.nexus/rules/${name}.md\` with user's directive.</system-notice>`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "sync":
|
|
203
|
+
notices.push(
|
|
204
|
+
`<system-notice>[sync] tag detected. ${expand('{{skill_activation skill="nx-sync"}}', harness)} to synchronize \`.nexus/context/\`.</system-notice>`
|
|
205
|
+
);
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case "init":
|
|
209
|
+
notices.push(
|
|
210
|
+
`<system-notice>[init] tag detected. ${expand('{{skill_activation skill="nx-init"}}', harness)} for project onboarding.</system-notice>`
|
|
211
|
+
);
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
case "init:reset":
|
|
215
|
+
notices.push(
|
|
216
|
+
`<system-notice>[init:reset] tag detected. ${expand('{{skill_activation skill="nx-init" mode="reset"}}', harness)} for full re-initialization.</system-notice>`
|
|
217
|
+
);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// No tags detected + active state → emit state notice
|
|
223
|
+
if (detected.length === 0) {
|
|
224
|
+
if (hasPlan) {
|
|
225
|
+
try {
|
|
226
|
+
const plan = JSON.parse(readFileSync(planPath, "utf-8")) as {
|
|
227
|
+
topic?: string;
|
|
228
|
+
issues?: Array<{ status: string }>;
|
|
229
|
+
};
|
|
230
|
+
const pending = plan.issues?.filter((i) => i.status === "pending").length ?? 0;
|
|
231
|
+
notices.push(
|
|
232
|
+
`<system-notice>Active plan session: "${plan.topic ?? "(unknown)"}", ${pending} issues pending.</system-notice>`
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
// Malformed plan.json — skip notice
|
|
236
|
+
}
|
|
237
|
+
} else if (hasTasks) {
|
|
238
|
+
try {
|
|
239
|
+
const tasks = JSON.parse(readFileSync(tasksPath, "utf-8")) as {
|
|
240
|
+
tasks?: Array<{ status: string }>;
|
|
241
|
+
};
|
|
242
|
+
const pending = tasks.tasks?.filter((t) => t.status !== "completed").length ?? 0;
|
|
243
|
+
if (pending > 0) {
|
|
244
|
+
notices.push(
|
|
245
|
+
`<system-notice>Active run session: ${pending} tasks remaining in tasks.json.</system-notice>`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Malformed tasks.json — skip notice
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (decision === "block") {
|
|
255
|
+
return { decision, block_reason };
|
|
256
|
+
}
|
|
257
|
+
if (notices.length === 0) return;
|
|
258
|
+
return { additional_context: notices.join("\n\n") };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default handler;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
name: prompt-router
|
|
2
|
+
description: Detect nexus tags, inject state notices and skill invocation guidance
|
|
3
|
+
events: [UserPromptSubmit]
|
|
4
|
+
matcher: "*"
|
|
5
|
+
timeout: 10
|
|
6
|
+
fallback: warn
|
|
7
|
+
priority: 0
|
|
8
|
+
requires_capabilities:
|
|
9
|
+
- event.user_prompt_submit
|
|
10
|
+
- output.additional_context.user_prompt
|
|
11
|
+
- output.decision_block
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as crypto from "node:crypto";
|
|
6
|
+
import handler from "./handler.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helper
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
function makeTmpDir(): string {
|
|
13
|
+
return fs.mkdtempSync(
|
|
14
|
+
path.join(os.tmpdir(), `nexus-session-init-${crypto.randomUUID()}-`)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionDir(cwd: string, sid: string): string {
|
|
19
|
+
return path.join(cwd, ".nexus/state", sid);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Scenario 1: New session_id (valid) → directory + files created
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
describe("scenario 1 — new session_id creates state files", () => {
|
|
27
|
+
let cwd: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
cwd = makeTmpDir();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("creates .nexus/state/sessions/<sid>/ directory", async () => {
|
|
38
|
+
const sid = crypto.randomUUID();
|
|
39
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
40
|
+
expect(fs.existsSync(sessionDir(cwd, sid))).toBe(true);
|
|
41
|
+
expect(fs.statSync(sessionDir(cwd, sid)).isDirectory()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("creates agent-tracker.json with content '[]'", async () => {
|
|
45
|
+
const sid = crypto.randomUUID();
|
|
46
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
47
|
+
const filePath = path.join(sessionDir(cwd, sid), "agent-tracker.json");
|
|
48
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
49
|
+
expect(fs.readFileSync(filePath, "utf8")).toBe("[]");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("creates tool-log.jsonl as empty file", async () => {
|
|
53
|
+
const sid = crypto.randomUUID();
|
|
54
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
55
|
+
const filePath = path.join(sessionDir(cwd, sid), "tool-log.jsonl");
|
|
56
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
57
|
+
expect(fs.readFileSync(filePath, "utf8")).toBe("");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Scenario 2: Existing session_id re-invoked → files overwritten (reset policy)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("scenario 2 — existing session_id re-initialises (overwrite)", () => {
|
|
66
|
+
let cwd: string;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
cwd = makeTmpDir();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("overwrites agent-tracker.json even when it had prior content", async () => {
|
|
77
|
+
const sid = crypto.randomUUID();
|
|
78
|
+
// First call
|
|
79
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
80
|
+
// Simulate prior state
|
|
81
|
+
const trackerPath = path.join(sessionDir(cwd, sid), "agent-tracker.json");
|
|
82
|
+
fs.writeFileSync(trackerPath, '[{"id":"agent-1"}]');
|
|
83
|
+
// Second call — must reset
|
|
84
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
85
|
+
expect(fs.readFileSync(trackerPath, "utf8")).toBe("[]");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("overwrites tool-log.jsonl even when it had prior content", async () => {
|
|
89
|
+
const sid = crypto.randomUUID();
|
|
90
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
91
|
+
const logPath = path.join(sessionDir(cwd, sid), "tool-log.jsonl");
|
|
92
|
+
fs.writeFileSync(logPath, '{"tool":"Bash","ts":"2026-01-01T00:00:00Z"}\n');
|
|
93
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
94
|
+
expect(fs.readFileSync(logPath, "utf8")).toBe("");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Scenario 3: Path traversal — session_id="../etc/passwd"
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe("scenario 3 — path traversal is prevented", () => {
|
|
103
|
+
let cwd: string;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
cwd = makeTmpDir();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does not write to the real /etc/passwd (mtime unchanged)", async () => {
|
|
114
|
+
// Guard: confirm /etc/passwd exists so the test is meaningful
|
|
115
|
+
expect(fs.existsSync("/etc/passwd")).toBe(true);
|
|
116
|
+
const before = fs.statSync("/etc/passwd").mtimeMs;
|
|
117
|
+
await handler({
|
|
118
|
+
hook_event_name: "SessionStart",
|
|
119
|
+
session_id: "../etc/passwd",
|
|
120
|
+
cwd,
|
|
121
|
+
});
|
|
122
|
+
const after = fs.statSync("/etc/passwd").mtimeMs;
|
|
123
|
+
expect(after).toBe(before);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("any created path is contained within cwd", async () => {
|
|
127
|
+
await handler({
|
|
128
|
+
hook_event_name: "SessionStart",
|
|
129
|
+
session_id: "../etc/passwd",
|
|
130
|
+
cwd,
|
|
131
|
+
});
|
|
132
|
+
// Walk everything created and assert it all lives inside cwd
|
|
133
|
+
const sessionsRoot = path.join(cwd, ".nexus/state");
|
|
134
|
+
if (fs.existsSync(sessionsRoot)) {
|
|
135
|
+
const entries = fs.readdirSync(sessionsRoot, { recursive: true, encoding: "utf8" }) as string[];
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const abs = path.resolve(sessionsRoot, entry);
|
|
138
|
+
expect(abs.startsWith(cwd)).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("basename extraction: if something is created it is named 'passwd' inside sessions", async () => {
|
|
144
|
+
await handler({
|
|
145
|
+
hook_event_name: "SessionStart",
|
|
146
|
+
session_id: "../etc/passwd",
|
|
147
|
+
cwd,
|
|
148
|
+
});
|
|
149
|
+
// basename('../etc/passwd') === 'passwd'
|
|
150
|
+
// Handler must either create state/passwd (safe) or nothing.
|
|
151
|
+
// It must NOT create state/../etc/passwd (which resolves to .nexus/etc/passwd — still inside cwd but wrong semantic).
|
|
152
|
+
// The only acceptable directory name is 'passwd' directly under state.
|
|
153
|
+
const sessionsRoot = path.join(cwd, ".nexus/state");
|
|
154
|
+
if (fs.existsSync(sessionsRoot)) {
|
|
155
|
+
const children = fs.readdirSync(sessionsRoot);
|
|
156
|
+
// Each direct child must be a non-traversal name
|
|
157
|
+
for (const child of children) {
|
|
158
|
+
expect(child).not.toContain("..");
|
|
159
|
+
expect(child).not.toContain("/");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("session_id with embedded slash is rejected or sanitised — no nested subdirs under sessions", async () => {
|
|
165
|
+
const sid = "foo/bar";
|
|
166
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
167
|
+
// If handler created something it must not be a nested foo/bar under state.
|
|
168
|
+
// Acceptable: nothing, or state/bar (basename). Not acceptable: state/foo/bar.
|
|
169
|
+
const fooInsideSessions = path.join(cwd, ".nexus/state/foo");
|
|
170
|
+
if (fs.existsSync(fooInsideSessions)) {
|
|
171
|
+
// foo exists — bar must NOT be a subdirectory of it (that would be nested traversal)
|
|
172
|
+
expect(fs.existsSync(path.join(fooInsideSessions, "bar"))).toBe(false);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Scenario 4: plan.json, tasks.json, memory-access.jsonl are not touched
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("scenario 4 — project-level state files are not modified", () => {
|
|
182
|
+
let cwd: string;
|
|
183
|
+
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
cwd = makeTmpDir();
|
|
186
|
+
// Seed pre-existing project-level state files
|
|
187
|
+
const stateDir = path.join(cwd, ".nexus/state");
|
|
188
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
189
|
+
fs.writeFileSync(path.join(stateDir, "plan.json"), '{"version":1}');
|
|
190
|
+
fs.writeFileSync(path.join(stateDir, "tasks.json"), '{"tasks":[]}');
|
|
191
|
+
fs.writeFileSync(path.join(stateDir, "memory-access.jsonl"), "entry1\n");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("plan.json content is unchanged after handler runs", async () => {
|
|
199
|
+
const sid = crypto.randomUUID();
|
|
200
|
+
const planPath = path.join(cwd, ".nexus/state/plan.json");
|
|
201
|
+
const before = fs.readFileSync(planPath, "utf8");
|
|
202
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
203
|
+
expect(fs.readFileSync(planPath, "utf8")).toBe(before);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("tasks.json content is unchanged after handler runs", async () => {
|
|
207
|
+
const sid = crypto.randomUUID();
|
|
208
|
+
const tasksPath = path.join(cwd, ".nexus/state/tasks.json");
|
|
209
|
+
const before = fs.readFileSync(tasksPath, "utf8");
|
|
210
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
211
|
+
expect(fs.readFileSync(tasksPath, "utf8")).toBe(before);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("memory-access.jsonl content is unchanged after handler runs", async () => {
|
|
215
|
+
const sid = crypto.randomUUID();
|
|
216
|
+
const memPath = path.join(cwd, ".nexus/state/memory-access.jsonl");
|
|
217
|
+
const before = fs.readFileSync(memPath, "utf8");
|
|
218
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
219
|
+
expect(fs.readFileSync(memPath, "utf8")).toBe(before);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("plan.json mtime is unchanged after handler runs", async () => {
|
|
223
|
+
const sid = crypto.randomUUID();
|
|
224
|
+
const planPath = path.join(cwd, ".nexus/state/plan.json");
|
|
225
|
+
const before = fs.statSync(planPath).mtimeMs;
|
|
226
|
+
await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
|
|
227
|
+
expect(fs.statSync(planPath).mtimeMs).toBe(before);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Scenario 5: Return value is undefined / void
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe("scenario 5 — return value is undefined (void)", () => {
|
|
236
|
+
let cwd: string;
|
|
237
|
+
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
cwd = makeTmpDir();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
afterEach(() => {
|
|
243
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("returns undefined for a valid SessionStart event", async () => {
|
|
247
|
+
const sid = crypto.randomUUID();
|
|
248
|
+
const result = await handler({
|
|
249
|
+
hook_event_name: "SessionStart",
|
|
250
|
+
session_id: sid,
|
|
251
|
+
cwd,
|
|
252
|
+
});
|
|
253
|
+
expect(result).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("returns undefined when hook_event_name is not SessionStart", async () => {
|
|
257
|
+
const result = await handler({
|
|
258
|
+
hook_event_name: "UserPromptSubmit",
|
|
259
|
+
session_id: crypto.randomUUID(),
|
|
260
|
+
cwd,
|
|
261
|
+
prompt: "hello",
|
|
262
|
+
});
|
|
263
|
+
expect(result).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("returns undefined for path traversal input (rejected)", async () => {
|
|
267
|
+
const result = await handler({
|
|
268
|
+
hook_event_name: "SessionStart",
|
|
269
|
+
session_id: "../etc/passwd",
|
|
270
|
+
cwd,
|
|
271
|
+
});
|
|
272
|
+
expect(result).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HookHandler } from "../../../src/hooks/types.js";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
const handler: HookHandler = async (input) => {
|
|
6
|
+
if (input.hook_event_name !== "SessionStart") return;
|
|
7
|
+
|
|
8
|
+
// Sanitize session_id to prevent path traversal
|
|
9
|
+
const safeSid = basename(input.session_id);
|
|
10
|
+
if (!safeSid || safeSid.startsWith(".") || safeSid.includes("/")) {
|
|
11
|
+
process.stderr.write(`[session-init] invalid session_id: ${input.session_id}\n`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sessionDir = join(input.cwd, ".nexus/state", safeSid);
|
|
16
|
+
|
|
17
|
+
// Ensure directory exists (idempotent)
|
|
18
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
19
|
+
|
|
20
|
+
// Initialize per-session state files — overwrite unconditionally (resume is intentional)
|
|
21
|
+
writeFileSync(join(sessionDir, "agent-tracker.json"), "[]");
|
|
22
|
+
writeFileSync(join(sessionDir, "tool-log.jsonl"), "");
|
|
23
|
+
|
|
24
|
+
// plan.json and tasks.json are MCP responsibility — not touched here
|
|
25
|
+
// memory-access.jsonl is project-level — not touched here
|
|
26
|
+
|
|
27
|
+
// No additional_context returned (decided: no context injection at session start)
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default handler;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./schema/lsp-servers.schema.json",
|
|
3
|
+
"languages": {
|
|
4
|
+
"typescript": {
|
|
5
|
+
"extensions": {
|
|
6
|
+
"ts": "typescript",
|
|
7
|
+
"tsx": "typescriptreact",
|
|
8
|
+
"js": "javascript",
|
|
9
|
+
"jsx": "javascriptreact",
|
|
10
|
+
"mjs": "javascript",
|
|
11
|
+
"cjs": "javascript",
|
|
12
|
+
"mts": "typescript",
|
|
13
|
+
"cts": "typescript"
|
|
14
|
+
},
|
|
15
|
+
"server": {
|
|
16
|
+
"command_chain": ["bunx", "npx"],
|
|
17
|
+
"args": ["typescript-language-server", "--stdio"]
|
|
18
|
+
},
|
|
19
|
+
"install_hint": "npm i -g typescript-language-server"
|
|
20
|
+
},
|
|
21
|
+
"python": {
|
|
22
|
+
"extensions": {
|
|
23
|
+
"py": "python",
|
|
24
|
+
"pyi": "python"
|
|
25
|
+
},
|
|
26
|
+
"server": {
|
|
27
|
+
"command_chain": ["bunx", "npx"],
|
|
28
|
+
"args": ["pyright-langserver", "--stdio"]
|
|
29
|
+
},
|
|
30
|
+
"install_hint": "npm i -g pyright"
|
|
31
|
+
},
|
|
32
|
+
"rust": {
|
|
33
|
+
"extensions": {
|
|
34
|
+
"rs": "rust"
|
|
35
|
+
},
|
|
36
|
+
"server": {
|
|
37
|
+
"command_chain": ["rust-analyzer"],
|
|
38
|
+
"search_paths": ["~/.cargo/bin/rust-analyzer"],
|
|
39
|
+
"args": []
|
|
40
|
+
},
|
|
41
|
+
"install_hint": "rustup component add rust-analyzer"
|
|
42
|
+
},
|
|
43
|
+
"go": {
|
|
44
|
+
"extensions": {
|
|
45
|
+
"go": "go"
|
|
46
|
+
},
|
|
47
|
+
"server": {
|
|
48
|
+
"command_chain": ["gopls"],
|
|
49
|
+
"search_paths": ["~/go/bin/gopls", "/usr/local/go/bin/gopls"],
|
|
50
|
+
"args": ["serve"]
|
|
51
|
+
},
|
|
52
|
+
"install_hint": "go install golang.org/x/tools/gopls@latest"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|