@nurix/etna 0.18.10 → 0.18.12
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 +13 -13
- package/bin/etna.js +98 -2
- package/data/harness-index.json +9 -1
- package/data/harnesses/code/CLAUDE.md +9 -10
- package/data/harnesses/code/agents/brainstorm.md +3 -3
- package/data/harnesses/code/agents/change-review.md +4 -4
- package/data/harnesses/code/agents/maintenance.md +177 -59
- package/data/harnesses/code/agents/spec-mode.md +3 -3
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/generate-ownership-index.mjs +148 -0
- package/data/harnesses/code/hooks/hooks.json +12 -0
- package/data/harnesses/code/skills/boundary/references/boundary-taxonomy.md +3 -3
- package/data/harnesses/code/skills/brainstorm/README.md +11 -9
- package/data/harnesses/code/skills/brainstorm/SKILL.md +2 -2
- package/data/harnesses/code/skills/brainstorm/harvest.md +5 -6
- package/data/harnesses/code/skills/distiller/SKILL.md +3 -3
- package/data/harnesses/code/skills/distiller/references/docs-mode.md +7 -7
- package/data/harnesses/code/skills/documentation/SKILL.md +23 -23
- package/data/harnesses/code/skills/documentation/references/templates.md +26 -64
- package/data/harnesses/etna/.prettierignore +12 -0
- package/data/harnesses/etna/.prettierrc.json +3 -0
- package/data/harnesses/etna/.vscode/settings.json +10 -0
- package/data/harnesses/etna/CLAUDE.md +151 -0
- package/data/harnesses/etna/agents/brainstorm.md +35 -0
- package/data/harnesses/etna/agents/change-review.md +251 -0
- package/data/harnesses/etna/agents/maintenance.md +240 -0
- package/data/harnesses/etna/agents/performance-review.md +223 -0
- package/data/harnesses/etna/agents/spec-mode.md +56 -0
- package/data/harnesses/etna/harness.json +11 -0
- package/data/harnesses/etna/hooks/generate-ownership-index.mjs +148 -0
- package/data/harnesses/etna/hooks/hooks.json +39 -0
- package/data/harnesses/etna/hooks/lib/redact.mjs +35 -0
- package/data/harnesses/etna/hooks/prompt-recall.mjs +155 -0
- package/data/harnesses/etna/hooks/session-reflect.mjs +285 -0
- package/data/harnesses/etna/rules/general.md +86 -0
- package/data/harnesses/etna/rules/kb.md +13 -0
- package/data/harnesses/etna/rules/nomenclature.md +41 -0
- package/data/harnesses/etna/rules/npm-cli.md +88 -0
- package/data/harnesses/etna/skills/boundary/SKILL.md +49 -0
- package/data/harnesses/etna/skills/boundary/references/boundary-taxonomy.md +796 -0
- package/data/harnesses/etna/skills/brainstorm/README.md +138 -0
- package/data/harnesses/etna/skills/brainstorm/SKILL.md +135 -0
- package/data/harnesses/etna/skills/brainstorm/harvest.md +25 -0
- package/data/harnesses/etna/skills/brainstorm/templates/backlog.md +39 -0
- package/data/harnesses/etna/skills/brainstorm/templates/build.md +37 -0
- package/data/harnesses/etna/skills/brainstorm/templates/decisions.md +29 -0
- package/data/harnesses/etna/skills/brainstorm/templates/doctrine.md +31 -0
- package/data/harnesses/etna/skills/brainstorm/templates/domain-model.md +25 -0
- package/data/harnesses/etna/skills/brainstorm/templates/glossary.md +20 -0
- package/data/harnesses/etna/skills/brainstorm/templates/milestones.md +43 -0
- package/data/harnesses/etna/skills/brainstorm/templates/non-goals.md +17 -0
- package/data/harnesses/etna/skills/brainstorm/templates/plan-doc.md +61 -0
- package/data/harnesses/etna/skills/brainstorm/templates/questions.md +36 -0
- package/data/harnesses/etna/skills/brainstorm/templates/references.md +28 -0
- package/data/harnesses/etna/skills/brainstorm/templates/rejected.md +29 -0
- package/data/harnesses/etna/skills/brainstorm/templates/state.md +1 -0
- package/data/harnesses/etna/skills/brainstorm/templates/tech.md +23 -0
- package/data/harnesses/etna/skills/brainstorm/templates/thesis.md +21 -0
- package/data/harnesses/etna/skills/distiller/SKILL.md +90 -0
- package/data/harnesses/etna/skills/distiller/references/docs-mode.md +299 -0
- package/data/harnesses/etna/skills/distiller/references/method.md +94 -0
- package/data/harnesses/etna/skills/documentation/SKILL.md +124 -0
- package/data/harnesses/etna/skills/documentation/references/examples.md +162 -0
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +308 -0
- package/data/harnesses/etna/skills/documentation/references/templates.md +853 -0
- package/data/harnesses/etna/skills/workflow-mapper/SKILL.md +451 -0
- package/data/harnesses/etna/skills/workflow-mapper/references/patterns.md +214 -0
- package/data/harnesses/hr/harness.json +1 -1
- package/data/harnesses/hr/skills/evaluator/criteria/testing.md +4 -4
- package/data/harnesses/mobile/.prettierignore +12 -0
- package/data/harnesses/mobile/.prettierrc.json +3 -0
- package/data/harnesses/mobile/.vscode/settings.json +10 -0
- package/data/harnesses/mobile/CLAUDE.md +128 -0
- package/data/harnesses/mobile/agents/brainstorm.md +35 -0
- package/data/harnesses/mobile/agents/change-review.md +251 -0
- package/data/harnesses/mobile/agents/maintenance.md +240 -0
- package/data/harnesses/mobile/agents/performance-review.md +223 -0
- package/data/harnesses/mobile/agents/spec-mode.md +56 -0
- package/data/harnesses/mobile/harness.json +11 -0
- package/data/harnesses/mobile/hooks/generate-ownership-index.mjs +148 -0
- package/data/harnesses/mobile/hooks/hooks.json +39 -0
- package/data/harnesses/mobile/hooks/lib/redact.mjs +35 -0
- package/data/harnesses/mobile/hooks/prompt-recall.mjs +155 -0
- package/data/harnesses/mobile/hooks/session-reflect.mjs +285 -0
- package/data/harnesses/mobile/rules/general.md +86 -0
- package/data/harnesses/mobile/rules/nomenclature.md +41 -0
- package/data/harnesses/mobile/rules/react-native.md +132 -0
- package/data/harnesses/mobile/rules/typescript.md +40 -0
- package/data/harnesses/mobile/skills/boundary/SKILL.md +49 -0
- package/data/harnesses/mobile/skills/boundary/references/boundary-taxonomy.md +796 -0
- package/data/harnesses/mobile/skills/brainstorm/README.md +138 -0
- package/data/harnesses/mobile/skills/brainstorm/SKILL.md +135 -0
- package/data/harnesses/mobile/skills/brainstorm/harvest.md +25 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/backlog.md +39 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/build.md +37 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/decisions.md +29 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/doctrine.md +31 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/domain-model.md +25 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/glossary.md +20 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/milestones.md +43 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/non-goals.md +17 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/plan-doc.md +61 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/questions.md +36 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/references.md +28 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/rejected.md +29 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/state.md +1 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/tech.md +23 -0
- package/data/harnesses/mobile/skills/brainstorm/templates/thesis.md +21 -0
- package/data/harnesses/mobile/skills/distiller/SKILL.md +90 -0
- package/data/harnesses/mobile/skills/distiller/references/docs-mode.md +299 -0
- package/data/harnesses/mobile/skills/distiller/references/method.md +94 -0
- package/data/harnesses/mobile/skills/documentation/SKILL.md +124 -0
- package/data/harnesses/mobile/skills/documentation/references/examples.md +162 -0
- package/data/harnesses/mobile/skills/documentation/references/style-guide.md +308 -0
- package/data/harnesses/mobile/skills/documentation/references/templates.md +853 -0
- package/data/harnesses/mobile/skills/workflow-mapper/SKILL.md +451 -0
- package/data/harnesses/mobile/skills/workflow-mapper/references/patterns.md +214 -0
- package/package.json +1 -1
- package/data/harnesses/code/agents/harness-manager.md +0 -184
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// plugins/code/hooks/generate-ownership-index.mjs — SessionStart hook: scan docs/features/**/*.md,
|
|
2
|
+
// extract the focal frontmatter (owns / module / keywords / status), and inject a compact
|
|
3
|
+
// code-path → owning-doc index via additionalContext. Fires on startup/resume/clear/compact so the
|
|
4
|
+
// map survives /clear and /compact and is regenerated fresh every time. Writes no file.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
10
|
+
function readStdin() {
|
|
11
|
+
try {
|
|
12
|
+
return fs.readFileSync(0, "utf8");
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
|
|
19
|
+
function resolveProjectDir(payload) {
|
|
20
|
+
return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Recursively collect every .md file under dir (returns [] if the dir is absent).
|
|
24
|
+
function listMarkdown(dir) {
|
|
25
|
+
const out = [];
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
const full = path.join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) out.push(...listMarkdown(full));
|
|
35
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pull the leading `--- … ---` frontmatter block; "" if the file has none.
|
|
41
|
+
function frontmatter(text) {
|
|
42
|
+
const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
43
|
+
return m ? m[1] : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Trim surrounding whitespace, then strip one layer of matching quotes.
|
|
47
|
+
function unquote(s) {
|
|
48
|
+
return String(s).trim().replace(/^["']|["']$/g, "").trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Read one frontmatter key: scalar (`k: v`), inline array (`k: [a, b]`), or a following `- item` block.
|
|
52
|
+
// Returns a string for scalars, an array for lists, or undefined when the key is absent.
|
|
53
|
+
function field(block, key) {
|
|
54
|
+
const lines = block.split(/\r?\n/);
|
|
55
|
+
const head = new RegExp(`^${key}\\s*:\\s*(.*)$`);
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const m = head.exec(lines[i]);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
const raw = m[1].trim();
|
|
60
|
+
if (raw === "") {
|
|
61
|
+
const items = [];
|
|
62
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
63
|
+
const li = /^\s*-\s+(.*)$/.exec(lines[j]);
|
|
64
|
+
if (!li) break;
|
|
65
|
+
items.push(unquote(li[1]));
|
|
66
|
+
}
|
|
67
|
+
return items;
|
|
68
|
+
}
|
|
69
|
+
if (raw.startsWith("[")) {
|
|
70
|
+
return raw.replace(/^\[|\]$/g, "").split(",").map((s) => unquote(s)).filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
return unquote(raw);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Coerce a field that may be scalar/array/undefined into a clean string array.
|
|
78
|
+
function asList(v) {
|
|
79
|
+
if (v == null) return [];
|
|
80
|
+
return (Array.isArray(v) ? v : [v]).map((s) => String(s).trim()).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build the ownership index text from every feature doc's frontmatter.
|
|
84
|
+
function buildIndex(projectDir) {
|
|
85
|
+
const featuresDir = path.join(projectDir, "docs", "features");
|
|
86
|
+
const files = listMarkdown(featuresDir);
|
|
87
|
+
const lines = [];
|
|
88
|
+
let deprecated = 0;
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = fs.readFileSync(file, "utf8");
|
|
93
|
+
} catch (e) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const block = frontmatter(text);
|
|
97
|
+
if (!block) continue;
|
|
98
|
+
const owns = asList(field(block, "owns"));
|
|
99
|
+
if (owns.length === 0) continue;
|
|
100
|
+
const status = (field(block, "status") || "active").toString().toLowerCase();
|
|
101
|
+
if (status === "deprecated") {
|
|
102
|
+
deprecated += 1;
|
|
103
|
+
continue; // keep deprecated docs out of the routing lines
|
|
104
|
+
}
|
|
105
|
+
const moduleLabel = (field(block, "module") || "—").toString();
|
|
106
|
+
const keywords = asList(field(block, "keywords"));
|
|
107
|
+
const doc = path.relative(projectDir, file).split(path.sep).join("/");
|
|
108
|
+
const kw = keywords.length ? `[${keywords.join(", ")}]` : "[]";
|
|
109
|
+
for (const glob of owns) {
|
|
110
|
+
lines.push(`${glob} → ${doc} · ${moduleLabel} · ${kw} · ${status}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (lines.length === 0) return "";
|
|
114
|
+
lines.sort();
|
|
115
|
+
const header =
|
|
116
|
+
"# Ownership index (generated) — code-path glob → owning feature doc · module · keywords · status\n" +
|
|
117
|
+
"# Match the file or topic you're about to touch, then read the owning doc before changing it.\n";
|
|
118
|
+
const footer = deprecated > 0 ? `\n# (${deprecated} deprecated feature doc(s) omitted)` : "";
|
|
119
|
+
return header + lines.join("\n") + footer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
const raw = readStdin();
|
|
124
|
+
let payload = {};
|
|
125
|
+
try {
|
|
126
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
127
|
+
} catch (e) {
|
|
128
|
+
payload = {};
|
|
129
|
+
}
|
|
130
|
+
const index = buildIndex(resolveProjectDir(payload));
|
|
131
|
+
if (!index) return; // no feature docs yet → inject nothing
|
|
132
|
+
process.stdout.write(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
hookSpecificOutput: {
|
|
135
|
+
hookEventName: "SessionStart",
|
|
136
|
+
additionalContext: index,
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
main();
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// Best-effort: a routing index is a convenience, never a gate. Inject nothing on failure.
|
|
146
|
+
} finally {
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/generate-ownership-index.mjs\"",
|
|
10
|
+
"timeout": 10
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"SessionEnd": [
|
|
16
|
+
{
|
|
17
|
+
"matcher": "",
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-reflect.mjs\"",
|
|
22
|
+
"async": true
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"UserPromptSubmit": [
|
|
28
|
+
{
|
|
29
|
+
"matcher": "",
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/prompt-recall.mjs\""
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// harness/code/hooks/lib/redact.mjs — shared privacy filter: redacts secret shapes before ledger append.
|
|
2
|
+
|
|
3
|
+
// Patterns ordered most-specific first; each replaces a match with [redacted:{kind}].
|
|
4
|
+
const RULES = [
|
|
5
|
+
// PEM private/public key blocks (multiline).
|
|
6
|
+
{ kind: "pem", re: /-----BEGIN[\s\S]*?-----END[^-]*-----/g },
|
|
7
|
+
// OpenAI / Anthropic-style keys (sk-..., sk-ant-...).
|
|
8
|
+
{ kind: "api-key", re: /\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b/g },
|
|
9
|
+
// AWS access key IDs.
|
|
10
|
+
{ kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
11
|
+
// Bearer tokens in Authorization context.
|
|
12
|
+
{ kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
13
|
+
// password= / passwd= / secret= / token= / api_key= style assignments (= or :).
|
|
14
|
+
{
|
|
15
|
+
kind: "credential",
|
|
16
|
+
re: /\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']?[^\s"'&)\]},;[]{3,}/gi,
|
|
17
|
+
},
|
|
18
|
+
// Generic long hex/base64 token ONLY in a key-value credential context.
|
|
19
|
+
{
|
|
20
|
+
kind: "credential",
|
|
21
|
+
re: /\b(?:key|auth|cred|credential|signature|sig)\b\s*[:=]\s*["']?[A-Za-z0-9+/=_-]{32,}/gi,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Redact secret shapes in arbitrary text; returns a string with placeholders.
|
|
26
|
+
function redact(text) {
|
|
27
|
+
if (text == null) return "";
|
|
28
|
+
let out = String(text);
|
|
29
|
+
for (const rule of RULES) {
|
|
30
|
+
out = out.replace(rule.re, `[redacted:${rule.kind}]`);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { redact };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// plugins/code/hooks/prompt-recall.mjs — UserPromptSubmit hook: surface fact-cards relevant to the prompt via qmd BM25; stdout is injected as context.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const MIN_PROMPT_CHARS = 24;
|
|
9
|
+
const MIN_SCORE = 0.45;
|
|
10
|
+
const MAX_CARDS = 3;
|
|
11
|
+
const MAX_STATEMENT_CHARS = 300;
|
|
12
|
+
const MAX_KEYWORDS = 8;
|
|
13
|
+
|
|
14
|
+
const STOPWORDS = new Set(
|
|
15
|
+
"a an and are as at be but by can could do does for from has have how i in is it its me my of on or our should so that the their then there these this to was we what when where which who why will with would you your".split(
|
|
16
|
+
" ",
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
21
|
+
function readStdin() {
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(0, "utf8");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
|
|
30
|
+
function resolveProjectDir(payload) {
|
|
31
|
+
return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Map a qmd://collection/relpath hit URI to the card file under docs/memory/facts/.
|
|
35
|
+
function cardPath(factsDir, fileUri) {
|
|
36
|
+
return path.join(factsDir, String(fileUri || "").replace(/^qmd:\/\/[^/]+\//, ""));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A card's atomic statement: the body after frontmatter, whitespace-flattened and clipped.
|
|
40
|
+
function cardStatement(file) {
|
|
41
|
+
try {
|
|
42
|
+
const body = fs
|
|
43
|
+
.readFileSync(file, "utf8")
|
|
44
|
+
.replace(/^---\n[\s\S]*?\n---\n?/, "")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
return body.length > MAX_STATEMENT_CHARS ? body.slice(0, MAX_STATEMENT_CHARS) + "…" : body;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Run one qmd search mode against the facts collection; [] on any failure — recall is strictly optional.
|
|
54
|
+
function qmdHits(mode, query, collection) {
|
|
55
|
+
try {
|
|
56
|
+
const out = JSON.parse(
|
|
57
|
+
execFileSync("qmd", [mode, query, "--format", "json", "-n", String(MAX_CARDS + 2), "-c", collection], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 10000,
|
|
60
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return Array.isArray(out) ? out : [];
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Main: search the facts collection for the prompt and emit unseen high-score cards.
|
|
70
|
+
function main() {
|
|
71
|
+
if (process.env.ETNA_REFLECT) return;
|
|
72
|
+
|
|
73
|
+
const raw = readStdin();
|
|
74
|
+
let payload = {};
|
|
75
|
+
try {
|
|
76
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
77
|
+
} catch (e) {
|
|
78
|
+
payload = {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const prompt = String(payload.prompt || "").trim();
|
|
82
|
+
if (!prompt || prompt.startsWith("/") || prompt.length < MIN_PROMPT_CHARS) return;
|
|
83
|
+
|
|
84
|
+
const projectDir = resolveProjectDir(payload);
|
|
85
|
+
const factsDir = path.join(projectDir, "docs", "memory", "facts");
|
|
86
|
+
let hasCards = false;
|
|
87
|
+
try {
|
|
88
|
+
hasCards = fs.readdirSync(factsDir).some((f) => f.endsWith(".md"));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!hasCards) return;
|
|
93
|
+
|
|
94
|
+
// BM25 keywords first (fast, strict-AND), vector similarity as the natural-language
|
|
95
|
+
// fallback (~1s warm) — `qmd query` (LLM expansion + rerank) is too slow to block a prompt.
|
|
96
|
+
const collection = `${path.basename(projectDir)}-facts`;
|
|
97
|
+
const keywords = Array.from(
|
|
98
|
+
new Set(
|
|
99
|
+
prompt
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9\s_./-]/g, " ")
|
|
102
|
+
.split(/\s+/)
|
|
103
|
+
.filter((w) => w.length > 2 && !STOPWORDS.has(w)),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
.slice(0, MAX_KEYWORDS)
|
|
107
|
+
.join(" ");
|
|
108
|
+
let hits = keywords ? qmdHits("search", keywords, collection) : [];
|
|
109
|
+
if (!hits.length) hits = qmdHits("vsearch", prompt.replace(/\s+/g, " ").slice(0, 300), collection);
|
|
110
|
+
if (!hits.length) return;
|
|
111
|
+
|
|
112
|
+
// Inject each card at most once per session.
|
|
113
|
+
const sessionKey = String(payload.session_id || "any").replace(/[^a-zA-Z0-9-]/g, "");
|
|
114
|
+
const stateFile = path.join(os.tmpdir(), `etna-recall-${sessionKey}.txt`);
|
|
115
|
+
let seen = [];
|
|
116
|
+
try {
|
|
117
|
+
seen = fs.readFileSync(stateFile, "utf8").split("\n").filter(Boolean);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
seen = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fresh = hits
|
|
123
|
+
.filter((h) => h && h.score >= MIN_SCORE && h.file && !seen.includes(h.file))
|
|
124
|
+
.slice(0, MAX_CARDS);
|
|
125
|
+
const lines = [];
|
|
126
|
+
for (const hit of fresh) {
|
|
127
|
+
const statement = cardStatement(cardPath(factsDir, hit.file));
|
|
128
|
+
if (statement) {
|
|
129
|
+
lines.push(`- ${statement} [${String(hit.file).replace(/^qmd:\/\/[^/]+\//, "docs/memory/facts/")}]`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!lines.length) return;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
fs.appendFileSync(stateFile, fresh.map((h) => h.file).join("\n") + "\n", "utf8");
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Dedupe state is best-effort; a re-injection is harmless.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`<memory-recall>\nProject memory possibly relevant to this prompt (distilled fact-cards):\n${lines.join("\n")}\n</memory-recall>\n`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
main();
|
|
147
|
+
} catch (e) {
|
|
148
|
+
try {
|
|
149
|
+
process.stderr.write(`prompt-recall hook: ${e && e.message ? e.message : e}\n`);
|
|
150
|
+
} catch (e2) {
|
|
151
|
+
// Nothing more we can do.
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// plugins/code/hooks/session-reflect.mjs — SessionEnd hook: reflect the session into durable lessons (headless haiku pass) appended to the actor's memory inbox.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { redact } from "./lib/redact.mjs";
|
|
9
|
+
|
|
10
|
+
// ESM has no __filename; derive it from this module's URL for the detached worker re-spawn.
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
|
|
13
|
+
// A session below all three thresholds carries no lesson worth a model call.
|
|
14
|
+
const MIN_TURNS = 8;
|
|
15
|
+
const MAX_MESSAGE_CHARS = 1500;
|
|
16
|
+
const MAX_DIGEST_CHARS = 80000;
|
|
17
|
+
const MAX_LESSONS = 6;
|
|
18
|
+
|
|
19
|
+
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
20
|
+
function readStdin() {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(0, "utf8");
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
|
|
29
|
+
function resolveProjectDir(payload) {
|
|
30
|
+
return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Run a git command in projectDir; returns trimmed stdout or fallback on any error.
|
|
34
|
+
function git(projectDir, args, fallback) {
|
|
35
|
+
try {
|
|
36
|
+
return execFileSync("git", ["-C", projectDir, ...args], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
}).trim() || fallback;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Slug a raw actor name into kebab-case ASCII.
|
|
46
|
+
function slugActor(name) {
|
|
47
|
+
return String(name || "")
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
50
|
+
.replace(/^-+|-+$/g, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Determine the actor: git user.name (slugged) → $USER → "agent".
|
|
54
|
+
function resolveActor(projectDir) {
|
|
55
|
+
const fromGit = slugActor(git(projectDir, ["config", "user.name"], ""));
|
|
56
|
+
if (fromGit) return fromGit;
|
|
57
|
+
const fromEnv = slugActor(process.env.USER || "");
|
|
58
|
+
if (fromEnv) return fromEnv;
|
|
59
|
+
return "agent";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Truncate a single line to n chars with an ellipsis marker.
|
|
63
|
+
function clip(text, n) {
|
|
64
|
+
const clean = String(text || "").replace(/\s+/g, " ").trim();
|
|
65
|
+
return clean.length > n ? clean.slice(0, n) + "…" : clean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract plain text from a message whose content is a string or content-block array.
|
|
69
|
+
function extractText(msg) {
|
|
70
|
+
if (!msg || !msg.content) return "";
|
|
71
|
+
if (typeof msg.content === "string") return msg.content;
|
|
72
|
+
if (Array.isArray(msg.content)) {
|
|
73
|
+
return msg.content
|
|
74
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
75
|
+
.map((b) => b.text)
|
|
76
|
+
.join(" ");
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Strip harness-injected wrappers so only what the user actually typed survives.
|
|
82
|
+
function stripInjected(text) {
|
|
83
|
+
return String(text || "")
|
|
84
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
85
|
+
.replace(/<(local-command-caveat|local-command-stdout|local-command-stderr|command-name|command-message|command-args|command-contents)>[\s\S]*?<\/\1>/g, "")
|
|
86
|
+
.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Scan an assistant message's tool_use blocks for edited files and git commits.
|
|
90
|
+
function collectToolUse(msg, summary) {
|
|
91
|
+
if (!msg || !Array.isArray(msg.content)) return;
|
|
92
|
+
for (const block of msg.content) {
|
|
93
|
+
if (!block || block.type !== "tool_use") continue;
|
|
94
|
+
const name = block.name || "";
|
|
95
|
+
const input = block.input || {};
|
|
96
|
+
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) && input.file_path) {
|
|
97
|
+
summary.files.add(input.file_path);
|
|
98
|
+
}
|
|
99
|
+
if (name === "Bash" && typeof input.command === "string" && /\bgit\s+commit\b/.test(input.command)) {
|
|
100
|
+
summary.sawCommit = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Walk the JSONL transcript once: turn/edit/commit counts plus a USER/ASSISTANT text digest.
|
|
106
|
+
function digestTranscript(transcriptPath) {
|
|
107
|
+
const out = { turns: 0, files: new Set(), sawCommit: false, lines: [] };
|
|
108
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return out;
|
|
109
|
+
for (const line of fs.readFileSync(transcriptPath, "utf8").split("\n")) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
let row;
|
|
112
|
+
try {
|
|
113
|
+
row = JSON.parse(line);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (row.isSidechain) continue;
|
|
118
|
+
const msg = row && row.message ? row.message : row;
|
|
119
|
+
const role = (msg && msg.role) || row.type;
|
|
120
|
+
if (role === "user") {
|
|
121
|
+
const text = stripInjected(extractText(msg));
|
|
122
|
+
if (!text || text.startsWith("Caveat: The messages below")) continue;
|
|
123
|
+
out.lines.push("USER: " + clip(text, MAX_MESSAGE_CHARS));
|
|
124
|
+
} else if (role === "assistant") {
|
|
125
|
+
out.turns += 1;
|
|
126
|
+
collectToolUse(msg, out);
|
|
127
|
+
const text = extractText(msg);
|
|
128
|
+
if (text) out.lines.push("ASSISTANT: " + clip(text, MAX_MESSAGE_CHARS));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Compute the per-actor inbox file path.
|
|
135
|
+
function inboxPath(projectDir, actor) {
|
|
136
|
+
const dir = path.join(projectDir, "docs", "memory", "inbox");
|
|
137
|
+
return { dir, file: path.join(dir, `${actor}.md`) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// True when this session already wrote an inbox entry (SessionEnd re-fires on resume).
|
|
141
|
+
function alreadyReflected(file, sessionId) {
|
|
142
|
+
try {
|
|
143
|
+
return Boolean(sessionId) && fs.existsSync(file) && fs.readFileSync(file, "utf8").includes(`session ${sessionId}`);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Build the inbox file header block.
|
|
150
|
+
function inboxHeader(actor) {
|
|
151
|
+
return `# Memory inbox — ${actor}\nSession lessons awaiting distillation — appended by the session-reflect hook, consumed by \`distiller facts\`.\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The full output contract sits AFTER the transcript — with it only up front, the model
|
|
155
|
+
// continues the conversation in the transcript instead of reflecting on it.
|
|
156
|
+
const PROMPT_HEADER = [
|
|
157
|
+
"You are a memory reflector. Below is the transcript of a FINISHED coding session,",
|
|
158
|
+
"delimited by <transcript> tags. Do not reply to it or continue it — you will be asked",
|
|
159
|
+
"to extract durable lessons from it after the closing tag.",
|
|
160
|
+
"",
|
|
161
|
+
"<transcript>",
|
|
162
|
+
"",
|
|
163
|
+
].join("\n");
|
|
164
|
+
|
|
165
|
+
const PROMPT_FOOTER = [
|
|
166
|
+
"",
|
|
167
|
+
"</transcript>",
|
|
168
|
+
"",
|
|
169
|
+
"The session above is over. Extract ONLY durable lessons a future session in this",
|
|
170
|
+
"repository would benefit from knowing:",
|
|
171
|
+
"- corrections or preferences the user stated",
|
|
172
|
+
"- failures whose root cause was found, with the fix",
|
|
173
|
+
"- decisions made, with their rationale",
|
|
174
|
+
"- discovered constraints or non-obvious facts about the project or its tooling",
|
|
175
|
+
"Rules:",
|
|
176
|
+
"- Most sessions contain none. If nothing qualifies, output exactly: NONE",
|
|
177
|
+
`- Otherwise output at most ${MAX_LESSONS} bullets and nothing else — no preamble, no headers.`,
|
|
178
|
+
'- Each bullet starts with "- [project]" (true of this repo or its tooling) or "- [user]" (true of this user).',
|
|
179
|
+
"- Each bullet is one complete, self-contained sentence with names and identifiers spelled out.",
|
|
180
|
+
"- Routine work (commits made, files edited, tasks completed) is NOT a lesson. Never include secrets.",
|
|
181
|
+
].join("\n");
|
|
182
|
+
|
|
183
|
+
// Hook mode: stash the payload and hand off to a detached worker so session exit is never delayed.
|
|
184
|
+
function hook() {
|
|
185
|
+
const raw = readStdin();
|
|
186
|
+
let payload = {};
|
|
187
|
+
try {
|
|
188
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
189
|
+
} catch (e) {
|
|
190
|
+
payload = {};
|
|
191
|
+
}
|
|
192
|
+
if (!payload.transcript_path) return;
|
|
193
|
+
|
|
194
|
+
const stash = {
|
|
195
|
+
projectDir: resolveProjectDir(payload),
|
|
196
|
+
transcriptPath: payload.transcript_path,
|
|
197
|
+
sessionId: payload.session_id || "",
|
|
198
|
+
};
|
|
199
|
+
const stashFile = path.join(os.tmpdir(), `etna-reflect-${stash.sessionId || process.pid}.json`);
|
|
200
|
+
fs.writeFileSync(stashFile, JSON.stringify(stash), "utf8");
|
|
201
|
+
spawn(process.execPath, [__filename, "--worker", stashFile], { detached: true, stdio: "ignore" }).unref();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Worker mode: filter, digest, run the reflection model, append surviving lessons to the inbox.
|
|
205
|
+
function worker(stashFile) {
|
|
206
|
+
let stash;
|
|
207
|
+
try {
|
|
208
|
+
stash = JSON.parse(fs.readFileSync(stashFile, "utf8"));
|
|
209
|
+
} finally {
|
|
210
|
+
try {
|
|
211
|
+
fs.rmSync(stashFile, { force: true });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// Stale stash files are harmless.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { projectDir, transcriptPath, sessionId } = stash;
|
|
218
|
+
const digest = digestTranscript(transcriptPath);
|
|
219
|
+
if (digest.turns < MIN_TURNS && digest.files.size === 0 && !digest.sawCommit) return;
|
|
220
|
+
|
|
221
|
+
const actor = resolveActor(projectDir);
|
|
222
|
+
const { dir, file } = inboxPath(projectDir, actor);
|
|
223
|
+
if (alreadyReflected(file, sessionId)) return;
|
|
224
|
+
|
|
225
|
+
let text = redact(digest.lines.join("\n"));
|
|
226
|
+
if (text.length > MAX_DIGEST_CHARS) {
|
|
227
|
+
text = text.slice(0, 8000) + "\n[... middle of session elided ...]\n" + text.slice(-(MAX_DIGEST_CHARS - 8000));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const output = execFileSync("claude", ["-p", "--model", "haiku"], {
|
|
231
|
+
input: PROMPT_HEADER + text + PROMPT_FOOTER,
|
|
232
|
+
encoding: "utf8",
|
|
233
|
+
cwd: os.tmpdir(),
|
|
234
|
+
env: Object.assign({}, process.env, { ETNA_REFLECT: "1" }),
|
|
235
|
+
timeout: 240000,
|
|
236
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
237
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const lessons = output
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) => l.trim())
|
|
243
|
+
.filter((l) => /^- \[(project|user)\]\s+\S/.test(l))
|
|
244
|
+
.slice(0, MAX_LESSONS)
|
|
245
|
+
.map((l) => redact(l));
|
|
246
|
+
if (!lessons.length) return;
|
|
247
|
+
|
|
248
|
+
const repo = path.basename(projectDir);
|
|
249
|
+
const branch = git(projectDir, ["rev-parse", "--abbrev-ref", "HEAD"], "-");
|
|
250
|
+
const now = new Date();
|
|
251
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
252
|
+
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
253
|
+
|
|
254
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
255
|
+
if (alreadyReflected(file, sessionId)) return;
|
|
256
|
+
const exists = fs.existsSync(file);
|
|
257
|
+
const block =
|
|
258
|
+
(exists ? "\n" : inboxHeader(actor) + "\n") +
|
|
259
|
+
`## ${stamp} · session ${sessionId || "unknown"} · ${repo}@${branch}\n` +
|
|
260
|
+
lessons.join("\n") +
|
|
261
|
+
"\n";
|
|
262
|
+
fs.appendFileSync(file, block, "utf8");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function main() {
|
|
266
|
+
if (process.env.ETNA_REFLECT) return;
|
|
267
|
+
const workerIdx = process.argv.indexOf("--worker");
|
|
268
|
+
if (workerIdx !== -1) {
|
|
269
|
+
worker(process.argv[workerIdx + 1]);
|
|
270
|
+
} else {
|
|
271
|
+
hook();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
main();
|
|
277
|
+
} catch (e) {
|
|
278
|
+
try {
|
|
279
|
+
process.stderr.write(`session-reflect hook: ${e && e.message ? e.message : e}\n`);
|
|
280
|
+
} catch (e2) {
|
|
281
|
+
// Nothing more we can do.
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|