karajan-code 4.43.0 → 4.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/register-meta.js +10 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/identity.js +9 -0
- package/src/git/automation.js +38 -4
- package/src/git/hu-automation.js +67 -5
- package/src/git/run-identity.js +48 -0
- package/src/harden/phone-invite.js +53 -0
- package/src/harden/sentinel/sessionstart.mjs +5 -0
- package/src/harden/sentinel-hooks.js +19 -0
- package/src/orchestrator/drivers/run-hu-batch.js +16 -8
- package/src/orchestrator/hu-sub-pipeline.js +2 -0
- package/src/orchestrator/preflight-checks.js +21 -0
- package/src/planning-game/pipeline-adapter.js +14 -5
- package/src/utils/git.js +19 -3
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -169,6 +169,16 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
// KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
|
|
173
|
+
identityCmd
|
|
174
|
+
.command("phone-invite")
|
|
175
|
+
.description("Print, once per kj version, the invitation to enroll the phone when none is enrolled (nothing otherwise)")
|
|
176
|
+
.action(async (flags) => {
|
|
177
|
+
await withConfig(pkgVersion, "identity-phone-invite", flags, async ({ config }) => {
|
|
178
|
+
process.exitCode = await identityCommand({ action: "phone-invite", config, flags: { ...flags, version: pkgVersion } });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
172
182
|
// BOOT-A (KJC-TSK-0857, cold-start ADR): the pieces existed, the ORDER did
|
|
173
183
|
// not. One command from an empty directory to a project under the method.
|
|
174
184
|
program
|
package/src/commands/doctor.js
CHANGED
|
@@ -21,6 +21,7 @@ import { getBinaryChecks } from "../checks/binaries.js";
|
|
|
21
21
|
import { getSonarChecks } from "../checks/sonar.js";
|
|
22
22
|
import { getOllamaChecks } from "../checks/ollama.js";
|
|
23
23
|
import { getHarnessScorecardChecks } from "../checks/harness-scorecard.js";
|
|
24
|
+
import { getPhoneChecks } from "../harden/phone-invite.js";
|
|
24
25
|
import { getCiChecks } from "../checks/ci.js";
|
|
25
26
|
import { getRtkChecks } from "../checks/rtk.js";
|
|
26
27
|
import { getSqueezrChecks } from "../checks/squeezr.js";
|
|
@@ -67,6 +68,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
|
|
|
67
68
|
...getSonarChecks(),
|
|
68
69
|
...getOllamaChecks(),
|
|
69
70
|
...getHarnessScorecardChecks(),
|
|
71
|
+
...getPhoneChecks(),
|
|
70
72
|
...getPortChecks(),
|
|
71
73
|
...getTokenChecks(config),
|
|
72
74
|
...getMcpHealthChecks(),
|
package/src/commands/identity.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { addSigner, enrollPhone } from "../harden/phone-sign.js";
|
|
12
|
+
import { phoneInvite } from "../harden/phone-invite.js";
|
|
12
13
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
13
14
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
14
15
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -39,6 +40,14 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
39
40
|
return 0;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// KJC-TSK-0986 (HUM-F): the once-per-version invitation to enroll the phone,
|
|
44
|
+
// printed for the SessionStart hook (which cannot import kj) and for scripts.
|
|
45
|
+
if (action === "phone-invite") {
|
|
46
|
+
const text = phoneInvite({ home: deps.home, version: flags.version });
|
|
47
|
+
if (text) log(text);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
if (action === "set") {
|
|
43
52
|
const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
|
|
44
53
|
if (!candidate.gh_user || !candidate.git_email) {
|
package/src/git/automation.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { addCheckpoint } from "../session/store.js";
|
|
7
7
|
import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
|
|
8
8
|
import { branchSize } from "../commands/pr-size.js";
|
|
9
|
+
import { pushRemedy } from "./hu-automation.js";
|
|
10
|
+
import { resolveRunIdentity } from "./run-identity.js";
|
|
11
|
+
import { getRunContext } from "../utils/run-context.js";
|
|
9
12
|
import {
|
|
10
13
|
ensureGitRepo,
|
|
11
14
|
currentBranch,
|
|
@@ -14,6 +17,7 @@ import {
|
|
|
14
17
|
syncBaseBranch,
|
|
15
18
|
ensureBranchUpToDateWithBase,
|
|
16
19
|
createBranch,
|
|
20
|
+
checkoutBranch,
|
|
17
21
|
buildBranchName,
|
|
18
22
|
commitAll,
|
|
19
23
|
pushBranch,
|
|
@@ -104,6 +108,17 @@ export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
|
|
|
104
108
|
return commitMessageFromTask(task, taskType);
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
// KJC-BUG-0249 (#1896): a branch named after the card is the same on a second
|
|
112
|
+
// run of that card, so one already there is taken, not recreated.
|
|
113
|
+
async function checkoutRunBranch(created) {
|
|
114
|
+
try {
|
|
115
|
+
await createBranch(created);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (!/already exists/i.test(err.message)) throw err;
|
|
118
|
+
await checkoutBranch(created);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
108
123
|
const enabled = config.git.auto_commit || config.git.auto_push || config.git.auto_pr;
|
|
109
124
|
if (!enabled) return { enabled: false };
|
|
@@ -112,6 +127,20 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
112
127
|
throw new Error("Git automation requested but current directory is not a git repository");
|
|
113
128
|
}
|
|
114
129
|
|
|
130
|
+
// KJC-BUG-0246 (#1893): one identity for every commit of the run, resolved
|
|
131
|
+
// once: the clone's declaration, else the repository's config, else the
|
|
132
|
+
// global one. From the global config it is said, because another session
|
|
133
|
+
// may switch it mid-run. Nothing resolvable: git refuses the commit loudly.
|
|
134
|
+
const identity = resolveRunIdentity({ projectDir: config.projectDir || process.cwd() });
|
|
135
|
+
const runContext = getRunContext();
|
|
136
|
+
if (identity && runContext) runContext.gitIdentity = identity;
|
|
137
|
+
if (identity) {
|
|
138
|
+
logger.info(`Git identity for this run: ${identity.name} <${identity.email}> (${identity.source})`);
|
|
139
|
+
if (identity.source === "global") logger.warn("The identity comes from the global git config, which another session may switch mid-run: declare this clone's with kj identity set, or set user.email in this repository");
|
|
140
|
+
} else {
|
|
141
|
+
logger.warn("No git identity resolvable (kj identity set, or git config user.name and user.email): the first commit will fail");
|
|
142
|
+
}
|
|
143
|
+
|
|
115
144
|
const baseBranch = config.base_branch;
|
|
116
145
|
const autoRebase = config.git.auto_rebase !== false;
|
|
117
146
|
// KJC-BUG-0294 (#1993): what is dirty or untracked NOW is not the run's; the
|
|
@@ -123,7 +152,7 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
123
152
|
|
|
124
153
|
// New repo without commits: create branch directly (no fetch/sync possible)
|
|
125
154
|
if (!repoHasCommits) {
|
|
126
|
-
const created = buildBranchName(config.git.branch_prefix || "feat/", task);
|
|
155
|
+
const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
|
|
127
156
|
// git checkout -b works even without commits (creates orphan-like branch on first commit)
|
|
128
157
|
await createBranch(created);
|
|
129
158
|
logger.info(`New repo — created working branch: ${created}`);
|
|
@@ -148,8 +177,8 @@ export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
|
148
177
|
await syncBaseBranch({ baseBranch, autoRebase }).catch(() => {
|
|
149
178
|
// No remote tracking — skip sync for new projects
|
|
150
179
|
});
|
|
151
|
-
const created = buildBranchName(config.git.branch_prefix || "feat/", task);
|
|
152
|
-
await
|
|
180
|
+
const created = buildBranchName(config.git.branch_prefix || "feat/", task, session?.pg_task_id || null);
|
|
181
|
+
await checkoutRunBranch(created);
|
|
153
182
|
branch = created;
|
|
154
183
|
logger.info(`Created working branch: ${branch}`);
|
|
155
184
|
await addCheckpoint(session, { stage: "git-prep", branch, created: true });
|
|
@@ -353,7 +382,12 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
353
382
|
}
|
|
354
383
|
|
|
355
384
|
if (remoteAvailable && (config.git.auto_push || config.git.auto_pr)) {
|
|
356
|
-
|
|
385
|
+
// KJC-BUG-0248 (#1895): a refused push stops the run loudly, with its remedy.
|
|
386
|
+
try {
|
|
387
|
+
await pushBranch(gitCtx.branch);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
throw new Error(`push of ${gitCtx.branch} failed: ${err.message}. ${pushRemedy(err.message, gitCtx.branch)}`, { cause: err });
|
|
390
|
+
}
|
|
357
391
|
await addCheckpoint(session, { stage: "git-push", branch: gitCtx.branch });
|
|
358
392
|
logger.info(`Pushed branch: ${gitCtx.branch}`);
|
|
359
393
|
}
|
package/src/git/hu-automation.js
CHANGED
|
@@ -69,6 +69,33 @@ async function resolveExistingBranchRef(preferred) {
|
|
|
69
69
|
return preferred; // give up; caller will surface the original error
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Whether a branch name references the card, as a whole token (BB-002 is not BB-0022). */
|
|
73
|
+
export function branchReferencesCard(branch, cardId) {
|
|
74
|
+
const id = String(cardId).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
75
|
+
return new RegExp(`(^|[^a-z0-9])${id}([^a-z0-9]|$)`, "i").test(String(branch || ""));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function cardBranch({ cardId, story, config, logger }) {
|
|
79
|
+
const head = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {});
|
|
80
|
+
const current = head.exitCode === 0 ? head.stdout.trim() : "";
|
|
81
|
+
if (branchReferencesCard(current, cardId)) {
|
|
82
|
+
logger.info(`HU ${story.id}: on '${current}', the branch of card ${cardId}`);
|
|
83
|
+
return current;
|
|
84
|
+
}
|
|
85
|
+
const branchName = buildHuBranchName(config.git?.branch_prefix || "feat/", { id: cardId, title: story.title });
|
|
86
|
+
// A card branch left by an earlier run keeps its commits: taken, never reset.
|
|
87
|
+
const exists = await runCommand("git", ["rev-parse", "--verify", "--quiet", branchName], {});
|
|
88
|
+
const base = exists.exitCode === 0 ? null : await resolveExistingBranchRef(config.base_branch || "main");
|
|
89
|
+
const how = base ? `created from '${base}'` : "taken";
|
|
90
|
+
const res = await runCommand("git", base ? ["checkout", "-b", branchName, base] : ["checkout", branchName], {});
|
|
91
|
+
if (res.exitCode !== 0) {
|
|
92
|
+
logger.warn(`HU git: branch ${branchName} could not be ${how}: ${res.stderr}`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
logger.info(`HU ${story.id}: branch '${branchName}' ${how} for card ${cardId}`);
|
|
96
|
+
return branchName;
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
/**
|
|
73
100
|
* Create a branch for an HU starting from its resolved base.
|
|
74
101
|
* Returns the branch name created (or null if git automation is disabled).
|
|
@@ -80,10 +107,22 @@ async function resolveExistingBranchRef(preferred) {
|
|
|
80
107
|
* @param {object} params.logger
|
|
81
108
|
* @returns {Promise<string|null>}
|
|
82
109
|
*/
|
|
83
|
-
export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
110
|
+
export async function prepareHuBranch({ story, huBranches, config, logger, cardId = null }) {
|
|
84
111
|
if (!config.git?.auto_commit && !config.git?.auto_push && !config.git?.auto_pr) {
|
|
85
112
|
return null;
|
|
86
113
|
}
|
|
114
|
+
// KJC-BUG-0249 (#1896): with a Planning Game card the run is that card's
|
|
115
|
+
// work and lives on ONE branch named after it: the current one when it
|
|
116
|
+
// already references the card, else feat/<CARD-ID>-<slug>, created once
|
|
117
|
+
// and shared by every HU. HU ids name branches only when there is no card.
|
|
118
|
+
if (cardId) {
|
|
119
|
+
const known = huBranches.get(cardId);
|
|
120
|
+
const branch = known ?? (await cardBranch({ cardId, story, config, logger }));
|
|
121
|
+
if (!branch) return null;
|
|
122
|
+
huBranches.set(cardId, branch);
|
|
123
|
+
huBranches.set(story.id, branch);
|
|
124
|
+
return branch;
|
|
125
|
+
}
|
|
87
126
|
const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
|
|
88
127
|
const effectiveBase = await resolveExistingBranchRef(baseBranch);
|
|
89
128
|
if (effectiveBase !== baseBranch) {
|
|
@@ -103,18 +142,37 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
|
103
142
|
return branchName;
|
|
104
143
|
}
|
|
105
144
|
|
|
145
|
+
/**
|
|
146
|
+
* KJC-BUG-0248 (#1895): the remedy for a push git refused, read from its
|
|
147
|
+
* message. GitHub refuses a push that adds or changes a workflow file when
|
|
148
|
+
* the token lacks the `workflow` scope; that one has an exact fix.
|
|
149
|
+
*/
|
|
150
|
+
export function pushRemedy(message, branch) {
|
|
151
|
+
if (/workflow/i.test(message) && /scope/i.test(message)) {
|
|
152
|
+
return `the token lacks the workflow scope: run gh auth refresh -h github.com -s workflow, then git push -u origin ${branch}`;
|
|
153
|
+
}
|
|
154
|
+
return `push the branch yourself: git push -u origin ${branch}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The warnings a run reports when the push or the PR of any HU failed. */
|
|
158
|
+
export function collectGitWarnings(results) {
|
|
159
|
+
return results.flatMap((r) => (r.result?.git?.errors ?? []).map((e) => `${r.huId}: ${e.step} failed: ${e.message}. ${e.remedy}`));
|
|
160
|
+
}
|
|
161
|
+
|
|
106
162
|
/**
|
|
107
163
|
* After an HU is approved, commit its changes and optionally push + create PR.
|
|
164
|
+
* A push or PR that fails is not only a warn line: it travels in `errors`
|
|
165
|
+
* with its remedy, so the run's result says it (KJC-BUG-0248).
|
|
108
166
|
*
|
|
109
167
|
* @param {object} params
|
|
110
168
|
* @param {object} params.story
|
|
111
169
|
* @param {string} params.branchName
|
|
112
170
|
* @param {object} params.config
|
|
113
171
|
* @param {object} params.logger
|
|
114
|
-
* @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null}>}
|
|
172
|
+
* @returns {Promise<{committed: boolean, pushed: boolean, prUrl: string|null, branch: string|null, errors: Array<{step: string, message: string, remedy: string}>}>}
|
|
115
173
|
*/
|
|
116
174
|
export async function finalizeHuCommit({ story, branchName, config, logger, cwd = null }) {
|
|
117
|
-
const result = { committed: false, pushed: false, prUrl: null };
|
|
175
|
+
const result = { committed: false, pushed: false, prUrl: null, branch: branchName || null, errors: [] };
|
|
118
176
|
if (!branchName) return result;
|
|
119
177
|
|
|
120
178
|
const changed = await hasChanges(cwd);
|
|
@@ -139,7 +197,9 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
|
|
|
139
197
|
result.pushed = true;
|
|
140
198
|
logger.info(`HU ${story.id}: pushed '${branchName}'`);
|
|
141
199
|
} catch (err) {
|
|
142
|
-
|
|
200
|
+
const remedy = pushRemedy(err.message, branchName);
|
|
201
|
+
result.errors.push({ step: "push", message: err.message, remedy });
|
|
202
|
+
logger.warn(`HU ${story.id}: push failed: ${err.message} (${remedy})`);
|
|
143
203
|
}
|
|
144
204
|
}
|
|
145
205
|
|
|
@@ -163,7 +223,9 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
|
|
|
163
223
|
result.prUrl = url;
|
|
164
224
|
logger.info(`HU ${story.id}: PR created ${url}`);
|
|
165
225
|
} catch (err) {
|
|
166
|
-
|
|
226
|
+
const remedy = `open the PR yourself: gh pr create --base ${config.base_branch || "main"} --head ${branchName}`;
|
|
227
|
+
result.errors.push({ step: "pr", message: err.message, remedy });
|
|
228
|
+
logger.warn(`HU ${story.id}: PR creation failed: ${err.message} (${remedy})`);
|
|
167
229
|
}
|
|
168
230
|
}
|
|
169
231
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0246 (#1893): one git identity for the whole run.
|
|
3
|
+
*
|
|
4
|
+
* Of five commits of one run, three carried one account and two another: the
|
|
5
|
+
* pipeline let git resolve the author at every commit, and the host works with
|
|
6
|
+
* several global identities that other sessions switch at will. The identity
|
|
7
|
+
* is resolved ONCE, before the first commit, in a fixed order: the clone's
|
|
8
|
+
* declaration (`kj identity set`, .karajan/identity.local.yml), else the
|
|
9
|
+
* repository's own config, else the global one. Nothing resolvable is null,
|
|
10
|
+
* never a guess: git itself refuses the commit loudly in that case.
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readIdentity } from "../identity/store.js";
|
|
14
|
+
|
|
15
|
+
const defaultGitFn = (projectDir, args) =>
|
|
16
|
+
execFileSync("git", ["config", ...args], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
17
|
+
|
|
18
|
+
const configValue = (gitFn, projectDir, args) => {
|
|
19
|
+
try {
|
|
20
|
+
const out = String(gitFn(projectDir, args) || "").trim();
|
|
21
|
+
return out.length > 0 ? out : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{projectDir: string, gitFn?: (projectDir: string, args: string[]) => string}} opts
|
|
29
|
+
* @returns {{name: string, email: string, source: "declared"|"repo"|"global"}|null}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveRunIdentity({ projectDir, gitFn = defaultGitFn }) {
|
|
32
|
+
const declared = readIdentity(projectDir);
|
|
33
|
+
const local = (key) => configValue(gitFn, projectDir, ["--local", "--get", `user.${key}`]);
|
|
34
|
+
const effective = (key) => configValue(gitFn, projectDir, ["--get", `user.${key}`]);
|
|
35
|
+
const localEmail = local("email");
|
|
36
|
+
const email = declared?.git_email ?? localEmail ?? effective("email");
|
|
37
|
+
const name = local("name") ?? effective("name");
|
|
38
|
+
if (!email || !name) return null;
|
|
39
|
+
let source = "global";
|
|
40
|
+
if (declared) source = "declared";
|
|
41
|
+
else if (localEmail) source = "repo";
|
|
42
|
+
return { name, email, source };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The environment that pins author and committer for one git command. */
|
|
46
|
+
export const identityEnv = ({ name, email }) => ({
|
|
47
|
+
GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email,
|
|
48
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0986 (HUM-F, ADR 0018): whoever runs kj without a phone enrolled
|
|
3
|
+
* learns it once per version, not the day the maximum level (HUM-D) refuses a
|
|
4
|
+
* seal. One text, used by the SessionStart notice, by `kj doctor` and, when
|
|
5
|
+
* HUM-D lands, by the deny itself: what the signature protects, how to enroll,
|
|
6
|
+
* and how one proceeds today without it.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { isPhoneEnrolled } from "./phone-sign.js";
|
|
13
|
+
import { STRATEGY } from "../checks/types.js";
|
|
14
|
+
|
|
15
|
+
export const PHONE_INVITE =
|
|
16
|
+
"El móvil no está enrolado: los sellos (kj harden --commit), las aprobaciones y los comandos del catálogo se firman hoy con un código tecleado, con menos garantía. Enrólalo con kj identity enroll-phone <clave pública de la app>: cada acto queda atado a tu clave privada, que nunca sale del móvil (ADR 0018).";
|
|
17
|
+
|
|
18
|
+
const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.json");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The invitation, once per kj version, or null (phone enrolled, or already said
|
|
22
|
+
* for this version). Saying it records it; a marker that cannot be written
|
|
23
|
+
* never silences the invitation.
|
|
24
|
+
* @param {{home?: string, version?: string}} [opts]
|
|
25
|
+
* @returns {string|null}
|
|
26
|
+
*/
|
|
27
|
+
export function phoneInvite({ home, version = "unknown" } = {}) {
|
|
28
|
+
if (isPhoneEnrolled({ home })) return null;
|
|
29
|
+
let seen = null;
|
|
30
|
+
try { seen = JSON.parse(readFileSync(markerPath(home), "utf8")).version; } catch { /* never invited */ }
|
|
31
|
+
if (seen === version) return null;
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(markerPath(home)), { recursive: true });
|
|
34
|
+
writeFileSync(markerPath(home), `${JSON.stringify({ version, at: new Date().toISOString() })}\n`, "utf8");
|
|
35
|
+
} catch { /* the invitation still goes out */ }
|
|
36
|
+
return PHONE_INVITE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `kj doctor`: WARN while the phone is not enrolled, with the same text. */
|
|
40
|
+
export function createPhoneCheck({ home } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
name: "phone-enrolled",
|
|
43
|
+
label: "móvil enrolado (firma de sellos y aprobaciones)",
|
|
44
|
+
strategy: STRATEGY.MANUAL,
|
|
45
|
+
describe: "Whether a phone's public key is enrolled for the supervisor seal and approvals (ADR 0018)",
|
|
46
|
+
async detect() {
|
|
47
|
+
if (isPhoneEnrolled({ home })) return { ok: true, severity: "info", detail: "clave pública en ~/.karajan/supervisor-phone.json" };
|
|
48
|
+
return { ok: false, severity: "warn", detail: PHONE_INVITE, fix: "kj identity enroll-phone <clave pública de la app>" };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const getPhoneChecks = (opts) => [createPhoneCheck(opts)];
|
|
@@ -38,6 +38,11 @@ process.stdin.on("end", () => {
|
|
|
38
38
|
lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
|
|
39
39
|
}
|
|
40
40
|
if (drift) lines.push(`Karajan: ${drift}`);
|
|
41
|
+
// KJC-TSK-0986 (HUM-F, ADR 0018): once per kj version, the invitation to
|
|
42
|
+
// enroll the phone when none is; kj decides and remembers, the hook relays.
|
|
43
|
+
const invite = spawnSync("kj", ["identity", "phone-invite"], { encoding: "utf8", timeout: 8000 });
|
|
44
|
+
const inviteText = invite.status === 0 ? String(invite.stdout || "").trim() : "";
|
|
45
|
+
if (inviteText) lines.push(`Karajan: ${inviteText}`);
|
|
41
46
|
if (lines.length === 0) process.exit(0);
|
|
42
47
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
|
|
43
48
|
} catch { /* never fails a session */ }
|
|
@@ -570,6 +570,25 @@ process.stdin.on("end", () => {
|
|
|
570
570
|
}
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
|
+
// KJC-BUG-0252 (#1899): una issue para karajan-code sale por kj report-issue
|
|
574
|
+
// (sanitiza y busca duplicados), nunca por gh issue create a mano.
|
|
575
|
+
{
|
|
576
|
+
const ghIssue = String(input.command || "");
|
|
577
|
+
if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghIssue) && /(^|[^a-z])issue([^a-z]|$)/.test(ghIssue) && /(^|[^a-z])create([^a-z]|$)/.test(ghIssue)) {
|
|
578
|
+
// The target: --repo/-R, an inline or inherited GH_REPO, else the
|
|
579
|
+
// checkout's own origin, which is what gh takes (review catches).
|
|
580
|
+
const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
|
|
581
|
+
let target = optionValues(ghIssue, ["--repo", "-R"]).at(-1) || (inlineRepo ? inlineRepo[2] : "") || String(process.env.GH_REPO || "");
|
|
582
|
+
if (!target) {
|
|
583
|
+
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: ROOT, encoding: "utf8" });
|
|
584
|
+
target = String(origin.stdout || "").trim();
|
|
585
|
+
}
|
|
586
|
+
if (/karajan-code([^a-z0-9]|$)/i.test(target)) {
|
|
587
|
+
console.error("karajan sentinel: las issues para karajan-code salen por kj report-issue (sanitiza y busca issues parecidas), no por gh issue create." + doc("report-issue"));
|
|
588
|
+
process.exit(2);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
573
592
|
// KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
|
|
574
593
|
// — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
|
|
575
594
|
// solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
|
|
@@ -57,7 +57,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
57
57
|
// Per-HU pipeline: focused max_iterations, fresh Brain state, own git branch.
|
|
58
58
|
const huMaxIterations = ctx.config.hu_max_iterations ?? 3;
|
|
59
59
|
const huBranches = new Map();
|
|
60
|
-
const { prepareHuBranch, finalizeHuCommit } = await import("../../git/hu-automation.js");
|
|
60
|
+
const { prepareHuBranch, finalizeHuCommit, collectGitWarnings } = await import("../../git/hu-automation.js");
|
|
61
61
|
|
|
62
62
|
// PR F (v2.7.5): the per-HU loop needs three pieces of plan context:
|
|
63
63
|
// • ADRs (loaded once from disk, applies to every HU on this plan)
|
|
@@ -142,7 +142,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
142
142
|
branchName = `kj-hu-${story.id}`;
|
|
143
143
|
huBranches.set(story.id, branchName);
|
|
144
144
|
} else {
|
|
145
|
-
branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger });
|
|
145
|
+
branchName = await prepareHuBranch({ story, huBranches, config: laneConfig, logger, cardId: ctx.session?.pg_task_id || null });
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
// KJC-TSK-0408 step 2: snapshot del workspace ANTES de invocar al
|
|
@@ -257,8 +257,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
257
257
|
);
|
|
258
258
|
if (pendingGherkinTests.length === 0) {
|
|
259
259
|
logger.info(`HU ${story.id}: all shell acceptance tests PASSED, no Gherkin to translate — approved`);
|
|
260
|
-
await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
261
|
-
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
|
|
260
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
261
|
+
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed", git };
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
// Shell tests passed; Gherkin needs translation. Invoke the
|
|
@@ -276,8 +276,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
276
276
|
const testerStage = testerOutcome?.stageResult;
|
|
277
277
|
if (testerOutcome?.action !== "continue" && testerStage?.verdict === "pass") {
|
|
278
278
|
logger.info(`HU ${story.id}: tester translated Gherkin and verdict=pass — approved`);
|
|
279
|
-
await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
280
|
-
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed" };
|
|
279
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
280
|
+
return { approved: true, sessionId: laneSession.id, reason: "acceptance_tests_passed", git };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// Tester rejected (translated tests failed or coverage
|
|
@@ -375,7 +375,10 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
375
375
|
const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask };
|
|
376
376
|
const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
|
|
377
377
|
if (result?.approved) {
|
|
378
|
-
|
|
378
|
+
// KJC-BUG-0248: what the HU's push or PR said joins what the loop recorded.
|
|
379
|
+
const git = await finalizeHuCommit({ story, branchName, config: laneConfig, logger, cwd: worktreePath });
|
|
380
|
+
const prev = result.git ?? {};
|
|
381
|
+
return { ...result, git: { ...prev, prUrl: prev.prUrl ?? git.prUrl, errors: [...(prev.errors ?? []), ...git.errors] } };
|
|
379
382
|
}
|
|
380
383
|
return result;
|
|
381
384
|
},
|
|
@@ -415,12 +418,17 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
415
418
|
}
|
|
416
419
|
}
|
|
417
420
|
|
|
421
|
+
// KJC-BUG-0248 (#1895): a push or a PR that failed is said in the result
|
|
422
|
+
// the host reads, not only in a warn line of the log.
|
|
423
|
+
const warnings = collectGitWarnings(subPipelineResult.results);
|
|
424
|
+
for (const w of warnings) logger.warn(`git automation: ${w}`);
|
|
418
425
|
const finalResult = {
|
|
419
426
|
approved: subPipelineResult.approved,
|
|
420
427
|
sessionId: ctx.session.id,
|
|
421
428
|
huResults: subPipelineResult.results,
|
|
422
429
|
blockedIds: subPipelineResult.blockedIds,
|
|
423
|
-
planId: ctx.session._planRef?.planId || null
|
|
430
|
+
planId: ctx.session._planRef?.planId || null,
|
|
431
|
+
...(warnings.length > 0 ? { warnings } : {})
|
|
424
432
|
};
|
|
425
433
|
await writeHistoryRecord({ sessionId: ctx.session.id, task, result: finalResult, logger });
|
|
426
434
|
return { handled: true, result: finalResult };
|
|
@@ -172,6 +172,8 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
|
|
|
172
172
|
if (Array.isArray(r.deferredIssues)) {
|
|
173
173
|
for (const d of r.deferredIssues) blockers.push(typeof d === "string" ? d : d.message || JSON.stringify(d));
|
|
174
174
|
}
|
|
175
|
+
// KJC-BUG-0248: a push or PR that failed is a blocker the board shows.
|
|
176
|
+
for (const e of git.errors ?? []) blockers.push(`${e.step} failed: ${e.message}. ${e.remedy}`);
|
|
175
177
|
// Plain-Spanish, one-or-two sentences.
|
|
176
178
|
let summary;
|
|
177
179
|
if (r.review?.summary) summary = String(r.review.summary);
|
|
@@ -33,6 +33,7 @@ import { getMcpHealthChecks } from "../checks/mcp-health.js";
|
|
|
33
33
|
import { getSkillsChecks } from "../checks/skills.js";
|
|
34
34
|
import { getProjectChecks } from "../checks/project-checks.js";
|
|
35
35
|
import { resolveTestHarness } from "../config/test-harness.js";
|
|
36
|
+
import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
|
|
36
37
|
|
|
37
38
|
function parseJsonSafe(text) {
|
|
38
39
|
try {
|
|
@@ -231,6 +232,26 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
|
|
|
231
232
|
errors: [],
|
|
232
233
|
};
|
|
233
234
|
|
|
235
|
+
// KJC-BUG-0252 (#1899): on a Claude host without the Sentinel harness nothing
|
|
236
|
+
// governs the host's own actions (issues by hand, commits, pushes, merges);
|
|
237
|
+
// a run reported Tier A OFF and the agent routed around every gate. kj never
|
|
238
|
+
// fakes that supervision: the run stops here, with the remedy.
|
|
239
|
+
const guarantee = collectGuaranteeFacts({ projectDir: config.projectDir || process.cwd() });
|
|
240
|
+
if (guarantee.host === "claude" && !guarantee.tierA.active) {
|
|
241
|
+
result.ok = false;
|
|
242
|
+
result.checks.push({ name: "sentinel-harness", ok: false, detail: guarantee.tierA.reason });
|
|
243
|
+
result.errors.push({
|
|
244
|
+
check: "sentinel-harness",
|
|
245
|
+
message: "Tier A tool-time is OFF: a Claude host without the Sentinel harness, so nothing governs the host's own actions outside this run.",
|
|
246
|
+
fix: "Run kj harden (kj init installs it) and seal it with kj harden --commit; then run again.",
|
|
247
|
+
});
|
|
248
|
+
logger.error("Preflight: Sentinel harness not installed on a Claude host — run kj harden");
|
|
249
|
+
emitProgress(emitter, makeEvent("preflight:end", { ...eventBase, stage: "preflight" }, {
|
|
250
|
+
status: "fail", message: "Preflight FAILED — Sentinel harness not installed", detail: result
|
|
251
|
+
}));
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
234
255
|
// Resolve extended preflight opt-in early so the short-circuit below can see it.
|
|
235
256
|
// Post-v2.7.5 this no longer reads globalThis directly — config.testHarness
|
|
236
257
|
// is populated by the loader from the global or the default.
|
|
@@ -195,10 +195,19 @@ export function accumulateCommit(session, commitInfo) {
|
|
|
195
195
|
// --- Internal helpers ---
|
|
196
196
|
|
|
197
197
|
async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
198
|
+
const { fetchCard, updateCard } = await import("./client.js");
|
|
199
|
+
// KJC-BUG-0247 (#1894): a card that cannot be read was one warn line and
|
|
200
|
+
// null, and the run went on without the card's acceptance criteria, invented
|
|
201
|
+
// its own HUs and said approved. With a card named, no card is a loud stop.
|
|
202
|
+
let pgCard;
|
|
198
203
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check PG_API_URL, or run without pgTask.`, { cause: err });
|
|
207
|
+
}
|
|
208
|
+
if (!pgCard) throw new Error(`Planning Game: card ${pgTaskId} not found in project ${pgProject}: the run would implement without the card's acceptance criteria. Check the id, or run without pgTask.`);
|
|
209
|
+
try {
|
|
210
|
+
if (pgCard.status !== "In Progress") {
|
|
202
211
|
await updateCard({
|
|
203
212
|
projectId: pgProject,
|
|
204
213
|
cardId: pgTaskId,
|
|
@@ -212,11 +221,11 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
|
212
221
|
});
|
|
213
222
|
logger.info(`Planning Game: ${pgTaskId} → In Progress`);
|
|
214
223
|
}
|
|
215
|
-
return pgCard;
|
|
216
224
|
} catch (err) {
|
|
225
|
+
// The card was read: its criteria govern the run; the status move is best-effort.
|
|
217
226
|
logger.warn(`Planning Game: could not update ${pgTaskId}: ${err.message}`);
|
|
218
|
-
return null;
|
|
219
227
|
}
|
|
228
|
+
return pgCard;
|
|
220
229
|
}
|
|
221
230
|
|
|
222
231
|
/**
|
package/src/utils/git.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runCommand } from "./process.js";
|
|
2
2
|
import { getRunContext } from "./run-context.js";
|
|
3
|
+
import { identityEnv } from "../git/run-identity.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Module-scoped runner — legacy back-compat for callers outside a
|
|
@@ -162,9 +163,20 @@ export async function createBranch(branchName) {
|
|
|
162
163
|
await runGit(["checkout", "-b", branchName]);
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
export function
|
|
166
|
+
export async function checkoutBranch(branchName) {
|
|
167
|
+
await runGit(["checkout", branchName]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* KJC-BUG-0249 (#1896): with a Planning Game card the branch carries its id,
|
|
172
|
+
* `feat/<CARD-ID>-<slug>`, the name the card-first gate reads; the stamp is
|
|
173
|
+
* for a run with no card, where nothing else makes the name unique.
|
|
174
|
+
*/
|
|
175
|
+
export function buildBranchName(prefix, task, cardId = null) {
|
|
176
|
+
const slug = slugifyTask(task) || "task";
|
|
177
|
+
if (cardId) return `${prefix}${cardId}-${slug}`;
|
|
166
178
|
const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-").slice(0, 16);
|
|
167
|
-
return `${prefix}${
|
|
179
|
+
return `${prefix}${slug}-${stamp}`;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
export async function hasChanges(cwd = null) {
|
|
@@ -263,9 +275,13 @@ export async function commitAll(message, cwd = null, { beforeCommit = null, excl
|
|
|
263
275
|
// The callback learns the exact paths, so a verdict stamped there binds to the
|
|
264
276
|
// diff this commit will contain and not to whatever else the index holds.
|
|
265
277
|
if (beforeCommit) await beforeCommit({ paths });
|
|
278
|
+
// KJC-BUG-0246 (#1893): every commit of a run carries the identity the run
|
|
279
|
+
// resolved once (prepareGitAutomation), never what git resolves at that moment.
|
|
280
|
+
const identity = getRunContext()?.gitIdentity;
|
|
281
|
+
const commitOpts = identity ? { ...opts, env: { ...process.env, ...identityEnv(identity) } } : opts;
|
|
266
282
|
try {
|
|
267
283
|
// --only: these paths and nothing else; what the person had staged stays staged.
|
|
268
|
-
await runGit(["commit", "-m", message, "--only", "--", ...paths],
|
|
284
|
+
await runGit(["commit", "-m", message, "--only", "--", ...paths], commitOpts);
|
|
269
285
|
} catch (err) {
|
|
270
286
|
// `git status --porcelain` and `git commit` disagreed about whether
|
|
271
287
|
// there was anything to commit. Don't escalate — the only outcome
|