karajan-code 4.22.0 → 4.24.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 +9 -0
- package/package.json +3 -2
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/utils/board-view.js +14 -13
- package/packages/hu-board/public/utils/command-launcher.js +42 -6
- package/packages/hu-board/public/utils/log-panel.js +4 -4
- package/packages/hu-board/public/utils/maggle-mode.js +124 -0
- package/packages/hu-board/public/utils/modals.js +11 -2
- package/packages/hu-board/public/utils/project-picker-view.js +3 -3
- package/packages/hu-board/src/auth.js +29 -1
- package/scripts/install-binary.ps1 +12 -3
- package/scripts/install-binary.sh +13 -3
- 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/claims/cross-check.js +10 -5
- package/src/cli/advanced-commands.js +2 -1
- package/src/cli/register-meta.js +36 -3
- package/src/commands/board.js +19 -4
- package/src/commands/claims.js +33 -1
- package/src/commands/go.js +110 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +30 -7
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/harden/sentinel-hooks.js +98 -6
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/privacy/scan.js +23 -1
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- 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 +174 -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,
|
|
@@ -41,8 +41,13 @@ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?
|
|
|
41
41
|
* @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
|
|
42
42
|
*/
|
|
43
43
|
export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
44
|
-
|
|
45
|
-
|
|
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) }));
|
|
46
51
|
return {
|
|
47
52
|
claims,
|
|
48
53
|
denied: claims.filter((c) => c.status === DENIED),
|
|
@@ -50,15 +55,15 @@ export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
function verdictFor(claim, haystack) {
|
|
58
|
+
function verdictFor(claim, haystack, sources) {
|
|
54
59
|
if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
|
|
55
60
|
|
|
56
|
-
// A count stated as non-zero while every
|
|
61
|
+
// A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
|
|
57
62
|
// that is the "four cards are waiting" case, and it is the one that blocks.
|
|
58
63
|
if (claim.kind === "count" && Number(claim.value) > 0) {
|
|
59
64
|
const noun = nounAfterCount(claim.sentence, claim.value);
|
|
60
65
|
if (noun) {
|
|
61
|
-
const mentions =
|
|
66
|
+
const mentions = sources.filter((h) => h.includes(norm(noun)));
|
|
62
67
|
if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
|
|
63
68
|
}
|
|
64
69
|
// Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
/** The few commands a newcomer needs. Shown in `kj --help`. */
|
|
9
9
|
export const CORE_COMMANDS = [
|
|
10
|
+
"go",
|
|
10
11
|
"start",
|
|
11
12
|
"init",
|
|
12
13
|
"run",
|
|
@@ -30,7 +31,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
31
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
32
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
33
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
|
|
34
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
|
|
34
35
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
35
36
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
37
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,9 +4,11 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
|
+
import { goCommand } from "../commands/go.js";
|
|
7
8
|
import { identityCommand } from "../commands/identity.js";
|
|
8
9
|
import { policyCommand } from "../commands/policy.js";
|
|
9
|
-
import { claimsCommand } from "../commands/claims.js";
|
|
10
|
+
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
11
|
+
import { stewardSweepCommand } from "../commands/steward.js";
|
|
10
12
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
11
13
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
12
14
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -147,6 +149,17 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
147
149
|
});
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
// MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
|
|
153
|
+
// question, and the person lands inside a governed conversation.
|
|
154
|
+
program
|
|
155
|
+
.command("go")
|
|
156
|
+
.description("Arranca Karajan sin saber nada: detecta tu agente, prepara el proyecto, abre el tablero y te deja en la conversación")
|
|
157
|
+
.action(async (flags) => {
|
|
158
|
+
await withConfig(pkgVersion, "go", flags, async ({ config, logger }) => {
|
|
159
|
+
await goCommand({ config, logger, flags });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
150
163
|
program
|
|
151
164
|
.command("start")
|
|
152
165
|
.description("Single entry point: assess the project and recommend the next step")
|
|
@@ -314,12 +327,32 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
314
327
|
|
|
315
328
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
316
329
|
// CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
|
|
317
|
-
program.command("
|
|
318
|
-
|
|
330
|
+
const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
|
|
331
|
+
stewardCmd.command("sweep")
|
|
332
|
+
.description("Barrido read-only de los invariantes: deja el informe versionado en .karajan/steward/ (md + json) y sale con 1 solo si algo está ROTO — unknown y not-observable informan con su remedio")
|
|
333
|
+
.option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
|
|
334
|
+
.option("--json", "Machine-readable")
|
|
335
|
+
.action(async (flags) => {
|
|
336
|
+
await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
|
|
337
|
+
process.exitCode = await stewardSweepCommand({ flags, config });
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
const claimsCmd = program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)");
|
|
341
|
+
claimsCmd.command("check")
|
|
319
342
|
.description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
|
|
320
343
|
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
|
|
344
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
321
345
|
.option("--json", "Machine-readable")
|
|
322
346
|
.action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
|
|
347
|
+
claimsCmd.command("gate")
|
|
348
|
+
.description("El mismo cruce, gobernado por method_gates.claims del proyecto (off|warn|block) — lo invocan los hooks; off = silencio, block = exit 2 solo con un dato desmentido")
|
|
349
|
+
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
|
|
350
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
351
|
+
.action(async (flags) => {
|
|
352
|
+
await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
|
|
353
|
+
process.exitCode = await claimsGateCommand({ flags, config });
|
|
354
|
+
});
|
|
355
|
+
});
|
|
323
356
|
|
|
324
357
|
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
|
|
325
358
|
policyCmd.command("eval")
|
package/src/commands/board.js
CHANGED
|
@@ -87,8 +87,23 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
|
|
|
87
87
|
* @returns {string}
|
|
88
88
|
*/
|
|
89
89
|
export function buildBoardUrl(port, projectSlug) {
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
return projectSlug ? boardUrl(port, `/#board/${projectSlug}`) : boardUrl(port);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Base board URL plus an optional absolute path (query and/or hash) —
|
|
95
|
+
* `kj go` opens `/?maggle=1` so the frontend renders in plain language
|
|
96
|
+
* (KJC-TSK-0810). A relative path is a caller bug, not something to fix
|
|
97
|
+
* up silently.
|
|
98
|
+
* @param {number} port
|
|
99
|
+
* @param {string} [path]
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function boardUrl(port, path = "") {
|
|
103
|
+
if (path && !path.startsWith("/")) {
|
|
104
|
+
throw new Error(`board path must start with "/": ${path}`);
|
|
105
|
+
}
|
|
106
|
+
return `http://localhost:${port}${path}`;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
/**
|
|
@@ -368,7 +383,7 @@ export function renderBoardBanner({ url, status, projectName }) {
|
|
|
368
383
|
return ["", rule, ...content, rule, ""].join("\n");
|
|
369
384
|
}
|
|
370
385
|
|
|
371
|
-
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
|
|
386
|
+
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger, path }) {
|
|
372
387
|
switch (action) {
|
|
373
388
|
case "start": {
|
|
374
389
|
let result;
|
|
@@ -422,7 +437,7 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
|
|
|
422
437
|
logger.info("HU Board is not running. Starting it first...");
|
|
423
438
|
await startBoard(port);
|
|
424
439
|
}
|
|
425
|
-
const url =
|
|
440
|
+
const url = boardUrl(port, path);
|
|
426
441
|
// eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
|
|
427
442
|
const { default: open } = await import("open").catch(() => ({ default: null }));
|
|
428
443
|
if (open) {
|
package/src/commands/claims.js
CHANGED
|
@@ -9,10 +9,41 @@
|
|
|
9
9
|
* It fails OPEN. A verifier that cannot read the transcript says so and gets out
|
|
10
10
|
* of the way: a broken check must never hold a session hostage.
|
|
11
11
|
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { readTurn } from "../claims/turn.js";
|
|
13
14
|
import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `kj claims gate` — the same check, run by the Stop hook with the PROJECT's
|
|
18
|
+
* say-so. The hook stays policy-free: kj reads `method_gates.claims` and
|
|
19
|
+
* decides. "off" (default: adoption is explicit) exits 0 in silence; "warn"
|
|
20
|
+
* reports and never blocks; "block" refuses only a datum DENIED by its own
|
|
21
|
+
* source — unbacked data is reported either way, per the accepted ADR:
|
|
22
|
+
* inform always, block almost never.
|
|
23
|
+
*/
|
|
24
|
+
export async function claimsGateCommand({ flags = {}, config = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
|
|
25
|
+
const mode = config?.method_gates?.claims ?? "off";
|
|
26
|
+
if (mode !== "warn" && mode !== "block") return 0;
|
|
27
|
+
let turn;
|
|
28
|
+
try {
|
|
29
|
+
turn = readTurnFn(flags.transcript);
|
|
30
|
+
// CLM-C: with --file the ARTIFACT is what gets checked — a PR body, a card, a
|
|
31
|
+
// note — against the same turn's outputs. The final message is what outlives
|
|
32
|
+
// the turn least; the artifact is what outlives it most.
|
|
33
|
+
if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
|
|
34
|
+
} catch {
|
|
35
|
+
return 0; // not observable: a gate that cannot read its inputs gets out of the way
|
|
36
|
+
}
|
|
37
|
+
const result = crossCheck(turn);
|
|
38
|
+
if (result.denied.length && mode === "block") {
|
|
39
|
+
logger.error(formatClaimReport(result));
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
if (result.denied.length || result.unbacked.length) logger.error(formatClaimReport(result));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
|
|
16
47
|
const path = flags.transcript;
|
|
17
48
|
if (!path) {
|
|
18
49
|
logger.error("kj claims check: --transcript <path> is required");
|
|
@@ -21,6 +52,7 @@ export async function claimsCommand({ flags = {}, logger = console, readTurnFn =
|
|
|
21
52
|
let turn;
|
|
22
53
|
try {
|
|
23
54
|
turn = readTurnFn(path);
|
|
55
|
+
if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
|
|
24
56
|
} catch (err) {
|
|
25
57
|
// Not observable: the transcript could not be read. Never reported as clean.
|
|
26
58
|
const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj go` (MGL-A, KJC-TSK-0808, epic KJC-PCS-0084) — the muggle launcher.
|
|
3
|
+
* One command for a person with no computing background: detect their agents,
|
|
4
|
+
* ask AT MOST one question, prepare the project silently, open the board, and
|
|
5
|
+
* leave them inside a conversation that already knows the method. What cannot
|
|
6
|
+
* be hidden is said honestly: the agent's account and login are THEIRS — kj
|
|
7
|
+
* never touches credentials. Everything is injectable so tests spawn nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
|
+
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
|
+
import { envInstallCommand } from "./env.js";
|
|
16
|
+
import { boardCommand } from "./board.js";
|
|
17
|
+
// The interactive launchers a muggle can live inside (v1: the two the epic
|
|
18
|
+
// names). Auth heuristics are the same cheap file checks reviewer-fallback
|
|
19
|
+
// uses: menu signal, never a spawned process.
|
|
20
|
+
export const MAGGLE_AGENTS = [
|
|
21
|
+
{ name: "claude", label: "Claude Code (Anthropic)", authPaths: [".claude.json", ".claude"], install: "npm install -g @anthropic-ai/claude-code", login: "claude (sigue el enlace de inicio de sesión que te muestre)" },
|
|
22
|
+
{ name: "codex", label: "Codex (OpenAI)", authPaths: [".codex/auth.json"], install: "npm install -g @openai/codex", login: "codex login" },
|
|
23
|
+
];
|
|
24
|
+
export async function detectMaggleAgents({ home = os.homedir(), checkBin = checkBinary } = {}) {
|
|
25
|
+
return Promise.all(
|
|
26
|
+
MAGGLE_AGENTS.map(async (a) => {
|
|
27
|
+
let installed = false;
|
|
28
|
+
try { installed = (await checkBin(a.name)).ok; } catch { /* not installed */ }
|
|
29
|
+
const authenticated = installed && a.authPaths.some((p) => existsSync(path.join(home, p)));
|
|
30
|
+
return { ...a, installed, authenticated };
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
/** The session's opening prompt — SHORT and in plain language. The full
|
|
35
|
+
* playbook already lives in the agent files env-install wrote; duplicating it
|
|
36
|
+
* here would dilute it. This prompt sets the tone for a muggle. */
|
|
37
|
+
export function buildGoPrompt() {
|
|
38
|
+
return [
|
|
39
|
+
"Arrancas dentro de un proyecto gobernado por Karajan (las reglas del método ya están en los ficheros de agente de este proyecto: síguelas siempre).",
|
|
40
|
+
"La persona que te habla puede no saber programar. Habla en lenguaje llano: sin jerga, sin siglas sin explicar, y resume cada resultado en una o dos frases antes del detalle.",
|
|
41
|
+
"Antes de cambios importantes, di en llano qué vas a hacer y qué pasará. Si algo falla, explica qué pasó y cuál es el siguiente paso — nunca un volcado de error a secas.",
|
|
42
|
+
"El tablero del proyecto está abierto en su navegador: cuando termines algo, recuérdale que puede verlo ahí.",
|
|
43
|
+
"Empieza presentándote en dos frases y preguntando qué quiere construir o cambiar hoy.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
async function defaultPrepare({ config, logger }) {
|
|
47
|
+
await envInstallCommand({ config, logger, flags: { yes: true } });
|
|
48
|
+
}
|
|
49
|
+
export async function defaultBoard({ config, logger, runBoard = boardCommand }) {
|
|
50
|
+
const port = config.hu_board?.port || 4000;
|
|
51
|
+
await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
|
|
52
|
+
// /?maggle=1 switches the frontend to plain language (KJC-TSK-0810) —
|
|
53
|
+
// the muggle's window opens already speaking their language.
|
|
54
|
+
await runBoard({ action: "open", port, bind: "127.0.0.1", path: "/?maggle=1", logger });
|
|
55
|
+
}
|
|
56
|
+
function defaultLaunch(agent, prompt) {
|
|
57
|
+
// Interactive session: the muggle LIVES here. CLAUDECODE is stripped so a
|
|
58
|
+
// nested Claude does not refuse to start (the known subprocess quirk).
|
|
59
|
+
const { CLAUDECODE: _omit, ...env } = process.env;
|
|
60
|
+
return new Promise((resolvePromise) => {
|
|
61
|
+
const child = spawn(agent.name, [prompt], { stdio: "inherit", env });
|
|
62
|
+
child.on("exit", (code) => resolvePromise(code ?? 0));
|
|
63
|
+
child.on("error", (err) => {
|
|
64
|
+
console.error(`No se pudo arrancar ${agent.label}: ${err.message}`);
|
|
65
|
+
resolvePromise(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function goCommand({ config = {}, logger = console, flags = {}, deps = {} } = {}) {
|
|
70
|
+
const projectDir = config.projectDir || process.cwd();
|
|
71
|
+
const agents = await (deps.detect ?? detectMaggleAgents)();
|
|
72
|
+
const ready = agents.filter((a) => a.installed && a.authenticated);
|
|
73
|
+
const needLogin = agents.filter((a) => a.installed && !a.authenticated);
|
|
74
|
+
if (ready.length === 0) {
|
|
75
|
+
if (needLogin.length > 0) {
|
|
76
|
+
logger.error?.("Tienes el agente instalado pero falta iniciar sesión — eso solo puedes hacerlo tú (la cuenta es tuya; Karajan nunca toca tus credenciales):");
|
|
77
|
+
for (const a of needLogin) logger.error?.(` ${a.label}: ${a.login}`);
|
|
78
|
+
logger.error?.("Cuando hayas iniciado sesión, vuelve a escribir: kj go");
|
|
79
|
+
} else {
|
|
80
|
+
logger.error?.("Karajan trabaja con un agente de IA que aún no tienes instalado. Elige uno, instálalo con su comando, e inicia sesión con TU cuenta (la cuenta es tuya):");
|
|
81
|
+
for (const a of agents) logger.error?.(` ${a.label}: ${a.install}`);
|
|
82
|
+
logger.error?.("Después, vuelve a escribir: kj go");
|
|
83
|
+
}
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
let chosen = ready[0];
|
|
88
|
+
if (ready.length > 1) {
|
|
89
|
+
const ask = deps.ask ?? createCliAskQuestion({ flags });
|
|
90
|
+
const answer = await ask("¿Con cuál de tus agentes quieres trabajar?", { options: ready.map((a) => a.name), default: ready[0].name });
|
|
91
|
+
chosen = ready.find((a) => a.name === String(answer).trim()) ?? ready[0];
|
|
92
|
+
}
|
|
93
|
+
logger.info?.(`Trabajarás con ${chosen.label}.`);
|
|
94
|
+
// Prepare ONCE: decisions already taken are never re-asked.
|
|
95
|
+
if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
|
|
96
|
+
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
97
|
+
await (deps.prepare ?? defaultPrepare)({ config, logger });
|
|
98
|
+
}
|
|
99
|
+
// The board is the muggle's window — unless the project turned it off
|
|
100
|
+
// (hu_board.enabled false is respected: KJC-BUG-0152). A board failure is
|
|
101
|
+
// said and never stops the conversation from starting.
|
|
102
|
+
if (config.hu_board?.enabled !== false) {
|
|
103
|
+
try { await (deps.board ?? defaultBoard)({ config, logger }); } catch (err) { logger.warn?.(`El tablero no pudo abrirse (${err.message}) — la conversación arranca igual.`); }
|
|
104
|
+
}
|
|
105
|
+
const prompt = (deps.prompt ?? buildGoPrompt)();
|
|
106
|
+
logger.info?.("Abriendo tu conversación… (escribe ahí lo que necesites, en tu idioma)");
|
|
107
|
+
const code = await (deps.launch ?? defaultLaunch)(chosen, prompt);
|
|
108
|
+
process.exitCode = code;
|
|
109
|
+
return code;
|
|
110
|
+
}
|
package/src/commands/hu.js
CHANGED
|
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
|
|
|
43
43
|
return { identical, similar };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
|
|
47
|
+
// invariants land in the same backlog the brain already consumes.
|
|
48
|
+
export async function backlogPlan(projectDir) {
|
|
47
49
|
const plans = await listPlans(projectDir);
|
|
48
50
|
const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
|
|
49
51
|
if (existing) return loadPlan(projectDir, existing.planId);
|