karajan-code 4.20.0 → 4.21.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/package.json +1 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +25 -0
- package/src/commands/harden.js +8 -0
- package/src/commands/identity.js +60 -0
- package/src/commands/start.js +6 -2
- package/src/harden/hook-templates.js +40 -1
- package/src/harden/sentinel-hooks.js +185 -7
- package/src/identity/bootstrap.js +48 -0
- package/src/identity/compare.js +38 -0
- package/src/identity/detect.js +51 -0
- package/src/identity/store.js +66 -0
- package/src/start/sweep.js +3 -0
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@ export const ADVANCED_GROUPS = [
|
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
33
|
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
|
|
34
|
-
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel"] },
|
|
34
|
+
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
|
37
37
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,6 +4,7 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
|
+
import { identityCommand } from "../commands/identity.js";
|
|
7
8
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
8
9
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
9
10
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -120,6 +121,30 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
120
121
|
});
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
// IDN-A (KJC-TSK-0762, epic KJC-PCS-0079): identity lock per clone.
|
|
125
|
+
const identityCmd = program
|
|
126
|
+
.command("identity")
|
|
127
|
+
.description("Identity lock: which gh account and git email this clone is worked with (.karajan/identity.local.yml, never tracked)");
|
|
128
|
+
identityCmd
|
|
129
|
+
.command("show", { isDefault: true })
|
|
130
|
+
.description("Declared identity vs the active one (exit 2 on mismatch or when undeclared)")
|
|
131
|
+
.action(async (flags) => {
|
|
132
|
+
await withConfig(pkgVersion, "identity-show", flags, async ({ config }) => {
|
|
133
|
+
process.exitCode = await identityCommand({ action: "show", config, flags });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
identityCmd
|
|
137
|
+
.command("set")
|
|
138
|
+
.description("Declare this clone's identity from the active gh session and git config (or --gh/--email)")
|
|
139
|
+
.option("--gh <user>", "gh account to bind")
|
|
140
|
+
.option("--email <email>", "git email to bind")
|
|
141
|
+
.option("--yes", "Do not ask for confirmation")
|
|
142
|
+
.action(async (flags) => {
|
|
143
|
+
await withConfig(pkgVersion, "identity-set", flags, async ({ config }) => {
|
|
144
|
+
process.exitCode = await identityCommand({ action: "set", config, flags });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
123
148
|
program
|
|
124
149
|
.command("start")
|
|
125
150
|
.description("Single entry point: assess the project and recommend the next step")
|
package/src/commands/harden.js
CHANGED
|
@@ -23,6 +23,7 @@ import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
|
23
23
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
24
24
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
25
25
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
26
|
+
import { ensureIdentity } from "../identity/bootstrap.js";
|
|
26
27
|
|
|
27
28
|
const JS_TEST_CMD = {
|
|
28
29
|
vitest: "npx vitest run",
|
|
@@ -191,5 +192,12 @@ export async function hardenCommand({
|
|
|
191
192
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
192
193
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
193
194
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
195
|
+
// IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
|
|
196
|
+
// only with a human confirming; headless runs get the pending command.
|
|
197
|
+
if (!dryRun) {
|
|
198
|
+
const id = await ensureIdentity({ projectDir, logger });
|
|
199
|
+
out.identity = id.declared ? id.identity : null;
|
|
200
|
+
if (id.pending) out.identityPending = id.pending;
|
|
201
|
+
}
|
|
194
202
|
return out;
|
|
195
203
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj identity show|set` (IDN-A, KJC-TSK-0762, epic KJC-PCS-0079).
|
|
3
|
+
*
|
|
4
|
+
* show — declared identity vs what is ACTIVE now; exit 2 on mismatch or
|
|
5
|
+
* when nothing is declared (scriptable: the gate and hooks reuse it).
|
|
6
|
+
* set — capture the effective gh account + git email (or --gh/--email),
|
|
7
|
+
* confirm on a TTY, write .karajan/identity.local.yml. Never invents:
|
|
8
|
+
* no session and no flags → exit 1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
12
|
+
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
13
|
+
import { compareIdentity } from "../identity/compare.js";
|
|
14
|
+
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
15
|
+
|
|
16
|
+
export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
|
|
17
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
18
|
+
const log = deps.log || console.log;
|
|
19
|
+
const tty = deps.isTTY || isTTY;
|
|
20
|
+
const detectOpts = { hostsPath: deps.hostsPath, gitFn: deps.gitFn };
|
|
21
|
+
const effective = {
|
|
22
|
+
gh_user: activeGhUser(detectOpts.hostsPath ? { hostsPath: detectOpts.hostsPath } : {}),
|
|
23
|
+
git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (action === "set") {
|
|
27
|
+
const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
|
|
28
|
+
if (!candidate.gh_user || !candidate.git_email) {
|
|
29
|
+
log(`kj identity: cannot declare — gh account: ${candidate.gh_user ?? "no session"}, git email: ${candidate.git_email ?? "none"}. Log in / set git config, or pass --gh <user> --email <email>.`);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
if (tty() && !flags.yes) {
|
|
33
|
+
const wizard = (deps.makeWizard || createWizard)();
|
|
34
|
+
try {
|
|
35
|
+
const ok = await wizard.confirm(`Bind this clone to gh ${candidate.gh_user} / git ${candidate.git_email}?`, true);
|
|
36
|
+
if (!ok) { log("kj identity: not declared."); return 1; }
|
|
37
|
+
} finally { wizard.close(); }
|
|
38
|
+
} else if (!flags.gh || !flags.email) {
|
|
39
|
+
// Proven live on day one: the active gh session had been flipped by
|
|
40
|
+
// ANOTHER session, and --yes bound the clone to the wrong account.
|
|
41
|
+
// Binding without confirmation is allowed, but never silent.
|
|
42
|
+
log("kj identity: WARNING — binding the ACTIVE session as-is without confirmation (another session may have switched it); prefer --gh/--email explicitly and verify with `kj identity show`.");
|
|
43
|
+
}
|
|
44
|
+
writeIdentity(projectDir, candidate);
|
|
45
|
+
log(`kj identity: declared gh ${candidate.gh_user} / git ${candidate.git_email} → .karajan/identity.local.yml (ignored by git).`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const declared = readIdentity(projectDir);
|
|
50
|
+
const result = compareIdentity(declared, effective);
|
|
51
|
+
if (!declared) {
|
|
52
|
+
log("kj identity: no identity declared for this clone — run `kj identity set`.");
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
const mark = (field) => (result.mismatches.some((m) => m.field === field) ? "DISTINTA" : "ACTIVA");
|
|
56
|
+
log(`gh_user ${declared.gh_user} [${mark("gh_user")}: ${effective.gh_user ?? "no session"}]`);
|
|
57
|
+
log(`git_email ${declared.git_email} [${mark("git_email")}: ${effective.git_email ?? "none"}]`);
|
|
58
|
+
for (const m of result.mismatches) log(` → ${m.remedy}`);
|
|
59
|
+
return result.ok ? 0 : 2;
|
|
60
|
+
}
|
package/src/commands/start.js
CHANGED
|
@@ -80,7 +80,11 @@ export async function startCommand({ task = "", config, logger, flags = {}, deps
|
|
|
80
80
|
const spawn = deps.spawnKj || spawnKj;
|
|
81
81
|
const makeWizard = deps.makeWizard || (() => createWizard());
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// KJC-BUG-0145 (campo, #1471): loadConfig never sets projectDir — passing
|
|
84
|
+
// config.projectDir straight through handed `undefined` to the sweep, every
|
|
85
|
+
// collector failed silently and the project read as "new, no source code".
|
|
86
|
+
const projectDir = config.projectDir || process.cwd();
|
|
87
|
+
const bundle = await sweep(projectDir, { declared: flags.maturity || null });
|
|
84
88
|
const { text, summary } = assess(bundle);
|
|
85
89
|
let result = await decide({ task, text, config, logger, makeDecider });
|
|
86
90
|
|
|
@@ -111,7 +115,7 @@ export async function startCommand({ task = "", config, logger, flags = {}, deps
|
|
|
111
115
|
// Dispatch the chosen intent to its saved command, confirming first (it writes).
|
|
112
116
|
const toArgs = DISPATCH[result.intent];
|
|
113
117
|
if (interactive && toArgs && (await confirmApply(makeWizard))) {
|
|
114
|
-
await spawn(toArgs(goal), { cwd:
|
|
118
|
+
await spawn(toArgs(goal), { cwd: projectDir });
|
|
115
119
|
}
|
|
116
120
|
return { ok: true, intent: result.intent };
|
|
117
121
|
}
|
|
@@ -52,11 +52,49 @@ function baseBranchGuard(baseBranch) {
|
|
|
52
52
|
];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// IDN-C (KJC-TSK-0764, ADR 0005): identity lock at tier B — the declared clone
|
|
56
|
+
// identity (.karajan/identity.local.yml) governs authorship on commit and the
|
|
57
|
+
// gh session on push, on ANY host. Undeclared = advisory only (old clones keep
|
|
58
|
+
// working); the fail-closed default is the Sentinel's (IDN-B).
|
|
59
|
+
function identityGuard(hook) {
|
|
60
|
+
const head = [
|
|
61
|
+
"# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
|
|
62
|
+
'if [ "$KJ_ALLOW_IDENTITY" != "1" ] && [ -f .karajan/identity.local.yml ]; then',
|
|
63
|
+
];
|
|
64
|
+
const tail = [
|
|
65
|
+
"elif [ ! -f .karajan/identity.local.yml ]; then",
|
|
66
|
+
" echo 'kj harden: no identity declared for this clone — run kj identity set (advisory)'",
|
|
67
|
+
"fi",
|
|
68
|
+
];
|
|
69
|
+
if (hook === "pre-commit") {
|
|
70
|
+
return [
|
|
71
|
+
...head,
|
|
72
|
+
" kj_declared_email=$(sed -n 's/^git_email:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
|
|
73
|
+
" kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
74
|
+
" kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
75
|
+
' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
|
|
76
|
+
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show; KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
|
|
77
|
+
" fi",
|
|
78
|
+
...tail,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
return [
|
|
82
|
+
...head,
|
|
83
|
+
" kj_declared_gh=$(sed -n 's/^gh_user:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
|
|
84
|
+
' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
|
|
85
|
+
" kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
|
|
86
|
+
' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
|
|
87
|
+
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh (KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
|
|
88
|
+
" fi",
|
|
89
|
+
...tail,
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
|
|
56
94
|
const chain = chainToGlobal(hook, globalHooksDir);
|
|
57
95
|
switch (hook) {
|
|
58
96
|
case "pre-commit": {
|
|
59
|
-
const lines = [...baseBranchGuard(baseBranch), "# Lint + format the working tree with the project's native tools."];
|
|
97
|
+
const lines = [...baseBranchGuard(baseBranch), ...identityGuard("pre-commit"), "# Lint + format the working tree with the project's native tools."];
|
|
60
98
|
if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
|
|
61
99
|
if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
|
|
62
100
|
if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
|
|
@@ -93,6 +131,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
93
131
|
" echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
|
|
94
132
|
"fi",
|
|
95
133
|
'echo "kj harden: pushing as $(git config user.name) <$(git config user.email)>"',
|
|
134
|
+
...identityGuard("pre-push"),
|
|
96
135
|
"# Run the test suite before pushing.",
|
|
97
136
|
];
|
|
98
137
|
if (cmds.test) lines.push(`${cmds.test} || { echo 'kj harden: tests failed'; exit 1; }`);
|
|
@@ -66,8 +66,12 @@ export const foreignLane = (targetDir) => {
|
|
|
66
66
|
const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
|
|
67
67
|
return common && common === _home.common ? top : null;
|
|
68
68
|
};
|
|
69
|
+
// KJC-TSK-0765 board-sync: cards merged in this session but not yet moved in
|
|
70
|
+
// the tracker. The method does not advance until the board is true.
|
|
71
|
+
export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
|
|
72
|
+
export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
|
|
69
73
|
export const violations = (s, branch) => {
|
|
70
|
-
const v =
|
|
74
|
+
const v = pendingMoves(s).map(pendingText);
|
|
71
75
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
72
76
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
73
77
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
@@ -89,13 +93,62 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
89
93
|
// Records deterministic method facts per session; never blocks, never fails
|
|
90
94
|
// a tool call (PostToolUse, always exit 0).
|
|
91
95
|
import { relative } from "node:path";
|
|
92
|
-
import {
|
|
93
|
-
|
|
96
|
+
import { spawnSync } from "node:child_process";
|
|
97
|
+
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
98
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD"];
|
|
94
99
|
let raw = "";
|
|
95
100
|
process.stdin.on("data", (d) => { raw += d; });
|
|
96
101
|
process.stdin.on("end", () => {
|
|
97
102
|
try {
|
|
98
|
-
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
103
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
|
|
104
|
+
// KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
|
|
105
|
+
// in the tracker (the PreToolUse gate then refuses to advance until it is
|
|
106
|
+
// moved). Recorded here, after the fact, so a failed merge never blocks.
|
|
107
|
+
const text = typeof response === "string" ? response : JSON.stringify(response);
|
|
108
|
+
// Closing states that count as "registered in the tracker" (PG statuses and
|
|
109
|
+
// HU Board moves). The pending entry is cleared by the REAL tool call that
|
|
110
|
+
// moved the card — never by a promise in the agent's prose.
|
|
111
|
+
const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
|
|
112
|
+
const clearPending = (cardId) => {
|
|
113
|
+
const state = load();
|
|
114
|
+
const s = session(state, sid);
|
|
115
|
+
const before = (s.pending_moves || []).length;
|
|
116
|
+
s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
|
|
117
|
+
if (s.pending_moves.length !== before) save(state);
|
|
118
|
+
};
|
|
119
|
+
if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
120
|
+
// The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
|
|
121
|
+
// stringified, the inner quotes are ESCAPED — match both forms (found live:
|
|
122
|
+
// the first real update_card did not clear its pending entry).
|
|
123
|
+
const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
|
|
124
|
+
const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
|
|
125
|
+
if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
if (tool === "Bash") {
|
|
129
|
+
const cmdText = String(input.command || "");
|
|
130
|
+
// gh prints "merged pull request #N" only on a TTY: under a tool call the
|
|
131
|
+
// success line is ABSENT (found live on the first dogfood merge). The
|
|
132
|
+
// authoritative signal is the PR state itself: one gh pr view per merge.
|
|
133
|
+
const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
|
|
134
|
+
const said = /merged pull request #(\\d+)/i.exec(text);
|
|
135
|
+
let mergedPr = said ? Number(said[1]) : null;
|
|
136
|
+
if (mergedPr === null && mergeCmd) {
|
|
137
|
+
const args = ["pr", "view", ...(mergeCmd[2] ? [mergeCmd[2]] : []), "--json", "number,state"];
|
|
138
|
+
const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
|
|
139
|
+
try { const v = JSON.parse(r.status === 0 ? r.stdout : "null"); if (v && v.state === "MERGED") mergedPr = Number(v.number); } catch { /* unknown = not confirmed */ }
|
|
140
|
+
}
|
|
141
|
+
if (mergedPr !== null) {
|
|
142
|
+
const state = load();
|
|
143
|
+
const s = session(state, sid);
|
|
144
|
+
const ref = CARD.exec(branchOf() || "");
|
|
145
|
+
(s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now() });
|
|
146
|
+
save(state);
|
|
147
|
+
}
|
|
148
|
+
const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
|
|
149
|
+
if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail/i.test(text)) clearPending(moved[1].toUpperCase());
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
99
152
|
const file = input.file_path || input.notebook_path;
|
|
100
153
|
if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
|
|
101
154
|
const state = load();
|
|
@@ -194,10 +247,11 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
194
247
|
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
195
248
|
// remediation); read-only tools are never wired here; every honored escape
|
|
196
249
|
// is recorded as an auditable event. Fails OPEN on anything unexpected.
|
|
197
|
-
import { dirname, relative, resolve } from "node:path";
|
|
198
|
-
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
250
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
251
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
199
252
|
import { spawnSync } from "node:child_process";
|
|
200
|
-
import {
|
|
253
|
+
import { homedir } from "node:os";
|
|
254
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
201
255
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
202
256
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
203
257
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -265,6 +319,108 @@ process.stdin.on("end", () => {
|
|
|
265
319
|
}
|
|
266
320
|
return true;
|
|
267
321
|
};
|
|
322
|
+
// IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
|
|
323
|
+
// run under an account other than the one THIS CLONE declared (the incident:
|
|
324
|
+
// a gh comment without switch went out as the client account). Deny BEFORE
|
|
325
|
+
// the damage with the literal remedy; never auto-switch; undeclared = closed.
|
|
326
|
+
if (tool === "Bash" && CMD_TEXT.length > 0) {
|
|
327
|
+
const idFile = join(ROOT, ".karajan", "identity.local.yml");
|
|
328
|
+
const idLine = (key) => {
|
|
329
|
+
if (!existsSync(idFile)) return null;
|
|
330
|
+
const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
|
|
331
|
+
const v = line ? line.slice(key.length + 1).trim() : "";
|
|
332
|
+
return v.length > 0 ? v : null;
|
|
333
|
+
};
|
|
334
|
+
const declaredGh = idLine("gh_user");
|
|
335
|
+
const declaredEmail = idLine("git_email");
|
|
336
|
+
const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
|
|
337
|
+
const activeGh = () => {
|
|
338
|
+
if (!existsSync(ghHostsPath)) return null;
|
|
339
|
+
const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
|
|
340
|
+
const start = lines.findIndex((l) => l.startsWith("github.com:"));
|
|
341
|
+
if (start < 0) return null;
|
|
342
|
+
for (let k = start + 1; k < lines.length; k += 1) {
|
|
343
|
+
if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
|
|
344
|
+
const t = lines[k].trim();
|
|
345
|
+
if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
};
|
|
349
|
+
const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
|
|
350
|
+
const headOf = (ws) => { let k = 0; while (k < ws.length && ws[k].indexOf("=") > 0 && ws[k].indexOf("=") < ws[k].length) k += 1; return ws.slice(k); };
|
|
351
|
+
// Wrapper shells hide the payload in a string (review catch): scan inside.
|
|
352
|
+
const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
|
|
353
|
+
let switchedTo = null;
|
|
354
|
+
const problems = [];
|
|
355
|
+
// $( and backticks are separators too (review catch): their payload is its own segment.
|
|
356
|
+
const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
|
|
357
|
+
const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
|
|
358
|
+
const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth, words(seg)); };
|
|
359
|
+
const checkSeg = (ws, depth, raw) => {
|
|
360
|
+
if (ws.length === 0) return;
|
|
361
|
+
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/git/wrapper token (review catch)
|
|
362
|
+
const j = ws.findIndex((w, i) => i > 0 && ["gh", "git", ...WRAPPERS].includes(bare(w)));
|
|
363
|
+
if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (ws[0] === "gh") {
|
|
367
|
+
if (ws[1] === "auth") {
|
|
368
|
+
if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
|
|
369
|
+
return; // session commands ARE the remedy — never blocked
|
|
370
|
+
}
|
|
371
|
+
if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
|
|
372
|
+
const eff = switchedTo || activeGh();
|
|
373
|
+
if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (ws[0] === "git") {
|
|
377
|
+
// Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
|
|
378
|
+
// are forwarded verbatim to git var, so git resolves identity exactly as the real
|
|
379
|
+
// command would (review catches: -C cumulative, --git-dir, --opt=value forms).
|
|
380
|
+
const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
|
|
381
|
+
let k = 1;
|
|
382
|
+
while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
|
|
383
|
+
const globals = ws.slice(1, k).map(bare);
|
|
384
|
+
const sub = ws[k];
|
|
385
|
+
if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
|
|
386
|
+
if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
|
|
387
|
+
const eff = switchedTo || activeGh();
|
|
388
|
+
if (eff !== declaredGh) problems.push("git push with gh session '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix: gh auth switch --user " + declaredGh + " && ...");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
|
|
392
|
+
if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
|
|
393
|
+
if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
|
|
394
|
+
// Let GIT resolve author and committer under the command's own environment
|
|
395
|
+
// (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
|
|
396
|
+
// its business): git var is the single source of truth.
|
|
397
|
+
const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
|
|
398
|
+
const env = Object.assign({}, process.env);
|
|
399
|
+
for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
|
|
400
|
+
const ident = (v) => {
|
|
401
|
+
const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
|
|
402
|
+
const s = r.status === 0 ? String(r.stdout) : "";
|
|
403
|
+
const a = s.indexOf("<");
|
|
404
|
+
const b = s.indexOf(">");
|
|
405
|
+
return a >= 0 && b > a ? s.slice(a + 1, b) : null;
|
|
406
|
+
};
|
|
407
|
+
const ai = ws.findIndex((w) => w.startsWith("--author"));
|
|
408
|
+
const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
|
|
409
|
+
const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
|
|
410
|
+
const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
|
|
411
|
+
const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
|
|
412
|
+
if (offender !== null) problems.push("git " + sub + " as '" + offender + "' but this clone is declared as '" + declaredEmail + "' — use: git -c user.email=" + declaredEmail + " " + sub + " ... (or git config user.email " + declaredEmail + ")");
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
scan(CMD_TEXT, 0);
|
|
416
|
+
if (problems.length > 0) {
|
|
417
|
+
if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
|
|
418
|
+
else {
|
|
419
|
+
console.error("kj sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)");
|
|
420
|
+
process.exit(2);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
268
424
|
// MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
|
|
269
425
|
// otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
|
|
270
426
|
const laneOf = (p) => {
|
|
@@ -475,6 +631,23 @@ process.stdin.on("end", () => {
|
|
|
475
631
|
}
|
|
476
632
|
if (tool === "Bash") {
|
|
477
633
|
const cmd = String(input.command || "");
|
|
634
|
+
// KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
|
|
635
|
+
// and nothing advances (commit, new PR, another merge; push and the end of
|
|
636
|
+
// the turn go through violations()) until the card is moved. The board is
|
|
637
|
+
// true at all times or the method stops — never a rule in the agent's memory.
|
|
638
|
+
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
639
|
+
const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
|
|
640
|
+
const mergeM = cmd.match(MERGE);
|
|
641
|
+
const pend = pendingMoves(load().sessions?.[sid]);
|
|
642
|
+
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
643
|
+
if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
|
|
644
|
+
else {
|
|
645
|
+
console.error("kj sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + pend.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)");
|
|
646
|
+
process.exit(2);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
650
|
+
// gh confirms "merged pull request #N" — a failed merge never blocks)
|
|
478
651
|
if (PUBLISH.test(cmd)) {
|
|
479
652
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
480
653
|
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
@@ -536,6 +709,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
536
709
|
{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
|
|
537
710
|
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
|
|
538
711
|
{ event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
|
|
712
|
+
// KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
|
|
713
|
+
// update_card) are observed AFTER the fact — found live: with only the
|
|
714
|
+
// edit matchers wired, the board-sync recorder never ran in production.
|
|
715
|
+
{ event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
|
|
716
|
+
{ event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
|
|
539
717
|
{ event: "Stop", script: "stop.mjs" },
|
|
540
718
|
],
|
|
541
719
|
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity lock — bootstrap (IDN-A, KJC-TSK-0762). Runs at the end of
|
|
3
|
+
* `kj harden` (and therefore `kj env install`): if this clone has no declared
|
|
4
|
+
* identity, capture it — but ONLY with a human confirming on a TTY. Headless
|
|
5
|
+
* runs never bind blindly (the first real `set --yes` bound a clone to an
|
|
6
|
+
* account another session had switched to); they report the pending step
|
|
7
|
+
* with the exact command instead. Fail-loud, never a guess.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readIdentity, writeIdentity } from "./store.js";
|
|
11
|
+
import { activeGhUser, effectiveGitEmail } from "./detect.js";
|
|
12
|
+
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{projectDir: string, logger?: object, deps?: object}} args
|
|
16
|
+
* @returns {Promise<{declared: boolean, identity: object|null, pending: string|null}>}
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
|
|
19
|
+
const existing = readIdentity(projectDir);
|
|
20
|
+
if (existing) return { declared: true, identity: existing, pending: null };
|
|
21
|
+
|
|
22
|
+
const gh = activeGhUser(deps.hostsPath ? { hostsPath: deps.hostsPath } : {});
|
|
23
|
+
const email = effectiveGitEmail(projectDir, deps.gitFn ? { gitFn: deps.gitFn } : {});
|
|
24
|
+
const hint = `kj identity set --gh ${gh ?? "<user>"} --email ${email ?? "<email>"}`;
|
|
25
|
+
const tty = (deps.isTTY || isTTY)();
|
|
26
|
+
|
|
27
|
+
if (!tty || !gh || !email) {
|
|
28
|
+
const why = !gh || !email ? "no active gh session or git email" : "non-interactive run";
|
|
29
|
+
const pending = `identity not declared for this clone (${why}) — run: ${hint}`;
|
|
30
|
+
logger?.warn?.(`kj identity: ${pending}`);
|
|
31
|
+
return { declared: false, identity: null, pending };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const wizard = (deps.makeWizard || createWizard)();
|
|
35
|
+
try {
|
|
36
|
+
const ok = await wizard.confirm(`Bind this clone to gh ${gh} / git ${email}? (other sessions may switch gh accounts — verify)`, true);
|
|
37
|
+
if (!ok) {
|
|
38
|
+
const pending = `identity not declared (declined) — run: ${hint}`;
|
|
39
|
+
logger?.warn?.(`kj identity: ${pending}`);
|
|
40
|
+
return { declared: false, identity: null, pending };
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
wizard.close();
|
|
44
|
+
}
|
|
45
|
+
const identity = writeIdentity(projectDir, { gh_user: gh, git_email: email });
|
|
46
|
+
logger?.info?.(`kj identity: declared gh ${gh} / git ${email} → .karajan/identity.local.yml (ignored by git)`);
|
|
47
|
+
return { declared: true, identity, pending: null };
|
|
48
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity lock — compare (IDN-A, KJC-TSK-0762). Pure: declared vs effective
|
|
3
|
+
* → {ok, mismatches}. Every mismatch carries the LITERAL remedy, because the
|
|
4
|
+
* gate (IDN-B) and the hooks (IDN-C) print it instead of auto-switching —
|
|
5
|
+
* changing the user's keyring is the user's act, never the agent's.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{gh_user: string, git_email: string}|null} declared
|
|
10
|
+
* @param {{gh_user: string|null, git_email: string|null}} effective
|
|
11
|
+
* @returns {{ok: boolean, mismatches: Array<{field: string, declared: string|null, effective: string|null, remedy: string}>}}
|
|
12
|
+
*/
|
|
13
|
+
export function compareIdentity(declared, effective) {
|
|
14
|
+
if (!declared) {
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
mismatches: [{ field: "identity", declared: null, effective: null, remedy: "kj identity set — this clone has no declared identity yet" }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const mismatches = [];
|
|
21
|
+
if (effective.gh_user !== declared.gh_user) {
|
|
22
|
+
mismatches.push({
|
|
23
|
+
field: "gh_user",
|
|
24
|
+
declared: declared.gh_user,
|
|
25
|
+
effective: effective.gh_user ?? null,
|
|
26
|
+
remedy: effective.gh_user ? `gh auth switch --user ${declared.gh_user}` : `gh auth login (as ${declared.gh_user}) or gh auth switch --user ${declared.gh_user}`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (effective.git_email !== declared.git_email) {
|
|
30
|
+
mismatches.push({
|
|
31
|
+
field: "git_email",
|
|
32
|
+
declared: declared.git_email,
|
|
33
|
+
effective: effective.git_email ?? null,
|
|
34
|
+
remedy: `git config user.email ${declared.git_email} (or -c user.email=${declared.git_email} on the command)`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { ok: mismatches.length === 0, mismatches };
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity lock — detect (IDN-A, KJC-TSK-0762). What is EFFECTIVE right now,
|
|
3
|
+
* with no network and no spawned `gh`: the active gh account is read from
|
|
4
|
+
* gh's own hosts.yml (the file `gh auth switch` mutates), the git email from
|
|
5
|
+
* `git config user.email`. Both return null when unknown — never a guess.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import yaml from "js-yaml";
|
|
13
|
+
|
|
14
|
+
export function defaultHostsPath() {
|
|
15
|
+
const base = process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh");
|
|
16
|
+
return join(base, "hosts.yml");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{hostsPath?: string, host?: string}} [opts]
|
|
21
|
+
* @returns {string|null} active gh login for the host, null without a session
|
|
22
|
+
*/
|
|
23
|
+
export function activeGhUser({ hostsPath = defaultHostsPath(), host = "github.com" } = {}) {
|
|
24
|
+
if (!existsSync(hostsPath)) return null;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = yaml.load(readFileSync(hostsPath, "utf8"));
|
|
27
|
+
const user = parsed?.[host]?.user;
|
|
28
|
+
return typeof user === "string" && user.length > 0 ? user : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defaultGitFn = (projectDir) =>
|
|
35
|
+
execFileSync("git", ["config", "--get", "user.email"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} projectDir
|
|
39
|
+
* @param {{gitFn?: (projectDir: string) => string}} [opts]
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
export function effectiveGitEmail(projectDir, { gitFn = defaultGitFn } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
// --get yields the single effective value; keep the last line anyway so a
|
|
45
|
+
// multi-line answer from an odd config can never leak a newline into it.
|
|
46
|
+
const out = String(gitFn(projectDir) || "").trim().split("\n").at(-1).trim();
|
|
47
|
+
return out.length > 0 ? out : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity lock — store (IDN-A, KJC-TSK-0762, epic KJC-PCS-0079).
|
|
3
|
+
*
|
|
4
|
+
* Each CLONE declares the identity it is worked with: the gh account and the
|
|
5
|
+
* git email. It is per developer, not per project, so it lives in
|
|
6
|
+
* `.karajan/identity.local.yml`, never tracked (this module keeps it in the
|
|
7
|
+
* .gitignore). Born from the 2026-08-20 incident: one `gh` call without an
|
|
8
|
+
* explicit account switch posted as a client account on a public repo.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
export const IDENTITY_FILE = ".karajan/identity.local.yml";
|
|
16
|
+
|
|
17
|
+
/** @param {string} projectDir */
|
|
18
|
+
export function identityPath(projectDir) {
|
|
19
|
+
return join(projectDir, ".karajan", "identity.local.yml");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} projectDir
|
|
24
|
+
* @returns {{gh_user: string, git_email: string, declared_at: string}|null}
|
|
25
|
+
*/
|
|
26
|
+
export function readIdentity(projectDir) {
|
|
27
|
+
const p = identityPath(projectDir);
|
|
28
|
+
if (!existsSync(p)) return null;
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = yaml.load(readFileSync(p, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return null; // corrupt or half-written file = no identity (review catch)
|
|
34
|
+
}
|
|
35
|
+
if (!parsed || typeof parsed !== "object" || !parsed.gh_user || !parsed.git_email) return null;
|
|
36
|
+
return { gh_user: String(parsed.gh_user), git_email: String(parsed.git_email), declared_at: String(parsed.declared_at || "") };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write the declaration and make sure it never gets tracked. Fails loud on
|
|
41
|
+
* an incomplete identity — a half-declared lock is no lock.
|
|
42
|
+
* @param {string} projectDir
|
|
43
|
+
* @param {{gh_user: string, git_email: string}} identity
|
|
44
|
+
*/
|
|
45
|
+
export function writeIdentity(projectDir, identity) {
|
|
46
|
+
for (const field of ["gh_user", "git_email"]) {
|
|
47
|
+
if (!identity?.[field] || typeof identity[field] !== "string") {
|
|
48
|
+
throw new Error(`identity: "${field}" is required (got ${JSON.stringify(identity?.[field])})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Ignore FIRST, write SECOND (review catch): if the ignore step fails, no
|
|
52
|
+
// unignored identity file is ever left on disk for git to pick up.
|
|
53
|
+
ensureIgnored(projectDir);
|
|
54
|
+
mkdirSync(join(projectDir, ".karajan"), { recursive: true });
|
|
55
|
+
const record = { gh_user: identity.gh_user, git_email: identity.git_email, declared_at: new Date().toISOString() };
|
|
56
|
+
writeFileSync(identityPath(projectDir), yaml.dump(record, { lineWidth: 120 }), "utf8");
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureIgnored(projectDir) {
|
|
61
|
+
const ignorePath = join(projectDir, ".gitignore");
|
|
62
|
+
const current = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
63
|
+
if (current.split("\n").some((line) => line.trim() === IDENTITY_FILE)) return;
|
|
64
|
+
const sep = current.length === 0 || current.endsWith("\n") ? "" : "\n";
|
|
65
|
+
writeFileSync(ignorePath, `${current}${sep}${IDENTITY_FILE}\n`, "utf8");
|
|
66
|
+
}
|
package/src/start/sweep.js
CHANGED
|
@@ -112,6 +112,9 @@ function readRagStatus(projectDir) {
|
|
|
112
112
|
* declared = user-declared maturity; deps = injected collaborators (defaults = real collectors).
|
|
113
113
|
*/
|
|
114
114
|
export async function runReadOnlySweep(projectDir, { declared = null, profile = "standard", deps = {} } = {}) {
|
|
115
|
+
// KJC-BUG-0145: a missing projectDir must fail LOUD — every collector is
|
|
116
|
+
// wrapped in safe(), so an undefined root used to read as an empty project.
|
|
117
|
+
if (!projectDir) throw new Error("runReadOnlySweep: projectDir is required (got " + String(projectDir) + ")");
|
|
115
118
|
const d = {
|
|
116
119
|
collectAll, detectTestFramework, checkHarden, compareHarden, sourceScan: deepSourceScan,
|
|
117
120
|
detectQmd, ragStatus: readRagStatus, gitAgeDays: lastCommitAgeDays, ...deps,
|