karajan-code 4.20.1 → 4.22.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/README.md +5 -3
- package/package.json +4 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +81 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +2 -2
- package/src/cli/register-meta.js +70 -5
- package/src/commands/claims.js +41 -0
- package/src/commands/harden.js +8 -0
- package/src/commands/identity.js +60 -0
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/review-gate.js +4 -2
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/hook-templates.js +40 -1
- package/src/harden/sentinel-hooks.js +267 -15
- 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/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/unparseable-verdict.js +50 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate class members (KJC-TSK-0796) — a bug JavaScript refuses to report.
|
|
3
|
+
*
|
|
4
|
+
* Declaring the same member twice in a class is legal: the LAST one wins and the
|
|
5
|
+
* first disappears in silence. No runtime error, no lint warning, no compiler
|
|
6
|
+
* complaint. That is how a `updated()` that loaded the data was lost in GREBLA
|
|
7
|
+
* and a tab stayed empty in production for 17 days.
|
|
8
|
+
*
|
|
9
|
+
* What matters is not that there is a duplicate — it is WHAT WAS LOST: the body
|
|
10
|
+
* that will never run. That is what the finding says.
|
|
11
|
+
*
|
|
12
|
+
* Honest about its limits (the fourth verdict): a file this guard could not read,
|
|
13
|
+
* or one whose member names are computed at runtime, is reported as NOT OBSERVABLE
|
|
14
|
+
* — never as clean.
|
|
15
|
+
*/
|
|
16
|
+
import { parse } from "@babel/parser";
|
|
17
|
+
|
|
18
|
+
const plugins = (file) => [
|
|
19
|
+
...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
|
|
20
|
+
...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
|
|
21
|
+
"decorators",
|
|
22
|
+
];
|
|
23
|
+
const isClass = (node) => node?.type === "ClassDeclaration" || node?.type === "ClassExpression";
|
|
24
|
+
|
|
25
|
+
/** Walks every node of the tree; enough for a syntax-only guard (no scope analysis). */
|
|
26
|
+
function walk(node, visit) {
|
|
27
|
+
if (!node || typeof node.type !== "string") return;
|
|
28
|
+
visit(node);
|
|
29
|
+
for (const key of Object.keys(node)) {
|
|
30
|
+
const child = node[key];
|
|
31
|
+
if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
|
|
32
|
+
else if (child && typeof child.type === "string") walk(child, visit);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const memberName = (member) => {
|
|
37
|
+
if (member.computed) return null; // `[expr]() {}` — the name is not known until it runs
|
|
38
|
+
const key = member.key ?? member.id;
|
|
39
|
+
if (key?.type === "Identifier") return key.name;
|
|
40
|
+
if (key?.type === "PrivateName") return `#${key.id.name}`;
|
|
41
|
+
if (key?.type === "StringLiteral" || key?.type === "NumericLiteral") return String(key.value);
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} source
|
|
47
|
+
* @param {{file?: string}} [where]
|
|
48
|
+
* @returns {{findings: Array<object>, notObservable: {file: string, reason: string}|null}}
|
|
49
|
+
* Each finding: {file, className, member, lostLine, winnerLine, message}.
|
|
50
|
+
*/
|
|
51
|
+
export function findDuplicateMembers(source, { file = "<source>" } = {}) {
|
|
52
|
+
let tree;
|
|
53
|
+
try {
|
|
54
|
+
tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { findings: [], notObservable: { file, reason: `could not be parsed (${err.message}) — not read as clean` } };
|
|
57
|
+
}
|
|
58
|
+
const findings = [];
|
|
59
|
+
let computed = 0;
|
|
60
|
+
walk(tree, (node) => {
|
|
61
|
+
if (!isClass(node)) return;
|
|
62
|
+
const className = node.id?.name ?? "(anonymous)";
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
for (const member of node.body.body) {
|
|
65
|
+
// get/set of the same name are legal, and a static member is a different slot from an
|
|
66
|
+
// instance one — treating either as a duplicate would be the false positive that gets a
|
|
67
|
+
// guard switched off (the credibility rule of KJC-PCS-0082).
|
|
68
|
+
// A TypeScript overload signature (TSDeclareMethod) declares the same name on purpose and
|
|
69
|
+
// carries no body: counting it would flag every overloaded method in the codebase.
|
|
70
|
+
if (member.type === "StaticBlock" || member.type === "TSDeclareMethod" || member.type === "TSIndexSignature" || member.kind === "get" || member.kind === "set") continue;
|
|
71
|
+
const name = memberName(member);
|
|
72
|
+
if (name === null) { computed += 1; continue; }
|
|
73
|
+
const key = `${member.static ? "static " : ""}${name}`;
|
|
74
|
+
const line = member.loc.start.line;
|
|
75
|
+
const first = seen.get(key);
|
|
76
|
+
if (first === undefined) { seen.set(key, line); continue; }
|
|
77
|
+
findings.push({
|
|
78
|
+
file, className, member: key, lostLine: first, winnerLine: line,
|
|
79
|
+
message: `${file}:${line} — ${className}: the ${key} of line ${first} never runs, the one of line ${line} replaces it`,
|
|
80
|
+
});
|
|
81
|
+
seen.set(key, line); // a third copy is reported against the second, not against the first
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const notObservable = computed > 0 && findings.length === 0 ? { file, reason: `${computed} member name(s) computed at runtime — this file cannot be fully checked` } : null;
|
|
85
|
+
return { findings, notObservable };
|
|
86
|
+
}
|
|
@@ -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; }`);
|
|
@@ -29,7 +29,7 @@ const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj h
|
|
|
29
29
|
// Single source for every sentinel script: state, branch, classification,
|
|
30
30
|
// violations, and escape recording.
|
|
31
31
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { execSync } from "node:child_process";
|
|
32
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
33
33
|
import { dirname, join } from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -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)");
|
|
@@ -81,6 +85,18 @@ export const recordEscape = (sid, escape, tool) => {
|
|
|
81
85
|
if (!s.escapes.includes(escape)) s.escapes.push(escape);
|
|
82
86
|
(state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
|
|
83
87
|
save(state);
|
|
88
|
+
// GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
|
|
89
|
+
// decision log hash-encadenado (kj policy seal), no solo en este estado.
|
|
90
|
+
// Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
|
|
91
|
+
// kj no carga, que es cuando mas se usa el escape (restaurar con git).
|
|
92
|
+
try {
|
|
93
|
+
const args = ["policy", "seal", "--escape", escape];
|
|
94
|
+
if (tool) args.push("--tool", String(tool));
|
|
95
|
+
const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
|
|
96
|
+
if (r.error || r.status !== 0) console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json");
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message);
|
|
99
|
+
}
|
|
84
100
|
};
|
|
85
101
|
`;
|
|
86
102
|
|
|
@@ -89,13 +105,74 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
89
105
|
// Records deterministic method facts per session; never blocks, never fails
|
|
90
106
|
// a tool call (PostToolUse, always exit 0).
|
|
91
107
|
import { relative } from "node:path";
|
|
92
|
-
import {
|
|
93
|
-
|
|
108
|
+
import { spawnSync } from "node:child_process";
|
|
109
|
+
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
110
|
+
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
111
|
let raw = "";
|
|
95
112
|
process.stdin.on("data", (d) => { raw += d; });
|
|
96
113
|
process.stdin.on("end", () => {
|
|
97
114
|
try {
|
|
98
|
-
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
115
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
|
|
116
|
+
// KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
|
|
117
|
+
// in the tracker (the PreToolUse gate then refuses to advance until it is
|
|
118
|
+
// moved). Recorded here, after the fact, so a failed merge never blocks.
|
|
119
|
+
const text = typeof response === "string" ? response : JSON.stringify(response);
|
|
120
|
+
// Closing states that count as "registered in the tracker" (PG statuses and
|
|
121
|
+
// HU Board moves). The pending entry is cleared by the REAL tool call that
|
|
122
|
+
// moved the card — never by a promise in the agent's prose.
|
|
123
|
+
const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
|
|
124
|
+
const clearPending = (cardId) => {
|
|
125
|
+
const state = load();
|
|
126
|
+
const s = session(state, sid);
|
|
127
|
+
s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
|
|
128
|
+
// KJC-TSK-0774: a card closed this turn is what the Stop hook points the
|
|
129
|
+
// user at when the focus comes back (the board is where to look).
|
|
130
|
+
if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
|
|
131
|
+
save(state);
|
|
132
|
+
};
|
|
133
|
+
if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
134
|
+
// The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
|
|
135
|
+
// stringified, the inner quotes are ESCAPED — match both forms (found live:
|
|
136
|
+
// the first real update_card did not clear its pending entry).
|
|
137
|
+
const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
|
|
138
|
+
const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
|
|
139
|
+
if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
if (tool === "Bash") {
|
|
143
|
+
const cmdText = String(input.command || "");
|
|
144
|
+
// gh prints "merged pull request #N" only on a TTY: under a tool call the
|
|
145
|
+
// success line is ABSENT (found live on the first dogfood merge). The
|
|
146
|
+
// authoritative signal is the PR state itself: one gh pr view per merge.
|
|
147
|
+
const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
|
|
148
|
+
const said = /merged pull request #(\\d+)/i.exec(text);
|
|
149
|
+
let mergedPr = said ? Number(said[1]) : null;
|
|
150
|
+
// KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
|
|
151
|
+
// branch the working tree happens to be on (found live: a merge issued
|
|
152
|
+
// from another card's lane pinned the pending move on the wrong card).
|
|
153
|
+
let headRef = null;
|
|
154
|
+
if (mergeCmd || said) {
|
|
155
|
+
const n = said ? said[1] : mergeCmd[2];
|
|
156
|
+
const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
|
|
157
|
+
const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
|
|
158
|
+
try {
|
|
159
|
+
const v = JSON.parse(r.status === 0 ? r.stdout : "null");
|
|
160
|
+
if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
|
|
161
|
+
} catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
|
|
162
|
+
}
|
|
163
|
+
if (mergedPr !== null) {
|
|
164
|
+
const state = load();
|
|
165
|
+
const s = session(state, sid);
|
|
166
|
+
const ref = CARD.exec(headRef || branchOf() || "");
|
|
167
|
+
(s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
|
|
168
|
+
save(state);
|
|
169
|
+
}
|
|
170
|
+
const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
|
|
171
|
+
// "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
|
|
172
|
+
// clear a pending — that would discard a LEGITIMATE one without touching the tracker.
|
|
173
|
+
if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
99
176
|
const file = input.file_path || input.notebook_path;
|
|
100
177
|
if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
|
|
101
178
|
const state = load();
|
|
@@ -124,7 +201,19 @@ const STOP_BODY = `#!/usr/bin/env node
|
|
|
124
201
|
// after 3 unresolved blocks — a sentinel bug never bricks the session — and
|
|
125
202
|
// the fail-open is recorded in the state. \`--status\` prints, never blocks.
|
|
126
203
|
import { spawnSync } from "node:child_process";
|
|
204
|
+
import { readFileSync } from "node:fs";
|
|
205
|
+
import { homedir } from "node:os";
|
|
206
|
+
import { join } from "node:path";
|
|
127
207
|
import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
208
|
+
// KJC-TSK-0774: when the turn ends green with cards closed, remind the user
|
|
209
|
+
// where to look — the board URL if it runs (pid file alive), else how to start it.
|
|
210
|
+
const boardHint = () => {
|
|
211
|
+
const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
|
|
212
|
+
try {
|
|
213
|
+
process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
|
|
214
|
+
return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
|
|
215
|
+
} catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
|
|
216
|
+
};
|
|
128
217
|
if (process.argv.includes("--status")) {
|
|
129
218
|
const st = load();
|
|
130
219
|
const branch = branchOf();
|
|
@@ -168,9 +257,14 @@ process.stdin.on("end", () => {
|
|
|
168
257
|
const v = violations(s, branchOf());
|
|
169
258
|
if (!v.length) {
|
|
170
259
|
s.blocks = 0;
|
|
260
|
+
const notes = [];
|
|
261
|
+
if ((s.escapes || []).length) notes.push("kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
262
|
+
if ((s.closed_cards || []).length) {
|
|
263
|
+
notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
264
|
+
s.closed_cards = [];
|
|
265
|
+
}
|
|
171
266
|
save(state);
|
|
172
|
-
if ((
|
|
173
|
-
console.log(JSON.stringify({ systemMessage: "kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status." }));
|
|
267
|
+
if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
|
|
174
268
|
process.exit(0);
|
|
175
269
|
}
|
|
176
270
|
s.blocks = (s.blocks || 0) + 1;
|
|
@@ -194,10 +288,11 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
194
288
|
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
195
289
|
// remediation); read-only tools are never wired here; every honored escape
|
|
196
290
|
// 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";
|
|
291
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
292
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
199
293
|
import { spawnSync } from "node:child_process";
|
|
200
|
-
import {
|
|
294
|
+
import { homedir } from "node:os";
|
|
295
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
201
296
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
202
297
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
203
298
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -259,12 +354,139 @@ process.stdin.on("end", () => {
|
|
|
259
354
|
if (tok === name + "=1") found = true;
|
|
260
355
|
}
|
|
261
356
|
if (!found) return false;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
357
|
+
// KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
|
|
358
|
+
// mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
|
|
359
|
+
// comillas dobles $ y backtick siguen expandiendo (se rechazan); en
|
|
360
|
+
// comillas simples todo es literal. Y si el escape esta pero se ignora,
|
|
361
|
+
// SE DICE: un escape ignorado en silencio era el bug.
|
|
362
|
+
const bad = escSimple();
|
|
363
|
+
if (bad === null) return true;
|
|
364
|
+
if (!escNoted.has(name)) {
|
|
365
|
+
escNoted.add(name);
|
|
366
|
+
console.error("kj sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)");
|
|
265
367
|
}
|
|
266
|
-
return
|
|
368
|
+
return false;
|
|
267
369
|
};
|
|
370
|
+
const escNoted = new Set();
|
|
371
|
+
const escSimple = () => {
|
|
372
|
+
let q = null;
|
|
373
|
+
for (let i = 0; i < CMD_TEXT.length; i += 1) {
|
|
374
|
+
const c = CMD_TEXT[i];
|
|
375
|
+
if (q === "'") { if (c === "'") q = null; continue; }
|
|
376
|
+
if (q === '"') {
|
|
377
|
+
if (c === '"') q = null;
|
|
378
|
+
else if (c === "$" || c === ESC_BT) return c;
|
|
379
|
+
else if (c === "\\\\") i += 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (c === "\\\\") { i += 1; continue; }
|
|
383
|
+
if (c === "'" || c === '"') { q = c; continue; }
|
|
384
|
+
if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
|
|
385
|
+
}
|
|
386
|
+
return q === null ? null : "comilla sin cerrar";
|
|
387
|
+
};
|
|
388
|
+
// IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
|
|
389
|
+
// run under an account other than the one THIS CLONE declared (the incident:
|
|
390
|
+
// a gh comment without switch went out as the client account). Deny BEFORE
|
|
391
|
+
// the damage with the literal remedy; never auto-switch; undeclared = closed.
|
|
392
|
+
if (tool === "Bash" && CMD_TEXT.length > 0) {
|
|
393
|
+
const idFile = join(ROOT, ".karajan", "identity.local.yml");
|
|
394
|
+
const idLine = (key) => {
|
|
395
|
+
if (!existsSync(idFile)) return null;
|
|
396
|
+
const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
|
|
397
|
+
const v = line ? line.slice(key.length + 1).trim() : "";
|
|
398
|
+
return v.length > 0 ? v : null;
|
|
399
|
+
};
|
|
400
|
+
const declaredGh = idLine("gh_user");
|
|
401
|
+
const declaredEmail = idLine("git_email");
|
|
402
|
+
const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
|
|
403
|
+
const activeGh = () => {
|
|
404
|
+
if (!existsSync(ghHostsPath)) return null;
|
|
405
|
+
const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
|
|
406
|
+
const start = lines.findIndex((l) => l.startsWith("github.com:"));
|
|
407
|
+
if (start < 0) return null;
|
|
408
|
+
for (let k = start + 1; k < lines.length; k += 1) {
|
|
409
|
+
if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
|
|
410
|
+
const t = lines[k].trim();
|
|
411
|
+
if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
};
|
|
415
|
+
const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
|
|
416
|
+
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); };
|
|
417
|
+
// Wrapper shells hide the payload in a string (review catch): scan inside.
|
|
418
|
+
const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
|
|
419
|
+
let switchedTo = null;
|
|
420
|
+
const problems = [];
|
|
421
|
+
// $( and backticks are separators too (review catch): their payload is its own segment.
|
|
422
|
+
const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
|
|
423
|
+
const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
|
|
424
|
+
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)); };
|
|
425
|
+
const checkSeg = (ws, depth, raw) => {
|
|
426
|
+
if (ws.length === 0) return;
|
|
427
|
+
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/git/wrapper token (review catch)
|
|
428
|
+
const j = ws.findIndex((w, i) => i > 0 && ["gh", "git", ...WRAPPERS].includes(bare(w)));
|
|
429
|
+
if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (ws[0] === "gh") {
|
|
433
|
+
if (ws[1] === "auth") {
|
|
434
|
+
if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
|
|
435
|
+
return; // session commands ARE the remedy — never blocked
|
|
436
|
+
}
|
|
437
|
+
if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
|
|
438
|
+
const eff = switchedTo || activeGh();
|
|
439
|
+
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 + " && ...");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (ws[0] === "git") {
|
|
443
|
+
// Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
|
|
444
|
+
// are forwarded verbatim to git var, so git resolves identity exactly as the real
|
|
445
|
+
// command would (review catches: -C cumulative, --git-dir, --opt=value forms).
|
|
446
|
+
const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
|
|
447
|
+
let k = 1;
|
|
448
|
+
while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
|
|
449
|
+
const globals = ws.slice(1, k).map(bare);
|
|
450
|
+
const sub = ws[k];
|
|
451
|
+
if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
|
|
452
|
+
if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
|
|
453
|
+
const eff = switchedTo || activeGh();
|
|
454
|
+
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 + " && ...");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
|
|
458
|
+
if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
|
|
459
|
+
if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
|
|
460
|
+
// Let GIT resolve author and committer under the command's own environment
|
|
461
|
+
// (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
|
|
462
|
+
// its business): git var is the single source of truth.
|
|
463
|
+
const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
|
|
464
|
+
const env = Object.assign({}, process.env);
|
|
465
|
+
for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
|
|
466
|
+
const ident = (v) => {
|
|
467
|
+
const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
|
|
468
|
+
const s = r.status === 0 ? String(r.stdout) : "";
|
|
469
|
+
const a = s.indexOf("<");
|
|
470
|
+
const b = s.indexOf(">");
|
|
471
|
+
return a >= 0 && b > a ? s.slice(a + 1, b) : null;
|
|
472
|
+
};
|
|
473
|
+
const ai = ws.findIndex((w) => w.startsWith("--author"));
|
|
474
|
+
const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
|
|
475
|
+
const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
|
|
476
|
+
const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
|
|
477
|
+
const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
|
|
478
|
+
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 + ")");
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
scan(CMD_TEXT, 0);
|
|
482
|
+
if (problems.length > 0) {
|
|
483
|
+
if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
|
|
484
|
+
else {
|
|
485
|
+
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)");
|
|
486
|
+
process.exit(2);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
268
490
|
// MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
|
|
269
491
|
// otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
|
|
270
492
|
const laneOf = (p) => {
|
|
@@ -475,6 +697,23 @@ process.stdin.on("end", () => {
|
|
|
475
697
|
}
|
|
476
698
|
if (tool === "Bash") {
|
|
477
699
|
const cmd = String(input.command || "");
|
|
700
|
+
// KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
|
|
701
|
+
// and nothing advances (commit, new PR, another merge; push and the end of
|
|
702
|
+
// the turn go through violations()) until the card is moved. The board is
|
|
703
|
+
// true at all times or the method stops — never a rule in the agent's memory.
|
|
704
|
+
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
705
|
+
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)
|
|
706
|
+
const mergeM = cmd.match(MERGE);
|
|
707
|
+
const pend = pendingMoves(load().sessions?.[sid]);
|
|
708
|
+
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
709
|
+
if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
|
|
710
|
+
else {
|
|
711
|
+
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)");
|
|
712
|
+
process.exit(2);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
716
|
+
// gh confirms "merged pull request #N" — a failed merge never blocks)
|
|
478
717
|
if (PUBLISH.test(cmd)) {
|
|
479
718
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
480
719
|
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
@@ -486,7 +725,15 @@ process.stdin.on("end", () => {
|
|
|
486
725
|
process.exit(2);
|
|
487
726
|
}
|
|
488
727
|
} else if (PUSH.test(cmd)) {
|
|
489
|
-
|
|
728
|
+
// KJC-BUG-0147: the board escape covers the push too — a pending move
|
|
729
|
+
// blocks the push like it blocks the commit, and the SAME conscious,
|
|
730
|
+
// recorded escape opens both (multi-PR cards are a legitimate plan).
|
|
731
|
+
const sess = load().sessions?.[sid];
|
|
732
|
+
let v = violations(sess, branchOf());
|
|
733
|
+
if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
|
|
734
|
+
recordEscape(sid, "KJ_ALLOW_BOARD", tool);
|
|
735
|
+
v = violations({ ...sess, pending_moves: [] }, branchOf());
|
|
736
|
+
}
|
|
490
737
|
if (v.length) {
|
|
491
738
|
console.error("kj sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status");
|
|
492
739
|
process.exit(2);
|
|
@@ -536,6 +783,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
536
783
|
{ event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
|
|
537
784
|
{ event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
|
|
538
785
|
{ event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
|
|
786
|
+
// KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
|
|
787
|
+
// update_card) are observed AFTER the fact — found live: with only the
|
|
788
|
+
// edit matchers wired, the board-sync recorder never ran in production.
|
|
789
|
+
{ event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
|
|
790
|
+
{ event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
|
|
539
791
|
{ event: "Stop", script: "stop.mjs" },
|
|
540
792
|
],
|
|
541
793
|
});
|
|
@@ -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
|
+
}
|