@crouton-kit/crouter 0.3.47 → 0.3.49
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/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +5 -3
- package/dist/clients/attach/attach-cmd.js +425 -425
- package/dist/clients/attach/titled-editor.js +6 -3
- package/dist/commands/human/shared.js +7 -3
- package/dist/commands/node.js +96 -11
- package/dist/commands/profile/default.d.ts +2 -0
- package/dist/commands/profile/default.js +143 -0
- package/dist/commands/profile/new.js +3 -3
- package/dist/commands/profile/project.d.ts +2 -0
- package/dist/commands/profile/project.js +97 -0
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +10 -11
- package/dist/commands/sys/__tests__/sync-import.test.js +89 -1
- package/dist/commands/sys/sync.js +242 -12
- package/dist/core/__tests__/broker-sdk-wiring.test.js +7 -5
- package/dist/core/__tests__/context-intro.test.js +12 -19
- package/dist/core/profiles/default-binding.d.ts +10 -0
- package/dist/core/profiles/default-binding.js +50 -0
- package/dist/core/profiles/select.d.ts +13 -1
- package/dist/core/profiles/select.js +92 -26
- package/dist/core/runtime/bearings.d.ts +15 -7
- package/dist/core/runtime/bearings.js +26 -85
- package/dist/core/runtime/broker.js +5 -4
- package/dist/core/runtime/front-door.js +11 -4
- package/dist/core/runtime/revive.js +2 -1
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/substrate/on-read.js +9 -6
- package/dist/daemon/crtrd.js +44 -2
- package/dist/daemon/manage.d.ts +20 -0
- package/dist/daemon/manage.js +64 -2
- package/package.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
- package/dist/commands/profile/add-project.d.ts +0 -1
- package/dist/commands/profile/add-project.js +0 -42
- package/dist/commands/profile/remove-project.d.ts +0 -1
- package/dist/commands/profile/remove-project.js +0 -42
package/dist/daemon/manage.js
CHANGED
|
@@ -6,11 +6,61 @@
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { mkdirSync, openSync, closeSync } from 'node:fs';
|
|
10
10
|
import { crtrHome } from '../core/canvas/paths.js';
|
|
11
11
|
import { hostExecPath } from '../core/runtime/branded-host.js';
|
|
12
12
|
import { isDaemonRunning, readPidfile, isPidAlive } from './crtrd.js';
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
|
+
// Daemon env sanitization
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/** Env keys that must NEVER reach the daemon process. Restarting crtrd is
|
|
17
|
+
* overwhelmingly done from inside an agent node's own bash tool (`crtr sys
|
|
18
|
+
* daemon stop && start`), which inherits that node's FULL env — its identity
|
|
19
|
+
* (`CRTR_NODE_ID`/`CRTR_KIND`/…, the `nodeEnv()` shape in `core/runtime/
|
|
20
|
+
* nodes.ts`), its front-door recursion-guard flag, and any pi-engine
|
|
21
|
+
* resolution seam a prior test/dev session left exported. The daemon is a
|
|
22
|
+
* singleton supervisor, never "a node" itself, so none of this belongs in its
|
|
23
|
+
* env regardless of whether today's code happens to read it — and at least one
|
|
24
|
+
* of these IS actively read: `host.ts`'s broker-engine resolution falls back to
|
|
25
|
+
* `process.env['CRTR_BROKER_ENGINE']` verbatim (nodeEnv() never sets that key,
|
|
26
|
+
* so nothing overrides it per child launch), so a daemon that inherits a
|
|
27
|
+
* stale/dev override throws inside `headlessBrokerHost.launch()` on EVERY
|
|
28
|
+
* relaunch it ever attempts, for its whole lifetime — the 2026-07-06 diagnosis
|
|
29
|
+
* root cause behind 19 nodes killed with "failed to relaunch and is now dead". */
|
|
30
|
+
export const DAEMON_ENV_STRIP_KEYS = [
|
|
31
|
+
// Node identity (nodeEnv() shape) — meaningless for a process supervising
|
|
32
|
+
// many nodes rather than being one.
|
|
33
|
+
'CRTR_NODE_ID',
|
|
34
|
+
'CRTR_KIND',
|
|
35
|
+
'CRTR_MODE',
|
|
36
|
+
'CRTR_LIFECYCLE',
|
|
37
|
+
'CRTR_NODE_CWD',
|
|
38
|
+
'CRTR_CONTEXT_DIR',
|
|
39
|
+
'CRTR_CYCLES',
|
|
40
|
+
'CRTR_PROFILE_ID',
|
|
41
|
+
'CRTR_PARENT_NODE_ID',
|
|
42
|
+
// Recursion-guard flag — only meaningful inside a pi engine process.
|
|
43
|
+
'CRTR_FRONT_DOOR',
|
|
44
|
+
// Engine-resolution seams NOT overridden per child launch — the proven and
|
|
45
|
+
// suspected poison vectors.
|
|
46
|
+
'CRTR_BROKER_ENGINE',
|
|
47
|
+
'CRTR_PI_BINARY',
|
|
48
|
+
'CRTR_FAULT_RETRY_PROBE',
|
|
49
|
+
// Generic Node.js env poisoning (a stray dev/debug flag from the restarting
|
|
50
|
+
// shell).
|
|
51
|
+
'NODE_OPTIONS',
|
|
52
|
+
];
|
|
53
|
+
/** A copy of `process.env` with every `DAEMON_ENV_STRIP_KEYS` entry removed.
|
|
54
|
+
* Deliberate global config the user actually wants the daemon to see —
|
|
55
|
+
* `CRTR_HOME`, `CRTR_SUBTREE`, `CRTR_LOG`, `CRTR_DEBUG`, etc. — passes through
|
|
56
|
+
* untouched; only the node-identity/engine-poisoning surface is stripped. */
|
|
57
|
+
export function sanitizedDaemonEnv() {
|
|
58
|
+
const env = { ...process.env };
|
|
59
|
+
for (const key of DAEMON_ENV_STRIP_KEYS)
|
|
60
|
+
delete env[key];
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
14
64
|
// Entry point resolution
|
|
15
65
|
// ---------------------------------------------------------------------------
|
|
16
66
|
/** Resolve the absolute path to the crtrd-cli entry point.
|
|
@@ -70,10 +120,22 @@ export async function spawnDaemon() {
|
|
|
70
120
|
// is launchd-owned, the plist's ProgramArguments must point at the branded
|
|
71
121
|
// binary too — this path only covers a manual `crtr sys daemon start`.
|
|
72
122
|
const entry = resolveCrtrdEntry();
|
|
123
|
+
// Route stdout+stderr to an append-mode file instead of discarding them
|
|
124
|
+
// (stdio:'ignore'). Every `[crtrd] …` diagnostic — including the one line
|
|
125
|
+
// that names WHY a relaunch failed (crtrd.ts's per-node supervise catch) —
|
|
126
|
+
// used to go to /dev/null for any manually-started daemon, the common case
|
|
127
|
+
// (only a launchd-owned daemon had a logging plist). Mirrors host.ts's
|
|
128
|
+
// broker.log pattern: one fd, both streams, append so a restart keeps history.
|
|
129
|
+
const errLogPath = join(crtrHome(), 'crtrd.err');
|
|
130
|
+
const errFd = openSync(errLogPath, 'a');
|
|
73
131
|
const child = spawn(hostExecPath(), [entry], {
|
|
74
132
|
detached: true,
|
|
75
|
-
stdio: 'ignore',
|
|
133
|
+
stdio: ['ignore', errFd, errFd],
|
|
134
|
+
env: sanitizedDaemonEnv(),
|
|
76
135
|
});
|
|
136
|
+
// The child holds its own dup of the fd; release the parent's copy so the
|
|
137
|
+
// launching process never leaks it.
|
|
138
|
+
closeSync(errFd);
|
|
77
139
|
const pid = child.pid;
|
|
78
140
|
if (pid === undefined) {
|
|
79
141
|
throw new Error('daemon spawn did not return a pid');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.49",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@crouton-kit/humanloop": "^0.3.
|
|
50
|
+
"@crouton-kit/humanloop": "^0.3.27",
|
|
51
51
|
"@earendil-works/pi-agent-core": "0.80.2",
|
|
52
52
|
"@earendil-works/pi-ai": "0.80.3",
|
|
53
53
|
"@earendil-works/pi-coding-agent": "0.80.2",
|
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nested Context Loader
|
|
3
|
-
*
|
|
4
|
-
* Pi only loads AGENTS.md / CLAUDE.md from the cwd's ancestor chain at startup.
|
|
5
|
-
* Anything *below* the launch dir (subproject CLAUDE.md files, .claude/rules/)
|
|
6
|
-
* is never pulled in. This extension fills that gap:
|
|
7
|
-
*
|
|
8
|
-
* When the `read` tool reads a file, we walk from that file's directory up to
|
|
9
|
-
* the session cwd and surface, appended to the read result:
|
|
10
|
-
* 1. Each directory's CLAUDE.md / AGENTS.md (first match per dir).
|
|
11
|
-
* 2. Each directory's .claude/rules/*.md — unconditionally if the rule has no
|
|
12
|
-
* `paths:` frontmatter, or only when the read file matches one of its
|
|
13
|
-
* `paths:` globs.
|
|
14
|
-
*
|
|
15
|
-
* Guarantees:
|
|
16
|
-
* - No duplicates. A `seen` set tracks every absolute path already loaded,
|
|
17
|
-
* including the files pi loaded at startup (seeded in session_start) and any
|
|
18
|
-
* context/rule file the agent reads directly.
|
|
19
|
-
* - Bounded. Anchored to the file's own parent chain (not the session cwd),
|
|
20
|
-
* stops at $HOME, and skips generated/dependency dirs (node_modules, ...).
|
|
21
|
-
*
|
|
22
|
-
* Install: drop in ~/.pi/agent/extensions/ (global) or .pi/extensions/ (project).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import * as fs from "node:fs";
|
|
27
|
-
import * as os from "node:os";
|
|
28
|
-
import * as path from "node:path";
|
|
29
|
-
|
|
30
|
-
// First-match-per-dir order mirrors pi's own loader (AGENTS.md wins over CLAUDE.md).
|
|
31
|
-
const CONTEXT_FILENAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
32
|
-
|
|
33
|
-
// Directory names we never descend into / load context from.
|
|
34
|
-
const JUNK_DIRS = new Set([
|
|
35
|
-
"node_modules",
|
|
36
|
-
".git",
|
|
37
|
-
".venv",
|
|
38
|
-
"venv",
|
|
39
|
-
"dist",
|
|
40
|
-
"build",
|
|
41
|
-
".next",
|
|
42
|
-
".cache",
|
|
43
|
-
".yalc",
|
|
44
|
-
".sisyphus",
|
|
45
|
-
".crouter",
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
interface RuleFile {
|
|
49
|
-
name: string;
|
|
50
|
-
paths: string[];
|
|
51
|
-
body: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function realpathOrSelf(p: string): string {
|
|
55
|
-
try {
|
|
56
|
-
return fs.realpathSync(p);
|
|
57
|
-
} catch {
|
|
58
|
-
return p;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isJunkPath(absDir: string): boolean {
|
|
63
|
-
return absDir.split(path.sep).some((seg) => JUNK_DIRS.has(seg));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Nearest enclosing git repo root for a path (walk up looking for `.git`).
|
|
67
|
-
function gitRoot(p: string): string | null {
|
|
68
|
-
let d = p;
|
|
69
|
-
const root = path.parse(d).root;
|
|
70
|
-
while (true) {
|
|
71
|
-
if (fs.existsSync(path.join(d, ".git"))) return d;
|
|
72
|
-
if (d === root) return null;
|
|
73
|
-
const parent = path.dirname(d);
|
|
74
|
-
if (parent === d) return null;
|
|
75
|
-
d = parent;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Display paths relative to the nearest git repo root, else absolute.
|
|
80
|
-
function disp(p: string): string {
|
|
81
|
-
const root = gitRoot(p);
|
|
82
|
-
if (!root) return p;
|
|
83
|
-
const rel = path.relative(root, p);
|
|
84
|
-
return rel === "" ? "." : rel;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Escape a value for use inside an XML-ish attribute in the injected block.
|
|
88
|
-
function attr(s: string): string {
|
|
89
|
-
return s
|
|
90
|
-
.replace(/&/g, "&")
|
|
91
|
-
.replace(/"/g, """)
|
|
92
|
-
.replace(/</g, "<")
|
|
93
|
-
.replace(/>/g, ">");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const AUTO_CONTEXT_RE = /<auto-loaded-context(?:\s[^>]*)?>\n([\s\S]*?)\n<\/auto-loaded-context>/;
|
|
97
|
-
|
|
98
|
-
function mergeAutoLoadedContext(items: string[], content: Array<{ type: string; text?: string }>) {
|
|
99
|
-
const existingText = content.map((block) => block.text ?? "").join("\n");
|
|
100
|
-
const fresh = items.map((item) => item.trim()).filter((item) => item !== "" && !existingText.includes(item));
|
|
101
|
-
if (fresh.length === 0) return content;
|
|
102
|
-
const inner = fresh.join("\n");
|
|
103
|
-
|
|
104
|
-
const existingIdx = content.findIndex((block) => typeof block.text === "string" && AUTO_CONTEXT_RE.test(block.text));
|
|
105
|
-
if (existingIdx !== -1) {
|
|
106
|
-
return content.map((block, idx) => {
|
|
107
|
-
if (idx !== existingIdx || typeof block.text !== "string") return block;
|
|
108
|
-
return {
|
|
109
|
-
...block,
|
|
110
|
-
text: block.text.replace(AUTO_CONTEXT_RE, (_whole, current: string) => {
|
|
111
|
-
const trimmed = String(current).trim();
|
|
112
|
-
return `<auto-loaded-context>\n${inner}${trimmed === "" ? "" : `\n${trimmed}`}\n</auto-loaded-context>`;
|
|
113
|
-
}),
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return [{ type: "text" as const, text: `<auto-loaded-context>\n${inner}\n</auto-loaded-context>` }, ...content];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function firstContextFile(dir: string): string | null {
|
|
122
|
-
for (const name of CONTEXT_FILENAMES) {
|
|
123
|
-
const fp = path.join(dir, name);
|
|
124
|
-
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) return fp;
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function findMarkdown(dir: string): string[] {
|
|
130
|
-
const out: string[] = [];
|
|
131
|
-
let entries: fs.Dirent[];
|
|
132
|
-
try {
|
|
133
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
134
|
-
} catch {
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
for (const e of entries) {
|
|
138
|
-
const fp = path.join(dir, e.name);
|
|
139
|
-
if (e.isDirectory()) out.push(...findMarkdown(fp));
|
|
140
|
-
else if (e.isFile() && e.name.endsWith(".md")) out.push(fp);
|
|
141
|
-
}
|
|
142
|
-
return out;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Minimal frontmatter parse: pulls `name` and the `paths:` list. Avoids a YAML dep.
|
|
146
|
-
function parseRule(filePath: string): RuleFile {
|
|
147
|
-
let raw = "";
|
|
148
|
-
try {
|
|
149
|
-
raw = fs.readFileSync(filePath, "utf-8");
|
|
150
|
-
} catch {
|
|
151
|
-
return { name: path.basename(filePath, ".md"), paths: [], body: "" };
|
|
152
|
-
}
|
|
153
|
-
const fm = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
154
|
-
if (!fm) return { name: path.basename(filePath, ".md"), paths: [], body: raw };
|
|
155
|
-
|
|
156
|
-
const [, front, body] = fm;
|
|
157
|
-
const lines = front.split("\n");
|
|
158
|
-
let name = path.basename(filePath, ".md");
|
|
159
|
-
const paths: string[] = [];
|
|
160
|
-
let inPaths = false;
|
|
161
|
-
|
|
162
|
-
const clean = (s: string) => s.trim().replace(/^["']|["']$/g, "");
|
|
163
|
-
for (const line of lines) {
|
|
164
|
-
const nameMatch = line.match(/^name:\s*(.+)$/);
|
|
165
|
-
if (nameMatch) {
|
|
166
|
-
name = clean(nameMatch[1]);
|
|
167
|
-
inPaths = false;
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const pathsInline = line.match(/^paths:\s*(.*)$/);
|
|
171
|
-
if (pathsInline) {
|
|
172
|
-
const rest = pathsInline[1].trim();
|
|
173
|
-
if (rest === "" || rest === "[]") {
|
|
174
|
-
inPaths = true; // YAML list form follows on subsequent lines
|
|
175
|
-
} else {
|
|
176
|
-
// Inline form: a single glob or a comma/space-separated list,
|
|
177
|
-
// optionally wrapped in [ ... ].
|
|
178
|
-
inPaths = false;
|
|
179
|
-
for (const part of rest.replace(/^\[|\]$/g, "").split(/[,]/)) {
|
|
180
|
-
const g = clean(part);
|
|
181
|
-
if (g) paths.push(g);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
if (inPaths) {
|
|
187
|
-
const item = line.match(/^\s*-\s*(.+)$/);
|
|
188
|
-
if (item) {
|
|
189
|
-
paths.push(clean(item[1]));
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (/^\S/.test(line)) inPaths = false; // dedented to next key
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return { name, paths, body: body.trim() };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function ruleMatches(absFile: string, baseDir: string, globs: string[]): boolean {
|
|
199
|
-
if (globs.length === 0) return true; // unconditional rule
|
|
200
|
-
const rel = path.relative(baseDir, absFile);
|
|
201
|
-
return globs.some((g) => {
|
|
202
|
-
try {
|
|
203
|
-
return path.matchesGlob(rel, g) || path.matchesGlob(absFile, g);
|
|
204
|
-
} catch {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export default function nestedContext(pi: ExtensionAPI) {
|
|
211
|
-
const seen = new Set<string>();
|
|
212
|
-
|
|
213
|
-
// Seed `seen` with everything pi already loaded at startup: the global
|
|
214
|
-
// agent file plus every CLAUDE.md/AGENTS.md on the cwd ancestor chain.
|
|
215
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
216
|
-
const globalDir = path.join(os.homedir(), ".pi", "agent");
|
|
217
|
-
const globalCtx = firstContextFile(globalDir);
|
|
218
|
-
if (globalCtx) seen.add(realpathOrSelf(globalCtx));
|
|
219
|
-
|
|
220
|
-
let dir = path.resolve(ctx.cwd);
|
|
221
|
-
const root = path.parse(dir).root;
|
|
222
|
-
while (true) {
|
|
223
|
-
const cf = firstContextFile(dir);
|
|
224
|
-
if (cf) seen.add(realpathOrSelf(cf));
|
|
225
|
-
if (dir === root) break;
|
|
226
|
-
const parent = path.dirname(dir);
|
|
227
|
-
if (parent === dir) break;
|
|
228
|
-
dir = parent;
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
233
|
-
if (event.toolName !== "read") return;
|
|
234
|
-
if (event.isError) return;
|
|
235
|
-
|
|
236
|
-
const rawPath = (event.input as { path?: string; file_path?: string })?.path
|
|
237
|
-
?? (event.input as { file_path?: string })?.file_path;
|
|
238
|
-
if (!rawPath) return;
|
|
239
|
-
|
|
240
|
-
const cwd = path.resolve(ctx.cwd);
|
|
241
|
-
// The model often passes a leading ~ ; pi's read tool expands it but
|
|
242
|
-
// event.input keeps the raw form, so expand it ourselves before resolving.
|
|
243
|
-
const expanded = rawPath.startsWith("~")
|
|
244
|
-
? path.join(os.homedir(), rawPath.slice(1))
|
|
245
|
-
: rawPath;
|
|
246
|
-
const absFile = realpathOrSelf(path.resolve(cwd, expanded));
|
|
247
|
-
|
|
248
|
-
// Anchor to the FILE's own location, not the session cwd: walk its parent
|
|
249
|
-
// chain up to the home directory (or filesystem root if outside home).
|
|
250
|
-
const home = os.homedir();
|
|
251
|
-
const fsRoot = path.parse(absFile).root;
|
|
252
|
-
|
|
253
|
-
// If the agent read a context/rule file directly, mark it loaded so we
|
|
254
|
-
// never re-inject it later, and don't append it to its own read.
|
|
255
|
-
seen.add(absFile);
|
|
256
|
-
|
|
257
|
-
type Block = { order: number; text: string };
|
|
258
|
-
const blocks: Block[] = [];
|
|
259
|
-
|
|
260
|
-
let dir = path.dirname(absFile);
|
|
261
|
-
let depth = 0;
|
|
262
|
-
while (true) {
|
|
263
|
-
if (!isJunkPath(dir)) {
|
|
264
|
-
// 1) Directory context file.
|
|
265
|
-
const cf = firstContextFile(dir);
|
|
266
|
-
if (cf) {
|
|
267
|
-
const real = realpathOrSelf(cf);
|
|
268
|
-
if (!seen.has(real)) {
|
|
269
|
-
seen.add(real);
|
|
270
|
-
try {
|
|
271
|
-
const content = fs.readFileSync(cf, "utf-8").trim();
|
|
272
|
-
blocks.push({
|
|
273
|
-
order: depth,
|
|
274
|
-
text:
|
|
275
|
-
`<project-guidance src="${attr(disp(cf))}" scope="${attr(`${disp(dir)}/`)}">\n` +
|
|
276
|
-
`${content}\n` +
|
|
277
|
-
`</project-guidance>`,
|
|
278
|
-
});
|
|
279
|
-
} catch {
|
|
280
|
-
/* unreadable; skip */
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// 2) .claude/rules for this directory.
|
|
286
|
-
const rulesDir = path.join(dir, ".claude", "rules");
|
|
287
|
-
for (const rf of findMarkdown(rulesDir)) {
|
|
288
|
-
// Skip stray context files living inside a rules dir; they are not rules.
|
|
289
|
-
if (CONTEXT_FILENAMES.includes(path.basename(rf))) continue;
|
|
290
|
-
const real = realpathOrSelf(rf);
|
|
291
|
-
if (seen.has(real)) continue;
|
|
292
|
-
const rule = parseRule(rf);
|
|
293
|
-
if (!ruleMatches(absFile, dir, rule.paths)) continue; // may match on a later read
|
|
294
|
-
seen.add(real);
|
|
295
|
-
blocks.push({
|
|
296
|
-
order: depth,
|
|
297
|
-
text:
|
|
298
|
-
`<rule name="${attr(rule.name)}" src="${attr(disp(rf))}">\n` +
|
|
299
|
-
`${rule.body}\n` +
|
|
300
|
-
`</rule>`,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (dir === home || dir === fsRoot) break;
|
|
306
|
-
const parent = path.dirname(dir);
|
|
307
|
-
if (parent === dir) break;
|
|
308
|
-
dir = parent;
|
|
309
|
-
depth += 1;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (blocks.length === 0) return;
|
|
313
|
-
|
|
314
|
-
// Outermost-first, so the most specific (nearest) guidance reads last/closest
|
|
315
|
-
// to the file content that follows it.
|
|
316
|
-
blocks.sort((a, b) => b.order - a.order);
|
|
317
|
-
const merged = mergeAutoLoadedContext(blocks.map((b) => b.text), event.content);
|
|
318
|
-
if (merged === event.content) return;
|
|
319
|
-
|
|
320
|
-
if (ctx.hasUI) {
|
|
321
|
-
ctx.ui.notify(`Loaded ${blocks.length} nested context file(s)`, "info");
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Prepend or merge, so the governing guidance appears before the file output.
|
|
325
|
-
return { content: merged };
|
|
326
|
-
});
|
|
327
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const addProjectLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { addProfileProject, loadProfileManifest } from '../../core/profiles/manifest.js';
|
|
3
|
-
export const addProjectLeaf = defineLeaf({
|
|
4
|
-
name: 'add-project',
|
|
5
|
-
description: "add a project directory to a profile's purview",
|
|
6
|
-
whenToUse: "a profile needs to see (memory + config from) another project directory — extend its purview after creation, including mid-session from a node already running under that profile.",
|
|
7
|
-
help: {
|
|
8
|
-
name: 'profile add-project',
|
|
9
|
-
summary: "append a project directory to a profile's manifest, deduped by real path",
|
|
10
|
-
params: [
|
|
11
|
-
{
|
|
12
|
-
kind: 'positional',
|
|
13
|
-
name: 'profile',
|
|
14
|
-
required: true,
|
|
15
|
-
constraint: 'Exact profile id, or a unique manifest name.',
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
kind: 'flag',
|
|
19
|
-
name: 'dir',
|
|
20
|
-
type: 'path',
|
|
21
|
-
required: true,
|
|
22
|
-
constraint: 'Directory to add. Must exist and be a directory; resolved to its absolute real path before storing. A path already on the manifest is a no-op, not an error.',
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
|
-
output: [
|
|
26
|
-
{ name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
|
|
27
|
-
{ name: 'projects', type: 'string[]', required: true, constraint: 'The updated, deduped project list in manifest order.' },
|
|
28
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
|
|
29
|
-
],
|
|
30
|
-
outputKind: 'object',
|
|
31
|
-
effects: ["Appends to the profile manifest's `projects` array. Invalidates the process scope cache."],
|
|
32
|
-
},
|
|
33
|
-
run: async (input) => {
|
|
34
|
-
const { profileId } = loadProfileManifest(input['profile']);
|
|
35
|
-
const { manifest } = addProfileProject(profileId, input['dir']);
|
|
36
|
-
return {
|
|
37
|
-
profile_id: profileId,
|
|
38
|
-
projects: manifest.projects,
|
|
39
|
-
follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
|
|
40
|
-
};
|
|
41
|
-
},
|
|
42
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const removeProjectLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { defineLeaf } from '../../core/command.js';
|
|
2
|
-
import { loadProfileManifest, removeProfileProject } from '../../core/profiles/manifest.js';
|
|
3
|
-
export const removeProjectLeaf = defineLeaf({
|
|
4
|
-
name: 'remove-project',
|
|
5
|
-
description: "remove a project directory from a profile's purview",
|
|
6
|
-
whenToUse: "a profile no longer needs purview over one of its listed project directories — narrow it back. Works even if the directory itself was since deleted.",
|
|
7
|
-
help: {
|
|
8
|
-
name: 'profile remove-project',
|
|
9
|
-
summary: "remove a project directory from a profile's manifest",
|
|
10
|
-
params: [
|
|
11
|
-
{
|
|
12
|
-
kind: 'positional',
|
|
13
|
-
name: 'profile',
|
|
14
|
-
required: true,
|
|
15
|
-
constraint: 'Exact profile id, or a unique manifest name.',
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
kind: 'flag',
|
|
19
|
-
name: 'dir',
|
|
20
|
-
type: 'path',
|
|
21
|
-
required: true,
|
|
22
|
-
constraint: 'Directory to remove, matched by its resolved real path against the manifest. Does not require the directory to still exist on disk.',
|
|
23
|
-
},
|
|
24
|
-
],
|
|
25
|
-
output: [
|
|
26
|
-
{ name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
|
|
27
|
-
{ name: 'projects', type: 'string[]', required: true, constraint: 'The updated project list in manifest order.' },
|
|
28
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
|
|
29
|
-
],
|
|
30
|
-
outputKind: 'object',
|
|
31
|
-
effects: ["Removes an entry from the profile manifest's `projects` array. Invalidates the process scope cache."],
|
|
32
|
-
},
|
|
33
|
-
run: async (input) => {
|
|
34
|
-
const { profileId } = loadProfileManifest(input['profile']);
|
|
35
|
-
const { manifest } = removeProfileProject(profileId, input['dir']);
|
|
36
|
-
return {
|
|
37
|
-
profile_id: profileId,
|
|
38
|
-
projects: manifest.projects,
|
|
39
|
-
follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
|
|
40
|
-
};
|
|
41
|
-
},
|
|
42
|
-
});
|