@stackstackstack/dsh-agent-instructions 0.1.5

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/lib/index.js ADDED
@@ -0,0 +1,1343 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { assertNever, createUserMessage } from "@stackstackstack/dsh-llm";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { dshHomeDisplay, resolveDshHome } from "@stackstackstack/dsh-home-paths";
6
+ import { createReadStream } from "node:fs";
7
+ import { stat } from "node:fs/promises";
8
+ import { createHash } from "node:crypto";
9
+ //#region lib/types/config.js
10
+ /**
11
+ * Configuration normalization for workspace instruction discovery and rendering.
12
+ *
13
+ * @module @stackstackstack/dsh-agent-instructions/config
14
+ */
15
+ const DEFAULT_PROJECT_ROOT_MARKERS = [".git"];
16
+ const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
17
+ const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ["AGENTS.local.md", "CLAUDE.local.md"];
18
+ const DEFAULT_MAX_SOURCE_BYTES = 1048576;
19
+ const DEFAULT_MAX_TOTAL_SOURCE_BYTES = 4 * DEFAULT_MAX_SOURCE_BYTES;
20
+ const RESERVED_PATH_SEGMENTS = new Set([
21
+ "",
22
+ ".",
23
+ ".."
24
+ ]);
25
+ const Config = z.object({
26
+ dshHome: z.string(),
27
+ projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
28
+ maxBytes: z.number().required(),
29
+ maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
30
+ maxTotalSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_TOTAL_SOURCE_BYTES),
31
+ instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
32
+ localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES])
33
+ });
34
+ /**
35
+ * Identify the discovery, precedence, and budget semantics of one baseline.
36
+ * @param config - normalized plugin configuration.
37
+ * @param cwd - absolute session working directory.
38
+ * @param projectRoot - project root selected for the current baseline.
39
+ * @returns stable serialized identity for compatibility checks on resume.
40
+ */
41
+ function workspaceBaselineIdentity(config, cwd, projectRoot) {
42
+ return JSON.stringify({
43
+ projectRoot: relative(cwd, projectRoot),
44
+ projectRootMarkers: config.projectRootMarkers,
45
+ maxBytes: config.maxBytes,
46
+ maxSourceBytes: config.maxSourceBytes,
47
+ maxTotalSourceBytes: config.maxTotalSourceBytes,
48
+ instructionFileCandidates: config.instructionFileCandidates,
49
+ localInstructionFileCandidates: config.localInstructionFileCandidates
50
+ });
51
+ }
52
+ /**
53
+ * Resolve defaults, the harness home, and valid same-directory candidates.
54
+ * @param config - user-facing plugin configuration.
55
+ * @returns normalized runtime configuration.
56
+ */
57
+ function resolveConfig(config) {
58
+ return {
59
+ ...resolveDiscoveryConfig(config),
60
+ maxBytes: config.maxBytes,
61
+ maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
62
+ maxTotalSourceBytes: config.maxTotalSourceBytes ?? DEFAULT_MAX_TOTAL_SOURCE_BYTES
63
+ };
64
+ }
65
+ /**
66
+ * Resolve the subset of configuration used before instruction content is rendered.
67
+ * @param config - optional discovery controls.
68
+ * @returns normalized home, root markers, and instruction candidates.
69
+ */
70
+ function resolveDiscoveryConfig(config) {
71
+ return {
72
+ dshHome: resolveDshHome(config.dshHome),
73
+ projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
74
+ instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates, DEFAULT_INSTRUCTION_FILE_CANDIDATES),
75
+ localInstructionFileCandidates: resolveInstructionFileCandidates(config.localInstructionFileCandidates, DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES)
76
+ };
77
+ }
78
+ function resolveInstructionFileCandidates(candidates, fallback) {
79
+ return (candidates ?? [...fallback]).filter((candidate) => !RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate));
80
+ }
81
+ //#endregion
82
+ //#region lib/types/digest.js
83
+ /**
84
+ * Content identity for workspace instruction duplicate suppression.
85
+ *
86
+ * @module @stackstackstack/dsh-agent-instructions/digest
87
+ */
88
+ /**
89
+ * Compute the content identity used across instruction loading and session state.
90
+ * @param content - exact UTF-8 instruction text.
91
+ * @returns lowercase SHA-1 digest in hexadecimal form.
92
+ */
93
+ function instructionContentSha1(content) {
94
+ return createHash("sha1").update(content).digest("hex");
95
+ }
96
+ /**
97
+ * Compute the whitespace-insensitive identity used for per-directory duplicate
98
+ * suppression. Leading and trailing whitespace is trimmed before hashing so a
99
+ * symlinked or byte-copied sibling that differs only by surrounding whitespace
100
+ * still collapses to a single rendered file.
101
+ * @param content - exact UTF-8 instruction text.
102
+ * @returns SHA-1 digest of the trimmed content.
103
+ */
104
+ function trimmedInstructionDigest(content) {
105
+ return instructionContentSha1(content.trim());
106
+ }
107
+ //#endregion
108
+ //#region lib/types/render.js
109
+ /**
110
+ * Model-facing workspace instruction rendering within an explicit byte budget.
111
+ *
112
+ * @module @stackstackstack/dsh-agent-instructions/render
113
+ */
114
+ const SYSTEM_REMINDER_OPEN = "<system-reminder>";
115
+ const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
116
+ const WORKSPACE_CONTEXT_INTRO = "The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.";
117
+ const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = "This complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.";
118
+ const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = "This complete workspace instruction baseline replaces all earlier workspace instruction baselines. No workspace instructions are currently active.";
119
+ const COMPACT_WORKSPACE_CONTEXT_INTRO = "Workspace instructions were omitted or truncated to fit the configured byte budget.";
120
+ function byteLength(value) {
121
+ return Buffer.byteLength(value, "utf8");
122
+ }
123
+ function truncateUtf8(value, maxBytes) {
124
+ const bytes = Buffer.from(value, "utf8");
125
+ if (bytes.length <= maxBytes) return value;
126
+ let end = Math.max(0, Math.trunc(maxBytes));
127
+ while (end > 0 && (bytes.readUInt8(end) & 192) === 128) end -= 1;
128
+ return bytes.subarray(0, end).toString("utf8");
129
+ }
130
+ function escapeInstructionFrameBody(body) {
131
+ return body.replaceAll(SYSTEM_REMINDER_CLOSE, "<\\/system-reminder>");
132
+ }
133
+ function sectionText(file) {
134
+ return `Instructions from: ${file.displayPath}\n\n${file.content}`;
135
+ }
136
+ /** Directory component that identifies the single user-global instruction scope. */
137
+ const USER_GLOBAL_DIRECTORY = "user-global";
138
+ /**
139
+ * File name of the single user-global instruction file under `$DSH_HOME`.
140
+ * Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
141
+ * candidate component) both key on this name, so it lives in one place: were the
142
+ * two to disagree, the user-global instruction would load but never reconcile.
143
+ */
144
+ const USER_GLOBAL_FILE = "AGENTS.md";
145
+ /**
146
+ * Derive the logical instruction scope from a model-facing path.
147
+ * @param displayPath - project-relative or user-global instruction path.
148
+ * @returns `user-global`, `.`, or the containing project-relative directory.
149
+ */
150
+ function scopeForDisplayPath(displayPath) {
151
+ if (displayPath === "~/.dsh/AGENTS.md" || displayPath === "$DSH_HOME/AGENTS.md") return USER_GLOBAL_DIRECTORY;
152
+ return dirname(displayPath);
153
+ }
154
+ const SCOPE_SEPARATOR = "\0";
155
+ /**
156
+ * Compose the reconciliation key for one instruction candidate file.
157
+ * Each loaded candidate is tracked independently, so the key pairs the logical
158
+ * directory with the exact candidate file name behind a NUL separator that no
159
+ * directory path or file name can contain. Distinct candidates in one directory
160
+ * (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
161
+ * never collide in the scope-keyed state maps.
162
+ * @param directory - `user-global`, `.`, or a project-relative directory.
163
+ * @param candidateName - instruction file name within that directory.
164
+ * @returns the per-candidate logical scope key.
165
+ */
166
+ function candidateScopeKey(directory, candidateName) {
167
+ return `${directory}${SCOPE_SEPARATOR}${candidateName}`;
168
+ }
169
+ /**
170
+ * Derive the per-candidate scope key for a loaded instruction file.
171
+ * @param displayPath - project-relative or user-global instruction path.
172
+ * @returns the scope key pairing the file's directory with its name.
173
+ */
174
+ function instructionScopeKey(displayPath) {
175
+ return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath));
176
+ }
177
+ /**
178
+ * Recover the directory and candidate name that {@link candidateScopeKey} encoded.
179
+ * @param scope - a per-candidate scope key.
180
+ * @returns the directory scope and the candidate file name within it.
181
+ */
182
+ function decodeScopeKey(scope) {
183
+ const separator = scope.indexOf(SCOPE_SEPARATOR);
184
+ /* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
185
+ if (separator < 0) return {
186
+ directory: scope,
187
+ candidateName: ""
188
+ };
189
+ return {
190
+ directory: scope.slice(0, separator),
191
+ candidateName: scope.slice(separator + 1)
192
+ };
193
+ }
194
+ function additionalSectionText(file) {
195
+ const scope = scopeForDisplayPath(file.displayPath);
196
+ return [
197
+ `Additional instructions from: ${file.displayPath}`,
198
+ "",
199
+ `These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`,
200
+ "",
201
+ file.content
202
+ ].join("\n");
203
+ }
204
+ const BASELINE_RENDER_STYLE = {
205
+ intro: WORKSPACE_CONTEXT_INTRO,
206
+ section: sectionText
207
+ };
208
+ function baselineRenderStyle(files, replacePreviousBaseline) {
209
+ if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE;
210
+ return {
211
+ ...BASELINE_RENDER_STYLE,
212
+ intro: files.length === 0 ? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO : REPLACEMENT_WORKSPACE_CONTEXT_INTRO
213
+ };
214
+ }
215
+ function changedSectionText(item) {
216
+ const { change, file } = item;
217
+ if (change.action === "set") return additionalSectionText(file);
218
+ if (change.action === "remove") return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`;
219
+ return [
220
+ `Updated instructions from: ${change.path}`,
221
+ "",
222
+ "This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.",
223
+ "",
224
+ file.content
225
+ ].join("\n");
226
+ }
227
+ /**
228
+ * Render one reconciliation batch and retain only transitions that fit.
229
+ * @param items - ordered state transitions and current file contents.
230
+ * @param maxBytes - maximum UTF-8 bytes allowed in the rendered batch.
231
+ * @returns bounded prompt text and the transitions actually represented by it.
232
+ */
233
+ function renderInstructionChanges(items, maxBytes) {
234
+ const byAbsolutePath = new Map(items.map((item) => [item.file.absolutePath, item]));
235
+ const rendered = renderInstructionContext(items.map((item) => item.file), maxBytes, {
236
+ intro: "",
237
+ section(file) {
238
+ const item = byAbsolutePath.get(file.absolutePath);
239
+ /* v8 ignore next -- the renderer receives exactly the files used to construct this map. */
240
+ return item === void 0 ? "" : changedSectionText({
241
+ ...item,
242
+ file
243
+ });
244
+ }
245
+ });
246
+ const represented = new Set(rendered.represented.map((file) => file.absolutePath));
247
+ return {
248
+ text: rendered.text,
249
+ changes: items.filter((item) => represented.has(item.file.absolutePath)).map((item) => item.change)
250
+ };
251
+ }
252
+ function markerText(maxBytes, omitted, truncated) {
253
+ if (omitted.length === 0 && truncated.length === 0) return "";
254
+ const parts = [];
255
+ if (omitted.length > 0) parts.push(`omitted ${omitted.map((file) => file.displayPath).join(", ")}`);
256
+ if (truncated.length > 0) parts.push(`truncated ${truncated.map((item) => `${item.displayPath} from ${item.originalBytes} to ${item.includedBytes} bytes`).join(", ")}`);
257
+ return `Workspace instruction budget ${maxBytes} bytes: ${parts.join("; ")}`;
258
+ }
259
+ function buildInstructionText(files, maxBytes, omitted, truncated, style) {
260
+ return [
261
+ SYSTEM_REMINDER_OPEN,
262
+ escapeInstructionFrameBody([
263
+ markerText(maxBytes, omitted, truncated),
264
+ style.intro,
265
+ ...files.map((file) => style.section(file))
266
+ ].filter((block) => block.length > 0).join("\n\n")),
267
+ SYSTEM_REMINDER_CLOSE
268
+ ].join("\n");
269
+ }
270
+ function withTruncatedContent(file, includedBytes) {
271
+ return {
272
+ ...file,
273
+ content: truncateUtf8(file.content, includedBytes)
274
+ };
275
+ }
276
+ function truncateToFit(file, includedFiles, maxBytes, omitted, style) {
277
+ const originalBytes = byteLength(file.content);
278
+ let low = 0;
279
+ let high = originalBytes;
280
+ let best = withTruncatedContent(file, 0);
281
+ while (low <= high) {
282
+ const mid = Math.floor((low + high) / 2);
283
+ const candidate = withTruncatedContent(file, mid);
284
+ const truncated = [{
285
+ displayPath: file.displayPath,
286
+ originalBytes,
287
+ includedBytes: byteLength(candidate.content)
288
+ }];
289
+ if (byteLength(buildInstructionText([...includedFiles, candidate], maxBytes, omitted, truncated, style)) <= maxBytes) {
290
+ best = candidate;
291
+ low = mid + 1;
292
+ } else high = mid - 1;
293
+ }
294
+ return best;
295
+ }
296
+ function renderInstructionContext(files, maxBytes, style) {
297
+ if (maxBytes <= 0 || !Number.isFinite(maxBytes)) return {
298
+ text: "",
299
+ omitted: files,
300
+ truncated: [],
301
+ represented: []
302
+ };
303
+ const fullText = buildInstructionText(files, maxBytes, [], [], style);
304
+ if (byteLength(fullText) <= maxBytes) return {
305
+ text: fullText,
306
+ omitted: [],
307
+ truncated: [],
308
+ represented: files
309
+ };
310
+ for (let start = 1; start < files.length; start += 1) {
311
+ const included = files.slice(start);
312
+ const omitted = files.slice(0, start).map((file) => ({
313
+ absolutePath: file.absolutePath,
314
+ displayPath: file.displayPath
315
+ }));
316
+ const suffixText = buildInstructionText(included, maxBytes, omitted, [], style);
317
+ if (byteLength(suffixText) <= maxBytes) return {
318
+ text: suffixText,
319
+ omitted,
320
+ truncated: [],
321
+ represented: included
322
+ };
323
+ }
324
+ const mostSpecific = files.at(-1);
325
+ /* v8 ignore next -- callers only reach this after a non-empty fullText was built. */
326
+ if (mostSpecific === void 0) return {
327
+ text: "",
328
+ omitted: [],
329
+ truncated: [],
330
+ represented: []
331
+ };
332
+ const omitted = files.slice(0, -1).map((file) => ({
333
+ absolutePath: file.absolutePath,
334
+ displayPath: file.displayPath
335
+ }));
336
+ const originalBytes = byteLength(mostSpecific.content);
337
+ for (const candidateStyle of [style, {
338
+ ...style,
339
+ intro: COMPACT_WORKSPACE_CONTEXT_INTRO
340
+ }]) {
341
+ const truncatedFile = truncateToFit(mostSpecific, [], maxBytes, omitted, candidateStyle);
342
+ const includedBytes = byteLength(truncatedFile.content);
343
+ const truncated = [{
344
+ displayPath: mostSpecific.displayPath,
345
+ originalBytes,
346
+ includedBytes
347
+ }];
348
+ const text = buildInstructionText([truncatedFile], maxBytes, omitted, truncated, candidateStyle);
349
+ if (byteLength(text) <= maxBytes) return {
350
+ text,
351
+ omitted,
352
+ truncated,
353
+ represented: includedBytes > 0 || originalBytes === 0 ? [mostSpecific] : []
354
+ };
355
+ }
356
+ const truncated = [{
357
+ displayPath: mostSpecific.displayPath,
358
+ originalBytes,
359
+ includedBytes: 0
360
+ }];
361
+ const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated));
362
+ const compactWithHeading = escapeInstructionFrameBody([compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join("\n\n"));
363
+ if (byteLength(compactWithHeading) <= maxBytes) return {
364
+ text: compactWithHeading,
365
+ omitted,
366
+ truncated,
367
+ represented: originalBytes === 0 ? [mostSpecific] : []
368
+ };
369
+ return {
370
+ text: byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes),
371
+ omitted,
372
+ truncated,
373
+ represented: []
374
+ };
375
+ }
376
+ /**
377
+ * Render a baseline together with the exact source files semantically represented in it.
378
+ * @param files - loaded files ordered from broadest to most specific.
379
+ * @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
380
+ * @returns bounded public rendering plus files with surviving content, including genuinely empty files.
381
+ * @internal
382
+ */
383
+ function renderWorkspaceInstructionSet(files, options) {
384
+ const style = baselineRenderStyle(files, options.replacePreviousBaseline);
385
+ const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style);
386
+ return {
387
+ rendered,
388
+ included: represented
389
+ };
390
+ }
391
+ /**
392
+ * Render the baseline instruction chain with deterministic precedence budgeting.
393
+ * @param files - loaded files ordered from broadest to most specific.
394
+ * @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
395
+ * @returns bounded baseline prompt text and budget diagnostics.
396
+ */
397
+ function renderWorkspaceContext(files, options) {
398
+ return renderWorkspaceInstructionSet(files, options).rendered;
399
+ }
400
+ //#endregion
401
+ //#region lib/types/files.js
402
+ /**
403
+ * Instruction-file discovery and bounded, abort-aware provider reads.
404
+ *
405
+ * @module @stackstackstack/dsh-agent-instructions/files
406
+ */
407
+ function signalOptions(signal) {
408
+ return signal === void 0 ? void 0 : { signal };
409
+ }
410
+ function isMissingPathError(error) {
411
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
412
+ }
413
+ async function nodeStatFile(path, signal) {
414
+ try {
415
+ signal?.throwIfAborted();
416
+ const info = await stat(path);
417
+ signal?.throwIfAborted();
418
+ if (!info.isFile()) return { kind: "absent" };
419
+ return {
420
+ kind: "present",
421
+ info: { size: info.size }
422
+ };
423
+ } catch (error) {
424
+ signal?.throwIfAborted();
425
+ return isMissingPathError(error) ? { kind: "absent" } : { kind: "unavailable" };
426
+ }
427
+ }
428
+ async function fsStatFile(path, fileSystem, signal) {
429
+ try {
430
+ const target = await fileSystem.resolve(path, signalOptions(signal));
431
+ signal?.throwIfAborted();
432
+ const info = await fileSystem.stat(target, signal);
433
+ signal?.throwIfAborted();
434
+ if (info?.type !== "file") return { kind: "absent" };
435
+ return {
436
+ kind: "present",
437
+ info: {
438
+ target,
439
+ version: info.version,
440
+ ...info.size === void 0 ? {} : { size: info.size }
441
+ }
442
+ };
443
+ } catch {
444
+ signal?.throwIfAborted();
445
+ return { kind: "unavailable" };
446
+ }
447
+ }
448
+ async function statFile(path, fileSystem, signal) {
449
+ return fileSystem === void 0 ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal);
450
+ }
451
+ async function existsAsMarker(path, fileSystem, signal) {
452
+ if (fileSystem !== void 0) try {
453
+ const target = await fileSystem.resolve(path, signalOptions(signal));
454
+ return await fileSystem.stat(target, signal) !== void 0;
455
+ } catch {
456
+ signal?.throwIfAborted();
457
+ return false;
458
+ }
459
+ try {
460
+ signal?.throwIfAborted();
461
+ await stat(path);
462
+ signal?.throwIfAborted();
463
+ return true;
464
+ } catch {
465
+ signal?.throwIfAborted();
466
+ return false;
467
+ }
468
+ }
469
+ /**
470
+ * Walk upward to the first directory containing a configured root marker.
471
+ * @param cwd - absolute session working directory where the walk begins.
472
+ * @param markers - child names that identify a project root.
473
+ * @param fileSystem - optional provider used instead of host filesystem probes.
474
+ * @param signal - cancellation for provider and host probes.
475
+ * @returns the discovered project root, or `cwd` when no marker exists.
476
+ */
477
+ async function findProjectRoot(cwd, markers, fileSystem, signal) {
478
+ let current = resolve(cwd);
479
+ for (;;) {
480
+ for (const marker of markers) if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current;
481
+ const parent = dirname(current);
482
+ if (parent === current) return resolve(cwd);
483
+ current = parent;
484
+ }
485
+ }
486
+ /**
487
+ * Build the inclusive root-to-cwd directory chain.
488
+ * @param root - root directory expected to contain or equal `cwd`.
489
+ * @param cwd - most-specific directory in the chain.
490
+ * @returns directories ordered from broadest to most specific.
491
+ */
492
+ function ancestorChain(root, cwd) {
493
+ const chain = [];
494
+ let current = resolve(cwd);
495
+ const resolvedRoot = resolve(root);
496
+ while (current !== resolvedRoot) {
497
+ chain.push(current);
498
+ const parent = dirname(current);
499
+ /* v8 ignore next -- discovery always supplies cwd or an ancestor root. */
500
+ if (parent === current) break;
501
+ current = parent;
502
+ }
503
+ chain.push(resolvedRoot);
504
+ return chain.reverse();
505
+ }
506
+ /**
507
+ * Find descendant directories crossed between a cwd and a touched file.
508
+ * @param root - session cwd that bounds nested discovery.
509
+ * @param touchedPath - absolute path or path relative to `root`.
510
+ * @returns descendant directories from shallowest through the touched file's parent.
511
+ */
512
+ function descendantDirsBetween(root, touchedPath) {
513
+ const resolvedRoot = resolve(root);
514
+ const targetDir = dirname(isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath));
515
+ const rel = relative(resolvedRoot, targetDir);
516
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return [];
517
+ return ancestorChain(resolvedRoot, targetDir).slice(1);
518
+ }
519
+ /**
520
+ * Convert an absolute instruction path to its project-root-relative display form.
521
+ * @param root - project root used as the display base.
522
+ * @param path - absolute path to display.
523
+ * @returns the root-relative path.
524
+ */
525
+ function relativeDisplay(root, path) {
526
+ return relative(root, path);
527
+ }
528
+ async function allExistingInstructionFiles(dir, root, instructionFileCandidates, fileSystem, signal) {
529
+ const found = [];
530
+ for (const candidate of instructionFileCandidates) {
531
+ const path = join(dir, candidate);
532
+ const probe = await statFile(path, fileSystem, signal);
533
+ switch (probe.kind) {
534
+ case "present":
535
+ found.push({
536
+ absolutePath: path,
537
+ displayPath: relativeDisplay(root, path),
538
+ ...probe.info
539
+ });
540
+ continue;
541
+ case "absent":
542
+ case "unavailable": continue;
543
+ /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
544
+ default: assertNever(probe, "StatFileProbe");
545
+ }
546
+ }
547
+ return found;
548
+ }
549
+ async function discoverInstructionFiles(options, fileSystem) {
550
+ const config = resolveDiscoveryConfig(options);
551
+ const files = [];
552
+ const seen = /* @__PURE__ */ new Set();
553
+ const addFile = (file) => {
554
+ if (seen.has(file.absolutePath)) return;
555
+ seen.add(file.absolutePath);
556
+ files.push(file);
557
+ };
558
+ const userGlobal = join(config.dshHome, USER_GLOBAL_FILE);
559
+ const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal);
560
+ switch (userGlobalProbe.kind) {
561
+ case "present":
562
+ addFile({
563
+ absolutePath: userGlobal,
564
+ displayPath: userGlobalDisplayPath(config.dshHome),
565
+ ...userGlobalProbe.info
566
+ });
567
+ break;
568
+ case "absent":
569
+ case "unavailable": break;
570
+ /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
571
+ default: assertNever(userGlobalProbe, "StatFileProbe");
572
+ }
573
+ const cwd = resolve(options.cwd);
574
+ const projectRoot = options.projectRoot ?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal);
575
+ for (const dir of ancestorChain(projectRoot, cwd)) for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) addFile(file);
576
+ return files;
577
+ }
578
+ /**
579
+ * Discover host-visible user-global and root-to-cwd instruction candidates.
580
+ * All present candidates in each directory are returned; trimmed-content
581
+ * duplicates are collapsed later, once content is read.
582
+ * @param options - cwd, home, root marker, and candidate configuration.
583
+ * @returns path-deduplicated instruction candidates in model precedence order.
584
+ */
585
+ async function discoverBaselineInstructionFiles(options) {
586
+ return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({
587
+ absolutePath,
588
+ displayPath
589
+ }));
590
+ }
591
+ async function readNodeBytesBounded(path, maxBytes, signal) {
592
+ const stream = createReadStream(path, {
593
+ end: maxBytes,
594
+ highWaterMark: Math.min(64 * 1024, maxBytes + 1),
595
+ signal
596
+ });
597
+ const parts = [];
598
+ let bytes = 0;
599
+ for await (const chunk of stream) {
600
+ const remaining = maxBytes - bytes;
601
+ if (chunk.length > remaining) return void 0;
602
+ parts.push(chunk);
603
+ bytes += chunk.length;
604
+ }
605
+ return Buffer.concat(parts, bytes);
606
+ }
607
+ async function readBounded(file, maxSourceBytes, sourceBudget, fileSystem, signal) {
608
+ signal?.throwIfAborted();
609
+ if (file.size !== void 0 && file.size > maxSourceBytes) return void 0;
610
+ if (file.size !== void 0 && sourceBudget.usedBytes + file.size > sourceBudget.maxBytes) return void 0;
611
+ try {
612
+ const remainingBytes = sourceBudget.maxBytes - sourceBudget.usedBytes;
613
+ if (remainingBytes <= 0) return void 0;
614
+ if (fileSystem !== void 0 && file.target !== void 0) {
615
+ const readLimit = Math.min(maxSourceBytes, remainingBytes);
616
+ const bytes = await fileSystem.readBytes(file.target, signal, readLimit);
617
+ signal?.throwIfAborted();
618
+ if (bytes.byteLength > readLimit) return void 0;
619
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
620
+ sourceBudget.usedBytes += bytes.byteLength;
621
+ return content;
622
+ }
623
+ const readLimit = Math.min(maxSourceBytes, remainingBytes);
624
+ const bytes = await readNodeBytesBounded(file.absolutePath, readLimit, signal);
625
+ if (bytes === void 0) return void 0;
626
+ signal?.throwIfAborted();
627
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
628
+ sourceBudget.usedBytes += bytes.byteLength;
629
+ return content;
630
+ } catch {
631
+ signal?.throwIfAborted();
632
+ return;
633
+ }
634
+ }
635
+ /**
636
+ * Drop later candidates whose trimmed content duplicates an earlier sibling in
637
+ * the same directory. Different directories never collapse even when identical;
638
+ * within one directory the earliest candidate in discovery order is kept and its
639
+ * original bytes are rendered. A candidate that symlinks a sibling resolves to
640
+ * the same content and collapses here like any byte-identical real file.
641
+ * @param files - loaded files in discovery order.
642
+ * @returns the retained files in the same order.
643
+ */
644
+ function dedupInstructionFilesByDirectory(files) {
645
+ const keptDigestsByDir = /* @__PURE__ */ new Map();
646
+ const kept = [];
647
+ for (const file of files) {
648
+ const dir = dirname(file.displayPath);
649
+ let digests = keptDigestsByDir.get(dir);
650
+ if (digests === void 0) {
651
+ digests = /* @__PURE__ */ new Set();
652
+ keptDigestsByDir.set(dir, digests);
653
+ }
654
+ const digest = trimmedInstructionDigest(file.content);
655
+ if (digests.has(digest)) continue;
656
+ digests.add(digest);
657
+ kept.push(file);
658
+ }
659
+ return kept;
660
+ }
661
+ /**
662
+ * Discover, read, and render the baseline instruction chain.
663
+ * @param options - discovery, source-size, byte-budget, and cancellation configuration.
664
+ * @param fileSystem - optional provider used instead of host filesystem reads.
665
+ * @returns rendered baseline context, or undefined when nothing can be loaded.
666
+ */
667
+ async function loadBaselineInstructions(options, fileSystem) {
668
+ return (await loadBaselineInstructionSet(options, fileSystem))?.rendered;
669
+ }
670
+ /**
671
+ * Load a baseline together with the files retained after rendering.
672
+ * @param options - discovery, source-size, byte-budget, and cancellation configuration.
673
+ * @param fileSystem - optional provider used instead of host filesystem reads.
674
+ * @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
675
+ */
676
+ async function loadBaselineInstructionSet(options, fileSystem) {
677
+ const config = resolveConfig(options);
678
+ if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return void 0;
679
+ if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return void 0;
680
+ if (config.maxTotalSourceBytes <= 0 || !Number.isFinite(config.maxTotalSourceBytes)) return void 0;
681
+ const discovered = await discoverInstructionFiles(options, fileSystem);
682
+ const loaded = [];
683
+ const sourceBudget = {
684
+ maxBytes: config.maxTotalSourceBytes,
685
+ usedBytes: 0
686
+ };
687
+ for (const file of discovered) {
688
+ const content = await readBounded(file, config.maxSourceBytes, sourceBudget, fileSystem, options.signal);
689
+ if (content !== void 0) loaded.push({
690
+ absolutePath: file.absolutePath,
691
+ displayPath: file.displayPath,
692
+ content,
693
+ ...file.version === void 0 ? {} : { version: file.version }
694
+ });
695
+ }
696
+ const deduped = dedupInstructionFilesByDirectory(loaded);
697
+ if (deduped.length === 0) {
698
+ if (options.replacePreviousBaseline !== true) return void 0;
699
+ const { rendered, included } = renderWorkspaceInstructionSet([], {
700
+ maxBytes: config.maxBytes,
701
+ replacePreviousBaseline: true
702
+ });
703
+ return {
704
+ rendered,
705
+ observed: [],
706
+ included
707
+ };
708
+ }
709
+ const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
710
+ maxBytes: config.maxBytes,
711
+ ...options.replacePreviousBaseline === void 0 ? {} : { replacePreviousBaseline: options.replacePreviousBaseline }
712
+ });
713
+ return {
714
+ rendered,
715
+ observed: loaded,
716
+ included
717
+ };
718
+ }
719
+ /**
720
+ * Probe the current provider metadata for one per-candidate instruction scope.
721
+ * @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
722
+ * @param projectRoot - project root used to resolve and display project scopes.
723
+ * @param resolved - normalized plugin configuration.
724
+ * @param fileSystem - provider used to resolve and stat scope candidates.
725
+ * @param signal - cancellation for provider probes.
726
+ * @returns present metadata, confirmed absence, or temporary unavailability.
727
+ */
728
+ async function probeScopeInstruction(scope, projectRoot, resolved, fileSystem, signal) {
729
+ const { directory, candidateName } = decodeScopeKey(scope);
730
+ const absolutePath = join(directory === "user-global" ? resolved.dshHome : directory === "." ? projectRoot : join(projectRoot, directory), candidateName);
731
+ let target;
732
+ let info;
733
+ try {
734
+ target = await fileSystem.resolve(absolutePath, signalOptions(signal));
735
+ info = await fileSystem.stat(target, signal);
736
+ } catch {
737
+ signal?.throwIfAborted();
738
+ return { kind: "unavailable" };
739
+ }
740
+ if (info?.type !== "file") return { kind: "absent" };
741
+ return {
742
+ kind: "present",
743
+ file: {
744
+ absolutePath,
745
+ displayPath: directory === "user-global" ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
746
+ target,
747
+ version: info.version,
748
+ ...info.size === void 0 ? {} : { size: info.size }
749
+ }
750
+ };
751
+ }
752
+ /**
753
+ * Read one already-probed scope candidate under the configured source cap.
754
+ * @param file - winning provider candidate and its metadata snapshot.
755
+ * @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
756
+ * @param sourceBudget - aggregate UTF-8 byte budget for the current batch.
757
+ * @param fileSystem - provider used for the streaming read.
758
+ * @param signal - cancellation for provider streaming.
759
+ * @returns loaded content with the probed version, or undefined when unavailable.
760
+ */
761
+ async function readScopeInstruction(file, maxSourceBytes, sourceBudget, fileSystem, signal) {
762
+ const content = await readBounded(file, maxSourceBytes, sourceBudget, fileSystem, signal);
763
+ if (content === void 0) return void 0;
764
+ return {
765
+ absolutePath: file.absolutePath,
766
+ displayPath: file.displayPath,
767
+ content,
768
+ version: file.version
769
+ };
770
+ }
771
+ function userGlobalDisplayPath(dshHome) {
772
+ return `${dshHomeDisplay(dshHome)}/AGENTS.md`;
773
+ }
774
+ //#endregion
775
+ //#region lib/types/state.js
776
+ /**
777
+ * Session-visible workspace instruction state and dynamic reconciliation.
778
+ *
779
+ * @module @stackstackstack/dsh-agent-instructions/state
780
+ */
781
+ const name = "agent-instructions";
782
+ function workspaceContextHook(text, changes) {
783
+ return createUserMessage({
784
+ content: [{
785
+ type: "text",
786
+ text
787
+ }],
788
+ source: {
789
+ kind: "agent-instructions",
790
+ form: "instructions",
791
+ changes
792
+ }
793
+ });
794
+ }
795
+ /**
796
+ * Build the user-role message for a rendered baseline.
797
+ * @param text - complete plugin-owned system-reminder text.
798
+ * @returns a user-role prefix message.
799
+ */
800
+ function workspaceContextMessage(text) {
801
+ return createUserMessage({
802
+ content: [{
803
+ type: "text",
804
+ text
805
+ }],
806
+ source: {
807
+ kind: "plugin",
808
+ plugin: name
809
+ }
810
+ });
811
+ }
812
+ function isWorkspaceContextSource(source) {
813
+ return typeof source === "object" && source !== null && "kind" in source && source.kind === "agent-instructions" && "changes" in source && Array.isArray(source.changes);
814
+ }
815
+ function isRecord(value) {
816
+ return typeof value === "object" && value !== null && !Array.isArray(value);
817
+ }
818
+ function workspaceInstructionChanges(source) {
819
+ const changes = [];
820
+ for (const value of source.changes) {
821
+ if (!isRecord(value)) continue;
822
+ if (value.action !== "set" && value.action !== "replace" && value.action !== "remove") continue;
823
+ if (typeof value.scope !== "string" || typeof value.path !== "string") continue;
824
+ if (value.digest !== void 0 && typeof value.digest !== "string") continue;
825
+ changes.push({
826
+ action: value.action,
827
+ scope: value.scope,
828
+ path: value.path,
829
+ ...value.digest !== void 0 ? { digest: value.digest } : {}
830
+ });
831
+ }
832
+ return changes;
833
+ }
834
+ function sameInstructionChange(a, b) {
835
+ return a.action === b.action && a.scope === b.scope && a.path === b.path && a.digest === b.digest;
836
+ }
837
+ function visibleInstructionChanges(agent, authorityMessages) {
838
+ const visibleSeqs = new Set(agent.session.surface.nodes);
839
+ const visible = /* @__PURE__ */ new Map();
840
+ for (const [seq, event] of agent.session.events.entries()) {
841
+ if (event.type !== "user/message" || !isWorkspaceContextSource(event.data.source)) continue;
842
+ const changes = workspaceInstructionChanges(event.data.source);
843
+ for (const change of changes) if (visibleSeqs.has(seq)) visible.set(change.scope, change);
844
+ }
845
+ for (const message of authorityMessages) {
846
+ if (!isWorkspaceContextSource(message.source)) continue;
847
+ for (const change of workspaceInstructionChanges(message.source)) visible.set(change.scope, change);
848
+ }
849
+ return visible;
850
+ }
851
+ /**
852
+ * Convert retained baseline files into comparison and metadata-cache state.
853
+ * @param files - baseline files that survived rendering.
854
+ * @returns latest baseline changes and provider versions keyed by logical scope.
855
+ */
856
+ function baselineInstructionState(files) {
857
+ const changes = /* @__PURE__ */ new Map();
858
+ const versions = /* @__PURE__ */ new Map();
859
+ for (const file of files) {
860
+ const digest = instructionContentSha1(file.content);
861
+ const change = {
862
+ action: "set",
863
+ scope: instructionScopeKey(file.displayPath),
864
+ path: file.displayPath,
865
+ digest
866
+ };
867
+ changes.set(change.scope, change);
868
+ if (file.version !== void 0) versions.set(change.scope, {
869
+ path: file.displayPath,
870
+ version: file.version,
871
+ digest,
872
+ trimmedDigest: trimmedInstructionDigest(file.content)
873
+ });
874
+ }
875
+ return {
876
+ changes,
877
+ versions
878
+ };
879
+ }
880
+ function versionStatesFor(session, cache) {
881
+ let states = cache.get(session);
882
+ if (states === void 0) {
883
+ states = /* @__PURE__ */ new Map();
884
+ cache.set(session, states);
885
+ }
886
+ return states;
887
+ }
888
+ /**
889
+ * Keep only cache updates represented by rendered changes.
890
+ * @param updates - proposed updates from one or more reconciliations.
891
+ * @param renderedChanges - transitions retained by the renderer.
892
+ * @returns updates represented by an exact retained transition.
893
+ */
894
+ function retainedInstructionVersionUpdates(updates, renderedChanges) {
895
+ return updates.filter((update) => renderedChanges.some((change) => sameInstructionChange(update.change, change)));
896
+ }
897
+ /**
898
+ * Apply metadata-cache transitions without retaining instruction prose.
899
+ * @param session - owning session.
900
+ * @param updates - ordered set/delete transitions.
901
+ * @param cache - session-isolated metadata cache.
902
+ */
903
+ function applyInstructionVersionUpdates(session, updates, cache) {
904
+ if (updates.length === 0) return;
905
+ const states = versionStatesFor(session, cache);
906
+ for (const update of updates) if (update.state === void 0) states.delete(update.change.scope);
907
+ else states.set(update.change.scope, update.state);
908
+ if (states.size === 0) cache.delete(session);
909
+ }
910
+ function relativeScope(projectRoot, dir) {
911
+ const scope = relativeDisplay(projectRoot, dir);
912
+ return scope.length === 0 ? "." : scope;
913
+ }
914
+ /**
915
+ * Compare visible state with provider-visible files and render transitions.
916
+ * @param agent - session owner whose visible surface supplies durable state.
917
+ * @param resolved - normalized plugin configuration.
918
+ * @param versionCache - per-session scope metadata used to skip unchanged reads.
919
+ * @param fileSystem - provider used for current file probes.
920
+ * @param options - authoritative claimed context, pending scope hints, touched paths, and baseline participation.
921
+ * @returns rendered context plus deferred cache updates, or undefined when unchanged/unavailable.
922
+ */
923
+ async function reconcileInstructionContext(agent, resolved, versionCache, fileSystem, options) {
924
+ const session = agent.session;
925
+ const effective = visibleInstructionChanges(agent, options.authorityMessages);
926
+ /* v8 ignore next -- normal agents carry an absolute session cwd. */
927
+ const cwd = session.header.cwd ?? process.cwd();
928
+ const projectRoot = options.projectRoot ?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal);
929
+ const scopes = /* @__PURE__ */ new Set();
930
+ const baselineScopes = /* @__PURE__ */ new Set();
931
+ const addDirScopes = (target, directory) => {
932
+ for (const candidate of resolved.instructionFileCandidates) target.add(candidateScopeKey(directory, candidate));
933
+ for (const candidate of resolved.localInstructionFileCandidates) target.add(candidateScopeKey(directory, candidate));
934
+ };
935
+ const addProjectScopes = (target, dir) => {
936
+ addDirScopes(target, relativeScope(projectRoot, dir));
937
+ };
938
+ baselineScopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE));
939
+ for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(baselineScopes, dir);
940
+ if (options.includeBaselineScopes) for (const scope of baselineScopes) scopes.add(scope);
941
+ for (const message of options.scopeMessages) {
942
+ /* v8 ignore next -- the plugin passes its workspace-only pending projection. */
943
+ if (!isWorkspaceContextSource(message.source)) continue;
944
+ for (const change of workspaceInstructionChanges(message.source)) {
945
+ if (!options.includeBaselineScopes && baselineScopes.has(change.scope)) continue;
946
+ scopes.add(change.scope);
947
+ }
948
+ }
949
+ for (const scope of effective.keys()) {
950
+ if (!options.includeBaselineScopes && baselineScopes.has(scope)) continue;
951
+ const { directory } = decodeScopeKey(scope);
952
+ if (directory === "user-global") scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE));
953
+ else addDirScopes(scopes, directory);
954
+ }
955
+ for (const touchedPath of options.touchedPaths) for (const dir of descendantDirsBetween(cwd, touchedPath)) addProjectScopes(scopes, dir);
956
+ const versions = versionStatesFor(session, versionCache);
957
+ const seenAbsolutePaths = /* @__PURE__ */ new Set();
958
+ const keptTrimmedByDir = /* @__PURE__ */ new Map();
959
+ const registerKeptTrimmed = (directory, digest) => {
960
+ let digests = keptTrimmedByDir.get(directory);
961
+ if (digests === void 0) {
962
+ digests = /* @__PURE__ */ new Set();
963
+ keptTrimmedByDir.set(directory, digests);
964
+ }
965
+ if (digests.has(digest)) return true;
966
+ digests.add(digest);
967
+ return false;
968
+ };
969
+ const items = [];
970
+ const versionUpdates = [];
971
+ const sourceBudget = {
972
+ maxBytes: resolved.maxTotalSourceBytes,
973
+ usedBytes: 0
974
+ };
975
+ const pushRemoval = (scope, path) => {
976
+ const change = {
977
+ action: "remove",
978
+ scope,
979
+ path
980
+ };
981
+ items.push({
982
+ change,
983
+ file: {
984
+ absolutePath: `removed:${scope}`,
985
+ displayPath: path,
986
+ content: ""
987
+ }
988
+ });
989
+ versionUpdates.push({ change });
990
+ };
991
+ const scopesByDirectory = /* @__PURE__ */ new Map();
992
+ for (const scope of scopes) {
993
+ const { directory } = decodeScopeKey(scope);
994
+ const directoryScopes = scopesByDirectory.get(directory);
995
+ if (directoryScopes === void 0) scopesByDirectory.set(directory, [scope]);
996
+ else directoryScopes.push(scope);
997
+ }
998
+ for (const [directory, directoryScopes] of scopesByDirectory) {
999
+ const probedScopes = [];
1000
+ for (const scope of directoryScopes) if (options.excludedBaselineScopes !== void 0 && baselineScopes.has(scope) && options.excludedBaselineScopes.has(scope)) {
1001
+ const previous = effective.get(scope);
1002
+ if (previous === void 0 || previous.action === "remove") versions.delete(scope);
1003
+ else pushRemoval(scope, previous.path);
1004
+ } else probedScopes.push(scope);
1005
+ const itemStart = items.length;
1006
+ const versionUpdateStart = versionUpdates.length;
1007
+ const addedAbsolutePaths = [];
1008
+ const priorVersions = new Map(probedScopes.map((scope) => [scope, versions.get(scope)]));
1009
+ for (const scope of probedScopes) {
1010
+ const previous = effective.get(scope);
1011
+ const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal);
1012
+ if (probe.kind === "unavailable") {
1013
+ if (previous === void 0 || previous.action === "remove") continue;
1014
+ items.splice(itemStart);
1015
+ versionUpdates.splice(versionUpdateStart);
1016
+ for (const [candidateScope, prior] of priorVersions) if (prior === void 0) versions.delete(candidateScope);
1017
+ else versions.set(candidateScope, prior);
1018
+ for (const absolutePath of addedAbsolutePaths) seenAbsolutePaths.delete(absolutePath);
1019
+ keptTrimmedByDir.delete(directory);
1020
+ break;
1021
+ }
1022
+ if (probe.kind === "absent") {
1023
+ if (previous === void 0 || previous.action === "remove") versions.delete(scope);
1024
+ else pushRemoval(scope, previous.path);
1025
+ continue;
1026
+ }
1027
+ const { file: probedFile } = probe;
1028
+ if (seenAbsolutePaths.has(probedFile.absolutePath)) continue;
1029
+ seenAbsolutePaths.add(probedFile.absolutePath);
1030
+ addedAbsolutePaths.push(probedFile.absolutePath);
1031
+ const cached = versions.get(scope);
1032
+ if (cached !== void 0 && cached.path === probedFile.displayPath && cached.version === probedFile.version && previous !== void 0 && previous.action !== "remove" && previous.path === cached.path && previous.digest === cached.digest) {
1033
+ if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path);
1034
+ continue;
1035
+ }
1036
+ const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, sourceBudget, fileSystem, options.signal);
1037
+ if (file === void 0) continue;
1038
+ const currentDigest = instructionContentSha1(file.content);
1039
+ const trimmedDigest = trimmedInstructionDigest(file.content);
1040
+ if (registerKeptTrimmed(directory, trimmedDigest)) {
1041
+ if (previous !== void 0 && previous.action !== "remove") pushRemoval(scope, previous.path);
1042
+ else versions.delete(scope);
1043
+ continue;
1044
+ }
1045
+ const nextVersion = {
1046
+ path: file.displayPath,
1047
+ version: probedFile.version,
1048
+ digest: currentDigest,
1049
+ trimmedDigest
1050
+ };
1051
+ if (previous !== void 0 && previous.action !== "remove" && previous.path === file.displayPath && previous.digest === currentDigest) {
1052
+ versions.set(scope, nextVersion);
1053
+ continue;
1054
+ }
1055
+ const change = {
1056
+ action: previous === void 0 || previous.action === "remove" ? "set" : "replace",
1057
+ scope,
1058
+ path: file.displayPath,
1059
+ digest: currentDigest
1060
+ };
1061
+ items.push({
1062
+ change,
1063
+ file
1064
+ });
1065
+ versionUpdates.push({
1066
+ change,
1067
+ state: nextVersion
1068
+ });
1069
+ }
1070
+ }
1071
+ if (items.length === 0) return void 0;
1072
+ const rendered = renderInstructionChanges(items, resolved.maxBytes);
1073
+ if (rendered.text.length === 0 || rendered.changes.length === 0) return void 0;
1074
+ return {
1075
+ context: workspaceContextHook(rendered.text, rendered.changes),
1076
+ versionUpdates: retainedInstructionVersionUpdates(versionUpdates, rendered.changes)
1077
+ };
1078
+ }
1079
+ //#endregion
1080
+ //#region lib/types/index.js
1081
+ /**
1082
+ * Workspace instruction loader for AGENTS.md-compatible files.
1083
+ *
1084
+ * Baseline instructions enter durable context before the first request; successful fs
1085
+ * tool touches project nested, changed, and removed instructions into the inbox.
1086
+ * Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
1087
+ * mount it as a no-op.
1088
+ *
1089
+ * @module @stackstackstack/dsh-agent-instructions
1090
+ */
1091
+ function visibleBaselineSource(agent, authorityMessages) {
1092
+ for (const message of authorityMessages.toReversed()) if (message.source.kind === "agent-instructions" && message.source.baseline === true) return message.source;
1093
+ for (const seq of agent.session.surface.nodes.toReversed()) {
1094
+ const event = agent.session.events[seq];
1095
+ if (event?.type === "user/message" && event.data.source.kind === "agent-instructions" && event.data.source.baseline === true) return event.data.source;
1096
+ }
1097
+ }
1098
+ function isWorkspaceContext(message) {
1099
+ return message.source.kind === "agent-instructions";
1100
+ }
1101
+ function sameContextPayload(left, right) {
1102
+ return isDeepStrictEqual(left.content, right.content) && isDeepStrictEqual(left.source, right.source);
1103
+ }
1104
+ const FILE_TOUCH_TOOL_NAMES = new Set([
1105
+ "read",
1106
+ "write",
1107
+ "edit"
1108
+ ]);
1109
+ function filePathFromExecution(exec) {
1110
+ if (!FILE_TOUCH_TOOL_NAMES.has(exec.name)) return void 0;
1111
+ if (typeof exec.arguments !== "object" || exec.arguments === null) return void 0;
1112
+ if (!("file_path" in exec.arguments) || typeof exec.arguments.file_path !== "string") return void 0;
1113
+ const filePath = exec.arguments.file_path.trim();
1114
+ return filePath.length > 0 ? filePath : void 0;
1115
+ }
1116
+ function apply(ctx, config) {
1117
+ const resolved = resolveConfig(config);
1118
+ const instructionVersions = /* @__PURE__ */ new WeakMap();
1119
+ const baselinePreparations = /* @__PURE__ */ new WeakMap();
1120
+ const projectionLifecycle = new AbortController();
1121
+ const executionTouches = /* @__PURE__ */ new Map();
1122
+ ctx.effect(() => () => {
1123
+ projectionLifecycle.abort(/* @__PURE__ */ new Error("agent-instructions disposed"));
1124
+ executionTouches.clear();
1125
+ }, "agent-instructions.projectionLifecycle");
1126
+ const projectionTails = /* @__PURE__ */ new WeakMap();
1127
+ const openSteps = /* @__PURE__ */ new WeakMap();
1128
+ const stepTouches = /* @__PURE__ */ new WeakMap();
1129
+ const compose = async (agent, signal, claimed, pending, touchedPaths = []) => {
1130
+ signal.throwIfAborted();
1131
+ if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes)) return;
1132
+ const fileSystem = ctx.get("fs");
1133
+ if (fileSystem === void 0) return void 0;
1134
+ if (touchedPaths.length === 0 && pending.length > 0) return pending[0];
1135
+ const content = [];
1136
+ const changes = [];
1137
+ let desiredBaseline = false;
1138
+ const authorityMessages = [...claimed];
1139
+ /* v8 ignore next -- normal agents carry an absolute session cwd. */
1140
+ const cwd = agent.session.header.cwd ?? process.cwd();
1141
+ const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal);
1142
+ const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot);
1143
+ const visibleBaseline = visibleBaselineSource(agent, authorityMessages);
1144
+ const baselinePresent = visibleBaseline !== void 0;
1145
+ const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity;
1146
+ const prepared = baselinePreparations.get(agent.session);
1147
+ let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity ? prepared.excludedScopes : void 0;
1148
+ let nextPreparation;
1149
+ if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === void 0) {
1150
+ const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline;
1151
+ const instructions = await loadBaselineInstructionSet({
1152
+ cwd,
1153
+ dshHome: resolved.dshHome,
1154
+ projectRootMarkers: resolved.projectRootMarkers,
1155
+ maxBytes: resolved.maxBytes,
1156
+ maxSourceBytes: resolved.maxSourceBytes,
1157
+ maxTotalSourceBytes: resolved.maxTotalSourceBytes,
1158
+ instructionFileCandidates: resolved.instructionFileCandidates,
1159
+ localInstructionFileCandidates: resolved.localInstructionFileCandidates,
1160
+ projectRoot,
1161
+ replacePreviousBaseline,
1162
+ signal
1163
+ }, fileSystem);
1164
+ const baseline = baselineInstructionState(instructions?.included ?? []);
1165
+ const observedBaseline = baselineInstructionState(instructions?.observed ?? []);
1166
+ const excludedScopes = new Set(observedBaseline.changes.keys());
1167
+ for (const scope of baseline.changes.keys()) excludedScopes.delete(scope);
1168
+ excludedBaselineScopes = excludedScopes;
1169
+ nextPreparation = {
1170
+ identity,
1171
+ excludedScopes
1172
+ };
1173
+ let versionStates = instructionVersions.get(agent.session);
1174
+ if (versionStates === void 0 && baseline.versions.size > 0) {
1175
+ versionStates = /* @__PURE__ */ new Map();
1176
+ instructionVersions.set(agent.session, versionStates);
1177
+ }
1178
+ for (const [scope, state] of baseline.versions) versionStates?.set(scope, state);
1179
+ if (!keepVisibleBaseline && instructions !== void 0 && instructions.rendered.text.length > 0) {
1180
+ const baselineContent = workspaceContextMessage(instructions.rendered.text).content;
1181
+ content.push(...baselineContent);
1182
+ const replacementScopes = new Set(baseline.changes.keys());
1183
+ const baselineChanges = [...replacePreviousBaseline ? visibleBaseline.changes.flatMap((change) => change.action === "remove" || replacementScopes.has(change.scope) ? [] : [{
1184
+ action: "remove",
1185
+ scope: change.scope,
1186
+ path: change.path
1187
+ }]) : [], ...baseline.changes.values()];
1188
+ changes.push(...baselineChanges);
1189
+ authorityMessages.push(createUserMessage({
1190
+ content: baselineContent,
1191
+ source: {
1192
+ kind: "agent-instructions",
1193
+ form: "instructions",
1194
+ baseline: true,
1195
+ baselineIdentity: identity,
1196
+ changes: baselineChanges
1197
+ }
1198
+ }));
1199
+ desiredBaseline = true;
1200
+ }
1201
+ }
1202
+ const update = await reconcileInstructionContext(agent, resolved, instructionVersions, fileSystem, {
1203
+ authorityMessages,
1204
+ scopeMessages: pending,
1205
+ includeBaselineScopes: keepVisibleBaseline,
1206
+ ...keepVisibleBaseline ? { excludedBaselineScopes } : {},
1207
+ touchedPaths,
1208
+ projectRoot,
1209
+ signal
1210
+ });
1211
+ if (update !== void 0) {
1212
+ content.push(...update.context.content);
1213
+ /* v8 ignore next -- reconciliation constructs only agent-instructions contexts. */
1214
+ if (update.context.source.kind === "agent-instructions") changes.push(...update.context.source.changes);
1215
+ applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions);
1216
+ }
1217
+ if (nextPreparation !== void 0) baselinePreparations.set(agent.session, nextPreparation);
1218
+ if (content.length === 0) return void 0;
1219
+ return createUserMessage({
1220
+ content,
1221
+ source: {
1222
+ kind: "agent-instructions",
1223
+ form: "instructions",
1224
+ ...desiredBaseline ? { baseline: true } : {},
1225
+ ...desiredBaseline ? { baselineIdentity: identity } : {},
1226
+ changes
1227
+ }
1228
+ });
1229
+ };
1230
+ const syncInbox = (agent, claimed, desired) => {
1231
+ const pending = agent.inbox.nextStep.filter(isWorkspaceContext);
1232
+ const alreadySupplied = desired !== void 0 && (claimed.some((message) => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => {
1233
+ const event = agent.session.events[seq];
1234
+ return event?.type === "user/message" && sameContextPayload(event.data, desired);
1235
+ }));
1236
+ if (desired === void 0 || alreadySupplied) {
1237
+ for (const message of pending) agent.inbox.remove(message.id);
1238
+ return;
1239
+ }
1240
+ const reusable = pending.find((message) => sameContextPayload(message, desired));
1241
+ if (reusable !== void 0) {
1242
+ for (const message of pending) if (message !== reusable) agent.inbox.remove(message.id);
1243
+ return;
1244
+ }
1245
+ const replaced = pending[0];
1246
+ if (replaced === void 0) agent.inbox.prepend("next-step", desired);
1247
+ else agent.inbox.replace(replaced.id, desired);
1248
+ for (const message of pending.slice(1)) agent.inbox.remove(message.id);
1249
+ };
1250
+ const composeAndSync = async (agent, signal, claimed, touchedPaths = []) => {
1251
+ const desired = await compose(agent, signal, claimed, agent.inbox.nextStep.filter(isWorkspaceContext), touchedPaths);
1252
+ signal.throwIfAborted();
1253
+ syncInbox(agent, claimed, desired);
1254
+ };
1255
+ const queueProjection = (agent, touchedPath) => {
1256
+ const current = (projectionTails.get(agent) ?? Promise.resolve()).then(() => composeAndSync(agent, projectionLifecycle.signal, [], [touchedPath])).catch((error) => {
1257
+ if (!projectionLifecycle.signal.aborted) ctx.logger.warn("workspace instruction refresh failed: %o", error);
1258
+ });
1259
+ projectionTails.set(agent, current);
1260
+ current.then(() => {
1261
+ if (projectionTails.get(agent) === current) projectionTails.delete(agent);
1262
+ });
1263
+ };
1264
+ const waitForProjections = async (agent) => {
1265
+ let projection;
1266
+ while ((projection = projectionTails.get(agent)) !== void 0) await projection;
1267
+ };
1268
+ const stepIsOpen = (session) => {
1269
+ const known = openSteps.get(session);
1270
+ if (known !== void 0) return known;
1271
+ let open = false;
1272
+ for (const event of session.events) if (event.type === "step/start") open = true;
1273
+ else if (event.type === "step/end" || event.type === "turn/end") open = false;
1274
+ openSteps.set(session, open);
1275
+ return open;
1276
+ };
1277
+ const projectTouch = (touch) => {
1278
+ const session = touch.agent.session;
1279
+ if (!stepIsOpen(session)) {
1280
+ queueProjection(touch.agent, touch.path);
1281
+ return;
1282
+ }
1283
+ const pending = stepTouches.get(session);
1284
+ if (pending === void 0) stepTouches.set(session, [touch]);
1285
+ else pending.push(touch);
1286
+ };
1287
+ ctx.on("session/event", (session, event) => {
1288
+ if (event.type === "step/start") {
1289
+ openSteps.set(session, true);
1290
+ return;
1291
+ }
1292
+ if (event.type === "turn/end") {
1293
+ openSteps.set(session, false);
1294
+ return;
1295
+ }
1296
+ if (event.type !== "step/end") return;
1297
+ openSteps.set(session, false);
1298
+ const pending = stepTouches.get(session);
1299
+ if (pending === void 0) return;
1300
+ stepTouches.delete(session);
1301
+ for (const touch of pending) queueProjection(touch.agent, touch.path);
1302
+ });
1303
+ ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => {
1304
+ const decision = await next();
1305
+ await waitForProjections(agent);
1306
+ const pending = agent.inbox.nextStep.filter(isWorkspaceContext);
1307
+ const desired = await compose(agent, signal, messages, pending);
1308
+ signal.throwIfAborted();
1309
+ if (decision.kind === "reject" || step === 1 && decision.messages.length === 0) {
1310
+ syncInbox(agent, messages, desired);
1311
+ return decision;
1312
+ }
1313
+ for (const message of pending) agent.inbox.remove(message.id);
1314
+ if (desired === void 0 || decision.messages.some((message) => sameContextPayload(message, desired))) return decision;
1315
+ const lastClaimedIndex = decision.messages.findLastIndex((message) => messages.includes(message));
1316
+ return {
1317
+ kind: "enter",
1318
+ messages: decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
1319
+ };
1320
+ });
1321
+ ctx.on("tools/result", (exec, result) => {
1322
+ const touches = executionTouches.get(exec.token) ?? [];
1323
+ executionTouches.delete(exec.token);
1324
+ if (!result.isError && exec.agent !== void 0 && !exec.signal.aborted) {
1325
+ const ownPath = filePathFromExecution(exec);
1326
+ if (ownPath !== void 0) touches.push({
1327
+ agent: exec.agent,
1328
+ path: ownPath
1329
+ });
1330
+ }
1331
+ if (exec.parent !== void 0) {
1332
+ if (touches.length > 0) {
1333
+ const parentTouches = executionTouches.get(exec.parent);
1334
+ if (parentTouches === void 0) executionTouches.set(exec.parent, touches);
1335
+ else parentTouches.push(...touches);
1336
+ }
1337
+ return;
1338
+ }
1339
+ for (const touch of touches) projectTouch(touch);
1340
+ });
1341
+ }
1342
+ //#endregion
1343
+ export { Config, apply, discoverBaselineInstructionFiles, loadBaselineInstructions, name, renderWorkspaceContext };