karajan-code 4.32.0 → 4.34.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/agents/aider-agent.js +2 -12
- package/src/agents/base-agent.js +9 -20
- package/src/agents/claude-agent.js +2 -12
- package/src/agents/codex-agent.js +2 -12
- package/src/agents/dead-models.js +74 -0
- package/src/agents/gemini-agent.js +2 -12
- package/src/agents/model-errors.js +35 -0
- package/src/agents/opencode-agent.js +2 -12
- package/src/brain/agent-error-classifier.js +19 -1
- package/src/brain/one-shot-policy.js +26 -0
- package/src/brain/role-fallback-chain.js +100 -0
- package/src/brain/with-brain-recovery.js +63 -4
- package/src/checks/action-pins.js +131 -0
- package/src/checks/project-checks.js +6 -0
- package/src/checks/release-check.js +51 -5
- package/src/cli/register-meta.js +42 -2
- package/src/cli/register-pipeline.js +11 -1
- package/src/commands/check.js +5 -0
- package/src/commands/code.js +59 -4
- package/src/commands/env.js +2 -1
- package/src/commands/harden.js +12 -3
- package/src/commands/policy.js +21 -14
- package/src/commands/report.js +28 -0
- package/src/commands/review-gate.js +25 -15
- package/src/environment/panel.js +85 -0
- package/src/environment/playbook.js +7 -6
- package/src/harden/config-templates.js +7 -1
- package/src/harden/guidelines-engine.js +14 -4
- package/src/harden/guidelines-templates.js +69 -10
- package/src/harden/harness-hooks.js +6 -0
- package/src/harden/hook-templates.js +4 -1
- package/src/harden/sentinel-hooks.js +227 -24
- package/src/harden/supervisor-commit.js +41 -1
- package/src/harden/workflow-templates.js +9 -3
- package/src/policy/supervisor-verify.js +45 -11
- package/src/privacy/diff-scope.js +58 -0
- package/src/privacy/scan.js +18 -0
- package/src/prompts/card-context.js +57 -0
- package/src/prompts/session-context.js +68 -0
- package/src/review/board-pending.js +68 -0
- package/src/review/loc-budget.js +97 -0
- package/src/roles/agent-role.js +11 -2
- package/src/roles/audit-role.js +28 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0870 — the maintenance that pinning moved onto kj.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1374 was right: `kj harden` generated workflows with moving tags
|
|
5
|
+
* (`actions/checkout@v4`), and kj's own `kj audit --security` flagged them, so
|
|
6
|
+
* five of the project's seven warnings came from files kj had just written.
|
|
7
|
+
* They are pinned to commit SHAs now.
|
|
8
|
+
*
|
|
9
|
+
* Pinning trades one risk for another: nobody can move the tag under you, and
|
|
10
|
+
* nobody delivers the upstream patch either. A pin that nobody revisits is a
|
|
11
|
+
* vulnerability with a long shelf life, and since kj writes those lines, kj is
|
|
12
|
+
* the one that has to notice. That is this check.
|
|
13
|
+
*
|
|
14
|
+
* It lives in `kj doctor` (a person asking about their environment) and NOT in
|
|
15
|
+
* `kj check` (a gate that CI runs): a gate that needs the network is a gate
|
|
16
|
+
* that fails on a plane, and turning red over an unreachable API is how a gate
|
|
17
|
+
* loses its credibility.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
|
|
23
|
+
import { PINNED_ACTIONS } from "../harden/workflow-templates.js";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
// Nobody waits for a diagnostic. Both clients are capped, and the pins are
|
|
27
|
+
// asked in parallel, so an unreachable GitHub costs one timeout and not one
|
|
28
|
+
// per action.
|
|
29
|
+
const CALL_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
const STRATEGY_MANUAL = "manual";
|
|
32
|
+
const API = "https://api.github.com/repos";
|
|
33
|
+
|
|
34
|
+
/** `owner/repo@sha # tag` as written in the templates. */
|
|
35
|
+
export function parsePin(pin) {
|
|
36
|
+
const m = /^([^@\s]+)@([0-9a-f]{40})\s*#\s*(\S+)$/i.exec(String(pin || "").trim());
|
|
37
|
+
return m ? { action: m[1], sha: m[2].toLowerCase(), tag: m[3] } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
41
|
+
const clean = (s) => String(s || "").trim().toLowerCase();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The SHA a tag points at today, asked through `gh` when it is there.
|
|
45
|
+
*
|
|
46
|
+
* Measured, not assumed: plain unauthenticated `fetch` answered 403 on the
|
|
47
|
+
* first real run, because GitHub allows 60 calls an hour PER IP and any shared
|
|
48
|
+
* or NAT-ed address burns that between everyone behind it. A check that
|
|
49
|
+
* answers "could not check" almost always is decorative. `gh` carries the
|
|
50
|
+
* user's own credentials (5000/hour) and kj already relies on it elsewhere;
|
|
51
|
+
* `fetch` stays as the fallback for whoever has no gh.
|
|
52
|
+
*
|
|
53
|
+
* @returns {Promise<string|null>} null when the answer cannot be trusted.
|
|
54
|
+
*/
|
|
55
|
+
export async function currentSha(action, tag, { fetchFn = fetch, ghFn = ghSha } = {}) {
|
|
56
|
+
const viaGh = await ghFn(action, tag);
|
|
57
|
+
if (viaGh) return viaGh;
|
|
58
|
+
const res = await fetchFn(`${API}/${action}/commits/${encodeURIComponent(tag)}`, {
|
|
59
|
+
headers: { Accept: "application/vnd.github.sha", "User-Agent": "karajan-code" },
|
|
60
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
if (!res?.ok) return null;
|
|
63
|
+
const body = clean(await res.text());
|
|
64
|
+
return SHA_RE.test(body) ? body : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {Promise<string|null>} null when gh is absent, unauthenticated or unhappy. */
|
|
68
|
+
async function ghSha(action, tag) {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await execFileAsync("gh", ["api", `repos/${action}/commits/${tag}`, "--jq", ".sha"], {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
timeout: CALL_TIMEOUT_MS,
|
|
73
|
+
});
|
|
74
|
+
const sha = clean(stdout);
|
|
75
|
+
return SHA_RE.test(sha) ? sha : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @internal Exported for dynamic import from tests. */
|
|
82
|
+
export function createActionPinsCheck({ pins = PINNED_ACTIONS, deps = {} } = {}) {
|
|
83
|
+
return {
|
|
84
|
+
name: "action-pins",
|
|
85
|
+
label: "actions fijadas por SHA",
|
|
86
|
+
strategy: STRATEGY_MANUAL,
|
|
87
|
+
describe: "Detect a pinned GitHub Action whose tag has moved on without it",
|
|
88
|
+
async detect() {
|
|
89
|
+
const entries = Object.entries(pins).map(([key, pin]) => ({ key, ...(parsePin(pin) || {}) }));
|
|
90
|
+
const malformed = entries.filter((e) => !e.sha);
|
|
91
|
+
const asked = await Promise.all(
|
|
92
|
+
entries.filter((x) => x.sha).map(async (e) => {
|
|
93
|
+
// offline, rate limited, DNS down, gh missing: not an answer
|
|
94
|
+
const latest = await currentSha(e.action, e.tag, deps).catch(() => null);
|
|
95
|
+
return { ...e, latest };
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const stale = asked.filter((e) => e.latest && e.latest !== e.sha);
|
|
99
|
+
const unchecked = asked.filter((e) => !e.latest).length;
|
|
100
|
+
|
|
101
|
+
if (malformed.length > 0) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
severity: "warn",
|
|
105
|
+
detail: `pin mal formado: ${malformed.map((m) => m.key).join(", ")} — un pin que no se puede leer no se puede comprobar`,
|
|
106
|
+
fix: "revisa PINNED_ACTIONS en src/harden/workflow-templates.js: owner/repo@<sha de 40> # <tag>",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (stale.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
severity: "warn",
|
|
113
|
+
detail: stale.map((s) => `${s.action} ${s.tag} apunta hoy a ${s.latest.slice(0, 12)} y kj fija ${s.sha.slice(0, 12)}`).join(" · "),
|
|
114
|
+
fix: stale.map((s) => `${s.action}@${s.latest} # ${s.tag}`).join(" · "),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Never claim what was not looked at (the card's own acceptance
|
|
118
|
+
// criterion): an unreachable API is said, not passed off as green.
|
|
119
|
+
if (unchecked > 0) {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
severity: "info",
|
|
123
|
+
detail: unchecked === entries.length
|
|
124
|
+
? "no se pudo comprobar ninguna action (¿sin red o con la API de GitHub limitando?)"
|
|
125
|
+
: `${unchecked} action(s) sin comprobar (¿sin red o con la API de GitHub limitando?)`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, severity: "info", detail: `${entries.length} action(s) fijadas y al día` };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -24,6 +24,7 @@ import { checkBinary } from "../utils/agent-detect.js";
|
|
|
24
24
|
import { getInstallCommand } from "../utils/os-detect.js";
|
|
25
25
|
import { STRATEGY } from "./types.js";
|
|
26
26
|
import { createRepoStateCheck } from "./repo-state.js";
|
|
27
|
+
import { createActionPinsCheck } from "./action-pins.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Detect signals del proyecto. Devuelve un set de tags con qué hay en el
|
|
@@ -246,5 +247,10 @@ export function getProjectChecks({ projectDir }) {
|
|
|
246
247
|
// check looks at — the order it was set up in, whether the contract
|
|
247
248
|
// travels, whether this clone declares who works it.
|
|
248
249
|
createRepoStateCheck({ projectDir }),
|
|
250
|
+
// KJC-TSK-0870: pinning the actions by SHA (issue #1374) moved their
|
|
251
|
+
// maintenance onto kj. Doctor asks upstream whether a tag has moved on
|
|
252
|
+
// without its pin; `kj check` deliberately does not, because a gate that
|
|
253
|
+
// needs the network is a gate that fails on a plane.
|
|
254
|
+
createActionPinsCheck(),
|
|
249
255
|
];
|
|
250
256
|
}
|
|
@@ -106,6 +106,42 @@ async function packPrivacyCheck(projectDir, pkg) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* KJC-BUG-0204 — an item may declare WHICH command repairs it (`remedied_by`),
|
|
111
|
+
* so the gate stops blocking the one action that would turn it green. It was
|
|
112
|
+
* the landing: the check demanded a deployed site and the guard blocked the
|
|
113
|
+
* deploy, leaving `KJ_ALLOW_RELEASE=1` — switching the gate off entirely — as
|
|
114
|
+
* the only way to reach the publish that came next.
|
|
115
|
+
*/
|
|
116
|
+
const remedy = (item) => (typeof item?.remedied_by === "string" && item.remedied_by.trim() ? { remediedBy: item.remedied_by.trim() } : {});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The words of a verb must appear in the command IN ORDER, as whole words:
|
|
120
|
+
* `firebase deploy` covers `firebase --account a@b --project p deploy --only x`,
|
|
121
|
+
* and `deploy firebase` is a different command. Separators (`;`, `|`, `&`,
|
|
122
|
+
* parentheses) split words, so `(npm publish)` is still a publication.
|
|
123
|
+
* A word is read by its basename, so `/usr/bin/npm publish` is the same
|
|
124
|
+
* publication as `npm publish` (review catch: an absolute path hid one).
|
|
125
|
+
*/
|
|
126
|
+
const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
|
|
127
|
+
|
|
128
|
+
export function remediesCommand(remediedBy, command) {
|
|
129
|
+
const want = wordsOf(remediedBy);
|
|
130
|
+
if (want.length === 0) return false;
|
|
131
|
+
let i = 0;
|
|
132
|
+
for (const word of wordsOf(command)) if (word === want[i] && ++i === want.length) return true;
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A publication is irreversible: no declared item may excuse one. Detected with
|
|
138
|
+
* the SAME tolerance as a remedy (review catch): matching only adjacent words
|
|
139
|
+
* left `npm --registry https://r.example publish` undetected, so an item could
|
|
140
|
+
* have lifted a red check while the publication went ahead.
|
|
141
|
+
*/
|
|
142
|
+
const PUBLICATION_VERBS = ["npm publish", "gh release create"];
|
|
143
|
+
const isPublication = (command) => PUBLICATION_VERBS.some((verb) => remediesCommand(verb, command));
|
|
144
|
+
|
|
109
145
|
async function declaredItems(projectDir, config, version) {
|
|
110
146
|
const items = config?.release_check?.items;
|
|
111
147
|
if (!Array.isArray(items)) return [];
|
|
@@ -116,13 +152,16 @@ async function declaredItems(projectDir, config, version) {
|
|
|
116
152
|
const p = isAbsolute(item.file_contains.path) ? item.file_contains.path : join(projectDir, item.file_contains.path);
|
|
117
153
|
const needle = String(item.file_contains.pattern).replaceAll("{version}", version ?? "");
|
|
118
154
|
const ok = existsSync(p) && readFileSync(p, "utf8").includes(needle);
|
|
119
|
-
checks.push({ name, ok, detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
|
|
155
|
+
checks.push({ name, ok, ...remedy(item), detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
|
|
120
156
|
} else if (item?.command) {
|
|
121
157
|
try {
|
|
122
158
|
const res = await runCommand("sh", ["-c", String(item.command).replaceAll("{version}", version ?? "")], { cwd: projectDir });
|
|
123
|
-
checks.push({ name, ok: res.exitCode === 0, detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
|
|
159
|
+
checks.push({ name, ok: res.exitCode === 0, ...remedy(item), detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
|
|
124
160
|
} catch (err) {
|
|
125
|
-
|
|
161
|
+
// El remedio viaja tambien cuando el item no se pudo ni ejecutar
|
|
162
|
+
// (catch de la review): si no, el comando que lo repara queda
|
|
163
|
+
// bloqueado justo cuando el check no ha podido comprobar nada.
|
|
164
|
+
checks.push({ name, ok: false, ...remedy(item), detail: `command failed to run: ${err.message}` });
|
|
126
165
|
}
|
|
127
166
|
} else {
|
|
128
167
|
checks.push({ name, ok: false, detail: "unknown item shape — use file_contains {path, pattern} or command" });
|
|
@@ -160,10 +199,17 @@ export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
|
|
|
160
199
|
}
|
|
161
200
|
}
|
|
162
201
|
|
|
163
|
-
export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
|
|
202
|
+
export async function runReleaseCheck({ projectDir = process.cwd(), config = {}, forCommand = null } = {}) {
|
|
164
203
|
const { checks, version, pkg } = await genericChecks(projectDir);
|
|
165
204
|
const pack = await packPrivacyCheck(projectDir, pkg);
|
|
166
205
|
const dual = await dualPublishCheck(projectDir, pkg);
|
|
167
206
|
checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
|
|
168
|
-
return { ok: checks.every((c) => c.ok), version, checks };
|
|
207
|
+
if (forCommand === null) return { ok: checks.every((c) => c.ok), version, checks };
|
|
208
|
+
// A lifted check stays RED in the report: the fact is not falsified, it just
|
|
209
|
+
// stops blocking the command that exists to repair it. The mark travels ON
|
|
210
|
+
// the check (review catch): two items may share a name, and a name-keyed
|
|
211
|
+
// list would have exempted a red check nothing remedies.
|
|
212
|
+
const liftable = !isPublication(forCommand);
|
|
213
|
+
const marked = checks.map((c) => (liftable && !c.ok && c.remediedBy && remediesCommand(c.remediedBy, forCommand) ? { ...c, lifted: true } : c));
|
|
214
|
+
return { ok: marked.every((c) => c.ok || c.lifted === true), version, checks: marked, lifted: marked.filter((c) => c.lifted).map((c) => c.name) };
|
|
169
215
|
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -40,6 +40,9 @@ import { existsSync } from "node:fs";
|
|
|
40
40
|
import { join } from "node:path";
|
|
41
41
|
import { spawnSync } from "node:child_process";
|
|
42
42
|
import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-hooks.js";
|
|
43
|
+
import { boardGate } from "../review/board-pending.js";
|
|
44
|
+
import { panelDeviation } from "../environment/panel.js";
|
|
45
|
+
import { detectHostAgent } from "../utils/agent-detect.js";
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* Register the "meta" / single-role / housekeeping commands: pre-pipeline
|
|
@@ -337,12 +340,16 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
337
340
|
release.command("check")
|
|
338
341
|
.description("Verify the release checklist deterministically (manifest vs CHANGELOG vs tags, privacy scan of publishable files, plus the project's release_check.items); exit 1 lists exactly what is missing")
|
|
339
342
|
.option("--json", "Machine-readable result")
|
|
343
|
+
// KJC-BUG-0204: el guard pregunta POR un comando. Un item que declara
|
|
344
|
+
// remedied_by no bloquea el comando que lo repara; publicar nunca se exime.
|
|
345
|
+
.option("--for-command <cmd>", "Evaluate the checklist for this command: red items whose remedied_by is this command do not block it")
|
|
340
346
|
.action(async (flags) => {
|
|
341
347
|
await withConfig(pkgVersion, "release-check", flags, async ({ config }) => {
|
|
342
|
-
const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config });
|
|
348
|
+
const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config, forCommand: flags.forCommand ?? null });
|
|
343
349
|
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
344
350
|
else {
|
|
345
351
|
for (const c of res.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
|
|
352
|
+
if (res.lifted?.length) console.log(` ℓ no bloquean este comando porque es su remedio: ${res.lifted.join(", ")}`);
|
|
346
353
|
console.log(res.ok ? `release check: ready to release ${res.version ?? ""}`.trim() : "release check: NOT ready — fix the red items first");
|
|
347
354
|
}
|
|
348
355
|
process.exitCode = res.ok ? 0 : 1;
|
|
@@ -419,8 +426,12 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
419
426
|
});
|
|
420
427
|
policyCmd.command("seal")
|
|
421
428
|
.description("Sella en el decision log un escape KJ_ALLOW_* usado en tool-time (exempt, chokepoint=tool, identidad declarada del clon) — lo invoca el Sentinel, GOV-F")
|
|
422
|
-
.
|
|
429
|
+
.option("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
|
|
423
430
|
.option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
|
|
431
|
+
// KJC-TSK-0868: el panel se sella igual, con su propio chokepoint.
|
|
432
|
+
.option("--panel <coder>", "Coder declarado cuando el anfitrión escribió el código")
|
|
433
|
+
.option("--host <agent>", "Anfitrión que escribió, para el asiento del panel")
|
|
434
|
+
.option("--honoured", "El panel se CUMPLIÓ: escribió el coder declarado (KJC-TSK-0873)")
|
|
424
435
|
.action(async (flags) => {
|
|
425
436
|
await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
|
|
426
437
|
process.exitCode = await policyCommand({ action: "seal", config, flags });
|
|
@@ -466,9 +477,38 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
466
477
|
.action((flags) => {
|
|
467
478
|
const res = verifySentinelScripts();
|
|
468
479
|
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
480
|
+
// KJC-BUG-0197: manipulación y desfase son hechos distintos y se leen
|
|
481
|
+
// distinto. Un desfase no es un defecto: los guardias que corren están
|
|
482
|
+
// sellados, solo son más viejos que las plantillas de este árbol.
|
|
483
|
+
else if (res.drift?.length && res.ok) console.log(`sentinel verify: scripts sellados, con desfase (${res.drift.join(", ")}) — ${res.reason}`);
|
|
469
484
|
else console.log(res.ok ? "sentinel verify: scripts intactos" : `sentinel verify: modificados fuera de kj harden: ${res.mismatched.join(", ")} — restaura con kj harden`);
|
|
470
485
|
process.exitCode = res.ok ? 0 : 1;
|
|
471
486
|
});
|
|
487
|
+
// KJC-TSK-0868: el anfitrión escribiendo con otro coder declarado no se
|
|
488
|
+
// bloquea, se registra. El Stop gate pregunta una vez por sesión.
|
|
489
|
+
sentinel.command("panel-check")
|
|
490
|
+
.description("Record in the decision log that the host wrote code while another coder is declared (chokepoint panel); prints nothing and records nothing when the panel was honoured")
|
|
491
|
+
.requiredOption("--session <id>", "Host session id, for the record")
|
|
492
|
+
.action(async (flags) => {
|
|
493
|
+
await withConfig(pkgVersion, "sentinel-panel-check", flags, async ({ config }) => {
|
|
494
|
+
const dev = panelDeviation(config, detectHostAgent());
|
|
495
|
+
if (!dev) return;
|
|
496
|
+
process.exitCode = await policyCommand({ action: "seal", config, flags: { panel: dev.coder, host: dev.host }, logger: { info: () => {}, error: console.error } });
|
|
497
|
+
if (process.exitCode === 0) console.log(`ℓ panel: el coder declarado es ${dev.coder} y ha escrito ${dev.host} — registrado como excepción consciente (kj report lo cuenta)`);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
// KJC-BUG-0198: el guard pregunta, kj decide con un hecho comprobable (otra
|
|
501
|
+
// PR abierta de la card), y exit 2 = bloquea.
|
|
502
|
+
sentinel.command("board-gate")
|
|
503
|
+
.description("Decide which pending board moves actually block: a card still being delivered in another open PR is carried, everything else blocks; exit 2 = blocked")
|
|
504
|
+
.requiredOption("--session <id>", "Host session id whose pending moves are evaluated")
|
|
505
|
+
.option("--json", "Machine-readable result")
|
|
506
|
+
.action((flags) => {
|
|
507
|
+
const res = boardGate({ projectDir: resolveSentinelRoot(), sessionId: flags.session });
|
|
508
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
509
|
+
else for (const c of res.carried) console.log(`ℓ board-sync: ${c.card} ${c.why} — se moverá al cerrarla`);
|
|
510
|
+
process.exitCode = res.blocking.length > 0 ? 2 : 0;
|
|
511
|
+
});
|
|
472
512
|
|
|
473
513
|
// KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
|
|
474
514
|
const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
|
|
@@ -179,11 +179,21 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
179
179
|
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
180
180
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
181
181
|
.option("--coder <name>")
|
|
182
|
+
// KJC-TSK-0864: --coder is the pipeline's word for it, --agent is the
|
|
183
|
+
// session's. Same knob, two vocabularies; naming both and disagreeing is
|
|
184
|
+
// an error, never a silent precedence.
|
|
185
|
+
.option("--agent <name>", "The agent that writes (alias of --coder)")
|
|
186
|
+
.option("--card <ref>", "Card/HU the work belongs to — its statement and criteria reach the coder")
|
|
182
187
|
.option("--coder-model <name>")
|
|
183
188
|
.action(async (task, flags) => {
|
|
189
|
+
if (flags.agent && flags.coder && flags.agent !== flags.coder) {
|
|
190
|
+
console.error(`kj code: --agent ${flags.agent} and --coder ${flags.coder} name different agents — pick one`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
if (flags.agent) flags.coder = flags.agent;
|
|
184
194
|
await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
|
|
185
195
|
const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
186
|
-
await codeCommand({ task: resolvedTask, config, logger });
|
|
196
|
+
await codeCommand({ task: resolvedTask, config, logger, flags });
|
|
187
197
|
});
|
|
188
198
|
});
|
|
189
199
|
|
package/src/commands/check.js
CHANGED
|
@@ -9,6 +9,7 @@ import { collectMethodStats, formatMethodStats } from "../checks/method.js";
|
|
|
9
9
|
import { createRagCoverageCheck } from "../checks/rag-coverage.js";
|
|
10
10
|
import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
|
|
11
11
|
import { detectObservedAgents } from "../utils/agent-detect.js";
|
|
12
|
+
import { panelSummary } from "../environment/panel.js";
|
|
12
13
|
import { loadConfig } from "../config.js";
|
|
13
14
|
|
|
14
15
|
export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
|
|
@@ -44,6 +45,10 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
|
|
|
44
45
|
if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
|
|
45
46
|
logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
|
|
46
47
|
if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
|
|
48
|
+
// KJC-TSK-0865: who writes and who reviews is the user's choice, so it is
|
|
49
|
+
// said out loud next to everything else that describes the project. Never
|
|
50
|
+
// a check: it has no ok/fail, it informs.
|
|
51
|
+
logger.info?.(` ${panelSummary(config)}`);
|
|
47
52
|
if (ok) logger.info?.("Harness OK.");
|
|
48
53
|
else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
|
|
49
54
|
else logger.info?.("Harness drift detected — run `kj harden` to repair.");
|
package/src/commands/code.js
CHANGED
|
@@ -2,13 +2,25 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import { createAgent } from "../agents/index.js";
|
|
3
3
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
4
4
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
5
|
+
import { resolveCardContext } from "../prompts/card-context.js";
|
|
6
|
+
import { composeTask, resolveRagContext } from "../prompts/session-context.js";
|
|
5
7
|
import { resolveRole } from "../config.js";
|
|
8
|
+
// KJC-TSK-0859: the agents no longer substitute a dead model behind your back,
|
|
9
|
+
// so the command needs the declared chain, with the one-shot policy a command
|
|
10
|
+
// deserves (KJC-BUG-0194 moved it to its own module: audit needs the same).
|
|
11
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
12
|
+
import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
|
|
13
|
+
import { ONE_SHOT_POLICY } from "../brain/one-shot-policy.js";
|
|
14
|
+
import { sealPanel } from "../environment/panel.js";
|
|
6
15
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
7
16
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
8
17
|
|
|
9
|
-
export async function codeCommand({ task, config, logger }) {
|
|
18
|
+
export async function codeCommand({ task, config, logger, flags = {} }) {
|
|
10
19
|
return withCliRunLog("code", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
11
20
|
const coderRole = resolveRole(config, "coder");
|
|
21
|
+
// KJC-TSK-0864: no quota, not authenticated or simply absent? This THROWS
|
|
22
|
+
// with the agent named. The one thing it never does is quietly fall back
|
|
23
|
+
// to whoever happens to be reachable.
|
|
12
24
|
await assertAgentsAvailable([coderRole.provider]);
|
|
13
25
|
logger.info(`Coder (${coderRole.provider}) starting...`);
|
|
14
26
|
runLog.logText(`[coder] provider=${coderRole.provider}`);
|
|
@@ -21,11 +33,39 @@ export async function codeCommand({ task, config, logger }) {
|
|
|
21
33
|
try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
|
|
22
34
|
}
|
|
23
35
|
}
|
|
24
|
-
const
|
|
36
|
+
const card = await resolveCardContext({ projectDir: config.projectDir, ref: flags.card || null });
|
|
37
|
+
if (card?.external) logger.warn(`card ${card.huId} lives on a board kj cannot read — passing the reference, not its text`);
|
|
38
|
+
else if (card) logger.info(`Card ${card.huId}: ${card.title}`);
|
|
39
|
+
// The method's first invariant is that the RAG answers before you assume.
|
|
40
|
+
// The session asks on the coder's behalf: a subprocess coder cannot run
|
|
41
|
+
// `kj rag query` itself, so it used to guess the codebase instead.
|
|
42
|
+
const rag = await resolveRagContext({ task, config, logger });
|
|
43
|
+
// The pipeline always passed projectDir (it arms the directory-boundary
|
|
44
|
+
// rule and the skills section) and the provider. `kj code` did not, so the
|
|
45
|
+
// declared coder got a weaker prompt from the session than from `kj run`.
|
|
46
|
+
const prompt = await buildCoderPrompt({
|
|
47
|
+
task: composeTask(task, [card?.section, rag?.section]),
|
|
48
|
+
coderRules,
|
|
49
|
+
methodology: config.development?.methodology || "tdd",
|
|
50
|
+
projectDir: config.projectDir,
|
|
51
|
+
provider: coderRole.provider,
|
|
52
|
+
huId: card?.huId ?? null,
|
|
53
|
+
acceptanceTests: card?.acceptanceTests ?? null,
|
|
54
|
+
serenaEnabled: Boolean(config.serena?.enabled),
|
|
55
|
+
rtkAvailable: Boolean(config.rtk?.available),
|
|
56
|
+
});
|
|
25
57
|
const progress = createCliProgressReporter({ role: "coder" });
|
|
26
58
|
let result;
|
|
27
59
|
try {
|
|
28
|
-
result = await
|
|
60
|
+
result = await withBrainRecovery({
|
|
61
|
+
agent: { runTask: (args) => coder.runTask(args), provider: coderRole.provider, model: coderRole.model },
|
|
62
|
+
taskArgs: { prompt, onOutput: progress.onOutput, role: "coder" },
|
|
63
|
+
role: "coder",
|
|
64
|
+
provider: coderRole.provider,
|
|
65
|
+
logger,
|
|
66
|
+
policy: ONE_SHOT_POLICY,
|
|
67
|
+
fallback: buildRoleFallbackChain({ config, role: "coder", logger }),
|
|
68
|
+
});
|
|
29
69
|
progress.finish(result.ok ? "done" : "failed");
|
|
30
70
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
31
71
|
if (!result.ok) {
|
|
@@ -39,7 +79,22 @@ export async function codeCommand({ task, config, logger }) {
|
|
|
39
79
|
logger.warn(result.error);
|
|
40
80
|
}
|
|
41
81
|
logger.info(`Coder completed (exit ${result.exitCode})`);
|
|
82
|
+
// KJC-TSK-0873: aqui es donde el panel SE CUMPLE — el coder declarado ha
|
|
83
|
+
// escrito. Queda asentado junto a las desviaciones para que el recuento de
|
|
84
|
+
// kj report signifique algo: casi siempre y casi nunca no se ven igual.
|
|
85
|
+
// Solo cuenta como cumplido si el coder declarado TERMINO bien: ni una
|
|
86
|
+
// salida distinta de cero ni una caida al fallback son "escribio el coder
|
|
87
|
+
// que elegiste" (dos catches de la review). Si escribio otro, se asienta
|
|
88
|
+
// como desviacion nombrandolo.
|
|
89
|
+
const ranBy = result.provider || coderRole.provider;
|
|
90
|
+
if (result.exitCode === 0) {
|
|
91
|
+
try { sealPanel({ projectDir: config.projectDir, coder: coderRole.provider, host: ranBy, honoured: ranBy === coderRole.provider }); }
|
|
92
|
+
catch (err) { logger.warn(`panel: no se pudo sellar quién escribió (${err.message}) — el trabajo del coder no se toca`); }
|
|
93
|
+
}
|
|
94
|
+
// The work is in the tree, not committed: the gate is the next step and a
|
|
95
|
+
// DIFFERENT AI runs it. Saying so here is what keeps the panel honest.
|
|
96
|
+
logger.info(`The work is in the tree. Review it before committing: kj review --staged (a different AI than ${coderRole.provider}).`);
|
|
42
97
|
runLog.logText(`[coder] finished (exit=${result.exitCode})`);
|
|
43
|
-
return { ok: true };
|
|
98
|
+
return { ok: true, provider: coderRole.provider, card: card?.huId ?? null, ragSources: rag?.sources ?? [] };
|
|
44
99
|
});
|
|
45
100
|
}
|
package/src/commands/env.js
CHANGED
|
@@ -71,6 +71,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
71
71
|
projectDir, target: flags.target || "all",
|
|
72
72
|
stateBackend: config?.state_backend || "hu-board",
|
|
73
73
|
boardName: config?.board?.name || null,
|
|
74
|
+
config, // KJC-TSK-0865: the panel travels with the method
|
|
74
75
|
});
|
|
75
76
|
console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
|
|
76
77
|
|
|
@@ -213,7 +214,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
213
214
|
// CLAUDE.md, but the running session loaded its context BEFORE — nobody
|
|
214
215
|
// re-reads it. Print the method so it enters THIS conversation now.
|
|
215
216
|
console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
|
|
216
|
-
console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
|
|
217
|
+
console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null, config }));
|
|
217
218
|
}
|
|
218
219
|
return result;
|
|
219
220
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -153,7 +153,10 @@ export async function hardenCommand({
|
|
|
153
153
|
result.harnessHooks = hh.wired ? "wired" : "script-only";
|
|
154
154
|
// KJC-TSK-0713 — the Sentinel: method state + Stop gate (turn cannot
|
|
155
155
|
// end red). Same Claude-only harness surface as the tool gate.
|
|
156
|
-
|
|
156
|
+
// KJC-BUG-0193: solo un humano avanza lo que un humano selló. Sin
|
|
157
|
+
// --commit, un guardia sellado se queda como está en lugar de quedarse
|
|
158
|
+
// sin sello, que es como una sesión se dejaba sin herramientas.
|
|
159
|
+
const sh = installSentinelHooks({ projectDir, logger, human: commitSupervisor });
|
|
157
160
|
result.sentinelHooks = sh.wired ? "wired" : "script-only";
|
|
158
161
|
}
|
|
159
162
|
} catch (err) {
|
|
@@ -171,7 +174,9 @@ export async function hardenCommand({
|
|
|
171
174
|
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
|
|
172
175
|
: null;
|
|
173
176
|
const withGuidelines = guidelines && profile !== "minimal";
|
|
174
|
-
|
|
177
|
+
// KJC-BUG-0199 (issue #1773): the same detected language the workflows and
|
|
178
|
+
// the configs already use — a Python project must not be told to use `const`.
|
|
179
|
+
const gl = withGuidelines ? installGuidelines({ projectDir, language: roots[0]?.language ?? null, dryRun }) : null;
|
|
175
180
|
const out = {
|
|
176
181
|
ok: true,
|
|
177
182
|
...result,
|
|
@@ -193,7 +198,11 @@ export async function hardenCommand({
|
|
|
193
198
|
logger.info?.(` • ${c.file}: ${c.action}${note}`);
|
|
194
199
|
}
|
|
195
200
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
196
|
-
for (const g of out.guidelines)
|
|
201
|
+
for (const g of out.guidelines) {
|
|
202
|
+
logger.info?.(` • ${g.file}: ${g.action}`);
|
|
203
|
+
// KJC-BUG-0206: quitar reglas no puede leerse como "updated" y nada más.
|
|
204
|
+
if (g.shrank) logger.warn?.(` ⚠ ${g.file} queda con MENOS reglas que antes — revisa si el lenguaje detectado es el que crees (kj check lo dice)`);
|
|
205
|
+
}
|
|
197
206
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
198
207
|
// IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
|
|
199
208
|
// only with a human confirming; headless runs get the pending command.
|
package/src/commands/policy.js
CHANGED
|
@@ -15,6 +15,8 @@ import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
|
15
15
|
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
16
16
|
import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
17
17
|
import { buildPolicyReport } from "../policy/report.js";
|
|
18
|
+
import { budgetedNet } from "../review/loc-budget.js";
|
|
19
|
+
import { sealPanel } from "../environment/panel.js";
|
|
18
20
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
19
21
|
import { readIdentity } from "../identity/store.js";
|
|
20
22
|
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
@@ -32,12 +34,10 @@ async function stagedFacts(projectDir, gitFn, range = null) {
|
|
|
32
34
|
.split("\n")
|
|
33
35
|
.map((s) => s.trim())
|
|
34
36
|
.filter(Boolean);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (r && r !== "-") net -= Number(r) || 0;
|
|
40
|
-
}
|
|
37
|
+
// KJC-BUG-0208: el presupuesto lo decide un solo modulo. Este sitio sumaba el
|
|
38
|
+
// numstat entero, asi que el job policy-check de CI contaba la documentacion
|
|
39
|
+
// y el gate bloqueante no: la misma regla con dos respuestas.
|
|
40
|
+
const { net } = budgetedNet(await run([...base, "--numstat"]));
|
|
41
41
|
return { files, netLinesAdded: net };
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -209,18 +209,23 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
209
209
|
// identidad DECLARADA del clon (identity.local.yml), para que el informe
|
|
210
210
|
// y el anchor la vean. Lo llama el Sentinel; cualquiera puede auditarlo.
|
|
211
211
|
if (action === "seal") {
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
// KJC-TSK-0868: el mismo asiento sirve para el panel — el anfitrión escribió
|
|
213
|
+
// teniendo otro coder declarado. Mismo log encadenado, otro chokepoint.
|
|
214
|
+
if (!flags.escape && !flags.panel) {
|
|
215
|
+
logger.error?.("policy seal: --escape <KJ_ALLOW_X> o --panel <coder> es obligatorio — un asiento sin nombre no es auditable");
|
|
214
216
|
return 1;
|
|
215
217
|
}
|
|
216
218
|
try {
|
|
217
219
|
const who = readIdentity(projectDir);
|
|
218
|
-
const rec =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
const rec = flags.panel
|
|
221
|
+
? sealPanel({ projectDir, coder: flags.panel, host: flags.host ?? null, honoured: Boolean(flags.honoured) })
|
|
222
|
+
: recordGateDecision(projectDir, {
|
|
223
|
+
decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
|
|
224
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
225
|
+
policy_hash: policyFileHash(projectDir),
|
|
226
|
+
});
|
|
227
|
+
if (rec.chokepoint === "panel") logger.info?.(`✓ panel sellado: el coder declarado es ${rec.coder} y escribió ${rec.host ?? "el anfitrión"}`);
|
|
228
|
+
else logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
|
|
224
229
|
return 0;
|
|
225
230
|
} catch (err) {
|
|
226
231
|
logger.error?.(`policy seal: ${err.message}`);
|
|
@@ -238,6 +243,8 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
238
243
|
const sup = liftSealedSupervisorViolations({ projectDir, violations });
|
|
239
244
|
if (sup.lifted > 0) {
|
|
240
245
|
logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
|
|
246
|
+
// KJC-BUG-0212: lo que este sitio no ha podido juzgar se dice, no se calla.
|
|
247
|
+
if (sup.note) logger.info?.(`ℓ policy: ${sup.note}`);
|
|
241
248
|
violations = sup.violations;
|
|
242
249
|
}
|
|
243
250
|
const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
|
package/src/commands/report.js
CHANGED
|
@@ -226,6 +226,26 @@ export function formatSentinelEscapes(state) {
|
|
|
226
226
|
return `Sentinel escapes used (${events.length}):\n${lines.join("\n")}`;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* KJC-TSK-0873 — the panel tally. The user chose who writes; this says how
|
|
231
|
+
* often it happened. One number alone would not: a project where the panel is
|
|
232
|
+
* honoured almost always and one where it almost never is look identical until
|
|
233
|
+
* both sides are counted.
|
|
234
|
+
*/
|
|
235
|
+
export function formatPanelTally(lines) {
|
|
236
|
+
let honoured = 0;
|
|
237
|
+
let host = 0;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
let rec;
|
|
240
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
241
|
+
if (rec?.chokepoint !== "panel") continue;
|
|
242
|
+
if (rec.decision === "ok") honoured += 1;
|
|
243
|
+
else host += 1;
|
|
244
|
+
}
|
|
245
|
+
if (honoured + host === 0) return null;
|
|
246
|
+
return `Panel (who wrote): declared coder ${honoured}, host ${host}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
229
249
|
// The sentinel state is a PROJECT artifact (<project>/.karajan/harness), not
|
|
230
250
|
// a session artifact: the `dir` the report handlers receive is the GLOBAL
|
|
231
251
|
// sessions root (~/.karajan/sessions) and never contains it. `kj report` is
|
|
@@ -241,6 +261,14 @@ async function printSentinelEscapes({ projectDir = process.cwd(), format } = {})
|
|
|
241
261
|
console.log(text);
|
|
242
262
|
}
|
|
243
263
|
} catch { /* no sentinel state in this project — nothing to report */ }
|
|
264
|
+
try {
|
|
265
|
+
const log = await fs.readFile(path.join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8");
|
|
266
|
+
const tally = formatPanelTally(log.split("\n").filter((l) => l.trim()));
|
|
267
|
+
if (tally) {
|
|
268
|
+
console.log("");
|
|
269
|
+
console.log(tally);
|
|
270
|
+
}
|
|
271
|
+
} catch { /* no decision log — no panel to tally */ }
|
|
244
272
|
}
|
|
245
273
|
|
|
246
274
|
function formatDuration(ms) {
|