Sign In

repotector

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

repotector - npm Package Compare versions

Comparing version
1.3.0
to
1.4.0
+33
-5
bin/psx-repotector.mjs

@@ -231,2 +231,8 @@ #!/usr/bin/env node

// blocks NEW regressions, never day-one debt). Never clobbers an existing hook.
//
// FAIL-OPEN by design: the shim blocks ONLY on the dedicated drift exit code
// (2). If the tool itself cannot run (missing node, moved path, offline), the
// commit goes through with a loud warning — a guard that locks every commit
// because IT is broken gets uninstalled, which is worse than one missed check.
// The shim pins the ABSOLUTE path of this CLI (no npx, no network at commit).
function cmdHooks () {

@@ -236,6 +242,27 @@ const hooksDir = join(ROOT, '.git', 'hooks')

const hookPath = join(hooksDir, 'pre-commit')
const shim = '#!/bin/sh\n# repotector commit guard — blocks only NEW regressions (grandfathered baseline).\nexec npx --no-install repotector gates || exec npx -y repotector gates\n'
const cliAbs = join(PKG_ROOT, 'bin', 'psx-repotector.mjs').replace(/\\/g, '/')
const shim = [
'#!/bin/sh',
'# repotector commit guard — blocks ONLY on gate regressions (exit 2).',
'# Tool failures fail OPEN with a warning (never a lockout).',
`node "${cliAbs}" gates`,
'code=$?',
'if [ "$code" -eq 2 ]; then',
' echo "[repotector] new regression(s) - commit blocked. Run: repotector gates" >&2',
' exit 1',
'fi',
'if [ "$code" -ne 0 ]; then',
' echo "[repotector] guard could not run (exit $code) - failing OPEN, commit allowed." >&2',
'fi',
'exit 0',
''
].join('\n')
if (existsSync(hookPath)) {
const current = readFileSync(hookPath, 'utf8')
if (/repotector/.test(current)) { console.log('✓ Commit guard already installed.'); return }
if (/repotector/.test(current)) {
writeFileSync(hookPath, shim)
try { chmodSync(hookPath, 0o755) } catch { /* git runs hooks via sh */ }
console.log('✓ Commit guard updated (fail-open shim, pinned CLI path).')
return
}
return fail('a pre-commit hook already exists — add "npx repotector gates" to it manually (never clobbering yours).')

@@ -245,3 +272,3 @@ }

try { chmodSync(hookPath, 0o755) } catch { /* windows: git runs hooks via sh regardless */ }
console.log('⬡ Commit guard installed (.git/hooks/pre-commit) — gates run on every commit; only regressions block.')
console.log('⬡ Commit guard installed (.git/hooks/pre-commit) — blocks only on regressions (exit 2); tool failures fail open.')
}

@@ -276,4 +303,5 @@

printProof(proof)
// Non-zero on regressions so hooks and CI can actually gate.
if (proof.verdict !== 'INTENT_HONORED') process.exit(1)
// Dedicated exit code 2 = drift (regressions), so the hook can block on
// exactly that and FAIL OPEN on any other failure (1 = tool error).
if (proof.verdict !== 'INTENT_HONORED') process.exit(2)
return

@@ -280,0 +308,0 @@ }

+5
-2
{
"name": "repotector",
"version": "1.3.0",
"version": "1.4.0",
"description": "Repotector ⬡ — the repo guardian AI agents handshake with before touching code: REPOTECTOR/2 handshake + visitor register, city-map (built-vs-missing), grandfathered gates that never fail you on day one, resilient depart, and a DNA-aware MCP server.",

@@ -42,3 +42,6 @@ "type": "module",

"homepage": "https://github.com/dir-ai/repotector#readme",
"license": "MIT"
"license": "MIT",
"scripts": {
"test": "node test/run.mjs"
}
}

@@ -104,3 +104,3 @@ # Repotector ⬡

in PSX Workbench repos, the Merge Machine's leases via the .psx mirror).
CLI: (exit 1 on conflicts, so hooks/CI
CLI: `repotector merge-check [target]` (exit 1 on conflicts, so hooks/CI
can gate on it). Zero network: it trial-merges against your LOCAL refs and

@@ -107,0 +107,0 @@ says so — fetch first for the freshest truth.

@@ -5,3 +5,3 @@ {

"description": "The repo guardian AI agents handshake with before they touch your code.",
"version": "1.3.0",
"version": "1.4.0",
"packages": [

@@ -11,3 +11,3 @@ {

"identifier": "repotector",
"version": "1.3.0",
"version": "1.4.0",
"runtimeHint": "npx",

@@ -14,0 +14,0 @@ "transport": {

@@ -78,5 +78,8 @@ // doctor.mjs — "how protected is this repo, really?" One command, semaphore

}
// Honest scope: doctor verifies INSTALLATION health, not live policy state —
// gates/merge-check answer "is the code OK right now"; doctor answers "is the
// guard wired". Saying "guarded" here would overclaim.
console.log(report.ok
? `\n ${C.g}${C.b}All green — this repo is guarded.${C.x}\n`
? `\n ${C.g}${C.b}All checks green — installation healthy.${C.x} ${C.dim}Live policy: run \`gates\` and \`merge-check\`.${C.x}\n`
: `\n ${C.r}${C.b}${report.reds} check(s) red.${C.x}\n`)
}

@@ -25,2 +25,3 @@ // mcp-server.mjs — stdio MCP server. The repo's front door for any arriving AI.

recordClaim, recordRelease, activeClaims, claimConflicts, recordDecisions, listDecisions,
offClaimFiles,
} from './register.mjs'

@@ -165,2 +166,5 @@ import { mergeCheck, allZones } from './merge.mjs'

const filesTouched = session ? filesChangedSince(ROOT, session.enterHead) : []
// Out-of-claim reconciliation: if this session claimed a zone, files touched
// OUTSIDE it are flagged — the claim gets consequences, not just courtesy.
const offClaim = session ? offClaimFiles(ROOT, { sessionId: session.sessionId, files: filesTouched }) : []
if (decisions?.length) {

@@ -170,3 +174,3 @@ recordDecisions(ROOT, { sessionId: session?.sessionId, who: session?.who, decisions })

}
recordDepart(ROOT, { sessionId: session?.sessionId, summary, filesTouched })
recordDepart(ROOT, { sessionId: session?.sessionId, summary, filesTouched, offClaim: offClaim.length ? offClaim : undefined })
try { writeJournalMd(ROOT) } catch { /* journal is a projection; best-effort */ }

@@ -176,3 +180,4 @@ const who = session?.who

const delta = filesTouched.length ? ` You touched ${filesTouched.length} file(s): ${filesTouched.slice(0, 8).join(', ')}${filesTouched.length > 8 ? '…' : ''}.` : ''
return { content: [{ type: 'text', text: `← ${who ?? 'agent'} signed out.${delta} Safe travels.` }], structuredContent: { departed: true, filesTouched } }
const scopeNote = offClaim.length ? ` ⚠ ${offClaim.length} file(s) OUTSIDE your claimed zone: ${offClaim.slice(0, 6).join(', ')}${offClaim.length > 6 ? '…' : ''} — recorded in the register.` : ''
return { content: [{ type: 'text', text: `← ${who ?? 'agent'} signed out.${delta}${scopeNote} Safe travels.` }], structuredContent: { departed: true, filesTouched, offClaim } }
}))

@@ -179,0 +184,0 @@

@@ -87,6 +87,18 @@ // merge.mjs — the portable front door of merge safety. When several agents

// Include the UNCOMMITTED work: `git stash create` mints a commit-ish of the
// working tree + index WITHOUT touching anything. An agent's not-yet-committed
// edits must count — otherwise merge_check says CLEAN while the worktree is
// already colliding. (Tracked changes only; brand-new untracked files are not
// captured — stated, not hidden.)
let ours = 'HEAD'
let includesWorktree = false
try {
const stashOid = git(root, ['stash', 'create']).trim()
if (stashOid) { ours = stashOid; includesWorktree = true }
} catch { /* clean tree or stash unavailable → HEAD is the honest side */ }
let conflictedFiles = []
let clean = null
try {
git(root, ['merge-tree', '--write-tree', '--name-only', 'HEAD', resolved])
git(root, ['merge-tree', '--write-tree', '--name-only', ours, resolved])
clean = true

@@ -98,6 +110,9 @@ } catch (error) {

clean = false
// Output: <tree-oid> then blank-separated conflicted file names.
conflictedFiles = out.split('\n').slice(1).map((s) => s.trim()).filter(Boolean)
// Output: <tree-oid>, conflicted file names, then a BLANK line followed by
// informational prose (Auto-merging…, CONFLICT…) — stop at the blank line.
const lines = out.split('\n').slice(1)
const blank = lines.findIndex((line) => line.trim() === '')
conflictedFiles = (blank === -1 ? lines : lines.slice(0, blank)).map((s) => s.trim()).filter(Boolean)
} else {
return { supported: false, target: resolved, ahead, behind, clean: null, conflicts: [], note: 'git merge-tree --write-tree unavailable (needs git >= 2.38) — cannot trial-merge.' }
return { supported: false, target: resolved, ahead, behind, clean: null, conflicts: [], includesWorktree, note: 'git merge-tree --write-tree unavailable (needs git >= 2.38) — cannot trial-merge.' }
}

@@ -108,6 +123,7 @@ }

const conflicts = conflictedFiles.map((file) => ({ file, heldBy: zonesTouching(zones, file).map((z) => ({ who: z.who, source: z.source, reason: z.reason })) }))
const note = behind === 0
const worktreeNote = includesWorktree ? 'Includes your uncommitted (tracked) changes. ' : ''
const note = worktreeNote + (behind === 0
? 'Up to date with the integration base.'
: `Base has moved (${behind} commit(s) not in HEAD) — trial merge is against the LOCAL ref; fetch first for the freshest truth.`
return { supported: true, target: resolved, ahead, behind, clean, conflicts, note }
: `Base has moved (${behind} commit(s) not in HEAD) — trial merge is against the LOCAL ref; fetch first for the freshest truth.`)
return { supported: true, target: resolved, ahead, behind, clean, conflicts, includesWorktree, note }
}

@@ -114,0 +130,0 @@

@@ -58,3 +58,3 @@ // register.mjs — the visitor ledger. Every AI that faces the repo must handshake

// resilient part: a depart carries the real work even when `summary` is absent.
export function recordDepart (root, { sessionId, summary, filesTouched, synthetic, reason }) {
export function recordDepart (root, { sessionId, summary, filesTouched, synthetic, reason, offClaim }) {
appendLine(root, {

@@ -66,2 +66,3 @@ event: 'depart',

filesTouched: filesTouched && filesTouched.length ? filesTouched : null,
offClaim: offClaim && offClaim.length ? offClaim : null,
synthetic: !!synthetic,

@@ -141,2 +142,20 @@ reason: reason || null

// The active claims held by ONE session (for out-of-claim reconciliation).
export function sessionClaims (root, sessionId) {
if (!sessionId) return []
return activeClaims(root).filter((c) => c.sessionId === sessionId)
}
// Files not covered by any of the session's claimed zones. Empty when the
// session holds no claims (claims are optional — no claim, no scope check).
export function offClaimFiles (root, { sessionId, files }) {
const claims = sessionClaims(root, sessionId)
if (claims.length === 0) return []
const roots = claims.flatMap((c) => c.paths.map(claimRoot))
return (files || []).filter((file) => {
const f = String(file).replace(/\\/g, '/')
return !roots.some((r) => r === '' || f === r || f.startsWith(r + '/'))
})
}
// Claims from OTHER live sessions that overlap the given paths.

@@ -143,0 +162,0 @@ export function claimConflicts (root, { paths, sessionId }) {