pi-soly 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,173 @@
1
+ # Temporary Files Rule
2
+
3
+ > **OS-aware temp paths.** Never hardcode `/tmp` — it's wrong on Windows
4
+ > (`%TEMP%` resolves to `C:\Users\<user>\AppData\Local\Temp`, not `/tmp`),
5
+ > wrong on macOS (`$TMPDIR` is per-user, e.g. `/var/folders/xx/yy/T/`),
6
+ > and fragile in multi-tenant / CI scenarios. Always resolve the
7
+ > OS-correct temp dir at runtime and put files under a project-scoped
8
+ > subfolder.
9
+
10
+ ## The rule
11
+
12
+ When creating temporary files — intermediate build artifacts, scratch
13
+ data, debug dumps, test fixtures, atomic-write staging, anything that
14
+ isn't meant to outlive the process:
15
+
16
+ 1. **Resolve the OS temp dir at runtime.** Never hardcode `/tmp`,
17
+ `/var/folders`, `C:\Temp`, `~/tmp`, or any other literal path.
18
+ 2. **Use a project-scoped subfolder** like `<tempdir>/<project>-<hash>/`
19
+ so multiple projects don't collide and cleanup is easy.
20
+ 3. **Clean up after yourself** — when the work is done, remove the
21
+ directory (or use `try`/`finally` / `trap EXIT` / `defer`).
22
+ 4. **Prefer atomic-write patterns** for files that will be renamed into
23
+ place: write to a temp file in the **same directory** as the target,
24
+ `fsync` if durability matters, then rename. Avoids half-written
25
+ files on crash and avoids cross-device rename failures.
26
+
27
+ ## How to get the temp dir per runtime
28
+
29
+ | Runtime | API | Notes |
30
+ |---------------|------------------------------|------------------------------------|
31
+ | Node.js / Bun | `os.tmpdir()` | already OS-aware; returns absolute |
32
+ | Python | `tempfile.gettempdir()` | stdlib |
33
+ | Go | `os.TempDir()` | stdlib |
34
+ | Rust | `std::env::temp_dir()` | stdlib; returns `PathBuf` |
35
+ | .NET | `Path.GetTempPath()` | returns trailing slash |
36
+ | Java | `System.getProperty("java.io.tmpdir")` | JVM-resolved |
37
+ | Bash (POSIX) | `${TMPDIR:-/tmp}` | fallback only if env unset |
38
+ | PowerShell | `$env:TEMP` | Windows |
39
+ | cmd.exe | `%TEMP%` | Windows |
40
+
41
+ **Do not** `cd /tmp && touch foo`. Use the API for the runtime you're in.
42
+
43
+ ## Why this matters
44
+
45
+ - **Windows**: `C:\Temp` may not exist or be writable; the real temp is
46
+ `C:\Users\<user>\AppData\Local\Temp`. Hardcoding `/tmp` either fails
47
+ outright or pollutes the wrong location.
48
+ - **macOS**: `$TMPDIR` is per-user and changes per reboot. `/tmp` is a
49
+ shared system directory, not appropriate for app scratch data.
50
+ - **Linux / multi-user**: `/tmp` is shared across users on the host.
51
+ Mixing files across users is a data-leak / collision risk.
52
+ - **CI/CD**: runners typically wipe `/tmp` between jobs but leave the
53
+ per-user temp dir intact, so using the right dir keeps artifacts
54
+ available for inspection post-run.
55
+ - **Sandboxing**: macOS sandbox and certain Linux sandboxes redirect
56
+ `/tmp` to a per-process private dir. The runtime API follows these
57
+ redirects; hardcoded `/tmp` does not.
58
+
59
+ ## Examples
60
+
61
+ ### Node.js / Bun
62
+
63
+ ```ts
64
+ import * as fs from "node:fs";
65
+ import * as os from "node:os";
66
+ import * as path from "node:path";
67
+
68
+ const projectId = "pi-soly"; // or hash(repoUrl) for uniqueness
69
+ const scratchDir = path.join(os.tmpdir(), `${projectId}-${process.pid}`);
70
+ fs.mkdirSync(scratchDir, { recursive: true });
71
+
72
+ try {
73
+ // ... use scratchDir for intermediate files ...
74
+ } finally {
75
+ fs.rmSync(scratchDir, { recursive: true, force: true });
76
+ }
77
+ ```
78
+
79
+ For atomic writes to a target file:
80
+
81
+ ```ts
82
+ import * as fs from "node:fs";
83
+ import * as os from "node:os";
84
+ import * as path from "node:path";
85
+
86
+ function atomicWrite(target: string, data: string | Uint8Array): void {
87
+ const dir = path.dirname(target);
88
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.tmp`);
89
+ const fh = fs.openSync(tmp, "w");
90
+ try {
91
+ fs.writeSync(fh, data);
92
+ fs.fsyncSync(fh);
93
+ } finally {
94
+ fs.closeSync(fh);
95
+ }
96
+ fs.renameSync(tmp, target); // atomic on same filesystem
97
+ }
98
+ ```
99
+
100
+ ### Python
101
+
102
+ ```python
103
+ import shutil
104
+ import tempfile
105
+
106
+ scratch = tempfile.mkdtemp(prefix="myproject-")
107
+ try:
108
+ # ... use scratch ...
109
+ pass
110
+ finally:
111
+ shutil.rmtree(scratch, ignore_errors=True)
112
+
113
+ # Atomic write:
114
+ import os
115
+ def atomic_write(path: str, data: bytes) -> None:
116
+ tmp = f"{path}.{os.getpid()}.tmp"
117
+ with open(tmp, "wb") as f:
118
+ f.write(data)
119
+ f.flush()
120
+ os.fsync(f.fileno())
121
+ os.replace(tmp, path)
122
+ ```
123
+
124
+ ### Bash (POSIX: Linux / macOS)
125
+
126
+ ```bash
127
+ SCRATCH="${TMPDIR:-/tmp}/myproject-$$"
128
+ mkdir -p "$SCRATCH"
129
+ trap 'rm -rf "$SCRATCH"' EXIT
130
+ # ... work in "$SCRATCH" ...
131
+ ```
132
+
133
+ ### PowerShell (Windows)
134
+
135
+ ```powershell
136
+ $scratch = Join-Path $env:TEMP "myproject-$PID"
137
+ New-Item -ItemType Directory -Path $scratch -Force | Out-Null
138
+ try {
139
+ # ... work in $scratch ...
140
+ } finally {
141
+ Remove-Item -Recurse -Force $scratch -ErrorAction SilentlyContinue
142
+ }
143
+ ```
144
+
145
+ ## Forbidden patterns
146
+
147
+ ```ts
148
+ // ❌ Hardcoded path — breaks on Windows, breaks sandboxing
149
+ const dir = "/tmp/myapp";
150
+ const dir = "C:\\Temp\\myapp";
151
+ const dir = path.join("/tmp", "myapp");
152
+
153
+ // ❌ mkdir without cleanup — leaks across runs
154
+ fs.mkdirSync("/tmp/myapp", { recursive: true });
155
+ // ... no rmSync ...
156
+
157
+ // ❌ Atomic write to /tmp when target is elsewhere — cross-device rename fails
158
+ const tmp = `/tmp/${path.basename(target)}`;
159
+ fs.writeFileSync(tmp, data);
160
+ fs.renameSync(tmp, target); // EXDEV on different filesystems
161
+
162
+ // ✓ Correct: resolve temp dir at runtime, project-scoped, cleaned up
163
+ const dir = path.join(os.tmpdir(), `${projectId}-${process.pid}`);
164
+ fs.mkdirSync(dir, { recursive: true });
165
+ try { /* work */ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
166
+ ```
167
+
168
+ ## Override
169
+
170
+ This rule is **built-in to the soly extension** and ships with every
171
+ install. It has highest priority and cannot be overridden by user rules.
172
+ If you need a different convention, name your rule differently
173
+ (e.g., `temp-files-windows-only.md` so the paths don't collide).
package/core.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
19
20
 
20
21
  // Shared leaf utilities now live in util.ts; re-exported here so existing
21
22
  // `import { ... } from "./core.ts"` call sites keep working unchanged.
@@ -58,7 +59,8 @@ export type RuleSource =
58
59
  | "phase-soly"
59
60
  | "project-agents"
60
61
  | "global-agents"
61
- | "phase-agents";
62
+ | "phase-agents"
63
+ | "built-in";
62
64
 
63
65
  export interface RuleFrontmatter {
64
66
  description?: string;
@@ -259,6 +261,40 @@ function loadRulesFromSource(spec: SourceSpec): RuleFile[] {
259
261
  return rules;
260
262
  }
261
263
 
264
+ /**
265
+ * Resolve the path to the extension's `built-in-rules/` directory. Lives
266
+ * at `<package>/built-in-rules/` relative to the compiled module. We use
267
+ * `fileURLToPath(import.meta.url)` so this works whether the package is
268
+ * loaded directly from source (Bun, tsx) or from compiled output.
269
+ *
270
+ * `here` is the directory containing this core.ts file (i.e. the package
271
+ * root for source, the package root for compiled output too — both files
272
+ * live alongside package.json), so we don't need to traverse up.
273
+ */
274
+ export function builtInRulesDir(): string {
275
+ const here = path.dirname(fileURLToPath(import.meta.url));
276
+ return path.resolve(here, "built-in-rules");
277
+ }
278
+
279
+ /**
280
+ * Load every markdown rule from the extension's `built-in-rules/` directory.
281
+ * These rules ship with the soly extension and are always present in the
282
+ * system prompt. Source spec uses priority=10 (highest), so a user rule at
283
+ * the same relPath is silently dropped into `overridden[]` — built-in
284
+ * rules win by design. `sourceLabel: "soly"` makes them visible in
285
+ * `/rules list` so users know which rules come from the package itself.
286
+ */
287
+ export function loadBuiltInRules(): RuleFile[] {
288
+ const dir = builtInRulesDir();
289
+ if (!fs.existsSync(dir)) return [];
290
+ return loadRulesFromSource({
291
+ dir,
292
+ source: "built-in",
293
+ sourceLabel: "soly",
294
+ priority: 10,
295
+ });
296
+ }
297
+
262
298
  export function loadAllRules(sources: SourceSpec[]): {
263
299
  rules: RuleFile[];
264
300
  overridden: string[];
@@ -537,17 +573,26 @@ export function buildRulesSection(
537
573
  if (r.interactiveOnly) interactive.push(r.relPath);
538
574
  }
539
575
 
576
+ // Split into built-in (shipped with soly) vs project/user rules. The two
577
+ // groups render in two distinct sections so the LLM can see the priority
578
+ // structure at a glance: vendor rules at the top (cannot be overridden),
579
+ // user rules below (can be edited locally).
580
+ const builtInRules = applicable.filter((r) => r.source === "built-in");
581
+ const userRules = applicable.filter((r) => r.source !== "built-in");
582
+
540
583
  // Optional grouping: phase rules in their own group, then everything else.
541
- let blocks: string[];
584
+ // Phase grouping only applies to user rules — built-in rules are always
585
+ // always-on and don't move with phases.
586
+ let userBlocks: string[];
542
587
  let headerHint: string;
543
588
  if (options?.groupByPhase) {
544
589
  const phase = options.phaseNumber;
545
- const phaseRules = applicable.filter((r) => r.phaseNumber === phase);
546
- const otherRules = applicable.filter((r) => r.phaseNumber !== phase);
547
- blocks = [...phaseRules.map(render), ...otherRules.map(render)];
590
+ const phaseRules = userRules.filter((r) => r.phaseNumber === phase);
591
+ const otherRules = userRules.filter((r) => r.phaseNumber !== phase);
592
+ userBlocks = [...phaseRules.map(render), ...otherRules.map(render)];
548
593
  headerHint = `Phase ${phase} rules are loaded for the currently active phase; all other rules are always-on. Inline @see references are resolved recursively.`;
549
594
  } else {
550
- blocks = applicable.map(render);
595
+ userBlocks = userRules.map(render);
551
596
  headerHint = `The following rules are loaded from \`.agents/rules/\` and \`~/.agents/rules/\` and are mandatory. Follow them strictly. Inline @see references are resolved recursively.`;
552
597
  }
553
598
 
@@ -557,7 +602,26 @@ export function buildRulesSection(
557
602
  .join(", ")}_`
558
603
  : "";
559
604
 
560
- const section = `
605
+ // Built-in section (shipped with soly, cannot be overridden).
606
+ // Renders ABOVE the MANDATORY project-rules section so the LLM sees the
607
+ // vendor content first, with a stronger "this is system-managed" framing.
608
+ const builtInSection = builtInRules.length
609
+ ? `
610
+
611
+ ## 🔒 Built-in rules (shipped with soly)
612
+
613
+ **The following rules ship with the soly extension and apply to every
614
+ session.** They are the highest-priority rules and cannot be overridden
615
+ by user rules in \`.agents/rules/\` or \`~/.agents/rules/\` (a user rule
616
+ at the same relPath is dropped silently). Follow them strictly.
617
+
618
+ ${builtInRules.map(render).join("\n\n---\n\n")}
619
+ `
620
+ : "";
621
+
622
+ // Project/user rules section (the existing MANDATORY contract).
623
+ const projectSection = userBlocks.length
624
+ ? `
561
625
 
562
626
  ## ⚠️ MANDATORY: soly project rules
563
627
 
@@ -565,8 +629,11 @@ export function buildRulesSection(
565
629
 
566
630
  ${headerHint}
567
631
 
568
- ${blocks.join("\n\n---\n\n")}${skippedNote}
569
- `;
632
+ ${userBlocks.join("\n\n---\n\n")}${skippedNote}
633
+ `
634
+ : "";
635
+
636
+ const section = builtInSection + projectSection;
570
637
 
571
638
  return { section, loaded: applicable.map(ruleKey), interactive };
572
639
  }
package/index.ts CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  extractFilePathsFromPrompt,
35
35
  formatTok,
36
36
  loadAllRules,
37
+ builtInRulesDir,
38
+ loadBuiltInRules,
37
39
  loadPhaseRules,
38
40
  loadProjectState,
39
41
  matchesGlob,
@@ -441,10 +443,14 @@ export default function solyExtension(pi: ExtensionAPI) {
441
443
  // Project rules always beat global rules. `.agents/rules.local/` is
442
444
  // gitignored — for personal overrides on top of the project's rules.
443
445
  // `.agents/rules/` is the vendor-neutral project-level convention.
446
+ // Built-in rules ship with the extension (priority=10 — highest) and
447
+ // can't be overridden: a user rule at the same relPath is dropped
448
+ // silently into the `overridden[]` list (see loadAllRules).
444
449
  ruleSources = [
445
- { dir: path.join(ctx.cwd, ".agents", "rules.local"), source: "project-agents", sourceLabel: "local", priority: 5 },
446
- { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
450
+ { dir: builtInRulesDir(), source: "built-in", sourceLabel: "soly", priority: 10 },
447
451
  { dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 },
452
+ { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
453
+ { dir: path.join(ctx.cwd, ".agents", "rules.local"), source: "project-agents", sourceLabel: "local", priority: 5 },
448
454
  ];
449
455
  refreshRules();
450
456
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -60,7 +60,8 @@
60
60
  "init.ts",
61
61
  "codemap.ts",
62
62
  "state.ts",
63
- "hotreload.ts"
63
+ "hotreload.ts",
64
+ "built-in-rules"
64
65
  ],
65
66
  "keywords": [
66
67
  "pi",