agent-project-sdlc 0.1.25 → 0.1.26

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 CHANGED
@@ -19,12 +19,13 @@ npx sdlc-harness init --adopt
19
19
 
20
20
  | Capability | Entry Point | Description |
21
21
  |---|---|---|
22
- | Project initialization | `npx sdlc-harness init` | Creates `AGENTS.md`, `<harnessRoot>/state/**`, workflow skills, managed templates/policies, `.docs/**` and a Makefile include. |
23
- | Existing project adoption | `npx sdlc-harness init --adopt` | Adds Harness non-destructively to an existing repository. |
24
- | Configurable Harness root | `--harness-folder`, `package.json#sdlcHarness.harnessFolderName`, `sdlc-harness.config.json` | Supports Codex `.codex`, Claude `.claude`, Cursor `.cursor`, Cline `.cline`, Roo `.roo`, Gemini `.gemini` or a custom folder. |
22
+ | Project initialization | `npx sdlc-harness init` | Creates `AGENTS.md`, `<harnessRoot>/state/**`, workflow skills, managed templates/policies, `.docs/**` and a Makefile include; fresh lifecycle starts at `REQUIREMENT_GATHERING`. |
23
+ | Existing project adoption | `npx sdlc-harness init --adopt` | Adds Harness non-destructively to an existing repository; adopt lifecycle starts at `SPRINTING`. |
24
+ | Configurable Harness root | `--harness-folder`, `package.json#sdlcHarness.harnessFolderName`, `sdlc-harness.config.json` | Supports Codex `.codex`, Claude `.claude`, Cursor `.cursor`, Cline `.cline`, Roo `.roo`, Gemini `.gemini` or a custom folder; root resolution prefers package.json, then config file, then `.agent`. |
25
25
  | Managed file sync | `npx sdlc-harness sync` | Materializes package canonical assets and safely updates package-managed guidance sections inside user-owned Markdown files while preserving project state, docs and local overrides. |
26
26
  | Upgrade | `npx sdlc-harness upgrade` | Runs migrations and sync for already-adopted projects, including legacy seed guidance migration. |
27
27
  | Diagnostics | `npx sdlc-harness doctor` | Reports Harness root, package version, schema version and key managed paths. |
28
+ | Workflow self-inspection | `npx sdlc-harness inspect-workflow` | Read-only check for workflow weight, fact-source drift, handoff clarity and recovery safety; metrics are labeled `measured`, `inferred`, `self_reported` or `unavailable`. |
28
29
  | Validators | `npx sdlc-harness validate-*`, `make validate-current`, `make validate-harness` | Checks product, UI/UX, architecture design slicing, development, review, test, release, RFC, active plan shape, prompt language contract and generated overview freshness. |
29
30
  | Harness Python tools | `tools/*.py` | Package-managed local workflow tools, including `transition.py`, Python validators and overview generation helpers. |
30
31
  | Lifecycle workflow | `<harnessRoot>/state/lifecycle.yaml`, `<harnessRoot>/state/plan.yaml`, `.docs/**` | Tracks REQUIREMENT_GATHERING, UI_UX_DESIGNING, ARCHITECTING, SPRINTING, REVIEWING, TESTING, RELEASING and RFC_RECALIBRATION facts. |
@@ -87,12 +88,28 @@ The generic rule is that any workflow promoting a draft task into a formal `TASK
87
88
 
88
89
  Phase routing is expressed as a lightweight explicit directed graph in `<harnessRoot>/pjsdlc_managed/policies/phase_contracts.yaml`: `phases` stores stable phase contracts, while `transitions` stores legal edges and small runtime effects such as setting or clearing `suspended_phase`. This makes normal advance, pre-development return, TESTING bugfix return, RFC interrupt/resume and BLOCKED resume rules consumable by both the transition helper and validators. It is intentionally not a heavy graph engine: no history graph, traversal framework, node/edge classes or visualizer are introduced; the goal is to reduce missed rules and drift.
89
90
 
90
- Migration cost is low for projects that use managed assets: run `npx sdlc-harness upgrade` to sync the new `phase_contracts.yaml`, `tools/transition.py`, `pjsdlc_uiux_design`, `UI_UX_DESIGN_TEMPLATE.md` and `validate-uiux`, or run `npx sdlc-harness sync` if only managed files need refreshing. `lifecycle.yaml` and `plan.yaml` do not need manual migration; old `allowed_next_phases` values are regenerated from the graph on the next transition. Projects with custom phase policies should convert node-local `next` / `returns` to top-level `transitions`, and add `REQUIREMENT_GATHERING -> UI_UX_DESIGNING -> ARCHITECTING`, `ARCHITECTING -> UI_UX_DESIGNING`, and the `TESTING -> UI_UX_DESIGNING` / `ARCHITECTING` / `SPRINTING` bugfix return edges when they want the new routing. If the new `validate-harness` reports missing `transitions`, run `upgrade` or `sync` before validating again.
91
+ Migration cost is low for projects that use managed assets: run `npx sdlc-harness upgrade` to sync the new `phase_contracts.yaml`, `tools/transition.py`, `pjsdlc_uiux_design`, `UI_UX_DESIGN_TEMPLATE.md`, `validate-uiux` and configured-root Python/Makefile gate fixes, or run `npx sdlc-harness sync` if only managed files need refreshing. `lifecycle.yaml` and `plan.yaml` do not need manual migration; old `allowed_next_phases` values are regenerated from the graph on the next transition, and the fresh/adopt initial phase split only affects new `init` runs. Projects with custom phase policies should convert node-local `next` / `returns` to top-level `transitions`, and add `REQUIREMENT_GATHERING -> UI_UX_DESIGNING -> ARCHITECTING`, `ARCHITECTING -> UI_UX_DESIGNING`, and the `TESTING -> UI_UX_DESIGNING` / `ARCHITECTING` / `SPRINTING` bugfix return edges when they want the new routing. If the new `validate-harness` reports missing `transitions`, run `upgrade` or `sync` before validating again.
91
92
 
92
93
  Before development starts, `ARCHITECTING` can return to `REQUIREMENT_GATHERING` for PRD edits or to `UI_UX_DESIGNING` for missing screen contracts, interaction states, responsive/a11y acceptance or `DESIGN.md`. The manager uses `python3 tools/transition.py --to REQUIREMENT_GATHERING` or `python3 tools/transition.py --to UI_UX_DESIGNING`, completes one stage task, runs that stage gate, then returns to the downstream phase. Requirement, acceptance, experience-contract or product-boundary changes after `SPRINTING` use RFC recalibration; `SPRINTING`, `REVIEWING`, `TESTING` and `RELEASING` can enter the controlled interrupt with `python3 tools/transition.py --to RFC_RECALIBRATION`, then return to `SPRINTING` after `validate-rfc`.
93
94
 
94
95
  When TESTING finds a bug, first record `Bugfix Route` in `.docs/07_test/TEST_REPORT.md`, then let the manager choose the lightweight return. `bugfix_uiux_replan` uses `python3 tools/transition.py --to UI_UX_DESIGNING` when PRD is correct but UX contract, screen contract, handoff matrix or `DESIGN.md` is wrong. `bugfix_replan` uses `python3 tools/transition.py --to ARCHITECTING` when the technical plan, interface contract, task breakdown, Development Self-Test Contract or Module Key Test Graph must change. `bugfix_implementation_gap` uses `python3 tools/transition.py --to SPRINTING` only when the technical plan is still correct and implementation deviated from it. Requirement, acceptance or product-boundary changes still use RFC recalibration.
95
96
 
97
+ ## Workflow Self-Inspection
98
+
99
+ Use `npx sdlc-harness inspect-workflow` when a user agent needs to check whether a repository is running the Harness workflow as intended, especially whether the workflow has become too heavy. The command is read-only: it does not write reports, run heavyweight tests, upload telemetry or invent precise token numbers.
100
+
101
+ Each metric includes a `data_source`: `measured` for local files, fields or validator results the script actually read; `inferred` for proxies derived from size, duplication, missing fields or long handoff docs; `self_reported` for values explicitly supplied by the user or agent; and `unavailable` for data the local environment cannot know, such as true model token telemetry when the client did not provide it.
102
+
103
+ Default workflow-weight thresholds are intentionally simple. `plan.yaml` over 200 lines is `WARN` and over 500 lines is `BLOCKED`; more than one open task is `BLOCKED`; current task `allowed_paths` over 12 is `WARN` and over 25 is `BLOCKED`; current task document refs over 5 is `WARN` and over 10 is `BLOCKED`; `working_notes` over 8 is `BLOCKED`; Development Self-Test Report size is `WARN` / `BLOCKED` above 80 / 120 lines for ordinary tasks and 120 / 180 lines for high-risk runtime tasks.
104
+
105
+ When the agent or client has real recent-cost data, pass it explicitly:
106
+
107
+ ```sh
108
+ npx sdlc-harness inspect-workflow --recent-minutes 18 --recent-turns 7 --estimated-tokens 12000
109
+ ```
110
+
111
+ Use `--prompt` to print a self-inspection prompt for qualitative checks such as whether the current entry, task, next step, hard constraints and Review/Testing handoff are clear. Use `--json` when CI or another agent needs a machine-readable `decision`, `metrics` and `findings` report. `BLOCKED` exits non-zero; `WARN` remains a successful diagnostic exit.
112
+
96
113
  `validate-uiux` requires at least one non-overview `.docs/02_experience/**` deliverable. UX slices must cite PRD / requirement IDs or explicitly declare `Applicability: not_applicable`; visual UI slices require root `DESIGN.md` and fail on `@google/design.md` linter errors. Warnings are reported by the linter but are not treated as default blockers.
97
114
 
98
115
  `validate-design` treats semantic slicing as a hard gate. Generated `overview.md` files do not count as deliverables, development draft tasks in `plan.draft.yaml` must reference existing tech plan slices through `docs.tech_plan`, UI/frontend draft tasks must reference existing UX slices through `docs.uiux` and root `DESIGN.md` through `docs.design_system`, multiple development draft tasks need distinct primary tech plan slices, and explicit AI provider/copilot, external-system, or compliance/permission/audit themes require dedicated architecture slices. Draft tasks with runnable boundaries must also include `self_test_contract`, backed by a `Development Self-Test Contract` section in the tech plan; the contract must include `module_key_test_path` from local start or invocation to all self-test scenarios completion, covering every runnable entry promised by the current task/module and its internal key paths. Complex or high-risk paths may set `graph_required: true` and provide `module_key_test_graph` to express entries, checkpoints, scenarios, exits and evidence refs as a lightweight DAG.
@@ -125,6 +142,7 @@ npx sdlc-harness init --adopt
125
142
  npx sdlc-harness sync
126
143
  npx sdlc-harness upgrade
127
144
  npx sdlc-harness doctor
145
+ npx sdlc-harness inspect-workflow
128
146
  npx sdlc-harness validate-plan
129
147
  npx sdlc-harness validate-uiux
130
148
  npx sdlc-harness validate-design
@@ -24,7 +24,7 @@ npm install -D agent-project-sdlc
24
24
  npx sdlc-harness init
25
25
  ```
26
26
 
27
- `init` 会先询问目标 Agent。直接回车选择默认 `Codex`,并把 Harness 配置写到 `.codex`。其它内置选项会写入对应目录,例如 `Claude Code -> .claude`、`Cursor -> .cursor`、`Cline -> .cline`、`Roo Code -> .roo`、`Gemini CLI -> .gemini`。选择 `Other` 时才会继续询问自定义文件夹名,此时直接回车默认 `.agent`。
27
+ `init` 会先询问目标 Agent。直接回车选择默认 `Codex`,并把 Harness 配置写到 `.codex`。其它内置选项会写入对应目录,例如 `Claude Code -> .claude`、`Cursor -> .cursor`、`Cline -> .cline`、`Roo Code -> .roo`、`Gemini CLI -> .gemini`。选择 `Other` 时才会继续询问自定义文件夹名,此时直接回车默认 `.agent`。新项目 fresh init 从 `REQUIREMENT_GATHERING` 开始,避免绕过 PRD / UI/UX / architecture;已有项目使用 `--adopt` 时从 `SPRINTING` 接入,方便先对齐既有代码和 Harness 事实源。
28
28
 
29
29
  如果已经确定目录,可以跳过交互:
30
30
 
@@ -54,12 +54,13 @@ npx sdlc-harness init --adopt
54
54
 
55
55
  | 能力 | 入口 | 说明 |
56
56
  |---|---|---|
57
- | 新项目初始化 | `npx sdlc-harness init` | 选择目标 Agent,生成 Harness 根目录、状态文件、workflow skills、模板、策略、`.docs/**` 和 Makefile include |
58
- | 已有项目接入 | `npx sdlc-harness init --adopt` | 非破坏性接入已有仓库,不覆盖业务代码和已有项目事实源 |
59
- | 可配置 Harness 根目录 | `--harness-folder`、`package.json#sdlcHarness.harnessFolderName`、`sdlc-harness.config.json` | 支持 `.codex`、`.claude`、`.cursor`、`.cline`、`.roo`、`.gemini` 或自定义目录 |
57
+ | 新项目初始化 | `npx sdlc-harness init` | 选择目标 Agent,生成 Harness 根目录、状态文件、workflow skills、模板、策略、`.docs/**` 和 Makefile include;fresh lifecycle 从 `REQUIREMENT_GATHERING` 开始 |
58
+ | 已有项目接入 | `npx sdlc-harness init --adopt` | 非破坏性接入已有仓库,不覆盖业务代码和已有项目事实源;adopt lifecycle 从 `SPRINTING` 开始 |
59
+ | 可配置 Harness 根目录 | `--harness-folder`、`package.json#sdlcHarness.harnessFolderName`、`sdlc-harness.config.json` | 支持 `.codex`、`.claude`、`.cursor`、`.cline`、`.roo`、`.gemini` 或自定义目录;解析优先级为 package.json、config file、默认 `.agent` |
60
60
  | 同步 managed workflow 文件 | `npx sdlc-harness sync` | 从包内 canonical assets 物化 `AGENTS.md` managed block、workflow skills、templates、policies、Makefile 片段、GitHub workflow,并安全更新 user-owned Markdown guidance sections |
61
61
  | 升级已接入项目 | `npx sdlc-harness upgrade` | 执行迁移并自动 `sync`,保留 state、docs、业务代码和本地 override,同时迁移旧 seed guidance |
62
62
  | 接入诊断 | `npx sdlc-harness doctor` | 检查 harness root、版本、schema、关键文件和 managed paths |
63
+ | 工作流自查 | `npx sdlc-harness inspect-workflow` | 只读检查 workflow weight、事实源漂移、交接清晰度和 recovery safety;每个指标标注 `measured` / `inferred` / `self_reported` / `unavailable` |
63
64
  | 阶段 gate | `npx sdlc-harness validate-*`、`make validate-current`、`make validate-harness` | 校验需求、UI/UX、架构设计、开发、Review、测试、发布、RFC、Harness 骨架、提示词语言契约和 overview freshness |
64
65
  | 生命周期工作流 | `lifecycle.yaml`、`plan.yaml`、`.docs/**` | 固定 REQUIREMENT_GATHERING、UI_UX_DESIGNING、ARCHITECTING、SPRINTING、REVIEWING、TESTING、RELEASING、RFC_RECALIBRATION 等阶段事实链 |
65
66
  | 阶段小任务管控 | `plan.yaml`、`make validate-plan` | 每个阶段的 Agent 主任务都应拆成足够小的 `TASK-*` open task,并用 `phase` 标明所属阶段 |
@@ -108,12 +109,43 @@ Agent 会读取 `<harnessRoot>/state/lifecycle.yaml` 和 `<harnessRoot>/state/pl
108
109
 
109
110
  阶段关系由 `<harnessRoot>/pjsdlc_managed/policies/phase_contracts.yaml` 中的轻量显式有向图表达:`phases` 保存稳定阶段 contract,`transitions` 保存合法流转边和少量效果,例如设置或清理 `suspended_phase`。这样做是为了让正常推进、开发前返回、TESTING bugfix return、RFC interrupt/resume 和 BLOCKED resume 都被 transition helper 与 validator 读取,避免规则埋在长文档或工具硬编码里。它不是重型图引擎,不保存历史、不做复杂遍历、不引入 node/edge class 或可视化;目标只是降低遗漏和漂移。
110
111
 
111
- 迁移成本较低:对使用 managed assets 的项目,运行 `npx sdlc-harness upgrade` 即可同步新的 `phase_contracts.yaml`、`tools/transition.py`、`pjsdlc_uiux_design`、`UI_UX_DESIGN_TEMPLATE.md` 和 `validate-uiux`;也可以运行 `npx sdlc-harness sync` 只刷新 managed 文件。`lifecycle.yaml` 和 `plan.yaml` 不需要手动迁移,旧的 `allowed_next_phases` 会在下一次 `transition.py` 执行后按图重新生成。只有维护了自定义 phase policy 的项目需要把阶段内的 `next` / `returns` 转成 top-level `transitions`,并加入 `REQUIREMENT_GATHERING -> UI_UX_DESIGNING -> ARCHITECTING`、`ARCHITECTING -> UI_UX_DESIGNING`、`TESTING -> UI_UX_DESIGNING` / `ARCHITECTING` / `SPRINTING` return edges;如果升级前直接运行新版 `validate-harness` 看到缺少 `transitions`,先执行 `upgrade` / `sync`。
112
+ 迁移成本较低:对使用 managed assets 的项目,运行 `npx sdlc-harness upgrade` 即可同步新的 `phase_contracts.yaml`、`tools/transition.py`、`pjsdlc_uiux_design`、`UI_UX_DESIGN_TEMPLATE.md`、`validate-uiux` 和 configured-root Python/Makefile gate 修复;也可以运行 `npx sdlc-harness sync` 只刷新 managed 文件。`lifecycle.yaml` 和 `plan.yaml` 不需要手动迁移,旧的 `allowed_next_phases` 会在下一次 `transition.py` 执行后按图重新生成;fresh/adopt 初始阶段只影响新执行的 `init`,不会重写已有 state。只有维护了自定义 phase policy 的项目需要把阶段内的 `next` / `returns` 转成 top-level `transitions`,并加入 `REQUIREMENT_GATHERING -> UI_UX_DESIGNING -> ARCHITECTING`、`ARCHITECTING -> UI_UX_DESIGNING`、`TESTING -> UI_UX_DESIGNING` / `ARCHITECTING` / `SPRINTING` return edges;如果升级前直接运行新版 `validate-harness` 看到缺少 `transitions`,先执行 `upgrade` / `sync`。
112
113
 
113
114
  在尚未进入开发前,`ARCHITECTING` 可以回到 `REQUIREMENT_GATHERING` 修改 PRD,也可以回到 `UI_UX_DESIGNING` 补 screen contracts、interaction states、responsive/a11y acceptance 或 `DESIGN.md`:Manager 使用 `python3 tools/transition.py --to REQUIREMENT_GATHERING` 或 `python3 tools/transition.py --to UI_UX_DESIGNING` 切回对应工作流,完成 task 和 gate 后再回到后续阶段。进入 `SPRINTING` 后的需求、验收标准、体验契约或产品边界变化走 RFC workflow;`SPRINTING`、`REVIEWING`、`TESTING` 和 `RELEASING` 都可以通过 `python3 tools/transition.py --to RFC_RECALIBRATION` 进入受控 RFC 中断,RFC 完成后回到 `SPRINTING` 重新完成开发自测和 handoff。
114
115
 
115
116
  TESTING 阶段发现 bug 时,先在 `.docs/07_test/TEST_REPORT.md` 记录 `Bugfix Route`,再由 Manager 选择轻量 return:`bugfix_uiux_replan` 走 `python3 tools/transition.py --to UI_UX_DESIGNING`,用于 PRD 正确但 UX contract、screen contract、handoff matrix 或 `DESIGN.md` 错误;`bugfix_replan` 走 `python3 tools/transition.py --to ARCHITECTING`,用于技术方案、接口契约、任务拆分、Development Self-Test Contract 或 Module Key Test Graph 需要改;`bugfix_implementation_gap` 走 `python3 tools/transition.py --to SPRINTING`,只用于技术方案正确但实现偏离的修复。需求、验收标准或产品边界变化仍走 RFC。
116
117
 
118
+ ### 工作流自查
119
+
120
+ 当你想知道“这个项目的 Harness 用法是否符合预期、是不是变得太重”时,运行:
121
+
122
+ ```sh
123
+ npx sdlc-harness inspect-workflow
124
+ ```
125
+
126
+ 该命令只读检查本地事实源,不写报告、不跑重型测试、不上传 telemetry。输出状态是 `PASS`、`WARN` 或 `BLOCKED`;`BLOCKED` 会返回非零退出码。每条 metric 都会标注数据来源:
127
+
128
+ - `measured`:脚本真实读到的文件、字段、validator 结果,例如 `plan.yaml` 行数、open task 数量、`allowed_paths` 数量。
129
+ - `inferred`:脚本只能从体量、重复、字段缺失或长文档现象推断,例如当前交接上下文是否可能过重。
130
+ - `self_reported`:用户或 Agent 显式传入的最近执行耗时、turns 或估算 token。
131
+ - `unavailable`:当前环境没有真实 telemetry,命令不会伪造精确 token 或真实模型耗时。
132
+
133
+ 工作流重量的默认阈值是:`plan.yaml` 超过 200 行 `WARN`、超过 500 行 `BLOCKED`;open task 超过 1 个 `BLOCKED`;当前 task `allowed_paths` 超过 12 个 `WARN`、超过 25 个 `BLOCKED`;当前 task 关联文档超过 5 个 `WARN`、超过 10 个 `BLOCKED`;`working_notes` 超过 8 条 `BLOCKED`;`Development Self-Test Report` 普通任务超过 80 行 `WARN`、超过 120 行 `BLOCKED`,high-risk 任务使用 120 / 180 阈值。
134
+
135
+ 如果 Agent 或客户端知道最近一次 workflow 处理的实际成本,可以显式传入:
136
+
137
+ ```sh
138
+ npx sdlc-harness inspect-workflow --recent-minutes 18 --recent-turns 7 --estimated-tokens 12000
139
+ ```
140
+
141
+ 也可以让 Agent 用提示词自查:
142
+
143
+ ```sh
144
+ npx sdlc-harness inspect-workflow --prompt
145
+ ```
146
+
147
+ `--prompt` 会要求 Agent 区分真实可测数据、推断数据、自报数据和不可测数据,并检查入口、当前任务、下一步、hard constraint promotion、交接卡边界和 Review / Testing 可消费性。`--json` 可用于 CI 或自动化读取。
148
+
117
149
  `validate-design` 会把架构阶段的语义切片作为硬 gate:`overview.md` 不计入 deliverables,`plan.draft.yaml` 中每个开发 draft task 必须通过 `docs.tech_plan` 指向存在的 tech plan slice;多个开发 draft task 默认需要不同 primary tech plan slice。PRD、tech plan 或 draft task 明确出现 AI provider / copilot、外部系统边界、合规 / 权限 / 审计等横切主题时,也需要对应的专门 architecture slice。可运行边界类 draft task 还必须带 `self_test_contract`,并在 tech plan 中有 `Development Self-Test Contract`;合同必须记录 `module_key_test_path`,说明从本地启动或调用入口开始,到完成全部自测 scenario 的模块关键测试路径,并覆盖本 task / 本模块承诺的所有可运行入口和内部关键路径。复杂或 high-risk 路径可设置 `graph_required: true` 并提供 `module_key_test_graph`,把入口、checkpoint、scenario、出口和 evidence refs 表达成轻量 DAG。
118
150
 
119
151
  SPRINTING 的 Definition of Done 包含模块级可运行交付边界:技术方案或 task 承诺的 API、CLI、server route、service、agent、runtime、adapter、worker、provider、配置契约和 fixture/live 边界必须在开发阶段实现或明确 `BLOCKED`。runtime/app/provider/live 类 task 必须在 `plan.yaml` 声明 `evidence_level.required`、`target_runtime_environment` 和 `self_test_contract`;`self_test_contract.required_gates` 必须同步出现在 task `required_gates`,`self_test_contract.module_key_test_path` 必须描述从本地启动或调用入口开始,到完成全部自测 scenario 的模块关键测试路径,并覆盖本 task / 本模块承诺的所有可运行入口和内部关键路径。复杂 task 的 `module_key_test_graph` 是 handoff path 的 canonical detail:它是 DAG 而不是树,因为多个 scenario 可能共享 setup、分支后汇合到同一 observable exit;它不是重型测试执行图,不保存 trace、debug log、operator log、runbook 正文或证据正文。`deployed_runtime` 不能用 `unit`、`local_runtime`、`external_provider_live`、provider smoke、fake adapter 或 localhost smoke 单独关闭,`business_handoff_ready` 还必须有 Testing Handoff Contract。当前 task 的 implementation doc 还必须写入 `Development Evidence` 和 `Development Self-Test Report`,其中自测报告记录 `Report Status: PASS | BLOCKED | IN_PROGRESS | STALE`、contract source、Module Application Entry、scenario results、executed gates、Module Key Test Path、必要时的 Module Key Test Graph、Observable Exit、Current Blocker、Testing Handoff Readiness 和 Evidence Index Refs;只有 `Report Status: PASS` 且所有 scenario 为 `PASS` 才能关闭 development task。`Development Self-Test Report` 只证明模块入口、核心路径、出口和最小证据指针,不承担 debug log、operator log、runbook、evidence dump 或探索流水职责;fallback / diagnostic 最多一句总结,详细内容进入 `.docs/09_runbooks/**` evidence index / appendix 或 git history;主报告不得使用 `Actual Evidence` 正文字段,普通报告目标不超过 80 行,high-risk 报告目标不超过 120 行。`Module Key Test Path` 必须记录实际入口、内部关键路径、关键边界、观察点和可观测完成证据。provider smoke、fixture smoke、fake adapter 或 one-shot smoke 只能证明局部链路,不能单独证明 application readiness。REVIEWING 会把缺少入口/出口、初始化、配置契约、目标运行环境、证据等级或开发自测证据作为阻断项;TESTING 只调用 Review 已确认 `PASS` 的既有入口做输入输出验证,复杂路径按 Module Key Test Graph 消费,不能新增 product runtime、bootstrap、provider adapter、deploy 或 package runtime script。
@@ -219,6 +251,13 @@ npx sdlc-harness sync
219
251
  npx sdlc-harness upgrade
220
252
  ```
221
253
 
254
+ 自查工作流重量和交接清晰度:
255
+
256
+ ```sh
257
+ npx sdlc-harness inspect-workflow
258
+ npx sdlc-harness inspect-workflow --prompt
259
+ ```
260
+
222
261
  运行当前阶段 gate:
223
262
 
224
263
  ```sh
@@ -64,4 +64,4 @@
64
64
 
65
65
  ### Out of Scope
66
66
 
67
- -
67
+ - TBD
@@ -12,6 +12,8 @@ from typing import Any
12
12
 
13
13
 
14
14
  ROOT = Path(__file__).resolve().parents[1]
15
+ DEFAULT_HARNESS_ROOT = ".agent"
16
+ HARNESS_JSON_CONFIG_PATH = "sdlc-harness.config.json"
15
17
 
16
18
  TASK_STATUSES = {
17
19
  "pending",
@@ -70,7 +72,67 @@ def repo_path(relative: str | Path) -> Path:
70
72
  return ROOT / relative
71
73
 
72
74
 
75
+ def normalize_harness_folder_name(value: str) -> str:
76
+ normalized = value.strip().replace("\\", "/").rstrip("/")
77
+ if not normalized or normalized in {".", ".."}:
78
+ raise HarnessError("harnessFolderName must be a non-empty relative directory")
79
+ if Path(normalized).is_absolute() or ".." in normalized.split("/"):
80
+ raise HarnessError("harnessFolderName must not be absolute or contain '..'")
81
+ return normalized
82
+
83
+
84
+ def folder_name_from_object(value: Any) -> str | None:
85
+ if not isinstance(value, dict):
86
+ return None
87
+ folder_name = value.get("harnessFolderName") or value.get("harnessFloderName")
88
+ if isinstance(folder_name, str) and folder_name.strip():
89
+ return folder_name
90
+ return None
91
+
92
+
93
+ def read_json_config(relative: str) -> Any:
94
+ path = repo_path(relative)
95
+ if not path.exists():
96
+ return None
97
+ return json.loads(path.read_text(encoding="utf-8"))
98
+
99
+
100
+ def harness_root() -> str:
101
+ package_json = read_json_config("package.json")
102
+ package_config = package_json.get("sdlcHarness") if isinstance(package_json, dict) else None
103
+ package_value = folder_name_from_object(package_config)
104
+ if package_value:
105
+ return normalize_harness_folder_name(package_value)
106
+
107
+ explicit_config = read_json_config(HARNESS_JSON_CONFIG_PATH)
108
+ explicit_value = folder_name_from_object(explicit_config)
109
+ if explicit_value:
110
+ return normalize_harness_folder_name(explicit_value)
111
+
112
+ return DEFAULT_HARNESS_ROOT
113
+
114
+
115
+ def harness_path(*segments: str) -> str:
116
+ return (Path(harness_root()).joinpath(*segments)).as_posix()
117
+
118
+
119
+ def resolve_harness_relative(relative: str | Path) -> str:
120
+ value = str(relative).replace("\\", "/")
121
+ root = harness_root()
122
+ if value == "<harnessRoot>":
123
+ return root
124
+ if value.startswith("<harnessRoot>/"):
125
+ return f"{root}/{value.removeprefix('<harnessRoot>/')}"
126
+ if root != ".codex":
127
+ if value == ".codex":
128
+ return root
129
+ if value.startswith(".codex/"):
130
+ return f"{root}/{value.removeprefix('.codex/')}"
131
+ return value
132
+
133
+
73
134
  def read_text(relative: str | Path) -> str:
135
+ relative = resolve_harness_relative(relative)
74
136
  path = repo_path(relative)
75
137
  if not path.exists():
76
138
  raise HarnessError(f"Missing required file: {relative}")
@@ -78,6 +140,7 @@ def read_text(relative: str | Path) -> str:
78
140
 
79
141
 
80
142
  def load_yaml(relative: str | Path) -> Any:
143
+ relative = resolve_harness_relative(relative)
81
144
  path = repo_path(relative)
82
145
  if not path.exists():
83
146
  raise HarnessError(f"Missing required YAML file: {relative}")
@@ -114,6 +177,7 @@ def parse_yaml_text(text: str) -> Any:
114
177
 
115
178
 
116
179
  def dump_yaml(data: Any, relative: str | Path) -> None:
180
+ relative = resolve_harness_relative(relative)
117
181
  path = repo_path(relative)
118
182
  path.write_text(to_simple_yaml(data).rstrip() + "\n", encoding="utf-8")
119
183
 
@@ -330,7 +394,8 @@ def require(condition: Any, message: str) -> None:
330
394
 
331
395
  def require_paths(paths: list[str]) -> None:
332
396
  for relative in paths:
333
- require(repo_path(relative).exists(), f"Missing required path: {relative}")
397
+ resolved = resolve_harness_relative(relative)
398
+ require(repo_path(resolved).exists(), f"Missing required path: {resolved}")
334
399
 
335
400
 
336
401
  def combined_text(paths: list[Path]) -> str:
@@ -844,13 +909,13 @@ def is_testing_runtime_boundary_change(path: str) -> bool:
844
909
 
845
910
 
846
911
  def load_lifecycle() -> dict[str, Any]:
847
- data = load_yaml(".codex/state/lifecycle.yaml")
912
+ data = load_yaml(harness_path("state", "lifecycle.yaml"))
848
913
  require(isinstance(data, dict), "lifecycle.yaml must be a mapping")
849
914
  return data
850
915
 
851
916
 
852
917
  def load_phase_contract_data() -> dict[str, Any]:
853
- data = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
918
+ data = load_yaml(harness_path("pjsdlc_managed", "policies", "phase_contracts.yaml"))
854
919
  require(isinstance(data, dict) and isinstance(data.get("phases"), dict), "phase_contracts.yaml must contain phases")
855
920
  return data
856
921
 
@@ -1021,11 +1086,12 @@ def phase_transition_contract_errors(contract_data: dict[str, Any], require_tran
1021
1086
  return errors
1022
1087
 
1023
1088
 
1024
- def load_plan(path: str = ".codex/state/plan.yaml") -> dict[str, Any]:
1025
- data = load_yaml(path)
1026
- require(isinstance(data, dict), f"{path} must be a mapping")
1089
+ def load_plan(path: str | None = None) -> dict[str, Any]:
1090
+ plan_path = path or harness_path("state", "plan.yaml")
1091
+ data = load_yaml(plan_path)
1092
+ require(isinstance(data, dict), f"{resolve_harness_relative(plan_path)} must be a mapping")
1027
1093
  tasks = data.get("tasks", [])
1028
- require(isinstance(tasks, list), f"{path} must contain a tasks list")
1094
+ require(isinstance(tasks, list), f"{resolve_harness_relative(plan_path)} must contain a tasks list")
1029
1095
  return data
1030
1096
 
1031
1097
 
@@ -1247,28 +1313,34 @@ def validate_parallel_worker_path_lock(data: dict[str, Any], worker: dict[str, A
1247
1313
  require(not glob_patterns_overlap(owned, forbidden), f"{prefix}.owned_paths must not overlap forbidden paths: {owned} vs {forbidden}")
1248
1314
 
1249
1315
 
1250
- def glob_prefix(pattern: str) -> str:
1251
- normalized = pattern.replace("\\", "/").replace("<harnessRoot>", ".codex")
1316
+ def normalize_harness_pattern(pattern: str, root: str | None = None) -> str:
1317
+ actual_root = root or harness_root()
1318
+ return pattern.replace("\\", "/").replace("<harnessRoot>", actual_root)
1319
+
1320
+
1321
+ def glob_prefix(pattern: str, root: str | None = None) -> str:
1322
+ normalized = normalize_harness_pattern(pattern, root)
1252
1323
  wildcard_positions = [pos for pos in (normalized.find("*"), normalized.find("["), normalized.find("?")) if pos >= 0]
1253
1324
  if wildcard_positions:
1254
1325
  normalized = normalized[: min(wildcard_positions)]
1255
1326
  return normalized.rstrip("/")
1256
1327
 
1257
1328
 
1258
- def glob_patterns_overlap(left: str, right: str) -> bool:
1259
- left_clean = left.replace("\\", "/").replace("<harnessRoot>", ".codex")
1260
- right_clean = right.replace("\\", "/").replace("<harnessRoot>", ".codex")
1329
+ def glob_patterns_overlap(left: str, right: str, root: str | None = None) -> bool:
1330
+ left_clean = normalize_harness_pattern(left, root)
1331
+ right_clean = normalize_harness_pattern(right, root)
1261
1332
  if fnmatch.fnmatch(left_clean, right_clean) or fnmatch.fnmatch(right_clean, left_clean):
1262
1333
  return True
1263
- left_prefix = glob_prefix(left_clean)
1264
- right_prefix = glob_prefix(right_clean)
1334
+ left_prefix = glob_prefix(left_clean, root)
1335
+ right_prefix = glob_prefix(right_clean, root)
1265
1336
  if not left_prefix or not right_prefix:
1266
1337
  return left_prefix == right_prefix
1267
1338
  return left_prefix.startswith(right_prefix + "/") or right_prefix.startswith(left_prefix + "/") or left_prefix == right_prefix
1268
1339
 
1269
1340
 
1270
- def expand_harness_root(patterns: list[str], root: str = ".codex") -> list[str]:
1271
- return [str(pattern).replace("<harnessRoot>", root) for pattern in patterns]
1341
+ def expand_harness_root(patterns: list[str], root: str | None = None) -> list[str]:
1342
+ actual_root = root or harness_root()
1343
+ return [str(pattern).replace("<harnessRoot>", actual_root) for pattern in patterns]
1272
1344
 
1273
1345
 
1274
1346
  def task_by_id(plan_data: dict[str, Any], task_id: str) -> dict[str, Any] | None:
@@ -2,6 +2,7 @@
2
2
  from harness_utils import (
3
3
  dump_yaml,
4
4
  find_phase_transition,
5
+ harness_path,
5
6
  load_lifecycle,
6
7
  load_phase_contract_data,
7
8
  make_arg_parser,
@@ -51,7 +52,7 @@ def main() -> None:
51
52
  str(lifecycle.get("suspended_phase") or ""),
52
53
  )
53
54
 
54
- dump_yaml(lifecycle, ".codex/state/lifecycle.yaml")
55
+ dump_yaml(lifecycle, harness_path("state", "lifecycle.yaml"))
55
56
  print(f"Transitioned {current} -> {target}")
56
57
  if args.reason:
57
58
  print(f"Note: {args.reason}")
@@ -18,7 +18,7 @@ def main() -> None:
18
18
  tasks = [task for task in data.get("tasks", []) if isinstance(task, dict)]
19
19
  open_tasks = [task for task in tasks if task.get("status") in OPEN_TASK_STATUSES]
20
20
 
21
- policies = load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
21
+ policies = load_yaml("<harnessRoot>/pjsdlc_managed/policies/allowed_paths.yaml")
22
22
  lifecycle = load_lifecycle()
23
23
  current_phase = lifecycle.get("current_phase") or "SPRINTING"
24
24
  phase_policy = ((policies.get("phases") or {}).get(current_phase) or {})
@@ -29,7 +29,7 @@ def main() -> None:
29
29
  task = task_by_id(data, current_task_id) if current_task_id else None
30
30
  require(task, "current_task_id must point to the task being validated")
31
31
  require(task.get("status") in OPEN_TASK_STATUSES, "current_task_id must point to an open task for path validation")
32
- allowed = list(task.get("allowed_paths") or []) + list(always_allow)
32
+ allowed = expand_harness_root(list(task.get("allowed_paths") or [])) + list(always_allow)
33
33
  else:
34
34
  print("Allowed paths skipped: no open task")
35
35
  return
@@ -75,7 +75,7 @@ def main() -> None:
75
75
 
76
76
 
77
77
  def validate_draft_task_tech_plan_refs(tech_plan_docs: list, experience_docs: list) -> list[dict]:
78
- draft = load_plan(".codex/state/plan.draft.yaml")
78
+ draft = load_plan("<harnessRoot>/state/plan.draft.yaml")
79
79
  require("current_phase" not in draft, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
80
80
  require("current_task_id" not in draft, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
81
81
  tasks = draft.get("tasks", [])
@@ -3,7 +3,7 @@ from harness_utils import load_plan, require, run_main
3
3
 
4
4
 
5
5
  def main() -> None:
6
- draft = load_plan(".codex/state/plan.draft.yaml")
6
+ draft = load_plan("<harnessRoot>/state/plan.draft.yaml")
7
7
  require("current_phase" not in draft, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
8
8
  require("current_task_id" not in draft, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
9
9
  tasks = [task for task in draft.get("tasks", []) if isinstance(task, dict)]
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  from harness_utils import (
3
+ harness_path,
3
4
  load_lifecycle,
4
5
  load_phase_contract_data,
5
6
  load_phase_contracts,
@@ -13,19 +14,20 @@ from harness_utils import (
13
14
 
14
15
 
15
16
  def main() -> None:
17
+ root = harness_path()
16
18
  required_files = [
17
19
  "AGENTS.md",
18
20
  "Makefile",
19
21
  ".docs/INDEX.md",
20
- ".codex/state/lifecycle.yaml",
21
- ".codex/state/plan.yaml",
22
- ".codex/state/plan.draft.yaml",
23
- ".codex/state/memory.md",
24
- ".codex/pjsdlc_managed/templates/PLAN_TEMPLATE.yaml",
25
- ".codex/pjsdlc_managed/policies/phase_contracts.yaml",
26
- ".codex/pjsdlc_managed/policies/gates.yaml",
27
- ".codex/pjsdlc_managed/policies/allowed_paths.yaml",
28
- ".codex/pjsdlc_managed/policies/risk_matrix.yaml",
22
+ harness_path("state", "lifecycle.yaml"),
23
+ harness_path("state", "plan.yaml"),
24
+ harness_path("state", "plan.draft.yaml"),
25
+ harness_path("state", "memory.md"),
26
+ harness_path("pjsdlc_managed", "templates", "PLAN_TEMPLATE.yaml"),
27
+ harness_path("pjsdlc_managed", "policies", "phase_contracts.yaml"),
28
+ harness_path("pjsdlc_managed", "policies", "gates.yaml"),
29
+ harness_path("pjsdlc_managed", "policies", "allowed_paths.yaml"),
30
+ harness_path("pjsdlc_managed", "policies", "risk_matrix.yaml"),
29
31
  "tools/build_doc_overviews.py",
30
32
  "tools/validate_plan.py",
31
33
  ]
@@ -42,7 +44,7 @@ def main() -> None:
42
44
  ".docs/08_release",
43
45
  ".docs/09_runbooks",
44
46
  ".docs/rfc",
45
- ".codex/skills",
47
+ harness_path("skills"),
46
48
  "tools",
47
49
  ]
48
50
  require_paths(required_files + required_dirs)
@@ -50,9 +52,9 @@ def main() -> None:
50
52
  lifecycle = load_lifecycle()
51
53
  phase_contract_data = load_phase_contract_data()
52
54
  phases = load_phase_contracts()
53
- load_yaml(".codex/pjsdlc_managed/policies/gates.yaml")
54
- load_yaml(".codex/pjsdlc_managed/policies/allowed_paths.yaml")
55
- load_yaml(".codex/pjsdlc_managed/policies/risk_matrix.yaml")
55
+ load_yaml(harness_path("pjsdlc_managed", "policies", "gates.yaml"))
56
+ load_yaml(harness_path("pjsdlc_managed", "policies", "allowed_paths.yaml"))
57
+ load_yaml(harness_path("pjsdlc_managed", "policies", "risk_matrix.yaml"))
56
58
 
57
59
  current_phase = lifecycle.get("current_phase")
58
60
  require(current_phase in phases, f"Lifecycle current_phase is not declared: {current_phase}")
@@ -62,7 +64,7 @@ def main() -> None:
62
64
  for phase_name, contract in phases.items():
63
65
  skill = contract.get("skill")
64
66
  require(skill, f"{phase_name} missing skill")
65
- skill_file = repo_path(f".codex/skills/{skill}/SKILL.md")
67
+ skill_file = repo_path(f"{root}/skills/{skill}/SKILL.md")
66
68
  require(skill_file.exists(), f"Missing skill file for {phase_name}: {skill_file.relative_to(repo_path('.'))}")
67
69
  require("inputs" in contract, f"{phase_name} missing inputs")
68
70
  require("outputs" in contract, f"{phase_name} missing outputs")
@@ -3,7 +3,7 @@ from harness_utils import load_plan, require, run_main, validate_task_shape
3
3
 
4
4
 
5
5
  def main() -> None:
6
- data = load_plan(".codex/state/plan.draft.yaml")
6
+ data = load_plan("<harnessRoot>/state/plan.draft.yaml")
7
7
  require("current_phase" not in data, "plan.draft.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase")
8
8
  require("current_task_id" not in data, "plan.draft.yaml must not define current_task_id because drafts are not active task state")
9
9
  tasks = data.get("tasks", [])
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from pathlib import Path
5
5
 
6
- from harness_utils import ROOT, HarnessError, load_yaml, require, run_main
6
+ from harness_utils import ROOT, HarnessError, harness_path, load_yaml, require, run_main
7
7
 
8
8
 
9
9
  SKILL_REQUIRED_SECTIONS = ["## 目的", "## 角色提示词", "## 输入", "## 规则", "## 完成检查"]
@@ -45,14 +45,6 @@ MACHINE_IDENTIFIERS = [
45
45
  "pending_revision",
46
46
  ]
47
47
 
48
- REQUIRED_AGENTS_TERMS = [
49
- "Skill Language Contract",
50
- "中文解释 + 英文精确标识符",
51
- ".codex/state/lifecycle.yaml",
52
- ".codex/state/plan.yaml",
53
- "make validate-current",
54
- ]
55
-
56
48
  YAML_KEYWORDS = {
57
49
  "lifecycle": [
58
50
  "project_name",
@@ -78,15 +70,23 @@ def text(path: Path) -> str:
78
70
 
79
71
  def validate_agents() -> None:
80
72
  content = text(ROOT / "AGENTS.md")
81
- for term in REQUIRED_AGENTS_TERMS:
73
+ required_agents_terms = [
74
+ "Skill Language Contract",
75
+ "中文解释 + 英文精确标识符",
76
+ harness_path("state", "lifecycle.yaml"),
77
+ harness_path("state", "plan.yaml"),
78
+ "make validate-current",
79
+ ]
80
+ for term in required_agents_terms:
82
81
  require(term in content, f"AGENTS.md missing skill language contract term: {term}")
83
82
  for identifier in MACHINE_IDENTIFIERS:
84
83
  require(identifier in content, f"AGENTS.md should preserve machine identifier: {identifier}")
85
84
 
86
85
 
87
86
  def validate_skills() -> None:
88
- skill_files = sorted((ROOT / ".codex/skills").glob("*/SKILL.md"))
89
- require(skill_files, "No workflow skill files found under .codex/skills/")
87
+ skill_root = ROOT / harness_path("skills")
88
+ skill_files = sorted(skill_root.glob("*/SKILL.md"))
89
+ require(skill_files, f"No workflow skill files found under {skill_root.relative_to(ROOT)}/")
90
90
 
91
91
  for path in skill_files:
92
92
  content = text(path)
@@ -101,8 +101,8 @@ def validate_skills() -> None:
101
101
 
102
102
 
103
103
  def validate_skill_template() -> None:
104
- path = ROOT / ".codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md"
105
- require(path.exists(), "Missing .codex/pjsdlc_managed/templates/SKILL_TEMPLATE.md")
104
+ path = ROOT / harness_path("pjsdlc_managed", "templates", "SKILL_TEMPLATE.md")
105
+ require(path.exists(), f"Missing {path.relative_to(ROOT)}")
106
106
  content = text(path)
107
107
  for section in SKILL_REQUIRED_SECTIONS:
108
108
  require(section in content, f"SKILL_TEMPLATE.md missing Chinese section: {section}")
@@ -111,9 +111,9 @@ def validate_skill_template() -> None:
111
111
 
112
112
 
113
113
  def validate_yaml_keys() -> None:
114
- lifecycle = load_yaml(".codex/state/lifecycle.yaml")
115
- tasks = load_yaml(".codex/state/plan.yaml")
116
- phase_contracts = load_yaml(".codex/pjsdlc_managed/policies/phase_contracts.yaml")
114
+ lifecycle = load_yaml(harness_path("state", "lifecycle.yaml"))
115
+ tasks = load_yaml(harness_path("state", "plan.yaml"))
116
+ phase_contracts = load_yaml(harness_path("pjsdlc_managed", "policies", "phase_contracts.yaml"))
117
117
 
118
118
  for key in YAML_KEYWORDS["lifecycle"]:
119
119
  require(key in lifecycle, f"lifecycle.yaml key was removed or translated: {key}")
File without changes
@@ -1,4 +1,5 @@
1
1
  import { doctor } from "./doctor.js";
2
+ import { inspectWorkflow } from "./inspect-workflow.js";
2
3
  import { init } from "./init.js";
3
4
  import { packageSource } from "./package-source.js";
4
5
  import { sync } from "./sync.js";
@@ -10,6 +11,7 @@ export const commands = {
10
11
  sync,
11
12
  upgrade,
12
13
  doctor,
14
+ "inspect-workflow": inspectWorkflow,
13
15
  validate,
14
16
  "validate-harness": (args) => validate(["validate-harness", ...args]),
15
17
  "validate-current": (args) => validate(["validate-current", ...args]),
@@ -31,6 +33,7 @@ export function help() {
31
33
  sync Materialize canonical assets into the workspace
32
34
  upgrade Run migrations and then sync
33
35
  doctor Diagnose project configuration and drift
36
+ inspect-workflow Lightly inspect workflow weight, fact-source drift, and handoff clarity
34
37
  validate <gate> Run a Harness validation gate
35
38
  validate-* Run a named gate directly, including validate-plan/uiux/design/dev/review/test/release/rfc
36
39
  package <subcommand> Maintain package canonical source`);
@@ -0,0 +1 @@
1
+ export declare function inspectWorkflow(args: string[]): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import { renderWorkflowInspection, renderWorkflowInspectionPrompt, runWorkflowInspection } from "../lib/workflow-inspector.js";
2
+ export async function inspectWorkflow(args) {
3
+ const options = {};
4
+ let json = false;
5
+ let prompt = false;
6
+ for (let index = 0; index < args.length; index += 1) {
7
+ const arg = args[index];
8
+ if (arg === "--json") {
9
+ json = true;
10
+ }
11
+ else if (arg === "--prompt") {
12
+ prompt = true;
13
+ }
14
+ else if (arg === "--recent-minutes") {
15
+ options.recentMinutes = parsePositiveNumber(requireValue(args, ++index, arg), arg);
16
+ }
17
+ else if (arg === "--recent-turns") {
18
+ options.recentTurns = parsePositiveNumber(requireValue(args, ++index, arg), arg);
19
+ }
20
+ else if (arg === "--estimated-tokens") {
21
+ options.estimatedTokens = parsePositiveNumber(requireValue(args, ++index, arg), arg);
22
+ }
23
+ else if (arg === "--help" || arg === "-h") {
24
+ printHelp();
25
+ return;
26
+ }
27
+ else {
28
+ throw new Error(`unknown inspect-workflow argument: ${arg}`);
29
+ }
30
+ }
31
+ const report = await runWorkflowInspection(process.cwd(), options);
32
+ if (json) {
33
+ console.log(JSON.stringify(report, null, 2));
34
+ }
35
+ else {
36
+ process.stdout.write(renderWorkflowInspection(report));
37
+ if (prompt) {
38
+ console.log("");
39
+ console.log(renderWorkflowInspectionPrompt(report));
40
+ }
41
+ }
42
+ if (report.decision === "BLOCKED") {
43
+ process.exitCode = 1;
44
+ }
45
+ }
46
+ function printHelp() {
47
+ console.log(`sdlc-harness inspect-workflow
48
+
49
+ Lightly inspect whether a user repository is running the Harness workflow as intended.
50
+
51
+ Options:
52
+ --json Emit a machine-readable report
53
+ --prompt Print an Agent self-inspection prompt after the measured report
54
+ --recent-minutes <n> Self-reported recent workflow orientation time
55
+ --recent-turns <n> Self-reported recent workflow conversation turns
56
+ --estimated-tokens <n> Self-reported recent workflow token estimate`);
57
+ }
58
+ function requireValue(args, index, flag) {
59
+ const value = args[index];
60
+ if (!value || value.startsWith("--")) {
61
+ throw new Error(`${flag} requires a value`);
62
+ }
63
+ return value;
64
+ }
65
+ function parsePositiveNumber(value, flag) {
66
+ const parsed = Number(value);
67
+ if (!Number.isFinite(parsed) || parsed < 0) {
68
+ throw new Error(`${flag} must be a non-negative number`);
69
+ }
70
+ return parsed;
71
+ }
@@ -2,17 +2,17 @@ import path from "node:path";
2
2
  import { DEFAULT_HARNESS_ROOT, HARNESS_JSON_CONFIG_PATH } from "./paths.js";
3
3
  import { pathExists, readText } from "./fs.js";
4
4
  export async function readHarnessRootConfig(projectRoot) {
5
- const explicitConfig = await readJsonConfig(path.join(projectRoot, HARNESS_JSON_CONFIG_PATH));
6
- const explicitValue = folderNameFromObject(explicitConfig);
7
- if (explicitValue) {
8
- return { harnessFolderName: normalizeHarnessFolderName(explicitValue), source: HARNESS_JSON_CONFIG_PATH };
9
- }
10
5
  const packageJson = await readJsonConfig(path.join(projectRoot, "package.json"));
11
6
  const packageConfig = packageJson && typeof packageJson === "object" ? packageJson.sdlcHarness : undefined;
12
7
  const packageValue = folderNameFromObject(packageConfig);
13
8
  if (packageValue) {
14
9
  return { harnessFolderName: normalizeHarnessFolderName(packageValue), source: "package.json#sdlcHarness" };
15
10
  }
11
+ const explicitConfig = await readJsonConfig(path.join(projectRoot, HARNESS_JSON_CONFIG_PATH));
12
+ const explicitValue = folderNameFromObject(explicitConfig);
13
+ if (explicitValue) {
14
+ return { harnessFolderName: normalizeHarnessFolderName(explicitValue), source: HARNESS_JSON_CONFIG_PATH };
15
+ }
16
16
  return { harnessFolderName: DEFAULT_HARNESS_ROOT, source: "default" };
17
17
  }
18
18
  export async function harnessRoot(projectRoot) {
package/dist/lib/init.js CHANGED
@@ -32,7 +32,7 @@ export async function runInit(projectRoot, options) {
32
32
  else {
33
33
  report.push(`kept existing ${configPath}`);
34
34
  }
35
- await createProjectState(projectRoot, root, report);
35
+ await createProjectState(projectRoot, root, options.adopt, report);
36
36
  await createDocs(projectRoot, report);
37
37
  const syncReport = await runSync(projectRoot);
38
38
  report.push(`sync changed=${syncReport.changed.length} skipped=${syncReport.skipped.length} blocked=${syncReport.blocked.length}`);
@@ -48,13 +48,16 @@ async function projectHasExistingFiles(projectRoot) {
48
48
  }
49
49
  return false;
50
50
  }
51
- async function createProjectState(projectRoot, root, report) {
51
+ async function createProjectState(projectRoot, root, adopt, report) {
52
52
  const stateRoot = path.join(projectRoot, root, "state");
53
53
  await ensureDir(stateRoot);
54
+ const lifecycle = adopt
55
+ ? `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "SPRINTING"\nactive_role: "developer"\nactive_skill: "pjsdlc_dev_sprint"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "REVIEWING"\n - "RFC_RECALIBRATION"\n - "BLOCKED"\n`
56
+ : `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "REQUIREMENT_GATHERING"\nactive_role: "product_manager"\nactive_skill: "pjsdlc_pm_prd"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "UI_UX_DESIGNING"\n - "BLOCKED"\n`;
54
57
  const files = [
55
58
  [
56
59
  harnessPath(root, "state", "lifecycle.yaml"),
57
- `project_name: "Project"\nversion: "v0.1"\ncurrent_phase: "SPRINTING"\nactive_role: "developer"\nactive_skill: "pjsdlc_dev_sprint"\ncurrent_milestone: "MVP"\nblocked_reason: ""\nsuspended_phase: ""\nallowed_next_phases:\n - "REVIEWING"\n - "RFC_RECALIBRATION"\n - "BLOCKED"\n`
60
+ lifecycle
58
61
  ],
59
62
  [harnessPath(root, "state", "plan.yaml"), `current_task_id: ""\nnext_task_sequence: 1\ntasks: []\n`],
60
63
  [harnessPath(root, "state", "plan.draft.yaml"), `next_task_sequence: 1\ntasks: []\n`],
@@ -1056,7 +1056,7 @@ async function validatePlanState(projectRoot, allowOpen) {
1056
1056
  if ("current_phase" in tasksData) {
1057
1057
  errors.push("plan.yaml must not define current_phase; lifecycle.yaml is the single source for current_phase");
1058
1058
  }
1059
- validateParallelExecutionContract(tasksData, currentPhase, errors);
1059
+ validateParallelExecutionContract(tasksData, currentPhase, errors, root);
1060
1060
  const tasks = Array.isArray(tasksData.tasks) ? tasksData.tasks : [];
1061
1061
  const nextTaskSequence = tasksData.next_task_sequence;
1062
1062
  if (!Number.isInteger(nextTaskSequence) || Number(nextTaskSequence) <= 0) {
@@ -1606,7 +1606,7 @@ async function validateSelfTestContractTechPlanBinding(projectRoot, task, normal
1606
1606
  }
1607
1607
  return errors;
1608
1608
  }
1609
- function validateParallelExecutionContract(plan, currentPhase, errors) {
1609
+ function validateParallelExecutionContract(plan, currentPhase, errors, root) {
1610
1610
  const contract = plan.parallel_execution;
1611
1611
  if (contract === undefined || contract === null)
1612
1612
  return;
@@ -1691,8 +1691,8 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
1691
1691
  if (!Array.isArray(worker.owned_paths) || worker.owned_paths.length === 0) {
1692
1692
  errors.push(`${prefix}.owned_paths must not be empty when writes_repo is true`);
1693
1693
  }
1694
- validateParallelWorkerPathLock(plan, worker, index, errors);
1695
- for (const owned of stringArray(worker.owned_paths).map(normalizeParallelPattern)) {
1694
+ validateParallelWorkerPathLock(plan, worker, index, errors, root);
1695
+ for (const owned of stringArray(worker.owned_paths).map((pattern) => normalizeParallelPattern(pattern, root))) {
1696
1696
  writeOwnedPaths.push({ index, path: owned });
1697
1697
  }
1698
1698
  }
@@ -1701,7 +1701,7 @@ function validateParallelExecutionContract(plan, currentPhase, errors) {
1701
1701
  for (let right = left + 1; right < writeOwnedPaths.length; right += 1) {
1702
1702
  const leftOwned = writeOwnedPaths[left];
1703
1703
  const rightOwned = writeOwnedPaths[right];
1704
- if (globPatternsOverlap(leftOwned.path, rightOwned.path)) {
1704
+ if (globPatternsOverlap(leftOwned.path, rightOwned.path, root)) {
1705
1705
  errors.push(`parallel_execution write worker owned_paths must not overlap: workers[${leftOwned.index}] ${leftOwned.path} vs workers[${rightOwned.index}] ${rightOwned.path}`);
1706
1706
  }
1707
1707
  }
@@ -1734,20 +1734,20 @@ function parallelRuntimeProvider(contract, errors) {
1734
1734
  }
1735
1735
  return String(runtime.provider ?? "");
1736
1736
  }
1737
- function validateParallelWorkerPathLock(plan, worker, index, errors) {
1737
+ function validateParallelWorkerPathLock(plan, worker, index, errors, root) {
1738
1738
  const currentTask = currentPlanTask(plan);
1739
1739
  if (!currentTask)
1740
1740
  return;
1741
- const taskAllowed = stringArray(currentTask.allowed_paths).map(normalizeParallelPattern);
1742
- const workerOwned = stringArray(worker.owned_paths).map(normalizeParallelPattern);
1743
- const workerForbidden = stringArray(worker.forbidden_paths).map(normalizeParallelPattern);
1744
- const protectedPatterns = PARALLEL_PROTECTED_WRITE_PATTERNS.map(normalizeParallelPattern);
1741
+ const taskAllowed = stringArray(currentTask.allowed_paths).map((pattern) => normalizeParallelPattern(pattern, root));
1742
+ const workerOwned = stringArray(worker.owned_paths).map((pattern) => normalizeParallelPattern(pattern, root));
1743
+ const workerForbidden = stringArray(worker.forbidden_paths).map((pattern) => normalizeParallelPattern(pattern, root));
1744
+ const protectedPatterns = PARALLEL_PROTECTED_WRITE_PATTERNS.map((pattern) => normalizeParallelPattern(pattern, root));
1745
1745
  for (const owned of workerOwned) {
1746
1746
  if (!matchesAny(owned, taskAllowed)) {
1747
1747
  errors.push(`parallel_execution.workers[${index}].owned_paths must be within current task allowed_paths: ${owned}`);
1748
1748
  }
1749
1749
  for (const forbidden of [...workerForbidden, ...protectedPatterns]) {
1750
- if (globPatternsOverlap(owned, forbidden)) {
1750
+ if (globPatternsOverlap(owned, forbidden, root)) {
1751
1751
  errors.push(`parallel_execution.workers[${index}].owned_paths must not overlap forbidden paths: ${owned} vs ${forbidden}`);
1752
1752
  }
1753
1753
  }
@@ -1761,22 +1761,22 @@ function currentPlanTask(plan) {
1761
1761
  function stringArray(value) {
1762
1762
  return Array.isArray(value) ? value.map((item) => String(item)) : [];
1763
1763
  }
1764
- function normalizeParallelPattern(pattern) {
1765
- return pattern.replace(/\\/g, "/").replaceAll("<harnessRoot>", ".codex");
1764
+ function normalizeParallelPattern(pattern, root) {
1765
+ return pattern.replace(/\\/g, "/").replaceAll("<harnessRoot>", root);
1766
1766
  }
1767
- function globPrefix(pattern) {
1768
- const normalized = normalizeParallelPattern(pattern);
1767
+ function globPrefix(pattern, root) {
1768
+ const normalized = normalizeParallelPattern(pattern, root);
1769
1769
  const positions = ["*", "[", "?"].map((token) => normalized.indexOf(token)).filter((index) => index >= 0);
1770
1770
  const prefix = positions.length > 0 ? normalized.slice(0, Math.min(...positions)) : normalized;
1771
1771
  return prefix.replace(/\/+$/, "");
1772
1772
  }
1773
- function globPatternsOverlap(left, right) {
1774
- const leftClean = normalizeParallelPattern(left);
1775
- const rightClean = normalizeParallelPattern(right);
1773
+ function globPatternsOverlap(left, right, root) {
1774
+ const leftClean = normalizeParallelPattern(left, root);
1775
+ const rightClean = normalizeParallelPattern(right, root);
1776
1776
  if (matchesGlob(leftClean, rightClean) || matchesGlob(rightClean, leftClean))
1777
1777
  return true;
1778
- const leftPrefix = globPrefix(leftClean);
1779
- const rightPrefix = globPrefix(rightClean);
1778
+ const leftPrefix = globPrefix(leftClean, root);
1779
+ const rightPrefix = globPrefix(rightClean, root);
1780
1780
  if (!leftPrefix || !rightPrefix)
1781
1781
  return leftPrefix === rightPrefix;
1782
1782
  return leftPrefix === rightPrefix || leftPrefix.startsWith(`${rightPrefix}/`) || rightPrefix.startsWith(`${leftPrefix}/`);
@@ -1784,6 +1784,7 @@ function globPatternsOverlap(left, right) {
1784
1784
  async function validateChangedPaths(projectRoot, plan, allowOpen) {
1785
1785
  if (!allowOpen)
1786
1786
  return [];
1787
+ const root = await harnessRoot(projectRoot);
1787
1788
  const currentTaskId = String(plan.current_task_id ?? "");
1788
1789
  if (!currentTaskId)
1789
1790
  return [];
@@ -1793,7 +1794,7 @@ async function validateChangedPaths(projectRoot, plan, allowOpen) {
1793
1794
  return [`current_task_id does not match a task: ${currentTaskId}`];
1794
1795
  if (!Array.isArray(task.allowed_paths))
1795
1796
  return [`${currentTaskId} must define allowed_paths`];
1796
- const patterns = task.allowed_paths.map((pattern) => String(pattern).replace("<harnessRoot>", ".codex"));
1797
+ const patterns = task.allowed_paths.map((pattern) => String(pattern).replace("<harnessRoot>", root));
1797
1798
  const changed = await changedFiles(projectRoot);
1798
1799
  const blocked = changed.filter((file) => !matchesAny(file, patterns));
1799
1800
  return blocked.length > 0 ? [`Changed files outside current task allowed_paths: ${blocked.join(", ")}`] : [];
@@ -0,0 +1,35 @@
1
+ export type InspectionDecision = "PASS" | "WARN" | "BLOCKED";
2
+ export type InspectionDataSource = "measured" | "inferred" | "self_reported" | "unavailable";
3
+ export interface WorkflowInspectionOptions {
4
+ recentMinutes?: number;
5
+ recentTurns?: number;
6
+ estimatedTokens?: number;
7
+ }
8
+ export interface WorkflowInspectionMetric {
9
+ id: string;
10
+ label: string;
11
+ value: string | number | boolean | null;
12
+ level: InspectionDecision;
13
+ data_source: InspectionDataSource;
14
+ details: string;
15
+ }
16
+ export interface WorkflowInspectionFinding {
17
+ severity: InspectionDecision;
18
+ code: string;
19
+ message: string;
20
+ recommendation: string;
21
+ data_source: InspectionDataSource;
22
+ }
23
+ export interface WorkflowInspectionReport {
24
+ decision: InspectionDecision;
25
+ harness_root: string;
26
+ harness_root_source: string;
27
+ current_phase: string;
28
+ current_task_id: string;
29
+ inspected_at: string;
30
+ metrics: WorkflowInspectionMetric[];
31
+ findings: WorkflowInspectionFinding[];
32
+ }
33
+ export declare function runWorkflowInspection(projectRoot: string, options?: WorkflowInspectionOptions): Promise<WorkflowInspectionReport>;
34
+ export declare function renderWorkflowInspection(report: WorkflowInspectionReport): string;
35
+ export declare function renderWorkflowInspectionPrompt(report: WorkflowInspectionReport): string;
@@ -0,0 +1,340 @@
1
+ import path from "node:path";
2
+ import { harnessPath, readHarnessRootConfig } from "./harness-root.js";
3
+ import { pathExists, readText } from "./fs.js";
4
+ import { parseYaml } from "./yaml.js";
5
+ import { runValidator } from "./validators.js";
6
+ const OPEN_TASK_STATUSES = new Set(["pending", "in_progress", "blocked", "pending_revision"]);
7
+ const HIGH_RISK_EVIDENCE_LEVELS = new Set(["external_provider_live", "deployed_runtime", "business_handoff_ready"]);
8
+ const HIGH_RISK_TARGETS = new Set(["cloud_vm", "managed_service", "browser", "worker"]);
9
+ export async function runWorkflowInspection(projectRoot, options = {}) {
10
+ const rootConfig = await readHarnessRootConfig(projectRoot);
11
+ const root = rootConfig.harnessFolderName;
12
+ const inspectedAt = new Date().toISOString();
13
+ const metrics = [];
14
+ const findings = [];
15
+ const lifecyclePath = path.join(projectRoot, harnessPath(root, "state", "lifecycle.yaml"));
16
+ const planPath = path.join(projectRoot, harnessPath(root, "state", "plan.yaml"));
17
+ const lifecycle = await readYamlObject(lifecyclePath);
18
+ const plan = await readYamlObject(planPath);
19
+ const currentPhase = String(lifecycle?.current_phase ?? "");
20
+ const currentTaskId = String(plan?.current_task_id ?? "");
21
+ const tasks = arrayOfRecords(plan?.tasks);
22
+ const openTasks = tasks.filter((task) => OPEN_TASK_STATUSES.has(String(task.status ?? "")));
23
+ const currentTask = currentTaskId ? tasks.find((task) => String(task.id ?? "") === currentTaskId) : undefined;
24
+ const inferredTask = currentTask ?? (openTasks.length === 1 ? openTasks[0] : undefined);
25
+ const planText = await readIfExists(planPath);
26
+ addMetric(metrics, findings, "workflow_weight.plan_lines", "plan.yaml line count", planText ? countLines(planText) : null, levelByThreshold(planText ? countLines(planText) : 0, 200, 500), "measured", "Active plan should stay short enough for an Agent to recover current work without reading historical execution flow.", "Keep only current/future task contracts in plan.yaml; move completed history to implementation docs, git or external release records.");
27
+ addMetric(metrics, findings, "workflow_weight.open_tasks", "open task count", openTasks.length, openTasks.length <= 1 ? "PASS" : "BLOCKED", "measured", "The Harness expects one active stage task at a time; multiple open tasks make recovery and allowed_paths ambiguous.", "Split sequentially or choose one current task, then remove or defer the other open task contracts.");
28
+ const docRefs = inferredTask ? collectTaskDocRefs(inferredTask) : [];
29
+ addMetric(metrics, findings, "workflow_weight.current_task_doc_refs", "current task document refs", inferredTask ? docRefs.length : null, levelByThreshold(docRefs.length, 5, 10), inferredTask ? "measured" : "unavailable", inferredTask
30
+ ? "Too many task-scoped docs usually means the Agent must hydrate too much context before acting."
31
+ : "No current/open task is selected, so task-scoped document weight is not measurable.", "Narrow the current task result_docs / implementation_doc / docs refs to the smallest handoff surface.");
32
+ const allowedPaths = inferredTask ? asStringList(inferredTask.allowed_paths) : [];
33
+ addMetric(metrics, findings, "workflow_weight.allowed_paths", "current task allowed_paths", inferredTask ? allowedPaths.length : null, levelByThreshold(allowedPaths.length, 12, 25), inferredTask ? "measured" : "unavailable", inferredTask
34
+ ? "Wide allowed_paths increase blast radius and make it harder to tell whether work stayed inside the task contract."
35
+ : "No current/open task is selected, so allowed_paths weight is not measurable.", "Split the task or replace broad globs with the concrete files/directories needed for this step.");
36
+ const workingNotesCount = inferredTask ? countWorkingNotes(inferredTask.working_notes) : null;
37
+ addMetric(metrics, findings, "workflow_weight.working_notes", "current task working_notes", workingNotesCount, workingNotesCount === null ? "PASS" : workingNotesCount <= 8 ? "PASS" : "BLOCKED", inferredTask ? "measured" : "unavailable", inferredTask
38
+ ? "working_notes is recovery-first scratch state; more than eight items usually means historical flow is leaking into active context."
39
+ : "No current/open task is selected, so working_notes weight is not measurable.", "Collapse notes to resume state, next step, blocker, last passed gate and do-not-retry facts; move history elsewhere.");
40
+ await addSelfTestReportMetric(projectRoot, inferredTask, metrics, findings);
41
+ await addLargestDocMetric(projectRoot, docRefs, metrics, findings);
42
+ await addValidatorMetric(projectRoot, "validate-harness", metrics, findings);
43
+ await addValidatorMetric(projectRoot, "validate-plan", metrics, findings);
44
+ await addTestingReadinessMetric(projectRoot, currentPhase, metrics, findings);
45
+ addLifecycleMetric(lifecycle, plan, currentPhase, currentTaskId, currentTask, openTasks, metrics, findings);
46
+ addRecoveryMetric(plan, inferredTask, metrics, findings);
47
+ addManualMetrics(options, metrics, findings);
48
+ const decision = combineDecision(metrics.map((metric) => metric.level));
49
+ return {
50
+ decision,
51
+ harness_root: root,
52
+ harness_root_source: rootConfig.source,
53
+ current_phase: currentPhase || "UNKNOWN",
54
+ current_task_id: currentTaskId,
55
+ inspected_at: inspectedAt,
56
+ metrics,
57
+ findings
58
+ };
59
+ }
60
+ export function renderWorkflowInspection(report) {
61
+ const lines = [
62
+ `Workflow inspection: ${report.decision}`,
63
+ `harness root: ${report.harness_root} (${report.harness_root_source})`,
64
+ `current phase: ${report.current_phase}`,
65
+ `current task: ${report.current_task_id || "(none)"}`,
66
+ "",
67
+ "Metrics:",
68
+ ...report.metrics.map((metric) => {
69
+ const value = metric.value === null ? "unknown" : String(metric.value);
70
+ return `- ${metric.level} ${metric.id}: ${value} [${metric.data_source}] - ${metric.details}`;
71
+ })
72
+ ];
73
+ if (report.findings.length > 0) {
74
+ lines.push("", "Findings:");
75
+ for (const finding of report.findings) {
76
+ lines.push(`- ${finding.severity} ${finding.code}: ${finding.message}`);
77
+ lines.push(` next: ${finding.recommendation}`);
78
+ }
79
+ }
80
+ return `${lines.join("\n")}\n`;
81
+ }
82
+ export function renderWorkflowInspectionPrompt(report) {
83
+ return [
84
+ "# Workflow Self-Inspection Prompt",
85
+ "",
86
+ "你是用户仓库里的 Harness workflow self-inspection agent。请结合上面的 measured / inferred 指标和你最近一次真实执行经历,判断工作流是否符合预期。",
87
+ "",
88
+ "必须区分数据来源:",
89
+ "- measured: 脚本真实读到的文件、字段、validator 结果或命令耗时。",
90
+ "- inferred: 脚本只能从体量、字段缺失、重复或过长现象推断。",
91
+ "- self_reported: 你根据最近一次执行过程填写的耗时、turns、估算 token 和反复回读情况。",
92
+ "- unavailable: 当前环境没有 telemetry,不能伪造精确 token 或真实执行耗时。",
93
+ "",
94
+ "请回答:",
95
+ "1. 当前 workflow 从哪里进、当前任务是什么、下一步是什么?如果不能在 2 分钟内回答,记为 WARN。",
96
+ "2. 最近一次只是为了理解 workflow / 找事实源花了多少分钟、多少轮对话、估算多少 tokens?>15 分钟或 >10k tokens 记 WARN;>30 分钟或 >20k tokens 记 BLOCKED 候选。",
97
+ "3. 是否有会改变下一步动作的判断只埋在 notes/evidence/appendix/long doc 中,而没有 promoted 到 hard constraint、do_not_retry 或短 runbook 顶部?",
98
+ "4. 当前 Development Self-Test Report / TEST_CASES / TEST_REPORT 是否像可执行交接卡,而不是 debug log、operator log、evidence dump 或历史流水?",
99
+ "5. Review / Testing 是否能直接消费入口、核心路径、checkpoint、observable exit 和 evidence refs,而不用重新发明 runtime?",
100
+ "",
101
+ "输出格式:",
102
+ "- Decision: PASS | WARN | BLOCKED",
103
+ "- Data gaps: 列出 unavailable 或只能 self_reported 的指标",
104
+ "- Findings: 每条注明 measured / inferred / self_reported / unavailable",
105
+ "- Next action: 最小修复动作,不要扩展工作流体量",
106
+ "",
107
+ `Current script decision: ${report.decision}`,
108
+ `Current phase: ${report.current_phase}`,
109
+ `Current task: ${report.current_task_id || "(none)"}`
110
+ ].join("\n");
111
+ }
112
+ async function addValidatorMetric(projectRoot, gate, metrics, findings) {
113
+ try {
114
+ const report = await runValidator(projectRoot, gate);
115
+ addMetric(metrics, findings, `fact_source_alignment.${gate}`, gate, report.errors.length, report.errors.length === 0 ? "PASS" : "BLOCKED", "measured", report.errors.length === 0 ? `${gate} reported no errors.` : report.errors.join("; "), `Run npx sdlc-harness ${gate} and fix the reported source-of-truth drift.`);
116
+ }
117
+ catch (error) {
118
+ addMetric(metrics, findings, `fact_source_alignment.${gate}`, gate, null, "BLOCKED", "measured", error instanceof Error ? error.message : String(error), `Restore files required by ${gate}, then rerun inspect-workflow.`);
119
+ }
120
+ }
121
+ async function addTestingReadinessMetric(projectRoot, currentPhase, metrics, findings) {
122
+ const reportPath = path.join(projectRoot, ".docs/07_test/TEST_REPORT.md");
123
+ const casesPath = path.join(projectRoot, ".docs/07_test/TEST_CASES.md");
124
+ const shouldValidate = currentPhase === "TESTING" || (await pathExists(reportPath)) || (await pathExists(casesPath));
125
+ if (!shouldValidate) {
126
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", null, "PASS", "unavailable", "No TESTING fact source exists yet; validate-test readiness is not evaluated for this phase.", "Create TEST_CASES.md / TEST_REPORT.md only when TESTING has executable entry/exit facts.");
127
+ return;
128
+ }
129
+ try {
130
+ const report = await runValidator(projectRoot, "validate-test");
131
+ const level = report.errors.length === 0 ? "PASS" : currentPhase === "TESTING" ? "BLOCKED" : "WARN";
132
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", report.errors.length, level, "measured", report.errors.length === 0 ? "TESTING fact sources are structurally consumable." : report.errors.join("; "), "Keep TEST_CASES as reusable test design and TEST_REPORT as execution evidence; fix missing or drifting TC references.");
133
+ }
134
+ catch (error) {
135
+ addMetric(metrics, findings, "testing_readiness.validate-test", "validate-test readiness", null, currentPhase === "TESTING" ? "BLOCKED" : "WARN", "measured", error instanceof Error ? error.message : String(error), "Restore TESTING fact sources or remove stale partial test files until TESTING is in scope.");
136
+ }
137
+ }
138
+ function addLifecycleMetric(lifecycle, plan, currentPhase, currentTaskId, currentTask, openTasks, metrics, findings) {
139
+ let level = "PASS";
140
+ const problems = [];
141
+ if (!currentPhase) {
142
+ level = "BLOCKED";
143
+ problems.push("lifecycle.yaml does not define current_phase");
144
+ }
145
+ if (plan && "current_phase" in plan) {
146
+ level = "BLOCKED";
147
+ problems.push("plan.yaml duplicates current_phase");
148
+ }
149
+ if (currentTaskId && !currentTask) {
150
+ level = "BLOCKED";
151
+ problems.push("current_task_id does not match any task");
152
+ }
153
+ if (!currentTaskId && openTasks.length === 1 && currentPhase !== "COMPLETED") {
154
+ level = maxLevel(level, "WARN");
155
+ problems.push("one open task exists but current_task_id is empty");
156
+ }
157
+ const allowedNext = Array.isArray(lifecycle?.allowed_next_phases) ? lifecycle?.allowed_next_phases.length : 0;
158
+ if (allowedNext === 0 && !["COMPLETED", "UNKNOWN"].includes(currentPhase || "UNKNOWN")) {
159
+ level = maxLevel(level, "WARN");
160
+ problems.push("allowed_next_phases is empty");
161
+ }
162
+ addMetric(metrics, findings, "handoff_clarity.lifecycle", "lifecycle and current task clarity", problems.length, level, "measured", problems.length === 0 ? "Lifecycle and current task pointers are coherent." : problems.join("; "), "Keep current_phase only in lifecycle.yaml, current task only in plan.yaml, and regenerate allowed_next_phases through transition.py.");
163
+ }
164
+ function addRecoveryMetric(plan, currentTask, metrics, findings) {
165
+ if (!currentTask || !isHighRiskTask(currentTask)) {
166
+ addMetric(metrics, findings, "recovery_safety.resume_capsule", "high-risk resume capsule", null, "PASS", currentTask ? "inferred" : "unavailable", currentTask ? "Current task is not classified as high-risk by evidence_level or target_runtime_environment." : "No current/open task is selected.", "When a task becomes live/runtime/high-risk, add resume_capsule with canonical path, next step, blocker, do-not-retry and recovery refs.");
167
+ return;
168
+ }
169
+ const capsule = isRecord(plan?.resume_capsule) ? plan?.resume_capsule : undefined;
170
+ const problems = [];
171
+ if (!capsule)
172
+ problems.push("resume_capsule missing");
173
+ if (capsule && asStringList(capsule.do_not_retry).length === 0)
174
+ problems.push("do_not_retry missing");
175
+ if (capsule && asStringList(capsule.recovery_refs).length === 0)
176
+ problems.push("recovery_refs missing");
177
+ addMetric(metrics, findings, "recovery_safety.resume_capsule", "high-risk resume capsule", problems.length, problems.length === 0 ? "PASS" : "BLOCKED", "measured", problems.length === 0 ? "High-risk task has resume-first recovery state." : problems.join("; "), "Record resume_capsule before continuing high-risk runtime/live work.");
178
+ }
179
+ function addManualMetrics(options, metrics, findings) {
180
+ addManualMetric(metrics, findings, "self_reported.recent_minutes", "recent workflow minutes", options.recentMinutes, 15, 30);
181
+ addManualMetric(metrics, findings, "self_reported.recent_turns", "recent workflow turns", options.recentTurns, 6, 10);
182
+ addManualMetric(metrics, findings, "self_reported.estimated_tokens", "estimated workflow tokens", options.estimatedTokens, 10000, 20000);
183
+ if (options.estimatedTokens === undefined) {
184
+ addMetric(metrics, findings, "workflow_weight.actual_tokens", "actual model tokens", null, "PASS", "unavailable", "No local token telemetry was provided; inspect-workflow will not invent a precise token number.", "Pass --estimated-tokens when the Agent/client has a reliable estimate, or answer the --prompt self-check.");
185
+ }
186
+ }
187
+ function addManualMetric(metrics, findings, id, label, value, warnThreshold, blockThreshold) {
188
+ if (value === undefined)
189
+ return;
190
+ addMetric(metrics, findings, id, label, value, levelByThreshold(value, warnThreshold, blockThreshold), "self_reported", `${label} was supplied by the user/Agent, not measured from local files.`, "If this is WARN/BLOCKED, reduce workflow context weight before continuing.");
191
+ }
192
+ async function addSelfTestReportMetric(projectRoot, currentTask, metrics, findings) {
193
+ const implementationDoc = typeof currentTask?.implementation_doc === "string" ? currentTask.implementation_doc.trim() : "";
194
+ if (!implementationDoc) {
195
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "PASS", "unavailable", "No current implementation_doc is selected, so self-test report size is not measurable.", "When SPRINTING is active, keep the report as a short handoff card.");
196
+ return;
197
+ }
198
+ const fullPath = path.join(projectRoot, implementationDoc);
199
+ const text = await readIfExists(fullPath);
200
+ if (!text) {
201
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "BLOCKED", "measured", `implementation_doc is missing: ${implementationDoc}`, "Create or point to the current task implementation doc.");
202
+ return;
203
+ }
204
+ const section = markdownSection(text, ["development self-test report", "开发自测报告"]);
205
+ if (!section) {
206
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", null, "PASS", "unavailable", "No Development Self-Test Report section is present in the current implementation doc.", "Add the report when the current task has a self_test_contract.");
207
+ return;
208
+ }
209
+ const lines = countLines(section);
210
+ const highRisk = isHighRiskTask(currentTask);
211
+ const level = levelByThreshold(lines, highRisk ? 120 : 80, highRisk ? 180 : 120);
212
+ addMetric(metrics, findings, "workflow_weight.self_test_report_lines", "Development Self-Test Report lines", lines, level, "measured", highRisk ? "High-risk report line count uses 120/180 thresholds." : "Ordinary report line count uses 80/120 thresholds.", "Move debug/operator/evidence/exploration detail to runbook, evidence index or appendix; keep the report as a handoff card.");
213
+ }
214
+ async function addLargestDocMetric(projectRoot, docRefs, metrics, findings) {
215
+ let largest = 0;
216
+ let largestRef = "";
217
+ for (const ref of docRefs) {
218
+ const text = await readIfExists(path.join(projectRoot, ref));
219
+ if (!text)
220
+ continue;
221
+ const lines = countLines(text);
222
+ if (lines > largest) {
223
+ largest = lines;
224
+ largestRef = ref;
225
+ }
226
+ }
227
+ addMetric(metrics, findings, "drift_risk.largest_current_doc_lines", "largest current task doc lines", largestRef ? largest : null, largest > 700 ? "WARN" : largest > 300 ? "WARN" : "PASS", largestRef ? "inferred" : "unavailable", largestRef ? `${largestRef} is the largest current task doc.` : "No current task document refs were available.", "If a current handoff doc keeps growing, split durable decisions into ADRs, runtime steps into runbooks, and evidence bodies into evidence indexes.");
228
+ }
229
+ function addMetric(metrics, findings, id, label, value, level, dataSource, details, recommendation) {
230
+ metrics.push({ id, label, value, level, data_source: dataSource, details });
231
+ if (level !== "PASS") {
232
+ findings.push({
233
+ severity: level,
234
+ code: id,
235
+ message: details,
236
+ recommendation,
237
+ data_source: dataSource
238
+ });
239
+ }
240
+ }
241
+ async function readYamlObject(filePath) {
242
+ if (!(await pathExists(filePath)))
243
+ return undefined;
244
+ const parsed = parseYaml(await readText(filePath));
245
+ return isRecord(parsed) ? parsed : undefined;
246
+ }
247
+ async function readIfExists(filePath) {
248
+ return (await pathExists(filePath)) ? readText(filePath) : undefined;
249
+ }
250
+ function levelByThreshold(value, warnThreshold, blockThreshold) {
251
+ if (value > blockThreshold)
252
+ return "BLOCKED";
253
+ if (value > warnThreshold)
254
+ return "WARN";
255
+ return "PASS";
256
+ }
257
+ function combineDecision(levels) {
258
+ if (levels.includes("BLOCKED"))
259
+ return "BLOCKED";
260
+ if (levels.includes("WARN"))
261
+ return "WARN";
262
+ return "PASS";
263
+ }
264
+ function maxLevel(left, right) {
265
+ return combineDecision([left, right]);
266
+ }
267
+ function countLines(text) {
268
+ if (!text)
269
+ return 0;
270
+ return text.split(/\r?\n/).length;
271
+ }
272
+ function countWorkingNotes(value) {
273
+ if (Array.isArray(value))
274
+ return value.length;
275
+ if (typeof value === "string" && value.trim())
276
+ return 1;
277
+ return 0;
278
+ }
279
+ function collectTaskDocRefs(task) {
280
+ const refs = new Set();
281
+ for (const ref of asStringList(task.implementation_doc))
282
+ refs.add(normalizeDocRef(ref));
283
+ for (const ref of asStringList(task.result_docs))
284
+ refs.add(normalizeDocRef(ref));
285
+ if (isRecord(task.docs)) {
286
+ for (const value of Object.values(task.docs)) {
287
+ for (const ref of asStringList(value))
288
+ refs.add(normalizeDocRef(ref));
289
+ }
290
+ }
291
+ return [...refs].filter(Boolean);
292
+ }
293
+ function normalizeDocRef(ref) {
294
+ return ref.replace(/\\/g, "/").replace(/^\.\//, "");
295
+ }
296
+ function arrayOfRecords(value) {
297
+ return Array.isArray(value) ? value.filter(isRecord) : [];
298
+ }
299
+ function asStringList(value) {
300
+ if (Array.isArray(value))
301
+ return value.map((item) => String(item).trim()).filter(Boolean);
302
+ if (typeof value === "string" && value.trim())
303
+ return [value.trim()];
304
+ return [];
305
+ }
306
+ function isRecord(value) {
307
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
308
+ }
309
+ function isHighRiskTask(task) {
310
+ const evidence = isRecord(task.evidence_level) ? String(task.evidence_level.required ?? "") : "";
311
+ const target = isRecord(task.target_runtime_environment) ? String(task.target_runtime_environment.kind ?? "") : "";
312
+ return HIGH_RISK_EVIDENCE_LEVELS.has(evidence) || HIGH_RISK_TARGETS.has(target);
313
+ }
314
+ function markdownSection(text, headerTerms) {
315
+ const lines = text.split(/\r?\n/);
316
+ let start = -1;
317
+ let level = 0;
318
+ for (let index = 0; index < lines.length; index += 1) {
319
+ const match = lines[index].match(/^(#{1,6})\s+(.+)$/);
320
+ if (!match)
321
+ continue;
322
+ const title = match[2].toLowerCase();
323
+ if (headerTerms.some((term) => title.includes(term.toLowerCase()))) {
324
+ start = index;
325
+ level = match[1].length;
326
+ break;
327
+ }
328
+ }
329
+ if (start === -1)
330
+ return undefined;
331
+ let end = lines.length;
332
+ for (let index = start + 1; index < lines.length; index += 1) {
333
+ const match = lines[index].match(/^(#{1,6})\s+/);
334
+ if (match && match[1].length <= level) {
335
+ end = index;
336
+ break;
337
+ }
338
+ }
339
+ return lines.slice(start, end).join("\n");
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-project-sdlc",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "CLI and canonical assets for the AI SDLC Harness workflow.",
5
5
  "type": "module",
6
6
  "bin": {