pi-para 0.8.4 → 0.8.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/config.d.ts +2 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +1 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/context.d.ts +4 -0
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +22 -1
  9. package/dist/context.js.map +1 -1
  10. package/dist/lint.d.ts +6 -0
  11. package/dist/lint.d.ts.map +1 -1
  12. package/dist/lint.js +62 -0
  13. package/dist/lint.js.map +1 -1
  14. package/dist/questions.d.ts +99 -0
  15. package/dist/questions.d.ts.map +1 -0
  16. package/dist/questions.js +200 -0
  17. package/dist/questions.js.map +1 -0
  18. package/dist/templates/prompts.d.ts +3 -3
  19. package/dist/templates/prompts.d.ts.map +1 -1
  20. package/dist/templates/prompts.js +8 -2
  21. package/dist/templates/prompts.js.map +1 -1
  22. package/dist/tools/edit-read.d.ts +2 -0
  23. package/dist/tools/edit-read.d.ts.map +1 -1
  24. package/dist/tools/edit-read.js +17 -3
  25. package/dist/tools/edit-read.js.map +1 -1
  26. package/dist/tools/lint.d.ts +1 -0
  27. package/dist/tools/lint.d.ts.map +1 -1
  28. package/dist/tools/lint.js +2 -0
  29. package/dist/tools/lint.js.map +1 -1
  30. package/dist/tools/query.d.ts +2 -2
  31. package/dist/tools/query.d.ts.map +1 -1
  32. package/dist/tools/query.js +12 -1
  33. package/dist/tools/query.js.map +1 -1
  34. package/dist/tools/write.d.ts.map +1 -1
  35. package/dist/tools/write.js +8 -0
  36. package/dist/tools/write.js.map +1 -1
  37. package/dist/tools.d.ts +1 -0
  38. package/dist/tools.d.ts.map +1 -1
  39. package/dist/tools.js.map +1 -1
  40. package/dist/wiki.d.ts.map +1 -1
  41. package/dist/wiki.js +4 -1
  42. package/dist/wiki.js.map +1 -1
  43. package/package.json +6 -2
  44. package/skills/para/SKILL.md +9 -0
  45. package/src/config.ts +3 -0
  46. package/src/context.ts +27 -1
  47. package/src/lint.ts +77 -0
  48. package/src/questions.ts +287 -0
  49. package/src/templates/prompts.ts +8 -2
  50. package/src/templates/schema.md +4 -1
  51. package/src/tools/edit-read.ts +21 -3
  52. package/src/tools/lint.ts +3 -1
  53. package/src/tools/query.ts +14 -2
  54. package/src/tools/write.ts +8 -0
  55. package/src/tools.ts +1 -1
  56. package/src/wiki.ts +4 -1
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Open questions: the "## Open Questions" section of a page, as tracked items.
3
+ *
4
+ * A question is one bullet line. Written by an agent it carries the date it was asked and, when the host has one, the
5
+ * session that asked it:
6
+ *
7
+ * - [ ] (2026-10-18, #993) Does the picker need a "key no longer works" state?
8
+ *
9
+ * When it is answered, the line moves to "## Resolved Questions", ticked, with the date and session that answered it,
10
+ * and the answer after an arrow:
11
+ *
12
+ * - [x] (2026-10-19, #1204) Does the picker need a "key no longer works" state? → yes: machine health (#72)
13
+ *
14
+ * Older pages hold plain bullets (`- question`); they count as open, asked when the page was last updated. A ticked or
15
+ * struck-through line left in the open section counts as resolved.
16
+ *
17
+ * The rest of pi-para uses this to show a page's open questions when it is read or found, to list the wiki's open
18
+ * questions for a session's prompt, to point out questions a page just written may answer, and to flag stale ones.
19
+ */
20
+ import { matchesScope, type ProjectScope } from "./scope.js";
21
+ import { stat } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+ import { listPages, type ParaCategory, readPage } from "./wiki.js";
24
+
25
+ export const OPEN_SECTION = "Open Questions";
26
+ export const RESOLVED_SECTION = "Resolved Questions";
27
+
28
+ /** One question line, as written. */
29
+ export interface QuestionLine {
30
+ /** The line itself, as it is in the page. */
31
+ line: string;
32
+ /** The question's words, without its mark, date, and session. */
33
+ text: string;
34
+ /** When it was asked (YYYY-MM-DD), if written. */
35
+ date?: string;
36
+ /** The session that asked it ("#993"), if written. */
37
+ session?: string;
38
+ /** Ticked, or struck through. */
39
+ done: boolean;
40
+ }
41
+
42
+ /** An open question on a page. */
43
+ export interface OpenQuestion extends QuestionLine {
44
+ /** `category/slug`. */
45
+ page: string;
46
+ title: string;
47
+ scope: string[];
48
+ /** When it was asked: its own date, else the page's last update (YYYY-MM-DD). */
49
+ asked: string;
50
+ }
51
+
52
+ const LINE = /^\s*[-*]\s+(?:\[( |x|X)\]\s+)?(?:\((\d{4}-\d{2}-\d{2})(?:,\s*(#?[\w.:/-]+))?\)\s+)?(.+?)\s*$/;
53
+
54
+ /** The lines of a `## <name>` section of a page's body, up to the next heading of level 1 or 2. */
55
+ export function sectionLines(body: string, name: string): string[] {
56
+ const lines = body.split("\n");
57
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${name.toLowerCase()}`);
58
+
59
+ if (start === -1) {
60
+ return [];
61
+ }
62
+
63
+ const rest = lines.slice(start + 1);
64
+ const end = rest.findIndex((line) => /^#{1,2}\s/.test(line));
65
+
66
+ return end === -1 ? rest : rest.slice(0, end);
67
+ }
68
+
69
+ /** The question lines of a section: its bullets, each read as a question. Placeholder bullets ("none") are left out. */
70
+ export function parseQuestions(body: string, name = OPEN_SECTION): QuestionLine[] {
71
+ return sectionLines(body, name).flatMap((line) => {
72
+ const match = LINE.exec(line);
73
+
74
+ if (match === null) {
75
+ return [];
76
+ }
77
+
78
+ const [, mark, date, session, words] = match;
79
+ const struck = /^~~.*~~$/.test(words!.trim());
80
+ const text = words!.replace(/^~~|~~$/g, "").trim();
81
+
82
+ if (/^(none|n\/a|-|—|\(none\)|nothing yet\.?)$/i.test(text) || /^\[.*gaps in knowledge.*\]$/i.test(text)) {
83
+ return [];
84
+ }
85
+
86
+ return [
87
+ {
88
+ line,
89
+ text,
90
+ ...(date === undefined ? {} : { date }),
91
+ ...(session === undefined ? {} : { session: session.startsWith("#") ? session : `#${session}` }),
92
+ done: (mark !== undefined && mark !== " ") || struck,
93
+ },
94
+ ];
95
+ });
96
+ }
97
+
98
+ /** A page's open questions: the section's lines that are not ticked. */
99
+ export function openQuestionsOf(body: string): QuestionLine[] {
100
+ return parseQuestions(body).filter((question) => !question.done);
101
+ }
102
+
103
+ /** A question line as an agent writes it: `- [ ] (date, #session) text`. */
104
+ export function questionLine(text: string, { date, session }: { date: string; session?: string }): string {
105
+ return `- [ ] (${date}${session === undefined ? "" : `, ${session.startsWith("#") ? session : `#${session}`}`}) ${text.trim()}`;
106
+ }
107
+
108
+ /** A resolved line: `- [x] (date, #session) text → answer`. */
109
+ export function resolvedLine(text: string, answer: string, { date, session }: { date: string; session?: string }): string {
110
+ return `- [x] (${date}${session === undefined ? "" : `, ${session.startsWith("#") ? session : `#${session}`}`}) ${text.trim()} → ${answer.trim()}`;
111
+ }
112
+
113
+ /** Whole days from `date` (YYYY-MM-DD or ISO) to `now`. */
114
+ export function daysSince(date: string, now = Date.now()): number {
115
+ const at = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
116
+
117
+ return Number.isNaN(at) ? 0 : Math.max(0, Math.floor((now - at) / 86_400_000));
118
+ }
119
+
120
+ /** Each page's open questions as last read, by file, with the file's mtime and size: a page reads again only when it changed. */
121
+ const pageCache = new Map<string, { mtimeMs: number; size: number; questions: Omit<OpenQuestion, "page">[] }>();
122
+
123
+ /** A page's open questions, from the cache while its file is unchanged. */
124
+ async function pageQuestions(wikiDir: string, category: ParaCategory, slug: string): Promise<Omit<OpenQuestion, "page">[]> {
125
+ const file = join(wikiDir, category, `${slug}.md`);
126
+ let info;
127
+
128
+ try {
129
+ info = await stat(file);
130
+ } catch {
131
+ pageCache.delete(file);
132
+
133
+ return [];
134
+ }
135
+
136
+ const cached = pageCache.get(file);
137
+
138
+ if (cached !== undefined && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
139
+ return cached.questions;
140
+ }
141
+
142
+ const page = await readPage(wikiDir, category, slug);
143
+ const updated = page === null ? "" : (page.frontmatter.updated || page.frontmatter.created || "").slice(0, 10);
144
+ const questions =
145
+ page === null
146
+ ? []
147
+ : openQuestionsOf(page.body).map((question) => ({
148
+ ...question,
149
+ title: page.frontmatter.title,
150
+ scope: page.frontmatter.scope,
151
+ asked: question.date ?? updated,
152
+ }));
153
+
154
+ pageCache.set(file, { mtimeMs: info.mtimeMs, size: info.size, questions });
155
+
156
+ return questions;
157
+ }
158
+
159
+ /**
160
+ * Every open question in the wiki, oldest first. Archives are left out unless asked for: a finished project's
161
+ * questions are history. With `scope`, the ones on pages in that scope come first (`inScope`). Pages are read again
162
+ * only when their file changed.
163
+ */
164
+ export async function collectOpenQuestions(
165
+ wikiDir: string,
166
+ { scope, includeArchives = false }: { scope?: ProjectScope; includeArchives?: boolean } = {},
167
+ ): Promise<(OpenQuestion & { inScope: boolean })[]> {
168
+ const refs = await listPages(wikiDir);
169
+ const found: (OpenQuestion & { inScope: boolean })[] = [];
170
+
171
+ for (const ref of refs) {
172
+ if (ref.category === "archives" && !includeArchives) {
173
+ continue;
174
+ }
175
+
176
+ for (const question of await pageQuestions(wikiDir, ref.category, ref.slug)) {
177
+ found.push({
178
+ ...question,
179
+ page: `${ref.category}/${ref.slug}`,
180
+ inScope: scope === undefined ? true : matchesScope(question.scope, scope),
181
+ });
182
+ }
183
+ }
184
+
185
+ return found.sort((a, b) => Number(b.inScope) - Number(a.inScope) || a.asked.localeCompare(b.asked));
186
+ }
187
+
188
+ /** Open questions as short Markdown lines: `- «text» on [[slug]] (asked date, #session)`. */
189
+ export function formatOpenQuestions(questions: readonly OpenQuestion[], { max = 10, chars = 180 } = {}): string {
190
+ const shown = questions.slice(0, max).map((question) => {
191
+ const text = question.text.length > chars ? `${question.text.slice(0, chars - 1)}…` : question.text;
192
+ const slug = question.page.split("/").pop();
193
+
194
+ return `- ${text} — on [[${slug}]] (${question.page}), asked ${question.asked}${question.session ? `, ${question.session}` : ""}`;
195
+ });
196
+ const more = questions.length - shown.length;
197
+
198
+ return [...shown, ...(more > 0 ? [`- …and ${more} more (wiki_lint lists them all)`] : [])].join("\n");
199
+ }
200
+
201
+ /** How to resolve one, in the words the tools and prompts use. */
202
+ export const RESOLVE_HOW =
203
+ 'To resolve one, wiki_edit its page: remove its line from "## Open Questions" and add under "## Resolved Questions" `- [x] (YYYY-MM-DD, #session) <question> → <answer>` (today, and your session if you know it).';
204
+
205
+ // -- Questions a page just written may answer ---------------------------------
206
+
207
+ const STOP = new Set(
208
+ "a an and are as at be been but by can could did do does for from had has have how if in into is it its of on or should so than that the their then there these this those to was were what when where whether which who why will with would yet not no any all our we you your".split(
209
+ " ",
210
+ ),
211
+ );
212
+
213
+ /** The meaningful words of a text: lower case, without stop words or very short ones. */
214
+ function words(text: string): Set<string> {
215
+ return new Set(
216
+ text
217
+ .toLowerCase()
218
+ .replace(/[`*_[\]()<>"'“”‘’.,;:!?/\\|{}=+]/g, " ")
219
+ .split(/\s+/)
220
+ .filter((word) => word.length > 2 && !STOP.has(word)),
221
+ );
222
+ }
223
+
224
+ /** Of a question's meaningful words, how many the text has, and the share. */
225
+ function overlap(question: string, text: Set<string>): { shared: number; share: number } {
226
+ const own = [...words(question)];
227
+ const shared = own.filter((word) => text.has(word)).length;
228
+
229
+ return { shared, share: own.length === 0 ? 0 : shared / own.length };
230
+ }
231
+
232
+ /** A page's body without its open questions section: what it says, not what it asks. */
233
+ function withoutOpenSection(body: string): string {
234
+ const lines = body.split("\n");
235
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${OPEN_SECTION.toLowerCase()}`);
236
+
237
+ if (start === -1) {
238
+ return body;
239
+ }
240
+
241
+ const end = lines.findIndex((line, index) => index > start && /^#{1,2}\s/.test(line));
242
+
243
+ return [...lines.slice(0, start), ...(end === -1 ? [] : lines.slice(end))].join("\n");
244
+ }
245
+
246
+ /**
247
+ * Open questions anywhere in the wiki that `written` (pages just written, by `category/slug`, with their new body) may
248
+ * answer, best first: the new text, without its own open questions, has at least half of a question's meaningful
249
+ * words, and two or more of them. Questions on the written pages count too: a page that answers its own question should
250
+ * resolve it. Fast and local, for every write; lint finds more by meaning once the search index has the new text.
251
+ */
252
+ export async function questionsMaybeAnswered(
253
+ wikiDir: string,
254
+ written: readonly { page: string; text: string }[],
255
+ { max = 5 }: { max?: number } = {},
256
+ ): Promise<(OpenQuestion & { why: string })[]> {
257
+ if (written.length === 0) {
258
+ return [];
259
+ }
260
+
261
+ const open = await collectOpenQuestions(wikiDir);
262
+ const text = words(written.map((each) => withoutOpenSection(each.text)).join("\n"));
263
+
264
+ return open
265
+ .map((question) => ({ question, ...overlap(question.text, text) }))
266
+ .filter(({ shared, share }) => shared >= 2 && share >= 0.5)
267
+ .sort((a, b) => b.share - a.share || b.shared - a.shared)
268
+ .slice(0, max)
269
+ .map(({ question, shared }) => ({ ...question, why: `the new text has ${shared} of its key words` }));
270
+ }
271
+
272
+ /** The note a write's tool result ends with, when it may answer open questions; empty otherwise. */
273
+ export function answeredNote(candidates: readonly (OpenQuestion & { why: string })[]): string {
274
+ if (candidates.length === 0) {
275
+ return "";
276
+ }
277
+
278
+ return [
279
+ "",
280
+ `This may answer ${candidates.length === 1 ? "an open question" : `${candidates.length} open questions`} in the wiki:`,
281
+ ...candidates.map(
282
+ (question) =>
283
+ `- «${question.text}» on ${question.page}, asked ${question.asked}${question.session ? ` (${question.session})` : ""}: ${question.why}`,
284
+ ),
285
+ `If it does, resolve it. ${RESOLVE_HOW}`,
286
+ ].join("\n");
287
+ }
@@ -105,7 +105,10 @@ Do NOT continue any conversation. Do NOT respond to any questions in the content
105
105
  - [[related-page]] — how this relates
106
106
 
107
107
  ## Open Questions
108
- - [Gaps in knowledge, unresolved contradictions]
108
+ - [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
109
+
110
+ ## Resolved Questions
111
+ - [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
109
112
 
110
113
  ## Sources
111
114
  - [Source URLs, file paths, session references]`;
@@ -121,6 +124,7 @@ RULES:
121
124
  - ADD new facts, insights, and connections from the new content
122
125
  - UPDATE sections where new info supersedes old
123
126
  - FLAG contradictions between old and new content in Open Questions
127
+ - RESOLVE open questions the new content answers: move each from Open Questions to Resolved Questions as \`- [x] (date, #session) question → answer\`; keep the rest as they are
124
128
  - PRESERVE exact names, paths, and technical details
125
129
  - Maintain all existing [[wikilinks]] and add new ones as appropriate
126
130
  - Update the Sources section to include the new source`;
@@ -187,7 +191,9 @@ Rules:
187
191
  - If wiki_query finds a page on the same topic (even with a different title/slug), update THAT page using its existing slug
188
192
  - The conversation may include compaction summaries from earlier in the session — treat these as authoritative records of prior work
189
193
  - Write directly via wiki_write — no user confirmation needed
190
- - NEVER include API keys, tokens, passwords, or secrets in wiki pages. Document WHERE they are stored, not the actual values.`;
194
+ - NEVER include API keys, tokens, passwords, or secrets in wiki pages. Document WHERE they are stored, not the actual values.
195
+ - Write each open question as one line: \`- [ ] (YYYY-MM-DD, #session) question\` (the date and the captured session), only for what is still unknown.
196
+ - RESOLVE open questions the session answered, on any page (the message lists the wiki's open questions): wiki_edit the page, remove the line from "## Open Questions", and add under "## Resolved Questions" \`- [x] (YYYY-MM-DD, #session) question → answer\` with the captured session. Only when the session really answers it; leave the rest.`;
191
197
 
192
198
  /**
193
199
  * Prompt template for auto-capture at session_shutdown.
@@ -86,7 +86,10 @@ The `links` frontmatter field is auto-synced from body `[[wikilinks]]` — don't
86
86
  - [[related-page]] — how this relates
87
87
 
88
88
  ## Open Questions
89
- - [Gaps in knowledge, unresolved contradictions]
89
+ - [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
90
+
91
+ ## Resolved Questions
92
+ - [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
90
93
 
91
94
  ## Sources
92
95
  - [Source URLs, file paths, session references]
@@ -7,6 +7,22 @@ import { redactSecrets } from "../redact.js";
7
7
  import { appendLog, gitCommit, listPages, readIndex, readPage, writePage, type WikiPage } from "../wiki.js";
8
8
  import type { WikiReadDetails, WikiWriteDetails } from "./schemas.js";
9
9
  import { parsePagePath, scheduleWikiMaintenance } from "./shared.js";
10
+ import { answeredNote, openQuestionsOf, questionsMaybeAnswered, RESOLVE_HOW } from "../questions.js";
11
+
12
+ /** A read page's open questions, first, so whoever reads it sees what is still unknown and can resolve what it knows. */
13
+ export function openQuestionsNote(body: string): string {
14
+ const open = openQuestionsOf(body);
15
+
16
+ if (open.length === 0) {
17
+ return "";
18
+ }
19
+
20
+ return [
21
+ "",
22
+ `Open questions on this page (${open.length}): resolve any you can answer. ${RESOLVE_HOW}`,
23
+ ...open.map((question) => `- ${question.text}${question.date ? ` (asked ${question.date}${question.session ? `, ${question.session}` : ""})` : ""}`),
24
+ ].join("\n");
25
+ }
10
26
 
11
27
  // -- Factory: wiki_read execute ----------------------------------------------
12
28
 
@@ -108,8 +124,10 @@ export function createEditExecute(
108
124
  await gitCommit(wikiDir, params.logSummary ?? `wiki_edit: ${pagePath}`);
109
125
  scheduleWikiMaintenance(wikiDir, store, markDirty);
110
126
 
127
+ const maybe = answeredNote(await questionsMaybeAnswered(wikiDir, [{ page: pagePath, text: linkedBody }]));
128
+
111
129
  return {
112
- content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh.` }],
130
+ content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh.${maybe}` }],
113
131
  details: {
114
132
  pagesWritten: [pagePath],
115
133
  pagesSkipped: [],
@@ -153,7 +171,7 @@ export function createReadExecute(wikiDir: string) {
153
171
  .join("\n");
154
172
 
155
173
  return {
156
- content: [{ type: "text", text: `${header}\n\n${page.body}` }],
174
+ content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
157
175
  details: { found: true, path: `${parsed.category}/${parsed.slug}` },
158
176
  };
159
177
  }
@@ -179,7 +197,7 @@ export function createReadExecute(wikiDir: string) {
179
197
  .join("\n");
180
198
 
181
199
  return {
182
- content: [{ type: "text", text: `${header}\n\n${page.body}` }],
200
+ content: [{ type: "text", text: `${header}${openQuestionsNote(page.body)}\n\n${page.body}` }],
183
201
  details: { found: true, path: `${ref.category}/${ref.slug}` },
184
202
  };
185
203
  }
package/src/tools/lint.ts CHANGED
@@ -12,7 +12,7 @@ export function createLintExecute(
12
12
  wikiDir: string,
13
13
  store: QMDStore,
14
14
  markDirty: () => void,
15
- getLintConfig?: () => { autoFix: boolean; staleDays: number },
15
+ getLintConfig?: () => { autoFix: boolean; staleDays: number; questionDays?: number },
16
16
  ) {
17
17
  return async (
18
18
  params: { autoFix?: boolean },
@@ -21,6 +21,8 @@ export function createLintExecute(
21
21
  const report = await lintWiki(wikiDir, {
22
22
  autoFix: params.autoFix ?? configured?.autoFix ?? true,
23
23
  staleDays: configured?.staleDays ?? 90,
24
+ questionDays: configured?.questionDays ?? 7,
25
+ store,
24
26
  });
25
27
  if (report.fixed.length > 0) {
26
28
  scheduleWikiMaintenance(wikiDir, store, markDirty);
@@ -1,15 +1,16 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import type { QMDStore } from "qmd-engine";
3
3
  import type { ProjectScope } from "../scope.js";
4
- import type { ParaCategory } from "../wiki.js";
4
+ import { readPage, type ParaCategory } from "../wiki.js";
5
5
  import { queryWiki as queryWikiLib, formatQueryResults } from "../query.js";
6
6
  import { QUERY_PROMPT } from "../templates/prompts.js";
7
7
  import type { WikiQueryDetails } from "./schemas.js";
8
+ import { openQuestionsOf, RESOLVE_HOW } from "../questions.js";
8
9
 
9
10
  // -- Factory: wiki_query execute ---------------------------------------------
10
11
 
11
12
  export function createQueryExecute(
12
- _wikiDir: string,
13
+ wikiDir: string,
13
14
  store: QMDStore,
14
15
  getScope: () => ProjectScope,
15
16
  getGraphBoost?: () => boolean,
@@ -42,6 +43,16 @@ export function createQueryExecute(
42
43
  }
43
44
 
44
45
  const formatted = formatQueryResults(result.results);
46
+ // The found pages' open questions: what is still unknown about this topic, and may be answered now.
47
+ const open: string[] = [];
48
+
49
+ for (const found of result.results) {
50
+ const page = await readPage(wikiDir, found.page.category, found.page.slug);
51
+
52
+ for (const question of page === null ? [] : openQuestionsOf(page.body)) {
53
+ open.push(`- ${question.text} — on ${found.page.category}/${found.page.slug}${question.date ? `, asked ${question.date}${question.session ? ` (${question.session})` : ""}` : ""}`);
54
+ }
55
+ }
45
56
  const toolResultText = [
46
57
  QUERY_PROMPT,
47
58
  "",
@@ -49,6 +60,7 @@ export function createQueryExecute(
49
60
  `Scope: ${result.wasGlobal ? "global (all scopes)" : scope.name}`,
50
61
  `Results: ${result.results.length}`,
51
62
  "",
63
+ ...(open.length === 0 ? [] : [`## Open questions on these pages (${open.length})`, "If what you know or find answers one, resolve it. " + RESOLVE_HOW, ...open, ""]),
52
64
  formatted,
53
65
  ].join("\n");
54
66
 
@@ -5,6 +5,7 @@ import { normalizeTags, normalizeScopes } from "../tag-registry.js";
5
5
  import { redactSecrets } from "../redact.js";
6
6
  import { autoLinkSlugs, syncFrontmatterLinks } from "../link-utils.js";
7
7
  import { validateFrontmatter } from "../frontmatter.js";
8
+ import { answeredNote, questionsMaybeAnswered } from "../questions.js";
8
9
  import { appendLog, gitCommit, listPageSlugs, readPage, rebuildIndex, writePage, type ParaCategory, type WikiPage } from "../wiki.js";
9
10
  import type { WikiWriteDetails } from "./schemas.js";
10
11
  import { scheduleWikiMaintenance } from "./shared.js";
@@ -35,6 +36,8 @@ export function createWriteExecute(
35
36
  ): Promise<AgentToolResult<WikiWriteDetails>> => {
36
37
  const pagesWritten: string[] = [];
37
38
  const pagesSkipped: string[] = [];
39
+ /** What was written, for the open questions it may answer. */
40
+ const writtenBodies: { page: string; text: string }[] = [];
38
41
  const now = new Date().toISOString();
39
42
  const scope = getScope();
40
43
 
@@ -113,6 +116,7 @@ export function createWriteExecute(
113
116
  body: linkedBody,
114
117
  };
115
118
  await writePage(wikiDir, updatedPage);
119
+ writtenBodies.push({ page: pagePath, text: updatedPage.body });
116
120
  pagesWritten.push(pagePath + (errors.length > 0 ? ` (${errors.length} edit(s) failed)` : ""));
117
121
  } else if (pageSpec.mode === "append" && existing) {
118
122
  // Append to existing page body
@@ -132,6 +136,7 @@ export function createWriteExecute(
132
136
  },
133
137
  };
134
138
  await writePage(wikiDir, updatedPage);
139
+ writtenBodies.push({ page: pagePath, text: updatedPage.body });
135
140
  pagesWritten.push(pagePath);
136
141
  } else if (pageSpec.mode === "replace" && existing) {
137
142
  // Replace existing page body, update frontmatter
@@ -154,6 +159,7 @@ export function createWriteExecute(
154
159
  body: linkedBody,
155
160
  };
156
161
  await writePage(wikiDir, updatedPage);
162
+ writtenBodies.push({ page: pagePath, text: updatedPage.body });
157
163
  pagesWritten.push(pagePath);
158
164
  } else {
159
165
  // Create new page (or mode=create/replace on non-existing)
@@ -178,6 +184,7 @@ export function createWriteExecute(
178
184
  body: linkedBody,
179
185
  };
180
186
  await writePage(wikiDir, newPage);
187
+ writtenBodies.push({ page: pagePath, text: newPage.body });
181
188
  pagesWritten.push(pagePath);
182
189
  }
183
190
  }
@@ -237,6 +244,7 @@ export function createWriteExecute(
237
244
  summary,
238
245
  indexUpdated ? "Updated index.md." : "",
239
246
  logAppended ? `Logged: ${params.logSummary}` : "",
247
+ answeredNote(await questionsMaybeAnswered(wikiDir, writtenBodies)).trim(),
240
248
  ]
241
249
  .filter(Boolean)
242
250
  .join("\n"),
package/src/tools.ts CHANGED
@@ -39,7 +39,7 @@ export function registerTools(
39
39
  getScope: () => ProjectScope,
40
40
  markDirty: () => void,
41
41
  getGraphBoost?: () => boolean,
42
- getLintConfig?: () => { autoFix: boolean; staleDays: number },
42
+ getLintConfig?: () => { autoFix: boolean; staleDays: number; questionDays?: number },
43
43
  ): void {
44
44
  const ingestExec = createIngestExecute(wikiDir, store, getScope);
45
45
  const queryExec = createQueryExecute(wikiDir, store, getScope, getGraphBoost);
package/src/wiki.ts CHANGED
@@ -121,7 +121,10 @@ links:
121
121
  - [[related-page]] — how this relates
122
122
 
123
123
  ## Open Questions
124
- - [Gaps in knowledge, unresolved contradictions]
124
+ - [ ] (YYYY-MM-DD, #session) [A gap in knowledge or an unresolved contradiction: one line each, with the date asked and the session if known]
125
+
126
+ ## Resolved Questions
127
+ - [x] (YYYY-MM-DD, #session) [The question, as it was] → [its answer: the date and session that resolved it]
125
128
 
126
129
  ## Sources
127
130
  - [Source URLs, file paths, session references]