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.
@@ -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 (mandatory when strategy = parallel)
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` → you MUST use sub-agents, one per unit, batched by `parallel_groups` level.
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:** keep at most 10 short memories. When the count exceeds 10, keep the
36
- latest 5; the rest become long-term distillation candidates (do not delete them here — the
37
- MEMORY_LONG retirement check decides their fate).
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 flow:
47
- 1. Read the `target_long` of out-of-window short memories; route to business/technical.
48
- 2. **Progressive loading** — read only the existing long entries matching this round's
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
- 3. **Conflict resolution** by priority: current code > latest user confirmation > this
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
- 4. **Retirement check** (triggered when short memories >= 10): for older entries decide
54
- delete (no value) / merge (semantic duplicate) / deprecate (was valid, now superseded).
55
- 5. Update the `MEMORY.md` index to reflect every change.
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. Emit a numbered fix list, return
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 (conditional)
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
- Count distinct `changed_files` across all `result` records. If >= 5, run multi-dimension
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 merges
52
- and dedups findings and decides the verdict — sub-agents cannot trigger stage transitions.
53
- Platform spawn rule: {{platform_spawn_instruction}}
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
- If < 5 changed files, the main agent reviews directly.
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
- Start these three concurrently — do not serialize:
30
- 1. lint (eslint/biome/project linter)
31
- 2. typecheck (`tsc --noEmit` or equivalent)
32
- 3. test (project test command)
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
- Use sub-agents or background commands; a verification sub-agent just runs a command and
35
- reports. Platform spawn rule: {{platform_spawn_instruction}}
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 `{{workflow_state_path}}`, then decide based on
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 state.json.
90
- 3. Set `current_stage` to task B's `status` (from task B's `task.json`).
91
- 4. Append a `{stage, agent, entered_at}` entry to `stage_history`.
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 `{{workflow_state_path}}` immediately (not at turn end):
109
- set `current_stage`, append `{stage, agent, entered_at}` to `stage_history`, 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
- state.json, output a summary (what was done, files changed, key decisions).
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 state.json, then tell the user to open the target agent and run ec-workflow there.
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 resolve_task_status(root: Path, state: dict, task_id: str | None) -> str:
33
- task = load_task(root, task_id)
34
- if task and task.get("status"):
35
- return str(task["status"])
36
- return str(state.get("current_stage") or "idle")
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, state: dict, agent: str | None = None) -> str:
40
- task_id = state.get("current_task")
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
- status = resolve_task_status(root, state, str(task_id))
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
- last_agent = state.get("last_agent")
45
- if agent and last_agent and last_agent != agent:
46
- line += f" · Handoff -> `{last_agent}`"
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, state: dict, agent: str | None = None) -> list[str]:
56
- stage = state.get("current_stage") or "idle"
57
- task_id = state.get("current_task")
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
- last_agent = state.get("last_agent")
63
- if agent and last_agent and last_agent != agent:
64
- lines.append(f"[easy-coding:handoff-from:{last_agent}]")
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, state: dict, agent: str | None = None) -> str:
73
- return "\n".join([build_status_line(root, state, agent), *build_machine_breadcrumbs(root, state, agent)])
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
- state = load_json(root / ".easy-coding" / "state.json") or {}
65
- task_id = state.get("current_task")
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
- state = load_state(root)
83
- if state is None:
84
- return 0
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, state, detect_agent()))
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 default_state(agent: str) -> dict:
63
- return {
64
- "current_stage": "idle",
65
- "current_task": None,
66
- "last_agent": agent,
67
- "stage_history": [],
68
- "confirmed_by_user": False,
69
- "test_strategy_confirmed": False,
70
- "repo_paths": {},
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
- emit(event_name, build_status_context(root, state, agent))
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