pi-orchestrator-config 4.24.0 → 4.24.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.24.2] - 2026-11-04
4
+
5
+ - Async work is tracked even when an agent starts it: the task trigger now covers work set in
6
+ motion by an async result, and `taskId: "-1"` is restricted to a single read-only check in
7
+ every place that taught the old habit.
8
+ - Task-id validation actually runs. `pitasks` exports the store as a live binding that the
9
+ subagent tool used to snapshot before it was assigned, so an unknown id was accepted; the
10
+ store is now published on a symbol bridge and read at access time. Verified live: unknown
11
+ ids rejected, a real id and the read-only sentinel accepted.
12
+ - Machine-generated notices declare their provenance: follow-ups are prefixed
13
+ "[system message — not written by the user]", so a guard rail cannot be read as user input.
14
+ - Board updates are success-gated — a failed agent keeps its task open — and reconciling the
15
+ board before new dispatch is mandatory. Status reports derive from `TaskList`.
16
+
17
+ ## [4.24.1] - 2026-11-03
18
+
19
+ - Dreaming no longer stores re-derivable changelog: `[done]` takes non-derivable outcomes only (conclusions, measurements, root causes) and reorganizing prunes PR/version/ticket
20
+ history, so `completions` shrinks instead of rotating changelog for changelog. A live run took this project's topic from 18 entries to 2, keeping the cost baseline and the
21
+ self-reload finding.
22
+ - Learned memory now survives maintenance: anything a dream deletes that is pinned, enforced, or review-distilled is restored when it finishes, and a topic file it deleted is
23
+ rebuilt. A deliberate removal is respected because removals are recorded as tombstones rather than inferred from score state — the inference inverted whenever the scheduled
24
+ rebuild ran mid-dream.
25
+ - `completions` is capped at 6,000 characters through per-topic caps, applied by both write paths. The cap is never satisfied by deleting memory: an over-cap topic is reported and
26
+ left intact.
27
+
3
28
  ## [4.24.0] - 2026-11-03
4
29
 
5
30
  - `/pr-review` asks how to decide after the findings table — `Post all`, `Skip all`, or
@@ -23,15 +23,248 @@ import { getSetting } from "./project-settings.js";
23
23
  import { discoverAgents } from "./agents.js";
24
24
  import { ICON_DREAM } from "./icons.js";
25
25
  import { setSlot } from "./status-bar.js";
26
+ import { distilledLessonTextList } from "./review-learning.js";
27
+ import { invalidateTopicFileCache, removalsSince, topicCharCap } from "./memory-tree.js";
28
+ import { entryHash } from "./memory-scoring.js";
29
+
30
+ /** Topic files memory maintenance rewrites. */
31
+ const MEMORY_TOPIC_FILES = ["lessons", "preferences", "patterns", "decisions", "completions", "mistakes"];
32
+
33
+ /** Line text without its `- [category] ` prefix, lock markers, or `{#id}` suffix. */
34
+ function topicLineText(line: string): string {
35
+ return line
36
+ .replace(/^- \[[a-z]+\] /, "")
37
+ .replace(/\s*\*\((?:pinned|enforced)\)\*/g, "")
38
+ .replace(/\s*\{#[0-9a-f]+\}\s*$/, "")
39
+ .trim();
40
+ }
41
+
42
+ /**
43
+ * Entries memory maintenance must never delete, whatever the prompt says:
44
+ * pinned/enforced entries (user-locked, or keyed by text hash for enforcement) and
45
+ * lessons distilled from reviews — their source is the reviews DB, but nothing
46
+ * re-materializes the topic line once a dream removes it (issue #1010).
47
+ */
48
+ export function isProtectedTopicLine(line: string, reviewLessons: readonly string[]): boolean {
49
+ if (!line.startsWith("- [")) return false;
50
+ if (line.includes("*(pinned)*") || line.includes("*(enforced)*")) return true;
51
+ const text = topicLineText(line);
52
+ return reviewLessons.some((lesson) => {
53
+ const candidate = lesson.trim();
54
+ return candidate.length > 0 && text === candidate;
55
+ });
56
+ }
57
+
58
+ /** Snapshot every memory topic file's lines, so a dream's deletions can be audited. */
59
+ export function snapshotTopicLines(topicsDir: string): Record<string, string[]> {
60
+ const before: Record<string, string[]> = {};
61
+ for (const name of MEMORY_TOPIC_FILES) {
62
+ try {
63
+ before[`${name}.md`] = fs.readFileSync(path.join(topicsDir, `${name}.md`), "utf-8").split("\n");
64
+ } catch {
65
+ // Topic absent — nothing to protect.
66
+ }
67
+ }
68
+ return before;
69
+ }
70
+
71
+ /**
72
+ * Restore protected entries a dream removed, at their original index. Returns how
73
+ * many lines were put back; zero means the dream stayed inside its mandate.
74
+ * Any failure is the caller's to log — this never throws.
75
+ */
76
+ /** Canonical `- [category] text` form used as the memory score key. */
77
+ export function topicLineCanonical(line: string): string {
78
+ const category = /^- \[([a-z]+)\] /.exec(line)?.[1] ?? "";
79
+ return `- [${category}] ${topicLineText(line)}`;
80
+ }
81
+
82
+ export type TopicRestoreReport = {
83
+ restored: number;
84
+ failed: number;
85
+ /** Topics still over their cap after reconcile. Never trimmed here: see below. */
86
+ overCapTopics: string[];
87
+ };
88
+
89
+ /**
90
+ * Restore protected entries a dream removed, at their original index, then keep the
91
+ * file inside its per-topic cap by dropping unprotected additions — never protected
92
+ * ones. Recreates a topic the dream deleted outright. Returns what it did; logging is
93
+ * the caller's, this never throws.
94
+ */
95
+ export function restoreProtectedTopicLines(
96
+ topicsDir: string,
97
+ before: Record<string, string[]>,
98
+ reviewLessons: readonly string[],
99
+ removedByUser: (line: string) => boolean = () => false,
100
+ ): TopicRestoreReport {
101
+ const report: TopicRestoreReport = { restored: 0, failed: 0, overCapTopics: [] };
102
+ for (const [file, beforeLines] of Object.entries(before)) {
103
+ const filePath = path.join(topicsDir, file);
104
+ let current: string[] | null = null;
105
+ try {
106
+ current = fs.readFileSync(filePath, "utf-8").split("\n");
107
+ } catch {
108
+ current = null; // Deleted outright — rebuilt below from the snapshot.
109
+ }
110
+ const base = current ?? beforeLines.filter((line) => !line.startsWith("- ["));
111
+ // A dream that *rewrites* a protected entry keeps it: compare canonical text, not the
112
+ // raw line, or the original returns beside the rewrite as a duplicate.
113
+ const surviving = new Set((current ?? []).map(topicLineCanonical));
114
+ const beforeEntries = new Set(
115
+ beforeLines.filter((line) => line.startsWith("- [")).map(topicLineCanonical),
116
+ );
117
+ const missing = beforeLines
118
+ .map((line, index) => ({ line, index }))
119
+ .filter(({ line }) => {
120
+ if (!line.startsWith("- [") || !isProtectedTopicLine(line, reviewLessons)) return false;
121
+ if (surviving.has(topicLineCanonical(line))) return false;
122
+ // Only an explicit tombstone proves a removal. Score absence does not: the
123
+ // scheduled rebuild prunes scores for lines a dream deleted, which would look
124
+ // exactly like a user removal and strand the entry (issue #1010).
125
+ return !removedByUser(line);
126
+ });
127
+ if (missing.length === 0 && current !== null) continue;
128
+
129
+ const merged = [...base];
130
+ for (const { line, index } of missing) {
131
+ merged.splice(Math.min(index, merged.length), 0, line);
132
+ report.restored += 1;
133
+ }
134
+
135
+ const cap = topicCharCap(file.replace(/\.md$/, ""));
136
+ // Never trim here. Text cannot distinguish a dream's addition from its rewrite of an
137
+ // existing memory, and deleting the wrong one loses knowledge that the score rebuild
138
+ // cannot recover. The write paths still enforce the cap for normal additions; an
139
+ // over-cap file is reported so the next prune can shrink it with judgment.
140
+ if (merged.join("\n").length > cap) report.overCapTopics.push(file);
141
+
142
+ try {
143
+ fs.writeFileSync(filePath, merged.join("\n"), "utf-8");
144
+ invalidateTopicFileCache(filePath);
145
+ } catch {
146
+ report.failed += 1;
147
+ report.restored -= missing.length;
148
+ }
149
+ }
150
+ return report;
151
+ }
152
+
26
153
  import { rebuildAndOrganize } from "./situation-report.js";
27
154
  import { runPromotionPass } from "./memory-promotion.js";
28
155
  import { mergeProvenancePending } from "./memory-provenance.js";
29
- import { getProjectTmpDir, getProjectMemoryDir, shouldSkipOneshotShutdownDream } from "./utils.js";
156
+ import { getProjectTmpDir, getProjectMemoryDir, getProjectDataDir, shouldSkipOneshotShutdownDream } from "./utils.js";
30
157
  import { fileLog } from "../shared/file-logger.js";
31
158
 
32
159
  // Scoring rebuild runs every 30 minutes (cheap, no LLM — just rescores and reorganizes)
33
160
  const REBUILD_INTERVAL_MS = 30 * 60 * 1000;
34
161
 
162
+ export function buildDreamPrompt(
163
+ lastSessionFile: string | undefined,
164
+ topicsDir: string,
165
+ ): string {
166
+ return (
167
+ `Memory dreaming — analyze session and maintain topic files.\n` +
168
+ `Session file: ${lastSessionFile || "none"}\n` +
169
+ `Topics directory: ${topicsDir}\n\n` +
170
+ `Topic files are the source of truth for project memory. Each file holds entries for one category.\n\n` +
171
+ `Steps:\n` +
172
+ `1. Read all existing topic files in ${topicsDir}/ (lessons.md, preferences.md, patterns.md, decisions.md, completions.md, mistakes.md).\n` +
173
+ ` If the directory doesn't exist, create it.\n` +
174
+ `2. QUALITY GATE: Before extracting from any session, assess its quality:\n` +
175
+ ` - Score depth (substantive exchanges > 100 chars? decisions made? corrections?)\n` +
176
+ ` - Skip sessions that are only greetings, trivial Q&A, or < 3 exchanges\n` +
177
+ ` - Only extract from sessions with real decisions, corrections, or completed work\n` +
178
+ `3. If a session file is provided, read it and extract things worth remembering:\n` +
179
+ ` - User corrections → [lesson] → lessons.md\n` +
180
+ ` - User preferences → [preference] → preferences.md\n` +
181
+ ` - Mistakes or repeated fix attempts → [mistake] → mistakes.md\n` +
182
+ ` - Completed work → [done] → completions.md — ONLY non-derivable outcome knowledge:\n` +
183
+ ` a conclusion, a measurement, a root cause, "X was fixed by Y because Z".\n` +
184
+ ` NEVER store a PR/issue number list, merge or release history, version changelogs,\n` +
185
+ ` or closed-ticket roundups — git log, gh, and the tracker already hold them, and a\n` +
186
+ ` stored "Open: #772" answers "is it fixed?" wrongly once it ships.\n` +
187
+ ` - Patterns or conventions → [pattern] → patterns.md\n` +
188
+ ` - Architectural/design decisions → [decision] → decisions.md\n` +
189
+ ` QUALITY GATE for extraction: one-off permissions granted in a single session\n` +
190
+ ` ("you can merge this PR", "skip tests just this once") AND task-scoped instructions\n` +
191
+ ` ("when done, I want an html report", "when bumping to 1.4.0, drop the vendor dir",\n` +
192
+ ` config-change notes like "every 30min (changed from 1h)") are session/task\n` +
193
+ ` instructions, NOT memories — never extract them. Apply the standing test: would\n` +
194
+ ` this apply to a future, unrelated task? No → discard. Only standing, repeated\n` +
195
+ ` rules are durable.\n` +
196
+ ` Do NOT add duplicates of existing entries.\n` +
197
+ `3b. Review the preference candidate queue: if ${topicsDir}/../preference-candidates.jsonl\n` +
198
+ ` exists and has entries, each holds a VERBATIM sentence the regex extractor queued\n` +
199
+ ` from user input (issue #961). For each candidate:\n` +
200
+ ` - Durable standing preference → DISTILL it into a clean one-line [preference]\n` +
201
+ ` (≤100 chars, actionable, no verbatim chatter) and add to preferences.md\n` +
202
+ ` - Task-scoped instruction, one-off ask, question, or already covered → discard\n` +
203
+ ` After processing: RE-READ preference-candidates.jsonl and rewrite it keeping ONLY\n` +
204
+ ` the entries that appeared after your first read — the extractor appends\n` +
205
+ ` concurrently, so do NOT truncate the whole file (an entry queued between your\n` +
206
+ ` read and your rewrite would be erased before any dream cycle reviewed it).\n` +
207
+ ` Keep the file bounded: if it exceeds 200 lines, also drop the oldest entries\n` +
208
+ ` beyond that cap.\n` +
209
+ `4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
210
+ ` If it does, read the timestamp — only process sessions newer than that.\n` +
211
+ ` Session directory: find .jsonl files under the pi sessions directory.\n` +
212
+ ` For each unprocessed session, extract durable knowledge (same categories as step 3).\n` +
213
+ ` Limit: process at most 5 sessions per dream cycle to avoid overload.\n` +
214
+ `5. Reorganize each topic file:\n` +
215
+ ` - Remove duplicate or near-duplicate entries\n` +
216
+ ` - Remove stale/useless entries\n` +
217
+ ` - Remove session-scoped or expired entries — they are transient by design\n` +
218
+ ` - Remove task-scoped instruction entries (issue #961): verbatim user utterances\n` +
219
+ ` (colloquial chat captured raw), mid-sentence cutoffs, one-off deliverable asks\n` +
220
+ ` ("when done I want a report"), version-pinned migration checklists, config-change\n` +
221
+ ` notes — these are task/session artifacts, not durable rules. NEVER remove\n` +
222
+ ` pinned or enforced entries even when they match these shapes.\n` +
223
+ ` - Remove entries that restate git/GitHub/Jira history — merged-PR lists, release and\n` +
224
+ ` version notes, closed-ticket roundups. Recoverable by query, therefore not memory.\n` +
225
+ ` When an entry mixes history with a non-derivable finding, keep only the finding.\n` +
226
+ ` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
227
+ ` - NEVER remove or modify entries marked with *(pinned)*\n` +
228
+ ` - NEVER remove or modify entries marked with *(enforced)* — these have code-enforced triggers/actions that are keyed by text hash. ANY text change destroys the enforcement binding.\n` +
229
+ `6. Write each updated topic file with this format:\n` +
230
+ ` # TopicName\n` +
231
+ ` \n` +
232
+ ` - [category] summary *(pinned)* (if pinned)\n` +
233
+ ` - [category] summary *(enforced)* (if enforced)\n` +
234
+ ` - [category] summary *(pinned)* *(enforced)* (if both)\n` +
235
+ ` - [category] summary (if neither)\n` +
236
+ `7. Auto-generate skills: if you notice a multi-step workflow pattern across entries,\n` +
237
+ ` create a skill file at .pi/skills/<name>/SKILL.md (project-level, NOT global ~/.agents/).\n` +
238
+ ` The SKILL.md MUST start with YAML frontmatter:\n` +
239
+ ` ---\n` +
240
+ ` name: <name>\n` +
241
+ ` description: "What this skill does and when to use it"\n` +
242
+ ` ---\n` +
243
+ ` Only create skills for workflows with 3+ steps that are likely to recur.\n` +
244
+ `8. Write the current timestamp to ${topicsDir}/../.dream-watermark to track progress.\n` +
245
+ `9. Memory rules: one line per entry, max ~100 chars, specific and actionable, no fluff.\n` +
246
+ `10. Promotion destinations: append candidates to ${topicsDir}/../promotions.md using this block format:\n` +
247
+ ` ### <12-char-id>\n` +
248
+ ` - destination: memory|skill|enforcement|project_rule|discard\n` +
249
+ ` - status: proposed\n` +
250
+ ` - category: <category>\n` +
251
+ ` - text: <exact topic entry text>\n` +
252
+ ` - reason: <why this should graduate>\n` +
253
+ ` - created: <ISO timestamp>\n` +
254
+ ` Optional fields: evidence_count, trigger, action, verifier, skill_name, skill_created.\n` +
255
+ ` Rules:\n` +
256
+ ` - skill: create .pi/skills/ when confident; set skill_created: true\n` +
257
+ ` - enforcement: propose trigger/action when mechanical (never/always + command); do NOT invent run_after\n` +
258
+ ` - project_rule: propose only — NEVER write rules/ or .pi/rules/\n` +
259
+ ` - discard: stale or superseded noise\n` +
260
+ ` - Do not reopen entries already marked applied or rejected in promotions.md\n` +
261
+ `11. Provenance sidecar (optional): for newly extracted entries, write\n` +
262
+ ` ${topicsDir}/../provenance-pending.jsonl as a single line of JSON (preferred):\n` +
263
+ ` {"entries":[{"category":"lesson","text":"<exact topic text>","sourceSession":"<session basename>","derivedFrom":"<optional>","informs":["optional"]}]}\n` +
264
+ ` Do NOT edit memory-scores.jsonl yourself — onComplete merges the sidecar.`
265
+ );
266
+ }
267
+
35
268
  export function registerDreaming(
36
269
  pi: ExtensionAPI,
37
270
  spawnAsyncAgent: (
@@ -102,99 +335,13 @@ export function registerDreaming(
102
335
  currentDreamId = ""; // Reset until we get the ID from spawnAsyncAgent
103
336
  const { agents } = discoverAgents(cwd, "user");
104
337
  const topicsDir = path.join(getProjectMemoryDir(cwd), "topics");
338
+ // Snapshot before the dream touches anything: whatever it removes that is learned
339
+ // knowledge rather than re-derivable history is put back below (issue #1010).
340
+ const topicsBeforeDream = snapshotTopicLines(topicsDir);
341
+ const dreamStartedAt = Date.now();
105
342
  const { id } = spawnAsyncAgent(
106
343
  "worker",
107
- `Memory dreaming — analyze session and maintain topic files.\n` +
108
- `Session file: ${lastSessionFile || "none"}\n` +
109
- `Topics directory: ${topicsDir}\n\n` +
110
- `Topic files are the source of truth for project memory. Each file holds entries for one category.\n\n` +
111
- `Steps:\n` +
112
- `1. Read all existing topic files in ${topicsDir}/ (lessons.md, preferences.md, patterns.md, decisions.md, completions.md, mistakes.md).\n` +
113
- ` If the directory doesn't exist, create it.\n` +
114
- `2. QUALITY GATE: Before extracting from any session, assess its quality:\n` +
115
- ` - Score depth (substantive exchanges > 100 chars? decisions made? corrections?)\n` +
116
- ` - Skip sessions that are only greetings, trivial Q&A, or < 3 exchanges\n` +
117
- ` - Only extract from sessions with real decisions, corrections, or completed work\n` +
118
- `3. If a session file is provided, read it and extract things worth remembering:\n` +
119
- ` - User corrections → [lesson] → lessons.md\n` +
120
- ` - User preferences → [preference] → preferences.md\n` +
121
- ` - Mistakes or repeated fix attempts → [mistake] → mistakes.md\n` +
122
- ` - Completed features/PRs merged → [done] → completions.md\n` +
123
- ` - Patterns or conventions → [pattern] → patterns.md\n` +
124
- ` - Architectural/design decisions → [decision] → decisions.md\n` +
125
- ` QUALITY GATE for extraction: one-off permissions granted in a single session\n` +
126
- ` ("you can merge this PR", "skip tests just this once") AND task-scoped instructions\n` +
127
- ` ("when done, I want an html report", "when bumping to 1.4.0, drop the vendor dir",\n` +
128
- ` config-change notes like "every 30min (changed from 1h)") are session/task\n` +
129
- ` instructions, NOT memories — never extract them. Apply the standing test: would\n` +
130
- ` this apply to a future, unrelated task? No → discard. Only standing, repeated\n` +
131
- ` rules are durable.\n` +
132
- ` Do NOT add duplicates of existing entries.\n` +
133
- `3b. Review the preference candidate queue: if ${topicsDir}/../preference-candidates.jsonl\n` +
134
- ` exists and has entries, each holds a VERBATIM sentence the regex extractor queued\n` +
135
- ` from user input (issue #961). For each candidate:\n` +
136
- ` - Durable standing preference → DISTILL it into a clean one-line [preference]\n` +
137
- ` (≤100 chars, actionable, no verbatim chatter) and add to preferences.md\n` +
138
- ` - Task-scoped instruction, one-off ask, question, or already covered → discard\n` +
139
- ` After processing: RE-READ preference-candidates.jsonl and rewrite it keeping ONLY\n` +
140
- ` the entries that appeared after your first read — the extractor appends\n` +
141
- ` concurrently, so do NOT truncate the whole file (an entry queued between your\n` +
142
- ` read and your rewrite would be erased before any dream cycle reviewed it).\n` +
143
- ` Keep the file bounded: if it exceeds 200 lines, also drop the oldest entries\n` +
144
- ` beyond that cap.\n` +
145
- `4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
146
- ` If it does, read the timestamp — only process sessions newer than that.\n` +
147
- ` Session directory: find .jsonl files under the pi sessions directory.\n` +
148
- ` For each unprocessed session, extract durable knowledge (same categories as step 3).\n` +
149
- ` Limit: process at most 5 sessions per dream cycle to avoid overload.\n` +
150
- `5. Reorganize each topic file:\n` +
151
- ` - Remove duplicate or near-duplicate entries\n` +
152
- ` - Remove stale/useless entries\n` +
153
- ` - Remove session-scoped or expired entries — they are transient by design\n` +
154
- ` - Remove task-scoped instruction entries (issue #961): verbatim user utterances\n` +
155
- ` (colloquial chat captured raw), mid-sentence cutoffs, one-off deliverable asks\n` +
156
- ` ("when done I want a report"), version-pinned migration checklists, config-change\n` +
157
- ` notes — these are task/session artifacts, not durable rules. NEVER remove\n` +
158
- ` pinned or enforced entries even when they match these shapes.\n` +
159
- ` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
160
- ` - NEVER remove or modify entries marked with *(pinned)*\n` +
161
- ` - NEVER remove or modify entries marked with *(enforced)* — these have code-enforced triggers/actions that are keyed by text hash. ANY text change destroys the enforcement binding.\n` +
162
- `6. Write each updated topic file with this format:\n` +
163
- ` # TopicName\n` +
164
- ` \n` +
165
- ` - [category] summary *(pinned)* (if pinned)\n` +
166
- ` - [category] summary *(enforced)* (if enforced)\n` +
167
- ` - [category] summary *(pinned)* *(enforced)* (if both)\n` +
168
- ` - [category] summary (if neither)\n` +
169
- `7. Auto-generate skills: if you notice a multi-step workflow pattern across entries,\n` +
170
- ` create a skill file at .pi/skills/<name>/SKILL.md (project-level, NOT global ~/.agents/).\n` +
171
- ` The SKILL.md MUST start with YAML frontmatter:\n` +
172
- ` ---\n` +
173
- ` name: <name>\n` +
174
- ` description: "What this skill does and when to use it"\n` +
175
- ` ---\n` +
176
- ` Only create skills for workflows with 3+ steps that are likely to recur.\n` +
177
- `8. Write the current timestamp to ${topicsDir}/../.dream-watermark to track progress.\n` +
178
- `9. Memory rules: one line per entry, max ~100 chars, specific and actionable, no fluff.\n` +
179
- `10. Promotion destinations: append candidates to ${topicsDir}/../promotions.md using this block format:\n` +
180
- ` ### <12-char-id>\n` +
181
- ` - destination: memory|skill|enforcement|project_rule|discard\n` +
182
- ` - status: proposed\n` +
183
- ` - category: <category>\n` +
184
- ` - text: <exact topic entry text>\n` +
185
- ` - reason: <why this should graduate>\n` +
186
- ` - created: <ISO timestamp>\n` +
187
- ` Optional fields: evidence_count, trigger, action, verifier, skill_name, skill_created.\n` +
188
- ` Rules:\n` +
189
- ` - skill: create .pi/skills/ when confident; set skill_created: true\n` +
190
- ` - enforcement: propose trigger/action when mechanical (never/always + command); do NOT invent run_after\n` +
191
- ` - project_rule: propose only — NEVER write rules/ or .pi/rules/\n` +
192
- ` - discard: stale or superseded noise\n` +
193
- ` - Do not reopen entries already marked applied or rejected in promotions.md\n` +
194
- `11. Provenance sidecar (optional): for newly extracted entries, write\n` +
195
- ` ${topicsDir}/../provenance-pending.jsonl as a single line of JSON (preferred):\n` +
196
- ` {"entries":[{"category":"lesson","text":"<exact topic text>","sourceSession":"<session basename>","derivedFrom":"<optional>","informs":["optional"]}]}\n` +
197
- ` Do NOT edit memory-scores.jsonl yourself — onComplete merges the sidecar.`,
344
+ buildDreamPrompt(lastSessionFile, topicsDir),
198
345
  cwd,
199
346
  agents,
200
347
  {
@@ -206,6 +353,34 @@ export function registerDreaming(
206
353
  dreamInFlight = false;
207
354
  log.info("dream complete", cwd);
208
355
  updateDreamStatus();
356
+ // Undo any deletion of learned knowledge before anything reads the topics.
357
+ try {
358
+ const reviewLessons = distilledLessonTextList(getProjectDataDir(cwd));
359
+ const removed = removalsSince(cwd, dreamStartedAt);
360
+ const report = restoreProtectedTopicLines(topicsDir, topicsBeforeDream, reviewLessons, (line) =>
361
+ removed.has(entryHash(topicLineCanonical(line))),
362
+ );
363
+ if (report.restored > 0) {
364
+ log.warn("dream_restored_protected_entries", { restored: report.restored });
365
+ }
366
+ if (report.overCapTopics.length > 0) {
367
+ log.warn("dream_restore_left_topic_over_cap", { topics: report.overCapTopics });
368
+ }
369
+ if (report.failed > 0) {
370
+ log.error("dream_restore_write_failed", { failedTopics: report.failed });
371
+ }
372
+ if (report.restored > 0 || report.failed > 0 || report.overCapTopics.length > 0) {
373
+ fileLog(
374
+ "dreaming",
375
+ report.failed > 0 ? "error" : "warn",
376
+ "dreaming",
377
+ `protected-memory reconcile: restored=${report.restored} over_cap=${report.overCapTopics.join(",") || "-"} failed=${report.failed}`,
378
+ );
379
+ }
380
+ } catch (err) {
381
+ log.error("dream_restore_failed", { reason: String((err as Error)?.message ?? err).slice(0, 200) });
382
+ fileLog("dreaming", "error", "dreaming", "protected-memory restore failed", err);
383
+ }
209
384
  // File log only — console.* leaks into the chat text box.
210
385
  try {
211
386
  rebuildAndOrganize(cwd);
@@ -31,9 +31,10 @@ import {
31
31
  listTopics,
32
32
  readAllTopicEntries,
33
33
  CATEGORY_TO_TOPIC,
34
- MAX_TOPIC_CHARS,
34
+ topicCharCap,
35
35
  findTopicLine,
36
36
  invalidateTopicFileCache,
37
+ recordRemoval,
37
38
  type TopicInfo,
38
39
  } from "./memory-tree.js";
39
40
  import { embedEntry, removeEmbedding, vectorSearch, embedMissing } from "./memory-embeddings.js";
@@ -406,6 +407,9 @@ function registerMemoryRemove(pi: ExtensionAPI): void {
406
407
  }
407
408
  });
408
409
 
410
+ // Tombstone the removal: the dream guard must be able to tell this from its own damage.
411
+ recordRemoval(cwd, canonicalLine);
412
+
409
413
  log.info("memory_remove", category, text.slice(0, 60));
410
414
  return {
411
415
  content: [{ type: "text", text: `Removed: [${category}] ${text}` }],
@@ -551,8 +555,10 @@ function registerMemoryEdit(pi: ExtensionAPI): void {
551
555
  const markers = `${wasPinned ? " *(pinned)*" : ""}${wasEnforced ? " *(enforced)*" : ""}${wasId ? ` {#${wasId}}` : ""}`;
552
556
  const replacement = `- [${category}] ${newText}${markers}`;
553
557
  const newContent = content.replace(lines[matchIndex]!, replacement);
554
- if (newContent.length > MAX_TOPIC_CHARS) {
555
- return { content: [{ type: "text", text: `Update would exceed topic size limit (${newContent.length}/${MAX_TOPIC_CHARS} chars).` }], details: undefined };
558
+ const cap = topicCharCap(topicName);
559
+ if (newContent.length > cap) {
560
+ log.warn("memory_edit_topic_cap_rejected", { topic: topicName, chars: newContent.length, cap });
561
+ return { content: [{ type: "text", text: `Update would exceed topic size limit (${newContent.length}/${cap} chars).` }], details: undefined };
556
562
  }
557
563
  // Replace the line
558
564
  lines[matchIndex] = replacement;
@@ -12,8 +12,8 @@
12
12
 
13
13
  import { createLogger } from "../shared/logger.js";
14
14
  import { getProjectMemoryDir } from "./utils.js";
15
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
16
- import { join } from "node:path";
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
17
  import {
18
18
  type MemoryCategory,
19
19
  loadScores,
@@ -27,6 +27,65 @@ const log = createLogger("memory");
27
27
  /** Max chars per topic file (~3000 tokens × 4 chars/token) */
28
28
  export const MAX_TOPIC_CHARS = 12000;
29
29
 
30
+ /** Per-topic override of MAX_TOPIC_CHARS. `completions` accretes by default, so it gets
31
+ * a tighter budget: completed-work entries crowd out the knowledge worth keeping. */
32
+ export const TOPIC_CHAR_CAP: Record<string, number> = { completions: 6000 };
33
+
34
+ /** Char cap for a topic file name (without `.md`), falling back to MAX_TOPIC_CHARS. */
35
+ export function topicCharCap(topic: string): number {
36
+ const cap = TOPIC_CHAR_CAP[topic] ?? MAX_TOPIC_CHARS;
37
+ log.debug("topic_char_cap_resolved", { topic, cap, overridden: topic in TOPIC_CHAR_CAP });
38
+ return cap;
39
+ }
40
+
41
+ /** Append-only log of deliberate entry removals, so background maintenance can tell a
42
+ * user removal from its own damage. Score absence cannot: the scheduled rebuild prunes
43
+ * scores for lines missing from topic files (issue #1010). */
44
+ const REMOVALS_FILE = "removals.jsonl";
45
+ const MAX_REMOVAL_ROWS = 5000;
46
+
47
+ /** Record that an entry was removed on purpose. Fail-soft: a lost tombstone can only
48
+ * make the dream guard restore a line the user deleted, never delete one. */
49
+ export function recordRemoval(cwd: string, canonicalLine: string): void {
50
+ try {
51
+ const file = join(getProjectMemoryDir(cwd), REMOVALS_FILE);
52
+ const row = `${JSON.stringify({ hash: entryHash(canonicalLine), at: new Date().toISOString() })}\n`;
53
+ mkdirSync(dirname(file), { recursive: true });
54
+ appendFileSync(file, row, "utf-8");
55
+ log.debug("memory_removal_recorded", { hash: entryHash(canonicalLine) });
56
+ const rows = readFileSync(file, "utf-8").split("\n").filter(Boolean);
57
+ if (rows.length > MAX_REMOVAL_ROWS) {
58
+ writeFileSync(file, `${rows.slice(-MAX_REMOVAL_ROWS).join("\n")}\n`, "utf-8");
59
+ }
60
+ } catch (error) {
61
+ log.warn("memory_removal_record_failed", { reason: String((error as Error)?.message ?? error).slice(0, 120) });
62
+ }
63
+ }
64
+
65
+ /** Hashes removed at or after `sinceMs` — the only trustworthy signal that a line
66
+ * missing after a background dream was the user's decision, not the dream's damage. */
67
+ export function removalsSince(cwd: string, sinceMs: number): Set<string> {
68
+ const hashes = new Set<string>();
69
+ try {
70
+ const file = join(getProjectMemoryDir(cwd), REMOVALS_FILE);
71
+ if (!existsSync(file)) return hashes;
72
+ for (const row of readFileSync(file, "utf-8").split("\n")) {
73
+ if (!row.trim()) continue;
74
+ try {
75
+ const parsed = JSON.parse(row) as { hash?: unknown; at?: unknown };
76
+ if (typeof parsed.hash !== "string" || typeof parsed.at !== "string") continue;
77
+ if (Date.parse(parsed.at) >= sinceMs) hashes.add(parsed.hash);
78
+ } catch {
79
+ // Skip a malformed row rather than losing the whole log.
80
+ }
81
+ }
82
+ log.debug("memory_removals_since", { sinceMs, count: hashes.size });
83
+ } catch (error) {
84
+ log.warn("memory_removals_read_failed", { reason: String((error as Error)?.message ?? error).slice(0, 120) });
85
+ }
86
+ return hashes;
87
+ }
88
+
30
89
  /** Topic mapping: which categories go into which topic files */
31
90
  export const CATEGORY_TO_TOPIC: Record<MemoryCategory, string> = {
32
91
  preference: "preferences",
@@ -39,7 +39,7 @@ import {
39
39
  isTaskScopedText,
40
40
  hasDurabilityMarker,
41
41
  } from "./memory-scoring.js";
42
- import { CATEGORY_TO_TOPIC, MAX_TOPIC_CHARS, readAllTopicEntries, formatTopicLine, findTopicLine, invalidateTopicFileCache } from "./memory-tree.js";
42
+ import { CATEGORY_TO_TOPIC, topicCharCap, readAllTopicEntries, formatTopicLine, findTopicLine, invalidateTopicFileCache, recordRemoval } from "./memory-tree.js";
43
43
  import { embedEntry, removeEmbedding, vectorSearch } from "./memory-embeddings.js";
44
44
  import { getSetting } from "./project-settings.js";
45
45
 
@@ -2165,6 +2165,8 @@ function supersedeEntry(cwd: string, category: string, text: string): void {
2165
2165
  removeEmbedding(cwd, text, category);
2166
2166
 
2167
2167
  const canonicalLine = `- [${category}] ${text}`;
2168
+ // The old text is gone on purpose: tombstone it so the dream guard does not undo an edit.
2169
+ recordRemoval(cwd, canonicalLine);
2168
2170
  const removedId = match.id;
2169
2171
  mutateScores(cwd, (scores) => {
2170
2172
  delete scores.entries[entryHash(canonicalLine)];
@@ -2197,11 +2199,13 @@ function topicCapRejection(cwd: string, category: string, entryLine: string): Ad
2197
2199
  content = `# ${title}\n`;
2198
2200
  }
2199
2201
  const newContent = content.trimEnd() + "\n" + entryLine + "\n";
2200
- if (newContent.length > MAX_TOPIC_CHARS) {
2202
+ const cap = topicCharCap(topicName);
2203
+ if (newContent.length > cap) {
2204
+ log.warn("memory_topic_cap_rejected", { topic: topicName, chars: newContent.length, cap });
2201
2205
  return {
2202
2206
  status: "rejected",
2203
2207
  message:
2204
- `Topic "${topicName}" would exceed size limit (${newContent.length}/${MAX_TOPIC_CHARS} chars). ` +
2208
+ `Topic "${topicName}" would exceed size limit (${newContent.length}/${cap} chars). ` +
2205
2209
  `Consolidate or remove entries first using memory_remove.`,
2206
2210
  };
2207
2211
  }
@@ -87,6 +87,8 @@ const MAX_LESSON_CHARS = 100;
87
87
  const STATE_FILE_NAME = "review-learning-state.json";
88
88
  /** Cap on tracked review ids — old reviews never re-distill in practice. */
89
89
  const MAX_TRACKED_REVIEWS = 200;
90
+ /** Cap on the long-lived protection list that outlives MAX_TRACKED_REVIEWS. */
91
+ const MAX_PROTECTED_LESSON_TEXTS = 5000;
90
92
 
91
93
  // ── Command matching ───────────────────────────────────────────────────────
92
94
 
@@ -350,17 +352,58 @@ export function loadReviewLessons(dataDir: string): Map<number, Set<string>> {
350
352
  return byReview;
351
353
  }
352
354
 
355
+ /** Every distilled review-lesson text recorded in the state file, flattened.
356
+ * Memory maintenance must treat these as learned knowledge, not history: the source
357
+ * (the reviews DB) keeps the finding, but nothing re-materializes the topic line
358
+ * once it is gone, so a dream that deletes one loses it for good (issue #1010).
359
+ * Unions the bounded per-review map with the long-lived protection list so lessons
360
+ * from reviews older than the tracking window stay protected. */
361
+ export function distilledLessonTextList(dataDir: string): string[] {
362
+ const texts = new Set<string>();
363
+ for (const lessons of loadReviewLessons(dataDir).values()) {
364
+ for (const lesson of lessons) if (lesson.trim()) texts.add(lesson.trim());
365
+ }
366
+ for (const lesson of loadProtectedLessonTexts(dataDir)) if (lesson.trim()) texts.add(lesson.trim());
367
+ log.debug("distilled_lesson_texts_loaded", { count: texts.size });
368
+ return [...texts];
369
+ }
370
+
353
371
  function writeStateFile(dataDir: string, distilledReviews: number[], reviewLessons: Map<number, Set<string>>): void {
354
372
  const lessons: Record<string, string[]> = {};
355
373
  const ids = [...reviewLessons.keys()];
356
374
  for (const id of ids.slice(-MAX_TRACKED_REVIEWS)) lessons[String(id)] = [...reviewLessons.get(id)!];
375
+ // Protection set for older reviews: `lessons` is bounded by MAX_TRACKED_REVIEWS, so
376
+ // without this union a lesson from review #1 stops being recognizable as reviewed
377
+ // knowledge once 200 newer reviews land, and memory maintenance may delete it (#1010).
378
+ const protectedTexts = new Set(loadProtectedLessonTexts(dataDir));
379
+ for (const set of reviewLessons.values()) for (const text of set) if (text.trim()) protectedTexts.add(text.trim());
357
380
  mkdirSync(dataDir, { recursive: true });
358
381
  writeFileSync(
359
382
  stateFilePath(dataDir),
360
- `${JSON.stringify({ distilledReviews: distilledReviews.slice(-MAX_TRACKED_REVIEWS), reviewLessons: lessons }, null, 2)}\n`,
383
+ `${JSON.stringify(
384
+ {
385
+ distilledReviews: distilledReviews.slice(-MAX_TRACKED_REVIEWS),
386
+ reviewLessons: lessons,
387
+ protectedLessonTexts: [...protectedTexts].slice(-MAX_PROTECTED_LESSON_TEXTS),
388
+ },
389
+ null,
390
+ 2,
391
+ )}\n`,
361
392
  );
362
393
  }
363
394
 
395
+ /** Lesson texts that must stay recognizable as reviewed knowledge, independent of
396
+ * the bounded per-review map. Empty on a fresh or unreadable state file. */
397
+ export function loadProtectedLessonTexts(dataDir: string): string[] {
398
+ try {
399
+ const raw = JSON.parse(readFileSync(stateFilePath(dataDir), "utf-8"));
400
+ const texts = Array.isArray(raw?.protectedLessonTexts) ? raw.protectedLessonTexts : [];
401
+ return texts.filter((text: unknown): text is string => typeof text === "string" && text.trim().length > 0);
402
+ } catch {
403
+ return [];
404
+ }
405
+ }
406
+
364
407
  /** Record a review id as distilled (bounded — oldest entries drop off). Fail-soft. */
365
408
  export function markReviewDistilled(dataDir: string, reviewId: number): void {
366
409
  try {
@@ -16,6 +16,8 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { createLogger } from "../shared/logger.js";
19
+ import { sendSystemNote } from "../shared/system-note.js";
20
+ import { publishedTaskStore } from "../shared/task-store-bridge.js";
19
21
  import { createBridgedUi } from "../shared/ui-bridge.js";
20
22
  import {
21
23
  getMarkdownTheme,
@@ -30,12 +32,44 @@ import {
30
32
  import { Type } from "typebox";
31
33
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
32
34
 
33
- // Task store access via shared instance from extensions/pitasks
34
- let _taskStore: any = null;
35
+ // Task store access via the pitasks module.
36
+ // `taskStore` is `export let` — a live binding the pitasks extension assigns when it
37
+ // initializes. Copying the value at import time captured `undefined` and disabled
38
+ // task-id validation for the whole session (issue #1016), so the binding is read fresh.
39
+ let _pitasksModule: any = null;
40
+ let _pitasksLoad: Promise<any> | null = null;
41
+ async function pitasksModule(): Promise<any> {
42
+ if (_pitasksModule) return _pitasksModule;
43
+ if (!_pitasksLoad) {
44
+ _pitasksLoad = (async () => {
45
+ try {
46
+ _pitasksModule = await import("../pitasks/index.js");
47
+ return _pitasksModule;
48
+ } catch (err) {
49
+ log.warn(`[subagent] pitasks module unavailable: ${String((err as Error)?.message ?? err).slice(0, 120)}`);
50
+ _pitasksLoad = null; // retry on the next call
51
+ return null;
52
+ }
53
+ })();
54
+ }
55
+ return _pitasksLoad;
56
+ }
57
+
58
+ /** The live task store, or null while pitasks has not initialized it yet.
59
+ * Read from the global symbol registry: this tool's module instance cannot see the
60
+ * pitasks extension's `export let taskStore` binding (it snapshots `undefined`), which
61
+ * is why task-id validation silently failed open (issue #1016). */
35
62
  async function getTaskStore(): Promise<any> {
36
- if (_taskStore) return _taskStore;
37
- try { const mod = await import("../pitasks/index.js"); _taskStore = mod.taskStore; } catch { /* ignore */ }
38
- return _taskStore;
63
+ const published = publishedTaskStore();
64
+ if (published) return published;
65
+ // Fall back to the module binding for in-process loads that share this instance.
66
+ const mod = await pitasksModule();
67
+ const store = mod?.taskStore ?? null;
68
+ if (!store) {
69
+ // Validation is about to be skipped: that is a degraded state, not routine flow.
70
+ log.warn("task_store_unavailable", { note: "pitasks store not initialized — taskId validation skipped" });
71
+ }
72
+ return store;
39
73
  }
40
74
  import {
41
75
  decideAsyncLlmDispatch,
@@ -153,7 +187,7 @@ const SubagentParams = Type.Object({
153
187
  Type.String({ description: "Display name for async agents in status line and notifications (e.g., 'Dream', 'Code Review'). Defaults to agent name." }),
154
188
  ),
155
189
  taskId: Type.Optional(
156
- Type.String({ description: "Task ID to auto-complete when this async agent finishes. Required for async agents — pass \"-1\" if not linked to any task." }),
190
+ Type.String({ description: "Task ID to auto-complete when this async agent finishes. Required for async agents — pass \"-1\" only for a single read-only check; an agent that edits, pushes, or drives a PR needs a real task id." }),
157
191
  ),
158
192
  asyncKill: Type.Optional(
159
193
  Type.String({ description: "Kill async agent(s) by name, id prefix, or 'all'. Returns which agents were killed." }),
@@ -577,22 +611,27 @@ export async function runSingleAgent(
577
611
  }
578
612
  }
579
613
 
580
- /** Validate that a taskId references an existing task in the pitasks store. */
581
- function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): string | null {
614
+ /** The message for a task id the store does not know; null when it is valid or exempt. */
615
+ export function taskIdError(taskId: string, lookup: (id: string) => boolean): string | null {
616
+ if (taskId === "-1") return null;
617
+ return lookup(taskId)
618
+ ? null
619
+ : `Task #${taskId} not found. Verify the task ID exists (use TaskList to check), or pass taskId: "-1" for a read-only check (editing or PR work needs a real task id).`;
620
+ }
621
+
622
+ /** Validate that a taskId references an existing task. Awaits the store load, so the
623
+ * first async call of a session validates too instead of failing open (issue #1016). */
624
+ async function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): Promise<string | null> {
582
625
  if (taskId === "-1") return null;
626
+ const store = await getTaskStore();
627
+ if (!store) return null; // pitasks genuinely unavailable — warned in getTaskStore
583
628
  try {
584
- // Ensure store is loaded (async init may not have completed)
585
- if (!_taskStore) {
586
- getTaskStore().catch(() => {});
587
- }
588
- const store = _taskStore;
589
- if (!store) {
590
- // Store not loaded yet (async init) — fail open, skip validation
591
- return null;
592
- }
593
- if (store.get(taskId)) return null;
594
- } catch { return null; }
595
- return `Task #${taskId} not found. Verify the task ID exists (use TaskList to check), or pass taskId: "-1" if not linked to a task.`;
629
+ return taskIdError(taskId, (id) => Boolean(store.get(id)));
630
+ } catch (err) {
631
+ // Same fail-open as before, but a broken store must not look like a valid id.
632
+ log.warn("task_id_validation_failed", { reason: String((err as Error)?.message ?? err).slice(0, 120) });
633
+ return null;
634
+ }
596
635
  }
597
636
 
598
637
  // ── Registration ─────────────────────────────────────────────────────────
@@ -629,7 +668,7 @@ export function registerSubagentTool(
629
668
  };
630
669
  }
631
670
 
632
- function executeAsync(
671
+ async function executeAsync(
633
672
  params: any,
634
673
  agents: AgentConfig[],
635
674
  mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
@@ -659,13 +698,13 @@ export function registerSubagentTool(
659
698
  }
660
699
  const noTaskId = params.tasks.filter((t: any) => (t as any).taskId == null);
661
700
  if (noTaskId.length > 0) {
662
- const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nMissing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}\n\nIf an agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf an agent is NOT linked to any task: pass taskId: "-1"`;
701
+ const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nMissing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}\n\nIf an agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf an agent is only doing a single read-only check: pass taskId: "-1" (editing or PR work needs a real task id)`;
663
702
  // Force a follow-up turn so the AI MUST deal with this error
664
- pi.sendMessage({
665
- customType: "subagent-taskid-error",
666
- content: `🚨 CRITICAL: Async subagent call was REJECTED — missing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}. NO agents were spawned. You MUST retry with taskId for each agent. Use TaskList to find task IDs, or pass taskId: "-1" if not linked.`,
667
- display: true,
668
- }, { triggerTurn: true, deliverAs: "followUp" });
703
+ await sendSystemNote(
704
+ pi,
705
+ `🚨 CRITICAL: Async subagent call was REJECTED — missing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}. NO agents were spawned. You MUST retry with taskId for each agent. Use TaskList to find task IDs; "-1" is only for a single read-only check.`,
706
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
707
+ );
669
708
  return {
670
709
  content: [{ type: "text" as const, text: errorMsg }],
671
710
  details: mkd("single")([]),
@@ -674,13 +713,13 @@ export function registerSubagentTool(
674
713
  }
675
714
  for (const t of params.tasks) {
676
715
  const tid = (t as any).taskId as string;
677
- const taskErr = validateTaskId(tid, ctx.cwd, ctx.sessionManager?.getSessionId?.());
716
+ const taskErr = await validateTaskId(tid, ctx.cwd, ctx.sessionManager?.getSessionId?.());
678
717
  if (taskErr) {
679
- pi.sendMessage({
680
- customType: "subagent-taskid-error",
681
- content: `🚨 CRITICAL: ${taskErr} (agent: ${t.agent}). NO agents were spawned. Fix the taskId and retry.`,
682
- display: true,
683
- }, { triggerTurn: true, deliverAs: "followUp" });
718
+ await sendSystemNote(
719
+ pi,
720
+ `🚨 CRITICAL: ${taskErr} (agent: ${t.agent}). NO agents were spawned. Fix the taskId and retry.`,
721
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
722
+ );
684
723
  return {
685
724
  content: [{ type: "text" as const, text: `${taskErr} (agent: ${t.agent})` }],
686
725
  details: mkd("single")([]),
@@ -747,13 +786,13 @@ export function registerSubagentTool(
747
786
  };
748
787
  }
749
788
  if (params.taskId == null) {
750
- const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nIf this agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf this agent is NOT linked to any task: pass taskId: "-1"\n\nExample: subagent(agent="${params.agent}", task="...", async=true, name="${params.name}", taskId="-1")`;
789
+ const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nIf this agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf this agent is only doing a single read-only check: pass taskId: "-1" (editing or PR work needs a real task id)\n\nExample: subagent(agent="${params.agent}", task="...", async=true, name="${params.name}", taskId="-1")`;
751
790
  // Force a follow-up turn so the AI MUST deal with this error
752
- pi.sendMessage({
753
- customType: "subagent-taskid-error",
754
- content: `🚨 CRITICAL: Async subagent call for "${params.agent}" was REJECTED — missing taskId. The agent was NOT spawned. You MUST retry the subagent call with taskId. Use TaskList to find the task ID, or pass taskId: "-1" if not linked to any task. Do NOT proceed without fixing this.`,
755
- display: true,
756
- }, { triggerTurn: true, deliverAs: "followUp" });
791
+ await sendSystemNote(
792
+ pi,
793
+ `🚨 CRITICAL: Async subagent call for "${params.agent}" was REJECTED — missing taskId. The agent was NOT spawned. You MUST retry the subagent call with taskId. Use TaskList to find the task ID; "-1" is only for a single read-only check. Do NOT proceed without fixing this.`,
794
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
795
+ );
757
796
  return {
758
797
  content: [{ type: "text" as const, text: errorMsg }],
759
798
  details: mkd("single")([]),
@@ -761,13 +800,13 @@ export function registerSubagentTool(
761
800
  };
762
801
  }
763
802
  {
764
- const taskErr = validateTaskId(params.taskId, ctx.cwd, ctx.sessionManager?.getSessionId?.());
803
+ const taskErr = await validateTaskId(params.taskId, ctx.cwd, ctx.sessionManager?.getSessionId?.());
765
804
  if (taskErr) {
766
- pi.sendMessage({
767
- customType: "subagent-taskid-error",
768
- content: `🚨 CRITICAL: ${taskErr} The agent was NOT spawned. Fix the taskId and retry.`,
769
- display: true,
770
- }, { triggerTurn: true, deliverAs: "followUp" });
805
+ await sendSystemNote(
806
+ pi,
807
+ `🚨 CRITICAL: ${taskErr} The agent was NOT spawned. Fix the taskId and retry.`,
808
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
809
+ );
771
810
  return {
772
811
  content: [{ type: "text" as const, text: taskErr }],
773
812
  details: mkd("single")([]),
@@ -1689,7 +1728,7 @@ export function registerSubagentTool(
1689
1728
 
1690
1729
  // Async mode — spawn in background and return immediately
1691
1730
  if (params.async === true) {
1692
- const result = executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx, explicit);
1731
+ const result = await executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx, explicit);
1693
1732
  if (capabilityNote && result.content?.[0]?.type === "text") {
1694
1733
  result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
1695
1734
  }
@@ -11,6 +11,7 @@ import { TaskStore } from "./task-store.js";
11
11
  import { TaskWidget } from "./task-widget.js";
12
12
  import { registerTaskTools } from "./task-tools.js";
13
13
  import { createLogger } from "../shared/logger.js";
14
+ import { publishTaskStore } from "../shared/task-store-bridge.js";
14
15
  import { createBridgedUi } from "../shared/ui-bridge.js";
15
16
  import { getSetting } from "../orchestrator/project-settings.js";
16
17
  import { shouldSkipOneshotRegister } from "../shared/oneshot.js";
@@ -43,6 +44,11 @@ export function setTaskTelemetryActive(taskId: string, active = true): void {
43
44
  /** Exported store instance — set during extension init. */
44
45
  export let taskStore: TaskStore;
45
46
 
47
+ // The store is published to other extension modules through the shared bridge: they
48
+ // cannot read this `export let` binding (they would snapshot `undefined`), which silently
49
+ // disabled task-id validation (issue #1016). Published once, from registration only —
50
+ // the bridge's getter follows later store swaps, so the upgrade path needs no republish.
51
+
46
52
  /** Create a task directly — for use by other extensions (e.g., coms auto-create). */
47
53
  export function createTask(subject: string, description: string, createdBy?: any, metadata?: Record<string, any>): any {
48
54
  return taskStore.create(subject, description, createdBy || { type: "local", origin: "system", session: "", project: "" }, undefined, metadata);
@@ -189,6 +195,8 @@ export default function (pi: ExtensionAPI) {
189
195
  currentWidget?.dispose();
190
196
  let store = new TaskStore(resolveStorePath());
191
197
  taskStore = store;
198
+ // Registration is the only publish site: the getter follows store swaps.
199
+ publishTaskStore(() => taskStore);
192
200
  currentStore = store;
193
201
  const widget = new TaskWidget(store);
194
202
  store.setOnChange(() => { widget.update(); broadcastTasks(); });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Provenance-marked injections for content the user did NOT write.
3
+ *
4
+ * The transcript gives an extension no room to say "this is not the user's words":
5
+ * `pi.sendMessage` renders as `role: "custom"` and `pi.sendUserMessage` renders as a
6
+ * user turn, so machine text arrives looking exactly like something the user typed.
7
+ * A model reading it will answer "as you said" and attribute its own guard rails to the
8
+ * user (issue #1016 review, where a REJECTED call looked like a user message).
9
+ *
10
+ * Every message this module sends starts with MARKER, which is self-explanatory on
11
+ * purpose: no rule has to teach the model what it means.
12
+ */
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { createLogger } from "./logger.js";
15
+
16
+ const log = createLogger("system-note");
17
+
18
+ /** Prefix that identifies machine-generated text in the transcript. */
19
+ export const SYSTEM_NOTE_MARKER = "[system message — not written by the user]";
20
+
21
+ export interface SystemNoteOptions {
22
+ /** Stable kind for renderers/filters, e.g. "subagent-taskid-error". */
23
+ customType: string;
24
+ /** Start a turn from this message when the session is idle. */
25
+ triggerTurn?: boolean;
26
+ /** Delivery slot for a busy session. */
27
+ deliverAs?: "steer" | "followUp" | "nextTurn";
28
+ /** Render in the TUI transcript. */
29
+ display?: boolean;
30
+ }
31
+
32
+ /** Send machine-generated text, marked so it cannot be mistaken for user input. */
33
+ export async function sendSystemNote(
34
+ pi: ExtensionAPI,
35
+ text: string,
36
+ options: SystemNoteOptions,
37
+ ): Promise<void> {
38
+ const body = text.startsWith(SYSTEM_NOTE_MARKER) ? text : `${SYSTEM_NOTE_MARKER}\n${text}`;
39
+ log.debug("send_system_note", { customType: options.customType, chars: body.length });
40
+ await pi.sendMessage(
41
+ { customType: options.customType, content: body, display: options.display ?? true },
42
+ { triggerTurn: options.triggerTurn, deliverAs: options.deliverAs },
43
+ );
44
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cross-module bridge for the pitasks store.
3
+ *
4
+ * `pitasks` exports `taskStore` as `export let`, assigned when the extension
5
+ * initializes. Another extension's module instance cannot read that binding: it
6
+ * snapshots `undefined` at import time, which silently disabled task-id validation
7
+ * (issue #1016). The store is therefore published on the global symbol registry, and
8
+ * the published object reads the live binding on every access — publishing once is
9
+ * enough, and a store swap needs no republish.
10
+ *
11
+ * Publish only from the instance that owns the store (pitasks registration), never from
12
+ * module-load code: a duplicate import would otherwise displace the real getter with one
13
+ * bound to a store that instance never creates.
14
+ *
15
+ * Same pattern as the pricing-provenance and session-cwd markers.
16
+ */
17
+ import { createLogger } from "./logger.js";
18
+
19
+ const log = createLogger("task-store-bridge");
20
+
21
+ export const TASK_STORE_SYMBOL = Symbol.for("pi-config.taskStore");
22
+
23
+ interface TaskStoreBridge {
24
+ readonly current: unknown;
25
+ }
26
+
27
+ /** Publish a getter over the live store binding. Called by the owning extension. */
28
+ export function publishTaskStore(getStore: () => unknown): void {
29
+ (globalThis as Record<symbol, unknown>)[TASK_STORE_SYMBOL] = {
30
+ get current(): unknown {
31
+ return getStore();
32
+ },
33
+ } satisfies TaskStoreBridge;
34
+ log.debug("task_store_bridge_published", { at: Date.now() });
35
+ }
36
+
37
+ /** The published store, or null when the owning extension has not registered. */
38
+ export function publishedTaskStore(): unknown {
39
+ const bridge = (globalThis as Record<symbol, unknown>)[TASK_STORE_SYMBOL] as TaskStoreBridge | undefined;
40
+ const store = bridge?.current ?? null;
41
+ log.debug("task_store_bridge_read", { found: Boolean(store) });
42
+ return store;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.24.0",
3
+ "version": "4.24.2",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -34,7 +34,8 @@ Outside slash commands, never use edit, write, or bash directly (except `codemod
34
34
 
35
35
  ## Before Implementation (MANDATORY)
36
36
 
37
- **For 2+ distinct actions, your first action is one `TaskBulkCreate` listing every step** — before the first edit or the first agent call (see `60-task-tracking.md`).
37
+ **For 2+ distinct actions — requested, or set in motion by an async result — your first action is one `TaskBulkCreate` listing every step**
38
+ — before the first edit or the first agent call (see `60-task-tracking.md`).
38
39
 
39
40
  Before ANY code changes, run the pre-implementation checklist:
40
41
 
@@ -34,7 +34,7 @@ its output. Use it ONLY for work whose result nobody consumes — in pi-config,
34
34
  dreaming and nothing else. Every watcher, poll, and review agent exists to report
35
35
  something back, so leave `fireAndForget` unset for them. If you are about to set it,
36
36
  ask whether you will ever read the result; if yes, do not set it.
37
- **`taskId` is required on every async call** (use `-1` when unlinked) — a call without
37
+ **`taskId` is required on every async call** (`-1` only for a single read-only check; anything that edits, pushes, or drives a PR needs a real task id) — a call without
38
38
  it is rejected, and the agent does not run.
39
39
 
40
40
  Do NOT write bash loops, sleep commands, or poll for results after spawning async agents.
@@ -9,12 +9,13 @@
9
9
 
10
10
  ## When Tasks Are Required
11
11
 
12
- The request needs **2+ distinct actions** — then tasks are required:
12
+ The request — **or work any step sets in motion** — needs **2+ distinct actions** — then tasks are required:
13
13
 
14
14
  - more than one file to change, or code plus tests plus docs
15
15
  - any workflow from `05-issue-first-workflow.md` or `20-code-review-loop.md`
16
16
  - the user lists several things to do ("do X, then Y", numbered items)
17
17
  - work conditioned on a future event ("after Qodo approves, deploy and test") — create the tasks now, put the trigger in the description, and block the dependents on it
18
+ - an async result comes back with follow-up work — reacting only to finished agents still needs a board
18
19
 
19
20
  **No tasks:** a single-step action, a one-file/one-line change, a question, an explanation, read-only investigation.
20
21
 
@@ -22,6 +23,10 @@ The request needs **2+ distinct actions** — then tasks are required:
22
23
 
23
24
  Plan the whole job in **one `TaskBulkCreate` call** listing every step — not one `TaskCreate` per step, and not after the first edit. `TaskCreate` is for a single step added later.
24
25
 
26
+ **Before dispatching new work, reconcile the board (MANDATORY):** complete, re-scope, or
27
+ delete stale tasks. Dispatching over a task that is `in_progress` for work that already changed
28
+ is a violation.
29
+
25
30
  When `TaskCreate`'s own tool description suggests per-step calls, **this rule wins**: one bulk call for the whole job.
26
31
 
27
32
  ## Granularity
@@ -31,10 +36,22 @@ One concrete action per task: which file, which change.
31
36
  - ❌ `Implement the fix` / `Create PR`
32
37
  - ✅ `Edit extensions/orchestrator/utils.ts — add timeout parameter to fetchData()` / `Create PR with the test plan`
33
38
 
39
+ **Multi-issue work:** one task per **stage** per issue, named `#<n>: <stage>` (e.g.
40
+ `#1016: contract test`). Later stages carry `blockedBy` in the bulk create — `addBlockedBy` is an
41
+ update field, and stage ids only exist after the call returns, so wire them with `TaskUpdate` if
42
+ the order was not set at creation. File-level detail lives in the description, not the subject.
43
+
34
44
  ## Lifecycle
35
45
 
36
46
  `in_progress` before starting a task, `completed` immediately after. Work in order. Never abandon a task — drop a stale one with `TaskUpdate status: "deleted"`.
37
47
 
48
+ **When an async result arrives, update the board in that same turn:** on success complete the
49
+ owning task, then plan any follow-up in one `TaskBulkCreate` and activate its first stage. A
50
+ failed agent keeps its task open — the runtime never auto-completes a failure, and closing it
51
+ would activate a stage whose input never arrived.
52
+
53
+ Status reports come from `TaskList`, not recollection.
54
+
38
55
  ## Side Questions
39
56
 
40
57
  Answer the question, then resume the next unchecked task — the task list says where you left off.
@@ -44,14 +61,18 @@ Answer the question, then resume the next unchecked task — the task list says
44
61
  ## Async Agent taskId (MANDATORY — code-enforced)
45
62
 
46
63
  **Every async agent call MUST include `taskId`.** Enforced — calls without it are rejected.
47
- Pass the task ID (e.g., `"5"`) when linked to a task, or `"-1"` when not.
48
- Linked tasks auto-complete on agent success — no manual `TaskUpdate` needed.
64
+ Pass the id of the task the agent performs (e.g. `"5"`). `"-1"` is only for a single read-only
65
+ check that changes nothing: an agent that edits code, pushes, or drives a PR must carry a real
66
+ task id, because an unlinked poll is work that never appears on the board.
49
67
 
50
68
  ```text
51
69
  subagent(agent="code-reviewer-quality", task="...", cwd="...", async=true, name="Review", taskId="5")
52
- subagent(agent="worker", task="...", cwd="...", async=true, name="Qodo Poll", taskId="-1")
70
+ subagent(agent="worker", task="...", cwd="...", async=true, name="Read one file", taskId="-1")
53
71
  ```
54
72
 
55
- - ✅ **ALWAYS** pass `taskId` — auto-completes on success, stays `in_progress` on failure
56
- - ❌ **NEVER** manually `TaskUpdate` a task to `completed` if it has an async agent
73
+ **Stage tasks are yours to close.** An agent links to the narrow task it performs; the stage
74
+ completes when the stage's acceptance holds — binding a stage to a poll marks it done when
75
+ only the poll finished.
76
+
77
+ - ❌ **NEVER** let a narrow agent task stand in for a stage — auto-complete ends the task the agent was linked to
57
78
  - ❌ **NEVER** omit `taskId` — the call will fail