@poltergeist-ai/cli 0.1.6 → 0.1.9
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 +28 -4
- package/dist/cli.js +315 -20
- package/dist/index.d.ts +18 -0
- package/dist/index.js +160 -8
- package/ghosts/example-ghost.md +152 -0
- package/package.json +7 -2
- package/skills/extract/SKILL.md +64 -0
- package/skills/poltergeist/SKILL.md +169 -0
package/README.md
CHANGED
|
@@ -17,6 +17,18 @@ npm install -g @poltergeist-ai/cli
|
|
|
17
17
|
poltergeist extract [options]
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
### Set up skills for your AI coding tool
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @poltergeist-ai/cli setup
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This installs the poltergeist review and extract skills for Claude Code, Codex, Cursor, Windsurf, or Cline. You can also pass `--tool` to skip the interactive prompt:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx @poltergeist-ai/cli setup --tool claude-code,cursor
|
|
30
|
+
```
|
|
31
|
+
|
|
20
32
|
Requires Node.js >= 18.17.0.
|
|
21
33
|
|
|
22
34
|
## Usage
|
|
@@ -70,12 +82,18 @@ npx @poltergeist-ai/cli extract \
|
|
|
70
82
|
--github-token ghp_xxxxxxxxxxxx
|
|
71
83
|
```
|
|
72
84
|
|
|
85
|
+
The CLI also reads `GITHUB_TOKEN` or `GITHUB_PERSONAL_ACCESS_TOKEN` from the environment, so you can skip the flag if either is set.
|
|
86
|
+
|
|
73
87
|
## CLI reference
|
|
74
88
|
|
|
75
89
|
```
|
|
76
|
-
Usage: poltergeist
|
|
90
|
+
Usage: poltergeist <command> [options]
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
Commands:
|
|
93
|
+
extract Build a contributor ghost profile from data sources
|
|
94
|
+
setup Install poltergeist skills for your AI coding tool
|
|
95
|
+
|
|
96
|
+
Extract options:
|
|
79
97
|
--contributor <name> Contributor name (required)
|
|
80
98
|
--email <email> Contributor email for git log filtering
|
|
81
99
|
--slug <slug> Output filename slug (default: derived from name)
|
|
@@ -86,6 +104,11 @@ Options:
|
|
|
86
104
|
--github-token <token> GitHub PAT for higher API rate limits
|
|
87
105
|
--output <path> Output path (default: .poltergeist/ghosts/<slug>.md)
|
|
88
106
|
--verbose Enable verbose logging
|
|
107
|
+
|
|
108
|
+
Setup options:
|
|
109
|
+
--tool <id> Tool to install for (claude-code,codex,cursor,windsurf,cline)
|
|
110
|
+
Comma-separated for multiple. Omit to choose interactively.
|
|
111
|
+
|
|
89
112
|
--help Show help
|
|
90
113
|
```
|
|
91
114
|
|
|
@@ -124,14 +147,15 @@ Use a Slack admin export (or workspace export) and point `--slack-export` at the
|
|
|
124
147
|
|
|
125
148
|
The CLI generates a ghost file in Markdown with these sections:
|
|
126
149
|
|
|
127
|
-
- **Identity** — name, slug, role, primary domains, sources used
|
|
150
|
+
- **Identity** — name, slug, version, status, role, primary domains, sources used, generator version
|
|
151
|
+
- **Review Heuristics** — weighted dimensions table with confidence levels and default severities, tradeoff preferences, scars
|
|
128
152
|
- **Review philosophy** — ranked values, dealbreakers, what they ignore
|
|
129
153
|
- **Communication style** — tone, severity prefixes, vocabulary, comment length
|
|
130
154
|
- **Code patterns** — patterns they prefer, push back on, and commonly suggest
|
|
131
155
|
- **Known blind spots** — areas they historically under-review
|
|
132
156
|
- **Example review comments** — verbatim excerpts for voice grounding
|
|
133
157
|
|
|
134
|
-
Sections marked `[fill in manually]` need human input. The manual pass is the most important step — especially ranked values and example comments.
|
|
158
|
+
The Review Heuristics table is auto-generated from review comment analysis — it drives priority and comment distribution during simulated reviews. Sections marked `[fill in manually]` need human input. The manual pass is the most important step — especially ranked values, tradeoff preferences, and example comments.
|
|
135
159
|
|
|
136
160
|
### After extraction
|
|
137
161
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync as
|
|
4
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "fs";
|
|
5
5
|
import path4 from "path";
|
|
6
6
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
7
7
|
import { parseArgs } from "util";
|
|
@@ -742,31 +742,115 @@ function sampleMatching(comments, patterns, max) {
|
|
|
742
742
|
}
|
|
743
743
|
return matches;
|
|
744
744
|
}
|
|
745
|
-
function extractThemes(comments) {
|
|
745
|
+
function extractThemes(comments, commentSeverities) {
|
|
746
746
|
if (comments.length === 0) return [];
|
|
747
747
|
const themes = [];
|
|
748
|
+
const severities = commentSeverities ?? [];
|
|
748
749
|
for (const def of THEME_DEFS) {
|
|
749
750
|
const matchingComments = [];
|
|
750
|
-
|
|
751
|
+
const matchingSeverities = [];
|
|
752
|
+
let totalLength = 0;
|
|
753
|
+
for (let i = 0; i < comments.length; i++) {
|
|
754
|
+
const comment = comments[i];
|
|
751
755
|
const lower = comment.toLowerCase();
|
|
752
756
|
if (def.patterns.some((p) => p.test(lower))) {
|
|
753
757
|
matchingComments.push(comment);
|
|
758
|
+
totalLength += comment.length;
|
|
759
|
+
if (i < severities.length) {
|
|
760
|
+
matchingSeverities.push(severities[i]);
|
|
761
|
+
}
|
|
754
762
|
}
|
|
755
763
|
}
|
|
756
764
|
if (matchingComments.length < 2) continue;
|
|
757
765
|
const ratio = Math.round(matchingComments.length / comments.length * 100) / 100;
|
|
758
766
|
const snippets = matchingComments.filter((c) => c.length > 20 && c.length < 300).slice(0, 3).map((c) => c.length > 150 ? c.slice(0, 150) + "..." : c);
|
|
767
|
+
const severityBreakdown = {};
|
|
768
|
+
for (const sev of matchingSeverities) {
|
|
769
|
+
severityBreakdown[sev] = (severityBreakdown[sev] ?? 0) + 1;
|
|
770
|
+
}
|
|
759
771
|
themes.push({
|
|
760
772
|
theme: def.theme,
|
|
761
773
|
label: def.label,
|
|
762
774
|
count: matchingComments.length,
|
|
763
775
|
ratio,
|
|
764
|
-
exampleSnippets: snippets
|
|
776
|
+
exampleSnippets: snippets,
|
|
777
|
+
severityBreakdown: matchingSeverities.length > 0 ? severityBreakdown : void 0,
|
|
778
|
+
avgCommentLength: Math.round(totalLength / matchingComments.length)
|
|
765
779
|
});
|
|
766
780
|
}
|
|
767
781
|
themes.sort((a, b) => b.count - a.count);
|
|
768
782
|
return themes;
|
|
769
783
|
}
|
|
784
|
+
var HIGH_SEVERITY = /* @__PURE__ */ new Set(["blocking", "major"]);
|
|
785
|
+
var MED_SEVERITY = /* @__PURE__ */ new Set(["suggestion", "question", "thought"]);
|
|
786
|
+
var LOW_SEVERITY = /* @__PURE__ */ new Set(["nit", "minor"]);
|
|
787
|
+
function dominantSeverity(breakdown) {
|
|
788
|
+
if (!breakdown) return "unknown";
|
|
789
|
+
let bestCategory = "unknown";
|
|
790
|
+
let bestCount = 0;
|
|
791
|
+
let highCount = 0;
|
|
792
|
+
let medCount = 0;
|
|
793
|
+
let lowCount = 0;
|
|
794
|
+
for (const [sev, count] of Object.entries(breakdown)) {
|
|
795
|
+
if (HIGH_SEVERITY.has(sev)) highCount += count;
|
|
796
|
+
else if (MED_SEVERITY.has(sev)) medCount += count;
|
|
797
|
+
else if (LOW_SEVERITY.has(sev)) lowCount += count;
|
|
798
|
+
}
|
|
799
|
+
if (highCount > bestCount) {
|
|
800
|
+
bestCategory = "blocking";
|
|
801
|
+
bestCount = highCount;
|
|
802
|
+
}
|
|
803
|
+
if (medCount > bestCount) {
|
|
804
|
+
bestCategory = "suggestion";
|
|
805
|
+
bestCount = medCount;
|
|
806
|
+
}
|
|
807
|
+
if (lowCount > bestCount) {
|
|
808
|
+
bestCategory = "nit";
|
|
809
|
+
bestCount = lowCount;
|
|
810
|
+
}
|
|
811
|
+
return bestCategory;
|
|
812
|
+
}
|
|
813
|
+
function computeWeightedDimensions(themes) {
|
|
814
|
+
if (themes.length === 0) return [];
|
|
815
|
+
const maxRatio = Math.max(...themes.map((t) => t.ratio));
|
|
816
|
+
const maxAvgLen = Math.max(
|
|
817
|
+
...themes.map((t) => t.avgCommentLength ?? 0),
|
|
818
|
+
1
|
|
819
|
+
);
|
|
820
|
+
return themes.map((theme) => {
|
|
821
|
+
const frequencyScore = maxRatio > 0 ? theme.ratio / maxRatio : 0;
|
|
822
|
+
let severityScore = 0.5;
|
|
823
|
+
if (theme.severityBreakdown) {
|
|
824
|
+
const total = Object.values(theme.severityBreakdown).reduce(
|
|
825
|
+
(a, b) => a + b,
|
|
826
|
+
0
|
|
827
|
+
);
|
|
828
|
+
if (total > 0) {
|
|
829
|
+
let highCount = 0;
|
|
830
|
+
for (const [sev, count] of Object.entries(theme.severityBreakdown)) {
|
|
831
|
+
if (HIGH_SEVERITY.has(sev)) highCount += count;
|
|
832
|
+
}
|
|
833
|
+
severityScore = highCount / total;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const avgLen = theme.avgCommentLength ?? 0;
|
|
837
|
+
const specificityScore = maxAvgLen > 0 ? avgLen / maxAvgLen : 0;
|
|
838
|
+
const rawWeight = frequencyScore * 0.5 + severityScore * 0.3 + specificityScore * 0.2;
|
|
839
|
+
const weight = Math.round(Math.min(1, Math.max(0, rawWeight)) * 100) / 100;
|
|
840
|
+
let confidence;
|
|
841
|
+
if (theme.count >= 20) confidence = "high";
|
|
842
|
+
else if (theme.count >= 10) confidence = "moderate";
|
|
843
|
+
else confidence = "low";
|
|
844
|
+
return {
|
|
845
|
+
dimension: theme.theme,
|
|
846
|
+
label: theme.label,
|
|
847
|
+
weight,
|
|
848
|
+
confidence,
|
|
849
|
+
commentCount: theme.count,
|
|
850
|
+
defaultSeverity: dominantSeverity(theme.severityBreakdown)
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
}
|
|
770
854
|
function buildToneProfile(comments) {
|
|
771
855
|
if (comments.length < 5) return void 0;
|
|
772
856
|
const n = comments.length;
|
|
@@ -810,10 +894,13 @@ function summariseReview(signals) {
|
|
|
810
894
|
indices.filter((i) => i >= 0 && i < n).map((i) => sorted[i])
|
|
811
895
|
)
|
|
812
896
|
];
|
|
813
|
-
obs.reviewThemes = extractThemes(comments);
|
|
897
|
+
obs.reviewThemes = extractThemes(comments, signals.commentSeverities);
|
|
814
898
|
obs.toneProfile = buildToneProfile(comments);
|
|
815
899
|
obs.recurringQuestions = extractRecurringQuestions(comments);
|
|
816
900
|
obs.recurringPhrases = extractRecurringPhrases(comments);
|
|
901
|
+
if (obs.reviewThemes.length > 0) {
|
|
902
|
+
obs.weightedDimensions = computeWeightedDimensions(obs.reviewThemes);
|
|
903
|
+
}
|
|
817
904
|
return obs;
|
|
818
905
|
}
|
|
819
906
|
|
|
@@ -823,6 +910,7 @@ function extractGitLabSignals(exportPath, contributor, verbose) {
|
|
|
823
910
|
reviewComments: [],
|
|
824
911
|
commentLengths: [],
|
|
825
912
|
severityPrefixes: {},
|
|
913
|
+
commentSeverities: [],
|
|
826
914
|
questionComments: 0,
|
|
827
915
|
totalComments: 0,
|
|
828
916
|
source: "gitlab"
|
|
@@ -860,7 +948,11 @@ function extractGitLabSignals(exportPath, contributor, verbose) {
|
|
|
860
948
|
signals.totalComments += 1;
|
|
861
949
|
const prefixMatch = body.match(prefixRe);
|
|
862
950
|
if (prefixMatch) {
|
|
863
|
-
|
|
951
|
+
const severity = prefixMatch[1].toLowerCase();
|
|
952
|
+
increment(signals.severityPrefixes, severity);
|
|
953
|
+
signals.commentSeverities.push(severity);
|
|
954
|
+
} else {
|
|
955
|
+
signals.commentSeverities.push("none");
|
|
864
956
|
}
|
|
865
957
|
if (body.endsWith("?") || body.toLowerCase().startsWith("do we") || body.toLowerCase().startsWith("should we")) {
|
|
866
958
|
signals.questionComments += 1;
|
|
@@ -953,6 +1045,7 @@ async function extractGitHubSignals(owner, repo, contributor, token, verbose) {
|
|
|
953
1045
|
reviewComments: [],
|
|
954
1046
|
commentLengths: [],
|
|
955
1047
|
severityPrefixes: {},
|
|
1048
|
+
commentSeverities: [],
|
|
956
1049
|
questionComments: 0,
|
|
957
1050
|
totalComments: 0,
|
|
958
1051
|
source: "github"
|
|
@@ -996,7 +1089,11 @@ async function extractGitHubSignals(owner, repo, contributor, token, verbose) {
|
|
|
996
1089
|
signals.totalComments += 1;
|
|
997
1090
|
const prefixMatch = body.match(prefixRe);
|
|
998
1091
|
if (prefixMatch) {
|
|
999
|
-
|
|
1092
|
+
const severity = prefixMatch[1].toLowerCase();
|
|
1093
|
+
increment(signals.severityPrefixes, severity);
|
|
1094
|
+
signals.commentSeverities.push(severity);
|
|
1095
|
+
} else {
|
|
1096
|
+
signals.commentSeverities.push("none");
|
|
1000
1097
|
}
|
|
1001
1098
|
if (body.endsWith("?") || body.toLowerCase().startsWith("do we") || body.toLowerCase().startsWith("should we")) {
|
|
1002
1099
|
signals.questionComments += 1;
|
|
@@ -1104,6 +1201,18 @@ function extractDocsSignals(docsDir, contributor, verbose) {
|
|
|
1104
1201
|
}
|
|
1105
1202
|
|
|
1106
1203
|
// src/generator.ts
|
|
1204
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1205
|
+
import { fileURLToPath } from "url";
|
|
1206
|
+
import { dirname, join } from "path";
|
|
1207
|
+
function getCliVersion() {
|
|
1208
|
+
try {
|
|
1209
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
1210
|
+
const pkg = JSON.parse(readFileSync4(join(dir, "..", "package.json"), "utf-8"));
|
|
1211
|
+
return pkg.version ?? "unknown";
|
|
1212
|
+
} catch {
|
|
1213
|
+
return "unknown";
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1107
1216
|
function formatPairs(pairs, suffix = "") {
|
|
1108
1217
|
return pairs.map(([name, count]) => `${name}${suffix} (${count})`).join(", ");
|
|
1109
1218
|
}
|
|
@@ -1129,23 +1238,66 @@ function buildGhostMarkdown(input) {
|
|
|
1129
1238
|
const { contributor, slug, gitObs, codeStyleObs, reviewObs, slackObs, docsSignals, sourcesUsed } = input;
|
|
1130
1239
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1131
1240
|
const domains = gitObs.inferredDomains?.length ? gitObs.inferredDomains.join(", ") : "_[fill in manually]_";
|
|
1241
|
+
const cliVersion = getCliVersion();
|
|
1132
1242
|
const lines = [
|
|
1133
1243
|
`# Contributor Soul: ${contributor}`,
|
|
1134
1244
|
"",
|
|
1135
1245
|
"## Identity",
|
|
1136
1246
|
`- **Slug**: ${slug}`,
|
|
1247
|
+
`- **Version**: 0.1.0`,
|
|
1248
|
+
`- **Status**: draft`,
|
|
1137
1249
|
"- **Role**: _[fill in manually]_",
|
|
1138
1250
|
`- **Primary domains**: ${domains}`,
|
|
1139
1251
|
`- **Soul last updated**: ${today}`,
|
|
1140
1252
|
`- **Sources used**: ${sourcesUsed.join(", ")}`,
|
|
1253
|
+
`- **Generated by**: @poltergeist-ai/cli@${cliVersion}`,
|
|
1141
1254
|
"",
|
|
1255
|
+
"---",
|
|
1256
|
+
""
|
|
1257
|
+
];
|
|
1258
|
+
const weighted = reviewObs.weightedDimensions;
|
|
1259
|
+
const themes = reviewObs.reviewThemes;
|
|
1260
|
+
if (weighted && weighted.length > 0) {
|
|
1261
|
+
lines.push(
|
|
1262
|
+
"## Review Heuristics",
|
|
1263
|
+
"",
|
|
1264
|
+
`_Inferred from ${reviewObs.totalReviewComments ?? "?"} review comments \u2014 adjust weights as needed_`,
|
|
1265
|
+
"",
|
|
1266
|
+
"| Dimension | Weight | Confidence | Default Severity |",
|
|
1267
|
+
"|---|---|---|---|"
|
|
1268
|
+
);
|
|
1269
|
+
for (const dim of weighted) {
|
|
1270
|
+
lines.push(
|
|
1271
|
+
`| ${dim.label} | ${dim.weight.toFixed(2)} | ${dim.confidence} (${dim.commentCount} comments) | ${dim.defaultSeverity} |`
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
lines.push("");
|
|
1275
|
+
}
|
|
1276
|
+
lines.push(
|
|
1277
|
+
"### Tradeoff Preferences",
|
|
1278
|
+
"_How this contributor resolves common engineering tensions. Fill in from review patterns._",
|
|
1279
|
+
"",
|
|
1280
|
+
"- abstraction vs duplication: _[prefer-abstraction | prefer-duplication | balanced]_",
|
|
1281
|
+
"- readability vs performance: _[prefer-readability | prefer-performance | balanced]_",
|
|
1282
|
+
"- speed vs correctness: _[prefer-speed | prefer-correctness | balanced]_",
|
|
1283
|
+
"- local vs system optimization: _[prefer-local | prefer-system | balanced]_",
|
|
1284
|
+
""
|
|
1285
|
+
);
|
|
1286
|
+
lines.push(
|
|
1287
|
+
"### Scars",
|
|
1288
|
+
"_Historical incidents that make this contributor unusually sensitive to certain patterns._",
|
|
1289
|
+
"_Format: **pattern** (multiplier) \u2014 description. Amplifies: dimension names._",
|
|
1290
|
+
"",
|
|
1291
|
+
"_[Fill in manually \u2014 e.g.: **shared-mutable-state** (\xD71.8) \u2014 production incident. Amplifies: error_handling, readability]_",
|
|
1292
|
+
""
|
|
1293
|
+
);
|
|
1294
|
+
lines.push(
|
|
1142
1295
|
"---",
|
|
1143
1296
|
"",
|
|
1144
1297
|
"## Review Philosophy",
|
|
1145
1298
|
"",
|
|
1146
1299
|
"### What they care about most (ranked)"
|
|
1147
|
-
|
|
1148
|
-
const themes = reviewObs.reviewThemes;
|
|
1300
|
+
);
|
|
1149
1301
|
if (themes && themes.length > 0) {
|
|
1150
1302
|
lines.push(
|
|
1151
1303
|
`_Inferred from ${reviewObs.totalReviewComments ?? "?"} review comments \u2014 verify and re-order as needed_`
|
|
@@ -1416,6 +1568,132 @@ function buildGhostMarkdown(input) {
|
|
|
1416
1568
|
return lines.join("\n");
|
|
1417
1569
|
}
|
|
1418
1570
|
|
|
1571
|
+
// src/setup.ts
|
|
1572
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync5, writeFileSync } from "fs";
|
|
1573
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1574
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1575
|
+
import { createInterface } from "readline";
|
|
1576
|
+
import { installRules, supportedTools } from "@poltergeist-ai/llm-rules";
|
|
1577
|
+
function getSkillsDir() {
|
|
1578
|
+
return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "skills");
|
|
1579
|
+
}
|
|
1580
|
+
function loadSkill(filePath) {
|
|
1581
|
+
const raw = readFileSync5(filePath, "utf-8");
|
|
1582
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1583
|
+
if (!fmMatch) {
|
|
1584
|
+
return { name: "", description: "", body: raw, raw };
|
|
1585
|
+
}
|
|
1586
|
+
const frontmatter = fmMatch[1];
|
|
1587
|
+
const body = fmMatch[2].trim();
|
|
1588
|
+
let name = "";
|
|
1589
|
+
let description = "";
|
|
1590
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
1591
|
+
if (nameMatch) name = nameMatch[1].trim();
|
|
1592
|
+
const descMatch = frontmatter.match(/description:\s*>\s*\n([\s\S]*?)$/);
|
|
1593
|
+
if (descMatch) {
|
|
1594
|
+
description = descMatch[1].replace(/\n\s*/g, " ").trim();
|
|
1595
|
+
} else {
|
|
1596
|
+
const descSimple = frontmatter.match(/^description:\s*(?!>)(.+)$/m);
|
|
1597
|
+
if (descSimple) description = descSimple[1].trim();
|
|
1598
|
+
}
|
|
1599
|
+
return { name, description, body, raw };
|
|
1600
|
+
}
|
|
1601
|
+
function stripPluginRoot(content) {
|
|
1602
|
+
return content.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, ".poltergeist/");
|
|
1603
|
+
}
|
|
1604
|
+
function prompt(question) {
|
|
1605
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1606
|
+
return new Promise((resolve) => {
|
|
1607
|
+
rl.question(question, (answer) => {
|
|
1608
|
+
rl.close();
|
|
1609
|
+
resolve(answer.trim());
|
|
1610
|
+
});
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
async function runSetup(toolFlag) {
|
|
1614
|
+
console.log("\n Poltergeist Setup\n");
|
|
1615
|
+
const tools = supportedTools();
|
|
1616
|
+
let selectedToolIds;
|
|
1617
|
+
if (toolFlag) {
|
|
1618
|
+
const ids = toolFlag.split(",").map((s) => s.trim().toLowerCase());
|
|
1619
|
+
const valid = ids.filter((id) => tools.some((t) => t.id === id));
|
|
1620
|
+
if (valid.length === 0) {
|
|
1621
|
+
console.error(
|
|
1622
|
+
`Unknown tool(s): ${toolFlag}
|
|
1623
|
+
Available: ${tools.map((t) => t.id).join(", ")}`
|
|
1624
|
+
);
|
|
1625
|
+
return 1;
|
|
1626
|
+
}
|
|
1627
|
+
selectedToolIds = valid;
|
|
1628
|
+
} else {
|
|
1629
|
+
console.log(" Available tools:\n");
|
|
1630
|
+
for (let i = 0; i < tools.length; i++) {
|
|
1631
|
+
console.log(` ${i + 1}. ${tools[i].name} (${tools[i].id})`);
|
|
1632
|
+
}
|
|
1633
|
+
console.log(` a. All
|
|
1634
|
+
`);
|
|
1635
|
+
const answer = await prompt(" Install for (numbers comma-separated, or 'a' for all): ");
|
|
1636
|
+
if (answer.toLowerCase() === "a") {
|
|
1637
|
+
selectedToolIds = tools.map((t) => t.id);
|
|
1638
|
+
} else {
|
|
1639
|
+
const indices = answer.split(",").map((s) => parseInt(s.trim()) - 1).filter((i) => i >= 0 && i < tools.length);
|
|
1640
|
+
if (indices.length === 0) {
|
|
1641
|
+
console.log("No tools selected.");
|
|
1642
|
+
return 0;
|
|
1643
|
+
}
|
|
1644
|
+
selectedToolIds = indices.map((i) => tools[i].id);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const skillsDir = getSkillsDir();
|
|
1648
|
+
const reviewSkillPath = join2(skillsDir, "poltergeist", "SKILL.md");
|
|
1649
|
+
const extractSkillPath = join2(skillsDir, "extract", "SKILL.md");
|
|
1650
|
+
const rules = [];
|
|
1651
|
+
if (existsSync(reviewSkillPath)) {
|
|
1652
|
+
const skill = loadSkill(reviewSkillPath);
|
|
1653
|
+
rules.push({
|
|
1654
|
+
name: skill.name || "poltergeist",
|
|
1655
|
+
description: skill.description || "Poltergeist review skill",
|
|
1656
|
+
content: stripPluginRoot(skill.raw)
|
|
1657
|
+
});
|
|
1658
|
+
} else {
|
|
1659
|
+
console.error(` [error] Review skill not found at ${reviewSkillPath}`);
|
|
1660
|
+
return 1;
|
|
1661
|
+
}
|
|
1662
|
+
if (existsSync(extractSkillPath)) {
|
|
1663
|
+
const skill = loadSkill(extractSkillPath);
|
|
1664
|
+
rules.push({
|
|
1665
|
+
name: skill.name || "extract",
|
|
1666
|
+
description: skill.description || "Poltergeist extract skill",
|
|
1667
|
+
content: stripPluginRoot(skill.raw)
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
console.log(`
|
|
1671
|
+
Installing for ${selectedToolIds.join(", ")}...`);
|
|
1672
|
+
const results = installRules(rules, {
|
|
1673
|
+
tools: selectedToolIds,
|
|
1674
|
+
force: true,
|
|
1675
|
+
namespace: "poltergeist"
|
|
1676
|
+
});
|
|
1677
|
+
for (const result of results) {
|
|
1678
|
+
console.log(` \u2713 ${result.path} (${result.action})`);
|
|
1679
|
+
}
|
|
1680
|
+
const exampleGhostPath = join2(skillsDir, "..", "ghosts", "example-ghost.md");
|
|
1681
|
+
if (existsSync(exampleGhostPath)) {
|
|
1682
|
+
const ghostDest = ".poltergeist/ghosts/example-ghost.md";
|
|
1683
|
+
if (!existsSync(ghostDest)) {
|
|
1684
|
+
mkdirSync(dirname2(ghostDest), { recursive: true });
|
|
1685
|
+
writeFileSync(ghostDest, readFileSync5(exampleGhostPath));
|
|
1686
|
+
console.log(`
|
|
1687
|
+
\u2713 ${ghostDest}`);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
console.log("\n Done. Next steps:");
|
|
1691
|
+
console.log(" 1. Build a ghost: npx @poltergeist-ai/cli extract --contributor <name> --git-repo <url>");
|
|
1692
|
+
console.log(' 2. Run a review: git diff main | claude "review as @<slug>"');
|
|
1693
|
+
console.log("");
|
|
1694
|
+
return 0;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1419
1697
|
// src/cli.ts
|
|
1420
1698
|
var POLTERGEIST_DIR = ".poltergeist";
|
|
1421
1699
|
var CACHE_DIR = `${POLTERGEIST_DIR}/repos`;
|
|
@@ -1423,25 +1701,27 @@ var GHOSTS_DIR = `${POLTERGEIST_DIR}/ghosts`;
|
|
|
1423
1701
|
function ensureReposGitignored() {
|
|
1424
1702
|
const gitignorePath = ".gitignore";
|
|
1425
1703
|
const entry = ".poltergeist/repos/";
|
|
1426
|
-
if (
|
|
1427
|
-
const content =
|
|
1704
|
+
if (existsSync2(gitignorePath)) {
|
|
1705
|
+
const content = readFileSync6(gitignorePath, "utf-8");
|
|
1428
1706
|
if (content.includes(entry)) return;
|
|
1429
1707
|
appendFileSync(gitignorePath, `
|
|
1430
1708
|
# Poltergeist cached clones
|
|
1431
1709
|
${entry}
|
|
1432
1710
|
`);
|
|
1433
1711
|
} else {
|
|
1434
|
-
|
|
1712
|
+
writeFileSync2(gitignorePath, `# Poltergeist cached clones
|
|
1435
1713
|
${entry}
|
|
1436
1714
|
`);
|
|
1437
1715
|
}
|
|
1438
1716
|
}
|
|
1439
1717
|
function printUsage() {
|
|
1440
|
-
console.log(`Usage: poltergeist
|
|
1718
|
+
console.log(`Usage: poltergeist <command> [options]
|
|
1441
1719
|
|
|
1442
|
-
|
|
1720
|
+
Commands:
|
|
1721
|
+
extract Build a contributor ghost profile from data sources
|
|
1722
|
+
setup Install poltergeist skills for your AI coding tool
|
|
1443
1723
|
|
|
1444
|
-
|
|
1724
|
+
Extract options:
|
|
1445
1725
|
--contributor <name> Contributor name (required; use GitHub username for best results)
|
|
1446
1726
|
--email <email> Contributor email (for git log filtering)
|
|
1447
1727
|
--slug <slug> Output slug (default: derived from name)
|
|
@@ -1452,6 +1732,11 @@ Options:
|
|
|
1452
1732
|
--github-token <token> GitHub personal access token (for higher API rate limits)
|
|
1453
1733
|
--output <path> Output path (default: .poltergeist/ghosts/<slug>.md)
|
|
1454
1734
|
--verbose Enable verbose logging
|
|
1735
|
+
|
|
1736
|
+
Setup options:
|
|
1737
|
+
--tool <id> Tool to install for (claude-code,codex,cursor,windsurf,cline)
|
|
1738
|
+
Comma-separated for multiple. Omit to choose interactively.
|
|
1739
|
+
|
|
1455
1740
|
--help Show this help message`);
|
|
1456
1741
|
}
|
|
1457
1742
|
function isRemoteUrl(value) {
|
|
@@ -1464,7 +1749,7 @@ function resolveGitRepo(value, verbose) {
|
|
|
1464
1749
|
if (!isRemoteUrl(value)) return value;
|
|
1465
1750
|
const slug = repoSlug(value);
|
|
1466
1751
|
const cloneDir = path4.join(CACHE_DIR, slug);
|
|
1467
|
-
if (
|
|
1752
|
+
if (existsSync2(cloneDir)) {
|
|
1468
1753
|
console.log(`[extract] Using cached clone at ${cloneDir}`);
|
|
1469
1754
|
try {
|
|
1470
1755
|
execFileSync2("git", ["-C", cloneDir, "fetch", "--quiet"], {
|
|
@@ -1478,7 +1763,7 @@ function resolveGitRepo(value, verbose) {
|
|
|
1478
1763
|
return cloneDir;
|
|
1479
1764
|
}
|
|
1480
1765
|
console.log(`[extract] Cloning ${value} into ${cloneDir}...`);
|
|
1481
|
-
|
|
1766
|
+
mkdirSync2(CACHE_DIR, { recursive: true });
|
|
1482
1767
|
ensureReposGitignored();
|
|
1483
1768
|
execFileSync2(
|
|
1484
1769
|
"git",
|
|
@@ -1497,6 +1782,16 @@ async function run() {
|
|
|
1497
1782
|
printUsage();
|
|
1498
1783
|
return 0;
|
|
1499
1784
|
}
|
|
1785
|
+
if (rawArgs[0] === "setup") {
|
|
1786
|
+
const setupArgs = rawArgs.slice(1);
|
|
1787
|
+
let toolFlag;
|
|
1788
|
+
for (let i = 0; i < setupArgs.length; i++) {
|
|
1789
|
+
if (setupArgs[i] === "--tool" && setupArgs[i + 1]) {
|
|
1790
|
+
toolFlag = setupArgs[i + 1];
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return runSetup(toolFlag);
|
|
1794
|
+
}
|
|
1500
1795
|
const args = rawArgs[0] === "extract" ? rawArgs.slice(1) : rawArgs;
|
|
1501
1796
|
const { values } = parseArgs({
|
|
1502
1797
|
args,
|
|
@@ -1529,7 +1824,7 @@ async function run() {
|
|
|
1529
1824
|
const slug = values.slug ?? slugify(contributor);
|
|
1530
1825
|
const outputPath = values.output ?? `${GHOSTS_DIR}/${slug}.md`;
|
|
1531
1826
|
const verbose = values.verbose ?? false;
|
|
1532
|
-
const githubToken = values["github-token"];
|
|
1827
|
+
const githubToken = values["github-token"] ?? process.env.GITHUB_PERSONAL_ACCESS_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
1533
1828
|
const sourcesUsed = [];
|
|
1534
1829
|
let gitObs = {};
|
|
1535
1830
|
let codeStyleObs = { observations: [], totalLinesAnalyzed: 0 };
|
|
@@ -1632,9 +1927,9 @@ async function run() {
|
|
|
1632
1927
|
});
|
|
1633
1928
|
const dir = path4.dirname(outputPath);
|
|
1634
1929
|
if (dir && dir !== ".") {
|
|
1635
|
-
|
|
1930
|
+
mkdirSync2(dir, { recursive: true });
|
|
1636
1931
|
}
|
|
1637
|
-
|
|
1932
|
+
writeFileSync2(outputPath, ghostMd);
|
|
1638
1933
|
console.log(`
|
|
1639
1934
|
Ghost draft written to: ${outputPath}`);
|
|
1640
1935
|
console.log("\nNext steps:");
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ interface ReviewSignals {
|
|
|
34
34
|
reviewComments: string[];
|
|
35
35
|
commentLengths: number[];
|
|
36
36
|
severityPrefixes: Record<string, number>;
|
|
37
|
+
/** Per-comment severity prefix (parallel array to reviewComments) */
|
|
38
|
+
commentSeverities?: string[];
|
|
37
39
|
questionComments: number;
|
|
38
40
|
totalComments: number;
|
|
39
41
|
source: "github" | "gitlab";
|
|
@@ -46,6 +48,10 @@ interface ReviewTheme {
|
|
|
46
48
|
ratio: number;
|
|
47
49
|
/** Verbatim snippets that matched this theme */
|
|
48
50
|
exampleSnippets: string[];
|
|
51
|
+
/** Breakdown of severity prefixes for comments matching this theme */
|
|
52
|
+
severityBreakdown?: Record<string, number>;
|
|
53
|
+
/** Average character length of comments matching this theme */
|
|
54
|
+
avgCommentLength?: number;
|
|
49
55
|
}
|
|
50
56
|
interface CommentToneProfile {
|
|
51
57
|
/** Fraction of comments that include praise / positive reinforcement */
|
|
@@ -61,6 +67,16 @@ interface CommentToneProfile {
|
|
|
61
67
|
/** Sample explanatory comments */
|
|
62
68
|
explanationExamples: string[];
|
|
63
69
|
}
|
|
70
|
+
interface WeightedDimension {
|
|
71
|
+
dimension: string;
|
|
72
|
+
label: string;
|
|
73
|
+
/** Composite weight (0.0–1.0) derived from frequency, severity, and specificity */
|
|
74
|
+
weight: number;
|
|
75
|
+
confidence: "high" | "moderate" | "low";
|
|
76
|
+
commentCount: number;
|
|
77
|
+
/** Most common severity when this theme appears */
|
|
78
|
+
defaultSeverity: "blocking" | "suggestion" | "nit" | "unknown";
|
|
79
|
+
}
|
|
64
80
|
interface ReviewObservations {
|
|
65
81
|
totalReviewComments?: number;
|
|
66
82
|
avgCommentLength?: number;
|
|
@@ -77,6 +93,8 @@ interface ReviewObservations {
|
|
|
77
93
|
recurringQuestions?: string[];
|
|
78
94
|
/** Phrases/vocabulary the contributor uses repeatedly */
|
|
79
95
|
recurringPhrases?: string[];
|
|
96
|
+
/** Weighted dimensions computed from frequency, severity, and specificity */
|
|
97
|
+
weightedDimensions?: WeightedDimension[];
|
|
80
98
|
}
|
|
81
99
|
/** @deprecated Use ReviewSignals instead */
|
|
82
100
|
type GitLabSignals = ReviewSignals;
|
package/dist/index.js
CHANGED
|
@@ -734,31 +734,115 @@ function sampleMatching(comments, patterns, max) {
|
|
|
734
734
|
}
|
|
735
735
|
return matches;
|
|
736
736
|
}
|
|
737
|
-
function extractThemes(comments) {
|
|
737
|
+
function extractThemes(comments, commentSeverities) {
|
|
738
738
|
if (comments.length === 0) return [];
|
|
739
739
|
const themes = [];
|
|
740
|
+
const severities = commentSeverities ?? [];
|
|
740
741
|
for (const def of THEME_DEFS) {
|
|
741
742
|
const matchingComments = [];
|
|
742
|
-
|
|
743
|
+
const matchingSeverities = [];
|
|
744
|
+
let totalLength = 0;
|
|
745
|
+
for (let i = 0; i < comments.length; i++) {
|
|
746
|
+
const comment = comments[i];
|
|
743
747
|
const lower = comment.toLowerCase();
|
|
744
748
|
if (def.patterns.some((p) => p.test(lower))) {
|
|
745
749
|
matchingComments.push(comment);
|
|
750
|
+
totalLength += comment.length;
|
|
751
|
+
if (i < severities.length) {
|
|
752
|
+
matchingSeverities.push(severities[i]);
|
|
753
|
+
}
|
|
746
754
|
}
|
|
747
755
|
}
|
|
748
756
|
if (matchingComments.length < 2) continue;
|
|
749
757
|
const ratio = Math.round(matchingComments.length / comments.length * 100) / 100;
|
|
750
758
|
const snippets = matchingComments.filter((c) => c.length > 20 && c.length < 300).slice(0, 3).map((c) => c.length > 150 ? c.slice(0, 150) + "..." : c);
|
|
759
|
+
const severityBreakdown = {};
|
|
760
|
+
for (const sev of matchingSeverities) {
|
|
761
|
+
severityBreakdown[sev] = (severityBreakdown[sev] ?? 0) + 1;
|
|
762
|
+
}
|
|
751
763
|
themes.push({
|
|
752
764
|
theme: def.theme,
|
|
753
765
|
label: def.label,
|
|
754
766
|
count: matchingComments.length,
|
|
755
767
|
ratio,
|
|
756
|
-
exampleSnippets: snippets
|
|
768
|
+
exampleSnippets: snippets,
|
|
769
|
+
severityBreakdown: matchingSeverities.length > 0 ? severityBreakdown : void 0,
|
|
770
|
+
avgCommentLength: Math.round(totalLength / matchingComments.length)
|
|
757
771
|
});
|
|
758
772
|
}
|
|
759
773
|
themes.sort((a, b) => b.count - a.count);
|
|
760
774
|
return themes;
|
|
761
775
|
}
|
|
776
|
+
var HIGH_SEVERITY = /* @__PURE__ */ new Set(["blocking", "major"]);
|
|
777
|
+
var MED_SEVERITY = /* @__PURE__ */ new Set(["suggestion", "question", "thought"]);
|
|
778
|
+
var LOW_SEVERITY = /* @__PURE__ */ new Set(["nit", "minor"]);
|
|
779
|
+
function dominantSeverity(breakdown) {
|
|
780
|
+
if (!breakdown) return "unknown";
|
|
781
|
+
let bestCategory = "unknown";
|
|
782
|
+
let bestCount = 0;
|
|
783
|
+
let highCount = 0;
|
|
784
|
+
let medCount = 0;
|
|
785
|
+
let lowCount = 0;
|
|
786
|
+
for (const [sev, count] of Object.entries(breakdown)) {
|
|
787
|
+
if (HIGH_SEVERITY.has(sev)) highCount += count;
|
|
788
|
+
else if (MED_SEVERITY.has(sev)) medCount += count;
|
|
789
|
+
else if (LOW_SEVERITY.has(sev)) lowCount += count;
|
|
790
|
+
}
|
|
791
|
+
if (highCount > bestCount) {
|
|
792
|
+
bestCategory = "blocking";
|
|
793
|
+
bestCount = highCount;
|
|
794
|
+
}
|
|
795
|
+
if (medCount > bestCount) {
|
|
796
|
+
bestCategory = "suggestion";
|
|
797
|
+
bestCount = medCount;
|
|
798
|
+
}
|
|
799
|
+
if (lowCount > bestCount) {
|
|
800
|
+
bestCategory = "nit";
|
|
801
|
+
bestCount = lowCount;
|
|
802
|
+
}
|
|
803
|
+
return bestCategory;
|
|
804
|
+
}
|
|
805
|
+
function computeWeightedDimensions(themes) {
|
|
806
|
+
if (themes.length === 0) return [];
|
|
807
|
+
const maxRatio = Math.max(...themes.map((t) => t.ratio));
|
|
808
|
+
const maxAvgLen = Math.max(
|
|
809
|
+
...themes.map((t) => t.avgCommentLength ?? 0),
|
|
810
|
+
1
|
|
811
|
+
);
|
|
812
|
+
return themes.map((theme) => {
|
|
813
|
+
const frequencyScore = maxRatio > 0 ? theme.ratio / maxRatio : 0;
|
|
814
|
+
let severityScore = 0.5;
|
|
815
|
+
if (theme.severityBreakdown) {
|
|
816
|
+
const total = Object.values(theme.severityBreakdown).reduce(
|
|
817
|
+
(a, b) => a + b,
|
|
818
|
+
0
|
|
819
|
+
);
|
|
820
|
+
if (total > 0) {
|
|
821
|
+
let highCount = 0;
|
|
822
|
+
for (const [sev, count] of Object.entries(theme.severityBreakdown)) {
|
|
823
|
+
if (HIGH_SEVERITY.has(sev)) highCount += count;
|
|
824
|
+
}
|
|
825
|
+
severityScore = highCount / total;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const avgLen = theme.avgCommentLength ?? 0;
|
|
829
|
+
const specificityScore = maxAvgLen > 0 ? avgLen / maxAvgLen : 0;
|
|
830
|
+
const rawWeight = frequencyScore * 0.5 + severityScore * 0.3 + specificityScore * 0.2;
|
|
831
|
+
const weight = Math.round(Math.min(1, Math.max(0, rawWeight)) * 100) / 100;
|
|
832
|
+
let confidence;
|
|
833
|
+
if (theme.count >= 20) confidence = "high";
|
|
834
|
+
else if (theme.count >= 10) confidence = "moderate";
|
|
835
|
+
else confidence = "low";
|
|
836
|
+
return {
|
|
837
|
+
dimension: theme.theme,
|
|
838
|
+
label: theme.label,
|
|
839
|
+
weight,
|
|
840
|
+
confidence,
|
|
841
|
+
commentCount: theme.count,
|
|
842
|
+
defaultSeverity: dominantSeverity(theme.severityBreakdown)
|
|
843
|
+
};
|
|
844
|
+
});
|
|
845
|
+
}
|
|
762
846
|
function buildToneProfile(comments) {
|
|
763
847
|
if (comments.length < 5) return void 0;
|
|
764
848
|
const n = comments.length;
|
|
@@ -802,10 +886,13 @@ function summariseReview(signals) {
|
|
|
802
886
|
indices.filter((i) => i >= 0 && i < n).map((i) => sorted[i])
|
|
803
887
|
)
|
|
804
888
|
];
|
|
805
|
-
obs.reviewThemes = extractThemes(comments);
|
|
889
|
+
obs.reviewThemes = extractThemes(comments, signals.commentSeverities);
|
|
806
890
|
obs.toneProfile = buildToneProfile(comments);
|
|
807
891
|
obs.recurringQuestions = extractRecurringQuestions(comments);
|
|
808
892
|
obs.recurringPhrases = extractRecurringPhrases(comments);
|
|
893
|
+
if (obs.reviewThemes.length > 0) {
|
|
894
|
+
obs.weightedDimensions = computeWeightedDimensions(obs.reviewThemes);
|
|
895
|
+
}
|
|
809
896
|
return obs;
|
|
810
897
|
}
|
|
811
898
|
|
|
@@ -815,6 +902,7 @@ function extractGitLabSignals(exportPath, contributor, verbose) {
|
|
|
815
902
|
reviewComments: [],
|
|
816
903
|
commentLengths: [],
|
|
817
904
|
severityPrefixes: {},
|
|
905
|
+
commentSeverities: [],
|
|
818
906
|
questionComments: 0,
|
|
819
907
|
totalComments: 0,
|
|
820
908
|
source: "gitlab"
|
|
@@ -852,7 +940,11 @@ function extractGitLabSignals(exportPath, contributor, verbose) {
|
|
|
852
940
|
signals.totalComments += 1;
|
|
853
941
|
const prefixMatch = body.match(prefixRe);
|
|
854
942
|
if (prefixMatch) {
|
|
855
|
-
|
|
943
|
+
const severity = prefixMatch[1].toLowerCase();
|
|
944
|
+
increment(signals.severityPrefixes, severity);
|
|
945
|
+
signals.commentSeverities.push(severity);
|
|
946
|
+
} else {
|
|
947
|
+
signals.commentSeverities.push("none");
|
|
856
948
|
}
|
|
857
949
|
if (body.endsWith("?") || body.toLowerCase().startsWith("do we") || body.toLowerCase().startsWith("should we")) {
|
|
858
950
|
signals.questionComments += 1;
|
|
@@ -945,6 +1037,7 @@ async function extractGitHubSignals(owner, repo, contributor, token, verbose) {
|
|
|
945
1037
|
reviewComments: [],
|
|
946
1038
|
commentLengths: [],
|
|
947
1039
|
severityPrefixes: {},
|
|
1040
|
+
commentSeverities: [],
|
|
948
1041
|
questionComments: 0,
|
|
949
1042
|
totalComments: 0,
|
|
950
1043
|
source: "github"
|
|
@@ -988,7 +1081,11 @@ async function extractGitHubSignals(owner, repo, contributor, token, verbose) {
|
|
|
988
1081
|
signals.totalComments += 1;
|
|
989
1082
|
const prefixMatch = body.match(prefixRe);
|
|
990
1083
|
if (prefixMatch) {
|
|
991
|
-
|
|
1084
|
+
const severity = prefixMatch[1].toLowerCase();
|
|
1085
|
+
increment(signals.severityPrefixes, severity);
|
|
1086
|
+
signals.commentSeverities.push(severity);
|
|
1087
|
+
} else {
|
|
1088
|
+
signals.commentSeverities.push("none");
|
|
992
1089
|
}
|
|
993
1090
|
if (body.endsWith("?") || body.toLowerCase().startsWith("do we") || body.toLowerCase().startsWith("should we")) {
|
|
994
1091
|
signals.questionComments += 1;
|
|
@@ -1096,6 +1193,18 @@ function extractDocsSignals(docsDir, contributor, verbose) {
|
|
|
1096
1193
|
}
|
|
1097
1194
|
|
|
1098
1195
|
// src/generator.ts
|
|
1196
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1197
|
+
import { fileURLToPath } from "url";
|
|
1198
|
+
import { dirname, join } from "path";
|
|
1199
|
+
function getCliVersion() {
|
|
1200
|
+
try {
|
|
1201
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
1202
|
+
const pkg = JSON.parse(readFileSync4(join(dir, "..", "package.json"), "utf-8"));
|
|
1203
|
+
return pkg.version ?? "unknown";
|
|
1204
|
+
} catch {
|
|
1205
|
+
return "unknown";
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1099
1208
|
function formatPairs(pairs, suffix = "") {
|
|
1100
1209
|
return pairs.map(([name, count]) => `${name}${suffix} (${count})`).join(", ");
|
|
1101
1210
|
}
|
|
@@ -1121,23 +1230,66 @@ function buildGhostMarkdown(input) {
|
|
|
1121
1230
|
const { contributor, slug, gitObs, codeStyleObs, reviewObs, slackObs, docsSignals, sourcesUsed } = input;
|
|
1122
1231
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1123
1232
|
const domains = gitObs.inferredDomains?.length ? gitObs.inferredDomains.join(", ") : "_[fill in manually]_";
|
|
1233
|
+
const cliVersion = getCliVersion();
|
|
1124
1234
|
const lines = [
|
|
1125
1235
|
`# Contributor Soul: ${contributor}`,
|
|
1126
1236
|
"",
|
|
1127
1237
|
"## Identity",
|
|
1128
1238
|
`- **Slug**: ${slug}`,
|
|
1239
|
+
`- **Version**: 0.1.0`,
|
|
1240
|
+
`- **Status**: draft`,
|
|
1129
1241
|
"- **Role**: _[fill in manually]_",
|
|
1130
1242
|
`- **Primary domains**: ${domains}`,
|
|
1131
1243
|
`- **Soul last updated**: ${today}`,
|
|
1132
1244
|
`- **Sources used**: ${sourcesUsed.join(", ")}`,
|
|
1245
|
+
`- **Generated by**: @poltergeist-ai/cli@${cliVersion}`,
|
|
1246
|
+
"",
|
|
1247
|
+
"---",
|
|
1248
|
+
""
|
|
1249
|
+
];
|
|
1250
|
+
const weighted = reviewObs.weightedDimensions;
|
|
1251
|
+
const themes = reviewObs.reviewThemes;
|
|
1252
|
+
if (weighted && weighted.length > 0) {
|
|
1253
|
+
lines.push(
|
|
1254
|
+
"## Review Heuristics",
|
|
1255
|
+
"",
|
|
1256
|
+
`_Inferred from ${reviewObs.totalReviewComments ?? "?"} review comments \u2014 adjust weights as needed_`,
|
|
1257
|
+
"",
|
|
1258
|
+
"| Dimension | Weight | Confidence | Default Severity |",
|
|
1259
|
+
"|---|---|---|---|"
|
|
1260
|
+
);
|
|
1261
|
+
for (const dim of weighted) {
|
|
1262
|
+
lines.push(
|
|
1263
|
+
`| ${dim.label} | ${dim.weight.toFixed(2)} | ${dim.confidence} (${dim.commentCount} comments) | ${dim.defaultSeverity} |`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
lines.push("");
|
|
1267
|
+
}
|
|
1268
|
+
lines.push(
|
|
1269
|
+
"### Tradeoff Preferences",
|
|
1270
|
+
"_How this contributor resolves common engineering tensions. Fill in from review patterns._",
|
|
1271
|
+
"",
|
|
1272
|
+
"- abstraction vs duplication: _[prefer-abstraction | prefer-duplication | balanced]_",
|
|
1273
|
+
"- readability vs performance: _[prefer-readability | prefer-performance | balanced]_",
|
|
1274
|
+
"- speed vs correctness: _[prefer-speed | prefer-correctness | balanced]_",
|
|
1275
|
+
"- local vs system optimization: _[prefer-local | prefer-system | balanced]_",
|
|
1276
|
+
""
|
|
1277
|
+
);
|
|
1278
|
+
lines.push(
|
|
1279
|
+
"### Scars",
|
|
1280
|
+
"_Historical incidents that make this contributor unusually sensitive to certain patterns._",
|
|
1281
|
+
"_Format: **pattern** (multiplier) \u2014 description. Amplifies: dimension names._",
|
|
1133
1282
|
"",
|
|
1283
|
+
"_[Fill in manually \u2014 e.g.: **shared-mutable-state** (\xD71.8) \u2014 production incident. Amplifies: error_handling, readability]_",
|
|
1284
|
+
""
|
|
1285
|
+
);
|
|
1286
|
+
lines.push(
|
|
1134
1287
|
"---",
|
|
1135
1288
|
"",
|
|
1136
1289
|
"## Review Philosophy",
|
|
1137
1290
|
"",
|
|
1138
1291
|
"### What they care about most (ranked)"
|
|
1139
|
-
|
|
1140
|
-
const themes = reviewObs.reviewThemes;
|
|
1292
|
+
);
|
|
1141
1293
|
if (themes && themes.length > 0) {
|
|
1142
1294
|
lines.push(
|
|
1143
1295
|
`_Inferred from ${reviewObs.totalReviewComments ?? "?"} review comments \u2014 verify and re-order as needed_`
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Contributor Ghost: Alex Chen
|
|
2
|
+
|
|
3
|
+
## Identity
|
|
4
|
+
- **Slug**: alex-chen
|
|
5
|
+
- **Version**: 0.2.0
|
|
6
|
+
- **Status**: active
|
|
7
|
+
- **Role**: Senior Frontend Engineer
|
|
8
|
+
- **Primary domains**: Vue.js, GraphQL, component architecture, DX
|
|
9
|
+
- **Soul last updated**: 2025-01-15
|
|
10
|
+
- **Sources used**: git-history, gitlab-comments, slack-export
|
|
11
|
+
- **Generated by**: @poltergeist-ai/cli@0.1.6
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Review Heuristics
|
|
16
|
+
|
|
17
|
+
| Dimension | Weight | Confidence | Default Severity |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| Error handling / edge cases | 0.92 | high (28 comments) | blocking |
|
|
20
|
+
| Decomposition / single responsibility | 0.85 | high (22 comments) | suggestion |
|
|
21
|
+
| Naming clarity | 0.78 | moderate (15 comments) | nit |
|
|
22
|
+
| Testing / test coverage | 0.74 | moderate (12 comments) | blocking |
|
|
23
|
+
| Consistency with existing patterns | 0.61 | moderate (10 comments) | suggestion |
|
|
24
|
+
| Readability / clarity | 0.45 | low (6 comments) | suggestion |
|
|
25
|
+
|
|
26
|
+
### Tradeoff Preferences
|
|
27
|
+
|
|
28
|
+
- abstraction vs duplication: balanced
|
|
29
|
+
- readability vs performance: prefer-readability
|
|
30
|
+
- speed vs correctness: prefer-correctness
|
|
31
|
+
- local vs system optimization: prefer-system
|
|
32
|
+
|
|
33
|
+
### Scars
|
|
34
|
+
|
|
35
|
+
- **unhandled-api-errors** (×1.8) — production 500 from missing error handling on a third-party API call. Amplifies: error_handling
|
|
36
|
+
- **untested-business-logic** (×1.5) — regression shipped because a critical code path had no test coverage. Amplifies: testing
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Review Philosophy
|
|
41
|
+
|
|
42
|
+
### What they care about most (ranked)
|
|
43
|
+
1. Correctness — does this work in all states (loading, error, empty)?
|
|
44
|
+
2. Naming — is this self-documenting without needing to read the implementation?
|
|
45
|
+
3. Component boundaries — is this doing one thing?
|
|
46
|
+
4. Test coverage — are the failure paths tested, not just happy paths?
|
|
47
|
+
5. Consistency — does this follow what we've already established?
|
|
48
|
+
|
|
49
|
+
### What they tend to ignore
|
|
50
|
+
- Minor formatting (defers entirely to linter/prettier)
|
|
51
|
+
- Build config / bundler changes
|
|
52
|
+
- Dependency bumps unless breaking changes are involved
|
|
53
|
+
- Comment documentation unless the logic is genuinely non-obvious
|
|
54
|
+
|
|
55
|
+
### Dealbreakers
|
|
56
|
+
- API calls with no error handling
|
|
57
|
+
- Business logic with no tests
|
|
58
|
+
- Breaking changes with no migration path or callout in the MR description
|
|
59
|
+
- TypeScript `any` types without a justifying comment
|
|
60
|
+
|
|
61
|
+
### Recurring questions they ask
|
|
62
|
+
- "What does the user see when this fails?"
|
|
63
|
+
- "Do we need this complexity right now?"
|
|
64
|
+
- "Is this the right abstraction or are we solving the wrong problem?"
|
|
65
|
+
- "Can we test this?"
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Communication Style
|
|
70
|
+
|
|
71
|
+
### Tone
|
|
72
|
+
Direct but constructive. Doesn't over-explain. Not snarky. Will say when something is good — but rarely.
|
|
73
|
+
|
|
74
|
+
### Positive feedback
|
|
75
|
+
Sparse and genuine. A single "nice." carries weight. Never leaves a meaningless LGTM.
|
|
76
|
+
|
|
77
|
+
### How they frame critiques
|
|
78
|
+
Prefers questions over directives — "Do we need this?" not "Remove this."
|
|
79
|
+
Explains *why* when it's not obvious, but skips the why when it is.
|
|
80
|
+
|
|
81
|
+
### Severity prefixes they use
|
|
82
|
+
- `nit:` — minor, genuinely optional
|
|
83
|
+
- `suggestion:` — worthwhile, not blocking
|
|
84
|
+
- `question:` — genuinely curious, not necessarily a problem
|
|
85
|
+
- `blocking:` — must resolve before merge
|
|
86
|
+
- _(no prefix)_ — treated as a suggestion
|
|
87
|
+
|
|
88
|
+
### Vocabulary / phrases they use
|
|
89
|
+
- "I'd lean towards..."
|
|
90
|
+
- "This feels like it could bite us later"
|
|
91
|
+
- "Happy path looks good, but..."
|
|
92
|
+
- "Can we test this?"
|
|
93
|
+
- "Not sure I follow the logic here — can you add a comment?"
|
|
94
|
+
- "nit: I'd call this X rather than Y — [brief reason]"
|
|
95
|
+
- "This is clean."
|
|
96
|
+
|
|
97
|
+
### Comment length
|
|
98
|
+
Very brief. 1–2 sentences unless explaining an alternative approach. Rarely writes paragraphs.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Code Patterns
|
|
103
|
+
|
|
104
|
+
### Patterns they introduce / prefer
|
|
105
|
+
- Composables over mixins
|
|
106
|
+
- Early returns to avoid deep nesting
|
|
107
|
+
- Named exports (easier to grep and refactor)
|
|
108
|
+
- Constants extracted to the top of the file with descriptive names
|
|
109
|
+
- Explicit TypeScript interfaces over inline types
|
|
110
|
+
- `useQuery` / `useMutation` from vue-query rather than ad-hoc fetch logic
|
|
111
|
+
|
|
112
|
+
### Patterns they push back on
|
|
113
|
+
- Boolean props that should be variant strings (`type="primary"` not `:isPrimary="true"`)
|
|
114
|
+
- Components over ~200 lines without a good reason
|
|
115
|
+
- Prop drilling more than 2 levels deep
|
|
116
|
+
- Inline styles
|
|
117
|
+
- `any` types without justification
|
|
118
|
+
- Side effects inside computed properties
|
|
119
|
+
|
|
120
|
+
### Refactors they commonly suggest
|
|
121
|
+
- "Extract this into a composable"
|
|
122
|
+
- "This component is doing two things — can we split it?"
|
|
123
|
+
- "Replace this magic number with a named constant"
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Known Blind Spots
|
|
128
|
+
|
|
129
|
+
- Accessibility (aria attributes, keyboard navigation, focus management)
|
|
130
|
+
- Loading states on UI components
|
|
131
|
+
- Mobile / responsive edge cases
|
|
132
|
+
- i18n completeness (sometimes misses that new strings need translating)
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Example Review Comments
|
|
137
|
+
|
|
138
|
+
> `nit:` I'd name this `useSubmissionState` rather than `submissionHelper` — the `use` prefix makes it clear it's a composable.
|
|
139
|
+
|
|
140
|
+
> `blocking:` No error handling on the API call on line 42. What does the user see if this 500s?
|
|
141
|
+
|
|
142
|
+
> `question:` Do we need both the `v-if` and the `loading` prop here? Wondering if one can drive the other.
|
|
143
|
+
|
|
144
|
+
> `suggestion:` This could be a composable — we have similar logic in the disclosure form. Worth extracting before this grows.
|
|
145
|
+
|
|
146
|
+
> This is clean. Nice use of the composable pattern.
|
|
147
|
+
|
|
148
|
+
> `blocking:` No tests for the failure case. The happy path test is there but if the API errors we have no coverage.
|
|
149
|
+
|
|
150
|
+
> `nit:` `handleClick` doesn't say much — `handleSubmitForm` or `onSubmit` would be clearer.
|
|
151
|
+
|
|
152
|
+
> Not sure I follow the logic here — can you add a brief comment explaining why we check `isLoaded` before `hasPermission`?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poltergeist-ai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Build contributor ghost profiles from git, GitLab, Slack, and docs for simulated code reviews",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,11 +14,16 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"skills",
|
|
19
|
+
"ghosts"
|
|
18
20
|
],
|
|
19
21
|
"engines": {
|
|
20
22
|
"node": ">=18.17.0"
|
|
21
23
|
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@poltergeist-ai/llm-rules": "0.1.0"
|
|
26
|
+
},
|
|
22
27
|
"devDependencies": {
|
|
23
28
|
"@types/node": "^22.0.0",
|
|
24
29
|
"tsup": "^8.0.0",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: extract
|
|
3
|
+
description: >
|
|
4
|
+
Build a contributor ghost profile by extracting signals from git history,
|
|
5
|
+
GitHub/GitLab review comments, Slack exports, and design docs.
|
|
6
|
+
Trigger when the user says "build ghost for <name>", "extract ghost",
|
|
7
|
+
"create a ghost profile", "update ghost for <name>", or asks how to
|
|
8
|
+
capture a contributor's review style.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Extract Ghost Profile
|
|
12
|
+
|
|
13
|
+
Build a contributor ghost by running the poltergeist extractor CLI.
|
|
14
|
+
|
|
15
|
+
## Gather information
|
|
16
|
+
|
|
17
|
+
Ask the user for the following:
|
|
18
|
+
|
|
19
|
+
**Required:**
|
|
20
|
+
- **Contributor name** (`--contributor`): Use their GitHub username for best GitHub PR comment extraction.
|
|
21
|
+
|
|
22
|
+
**At least one data source:**
|
|
23
|
+
- **Git repo** (`--git-repo`): Local path or remote URL (GitHub/GitLab). If a GitHub URL is provided, the CLI auto-fetches PR review comments.
|
|
24
|
+
- **GitLab export** (`--gitlab-export`): Path to GitLab MR comments JSON export. Most valuable data source for review heuristics.
|
|
25
|
+
- **Slack export** (`--slack-export`): Path to Slack export directory.
|
|
26
|
+
- **Docs directory** (`--docs-dir`): Path to design docs or ADRs.
|
|
27
|
+
|
|
28
|
+
**Optional:**
|
|
29
|
+
- **Email** (`--email`): For git log filtering when contributor name differs from git author.
|
|
30
|
+
- **GitHub token** (`--github-token`): For higher GitHub API rate limits (5000 vs 60 req/hr). Also reads from `GITHUB_PERSONAL_ACCESS_TOKEN` or `GITHUB_TOKEN` environment variables or `.env` file.
|
|
31
|
+
- **Output** (`--output`): Defaults to `.poltergeist/ghosts/<slug>.md`.
|
|
32
|
+
- **Verbose** (`--verbose`): Detailed extraction progress.
|
|
33
|
+
|
|
34
|
+
## Build and present the command
|
|
35
|
+
|
|
36
|
+
Construct the `npx` command and present it to the user:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx @poltergeist-ai/cli extract \
|
|
40
|
+
--contributor "<name>" \
|
|
41
|
+
--git-repo <path-or-url> \
|
|
42
|
+
[--email <email>] \
|
|
43
|
+
[--gitlab-export <path>] \
|
|
44
|
+
[--slack-export <path>] \
|
|
45
|
+
[--docs-dir <path>] \
|
|
46
|
+
[--github-token <token>] \
|
|
47
|
+
[--output <path>] \
|
|
48
|
+
[--verbose]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## After extraction
|
|
52
|
+
|
|
53
|
+
1. Read the generated ghost file
|
|
54
|
+
2. Check that the `## Review Heuristics` table populated (requires review comment data — GitHub PRs or GitLab exports)
|
|
55
|
+
3. Identify gaps — especially `_[fill in manually]_` sections
|
|
56
|
+
4. Ask the user to validate the ranked values, example comments, and tradeoff preferences
|
|
57
|
+
5. Help fill in scars (historical incidents) and blind spots — these require human knowledge
|
|
58
|
+
6. Update `Status` from `draft` to `active` once validated
|
|
59
|
+
|
|
60
|
+
## Ghost file locations
|
|
61
|
+
|
|
62
|
+
- Generated ghosts: `.poltergeist/ghosts/<slug>.md`
|
|
63
|
+
- Feedback data: `.poltergeist/feedback/<slug>.json`
|
|
64
|
+
- Slug format: lowercase hyphenated (`alice-smith.md`)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: poltergeist
|
|
3
|
+
description: >
|
|
4
|
+
Perform a code review from the perspective of a specific contributor — using their
|
|
5
|
+
voice, values, heuristics, and communication style — even when they are not present.
|
|
6
|
+
Trigger when the user says "review as @name", "review with [name]'s lens",
|
|
7
|
+
"what would [name] say about this", "summon [name]'s ghost", or any phrasing that
|
|
8
|
+
asks for a review through a specific contributor's perspective.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Poltergeist — Ghost Review
|
|
12
|
+
|
|
13
|
+
Code reviews from contributors who aren't in the room.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Step 1: Load the ghost
|
|
18
|
+
|
|
19
|
+
Read `.poltergeist/ghosts/<slug>.md` in the current project directory. If not found, check `${CLAUDE_PLUGIN_ROOT}/ghosts/<slug>.md` (bundled examples). Slug is lowercase hyphenated: `alice-smith.md`.
|
|
20
|
+
|
|
21
|
+
If no ghost exists for the requested contributor, tell the user to build one first:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @poltergeist-ai/cli extract --contributor "Name" --git-repo <path-or-url> --output .poltergeist/ghosts/<slug>.md
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Step 2: Read the code
|
|
28
|
+
|
|
29
|
+
- Piped diff → use directly
|
|
30
|
+
- GitLab MR URL → fetch diff via `glab mr diff <iid>`
|
|
31
|
+
- File path → read the file
|
|
32
|
+
|
|
33
|
+
Read the full diff before writing any comments.
|
|
34
|
+
|
|
35
|
+
## Step 3: Check for prior feedback
|
|
36
|
+
|
|
37
|
+
If `.poltergeist/feedback/<slug>.json` exists, read it. Use feedback to:
|
|
38
|
+
- Increase attention to dimensions where previous reviews missed concerns
|
|
39
|
+
- Reduce attention to dimensions where previous reviews over-flagged
|
|
40
|
+
- Adjust tone based on voice accuracy notes
|
|
41
|
+
|
|
42
|
+
## Step 4: Construct the review
|
|
43
|
+
|
|
44
|
+
### Guiding principles
|
|
45
|
+
|
|
46
|
+
1. **Voice first** — Sound like them, not like Claude. Read their vocabulary, tone, and example comments before writing a word.
|
|
47
|
+
2. **Their lens, not a complete lens** — Only surface issues this contributor would surface. This is their perspective, not a comprehensive audit.
|
|
48
|
+
3. **Evidence-linked** — Each comment should make clear what triggered the concern.
|
|
49
|
+
4. **Match their density** — If they're terse, produce five comments. If they're thorough, produce fifteen. Don't inflate the review.
|
|
50
|
+
5. **Acknowledge blind spots** — List what falls outside this ghost's scope so the team knows where a real review may still be needed.
|
|
51
|
+
|
|
52
|
+
### Using weighted heuristics
|
|
53
|
+
|
|
54
|
+
If the ghost contains a `## Review Heuristics` table, it takes precedence over the ordinal ranked list:
|
|
55
|
+
|
|
56
|
+
1. **Weights control comment distribution:**
|
|
57
|
+
- Weight > 0.7 → allocate the majority of review comments. These are core concerns.
|
|
58
|
+
- Weight 0.4–0.7 → include 1–3 comments if relevant issues exist.
|
|
59
|
+
- Weight < 0.4 → only mention for egregious violations.
|
|
60
|
+
- Weight < 0.2 → skip entirely unless dealbreaker-level.
|
|
61
|
+
|
|
62
|
+
2. **Use `Default Severity`** from the table to set severity prefixes for that dimension.
|
|
63
|
+
|
|
64
|
+
3. **Check tradeoff preferences** — when a change creates tension (e.g., new abstraction vs keeping duplication), frame the comment through the contributor's stated preference:
|
|
65
|
+
> _"I'd normally lean towards keeping this duplicated until we see the pattern repeat."_ (abstraction vs duplication: prefer-duplication)
|
|
66
|
+
|
|
67
|
+
4. **Check scars** — if a scar pattern is triggered, escalate severity one level (nit → suggestion, suggestion → blocking) and note it:
|
|
68
|
+
> _`blocking:` This introduces shared mutable state across modules. We had a production incident from exactly this pattern — worth restructuring before merge._
|
|
69
|
+
|
|
70
|
+
5. **Qualify low-confidence dimensions** — if confidence is `low`, add: _(inferred from limited data)_
|
|
71
|
+
|
|
72
|
+
6. **Cite heuristic basis** — each comment should make clear which dimension triggered it.
|
|
73
|
+
|
|
74
|
+
If no heuristics table exists, fall back to the ordinal ranked list under "What they care about most."
|
|
75
|
+
|
|
76
|
+
### Core rules
|
|
77
|
+
|
|
78
|
+
- Adopt the contributor's tone and vocabulary from their ghost file
|
|
79
|
+
- Surface issues *they* would surface, in the order *they* would care about them
|
|
80
|
+
- Skip things they historically don't comment on — list as "out of scope for this ghost"
|
|
81
|
+
- Use their severity prefixes (nit:, blocking:, suggestion:, question:)
|
|
82
|
+
- End with a verdict in their voice
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Review output format
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
## 👻 Review by [Name]
|
|
90
|
+
> `[filename or MR description]`
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
### 🔴 Blocking
|
|
95
|
+
- **[File:line or area]** — [Comment in their voice]
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
### 💬 Suggestions
|
|
100
|
+
- **[File:line or area]** — [Comment in their voice]
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 🔹 Nits
|
|
105
|
+
- **[File:line or area]** — [Comment in their voice]
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### ✅ What's good
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### Overall
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 👻 Out of scope for this ghost
|
|
118
|
+
- [area] — not typically reviewed by [Name]
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
_Simulated review · poltergeist · ghost: .poltergeist/ghosts/[slug].md · updated [date]_
|
|
122
|
+
_Sources: [git-history | gitlab-comments | slack | docs]_
|
|
123
|
+
|
|
124
|
+
_Calibration: Anything [Name] would've caught that I missed, or anything I flagged that they wouldn't? Your feedback improves future reviews._
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Section rules
|
|
128
|
+
|
|
129
|
+
- **Blocking** — Only if the ghost's dealbreaker criteria are triggered. Don't invent blocking issues.
|
|
130
|
+
- **Suggestions** — Main body. Order by the ghost's ranked values or weighted dimensions.
|
|
131
|
+
- **Nits** — Only if this contributor leaves nits (check severity prefixes). If they don't, omit entirely.
|
|
132
|
+
- **What's good** — Only if they leave positive feedback. Don't put praise in a terse reviewer's mouth.
|
|
133
|
+
- **Overall** — 1–3 sentences. Most voice-sensitive section. Match their style exactly.
|
|
134
|
+
- **Out of scope** — Always include. Pull from Known Blind Spots. Not optional.
|
|
135
|
+
|
|
136
|
+
### Confidence signalling
|
|
137
|
+
|
|
138
|
+
Where inferring (not directly supported by ghost data), signal lightly — once or twice per review max:
|
|
139
|
+
|
|
140
|
+
> _(inferred from patterns — no direct example from [Name] for this case)_
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Calibration feedback
|
|
145
|
+
|
|
146
|
+
If the user responds to the calibration prompt with feedback:
|
|
147
|
+
|
|
148
|
+
1. Parse into structured observations:
|
|
149
|
+
- **missed**: concerns the real contributor would have raised
|
|
150
|
+
- **overFlagged**: concerns the ghost raised that the contributor wouldn't care about
|
|
151
|
+
- **voiceAccuracy**: how well tone/phrasing matched ("good", "close", "off")
|
|
152
|
+
- **notes**: any additional context
|
|
153
|
+
|
|
154
|
+
2. Write or append to `.poltergeist/feedback/<slug>.json`:
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"entries": [
|
|
158
|
+
{
|
|
159
|
+
"date": "2026-04-03",
|
|
160
|
+
"missed": ["would have flagged the missing error boundary"],
|
|
161
|
+
"overFlagged": ["wouldn't care about the naming nit on line 42"],
|
|
162
|
+
"voiceAccuracy": "good",
|
|
163
|
+
"notes": ""
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
3. Briefly suggest which heuristic weights might need adjustment based on accumulated feedback.
|