easy-coding-harness 0.10.0-beta.0 → 0.10.0-beta.10
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 +166 -0
- package/README.md +53 -19
- package/dist/cli.js +573 -53
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-implementer.md +11 -0
- package/templates/claude/agents/ec-reviewer.md +6 -1
- package/templates/codex/agents/ec-implementer.toml +11 -0
- package/templates/codex/agents/ec-reviewer.toml +6 -1
- package/templates/common/bundled-skills/ec-init/SKILL.md +20 -2
- package/templates/common/bundled-skills/ec-meta/references/local-architecture/README.md +33 -11
- package/templates/common/bundled-skills/ec-meta/references/platform-files/README.md +1 -1
- package/templates/common/skills/ec-analysis/SKILL.md +162 -26
- package/templates/common/skills/ec-config/SKILL.md +76 -0
- package/templates/common/skills/ec-git/SKILL.md +7 -1
- package/templates/common/skills/ec-implementing/SKILL.md +72 -1
- package/templates/common/skills/ec-memory/SKILL.md +77 -3
- package/templates/common/skills/ec-reviewing/SKILL.md +25 -1
- package/templates/common/skills/ec-task-close/SKILL.md +4 -0
- package/templates/common/skills/ec-task-management/SKILL.md +13 -32
- package/templates/common/skills/ec-tdd-init/SKILL.md +101 -0
- package/templates/common/skills/ec-verification/SKILL.md +91 -5
- package/templates/common/skills/ec-workflow/SKILL.md +86 -21
- package/templates/main-constraint/AGENTS.md.tpl +54 -12
- package/templates/main-constraint/CLAUDE.md.tpl +51 -12
- package/templates/qoder/agents/ec-implementer.md +11 -0
- package/templates/qoder/agents/ec-reviewer.md +6 -1
- package/templates/runtime/templates/dev-spec-skeleton.md +8 -1
- package/templates/runtime/tools/easy_coding_java_coverage.py +317 -0
- package/templates/runtime/tools/easy_coding_tdd_readiness.py +306 -0
- package/templates/shared-hooks/easy_coding_state.py +4782 -574
- package/templates/shared-hooks/easy_dev_spec.py +444 -30
- package/templates/shared-hooks/easy_dev_spec_execution.py +1014 -0
- package/templates/shared-hooks/easy_dev_spec_protocol.py +1426 -18
- package/templates/shared-hooks/inject-subagent-context.py +5 -0
package/dist/cli.js
CHANGED
|
@@ -103,7 +103,8 @@ async function isDirectory(filePath) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// src/utils/config-yaml.ts
|
|
106
|
-
var CONFIG_SCHEMA_VERSION =
|
|
106
|
+
var CONFIG_SCHEMA_VERSION = 5;
|
|
107
|
+
var DEFAULT_TDD_COVERAGE_THRESHOLD = 90;
|
|
107
108
|
var APPROVAL_MODES = ["approve", "guard", "confirm", "auto"];
|
|
108
109
|
var CONFIGURED_WORKFLOW_MODES = ["adaptive", "fast", "standard", "strict"];
|
|
109
110
|
function createDefaultConfig(params) {
|
|
@@ -125,7 +126,9 @@ function createDefaultConfig(params) {
|
|
|
125
126
|
},
|
|
126
127
|
behavior: {
|
|
127
128
|
approval_mode: "guard",
|
|
128
|
-
workflow_mode: "adaptive"
|
|
129
|
+
workflow_mode: "adaptive",
|
|
130
|
+
tdd_enabled: false,
|
|
131
|
+
tdd_coverage_threshold: DEFAULT_TDD_COVERAGE_THRESHOLD
|
|
129
132
|
}
|
|
130
133
|
};
|
|
131
134
|
if (params.supermodule) {
|
|
@@ -179,31 +182,44 @@ function isApprovalMode(value) {
|
|
|
179
182
|
function isConfiguredWorkflowMode(value) {
|
|
180
183
|
return typeof value === "string" && CONFIGURED_WORKFLOW_MODES.includes(value);
|
|
181
184
|
}
|
|
185
|
+
function isTddCoverageThreshold(value) {
|
|
186
|
+
return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 100;
|
|
187
|
+
}
|
|
182
188
|
function resolveLegacyBehavior(config2) {
|
|
183
189
|
const behavior = config2.behavior ?? {};
|
|
184
190
|
const legacyLite = behavior.confirm_mode === "lite";
|
|
185
191
|
const approvalMode = isApprovalMode(behavior.approval_mode) ? behavior.approval_mode : isApprovalMode(behavior.confirm_mode) ? behavior.confirm_mode : behavior.auto_mode === true ? "auto" : behavior.strict_confirm === true ? "approve" : "guard";
|
|
186
192
|
const workflowMode = isConfiguredWorkflowMode(behavior.workflow_mode) ? behavior.workflow_mode : legacyLite ? "fast" : "adaptive";
|
|
187
|
-
|
|
193
|
+
const supportsTddThreshold = Number(config2.version) >= 4;
|
|
194
|
+
const supportsReadyTdd = Number(config2.version) >= CONFIG_SCHEMA_VERSION;
|
|
195
|
+
const tddEnabled = supportsReadyTdd && behavior.tdd_enabled === true;
|
|
196
|
+
const tddCoverageThreshold = supportsTddThreshold && isTddCoverageThreshold(behavior.tdd_coverage_threshold) ? behavior.tdd_coverage_threshold : DEFAULT_TDD_COVERAGE_THRESHOLD;
|
|
197
|
+
return { approvalMode, workflowMode, tddEnabled, tddCoverageThreshold };
|
|
188
198
|
}
|
|
189
|
-
async function setBehaviorModes(filePath, approvalMode, workflowMode) {
|
|
199
|
+
async function setBehaviorModes(filePath, approvalMode, workflowMode, tddEnabled, tddCoverageThreshold) {
|
|
200
|
+
if (tddCoverageThreshold !== void 0 && !isTddCoverageThreshold(tddCoverageThreshold)) {
|
|
201
|
+
throw new Error("TDD coverage threshold must be an integer from 1 to 100.");
|
|
202
|
+
}
|
|
190
203
|
return updateConfigYaml(filePath, (config2) => {
|
|
191
204
|
const legacyBehavior = config2.behavior ?? {};
|
|
205
|
+
const resolvedBehavior = resolveLegacyBehavior(config2);
|
|
192
206
|
const behavior = Object.fromEntries(
|
|
193
207
|
Object.entries(legacyBehavior).filter(
|
|
194
|
-
([key]) => key !== "strict_confirm" && key !== "auto_mode" && key !== "confirm_mode" && key !== "approval_mode" && key !== "workflow_mode"
|
|
208
|
+
([key]) => key !== "strict_confirm" && key !== "auto_mode" && key !== "confirm_mode" && key !== "approval_mode" && key !== "workflow_mode" && key !== "tdd_enabled" && key !== "tdd_coverage_threshold"
|
|
195
209
|
)
|
|
196
210
|
);
|
|
197
211
|
behavior.approval_mode = approvalMode;
|
|
198
212
|
behavior.workflow_mode = workflowMode;
|
|
213
|
+
behavior.tdd_enabled = tddEnabled ?? resolvedBehavior.tddEnabled;
|
|
214
|
+
behavior.tdd_coverage_threshold = tddCoverageThreshold ?? resolvedBehavior.tddCoverageThreshold;
|
|
199
215
|
config2.behavior = behavior;
|
|
200
216
|
config2.version = CONFIG_SCHEMA_VERSION;
|
|
201
217
|
});
|
|
202
218
|
}
|
|
203
219
|
async function migrateBehaviorConfig(filePath) {
|
|
204
220
|
const config2 = await readConfigYaml(filePath);
|
|
205
|
-
const { approvalMode, workflowMode } = resolveLegacyBehavior(config2);
|
|
206
|
-
return setBehaviorModes(filePath, approvalMode, workflowMode);
|
|
221
|
+
const { approvalMode, workflowMode, tddEnabled, tddCoverageThreshold } = resolveLegacyBehavior(config2);
|
|
222
|
+
return setBehaviorModes(filePath, approvalMode, workflowMode, tddEnabled, tddCoverageThreshold);
|
|
207
223
|
}
|
|
208
224
|
async function ensureProjectId(filePath) {
|
|
209
225
|
let projectId = "";
|
|
@@ -242,6 +258,9 @@ var SPEC_DIR = "spec";
|
|
|
242
258
|
var MAIN_SPEC_DIR = "main";
|
|
243
259
|
var DEV_SPEC_DIR = "dev";
|
|
244
260
|
var TEMPLATES_DIR = "templates";
|
|
261
|
+
var TOOLS_DIR = "tools";
|
|
262
|
+
var TDD_DIR = "tdd";
|
|
263
|
+
var TDD_READINESS_FILE = "readiness.json";
|
|
245
264
|
var SESSIONS_GITIGNORE_ENTRY = ".easy-coding/sessions/";
|
|
246
265
|
var HOOK_BYTECODE_GITIGNORE_ENTRY = "__pycache__/";
|
|
247
266
|
var GENERATED_REGION_START = "<!-- \u2550\u2550\u2550 easy-coding-harness generated (DO NOT EDIT BETWEEN MARKERS) \u2550\u2550\u2550 -->";
|
|
@@ -292,6 +311,7 @@ var PLATFORM_META = {
|
|
|
292
311
|
stateInjectEvent: ["SessionStart", "UserPromptSubmit"],
|
|
293
312
|
hasSubagentContext: true,
|
|
294
313
|
templateContext: {
|
|
314
|
+
workflow_agent_id: "claude-code",
|
|
295
315
|
sub_agent_dispatch: "Agent tool",
|
|
296
316
|
platform_spawn_instruction: 'Use the Agent tool with run_in_background when useful; use isolation: "worktree" for parallel file edits.',
|
|
297
317
|
skill_trigger: "/",
|
|
@@ -315,6 +335,7 @@ var PLATFORM_META = {
|
|
|
315
335
|
stateInjectEvent: ["SessionStart", "UserPromptSubmit"],
|
|
316
336
|
hasSubagentContext: false,
|
|
317
337
|
templateContext: {
|
|
338
|
+
workflow_agent_id: "codex",
|
|
318
339
|
sub_agent_dispatch: "Codex sub-agent dispatch",
|
|
319
340
|
platform_spawn_instruction: "Use Codex sub-agent delegation where available; pass the full task card in the prompt.",
|
|
320
341
|
skill_trigger: "$",
|
|
@@ -339,6 +360,7 @@ var PLATFORM_META = {
|
|
|
339
360
|
hasSubagentContext: true,
|
|
340
361
|
cnVariant: ".qodercn",
|
|
341
362
|
templateContext: {
|
|
363
|
+
workflow_agent_id: "qoder",
|
|
342
364
|
sub_agent_dispatch: "Agent tool",
|
|
343
365
|
platform_spawn_instruction: "Use the Agent tool with worktree isolation for parallel file edits.",
|
|
344
366
|
skill_trigger: "/",
|
|
@@ -659,8 +681,17 @@ async function writeRuntimeScaffold(cwd, agents, opts = {}) {
|
|
|
659
681
|
await ensureDir(path6.join(easyCodingDir, SPEC_DIR, DEV_SPEC_DIR));
|
|
660
682
|
await writeMemoryScaffold(easyCodingDir);
|
|
661
683
|
await writeTemplatesScaffold(easyCodingDir);
|
|
684
|
+
await writeToolsScaffold(easyCodingDir);
|
|
662
685
|
return projectId;
|
|
663
686
|
}
|
|
687
|
+
async function writeToolsScaffold(easyCodingDir) {
|
|
688
|
+
const toolsDir = path6.join(easyCodingDir, TOOLS_DIR);
|
|
689
|
+
await ensureDir(toolsDir);
|
|
690
|
+
for (const file of ["easy_coding_java_coverage.py", "easy_coding_tdd_readiness.py"]) {
|
|
691
|
+
const src = getTemplatePath("runtime", "tools", file);
|
|
692
|
+
await writeTextFile(path6.join(toolsDir, file), await readTextFile(src));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
664
695
|
async function writeTemplatesScaffold(easyCodingDir) {
|
|
665
696
|
const templatesDir = path6.join(easyCodingDir, TEMPLATES_DIR);
|
|
666
697
|
await ensureDir(templatesDir);
|
|
@@ -748,6 +779,87 @@ function isLegacyStage(value) {
|
|
|
748
779
|
function migrateStage(value) {
|
|
749
780
|
return isLegacyStage(value) ? LEGACY_STAGE_MAP[value] : value;
|
|
750
781
|
}
|
|
782
|
+
var LEGACY_DISPLAY_AGENT_IDENTITIES = {
|
|
783
|
+
"claude with easy coding": "claude-code",
|
|
784
|
+
"claude-code with easy coding": "claude-code",
|
|
785
|
+
"claude code with easy coding": "claude-code",
|
|
786
|
+
"codex with easy coding": "codex",
|
|
787
|
+
"qoder with easy coding": "qoder"
|
|
788
|
+
};
|
|
789
|
+
function migratedAgentIdentity(value) {
|
|
790
|
+
if (typeof value !== "string") return void 0;
|
|
791
|
+
const normalized = value.trim().toLowerCase();
|
|
792
|
+
if (normalized === "claude-code" || normalized === "codex" || normalized === "qoder") {
|
|
793
|
+
return normalized;
|
|
794
|
+
}
|
|
795
|
+
if (/^\/?root(?:\/[a-z0-9._-]+)*$/.test(normalized)) return "codex";
|
|
796
|
+
return LEGACY_DISPLAY_AGENT_IDENTITIES[normalized];
|
|
797
|
+
}
|
|
798
|
+
function migrateAgentFields(record, fields) {
|
|
799
|
+
let changed = false;
|
|
800
|
+
for (const field of fields) {
|
|
801
|
+
const migrated = migratedAgentIdentity(record[field]);
|
|
802
|
+
if (migrated && migrated !== record[field]) {
|
|
803
|
+
record[field] = migrated;
|
|
804
|
+
changed = true;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return changed;
|
|
808
|
+
}
|
|
809
|
+
function taskAgentFieldGroups(task) {
|
|
810
|
+
const groups = [
|
|
811
|
+
{
|
|
812
|
+
record: task,
|
|
813
|
+
fields: ["created_by", "last_agent", "workflow_mode_confirmed_by", "tdd_confirmed_by"]
|
|
814
|
+
}
|
|
815
|
+
];
|
|
816
|
+
for (const field of ["pending_transition", "workflow_mode_proposal", "verification_checkpoint"]) {
|
|
817
|
+
const value = task[field];
|
|
818
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
819
|
+
const identityField = field === "pending_transition" ? "requested_by" : field === "workflow_mode_proposal" ? "proposed_by" : "recorded_by";
|
|
820
|
+
groups.push({ record: value, fields: [identityField] });
|
|
821
|
+
}
|
|
822
|
+
if (Array.isArray(task.workflow_mode_escalations)) {
|
|
823
|
+
for (const escalation of task.workflow_mode_escalations) {
|
|
824
|
+
if (!escalation || typeof escalation !== "object" || Array.isArray(escalation)) continue;
|
|
825
|
+
groups.push({ record: escalation, fields: ["raised_by"] });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const memoryProgress = task.memory_progress;
|
|
829
|
+
if (memoryProgress && typeof memoryProgress === "object" && !Array.isArray(memoryProgress)) {
|
|
830
|
+
const assessment = memoryProgress.architecture_assessment;
|
|
831
|
+
if (assessment && typeof assessment === "object" && !Array.isArray(assessment)) {
|
|
832
|
+
groups.push({ record: assessment, fields: ["recorded_by"] });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (Array.isArray(task.spec_dependency_evidence)) {
|
|
836
|
+
for (const evidence of task.spec_dependency_evidence) {
|
|
837
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) continue;
|
|
838
|
+
groups.push({ record: evidence, fields: ["satisfied_by"] });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (Array.isArray(task.stage_history)) {
|
|
842
|
+
for (const entry of task.stage_history) {
|
|
843
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
844
|
+
groups.push({ record: entry, fields: ["agent"] });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return groups;
|
|
848
|
+
}
|
|
849
|
+
function migrateTaskAgentIdentities(task) {
|
|
850
|
+
return taskAgentFieldGroups(task).reduce(
|
|
851
|
+
(changed, group) => migrateAgentFields(group.record, group.fields) || changed,
|
|
852
|
+
false
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
function hasLegacyTaskAgentIdentities(task) {
|
|
856
|
+
return taskAgentFieldGroups(task).some(
|
|
857
|
+
({ record, fields }) => fields.some((field) => {
|
|
858
|
+
const migrated = migratedAgentIdentity(record[field]);
|
|
859
|
+
return migrated !== void 0 && migrated !== record[field];
|
|
860
|
+
})
|
|
861
|
+
);
|
|
862
|
+
}
|
|
751
863
|
function migrateStageHistory(task) {
|
|
752
864
|
if (!Array.isArray(task.stage_history)) return false;
|
|
753
865
|
let changed = false;
|
|
@@ -778,7 +890,8 @@ function migrateTaskWorkflowState(task) {
|
|
|
778
890
|
const legacyRequestedAt = Array.isArray(task.stage_history) ? [...task.stage_history].reverse().find(
|
|
779
891
|
(entry) => entry && typeof entry === "object" && !Array.isArray(entry) && entry.stage === "WAITING_CONFIRM"
|
|
780
892
|
) : null;
|
|
781
|
-
let changed =
|
|
893
|
+
let changed = migrateTaskAgentIdentities(task);
|
|
894
|
+
changed = migrateStageHistory(task) || changed;
|
|
782
895
|
if (legacyStatus) {
|
|
783
896
|
task.status = LEGACY_STAGE_MAP[legacyStatus];
|
|
784
897
|
changed = true;
|
|
@@ -816,10 +929,21 @@ function migrateTaskWorkflowState(task) {
|
|
|
816
929
|
task.workflow_mode_legacy = true;
|
|
817
930
|
changed = true;
|
|
818
931
|
}
|
|
932
|
+
if (isActive && taskType !== "project-init" && typeof task.tdd_enabled !== "boolean") {
|
|
933
|
+
task.tdd_enabled = false;
|
|
934
|
+
task.tdd_coverage_threshold = DEFAULT_TDD_COVERAGE_THRESHOLD;
|
|
935
|
+
task.tdd_confirmed_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
936
|
+
task.tdd_confirmed_by = "upgrade-migration";
|
|
937
|
+
changed = true;
|
|
938
|
+
}
|
|
939
|
+
if (task.tdd_enabled === false && "tdd_baselines" in task) {
|
|
940
|
+
task.tdd_baselines = void 0;
|
|
941
|
+
changed = true;
|
|
942
|
+
}
|
|
819
943
|
return changed;
|
|
820
944
|
}
|
|
821
945
|
function migrateSessionBehavior(session) {
|
|
822
|
-
let changed =
|
|
946
|
+
let changed = migrateAgentFields(session, ["agent", "last_agent"]);
|
|
823
947
|
const legacyMode = session.confirm_mode;
|
|
824
948
|
const legacyLite = legacyMode === "lite";
|
|
825
949
|
if (!["approve", "guard", "confirm", "auto"].includes(String(session.approval_mode ?? ""))) {
|
|
@@ -930,7 +1054,7 @@ async function hasLegacyWorkflowState(cwd) {
|
|
|
930
1054
|
if (!await pathExists(filePath)) continue;
|
|
931
1055
|
const task = await readJsonRecord(filePath);
|
|
932
1056
|
if (!task) continue;
|
|
933
|
-
if (isLegacyStage(task.status) || !["PENDING", "COMPLETE", "CLOSED"].includes(String(task.status ?? "")) && String(task.type ?? "") !== "project-init" && !["fast", "standard", "strict"].includes(String(task.workflow_mode ?? "")) || Array.isArray(task.stage_history) && task.stage_history.some(
|
|
1057
|
+
if (hasLegacyTaskAgentIdentities(task) || isLegacyStage(task.status) || !["PENDING", "COMPLETE", "CLOSED"].includes(String(task.status ?? "")) && String(task.type ?? "") !== "project-init" && !["fast", "standard", "strict"].includes(String(task.workflow_mode ?? "")) || Array.isArray(task.stage_history) && task.stage_history.some(
|
|
934
1058
|
(entry) => entry && typeof entry === "object" && !Array.isArray(entry) && isLegacyStage(entry.stage)
|
|
935
1059
|
)) {
|
|
936
1060
|
return true;
|
|
@@ -939,7 +1063,12 @@ async function hasLegacyWorkflowState(cwd) {
|
|
|
939
1063
|
for (const filePath of await sessionFiles(cwd)) {
|
|
940
1064
|
const session = await readJsonRecord(filePath);
|
|
941
1065
|
if (!session) continue;
|
|
942
|
-
if (
|
|
1066
|
+
if (["agent", "last_agent"].some((field) => {
|
|
1067
|
+
const migrated = migratedAgentIdentity(session[field]);
|
|
1068
|
+
return migrated !== void 0 && migrated !== session[field];
|
|
1069
|
+
}) || isLegacyStage(session.last_seen_stage) || "confirm_mode" in session) {
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
943
1072
|
}
|
|
944
1073
|
return false;
|
|
945
1074
|
}
|
|
@@ -2515,6 +2644,7 @@ function addRuntimeClearEntries(plan, cwd) {
|
|
|
2515
2644
|
path16.join(cwd, EASY_CODING_DIR, CONFIG_FILE),
|
|
2516
2645
|
path16.join(cwd, EASY_CODING_DIR, SESSIONS_DIR),
|
|
2517
2646
|
path16.join(cwd, EASY_CODING_DIR, TEMPLATES_DIR),
|
|
2647
|
+
path16.join(cwd, EASY_CODING_DIR, TOOLS_DIR),
|
|
2518
2648
|
path16.join(cwd, EASY_CODING_DIR, INSTALL_MANIFEST_FILE)
|
|
2519
2649
|
])
|
|
2520
2650
|
];
|
|
@@ -2737,8 +2867,8 @@ function renderTargetPlans(targetPlans) {
|
|
|
2737
2867
|
}
|
|
2738
2868
|
|
|
2739
2869
|
// src/commands/config.ts
|
|
2740
|
-
import
|
|
2741
|
-
import { cancel as cancel4, confirm as confirm3, outro as outro3, select } from "@clack/prompts";
|
|
2870
|
+
import path19 from "path";
|
|
2871
|
+
import { cancel as cancel4, confirm as confirm3, outro as outro3, select, text } from "@clack/prompts";
|
|
2742
2872
|
import chalk4 from "chalk";
|
|
2743
2873
|
|
|
2744
2874
|
// src/utils/compare-versions.ts
|
|
@@ -2816,10 +2946,169 @@ async function checkForUpgrade(cwd) {
|
|
|
2816
2946
|
}
|
|
2817
2947
|
}
|
|
2818
2948
|
|
|
2949
|
+
// src/utils/tdd-readiness.ts
|
|
2950
|
+
import { createHash as createHash2 } from "crypto";
|
|
2951
|
+
import { readFile as readFile4, readdir as readdir5, realpath as realpath2 } from "fs/promises";
|
|
2952
|
+
import path18 from "path";
|
|
2953
|
+
var TDD_READINESS_SCHEMA = "easy-coding/tdd-readiness-v1";
|
|
2954
|
+
var TDD_READINESS_SCOPE = "changed-production-lines";
|
|
2955
|
+
var TDD_BASE_VARIABLE = "EASY_CODING_TDD_BASE_SHA";
|
|
2956
|
+
var TDD_THRESHOLD_VARIABLE = "EASY_CODING_TDD_THRESHOLD";
|
|
2957
|
+
var COVERAGE_TOOL_PATH = ".easy-coding/tools/easy_coding_java_coverage.py";
|
|
2958
|
+
var JAVA_BUILD_FILE_NAMES = /* @__PURE__ */ new Set(["pom.xml", "build.gradle", "build.gradle.kts"]);
|
|
2959
|
+
var GITLAB_CI_ENTRY_FILES = /* @__PURE__ */ new Set([".gitlab-ci.yml", ".gitlab-ci.yaml"]);
|
|
2960
|
+
function readinessPath(root) {
|
|
2961
|
+
return path18.join(root, EASY_CODING_DIR, TDD_DIR, TDD_READINESS_FILE);
|
|
2962
|
+
}
|
|
2963
|
+
function parseFileRecords(value, field, reasons) {
|
|
2964
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
2965
|
+
reasons.push(`${field} must contain at least one file`);
|
|
2966
|
+
return [];
|
|
2967
|
+
}
|
|
2968
|
+
const records = [];
|
|
2969
|
+
for (const item of value) {
|
|
2970
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
2971
|
+
reasons.push(`${field} contains an invalid record`);
|
|
2972
|
+
continue;
|
|
2973
|
+
}
|
|
2974
|
+
const record = item;
|
|
2975
|
+
if (typeof record.path !== "string" || !record.path.trim() || path18.isAbsolute(record.path) || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)) {
|
|
2976
|
+
reasons.push(`${field} contains an invalid path or SHA-256`);
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
records.push({ path: record.path, sha256: record.sha256 });
|
|
2980
|
+
}
|
|
2981
|
+
return records;
|
|
2982
|
+
}
|
|
2983
|
+
function usesRequiredGateVariables(command) {
|
|
2984
|
+
const normalized = command.replaceAll(`\${${TDD_BASE_VARIABLE}}`, `$${TDD_BASE_VARIABLE}`).replaceAll(`\${${TDD_THRESHOLD_VARIABLE}}`, `$${TDD_THRESHOLD_VARIABLE}`);
|
|
2985
|
+
return new RegExp(`--base\\s+['"]?\\$${TDD_BASE_VARIABLE}(?:['"]|\\s|$)`).test(normalized) && new RegExp(`--threshold\\s+['"]?\\$${TDD_THRESHOLD_VARIABLE}(?:['"]|\\s|$)`).test(normalized);
|
|
2986
|
+
}
|
|
2987
|
+
function isSafeReportPattern(value) {
|
|
2988
|
+
const normalized = value.replaceAll("\\", "/");
|
|
2989
|
+
return !path18.isAbsolute(value) && !normalized.split("/").includes("..");
|
|
2990
|
+
}
|
|
2991
|
+
function activeCiContent(contents) {
|
|
2992
|
+
return contents.join("\n").split("\n").map((line) => line.replace(/^\s*#.*$/, "").replace(/\s+#.*$/, "")).join("\n");
|
|
2993
|
+
}
|
|
2994
|
+
async function validateFiles(root, records, reasons) {
|
|
2995
|
+
const contents = [];
|
|
2996
|
+
const resolvedRoot = await realpath2(root);
|
|
2997
|
+
for (const record of records) {
|
|
2998
|
+
const absolute = path18.resolve(root, record.path);
|
|
2999
|
+
try {
|
|
3000
|
+
const resolved = await realpath2(absolute);
|
|
3001
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path18.sep}`)) {
|
|
3002
|
+
reasons.push(`readiness file escapes project root: ${record.path}`);
|
|
3003
|
+
continue;
|
|
3004
|
+
}
|
|
3005
|
+
const content = await readFile4(resolved);
|
|
3006
|
+
const digest = createHash2("sha256").update(content).digest("hex");
|
|
3007
|
+
if (digest !== record.sha256) reasons.push(`readiness file changed: ${record.path}`);
|
|
3008
|
+
contents.push(content.toString("utf8"));
|
|
3009
|
+
} catch {
|
|
3010
|
+
reasons.push(`readiness file is missing or unreadable: ${record.path}`);
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
return contents;
|
|
3014
|
+
}
|
|
3015
|
+
async function inspectTddReadiness(root) {
|
|
3016
|
+
const manifestPath2 = readinessPath(root);
|
|
3017
|
+
if (!await pathExists(manifestPath2)) {
|
|
3018
|
+
return { status: "needs_init", reasons: ["TDD readiness receipt is missing"], manifestPath: manifestPath2 };
|
|
3019
|
+
}
|
|
3020
|
+
let manifest;
|
|
3021
|
+
try {
|
|
3022
|
+
manifest = JSON.parse(await readFile4(manifestPath2, "utf8"));
|
|
3023
|
+
} catch {
|
|
3024
|
+
return { status: "needs_init", reasons: ["TDD readiness receipt is invalid"], manifestPath: manifestPath2 };
|
|
3025
|
+
}
|
|
3026
|
+
const reasons = [];
|
|
3027
|
+
if (manifest.schema !== TDD_READINESS_SCHEMA) reasons.push("unsupported readiness schema");
|
|
3028
|
+
if (manifest.provider !== "gitlab") reasons.push("readiness provider must be gitlab");
|
|
3029
|
+
if (manifest.coverage_scope !== TDD_READINESS_SCOPE) {
|
|
3030
|
+
reasons.push("coverage scope must be changed-production-lines");
|
|
3031
|
+
}
|
|
3032
|
+
if (manifest.historical_coverage_required !== false) {
|
|
3033
|
+
reasons.push("historical coverage must remain disabled");
|
|
3034
|
+
}
|
|
3035
|
+
if (!Array.isArray(manifest.coverage_report_patterns) || manifest.coverage_report_patterns.length === 0 || manifest.coverage_report_patterns.some(
|
|
3036
|
+
(item) => typeof item !== "string" || !item.trim() || !isSafeReportPattern(item)
|
|
3037
|
+
)) {
|
|
3038
|
+
reasons.push("coverage_report_patterns must contain safe project-relative report patterns");
|
|
3039
|
+
}
|
|
3040
|
+
if (typeof manifest.changed_line_gate_command !== "string" || !manifest.changed_line_gate_command.includes(COVERAGE_TOOL_PATH)) {
|
|
3041
|
+
reasons.push("changed-line coverage gate command is missing");
|
|
3042
|
+
} else if (!usesRequiredGateVariables(manifest.changed_line_gate_command)) {
|
|
3043
|
+
reasons.push("changed-line coverage gate must use the task baseline and threshold variables");
|
|
3044
|
+
}
|
|
3045
|
+
const buildFiles = parseFileRecords(manifest.build_files, "build_files", reasons);
|
|
3046
|
+
const ciFiles = parseFileRecords(manifest.ci_files, "ci_files", reasons);
|
|
3047
|
+
const toolFiles = parseFileRecords(manifest.tool_files, "tool_files", reasons);
|
|
3048
|
+
if (!buildFiles.some((record) => JAVA_BUILD_FILE_NAMES.has(path18.basename(record.path)))) {
|
|
3049
|
+
reasons.push("build_files must include a Maven or Gradle Java build file");
|
|
3050
|
+
}
|
|
3051
|
+
if (!ciFiles.some((record) => GITLAB_CI_ENTRY_FILES.has(record.path.replaceAll("\\", "/")))) {
|
|
3052
|
+
reasons.push("ci_files must include the project-root GitLab CI entry file");
|
|
3053
|
+
}
|
|
3054
|
+
if (!toolFiles.some((record) => record.path.replaceAll("\\", "/") === COVERAGE_TOOL_PATH)) {
|
|
3055
|
+
reasons.push(`tool_files must include ${COVERAGE_TOOL_PATH}`);
|
|
3056
|
+
}
|
|
3057
|
+
const buildContents = await validateFiles(root, buildFiles, reasons);
|
|
3058
|
+
const ciContents = await validateFiles(root, ciFiles, reasons);
|
|
3059
|
+
await validateFiles(root, toolFiles, reasons);
|
|
3060
|
+
if (!buildContents.some((content) => /jacoco/i.test(content))) {
|
|
3061
|
+
reasons.push("build files do not configure JaCoCo");
|
|
3062
|
+
}
|
|
3063
|
+
const combinedCi = activeCiContent(ciContents);
|
|
3064
|
+
for (const marker of [
|
|
3065
|
+
"jacoco",
|
|
3066
|
+
"artifacts",
|
|
3067
|
+
COVERAGE_TOOL_PATH,
|
|
3068
|
+
TDD_BASE_VARIABLE,
|
|
3069
|
+
TDD_THRESHOLD_VARIABLE
|
|
3070
|
+
]) {
|
|
3071
|
+
if (!combinedCi.toLowerCase().includes(marker.toLowerCase())) {
|
|
3072
|
+
reasons.push(`CI files do not contain required marker: ${marker}`);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
if (!usesRequiredGateVariables(combinedCi)) {
|
|
3076
|
+
reasons.push("CI changed-line gate must use the task baseline and threshold variables");
|
|
3077
|
+
}
|
|
3078
|
+
if (!/(?:^|\n)\s*stage\s*:\s*['"]?test['"]?\s*(?:#.*)?(?:\n|$)/i.test(combinedCi)) {
|
|
3079
|
+
reasons.push("CI files do not declare a TEST-stage job");
|
|
3080
|
+
}
|
|
3081
|
+
return {
|
|
3082
|
+
status: reasons.length === 0 ? "ready" : "needs_init",
|
|
3083
|
+
reasons: [...new Set(reasons)],
|
|
3084
|
+
manifestPath: manifestPath2
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
async function disableUnreadySessionTddOverrides(root) {
|
|
3088
|
+
if ((await inspectTddReadiness(root)).status === "ready") return 0;
|
|
3089
|
+
const sessionsDir = path18.join(root, EASY_CODING_DIR, SESSIONS_DIR);
|
|
3090
|
+
if (!await pathExists(sessionsDir)) return 0;
|
|
3091
|
+
let updated = 0;
|
|
3092
|
+
for (const entry of await readdir5(sessionsDir, { withFileTypes: true })) {
|
|
3093
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
3094
|
+
const filePath = path18.join(sessionsDir, entry.name);
|
|
3095
|
+
try {
|
|
3096
|
+
const session = JSON.parse(await readFile4(filePath, "utf8"));
|
|
3097
|
+
if (session.tdd_enabled !== true) continue;
|
|
3098
|
+
session.tdd_enabled = false;
|
|
3099
|
+
await writeTextFile(filePath, `${JSON.stringify(session, null, 2)}
|
|
3100
|
+
`);
|
|
3101
|
+
updated += 1;
|
|
3102
|
+
} catch {
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
return updated;
|
|
3106
|
+
}
|
|
3107
|
+
|
|
2819
3108
|
// src/commands/config.ts
|
|
2820
3109
|
async function config() {
|
|
2821
3110
|
renderBanner();
|
|
2822
|
-
const configPath2 =
|
|
3111
|
+
const configPath2 = path19.join(process.cwd(), EASY_CODING_DIR, CONFIG_FILE);
|
|
2823
3112
|
if (!await pathExists(configPath2)) {
|
|
2824
3113
|
throw new Error("No easy-coding harness found in this project.");
|
|
2825
3114
|
}
|
|
@@ -2863,7 +3152,7 @@ async function config() {
|
|
|
2863
3152
|
{
|
|
2864
3153
|
value: "auto",
|
|
2865
3154
|
label: "auto \u2014 advance workflow stages automatically",
|
|
2866
|
-
hint: "
|
|
3155
|
+
hint: "only new post-verification code drift pauses for exact acceptance"
|
|
2867
3156
|
}
|
|
2868
3157
|
]
|
|
2869
3158
|
});
|
|
@@ -2901,33 +3190,83 @@ async function config() {
|
|
|
2901
3190
|
cancel4("Configuration cancelled.");
|
|
2902
3191
|
return;
|
|
2903
3192
|
}
|
|
3193
|
+
const tddEnabled = await select({
|
|
3194
|
+
message: `Enable Java TDD for this project (current: ${current.tddEnabled ? "enabled" : "disabled"})`,
|
|
3195
|
+
initialValue: current.tddEnabled,
|
|
3196
|
+
options: [
|
|
3197
|
+
{ value: false, label: "disabled \u2014 preserve current test depth (default)" },
|
|
3198
|
+
{ value: true, label: "enabled \u2014 require TDD evidence and changed-line coverage" }
|
|
3199
|
+
]
|
|
3200
|
+
});
|
|
3201
|
+
if (typeof tddEnabled === "symbol") {
|
|
3202
|
+
cancel4("Configuration cancelled.");
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
if (tddEnabled) {
|
|
3206
|
+
const readiness = await inspectTddReadiness(process.cwd());
|
|
3207
|
+
if (readiness.status !== "ready") {
|
|
3208
|
+
cancel4(
|
|
3209
|
+
`TDD was not enabled. Run ec-tdd-init first: ${readiness.reasons.join("; ")}. No project modes were changed.`
|
|
3210
|
+
);
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
let tddCoverageThreshold = current.tddCoverageThreshold;
|
|
3215
|
+
if (tddEnabled) {
|
|
3216
|
+
const thresholdInput = await text({
|
|
3217
|
+
message: "Minimum changed-production-line coverage percentage",
|
|
3218
|
+
initialValue: String(current.tddCoverageThreshold),
|
|
3219
|
+
validate(value) {
|
|
3220
|
+
const parsed = Number(value);
|
|
3221
|
+
return isTddCoverageThreshold(parsed) ? void 0 : "Enter an integer from 1 to 100.";
|
|
3222
|
+
}
|
|
3223
|
+
});
|
|
3224
|
+
if (typeof thresholdInput === "symbol") {
|
|
3225
|
+
cancel4("Configuration cancelled.");
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
tddCoverageThreshold = Number(thresholdInput);
|
|
3229
|
+
}
|
|
2904
3230
|
const shouldSave = await confirm3({
|
|
2905
|
-
message: `Set
|
|
3231
|
+
message: `Set approval=${approvalMode}, workflow=${workflowMode}, TDD=${tddEnabled ? `enabled (${tddCoverageThreshold}%)` : "disabled"}?`,
|
|
2906
3232
|
initialValue: true
|
|
2907
3233
|
});
|
|
2908
3234
|
if (typeof shouldSave === "symbol" || !shouldSave) {
|
|
2909
3235
|
cancel4("Configuration cancelled.");
|
|
2910
3236
|
return;
|
|
2911
3237
|
}
|
|
2912
|
-
|
|
2913
|
-
|
|
3238
|
+
if (tddEnabled) {
|
|
3239
|
+
const readiness = await inspectTddReadiness(process.cwd());
|
|
3240
|
+
if (readiness.status !== "ready") {
|
|
3241
|
+
cancel4(
|
|
3242
|
+
`TDD was not enabled because readiness changed before save: ${readiness.reasons.join("; ")}. No project modes were changed.`
|
|
3243
|
+
);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
await setBehaviorModes(configPath2, approvalMode, workflowMode, tddEnabled, tddCoverageThreshold);
|
|
3248
|
+
outro3(
|
|
3249
|
+
chalk4.green(
|
|
3250
|
+
`Project modes updated: approval=${approvalMode}, workflow=${workflowMode}, TDD=${tddEnabled ? `${tddCoverageThreshold}%` : "off"}.`
|
|
3251
|
+
)
|
|
3252
|
+
);
|
|
2914
3253
|
}
|
|
2915
3254
|
|
|
2916
3255
|
// src/commands/init.ts
|
|
2917
|
-
import
|
|
3256
|
+
import path21 from "path";
|
|
2918
3257
|
import { note, outro as outro4 } from "@clack/prompts";
|
|
2919
3258
|
import chalk5 from "chalk";
|
|
2920
3259
|
|
|
2921
3260
|
// src/utils/install-state.ts
|
|
2922
|
-
import { readdir as
|
|
2923
|
-
import
|
|
3261
|
+
import { readdir as readdir6 } from "fs/promises";
|
|
3262
|
+
import path20 from "path";
|
|
2924
3263
|
var LEGACY_ROOT_FILES = ["SOUL.md", "RULES.md", "ABSTRACT.md"];
|
|
2925
3264
|
async function detectEasyCodingInstallState(cwd) {
|
|
2926
|
-
const easyCodingDir =
|
|
3265
|
+
const easyCodingDir = path20.join(cwd, EASY_CODING_DIR);
|
|
2927
3266
|
if (!await pathExists(easyCodingDir)) {
|
|
2928
3267
|
return { kind: "fresh", easyCodingDir };
|
|
2929
3268
|
}
|
|
2930
|
-
const configPath2 =
|
|
3269
|
+
const configPath2 = path20.join(easyCodingDir, CONFIG_FILE);
|
|
2931
3270
|
if (await pathExists(configPath2)) {
|
|
2932
3271
|
return { kind: "installed", easyCodingDir, configPath: configPath2 };
|
|
2933
3272
|
}
|
|
@@ -2945,17 +3284,17 @@ async function detectEasyCodingInstallState(cwd) {
|
|
|
2945
3284
|
async function detectLegacyAssets(easyCodingDir) {
|
|
2946
3285
|
const assets = [];
|
|
2947
3286
|
for (const file of LEGACY_ROOT_FILES) {
|
|
2948
|
-
if (await pathExists(
|
|
3287
|
+
if (await pathExists(path20.join(easyCodingDir, file))) {
|
|
2949
3288
|
assets.push(relativeEasyCodingPath(file));
|
|
2950
3289
|
}
|
|
2951
3290
|
}
|
|
2952
|
-
if (await pathExists(
|
|
3291
|
+
if (await pathExists(path20.join(easyCodingDir, "memory", "long", "MEMORY.md"))) {
|
|
2953
3292
|
assets.push(relativeEasyCodingPath("memory", "long", "MEMORY.md"));
|
|
2954
3293
|
}
|
|
2955
|
-
const shortMemoryFiles = await listMarkdownFiles(
|
|
3294
|
+
const shortMemoryFiles = await listMarkdownFiles(path20.join(easyCodingDir, "memory", "short"));
|
|
2956
3295
|
assets.push(...shortMemoryFiles.map((file) => relativeEasyCodingPath("memory", "short", file)));
|
|
2957
3296
|
for (const dir of ["spec", "prototype"]) {
|
|
2958
|
-
if (await hasAnyDirectoryEntry(
|
|
3297
|
+
if (await hasAnyDirectoryEntry(path20.join(easyCodingDir, dir))) {
|
|
2959
3298
|
assets.push(relativeEasyCodingPath(dir));
|
|
2960
3299
|
}
|
|
2961
3300
|
}
|
|
@@ -2965,23 +3304,23 @@ async function listMarkdownFiles(dir) {
|
|
|
2965
3304
|
if (!await isDirectory(dir)) {
|
|
2966
3305
|
return [];
|
|
2967
3306
|
}
|
|
2968
|
-
const entries = await
|
|
3307
|
+
const entries = await readdir6(dir, { withFileTypes: true });
|
|
2969
3308
|
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name).sort();
|
|
2970
3309
|
}
|
|
2971
3310
|
async function hasAnyDirectoryEntry(dir) {
|
|
2972
3311
|
if (!await isDirectory(dir)) {
|
|
2973
3312
|
return false;
|
|
2974
3313
|
}
|
|
2975
|
-
return (await
|
|
3314
|
+
return (await readdir6(dir)).length > 0;
|
|
2976
3315
|
}
|
|
2977
3316
|
function relativeEasyCodingPath(...segments) {
|
|
2978
|
-
return
|
|
3317
|
+
return path20.posix.join(EASY_CODING_DIR, ...segments);
|
|
2979
3318
|
}
|
|
2980
3319
|
function relativeConfigPath() {
|
|
2981
|
-
return
|
|
3320
|
+
return path20.posix.join(EASY_CODING_DIR, CONFIG_FILE);
|
|
2982
3321
|
}
|
|
2983
3322
|
function relativeProjectInitTaskPath() {
|
|
2984
|
-
return
|
|
3323
|
+
return path20.posix.join(EASY_CODING_DIR, TASKS_DIR, PROJECT_INIT_TASK_ID, "task.json");
|
|
2985
3324
|
}
|
|
2986
3325
|
|
|
2987
3326
|
// src/commands/init.ts
|
|
@@ -3062,7 +3401,7 @@ async function supermoduleTargets(cwd, opts, submodules) {
|
|
|
3062
3401
|
if (!targetSubmodulePaths.has(entry.path)) {
|
|
3063
3402
|
continue;
|
|
3064
3403
|
}
|
|
3065
|
-
const dir =
|
|
3404
|
+
const dir = path21.join(cwd, entry.path);
|
|
3066
3405
|
targets.push(
|
|
3067
3406
|
await targetFromState(dir, entry.path, "submodule-child", {
|
|
3068
3407
|
parent: toPosixRelative2(dir, cwd)
|
|
@@ -3109,24 +3448,24 @@ function contextFromState(role, installState) {
|
|
|
3109
3448
|
};
|
|
3110
3449
|
}
|
|
3111
3450
|
function toPosixRelative2(from, to) {
|
|
3112
|
-
const relative =
|
|
3113
|
-
return relative ? relative.split(
|
|
3451
|
+
const relative = path21.relative(from, to);
|
|
3452
|
+
return relative ? relative.split(path21.sep).join("/") : ".";
|
|
3114
3453
|
}
|
|
3115
3454
|
async function resolveInitPlatforms(cwd, opts, parentInstalled) {
|
|
3116
3455
|
if (opts.agent || !parentInstalled) {
|
|
3117
3456
|
return resolvePlatforms(opts, ["claude-code"]);
|
|
3118
3457
|
}
|
|
3119
|
-
const config2 = await readConfigYaml(
|
|
3458
|
+
const config2 = await readConfigYaml(path21.join(cwd, EASY_CODING_DIR, CONFIG_FILE));
|
|
3120
3459
|
if (Array.isArray(config2.agents) && config2.agents.length > 0) {
|
|
3121
3460
|
return config2.agents;
|
|
3122
3461
|
}
|
|
3123
3462
|
return resolvePlatforms(opts, ["claude-code"]);
|
|
3124
3463
|
}
|
|
3125
3464
|
async function refreshParentTopologyIfNeeded(cwd, parentTarget2, installPlatforms) {
|
|
3126
|
-
if (!await pathExists(
|
|
3465
|
+
if (!await pathExists(path21.join(cwd, EASY_CODING_DIR, CONFIG_FILE))) {
|
|
3127
3466
|
return;
|
|
3128
3467
|
}
|
|
3129
|
-
const config2 = parentTarget2.installed ? await readConfigYaml(
|
|
3468
|
+
const config2 = parentTarget2.installed ? await readConfigYaml(path21.join(cwd, EASY_CODING_DIR, CONFIG_FILE)) : { agents: installPlatforms };
|
|
3130
3469
|
const platforms = Array.isArray(config2.agents) && config2.agents.length > 0 ? config2.agents : installPlatforms;
|
|
3131
3470
|
await refreshSupermoduleParent(cwd, platforms, parentTarget2.context.submodulePaths ?? []);
|
|
3132
3471
|
}
|
|
@@ -3135,7 +3474,7 @@ async function refreshInstalledChildTopologies(targets) {
|
|
|
3135
3474
|
if (!target.installed || target.context.role !== "submodule-child") {
|
|
3136
3475
|
continue;
|
|
3137
3476
|
}
|
|
3138
|
-
const configPath2 =
|
|
3477
|
+
const configPath2 = path21.join(target.dir, EASY_CODING_DIR, CONFIG_FILE);
|
|
3139
3478
|
if (!await pathExists(configPath2)) {
|
|
3140
3479
|
continue;
|
|
3141
3480
|
}
|
|
@@ -3144,22 +3483,26 @@ async function refreshInstalledChildTopologies(targets) {
|
|
|
3144
3483
|
}
|
|
3145
3484
|
|
|
3146
3485
|
// src/commands/status.ts
|
|
3147
|
-
import
|
|
3486
|
+
import path23 from "path";
|
|
3148
3487
|
import chalk6 from "chalk";
|
|
3149
3488
|
|
|
3150
3489
|
// src/utils/session.ts
|
|
3151
|
-
import { readdir as
|
|
3152
|
-
import
|
|
3153
|
-
var
|
|
3490
|
+
import { readdir as readdir7, stat as stat3, unlink } from "fs/promises";
|
|
3491
|
+
import path22 from "path";
|
|
3492
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3493
|
+
var IDLE_SESSION_RETENTION_MS = 7 * DAY_MS;
|
|
3494
|
+
var ATTACHED_SESSION_RETENTION_MS = 30 * DAY_MS;
|
|
3495
|
+
var MAX_SESSION_FILES = 100;
|
|
3154
3496
|
function parseSessionFile(content) {
|
|
3155
3497
|
try {
|
|
3156
|
-
|
|
3498
|
+
const parsed = JSON.parse(content);
|
|
3499
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
3157
3500
|
} catch {
|
|
3158
3501
|
return null;
|
|
3159
3502
|
}
|
|
3160
3503
|
}
|
|
3161
3504
|
function getSessionDir(cwd) {
|
|
3162
|
-
return
|
|
3505
|
+
return path22.join(cwd, EASY_CODING_DIR, SESSIONS_DIR);
|
|
3163
3506
|
}
|
|
3164
3507
|
async function listSessionFiles(cwd) {
|
|
3165
3508
|
const dir = getSessionDir(cwd);
|
|
@@ -3167,11 +3510,11 @@ async function listSessionFiles(cwd) {
|
|
|
3167
3510
|
return [];
|
|
3168
3511
|
}
|
|
3169
3512
|
const entries = [];
|
|
3170
|
-
for (const name of await
|
|
3513
|
+
for (const name of await readdir7(dir)) {
|
|
3171
3514
|
if (!name.endsWith(".json")) {
|
|
3172
3515
|
continue;
|
|
3173
3516
|
}
|
|
3174
|
-
const filePath =
|
|
3517
|
+
const filePath = path22.join(dir, name);
|
|
3175
3518
|
const content = await readTextIfExists(filePath);
|
|
3176
3519
|
if (!content) {
|
|
3177
3520
|
continue;
|
|
@@ -3188,12 +3531,144 @@ async function listSessionFiles(cwd) {
|
|
|
3188
3531
|
}
|
|
3189
3532
|
return entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
3190
3533
|
}
|
|
3534
|
+
async function cleanSessionRuntime(cwd, options = {}) {
|
|
3535
|
+
const now = Date.now();
|
|
3536
|
+
const idleRetentionMs = options.idleRetentionMs ?? IDLE_SESSION_RETENTION_MS;
|
|
3537
|
+
const attachedRetentionMs = options.attachedRetentionMs ?? ATTACHED_SESSION_RETENTION_MS;
|
|
3538
|
+
const maxSessions = options.maxSessions ?? MAX_SESSION_FILES;
|
|
3539
|
+
const reserveSlots = options.reserveSlots ?? 0;
|
|
3540
|
+
const candidates = await listSessionCleanupCandidates(cwd);
|
|
3541
|
+
const removed = /* @__PURE__ */ new Set();
|
|
3542
|
+
for (const candidate of candidates) {
|
|
3543
|
+
const retentionMs = candidate.attached ? attachedRetentionMs : idleRetentionMs;
|
|
3544
|
+
if (now - candidate.activityTime <= retentionMs) {
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
if (await unlinkIfUnchanged(candidate)) {
|
|
3548
|
+
removed.add(candidate.filePath);
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
const allowedExistingSessions = Math.max(0, maxSessions - reserveSlots);
|
|
3552
|
+
const remaining = candidates.filter((candidate) => !removed.has(candidate.filePath)).sort((left, right) => left.activityTime - right.activityTime);
|
|
3553
|
+
for (const candidate of remaining.slice(
|
|
3554
|
+
0,
|
|
3555
|
+
Math.max(0, remaining.length - allowedExistingSessions)
|
|
3556
|
+
)) {
|
|
3557
|
+
if (await unlinkIfUnchanged(candidate)) {
|
|
3558
|
+
removed.add(candidate.filePath);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
return {
|
|
3562
|
+
sessionsRemoved: removed.size,
|
|
3563
|
+
acceptanceSnapshotsRemoved: await cleanOrphanAcceptanceSnapshots(cwd)
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
async function listSessionCleanupCandidates(cwd) {
|
|
3567
|
+
const dir = getSessionDir(cwd);
|
|
3568
|
+
if (!await pathExists(dir)) {
|
|
3569
|
+
return [];
|
|
3570
|
+
}
|
|
3571
|
+
const candidates = [];
|
|
3572
|
+
for (const entry of await readdir7(dir, { withFileTypes: true })) {
|
|
3573
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
3574
|
+
continue;
|
|
3575
|
+
}
|
|
3576
|
+
const filePath = path22.join(dir, entry.name);
|
|
3577
|
+
try {
|
|
3578
|
+
const [content, fileStat] = await Promise.all([readTextFile(filePath), stat3(filePath)]);
|
|
3579
|
+
const session = parseSessionFile(content);
|
|
3580
|
+
const activityValue = session?.last_active_at ?? session?.created_at;
|
|
3581
|
+
const parsedActivity = typeof activityValue === "string" ? new Date(activityValue).getTime() : Number.NaN;
|
|
3582
|
+
candidates.push({
|
|
3583
|
+
filePath,
|
|
3584
|
+
content,
|
|
3585
|
+
activityTime: Number.isNaN(parsedActivity) ? fileStat.mtimeMs : parsedActivity,
|
|
3586
|
+
attached: Boolean(session?.current_task)
|
|
3587
|
+
});
|
|
3588
|
+
} catch (error) {
|
|
3589
|
+
if (!isFileNotFound(error)) {
|
|
3590
|
+
throw error;
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
return candidates;
|
|
3595
|
+
}
|
|
3596
|
+
async function unlinkIfUnchanged(candidate) {
|
|
3597
|
+
try {
|
|
3598
|
+
if (await readTextFile(candidate.filePath) !== candidate.content) {
|
|
3599
|
+
return false;
|
|
3600
|
+
}
|
|
3601
|
+
await unlink(candidate.filePath);
|
|
3602
|
+
return true;
|
|
3603
|
+
} catch (error) {
|
|
3604
|
+
if (isFileNotFound(error)) {
|
|
3605
|
+
return false;
|
|
3606
|
+
}
|
|
3607
|
+
throw error;
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
async function cleanOrphanAcceptanceSnapshots(cwd) {
|
|
3611
|
+
const acceptanceDir = path22.join(getSessionDir(cwd), "acceptance");
|
|
3612
|
+
if (!await pathExists(acceptanceDir)) {
|
|
3613
|
+
return 0;
|
|
3614
|
+
}
|
|
3615
|
+
let removed = 0;
|
|
3616
|
+
for (const entry of await readdir7(acceptanceDir, { withFileTypes: true })) {
|
|
3617
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
3618
|
+
continue;
|
|
3619
|
+
}
|
|
3620
|
+
const snapshotPath = path22.join(acceptanceDir, entry.name);
|
|
3621
|
+
if (!await isOrphanAcceptanceSnapshot(cwd, snapshotPath, entry.name.slice(0, -5))) {
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
try {
|
|
3625
|
+
await unlink(snapshotPath);
|
|
3626
|
+
removed++;
|
|
3627
|
+
} catch (error) {
|
|
3628
|
+
if (!isFileNotFound(error)) {
|
|
3629
|
+
throw error;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
return removed;
|
|
3634
|
+
}
|
|
3635
|
+
async function isOrphanAcceptanceSnapshot(cwd, snapshotPath, taskId) {
|
|
3636
|
+
let taskContent;
|
|
3637
|
+
try {
|
|
3638
|
+
taskContent = await readTextFile(
|
|
3639
|
+
path22.join(cwd, EASY_CODING_DIR, TASKS_DIR, taskId, "task.json")
|
|
3640
|
+
);
|
|
3641
|
+
} catch (error) {
|
|
3642
|
+
if (isFileNotFound(error)) {
|
|
3643
|
+
return true;
|
|
3644
|
+
}
|
|
3645
|
+
throw error;
|
|
3646
|
+
}
|
|
3647
|
+
let parsedTask;
|
|
3648
|
+
try {
|
|
3649
|
+
parsedTask = JSON.parse(taskContent);
|
|
3650
|
+
} catch {
|
|
3651
|
+
return false;
|
|
3652
|
+
}
|
|
3653
|
+
if (typeof parsedTask !== "object" || parsedTask === null || Array.isArray(parsedTask)) {
|
|
3654
|
+
return false;
|
|
3655
|
+
}
|
|
3656
|
+
const task = parsedTask;
|
|
3657
|
+
if (task.status === "COMPLETE" || task.status === "CLOSED") {
|
|
3658
|
+
return true;
|
|
3659
|
+
}
|
|
3660
|
+
const checkpoint = task.verification_checkpoint;
|
|
3661
|
+
return typeof checkpoint?.snapshot_file !== "string" || path22.resolve(cwd, checkpoint.snapshot_file) !== path22.resolve(snapshotPath);
|
|
3662
|
+
}
|
|
3663
|
+
function isFileNotFound(error) {
|
|
3664
|
+
return error.code === "ENOENT";
|
|
3665
|
+
}
|
|
3191
3666
|
|
|
3192
3667
|
// src/commands/status.ts
|
|
3193
3668
|
async function status() {
|
|
3194
3669
|
renderBanner();
|
|
3195
3670
|
const cwd = process.cwd();
|
|
3196
|
-
const configPath2 =
|
|
3671
|
+
const configPath2 = path23.join(cwd, EASY_CODING_DIR, CONFIG_FILE);
|
|
3197
3672
|
if (!await pathExists(configPath2)) {
|
|
3198
3673
|
throw new Error("No easy-coding harness found in this project.");
|
|
3199
3674
|
}
|
|
@@ -3203,6 +3678,7 @@ async function status() {
|
|
|
3203
3678
|
const activeTasks = tasks.filter((item) => isActiveTask(item.task));
|
|
3204
3679
|
const sessions = await listSessionFiles(cwd);
|
|
3205
3680
|
const versionRelation = compareVersions(config2.harness_version, VERSION);
|
|
3681
|
+
const tddReadiness = await inspectTddReadiness(cwd);
|
|
3206
3682
|
console.log(chalk6.bold("Harness"));
|
|
3207
3683
|
console.log(` version: ${config2.harness_version}`);
|
|
3208
3684
|
console.log(` cli: ${VERSION}`);
|
|
@@ -3218,14 +3694,28 @@ async function status() {
|
|
|
3218
3694
|
const migratedBehavior = resolveLegacyBehavior(config2);
|
|
3219
3695
|
const projectApprovalMode = isApprovalMode(config2.behavior?.approval_mode) ? config2.behavior.approval_mode : migratedBehavior.approvalMode;
|
|
3220
3696
|
const projectWorkflowMode = isConfiguredWorkflowMode(config2.behavior?.workflow_mode) ? config2.behavior.workflow_mode : migratedBehavior.workflowMode;
|
|
3697
|
+
const projectTddEnabled = migratedBehavior.tddEnabled;
|
|
3698
|
+
const projectTddCoverageThreshold = migratedBehavior.tddCoverageThreshold;
|
|
3221
3699
|
console.log(` approval_mode: ${projectApprovalMode}`);
|
|
3222
3700
|
console.log(` workflow_mode: ${projectWorkflowMode}`);
|
|
3701
|
+
console.log(` tdd_enabled: ${projectTddEnabled}`);
|
|
3702
|
+
console.log(` tdd_coverage_threshold: ${projectTddCoverageThreshold}`);
|
|
3703
|
+
console.log(` tdd_readiness: ${tddReadiness.status}`);
|
|
3704
|
+
if (tddReadiness.status !== "ready") {
|
|
3705
|
+
console.log(` tdd_readiness_reasons: ${tddReadiness.reasons.join("; ")}`);
|
|
3706
|
+
}
|
|
3223
3707
|
console.log("");
|
|
3224
3708
|
console.log(chalk6.bold("Sessions"));
|
|
3225
3709
|
console.log(` project_approval_mode: ${projectApprovalMode}`);
|
|
3226
3710
|
console.log(` project_workflow_mode: ${projectWorkflowMode}`);
|
|
3711
|
+
console.log(` project_tdd_enabled: ${projectTddEnabled}`);
|
|
3712
|
+
console.log(` project_tdd_coverage_threshold: ${projectTddCoverageThreshold}`);
|
|
3227
3713
|
console.log(` effective_approval_mode: ${projectApprovalMode} (without a session override)`);
|
|
3228
3714
|
console.log(` configured_workflow_mode: ${projectWorkflowMode} (without a session override)`);
|
|
3715
|
+
console.log(` effective_tdd_enabled: ${projectTddEnabled} (without a session override)`);
|
|
3716
|
+
console.log(
|
|
3717
|
+
` effective_tdd_coverage_threshold: ${projectTddCoverageThreshold} (without a session override)`
|
|
3718
|
+
);
|
|
3229
3719
|
if (sessions.length === 0) {
|
|
3230
3720
|
console.log(" no session files");
|
|
3231
3721
|
}
|
|
@@ -3236,13 +3726,21 @@ async function status() {
|
|
|
3236
3726
|
);
|
|
3237
3727
|
const sessionApprovalMode = session.approval_mode ?? (legacySessionMode === "lite" ? "guard" : legacySessionMode);
|
|
3238
3728
|
const sessionWorkflowMode = session.workflow_mode ?? (legacySessionMode === "lite" ? "fast" : hasLegacySessionMode ? "adaptive" : void 0);
|
|
3729
|
+
const sessionTddEnabled = session.tdd_enabled;
|
|
3730
|
+
const sessionTddCoverageThreshold = session.tdd_coverage_threshold;
|
|
3239
3731
|
console.log(` - ${key}`);
|
|
3240
3732
|
console.log(` agent: ${session.agent ?? "legacy/unknown"}`);
|
|
3241
3733
|
console.log(` source: ${session.session_source ?? "legacy"}`);
|
|
3242
3734
|
console.log(` approval_mode: ${sessionApprovalMode ?? "project default"}`);
|
|
3243
3735
|
console.log(` workflow_mode: ${sessionWorkflowMode ?? "project default"}`);
|
|
3736
|
+
console.log(` tdd_enabled: ${sessionTddEnabled ?? "project default"}`);
|
|
3737
|
+
console.log(` tdd_coverage_threshold: ${sessionTddCoverageThreshold ?? "project default"}`);
|
|
3244
3738
|
console.log(` effective_approval_mode: ${sessionApprovalMode ?? projectApprovalMode}`);
|
|
3245
3739
|
console.log(` configured_workflow_mode: ${sessionWorkflowMode ?? projectWorkflowMode}`);
|
|
3740
|
+
console.log(` effective_tdd_enabled: ${sessionTddEnabled ?? projectTddEnabled}`);
|
|
3741
|
+
console.log(
|
|
3742
|
+
` effective_tdd_coverage_threshold: ${sessionTddCoverageThreshold ?? projectTddCoverageThreshold}`
|
|
3743
|
+
);
|
|
3246
3744
|
console.log(
|
|
3247
3745
|
` harness: ${session.harness_disabled ? "disabled for this session" : "enabled"}`
|
|
3248
3746
|
);
|
|
@@ -3258,6 +3756,10 @@ async function status() {
|
|
|
3258
3756
|
console.log(
|
|
3259
3757
|
` task_workflow_mode: ${task.workflow_mode ?? task.workflow_mode_proposal?.selected_mode ?? "not resolved"}`
|
|
3260
3758
|
);
|
|
3759
|
+
console.log(` task_tdd_enabled: ${task.tdd_enabled ?? "not frozen"}`);
|
|
3760
|
+
console.log(
|
|
3761
|
+
` task_tdd_coverage_threshold: ${task.tdd_coverage_threshold ?? "not frozen"}`
|
|
3762
|
+
);
|
|
3261
3763
|
console.log(` last_agent: ${task.last_agent}`);
|
|
3262
3764
|
} else {
|
|
3263
3765
|
console.log(` current_task: ${session.current_task} (task.json missing)`);
|
|
@@ -3309,7 +3811,7 @@ async function update(opts) {
|
|
|
3309
3811
|
}
|
|
3310
3812
|
|
|
3311
3813
|
// src/commands/upgrade.ts
|
|
3312
|
-
import
|
|
3814
|
+
import path24 from "path";
|
|
3313
3815
|
import { cancel as cancel6, confirm as confirm5, outro as outro6 } from "@clack/prompts";
|
|
3314
3816
|
import chalk8 from "chalk";
|
|
3315
3817
|
var EXPECTED_HOOK_REGISTRATION_SCRIPTS = {
|
|
@@ -3371,8 +3873,9 @@ async function upgrade(opts) {
|
|
|
3371
3873
|
].filter(Boolean) : [],
|
|
3372
3874
|
"Will overwrite managed skills, hooks, agents, templates, and generated main-constraint regions.",
|
|
3373
3875
|
"Will update project-init task to recommend ec-init re-run for version adaptation.",
|
|
3374
|
-
"Will migrate
|
|
3375
|
-
"Will
|
|
3876
|
+
"Will migrate behavior config to schema 5 and disable unready project/session TDD settings.",
|
|
3877
|
+
"Will prune expired session bindings and orphan acceptance snapshots in each upgraded target while preserving tasks, memory, spec, and project knowledge.",
|
|
3878
|
+
"Will migrate legacy workflow/TDD task metadata; memory content, spec, and project knowledge files remain untouched."
|
|
3376
3879
|
].join("\n");
|
|
3377
3880
|
if (opts.dryRun) {
|
|
3378
3881
|
console.log(summary);
|
|
@@ -3389,6 +3892,15 @@ async function upgrade(opts) {
|
|
|
3389
3892
|
}
|
|
3390
3893
|
}
|
|
3391
3894
|
for (const { target, config: config2 } of pending) {
|
|
3895
|
+
const sessionCleanup = await cleanSessionRuntime(target.dir);
|
|
3896
|
+
if (sessionCleanup.sessionsRemoved > 0 || sessionCleanup.acceptanceSnapshotsRemoved > 0) {
|
|
3897
|
+
console.log(
|
|
3898
|
+
chalk8.yellow(
|
|
3899
|
+
`${target.label}: session GC removed ${sessionCleanup.sessionsRemoved} session file(s) and ${sessionCleanup.acceptanceSnapshotsRemoved} orphan acceptance snapshot(s).`
|
|
3900
|
+
)
|
|
3901
|
+
);
|
|
3902
|
+
}
|
|
3903
|
+
const beta1ProjectTddRequested = Number(config2.version) === 4 && config2.behavior?.tdd_enabled === true;
|
|
3392
3904
|
const projectId = await writeRuntimeScaffold(target.dir, config2.agents, {
|
|
3393
3905
|
supermodule: target.supermodule
|
|
3394
3906
|
});
|
|
@@ -3405,6 +3917,14 @@ async function upgrade(opts) {
|
|
|
3405
3917
|
await ensureHookBytecodeIgnored(target.dir);
|
|
3406
3918
|
await migrateLegacyWorkflowState(target.dir);
|
|
3407
3919
|
await migrateBehaviorConfig(target.configPath);
|
|
3920
|
+
const disabledSessionTddOverrides = await disableUnreadySessionTddOverrides(target.dir);
|
|
3921
|
+
if (beta1ProjectTddRequested || disabledSessionTddOverrides > 0) {
|
|
3922
|
+
console.log(
|
|
3923
|
+
chalk8.yellow(
|
|
3924
|
+
`${target.label}: TDD remains off until ec-tdd-init succeeds (project=${beta1ProjectTddRequested ? "disabled" : "unchanged"}, sessions_disabled=${disabledSessionTddOverrides}).`
|
|
3925
|
+
)
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3408
3928
|
await updateHarnessVersion(target.configPath, VERSION);
|
|
3409
3929
|
await updateSupermoduleConfig(target.configPath, target.supermodule);
|
|
3410
3930
|
await setPendingInitSince(target.dir, VERSION);
|
|
@@ -3460,7 +3980,7 @@ async function needsHookConfigRefresh(target, config2) {
|
|
|
3460
3980
|
const manifest = await readInstallManifest(target.dir);
|
|
3461
3981
|
for (const agent of config2.agents) {
|
|
3462
3982
|
const meta = resolvePlatformMeta(target.dir, agent);
|
|
3463
|
-
const configPath2 =
|
|
3983
|
+
const configPath2 = path24.join(target.dir, meta.hookConfigFile);
|
|
3464
3984
|
const content = await readTextIfExists(configPath2);
|
|
3465
3985
|
if (content === null) {
|
|
3466
3986
|
return true;
|
|
@@ -3600,7 +4120,7 @@ function isCurrentProjectManagedHookPath(cwd, hookPath, meta, platform) {
|
|
|
3600
4120
|
return true;
|
|
3601
4121
|
}
|
|
3602
4122
|
return pathAliases(
|
|
3603
|
-
normalizePathForHookComparison(
|
|
4123
|
+
normalizePathForHookComparison(path24.resolve(cwd, relativeHookPath))
|
|
3604
4124
|
).includes(normalizedHookPath);
|
|
3605
4125
|
});
|
|
3606
4126
|
}
|