compound-agent 1.4.4 → 1.5.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/CHANGELOG.md +55 -35
- package/LICENSE +1 -1
- package/README.md +80 -138
- package/context7.json +21 -0
- package/dist/cli.js +933 -753
- package/dist/cli.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/llms.txt +31 -0
- package/package.json +22 -8
package/dist/cli.js
CHANGED
|
@@ -3348,7 +3348,7 @@ Before capturing, verify the lesson is:
|
|
|
3348
3348
|
The JSONL file requires proper ID generation, schema validation, and SQLite sync.
|
|
3349
3349
|
Use CLI (\`npx ca learn\`) \u2014 never manual edits.
|
|
3350
3350
|
|
|
3351
|
-
See [documentation](https://github.com/Nathandela/
|
|
3351
|
+
See [documentation](https://github.com/Nathandela/compound-agent) for more details.
|
|
3352
3352
|
${AGENTS_SECTION_END_MARKER}
|
|
3353
3353
|
`;
|
|
3354
3354
|
var LEGACY_ROOT_SLASH_COMMANDS = [
|
|
@@ -3368,7 +3368,7 @@ var PLUGIN_MANIFEST = {
|
|
|
3368
3368
|
name: "Nathan Delacr\xE9taz",
|
|
3369
3369
|
url: "https://github.com/Nathandela"
|
|
3370
3370
|
},
|
|
3371
|
-
repository: "https://github.com/Nathandela/
|
|
3371
|
+
repository: "https://github.com/Nathandela/compound-agent",
|
|
3372
3372
|
license: "MIT",
|
|
3373
3373
|
hooks: {
|
|
3374
3374
|
SessionStart: [
|
|
@@ -3737,674 +3737,6 @@ function formatError(command, code, message, remediation) {
|
|
|
3737
3737
|
return `ERROR [${command}] ${code}: ${message} \u2014 ${remediation}`;
|
|
3738
3738
|
}
|
|
3739
3739
|
|
|
3740
|
-
// src/setup/hooks-user-prompt.ts
|
|
3741
|
-
var CORRECTION_PATTERNS = [
|
|
3742
|
-
/\bactually\b/i,
|
|
3743
|
-
/\bno[,.]?\s/i,
|
|
3744
|
-
/\bwrong\b/i,
|
|
3745
|
-
/\bthat'?s not right\b/i,
|
|
3746
|
-
/\bthat'?s incorrect\b/i,
|
|
3747
|
-
/\buse .+ instead\b/i,
|
|
3748
|
-
/\bi told you\b/i,
|
|
3749
|
-
/\bi already said\b/i,
|
|
3750
|
-
/\bnot like that\b/i,
|
|
3751
|
-
/\byou forgot\b/i,
|
|
3752
|
-
/\byou missed\b/i,
|
|
3753
|
-
/\bstop\s*(,\s*)?(doing|using|that)\b/i,
|
|
3754
|
-
/\bwait\s*(,\s*)?(that|no|wrong)\b/i
|
|
3755
|
-
];
|
|
3756
|
-
var HIGH_CONFIDENCE_PLANNING = [
|
|
3757
|
-
/\bdecide\b/i,
|
|
3758
|
-
/\bchoose\b/i,
|
|
3759
|
-
/\bpick\b/i,
|
|
3760
|
-
/\bwhich approach\b/i,
|
|
3761
|
-
/\bwhat do you think\b/i,
|
|
3762
|
-
/\bshould we\b/i,
|
|
3763
|
-
/\bwould you\b/i,
|
|
3764
|
-
/\bhow should\b/i,
|
|
3765
|
-
/\bwhat'?s the best\b/i,
|
|
3766
|
-
/\badd feature\b/i,
|
|
3767
|
-
/\bset up\b/i
|
|
3768
|
-
];
|
|
3769
|
-
var LOW_CONFIDENCE_PLANNING = [
|
|
3770
|
-
/\bimplement\b/i,
|
|
3771
|
-
/\bbuild\b/i,
|
|
3772
|
-
/\bcreate\b/i,
|
|
3773
|
-
/\brefactor\b/i,
|
|
3774
|
-
/\bfix\b/i,
|
|
3775
|
-
/\bwrite\b/i,
|
|
3776
|
-
/\bdevelop\b/i
|
|
3777
|
-
];
|
|
3778
|
-
var CORRECTION_REMINDER = "Remember: You have memory tools available - `npx ca learn` to save insights, `npx ca search` to find past solutions.";
|
|
3779
|
-
var PLANNING_REMINDER = "If you're uncertain or hesitant, remember your memory tools: `npx ca search` may have relevant context from past sessions.";
|
|
3780
|
-
function detectCorrection(prompt) {
|
|
3781
|
-
return CORRECTION_PATTERNS.some((pattern) => pattern.test(prompt));
|
|
3782
|
-
}
|
|
3783
|
-
function detectPlanning(prompt) {
|
|
3784
|
-
if (HIGH_CONFIDENCE_PLANNING.some((pattern) => pattern.test(prompt))) {
|
|
3785
|
-
return true;
|
|
3786
|
-
}
|
|
3787
|
-
const lowMatches = LOW_CONFIDENCE_PLANNING.filter((pattern) => pattern.test(prompt));
|
|
3788
|
-
return lowMatches.length >= 2;
|
|
3789
|
-
}
|
|
3790
|
-
function processUserPrompt(prompt) {
|
|
3791
|
-
if (detectCorrection(prompt)) {
|
|
3792
|
-
return {
|
|
3793
|
-
hookSpecificOutput: {
|
|
3794
|
-
hookEventName: "UserPromptSubmit",
|
|
3795
|
-
additionalContext: CORRECTION_REMINDER
|
|
3796
|
-
}
|
|
3797
|
-
};
|
|
3798
|
-
}
|
|
3799
|
-
if (detectPlanning(prompt)) {
|
|
3800
|
-
return {
|
|
3801
|
-
hookSpecificOutput: {
|
|
3802
|
-
hookEventName: "UserPromptSubmit",
|
|
3803
|
-
additionalContext: PLANNING_REMINDER
|
|
3804
|
-
}
|
|
3805
|
-
};
|
|
3806
|
-
}
|
|
3807
|
-
return {};
|
|
3808
|
-
}
|
|
3809
|
-
var SAME_TARGET_THRESHOLD = 2;
|
|
3810
|
-
var TOTAL_FAILURE_THRESHOLD = 3;
|
|
3811
|
-
var STATE_FILE_NAME = ".ca-failure-state.json";
|
|
3812
|
-
var STATE_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
3813
|
-
var failureCount = 0;
|
|
3814
|
-
var lastFailedTarget = null;
|
|
3815
|
-
var sameTargetCount = 0;
|
|
3816
|
-
function defaultState() {
|
|
3817
|
-
return { count: 0, lastTarget: null, sameTargetCount: 0, timestamp: Date.now() };
|
|
3818
|
-
}
|
|
3819
|
-
function readFailureState(stateDir) {
|
|
3820
|
-
try {
|
|
3821
|
-
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
3822
|
-
if (!existsSync(filePath)) return defaultState();
|
|
3823
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
3824
|
-
const parsed = JSON.parse(raw);
|
|
3825
|
-
if (Date.now() - parsed.timestamp > STATE_MAX_AGE_MS) return defaultState();
|
|
3826
|
-
return parsed;
|
|
3827
|
-
} catch {
|
|
3828
|
-
return defaultState();
|
|
3829
|
-
}
|
|
3830
|
-
}
|
|
3831
|
-
function writeFailureState(stateDir, state) {
|
|
3832
|
-
try {
|
|
3833
|
-
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
3834
|
-
writeFileSync(filePath, JSON.stringify(state), "utf-8");
|
|
3835
|
-
} catch {
|
|
3836
|
-
}
|
|
3837
|
-
}
|
|
3838
|
-
function deleteStateFile(stateDir) {
|
|
3839
|
-
try {
|
|
3840
|
-
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
3841
|
-
if (existsSync(filePath)) unlinkSync(filePath);
|
|
3842
|
-
} catch {
|
|
3843
|
-
}
|
|
3844
|
-
}
|
|
3845
|
-
var FAILURE_TIP = "Tip: Multiple failures detected. `npx ca search` may have solutions for similar issues.";
|
|
3846
|
-
function resetFailureState(stateDir) {
|
|
3847
|
-
failureCount = 0;
|
|
3848
|
-
lastFailedTarget = null;
|
|
3849
|
-
sameTargetCount = 0;
|
|
3850
|
-
if (stateDir) deleteStateFile(stateDir);
|
|
3851
|
-
}
|
|
3852
|
-
function getFailureTarget(toolName, toolInput) {
|
|
3853
|
-
if (toolName === "Bash" && typeof toolInput.command === "string") {
|
|
3854
|
-
const trimmed = toolInput.command.trim();
|
|
3855
|
-
const firstSpace = trimmed.indexOf(" ");
|
|
3856
|
-
return firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
3857
|
-
}
|
|
3858
|
-
if ((toolName === "Edit" || toolName === "Write") && typeof toolInput.file_path === "string") {
|
|
3859
|
-
return toolInput.file_path;
|
|
3860
|
-
}
|
|
3861
|
-
return null;
|
|
3862
|
-
}
|
|
3863
|
-
function processToolFailure(toolName, toolInput, stateDir) {
|
|
3864
|
-
if (stateDir) {
|
|
3865
|
-
const persisted = readFailureState(stateDir);
|
|
3866
|
-
failureCount = persisted.count;
|
|
3867
|
-
lastFailedTarget = persisted.lastTarget;
|
|
3868
|
-
sameTargetCount = persisted.sameTargetCount;
|
|
3869
|
-
}
|
|
3870
|
-
failureCount++;
|
|
3871
|
-
const target = getFailureTarget(toolName, toolInput);
|
|
3872
|
-
if (target !== null && target === lastFailedTarget) {
|
|
3873
|
-
sameTargetCount++;
|
|
3874
|
-
} else {
|
|
3875
|
-
sameTargetCount = 1;
|
|
3876
|
-
lastFailedTarget = target;
|
|
3877
|
-
}
|
|
3878
|
-
const shouldShowTip = sameTargetCount >= SAME_TARGET_THRESHOLD || failureCount >= TOTAL_FAILURE_THRESHOLD;
|
|
3879
|
-
if (shouldShowTip) {
|
|
3880
|
-
resetFailureState(stateDir);
|
|
3881
|
-
return {
|
|
3882
|
-
hookSpecificOutput: {
|
|
3883
|
-
hookEventName: "PostToolUseFailure",
|
|
3884
|
-
additionalContext: FAILURE_TIP
|
|
3885
|
-
}
|
|
3886
|
-
};
|
|
3887
|
-
}
|
|
3888
|
-
if (stateDir) {
|
|
3889
|
-
writeFailureState(stateDir, {
|
|
3890
|
-
count: failureCount,
|
|
3891
|
-
lastTarget: lastFailedTarget,
|
|
3892
|
-
sameTargetCount,
|
|
3893
|
-
timestamp: Date.now()
|
|
3894
|
-
});
|
|
3895
|
-
}
|
|
3896
|
-
return {};
|
|
3897
|
-
}
|
|
3898
|
-
function processToolSuccess(stateDir) {
|
|
3899
|
-
resetFailureState(stateDir);
|
|
3900
|
-
}
|
|
3901
|
-
var STATE_DIR = ".claude";
|
|
3902
|
-
var STATE_FILE = ".ca-phase-state.json";
|
|
3903
|
-
var PHASE_STATE_MAX_AGE_MS = 72 * 60 * 60 * 1e3;
|
|
3904
|
-
var PHASES = ["brainstorm", "plan", "work", "review", "compound"];
|
|
3905
|
-
var GATES = ["post-plan", "gate-3", "gate-4", "final"];
|
|
3906
|
-
var PHASE_INDEX = {
|
|
3907
|
-
brainstorm: 1,
|
|
3908
|
-
plan: 2,
|
|
3909
|
-
work: 3,
|
|
3910
|
-
review: 4,
|
|
3911
|
-
compound: 5
|
|
3912
|
-
};
|
|
3913
|
-
function getStatePath(repoRoot) {
|
|
3914
|
-
return join(repoRoot, STATE_DIR, STATE_FILE);
|
|
3915
|
-
}
|
|
3916
|
-
function isPhaseName(value) {
|
|
3917
|
-
return typeof value === "string" && PHASES.includes(value);
|
|
3918
|
-
}
|
|
3919
|
-
function isGateName(value) {
|
|
3920
|
-
return typeof value === "string" && GATES.includes(value);
|
|
3921
|
-
}
|
|
3922
|
-
function isIsoDate(value) {
|
|
3923
|
-
if (typeof value !== "string") return false;
|
|
3924
|
-
return !Number.isNaN(Date.parse(value));
|
|
3925
|
-
}
|
|
3926
|
-
function isStringArray(value) {
|
|
3927
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
3928
|
-
}
|
|
3929
|
-
function validatePhaseState(raw) {
|
|
3930
|
-
if (typeof raw !== "object" || raw === null) return false;
|
|
3931
|
-
const state = raw;
|
|
3932
|
-
return typeof state.lfg_active === "boolean" && typeof state.epic_id === "string" && isPhaseName(state.current_phase) && typeof state.phase_index === "number" && state.phase_index >= 1 && state.phase_index <= 5 && isStringArray(state.skills_read) && Array.isArray(state.gates_passed) && state.gates_passed.every((gate) => isGateName(gate)) && isIsoDate(state.started_at);
|
|
3933
|
-
}
|
|
3934
|
-
function expectedGateForPhase(phaseIndex) {
|
|
3935
|
-
if (phaseIndex === 2) return "post-plan";
|
|
3936
|
-
if (phaseIndex === 3) return "gate-3";
|
|
3937
|
-
if (phaseIndex === 4) return "gate-4";
|
|
3938
|
-
if (phaseIndex === 5) return "final";
|
|
3939
|
-
return null;
|
|
3940
|
-
}
|
|
3941
|
-
function initPhaseState(repoRoot, epicId) {
|
|
3942
|
-
const dir = join(repoRoot, STATE_DIR);
|
|
3943
|
-
mkdirSync(dir, { recursive: true });
|
|
3944
|
-
const state = {
|
|
3945
|
-
lfg_active: true,
|
|
3946
|
-
epic_id: epicId,
|
|
3947
|
-
current_phase: "brainstorm",
|
|
3948
|
-
phase_index: PHASE_INDEX.brainstorm,
|
|
3949
|
-
skills_read: [],
|
|
3950
|
-
gates_passed: [],
|
|
3951
|
-
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3952
|
-
};
|
|
3953
|
-
writeFileSync(getStatePath(repoRoot), JSON.stringify(state, null, 2), "utf-8");
|
|
3954
|
-
return state;
|
|
3955
|
-
}
|
|
3956
|
-
function getPhaseState(repoRoot) {
|
|
3957
|
-
try {
|
|
3958
|
-
const path2 = getStatePath(repoRoot);
|
|
3959
|
-
if (!existsSync(path2)) return null;
|
|
3960
|
-
const raw = readFileSync(path2, "utf-8");
|
|
3961
|
-
const parsed = JSON.parse(raw);
|
|
3962
|
-
if (!validatePhaseState(parsed)) return null;
|
|
3963
|
-
const age = Date.now() - new Date(parsed.started_at).getTime();
|
|
3964
|
-
if (age > PHASE_STATE_MAX_AGE_MS) {
|
|
3965
|
-
cleanPhaseState(repoRoot);
|
|
3966
|
-
return null;
|
|
3967
|
-
}
|
|
3968
|
-
return parsed;
|
|
3969
|
-
} catch {
|
|
3970
|
-
return null;
|
|
3971
|
-
}
|
|
3972
|
-
}
|
|
3973
|
-
function updatePhaseState(repoRoot, partial) {
|
|
3974
|
-
const current = getPhaseState(repoRoot);
|
|
3975
|
-
if (current === null) return null;
|
|
3976
|
-
const updated = {
|
|
3977
|
-
...current,
|
|
3978
|
-
...partial
|
|
3979
|
-
};
|
|
3980
|
-
if (!validatePhaseState(updated)) return null;
|
|
3981
|
-
writeFileSync(getStatePath(repoRoot), JSON.stringify(updated, null, 2), "utf-8");
|
|
3982
|
-
return updated;
|
|
3983
|
-
}
|
|
3984
|
-
function startPhase(repoRoot, phase) {
|
|
3985
|
-
return updatePhaseState(repoRoot, {
|
|
3986
|
-
current_phase: phase,
|
|
3987
|
-
phase_index: PHASE_INDEX[phase]
|
|
3988
|
-
});
|
|
3989
|
-
}
|
|
3990
|
-
function cleanPhaseState(repoRoot) {
|
|
3991
|
-
try {
|
|
3992
|
-
const path2 = getStatePath(repoRoot);
|
|
3993
|
-
if (existsSync(path2)) unlinkSync(path2);
|
|
3994
|
-
} catch {
|
|
3995
|
-
}
|
|
3996
|
-
}
|
|
3997
|
-
function recordGatePassed(repoRoot, gate) {
|
|
3998
|
-
const current = getPhaseState(repoRoot);
|
|
3999
|
-
if (current === null) return null;
|
|
4000
|
-
const gatesPassed = current.gates_passed.includes(gate) ? current.gates_passed : [...current.gates_passed, gate];
|
|
4001
|
-
const updated = { ...current, gates_passed: gatesPassed };
|
|
4002
|
-
if (gate === "final") {
|
|
4003
|
-
cleanPhaseState(repoRoot);
|
|
4004
|
-
return updated;
|
|
4005
|
-
}
|
|
4006
|
-
writeFileSync(getStatePath(repoRoot), JSON.stringify(updated, null, 2), "utf-8");
|
|
4007
|
-
return updated;
|
|
4008
|
-
}
|
|
4009
|
-
function printStatusHuman(state) {
|
|
4010
|
-
if (state === null) {
|
|
4011
|
-
console.log("No active LFG session.");
|
|
4012
|
-
return;
|
|
4013
|
-
}
|
|
4014
|
-
console.log("Active LFG Session");
|
|
4015
|
-
console.log(` Epic: ${state.epic_id}`);
|
|
4016
|
-
console.log(` Phase: ${state.current_phase} (${state.phase_index}/5)`);
|
|
4017
|
-
console.log(` Skills read: ${state.skills_read.length === 0 ? "(none)" : state.skills_read.join(", ")}`);
|
|
4018
|
-
console.log(` Gates passed: ${state.gates_passed.length === 0 ? "(none)" : state.gates_passed.join(", ")}`);
|
|
4019
|
-
console.log(` Started: ${state.started_at}`);
|
|
4020
|
-
}
|
|
4021
|
-
function registerPhaseSubcommands(phaseCheck, getDryRun, repoRoot) {
|
|
4022
|
-
phaseCheck.command("init <epic-id>").description("Initialize phase state for an epic").action((epicId) => {
|
|
4023
|
-
if (!EPIC_ID_PATTERN.test(epicId)) {
|
|
4024
|
-
console.error(`Invalid epic ID: "${epicId}"`);
|
|
4025
|
-
process.exitCode = 1;
|
|
4026
|
-
return;
|
|
4027
|
-
}
|
|
4028
|
-
if (getDryRun()) {
|
|
4029
|
-
console.log(`[dry-run] Would initialize phase state for epic ${epicId} in ${repoRoot()}`);
|
|
4030
|
-
return;
|
|
4031
|
-
}
|
|
4032
|
-
initPhaseState(repoRoot(), epicId);
|
|
4033
|
-
console.log(`Phase state initialized for ${epicId}. Current phase: brainstorm (1/5).`);
|
|
4034
|
-
});
|
|
4035
|
-
phaseCheck.command("start <phase>").description("Start or resume a phase").action((phase) => {
|
|
4036
|
-
if (!isPhaseName(phase)) {
|
|
4037
|
-
console.error(`Invalid phase: "${phase}". Valid phases: ${PHASES.join(", ")}`);
|
|
4038
|
-
process.exitCode = 1;
|
|
4039
|
-
return;
|
|
4040
|
-
}
|
|
4041
|
-
if (getDryRun()) {
|
|
4042
|
-
console.log(`[dry-run] Would start phase ${phase}`);
|
|
4043
|
-
return;
|
|
4044
|
-
}
|
|
4045
|
-
const state = startPhase(repoRoot(), phase);
|
|
4046
|
-
if (state === null) {
|
|
4047
|
-
console.error("No active phase state. Run: ca phase-check init <epic-id>");
|
|
4048
|
-
process.exitCode = 1;
|
|
4049
|
-
return;
|
|
4050
|
-
}
|
|
4051
|
-
console.log(`Phase updated: ${state.current_phase} (${state.phase_index}/5).`);
|
|
4052
|
-
});
|
|
4053
|
-
phaseCheck.command("gate <gate-name>").description("Record a phase gate as passed").action((gateName) => {
|
|
4054
|
-
if (!isGateName(gateName)) {
|
|
4055
|
-
console.error(`Invalid gate: "${gateName}". Valid gates: ${GATES.join(", ")}`);
|
|
4056
|
-
process.exitCode = 1;
|
|
4057
|
-
return;
|
|
4058
|
-
}
|
|
4059
|
-
if (getDryRun()) {
|
|
4060
|
-
console.log(`[dry-run] Would record gate ${gateName}`);
|
|
4061
|
-
return;
|
|
4062
|
-
}
|
|
4063
|
-
const state = recordGatePassed(repoRoot(), gateName);
|
|
4064
|
-
if (state === null) {
|
|
4065
|
-
console.error("No active phase state. Run: ca phase-check init <epic-id>");
|
|
4066
|
-
process.exitCode = 1;
|
|
4067
|
-
return;
|
|
4068
|
-
}
|
|
4069
|
-
if (gateName === "final") {
|
|
4070
|
-
console.log("Final gate recorded. Phase state cleaned.");
|
|
4071
|
-
return;
|
|
4072
|
-
}
|
|
4073
|
-
console.log(`Gate recorded: ${gateName}.`);
|
|
4074
|
-
});
|
|
4075
|
-
phaseCheck.command("status").description("Show current phase state").option("--json", "Output raw JSON").action((options) => {
|
|
4076
|
-
const state = getPhaseState(repoRoot());
|
|
4077
|
-
if (options.json) {
|
|
4078
|
-
console.log(JSON.stringify(state ?? { lfg_active: false }));
|
|
4079
|
-
return;
|
|
4080
|
-
}
|
|
4081
|
-
printStatusHuman(state);
|
|
4082
|
-
});
|
|
4083
|
-
phaseCheck.command("clean").description("Remove phase state file").action(() => {
|
|
4084
|
-
if (getDryRun()) {
|
|
4085
|
-
console.log("[dry-run] Would delete phase state file");
|
|
4086
|
-
return;
|
|
4087
|
-
}
|
|
4088
|
-
cleanPhaseState(repoRoot());
|
|
4089
|
-
console.log("Phase state cleaned.");
|
|
4090
|
-
});
|
|
4091
|
-
}
|
|
4092
|
-
function registerPhaseCheckCommand(program2) {
|
|
4093
|
-
const phaseCheck = program2.command("phase-check").description("Manage LFG phase state").option("--dry-run", "Show what would be done without making changes");
|
|
4094
|
-
const getDryRun = () => phaseCheck.opts().dryRun ?? false;
|
|
4095
|
-
const repoRoot = () => getRepoRoot();
|
|
4096
|
-
registerPhaseSubcommands(phaseCheck, getDryRun, repoRoot);
|
|
4097
|
-
program2.command("phase-clean").description("Remove phase state file (alias for `phase-check clean`)").action(() => {
|
|
4098
|
-
cleanPhaseState(repoRoot());
|
|
4099
|
-
console.log("Phase state cleaned.");
|
|
4100
|
-
});
|
|
4101
|
-
}
|
|
4102
|
-
|
|
4103
|
-
// src/setup/hooks-phase-guard.ts
|
|
4104
|
-
function processPhaseGuard(repoRoot, toolName, _toolInput) {
|
|
4105
|
-
try {
|
|
4106
|
-
if (toolName !== "Edit" && toolName !== "Write") return {};
|
|
4107
|
-
const state = getPhaseState(repoRoot);
|
|
4108
|
-
if (state === null || !state.lfg_active) return {};
|
|
4109
|
-
const expectedSkillPath = `.claude/skills/compound/${state.current_phase}/SKILL.md`;
|
|
4110
|
-
const skillRead = state.skills_read.includes(expectedSkillPath);
|
|
4111
|
-
if (!skillRead) {
|
|
4112
|
-
return {
|
|
4113
|
-
hookSpecificOutput: {
|
|
4114
|
-
hookEventName: "PreToolUse",
|
|
4115
|
-
additionalContext: `PHASE GUARD WARNING: You are in LFG phase ${state.phase_index}/5 (${state.current_phase}) but have NOT read the skill file yet. Read ${expectedSkillPath} before continuing.`
|
|
4116
|
-
}
|
|
4117
|
-
};
|
|
4118
|
-
}
|
|
4119
|
-
return {};
|
|
4120
|
-
} catch {
|
|
4121
|
-
return {};
|
|
4122
|
-
}
|
|
4123
|
-
}
|
|
4124
|
-
|
|
4125
|
-
// src/setup/hooks-read-tracker.ts
|
|
4126
|
-
var SKILL_PATH_PATTERN = /(?:^|\/)\.claude\/skills\/compound\/([^/]+)\/SKILL\.md$/;
|
|
4127
|
-
function normalizePath(path2) {
|
|
4128
|
-
return path2.replaceAll("\\", "/");
|
|
4129
|
-
}
|
|
4130
|
-
function toCanonicalSkillPath(filePath) {
|
|
4131
|
-
const normalized = normalizePath(filePath);
|
|
4132
|
-
const match = SKILL_PATH_PATTERN.exec(normalized);
|
|
4133
|
-
if (!match?.[1]) return null;
|
|
4134
|
-
return `.claude/skills/compound/${match[1]}/SKILL.md`;
|
|
4135
|
-
}
|
|
4136
|
-
function processReadTracker(repoRoot, toolName, toolInput) {
|
|
4137
|
-
try {
|
|
4138
|
-
if (toolName !== "Read") return {};
|
|
4139
|
-
const state = getPhaseState(repoRoot);
|
|
4140
|
-
if (state === null || !state.lfg_active) return {};
|
|
4141
|
-
const filePath = typeof toolInput.file_path === "string" ? toolInput.file_path : null;
|
|
4142
|
-
if (filePath === null) return {};
|
|
4143
|
-
const canonicalPath = toCanonicalSkillPath(filePath);
|
|
4144
|
-
if (canonicalPath === null) return {};
|
|
4145
|
-
if (!state.skills_read.includes(canonicalPath)) {
|
|
4146
|
-
updatePhaseState(repoRoot, {
|
|
4147
|
-
skills_read: [...state.skills_read, canonicalPath]
|
|
4148
|
-
});
|
|
4149
|
-
}
|
|
4150
|
-
return {};
|
|
4151
|
-
} catch {
|
|
4152
|
-
return {};
|
|
4153
|
-
}
|
|
4154
|
-
}
|
|
4155
|
-
|
|
4156
|
-
// src/setup/hooks-stop-audit.ts
|
|
4157
|
-
function hasTransitionEvidence(state) {
|
|
4158
|
-
if (state.phase_index === 5) return true;
|
|
4159
|
-
const nextPhase = PHASES[state.phase_index];
|
|
4160
|
-
if (nextPhase === void 0) return false;
|
|
4161
|
-
const nextSkillPath = `.claude/skills/compound/${nextPhase}/SKILL.md`;
|
|
4162
|
-
return state.skills_read.includes(nextSkillPath);
|
|
4163
|
-
}
|
|
4164
|
-
function processStopAudit(repoRoot, stopHookActive = false) {
|
|
4165
|
-
try {
|
|
4166
|
-
if (stopHookActive) return {};
|
|
4167
|
-
const state = getPhaseState(repoRoot);
|
|
4168
|
-
if (state === null || !state.lfg_active) return {};
|
|
4169
|
-
const expectedGate = expectedGateForPhase(state.phase_index);
|
|
4170
|
-
if (expectedGate === null) return {};
|
|
4171
|
-
if (state.gates_passed.includes(expectedGate)) return {};
|
|
4172
|
-
if (!hasTransitionEvidence(state)) return {};
|
|
4173
|
-
return {
|
|
4174
|
-
continue: false,
|
|
4175
|
-
stopReason: `PHASE GATE NOT VERIFIED: ${state.current_phase} requires gate '${expectedGate}'. Run: npx ca phase-check gate ${expectedGate}`
|
|
4176
|
-
};
|
|
4177
|
-
} catch {
|
|
4178
|
-
return {};
|
|
4179
|
-
}
|
|
4180
|
-
}
|
|
4181
|
-
|
|
4182
|
-
// src/setup/hooks.ts
|
|
4183
|
-
var HOOK_FILE_MODE = 493;
|
|
4184
|
-
function hasCompoundAgentHook(content) {
|
|
4185
|
-
return content.includes(HOOK_MARKER);
|
|
4186
|
-
}
|
|
4187
|
-
async function getGitHooksDir(repoRoot) {
|
|
4188
|
-
const gitPath = join(repoRoot, ".git");
|
|
4189
|
-
if (!existsSync(gitPath)) {
|
|
4190
|
-
return null;
|
|
4191
|
-
}
|
|
4192
|
-
let gitDir = gitPath;
|
|
4193
|
-
if (lstatSync(gitPath).isFile()) {
|
|
4194
|
-
const content = readFileSync(gitPath, "utf-8").trim();
|
|
4195
|
-
const match = /^gitdir:\s*(.+)$/.exec(content);
|
|
4196
|
-
if (!match?.[1]) return null;
|
|
4197
|
-
gitDir = resolve(repoRoot, match[1]);
|
|
4198
|
-
}
|
|
4199
|
-
const configPath2 = join(gitDir, "config");
|
|
4200
|
-
if (existsSync(configPath2)) {
|
|
4201
|
-
const config = await readFile(configPath2, "utf-8");
|
|
4202
|
-
const match = /hooksPath\s*=\s*(.+)$/m.exec(config);
|
|
4203
|
-
if (match?.[1]) {
|
|
4204
|
-
const hooksPath = match[1].trim();
|
|
4205
|
-
return hooksPath.startsWith("/") ? hooksPath : join(repoRoot, hooksPath);
|
|
4206
|
-
}
|
|
4207
|
-
}
|
|
4208
|
-
const defaultHooksDir = join(gitDir, "hooks");
|
|
4209
|
-
return existsSync(defaultHooksDir) ? defaultHooksDir : null;
|
|
4210
|
-
}
|
|
4211
|
-
function findFirstTopLevelExitLine(lines) {
|
|
4212
|
-
let insideFunction = 0;
|
|
4213
|
-
let heredocDelimiter = null;
|
|
4214
|
-
for (let i = 0; i < lines.length; i++) {
|
|
4215
|
-
const line = lines[i] ?? "";
|
|
4216
|
-
const trimmed = line.trim();
|
|
4217
|
-
if (heredocDelimiter !== null) {
|
|
4218
|
-
if (trimmed === heredocDelimiter) {
|
|
4219
|
-
heredocDelimiter = null;
|
|
4220
|
-
}
|
|
4221
|
-
continue;
|
|
4222
|
-
}
|
|
4223
|
-
const heredocMatch = /<<-?\s*['"]?(\w+)['"]?/.exec(line);
|
|
4224
|
-
if (heredocMatch?.[1]) {
|
|
4225
|
-
heredocDelimiter = heredocMatch[1];
|
|
4226
|
-
continue;
|
|
4227
|
-
}
|
|
4228
|
-
for (const char of line) {
|
|
4229
|
-
if (char === "{") insideFunction++;
|
|
4230
|
-
if (char === "}") insideFunction = Math.max(0, insideFunction - 1);
|
|
4231
|
-
}
|
|
4232
|
-
if (insideFunction > 0) {
|
|
4233
|
-
continue;
|
|
4234
|
-
}
|
|
4235
|
-
if (/^\s*exit\s+(\d+|\$\w+|\$\?)\s*$/.test(trimmed)) {
|
|
4236
|
-
return i;
|
|
4237
|
-
}
|
|
4238
|
-
}
|
|
4239
|
-
return -1;
|
|
4240
|
-
}
|
|
4241
|
-
async function installPreCommitHook(repoRoot) {
|
|
4242
|
-
const gitHooksDir = await getGitHooksDir(repoRoot);
|
|
4243
|
-
if (!gitHooksDir) {
|
|
4244
|
-
return { status: "not_git_repo" };
|
|
4245
|
-
}
|
|
4246
|
-
await mkdir(gitHooksDir, { recursive: true });
|
|
4247
|
-
const hookPath = join(gitHooksDir, "pre-commit");
|
|
4248
|
-
if (existsSync(hookPath)) {
|
|
4249
|
-
const content = await readFile(hookPath, "utf-8");
|
|
4250
|
-
if (hasCompoundAgentHook(content)) {
|
|
4251
|
-
return { status: "already_installed" };
|
|
4252
|
-
}
|
|
4253
|
-
const lines = content.split("\n");
|
|
4254
|
-
const exitLineIndex = findFirstTopLevelExitLine(lines);
|
|
4255
|
-
let newContent;
|
|
4256
|
-
if (exitLineIndex === -1) {
|
|
4257
|
-
newContent = content.trimEnd() + "\n" + COMPOUND_AGENT_HOOK_BLOCK;
|
|
4258
|
-
} else {
|
|
4259
|
-
const before = lines.slice(0, exitLineIndex);
|
|
4260
|
-
const after = lines.slice(exitLineIndex);
|
|
4261
|
-
newContent = before.join("\n") + COMPOUND_AGENT_HOOK_BLOCK + after.join("\n");
|
|
4262
|
-
}
|
|
4263
|
-
await writeFile(hookPath, newContent, "utf-8");
|
|
4264
|
-
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
4265
|
-
return { status: "appended" };
|
|
4266
|
-
}
|
|
4267
|
-
await writeFile(hookPath, PRE_COMMIT_HOOK_TEMPLATE, "utf-8");
|
|
4268
|
-
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
4269
|
-
return { status: "installed" };
|
|
4270
|
-
}
|
|
4271
|
-
async function installPostCommitHook(repoRoot) {
|
|
4272
|
-
const gitHooksDir = await getGitHooksDir(repoRoot);
|
|
4273
|
-
if (!gitHooksDir) {
|
|
4274
|
-
return { status: "not_git_repo" };
|
|
4275
|
-
}
|
|
4276
|
-
await mkdir(gitHooksDir, { recursive: true });
|
|
4277
|
-
const hookPath = join(gitHooksDir, "post-commit");
|
|
4278
|
-
if (existsSync(hookPath)) {
|
|
4279
|
-
const content = await readFile(hookPath, "utf-8");
|
|
4280
|
-
if (content.includes(POST_COMMIT_HOOK_MARKER)) {
|
|
4281
|
-
return { status: "already_installed" };
|
|
4282
|
-
}
|
|
4283
|
-
const lines = content.split("\n");
|
|
4284
|
-
const exitLineIndex = findFirstTopLevelExitLine(lines);
|
|
4285
|
-
let newContent;
|
|
4286
|
-
if (exitLineIndex === -1) {
|
|
4287
|
-
newContent = content.trimEnd() + "\n" + COMPOUND_AGENT_POST_COMMIT_BLOCK;
|
|
4288
|
-
} else {
|
|
4289
|
-
const before = lines.slice(0, exitLineIndex);
|
|
4290
|
-
const after = lines.slice(exitLineIndex);
|
|
4291
|
-
newContent = before.join("\n") + COMPOUND_AGENT_POST_COMMIT_BLOCK + after.join("\n");
|
|
4292
|
-
}
|
|
4293
|
-
await writeFile(hookPath, newContent, "utf-8");
|
|
4294
|
-
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
4295
|
-
return { status: "appended" };
|
|
4296
|
-
}
|
|
4297
|
-
await writeFile(hookPath, POST_COMMIT_HOOK_TEMPLATE, "utf-8");
|
|
4298
|
-
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
4299
|
-
return { status: "installed" };
|
|
4300
|
-
}
|
|
4301
|
-
async function readStdin() {
|
|
4302
|
-
const chunks = [];
|
|
4303
|
-
for await (const chunk of process.stdin) {
|
|
4304
|
-
chunks.push(chunk);
|
|
4305
|
-
}
|
|
4306
|
-
return Buffer.concat(chunks).toString("utf-8");
|
|
4307
|
-
}
|
|
4308
|
-
async function runUserPromptHook() {
|
|
4309
|
-
try {
|
|
4310
|
-
const input = await readStdin();
|
|
4311
|
-
const data = JSON.parse(input);
|
|
4312
|
-
if (!data.prompt) {
|
|
4313
|
-
console.log(JSON.stringify({}));
|
|
4314
|
-
return;
|
|
4315
|
-
}
|
|
4316
|
-
const result = processUserPrompt(data.prompt);
|
|
4317
|
-
console.log(JSON.stringify(result));
|
|
4318
|
-
} catch {
|
|
4319
|
-
console.log(JSON.stringify({}));
|
|
4320
|
-
}
|
|
4321
|
-
}
|
|
4322
|
-
async function runPostToolFailureHook() {
|
|
4323
|
-
try {
|
|
4324
|
-
const input = await readStdin();
|
|
4325
|
-
const data = JSON.parse(input);
|
|
4326
|
-
if (!data.tool_name) {
|
|
4327
|
-
console.log(JSON.stringify({}));
|
|
4328
|
-
return;
|
|
4329
|
-
}
|
|
4330
|
-
const stateDir = join(getRepoRoot(), ".claude");
|
|
4331
|
-
const result = processToolFailure(data.tool_name, data.tool_input ?? {}, stateDir);
|
|
4332
|
-
console.log(JSON.stringify(result));
|
|
4333
|
-
} catch {
|
|
4334
|
-
console.log(JSON.stringify({}));
|
|
4335
|
-
}
|
|
4336
|
-
}
|
|
4337
|
-
async function runPostToolSuccessHook() {
|
|
4338
|
-
try {
|
|
4339
|
-
await readStdin();
|
|
4340
|
-
const stateDir = join(getRepoRoot(), ".claude");
|
|
4341
|
-
processToolSuccess(stateDir);
|
|
4342
|
-
console.log(JSON.stringify({}));
|
|
4343
|
-
} catch {
|
|
4344
|
-
console.log(JSON.stringify({}));
|
|
4345
|
-
}
|
|
4346
|
-
}
|
|
4347
|
-
async function runToolHook(processor) {
|
|
4348
|
-
try {
|
|
4349
|
-
const input = await readStdin();
|
|
4350
|
-
const data = JSON.parse(input);
|
|
4351
|
-
if (!data.tool_name) {
|
|
4352
|
-
console.log(JSON.stringify({}));
|
|
4353
|
-
return;
|
|
4354
|
-
}
|
|
4355
|
-
console.log(JSON.stringify(processor(getRepoRoot(), data.tool_name, data.tool_input ?? {})));
|
|
4356
|
-
} catch {
|
|
4357
|
-
console.log(JSON.stringify({}));
|
|
4358
|
-
}
|
|
4359
|
-
}
|
|
4360
|
-
async function runStopAuditHook() {
|
|
4361
|
-
try {
|
|
4362
|
-
const input = await readStdin();
|
|
4363
|
-
const data = JSON.parse(input);
|
|
4364
|
-
console.log(JSON.stringify(processStopAudit(getRepoRoot(), data.stop_hook_active ?? false)));
|
|
4365
|
-
} catch {
|
|
4366
|
-
console.log(JSON.stringify({}));
|
|
4367
|
-
}
|
|
4368
|
-
}
|
|
4369
|
-
function registerHooksCommand(program2) {
|
|
4370
|
-
const hooksCommand = program2.command("hooks").description("Git hooks management");
|
|
4371
|
-
hooksCommand.command("run <hook>").description("Run a hook script (called by git/Claude hooks)").option("--json", "Output as JSON").action(async (hook, options) => {
|
|
4372
|
-
if (hook === "pre-commit") {
|
|
4373
|
-
if (options.json) {
|
|
4374
|
-
console.log(JSON.stringify({ hook: "pre-commit", message: PRE_COMMIT_MESSAGE }));
|
|
4375
|
-
} else {
|
|
4376
|
-
console.log(PRE_COMMIT_MESSAGE);
|
|
4377
|
-
}
|
|
4378
|
-
} else if (hook === "user-prompt") {
|
|
4379
|
-
await runUserPromptHook();
|
|
4380
|
-
} else if (hook === "post-tool-failure") {
|
|
4381
|
-
await runPostToolFailureHook();
|
|
4382
|
-
} else if (hook === "post-tool-success") {
|
|
4383
|
-
await runPostToolSuccessHook();
|
|
4384
|
-
} else if (hook === "phase-guard") {
|
|
4385
|
-
await runToolHook(processPhaseGuard);
|
|
4386
|
-
} else if (hook === "post-read" || hook === "read-tracker") {
|
|
4387
|
-
await runToolHook(processReadTracker);
|
|
4388
|
-
} else if (hook === "phase-audit" || hook === "stop-audit") {
|
|
4389
|
-
await runStopAuditHook();
|
|
4390
|
-
} else {
|
|
4391
|
-
if (options.json) {
|
|
4392
|
-
console.log(JSON.stringify({ error: `Unknown hook: ${hook}` }));
|
|
4393
|
-
} else {
|
|
4394
|
-
console.error(
|
|
4395
|
-
formatError(
|
|
4396
|
-
"hooks",
|
|
4397
|
-
"UNKNOWN_HOOK",
|
|
4398
|
-
`Unknown hook: ${hook}`,
|
|
4399
|
-
"Valid hooks: pre-commit, user-prompt, post-tool-failure, post-tool-success, post-read (or read-tracker), phase-guard, phase-audit (or stop-audit)"
|
|
4400
|
-
)
|
|
4401
|
-
);
|
|
4402
|
-
}
|
|
4403
|
-
process.exitCode = 1;
|
|
4404
|
-
}
|
|
4405
|
-
});
|
|
4406
|
-
}
|
|
4407
|
-
|
|
4408
3740
|
// src/setup/templates/agents-external.ts
|
|
4409
3741
|
var EXTERNAL_AGENT_TEMPLATES = {
|
|
4410
3742
|
"external-reviewer-gemini.md": `---
|
|
@@ -6850,80 +6182,907 @@ name: Test Cleaner
|
|
|
6850
6182
|
description: Multi-phase test suite optimization with adversarial review
|
|
6851
6183
|
---
|
|
6852
6184
|
|
|
6853
|
-
# Test Cleaner Skill
|
|
6854
|
-
|
|
6855
|
-
## Overview
|
|
6856
|
-
Analyze, optimize, and clean a project's test suite through a multi-phase workflow with adversarial review. Produces machine-readable output and feeds findings into compound-agent memory.
|
|
6857
|
-
|
|
6858
|
-
## Methodology
|
|
6859
|
-
|
|
6860
|
-
### Phase 1: Analysis
|
|
6861
|
-
Spawn multiple analysis subagents in parallel:
|
|
6862
|
-
- **Cargo-cult detector**: Find fake tests, mocked business logic, trivial assertions
|
|
6863
|
-
- **Redundancy analyzer**: Identify overlapping/duplicate test coverage
|
|
6864
|
-
- **Independence checker**: Verify tests don't depend on execution order or shared state
|
|
6865
|
-
- **Invariant tracer**: Map which invariants each test verifies (Lamport framework)
|
|
6866
|
-
- **Coverage analyzer**: Identify untested code paths and modules
|
|
6867
|
-
|
|
6868
|
-
### Phase 2: Planning
|
|
6869
|
-
Synthesize analysis results into a refined optimization plan:
|
|
6870
|
-
- Categorize findings by severity (P1/P2/P3)
|
|
6871
|
-
- Propose specific changes for each finding
|
|
6872
|
-
- Estimate impact on test suite speed and coverage
|
|
6873
|
-
- Iterate with subagents until the plan is comprehensive
|
|
6874
|
-
|
|
6875
|
-
### Phase 3: Adversarial Review (CRITICAL QUALITY GATE)
|
|
6876
|
-
**This is THE KEY PHASE -- the most important phase in the entire workflow. NEVER skip, NEVER rush, NEVER settle for "good enough."**
|
|
6877
|
-
|
|
6878
|
-
Expose the plan to two neutral reviewer subagents:
|
|
6879
|
-
- **Reviewer A** (Opus): Independent critique of the optimization plan
|
|
6880
|
-
- **Reviewer B** (Sonnet): Independent critique from a different perspective
|
|
6881
|
-
|
|
6882
|
-
Both reviewers challenge assumptions, identify risks, and suggest improvements.
|
|
6883
|
-
|
|
6884
|
-
**Mandatory iteration loop**: After each reviewer pass, if ANY issues, concerns, or suggestions remain from EITHER reviewer, revise the plan and re-submit to BOTH reviewers. Repeat until BOTH reviewers explicitly approve with ZERO reservations. Do not proceed to Phase 4 until unanimous, unconditional approval is reached.
|
|
6885
|
-
|
|
6886
|
-
This is the critical quality gate. Loop as many times as needed. The test suite must be bulletproof before execution begins.
|
|
6887
|
-
|
|
6888
|
-
### Phase 4: Execution
|
|
6889
|
-
Apply the agreed changes:
|
|
6890
|
-
- Machine-readable output format: \`ERROR [file:line] type: description\`
|
|
6891
|
-
- Include \`REMEDIATION\` suggestions and \`SEE\` references
|
|
6892
|
-
- Use \`pnpm test:segment\`, \`pnpm test:random\`, \`pnpm test:critical\` for targeted validation
|
|
6185
|
+
# Test Cleaner Skill
|
|
6186
|
+
|
|
6187
|
+
## Overview
|
|
6188
|
+
Analyze, optimize, and clean a project's test suite through a multi-phase workflow with adversarial review. Produces machine-readable output and feeds findings into compound-agent memory.
|
|
6189
|
+
|
|
6190
|
+
## Methodology
|
|
6191
|
+
|
|
6192
|
+
### Phase 1: Analysis
|
|
6193
|
+
Spawn multiple analysis subagents in parallel:
|
|
6194
|
+
- **Cargo-cult detector**: Find fake tests, mocked business logic, trivial assertions
|
|
6195
|
+
- **Redundancy analyzer**: Identify overlapping/duplicate test coverage
|
|
6196
|
+
- **Independence checker**: Verify tests don't depend on execution order or shared state
|
|
6197
|
+
- **Invariant tracer**: Map which invariants each test verifies (Lamport framework)
|
|
6198
|
+
- **Coverage analyzer**: Identify untested code paths and modules
|
|
6199
|
+
|
|
6200
|
+
### Phase 2: Planning
|
|
6201
|
+
Synthesize analysis results into a refined optimization plan:
|
|
6202
|
+
- Categorize findings by severity (P1/P2/P3)
|
|
6203
|
+
- Propose specific changes for each finding
|
|
6204
|
+
- Estimate impact on test suite speed and coverage
|
|
6205
|
+
- Iterate with subagents until the plan is comprehensive
|
|
6206
|
+
|
|
6207
|
+
### Phase 3: Adversarial Review (CRITICAL QUALITY GATE)
|
|
6208
|
+
**This is THE KEY PHASE -- the most important phase in the entire workflow. NEVER skip, NEVER rush, NEVER settle for "good enough."**
|
|
6209
|
+
|
|
6210
|
+
Expose the plan to two neutral reviewer subagents:
|
|
6211
|
+
- **Reviewer A** (Opus): Independent critique of the optimization plan
|
|
6212
|
+
- **Reviewer B** (Sonnet): Independent critique from a different perspective
|
|
6213
|
+
|
|
6214
|
+
Both reviewers challenge assumptions, identify risks, and suggest improvements.
|
|
6215
|
+
|
|
6216
|
+
**Mandatory iteration loop**: After each reviewer pass, if ANY issues, concerns, or suggestions remain from EITHER reviewer, revise the plan and re-submit to BOTH reviewers. Repeat until BOTH reviewers explicitly approve with ZERO reservations. Do not proceed to Phase 4 until unanimous, unconditional approval is reached.
|
|
6217
|
+
|
|
6218
|
+
This is the critical quality gate. Loop as many times as needed. The test suite must be bulletproof before execution begins.
|
|
6219
|
+
|
|
6220
|
+
### Phase 4: Execution
|
|
6221
|
+
Apply the agreed changes:
|
|
6222
|
+
- Machine-readable output format: \`ERROR [file:line] type: description\`
|
|
6223
|
+
- Include \`REMEDIATION\` suggestions and \`SEE\` references
|
|
6224
|
+
- Use \`pnpm test:segment\`, \`pnpm test:random\`, \`pnpm test:critical\` for targeted validation
|
|
6225
|
+
|
|
6226
|
+
### Phase 5: Verification
|
|
6227
|
+
- Run full test suite after changes
|
|
6228
|
+
- Compare before/after metrics (count, duration, coverage)
|
|
6229
|
+
- Feed findings into compound-agent memory via \`npx ca learn\`
|
|
6230
|
+
|
|
6231
|
+
## Test Scripts Integration
|
|
6232
|
+
- \`pnpm test:segment <module>\` -- Test specific module in isolation
|
|
6233
|
+
- \`pnpm test:random <pct>\` -- Deterministic random subset (seeded per-agent)
|
|
6234
|
+
- \`pnpm test:critical\` -- P1/critical tests only (fast CI feedback)
|
|
6235
|
+
|
|
6236
|
+
## Memory Integration
|
|
6237
|
+
- Run \`npx ca search "test optimization"\` before starting
|
|
6238
|
+
- After completion, capture findings via \`npx ca learn\`
|
|
6239
|
+
- Feed patterns into CCT system for future sessions
|
|
6240
|
+
|
|
6241
|
+
## Common Pitfalls
|
|
6242
|
+
- Deleting tests without verifying coverage is maintained elsewhere
|
|
6243
|
+
- Optimizing for speed at the cost of correctness
|
|
6244
|
+
- Settling for partial approval or cutting the Phase 3 review loop short before BOTH reviewers approve with zero reservations
|
|
6245
|
+
- Making changes without machine-readable output
|
|
6246
|
+
- Not feeding results back into compound-agent memory
|
|
6247
|
+
|
|
6248
|
+
## Quality Criteria
|
|
6249
|
+
- All 5 phases completed (analysis, planning, review, execution, verification)
|
|
6250
|
+
- Both adversarial reviewers approved with zero reservations after iterative refinement
|
|
6251
|
+
- Machine-readable output format used throughout
|
|
6252
|
+
- Full test suite passes after changes
|
|
6253
|
+
- Coverage not degraded
|
|
6254
|
+
- Findings captured in compound-agent memory
|
|
6255
|
+
`
|
|
6256
|
+
};
|
|
6257
|
+
|
|
6258
|
+
// src/setup/gemini.ts
|
|
6259
|
+
var HOOKS = {
|
|
6260
|
+
"ca-prime.sh": `#!/usr/bin/env bash
|
|
6261
|
+
input=$(cat)
|
|
6262
|
+
echo "$input" | npx ca prime > /dev/null 2>&1
|
|
6263
|
+
echo '{"decision": "allow"}'
|
|
6264
|
+
`,
|
|
6265
|
+
"ca-user-prompt.sh": `#!/usr/bin/env bash
|
|
6266
|
+
input=$(cat)
|
|
6267
|
+
echo "$input" | npx ca hooks run user-prompt > /dev/null 2>&1
|
|
6268
|
+
echo '{"decision": "allow"}'
|
|
6269
|
+
`,
|
|
6270
|
+
"ca-post-tool.sh": `#!/usr/bin/env bash
|
|
6271
|
+
input=$(cat)
|
|
6272
|
+
echo "$input" | npx ca hooks run post-tool-success > /dev/null 2>&1
|
|
6273
|
+
echo '{"decision": "allow"}'
|
|
6274
|
+
`,
|
|
6275
|
+
"ca-phase-guard.sh": `#!/usr/bin/env bash
|
|
6276
|
+
input=$(cat)
|
|
6277
|
+
echo "$input" | npx ca hooks run phase-guard > /dev/null 2>&1
|
|
6278
|
+
rc=$?
|
|
6279
|
+
if [ $rc -ne 0 ]; then
|
|
6280
|
+
echo '{"decision": "deny", "reason": "Phase guard: read the phase skill before editing"}'
|
|
6281
|
+
exit 0
|
|
6282
|
+
fi
|
|
6283
|
+
echo '{"decision": "allow"}'
|
|
6284
|
+
`
|
|
6285
|
+
};
|
|
6286
|
+
var SETTINGS_JSON = {
|
|
6287
|
+
hooks: {
|
|
6288
|
+
SessionStart: [
|
|
6289
|
+
{
|
|
6290
|
+
matcher: ".*",
|
|
6291
|
+
hooks: [{ name: "ca-prime", type: "command", command: "bash .gemini/hooks/ca-prime.sh" }]
|
|
6292
|
+
}
|
|
6293
|
+
],
|
|
6294
|
+
BeforeAgent: [
|
|
6295
|
+
{
|
|
6296
|
+
matcher: ".*",
|
|
6297
|
+
hooks: [{ name: "ca-user-prompt", type: "command", command: "bash .gemini/hooks/ca-user-prompt.sh" }]
|
|
6298
|
+
}
|
|
6299
|
+
],
|
|
6300
|
+
BeforeTool: [
|
|
6301
|
+
{
|
|
6302
|
+
matcher: "replace|write_file|create_file",
|
|
6303
|
+
hooks: [{ name: "ca-phase-guard", type: "command", command: "bash .gemini/hooks/ca-phase-guard.sh" }]
|
|
6304
|
+
}
|
|
6305
|
+
],
|
|
6306
|
+
AfterTool: [
|
|
6307
|
+
{
|
|
6308
|
+
matcher: "run_shell_command|replace|write_file|create_file",
|
|
6309
|
+
hooks: [{ name: "ca-post-tool", type: "command", command: "bash .gemini/hooks/ca-post-tool.sh" }]
|
|
6310
|
+
}
|
|
6311
|
+
]
|
|
6312
|
+
}
|
|
6313
|
+
};
|
|
6314
|
+
function parseDescription(content, fallback) {
|
|
6315
|
+
const raw = content.match(/description:\s*(.*)/)?.[1] ?? fallback;
|
|
6316
|
+
return raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
6317
|
+
}
|
|
6318
|
+
async function writeSettings(geminiDir) {
|
|
6319
|
+
const settingsPath = join(geminiDir, "settings.json");
|
|
6320
|
+
let settings = SETTINGS_JSON;
|
|
6321
|
+
if (existsSync(settingsPath)) {
|
|
6322
|
+
try {
|
|
6323
|
+
const existing = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
6324
|
+
settings = {
|
|
6325
|
+
...existing,
|
|
6326
|
+
hooks: {
|
|
6327
|
+
...existing.hooks,
|
|
6328
|
+
...SETTINGS_JSON.hooks
|
|
6329
|
+
}
|
|
6330
|
+
};
|
|
6331
|
+
} catch {
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
6335
|
+
}
|
|
6336
|
+
async function writeTomlCommands(geminiDir) {
|
|
6337
|
+
for (const [filename, content] of Object.entries(WORKFLOW_COMMANDS)) {
|
|
6338
|
+
const cmdName = filename.replace(".md", "");
|
|
6339
|
+
const description = parseDescription(content, `Compound ${cmdName} command`);
|
|
6340
|
+
const toml = `description = "${description}"
|
|
6341
|
+
prompt = """
|
|
6342
|
+
@{.claude/commands/compound/${filename}}
|
|
6343
|
+
|
|
6344
|
+
{{args}}
|
|
6345
|
+
"""
|
|
6346
|
+
`;
|
|
6347
|
+
await writeFile(join(geminiDir, "commands", "compound", `${cmdName}.toml`), toml, "utf8");
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
6350
|
+
async function writeSkills(geminiDir) {
|
|
6351
|
+
for (const [phase, content] of Object.entries(PHASE_SKILLS)) {
|
|
6352
|
+
const skillDir = join(geminiDir, "skills", `compound-${phase}`);
|
|
6353
|
+
await mkdir(skillDir, { recursive: true });
|
|
6354
|
+
const description = parseDescription(content, `Compound ${phase} skill`);
|
|
6355
|
+
await writeFile(join(skillDir, "SKILL.md"), `---
|
|
6356
|
+
name: compound-${phase}
|
|
6357
|
+
description: ${description}
|
|
6358
|
+
---
|
|
6359
|
+
|
|
6360
|
+
${content}
|
|
6361
|
+
`, "utf8");
|
|
6362
|
+
}
|
|
6363
|
+
for (const [name, content] of Object.entries(AGENT_ROLE_SKILLS)) {
|
|
6364
|
+
const skillDir = join(geminiDir, "skills", `compound-agent-${name}`);
|
|
6365
|
+
await mkdir(skillDir, { recursive: true });
|
|
6366
|
+
const description = parseDescription(content, `Compound agent ${name} skill`);
|
|
6367
|
+
await writeFile(join(skillDir, "SKILL.md"), `---
|
|
6368
|
+
name: compound-agent-${name}
|
|
6369
|
+
description: ${description}
|
|
6370
|
+
---
|
|
6893
6371
|
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6372
|
+
${content}
|
|
6373
|
+
`, "utf8");
|
|
6374
|
+
}
|
|
6375
|
+
}
|
|
6376
|
+
async function installGeminiAdapter(options) {
|
|
6377
|
+
const repoRoot = getRepoRoot();
|
|
6378
|
+
const geminiDir = join(repoRoot, ".gemini");
|
|
6379
|
+
if (options.dryRun) {
|
|
6380
|
+
if (options.json) {
|
|
6381
|
+
console.log(JSON.stringify({ dryRun: true, wouldInstall: true, location: geminiDir }));
|
|
6382
|
+
} else {
|
|
6383
|
+
console.log(`Would install gemini hooks and commands to ${geminiDir}`);
|
|
6384
|
+
}
|
|
6385
|
+
return;
|
|
6386
|
+
}
|
|
6387
|
+
await mkdir(join(geminiDir, "hooks"), { recursive: true });
|
|
6388
|
+
await mkdir(join(geminiDir, "commands", "compound"), { recursive: true });
|
|
6389
|
+
for (const [filename, content] of Object.entries(HOOKS)) {
|
|
6390
|
+
await writeFile(join(geminiDir, "hooks", filename), content, { mode: 493 });
|
|
6391
|
+
}
|
|
6392
|
+
await writeSettings(geminiDir);
|
|
6393
|
+
await writeTomlCommands(geminiDir);
|
|
6394
|
+
await writeSkills(geminiDir);
|
|
6395
|
+
if (options.json) {
|
|
6396
|
+
console.log(JSON.stringify({ installed: true, location: geminiDir, action: "created" }));
|
|
6397
|
+
} else {
|
|
6398
|
+
out.success("Gemini CLI compatibility hooks installed");
|
|
6399
|
+
console.log(` Location: ${geminiDir}`);
|
|
6400
|
+
console.log(" Hooks: SessionStart, BeforeAgent, BeforeTool, AfterTool");
|
|
6401
|
+
console.log(" Commands: /compound:* aliases generated");
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6404
|
+
function registerGeminiSubcommand(setupCommand) {
|
|
6405
|
+
setupCommand.command("gemini").description("Install Gemini CLI compatibility hooks (Adapter Pattern)").option("--dry-run", "Show what would change without writing").option("--json", "Output as JSON").action(async (options) => {
|
|
6406
|
+
try {
|
|
6407
|
+
await installGeminiAdapter(options);
|
|
6408
|
+
} catch (err) {
|
|
6409
|
+
if (options.json) {
|
|
6410
|
+
console.log(JSON.stringify({ error: String(err) }));
|
|
6411
|
+
} else {
|
|
6412
|
+
console.error(formatError("setup", "GEMINI_INSTALL_ERROR", String(err), "Check .gemini/ directory permissions"));
|
|
6413
|
+
}
|
|
6414
|
+
process.exitCode = 1;
|
|
6415
|
+
}
|
|
6416
|
+
});
|
|
6417
|
+
}
|
|
6898
6418
|
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6419
|
+
// src/setup/hooks-user-prompt.ts
|
|
6420
|
+
var CORRECTION_PATTERNS = [
|
|
6421
|
+
/\bactually\b/i,
|
|
6422
|
+
/\bno[,.]?\s/i,
|
|
6423
|
+
/\bwrong\b/i,
|
|
6424
|
+
/\bthat'?s not right\b/i,
|
|
6425
|
+
/\bthat'?s incorrect\b/i,
|
|
6426
|
+
/\buse .+ instead\b/i,
|
|
6427
|
+
/\bi told you\b/i,
|
|
6428
|
+
/\bi already said\b/i,
|
|
6429
|
+
/\bnot like that\b/i,
|
|
6430
|
+
/\byou forgot\b/i,
|
|
6431
|
+
/\byou missed\b/i,
|
|
6432
|
+
/\bstop\s*(,\s*)?(doing|using|that)\b/i,
|
|
6433
|
+
/\bwait\s*(,\s*)?(that|no|wrong)\b/i
|
|
6434
|
+
];
|
|
6435
|
+
var HIGH_CONFIDENCE_PLANNING = [
|
|
6436
|
+
/\bdecide\b/i,
|
|
6437
|
+
/\bchoose\b/i,
|
|
6438
|
+
/\bpick\b/i,
|
|
6439
|
+
/\bwhich approach\b/i,
|
|
6440
|
+
/\bwhat do you think\b/i,
|
|
6441
|
+
/\bshould we\b/i,
|
|
6442
|
+
/\bwould you\b/i,
|
|
6443
|
+
/\bhow should\b/i,
|
|
6444
|
+
/\bwhat'?s the best\b/i,
|
|
6445
|
+
/\badd feature\b/i,
|
|
6446
|
+
/\bset up\b/i
|
|
6447
|
+
];
|
|
6448
|
+
var LOW_CONFIDENCE_PLANNING = [
|
|
6449
|
+
/\bimplement\b/i,
|
|
6450
|
+
/\bbuild\b/i,
|
|
6451
|
+
/\bcreate\b/i,
|
|
6452
|
+
/\brefactor\b/i,
|
|
6453
|
+
/\bfix\b/i,
|
|
6454
|
+
/\bwrite\b/i,
|
|
6455
|
+
/\bdevelop\b/i
|
|
6456
|
+
];
|
|
6457
|
+
var CORRECTION_REMINDER = "Remember: You have memory tools available - `npx ca learn` to save insights, `npx ca search` to find past solutions.";
|
|
6458
|
+
var PLANNING_REMINDER = "If you're uncertain or hesitant, remember your memory tools: `npx ca search` may have relevant context from past sessions.";
|
|
6459
|
+
function detectCorrection(prompt) {
|
|
6460
|
+
return CORRECTION_PATTERNS.some((pattern) => pattern.test(prompt));
|
|
6461
|
+
}
|
|
6462
|
+
function detectPlanning(prompt) {
|
|
6463
|
+
if (HIGH_CONFIDENCE_PLANNING.some((pattern) => pattern.test(prompt))) {
|
|
6464
|
+
return true;
|
|
6465
|
+
}
|
|
6466
|
+
const lowMatches = LOW_CONFIDENCE_PLANNING.filter((pattern) => pattern.test(prompt));
|
|
6467
|
+
return lowMatches.length >= 2;
|
|
6468
|
+
}
|
|
6469
|
+
function processUserPrompt(prompt) {
|
|
6470
|
+
if (detectCorrection(prompt)) {
|
|
6471
|
+
return {
|
|
6472
|
+
hookSpecificOutput: {
|
|
6473
|
+
hookEventName: "UserPromptSubmit",
|
|
6474
|
+
additionalContext: CORRECTION_REMINDER
|
|
6475
|
+
}
|
|
6476
|
+
};
|
|
6477
|
+
}
|
|
6478
|
+
if (detectPlanning(prompt)) {
|
|
6479
|
+
return {
|
|
6480
|
+
hookSpecificOutput: {
|
|
6481
|
+
hookEventName: "UserPromptSubmit",
|
|
6482
|
+
additionalContext: PLANNING_REMINDER
|
|
6483
|
+
}
|
|
6484
|
+
};
|
|
6485
|
+
}
|
|
6486
|
+
return {};
|
|
6487
|
+
}
|
|
6488
|
+
var SAME_TARGET_THRESHOLD = 2;
|
|
6489
|
+
var TOTAL_FAILURE_THRESHOLD = 3;
|
|
6490
|
+
var STATE_FILE_NAME = ".ca-failure-state.json";
|
|
6491
|
+
var STATE_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
6492
|
+
var failureCount = 0;
|
|
6493
|
+
var lastFailedTarget = null;
|
|
6494
|
+
var sameTargetCount = 0;
|
|
6495
|
+
function defaultState() {
|
|
6496
|
+
return { count: 0, lastTarget: null, sameTargetCount: 0, timestamp: Date.now() };
|
|
6497
|
+
}
|
|
6498
|
+
function readFailureState(stateDir) {
|
|
6499
|
+
try {
|
|
6500
|
+
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
6501
|
+
if (!existsSync(filePath)) return defaultState();
|
|
6502
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
6503
|
+
const parsed = JSON.parse(raw);
|
|
6504
|
+
if (Date.now() - parsed.timestamp > STATE_MAX_AGE_MS) return defaultState();
|
|
6505
|
+
return parsed;
|
|
6506
|
+
} catch {
|
|
6507
|
+
return defaultState();
|
|
6508
|
+
}
|
|
6509
|
+
}
|
|
6510
|
+
function writeFailureState(stateDir, state) {
|
|
6511
|
+
try {
|
|
6512
|
+
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
6513
|
+
writeFileSync(filePath, JSON.stringify(state), "utf-8");
|
|
6514
|
+
} catch {
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
function deleteStateFile(stateDir) {
|
|
6518
|
+
try {
|
|
6519
|
+
const filePath = join(stateDir, STATE_FILE_NAME);
|
|
6520
|
+
if (existsSync(filePath)) unlinkSync(filePath);
|
|
6521
|
+
} catch {
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
var FAILURE_TIP = "Tip: Multiple failures detected. `npx ca search` may have solutions for similar issues.";
|
|
6525
|
+
function resetFailureState(stateDir) {
|
|
6526
|
+
failureCount = 0;
|
|
6527
|
+
lastFailedTarget = null;
|
|
6528
|
+
sameTargetCount = 0;
|
|
6529
|
+
if (stateDir) deleteStateFile(stateDir);
|
|
6530
|
+
}
|
|
6531
|
+
function getFailureTarget(toolName, toolInput) {
|
|
6532
|
+
if (toolName === "Bash" && typeof toolInput.command === "string") {
|
|
6533
|
+
const trimmed = toolInput.command.trim();
|
|
6534
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
6535
|
+
return firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
6536
|
+
}
|
|
6537
|
+
if ((toolName === "Edit" || toolName === "Write") && typeof toolInput.file_path === "string") {
|
|
6538
|
+
return toolInput.file_path;
|
|
6539
|
+
}
|
|
6540
|
+
return null;
|
|
6541
|
+
}
|
|
6542
|
+
function processToolFailure(toolName, toolInput, stateDir) {
|
|
6543
|
+
if (stateDir) {
|
|
6544
|
+
const persisted = readFailureState(stateDir);
|
|
6545
|
+
failureCount = persisted.count;
|
|
6546
|
+
lastFailedTarget = persisted.lastTarget;
|
|
6547
|
+
sameTargetCount = persisted.sameTargetCount;
|
|
6548
|
+
}
|
|
6549
|
+
failureCount++;
|
|
6550
|
+
const target = getFailureTarget(toolName, toolInput);
|
|
6551
|
+
if (target !== null && target === lastFailedTarget) {
|
|
6552
|
+
sameTargetCount++;
|
|
6553
|
+
} else {
|
|
6554
|
+
sameTargetCount = 1;
|
|
6555
|
+
lastFailedTarget = target;
|
|
6556
|
+
}
|
|
6557
|
+
const shouldShowTip = sameTargetCount >= SAME_TARGET_THRESHOLD || failureCount >= TOTAL_FAILURE_THRESHOLD;
|
|
6558
|
+
if (shouldShowTip) {
|
|
6559
|
+
resetFailureState(stateDir);
|
|
6560
|
+
return {
|
|
6561
|
+
hookSpecificOutput: {
|
|
6562
|
+
hookEventName: "PostToolUseFailure",
|
|
6563
|
+
additionalContext: FAILURE_TIP
|
|
6564
|
+
}
|
|
6565
|
+
};
|
|
6566
|
+
}
|
|
6567
|
+
if (stateDir) {
|
|
6568
|
+
writeFailureState(stateDir, {
|
|
6569
|
+
count: failureCount,
|
|
6570
|
+
lastTarget: lastFailedTarget,
|
|
6571
|
+
sameTargetCount,
|
|
6572
|
+
timestamp: Date.now()
|
|
6573
|
+
});
|
|
6574
|
+
}
|
|
6575
|
+
return {};
|
|
6576
|
+
}
|
|
6577
|
+
function processToolSuccess(stateDir) {
|
|
6578
|
+
resetFailureState(stateDir);
|
|
6579
|
+
}
|
|
6580
|
+
var STATE_DIR = ".claude";
|
|
6581
|
+
var STATE_FILE = ".ca-phase-state.json";
|
|
6582
|
+
var PHASE_STATE_MAX_AGE_MS = 72 * 60 * 60 * 1e3;
|
|
6583
|
+
var PHASES = ["brainstorm", "plan", "work", "review", "compound"];
|
|
6584
|
+
var GATES = ["post-plan", "gate-3", "gate-4", "final"];
|
|
6585
|
+
var PHASE_INDEX = {
|
|
6586
|
+
brainstorm: 1,
|
|
6587
|
+
plan: 2,
|
|
6588
|
+
work: 3,
|
|
6589
|
+
review: 4,
|
|
6590
|
+
compound: 5
|
|
6591
|
+
};
|
|
6592
|
+
function getStatePath(repoRoot) {
|
|
6593
|
+
return join(repoRoot, STATE_DIR, STATE_FILE);
|
|
6594
|
+
}
|
|
6595
|
+
function isPhaseName(value) {
|
|
6596
|
+
return typeof value === "string" && PHASES.includes(value);
|
|
6597
|
+
}
|
|
6598
|
+
function isGateName(value) {
|
|
6599
|
+
return typeof value === "string" && GATES.includes(value);
|
|
6600
|
+
}
|
|
6601
|
+
function isIsoDate(value) {
|
|
6602
|
+
if (typeof value !== "string") return false;
|
|
6603
|
+
return !Number.isNaN(Date.parse(value));
|
|
6604
|
+
}
|
|
6605
|
+
function isStringArray(value) {
|
|
6606
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
6607
|
+
}
|
|
6608
|
+
function validatePhaseState(raw) {
|
|
6609
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
6610
|
+
const state = raw;
|
|
6611
|
+
return typeof state.lfg_active === "boolean" && typeof state.epic_id === "string" && isPhaseName(state.current_phase) && typeof state.phase_index === "number" && state.phase_index >= 1 && state.phase_index <= 5 && isStringArray(state.skills_read) && Array.isArray(state.gates_passed) && state.gates_passed.every((gate) => isGateName(gate)) && isIsoDate(state.started_at);
|
|
6612
|
+
}
|
|
6613
|
+
function expectedGateForPhase(phaseIndex) {
|
|
6614
|
+
if (phaseIndex === 2) return "post-plan";
|
|
6615
|
+
if (phaseIndex === 3) return "gate-3";
|
|
6616
|
+
if (phaseIndex === 4) return "gate-4";
|
|
6617
|
+
if (phaseIndex === 5) return "final";
|
|
6618
|
+
return null;
|
|
6619
|
+
}
|
|
6620
|
+
function initPhaseState(repoRoot, epicId) {
|
|
6621
|
+
const dir = join(repoRoot, STATE_DIR);
|
|
6622
|
+
mkdirSync(dir, { recursive: true });
|
|
6623
|
+
const state = {
|
|
6624
|
+
lfg_active: true,
|
|
6625
|
+
epic_id: epicId,
|
|
6626
|
+
current_phase: "brainstorm",
|
|
6627
|
+
phase_index: PHASE_INDEX.brainstorm,
|
|
6628
|
+
skills_read: [],
|
|
6629
|
+
gates_passed: [],
|
|
6630
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
6631
|
+
};
|
|
6632
|
+
writeFileSync(getStatePath(repoRoot), JSON.stringify(state, null, 2), "utf-8");
|
|
6633
|
+
return state;
|
|
6634
|
+
}
|
|
6635
|
+
function getPhaseState(repoRoot) {
|
|
6636
|
+
try {
|
|
6637
|
+
const path2 = getStatePath(repoRoot);
|
|
6638
|
+
if (!existsSync(path2)) return null;
|
|
6639
|
+
const raw = readFileSync(path2, "utf-8");
|
|
6640
|
+
const parsed = JSON.parse(raw);
|
|
6641
|
+
if (!validatePhaseState(parsed)) return null;
|
|
6642
|
+
const age = Date.now() - new Date(parsed.started_at).getTime();
|
|
6643
|
+
if (age > PHASE_STATE_MAX_AGE_MS) {
|
|
6644
|
+
cleanPhaseState(repoRoot);
|
|
6645
|
+
return null;
|
|
6646
|
+
}
|
|
6647
|
+
return parsed;
|
|
6648
|
+
} catch {
|
|
6649
|
+
return null;
|
|
6650
|
+
}
|
|
6651
|
+
}
|
|
6652
|
+
function updatePhaseState(repoRoot, partial) {
|
|
6653
|
+
const current = getPhaseState(repoRoot);
|
|
6654
|
+
if (current === null) return null;
|
|
6655
|
+
const updated = {
|
|
6656
|
+
...current,
|
|
6657
|
+
...partial
|
|
6658
|
+
};
|
|
6659
|
+
if (!validatePhaseState(updated)) return null;
|
|
6660
|
+
writeFileSync(getStatePath(repoRoot), JSON.stringify(updated, null, 2), "utf-8");
|
|
6661
|
+
return updated;
|
|
6662
|
+
}
|
|
6663
|
+
function startPhase(repoRoot, phase) {
|
|
6664
|
+
return updatePhaseState(repoRoot, {
|
|
6665
|
+
current_phase: phase,
|
|
6666
|
+
phase_index: PHASE_INDEX[phase]
|
|
6667
|
+
});
|
|
6668
|
+
}
|
|
6669
|
+
function cleanPhaseState(repoRoot) {
|
|
6670
|
+
try {
|
|
6671
|
+
const path2 = getStatePath(repoRoot);
|
|
6672
|
+
if (existsSync(path2)) unlinkSync(path2);
|
|
6673
|
+
} catch {
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
6676
|
+
function recordGatePassed(repoRoot, gate) {
|
|
6677
|
+
const current = getPhaseState(repoRoot);
|
|
6678
|
+
if (current === null) return null;
|
|
6679
|
+
const gatesPassed = current.gates_passed.includes(gate) ? current.gates_passed : [...current.gates_passed, gate];
|
|
6680
|
+
const updated = { ...current, gates_passed: gatesPassed };
|
|
6681
|
+
if (gate === "final") {
|
|
6682
|
+
cleanPhaseState(repoRoot);
|
|
6683
|
+
return updated;
|
|
6684
|
+
}
|
|
6685
|
+
writeFileSync(getStatePath(repoRoot), JSON.stringify(updated, null, 2), "utf-8");
|
|
6686
|
+
return updated;
|
|
6687
|
+
}
|
|
6688
|
+
function printStatusHuman(state) {
|
|
6689
|
+
if (state === null) {
|
|
6690
|
+
console.log("No active LFG session.");
|
|
6691
|
+
return;
|
|
6692
|
+
}
|
|
6693
|
+
console.log("Active LFG Session");
|
|
6694
|
+
console.log(` Epic: ${state.epic_id}`);
|
|
6695
|
+
console.log(` Phase: ${state.current_phase} (${state.phase_index}/5)`);
|
|
6696
|
+
console.log(` Skills read: ${state.skills_read.length === 0 ? "(none)" : state.skills_read.join(", ")}`);
|
|
6697
|
+
console.log(` Gates passed: ${state.gates_passed.length === 0 ? "(none)" : state.gates_passed.join(", ")}`);
|
|
6698
|
+
console.log(` Started: ${state.started_at}`);
|
|
6699
|
+
}
|
|
6700
|
+
function registerPhaseSubcommands(phaseCheck, getDryRun, repoRoot) {
|
|
6701
|
+
phaseCheck.command("init <epic-id>").description("Initialize phase state for an epic").action((epicId) => {
|
|
6702
|
+
if (!EPIC_ID_PATTERN.test(epicId)) {
|
|
6703
|
+
console.error(`Invalid epic ID: "${epicId}"`);
|
|
6704
|
+
process.exitCode = 1;
|
|
6705
|
+
return;
|
|
6706
|
+
}
|
|
6707
|
+
if (getDryRun()) {
|
|
6708
|
+
console.log(`[dry-run] Would initialize phase state for epic ${epicId} in ${repoRoot()}`);
|
|
6709
|
+
return;
|
|
6710
|
+
}
|
|
6711
|
+
initPhaseState(repoRoot(), epicId);
|
|
6712
|
+
console.log(`Phase state initialized for ${epicId}. Current phase: brainstorm (1/5).`);
|
|
6713
|
+
});
|
|
6714
|
+
phaseCheck.command("start <phase>").description("Start or resume a phase").action((phase) => {
|
|
6715
|
+
if (!isPhaseName(phase)) {
|
|
6716
|
+
console.error(`Invalid phase: "${phase}". Valid phases: ${PHASES.join(", ")}`);
|
|
6717
|
+
process.exitCode = 1;
|
|
6718
|
+
return;
|
|
6719
|
+
}
|
|
6720
|
+
if (getDryRun()) {
|
|
6721
|
+
console.log(`[dry-run] Would start phase ${phase}`);
|
|
6722
|
+
return;
|
|
6723
|
+
}
|
|
6724
|
+
const state = startPhase(repoRoot(), phase);
|
|
6725
|
+
if (state === null) {
|
|
6726
|
+
console.error("No active phase state. Run: ca phase-check init <epic-id>");
|
|
6727
|
+
process.exitCode = 1;
|
|
6728
|
+
return;
|
|
6729
|
+
}
|
|
6730
|
+
console.log(`Phase updated: ${state.current_phase} (${state.phase_index}/5).`);
|
|
6731
|
+
});
|
|
6732
|
+
phaseCheck.command("gate <gate-name>").description("Record a phase gate as passed").action((gateName) => {
|
|
6733
|
+
if (!isGateName(gateName)) {
|
|
6734
|
+
console.error(`Invalid gate: "${gateName}". Valid gates: ${GATES.join(", ")}`);
|
|
6735
|
+
process.exitCode = 1;
|
|
6736
|
+
return;
|
|
6737
|
+
}
|
|
6738
|
+
if (getDryRun()) {
|
|
6739
|
+
console.log(`[dry-run] Would record gate ${gateName}`);
|
|
6740
|
+
return;
|
|
6741
|
+
}
|
|
6742
|
+
const state = recordGatePassed(repoRoot(), gateName);
|
|
6743
|
+
if (state === null) {
|
|
6744
|
+
console.error("No active phase state. Run: ca phase-check init <epic-id>");
|
|
6745
|
+
process.exitCode = 1;
|
|
6746
|
+
return;
|
|
6747
|
+
}
|
|
6748
|
+
if (gateName === "final") {
|
|
6749
|
+
console.log("Final gate recorded. Phase state cleaned.");
|
|
6750
|
+
return;
|
|
6751
|
+
}
|
|
6752
|
+
console.log(`Gate recorded: ${gateName}.`);
|
|
6753
|
+
});
|
|
6754
|
+
phaseCheck.command("status").description("Show current phase state").option("--json", "Output raw JSON").action((options) => {
|
|
6755
|
+
const state = getPhaseState(repoRoot());
|
|
6756
|
+
if (options.json) {
|
|
6757
|
+
console.log(JSON.stringify(state ?? { lfg_active: false }));
|
|
6758
|
+
return;
|
|
6759
|
+
}
|
|
6760
|
+
printStatusHuman(state);
|
|
6761
|
+
});
|
|
6762
|
+
phaseCheck.command("clean").description("Remove phase state file").action(() => {
|
|
6763
|
+
if (getDryRun()) {
|
|
6764
|
+
console.log("[dry-run] Would delete phase state file");
|
|
6765
|
+
return;
|
|
6766
|
+
}
|
|
6767
|
+
cleanPhaseState(repoRoot());
|
|
6768
|
+
console.log("Phase state cleaned.");
|
|
6769
|
+
});
|
|
6770
|
+
}
|
|
6771
|
+
function registerPhaseCheckCommand(program2) {
|
|
6772
|
+
const phaseCheck = program2.command("phase-check").description("Manage LFG phase state").option("--dry-run", "Show what would be done without making changes");
|
|
6773
|
+
const getDryRun = () => phaseCheck.opts().dryRun ?? false;
|
|
6774
|
+
const repoRoot = () => getRepoRoot();
|
|
6775
|
+
registerPhaseSubcommands(phaseCheck, getDryRun, repoRoot);
|
|
6776
|
+
program2.command("phase-clean").description("Remove phase state file (alias for `phase-check clean`)").action(() => {
|
|
6777
|
+
cleanPhaseState(repoRoot());
|
|
6778
|
+
console.log("Phase state cleaned.");
|
|
6779
|
+
});
|
|
6780
|
+
}
|
|
6903
6781
|
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6782
|
+
// src/setup/hooks-phase-guard.ts
|
|
6783
|
+
function processPhaseGuard(repoRoot, toolName, _toolInput) {
|
|
6784
|
+
try {
|
|
6785
|
+
if (toolName !== "Edit" && toolName !== "Write") return {};
|
|
6786
|
+
const state = getPhaseState(repoRoot);
|
|
6787
|
+
if (state === null || !state.lfg_active) return {};
|
|
6788
|
+
const expectedSkillPath = `.claude/skills/compound/${state.current_phase}/SKILL.md`;
|
|
6789
|
+
const skillRead = state.skills_read.includes(expectedSkillPath);
|
|
6790
|
+
if (!skillRead) {
|
|
6791
|
+
return {
|
|
6792
|
+
hookSpecificOutput: {
|
|
6793
|
+
hookEventName: "PreToolUse",
|
|
6794
|
+
additionalContext: `PHASE GUARD WARNING: You are in LFG phase ${state.phase_index}/5 (${state.current_phase}) but have NOT read the skill file yet. Read ${expectedSkillPath} before continuing.`
|
|
6795
|
+
}
|
|
6796
|
+
};
|
|
6797
|
+
}
|
|
6798
|
+
return {};
|
|
6799
|
+
} catch {
|
|
6800
|
+
return {};
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6908
6803
|
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6804
|
+
// src/setup/hooks-read-tracker.ts
|
|
6805
|
+
var SKILL_PATH_PATTERN = /(?:^|\/)\.claude\/skills\/compound\/([^/]+)\/SKILL\.md$/;
|
|
6806
|
+
function normalizePath(path2) {
|
|
6807
|
+
return path2.replaceAll("\\", "/");
|
|
6808
|
+
}
|
|
6809
|
+
function toCanonicalSkillPath(filePath) {
|
|
6810
|
+
const normalized = normalizePath(filePath);
|
|
6811
|
+
const match = SKILL_PATH_PATTERN.exec(normalized);
|
|
6812
|
+
if (!match?.[1]) return null;
|
|
6813
|
+
return `.claude/skills/compound/${match[1]}/SKILL.md`;
|
|
6814
|
+
}
|
|
6815
|
+
function processReadTracker(repoRoot, toolName, toolInput) {
|
|
6816
|
+
try {
|
|
6817
|
+
if (toolName !== "Read") return {};
|
|
6818
|
+
const state = getPhaseState(repoRoot);
|
|
6819
|
+
if (state === null || !state.lfg_active) return {};
|
|
6820
|
+
const filePath = typeof toolInput.file_path === "string" ? toolInput.file_path : null;
|
|
6821
|
+
if (filePath === null) return {};
|
|
6822
|
+
const canonicalPath = toCanonicalSkillPath(filePath);
|
|
6823
|
+
if (canonicalPath === null) return {};
|
|
6824
|
+
if (!state.skills_read.includes(canonicalPath)) {
|
|
6825
|
+
updatePhaseState(repoRoot, {
|
|
6826
|
+
skills_read: [...state.skills_read, canonicalPath]
|
|
6827
|
+
});
|
|
6828
|
+
}
|
|
6829
|
+
return {};
|
|
6830
|
+
} catch {
|
|
6831
|
+
return {};
|
|
6832
|
+
}
|
|
6833
|
+
}
|
|
6915
6834
|
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6835
|
+
// src/setup/hooks-stop-audit.ts
|
|
6836
|
+
function hasTransitionEvidence(state) {
|
|
6837
|
+
if (state.phase_index === 5) return true;
|
|
6838
|
+
const nextPhase = PHASES[state.phase_index];
|
|
6839
|
+
if (nextPhase === void 0) return false;
|
|
6840
|
+
const nextSkillPath = `.claude/skills/compound/${nextPhase}/SKILL.md`;
|
|
6841
|
+
return state.skills_read.includes(nextSkillPath);
|
|
6842
|
+
}
|
|
6843
|
+
function processStopAudit(repoRoot, stopHookActive = false) {
|
|
6844
|
+
try {
|
|
6845
|
+
if (stopHookActive) return {};
|
|
6846
|
+
const state = getPhaseState(repoRoot);
|
|
6847
|
+
if (state === null || !state.lfg_active) return {};
|
|
6848
|
+
const expectedGate = expectedGateForPhase(state.phase_index);
|
|
6849
|
+
if (expectedGate === null) return {};
|
|
6850
|
+
if (state.gates_passed.includes(expectedGate)) return {};
|
|
6851
|
+
if (!hasTransitionEvidence(state)) return {};
|
|
6852
|
+
return {
|
|
6853
|
+
continue: false,
|
|
6854
|
+
stopReason: `PHASE GATE NOT VERIFIED: ${state.current_phase} requires gate '${expectedGate}'. Run: npx ca phase-check gate ${expectedGate}`
|
|
6855
|
+
};
|
|
6856
|
+
} catch {
|
|
6857
|
+
return {};
|
|
6858
|
+
}
|
|
6859
|
+
}
|
|
6925
6860
|
|
|
6926
|
-
// src/setup/
|
|
6861
|
+
// src/setup/hooks.ts
|
|
6862
|
+
var HOOK_FILE_MODE = 493;
|
|
6863
|
+
function hasCompoundAgentHook(content) {
|
|
6864
|
+
return content.includes(HOOK_MARKER);
|
|
6865
|
+
}
|
|
6866
|
+
async function getGitHooksDir(repoRoot) {
|
|
6867
|
+
const gitPath = join(repoRoot, ".git");
|
|
6868
|
+
if (!existsSync(gitPath)) {
|
|
6869
|
+
return null;
|
|
6870
|
+
}
|
|
6871
|
+
let gitDir = gitPath;
|
|
6872
|
+
if (lstatSync(gitPath).isFile()) {
|
|
6873
|
+
const content = readFileSync(gitPath, "utf-8").trim();
|
|
6874
|
+
const match = /^gitdir:\s*(.+)$/.exec(content);
|
|
6875
|
+
if (!match?.[1]) return null;
|
|
6876
|
+
gitDir = resolve(repoRoot, match[1]);
|
|
6877
|
+
}
|
|
6878
|
+
const configPath2 = join(gitDir, "config");
|
|
6879
|
+
if (existsSync(configPath2)) {
|
|
6880
|
+
const config = await readFile(configPath2, "utf-8");
|
|
6881
|
+
const match = /hooksPath\s*=\s*(.+)$/m.exec(config);
|
|
6882
|
+
if (match?.[1]) {
|
|
6883
|
+
const hooksPath = match[1].trim();
|
|
6884
|
+
return hooksPath.startsWith("/") ? hooksPath : join(repoRoot, hooksPath);
|
|
6885
|
+
}
|
|
6886
|
+
}
|
|
6887
|
+
const defaultHooksDir = join(gitDir, "hooks");
|
|
6888
|
+
return existsSync(defaultHooksDir) ? defaultHooksDir : null;
|
|
6889
|
+
}
|
|
6890
|
+
function findFirstTopLevelExitLine(lines) {
|
|
6891
|
+
let insideFunction = 0;
|
|
6892
|
+
let heredocDelimiter = null;
|
|
6893
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6894
|
+
const line = lines[i] ?? "";
|
|
6895
|
+
const trimmed = line.trim();
|
|
6896
|
+
if (heredocDelimiter !== null) {
|
|
6897
|
+
if (trimmed === heredocDelimiter) {
|
|
6898
|
+
heredocDelimiter = null;
|
|
6899
|
+
}
|
|
6900
|
+
continue;
|
|
6901
|
+
}
|
|
6902
|
+
const heredocMatch = /<<-?\s*['"]?(\w+)['"]?/.exec(line);
|
|
6903
|
+
if (heredocMatch?.[1]) {
|
|
6904
|
+
heredocDelimiter = heredocMatch[1];
|
|
6905
|
+
continue;
|
|
6906
|
+
}
|
|
6907
|
+
for (const char of line) {
|
|
6908
|
+
if (char === "{") insideFunction++;
|
|
6909
|
+
if (char === "}") insideFunction = Math.max(0, insideFunction - 1);
|
|
6910
|
+
}
|
|
6911
|
+
if (insideFunction > 0) {
|
|
6912
|
+
continue;
|
|
6913
|
+
}
|
|
6914
|
+
if (/^\s*exit\s+(\d+|\$\w+|\$\?)\s*$/.test(trimmed)) {
|
|
6915
|
+
return i;
|
|
6916
|
+
}
|
|
6917
|
+
}
|
|
6918
|
+
return -1;
|
|
6919
|
+
}
|
|
6920
|
+
async function installPreCommitHook(repoRoot) {
|
|
6921
|
+
const gitHooksDir = await getGitHooksDir(repoRoot);
|
|
6922
|
+
if (!gitHooksDir) {
|
|
6923
|
+
return { status: "not_git_repo" };
|
|
6924
|
+
}
|
|
6925
|
+
await mkdir(gitHooksDir, { recursive: true });
|
|
6926
|
+
const hookPath = join(gitHooksDir, "pre-commit");
|
|
6927
|
+
if (existsSync(hookPath)) {
|
|
6928
|
+
const content = await readFile(hookPath, "utf-8");
|
|
6929
|
+
if (hasCompoundAgentHook(content)) {
|
|
6930
|
+
return { status: "already_installed" };
|
|
6931
|
+
}
|
|
6932
|
+
const lines = content.split("\n");
|
|
6933
|
+
const exitLineIndex = findFirstTopLevelExitLine(lines);
|
|
6934
|
+
let newContent;
|
|
6935
|
+
if (exitLineIndex === -1) {
|
|
6936
|
+
newContent = content.trimEnd() + "\n" + COMPOUND_AGENT_HOOK_BLOCK;
|
|
6937
|
+
} else {
|
|
6938
|
+
const before = lines.slice(0, exitLineIndex);
|
|
6939
|
+
const after = lines.slice(exitLineIndex);
|
|
6940
|
+
newContent = before.join("\n") + COMPOUND_AGENT_HOOK_BLOCK + after.join("\n");
|
|
6941
|
+
}
|
|
6942
|
+
await writeFile(hookPath, newContent, "utf-8");
|
|
6943
|
+
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
6944
|
+
return { status: "appended" };
|
|
6945
|
+
}
|
|
6946
|
+
await writeFile(hookPath, PRE_COMMIT_HOOK_TEMPLATE, "utf-8");
|
|
6947
|
+
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
6948
|
+
return { status: "installed" };
|
|
6949
|
+
}
|
|
6950
|
+
async function installPostCommitHook(repoRoot) {
|
|
6951
|
+
const gitHooksDir = await getGitHooksDir(repoRoot);
|
|
6952
|
+
if (!gitHooksDir) {
|
|
6953
|
+
return { status: "not_git_repo" };
|
|
6954
|
+
}
|
|
6955
|
+
await mkdir(gitHooksDir, { recursive: true });
|
|
6956
|
+
const hookPath = join(gitHooksDir, "post-commit");
|
|
6957
|
+
if (existsSync(hookPath)) {
|
|
6958
|
+
const content = await readFile(hookPath, "utf-8");
|
|
6959
|
+
if (content.includes(POST_COMMIT_HOOK_MARKER)) {
|
|
6960
|
+
return { status: "already_installed" };
|
|
6961
|
+
}
|
|
6962
|
+
const lines = content.split("\n");
|
|
6963
|
+
const exitLineIndex = findFirstTopLevelExitLine(lines);
|
|
6964
|
+
let newContent;
|
|
6965
|
+
if (exitLineIndex === -1) {
|
|
6966
|
+
newContent = content.trimEnd() + "\n" + COMPOUND_AGENT_POST_COMMIT_BLOCK;
|
|
6967
|
+
} else {
|
|
6968
|
+
const before = lines.slice(0, exitLineIndex);
|
|
6969
|
+
const after = lines.slice(exitLineIndex);
|
|
6970
|
+
newContent = before.join("\n") + COMPOUND_AGENT_POST_COMMIT_BLOCK + after.join("\n");
|
|
6971
|
+
}
|
|
6972
|
+
await writeFile(hookPath, newContent, "utf-8");
|
|
6973
|
+
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
6974
|
+
return { status: "appended" };
|
|
6975
|
+
}
|
|
6976
|
+
await writeFile(hookPath, POST_COMMIT_HOOK_TEMPLATE, "utf-8");
|
|
6977
|
+
chmodSync(hookPath, HOOK_FILE_MODE);
|
|
6978
|
+
return { status: "installed" };
|
|
6979
|
+
}
|
|
6980
|
+
async function readStdin() {
|
|
6981
|
+
const chunks = [];
|
|
6982
|
+
for await (const chunk of process.stdin) {
|
|
6983
|
+
chunks.push(chunk);
|
|
6984
|
+
}
|
|
6985
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
6986
|
+
}
|
|
6987
|
+
async function runUserPromptHook() {
|
|
6988
|
+
try {
|
|
6989
|
+
const input = await readStdin();
|
|
6990
|
+
const data = JSON.parse(input);
|
|
6991
|
+
if (!data.prompt) {
|
|
6992
|
+
console.log(JSON.stringify({}));
|
|
6993
|
+
return;
|
|
6994
|
+
}
|
|
6995
|
+
const result = processUserPrompt(data.prompt);
|
|
6996
|
+
console.log(JSON.stringify(result));
|
|
6997
|
+
} catch {
|
|
6998
|
+
console.log(JSON.stringify({}));
|
|
6999
|
+
}
|
|
7000
|
+
}
|
|
7001
|
+
async function runPostToolFailureHook() {
|
|
7002
|
+
try {
|
|
7003
|
+
const input = await readStdin();
|
|
7004
|
+
const data = JSON.parse(input);
|
|
7005
|
+
if (!data.tool_name) {
|
|
7006
|
+
console.log(JSON.stringify({}));
|
|
7007
|
+
return;
|
|
7008
|
+
}
|
|
7009
|
+
const stateDir = join(getRepoRoot(), ".claude");
|
|
7010
|
+
const result = processToolFailure(data.tool_name, data.tool_input ?? {}, stateDir);
|
|
7011
|
+
console.log(JSON.stringify(result));
|
|
7012
|
+
} catch {
|
|
7013
|
+
console.log(JSON.stringify({}));
|
|
7014
|
+
}
|
|
7015
|
+
}
|
|
7016
|
+
async function runPostToolSuccessHook() {
|
|
7017
|
+
try {
|
|
7018
|
+
await readStdin();
|
|
7019
|
+
const stateDir = join(getRepoRoot(), ".claude");
|
|
7020
|
+
processToolSuccess(stateDir);
|
|
7021
|
+
console.log(JSON.stringify({}));
|
|
7022
|
+
} catch {
|
|
7023
|
+
console.log(JSON.stringify({}));
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
async function runToolHook(processor) {
|
|
7027
|
+
try {
|
|
7028
|
+
const input = await readStdin();
|
|
7029
|
+
const data = JSON.parse(input);
|
|
7030
|
+
if (!data.tool_name) {
|
|
7031
|
+
console.log(JSON.stringify({}));
|
|
7032
|
+
return;
|
|
7033
|
+
}
|
|
7034
|
+
console.log(JSON.stringify(processor(getRepoRoot(), data.tool_name, data.tool_input ?? {})));
|
|
7035
|
+
} catch {
|
|
7036
|
+
console.log(JSON.stringify({}));
|
|
7037
|
+
}
|
|
7038
|
+
}
|
|
7039
|
+
async function runStopAuditHook() {
|
|
7040
|
+
try {
|
|
7041
|
+
const input = await readStdin();
|
|
7042
|
+
const data = JSON.parse(input);
|
|
7043
|
+
console.log(JSON.stringify(processStopAudit(getRepoRoot(), data.stop_hook_active ?? false)));
|
|
7044
|
+
} catch {
|
|
7045
|
+
console.log(JSON.stringify({}));
|
|
7046
|
+
}
|
|
7047
|
+
}
|
|
7048
|
+
function registerHooksCommand(program2) {
|
|
7049
|
+
const hooksCommand = program2.command("hooks").description("Git hooks management");
|
|
7050
|
+
hooksCommand.command("run <hook>").description("Run a hook script (called by git/Claude hooks)").option("--json", "Output as JSON").action(async (hook, options) => {
|
|
7051
|
+
if (hook === "pre-commit") {
|
|
7052
|
+
if (options.json) {
|
|
7053
|
+
console.log(JSON.stringify({ hook: "pre-commit", message: PRE_COMMIT_MESSAGE }));
|
|
7054
|
+
} else {
|
|
7055
|
+
console.log(PRE_COMMIT_MESSAGE);
|
|
7056
|
+
}
|
|
7057
|
+
} else if (hook === "user-prompt") {
|
|
7058
|
+
await runUserPromptHook();
|
|
7059
|
+
} else if (hook === "post-tool-failure") {
|
|
7060
|
+
await runPostToolFailureHook();
|
|
7061
|
+
} else if (hook === "post-tool-success") {
|
|
7062
|
+
await runPostToolSuccessHook();
|
|
7063
|
+
} else if (hook === "phase-guard") {
|
|
7064
|
+
await runToolHook(processPhaseGuard);
|
|
7065
|
+
} else if (hook === "post-read" || hook === "read-tracker") {
|
|
7066
|
+
await runToolHook(processReadTracker);
|
|
7067
|
+
} else if (hook === "phase-audit" || hook === "stop-audit") {
|
|
7068
|
+
await runStopAuditHook();
|
|
7069
|
+
} else {
|
|
7070
|
+
if (options.json) {
|
|
7071
|
+
console.log(JSON.stringify({ error: `Unknown hook: ${hook}` }));
|
|
7072
|
+
} else {
|
|
7073
|
+
console.error(
|
|
7074
|
+
formatError(
|
|
7075
|
+
"hooks",
|
|
7076
|
+
"UNKNOWN_HOOK",
|
|
7077
|
+
`Unknown hook: ${hook}`,
|
|
7078
|
+
"Valid hooks: pre-commit, user-prompt, post-tool-failure, post-tool-success, post-read (or read-tracker), phase-guard, phase-audit (or stop-audit)"
|
|
7079
|
+
)
|
|
7080
|
+
);
|
|
7081
|
+
}
|
|
7082
|
+
process.exitCode = 1;
|
|
7083
|
+
}
|
|
7084
|
+
});
|
|
7085
|
+
}
|
|
6927
7086
|
var GENERATED_MARKER = "<!-- generated by compound-agent -->\n";
|
|
6928
7087
|
function hasCompoundAgentSection(content) {
|
|
6929
7088
|
return content.includes(COMPOUND_AGENT_SECTION_HEADER);
|
|
@@ -7410,6 +7569,7 @@ async function runSetup(options) {
|
|
|
7410
7569
|
postCommitHook = (await installPostCommitHook(repoRoot)).status;
|
|
7411
7570
|
}
|
|
7412
7571
|
const { hooks } = await configureClaudeSettings();
|
|
7572
|
+
await installGeminiAdapter({ dryRun: false, json: true });
|
|
7413
7573
|
const gitignore = await ensureGitignore(repoRoot);
|
|
7414
7574
|
let modelStatus = "skipped";
|
|
7415
7575
|
if (!options.skipModel) {
|
|
@@ -7499,6 +7659,7 @@ async function runUpdate(repoRoot, dryRun) {
|
|
|
7499
7659
|
let configUpdated = false;
|
|
7500
7660
|
if (!dryRun) {
|
|
7501
7661
|
const { hooks } = await configureClaudeSettings();
|
|
7662
|
+
await installGeminiAdapter({ dryRun: false, json: true });
|
|
7502
7663
|
configUpdated = hooks;
|
|
7503
7664
|
}
|
|
7504
7665
|
const gitignore = dryRun ? { added: [] } : await ensureGitignore(repoRoot);
|
|
@@ -9030,7 +9191,27 @@ function registerVerifyGatesCommand(program2) {
|
|
|
9030
9191
|
}
|
|
9031
9192
|
|
|
9032
9193
|
// src/changelog-data.ts
|
|
9033
|
-
var CHANGELOG_RECENT = `## [1.
|
|
9194
|
+
var CHANGELOG_RECENT = `## [1.5.0] - 2026-02-24
|
|
9195
|
+
|
|
9196
|
+
### Added
|
|
9197
|
+
|
|
9198
|
+
- **Gemini CLI compatibility adapter**: \`ca setup gemini\` scaffolds \`.gemini/\` directory with hook scripts, TOML slash commands, and inlined skills -- bridging compound-agent to work with Google's Gemini CLI via the Adapter Pattern
|
|
9199
|
+
- **Gemini hooks**: Maps SessionStart, BeforeAgent, BeforeTool, AfterTool to compound-agent's existing hook pipeline (\`ca prime\`, \`ca hooks run user-prompt\`, \`ca hooks run phase-guard\`, \`ca hooks run post-tool-success\`)
|
|
9200
|
+
- **Gemini TOML commands**: Auto-generates \`.gemini/commands/compound/*.toml\` using \`@{path}\` file injection to maintain a single source of truth with Claude commands
|
|
9201
|
+
- **Gemini skills proxying**: Inlines phase and agent role skill content into \`.gemini/skills/\` with YAML frontmatter
|
|
9202
|
+
- **23 integration tests** for the Gemini adapter covering hooks, settings.json, TOML commands, skills, and dry-run mode
|
|
9203
|
+
|
|
9204
|
+
### Fixed
|
|
9205
|
+
|
|
9206
|
+
- **Gemini hook stderr leak**: Corrected \`2>&1 > /dev/null\` (leaks stderr to stdout, corrupting JSON) to \`> /dev/null 2>&1\`
|
|
9207
|
+
- **Gemini TOML file injection syntax**: Changed \`@path\` to \`@{path}\` (Gemini CLI requires curly braces)
|
|
9208
|
+
- **Gemini skill file injection**: Skills now inline content instead of using \`@{path}\` which only works in TOML prompt fields, not SKILL.md
|
|
9209
|
+
- **Gemini phase guard always allowing**: Hook now checks \`ca hooks run phase-guard\` exit code and returns structured \`{"decision": "deny"}\` on failure (exit 0, not exit 2, so Gemini parses the reason from stdout)
|
|
9210
|
+
- **Gemini BeforeTool matcher incomplete**: Added \`create_file\` to BeforeTool and AfterTool matchers alongside \`replace\` and \`write_file\`
|
|
9211
|
+
- **TOML description escaping**: \`parseDescription\` now escapes \`\\\` and \`"\` to prevent malformed TOML output
|
|
9212
|
+
- **Flaky embedding test**: Added 15s timeout to \`isModelUsable\` test
|
|
9213
|
+
|
|
9214
|
+
## [1.4.4] - 2026-02-23
|
|
9034
9215
|
|
|
9035
9216
|
### Added
|
|
9036
9217
|
|
|
@@ -9076,15 +9257,7 @@ var CHANGELOG_RECENT = `## [1.4.4] - 2026-02-23
|
|
|
9076
9257
|
|
|
9077
9258
|
- **SQLite health check in \`ca doctor\`**: New check reports \`[FAIL]\` with fix hint when \`better-sqlite3\` cannot load
|
|
9078
9259
|
- **SQLite status in \`ca setup --status\`**: Shows "OK" or "not available" alongside other status checks
|
|
9079
|
-
- **\`resetSqliteAvailability()\` export**: Allows re-probing SQLite after native module rebuild
|
|
9080
|
-
|
|
9081
|
-
## [1.4.2] - 2026-02-23
|
|
9082
|
-
|
|
9083
|
-
### Fixed
|
|
9084
|
-
|
|
9085
|
-
- **Banner audio crash on headless Linux**: Async \`ENOENT\` error from missing \`aplay\` no longer crashes \`ca setup --update\`
|
|
9086
|
-
- **PowerShell path injection on Windows**: Temp paths containing apostrophes no longer break or inject commands in \`banner-audio.ts\`
|
|
9087
|
-
- **Banner audio test coverage**: Rewrote tests with proper mock isolation (\`vi.spyOn\` + file-scope \`vi.mock\`), covering async ENOENT, sync throw, stop() idempotency, and normal exit cleanup`;
|
|
9260
|
+
- **\`resetSqliteAvailability()\` export**: Allows re-probing SQLite after native module rebuild`;
|
|
9088
9261
|
|
|
9089
9262
|
// src/commands/about.ts
|
|
9090
9263
|
function registerAboutCommand(program2) {
|
|
@@ -9759,21 +9932,27 @@ fi
|
|
|
9759
9932
|
|
|
9760
9933
|
# parse_json() - extract a value from JSON stdin
|
|
9761
9934
|
# Uses jq (primary) with python3 fallback
|
|
9762
|
-
#
|
|
9935
|
+
# Auto-unwraps single-element arrays (bd show --json returns [...])
|
|
9936
|
+
# Usage: echo '[{"status":"open"}]' | parse_json '.status'
|
|
9763
9937
|
parse_json() {
|
|
9764
9938
|
local filter="$1"
|
|
9765
9939
|
if [ "$HAS_JQ" = true ]; then
|
|
9766
|
-
jq -r "$filter"
|
|
9940
|
+
jq -r "if type == \\"array\\" then .[0] else . end | $filter"
|
|
9767
9941
|
else
|
|
9768
9942
|
python3 -c "
|
|
9769
9943
|
import sys, json
|
|
9770
9944
|
data = json.load(sys.stdin)
|
|
9945
|
+
if isinstance(data, list):
|
|
9946
|
+
data = data[0] if data else {}
|
|
9771
9947
|
f = '$filter'.strip('.')
|
|
9772
9948
|
parts = [p for p in f.split('.') if p]
|
|
9773
9949
|
v = data
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
9950
|
+
try:
|
|
9951
|
+
for p in parts:
|
|
9952
|
+
v = v[p]
|
|
9953
|
+
except (KeyError, IndexError, TypeError):
|
|
9954
|
+
v = None
|
|
9955
|
+
print('' if v is None else v)
|
|
9777
9956
|
"
|
|
9778
9957
|
fi
|
|
9779
9958
|
}
|
|
@@ -10299,6 +10478,7 @@ function registerSetupCommands(program2) {
|
|
|
10299
10478
|
const setupCommand = program2.command("setup");
|
|
10300
10479
|
registerSetupAllCommand(setupCommand);
|
|
10301
10480
|
registerClaudeSubcommand(setupCommand);
|
|
10481
|
+
registerGeminiSubcommand(setupCommand);
|
|
10302
10482
|
registerDownloadModelCommand(program2);
|
|
10303
10483
|
}
|
|
10304
10484
|
function registerManagementCommands(program2) {
|