scoops 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/bin/arivu.js +2 -0
- package/bin/ascr.js +2 -0
- package/bin/scoops.js +2 -0
- package/bin/zute.js +2 -0
- package/package.json +31 -0
- package/src/cli.js +70 -0
- package/src/commands/export.js +115 -0
- package/src/commands/init.js +110 -0
- package/src/commands/list.js +81 -0
- package/src/commands/prune.js +107 -0
- package/src/commands/status.js +62 -0
- package/src/hooks/prompt-retriever.js +147 -0
- package/src/hooks/session-start.js +82 -0
- package/src/hooks/stop-gate.js +124 -0
- package/src/lib/index.js +65 -0
- package/src/lib/memory.js +49 -0
- package/src/lib/merge-settings.js +59 -0
- package/src/lib/paths.js +38 -0
- package/src/lib/scoring.js +60 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseArgs } = require('node:util');
|
|
4
|
+
const readline = require('node:readline');
|
|
5
|
+
const { readKnowledge, writeKnowledge, TYPES } = require('../lib/memory');
|
|
6
|
+
const { rebuildIndex } = require('../lib/index');
|
|
7
|
+
|
|
8
|
+
const { values } = parseArgs({
|
|
9
|
+
options: {
|
|
10
|
+
keep: { type: 'string' },
|
|
11
|
+
before: { type: 'string' },
|
|
12
|
+
tag: { type: 'string' },
|
|
13
|
+
type: { type: 'string', short: 't' },
|
|
14
|
+
'dry-run':{ type: 'boolean', default: false },
|
|
15
|
+
yes: { type: 'boolean', short: 'y', default: false },
|
|
16
|
+
},
|
|
17
|
+
strict: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const dryRun = values['dry-run'];
|
|
22
|
+
const keepN = values.keep ? parseInt(values.keep, 10) : null;
|
|
23
|
+
const beforeDate = values.before ? new Date(values.before) : null;
|
|
24
|
+
const tagFilter = values.tag;
|
|
25
|
+
const typeFilter = values.type;
|
|
26
|
+
|
|
27
|
+
if (!keepN && !beforeDate && !tagFilter) {
|
|
28
|
+
console.error('Specify at least one filter: --keep N, --before DATE, or --tag TAG');
|
|
29
|
+
console.error('Example: ascr prune --keep 50');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const typesToPrune = typeFilter ? [typeFilter] : TYPES;
|
|
34
|
+
const removals = {};
|
|
35
|
+
let totalRemoved = 0;
|
|
36
|
+
|
|
37
|
+
for (const type of typesToPrune) {
|
|
38
|
+
const entries = readKnowledge(cwd, type);
|
|
39
|
+
let toRemove = [];
|
|
40
|
+
|
|
41
|
+
if (tagFilter) {
|
|
42
|
+
toRemove = entries.filter(e => (e.tags || []).includes(tagFilter));
|
|
43
|
+
}
|
|
44
|
+
if (beforeDate) {
|
|
45
|
+
const byDate = entries.filter(e => e.date && new Date(e.date) < beforeDate);
|
|
46
|
+
toRemove = [...new Set([...toRemove, ...byDate])];
|
|
47
|
+
}
|
|
48
|
+
if (keepN !== null) {
|
|
49
|
+
const excess = entries.length - keepN;
|
|
50
|
+
if (excess > 0) {
|
|
51
|
+
const byAge = entries.slice(0, excess);
|
|
52
|
+
toRemove = [...new Set([...toRemove, ...byAge])];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (toRemove.length > 0) {
|
|
57
|
+
removals[type] = { entries, toRemove };
|
|
58
|
+
totalRemoved += toRemove.length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (totalRemoved === 0) {
|
|
63
|
+
console.log('\nNothing to prune with the given filters.\n');
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Show what will be removed
|
|
68
|
+
console.log(`\nWill remove ${totalRemoved} entr${totalRemoved === 1 ? 'y' : 'ies'}:\n`);
|
|
69
|
+
for (const [type, { toRemove }] of Object.entries(removals)) {
|
|
70
|
+
console.log(` ${type}:`);
|
|
71
|
+
for (const e of toRemove) {
|
|
72
|
+
const label = e.decision || e.component || e.thread || e.warning || e.id;
|
|
73
|
+
console.log(` - [${e.id || '?'}] ${label}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
|
|
78
|
+
if (dryRun) {
|
|
79
|
+
console.log('Dry run — nothing removed. Remove --dry-run to apply.\n');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Confirm unless --yes
|
|
84
|
+
function doRemoval() {
|
|
85
|
+
for (const [type, { entries, toRemove }] of Object.entries(removals)) {
|
|
86
|
+
const removeIds = new Set(toRemove.map(e => e.id));
|
|
87
|
+
const remaining = entries.filter(e => !removeIds.has(e.id));
|
|
88
|
+
writeKnowledge(cwd, type, remaining);
|
|
89
|
+
}
|
|
90
|
+
rebuildIndex(cwd);
|
|
91
|
+
console.log(`Removed ${totalRemoved} entr${totalRemoved === 1 ? 'y' : 'ies'} and rebuilt index.\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (values.yes) {
|
|
95
|
+
doRemoval();
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
+
rl.question('Proceed? [y/N] ', (answer) => {
|
|
101
|
+
rl.close();
|
|
102
|
+
if (answer.toLowerCase() === 'y') {
|
|
103
|
+
doRemoval();
|
|
104
|
+
} else {
|
|
105
|
+
console.log('Aborted.\n');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const paths = require('../lib/paths');
|
|
5
|
+
const { getAscrHookEvents } = require('../lib/merge-settings');
|
|
6
|
+
const { countAll } = require('../lib/memory');
|
|
7
|
+
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
let allOk = true;
|
|
10
|
+
|
|
11
|
+
function check(label, ok, detail = '') {
|
|
12
|
+
const icon = ok ? 'OK ' : 'FAIL';
|
|
13
|
+
console.log(` [${icon}] ${label}${detail ? ' — ' + detail : ''}`);
|
|
14
|
+
if (!ok) allOk = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log('\nASCR Status\n───────────');
|
|
18
|
+
|
|
19
|
+
// Knowledge files
|
|
20
|
+
const knowledgeOk = fs.existsSync(paths.decisionsFile(cwd));
|
|
21
|
+
check('.ascr/ directory', knowledgeOk, knowledgeOk ? '' : 'Run `ascr init`');
|
|
22
|
+
|
|
23
|
+
if (knowledgeOk) {
|
|
24
|
+
const counts = countAll(cwd);
|
|
25
|
+
const summary = Object.entries(counts).map(([t, n]) => `${n} ${t}`).join(', ');
|
|
26
|
+
check('Knowledge files', true, summary);
|
|
27
|
+
|
|
28
|
+
const indexOk = fs.existsSync(paths.indexFile(cwd));
|
|
29
|
+
check('Index file (.ascr/index.json)', indexOk, indexOk ? '' : 'Run `ascr init --force`');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Hook scripts
|
|
33
|
+
const hooksOk = fs.existsSync(paths.hooksDir(cwd));
|
|
34
|
+
const sessionStartOk = fs.existsSync(require('node:path').join(paths.hooksDir(cwd), 'session-start.js'));
|
|
35
|
+
const retrieverOk = fs.existsSync(require('node:path').join(paths.hooksDir(cwd), 'prompt-retriever.js'));
|
|
36
|
+
const stopGateOk = fs.existsSync(require('node:path').join(paths.hooksDir(cwd), 'stop-gate.js'));
|
|
37
|
+
check('Hook: session-start.js', sessionStartOk, sessionStartOk ? '' : 'Run `ascr init`');
|
|
38
|
+
check('Hook: prompt-retriever.js', retrieverOk, retrieverOk ? '' : 'Run `ascr init`');
|
|
39
|
+
check('Hook: stop-gate.js', stopGateOk, stopGateOk ? '' : 'Run `ascr init`');
|
|
40
|
+
|
|
41
|
+
// Settings registration
|
|
42
|
+
const settingsPath = paths.settingsFile(cwd);
|
|
43
|
+
const settingsExist = fs.existsSync(settingsPath);
|
|
44
|
+
check('.claude/settings.json exists', settingsExist, settingsExist ? '' : 'Run `ascr init`');
|
|
45
|
+
|
|
46
|
+
if (settingsExist) {
|
|
47
|
+
let settings = {};
|
|
48
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { /* ignore */ }
|
|
49
|
+
const registered = getAscrHookEvents(settings);
|
|
50
|
+
const expectedEvents = ['SessionStart', 'UserPromptSubmit', 'Stop'];
|
|
51
|
+
for (const event of expectedEvents) {
|
|
52
|
+
check(`Hook registered: ${event}`, registered.includes(event), registered.includes(event) ? '' : 'Run `ascr init`');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('');
|
|
57
|
+
if (allOk) {
|
|
58
|
+
console.log('Everything looks good. Start a Claude Code session to activate ASCR.');
|
|
59
|
+
} else {
|
|
60
|
+
console.log('Some checks failed. Run `ascr init` to fix.');
|
|
61
|
+
}
|
|
62
|
+
console.log('');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ASCR UserPromptSubmit Hook — standalone, no external dependencies
|
|
5
|
+
// Extracts keywords from the user's prompt, scores against .ascr/index.json,
|
|
6
|
+
// pulls top matching entries, and outputs them to stdout for context injection.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
|
|
11
|
+
const STOP_WORDS = new Set([
|
|
12
|
+
'a','an','the','and','or','but','in','on','at','to','for','of','with',
|
|
13
|
+
'by','from','is','it','its','be','as','are','was','were','been','has',
|
|
14
|
+
'have','had','do','does','did','will','would','could','should','may',
|
|
15
|
+
'might','can','i','we','you','he','she','they','this','that','these',
|
|
16
|
+
'those','what','how','why','when','where','which','who','not','no','so',
|
|
17
|
+
'if','then','my','our','your','their','me','us','add','make','create',
|
|
18
|
+
'update','get','set','use','using','want','need','please','help','let',
|
|
19
|
+
'just','also','now','like','new','old','all','some','any','into','out',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function extractKeywords(prompt) {
|
|
23
|
+
return prompt
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9\s\-_]/g, ' ')
|
|
26
|
+
.split(/[\s\-_]+/)
|
|
27
|
+
.map(w => w.trim())
|
|
28
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w))
|
|
29
|
+
.filter((w, i, arr) => arr.indexOf(w) === i);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scoreIndex(index, keywords) {
|
|
33
|
+
const results = [];
|
|
34
|
+
const typePriority = { threads: 4, architecture: 3, decisions: 2, warnings: 1 };
|
|
35
|
+
for (const [type, entries] of Object.entries(index)) {
|
|
36
|
+
for (const entry of (entries || [])) {
|
|
37
|
+
let score = 0;
|
|
38
|
+
const tags = (entry.tags || []).map(t => t.toLowerCase());
|
|
39
|
+
const key = (entry.key || '').toLowerCase();
|
|
40
|
+
for (const kw of keywords) {
|
|
41
|
+
if (tags.includes(kw)) score += 2;
|
|
42
|
+
else if (tags.some(t => t.includes(kw) || kw.includes(t))) score += 1;
|
|
43
|
+
if (key.includes(kw)) score += 1;
|
|
44
|
+
}
|
|
45
|
+
// Boost in-progress threads ONLY if there's already a keyword match
|
|
46
|
+
if (score > 0 && type === 'threads' && entry.status === 'in_progress') score += 1;
|
|
47
|
+
if (score > 0) results.push({ type, id: entry.id, score, priority: typePriority[type] || 0 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return results.sort((a, b) => b.score !== a.score ? b.score - a.score : b.priority - a.priority);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatEntry(type, entry) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
switch (type) {
|
|
56
|
+
case 'decisions':
|
|
57
|
+
lines.push(`[Decision] ${entry.decision}`);
|
|
58
|
+
if (entry.why) lines.push(` Why: ${entry.why}`);
|
|
59
|
+
if (entry.alternatives_rejected?.length) lines.push(` Rejected: ${entry.alternatives_rejected.join(', ')}`);
|
|
60
|
+
if (entry.affects?.length) lines.push(` Affects: ${entry.affects.join(', ')}`);
|
|
61
|
+
break;
|
|
62
|
+
case 'architecture':
|
|
63
|
+
lines.push(`[Architecture] ${entry.component}${entry.location ? ` (${entry.location})` : ''}`);
|
|
64
|
+
if (entry.does) lines.push(` ${entry.does}`);
|
|
65
|
+
if (entry.depends_on?.length) lines.push(` Depends on: ${entry.depends_on.join(', ')}`);
|
|
66
|
+
if (entry.depended_by?.length) lines.push(` Used by: ${entry.depended_by.join(', ')}`);
|
|
67
|
+
if (entry.gotchas?.length) lines.push(` Gotcha: ${entry.gotchas.join(' | ')}`);
|
|
68
|
+
break;
|
|
69
|
+
case 'threads':
|
|
70
|
+
lines.push(`[Thread] ${entry.thread} — ${(entry.status || 'unknown').toUpperCase()}`);
|
|
71
|
+
if (entry.done?.length) lines.push(` Done: ${entry.done.join(', ')}`);
|
|
72
|
+
if (entry.remaining?.length) lines.push(` Remaining: ${entry.remaining.join(', ')}`);
|
|
73
|
+
if (entry.blocked_by) lines.push(` Blocked by: ${entry.blocked_by}`);
|
|
74
|
+
break;
|
|
75
|
+
case 'warnings':
|
|
76
|
+
lines.push(`[Warning] ${entry.warning}`);
|
|
77
|
+
if (entry.context) lines.push(` Context: ${entry.context}`);
|
|
78
|
+
if (entry.avoid) lines.push(` Avoid: ${entry.avoid}`);
|
|
79
|
+
break;
|
|
80
|
+
default:
|
|
81
|
+
lines.push(JSON.stringify(entry));
|
|
82
|
+
}
|
|
83
|
+
return lines.join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readJson(file) {
|
|
87
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getFullEntry(ascrDir, type, id) {
|
|
91
|
+
const typeFiles = {
|
|
92
|
+
decisions: 'decisions.json',
|
|
93
|
+
architecture: 'architecture.json',
|
|
94
|
+
threads: 'threads.json',
|
|
95
|
+
warnings: 'warnings.json',
|
|
96
|
+
};
|
|
97
|
+
const file = path.join(ascrDir, typeFiles[type]);
|
|
98
|
+
const entries = readJson(file);
|
|
99
|
+
if (!Array.isArray(entries)) return null;
|
|
100
|
+
return entries.find(e => e.id === id) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let input = '';
|
|
104
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
105
|
+
process.stdin.on('end', () => {
|
|
106
|
+
try {
|
|
107
|
+
const data = JSON.parse(input || '{}');
|
|
108
|
+
const cwd = data.cwd || process.cwd();
|
|
109
|
+
const prompt = data.prompt || '';
|
|
110
|
+
const ascrDir = path.join(cwd, '.ascr');
|
|
111
|
+
|
|
112
|
+
// Skip if not initialized or prompt is empty
|
|
113
|
+
if (!fs.existsSync(ascrDir) || !prompt.trim()) process.exit(0);
|
|
114
|
+
|
|
115
|
+
const indexFile = path.join(ascrDir, 'index.json');
|
|
116
|
+
if (!fs.existsSync(indexFile)) process.exit(0);
|
|
117
|
+
|
|
118
|
+
const index = readJson(indexFile);
|
|
119
|
+
if (!index) process.exit(0);
|
|
120
|
+
|
|
121
|
+
const keywords = extractKeywords(prompt);
|
|
122
|
+
if (keywords.length === 0) process.exit(0);
|
|
123
|
+
|
|
124
|
+
const scored = scoreIndex(index, keywords);
|
|
125
|
+
if (scored.length === 0) process.exit(0);
|
|
126
|
+
|
|
127
|
+
// Pull top 3 full entries
|
|
128
|
+
const TOP_N = 3;
|
|
129
|
+
const topMatches = scored.slice(0, TOP_N);
|
|
130
|
+
const formatted = [];
|
|
131
|
+
for (const match of topMatches) {
|
|
132
|
+
const entry = getFullEntry(ascrDir, match.type, match.id);
|
|
133
|
+
if (entry) formatted.push(formatEntry(match.type, entry));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (formatted.length === 0) process.exit(0);
|
|
137
|
+
|
|
138
|
+
// Output — injected into agent's context alongside the prompt
|
|
139
|
+
console.log('\n=== ASCR Context ===');
|
|
140
|
+
console.log(formatted.join('\n\n'));
|
|
141
|
+
console.log('=== End ASCR Context ===\n');
|
|
142
|
+
|
|
143
|
+
process.exit(0);
|
|
144
|
+
} catch {
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ASCR SessionStart Hook — standalone, no external dependencies
|
|
5
|
+
// Announces ASCR is active and snapshots knowledge base hashes.
|
|
6
|
+
// Stdout is injected into the agent's context by Claude Code.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
|
|
12
|
+
let input = '';
|
|
13
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
14
|
+
process.stdin.on('end', () => {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(input || '{}');
|
|
17
|
+
const cwd = data.cwd || process.cwd();
|
|
18
|
+
const sessionId = data.session_id || 'default';
|
|
19
|
+
const ascrDir = path.join(cwd, '.ascr');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(ascrDir)) process.exit(0);
|
|
22
|
+
|
|
23
|
+
const files = {
|
|
24
|
+
decisions: path.join(ascrDir, 'decisions.json'),
|
|
25
|
+
architecture: path.join(ascrDir, 'architecture.json'),
|
|
26
|
+
threads: path.join(ascrDir, 'threads.json'),
|
|
27
|
+
warnings: path.join(ascrDir, 'warnings.json'),
|
|
28
|
+
index: path.join(ascrDir, 'index.json'),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Count entries and build hashes for stop gate
|
|
32
|
+
const counts = {};
|
|
33
|
+
const hashes = {};
|
|
34
|
+
for (const [type, file] of Object.entries(files)) {
|
|
35
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '[]';
|
|
36
|
+
hashes[type] = crypto.createHash('sha256').update(content).digest('hex');
|
|
37
|
+
if (type !== 'index') {
|
|
38
|
+
try { counts[type] = JSON.parse(content).length; } catch { counts[type] = 0; }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Write session hash snapshot for stop gate
|
|
43
|
+
const hashFile = path.join(ascrDir, `.session-hash-${sessionId}`);
|
|
44
|
+
fs.writeFileSync(hashFile, JSON.stringify(hashes), 'utf8');
|
|
45
|
+
|
|
46
|
+
// Clean up stale hash files older than 24 hours
|
|
47
|
+
try {
|
|
48
|
+
const entries = fs.readdirSync(ascrDir);
|
|
49
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.startsWith('.session-hash-')) {
|
|
52
|
+
const fullPath = path.join(ascrDir, entry);
|
|
53
|
+
if (fs.statSync(fullPath).mtimeMs < cutoff) fs.unlinkSync(fullPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { /* ignore cleanup errors */ }
|
|
57
|
+
|
|
58
|
+
// Build announcement output (injected into agent context)
|
|
59
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
60
|
+
const parts = Object.entries(counts)
|
|
61
|
+
.filter(([, n]) => n > 0)
|
|
62
|
+
.map(([type, n]) => `${n} ${type}`);
|
|
63
|
+
|
|
64
|
+
const summary = parts.length > 0
|
|
65
|
+
? `Knowledge base: ${parts.join(', ')}`
|
|
66
|
+
: 'Knowledge base: empty (no entries yet)';
|
|
67
|
+
|
|
68
|
+
console.log(`=== ASCR Active ===`);
|
|
69
|
+
console.log(summary);
|
|
70
|
+
if (total > 0) {
|
|
71
|
+
console.log(`Context injected automatically per prompt. For deep search, read .ascr/ files directly.`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log(`First session — update .ascr/ files when you finish work to build memory.`);
|
|
74
|
+
}
|
|
75
|
+
console.log(`===`);
|
|
76
|
+
|
|
77
|
+
process.exit(0);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Never crash — just exit cleanly
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ASCR Stop Hook — standalone, no external dependencies
|
|
5
|
+
// Blocks the agent from stopping if it made code changes but didn't update the knowledge base.
|
|
6
|
+
// Uses a hash snapshot (written by session-start) to detect knowledge base changes.
|
|
7
|
+
// Uses git diff to detect code changes (falls back to hash-only if not a git repo).
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const { execSync } = require('node:child_process');
|
|
13
|
+
|
|
14
|
+
const BLOCK_MESSAGE = `ASCR: You made changes to the project but did not update the knowledge base.
|
|
15
|
+
Update the relevant .ascr/ files before ending:
|
|
16
|
+
|
|
17
|
+
.ascr/decisions.json — if you made or changed an architectural decision
|
|
18
|
+
.ascr/architecture.json — if you created or modified components/files
|
|
19
|
+
.ascr/threads.json — if work is in progress or was completed
|
|
20
|
+
.ascr/warnings.json — if you hit dead ends or gotchas to avoid
|
|
21
|
+
|
|
22
|
+
Also update .ascr/index.json with tags for any new or modified entries.
|
|
23
|
+
|
|
24
|
+
Schema reference:
|
|
25
|
+
decisions: { id, decision, why, alternatives_rejected, tags, affects, date }
|
|
26
|
+
architecture: { id, component, location, does, depends_on, depended_by, tags, gotchas }
|
|
27
|
+
threads: { id, thread, status, done, remaining, tags, blocked_by, updated }
|
|
28
|
+
warnings: { id, warning, context, avoid, tags, date }
|
|
29
|
+
|
|
30
|
+
Generate id: first 8 chars of sha256(key + timestamp). Example:
|
|
31
|
+
echo -n "jose-over-jsonwebtoken2026-03-27" | sha256sum | cut -c1-8`;
|
|
32
|
+
|
|
33
|
+
function hashFile(file) {
|
|
34
|
+
try {
|
|
35
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '[]';
|
|
36
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function codeFilesChanged(cwd) {
|
|
43
|
+
try {
|
|
44
|
+
// Check for any changes: staged, unstaged, or untracked (new files)
|
|
45
|
+
const staged = execSync('git diff --cached --name-only', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
46
|
+
const unstaged = execSync('git diff --name-only', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
47
|
+
// Exclude .ascr/ changes from the check — those are memory, not code
|
|
48
|
+
const allChanges = [...staged.split('\n'), ...unstaged.split('\n')]
|
|
49
|
+
.filter(f => f && !f.startsWith('.ascr/'));
|
|
50
|
+
return allChanges.length > 0;
|
|
51
|
+
} catch {
|
|
52
|
+
// Not a git repo or git not available — skip this check
|
|
53
|
+
return null; // null = unknown
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function knowledgeBaseChanged(ascrDir, sessionId, savedHashes) {
|
|
58
|
+
const files = {
|
|
59
|
+
decisions: path.join(ascrDir, 'decisions.json'),
|
|
60
|
+
architecture: path.join(ascrDir, 'architecture.json'),
|
|
61
|
+
threads: path.join(ascrDir, 'threads.json'),
|
|
62
|
+
warnings: path.join(ascrDir, 'warnings.json'),
|
|
63
|
+
index: path.join(ascrDir, 'index.json'),
|
|
64
|
+
};
|
|
65
|
+
for (const [type, file] of Object.entries(files)) {
|
|
66
|
+
const currentHash = hashFile(file);
|
|
67
|
+
if (currentHash && savedHashes[type] && currentHash !== savedHashes[type]) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let input = '';
|
|
75
|
+
process.stdin.on('data', chunk => { input += chunk; });
|
|
76
|
+
process.stdin.on('end', () => {
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(input || '{}');
|
|
79
|
+
const cwd = data.cwd || process.cwd();
|
|
80
|
+
const sessionId = data.session_id || 'default';
|
|
81
|
+
|
|
82
|
+
// 1. Loop guard — if stop_hook_active, allow unconditionally
|
|
83
|
+
if (data.stop_hook_active) process.exit(0);
|
|
84
|
+
|
|
85
|
+
const ascrDir = path.join(cwd, '.ascr');
|
|
86
|
+
|
|
87
|
+
// 2. If ASCR not initialized, skip enforcement
|
|
88
|
+
if (!fs.existsSync(ascrDir)) process.exit(0);
|
|
89
|
+
|
|
90
|
+
// 3. Read saved hash snapshot from session start
|
|
91
|
+
const hashFile = path.join(ascrDir, `.session-hash-${sessionId}`);
|
|
92
|
+
if (!fs.existsSync(hashFile)) process.exit(0); // no snapshot = can't enforce
|
|
93
|
+
|
|
94
|
+
let savedHashes;
|
|
95
|
+
try {
|
|
96
|
+
savedHashes = JSON.parse(fs.readFileSync(hashFile, 'utf8'));
|
|
97
|
+
} catch {
|
|
98
|
+
process.exit(0); // malformed hash file = skip
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 4. Smart trigger: check if code files actually changed
|
|
102
|
+
const codeChanged = codeFilesChanged(cwd);
|
|
103
|
+
if (codeChanged === false) {
|
|
104
|
+
// Git confirms no code changes outside .ascr/ — Q&A turn, no enforcement
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
// codeChanged === null means git unavailable — fall through to hash check
|
|
108
|
+
|
|
109
|
+
// 5. Check if knowledge base was updated
|
|
110
|
+
if (knowledgeBaseChanged(ascrDir, sessionId, savedHashes)) {
|
|
111
|
+
// Agent already updated memory — clean up hash file and allow
|
|
112
|
+
try { fs.unlinkSync(hashFile); } catch { /* ignore */ }
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 6. Code changed but knowledge base didn't — block
|
|
117
|
+
process.stderr.write(BLOCK_MESSAGE);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
|
|
120
|
+
} catch {
|
|
121
|
+
// Never crash — always allow on unexpected error
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}
|
|
124
|
+
});
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
|
|
4
|
+
const paths = require('./paths');
|
|
5
|
+
const { readKnowledge, TYPES } = require('./memory');
|
|
6
|
+
|
|
7
|
+
function readIndex(cwd) {
|
|
8
|
+
const file = paths.indexFile(cwd);
|
|
9
|
+
if (!existsSync(file)) return { decisions: [], architecture: [], threads: [], warnings: [] };
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
12
|
+
} catch {
|
|
13
|
+
return { decisions: [], architecture: [], threads: [], warnings: [] };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeIndex(cwd, index) {
|
|
18
|
+
writeFileSync(paths.indexFile(cwd), JSON.stringify(index, null, 2) + '\n', 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Rebuild the full index from knowledge files
|
|
22
|
+
function rebuildIndex(cwd) {
|
|
23
|
+
const index = {};
|
|
24
|
+
for (const type of TYPES) {
|
|
25
|
+
const entries = readKnowledge(cwd, type);
|
|
26
|
+
index[type] = entries.map(e => {
|
|
27
|
+
const entry = { id: e.id, key: e.key || e.decision || e.component || e.thread || e.warning || e.id, tags: e.tags || [] };
|
|
28
|
+
if (type === 'threads' && e.status) entry.status = e.status;
|
|
29
|
+
return entry;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
writeIndex(cwd, index);
|
|
33
|
+
return index;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Score index entries against a set of keywords
|
|
37
|
+
// Returns sorted array of { type, id, score }
|
|
38
|
+
function scoreIndex(index, keywords) {
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const type of TYPES) {
|
|
41
|
+
const entries = index[type] || [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
let score = 0;
|
|
44
|
+
const tags = (entry.tags || []).map(t => t.toLowerCase());
|
|
45
|
+
const key = (entry.key || '').toLowerCase();
|
|
46
|
+
for (const kw of keywords) {
|
|
47
|
+
if (tags.includes(kw)) score += 2;
|
|
48
|
+
else if (tags.some(t => t.includes(kw) || kw.includes(t))) score += 1;
|
|
49
|
+
if (key.includes(kw)) score += 1;
|
|
50
|
+
}
|
|
51
|
+
// Boost in-progress threads ONLY if there's already a keyword match
|
|
52
|
+
if (score > 0 && type === 'threads' && entry.status === 'in_progress') score += 1;
|
|
53
|
+
if (score > 0) results.push({ type, id: entry.id, score });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Sort by score desc, then prefer threads > architecture > decisions > warnings
|
|
57
|
+
const typePriority = { threads: 4, architecture: 3, decisions: 2, warnings: 1 };
|
|
58
|
+
results.sort((a, b) => {
|
|
59
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
60
|
+
return (typePriority[b.type] || 0) - (typePriority[a.type] || 0);
|
|
61
|
+
});
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { readIndex, writeIndex, rebuildIndex, scoreIndex };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { readFileSync, writeFileSync, existsSync } = require('node:fs');
|
|
4
|
+
const paths = require('./paths');
|
|
5
|
+
|
|
6
|
+
const TYPES = ['decisions', 'architecture', 'threads', 'warnings'];
|
|
7
|
+
|
|
8
|
+
function readKnowledge(cwd, type) {
|
|
9
|
+
const file = paths.KNOWLEDGE_FILES(cwd)[type];
|
|
10
|
+
if (!existsSync(file)) return [];
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(file, 'utf8').trim();
|
|
13
|
+
if (!raw || raw === '') return [];
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeKnowledge(cwd, type, entries) {
|
|
21
|
+
const file = paths.KNOWLEDGE_FILES(cwd)[type];
|
|
22
|
+
writeFileSync(file, JSON.stringify(entries, null, 2) + '\n', 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readAllKnowledge(cwd) {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const type of TYPES) {
|
|
28
|
+
result[type] = readKnowledge(cwd, type);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function filterEntries(entries, { tag, status } = {}) {
|
|
34
|
+
return entries.filter(e => {
|
|
35
|
+
if (tag && !(e.tags || []).includes(tag)) return false;
|
|
36
|
+
if (status && e.status !== status) return false;
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function countAll(cwd) {
|
|
42
|
+
const counts = {};
|
|
43
|
+
for (const type of TYPES) {
|
|
44
|
+
counts[type] = readKnowledge(cwd, type).length;
|
|
45
|
+
}
|
|
46
|
+
return counts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { TYPES, readKnowledge, writeKnowledge, readAllKnowledge, filterEntries, countAll };
|