@markstanden/opencode-docs-plugin 0.3.0 → 0.4.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 CHANGED
@@ -9,10 +9,14 @@ writing convention those docs follow.
9
9
  - `docs` (tool) — blank search lists the 10 most recently changed docs
10
10
  (`path — Area — date — Summary`); a search ranks docs over their
11
11
  front-matter, path and body identifiers, falling back to the closest
12
- matches rather than a dead end; `add` writes knowledge through a typed DTO;
13
- `check` validates the tree against the convention. Nested project
14
- `README.md` files are indexed too — `.gitignore` rules decide what is
15
- skipped — but only for search: `add` and `check` never touch them.
12
+ matches rather than a dead end; `read` returns a matched doc verbatim
13
+ (front-matter and body), so find-then-read is one call; `add` writes
14
+ knowledge through a typed DTO; `check` validates the tree against the
15
+ convention. Nested project `README.md` files are indexed too —
16
+ `.gitignore` rules decide what is skipped — but only for search: `add`
17
+ and `check` never touch them. A Code Mode twin, `docs_search`, registers
18
+ alongside it, so `execute` reaches the same lake as
19
+ `tools.docs.docs_search(…)`.
16
20
  - `/docs` (command) — the same core, driven from the prompt:
17
21
  `search <text>` (bare text searches too), `add <text>`, `check`, `help`.
18
22
  - The plugin also registers the `docs` skill, so the convention travels with
@@ -67,6 +71,13 @@ advance — it is created on the first `add`, and a search or `check` over a
67
71
  still-empty tree simply reports nothing. Restart OpenCode after editing the
68
72
  config — it isn't hot-reloaded.
69
73
 
74
+ The runtime nudges default on: a one-line pointer appended to the first
75
+ grep/glob miss of a session (suggesting a `docs search`), and a record-ready
76
+ block format injected into compaction summarisation, so durable findings are
77
+ shaped as separate focused docs — one artifact per topic, never a session
78
+ dump. Set `"nudge": false` to restore silence; a non-boolean value fails at
79
+ startup.
80
+
70
81
  ## Status
71
82
 
72
83
  Standalone extract of the docs tooling, under active development.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markstanden/opencode-docs-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode V2 plugin: a `docs` tool and `/docs` command over a repository's knowledge tree and the project's nested README.md files (search only), with the writing convention it owns",
6
6
  "repository": {
package/src/SKILL.md CHANGED
@@ -5,9 +5,9 @@ option; default `<location>/docs`). This skill covers both
5
5
  halves of the loop: **find** (discover with the `docs` tool, read the match) and
6
6
  **record** (write or update a doc for the current task).
7
7
 
8
- Call the `docs` tool directly at top level. It is a plugin-registered tool and
9
- is **not** reachable from the `execute` (Code Mode) catalog — do not try to
10
- batch it inside `execute`, and do not be misled by the unknown-tool suggestion.
8
+ Call the `docs` tool at top level. Inside `execute` (Code Mode) the same tool
9
+ is reachable as the twin `tools.docs.docs_search(…)` — same verbs, same lake —
10
+ so batch it with the rest of the catalog there.
11
11
 
12
12
  ## Find it
13
13
 
@@ -27,9 +27,11 @@ Call the `docs` tool:
27
27
  - **exactly `help`** — plain text explaining the weights, the hit qualities and
28
28
  the outcomes, instead of a search.
29
29
 
30
- Then open the match with the built-in `read` tool. The tool only discovers; it
31
- never returns a doc's body. `/docs [search]` runs the same search into the
32
- session (`/docs search <text>` is the explicit form).
30
+ Then open the match: either the built-in `read` tool (project files and
31
+ READMEs), or the tool's own `read` verb — pass the root-relative path and it
32
+ returns the doc verbatim, front-matter and body, in one call.
33
+ `/docs [search]` runs the same search into the session (`/docs search <text>`
34
+ is the explicit form).
33
35
 
34
36
  Every `*.md` under the root is covered except the excluded areas — scratch (`plans/`)
35
37
  and vendored mirrors (`requirements/`, `wiki/`). `Area` comes from the path,
@@ -66,6 +68,10 @@ Before writing:
66
68
  doc into per-topic files; after any split, merge or move, grep for inbound
67
69
  references and repoint them, leaving a stub if needed.
68
70
 
71
+ One artifact per topic: a session's findings land as several focused docs,
72
+ never one session dump — a compaction nudge hands you `Record with docs add:`
73
+ blocks already shaped for this.
74
+
69
75
  Then record through the `docs` tool's typed `add`: metadata (`Area`, `Keywords`
70
76
  as a list, `Summary`; optionally `Status`/`Supersedes`) plus a body carrying an
71
77
  `H1` and the claim sections (`Facts`, `Inferred`, `Tested`, `Open questions`).
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  CommandDefinition,
5
5
  CommandInvocation,
6
6
  } from "@opencode/plugin/promise/command";
7
- import type { Info } from "@opencode/plugin/promise/tool";
7
+ import type { Info, Result } from "@opencode/plugin/promise/tool";
8
8
  import { spawnSync } from "node:child_process";
9
9
  import {
10
10
  type Dirent,
@@ -106,6 +106,12 @@ export const DOCS_INPUT = Schema.Struct({
106
106
  "Validate every doc under the docs root against the convention: required front-matter, an H1, claim sections, no hand-maintained Date, a distinctive H1+Keywords identity, a Supersedes naming a doc in the tree, and same-area near-twins. An empty report means compliant.",
107
107
  }),
108
108
  ),
109
+ read: Schema.optional(
110
+ Schema.String.annotate({
111
+ description:
112
+ "A root-relative doc path to return verbatim — front-matter and body — so find-then-read is one call. Lake docs only: project files and READMEs stay with the built-in `read` tool.",
113
+ }),
114
+ ),
109
115
  });
110
116
 
111
117
  /**
@@ -602,21 +608,23 @@ export function explainRanking(): string {
602
608
  ].join("\n");
603
609
  }
604
610
 
605
- /** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `help` > `search`). */
611
+ /** A resolved `docs` tool call: exactly one verb wins (`add` > `check` > `read` > `help` > `search`). */
606
612
  export type DocsRequest =
607
613
  | { verb: "add"; add: DocsAdd }
608
614
  | { verb: "check" }
615
+ | { verb: "read"; read: string }
609
616
  | { verb: "help" }
610
617
  | { verb: "search"; search: string };
611
618
 
612
619
  /**
613
620
  * Resolve a tool call to one verb. An explicit `add` is never downgraded; a
614
- * `check` outranks a search; a lone `help` search explains the ranking;
615
- * anything else searches.
621
+ * `check` outranks a read; a non-blank `read` outranks a search; a lone `help`
622
+ * search explains the ranking; anything else searches.
616
623
  * @param input The raw tool input.
617
624
  * @param input.search The raw search text, when given.
618
625
  * @param input.add The typed add DTO, when given.
619
626
  * @param input.check The check flag, when given.
627
+ * @param input.read The root-relative doc path to read, when given.
620
628
  * @returns The single resolved request.
621
629
  */
622
630
  export function resolveRequest(input: DocsInput): DocsRequest {
@@ -626,6 +634,10 @@ export function resolveRequest(input: DocsInput): DocsRequest {
626
634
  if (input.check === true) {
627
635
  return { verb: "check" };
628
636
  }
637
+ const read = input.read?.trim() ?? "";
638
+ if (read !== "") {
639
+ return { verb: "read", read };
640
+ }
629
641
  if (isHelp(input.search ?? "")) {
630
642
  return { verb: "help" };
631
643
  }
@@ -1234,6 +1246,145 @@ export function checkDocs(
1234
1246
  : lines.join("\n");
1235
1247
  }
1236
1248
 
1249
+ /** The built-in tools the miss-nudge watches: content searches, both miss-prone. */
1250
+ export const NUDGED_TOOLS = ["grep", "glob"] as const;
1251
+
1252
+ /**
1253
+ * The text of a tool result's content: the string itself, or its text parts
1254
+ * joined. Non-text parts and foreign shapes contribute nothing.
1255
+ * @param content A `Tool.Result` content of any provenance.
1256
+ * @returns The searchable text, possibly empty.
1257
+ */
1258
+ function contentText(content: unknown): string {
1259
+ if (typeof content === "string") {
1260
+ return content;
1261
+ }
1262
+ if (Array.isArray(content)) {
1263
+ return content
1264
+ .map((part) =>
1265
+ typeof part === "object" &&
1266
+ part !== null &&
1267
+ "text" in part &&
1268
+ typeof (part as { text: unknown }).text === "string"
1269
+ ? (part as { text: string }).text
1270
+ : "",
1271
+ )
1272
+ .join("\n");
1273
+ }
1274
+ return "";
1275
+ }
1276
+
1277
+ /**
1278
+ * Whether a grep/glob result reads as a search miss. Raw rg prints nothing
1279
+ * on a miss (exit 1, empty stdout) — "No matches found" and "No files found"
1280
+ * are the host wrapper's phrasings — so emptiness is the primary signal and
1281
+ * the phrases are a bonus that keeps working if the wrapper rewords.
1282
+ * @param content The tool result's content (string or content parts).
1283
+ * @returns True when the result is a miss.
1284
+ */
1285
+ export function isSearchMiss(content: unknown): boolean {
1286
+ const trimmed = contentText(content).trim();
1287
+ return trimmed === "" || /no (matches|files) found/i.test(trimmed);
1288
+ }
1289
+
1290
+ /** The grep/glob call's `pattern`, when the input carries a string one. */
1291
+ function patternOf(input: unknown): string {
1292
+ if (typeof input === "object" && input !== null && "pattern" in input) {
1293
+ const pattern = (input as { pattern: unknown }).pattern;
1294
+ if (typeof pattern === "string") {
1295
+ return pattern.trim();
1296
+ }
1297
+ }
1298
+ return "";
1299
+ }
1300
+
1301
+ /**
1302
+ * The one-line pointer appended to a grep/glob miss: the docs tool indexes
1303
+ * the same knowledge, so echo the failed pattern when there is one.
1304
+ * @param pattern The failed call's pattern, possibly blank.
1305
+ * @returns The nudge line.
1306
+ */
1307
+ export function missNudge(pattern: string): string {
1308
+ const suggestion =
1309
+ pattern === ""
1310
+ ? "a knowledge question may already be answered there."
1311
+ : `try \`docs search\` with terms from "${pattern}".`;
1312
+ return (
1313
+ "Note: the `docs` tool also indexes the knowledge docs and project " +
1314
+ `READMEs before you move on — ${suggestion}`
1315
+ );
1316
+ }
1317
+
1318
+ /** One tool call, as the miss-nudge decision sees it. */
1319
+ export interface NudgeCall {
1320
+ /** The tool's registered name. */
1321
+ readonly tool: string;
1322
+ /** `"completed"` results only — an errored call is not a knowledge miss. */
1323
+ readonly status: "completed" | "error";
1324
+ /** The result's content (string or parts). */
1325
+ readonly content: unknown;
1326
+ /** The call's input, for the failed pattern. */
1327
+ readonly input: unknown;
1328
+ }
1329
+
1330
+ /**
1331
+ * The nudge line for a tool call, or null when the call deserves none: not a
1332
+ * watched tool, an errored call, a genuine hit, or a session that has already
1333
+ * been nudged (or already used the lake).
1334
+ * @param call The tool call to judge.
1335
+ * @param alreadyNudged Whether the session was nudged — or used the docs
1336
+ * tool — already.
1337
+ * @returns The line to append, or null.
1338
+ */
1339
+ export function missNudgeFor(
1340
+ call: NudgeCall,
1341
+ alreadyNudged: boolean,
1342
+ ): string | null {
1343
+ if (alreadyNudged || call.status !== "completed") {
1344
+ return null;
1345
+ }
1346
+ if (!(NUDGED_TOOLS as readonly string[]).includes(call.tool)) {
1347
+ return null;
1348
+ }
1349
+ if (!isSearchMiss(call.content)) {
1350
+ return null;
1351
+ }
1352
+ return missNudge(patternOf(call.input));
1353
+ }
1354
+
1355
+ /**
1356
+ * The system part injected into compaction requests: the summariser has no
1357
+ * tools, so it cannot record — instead it shapes each durable finding as its
1358
+ * own record-ready block for the continuing agent, one artifact per topic,
1359
+ * never a session dump (the near-twin guard catches duplicates, not dumps).
1360
+ */
1361
+ export const RECORD_NUDGE =
1362
+ "Before summarising: if this session established durable knowledge " +
1363
+ "(decisions, traps, runbooks, verified behaviour), open the " +
1364
+ "continuation with one `Record with docs add:` block per finding — " +
1365
+ "never one session dump. Each block names its own artifact's `Area`, " +
1366
+ "`H1` title and `Keywords`, plus a one-line what-and-why, so the next " +
1367
+ "agent can persist each as a separate focused doc.";
1368
+
1369
+ /**
1370
+ * Resolve the plugin's `nudge` option: unset means on; a boolean passes
1371
+ * through; anything else throws, matching `root`'s loud-failure precedent.
1372
+ * @param configured The raw option value as the host delivers it.
1373
+ * @returns Whether the runtime nudges are active.
1374
+ */
1375
+ export function resolveNudge(configured: unknown): boolean {
1376
+ if (configured === undefined) {
1377
+ return true;
1378
+ }
1379
+ if (typeof configured === "boolean") {
1380
+ return configured;
1381
+ }
1382
+ throw new Error(
1383
+ `docs plugin option 'nudge' must be a boolean, ` +
1384
+ `got ${JSON.stringify(configured)}.`,
1385
+ );
1386
+ }
1387
+
1237
1388
  export interface DocsToolDeps {
1238
1389
  /** Collects the lake docs; `add` and `check` use only these. */
1239
1390
  collect: () => DocEntry[];
@@ -1268,19 +1419,45 @@ export interface DocsCommandDeps {
1268
1419
  prompt: PromptSender;
1269
1420
  }
1270
1421
 
1422
+ /**
1423
+ * The twin's description: the Code Mode catalog is injected into every
1424
+ * `execute` prompt, so it pays its way in a short paragraph — the verbs, not
1425
+ * the full doctrine the native `docs` description carries.
1426
+ */
1427
+ const TWIN_DESCRIPTION =
1428
+ "Search, read, add and check the knowledge docs under the configured " +
1429
+ "docs root (plus nested project `README.md` files, search-only) from " +
1430
+ "inside `execute` — the `docs` tool's Code Mode twin. A blank search " +
1431
+ "lists the most recently changed docs; `read` returns a matched doc " +
1432
+ "verbatim; `add` writes through a typed DTO; `check` validates the " +
1433
+ "convention.";
1434
+
1271
1435
  /**
1272
1436
  * Build the `docs` tool from its dependencies.
1273
1437
  *
1274
- * Registered with `codemode: false` so the tool stays on the provider's native
1275
- * tool list. That option is exclusive: a tool defaults into Code Mode
1276
- * (`codemode` defaults true) and is dropped from the native list; only
1277
- * `codemode: false` keeps it natively callable. This tool is called directly,
1278
- * so it opts out.
1438
+ * By default the tool is registered with `codemode: false` so it stays on the
1439
+ * provider's native tool list. That option is exclusive: a tool defaults into
1440
+ * Code Mode (`codemode` defaults true) and is dropped from the native list;
1441
+ * only `codemode: false` keeps it natively callable — and `codemode: true`
1442
+ * with a `namespace` is how the host's own plugins expose tools to `execute`
1443
+ * (the catalog renders `tools.<namespace>.<name>(…)`). Pass a distinct
1444
+ * `name` and `codemode: true` to register a Code Mode twin of the same core;
1445
+ * the registry is name-keyed, so the twin cannot also be called `docs`.
1279
1446
  * @param deps Runtime seams used on each call: the docs to serve, and the
1280
1447
  * write seam {@link DocsToolDeps} for `add`.
1448
+ * @param options Registration shape.
1449
+ * @param options.name The tool name (default `"docs"`); a Code Mode twin
1450
+ * passes a distinct name — the registry is name-keyed, so two tools cannot
1451
+ * share one.
1452
+ * @param options.codemode True to register for the `execute` catalog under
1453
+ * the `docs` namespace (default `false` — the native tool list).
1281
1454
  * @returns The tool the host registers.
1282
1455
  */
1283
- export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1456
+ export function createDocsTool(
1457
+ deps: DocsToolDeps,
1458
+ options: { name?: string; codemode?: boolean } = {},
1459
+ ): Info<typeof DOCS_INPUT> {
1460
+ const { name = "docs", codemode = false } = options;
1284
1461
  const addDoc = (add: DocsAdd) => {
1285
1462
  const outcome = prepareAdd(add, deps.collect());
1286
1463
  if (outcome.action === "refuse") {
@@ -1298,18 +1475,45 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1298
1475
  metadata: { written: true },
1299
1476
  };
1300
1477
  };
1478
+ const readDoc = (given: string) => {
1479
+ // The same guard an explicit `add` path passes: relative, inside the
1480
+ // root, a `.md` file — the read verb never widens the surface.
1481
+ const target = explicitTarget(given.trim());
1482
+ if ("reason" in target) {
1483
+ return {
1484
+ content: `${REFUSE} ${target.reason}`,
1485
+ metadata: { read: null },
1486
+ };
1487
+ }
1488
+ if (!deps.exists(target.path)) {
1489
+ return {
1490
+ content: `No doc at ${target.path}.`,
1491
+ metadata: { read: null },
1492
+ };
1493
+ }
1494
+ const raw = deps.read(target.path);
1495
+ const { body } = parseFrontMatter(raw);
1496
+ return {
1497
+ content: `${target.path} — ${titleOf(target.path, body)}\n\n${raw}`,
1498
+ metadata: { read: target.path },
1499
+ };
1500
+ };
1301
1501
  return {
1302
- name: "docs",
1303
- description:
1304
- "Discover the knowledge docs under the configured docs root " +
1305
- "(default: the location's `docs/` directory), plus nested `README.md` " +
1306
- "files in the project tree. A blank search lists the " +
1307
- MAX_RESULTS +
1308
- " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
1309
- MAX_RESULTS +
1310
- " results and counts the rest. Search exactly `help` explains the ranking. `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Local READMEs are search-only: `add` and `check` ignore them. Read a matched file with the read tool.",
1502
+ name,
1503
+ description: codemode
1504
+ ? TWIN_DESCRIPTION
1505
+ : "Discover the knowledge docs under the configured docs root " +
1506
+ "(default: the location's `docs/` directory), plus nested `README.md` " +
1507
+ "files in the project tree. Prefer it over a broad grep when the " +
1508
+ "question is how-to knowledge rather than where code lives. A blank search lists the " +
1509
+ MAX_RESULTS +
1510
+ " most recently changed docs (path — Area — date — Summary); a search ranks docs over title, path, keywords, body headings and code spans, area and summary, falling back to the closest matches. Every call returns at most " +
1511
+ MAX_RESULTS +
1512
+ " results and counts the rest. Search exactly `help` explains the ranking. `read` returns a matched doc verbatim (front-matter and body). `add` writes a new knowledge doc (or wholesale-replaces the doc at `path`) from a typed DTO — metadata becomes front-matter, the body must start with an H1; blank required fields, path traversal, non-.md targets and same-area near-twins are refused. `check` validates every doc against the convention (required front-matter, H1, claim sections, no hand-maintained Date, distinctive H1+Keywords, a Supersedes naming a doc in the tree, near-twins) and reports findings; an empty report means compliant. Local READMEs are search-only: `add` and `check` ignore them. Read a matched project file with the built-in read tool.",
1311
1513
  input: DOCS_INPUT,
1312
- options: { codemode: false },
1514
+ options: codemode
1515
+ ? { namespace: "docs", codemode: true }
1516
+ : { codemode: false },
1313
1517
  async execute(input) {
1314
1518
  const request = resolveRequest(input);
1315
1519
  if (request.verb === "add") {
@@ -1322,6 +1526,9 @@ export function createDocsTool(deps: DocsToolDeps): Info<typeof DOCS_INPUT> {
1322
1526
  metadata: { checked: entries.length },
1323
1527
  };
1324
1528
  }
1529
+ if (request.verb === "read") {
1530
+ return readDoc(request.read);
1531
+ }
1325
1532
  if (request.verb === "help") {
1326
1533
  return {
1327
1534
  content: explainRanking(),
@@ -1474,6 +1681,27 @@ export function unversionedNote(root: string): readonly string[] {
1474
1681
  interface DocsPluginOptions {
1475
1682
  /** The docs root override; defaults to `docs` beside the plugin. */
1476
1683
  root?: unknown;
1684
+ /** The runtime nudges' switch; defaults to on. */
1685
+ nudge?: unknown;
1686
+ }
1687
+
1688
+ /** The registered tool names that count as using the lake. */
1689
+ const DOCS_TOOL_NAMES = new Set(["docs", "docs_search"]);
1690
+
1691
+ /**
1692
+ * Content with `line` appended: string concatenation, or one extra text part.
1693
+ * @param content The tool result's content, when it has one.
1694
+ * @param line The nudge line.
1695
+ * @returns The content to write back.
1696
+ */
1697
+ function appendLine(
1698
+ content: NonNullable<Result["content"]> | undefined,
1699
+ line: string,
1700
+ ): NonNullable<Result["content"]> {
1701
+ if (typeof content === "string" || content === undefined) {
1702
+ return `${content ?? ""}\n\n${line}`;
1703
+ }
1704
+ return [...content, { type: "text", text: line }];
1477
1705
  }
1478
1706
 
1479
1707
  export default Plugin.define({
@@ -1481,6 +1709,7 @@ export default Plugin.define({
1481
1709
  async setup(ctx) {
1482
1710
  const options = ctx.options as DocsPluginOptions | undefined;
1483
1711
  const root = resolveRoot(options?.root, ctx.location.directory);
1712
+ const nudge = resolveNudge(options?.nudge);
1484
1713
  const notes = unversionedNote(root);
1485
1714
  const collect = () =>
1486
1715
  collectDocs(root, (paths) => fallbackDates(root, paths));
@@ -1493,19 +1722,28 @@ export default Plugin.define({
1493
1722
  ),
1494
1723
  ];
1495
1724
  const read = (path: string) => readFileSync(join(root, path), "utf8");
1725
+ const toolDeps = {
1726
+ collect,
1727
+ search,
1728
+ exists: (path: string) => existsSync(join(root, path)),
1729
+ write: (path: string, contents: string) => {
1730
+ const target = join(root, path);
1731
+ mkdirSync(dirname(target), { recursive: true });
1732
+ writeFileSync(target, contents);
1733
+ },
1734
+ read,
1735
+ notes,
1736
+ };
1737
+ // Two registrations of one core: the native `docs` for direct calls,
1738
+ // and the Code Mode twin `docs_search` so `execute` reaches the same
1739
+ // lake as `tools.docs.docs_search(…)`. The registry is name-keyed, so
1740
+ // the twin must carry its own name.
1496
1741
  await ctx.tool.transform((editor) => {
1742
+ editor.add(createDocsTool(toolDeps));
1497
1743
  editor.add(
1498
- createDocsTool({
1499
- collect,
1500
- search,
1501
- exists: (path) => existsSync(join(root, path)),
1502
- write: (path, contents) => {
1503
- const target = join(root, path);
1504
- mkdirSync(dirname(target), { recursive: true });
1505
- writeFileSync(target, contents);
1506
- },
1507
- read,
1508
- notes,
1744
+ createDocsTool(toolDeps, {
1745
+ name: "docs_search",
1746
+ codemode: true,
1509
1747
  }),
1510
1748
  );
1511
1749
  });
@@ -1516,11 +1754,55 @@ export default Plugin.define({
1516
1754
  search,
1517
1755
  read,
1518
1756
  notes,
1519
- prompt: (sessionID, text) =>
1520
- ctx.session.prompt({ sessionID, text }),
1757
+ prompt: (sessionID, text) => {
1758
+ // Any `/docs` use counts as consulting the lake.
1759
+ nudged.add(sessionID);
1760
+ return ctx.session.prompt({ sessionID, text });
1761
+ },
1521
1762
  }),
1522
1763
  );
1523
1764
  });
1765
+ // The nudges (default on; `nudge: false` restores silence). The
1766
+ // miss-nudge fires once per session, at the first grep/glob miss, and
1767
+ // is fail-quiet by construction — it must never break the tool it
1768
+ // wraps. The record-nudge rides compaction: the summariser has no
1769
+ // tools, so it shapes record-ready blocks for the continuing agent.
1770
+ const nudged = new Set<string>();
1771
+ if (nudge) {
1772
+ await ctx.tool.hook("execute.after", (event) => {
1773
+ try {
1774
+ if (event.status !== "completed") {
1775
+ return;
1776
+ }
1777
+ if (DOCS_TOOL_NAMES.has(event.tool)) {
1778
+ nudged.add(event.sessionID);
1779
+ return;
1780
+ }
1781
+ const line = missNudgeFor(
1782
+ {
1783
+ tool: event.tool,
1784
+ status: event.status,
1785
+ content: event.result.content,
1786
+ input: event.input,
1787
+ },
1788
+ nudged.has(event.sessionID),
1789
+ );
1790
+ if (line === null) {
1791
+ return;
1792
+ }
1793
+ nudged.add(event.sessionID);
1794
+ event.result = {
1795
+ ...event.result,
1796
+ content: appendLine(event.result.content, line),
1797
+ };
1798
+ } catch {
1799
+ // Fail-quiet: a nudge must never break the wrapped tool.
1800
+ }
1801
+ });
1802
+ await ctx.session.hook("compaction", (compaction) => {
1803
+ compaction.system.push({ type: "text", text: RECORD_NUDGE });
1804
+ });
1805
+ }
1524
1806
  // The skill is the doctrine's runtime face. Its content ships next to
1525
1807
  // this module and is resolved through `import.meta.url`, so it works
1526
1808
  // whether the plugin is loaded from a local path or an installed
@@ -1536,9 +1818,13 @@ export default Plugin.define({
1536
1818
  "configured docs root, plus the project's nested " +
1537
1819
  "README.md files (search only): find the right file with " +
1538
1820
  "the docs tool, read it, and record plans and task " +
1539
- "documentation for the next human or agent. Use at the " +
1540
- "start of unfamiliar work, when hunting for prior " +
1541
- "decisions or traps, and when closing out a session.",
1821
+ "documentation for the next human or agent. Use before " +
1822
+ "searching the codebase for how-to knowledge, when a " +
1823
+ "task names a service, flag or error string the lake " +
1824
+ "may already explain, at the start of unfamiliar work, " +
1825
+ "when hunting for prior decisions or traps, when a " +
1826
+ "session learns something the next one will need, and " +
1827
+ "when closing out a session.",
1542
1828
  path: AbsolutePath.make(fileURLToPath(skillURL)),
1543
1829
  content: skill,
1544
1830
  });