easy-coding-harness 0.10.0-beta.5 → 0.10.0-beta.6

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 CHANGED
@@ -6,6 +6,22 @@
6
6
  - `y`:常规功能升级
7
7
  - `z`:日常 bug 修复
8
8
 
9
+ ## 0.10.0-beta.6
10
+
11
+ - Canonical Spec 首次路由新增 `inspect-dev-spec --manifest-only`:只通过 normalized remote
12
+ 识别当前 worktree、展示任务目录和共享 execution 状态,不再为未选仓库解析本地路径或
13
+ 为任何未选任务计算 baseline;`path_hint` 明确降级为提示信息。
14
+ - 用户选定 task 后,`inspect-dev-spec --spec-task ...` 只检查所选任务所属仓库及其
15
+ change/test 范围;当前 worktree remote 唯一匹配时无需手工传 `--repo-path`,即使旧
16
+ `path_hint` 指向仍存在的原 checkout,也优先绑定当前 worktree。
17
+ - `ec-workflow` 不再在路由阶段提前读取消费闭包;`ec-analysis` 负责唯一一次精确 selector
18
+ 调用,并对 `exact` / `scope-unchanged` 使用快速投影,对 `scope-drifted` 只分析所选任务
19
+ 的漂移文件和符号,派生 dev-spec 不再被解释为第二轮 Spec 创作。
20
+ - 共享 `EDS:EXECUTION` 固化为依赖事实来源:已完成 hard task 或已满足依赖边直接放行;
21
+ 禁止通过另一个本地 Harness task、Git 历史或 Agent 推断重复考古完成状态。
22
+ - 保持项目外原始 Spec absolute locator、身份校验 rebind、共享 writer/CAS 及旧版全量
23
+ `inspect-dev-spec` 调用兼容;新增 worktree、未选仓库隔离和三平台安装产物回归测试。
24
+
9
25
  ## 0.10.0-beta.5
10
26
 
11
27
  - 兼容新版 `easy-dev-spec/v1` 共享执行区:同步设计/整文双摘要与 execution 投影协议,新增
package/README.md CHANGED
@@ -101,15 +101,19 @@ any stage --[user abort via ec-task-close]--> CLOSED
101
101
 
102
102
  Harness 可选择性消费 easy-dev-spec 生成的单文件 `easy-dev-spec/v1` Canonical Spec:
103
103
 
104
- 1. `ec-workflow` 先只读检查 manifest、仓库、任务 DAG、依赖、baseline 与共享 execution;
105
- Spec 仍可只读检查,但成为可执行任务前必须初始化共享执行区。
106
- 2. 用户明确选择一个或多个 Spec task;`select-dev-spec-scope` 按仓库提取确定性消费
107
- 闭包,Harness 不默认导入整份 Spec,也不会读取未选任务正文。
108
- 3. 一次选择创建一个 Harness task;ANALYSIS 使用最终 producer READY 门禁,并将
109
- selected task 映射为带 `repo_id`、`source_task_id`、source steps、文件、符号和测试
110
- 命令的 Unit
111
- 4. `hard` 依赖决定执行顺序,冻结的 `contract` 依赖允许并行编码,`integration` 依赖
112
- 在证据闭合前阻止全链路完成。
104
+ 1. `ec-workflow` 先用 `inspect-dev-spec --manifest-only` 只读校验 manifest,通过 normalized
105
+ remote 识别当前 worktree,并展示任务 DAG 与共享 execution;不会解析未选仓库路径,也
106
+ 不在选择前计算任何任务的 baseline。旧 Spec 仍可只读检查,但成为可执行任务前必须
107
+ 初始化共享执行区。
108
+ 2. 用户明确选择一个或多个 Spec task 后,Harness `--spec-task` 只检查所选任务仓库和
109
+ change/test 范围。当前 worktree remote 唯一匹配时无需手工路径;`path_hint` 不一致只会
110
+ 形成一次运行时映射提示,不会复制或修复原 Spec
111
+ 3. 一次选择创建一个 Harness task;ANALYSIS 唯一一次调用 `select-dev-spec-scope` 提取确定性
112
+ 消费闭包。`exact` / `scope-unchanged` 直接快速投影为 Unit、测试策略和派生 dev-spec,
113
+ `scope-drifted` 才读取所选范围内的漂移文件与符号;未选任务正文始终不进入上下文。
114
+ 4. `EDS:EXECUTION` 是依赖事实来源:`hard` 依赖决定执行顺序,冻结的 `contract` 依赖允许
115
+ 并行编码,`integration` 依赖在证据闭合前阻止全链路完成;不会再从本地任务或 Git 历史
116
+ 重复推断共享状态。
113
117
 
114
118
  Canonical Spec 的静态设计由 design revision + `design_sha256` 冻结;共享
115
119
  `EDS:EXECUTION` 则接收 Harness 的 Task/Step/dependency 投影。写回使用
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-coding-harness",
3
- "version": "0.10.0-beta.5",
3
+ "version": "0.10.0-beta.6",
4
4
  "description": "CLI scaffold for installing Easy Coding harness files into agent-native directories.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,15 +21,18 @@ Communicate with the user in the user's language.
21
21
  4. Inspect concrete code paths and tests. Expand context only when evidence reveals another
22
22
  dependency or risk.
23
23
 
24
- For a task with `task.json.spec_source`, re-run `inspect-dev-spec` against the stored source and
25
- every stored `task.repo_paths` repository binding. Schema, Spec ID, design revision, and
26
- `design_sha256` must still match. A changed `document_sha256` with the same design is normal
27
- shared progress; refresh `execution_revision` without invalidating plan/review/verify evidence.
28
- An execution revision rollback is blocking. Then call the selector for the exact selection:
24
+ For a task with `task.json.spec_source`, re-run `inspect-dev-spec` against the stored source, exact
25
+ `selected_spec_tasks`, and only their stored `task.repo_paths` bindings. Schema, Spec ID, design
26
+ revision, and `design_sha256` must still match. A changed `document_sha256` with the same design is
27
+ normal shared progress; refresh `execution_revision` without invalidating plan/review/verify
28
+ evidence. An execution revision rollback is blocking. Then call the selector once for the exact
29
+ selection:
29
30
 
30
31
  ```bash
31
32
  {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py inspect-dev-spec \
32
- --spec <stored-source> --repo-path <repo-id>=<stored-path> [--repo-path <repo-id>=<stored-path>]...
33
+ --spec <stored-source> \
34
+ --spec-task <selected-task-id> [--spec-task <selected-task-id>]... \
35
+ [--repo-path <repo-id>=<stored-path>]...
33
36
 
34
37
  {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py select-dev-spec-scope \
35
38
  --spec <stored-source> --spec-task <selected-task-id> [--spec-task <selected-task-id>]...
@@ -38,8 +41,20 @@ An execution revision rollback is blocking. Then call the selector for the exact
38
41
  Load only the returned per-repository consumption closures: manifest/global context, selected
39
42
  task and repository sections, related contracts, direct dependency summaries, selected
40
43
  changes/steps/tests, and relevant integration rows. Never replace the selector with a whole-file
41
- read. `scope-drifted` requires current-code conflict analysis before confirmation;
42
- `baseline-unavailable` or unresolved repository identity remains blocked in ANALYSIS.
44
+ read. Treat the Canonical closure as frozen design, not as a prompt to design the task again:
45
+
46
+ - `exact` and `scope-unchanged` use the fast projection path. Confirm the selected paths, symbols,
47
+ and test entry points, then map the closure into Units, `test-strategy.md`, and the derived
48
+ `dev-spec.md` in one pass. Do not reselect interfaces, fields, task boundaries, Steps, or Tests,
49
+ and do not ask questions already answered by the source Spec.
50
+ - `scope-drifted` reads and analyzes only changed files and symbols in the selected task scope.
51
+ Escalate to a static design revision only when that evidence changes a frozen contract or task
52
+ boundary; unrelated repository or completed-task changes are background, not current drift.
53
+ - `baseline-unavailable` or unresolved selected-repository identity remains blocked in ANALYSIS.
54
+
55
+ Use shared execution dependency status directly. Do not inspect another local Harness task or Git
56
+ history to re-prove a completed hard dependency, and do not repeat dependency or baseline
57
+ explanations after the selected inspection has recorded them.
43
58
 
44
59
  ## Analysis artifacts
45
60
 
@@ -107,6 +122,10 @@ digest, current document digest/execution revision/writeback status, selected ta
107
122
  and repositories, baseline/conflict result, Unit mapping, source test mapping, and pending
108
123
  integration edges.
109
124
 
125
+ The required skeleton is a mechanical artifact schema. For a Canonical-backed task it is filled
126
+ from the selected closure and current-code delta; it must never become a second round of Spec
127
+ authoring.
128
+
110
129
  Every source test command remains mandatory. Additional commands from the current repository are
111
130
  allowed only when `test-strategy.md` records why the Canonical command alone is insufficient.
112
131
  For every selected source test, `test-strategy.md` must spell out its Test ID, source task ID,
@@ -61,36 +61,52 @@ plan decision; Auto continues immediately. Both remove later waiting, not qualit
61
61
  - Present with `status != "COMPLETE"`: invoke `{{skill_trigger}}ec-init`, then stop.
62
62
  - `[easy-coding:upgrade-init-pending:X]`: recommend `{{skill_trigger}}ec-init` for vX
63
63
  adaptation, but allow the user to continue; this reminder is not a workflow block.
64
- 3. When the user explicitly references a Dev-Spec, run the read-only `inspect-dev-spec` command
65
- before ordinary task creation. For `protocol=canonical-v1`, show every task ID, repository,
66
- title, dependency, and baseline status; never select all tasks by default. After the user
67
- chooses one or more tasks and resolves repository paths or omitted hard-dependency evidence,
68
- call `create-task-from-spec` once for the complete selection. A document without a Canonical
69
- manifest remains a legacy ANALYSIS input for an ordinary task. A malformed, DRAFT, or otherwise
70
- non-READY Canonical Spec stays blocked and must never be downgraded to the legacy route. A
71
- READY Canonical Spec without shared execution remains readable, but run
72
- `initialize-spec-execution` before selection can become an executable Harness task.
64
+ 3. When the user explicitly references a Dev-Spec, first run read-only `inspect-dev-spec
65
+ --manifest-only`. This routing pass validates the document, identifies the current worktree by
66
+ normalized remote, and returns the task catalog without resolving unrelated repositories. For
67
+ `protocol=canonical-v1`, show every task ID, repository, title, static status, actual execution
68
+ status, and dependency; never select all tasks by default and never calculate baseline drift
69
+ before selection.
70
+
71
+ After the user chooses one or more tasks, run one selected inspection with repeated
72
+ `--spec-task`. Resolve paths only for repositories that own selected tasks. The current
73
+ worktree needs no explicit mapping when its normalized remote matches uniquely; pass
74
+ `--repo-path` only for an additional selected repository or to confirm an ambiguous current
75
+ match. A differing `path_hint` is a one-time runtime mapping notice, not a Spec portability
76
+ failure: never copy, mirror, or rewrite the source Spec because of it.
77
+
78
+ Then call `create-task-from-spec` once for the complete selection. Do not call
79
+ `select-dev-spec-scope` during routing; `ec-analysis` owns the single consumption-closure read
80
+ after task creation. A document without a Canonical manifest remains a legacy ANALYSIS input
81
+ for an ordinary task. A malformed, DRAFT, or otherwise non-READY Canonical Spec stays blocked
82
+ and must never be downgraded to the legacy route. A READY Canonical Spec without shared
83
+ execution remains readable, but run `initialize-spec-execution` before selection can become an
84
+ executable Harness task.
73
85
 
74
86
  ```bash
75
87
  {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py inspect-dev-spec \
76
- --spec <path> [--repo-path <repo-id>=<path>]...
88
+ --spec <path> --manifest-only
89
+
90
+ {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py inspect-dev-spec \
91
+ --spec <path> --spec-task <task-id> [--spec-task <task-id>]... \
92
+ [--repo-path <repo-id>=<path>]...
77
93
 
78
94
  {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py initialize-spec-execution \
79
95
  --spec <path>
80
96
 
81
- {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py select-dev-spec-scope \
82
- --spec <path> --spec-task <task-id> [--spec-task <task-id>]...
83
-
84
97
  {{PYTHON_CMD}} {{platform_config_dir}}/hooks/easy_coding_state.py create-task-from-spec \
85
98
  --spec <path> --spec-task <task-id> [--spec-task <task-id>]... \
86
99
  --task-id <harness-task-id> --type <type> --title <title> \
87
- --repo-path <repo-id>=<path> [--dependency-evidence <dependency-id>=<evidence>]... \
100
+ [--repo-path <repo-id>=<path>]... \
101
+ [--dependency-evidence <dependency-id>=<evidence>]... \
88
102
  --agent <agent-id> --session-file <P>
89
103
  ```
90
104
 
91
- `select-dev-spec-scope` is read-only and may run only after explicit task selection. Its
92
- per-repository payload is the authoritative ANALYSIS context; do not load unselected task
93
- bodies from the source document.
105
+ Shared `EDS:EXECUTION` is the dependency fact source. Accept a completed hard dependency or a
106
+ satisfied edge directly from that snapshot. Only accept manual dependency evidence when the
107
+ shared edge is still pending and the user explicitly supplies independently verifiable
108
+ evidence; never reconstruct completion from another local Harness task, Git history, or an
109
+ agent's inference.
94
110
 
95
111
  When multiple selected tasks depend on the same target, disambiguate creation evidence with
96
112
  `<source-task-id>-><dependency-task-id>=<evidence>`.
@@ -92,6 +92,11 @@ First run `ec-init`; daily work goes through `ec-workflow`.
92
92
  external explicit Spec paths are allowed and may be repaired only with identity-checked rebind.
93
93
  Runtime progress must use the shared writer with CAS/idempotency and reconciliation; static
94
94
  design changes require revision + READY + `sync-spec-design`. Never hand-edit `EDS:EXECUTION`.
95
+ - Canonical routing is two-pass: first use manifest-only discovery for the current worktree, then
96
+ inspect only the explicitly selected task IDs and repositories. A remote-confirmed worktree
97
+ overrides a stale `path_hint`; never mirror the source Spec or re-check unselected repositories.
98
+ ANALYSIS reads the selected consumption closure once and treats exact/scope-unchanged as a fast
99
+ projection, while shared execution is the dependency fact source.
95
100
  - MEMORY combines short-memory creation and the conditional long-memory gate. Entry follows the
96
101
  effective confirmation mode; once memory processing completes, COMPLETE is automatic.
97
102
  - NO CODE-TASK COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE.
@@ -90,6 +90,11 @@ First run `/ec-init`; daily work goes through `/ec-workflow`.
90
90
  external explicit Spec paths are allowed and may be repaired only with identity-checked rebind.
91
91
  Runtime progress must use the shared writer with CAS/idempotency and reconciliation; static
92
92
  design changes require revision + READY + `sync-spec-design`. Never hand-edit `EDS:EXECUTION`.
93
+ - Canonical routing is two-pass: first use manifest-only discovery for the current worktree, then
94
+ inspect only the explicitly selected task IDs and repositories. A remote-confirmed worktree
95
+ overrides a stale `path_hint`; never mirror the source Spec or re-check unselected repositories.
96
+ ANALYSIS reads the selected consumption closure once and treats exact/scope-unchanged as a fast
97
+ projection, while shared execution is the dependency fact source.
93
98
  - MEMORY combines short-memory creation and the conditional long-memory gate. Entry follows the
94
99
  effective confirmation mode; once memory processing completes, COMPLETE is automatic.
95
100
  - NO CODE-TASK COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE.
@@ -16,6 +16,7 @@ import sys
16
16
 
17
17
  from easy_dev_spec import (
18
18
  EasyDevSpecError,
19
+ inspect_manifest,
19
20
  inspect_spec,
20
21
  inspection_summary,
21
22
  select_consumption_scopes,
@@ -1754,8 +1755,16 @@ def inspect_task_spec(root: Path, task: dict) -> tuple[dict, dict]:
1754
1755
  if stored.get(field) != expected.get(field):
1755
1756
  raise StateError("Canonical Spec dependency metadata no longer matches source selection.")
1756
1757
  refreshed = dict(stored)
1757
- refreshed["status"] = expected.get("status")
1758
- refreshed["shared_status"] = expected.get("shared_status")
1758
+ for field in (
1759
+ "status",
1760
+ "shared_status",
1761
+ "dependency_task_status",
1762
+ "basis",
1763
+ ):
1764
+ if expected.get(field) is None:
1765
+ refreshed.pop(field, None)
1766
+ else:
1767
+ refreshed[field] = expected.get(field)
1759
1768
  if expected.get("evidence"):
1760
1769
  refreshed["evidence"] = expected.get("evidence")
1761
1770
  refreshed_dependencies.append(refreshed)
@@ -6735,6 +6744,8 @@ def main() -> int:
6735
6744
  inspect_spec_parser = subcommands.add_parser("inspect-dev-spec", parents=[common])
6736
6745
  inspect_spec_parser.add_argument("--spec", required=True)
6737
6746
  inspect_spec_parser.add_argument("--repo-path", action="append", default=[])
6747
+ inspect_spec_parser.add_argument("--spec-task", action="append", default=[])
6748
+ inspect_spec_parser.add_argument("--manifest-only", action="store_true")
6738
6749
 
6739
6750
  initialize_spec = subcommands.add_parser("initialize-spec-execution", parents=[common])
6740
6751
  initialize_spec.add_argument("--spec", required=True)
@@ -6756,7 +6767,7 @@ def main() -> int:
6756
6767
  create_from_spec.add_argument("--task-id", required=True)
6757
6768
  create_from_spec.add_argument("--type", required=True)
6758
6769
  create_from_spec.add_argument("--title", required=True)
6759
- create_from_spec.add_argument("--repo-path", required=True, action="append")
6770
+ create_from_spec.add_argument("--repo-path", action="append", default=[])
6760
6771
  create_from_spec.add_argument("--dependency-evidence", action="append", default=[])
6761
6772
  create_from_spec.add_argument("--agent", required=True)
6762
6773
  create_from_spec.add_argument("--no-set-current", action="store_true")
@@ -7017,15 +7028,21 @@ def main() -> int:
7017
7028
  emit(snapshot_state(root, session_file))
7018
7029
  elif command == "inspect-dev-spec":
7019
7030
  spec_path = Path(args.spec).expanduser()
7020
- emit(
7021
- inspection_summary(
7022
- inspect_spec(
7023
- spec_path if spec_path.is_absolute() else root / spec_path,
7024
- root,
7025
- parse_mapping_args(args.repo_path, "--repo-path"),
7026
- )
7031
+ if args.manifest_only and args.spec_task:
7032
+ raise StateError("--manifest-only cannot be combined with --spec-task")
7033
+ resolved_spec = spec_path if spec_path.is_absolute() else root / spec_path
7034
+ repo_paths = parse_mapping_args(args.repo_path, "--repo-path")
7035
+ inspection = (
7036
+ inspect_manifest(resolved_spec, root, repo_paths)
7037
+ if args.manifest_only
7038
+ else inspect_spec(
7039
+ resolved_spec,
7040
+ root,
7041
+ repo_paths,
7042
+ args.spec_task or None,
7027
7043
  )
7028
7044
  )
7045
+ emit(inspection_summary(inspection))
7029
7046
  elif command == "initialize-spec-execution":
7030
7047
  emit(initialize_spec_execution_state(root, args.spec))
7031
7048
  elif command == "select-dev-spec-scope":
@@ -128,31 +128,300 @@ def portable_path(root: Path, path: Path) -> str:
128
128
  )
129
129
 
130
130
 
131
+ def _path_hint_status(root: Path, repository: dict[str, Any], repository_path: Path) -> str:
132
+ hint = Path(str(repository.get("path_hint") or ""))
133
+ if not str(hint):
134
+ return "missing"
135
+ resolved_hint = (hint if hint.is_absolute() else root / hint).resolve()
136
+ return "matched" if resolved_hint == repository_path.resolve() else "different"
137
+
138
+
131
139
  def _candidate_repository_paths(
132
140
  root: Path,
133
141
  repository: dict[str, Any],
134
142
  explicit_paths: dict[str, str],
135
- ) -> list[Path]:
143
+ ) -> list[tuple[str, Path]]:
136
144
  repo_id = str(repository["repo_id"])
137
- candidates: list[Path] = []
145
+ candidates: list[tuple[str, Path]] = []
138
146
  explicit = explicit_paths.get(repo_id)
139
147
  if explicit:
140
148
  path = Path(explicit)
141
149
  # An explicit binding is authoritative. Falling back to path_hint/root would make a
142
150
  # mistyped --repo-path appear valid while silently binding a different checkout.
143
- return [(path if path.is_absolute() else root / path).resolve()]
151
+ return [("explicit", (path if path.is_absolute() else root / path).resolve())]
152
+ candidates.append(("current-root", root))
144
153
  hint = Path(str(repository.get("path_hint") or ""))
145
154
  if str(hint):
146
- candidates.append(hint if hint.is_absolute() else root / hint)
147
- candidates.append(root)
148
- unique: list[Path] = []
149
- for candidate in candidates:
155
+ candidates.append(("path-hint", hint if hint.is_absolute() else root / hint))
156
+ unique: list[tuple[str, Path]] = []
157
+ seen: set[Path] = set()
158
+ for binding_source, candidate in candidates:
150
159
  resolved = candidate.resolve()
151
- if resolved not in unique:
152
- unique.append(resolved)
160
+ if resolved not in seen:
161
+ unique.append((binding_source, resolved))
162
+ seen.add(resolved)
153
163
  return unique
154
164
 
155
165
 
166
+ def _resolve_repository_binding(
167
+ root: Path,
168
+ repository: dict[str, Any],
169
+ explicit_paths: dict[str, str],
170
+ ) -> dict[str, Any] | None:
171
+ expected_remotes = {
172
+ normalize_remote(remote) for remote in repository["remote_urls"]
173
+ }
174
+ # 显式路径保持权威;否则当前 worktree 一旦通过 remote 验证就立即返回,不再探测旧 path_hint。
175
+ for binding_source, repository_path in _candidate_repository_paths(
176
+ root, repository, explicit_paths
177
+ ):
178
+ if not repository_path.is_dir():
179
+ continue
180
+ if not expected_remotes.intersection(repository_remotes(repository_path)):
181
+ continue
182
+ return {
183
+ "path": repository_path,
184
+ "binding_source": binding_source,
185
+ "path_hint_status": _path_hint_status(root, repository, repository_path),
186
+ }
187
+ return None
188
+
189
+
190
+ def _current_repository_match(
191
+ root: Path,
192
+ repositories: list[dict[str, Any]],
193
+ explicit_paths: dict[str, str],
194
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
195
+ resolved_root = root.resolve()
196
+ repository_by_id = {
197
+ str(repository["repo_id"]): repository for repository in repositories
198
+ }
199
+ if len(explicit_paths) > 1:
200
+ raise EasyDevSpecError(
201
+ "Manifest routing accepts at most one --repo-path for the current repository"
202
+ )
203
+ if explicit_paths:
204
+ repo_id, raw_path = next(iter(explicit_paths.items()))
205
+ repository = repository_by_id.get(repo_id)
206
+ if repository is None:
207
+ raise EasyDevSpecError(f"Unknown Canonical repository: {repo_id}")
208
+ path = Path(raw_path)
209
+ resolved_path = (path if path.is_absolute() else root / path).resolve()
210
+ if resolved_path != resolved_root:
211
+ raise EasyDevSpecError(
212
+ "Manifest routing --repo-path must identify the current Git worktree"
213
+ )
214
+ binding = _resolve_repository_binding(root, repository, explicit_paths)
215
+ if binding is None:
216
+ raise EasyDevSpecError(
217
+ f"Current worktree does not match Canonical repository {repo_id}"
218
+ )
219
+ return repository, binding
220
+
221
+ local_remotes = set(repository_remotes(resolved_root))
222
+ if not local_remotes:
223
+ raise EasyDevSpecError(
224
+ "Current worktree has no Git remote; configure a matching remote before "
225
+ "Canonical manifest routing"
226
+ )
227
+ matches = [
228
+ repository
229
+ for repository in repositories
230
+ if local_remotes.intersection(
231
+ normalize_remote(remote) for remote in repository["remote_urls"]
232
+ )
233
+ ]
234
+ if len(matches) != 1:
235
+ candidates = ", ".join(str(item["repo_id"]) for item in matches) or "none"
236
+ raise EasyDevSpecError(
237
+ "Current worktree must match exactly one Canonical repository; "
238
+ f"matched: {candidates}. Pass --repo-path <repo-id>=<current-worktree>."
239
+ )
240
+ repository = matches[0]
241
+ return repository, {
242
+ "path": resolved_root,
243
+ "binding_source": "current-root-remote",
244
+ "path_hint_status": _path_hint_status(root, repository, resolved_root),
245
+ }
246
+
247
+
248
+ def _dependency_execution_summary(
249
+ dependency: dict[str, Any],
250
+ source_snapshot: dict[str, Any],
251
+ execution_by_task: dict[str, dict[str, Any]],
252
+ manifest_status: str,
253
+ execution_available: bool,
254
+ ) -> dict[str, Any]:
255
+ dependency_id = str(dependency["task_id"])
256
+ shared_dependency = next(
257
+ (
258
+ item
259
+ for item in source_snapshot.get("dependencies", [])
260
+ if isinstance(item, dict) and item.get("task_id") == dependency_id
261
+ ),
262
+ None,
263
+ )
264
+ shared_status = (
265
+ shared_dependency.get("status")
266
+ if execution_available and isinstance(shared_dependency, dict)
267
+ else "pending"
268
+ if execution_available
269
+ else None
270
+ )
271
+ dependency_task_status = (
272
+ execution_by_task.get(dependency_id, {}).get("status", "not_started")
273
+ if execution_available
274
+ else None
275
+ )
276
+ dependency_type = str(dependency.get("type"))
277
+ if dependency_type == "contract":
278
+ status = "satisfied" if manifest_status == "READY" else "pending"
279
+ basis = "design-ready" if status == "satisfied" else "design-not-ready"
280
+ elif shared_status == "satisfied":
281
+ status = "satisfied"
282
+ basis = "recorded-evidence"
283
+ elif dependency_type == "hard" and dependency_task_status == "completed":
284
+ status = "satisfied"
285
+ basis = "dependency-task-completed"
286
+ else:
287
+ status = "pending"
288
+ basis = "pending-integration" if dependency_type == "integration" else "pending"
289
+ return {
290
+ "shared_status": shared_status,
291
+ "dependency_task_status": dependency_task_status,
292
+ "status": status,
293
+ "basis": basis,
294
+ }
295
+
296
+
297
+ def inspect_manifest(
298
+ spec_path: Path,
299
+ root: Path,
300
+ repo_paths: dict[str, str] | None = None,
301
+ ) -> dict[str, Any]:
302
+ """Return the current-repository task catalog without resolving unrelated checkouts."""
303
+
304
+ resolved_spec_path = spec_path.resolve()
305
+ if not resolved_spec_path.is_file():
306
+ raise EasyDevSpecError(f"Spec file does not exist: {resolved_spec_path}")
307
+ try:
308
+ text = resolved_spec_path.read_text(encoding="utf-8")
309
+ report = validate_spec(text)
310
+ except (OSError, UnicodeError) as exc:
311
+ raise EasyDevSpecError(
312
+ f"Cannot read Spec as UTF-8: {resolved_spec_path}: {exc}"
313
+ ) from exc
314
+ if report.protocol == "legacy":
315
+ return {
316
+ "inspection_mode": "manifest-only",
317
+ "protocol": "legacy",
318
+ "source_path": str(resolved_spec_path),
319
+ "source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
320
+ "design_sha256": report.design_sha256,
321
+ "document_sha256": report.document_sha256,
322
+ "execution_revision": None,
323
+ "execution": None,
324
+ "selection_required": False,
325
+ "selected_task_ids": [],
326
+ "task_catalog": [],
327
+ "matched_repo_paths": {},
328
+ "unresolved_repositories": [],
329
+ "baseline_status": {},
330
+ "repository_bindings": [],
331
+ }
332
+ if report.protocol != "canonical-v1" or report.manifest is None or not report.ok:
333
+ details = "; ".join(
334
+ f"{issue.code}: {issue.message}" for issue in report.issues
335
+ ) or "unknown validation failure"
336
+ raise EasyDevSpecError(f"Canonical Spec validation failed: {details}")
337
+ manifest = report.manifest
338
+ repositories = manifest["repositories"]
339
+ tasks = manifest["tasks"]
340
+ repository, binding = _current_repository_match(
341
+ root, repositories, repo_paths or {}
342
+ )
343
+ current_repo_id = str(repository["repo_id"])
344
+ repository_names = {
345
+ str(item["repo_id"]): str(item["name"]) for item in repositories
346
+ }
347
+ execution = report.execution
348
+ execution_by_task = {
349
+ str(snapshot.get("task_id")): snapshot
350
+ for snapshot in execution.get("tasks", [])
351
+ if isinstance(execution, dict)
352
+ and isinstance(snapshot, dict)
353
+ and isinstance(snapshot.get("task_id"), str)
354
+ } if isinstance(execution, dict) else {}
355
+ task_catalog: list[dict[str, Any]] = []
356
+ for task in tasks:
357
+ task_id = str(task["task_id"])
358
+ source_snapshot = execution_by_task.get(task_id, {})
359
+ dependencies = [
360
+ {
361
+ **dependency,
362
+ **_dependency_execution_summary(
363
+ dependency,
364
+ source_snapshot,
365
+ execution_by_task,
366
+ str(manifest["status"]),
367
+ isinstance(execution, dict),
368
+ ),
369
+ }
370
+ for dependency in task.get("depends_on", [])
371
+ ]
372
+ task_catalog.append(
373
+ {
374
+ "task_id": task_id,
375
+ "repo_id": task["repo_id"],
376
+ "repository_name": repository_names[str(task["repo_id"])],
377
+ "title": task["title"],
378
+ "status": task["status"],
379
+ "execution_status": (
380
+ execution_by_task.get(task_id, {}).get("status", "not_started")
381
+ if isinstance(execution, dict)
382
+ else None
383
+ ),
384
+ "depends_on": dependencies,
385
+ "baseline_status": "not-inspected",
386
+ }
387
+ )
388
+ try:
389
+ source_path = portable_path(root, resolved_spec_path)
390
+ except EasyDevSpecError:
391
+ source_path = str(resolved_spec_path)
392
+ return {
393
+ "inspection_mode": "manifest-only",
394
+ "protocol": "canonical-v1",
395
+ "schema": SCHEMA,
396
+ "spec_id": manifest["spec_id"],
397
+ "revision": manifest["revision"],
398
+ "status": manifest["status"],
399
+ "title": manifest["title"],
400
+ "source_path": source_path,
401
+ "source_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
402
+ "design_sha256": report.design_sha256,
403
+ "document_sha256": report.document_sha256,
404
+ "execution_revision": (
405
+ execution.get("execution_revision") if isinstance(execution, dict) else None
406
+ ),
407
+ "execution": execution,
408
+ "repository_match": {
409
+ "repo_id": current_repo_id,
410
+ "name": repository["name"],
411
+ "path": portable_path(root, binding["path"]),
412
+ "binding_source": binding["binding_source"],
413
+ "path_hint_status": binding["path_hint_status"],
414
+ },
415
+ "task_catalog": task_catalog,
416
+ "selection_required": True,
417
+ "selected_task_ids": [],
418
+ "matched_repo_paths": {},
419
+ "unresolved_repositories": [],
420
+ "baseline_status": {},
421
+ "repository_bindings": [],
422
+ }
423
+
424
+
156
425
  def inspect_spec(
157
426
  spec_path: Path,
158
427
  root: Path,
@@ -168,6 +437,14 @@ def inspect_spec(
168
437
  task_by_id = {task["task_id"]: task for task in tasks}
169
438
  explicit_paths = repo_paths or {}
170
439
  selected_task_set = set(selected_task_ids or [])
440
+ unknown_tasks = selected_task_set - set(task_by_id)
441
+ if unknown_tasks:
442
+ raise EasyDevSpecError(
443
+ "Unknown Canonical Spec tasks: " + ", ".join(sorted(unknown_tasks))
444
+ )
445
+ selected_repo_ids = {
446
+ str(task_by_id[task_id]["repo_id"]) for task_id in selected_task_set
447
+ }
171
448
  matched_repo_paths: dict[str, str] = {}
172
449
  unresolved_repositories: list[str] = []
173
450
  baseline_status: dict[str, str] = {}
@@ -175,18 +452,15 @@ def inspect_spec(
175
452
 
176
453
  for repository in repositories:
177
454
  repo_id = str(repository["repo_id"])
178
- expected_remotes = {normalize_remote(remote) for remote in repository["remote_urls"]}
179
- matches = [
180
- candidate
181
- for candidate in _candidate_repository_paths(root, repository, explicit_paths)
182
- if candidate.is_dir() and expected_remotes.intersection(repository_remotes(candidate))
183
- ]
184
- matches = list(dict.fromkeys(matches))
185
- if len(matches) != 1:
455
+ # task 已选定后只解析其所属仓库,未选仓库不得制造路径或 baseline 阻塞。
456
+ if selected_repo_ids and repo_id not in selected_repo_ids:
457
+ continue
458
+ binding = _resolve_repository_binding(root, repository, explicit_paths)
459
+ if binding is None:
186
460
  unresolved_repositories.append(repo_id)
187
461
  baseline_status[repo_id] = "baseline-unavailable"
188
462
  continue
189
- repository_path = matches[0]
463
+ repository_path = binding["path"]
190
464
  selected_paths = [
191
465
  str(change["path"])
192
466
  for change in changes
@@ -214,6 +488,8 @@ def inspect_spec(
214
488
  "path": stored_path,
215
489
  "baseline_commit": repository["baseline"]["commit"],
216
490
  "baseline_status": status,
491
+ "binding_source": binding["binding_source"],
492
+ "path_hint_status": binding["path_hint_status"],
217
493
  }
218
494
  )
219
495
 
@@ -234,6 +510,7 @@ def inspect_spec(
234
510
  source_path = str(resolved_spec_path)
235
511
  execution = report.execution
236
512
  return {
513
+ "inspection_mode": "selected" if selected_task_set else "full",
237
514
  "protocol": "canonical-v1",
238
515
  "schema": SCHEMA,
239
516
  "spec_id": manifest["spec_id"],
@@ -255,6 +532,7 @@ def inspect_spec(
255
532
  "tests": manifest["tests"],
256
533
  "contracts": manifest["contracts"],
257
534
  "dependency_edges": dependency_edges,
535
+ "selected_task_ids": sorted(selected_task_set),
258
536
  "matched_repo_paths": matched_repo_paths,
259
537
  "unresolved_repositories": unresolved_repositories,
260
538
  "baseline_status": baseline_status,
@@ -376,29 +654,31 @@ def select_tasks(
376
654
  if evidence is None and dependency_target_counts[dependency_id] == 1:
377
655
  evidence = evidence_by_dependency.get(dependency_id)
378
656
  source_snapshot = execution_by_task.get(source_task_id, {})
379
- shared_dependency = next(
380
- (
381
- item
382
- for item in source_snapshot.get("dependencies", [])
383
- if isinstance(item, dict) and item.get("task_id") == dependency_id
384
- ),
385
- None,
657
+ execution_fact = _dependency_execution_summary(
658
+ dependency,
659
+ source_snapshot,
660
+ execution_by_task,
661
+ str(inspection.get("status")),
662
+ isinstance(execution, dict),
386
663
  )
387
- dependency_snapshot = execution_by_task.get(dependency_id, {})
388
- shared_satisfied = bool(
389
- isinstance(shared_dependency, dict)
390
- and shared_dependency.get("status") == "satisfied"
664
+ shared_satisfied = execution_fact["shared_status"] == "satisfied"
665
+ dependency_completed = (
666
+ execution_fact["dependency_task_status"] == "completed"
391
667
  )
392
- dependency_completed = dependency_snapshot.get("status") == "completed"
668
+ basis = execution_fact.get("basis")
393
669
  if dependency_type == "hard":
394
670
  satisfied = shared_satisfied or dependency_completed or bool(evidence)
671
+ if execution_fact["status"] != "satisfied" and evidence:
672
+ basis = "manual-evidence"
395
673
  if dependency_id not in selected_set and not satisfied:
396
674
  missing_hard.append(f"{source_task_id}->{dependency_id}")
397
675
  elif dependency_type == "contract":
398
- satisfied = shared_satisfied or inspection.get("status") == "READY"
676
+ satisfied = execution_fact["status"] == "satisfied"
399
677
  evidence = evidence or "canonical-spec-ready-contract"
400
678
  else:
401
679
  satisfied = shared_satisfied or bool(evidence)
680
+ if execution_fact["status"] != "satisfied" and evidence:
681
+ basis = "manual-evidence"
402
682
  dependency_records.append(
403
683
  {
404
684
  "source_task_id": source_task_id,
@@ -406,11 +686,11 @@ def select_tasks(
406
686
  "dependency_type": dependency_type,
407
687
  "required_evidence": dependency["required_evidence"],
408
688
  "status": "satisfied" if satisfied else "pending",
409
- "shared_status": (
410
- shared_dependency.get("status")
411
- if isinstance(shared_dependency, dict)
412
- else "pending"
413
- ),
689
+ "shared_status": execution_fact["shared_status"],
690
+ "dependency_task_status": execution_fact[
691
+ "dependency_task_status"
692
+ ],
693
+ "basis": basis,
414
694
  **({"evidence": evidence} if evidence else {}),
415
695
  }
416
696
  )
@@ -453,6 +733,7 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
453
733
  """Return the discovery surface without unrelated implementation routing objects."""
454
734
 
455
735
  keys = (
736
+ "inspection_mode",
456
737
  "protocol",
457
738
  "schema",
458
739
  "spec_id",
@@ -467,26 +748,84 @@ def inspection_summary(inspection: dict[str, Any]) -> dict[str, Any]:
467
748
  "repositories",
468
749
  "tasks",
469
750
  "dependency_edges",
751
+ "selected_task_ids",
752
+ "repository_match",
753
+ "task_catalog",
754
+ "selection_required",
470
755
  "matched_repo_paths",
471
756
  "unresolved_repositories",
472
757
  "baseline_status",
473
758
  "repository_bindings",
474
759
  )
475
- summary = {key: inspection[key] for key in keys}
760
+ summary = {key: inspection[key] for key in keys if key in inspection}
476
761
  execution = inspection.get("execution")
762
+ relevant_execution_task_ids: set[str] | None = None
763
+ if inspection.get("inspection_mode") == "selected":
764
+ selected_task_ids = set(inspection.get("selected_task_ids", []))
765
+ selected_tasks = [
766
+ task
767
+ for task in inspection.get("tasks", [])
768
+ if str(task.get("task_id")) in selected_task_ids
769
+ ]
770
+ selected_repo_ids = {str(task.get("repo_id")) for task in selected_tasks}
771
+ execution_by_task = {
772
+ str(task.get("task_id")): task
773
+ for task in execution.get("tasks", [])
774
+ if isinstance(execution, dict)
775
+ and isinstance(task, dict)
776
+ and isinstance(task.get("task_id"), str)
777
+ } if isinstance(execution, dict) else {}
778
+ selected_edges: list[dict[str, Any]] = []
779
+ for edge in inspection.get("dependency_edges", []):
780
+ source_task_id = str(edge.get("source_task_id"))
781
+ if source_task_id not in selected_task_ids:
782
+ continue
783
+ selected_edges.append(
784
+ {
785
+ **edge,
786
+ **_dependency_execution_summary(
787
+ {
788
+ "task_id": edge.get("task_id"),
789
+ "type": edge.get("dependency_type"),
790
+ },
791
+ execution_by_task.get(source_task_id, {}),
792
+ execution_by_task,
793
+ str(inspection.get("status")),
794
+ isinstance(execution, dict),
795
+ ),
796
+ }
797
+ )
798
+ summary["repositories"] = [
799
+ repository
800
+ for repository in inspection.get("repositories", [])
801
+ if str(repository.get("repo_id")) in selected_repo_ids
802
+ ]
803
+ summary["tasks"] = selected_tasks
804
+ summary["dependency_edges"] = selected_edges
805
+ relevant_execution_task_ids = selected_task_ids | {
806
+ str(edge.get("task_id")) for edge in selected_edges
807
+ }
477
808
  if isinstance(execution, dict):
809
+ execution_keys = [
810
+ "schema",
811
+ "spec_id",
812
+ "design_revision",
813
+ "design_sha256",
814
+ "execution_revision",
815
+ "updated_at",
816
+ ]
817
+ if inspection.get("inspection_mode") != "manifest-only":
818
+ execution_keys.append("tasks")
478
819
  summary["execution"] = {
479
820
  key: execution.get(key)
480
- for key in (
481
- "schema",
482
- "spec_id",
483
- "design_revision",
484
- "design_sha256",
485
- "execution_revision",
486
- "updated_at",
487
- "tasks",
488
- )
821
+ for key in execution_keys
489
822
  }
823
+ if relevant_execution_task_ids is not None:
824
+ summary["execution"]["tasks"] = [
825
+ task
826
+ for task in execution.get("tasks", [])
827
+ if str(task.get("task_id")) in relevant_execution_task_ids
828
+ ]
490
829
  else:
491
830
  summary["execution"] = None
492
831
  return summary