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 +47 -0
- package/extensions/orchestrator/dreaming.ts +23 -2
- package/extensions/orchestrator/enforcement-helpers.ts +166 -32
- package/extensions/orchestrator/enforcement.ts +35 -0
- package/extensions/orchestrator/memory-scoring.ts +93 -3
- package/extensions/orchestrator/memory-write.ts +55 -0
- package/extensions/orchestrator/preference-extractor.ts +93 -57
- package/package.json +1 -1
- package/prompts/release.md +74 -0
- package/rules/35-memory.md +15 -1
- package/rules/40-critical-rules.md +8 -0
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")
|
|
127
|
-
`
|
|
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
|
-
*
|
|
1516
|
-
* Returns
|
|
1517
|
-
*
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
1536
|
-
const
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
711
|
-
|
|
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
|
|
2
|
+
* Preference Auto-Extractor — detects user preference statements from
|
|
3
|
+
* conversation and queues them for dreaming review (issue #961).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
17
|
-
import {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
package/prompts/release.md
CHANGED
|
@@ -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
|
package/rules/35-memory.md
CHANGED
|
@@ -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.
|