@mccune1224/pi-pstack 0.1.0 → 0.2.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.
Files changed (126) hide show
  1. package/README.md +52 -100
  2. package/agents/comment-sicko.md +1 -1
  3. package/agents/poteto-agent.md +2 -2
  4. package/docs/compare.md +122 -0
  5. package/docs/guide/01-setup.md +37 -12
  6. package/docs/guide/02-poteto-mode.md +1 -1
  7. package/docs/guide/03-understand.md +0 -8
  8. package/docs/guide/05-build-and-clean.md +1 -1
  9. package/docs/guide/06-verify-and-ship.md +1 -1
  10. package/docs/guide/07-overnight.md +2 -2
  11. package/docs/guide/08-principles.md +4 -2
  12. package/docs/guide/10-recipes-and-pitfalls.md +2 -2
  13. package/docs/guide/README.md +2 -2
  14. package/extensions/compaction.test.ts +132 -0
  15. package/extensions/compaction.ts +183 -0
  16. package/extensions/context-guard.test.ts +53 -0
  17. package/extensions/context-guard.ts +65 -0
  18. package/extensions/doctor.test.ts +85 -0
  19. package/extensions/doctor.ts +100 -0
  20. package/extensions/index.ts +289 -26
  21. package/extensions/settings.test.ts +71 -16
  22. package/extensions/settings.ts +57 -9
  23. package/extensions/skill-budget.test.ts +110 -0
  24. package/extensions/skill-budget.ts +101 -0
  25. package/package.json +9 -4
  26. package/scripts/check-pi-isms.mjs +106 -0
  27. package/scripts/lib/pi-delta-rules.mjs +110 -0
  28. package/scripts/lib/pi-delta-rules.test.mjs +100 -0
  29. package/scripts/lib/upstream-vendor.mjs +32 -0
  30. package/scripts/pi-deltas.json +754 -0
  31. package/scripts/sync-upstream.mjs +117 -0
  32. package/scripts/verify-guards.mjs +132 -0
  33. package/scripts/verify-sync.mjs +206 -0
  34. package/skills/architect/SKILL.md +10 -10
  35. package/skills/architect/references/rationale-template.md +2 -2
  36. package/skills/architect/references/runner-prompt.md +7 -7
  37. package/skills/arena/SKILL.md +11 -11
  38. package/skills/automate-me/SKILL.md +21 -26
  39. package/skills/blast-radius/SKILL.md +5 -5
  40. package/skills/create-verification-skill/SKILL.md +5 -5
  41. package/skills/figure-it-out/SKILL.md +11 -13
  42. package/skills/how/SKILL.md +30 -104
  43. package/skills/how/references/explainer-prompt.md +6 -6
  44. package/skills/how/references/explorer-prompt.md +1 -1
  45. package/skills/interrogate/SKILL.md +13 -14
  46. package/skills/interrogate/references/code-quality-review.md +1 -1
  47. package/skills/interrogate/references/lead-judgment.md +1 -1
  48. package/skills/interrogate/references/rubric.md +2 -2
  49. package/skills/maintain-verification-skill/SKILL.md +5 -5
  50. package/skills/no-comments/SKILL.md +3 -3
  51. package/skills/poteto-mode/SKILL.md +9 -8
  52. package/skills/poteto-mode/playbooks/authoring-a-skill.md +4 -4
  53. package/skills/poteto-mode/playbooks/autonomous-run.md +4 -4
  54. package/skills/poteto-mode/playbooks/autopilot-full.md +9 -9
  55. package/skills/poteto-mode/playbooks/autopilot-stack.md +8 -8
  56. package/skills/poteto-mode/playbooks/babysit.md +17 -17
  57. package/skills/poteto-mode/playbooks/bug-fix.md +6 -6
  58. package/skills/poteto-mode/playbooks/eval.md +6 -8
  59. package/skills/poteto-mode/playbooks/feature.md +7 -7
  60. package/skills/poteto-mode/playbooks/hillclimb.md +10 -10
  61. package/skills/poteto-mode/playbooks/investigation.md +3 -3
  62. package/skills/poteto-mode/playbooks/multi-phase-plan.md +26 -25
  63. package/skills/poteto-mode/playbooks/opening-a-pr.md +17 -13
  64. package/skills/poteto-mode/playbooks/orchestrate.md +37 -39
  65. package/skills/poteto-mode/playbooks/pause-safely.md +4 -4
  66. package/skills/poteto-mode/playbooks/perf-issue.md +9 -9
  67. package/skills/poteto-mode/playbooks/prototype.md +5 -5
  68. package/skills/poteto-mode/playbooks/refactoring.md +9 -9
  69. package/skills/poteto-mode/playbooks/runtime-forensics.md +3 -3
  70. package/skills/poteto-mode/playbooks/session-pickup.md +4 -6
  71. package/skills/poteto-mode/playbooks/shipping.md +11 -14
  72. package/skills/poteto-mode/playbooks/trace-forensics.md +4 -4
  73. package/skills/poteto-mode/playbooks/visual-parity.md +3 -3
  74. package/skills/poteto-mode/playbooks/worktree-cleanup.md +4 -4
  75. package/skills/poteto-mode/references/bugbot-triage.md +3 -3
  76. package/skills/poteto-mode/scripts/worktree-audit.mjs +0 -7
  77. package/skills/principle-attack-the-premise/SKILL.md +23 -0
  78. package/skills/principle-boundary-discipline/SKILL.md +2 -2
  79. package/skills/principle-build-the-lever/SKILL.md +5 -5
  80. package/skills/principle-encode-lessons-in-structure/SKILL.md +3 -3
  81. package/skills/principle-experience-first/SKILL.md +5 -5
  82. package/skills/principle-fix-root-causes/SKILL.md +4 -4
  83. package/skills/principle-foundational-thinking/SKILL.md +3 -3
  84. package/skills/principle-guard-the-context-window/SKILL.md +2 -2
  85. package/skills/principle-laziness-protocol/SKILL.md +2 -2
  86. package/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +1 -1
  87. package/skills/principle-minimize-reader-load/SKILL.md +2 -2
  88. package/skills/principle-model-the-domain/SKILL.md +3 -3
  89. package/skills/principle-never-block-on-the-human/SKILL.md +4 -5
  90. package/skills/principle-prove-it-works/SKILL.md +3 -3
  91. package/skills/principle-redesign-from-first-principles/SKILL.md +3 -3
  92. package/skills/principle-separate-before-serializing-shared-state/SKILL.md +3 -3
  93. package/skills/principle-sequence-verifiable-units/SKILL.md +3 -3
  94. package/skills/principle-subtract-before-you-add/SKILL.md +3 -4
  95. package/skills/principle-test-behavior-not-implementation/SKILL.md +25 -0
  96. package/skills/principle-type-system-discipline/SKILL.md +5 -5
  97. package/skills/recall/SKILL.md +7 -7
  98. package/skills/reflect/SKILL.md +14 -20
  99. package/skills/reflect/references/divergent-reviewer.md +3 -3
  100. package/skills/reflect/references/judgment-reviewer.md +3 -3
  101. package/skills/reflect/references/synthesizer.md +2 -2
  102. package/skills/reflect/references/tooling-reviewer.md +4 -6
  103. package/skills/setup-pstack/SKILL.md +61 -49
  104. package/skills/show-me-your-work/SKILL.md +17 -17
  105. package/skills/show-me-your-work/scripts/log.mjs +2 -8
  106. package/skills/swarm/SKILL.md +6 -6
  107. package/skills/tdd/SKILL.md +2 -2
  108. package/skills/teach/SKILL.md +7 -7
  109. package/skills/technical-writing/SKILL.md +5 -8
  110. package/skills/typescript-best-practices/SKILL.md +6 -3
  111. package/skills/typescript-best-practices/references/patterns.md +35 -14
  112. package/skills/unslop/SKILL.md +8 -20
  113. package/skills/why/SKILL.md +27 -100
  114. package/skills/why/references/epistemics.md +6 -6
  115. package/skills/why/references/investigator-prompt.md +5 -5
  116. package/skills/why/references/source-playbook.md +1 -1
  117. package/skills/why/references/sources/databricks.md +7 -7
  118. package/skills/why/references/sources/datadog.md +5 -5
  119. package/skills/why/references/sources/incident-postmortem.md +2 -2
  120. package/skills/why/references/sources/linear.md +2 -2
  121. package/skills/why/references/sources/notion.md +3 -3
  122. package/skills/why/references/sources/sentry.md +4 -4
  123. package/skills/why/references/sources/slack.md +2 -2
  124. package/skills/why/references/synthesizer-prompt.md +3 -3
  125. package/skills/how/references/critic-prompt.md +0 -59
  126. package/skills/how/references/critique-rubric.md +0 -58
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { test } from "node:test";
5
+ import {
6
+ analyzeSkillBudget,
7
+ promptBlockEntry,
8
+ readSkillSources,
9
+ type SkillSource,
10
+ } from "./skill-budget.ts";
11
+
12
+ const REPO_SKILLS = join(import.meta.dirname, "..", "skills");
13
+
14
+ const FIXTURES: SkillSource[] = [
15
+ {
16
+ path: "/pkg/skills/demo/SKILL.md",
17
+ content: [
18
+ "---",
19
+ "name: demo",
20
+ 'description: "Says \\"hello\\" to <the> world."',
21
+ "disable-model-invocation: false",
22
+ "---",
23
+ "",
24
+ "# Demo",
25
+ ].join("\n"),
26
+ },
27
+ {
28
+ path: "/pkg/skills/hidden/SKILL.md",
29
+ content: [
30
+ "---",
31
+ "name: hidden",
32
+ "description: Filtered out of the prompt.",
33
+ "disable-model-invocation: true",
34
+ "---",
35
+ ].join("\n"),
36
+ },
37
+ {
38
+ path: "/pkg/skills/quoted/SKILL.md",
39
+ content: [
40
+ "---",
41
+ "name: quoted",
42
+ "description: Not a boolean.",
43
+ 'disable-model-invocation: "true"',
44
+ "---",
45
+ ].join("\n"),
46
+ },
47
+ {
48
+ path: "/pkg/skills/noName/SKILL.md",
49
+ content: ["---", "description: Name comes from the directory.", "---"].join("\n"),
50
+ },
51
+ ];
52
+
53
+ test("promptBlockEntry writes Pi's available_skills entry", () => {
54
+ assert.equal(
55
+ promptBlockEntry("demo", 'Says "hello" to <the> world.', "/pkg/skills/demo/SKILL.md"),
56
+ [
57
+ " <skill>",
58
+ " <name>demo</name>",
59
+ " <description>Says &quot;hello&quot; to &lt;the&gt; world.</description>",
60
+ " <location>/pkg/skills/demo/SKILL.md</location>",
61
+ " </skill>",
62
+ ].join("\n"),
63
+ );
64
+ });
65
+
66
+ test("analyzeSkillBudget prices only the skills Pi exposes to the model", () => {
67
+ const budget = analyzeSkillBudget(FIXTURES);
68
+ assert.deepEqual(budget.skills, [
69
+ { name: "demo", modelInvocable: true, characters: 169 },
70
+ { name: "hidden", modelInvocable: false, characters: 156 },
71
+ { name: "quoted", modelInvocable: true, characters: 143 },
72
+ { name: "noName", modelInvocable: true, characters: 159 },
73
+ ]);
74
+ assert.deepEqual(
75
+ {
76
+ total: budget.total,
77
+ modelInvocable: budget.modelInvocable,
78
+ filtered: budget.filtered,
79
+ characters: budget.characters,
80
+ estimatedTokens: budget.estimatedTokens,
81
+ },
82
+ { total: 4, modelInvocable: 3, filtered: 1, characters: 473, estimatedTokens: 118 },
83
+ );
84
+ });
85
+
86
+ test("analyzeSkillBudget reports this repo's real skills directory", () => {
87
+ const budget = analyzeSkillBudget(readSkillSources(REPO_SKILLS));
88
+ const dirs = readdirSync(REPO_SKILLS, { withFileTypes: true })
89
+ .filter((entry) => entry.isDirectory())
90
+ .map((entry) => entry.name);
91
+ for (const name of dirs) {
92
+ assert.ok(existsSync(join(REPO_SKILLS, name, "SKILL.md")), `${name}/SKILL.md is missing`);
93
+ }
94
+ assert.deepEqual(
95
+ budget.skills.map((skill) => skill.name).sort(),
96
+ [...dirs].sort(),
97
+ "every skill directory must be priced, and nothing outside them",
98
+ );
99
+ assert.deepEqual(
100
+ { total: budget.total, modelInvocable: budget.modelInvocable, filtered: budget.filtered },
101
+ { total: 50, modelInvocable: 5, filtered: 45 },
102
+ "the exposure counts changed; a sync that changes a disable-model-invocation flag lands here",
103
+ );
104
+ assert.deepEqual(
105
+ budget.skills.filter((skill) => skill.modelInvocable).map((skill) => skill.name),
106
+ ["control-cli", "control-ui", "create-skill", "deslop", "setup-pstack"],
107
+ "the set of skills Pi offers changed; this usually means upstream changed a disable-model-invocation flag, so update the README exposure claim and docs/compare.md together with this test",
108
+ );
109
+ assert.equal(budget.modelInvocable + budget.filtered, dirs.length);
110
+ });
@@ -0,0 +1,101 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+
4
+ export interface SkillSource {
5
+ path: string;
6
+ content: string;
7
+ }
8
+
9
+ export interface SkillCost {
10
+ name: string;
11
+ modelInvocable: boolean;
12
+ characters: number;
13
+ }
14
+
15
+ export interface SkillBudget {
16
+ total: number;
17
+ modelInvocable: number;
18
+ filtered: number;
19
+ characters: number;
20
+ estimatedTokens: number;
21
+ skills: SkillCost[];
22
+ }
23
+
24
+ function escapeXml(value: string): string {
25
+ return value
26
+ .replace(/&/g, "&amp;")
27
+ .replace(/</g, "&lt;")
28
+ .replace(/>/g, "&gt;")
29
+ .replace(/"/g, "&quot;")
30
+ .replace(/'/g, "&apos;");
31
+ }
32
+
33
+ function frontmatter(content: string): string {
34
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
35
+ return match ? match[1] : "";
36
+ }
37
+
38
+ function frontmatterField(frontmatterText: string, key: string): string | undefined {
39
+ const match = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(frontmatterText);
40
+ if (!match) return undefined;
41
+ const raw = match[1].trim();
42
+ if (raw.length > 1 && raw.startsWith('"') && raw.endsWith('"')) {
43
+ return raw.slice(1, -1).replace(/\\(["\\])/g, "$1");
44
+ }
45
+ return raw.replace(/[ \t]+#.*$/, "");
46
+ }
47
+
48
+ // Pi filters on a YAML boolean true, so the string "true" stays model-invocable.
49
+ function yamlBooleanTrue(frontmatterText: string, key: string): boolean {
50
+ const match = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(frontmatterText);
51
+ if (!match) return false;
52
+ return /^(?:true|True|TRUE)$/.test(match[1].trim().replace(/[ \t]+#.*$/, ""));
53
+ }
54
+
55
+ export function promptBlockEntry(name: string, description: string, location: string): string {
56
+ return [
57
+ " <skill>",
58
+ ` <name>${escapeXml(name)}</name>`,
59
+ ` <description>${escapeXml(description)}</description>`,
60
+ ` <location>${escapeXml(location)}</location>`,
61
+ " </skill>",
62
+ ].join("\n");
63
+ }
64
+
65
+ export function readSkillSources(skillsDir: string): SkillSource[] {
66
+ const sources: SkillSource[] = [];
67
+ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
68
+ if (!entry.isDirectory()) continue;
69
+ const path = join(skillsDir, entry.name, "SKILL.md");
70
+ if (!existsSync(path)) continue;
71
+ sources.push({ path, content: readFileSync(path, "utf8") });
72
+ }
73
+ return sources;
74
+ }
75
+
76
+ export function analyzeSkillBudget(sources: SkillSource[]): SkillBudget {
77
+ const skills: SkillCost[] = [];
78
+ for (const source of sources) {
79
+ const frontmatterText = frontmatter(source.content);
80
+ const description = frontmatterField(frontmatterText, "description");
81
+ if (!description) continue;
82
+ const name = frontmatterField(frontmatterText, "name") || basename(dirname(source.path));
83
+ skills.push({
84
+ name,
85
+ modelInvocable: !yamlBooleanTrue(frontmatterText, "disable-model-invocation"),
86
+ characters: promptBlockEntry(name, description, source.path).length,
87
+ });
88
+ }
89
+ const exposed = skills.filter((skill) => skill.modelInvocable);
90
+ // Pi pushes each entry into the same lines array, so every entry also pays a newline.
91
+ const characters =
92
+ exposed.reduce((sum, skill) => sum + skill.characters, 0) + Math.max(exposed.length - 1, 0);
93
+ return {
94
+ total: skills.length,
95
+ modelInvocable: exposed.length,
96
+ filtered: skills.length - exposed.length,
97
+ characters,
98
+ estimatedTokens: Math.round(characters / 4),
99
+ skills,
100
+ };
101
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mccune1224/pi-pstack",
3
- "version": "0.1.0",
4
- "description": "pstack for Pi. Poteto's rigorous engineering workflow adapted from the pstack plugin into a Pi plugin: skills, subagents, and commands for write-less-higher-quality code with fearless parallelism.",
3
+ "version": "0.2.0",
4
+ "description": "pstack for Pi. Poteto's rigorous engineering workflow ported to the Pi Agent Harness: 50 skills and 2 agents, plus an extension that wires the mode into Pi settings, sessions, compaction, and the context window.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
@@ -23,6 +23,8 @@
23
23
  "workflow",
24
24
  "principles",
25
25
  "subagents",
26
+ "context-guard",
27
+ "compaction",
26
28
  "unslop",
27
29
  "agent-style"
28
30
  ],
@@ -31,6 +33,7 @@
31
33
  "agents/",
32
34
  "docs/",
33
35
  "extensions/",
36
+ "scripts/",
34
37
  "README.md",
35
38
  "LICENSE"
36
39
  ],
@@ -61,9 +64,11 @@
61
64
  },
62
65
  "scripts": {
63
66
  "typecheck": "tsc --noEmit",
64
- "prepublishOnly": "npm run typecheck && npm test",
65
- "test": "node --experimental-strip-types --test extensions/*.test.ts",
67
+ "prepublishOnly": "npm run typecheck && npm test && npm run verify:sync && npm run verify:guards",
68
+ "test": "node --experimental-strip-types --test extensions/*.test.ts scripts/lib/*.test.mjs",
66
69
  "check:piisms": "node scripts/check-pi-isms.mjs",
70
+ "verify:sync": "node scripts/verify-sync.mjs",
71
+ "verify:guards": "node scripts/verify-guards.mjs",
67
72
  "sync:upstream": "node scripts/sync-upstream.mjs"
68
73
  },
69
74
  "devDependencies": {
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ const root = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
6
+
7
+ const COUPLING = [
8
+ "grok-4\\.6",
9
+ "claude-fable",
10
+ "gpt-5\\.6-sol",
11
+ "claude-opus",
12
+ "api2\\.cursor\\.sh",
13
+ "AskQuestion",
14
+ "subagent_type",
15
+ "Task\\(model",
16
+ "pstack-models\\.mdc",
17
+ "/add-plugin",
18
+ "cursor-team-kit",
19
+ "Cursor's",
20
+ "Cursor cloud",
21
+ "Cursor environment",
22
+ "Cursor restart",
23
+ "Cursor dashboard",
24
+ "\\.cursor/",
25
+ "cloud agent",
26
+ "cloud root",
27
+ "cloud VM",
28
+ "cloud-sleeper",
29
+ 'environment: "cloud"',
30
+ "agent-transcripts",
31
+ "(?<![a-z-])/loop(?![a-z-])",
32
+ "pstack/skills/",
33
+ ];
34
+
35
+ const PAT = new RegExp(COUPLING.join("|"), "i");
36
+
37
+ const ALLOWED = [
38
+ {
39
+ prefix: "skills/poteto-mode/scripts/watch-pr/",
40
+ why: "Detects Cursor's bugbot review comments on GitHub, which is a real GitHub app and still worth gating on.",
41
+ },
42
+ {
43
+ prefix: "docs/compare.md",
44
+ why: "This file exists to name what differs from upstream and from the other Pi port, so it has to name the things it excludes.",
45
+ },
46
+ ];
47
+
48
+ function allowed(path) {
49
+ return ALLOWED.some((entry) => path.startsWith(entry.prefix));
50
+ }
51
+
52
+ const DOC_PAT = /pi note|has no|does not|do not route|instead|never require|not a pstack skill|do NOT write/i;
53
+
54
+ function* walk(dir) {
55
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
56
+ const path = join(dir, entry.name);
57
+ if (entry.isDirectory()) yield* walk(path);
58
+ else yield path;
59
+ }
60
+ }
61
+
62
+ const action = [];
63
+ const documented = [];
64
+ const skipped = [];
65
+ const emDashes = [];
66
+ const brokenLinks = [];
67
+
68
+ for (const base of ["skills", "agents", "docs"]) {
69
+ const dir = join(root, base);
70
+ if (!existsSync(dir)) continue;
71
+ for (const file of walk(dir)) {
72
+ if (!/\.(md|ts|sh|mjs|js)$/.test(file)) continue;
73
+ const rel = file.replace(`${root}/`, "");
74
+ const source = readFileSync(file, "utf8");
75
+ if (file.endsWith(".md")) {
76
+ for (const match of source.matchAll(/\]\(([^)\s]+)\)/g)) {
77
+ const target = match[1].split("#")[0];
78
+ if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || allowed(rel)) continue;
79
+ if (!/[./]/.test(target)) continue;
80
+ if (!existsSync(resolve(dirname(file), target))) brokenLinks.push({ file: rel, line: 0, text: target });
81
+ }
82
+ }
83
+ source.split("\n").forEach((line, index) => {
84
+ if (/\u2014/.test(line) && !allowed(rel)) {
85
+ emDashes.push({ file: rel, line: index + 1, text: line.trim().slice(0, 150) });
86
+ }
87
+ if (!PAT.test(line)) return;
88
+ const hit = { file: rel, line: index + 1, text: line.trim().slice(0, 150) };
89
+ if (allowed(rel)) skipped.push(hit);
90
+ else if (DOC_PAT.test(line)) documented.push(hit);
91
+ else action.push(hit);
92
+ });
93
+ }
94
+ }
95
+
96
+ for (const hit of action) console.log(`ACTION ${hit.file}:${hit.line}: ${hit.text}`);
97
+ for (const hit of skipped) console.log(`allowed ${hit.file}:${hit.line}: ${hit.text}`);
98
+ for (const hit of emDashes) console.log(`EM-DASH ${hit.file}:${hit.line}: ${hit.text}`);
99
+ for (const hit of brokenLinks) console.log(`LINK ${hit.file}: ${hit.text}`);
100
+
101
+ console.log(`\n${action.length} Cursor-coupling, ${emDashes.length} long-dash character, ${brokenLinks.length} broken relative link, ${documented.length} documented as a Pi note, ${skipped.length} allowlisted.`);
102
+ if (action.length === 0 && emDashes.length === 0 && brokenLinks.length === 0) {
103
+ console.log("clean: no unexplained Cursor coupling, no long-dash characters, and every relative link resolves.");
104
+ process.exit(0);
105
+ }
106
+ process.exit(1);
@@ -0,0 +1,110 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const GLOB_SPECIAL = /[.+^${}()|[\]\\]/g;
4
+
5
+ export const RULE_KINDS = new Set(["substitute", "insert", "exclude", "add", "override"]);
6
+
7
+ export function hashContent(content) {
8
+ return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
9
+ }
10
+
11
+ export function globToRegExp(glob) {
12
+ const source = glob
13
+ .replace(GLOB_SPECIAL, "\\$&")
14
+ .replace(/\*\*\//g, "\u0000")
15
+ .replace(/\*\*/g, "\u0001")
16
+ .replace(/\*/g, "[^/]*")
17
+ .replace(/\u0000/g, "(?:.*/)?")
18
+ .replace(/\u0001/g, ".*");
19
+ return new RegExp(`^${source}$`);
20
+ }
21
+
22
+ export function matchGlob(glob, path) {
23
+ return globToRegExp(glob).test(path);
24
+ }
25
+
26
+ export function globsOf(spec, kind) {
27
+ return spec.rules.filter((rule) => rule.kind === kind).map((rule) => rule.path);
28
+ }
29
+
30
+ export function matchesAny(globs, path) {
31
+ return globs.some((glob) => matchGlob(glob, path));
32
+ }
33
+
34
+ const REQUIRED_FIELDS = {
35
+ substitute: ["id", "path", "find", "replace", "reason"],
36
+ insert: ["id", "path", "anchor", "position", "text", "reason"],
37
+ exclude: ["id", "path", "reason"],
38
+ add: ["id", "path", "reason"],
39
+ override: ["id", "path", "basedOn", "reason"],
40
+ };
41
+
42
+ export function validateSpec(spec) {
43
+ const problems = [];
44
+ if (!spec || typeof spec !== "object") return ["spec is not an object"];
45
+ if (typeof spec.upstream?.ref !== "string" || !spec.upstream.ref) {
46
+ problems.push("upstream.ref is required");
47
+ }
48
+ if (!Array.isArray(spec.rules)) return [...problems, "rules must be an array"];
49
+ const seen = new Set();
50
+ spec.rules.forEach((rule, index) => {
51
+ const label = `rules[${index}]${rule?.id ? ` (${rule.id})` : ""}`;
52
+ if (!RULE_KINDS.has(rule?.kind)) {
53
+ problems.push(`${label}: unknown kind ${JSON.stringify(rule?.kind)}`);
54
+ return;
55
+ }
56
+ for (const field of REQUIRED_FIELDS[rule.kind]) {
57
+ if (typeof rule[field] !== "string" || rule[field] === "") {
58
+ problems.push(`${label}: ${field} must be a non-empty string`);
59
+ }
60
+ }
61
+ if (rule.kind === "insert" && !["before", "after"].includes(rule.position)) {
62
+ problems.push(`${label}: position must be "before" or "after"`);
63
+ }
64
+ if (rule.id) {
65
+ if (seen.has(rule.id)) problems.push(`${label}: duplicate id`);
66
+ seen.add(rule.id);
67
+ }
68
+ });
69
+ return problems;
70
+ }
71
+
72
+ function applySubstitute(content, rule) {
73
+ if (rule.regex) {
74
+ const pattern = new RegExp(rule.find, "g");
75
+ return content.replace(pattern, rule.replace);
76
+ }
77
+ return content.includes(rule.find) ? content.replaceAll(rule.find, rule.replace) : content;
78
+ }
79
+
80
+ function applyInsert(content, rule) {
81
+ const pattern = new RegExp(rule.anchor, "m");
82
+ const match = pattern.exec(content);
83
+ if (!match) return content;
84
+ const lineStart = match.index;
85
+ const lineEnd = content.indexOf("\n", lineStart);
86
+ const insertAt = rule.position === "before" || lineEnd === -1
87
+ ? lineStart
88
+ : lineEnd + 1;
89
+ const block = content.endsWith("\n") || insertAt === 0 ? `${rule.text}\n` : rule.text;
90
+ return content.slice(0, insertAt) + block + content.slice(insertAt);
91
+ }
92
+
93
+ export function applyRules(content, path, rules) {
94
+ let out = content;
95
+ const applied = [];
96
+ for (const rule of rules) {
97
+ if (rule.kind !== "substitute" && rule.kind !== "insert") continue;
98
+ if (!matchGlob(rule.path, path)) continue;
99
+ const next = rule.kind === "substitute" ? applySubstitute(out, rule) : applyInsert(out, rule);
100
+ if (next !== out) {
101
+ applied.push(rule.id);
102
+ out = next;
103
+ }
104
+ }
105
+ return { content: out, applied };
106
+ }
107
+
108
+ export function overrideFor(spec, path) {
109
+ return spec.rules.find((rule) => rule.kind === "override" && matchGlob(rule.path, path));
110
+ }
@@ -0,0 +1,100 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyRules, globToRegExp, hashContent, matchGlob, overrideFor, validateSpec } from "./pi-delta-rules.mjs";
4
+
5
+ test("glob matches nested paths for ** and one level for *", () => {
6
+ assert.ok(matchGlob("**/*.md", "skills/how/references/explainer-prompt.md"));
7
+ assert.ok(matchGlob("**/*.md", "top.md"), "**/ also matches zero directories");
8
+ assert.ok(matchGlob("agents/*.md", "agents/poteto-agent.md"));
9
+ assert.ok(!matchGlob("agents/*.md", "agents/nested/poteto-agent.md"));
10
+ assert.ok(matchGlob("**/*", "skills/poteto-mode/scripts/check-plan.mjs"));
11
+ assert.equal(globToRegExp("a*b").test("a/b"), false);
12
+ });
13
+
14
+ test("validateSpec rejects a bad kind, a duplicate id, and a bad insert position", () => {
15
+ const base = { upstream: { ref: "abc" }, rules: [] };
16
+ assert.deepEqual(validateSpec(base), []);
17
+
18
+ const unknown = validateSpec({ ...base, rules: [{ kind: "rewrite", id: "x" }] });
19
+ assert.ok(unknown.some((problem) => problem.includes("unknown kind")));
20
+
21
+ const duplicate = validateSpec({
22
+ ...base,
23
+ rules: [
24
+ { kind: "add", id: "same", path: "a/**", reason: "r" },
25
+ { kind: "add", id: "same", path: "b/**", reason: "r" },
26
+ ],
27
+ });
28
+ assert.ok(duplicate.some((problem) => problem.includes("duplicate id")));
29
+
30
+ const badPosition = validateSpec({
31
+ ...base,
32
+ rules: [{ kind: "insert", id: "i", path: "*", anchor: "^a$", position: "beside", text: "t", reason: "r" }],
33
+ });
34
+ assert.ok(badPosition.some((problem) => problem.includes("position")));
35
+
36
+ assert.ok(validateSpec({ rules: [] }).some((problem) => problem.includes("upstream.ref")));
37
+ });
38
+
39
+ test("substitute replaces every literal occurrence and reports which rules fired", () => {
40
+ const rules = [
41
+ { kind: "substitute", id: "swap", path: "**/*.md", find: ".cursor/skills/", replace: ".pi/skills/", reason: "r" },
42
+ ];
43
+ const input = "a .cursor/skills/ b .cursor/skills/ c";
44
+ const { content, applied } = applyRules(input, "skills/x/SKILL.md", rules);
45
+ assert.equal(content, "a .pi/skills/ b .pi/skills/ c");
46
+ assert.deepEqual(applied, ["swap"]);
47
+ });
48
+
49
+ test("a rule whose find is absent reports nothing applied, which is how stale rules surface", () => {
50
+ const rules = [{ kind: "substitute", id: "gone", path: "**/*.md", find: "no such text", replace: "x", reason: "r" }];
51
+ const { content, applied } = applyRules("untouched", "skills/x/SKILL.md", rules);
52
+ assert.equal(content, "untouched");
53
+ assert.deepEqual(applied, []);
54
+ });
55
+
56
+ test("a rule only applies to paths its glob matches", () => {
57
+ const rules = [{ kind: "substitute", id: "scoped", path: "agents/*.md", find: "a", replace: "b", reason: "r" }];
58
+ assert.deepEqual(applyRules("a", "skills/x/SKILL.md", rules).applied, []);
59
+ assert.deepEqual(applyRules("a", "agents/x.md", rules).applied, ["scoped"]);
60
+ });
61
+
62
+ test("insert places text on its own line before or after the anchor line", () => {
63
+ const input = "---\nname: x\ndescription: d\nis_background: true\n---\nbody\n";
64
+ const before = applyRules(input, "agents/x.md", [
65
+ { kind: "insert", id: "front", path: "agents/*.md", anchor: "^is_background: true$", position: "before", text: "thinking: high\ntools: read", reason: "r" },
66
+ ]);
67
+ assert.equal(before.content, "---\nname: x\ndescription: d\nthinking: high\ntools: read\nis_background: true\n---\nbody\n");
68
+
69
+ const after = applyRules(input, "agents/x.md", [
70
+ { kind: "insert", id: "desc", path: "agents/*.md", anchor: "^description: ", position: "after", text: "thinking: high", reason: "r" },
71
+ ]);
72
+ assert.equal(after.content, "---\nname: x\ndescription: d\nthinking: high\nis_background: true\n---\nbody\n");
73
+ });
74
+
75
+ test("declaration order decides the winner when two rules could match the same text", () => {
76
+ const shared = "Cursor's built-in `create-skill` skill";
77
+ const specific = { kind: "substitute", id: "specific", path: "**/*.md", find: shared, replace: "the bundled `create-skill` skill", reason: "r" };
78
+ const generic = { kind: "substitute", id: "generic", path: "**/*.md", find: "Cursor's built-in `create-skill`", replace: "the bundled `create-skill` skill", reason: "r" };
79
+
80
+ assert.deepEqual(applyRules(shared, "a.md", [specific, generic]).applied, ["specific"]);
81
+ assert.deepEqual(applyRules(shared, "a.md", [generic, specific]).applied, ["generic"]);
82
+ });
83
+
84
+ test("applyRules uses a regex rule when regex is set", () => {
85
+ const rules = [{ kind: "substitute", id: "rx", path: "**/*.md", regex: true, find: "\\(default `[^`]+`\\)", replace: "(default: inherit)", reason: "r" }];
86
+ const { content } = applyRules("- model: x (default `grok-4.6-fast-xhigh`)", "skills/x/SKILL.md", rules);
87
+ assert.equal(content, "- model: x (default: inherit)");
88
+ });
89
+
90
+ test("hashContent is stable and content-sensitive", () => {
91
+ assert.equal(hashContent("a"), hashContent("a"));
92
+ assert.notEqual(hashContent("a"), hashContent("b"));
93
+ assert.match(hashContent("a"), /^sha256:[0-9a-f]{64}$/);
94
+ });
95
+
96
+ test("overrideFor finds the baselined override that owns a path", () => {
97
+ const spec = { rules: [{ kind: "override", id: "ov", path: "skills/poteto-mode/SKILL.md", basedOn: "sha256:x", reason: "r" }] };
98
+ assert.equal(overrideFor(spec, "skills/poteto-mode/SKILL.md").id, "ov");
99
+ assert.equal(overrideFor(spec, "skills/how/SKILL.md"), undefined);
100
+ });
@@ -0,0 +1,32 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export function git(upstreamDir, args, encoding = "utf8") {
4
+ const result = spawnSync("git", ["-C", upstreamDir, ...args], {
5
+ encoding,
6
+ maxBuffer: 64 * 1024 * 1024,
7
+ });
8
+ if (result.status !== 0) {
9
+ throw new Error(`git ${args.join(" ")} failed in ${upstreamDir}: ${result.stderr?.toString().trim() ?? ""}`);
10
+ }
11
+ return result.stdout;
12
+ }
13
+
14
+ export function upstreamVersion(upstreamDir, ref) {
15
+ const raw = git(upstreamDir, ["show", `${ref}:pstack/.cursor-plugin/plugin.json`]);
16
+ return JSON.parse(raw).version;
17
+ }
18
+
19
+ export function isBinary(buffer) {
20
+ return buffer.includes(0);
21
+ }
22
+
23
+ export function readUpstreamTree(upstreamDir, ref, subtrees = ["skills", "agents", "docs"]) {
24
+ const pathspecs = subtrees.map((subtree) => `pstack/${subtree}`);
25
+ const listed = git(upstreamDir, ["ls-tree", "-r", "--name-only", ref, "--", ...pathspecs]);
26
+ const files = new Map();
27
+ for (const line of listed.split("\n")) {
28
+ if (!line) continue;
29
+ files.set(line.replace(/^pstack\//, ""), git(upstreamDir, ["show", `${ref}:${line}`], null));
30
+ }
31
+ return files;
32
+ }