pi-orchestrator-config 4.18.0 → 4.20.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
@@ -805,6 +805,8 @@ PI_PIDIFF_ENABLE=false pi
805
805
  | `myk-pi-tools pr get-skipped-comments` | Get previously skipped review comments for a PR |
806
806
  | `myk-pi-tools pr claude-md` | Fetch `CLAUDE.md` and `AGENTS.md` content for a PR's repository |
807
807
  | `myk-pi-tools release create` | Create a GitHub release with changelog |
808
+ | `myk-pi-tools release plan` | Scoped-release plan: which packages changed since their last publish (per-package registry-anchored diff windows). Detection only — no proposed versions. Read-only; requires `release-map.toml` at the repo root — without it, reports lockstep mode and changes nothing (issue #965) |
809
+ | `myk-pi-tools release bump-scoped` | Bump ONLY the affected packages' version files. Versions are ALWAYS explicit: `--set NAME=VERSION` per affected package (the versions the LLM/user decided in the `/release` flow) or a global `--version X.Y.Z` — the plan never proposes versions. Each package versions from its own registry history (no global repo-tag guard — no forced jumps for long-skipped packages); tags are per-package `<name>@<version>`. Versions are validated (strict x.y.z, no leading zeros, strictly greater than the registry-published version). Errored packages, undetected version files, or partially-updatable manifests abort before or at the first bad write. Requires `release-map.toml` — without it, exit 1 (use `bump-version` for lockstep) (issue #965) |
808
810
  | `myk-pi-tools db` | Review database query commands |
809
811
  | `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store, store-session) |
810
812
  | `myk-pi-tools reviews poll` | Poll a PR for review comments until approved or actionable — `--source qodo` reads findings through the `qodo` CLI (structured, with spans and authoritative open/closed status) when the binary is installed and authenticated, and falls back to the GitHub sticky-comment scrape otherwise. Read-only: replies are always posted as GitHub comments via `reviews address` |
@@ -815,6 +817,51 @@ PI_PIDIFF_ENABLE=false pi
815
817
  | `myk-pi-tools coderabbit` | CodeRabbit commands (check, trigger, retry) |
816
818
  | `myk-pi-tools settings get [key ...]` | Resolve settings (project → global → env → default). No args = all keys |
817
819
 
820
+ ### Scoped releases
821
+
822
+ By default every release is **lockstep**: all detected version files bump together, exactly as before.
823
+ A repo can opt into **scoped releases** by committing a `release-map.toml` at its root
824
+ (see this repo's own map for the format): a `[release]` section, one `[[package]]` block per
825
+ published artifact (`name`, `registry` `npm`/`pypi`/`npm+pypi`, `version-files`, `paths`,
826
+ optional `exclude-paths`, `npm-name`/`pypi-name` overrides for scoped or renamed registry
827
+ names), and a `[container]` `rebuild-paths` list.
828
+
829
+ With a map present, `myk-pi-tools release plan` (read-only) reports which packages changed since **their own last publish**:
830
+
831
+ ```bash
832
+ myk-pi-tools release plan # human-readable plan
833
+ myk-pi-tools release plan --json # machine-readable, for tooling
834
+ myk-pi-tools release plan --force-package pi-docsite
835
+ ```
836
+
837
+ - The last published version comes from the registries (`npm view`, PyPI JSON API) — ground truth that cannot drift from tags.
838
+ - Each package's diff window is anchored at the commit where its version file was bumped to
839
+ that published version, so a package skipped for several releases reaches back to its own last publish.
840
+ - Global excludes configured in the map (e.g. `tests/`) never trigger a release by themselves;
841
+ multi-registry packages must agree across their registries or they are reported as errored,
842
+ never silently skipped.
843
+ - The plan also reports **unmapped changes**: files changed since the last repo tag that
844
+ belong to no package, no exclude, and no container rebuild-path. A change that ships
845
+ nowhere silently is the broken failure mode — the operator maps the path or consciously
846
+ accepts it as release-inert.
847
+ - The container image is reported as needing a rebuild when any package ships or a `rebuild-paths` entry changed since the last repo tag.
848
+
849
+ `myk-pi-tools release bump-scoped` performs the scoped version bump: only affected
850
+ packages' version files are written, and any errored package or undetected version file
851
+ aborts before a single write. Each package versions from **its own registry history** —
852
+ there is deliberately no global repo-tag guard, so a package skipped for several releases
853
+ takes the next version after its own last publish (never a forced jump past the global tag
854
+ sequence). Tags are **per package** (`<name>@<version>`) and cannot collide. The plan
855
+ detects WHAT changed and nothing more — no proposed versions, no bump types. The next
856
+ versions are decided by the LLM and the user in `/release` and passed as
857
+ `--set NAME=VERSION` (without `--set` or `--version`, `bump-scoped` refuses to run).
858
+ `/release X.Y.Z` overrides every affected package to that version. Each shipped package
859
+ gets its own GitHub release tagged `<name>@<version>`. See the Scoped Mode Check in the
860
+ release prompt.
861
+
862
+ Without a `release-map.toml`, `release plan` reports lockstep mode and nothing else changes —
863
+ the generic release flow is untouched.
864
+
818
865
  `reviews address` — bulk-apply replies to a fetched reviews JSON, then post and store it:
819
866
 
820
867
  ```bash
@@ -123,9 +123,25 @@ export function registerDreaming(
123
123
  ` - Patterns or conventions → [pattern] → patterns.md\n` +
124
124
  ` - Architectural/design decisions → [decision] → decisions.md\n` +
125
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") are session instructions,\n` +
127
- ` NOT memories — never extract them. Only standing, repeated rules are durable.\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` +
128
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` +
129
145
  `4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
130
146
  ` If it does, read the timestamp — only process sessions newer than that.\n` +
131
147
  ` Session directory: find .jsonl files under the pi sessions directory.\n` +
@@ -135,6 +151,11 @@ export function registerDreaming(
135
151
  ` - Remove duplicate or near-duplicate entries\n` +
136
152
  ` - Remove stale/useless entries\n` +
137
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` +
138
159
  ` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
139
160
  ` - NEVER remove or modify entries marked with *(pinned)*\n` +
140
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` +
@@ -1511,62 +1511,196 @@ export function isReadOnlyStatement(stmt: string): boolean {
1511
1511
  /** Pattern matching direct recursive rm commands (subset of DANGEROUS patterns). */
1512
1512
  const RM_PATTERN = /\brm\s+(?:-[a-zA-Z]+\s+)*(-[a-zA-Z]*r[a-zA-Z]*|--recursive)/i;
1513
1513
 
1514
- /**
1515
- * Check if a dangerous rm command only targets paths within .pi/tmp/ or /tmp/<something>.
1516
- * Returns true if the command should be silently allowed.
1517
- * Note: bare /tmp (without subpath) is NOT allowed — only /tmp/<file-or-folder>.
1518
- */
1519
- export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1520
- // Only applies to direct rm commands, not find/xargs variants
1521
- if (!RM_PATTERN.test(stmt)) return false;
1522
-
1523
- // Ensure rm is the actual command, not an argument to another command (e.g., xargs rm)
1524
- const firstWord = stmt.trim().split(/\s+/)[0];
1525
- if (firstWord !== "rm" && !firstWord?.endsWith("/rm")) return false;
1526
-
1527
- // Don't silently allow if the statement also matches other DANGEROUS patterns
1528
- // (e.g., sudo rm -rf .pi/tmp/foo — sudo should still trigger confirmation)
1529
- if (/\bsudo\b/i.test(stmt)) return false;
1530
-
1531
- // Reject statements containing subshells or process substitutions — these could
1532
- // embed arbitrary commands that bypass the allowlist check
1533
- if (extractSubshells(stmt).length > 0) return false;
1514
+ /** Tokenize the target paths of an rm command found in `stmt` (flags, the
1515
+ * `--` separator, and shell redirections skipped; surrounding quotes
1516
+ * stripped). Returns null when no rm token is present. Shared by
1517
+ * isRmInProjectTmp and relativeRecursiveRmTarget (issue #963). */
1518
+ interface RmPathToken {
1519
+ value: string;
1520
+ /** Quote character that surrounded the token, if any. SINGLE quotes make
1521
+ * the content fully literal; DOUBLE quotes still expand $VAR and
1522
+ * backticks while suppressing tilde expansion and globbing (Qodo PR
1523
+ * #964 round 3 — a boolean quoted flag conflated the two). */
1524
+ quote: "'" | '"' | null;
1525
+ }
1526
+
1527
+ /** Whitespace tokenizer that keeps quoted spans together: `rm -rf "/abs/My
1528
+ * Dir"` is ONE path token, not the fragments `"/abs/My` and `Dir"` (Qodo
1529
+ * PR #964 round 3). A quote character always starts a new token —
1530
+ * mid-token quotes (`--flag="x"`) split, which is conservative for rm
1531
+ * argument parsing. */
1532
+ function splitRmTokens(stmt: string): RmPathToken[] {
1533
+ const out: RmPathToken[] = [];
1534
+ let cur = "";
1535
+ let quote: "'" | '"' | null = null;
1536
+ const push = (q: "'" | '"' | null) => {
1537
+ if (cur) out.push({ value: cur, quote: q });
1538
+ cur = "";
1539
+ };
1540
+ for (const ch of stmt.trim()) {
1541
+ if (quote) {
1542
+ if (ch === quote) {
1543
+ push(quote);
1544
+ quote = null;
1545
+ } else cur += ch;
1546
+ } else if (ch === '"' || ch === "'") {
1547
+ push(null);
1548
+ quote = ch;
1549
+ } else if (/\s/.test(ch)) {
1550
+ push(null);
1551
+ } else cur += ch;
1552
+ }
1553
+ push(quote); // unterminated quote — keep the residue as its own token
1554
+ return out;
1555
+ }
1534
1556
 
1535
- // Parse arguments: split on whitespace, skip flags, handle -- separator
1536
- const tokens = stmt.trim().split(/\s+/);
1537
- const paths: string[] = [];
1557
+ function parseRmPathTokens(stmt: string): RmPathToken[] | null {
1558
+ const paths: RmPathToken[] = [];
1538
1559
  let pastSeparator = false;
1539
1560
  let pastRm = false;
1540
1561
  let skipNext = false;
1541
1562
 
1542
- for (const token of tokens) {
1563
+ for (const token of splitRmTokens(stmt)) {
1543
1564
  if (skipNext) {
1544
1565
  skipNext = false;
1545
1566
  continue;
1546
1567
  }
1547
1568
  if (!pastRm) {
1548
- if (token === "rm" || token.endsWith("/rm")) pastRm = true;
1569
+ if (token.value === "rm" || token.value.endsWith("/rm")) pastRm = true;
1549
1570
  continue;
1550
1571
  }
1551
- if (token === "--") {
1572
+ if (token.value === "--" && token.quote === null) {
1552
1573
  pastSeparator = true;
1553
1574
  continue;
1554
1575
  }
1555
- if (!pastSeparator && token.startsWith("-")) continue;
1576
+ if (!pastSeparator && token.value.startsWith("-") && token.quote === null) continue;
1556
1577
  // Skip shell redirections — handle both joined (2>/dev/null) and spaced (2> /dev/null)
1557
- if (/^[0-9]*>{1,2}|^&>|^[0-9]*</.test(token)) {
1578
+ if (/^[0-9]*>{1,2}|^&>|^[0-9]*</.test(token.value)) {
1558
1579
  // If the token is ONLY the operator (no target attached), skip the next token too
1559
- if (/^(?:[0-9]*>{1,2}|&>|[0-9]*<)$/.test(token)) {
1580
+ if (/^(?:[0-9]*>{1,2}|&>|[0-9]*<)$/.test(token.value)) {
1560
1581
  skipNext = true;
1561
1582
  }
1562
1583
  continue;
1563
1584
  }
1564
- // Strip surrounding quotes — prevents false positives on rm -rf ".pi/tmp/foo"
1565
- paths.push(token.replace(/^["']|["']$/g, ""));
1585
+ paths.push(token);
1566
1586
  }
1567
1587
 
1588
+ return pastRm ? paths : null;
1589
+ }
1590
+
1591
+ /** Leading wrapper prefixes that may precede a direct `rm` invocation
1592
+ * (issue #963). Same family as the bare-shell wrapper alternation in the
1593
+ * DANGEROUS patterns, but INCLUDES sudo: the absolute-path requirement
1594
+ * applies to `sudo rm -rf rel` just as much as to bare `rm -rf rel`. */
1595
+ // The `--` end-of-options marker is its own alternative that consumes NO
1596
+ // value: the old `-.S+` matched `--` and then its optional value slot ate
1597
+ // the command itself (`env -- rm -rf x` left `-rf x` with no command token,
1598
+ // evading the gate — Qodo PR #964).
1599
+ const RM_LEADING_WRAPPERS_RE =
1600
+ /^\s*(?:\S+=\S*\s+|sudo(?:\s+(?:--\S+|--|-(?!-)\S+(?:\s+\S+)?))*\s+|rtk(?:\s+(?:--\S+|-(?!-)\S+))*\s+|(?:command|builtin|exec)\s+|env(?:\s+(?:--\S+|--|-(?!-)\S+(?:\s+\S+)?|\S+=\S*))*\s+|timeout\s+(?:(?:--\S+|--|-(?!-)\S+(?:\s+\S+)?)\s+)*\S+\s+|nice(?:\s+(?:--\S+|--|-(?!-)\S+(?:\s+\S+)?))*\s+|nohup\s+)+/;
1601
+
1602
+ /**
1603
+ * First non-absolute target of a direct (optionally wrapped) recursive rm
1604
+ * statement, or null (issue #963). "Absolute" = starts with `/` or `~`.
1605
+ *
1606
+ * find -exec / xargs rm variants return null — their targets come from find
1607
+ * output / stdin, not statement text. Recursive rm with a relative target
1608
+ * must be blocked: a persistent "always allow" approval stores the
1609
+ * statement text, so a relative target would approve the identical text in
1610
+ * ANY directory; an absolute path binds the approval to exactly one folder.
1611
+ */
1612
+ export function relativeRecursiveRmTarget(stmt: string): string | null {
1613
+ if (!RM_PATTERN.test(stmt)) return null;
1614
+ // rm must be the COMMAND, not a word inside another command (Qodo PR
1615
+ // #964): `git rm -r --cached build` and `git commit -m "chore: rm -rf
1616
+ // build dir"` previously matched because the tokenizer scanned for `rm`
1617
+ // anywhere — hard-blocking git subcommands and commit messages that the
1618
+ // dangerous-prompt path already handles.
1619
+ const stripped = stmt.replace(/^\(\s*/, "").replace(RM_LEADING_WRAPPERS_RE, "");
1620
+ const firstWord = stripped.trim().split(/\s+/)[0];
1621
+ if (firstWord !== "rm" && !firstWord?.endsWith("/rm")) return null;
1622
+ // find -exec rm / xargs rm carry their targets from find output / stdin.
1623
+ // COMMAND-POSITION only: `rm -rf find` (a directory named find) must stay
1624
+ // blocked (Qodo PR #964) — the old word-boundary test exempted it.
1625
+ if (firstWord === "find" || /(?:^|\|\s*)xargs\s+rm\b/.test(stmt) || /\bfind\b[^|;]*-exec\b/.test(stmt)) {
1626
+ return null;
1627
+ }
1628
+ const paths = parseRmPathTokens(stripped);
1629
+ if (!paths || paths.length === 0) return null;
1630
+ for (const p of paths) {
1631
+ const t = p.value;
1632
+ // Project temp dir by variable: deterministic per-cwd, expanded and
1633
+ // validated by isRmInProjectTmp downstream (Qodo PR #964 — the gate
1634
+ // must not block the documented ${PROJECT_TMP_DIR} cleanup pattern).
1635
+ // ANCHORED (Qodo PR #964 round 3): the variable must END here or be
1636
+ // followed by / — a prefix match let $PROJECT_TMP_DIR_BACKUP skip the
1637
+ // gate. The variable expands unquoted AND inside double quotes.
1638
+ const projTmpVar = /^\$\{PROJECT_TMP_DIR\}(?:$|\/)|^\$PROJECT_TMP_DIR(?![\w$])(?:$|\/)/.test(t);
1639
+ // Traversal and non-literal suffixes drop the exemption (Qodo PR #964
1640
+ // rounds 4-5): a legit temp path never needs ".." (the variable already
1641
+ // points at the tmp root), and its suffix must be LITERAL —
1642
+ // "${PROJECT_TMP_DIR}/$TARGET" with TARGET=../../important expanded
1643
+ // past both the gate and the tmp check while still looking exempt.
1644
+ const suffix = t
1645
+ .replace(/^\$\{PROJECT_TMP_DIR\}/, "")
1646
+ .replace(/^\$PROJECT_TMP_DIR(?![\w$])/, "");
1647
+ const suffixLiteral = !/[$`*?[\]]/.test(suffix) && !t.includes("..");
1648
+ if (projTmpVar && p.quote !== "'" && suffixLiteral) continue;
1649
+ if (p.quote === "'") {
1650
+ // Single quotes: fully literal. A quoted tilde does NOT expand (it
1651
+ // is a relative path), and $/glob chars are inert — only a quoted
1652
+ // absolute path binds to one folder.
1653
+ if (t.startsWith("/")) continue;
1654
+ return t;
1655
+ }
1656
+ if (p.quote === '"') {
1657
+ // Double quotes: tilde does NOT expand (relative → block), but $VAR
1658
+ // and backticks DO — an approval for "/home/$USER/build" must not
1659
+ // bind. Globs are inert inside quotes, so only / $ ` matter.
1660
+ if (t.startsWith("~")) return t;
1661
+ if (/[$`]/.test(t)) return t;
1662
+ if (t.startsWith("/")) continue;
1663
+ return t;
1664
+ }
1665
+ // Unquoted: tilde expands to $HOME (absolute); any expansion or glob
1666
+ // char makes the target non-literal — an approval for
1667
+ // /home/$USER/build or /home/*/build must not bind to varying
1668
+ // directories (Qodo PR #964).
1669
+ if (t.startsWith("~")) continue;
1670
+ if (/[$`*?[\]]/.test(t)) return t;
1671
+ if (t.startsWith("/")) continue;
1672
+ return t;
1673
+ }
1674
+ return null;
1675
+ }
1676
+
1677
+ /**
1678
+ * Check if a dangerous rm command only targets paths within .pi/tmp/ or /tmp/<something>.
1679
+ * Returns true if the command should be silently allowed.
1680
+ * Note: bare /tmp (without subpath) is NOT allowed — only /tmp/<file-or-folder>.
1681
+ */
1682
+ export function isRmInProjectTmp(stmt: string, cwd: string): boolean {
1683
+ // Only applies to direct rm commands, not find/xargs variants
1684
+ if (!RM_PATTERN.test(stmt)) return false;
1685
+
1686
+ // Ensure rm is the actual command, not an argument to another command (e.g., xargs rm)
1687
+ const firstWord = stmt.trim().split(/\s+/)[0];
1688
+ if (firstWord !== "rm" && !firstWord?.endsWith("/rm")) return false;
1689
+
1690
+ // Don't silently allow if the statement also matches other DANGEROUS patterns
1691
+ // (e.g., sudo rm -rf .pi/tmp/foo — sudo should still trigger confirmation)
1692
+ if (/\bsudo\b/i.test(stmt)) return false;
1693
+
1694
+ // Reject statements containing subshells or process substitutions — these could
1695
+ // embed arbitrary commands that bypass the allowlist check
1696
+ if (extractSubshells(stmt).length > 0) return false;
1697
+
1698
+ // Parse arguments: flags, -- separator, redirections (shared tokenizer)
1699
+ const tokens = parseRmPathTokens(stmt);
1700
+
1568
1701
  // Guard against vacuous truth — if no paths extracted, don't silently allow
1569
- if (paths.length === 0) return false;
1702
+ if (!tokens || tokens.length === 0) return false;
1703
+ const paths = tokens.map((t) => t.value);
1570
1704
 
1571
1705
  // Resolve and substitute PROJECT_TMP_DIR env var
1572
1706
  const projectTmpDir = path.join(cwd, ".pi", "tmp");
@@ -54,6 +54,7 @@ import {
54
54
  isReadOnlyStatement,
55
55
  isRealGitCommitOrPush,
56
56
  isRmInProjectTmp,
57
+ relativeRecursiveRmTarget,
57
58
  normalizeForRepeatCheck,
58
59
  realGitSub,
59
60
  resolveEffectiveCwd,
@@ -437,6 +438,40 @@ async function checkDangerousCommands(command: string, cwd: string, ctx: any): P
437
438
  return DANGEROUS.some((p) => p.test(stmt));
438
439
  });
439
440
  if (hasDangerous) {
441
+ // Absolute-path requirement for recursive rm (issue #963): a persistent
442
+ // "always allow" stores the statement TEXT, so a relative target would
443
+ // approve the identical text in ANY directory. Force the agent to spell
444
+ // out the full path — the approval then binds to exactly one folder the
445
+ // user knows. Blocks in every mode (headless included): this is a
446
+ // rewrite rule, not a confirmation.
447
+ const relativeRm = statements
448
+ .map((stmt) => ({
449
+ stmt,
450
+ target: isReadOnlyStatement(stmt) ? null : relativeRecursiveRmTarget(stmt),
451
+ }))
452
+ .find((r) => r.target !== null);
453
+ if (relativeRm && relativeRm.target !== null) {
454
+ log.warn("recursive_rm_relative_path_blocked", { target: relativeRm.target });
455
+ // A target with unquoted expansion chars ($VAR, globs) is not a literal
456
+ // folder — echoing it back as the "rewrite" would suggest re-running the
457
+ // same non-binding command (live battery, Qodo PR #964).
458
+ const nonLiteral = /[$`*?[\]]/.test(relativeRm.target);
459
+ const rewrite = nonLiteral
460
+ ? `Rewrite with a LITERAL full path (no variables or globs — an approval must bind to ` +
461
+ `one concrete folder) and re-run.`
462
+ : `Rewrite as rm -rf ${resolve(resolveEffectiveCwd(command, cwd), relativeRm.target)} (full path) and re-run. `;
463
+ return {
464
+ block: true,
465
+ reason:
466
+ `Blocked: recursive rm must use ABSOLUTE paths — "${relativeRm.stmt.trim()}" targets ` +
467
+ `"${relativeRm.target}", which is ${nonLiteral ? "not a literal path" : "relative"}. ` +
468
+ `A persistent "always allow" stores the statement text, so a relative ` +
469
+ `target would approve the same text in ANY directory. ${rewrite} ` +
470
+ `Do NOT bypass with an equivalent command (find -delete, git clean, perl, python ` +
471
+ `os.remove). Stop and report this block to the user if a rewrite is not possible.`,
472
+ };
473
+ }
474
+
440
475
  // Allow rm -rf targeting only .pi/tmp/ paths without confirmation
441
476
  const allRmInTmp = statements
442
477
  .filter((stmt) => DANGEROUS.some((p) => p.test(stmt)) && !isReadOnlyStatement(stmt))
@@ -674,19 +674,104 @@ const GRANT_PHRASE_RE: RegExp[] = [
674
674
  /\b(this|that|specific) (pr|issue|task|ticket|branch)\b/i,
675
675
  ];
676
676
 
677
+ /** Strip quoted spans for natural-language guardrail text: double-quoted
678
+ * spans always; single-quoted spans only when the opening quote is not
679
+ * mid-word (excludes contractions like "user's"). Quoting someone else's
680
+ * words is not granting or asking them (live-battery false positive,
681
+ * issue #961): 'Never tell the user "you can test it live"' must not read
682
+ * as a grant. */
683
+ function stripQuotedText(text: string): string {
684
+ let out = text.replace(/"(?:\\.|[^"\\])*"/g, '""');
685
+ // Single quotes: opening ' must be at start or after whitespace/bracket —
686
+ // never directly after a letter (that is a contraction), and the span must
687
+ // contain a space (a quotation, not a possessive artifact).
688
+ out = out.replace(/(^|[\s(\[{])'([^']*\s[^']*)'/g, "$1''");
689
+ return out;
690
+ }
691
+
677
692
  /** Detect one-off grant phrasing (shared by extractor and memory_add guardrail). */
678
693
  export function isGrantText(text: string): boolean {
694
+ // Quoted payloads are stripped first — a quoted example is not a grant.
679
695
  // Classification is logged (Qodo 64954ee5): the negation-aware grant
680
696
  // patterns decide whether a permission-looking sentence is a durable
681
697
  // rule or a one-off grant, and silent decisions left runtime
682
698
  // diagnostics blind when the classification changed behavior.
683
- const matched = GRANT_PHRASE_RE.find((re) => re.test(text));
699
+ const stripped = stripQuotedText(text);
700
+ const matched = GRANT_PHRASE_RE.find((re) => re.test(stripped));
684
701
  if (matched) {
685
702
  memLog.debug("memory-scoring: grant phrase detected", text.slice(0, 120), matched.source);
686
703
  }
687
704
  return matched !== undefined;
688
705
  }
689
706
 
707
+ /** Task-scoped instruction shapes — high-precision only (issue #961).
708
+ * These mark an instruction as bound to ONE task, deliverable, or
709
+ * version transition, so it is a task attribute or session instruction,
710
+ * not a durable memory. Deliberately NARROW: standing rules like
711
+ * "When done, commit and push everything" or "After PR merge, switch
712
+ * back to main" must NOT match (recurring events, not one-off asks).
713
+ * Fuzzy cases are left to the dreaming review / candidate queue, not
714
+ * this regex. */
715
+ const TASK_SCOPED_RE: RegExp[] = [
716
+ // Deliverable asks tied to a completion event: "once it's done I want an
717
+ // html report", "after this is done, give me a table". Only FIRST-PERSON
718
+ // wants count (Qodo PR #964): bare make/create/generate/produce also
719
+ // matched standing workflow rules like "when done, make sure the linter
720
+ // passes", rejecting durable rules as one-off asks.
721
+ /\b(?:when|once|after)\b[^.?!]{0,50}\b(?:done|finished|complete[d]?)\b[^.?!]{0,60}\b(?:i (?:want|need|would like|'d like)|give me|send me|show me|make me|(?:produce|generate|create)\b[^.?!]{0,30}\bfor me\b)\b/i,
722
+ /\bi (?:want|need|would like|'d like)\b[^.?!]{0,60}\b(?:when|once|after)\b[^.?!]{0,50}\b(?:done|finished|complete[d]?)\b/i,
723
+ // Version-pinned migration steps: "when bumping to 1.4.0, drop the
724
+ // vendor dir" — a one-time transition checklist, not a standing rule.
725
+ /\bwhen (?:bump(?:ing)?|upgrad(?:e|ing)|migrat(?:e|ing)|mov(?:e|ing)) to\s+v?\d/i,
726
+ // Bounded deliverable references: "for this report", "for this task",
727
+ // "for this PR" (Qodo PR #964: explicit task/PR bounds passed the old
728
+ // list, so "For this task, always use pnpm" landed as durable).
729
+ /\bfor this (?:report|summary|document|doc|ticket|task|pr|migration|demo|poc|analysis|investigation|presentation|benchmark)\b/i,
730
+ // Config-change notes: "every 30min (changed from 1h)" — a change log
731
+ // entry, not a preference. Category-locked to preferences: durable
732
+ // lessons/decisions describing a past change legitimately contain the
733
+ // phrase (Qodo PR #964).
734
+ /\bchanged from\b/i,
735
+ ];
736
+
737
+ /** Categories whose entries are standing behavioral rules; only these
738
+ * are screened by the task-scope and durability gates. Lessons, decisions,
739
+ * mistakes, patterns, and completions are OUTCOME records — historical
740
+ * facts are legitimately one-time and are stored as such (Qodo PR #964). */
741
+ const STANDING_CATEGORIES = new Set(["preference"]);
742
+
743
+ /** Detect task-scoped instruction phrasing (shared by extractor and
744
+ * memory_add guardrail). High precision, lower recall by design —
745
+ * ambiguous shapes go to the candidate queue for dreaming review.
746
+ * Quoted payloads are stripped first (quoting is not asking).
747
+ * `category` (optional) locks config-change patterns to preferences. */
748
+ export function isTaskScopedText(text: string, category?: string): boolean {
749
+ const stripped = stripQuotedText(text);
750
+ const patterns = TASK_SCOPED_RE.filter(
751
+ (re) =>
752
+ // "changed from" only rejects preferences: lessons and decisions
753
+ // describing a past change are exactly what those categories are for.
754
+ re.source !== "\\bchanged from\\b" || !category || STANDING_CATEGORIES.has(category),
755
+ );
756
+ const matched = patterns.find((re) => re.test(stripped));
757
+ if (matched) {
758
+ memLog.debug("memory-scoring: task-scoped instruction detected", text.slice(0, 120), matched.source);
759
+ }
760
+ return matched !== undefined;
761
+ }
762
+
763
+ const DURABILITY_MARKERS_RE =
764
+ /\b(?:always|never|only|prefer(?:s|ence|red)?|from now on|going forward|each time|whenever|every|permanently|exclusively|by default|make it a rule|as a rule)\b/i;
765
+
766
+ /** Does the text carry an explicit durability marker? A preference is
767
+ * durable only when the user marked it (always/never/from now on…) or
768
+ * confirmed it — a single unmarked utterance is a guess, not a rule
769
+ * (issue #961, live-battery discussion). Unmarked one-timers queue as
770
+ * candidates and evaporate unless repeated. */
771
+ export function hasDurabilityMarker(text: string): boolean {
772
+ return DURABILITY_MARKERS_RE.test(stripQuotedText(text));
773
+ }
774
+
690
775
  /**
691
776
  * Extract preference statements from user text.
692
777
  * Questions and one-off permission grants are excluded — they are not
@@ -706,9 +791,14 @@ export function extractPreferences(text: string): string[] {
706
791
  for (const sentence of sentences) {
707
792
  if (sentence.endsWith("?")) continue; // question, not a statement
708
793
  if (isGrantText(sentence)) continue; // one-off grant, not a preference
794
+ if (isTaskScopedText(sentence, "preference")) continue; // task-scoped instruction, not a preference (issue #961)
709
795
  if (PREFERENCE_PATTERNS.some((p) => p.test(sentence))) {
710
- // Normalize to a clean one-liner
711
- const clean = sentence.replace(/\s+/g, " ").slice(0, 100);
796
+ // Normalize whitespace but keep the FULL sentence (Qodo PR #964): the
797
+ // 280-char slice still amputated long preferences mid-thought, and
798
+ // dreaming needs the omitted instructions to distill correctly.
799
+ // Input length is bounded by the message itself; a generous sanity
800
+ // cap of 1000 guards against pathological input.
801
+ const clean = sentence.replace(/\s+/g, " ").slice(0, 1000);
712
802
  found.push(clean);
713
803
  }
714
804
  }
@@ -36,6 +36,8 @@ import {
36
36
  newEntryId,
37
37
  reinforce,
38
38
  isGrantText,
39
+ isTaskScopedText,
40
+ hasDurabilityMarker,
39
41
  } from "./memory-scoring.js";
40
42
  import { CATEGORY_TO_TOPIC, MAX_TOPIC_CHARS, readAllTopicEntries, formatTopicLine, findTopicLine, invalidateTopicFileCache } from "./memory-tree.js";
41
43
  import { embedEntry, removeEmbedding, vectorSearch } from "./memory-embeddings.js";
@@ -2403,6 +2405,28 @@ export async function addMemoryEntry(cwd: string, input: AddMemoryInput): Promis
2403
2405
  };
2404
2406
  }
2405
2407
 
2408
+ // 2b. Task-scope guardrail (issue #961): task-scoped instructions are task
2409
+ // attributes or session instructions, not durable memories. High-precision
2410
+ // shapes only (deliverable asks tied to a completion event, version-pinned
2411
+ // migration steps, bounded deliverable references, config-change notes) —
2412
+ // the same relative-path logic as the grant gate: a stored approval/rule
2413
+ // must bind to a standing behavior, not to ONE task's deliverable.
2414
+ // Category-locked config-change pattern: lessons/decisions describing a
2415
+ // past change are legitimate outcome records (Qodo PR #964).
2416
+ if (isTaskScopedText(text, category) && input.scope !== "session") {
2417
+ log.info("memory_task_scoped_rejected", { category, text: text.slice(0, 120) });
2418
+ return {
2419
+ status: "rejected",
2420
+ message:
2421
+ `Rejected: "${text}" reads like a task-scoped instruction (e.g. "when done, I want an ` +
2422
+ `html report" or "when bumping to 1.4.0, drop the vendor dir"). ` +
2423
+ `Task-scoped asks belong to the CURRENT task (its description, a task attribute, or a ` +
2424
+ `session instruction) — NOT memory. If the user confirmed a standing rule, rephrase it ` +
2425
+ `as the durable rule itself (e.g. "Always produce an HTML report when a task completes") ` +
2426
+ `without the task-scoped phrasing.`,
2427
+ };
2428
+ }
2429
+
2406
2430
  // 3. Contradiction gate — before dedup, so a permission-flipping near-duplicate
2407
2431
  // is refused rather than silently reinforcing the wrong entry. A conflict is
2408
2432
  // a decision point, not a dead end (issue #915): the model asks the user, and
@@ -2470,6 +2494,37 @@ export async function addMemoryEntry(cwd: string, input: AddMemoryInput): Promis
2470
2494
  log.info("memory_conflict_override", conflict.text.slice(0, 60), `count=${conflicts.length}`);
2471
2495
  }
2472
2496
 
2497
+ // 3b. Durability gate (issue #961, live-battery discussion): a preference
2498
+ // is durable only when the user MARKED it (always/never/from now on…),
2499
+ // pinned it, or confirmed it. A single unmarked utterance is a guess about
2500
+ // intent — the same words are a task instruction when said casually and a
2501
+ // standing rule when said emphatically, and phrasing cannot tell them
2502
+ // apart. Unmarked one-timers belong to the session or the candidate queue
2503
+ // (repetition earns durability); they must not silently apply to all
2504
+ // future work. Outcome categories (lesson, decision, mistake, pattern,
2505
+ // done) are exempt — they record what happened, not standing behavior.
2506
+ // Runs AFTER the contradiction gate so a permission flip reports the
2507
+ // conflict rather than a missing marker, and exempts overrideConflicts —
2508
+ // a user-confirmed rule change is durable by definition.
2509
+ if (
2510
+ category === "preference" &&
2511
+ !input.pinned &&
2512
+ !input.overrideConflicts &&
2513
+ input.scope !== "session" &&
2514
+ !hasDurabilityMarker(text)
2515
+ ) {
2516
+ log.info("memory_unmarked_preference_rejected", { text: text.slice(0, 120) });
2517
+ return {
2518
+ status: "rejected",
2519
+ message:
2520
+ `Rejected: "${text}" carries no durability marker (always, never, prefer, from now on, ` +
2521
+ `by default…). A preference said once, unmarked, is a session instruction — it must not ` +
2522
+ `apply to all future work. Either confirm with the user that it is a standing rule ` +
2523
+ `(then rephrase as the rule itself, e.g. "Always commit and push before reporting done"), ` +
2524
+ `store it with scope=session, or pin it with "remember this".`,
2525
+ };
2526
+ }
2527
+
2473
2528
  const canonicalLine = `- [${category}] ${text}`;
2474
2529
  const topicEntries = readAllTopicEntries(cwd);
2475
2530
 
@@ -1,53 +1,107 @@
1
1
  /**
2
- * Preference Auto-Extractor — detects user preferences from conversation.
2
+ * Preference Auto-Extractor — detects user preference statements from
3
+ * conversation and queues them for dreaming review (issue #961).
3
4
  *
4
- * Listens to user input and routes detected preference statements through the
5
- * unified memory write path (memory-write.ts, issue #915). Extraction filters
6
- * out questions and one-off permission grants; the write path enforces the
7
- * topic cap, near-duplicate dedup, and the contradiction gate — the extractor
8
- * no longer bypasses any memory_add safeguard.
5
+ * The extractor no longer lands memories directly. Regex extraction fires
6
+ * on PHRASE, not meaning, so direct writes stored verbatim sentences —
7
+ * including half-finished, task-scoped thoughts — as durable preferences
8
+ * (e.g. "for the tag normalization we should have maping for datadog in
9
+ * the settings, i always prefer to have", cut mid-sentence at the 100-char
10
+ * topic-line cap). Candidates now go to
11
+ * .pi/memory/preference-candidates.jsonl (full sentence, source session)
12
+ * and dreaming distills/lands only the durable standing preferences.
13
+ * Questions, one-off grants, and task-scoped instructions are filtered
14
+ * before queueing; the LLM path (memory_add + guardrails) remains for
15
+ * immediate capture.
9
16
  *
10
17
  * Architecture inspired by OpenHuman (https://github.com/tinyhumansai/openhuman).
11
18
  * Clean-room TypeScript implementation under MIT — not a code translation.
12
19
  */
13
20
 
14
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import fs from "node:fs";
23
+ import { join } from "node:path";
15
24
  import { createLogger } from "../shared/logger.js";
16
- import { extractPreferences, entryHash } from "./memory-scoring.js";
17
- import { addMemoryEntry } from "./memory-write.js";
25
+ import { extractPreferences } from "./memory-scoring.js";
26
+ import { getProjectMemoryDir } from "./utils.js";
18
27
 
19
28
  const log = createLogger("memory");
20
29
 
21
30
  // Debounce: don't extract from every message, only meaningful ones
22
31
  const MIN_MESSAGE_LENGTH = 20;
23
32
 
24
- // Cooldown: don't re-extract the same preference within 1 hour. Tracked
25
- // per pending write and only STAMPED once the write actually lands (added or
26
- // reinforced) — a rejected write (conflict gate, cap, guardrail) must not
27
- // suppress the next attempt for an hour (Qodo #14, PR #916).
28
- const COOLDOWN_MS = 60 * 60 * 1000;
29
- const recentExtractions = new Map<string, number>();
33
+ // Candidate queue bounds: the file stays small (dreaming consumes it each
34
+ // cycle), but a repo where dreaming never runs must not grow it unbounded.
35
+ const MAX_CANDIDATES = 200;
36
+ const CANDIDATE_FILE = "preference-candidates.jsonl";
30
37
 
31
- // Pending fire-and-forget writes — drained on session shutdown so a write is
32
- // never lost to process exit (Qodo #13, PR #916).
33
- const pendingWrites = new Set<Promise<unknown>>();
38
+ export function candidateQueuePath(cwd: string): string {
39
+ return join(getProjectMemoryDir(cwd), CANDIDATE_FILE);
40
+ }
41
+
42
+ interface PreferenceCandidate {
43
+ text: string;
44
+ sourceSession: string | null;
45
+ timestamp: string;
46
+ }
47
+
48
+ function readCandidates(file: string): PreferenceCandidate[] {
49
+ try {
50
+ return fs
51
+ .readFileSync(file, "utf-8")
52
+ .split("\n")
53
+ .filter(Boolean)
54
+ .map((line) => {
55
+ try {
56
+ const parsed = JSON.parse(line) as Partial<PreferenceCandidate>;
57
+ return {
58
+ text: String(parsed.text ?? ""),
59
+ sourceSession: parsed.sourceSession ?? null,
60
+ timestamp: String(parsed.timestamp ?? ""),
61
+ };
62
+ } catch {
63
+ return null; // malformed line — dropped below
64
+ }
65
+ })
66
+ .filter((c): c is PreferenceCandidate => c !== null && c.text.length > 0);
67
+ } catch {
68
+ return []; // no file yet
69
+ }
70
+ }
71
+
72
+ /** Append a preference candidate for dreaming review. Synchronous by
73
+ * design: a single sub-millisecond line append. APPEND-ONLY (Qodo PR
74
+ * #964): the old read-modify-write lost a concurrent session's candidate
75
+ * when both read the same snapshot and each replaced the whole file.
76
+ * `appendFileSync` composes line appends from concurrent writers — the
77
+ * read-before-append dedup keeps the common case duplicate-free, and a
78
+ * race-window duplicate is harmless (dreaming dedups at distillation).
79
+ * The size cap is enforced during dream compaction, not here: a rewrite
80
+ * here would reintroduce the lost-update race. */
81
+ export function appendPreferenceCandidate(
82
+ cwd: string,
83
+ text: string,
84
+ sourceSession?: string,
85
+ ): "queued" | "duplicate" {
86
+ const file = candidateQueuePath(cwd);
87
+ const existing = readCandidates(file);
88
+ if (existing.some((c) => c.text === text)) return "duplicate";
89
+ fs.appendFileSync(
90
+ file,
91
+ JSON.stringify({ text, sourceSession: sourceSession ?? null, timestamp: new Date().toISOString() }) + "\n",
92
+ );
93
+ return "queued";
94
+ }
95
+
96
+ /** Read the pending preference candidates (used by dreaming tooling/tests). */
97
+ export function readPreferenceCandidates(cwd: string): PreferenceCandidate[] {
98
+ return readCandidates(candidateQueuePath(cwd));
99
+ }
34
100
 
35
101
  export function registerPreferenceExtractor(pi: ExtensionAPI): void {
36
102
  // Only run in the orchestrator, not in subagents
37
103
  if (process.env.PI_SUBAGENT_CHILD === "1") return;
38
104
 
39
- pi.on("session_shutdown", async () => {
40
- if (pendingWrites.size === 0) return;
41
- const pending = [...pendingWrites];
42
- log.debug("preference-extractor: draining pending memory writes on shutdown", pending.length);
43
- try {
44
- await Promise.allSettled(pending);
45
- } catch {
46
- // allSettled never rejects — belt and braces
47
- }
48
- log.info("preference-extractor: drained pending memory writes", pending.length);
49
- });
50
-
51
105
  pi.on("input", (event, ctx) => {
52
106
  const text = event.text;
53
107
  if (!text || text.length < MIN_MESSAGE_LENGTH) return;
@@ -61,34 +115,16 @@ export function registerPreferenceExtractor(pi: ExtensionAPI): void {
61
115
  (ctx as any).sessionManager?.getSessionId?.() || undefined;
62
116
 
63
117
  for (const pref of preferences) {
64
- const hash = entryHash(`- [preference] ${pref}`);
65
-
66
- // Cooldown check (against LANDED writes only)
67
- const lastExtracted = recentExtractions.get(hash);
68
- if (lastExtracted && Date.now() - lastExtracted < COOLDOWN_MS) continue;
69
-
70
- // Fire-and-forget: the write path may load the embedding model on first
71
- // use (~2.7s) — never block user input on it. The promise is tracked so
72
- // session_shutdown can drain it before the process exits.
73
- const write = addMemoryEntry(ctx.cwd, {
74
- text: pref,
75
- category: "preference",
76
- sourceSession,
77
- })
78
- .then((result) => {
79
- log.info("preference_extracted", pref.slice(0, 60), result.status);
80
- // Stamp cooldown only on success — a rejected write must retry
81
- if (result.status !== "rejected") {
82
- recentExtractions.set(hash, Date.now());
83
- }
84
- })
85
- .catch((err) => {
86
- log.warn("preference extraction failed:", err?.message || err);
87
- })
88
- .finally(() => {
89
- pendingWrites.delete(write);
90
- });
91
- pendingWrites.add(write);
118
+ try {
119
+ const status = appendPreferenceCandidate(ctx.cwd, pref, sourceSession);
120
+ // Routine queue/duplicate writes are debug, not info (Qodo PR
121
+ // #964): every user message with a preference-shaped sentence
122
+ // logs here — the queue file itself is the record, and info-level
123
+ // noise drowns real events. Failures stay warn.
124
+ log.debug("preference_candidate", status, pref.slice(0, 60));
125
+ } catch (err) {
126
+ log.warn("preference candidate queue append failed:", (err as Error)?.message || err);
127
+ }
92
128
  }
93
129
  });
94
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.18.0",
3
+ "version": "4.20.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -65,6 +65,80 @@ Check validations:
65
65
  - Working tree must be clean
66
66
  - Must be synced with remote
67
67
 
68
+ ### Scoped Mode Check (after Phase 1)
69
+
70
+ Check for `release-map.toml` at the repository root (exact filename, repo
71
+ root only).
72
+
73
+ **If ABSENT:** continue with the standard lockstep flow below, unchanged.
74
+
75
+ **If PRESENT:** run the SCOPED flow instead of Phases 2–5:
76
+
77
+ - **Phase S2 — Detection:** `myk-pi-tools release plan --json`. If any
78
+ package is errored, show the errors and abort — do not bump. The plan
79
+ only reports WHICH packages changed and their published versions — it
80
+ does NOT decide the next versions. ALSO inspect `unmapped_changes` in
81
+ the plan JSON — ALWAYS, before any other decision: files that changed
82
+ but belong to no package, exclude, or container rebuild-path. If
83
+ non-empty, show them to the user as text IMMEDIATELY (a changed file
84
+ that ships nowhere silently is the broken failure mode) — map the
85
+ path (edit `release-map.toml`) or consciously accept it as
86
+ release-inert. This display is NOT part of Phase S4 approval: it
87
+ happens even when an explicit version was given and even when no
88
+ packages are affected. Then, if no packages are affected, report
89
+ "nothing to release — no package changed since its last publish"
90
+ (plus the unmapped list, if any) and stop.
91
+ - **Phase S3 — Changelog + version proposals:** generate the changelog
92
+ with the standard Phase 3 rules, EXCEPT the commit window: scoped
93
+ repos have per-package tags (`name@version`), so do NOT anchor the
94
+ changelog on `release info`'s `last_tag` or its version-branch tag
95
+ filter (`v2.10.*` inference finds no per-package tags and would treat
96
+ the run as a first release) — anchor on each affected package's own
97
+ `anchor` from the plan JSON. Then, for EACH affected package, YOU
98
+ (the LLM) decide the next version. IMPORTANT: a package's history
99
+ reaches back to ITS last publish, which may predate the last repo tag
100
+ — do NOT rely on the changelog alone. For each affected package run
101
+ `git log <anchor>..HEAD --format="%h %s%n%b" -- <its mapped paths>`
102
+ (the plan's JSON carries each package's `anchor` and `changed_files`)
103
+ and apply the standard semver rules to THOSE commits (`feat:` →
104
+ minor, `fix:` → patch, breaking → major). Read FULL messages, not
105
+ just subjects — a `BREAKING CHANGE:` footer lives in the commit body
106
+ and forces major even under a `feat:`/`fix:` subject. The plan does NOT propose versions — you
107
+ do. An explicit version argument (`/release X.Y.Z`) instead sets EVERY
108
+ affected package to that version, skipping per-package decisions.
109
+ Each package versions from ITS OWN registry history only — a package
110
+ skipped for N releases takes the next version after its own last
111
+ publish (e.g. 4.18.0 → 4.19.0), never a jump past the global tag
112
+ sequence.
113
+ - **Phase S4 — Approval:** skip the `ask_user` entirely if an explicit
114
+ version was provided in the raw arguments (same contract as lockstep
115
+ Phase 4 — the unmapped-changes display already happened in S2).
116
+ Otherwise: show the changelog, the per-package version
117
+ proposals (`current → proposed`), the plan's skipped-packages list,
118
+ AND any `unmapped_changes` files as text — the human validates the
119
+ detection on every scoped release (a package wrongly skipped is the
120
+ broken failure mode). Then a short `ask_user`: `Release?` with
121
+ options `yes`, `no`, plus one override option per package in the form
122
+ `<name>: <level>` (e.g. `myk-pi-tools: minor`) so the user can adjust
123
+ any package's level before confirming.
124
+ - **Phase S5 — Bump:** `myk-pi-tools release bump-scoped --json` with
125
+ one `--set <name>=<version>` per affected package carrying YOUR final
126
+ proposed versions (after any user overrides). Use `--version X.Y.Z`
127
+ instead when the user passed an explicit version. Parse the JSON:
128
+ stage ONLY the files in `updated[]`; each entry in `packages[]`
129
+ carries its per-package `tag` (`<name>@<version>`). Bump PR, merge,
130
+ and branch sync follow the standard Phase 5 delegation (git-expert /
131
+ github-expert), with the bump PR message `chore: bump version to
132
+ <name1>@<v1>[, <name2>@<v2>...]` (keep the `chore: bump version`
133
+ prefix — it is filtered from changelogs).
134
+ - **Phase S6/S7:** release create ONE GitHub RELEASE PER SHIPPED
135
+ PACKAGE, tag `<name>@<version>` from the bump JSON, same changelog
136
+ body (including the Shipped packages section), and PASS
137
+ `--title "<name> <version>"` explicitly — without it the release
138
+ title defaults to the raw `name@version` tag. Tags are per-package
139
+ and cannot collide — there is no repo-wide `v<TAG>` in scoped mode.
140
+ Summary lists each release URL and per-package versions.
141
+
68
142
  ### Phase 2: Version Detection
69
143
 
70
144
  ```bash
@@ -43,6 +43,14 @@ When a correction is mechanical (can be checked by code), add `trigger` + `actio
43
43
  Never `memory_add` them. The write path rejects grant phrasing and contradictions of pinned entries.
44
44
  Only standing, repeated rules are durable.
45
45
 
46
+ **Task-scoped instructions are NOT memories** (issue #961). "When done, I want an HTML report",
47
+ "when bumping to 1.4.0, drop the vendor dir", "every 30min (changed from 1h)" — deliverable formats,
48
+ one-time migration steps, and config-change notes belong to the current task (its description or a
49
+ session instruction), never to memory. The write path rejects task-scoped phrasing.
50
+
51
+ **Apply the standing test before storing any preference:** would it apply to a future, unrelated task?
52
+ No → it is a session instruction or a task attribute, not a memory.
53
+
46
54
  **Conflicts are decision points, not dead ends.** When `memory_add` reports a contradiction:
47
55
 
48
56
  1. Ask the user (`ask_user`) which rule is correct — never decide for them.
@@ -127,8 +135,9 @@ The situation report header shows usage: `# Project Memory [72% — 1,224/1,700
127
135
  | PR merged | `done` | Learned |
128
136
  | User corrects you | `lesson` | Learned |
129
137
  | Multiple fix attempts | `mistake` | Learned |
130
- | User states a preference | `preference` | Learned |
138
+ | User states a preference that passes the standing test | `preference` | Learned |
131
139
  | User grants a one-off permission ("you can merge this PR") | — | **NOT a memory** — session instruction only |
140
+ | User asks for a one-off deliverable/format ("when done, I want an html report") | — | **NOT a memory** — task attribute or session instruction |
132
141
 
133
142
  ---
134
143
 
@@ -180,6 +189,8 @@ If it's knowledge that informs decisions but can't be checked mechanically — p
180
189
  - Unfinished conversations where no conclusion was reached
181
190
  - User requirements that were never implemented
182
191
  - One-off permissions granted for a specific PR, task, or session — session instructions, not durable rules
192
+ - Task-scoped instructions: deliverable formats for the current task, one-time migration steps,
193
+ session config notes — task attributes or session instructions, not durable rules
183
194
 
184
195
  Only memorize **outcomes** (what happened), not **intentions** (what was discussed but dropped).
185
196
 
@@ -212,6 +223,9 @@ agent.
212
223
 
213
224
  **Dreaming follows the same NEVER memorize rules as per-turn self-improvement:**
214
225
  do NOT extract rejected/abandoned ideas, unfinished conversations, or unimplemented requirements.
226
+ Do NOT extract one-off permissions or task-scoped instructions (deliverable formats, one-time
227
+ migration steps, config-change notes) — dreaming also distills the preference candidate queue
228
+ (`.pi/memory/preference-candidates.jsonl`) into durable standing preferences and discards the rest.
215
229
  Only extract outcomes, not intentions.
216
230
 
217
231
  ---
@@ -114,6 +114,14 @@ Skip when the user explicitly says "skip audit", has previously approved the sou
114
114
 
115
115
  ---
116
116
 
117
+ ## Truncated Command Output (rtk)
118
+
119
+ When command output contains `[+N hidden: rtk recall <id>]` and you need the hidden results,
120
+ run `rtk recall <id>` to retrieve them — never re-run the command or bypass with path-qualified
121
+ binaries (`/usr/bin/grep`). If the hidden data is irrelevant, continue normally.
122
+
123
+ ---
124
+
117
125
  ## Temp Files
118
126
 
119
127
  All temp files go to `.pi/tmp/` via `getProjectTmpDir(cwd)` — NEVER create temp files directly in the project tree.