karajan-code 4.21.0 → 4.23.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 +6 -3
- package/package.json +5 -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/auth.js +29 -1
- 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/audit/basal-cost.js +4 -0
- package/src/audit/dead-exports.js +52 -1
- package/src/audit/deterministic-summary.js +18 -3
- package/src/audit/member-reachability.js +158 -0
- package/src/audit/osv-findings.js +1 -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 +86 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +66 -5
- package/src/commands/claims.js +73 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +34 -9
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +188 -22
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/privacy/scan.js +23 -1
- 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/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- package/src/review/unparseable-verdict.js +50 -0
- package/src/roles/audit-role.js +19 -1
- package/src/sonar/scanner.js +34 -8
- package/src/steward/invariants.js +190 -0
- package/src/steward/phantom-coverage.js +137 -0
- package/src/steward/proposed-work.js +68 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { execFile } from "node:child_process";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
|
+
import os from "node:os";
|
|
19
20
|
import path from "node:path";
|
|
20
21
|
import fs from "node:fs/promises";
|
|
21
22
|
import { filterFalsePositives } from "./issue-filter.js";
|
|
@@ -26,6 +27,42 @@ const SCAN_TIMEOUT_MS = 120_000;
|
|
|
26
27
|
const MAX_DEAD_EXPORTS_REPORTED = 100;
|
|
27
28
|
const MAX_UNUSED_FILES_REPORTED = 50;
|
|
28
29
|
|
|
30
|
+
// KJC-TSK-0794 AC7: a Firebase callable or a declared global-setup reported as
|
|
31
|
+
// dead is the false positive that gets the inventory switched off. When the
|
|
32
|
+
// project declares nothing itself, kj declares FOR it what it can read.
|
|
33
|
+
const KNIP_OWN_CONFIGS = ["knip.json", "knip.jsonc", ".knip.json", "knip.js", "knip.ts", "knip.config.js", "knip.config.ts"];
|
|
34
|
+
// knip's default root entry patterns, restated because an explicit `entry`
|
|
35
|
+
// REPLACES them (package.json main/bin/exports always count on their own).
|
|
36
|
+
const KNIP_DEFAULT_ENTRY = ["{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}", "src/{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}"];
|
|
37
|
+
|
|
38
|
+
async function hasOwnKnipConfig(projectDir) {
|
|
39
|
+
for (const f of KNIP_OWN_CONFIGS) {
|
|
40
|
+
try { await fs.access(path.join(projectDir, f)); return true; } catch { /* keep looking */ }
|
|
41
|
+
}
|
|
42
|
+
try { return Boolean(JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).knip); } catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Entrypoints kj can vouch for: config.audit.entrypoints (the user's word) and
|
|
46
|
+
* firebase.json's functions.source (the platform's word). Null when nothing. */
|
|
47
|
+
async function resolveProjectEntrypoints(projectDir, config) {
|
|
48
|
+
const declared = (config?.audit?.entrypoints || []).filter((e) => typeof e === "string");
|
|
49
|
+
const firebase = [];
|
|
50
|
+
try {
|
|
51
|
+
const fb = JSON.parse(await fs.readFile(path.join(projectDir, "firebase.json"), "utf8"));
|
|
52
|
+
const fns = Array.isArray(fb.functions) ? fb.functions : fb.functions ? [fb.functions] : [];
|
|
53
|
+
for (const f of fns) if (typeof f?.source === "string") firebase.push(f.source);
|
|
54
|
+
} catch { /* no firebase.json — nothing to declare */ }
|
|
55
|
+
return declared.length || firebase.length ? { declared, firebase } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildEphemeralKnipConfig({ declared, firebase }) {
|
|
59
|
+
const root = { entry: [...KNIP_DEFAULT_ENTRY, ...declared] };
|
|
60
|
+
if (!firebase.length) return root;
|
|
61
|
+
const workspaces = { ".": root };
|
|
62
|
+
for (const src of firebase) workspaces[src] = { entry: ["index.{js,ts}", "src/index.{js,ts}"] };
|
|
63
|
+
return { workspaces };
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
function hasJsTsStack(stack) {
|
|
30
67
|
if (!stack) return false;
|
|
31
68
|
const lang = (stack.language || "").toLowerCase();
|
|
@@ -78,11 +115,24 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
|
|
|
78
115
|
return { available: false, reason: `knip binary not resolvable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
|
|
79
116
|
}
|
|
80
117
|
|
|
118
|
+
const args = [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"];
|
|
119
|
+
let declaredEntrypoints = null;
|
|
120
|
+
try {
|
|
121
|
+
const eps = await resolveProjectEntrypoints(projectDir, config);
|
|
122
|
+
// a project with its OWN knip config already decided — it is respected
|
|
123
|
+
if (eps && !(await hasOwnKnipConfig(projectDir))) {
|
|
124
|
+
const cfgPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kj-knip-")), "knip.json");
|
|
125
|
+
await fs.writeFile(cfgPath, JSON.stringify(buildEphemeralKnipConfig(eps)));
|
|
126
|
+
args.push("--config", cfgPath);
|
|
127
|
+
declaredEntrypoints = eps;
|
|
128
|
+
}
|
|
129
|
+
} catch { /* declaring is best-effort; the default scan still runs */ }
|
|
130
|
+
|
|
81
131
|
let raw;
|
|
82
132
|
try {
|
|
83
133
|
const { stdout } = await execFileAsync(
|
|
84
134
|
process.execPath,
|
|
85
|
-
|
|
135
|
+
args,
|
|
86
136
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
|
|
87
137
|
);
|
|
88
138
|
raw = stdout;
|
|
@@ -163,6 +213,7 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
|
|
|
163
213
|
available: true,
|
|
164
214
|
total: kept.length,
|
|
165
215
|
suppressedCount: suppressed.length,
|
|
216
|
+
declaredEntrypoints,
|
|
166
217
|
exports: keptExports,
|
|
167
218
|
files: keptFiles,
|
|
168
219
|
suppressed,
|
|
@@ -185,9 +185,20 @@ function formatDeadExportsBlock(deadExports) {
|
|
|
185
185
|
const lines = ["### Dead Code (knip)"];
|
|
186
186
|
const exportsTotal = (deadExports.exports || []).length;
|
|
187
187
|
const filesTotal = (deadExports.files || []).length;
|
|
188
|
+
const reported = exportsTotal + filesTotal;
|
|
189
|
+
const suppressed = deadExports.suppressedCount || 0;
|
|
190
|
+
// AC8 (KJC-TSK-0794): derivative first; then how many ENTERED and how many
|
|
191
|
+
// were FILTERED — a shrinking number must never hide a shrinking scan.
|
|
192
|
+
const prev = deadExports.previous;
|
|
193
|
+
if (prev && typeof prev.exports === "number") {
|
|
194
|
+
const d = reported - (prev.exports + (prev.files || 0));
|
|
195
|
+
lines.push(`- Δ dead code: ${d >= 0 ? "+" : ""}${d} since ${prev.timestamp || "last audit"} (now ${reported})`);
|
|
196
|
+
}
|
|
197
|
+
lines.push(`- ${reported + suppressed} entered the scan, ${suppressed} filtered as declared false positives, ${reported} reported`);
|
|
198
|
+
const eps = deadExports.declaredEntrypoints;
|
|
199
|
+
if (eps) lines.push(`- Declared entrypoints honored: ${[...eps.declared, ...eps.firebase.map((s) => `${s} (firebase functions)`)].join(", ")}`);
|
|
188
200
|
lines.push(`- Unused exports/types: ${exportsTotal}`);
|
|
189
201
|
lines.push(`- Unused files: ${filesTotal}`);
|
|
190
|
-
if (deadExports.suppressedCount) lines.push(`- Suppressed (FP filter): ${deadExports.suppressedCount}`);
|
|
191
202
|
const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])];
|
|
192
203
|
if (allItems.length > 0) {
|
|
193
204
|
const groups = groupDeadExportsBySeverity(allItems);
|
|
@@ -234,8 +245,12 @@ function formatBasalCostBlock(basalCost, growthDelta) {
|
|
|
234
245
|
}
|
|
235
246
|
|
|
236
247
|
const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
|
|
248
|
+
// AC8 (KJC-TSK-0794): the DERIVATIVE leads — "went from N to M" is what a
|
|
249
|
+
// reader acts on; the absolute is the secondary datum.
|
|
250
|
+
const dDelta = growthDelta?.deadExports;
|
|
251
|
+
const lead = typeof dDelta === "number" ? `${dDelta >= 0 ? "+" : ""}${dDelta} since last audit — ` : "";
|
|
237
252
|
if (dead.length > 0) {
|
|
238
|
-
lines.push(`- Dead exports: ${dead.length}`);
|
|
253
|
+
lines.push(`- Dead exports: ${lead}${dead.length} total`);
|
|
239
254
|
for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
|
|
240
255
|
lines.push(` - \`${de.name}\` in ${de.file}`);
|
|
241
256
|
}
|
|
@@ -243,7 +258,7 @@ function formatBasalCostBlock(basalCost, growthDelta) {
|
|
|
243
258
|
lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
|
|
244
259
|
}
|
|
245
260
|
} else {
|
|
246
|
-
lines.push(
|
|
261
|
+
lines.push(`- Dead exports: ${lead}0`);
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
if (growthDelta) {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member reachability (KJC-TSK-0794, epic KJC-PCS-0082) — which class members
|
|
3
|
+
* no entrypoint can reach, said ONLY inside the perimeter validated with known
|
|
4
|
+
* truth (GREBLA: 31/139 unreachable at dd5a91a checked by hand, 0/108 on their
|
|
5
|
+
* cleaned main): one file, a recognized framework contract, no dynamic dispatch.
|
|
6
|
+
* Everything outside comes out NOT OBSERVABLE with its reason, never as clean —
|
|
7
|
+
* an inflated inventory gets switched off, and then nobody reads the real one.
|
|
8
|
+
*/
|
|
9
|
+
import { parse } from "@babel/parser";
|
|
10
|
+
const plugins = (file) => [
|
|
11
|
+
...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
|
|
12
|
+
...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
|
|
13
|
+
"decorators",
|
|
14
|
+
];
|
|
15
|
+
// Entrypoints are what the FRAMEWORK calls — declared per framework, VERSIONED
|
|
16
|
+
// (bump on any list change), never a hand-kept list in a run. v1 covers Lit;
|
|
17
|
+
// other frameworks stay NOT OBSERVABLE until their contract is declared here.
|
|
18
|
+
export const ENTRYPOINT_CATALOG = {
|
|
19
|
+
version: 2, // v2: each list says WHICH slot the framework touches — static and instance are different worlds
|
|
20
|
+
lit: {
|
|
21
|
+
instance: ["constructor", "render", "connectedCallback", "disconnectedCallback", "attributeChangedCallback", "adoptedCallback", "firstUpdated", "updated", "willUpdate", "shouldUpdate", "performUpdate", "getUpdateComplete", "createRenderRoot"],
|
|
22
|
+
static: ["properties", "styles", "observedAttributes"],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const BASES = new Map([["LitElement", "lit"]]);
|
|
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
|
+
const keyName = (m) => {
|
|
36
|
+
if (m.key?.type === "PrivateName") return `#${m.key.id.name}`;
|
|
37
|
+
if (!m.computed && m.key?.type === "Identifier") return m.key.name;
|
|
38
|
+
return m.key?.type === "StringLiteral" || m.key?.type === "NumericLiteral" ? String(m.key.value) : null;
|
|
39
|
+
};
|
|
40
|
+
const ACCESSOR_KINDS = new Map([["get", "getter"], ["set", "setter"]]);
|
|
41
|
+
const kindOf = (m) => ACCESSOR_KINDS.get(m.kind) ?? (/Method/.test(m.type) ? "method" : "field");
|
|
42
|
+
/** `this.x` / `this['x']` / `this.#x` inside a node, prefixed with the slot the
|
|
43
|
+
* context can actually reach ("static " inside static members, "" otherwise).
|
|
44
|
+
* Over-collecting through functions that rebind `this` only makes members MORE
|
|
45
|
+
* alive — never dead. */
|
|
46
|
+
const thisRefs = (node, prefix = "") => {
|
|
47
|
+
const refs = new Set();
|
|
48
|
+
walk(node, (n) => {
|
|
49
|
+
if (n.type !== "MemberExpression" || n.object?.type !== "ThisExpression") return;
|
|
50
|
+
if (n.property.type === "PrivateName") refs.add(`${prefix}#${n.property.id.name}`);
|
|
51
|
+
else if (!n.computed && n.property.type === "Identifier") refs.add(prefix + n.property.name);
|
|
52
|
+
else if (n.property.type === "StringLiteral" || n.property.type === "NumericLiteral") refs.add(prefix + String(n.property.value));
|
|
53
|
+
});
|
|
54
|
+
return refs;
|
|
55
|
+
};
|
|
56
|
+
const notObs = (name, line, reason) => ({ name, line, observable: false, reason, unreachable: [] });
|
|
57
|
+
|
|
58
|
+
function analyzeClass(node, { staticUses, thisPropCount }) {
|
|
59
|
+
const name = node.id?.name ?? "(anonymous)";
|
|
60
|
+
const line = node.loc.start.line;
|
|
61
|
+
const sup = node.superClass;
|
|
62
|
+
if (!sup) return notObs(name, line, "no framework contract — members may be called from outside the file");
|
|
63
|
+
if (sup.type !== "Identifier") return notObs(name, line, "mixin or computed base class — inherited entrypoints cannot be resolved");
|
|
64
|
+
const framework = BASES.get(sup.name);
|
|
65
|
+
if (!framework) return notObs(name, line, `unknown base class ${sup.name} — its contract is not in the entrypoint catalog (v${ENTRYPOINT_CATALOG.version})`);
|
|
66
|
+
|
|
67
|
+
const cat = ENTRYPOINT_CATALOG[framework];
|
|
68
|
+
const entries = new Set([...cat.instance, ...cat.static.map((s) => `static ${s}`)]);
|
|
69
|
+
const members = new Map(); // slot → { refs, spots, entry }
|
|
70
|
+
// reached at class-definition time (static blocks, static field initializers)
|
|
71
|
+
// or by ClassName.X anywhere in the file — a use is a use, wherever it sits
|
|
72
|
+
const roots = new Set([...(staticUses.get(name) ?? [])].map((s) => `static ${s}`));
|
|
73
|
+
const ctorFields = new Map(); // this._x = v in the constructor: no AST member exists
|
|
74
|
+
for (const m of node.body.body) {
|
|
75
|
+
if (m.type === "StaticBlock") { thisRefs(m, "static ").forEach((r) => roots.add(r)); continue; }
|
|
76
|
+
if (m.type === "TSDeclareMethod" || m.type === "TSIndexSignature") continue;
|
|
77
|
+
if (m.static && /Property/.test(m.type) && m.value) thisRefs(m.value, "static ").forEach((r) => roots.add(r));
|
|
78
|
+
const n = keyName(m);
|
|
79
|
+
if (n === null) return notObs(name, line, `a member name is computed at line ${m.loc.start.line} — the inventory cannot name what it cannot see`);
|
|
80
|
+
const slot = m.static ? `static ${n}` : n;
|
|
81
|
+
const refs = thisRefs(m, m.static ? "static " : "");
|
|
82
|
+
refs.delete(slot); // recursion does not keep itself alive
|
|
83
|
+
const rec = members.get(slot) ?? { refs: new Set(), spots: [], entry: false };
|
|
84
|
+
refs.forEach((r) => rec.refs.add(r));
|
|
85
|
+
rec.spots.push({ line: m.loc.start.line, endLine: m.loc.end.line, kind: kindOf(m) });
|
|
86
|
+
// a decorated member is registered by the framework: it may call or expose it
|
|
87
|
+
if (entries.has(slot) || (m.decorators?.length ?? 0) > 0) rec.entry = true;
|
|
88
|
+
members.set(slot, rec);
|
|
89
|
+
if (n === "constructor" && !m.static) collectCtorAssignments(m, ctorFields);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const alive = new Set();
|
|
93
|
+
const queue = [...members.keys()].filter((k) => members.get(k).entry || roots.has(k));
|
|
94
|
+
while (queue.length) {
|
|
95
|
+
const k = queue.pop();
|
|
96
|
+
if (alive.has(k) || !members.has(k)) continue;
|
|
97
|
+
alive.add(k);
|
|
98
|
+
members.get(k).refs.forEach((r) => { if (members.has(r) && !alive.has(r)) queue.push(r); });
|
|
99
|
+
}
|
|
100
|
+
const unreachable = [...members.entries()]
|
|
101
|
+
.filter(([k]) => !alive.has(k))
|
|
102
|
+
.flatMap(([k, rec]) => rec.spots.map((s) => ({ name: k, ...s })))
|
|
103
|
+
.sort((a, b) => a.line - b.line);
|
|
104
|
+
// HEURISTIC, reported apart (AC5): a constructor field whose name appears in
|
|
105
|
+
// `this.X` form exactly ONCE in the whole file exists only to be initialized.
|
|
106
|
+
const constructorFields = [...ctorFields.entries()]
|
|
107
|
+
.filter(([n]) => !members.has(n) && thisPropCount.get(n) === 1)
|
|
108
|
+
.map(([n, l]) => ({ name: n, line: l, heuristic: "single this-appearance in file" }));
|
|
109
|
+
// memberNames: the class's member slots — phantom-coverage (KJC-TSK-0800)
|
|
110
|
+
// needs to tell "a call to a member of THIS class" from any other call.
|
|
111
|
+
return { name, line, observable: true, reason: null, framework, catalogVersion: ENTRYPOINT_CATALOG.version, total: members.size, memberNames: [...members.keys()], unreachable, constructorFields };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `this.x = …` statements inside the constructor body (first line wins). */
|
|
115
|
+
function collectCtorAssignments(ctor, out) {
|
|
116
|
+
walk(ctor.body, (n) => {
|
|
117
|
+
if (n.type !== "AssignmentExpression" || n.left?.type !== "MemberExpression") return;
|
|
118
|
+
const l = n.left;
|
|
119
|
+
if (l.object?.type !== "ThisExpression" || l.computed || l.property.type !== "Identifier") return;
|
|
120
|
+
if (!out.has(l.property.name)) out.set(l.property.name, n.loc.start.line);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} source
|
|
126
|
+
* @param {{file?: string}} [where]
|
|
127
|
+
* @returns {{file: string, observable: boolean, reason: string|null, classes: Array<object>}}
|
|
128
|
+
*/
|
|
129
|
+
export function analyzeMemberReachability(source, { file = "<source>" } = {}) {
|
|
130
|
+
let tree;
|
|
131
|
+
try {
|
|
132
|
+
tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { file, observable: false, reason: `could not be parsed (${err.message}) — not read as clean`, classes: [] };
|
|
135
|
+
}
|
|
136
|
+
// String dispatch is the most dangerous false positive: ONE computed access
|
|
137
|
+
// on `this` that is not a literal makes the whole file not observable — a
|
|
138
|
+
// list with garbage is worth less than an honest "I do not know".
|
|
139
|
+
let dynamic = null;
|
|
140
|
+
const staticUses = new Map(); // ClassName → Set of properties used as ClassName.X
|
|
141
|
+
const thisPropCount = new Map(); // property → how many `this.X` appearances in the file
|
|
142
|
+
walk(tree, (n) => {
|
|
143
|
+
if (n.type !== "MemberExpression") return;
|
|
144
|
+
if (n.object?.type === "Identifier" && !n.computed && n.property.type === "Identifier") {
|
|
145
|
+
(staticUses.get(n.object.name) ?? staticUses.set(n.object.name, new Set()).get(n.object.name)).add(n.property.name);
|
|
146
|
+
}
|
|
147
|
+
if (n.object?.type !== "ThisExpression") return;
|
|
148
|
+
if (n.computed && n.property.type !== "StringLiteral" && n.property.type !== "NumericLiteral") { dynamic ??= n.loc.start.line; return; }
|
|
149
|
+
if (!n.computed && n.property.type === "Identifier") thisPropCount.set(n.property.name, (thisPropCount.get(n.property.name) ?? 0) + 1);
|
|
150
|
+
});
|
|
151
|
+
if (dynamic !== null) return { file, observable: false, reason: `computed access on this at line ${dynamic} — string dispatch cannot be followed`, classes: [] };
|
|
152
|
+
|
|
153
|
+
const classes = [];
|
|
154
|
+
walk(tree, (node) => {
|
|
155
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") classes.push(analyzeClass(node, { staticUses, thisPropCount }));
|
|
156
|
+
});
|
|
157
|
+
return { file, observable: true, reason: null, classes };
|
|
158
|
+
}
|
|
@@ -104,6 +104,7 @@ export function parseOsvOutput(raw) {
|
|
|
104
104
|
aliases: vuln.aliases || [],
|
|
105
105
|
severity: extractSeverity(vuln, groups),
|
|
106
106
|
summary: vuln.summary || vuln.details?.split("\n")[0] || "",
|
|
107
|
+
publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
|
|
107
108
|
package: name,
|
|
108
109
|
version,
|
|
109
110
|
ecosystem,
|
package/src/checks/ai-trash.js
CHANGED
|
@@ -23,7 +23,7 @@ function createAiTrashCheck() {
|
|
|
23
23
|
ok: false,
|
|
24
24
|
severity: "warn",
|
|
25
25
|
detail: "kj-trash not found — destructive ops unprotected",
|
|
26
|
-
fix: "Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
|
|
26
|
+
fix: "Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
|
|
27
27
|
};
|
|
28
28
|
},
|
|
29
29
|
};
|
package/src/checks/mcp-health.js
CHANGED
|
@@ -109,7 +109,7 @@ export function createKarajanMcpCheck() {
|
|
|
109
109
|
detail: result.ok
|
|
110
110
|
? result.detail
|
|
111
111
|
: `karajan-mcp did not respond: ${result.detail} — OPTIONAL in v4: your agent runs kj directly; only shell-less hosts need MCP`,
|
|
112
|
-
fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g karajan-code`) — the standalone binary does not bundle it",
|
|
112
|
+
fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g @karajan-family/code`) — the standalone binary does not bundle it",
|
|
113
113
|
extra: { binPath },
|
|
114
114
|
};
|
|
115
115
|
},
|
|
@@ -45,7 +45,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
|
|
|
45
45
|
ok: false,
|
|
46
46
|
severity: "warn",
|
|
47
47
|
detail: "better-sqlite3 native build was skipped — pnpm blocks dependency build scripts by default",
|
|
48
|
-
fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g karajan-code`",
|
|
48
|
+
fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g @karajan-family/code`",
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
return {
|
|
@@ -55,7 +55,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
|
|
|
55
55
|
// modules by design — name the consequence (RAG/board/MCP need the
|
|
56
56
|
// npm install) instead of implying the install is broken.
|
|
57
57
|
detail: `better-sqlite3 failed to load: ${firstLine} — DB-backed features (RAG, board, MCP) unavailable; the standalone binary does not bundle native modules`,
|
|
58
|
-
fix: "Reinstall to rebuild native modules: `npm i -g karajan-code`",
|
|
58
|
+
fix: "Reinstall to rebuild native modules: `npm i -g @karajan-family/code`",
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
},
|
|
@@ -9,6 +9,37 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
11
|
import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
|
|
12
|
+
import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
13
|
+
|
|
14
|
+
// KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
|
|
15
|
+
// policy IN FORCE now, not the one each PR was merged under. Artifact rules
|
|
16
|
+
// only: diff-threshold invariants are PR-scoped by definition — skipped, and
|
|
17
|
+
// said — so no line metric is needed (without a tag, the whole tree counts).
|
|
18
|
+
async function policyRangeCheck(projectDir) {
|
|
19
|
+
const { policy, errors } = loadPolicy({ projectDir });
|
|
20
|
+
if (errors.length > 0) return { name: "policy", ok: false, detail: `policy.yml invalid — ${errors[0]}` };
|
|
21
|
+
const described = await runCommand("git", ["-C", projectDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]);
|
|
22
|
+
const tag = described.exitCode === 0 ? described.stdout.trim() : null;
|
|
23
|
+
const scope = tag ? `${tag}..HEAD` : "whole history (no previous tag)";
|
|
24
|
+
const prScoped = new Set((policy.invariants ?? []).filter((i) => i.kind === "diff-threshold").map((i) => i.id));
|
|
25
|
+
try {
|
|
26
|
+
const listed = await runCommand("git", tag
|
|
27
|
+
? ["-C", projectDir, "diff", `${tag}..HEAD`, "--name-only"]
|
|
28
|
+
: ["-C", projectDir, "ls-tree", "-r", "--name-only", "HEAD"]);
|
|
29
|
+
if (listed.exitCode !== 0) throw new Error((listed.stderr || "git failed").trim());
|
|
30
|
+
const files = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
const violations = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
|
|
32
|
+
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
33
|
+
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
34
|
+
if (hard.length > 0) {
|
|
35
|
+
const list = hard.map((v) => `[${v.rule_id}]${v.file ? ` ${v.file}` : ""}`).join(", ");
|
|
36
|
+
return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}` };
|
|
37
|
+
}
|
|
38
|
+
return { name: "policy", ok: true, detail: `${scope} clean against the current policy (${violations.length} warning(s))${skipped}` };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { name: "policy", ok: false, detail: `could not evaluate ${scope}: ${err.message}` };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
12
43
|
|
|
13
44
|
const semverCmp = (a, b) => {
|
|
14
45
|
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
@@ -93,10 +124,39 @@ async function declaredItems(projectDir, config, version) {
|
|
|
93
124
|
return checks;
|
|
94
125
|
}
|
|
95
126
|
|
|
127
|
+
/**
|
|
128
|
+
* MIG-B (KJC-TSK-0752, ADR 0004): while a package dual-publishes under two npm
|
|
129
|
+
* names, their `latest` dist-tags must move in LOCKSTEP. A torn dual-publish —
|
|
130
|
+
* one name released, the other not — is invisible from the repo (both installs
|
|
131
|
+
* "work") and every surface that teaches one name silently diverges from the
|
|
132
|
+
* other. The pair is read from scripts/dual-publish.mjs, which is the one
|
|
133
|
+
* place that knows it; no dual script, no check.
|
|
134
|
+
*/
|
|
135
|
+
export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
|
|
136
|
+
const script = join(projectDir, "scripts", "dual-publish.mjs");
|
|
137
|
+
if (!pkg?.name || !existsSync(script)) return null;
|
|
138
|
+
const src = readFileSync(script, "utf8");
|
|
139
|
+
const legacy = (src.match(/LEGACY_NAME = "([^"]+)"/) || [])[1];
|
|
140
|
+
const scoped = (src.match(/SCOPED_NAME = "([^"]+)"/) || [])[1];
|
|
141
|
+
if (!legacy || !scoped || pkg.name !== legacy) return null;
|
|
142
|
+
const latest = async (name) => {
|
|
143
|
+
const out = await run("npm", ["view", name, "dist-tags.latest"], { cwd: projectDir });
|
|
144
|
+
if (out.exitCode !== 0) throw new Error(`npm view ${name} failed`);
|
|
145
|
+
return (out.stdout || "").trim();
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
const [a, b] = await Promise.all([latest(legacy), latest(scoped)]);
|
|
149
|
+
const ok = Boolean(a) && a === b;
|
|
150
|
+
return { name: "dual-publish", ok, detail: ok ? `${legacy} and ${scoped} both at ${a}` : `dist-tags diverge: ${legacy}@${a || "?"} vs ${scoped}@${b || "?"} — a torn dual-publish; publish the missing name before releasing on top` };
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return { name: "dual-publish", ok: false, detail: `could not read npm dist-tags (${err.message}) — the lockstep cannot be verified, and unverified is not ok` };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
96
156
|
export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
|
|
97
157
|
const { checks, version, pkg } = await genericChecks(projectDir);
|
|
98
158
|
const pack = await packPrivacyCheck(projectDir, pkg);
|
|
99
|
-
|
|
100
|
-
checks.push(...await declaredItems(projectDir, config, version));
|
|
159
|
+
const dual = await dualPublishCheck(projectDir, pkg);
|
|
160
|
+
checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
|
|
101
161
|
return { ok: checks.every((c) => c.ok), version, checks };
|
|
102
162
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossing what was said against what actually ran (CLM-A, KJC-TSK-0801).
|
|
3
|
+
*
|
|
4
|
+
* The transcript is the register of sources: every command, query and read left
|
|
5
|
+
* its output there, so nothing has to be annotated by hand. A datum that appears
|
|
6
|
+
* in some output is BACKED; one that appears nowhere came out of the model's
|
|
7
|
+
* memory (UNBACKED); one whose own source says otherwise is DENIED — and that
|
|
8
|
+
* is the only verdict that blocks, because it is a proven hallucination and not
|
|
9
|
+
* a suspicion.
|
|
10
|
+
*
|
|
11
|
+
* What cannot be decided is NOT_CHECKABLE, never an accusation: a guard that
|
|
12
|
+
* cries wolf gets switched off (KJC-PCS-0082).
|
|
13
|
+
*/
|
|
14
|
+
import { extractClaims } from "./extract.js";
|
|
15
|
+
|
|
16
|
+
export const BACKED = "backed";
|
|
17
|
+
export const UNBACKED = "unbacked";
|
|
18
|
+
export const DENIED = "denied";
|
|
19
|
+
export const NOT_CHECKABLE = "not_checkable";
|
|
20
|
+
|
|
21
|
+
const norm = (s) => String(s ?? "").toLowerCase();
|
|
22
|
+
const escape = (s) => s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Numbers must match as WHOLE tokens: searching "4" as a substring finds it inside "24.6 kB"
|
|
26
|
+
* and backs a figure nobody measured. Found while running this over a real message — the
|
|
27
|
+
* synthetic tests had passed. Ids, paths and versions are distinctive enough as substrings.
|
|
28
|
+
*/
|
|
29
|
+
function appearsIn(output, claim) {
|
|
30
|
+
const needle = norm(claim.value);
|
|
31
|
+
if (claim.kind === "path" || claim.kind === "card" || claim.kind === "version") return output.includes(needle);
|
|
32
|
+
return new RegExp(`(?<![\\w.])${escape(needle)}(?![\\w.])`).test(output);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "no cards", "[]", "0 results" — a source that positively states emptiness. */
|
|
36
|
+
const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?|items?|matches|cards?|files?)\b)/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{text: string, outputs: string[], userSaid?: string}} input
|
|
40
|
+
* outputs: tool outputs of the turn, in order. userSaid: what the user wrote (also a source).
|
|
41
|
+
* @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
|
|
42
|
+
*/
|
|
43
|
+
export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
44
|
+
// Backing can come from an output OR from the user (repeating their datum is not
|
|
45
|
+
// inventing). The DENIED analysis reads only the OUTPUTS: the user asking "how
|
|
46
|
+
// many cards are left?" mentions the noun without saying anything about emptiness,
|
|
47
|
+
// and must not veto a denial — found by the stop-gate wiring test.
|
|
48
|
+
const sources = outputs.map(norm);
|
|
49
|
+
const haystack = [...sources, norm(userSaid)];
|
|
50
|
+
const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack, sources) }));
|
|
51
|
+
return {
|
|
52
|
+
claims,
|
|
53
|
+
denied: claims.filter((c) => c.status === DENIED),
|
|
54
|
+
unbacked: claims.filter((c) => c.status === UNBACKED),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function verdictFor(claim, haystack, sources) {
|
|
59
|
+
if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
|
|
60
|
+
|
|
61
|
+
// A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
|
|
62
|
+
// that is the "four cards are waiting" case, and it is the one that blocks.
|
|
63
|
+
if (claim.kind === "count" && Number(claim.value) > 0) {
|
|
64
|
+
const noun = nounAfterCount(claim.sentence, claim.value);
|
|
65
|
+
if (noun) {
|
|
66
|
+
const mentions = sources.filter((h) => h.includes(norm(noun)));
|
|
67
|
+
if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
|
|
68
|
+
}
|
|
69
|
+
// Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
|
|
70
|
+
if (Number(claim.value) <= 3) return NOT_CHECKABLE;
|
|
71
|
+
}
|
|
72
|
+
return UNBACKED;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nounAfterCount(sentence, value) {
|
|
76
|
+
const m = new RegExp(`${value}\\s+([a-zá-ú]{4,})`, "i").exec(sentence);
|
|
77
|
+
return m ? m[1] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One human line per problem; the message is what makes a guard usable. */
|
|
81
|
+
export function formatClaimReport({ denied, unbacked }) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
for (const c of denied) lines.push(`✗ "${c.value}" (${c.kind}) is DENIED by the output that should back it — ${c.sentence.slice(0, 100)}`);
|
|
84
|
+
for (const c of unbacked) lines.push(`? "${c.value}" (${c.kind}) has no backing in this turn — verify it or say it is from memory`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard data in what the AI says (CLM-A, KJC-TSK-0801, ADR "claims with evidence").
|
|
3
|
+
*
|
|
4
|
+
* A model states an invented figure with the same confidence as a measured one,
|
|
5
|
+
* and that figure travels to a PR, a card, another session or the user, where
|
|
6
|
+
* nobody checks it again. This module pulls the CHECKABLE data out of a text —
|
|
7
|
+
* counts, versions, file paths, card ids, commit SHAs — so it can be crossed
|
|
8
|
+
* against what actually ran. Prose is left alone: only data is verifiable.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic and free: no model in the loop. Verifying must be cheaper than
|
|
11
|
+
* inventing, or nobody will verify.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Sentences that ALREADY admit they are unverified are respected, never reported. */
|
|
15
|
+
const HEDGES = /\b(de memoria|sin (comprobar|verificar)|no (lo )?he (comprobado|verificado)|creo que|puede que|quizá|quizás|probablemente|from memory|unverified|not checked|i think|probably)\b/i;
|
|
16
|
+
|
|
17
|
+
// Most specific first: a number already claimed as a PR or a version is not also a bare count.
|
|
18
|
+
// A number with no unit next to it (an OTP, a phone) is deliberately NOT a claim: it is not
|
|
19
|
+
// verifiable from prose, and some of them are secrets that must never travel into a report.
|
|
20
|
+
const PATTERNS = [
|
|
21
|
+
{ kind: "card", re: /\b([A-Z]{3}-(?:TSK|BUG|PCS|SPR|PLA|PRP)-\d{4})\b/g, value: (m) => m[1] },
|
|
22
|
+
{ kind: "path", re: /\b((?:[\w.-]+\/){1,}[\w.-]+\.\w{1,5})\b/g, value: (m) => m[1] },
|
|
23
|
+
{ kind: "version", re: /\bv?(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/g, value: (m) => m[1] },
|
|
24
|
+
{ kind: "pr", re: /(?:^|[\s(])#(\d{2,6})\b/g, value: (m) => m[1] },
|
|
25
|
+
{ kind: "sha", re: /\b([0-9a-f]{7,40})\b/g, value: (m) => m[1] },
|
|
26
|
+
// A number that means something: "1004 ficheros", "8 ocurrencias", "53 tests".
|
|
27
|
+
{ kind: "count", re: /\b(\d[\d.,]*)\s+(?=[a-záéíóúñ]{3,})/gi, value: (m) => m[1].replaceAll(".", "").replaceAll(",", "") },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Splits into sentences so a hedge only covers what it is attached to. */
|
|
31
|
+
const sentences = (text) => String(text || "").split(/(?<=[.!?\n])\s+/).filter(Boolean);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {Array<{kind: string, value: string, sentence: string}>} unique claims, in order.
|
|
36
|
+
*/
|
|
37
|
+
export function extractClaims(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const sentence of sentences(text)) {
|
|
41
|
+
if (HEDGES.test(sentence)) continue; // saying "I did not check" is the behaviour to encourage
|
|
42
|
+
const claimedHere = new Set();
|
|
43
|
+
for (const { kind, re, value } of PATTERNS) {
|
|
44
|
+
for (const m of sentence.matchAll(re)) {
|
|
45
|
+
const v = value(m);
|
|
46
|
+
if (claimedHere.has(v)) continue; // already claimed as something more specific
|
|
47
|
+
claimedHere.add(v);
|
|
48
|
+
const key = `${kind}:${v}`;
|
|
49
|
+
if (seen.has(key)) continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
out.push({ kind, value: v, sentence: sentence.trim() });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading one turn out of the transcript (CLM-B, KJC-TSK-0802).
|
|
3
|
+
*
|
|
4
|
+
* The transcript is the register of sources: what the AI finally said, what the
|
|
5
|
+
* user asked, and every tool output in between. Nothing is annotated by hand —
|
|
6
|
+
* this just reads what the session already wrote.
|
|
7
|
+
*
|
|
8
|
+
* A turn = from the user's last real message to the end. A "user" entry whose
|
|
9
|
+
* content is a tool_result is NOT the user talking: it is the machine answering.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const MAX_OUTPUT = 20_000; // a single huge output must not eat the whole check
|
|
14
|
+
const blocks = (entry) => {
|
|
15
|
+
const c = entry?.message?.content;
|
|
16
|
+
return Array.isArray(c) ? c : typeof c === "string" ? [{ type: "text", text: c }] : [];
|
|
17
|
+
};
|
|
18
|
+
const isToolResult = (entry) => blocks(entry).some((b) => b.type === "tool_result");
|
|
19
|
+
const textOf = (value) =>
|
|
20
|
+
typeof value === "string" ? value : Array.isArray(value) ? value.map((b) => b?.text ?? "").join("\n") : String(value ?? "");
|
|
21
|
+
|
|
22
|
+
/** Parses the JSONL, ignoring lines that are not valid JSON (a partial write must not throw). */
|
|
23
|
+
export function readEntries(path) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
try { out.push(JSON.parse(line)); } catch { /* a half-written line is not a reason to fail */ }
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} path transcript_path given by the hook
|
|
34
|
+
* @returns {{text: string, outputs: string[], userSaid: string}}
|
|
35
|
+
* text: what the AI says at the end of the turn (its final prose, no thinking).
|
|
36
|
+
*/
|
|
37
|
+
export function readTurn(path) {
|
|
38
|
+
const entries = readEntries(path);
|
|
39
|
+
const startedAt = entries.findLastIndex((e) => e.type === "user" && !isToolResult(e));
|
|
40
|
+
const turn = startedAt >= 0 ? entries.slice(startedAt) : entries;
|
|
41
|
+
|
|
42
|
+
const userSaid = startedAt >= 0 ? textOf(entries[startedAt]?.message?.content) : "";
|
|
43
|
+
const outputs = [];
|
|
44
|
+
for (const entry of turn) {
|
|
45
|
+
for (const b of blocks(entry)) {
|
|
46
|
+
if (b.type === "tool_result") outputs.push(textOf(b.content).slice(0, MAX_OUTPUT));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The final message is the last assistant entry that actually says something
|
|
50
|
+
// (one with tool_use only is the AI working, not the AI reporting).
|
|
51
|
+
const finals = turn.filter((e) => e.type === "assistant" && blocks(e).some((b) => b.type === "text" && b.text?.trim()));
|
|
52
|
+
const text = finals.length ? blocks(finals.at(-1)).filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
|
|
53
|
+
return { text, outputs, userSaid };
|
|
54
|
+
}
|
|
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
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
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
|
|
34
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"] },
|