@damiangilgonzalez/harness 1.3.10 → 1.3.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/manifest.json +1 -1
- package/package.json +1 -1
- package/targets/claude.js +6 -3
- package/targets/danger-guard.mjs +2 -2
- package/targets/gitmoji-guard.mjs +2 -2
- package/targets/session-memory-core.mjs +8 -2
- package/targets/session-start.mjs +37 -11
- package/targets/ship-guard.mjs +2 -2
- package/targets/userprompt-gate.mjs +6 -1
package/manifest.json
CHANGED
package/package.json
CHANGED
package/targets/claude.js
CHANGED
|
@@ -4,6 +4,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { linkOrCopy } from './index.js';
|
|
5
5
|
|
|
6
6
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
// Shell tools the PreToolUse guards listen on. On Windows the primary shell is the PowerShell tool,
|
|
8
|
+
// so a `Bash`-only matcher let `git push --force` or a commit without gitmoji through unasked.
|
|
9
|
+
const SHELL_TOOLS = 'Bash|PowerShell';
|
|
7
10
|
|
|
8
11
|
export function writeSkill(id, fromDir, toPath) {
|
|
9
12
|
// Migrate away from the legacy nested layout (.claude/skills/rsc/<id>) that
|
|
@@ -142,7 +145,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
142
145
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
143
146
|
(e) => !hookWiringOf(e).includes('.rsc/ship-guard.'),
|
|
144
147
|
);
|
|
145
|
-
settings.hooks.PreToolUse.push({ matcher:
|
|
148
|
+
settings.hooks.PreToolUse.push({ matcher: SHELL_TOOLS, hooks: [{ type: 'command', command: sgCmd }] });
|
|
146
149
|
|
|
147
150
|
// Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
|
|
148
151
|
// (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
|
|
@@ -155,7 +158,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
155
158
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
156
159
|
(e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
|
|
157
160
|
);
|
|
158
|
-
settings.hooks.PreToolUse.push({ matcher:
|
|
161
|
+
settings.hooks.PreToolUse.push({ matcher: SHELL_TOOLS, hooks: [{ type: 'command', command: dgCmd }] });
|
|
159
162
|
|
|
160
163
|
// Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
|
|
161
164
|
// carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
|
|
@@ -170,7 +173,7 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
170
173
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
171
174
|
(e) => !hookWiringOf(e).includes('.rsc/gitmoji-guard.'),
|
|
172
175
|
);
|
|
173
|
-
settings.hooks.PreToolUse.push({ matcher:
|
|
176
|
+
settings.hooks.PreToolUse.push({ matcher: SHELL_TOOLS, hooks: [{ type: 'command', command: gmCmd }] });
|
|
174
177
|
|
|
175
178
|
// New-feature gate: a UserPromptSubmit hook that re-injects the SDD new-feature gate as
|
|
176
179
|
// the most-recent instruction on every user turn. SessionStart injects the full gate (the
|
package/targets/danger-guard.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// rsc Danger guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
|
|
2
|
+
// rsc Danger guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash|PowerShell)
|
|
3
3
|
// as `node ...` so it runs on every platform including Windows.
|
|
4
4
|
// argv[2] = absolute project root stdin = PreToolUse hook JSON
|
|
5
5
|
//
|
|
@@ -46,7 +46,7 @@ if (technicalLevel() === 'technical') allow();
|
|
|
46
46
|
// Only Bash commands can be dangerous here.
|
|
47
47
|
let input = {};
|
|
48
48
|
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
49
|
-
if ((input.tool_name || input.toolName)
|
|
49
|
+
if (!['Bash', 'PowerShell'].includes(input.tool_name || input.toolName)) allow();
|
|
50
50
|
const cmd = input.tool_input?.command || input.toolInput?.command || '';
|
|
51
51
|
if (typeof cmd !== 'string' || !cmd) allow();
|
|
52
52
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// rsc Gitmoji guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
|
|
2
|
+
// rsc Gitmoji guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash|PowerShell)
|
|
3
3
|
// as `node ...` so it runs on every platform including Windows.
|
|
4
4
|
// argv[2] = absolute project root stdin = PreToolUse hook JSON
|
|
5
5
|
//
|
|
@@ -230,7 +230,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
230
230
|
|
|
231
231
|
let input = {};
|
|
232
232
|
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
233
|
-
if ((input.tool_name || input.toolName)
|
|
233
|
+
if (!['Bash', 'PowerShell'].includes(input.tool_name || input.toolName)) allow();
|
|
234
234
|
const command = input.tool_input?.command || input.toolInput?.command || '';
|
|
235
235
|
if (typeof command !== 'string' || !command) allow();
|
|
236
236
|
|
|
@@ -313,7 +313,13 @@ export function capture(input = {}) {
|
|
|
313
313
|
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
314
314
|
|
|
315
315
|
const repo = snapshot(cwd, anchor.baselineHead);
|
|
316
|
-
|
|
316
|
+
// Compaction closes a context window, so the edit budget starts again from zero.
|
|
317
|
+
const previousEdits = input.event === 'compact' ? 0 : (existing?.editCount || 0);
|
|
318
|
+
const editCount = Math.max(0, previousEdits + (finiteOrNull(input.editDelta, true) || 0));
|
|
319
|
+
// Hint once per threshold crossed, and only on the edit that crosses it: re-emitting on
|
|
320
|
+
// every later event (Stop in particular) wakes the model again and loops.
|
|
321
|
+
const crossedThreshold = editCount > previousEdits
|
|
322
|
+
&& Math.floor(editCount / config.editThreshold) > Math.floor(previousEdits / config.editThreshold);
|
|
317
323
|
const baselineFiles = new Set(anchor.baselineFiles || []);
|
|
318
324
|
const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
|
|
319
325
|
const baselineFingerprints = anchor.baselineFingerprints || {};
|
|
@@ -356,7 +362,7 @@ export function capture(input = {}) {
|
|
|
356
362
|
record,
|
|
357
363
|
path: recordPath,
|
|
358
364
|
notice: consumeNotice(store),
|
|
359
|
-
compactionHint: Boolean(config.compactionHint &&
|
|
365
|
+
compactionHint: Boolean(config.compactionHint && crossedThreshold),
|
|
360
366
|
};
|
|
361
367
|
}
|
|
362
368
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
11
12
|
import { claimOnce, readHookInput } from './hook-once.mjs';
|
|
12
13
|
|
|
13
14
|
const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
|
|
@@ -27,6 +28,19 @@ const emitBody = hook.session_id
|
|
|
27
28
|
|
|
28
29
|
const has = (...p) => existsSync(join(root, ...p));
|
|
29
30
|
|
|
31
|
+
// The npm lookup (up to 1.5 s) starts now and runs while the local checks below do their work;
|
|
32
|
+
// the update block at the end only awaits it.
|
|
33
|
+
const latestVersion = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve(null)
|
|
34
|
+
: process.env.RSC_LATEST ? Promise.resolve(process.env.RSC_LATEST)
|
|
35
|
+
: (async () => {
|
|
36
|
+
const ctrl = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch('https://registry.npmjs.org/@damiangilgonzalez%2fharness/latest', { signal: ctrl.signal });
|
|
40
|
+
return (await res.json()).version;
|
|
41
|
+
} catch { return null; } finally { clearTimeout(timer); }
|
|
42
|
+
})();
|
|
43
|
+
|
|
30
44
|
// Is a Context7 MCP server configured for this project (.mcp.json → mcpServers.context7)?
|
|
31
45
|
function hasContext7() {
|
|
32
46
|
try {
|
|
@@ -221,10 +235,24 @@ if (has('.git')) {
|
|
|
221
235
|
// off, so a second check could never fail and would only look like protection (P2). Test 36 holds
|
|
222
236
|
// the behaviour end to end, through this hook.
|
|
223
237
|
const W = await import('./worktree-reaper.mjs');
|
|
224
|
-
|
|
238
|
+
// Classifying spawns several git processes per worktree (1.2 s with a dozen of them, on every
|
|
239
|
+
// session start). A verdict only moves when a worktree HEAD, the trunk or the opt-out moves, so
|
|
240
|
+
// those make the fingerprint and an unchanged one reuses the last notice. Safe because `reap`
|
|
241
|
+
// re-classifies before it removes anything.
|
|
242
|
+
const trunk = W.resolveTrunk(root);
|
|
243
|
+
const tip = trunk ? spawnSync('git', ['-C', root, 'rev-parse', trunk], { encoding: 'utf8' }).stdout : '';
|
|
244
|
+
const list = spawnSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' }).stdout;
|
|
245
|
+
const fingerprint = `${W.isCleanupEnabled(root)}\n${trunk}\n${tip}\n${list}`;
|
|
246
|
+
const cacheFile = join(root, '.rsc', 'worktree-sweep.json');
|
|
247
|
+
let cached = null;
|
|
248
|
+
try { cached = JSON.parse(readFileSync(cacheFile, 'utf8')); } catch { /* first run */ }
|
|
249
|
+
if (cached && cached.fingerprint === fingerprint && typeof cached.text === 'string') {
|
|
250
|
+
process.stdout.write(cached.text);
|
|
251
|
+
} else {
|
|
252
|
+
let text = '';
|
|
225
253
|
const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
226
254
|
if (candidates.length) {
|
|
227
|
-
|
|
255
|
+
text = `
|
|
228
256
|
===== rsc worktree cleanup =====
|
|
229
257
|
${candidates.length} worktree(s) hold work that is already in the trunk:
|
|
230
258
|
${candidates.map((c) => W.summarize(c, root)).join('\n')}
|
|
@@ -234,8 +262,13 @@ ACTION: offer to retire them in ONE line and wait for a yes. On a yes run:
|
|
|
234
262
|
A yes in bulk covers only the safe ones; anything marked \`ask\` is confirmed on its own.
|
|
235
263
|
A no holds for this session. Permanent off: .rsc/.no-worktree-cleanup
|
|
236
264
|
================================
|
|
237
|
-
|
|
265
|
+
`;
|
|
238
266
|
}
|
|
267
|
+
process.stdout.write(text);
|
|
268
|
+
try {
|
|
269
|
+
mkdirSync(join(root, '.rsc'), { recursive: true });
|
|
270
|
+
writeFileSync(cacheFile, JSON.stringify({ fingerprint, text }) + '\n');
|
|
271
|
+
} catch { /* unwritable → scan again next session */ }
|
|
239
272
|
}
|
|
240
273
|
} catch { /* reaper missing or git unhappy → say nothing; this is never worth breaking startup for */ }
|
|
241
274
|
}
|
|
@@ -257,14 +290,7 @@ function isNewer(a, b) {
|
|
|
257
290
|
if (!process.env.RSC_NO_UPDATE_CHECK) {
|
|
258
291
|
try {
|
|
259
292
|
const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
|
|
260
|
-
|
|
261
|
-
if (!latest) {
|
|
262
|
-
const ctrl = new AbortController();
|
|
263
|
-
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
264
|
-
const res = await fetch('https://registry.npmjs.org/@damiangilgonzalez%2fharness/latest', { signal: ctrl.signal });
|
|
265
|
-
clearTimeout(timer);
|
|
266
|
-
latest = (await res.json()).version;
|
|
267
|
-
}
|
|
293
|
+
const latest = await latestVersion;
|
|
268
294
|
if (installed && latest && isNewer(latest, installed)) {
|
|
269
295
|
process.stdout.write(`
|
|
270
296
|
===== rsc update available =====
|
package/targets/ship-guard.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// rsc Ship guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
|
|
2
|
+
// rsc Ship guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash|PowerShell)
|
|
3
3
|
// as `node ...` so it runs on every platform including Windows.
|
|
4
4
|
// argv[2] = absolute project root stdin = PreToolUse hook JSON
|
|
5
5
|
//
|
|
@@ -39,7 +39,7 @@ if (!existsSync(join(root, '.git'))) allow();
|
|
|
39
39
|
// Read the tool call. Only Bash commands can move branches.
|
|
40
40
|
let input = {};
|
|
41
41
|
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
42
|
-
if ((input.tool_name || input.toolName)
|
|
42
|
+
if (!['Bash', 'PowerShell'].includes(input.tool_name || input.toolName)) allow();
|
|
43
43
|
const command = input.tool_input?.command || input.toolInput?.command || '';
|
|
44
44
|
if (typeof command !== 'string' || !command) allow();
|
|
45
45
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// Fail-open (never blocks a turn). Opt out per project with .rsc/.no-feature-gate.
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
16
17
|
import { claimOnce, readHookInput, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
|
|
17
18
|
|
|
18
19
|
const root = process.argv[2] || process.cwd();
|
|
@@ -28,4 +29,8 @@ const hook = readHookInput();
|
|
|
28
29
|
const turnKey = hook.prompt_id || (hook.prompt ? `p${hook.prompt.length}:${hook.prompt.slice(0, 64)}` : null);
|
|
29
30
|
if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)) process.exit(0);
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// The gate routes screen work through `flujo-interfaz`, but that skill is optional. Naming a skill
|
|
33
|
+
// that is not installed sends the agent looking for something that does not exist, so the line only
|
|
34
|
+
// ships when the skill is present in this project or in the user scope.
|
|
35
|
+
const hasFlujo = [root, homedir()].some((r) => existsSync(join(r, '.claude', 'skills', 'flujo-interfaz', 'SKILL.md')));
|
|
36
|
+
process.stdout.write(hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n'));
|