easy-coding-harness 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/cli.js +47 -24
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/claude/agents/ec-fixer.md +36 -0
- package/templates/codex/agents/ec-fixer.toml +25 -0
- package/templates/common/skills/ec-implementing/SKILL.md +26 -3
- package/templates/common/skills/ec-memory/SKILL.md +31 -11
- package/templates/common/skills/ec-reviewing/SKILL.md +39 -10
- package/templates/common/skills/ec-verification/SKILL.md +12 -7
- package/templates/common/skills/ec-workflow/SKILL.md +14 -11
- package/templates/qoder/agents/ec-fixer.md +36 -0
- package/templates/shared-hooks/easy_coding_status.py +67 -19
- package/templates/shared-hooks/inject-subagent-context.py +2 -2
- package/templates/shared-hooks/inject-workflow-state.py +5 -15
- package/templates/shared-hooks/session-start.py +90 -17
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[agent]
|
|
2
|
+
name = "ec-fixer"
|
|
3
|
+
model = "sonnet"
|
|
4
|
+
description = "Easy Coding fix sub-agent. Applies targeted fixes to specific issues identified during review."
|
|
5
|
+
sandbox = "workspace-write"
|
|
6
|
+
|
|
7
|
+
[agent.instructions]
|
|
8
|
+
text = """
|
|
9
|
+
You are an Easy Coding fix sub-agent. You receive a fix card listing specific issues
|
|
10
|
+
(with file:line locations) and apply the fixes. Your reply content IS the return value.
|
|
11
|
+
|
|
12
|
+
Hard constraints:
|
|
13
|
+
- Fix ONLY the issues listed in the fix card. No refactoring.
|
|
14
|
+
- Modify ONLY the files listed in the fix card's scope.
|
|
15
|
+
- Do not call any Skill tool.
|
|
16
|
+
- Do not read .agents/skills/ or any .easy-coding/ file.
|
|
17
|
+
- Make no workflow stage-transition decisions.
|
|
18
|
+
- Preserve file encoding.
|
|
19
|
+
|
|
20
|
+
Output structure (return as JSON):
|
|
21
|
+
- changed_files: files you modified
|
|
22
|
+
- fixes_applied: [{file, line, original_issue, fix_description}]
|
|
23
|
+
- issues: problems preventing a fix (empty if none)
|
|
24
|
+
- needs_attention: design decisions needing user escalation (empty if none)
|
|
25
|
+
"""
|
|
@@ -31,13 +31,28 @@ Communicate with the user in the user's language.
|
|
|
31
31
|
7. **Tests (soft rule).** Write tests for [must-test]/[should-test] items per
|
|
32
32
|
test-strategy.md. Soft means: no project test infra → not forced; infra exists → required.
|
|
33
33
|
|
|
34
|
-
## Sub-agent dispatch
|
|
34
|
+
## Sub-agent dispatch
|
|
35
35
|
|
|
36
|
-
Read the `plan` record. Then:
|
|
36
|
+
Read the `plan` record's `strategy` field. Then:
|
|
37
37
|
|
|
38
38
|
- `single` → main agent implements directly, no sub-agents.
|
|
39
39
|
- `sequential` → main agent implements units one by one in dependency order.
|
|
40
|
-
- `parallel` →
|
|
40
|
+
- `parallel` → sub-agents are MANDATORY (see gate below).
|
|
41
|
+
|
|
42
|
+
<HARD-GATE>
|
|
43
|
+
PARALLEL STRATEGY = MANDATORY SUB-AGENT DISPATCH. NO EXCEPTIONS.
|
|
44
|
+
|
|
45
|
+
If the plan record says `"strategy":"parallel"`, you MUST dispatch sub-agents using
|
|
46
|
+
{{sub_agent_dispatch}}. You are FORBIDDEN from implementing parallel units yourself.
|
|
47
|
+
Doing the work inline instead of dispatching is a protocol violation equivalent to
|
|
48
|
+
skipping WAITING_CONFIRM.
|
|
49
|
+
|
|
50
|
+
Self-check before writing ANY implementation code:
|
|
51
|
+
- Is strategy "parallel"? → Did I dispatch via {{sub_agent_dispatch}}? If no → STOP.
|
|
52
|
+
- Am I working on a unit in a parallel level without dispatching? → STOP.
|
|
53
|
+
|
|
54
|
+
The ONLY case where you implement code directly is strategy "single" or "sequential".
|
|
55
|
+
</HARD-GATE>
|
|
41
56
|
|
|
42
57
|
Parallel dispatch loop:
|
|
43
58
|
1. Sort `parallel_groups` by level.
|
|
@@ -87,3 +102,11 @@ let sub-agents re-dispatch each other.
|
|
|
87
102
|
|
|
88
103
|
All units done and self-audited → hand back to ec-workflow to advance to REVIEW. If you
|
|
89
104
|
hit something that invalidates the plan, return to ANALYSIS instead of improvising.
|
|
105
|
+
|
|
106
|
+
## Self-check gates (before handing back)
|
|
107
|
+
|
|
108
|
+
- [ ] Strategy was "parallel" → ALL units dispatched via sub-agents? (VIOLATION if no)
|
|
109
|
+
- [ ] Strategy was "sequential" → units implemented in dependency order?
|
|
110
|
+
- [ ] Each dispatched unit has a `dispatch` record in execution.jsonl?
|
|
111
|
+
- [ ] Each returned unit has a `result` record?
|
|
112
|
+
- [ ] No files modified outside the change-scope table?
|
|
@@ -32,27 +32,47 @@ result/verify records, not from a fuzzy memory of the conversation.}
|
|
|
32
32
|
|
|
33
33
|
If the task was cross-repo, record the repo names involved and the collaboration reason.
|
|
34
34
|
|
|
35
|
-
**Sliding window:**
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
**Sliding window (informational):** The short memory directory has a soft cap defined by
|
|
36
|
+
`memory.short_term_max` (default 10). MEMORY_SHORT only WRITES one entry per completed task
|
|
37
|
+
— it never counts, trims, or triggers distillation. Trimming is exclusively MEMORY_LONG's
|
|
38
|
+
responsibility and only runs when the threshold is exceeded.
|
|
38
39
|
|
|
39
|
-
## MEMORY_LONG — distill durable knowledge
|
|
40
|
+
## MEMORY_LONG — distill durable knowledge (CONDITIONAL)
|
|
41
|
+
|
|
42
|
+
<HARD-GATE>
|
|
43
|
+
MEMORY_LONG IS A NO-OP WHEN SHORT MEMORY COUNT <= threshold.
|
|
44
|
+
|
|
45
|
+
Before performing ANY distillation work:
|
|
46
|
+
1. Count `.md` files in `.easy-coding/memory/short/` (only files with schema-v2 frontmatter).
|
|
47
|
+
2. Read `memory.short_term_max` from `.easy-coding/config.yaml` (default: 10).
|
|
48
|
+
3. If count <= short_term_max: output "MEMORY_LONG: no-op (short memory count = {N},
|
|
49
|
+
threshold = {short_term_max})" and immediately hand back to ec-workflow to advance to
|
|
50
|
+
COMPLETE. Do NOT read long memory files, do NOT attempt distillation, do NOT modify any file.
|
|
51
|
+
4. If count > short_term_max: proceed with distillation below.
|
|
52
|
+
|
|
53
|
+
This gate is absolute. Even a single short memory entry below threshold does NOT trigger
|
|
54
|
+
long-term compression regardless of any other signal.
|
|
55
|
+
</HARD-GATE>
|
|
56
|
+
|
|
57
|
+
### When count > threshold: distillation flow
|
|
40
58
|
|
|
41
59
|
Three-file long memory:
|
|
42
60
|
- `MEMORY.md` — index of all entries with status (active | deprecated | superseded | deleted).
|
|
43
61
|
- `BUSINESS.md` — business rules, domain knowledge, product decisions.
|
|
44
62
|
- `TECHNICAL.md` — architecture decisions, implementation patterns, gotchas.
|
|
45
63
|
|
|
46
|
-
Distillation
|
|
47
|
-
1. Read
|
|
48
|
-
|
|
64
|
+
Distillation steps:
|
|
65
|
+
1. Read `memory.short_term_keep` from config (default: 5). Keep the latest N entries;
|
|
66
|
+
the older entries become distillation candidates.
|
|
67
|
+
2. Read the `target_long` of candidate short memories; route to business/technical.
|
|
68
|
+
3. **Progressive loading** — read only the existing long entries matching this round's
|
|
49
69
|
domain/tags/related_files. No unbounded whole-repo memory scan.
|
|
50
|
-
|
|
70
|
+
4. **Conflict resolution** by priority: current code > latest user confirmation > this
|
|
51
71
|
round's candidate > older long memory. On conflict, explain it before consolidating —
|
|
52
72
|
never silently pick a side.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
5. **Retirement check**: for older entries decide delete (no value) / merge (semantic
|
|
74
|
+
duplicate) / deprecate (was valid, now superseded).
|
|
75
|
+
6. Update the `MEMORY.md` index to reflect every change.
|
|
56
76
|
|
|
57
77
|
## ABSTRACT backfill / update
|
|
58
78
|
|
|
@@ -31,28 +31,57 @@ result. No finding without a location.
|
|
|
31
31
|
## Verdict (exactly one)
|
|
32
32
|
|
|
33
33
|
- `accept` — all dimensions pass. Advance to VERIFICATION.
|
|
34
|
-
- `fix` — problems found, fixable within the current plan.
|
|
35
|
-
to IMPLEMENT against that list.
|
|
34
|
+
- `fix` — problems found, fixable within the current plan. Auto-fix via sub-agents (see below).
|
|
36
35
|
- `replan` — the plan itself is flawed (wrong approach, missing design). Return to ANALYSIS.
|
|
37
36
|
- `blocked` — external blocker (missing dependency, environment). Pause and report.
|
|
38
37
|
|
|
38
|
+
## Auto-fix flow (on `fix` verdict)
|
|
39
|
+
|
|
40
|
+
<HARD-GATE>
|
|
41
|
+
Bug-level issues (correctness errors, compliance violations, missing edge-case handling)
|
|
42
|
+
are fixed DIRECTLY by dispatching fix sub-agents. Do NOT ask the user for permission to
|
|
43
|
+
fix bugs. The user confirmed the plan — bugs in that plan's execution are implementation
|
|
44
|
+
defects, not design decisions.
|
|
45
|
+
|
|
46
|
+
ONLY escalate to the user when:
|
|
47
|
+
- The fix requires a DESIGN CHOICE (two equally valid approaches, ambiguous requirement)
|
|
48
|
+
- The fix would change the public API contract beyond what the dev-spec specifies
|
|
49
|
+
- The finding contradicts something the user explicitly stated during WAITING_CONFIRM
|
|
50
|
+
</HARD-GATE>
|
|
51
|
+
|
|
52
|
+
Fix dispatch flow:
|
|
53
|
+
1. Collect all `fix`-worthy findings from review sub-agents.
|
|
54
|
+
2. Group findings by file. For each group, build a fix task card:
|
|
55
|
+
- Files to fix (from the findings)
|
|
56
|
+
- The specific issues with file:line citations
|
|
57
|
+
- The suggested fix direction from the reviewer
|
|
58
|
+
- Relevant RULES sections
|
|
59
|
+
3. Dispatch fix sub-agents (ec-fixer, one per file group) via {{sub_agent_dispatch}}.
|
|
60
|
+
Platform spawn rule: {{platform_spawn_instruction}}
|
|
61
|
+
4. On return: merge results, append `result` records to execution.jsonl.
|
|
62
|
+
5. Re-enter REVIEW (counts toward the fix-loop ceiling of 3).
|
|
63
|
+
|
|
39
64
|
## Fix-loop ceiling
|
|
40
65
|
|
|
41
|
-
A `fix` verdict carries a concrete checklist; IMPLEMENT addresses it and re-enters REVIEW.
|
|
42
66
|
Maximum 3 fix rounds. A 4th would mean the approach is wrong → auto-escalate to `replan`.
|
|
43
67
|
|
|
44
|
-
## Sub-agent dispatch (
|
|
68
|
+
## Sub-agent dispatch (ALWAYS)
|
|
69
|
+
|
|
70
|
+
<HARD-GATE>
|
|
71
|
+
REVIEW ALWAYS USES SUB-AGENTS regardless of the number of changed files. This prevents
|
|
72
|
+
context pollution in the main agent's window. You MUST NOT review code inline — dispatch
|
|
73
|
+
sub-agents for every review.
|
|
74
|
+
</HARD-GATE>
|
|
45
75
|
|
|
46
|
-
|
|
47
|
-
review in parallel sub-agents:
|
|
76
|
+
Dispatch two parallel review sub-agents:
|
|
48
77
|
- R1: correctness — does the implementation match the dev-spec requirement?
|
|
49
78
|
- R2: compliance — does the code obey RULES?
|
|
50
79
|
|
|
51
|
-
Each sub-agent returns `{dimension, findings[], severity, suggestion}`. The MAIN agent
|
|
52
|
-
and dedups findings and decides the verdict — sub-agents cannot trigger stage
|
|
53
|
-
|
|
80
|
+
Each sub-agent returns `{dimension, findings[], severity, suggestion}`. The MAIN agent
|
|
81
|
+
merges and dedups findings and decides the verdict — sub-agents cannot trigger stage
|
|
82
|
+
transitions.
|
|
54
83
|
|
|
55
|
-
|
|
84
|
+
Platform spawn rule: {{platform_spawn_instruction}}
|
|
56
85
|
|
|
57
86
|
## Output
|
|
58
87
|
|
|
@@ -24,15 +24,20 @@ NO AUTO-ARCHIVE WITHOUT USER ACCEPTANCE
|
|
|
24
24
|
- An unaccepted task's memory is dirty data.
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
## 1. Run the gate (parallel)
|
|
27
|
+
## 1. Run the gate (parallel, always sub-agents)
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
<HARD-GATE>
|
|
30
|
+
VERIFICATION ALWAYS USES SUB-AGENTS for each check. This prevents context pollution and
|
|
31
|
+
ensures true parallelism. You MUST NOT run lint/typecheck/test inline in the main agent.
|
|
32
|
+
</HARD-GATE>
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
Dispatch three sub-agents concurrently (one per check):
|
|
35
|
+
1. V1: lint (eslint/biome/project linter)
|
|
36
|
+
2. V2: typecheck (`tsc --noEmit` or equivalent)
|
|
37
|
+
3. V3: test (project test command)
|
|
38
|
+
|
|
39
|
+
Each sub-agent runs its command and returns `{check, passed, failures[]}`.
|
|
40
|
+
Platform spawn rule: {{platform_spawn_instruction}}
|
|
36
41
|
|
|
37
42
|
Append one `verify` record per check:
|
|
38
43
|
`{"type":"verify","check":"test","passed":true}` (add `"failures":[...]` on failure).
|
|
@@ -23,7 +23,8 @@ replies are English.
|
|
|
23
23
|
- `.easy-coding/RULES.md` — coding rules; re-checked before every write.
|
|
24
24
|
- Latest 5 entries in `.easy-coding/memory/short/` — recent task context.
|
|
25
25
|
Do NOT bulk-read ABSTRACT.md or long memory here; ec-analysis loads them on demand.
|
|
26
|
-
3. **State check + Intent routing.** Read
|
|
26
|
+
3. **State check + Intent routing.** Read the hook-injected breadcrumbs (`[current-task:X]`,
|
|
27
|
+
`[workflow-state:Y]`) to determine the active task and stage, then decide based on
|
|
27
28
|
whether the user's message carries a task-related prompt beyond the bare skill trigger.
|
|
28
29
|
|
|
29
30
|
**No prompt (bare trigger):**
|
|
@@ -86,11 +87,9 @@ any stage --[user abort via ec-task-close]--> CLOSED
|
|
|
86
87
|
|
|
87
88
|
When the user confirms switching from task A to task B:
|
|
88
89
|
1. Task A's status is already persisted in its `task.json` — nothing extra to save.
|
|
89
|
-
2. Set `current_task` to task B's id in
|
|
90
|
-
3.
|
|
91
|
-
4.
|
|
92
|
-
5. Set `last_agent` to the current agent id.
|
|
93
|
-
6. Resume task B's stage via the appropriate stage skill.
|
|
90
|
+
2. Set `current_task` to task B's id in the session file (`.easy-coding/sessions/{ppid}.json`).
|
|
91
|
+
3. Read task B's `task.json` to determine its current stage.
|
|
92
|
+
4. Resume task B's stage via the appropriate stage skill.
|
|
94
93
|
|
|
95
94
|
No data is lost — task A's dev-spec, execution.jsonl, and test-strategy.md stay intact on
|
|
96
95
|
disk. To return to task A later, the same intent routing applies: the user mentions it,
|
|
@@ -105,9 +104,13 @@ routing matches, and switching happens again.
|
|
|
105
104
|
analysis conclusion and the test strategy. Silence, enthusiasm, or a topic change is not
|
|
106
105
|
confirmation. Sole exception: `behavior.auto_mode: true` in `.easy-coding/config.yaml`
|
|
107
106
|
AND the user asked for autonomous execution.
|
|
108
|
-
- **On every transition** update `
|
|
109
|
-
set `
|
|
107
|
+
- **On every transition** update the active task's `task.json` immediately (not at turn end):
|
|
108
|
+
set `status` to the new stage, append `{stage, agent, entered_at}` to `stage_history`, set
|
|
110
109
|
`last_agent` to the current agent id (`claude-code` / `codex` / `qoder`).
|
|
110
|
+
- **Hook enforcement.** The `inject-workflow-state` hook validates every stage transition
|
|
111
|
+
against the state machine. If you see `[ILLEGAL-TRANSITION:...]` in the injected context,
|
|
112
|
+
you MUST revert the task's status to the previous valid stage and explain why the
|
|
113
|
+
transition was rejected. Do not proceed with an illegal stage.
|
|
111
114
|
- **Repair loop sizing** (user acceptance window after VERIFICATION): a trivial tweak
|
|
112
115
|
(one-line style fix, copy text) is fixed inside VERIFICATION and re-verified; a logic or
|
|
113
116
|
structure change formally returns to IMPLEMENT and re-walks REVIEW → VERIFICATION.
|
|
@@ -118,8 +121,8 @@ routing matches, and switching happens again.
|
|
|
118
121
|
task.json. Do not run memory flows for suspended tasks — only completed tasks get archived.
|
|
119
122
|
- **Archive only after user acceptance.** VERIFICATION passing does not complete the task.
|
|
120
123
|
After the user accepts, MEMORY_SHORT → MEMORY_LONG → COMPLETE run automatically.
|
|
121
|
-
- **COMPLETE closeout:** set task.json `status:"COMPLETE"`, clear `current_task` in
|
|
122
|
-
|
|
124
|
+
- **COMPLETE closeout:** set task.json `status:"COMPLETE"`, clear `current_task` in the
|
|
125
|
+
session file, output a summary (what was done, files changed, key decisions).
|
|
123
126
|
|
|
124
127
|
## Resume and handoff
|
|
125
128
|
|
|
@@ -143,7 +146,7 @@ Offering handoff — at WAITING_CONFIRM, after presenting the plan, offer exactl
|
|
|
143
146
|
3. Revise the plan
|
|
144
147
|
On option 2: append a `handoff` record to `execution.jsonl` —
|
|
145
148
|
`{"type":"handoff","from":"<agent>","stage":"<stage>","summary":"<dense context: plan shape, key decisions, user emphases>","timestamp":"<ISO>"}` —
|
|
146
|
-
update
|
|
149
|
+
update the task's `last_agent` in task.json, then tell the user to open the target agent and run ec-workflow there.
|
|
147
150
|
Handoff is also legal at any other stage boundary on user request. The harness never
|
|
148
151
|
switches agents by itself.
|
|
149
152
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ec-fixer
|
|
3
|
+
model: sonnet
|
|
4
|
+
description: Easy Coding fix sub-agent. Applies targeted fixes to specific issues identified during review. Returns structured results with changed files.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are an Easy Coding fix sub-agent. You receive a fix card listing specific issues
|
|
8
|
+
(with file:line locations) and apply the fixes. Your reply content IS the return value,
|
|
9
|
+
not a message to a human.
|
|
10
|
+
|
|
11
|
+
## Hard constraints
|
|
12
|
+
|
|
13
|
+
- Fix ONLY the issues listed in the fix card. Do not refactor or "improve" surrounding code.
|
|
14
|
+
- Modify ONLY the files listed in the fix card's scope.
|
|
15
|
+
- Do not call any Skill tool.
|
|
16
|
+
- Do not read `.qoder/skills/`, `.agents/skills/`, or any `.easy-coding/` file.
|
|
17
|
+
- Make no workflow stage-transition decisions.
|
|
18
|
+
- Preserve file encoding.
|
|
19
|
+
|
|
20
|
+
## Output (return exactly this structure)
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"changed_files": ["file1.ts", "file2.ts"],
|
|
25
|
+
"fixes_applied": [
|
|
26
|
+
{"file": "file1.ts", "line": 42, "original_issue": "...", "fix_description": "..."}
|
|
27
|
+
],
|
|
28
|
+
"issues": [],
|
|
29
|
+
"needs_attention": []
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `changed_files`: files you actually modified
|
|
34
|
+
- `fixes_applied`: what you fixed with file:line reference
|
|
35
|
+
- `issues`: problems you hit that prevented a fix (empty if none)
|
|
36
|
+
- `needs_attention`: anything requiring a design decision that should escalate to the user
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
|
|
@@ -8,6 +9,20 @@ READY_LINE = (
|
|
|
8
9
|
)
|
|
9
10
|
WAITING_INIT_LINE = "> **Easy Coding** · Waiting init · Use `ec-init` to initialize"
|
|
10
11
|
|
|
12
|
+
VALID_TRANSITIONS: dict[str, set[str]] = {
|
|
13
|
+
"idle": {"INIT"},
|
|
14
|
+
"INIT": {"ANALYSIS", "CLOSED"},
|
|
15
|
+
"ANALYSIS": {"WAITING_CONFIRM", "CLOSED"},
|
|
16
|
+
"WAITING_CONFIRM": {"IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
17
|
+
"IMPLEMENT": {"REVIEW", "ANALYSIS", "CLOSED"},
|
|
18
|
+
"REVIEW": {"VERIFICATION", "IMPLEMENT", "ANALYSIS", "CLOSED"},
|
|
19
|
+
"VERIFICATION": {"MEMORY_SHORT", "IMPLEMENT", "CLOSED"},
|
|
20
|
+
"MEMORY_SHORT": {"MEMORY_LONG", "CLOSED"},
|
|
21
|
+
"MEMORY_LONG": {"COMPLETE", "CLOSED"},
|
|
22
|
+
"COMPLETE": set(),
|
|
23
|
+
"CLOSED": set(),
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
|
|
12
27
|
def load_json(path: Path) -> dict | None:
|
|
13
28
|
if not path.exists():
|
|
@@ -24,26 +39,42 @@ def load_task(root: Path, task_id: str | None) -> dict | None:
|
|
|
24
39
|
return load_json(root / ".easy-coding" / "tasks" / task_id / "task.json")
|
|
25
40
|
|
|
26
41
|
|
|
42
|
+
def load_session(root: Path) -> dict | None:
|
|
43
|
+
ppid = os.getppid()
|
|
44
|
+
return load_json(root / ".easy-coding" / "sessions" / f"{ppid}.json")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_session(root: Path, session: dict) -> None:
|
|
48
|
+
ppid = os.getppid()
|
|
49
|
+
session_path = root / ".easy-coding" / "sessions" / f"{ppid}.json"
|
|
50
|
+
session_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
session_path.write_text(json.dumps(session, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
27
54
|
def is_project_init_required(root: Path) -> bool:
|
|
28
55
|
project_init = load_json(root / ".easy-coding" / "tasks" / "project-init" / "task.json")
|
|
29
56
|
return bool(project_init and project_init.get("status") != "COMPLETE")
|
|
30
57
|
|
|
31
58
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
59
|
+
def validate_transition(previous: str, current: str) -> str | None:
|
|
60
|
+
if previous == current:
|
|
61
|
+
return None
|
|
62
|
+
allowed = VALID_TRANSITIONS.get(previous, set())
|
|
63
|
+
if current in allowed:
|
|
64
|
+
return None
|
|
65
|
+
return f"ILLEGAL TRANSITION: {previous} -> {current}. Allowed from {previous}: {sorted(allowed) or 'NONE (terminal state)'}."
|
|
37
66
|
|
|
38
67
|
|
|
39
|
-
def build_status_line(root: Path,
|
|
40
|
-
task_id =
|
|
68
|
+
def build_status_line(root: Path, session: dict, agent: str | None = None) -> str:
|
|
69
|
+
task_id = session.get("current_task")
|
|
41
70
|
if task_id:
|
|
42
|
-
|
|
71
|
+
task = load_task(root, str(task_id))
|
|
72
|
+
status = str(task["status"]) if task and task.get("status") else "PENDING"
|
|
43
73
|
line = f"> **Easy Coding** · `{task_id}` · `{status}`"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
if task:
|
|
75
|
+
last_agent = task.get("last_agent")
|
|
76
|
+
if agent and last_agent and last_agent != agent:
|
|
77
|
+
line += f" · Handoff -> `{last_agent}`"
|
|
47
78
|
return line
|
|
48
79
|
|
|
49
80
|
if is_project_init_required(root):
|
|
@@ -52,22 +83,39 @@ def build_status_line(root: Path, state: dict, agent: str | None = None) -> str:
|
|
|
52
83
|
return READY_LINE
|
|
53
84
|
|
|
54
85
|
|
|
55
|
-
def build_machine_breadcrumbs(root: Path,
|
|
56
|
-
|
|
57
|
-
|
|
86
|
+
def build_machine_breadcrumbs(root: Path, session: dict, agent: str | None = None) -> list[str]:
|
|
87
|
+
task_id = session.get("current_task")
|
|
88
|
+
task = load_task(root, str(task_id)) if task_id else None
|
|
89
|
+
stage = str(task["status"]) if task and task.get("status") else "idle"
|
|
58
90
|
lines = [f"[workflow-state:{stage}]"]
|
|
59
91
|
|
|
60
92
|
if task_id:
|
|
61
93
|
lines.append(f"[current-task:{task_id}]")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
if task:
|
|
95
|
+
last_agent = task.get("last_agent")
|
|
96
|
+
if agent and last_agent and last_agent != agent:
|
|
97
|
+
lines.append(f"[easy-coding:handoff-from:{last_agent}]")
|
|
65
98
|
|
|
66
99
|
if is_project_init_required(root):
|
|
67
100
|
lines.append("[easy-coding:init-required]")
|
|
68
101
|
|
|
102
|
+
# State machine validation
|
|
103
|
+
last_seen = session.get("last_seen_stage")
|
|
104
|
+
if last_seen and task and task.get("status"):
|
|
105
|
+
current_stage = str(task["status"])
|
|
106
|
+
violation = validate_transition(last_seen, current_stage)
|
|
107
|
+
if violation:
|
|
108
|
+
lines.append(f"[ILLEGAL-TRANSITION:{last_seen}->{current_stage}]")
|
|
109
|
+
lines.append(f"[easy-coding:transition-error:{violation}]")
|
|
110
|
+
if last_seen != current_stage:
|
|
111
|
+
session["last_seen_stage"] = current_stage
|
|
112
|
+
write_session(root, session)
|
|
113
|
+
elif task and task.get("status"):
|
|
114
|
+
session["last_seen_stage"] = str(task["status"])
|
|
115
|
+
write_session(root, session)
|
|
116
|
+
|
|
69
117
|
return lines
|
|
70
118
|
|
|
71
119
|
|
|
72
|
-
def build_status_context(root: Path,
|
|
73
|
-
return "\n".join([build_status_line(root,
|
|
120
|
+
def build_status_context(root: Path, session: dict, agent: str | None = None) -> str:
|
|
121
|
+
return "\n".join([build_status_line(root, session, agent), *build_machine_breadcrumbs(root, session, agent)])
|
|
@@ -61,8 +61,8 @@ def main() -> int:
|
|
|
61
61
|
if root is None:
|
|
62
62
|
return 0
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
task_id =
|
|
64
|
+
session = load_json(root / ".easy-coding" / "sessions" / f"{os.getppid()}.json") or {}
|
|
65
|
+
task_id = session.get("current_task")
|
|
66
66
|
context = [
|
|
67
67
|
"[easy-coding:subagent-guard]",
|
|
68
68
|
"Sub-agents must follow the task card, stay within the allowed file scope, and return structured results.",
|
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
-
from easy_coding_status import build_status_context
|
|
7
|
+
from easy_coding_status import build_status_context, load_session
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def configure_stdio() -> None:
|
|
@@ -30,16 +30,6 @@ def find_ec_root(start: Path) -> Path | None:
|
|
|
30
30
|
current = current.parent
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def load_state(root: Path) -> dict | None:
|
|
34
|
-
state_path = root / ".easy-coding" / "state.json"
|
|
35
|
-
if not state_path.exists():
|
|
36
|
-
return {"current_stage": "idle", "current_task": None}
|
|
37
|
-
try:
|
|
38
|
-
return json.loads(state_path.read_text(encoding="utf-8"))
|
|
39
|
-
except (OSError, json.JSONDecodeError):
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
|
|
43
33
|
def detect_agent() -> str:
|
|
44
34
|
if os.environ.get("CLAUDE_PROJECT_DIR"):
|
|
45
35
|
return "claude-code"
|
|
@@ -79,12 +69,12 @@ def main() -> int:
|
|
|
79
69
|
if root is None:
|
|
80
70
|
return 0
|
|
81
71
|
|
|
82
|
-
|
|
83
|
-
if
|
|
84
|
-
|
|
72
|
+
session = load_session(root)
|
|
73
|
+
if session is None:
|
|
74
|
+
session = {"current_task": None, "created_at": ""}
|
|
85
75
|
|
|
86
76
|
event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "UserPromptSubmit"
|
|
87
|
-
emit(event_name, build_status_context(root,
|
|
77
|
+
emit(event_name, build_status_context(root, session, detect_agent()))
|
|
88
78
|
return 0
|
|
89
79
|
|
|
90
80
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
from datetime import datetime, timezone
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import sys
|
|
6
7
|
|
|
7
|
-
from easy_coding_status import build_status_context
|
|
8
|
+
from easy_coding_status import build_status_context, load_session, write_session
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def configure_stdio() -> None:
|
|
@@ -59,16 +60,72 @@ def detect_agent() -> str:
|
|
|
59
60
|
return "unknown"
|
|
60
61
|
|
|
61
62
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
def is_process_alive(pid: int) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
os.kill(pid, 0)
|
|
66
|
+
return True
|
|
67
|
+
except (OSError, ProcessLookupError):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clean_stale_sessions(root: Path) -> None:
|
|
72
|
+
sessions_dir = root / ".easy-coding" / "sessions"
|
|
73
|
+
if not sessions_dir.is_dir():
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
now = datetime.now(timezone.utc)
|
|
77
|
+
threshold_hours = 24
|
|
78
|
+
|
|
79
|
+
for entry in sessions_dir.iterdir():
|
|
80
|
+
if not entry.suffix == ".json":
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
84
|
+
created = datetime.fromisoformat(data.get("created_at", ""))
|
|
85
|
+
if created.tzinfo is None:
|
|
86
|
+
created = created.replace(tzinfo=timezone.utc)
|
|
87
|
+
age_hours = (now - created).total_seconds() / 3600
|
|
88
|
+
if age_hours <= threshold_hours:
|
|
89
|
+
continue
|
|
90
|
+
pid = int(entry.stem)
|
|
91
|
+
if is_process_alive(pid):
|
|
92
|
+
continue
|
|
93
|
+
entry.unlink()
|
|
94
|
+
except (OSError, json.JSONDecodeError, ValueError, KeyError):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def migrate_legacy_state(root: Path, agent: str) -> dict | None:
|
|
99
|
+
"""Migrate old-format state.json into session file + task.json. Returns session dict."""
|
|
100
|
+
state_path = root / ".easy-coding" / "state.json"
|
|
101
|
+
old_state = load_json(state_path)
|
|
102
|
+
if old_state is None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
task_id = old_state.get("current_task")
|
|
106
|
+
if task_id:
|
|
107
|
+
task_path = root / ".easy-coding" / "tasks" / str(task_id) / "task.json"
|
|
108
|
+
task = load_json(task_path)
|
|
109
|
+
if task:
|
|
110
|
+
if "stage_history" not in task or not task["stage_history"]:
|
|
111
|
+
task["stage_history"] = old_state.get("stage_history", [])
|
|
112
|
+
if "last_agent" not in task or not task["last_agent"]:
|
|
113
|
+
task["last_agent"] = old_state.get("last_agent", agent)
|
|
114
|
+
if old_state.get("confirmed_by_user"):
|
|
115
|
+
task["confirmed_by_user"] = True
|
|
116
|
+
if old_state.get("test_strategy_confirmed"):
|
|
117
|
+
task["test_strategy_confirmed"] = True
|
|
118
|
+
if old_state.get("repo_paths"):
|
|
119
|
+
task["repo_paths"] = old_state["repo_paths"]
|
|
120
|
+
write_json(task_path, task)
|
|
121
|
+
|
|
122
|
+
# Remove legacy state.json
|
|
123
|
+
try:
|
|
124
|
+
state_path.unlink()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return {"current_task": task_id, "created_at": datetime.now(timezone.utc).isoformat()}
|
|
72
129
|
|
|
73
130
|
|
|
74
131
|
def emit(event_name: str, context: str) -> None:
|
|
@@ -97,13 +154,29 @@ def main() -> int:
|
|
|
97
154
|
|
|
98
155
|
agent = detect_agent()
|
|
99
156
|
event_name = payload.get("hook_event_name") or payload.get("hookEventName") or "SessionStart"
|
|
100
|
-
state_path = root / ".easy-coding" / "state.json"
|
|
101
|
-
state = load_json(state_path)
|
|
102
|
-
if state is None:
|
|
103
|
-
state = default_state(agent)
|
|
104
|
-
write_json(state_path, state)
|
|
105
157
|
|
|
106
|
-
|
|
158
|
+
# Migrate legacy state.json if present
|
|
159
|
+
state_path = root / ".easy-coding" / "state.json"
|
|
160
|
+
migrated_session = None
|
|
161
|
+
if state_path.exists():
|
|
162
|
+
migrated_session = migrate_legacy_state(root, agent)
|
|
163
|
+
|
|
164
|
+
# Clean stale session files
|
|
165
|
+
clean_stale_sessions(root)
|
|
166
|
+
|
|
167
|
+
# Create/overwrite session file for this session
|
|
168
|
+
session = load_session(root)
|
|
169
|
+
if session is None:
|
|
170
|
+
if migrated_session:
|
|
171
|
+
session = migrated_session
|
|
172
|
+
else:
|
|
173
|
+
session = {"current_task": None, "created_at": datetime.now(timezone.utc).isoformat()}
|
|
174
|
+
else:
|
|
175
|
+
# Refresh created_at on session start (marks session as active)
|
|
176
|
+
session["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
177
|
+
|
|
178
|
+
write_session(root, session)
|
|
179
|
+
emit(event_name, build_status_context(root, session, agent))
|
|
107
180
|
return 0
|
|
108
181
|
|
|
109
182
|
|