@istuen/pt 0.1.1 → 0.1.2

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.
@@ -1,3 +1,4 @@
1
+ import type { PackSource } from "../schema.js";
1
2
  /** manifest 解析结果。ok=false 表示文件不存在/解析失败/校验不过——调用方走默认值。 */
2
3
  export interface ParsedManifest {
3
4
  /** 文件存在且 YAML 解析成功(name/version/description 校验失败仍 ok=true,错误进 warnings)。 */
@@ -18,6 +19,10 @@ export interface ParsedManifest {
18
19
  * - name 非 kebab-case / 保留名冲突 → warnings + name 丢弃(走 basename)
19
20
  * - version 非 semver → warnings + version 丢弃(走 "0.0.0")
20
21
  *
22
+ * v15.x builtin 特例:source="builtin" 时 manifest.name 等于保留名合法
23
+ * (位置 alias @pt = 身份 alias 合一;builtin pack 的"身份"就是"内置")。
24
+ * project/global/settings pack 仍禁用保留名(保护位置 slot)。
25
+ *
21
26
  * 永远不抛异常(§2.2 校验规则:解析失败当无 manifest 处理,不阻断加载)。
22
27
  */
23
- export declare function parseManifest(rootDir: string): Promise<ParsedManifest>;
28
+ export declare function parseManifest(rootDir: string, source?: PackSource): Promise<ParsedManifest>;
@@ -36,9 +36,13 @@ const KEBAB_RE = /^[a-z0-9-]{1,64}$/;
36
36
  * - name 非 kebab-case / 保留名冲突 → warnings + name 丢弃(走 basename)
37
37
  * - version 非 semver → warnings + version 丢弃(走 "0.0.0")
38
38
  *
39
+ * v15.x builtin 特例:source="builtin" 时 manifest.name 等于保留名合法
40
+ * (位置 alias @pt = 身份 alias 合一;builtin pack 的"身份"就是"内置")。
41
+ * project/global/settings pack 仍禁用保留名(保护位置 slot)。
42
+ *
39
43
  * 永远不抛异常(§2.2 校验规则:解析失败当无 manifest 处理,不阻断加载)。
40
44
  */
41
- export async function parseManifest(rootDir) {
45
+ export async function parseManifest(rootDir, source) {
42
46
  const file = join(rootDir, "pt-asset-pack.yaml");
43
47
  let raw;
44
48
  try {
@@ -66,10 +70,13 @@ export async function parseManifest(rootDir) {
66
70
  if (typeof data.name === "string" && data.name.trim()) {
67
71
  const n = data.name.trim();
68
72
  if (!KEBAB_RE.test(n)) {
69
- warnings.push(`manifest.name "${n}" not kebab-case, falling back to basename`);
73
+ warnings.push(`[repair-required][name-kebab] manifest.name "${n}" not kebab-case, fallback: basename. Auto-fix: /manual:pack-management#pack-repair`);
70
74
  }
71
- else if (RESERVED_NAMES.has(n)) {
72
- warnings.push(`manifest.name "${n}" is reserved, falling back to basename`);
75
+ else if (RESERVED_NAMES.has(n) && source !== "builtin") {
76
+ // v15.x builtin 特例:source="builtin" 时保留名(prj/gbl/pt)合法——位置 alias = 身份 alias 合一。
77
+ // 其他 source(project/global/settings)仍禁用保留名:位置 slot 是 reserved pack 的,身份 alias
78
+ // 不能占用。project pack 应该用跨项目身份名(如 pt-internal)走身份寻址,不是用保留名。
79
+ warnings.push(`[repair-required][name-reserved] manifest.name "${n}" is reserved, fallback: basename. Auto-fix: /manual:pack-management#pack-repair`);
73
80
  }
74
81
  else {
75
82
  name = n;
@@ -79,7 +86,7 @@ export async function parseManifest(rootDir) {
79
86
  if (typeof data.version === "string" && data.version.trim()) {
80
87
  const v = data.version.trim();
81
88
  if (!SEMVER_RE.test(v)) {
82
- warnings.push(`manifest.version "${v}" not semver, treating as "0.0.0"`);
89
+ warnings.push(`[repair-required][version-semver] manifest.version "${v}" not semver, default: "0.0.0". Auto-fix: /manual:pack-management#pack-repair`);
83
90
  }
84
91
  else {
85
92
  version = v;
@@ -65,11 +65,25 @@ export class MdFilePack {
65
65
  * - reserved pack 无 manifest → 退化到 RESERVED_FALLBACK_NAME.get(source)
66
66
  * - 非 reserved pack 无 manifest → basename 兜底 */
67
67
  static async create(args) {
68
- const manifest = await parseManifest(args.rootDir);
68
+ const manifest = await parseManifest(args.rootDir, args.source);
69
69
  const dirName = pathBasename(args.rootDir);
70
70
  // manifest warnings 上抛 notify(不阻断——parseManifest 已容错)
71
- if (manifest.warnings.length > 0 && args.adapterCtx?.notify) {
72
- args.adapterCtx.notify(`Pt: pack "${dirName}" manifest 警告:${manifest.warnings.join("; ")}`, "warning");
71
+ if (args.adapterCtx?.notify) {
72
+ if (manifest.warnings.length > 0) {
73
+ // 检测 [repair-required] 前缀的 warnings——加 manual hint 引导 LLM 调 /manual:pack-management
74
+ const hasRepairRequired = manifest.warnings.some((w) => w.startsWith("[repair-required]"));
75
+ const manualHint = hasRepairRequired
76
+ ? "\n→ 调 /manual:pack-management 让 LLM 自动修复"
77
+ : "";
78
+ args.adapterCtx.notify(`Pt: pack "${dirName}" manifest 警告:${manifest.warnings.join("; ")}${manualHint}`, "warning");
79
+ }
80
+ else if (!manifest.ok && args.source === "settings") {
81
+ // v15.x §2.4.2 + pack-naming:reserved pack(project/global/builtin)无 manifest 是
82
+ // back-compat 退化路径(退到位置别名 prj/gbl/pt),设计预期——silent。
83
+ // 只有 settings pack(用户主动声明)无 manifest 时才通知:basename 兜底"易碎",
84
+ // 建议加 pt-asset-pack.yaml 让 pack 成为自描述实体,/manual:pack-management#pack-create。
85
+ args.adapterCtx.notify(`Pt: pack "${dirName}" 无 manifest(basename 兜底)— 建议添加 pt-asset-pack.yaml 让 pack 成为自描述实体。/manual:pack-management#pack-create`, "warning");
86
+ }
73
87
  }
74
88
  // name 解析优先级(§2.4.2):
75
89
  // 1. manifest.name(身份 alias 优先)
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: pack-management
3
+ ---
4
+
5
+ # pack-management
6
+
7
+ Pack 全生命周期管理 domain——创建 / 调整 / 迭代 / 迁移 / 修复。builtin profile
8
+ `guide` 引用本 domain,用户 `/manual:pack-management` 触发任意 FlowTemplate,
9
+ LLM 跟着 Scene + Rules + Flow + Checklist 走即可。
10
+
11
+ **核心立场**:manifest 是 pack 的**身份证 + 说明书**——没有 manifest 的目录只是
12
+ back-compat 兜底状态(参见 `pt-pack-location-vs-identity-alias`),不是真正的
13
+ pt pack。缺 manifest / 写错时,跟着 `pack-repair` flow 走即可自描述修复。
14
+
15
+ ## Scene
16
+
17
+ ### pack-lifecycle
18
+ - desc: Pack 五态——创建(init)→ 迭代(add/change asset)→ 迁移(schema 升级)→ 废弃(deprecate)→ 修复(manifest 缺失/错误回退到创建态)。每态对应一个 FlowTemplate。
19
+
20
+ ### pack-structure
21
+ - desc: Pt pack 标准目录结构——必须含 `domains/` + `blueprints/` + `profiles/` 三个子目录之**一**(至少一个);可选 `pt-asset-pack.yaml` manifest(**强烈建议始终提供**——manifest 是身份证,不是装饰)
22
+
23
+ ### pack-sources
24
+ - desc: v15.x Pack 有 4 类来源——project(`<cwd>/.pt/assets/`)/ settings(`.pi/settings.json` 的 `pt.asset-packs[]`,PR4 启用)/ global(`~/.pt/assets/`)/ builtin(`src/builtin/assets/`,随 npm 包发布)。每类走相同 MdFilePack 管线,差别只在入口函数和 name fallback 表。
25
+
26
+ ### pack-manifest
27
+ - desc: `pt-asset-pack.yaml` 是 pack 自描述 manifest——三个字段:`name`(kebab-case 身份 alias,跨项目寻址用)/ `version`(semver,缓存失效标识)/ `description`(人类可读说明,UI 展示用)。字段全部可选——但**强烈建议始终提供**(back-compat fallback 是过渡方案)。
28
+
29
+ ### validation-codes
30
+ - desc: validatePack 返回的错误码——`dir-not-found`(路径不存在)/ `no-asset-subdir`(无 asset 子目录)/ `load-failed`(加载抛异常)/ `manifest-warnings`(manifest 缺失/解析失败/字段校验失败,**不阻断**但会走 notify + manual hint)
31
+
32
+ ### pack-naming
33
+ - desc: 寻址双层语义——位置 alias(`@prj`/`@gbl`/`@pt`,固定 3 slot 物理位置指针,reserved pack 用)+ 身份 alias(`@<manifest-name>`,跨项目寻址用,settings pack 用)。manifest.name 走身份 alias,无 manifest 时 reserved pack 退到位置 alias,settings pack 退到 basename(**易碎**,建议始终提供 manifest)。
34
+
35
+ ### pack-reserved-vs-external
36
+ - desc: Pt Pack 体系按"是否有固定位置约定"分两类——
37
+
38
+ **① 固定位置 slot(reserved,3 个 slot,本质是位置约定)**:
39
+ - `@prj` → 项目 cwd 下的 `.pt/assets/`(项目 pack)
40
+ - `@gbl` → 用户 home 下的 `~/.pt/assets/`(用户私有通用 pack)
41
+ - `@pt` → npm 包内嵌 `src/builtin/assets/`(工具内嵌 pack)
42
+
43
+ 三者都是"外部 pack"——它们不在 pt 工具代码本身里,是用户在文件系统 / npm 包里的资产。reserved 不是因为"内置",而是因为**有固定的物理位置约定**——pt 工具预先知道去哪里找它们,不用用户声明路径。
44
+
45
+ **② 用户/外部 Pt Packs(settings,通过 manifest.name 走身份 alias)**:
46
+ - 用户主动声明在 `.pi/settings.json` 的 `pt.asset-packs[]` 中
47
+ - 身份由 manifest.name 决定(自由命名,避开保留名)
48
+ - 没有固定位置约定——可以是任意路径、任意名字
49
+ - 包含第三方 pack(团队 / 公司 / 社区发布的)
50
+
51
+ **关键区别**:① 有固定位置(物理约定)→ 工具自己找;② 有固定身份(manifest.name)→ 用户声明路径找。
52
+
53
+ ### pack-builtin-special
54
+ - desc: `@pt` 是最特殊的 reserved pack——位置 alias + 身份 alias 合一。
55
+
56
+ - builtin pack 的"身份"就是"内置",**不需要跨项目身份寻址**(位置固定 = `pt`)
57
+ - 位置 slot `@pt` 已经是其完整身份表达
58
+ - manifest.name="pt" 合法(保留名作为身份 alias)——位置 alias = 身份 alias 合一
59
+ - working set 双索引 key 重合:`@pt/foo` 的 location 和 identity entry 指向同一份 asset
60
+ - 其他 reserved pack(prj/gbl)不享受合一——项目/全局 pack 仍用跨项目身份名(`pt-internal` 等)
61
+
62
+ 设计动机:v10.x 时期 builtin pack.name="pt"(位置别名退化)是 back-compat 默认行为;本轮修复把这个行为**显式声明**为设计意图,而不是引入 `pt-builtin` 之类冗余名字。保留名规则对 builtin 特例放行(`parseManifest(rootDir, "builtin")`),project/global/settings pack 仍禁用保留名。
63
+
64
+ ## Flows
65
+
66
+ ### pack-create
67
+ - argument-hint: <pack-root>
68
+ - intent: 从零创建新 pack——创建目录结构 + 写 `pt-asset-pack.yaml` 模板(name 走 kebab-case 提示用户输入,version 默认 `0.1.0`)
69
+ - vars: [pack-root]
70
+ - step: 确认 pack-root 不存在或为空(避免覆盖现有 pack)
71
+ - step: `mkdir -p <pack-root>/{domains,blueprints,profiles}`
72
+ - step: 写 `<pack-root>/pt-asset-pack.yaml` 模板:
73
+ - step: ```yaml
74
+ name: <kebab-case-name> # 跨项目身份 alias
75
+ version: 0.1.0 # semver
76
+ description: <一句话说明>
77
+ ```
78
+ - step: 校验:name 满足 `[a-z0-9-]{1,64}` 且非保留字(prj/gbl/pt/project/global/builtin)
79
+ - step: 校验:version 满足 `^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`
80
+ - step: 若作为 settings pack 暴露:在 `.pi/settings.json` 加 `pt.asset-packs: [{ "path": "<pack-root>" }]`
81
+ - step: 验证:`/pt status` → 新 pack 显示在 Pack 健康行
82
+
83
+ ### pack-repair
84
+ - argument-hint: (无)
85
+ - intent: Manifest 缺失/错误时自描述修复——按错误码分类处置后重启 session 验证
86
+ - vars: []
87
+ - step: `/pt status` 查看 pack 健康状态 + manifest warnings(如果有 `[repair-required]` 前缀,说明需要修复)
88
+ - step: 按错误码分类处置:
89
+ - step: - manifest 缺失:走 `pack-create` flow 创建 `pt-asset-pack.yaml`(保留现有 assets 子目录)
90
+ - step: - manifest 解析失败:检查 YAML 语法(top-level 必须是 mapping)
91
+ - step: - name 非 kebab-case:改名满足 `[a-z0-9-]{1,64}`
92
+ - step: - name 是保留字:改用其他身份 alias(不能是 prj/gbl/pt/project/global/builtin)
93
+ - step: - version 非 semver:改成 `^\d+\.\d+\.\d+` 格式
94
+ - step: - dir-not-found:`mkdir -p <pack-root>/{domains,blueprints,profiles}`
95
+ - step: - no-asset-subdir:至少创建一个 asset 子目录
96
+ - step: - load-failed:检查资产文件格式(`.md` frontmatter 合法 / `.blueprint.yaml` 语法正确)
97
+ - step: 修复后重启 pi session(`projectPackDegraded` 在 session_start 重新校验时清零)
98
+ - step: `/pt status` 确认 pack 健康(✅,无 `[repair-required]` warnings)
99
+
100
+ ### pack-iterate
101
+ - argument-hint: <pack-root>
102
+ - intent: Pack 内容迭代——添加 / 修改 / 删除 asset,并按需 bump version
103
+ - vars: [pack-root]
104
+ - step: 修改 asset 文件(`domains/*.md` / `blueprints/*.blueprint.yaml` / `profiles/*.profile.md`)
105
+ - step: 资产改动后删 `.pt/cache/agent-contexts/*.agent-context.md`(强制重编译,sourceHash 自动失效)
106
+ - step: 修改 manifest.version——breaking change 升 major,向后兼容加 feature 升 minor,bug fix 升 patch
107
+ - step: 同步 description(如果 pack 用途变化)
108
+ - step: 验证:`/pt status` → pack version 已更新 → 跑 `npm run verify` 全测试通过
109
+
110
+ ### pack-migrate
111
+ - argument-hint: <pack-root>
112
+ - intent: Pt schema 升级时 pack 内容迁移——按迁移指南更新 asset 格式
113
+ - vars: [pack-root]
114
+ - step: 读迁移指南(`.pt/docs/migrations/<from>-to-<to>-*.md` 或 `/manual:pt-asset-migration`)
115
+ - step: 按指南转换每个 asset 文件(如 v9.0 → v9.1 modules-to-profile-complete:Blueprint `modules` 字段删除,迁到 Profile `### Modules`)
116
+ - step: 跑 `pt_check` LLM 工具检查 pack 健康(missing-modules / dangling-blueprint-ref / empty-segment / orphan-h2 等)
117
+ - step: 修完所有报错后 bump version(breaking change 升 major)
118
+ - step: 验证:`/pt status` 健康 + `npm run verify` 通过
119
+
120
+ ## Rules
121
+
122
+ ### manifest-required-as-identity
123
+ - check: Pack 必须有 `pt-asset-pack.yaml` manifest——没有 manifest 的目录只是 back-compat 兜底状态(reserved pack 退到 prj/gbl/pt,settings pack 退到 basename)。Manifest 是 pack 的身份证 + 说明书,缺它 pack 不是真正的 pt pack
124
+
125
+ ### manifest-name-kebab-case
126
+ - check: `manifest.name` 必须满足 `[a-z0-9-]{1,64}`——kebab-case 格式,1-64 字符。校验失败 → warning + fallback 到 basename(settings pack)或位置 alias(reserved pack)
127
+
128
+ ### manifest-name-not-reserved
129
+ - check: `manifest.name` 不能等于保留字——`prj`/`gbl`/`pt`/`project`/`global`/`builtin` 全部禁用(reserved pack 位置 alias 专用)。冲突 → warning + fallback
130
+
131
+ ### manifest-version-semver
132
+ - check: `manifest.version` 必须满足 `^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$`——semver 格式。校验失败 → warning + 默认 "0.0.0"
133
+
134
+ ### manifest-fallback-is-transitional
135
+ - check: Manifest 缺失时的 fallback 是**过渡方案**,不是设计意图——reserved pack 退到位置 alias、settings pack 退到 basename 都是 back-compat 妥协。Pack 作者应始终提供 manifest,让 pack 成为自描述实体
136
+
137
+ ### reserved-pack-position-agreement
138
+ - check: Reserved pack(`@prj` / `@gbl` / `@pt`)的"reserved"**不是"内置"**——三者都是"外部 pack"(项目 / 用户 home / npm 包里的资产),reserved 是因为有**固定的物理位置约定**(pt 工具预先知道去哪里找)。用户资产 / 第三方 pack 走 settings,通过 manifest.name 走身份 alias(见 `pack-reserved-vs-external`)
139
+
140
+ ### builtin-pack-name-merges-position-and-identity
141
+ - check: builtin pack 是 3 个 reserved slot 里最特殊的——位置 alias `@pt` = 身份 alias `@pt` 合一。`src/builtin/assets/pt-asset-pack.yaml` 的 `name: pt` 合法(保留名作为身份 alias),working set 双索引 key 重合。其他 reserved pack(prj/gbl)仍用跨项目身份名(`pt-internal` 等),不合一(见 `pack-builtin-special`)
142
+
143
+ ### validate-pack-never-throws
144
+ - check: validatePack 失败时返 `ValidationResult` 对象(`ok=false` + `errors[]`),不抛异常——保证加载链不阻断(§6.7.7)。Manifest parse 失败同样不阻断(parseManifest 已容错,warning 进 notify)
145
+
146
+ ### project-pack-degradation
147
+ - check: Project pack 失效时强制激活 builtin `guide` profile(`projectPackDegraded=true`),覆盖用户配置的 `pt.default-profile`——保证 pi 可用让用户走 `pack-repair` flow 修复(§6.7.3)
148
+
149
+ ### restart-after-repair
150
+ - check: 修复 pack 后必须重启 pi session——`projectPackDegraded` 标记在 session_start 重新校验时清零,不重启则继续降级
151
+
152
+ ### global-pack-guide-non-interactive
153
+ - check: 全局 Pack 初始化引导仅在 TTY + 非 CI + 无 `PT_NO_GUIDE` 环境触发(§7.5.1)——避免阻塞 CI / 后台进程
154
+
155
+ ## Checklists
156
+
157
+ ### pack-create-checklist
158
+ - step: pack-root 路径合法(绝对路径或相对 cwd)
159
+ - step: pack-root 不存在或为空(避免覆盖)
160
+ - step: 至少一个 asset 子目录存在(domains/blueprints/profiles)
161
+ - step: `pt-asset-pack.yaml` 存在且字段合法(kebab-case name / semver version / description 可选)
162
+ - step: 若作为 settings pack 暴露,`.pi/settings.json` 的 `pt.asset-packs[]` 已声明
163
+ - step: `/pt status` 显示新 pack 健康(✅)
164
+
165
+ ### pack-repair-checklist
166
+ - step: `/pt status` 列出所有 `[repair-required]` warnings
167
+ - step: 每个 warning 对应 pack-repair flow 的一个 step(manifest 缺失 / 解析失败 / name 校验 / version 校验 / dir-not-found / no-asset-subdir / load-failed)
168
+ - step: 修复后 `/pt status` 无 `[repair-required]` warnings
169
+ - step: 重启 pi session 后 `projectPackDegraded` 标记清零
170
+ - step: `npm run verify` 通过(除 phase9 fixture 错位独立 bug)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: guide
3
3
  blueprint: dev-knowledge
4
- domains: [user-info, agent-info, project-analysis, authoring, usage, pack-repair]
4
+ domains: [user-info, agent-info, project-analysis, authoring, usage, pack-management]
5
5
  ---
6
6
 
7
7
  # guide (profile)
@@ -0,0 +1,22 @@
1
+ # pt-asset-pack.yaml — Pt builtin 资产(随 npm 包发布)
2
+ #
3
+ # 加载位置:src/builtin/assets/(builtin pack 根目录)
4
+ # 加载方式:builtin pack(reserved)→ pack name 走 manifest.name="pt"。
5
+ # 位置 alias @pt + 身份 alias @pt 合一(同一索引条目覆盖同一 key)。
6
+ #
7
+ # 设计意图(§2.4.2 + builtin 特例):
8
+ # - builtin pack 的"身份"就是"内置",位置 slot @pt 已是其完整身份表达
9
+ # - 不需要额外身份别名(如 pt-builtin)——跨项目身份寻址对 npm 内嵌 pack 无意义
10
+ # - 与 back-compat 默认行为一致(无 manifest 时 pack.name=位置别名)
11
+ # - 保留名规则对 builtin 特例放行(manifest.ts:name-validation):builtin 允许用
12
+ # "prj/gbl/pt" 作 manifest.name;project/global/settings pack 仍禁用保留名
13
+ #
14
+ # 加 manifest 的好处(§2.4.2):
15
+ # - /pt status 显示真实 version/description(不再永远 v0.0.0 / undefined)
16
+ # - 跨 builtin 的 manifest 校验走同一条 parseManifest 路径,无双轨
17
+ # - 与 project pack 的 manifest 校验语义对称(同样:reserved pack 读 manifest,
18
+ # 身份 alias 优先;只是 builtin 的"身份"恰好等于位置别名)
19
+
20
+ name: pt
21
+ version: 0.0.0
22
+ description: Pt builtin assets(随 npm 包发布,跨项目复用——guide profile + usage/authoring 等参考文档)
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // - `session_shutdown` 调 `clearSessionById(sessionId)` 精确清本 session
22
22
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
- import { mkdir, writeFile } from "node:fs/promises";
24
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
25
25
  import { existsSync } from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { randomUUID } from "node:crypto";
@@ -39,6 +39,7 @@ import { clearSessionById, getSessionById, resetSessionState, } from "./session.
39
39
  import { buildFullPrompt, buildManualDoc, checkText, flowsText, packsText, statusText, } from "./commands.js";
40
40
  import { loadAndTranspile } from "./transpile.js";
41
41
  import { persistManualToSession, refreshInjectionFooter, refreshManualWidget, tryRestoreManual, } from "./manual-session.js";
42
+ import { writeProbeResult } from "./manual-writeback.js";
42
43
  import { slog } from "./slog.js";
43
44
  /** 从 ExtensionContext 拿 sessionId(tool / command handler ctx 形态)。 */
44
45
  function getSessionIdFromCtx(ctx) {
@@ -762,15 +763,40 @@ export default function (pi) {
762
763
  : `? ${result.message}`;
763
764
  // v11.x:verify 后重读文件刷新 widget(用户可能手动 tick 了 checklist)
764
765
  // 不改 session.activeManual,只 refresh 派生数据(widget + cachedManualProgress)
766
+ // v15.x(issue pt-verify-result-not-written-back-to-manual):自动写回 manual 文件
767
+ // - 按 probe 名匹配 step 的 observe 列表,定位 ## 执行状态 对应行
768
+ // - 写 outcome + message + 维护 frontmatter 注释的 completed probes 列表
769
+ // - step 全部 observe 都 completed → checklist - [ ] → - [x]
770
+ // - withFileMutationQueue 保证并发安全
771
+ // - 写回失败不阻断 verify 结果返回(异常 catch 走 text 末尾提示)
765
772
  const sessionId = getSessionIdFromCtx(ctx);
766
773
  const s = sessionId ? getSessionById(sessionId) : null;
774
+ let writebackNote = "";
767
775
  if (s?.activeManual) {
776
+ const filePath = s.activeManual.filePath;
777
+ try {
778
+ await withFileMutationQueue(filePath, async () => {
779
+ const before = await readFile(filePath, "utf8");
780
+ const wb = writeProbeResult(before, params.probe, result.outcome, result.message);
781
+ if (wb.changed) {
782
+ await writeFile(filePath, wb.content, "utf8");
783
+ const checked = wb.checkedSteps.length > 0 ? `,勾选 ${wb.checkedSteps.length} 个 checklist` : "";
784
+ writebackNote = `\n↳ 已写回 manual step ${wb.stepIndexes.join(", ")}${checked}`;
785
+ }
786
+ else if (wb.matchCount === 0) {
787
+ writebackNote = `\n? probe "${params.probe}" 未匹配 activeManual 任何 step 的 observe(${filePath})`;
788
+ }
789
+ });
790
+ }
791
+ catch (e) {
792
+ writebackNote = `\n! 写回 manual 失败: ${errMsg(e)}`;
793
+ }
768
794
  await refreshManualWidget(ctx.ui, s);
769
795
  refreshInjectionFooter(ctx.ui, s);
770
796
  }
771
797
  return {
772
- content: [{ type: "text", text }],
773
- details: result,
798
+ content: [{ type: "text", text: text + writebackNote }],
799
+ details: { ...result, writeback: writebackNote || undefined },
774
800
  };
775
801
  },
776
802
  });
@@ -10,9 +10,12 @@ declare function persistManualToSession(pi: ExtensionAPI, m: ActiveManual): void
10
10
  declare function refreshInjectionFooter(ui: ExtensionUIContext, session: SessionState): void;
11
11
  /** 刷新 widget(aboveEditor)。根据 session.activeManual 决定显示/撤掉。
12
12
  * - 无 activeManual → 撤 widget
13
- * - 文件不存在 / completed 清 activeManual + 撤 widget
13
+ * - 文件不存在 / 真完成(status=completed + stepDone===stepTotal + 无 —/INCONCLUSIVE)→ 清 activeManual + 撤 widget
14
+ * - 伪完成(status=completed 但 step 未全勾或有 —/INCONCLUSIVE)→ 仍渲染 widget,加 ⚠ fake done 提示
14
15
  * - in-progress → 渲染 3 行 widget + 更新 cachedManualProgress(footer 同步读)
15
- * v12.x:state 全部从 session 参数读,不再读写 module-level 单例。 */
16
+ * v12.x:state 全部从 session 参数读,不再读写 module-level 单例。
17
+ * v15.x(issue pt-manual-completion-check-too-loose):用 checkManualCompletion 替代 `p.status === "completed"`,
18
+ * 让伪完成的 manual 仍 active(widget 重新挂载,提示用户步骤未全完成)。 */
16
19
  declare function refreshManualWidget(ui: ExtensionUIContext, session: SessionState): Promise<void>;
17
20
  /** session_start 时试恢复 manual:读 pt:active-manual entry → 校验文件存在 + status !== completed。
18
21
  * 独立于 profile 加载链——profile 失败 / 无 profile 也能恢复 manual 追踪。 */
@@ -21,7 +21,7 @@
21
21
  // - profile-persist:MinimalSessionManager
22
22
  // - slog:slog(session-scoped logger 快捷)
23
23
  // - diagnostics:errMsg
24
- import { isManualActive, parseManualProgress, renderManualFooterSuffix, renderManualWidgetLines, } from "./manual-track.js";
24
+ import { checkManualCompletion, isManualActive, parseManualProgress, renderManualFooterSuffix, renderManualWidgetLines, } from "./manual-track.js";
25
25
  import { renderInjectionFooter } from "./injection-status.js";
26
26
  import { errMsg } from "./diagnostics.js";
27
27
  import { slog } from "./slog.js";
@@ -94,9 +94,12 @@ function refreshInjectionFooter(ui, session) {
94
94
  }
95
95
  /** 刷新 widget(aboveEditor)。根据 session.activeManual 决定显示/撤掉。
96
96
  * - 无 activeManual → 撤 widget
97
- * - 文件不存在 / completed 清 activeManual + 撤 widget
97
+ * - 文件不存在 / 真完成(status=completed + stepDone===stepTotal + 无 —/INCONCLUSIVE)→ 清 activeManual + 撤 widget
98
+ * - 伪完成(status=completed 但 step 未全勾或有 —/INCONCLUSIVE)→ 仍渲染 widget,加 ⚠ fake done 提示
98
99
  * - in-progress → 渲染 3 行 widget + 更新 cachedManualProgress(footer 同步读)
99
- * v12.x:state 全部从 session 参数读,不再读写 module-level 单例。 */
100
+ * v12.x:state 全部从 session 参数读,不再读写 module-level 单例。
101
+ * v15.x(issue pt-manual-completion-check-too-loose):用 checkManualCompletion 替代 `p.status === "completed"`,
102
+ * 让伪完成的 manual 仍 active(widget 重新挂载,提示用户步骤未全完成)。 */
100
103
  async function refreshManualWidget(ui, session) {
101
104
  const m = session.activeManual;
102
105
  if (!m) {
@@ -105,7 +108,7 @@ async function refreshManualWidget(ui, session) {
105
108
  return;
106
109
  }
107
110
  const p = await parseManualProgress(m.filePath);
108
- if (!p || p.status === "completed") {
111
+ if (!p || !checkManualCompletion(p)) {
109
112
  session.activeManual = null;
110
113
  session.cachedManualProgress = null;
111
114
  ui.setWidget("pt-manual", undefined);
@@ -1,14 +1,32 @@
1
+ /** ## 执行状态 表的 Outcome 列规范化为 4 个值之一(其他字符串如 TODO 视为 INCONCLUSIVE)。
2
+ * - "—" :未填写(buildManualDoc 表头初始值)
3
+ * - "COMPLETED":执行符合预期
4
+ * - "DEVIATED" :偏离预期(不是失败,见 src/schema.ts ProbeOutcomeKind 注释)
5
+ * - "INCONCLUSIVE":无法判定(人工评估 / probe 缺参数等) */
6
+ export type OutcomeKind = "—" | "COMPLETED" | "DEVIATED" | "INCONCLUSIVE";
7
+ /** Manual 实例文件 ## 执行状态 表的某一行(stepIndex + outcome)。
8
+ * stepIndex 1-indexed(与表行 | N | ... 对齐)。 */
9
+ export interface StepOutcome {
10
+ stepIndex: number;
11
+ outcome: OutcomeKind;
12
+ }
1
13
  /** Manual 实例文件 parse 结果。
2
14
  * - procedure:从 frontmatter.procedure 取
3
15
  * - stepDone / stepTotal:实时正则数 "- [x]" / "- [ ]"
4
16
  * - status:frontmatter.status(in-progress / completed)
5
- * - nextStep:第一个 "- [ ]" 行去前缀的文本(下一步该做的),无则 null */
17
+ * - nextStep:第一个 "- [ ]" 行去前缀的文本(下一步该做的),无则 null
18
+ * - stepOutcomes:从 ## 执行状态 表抽的每步 outcome(长度 ≤ stepTotal,缺行视为未完成)
19
+ * - hasUnfinishedOutcome:任一 outcome 是 "—" 或 "INCONCLUSIVE"(伪完成征兆之一)
20
+ * - pseudoComplete:status=completed 但有伪完成征兆(stepDone < stepTotal 或 hasUnfinishedOutcome) */
6
21
  export interface ManualProgress {
7
22
  procedure: string;
8
23
  stepDone: number;
9
24
  stepTotal: number;
10
25
  status: string;
11
26
  nextStep: string | null;
27
+ stepOutcomes: StepOutcome[];
28
+ hasUnfinishedOutcome: boolean;
29
+ pseudoComplete: boolean;
12
30
  }
13
31
  /** 从 manual 实例文件 parse 进度。失败返回 null(容错,不抛)。 */
14
32
  export declare function parseManualProgress(filePath: string): Promise<ManualProgress | null>;
@@ -17,19 +35,33 @@ export declare function parseManualProgress(filePath: string): Promise<ManualPro
17
35
  * 1. frontmatter:取首段 `---` ... `---` 之间的 YAML
18
36
  * 2. procedure / status:从 frontmatter 行解析(`key: value` 形式,宽松)
19
37
  * 3. checklist:全文正则 `/^- \[([ x])\]\s+(.+)$/gm` 数 done / total
20
- * 4. nextStep:第一个未完成项的文本(去前缀) */
38
+ * 4. nextStep:第一个未完成项的文本(去前缀)
39
+ * 5. stepOutcomes:从 ## 执行状态 段抽 `| N | <outcome> | <message> |` 行
40
+ * 6. hasUnfinishedOutcome:任一 outcome 是 "—" 或 "INCONCLUSIVE"
41
+ * 7. pseudoComplete:status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome */
21
42
  export declare function parseManualProgressFromContent(content: string): ManualProgress | null;
22
43
  /** widget 渲染:3 行 string[],给 `ui.setWidget("pt-manual", lines, { placement: "aboveEditor" })` 用。
23
- * - 第 1 行:`pt ▶ <procedure> step <done>/<total> (<status>)`(completed 加 ` ✓ done`)
44
+ * - 第 1 行:`pt ▶ <procedure> step <done>/<total> (<status>)`(completed 加 ` ✓ done`;pseudoComplete 加 `⚠ fake done`)
24
45
  * - 第 2 行:` next: <nextStep 文本>`(无 nextStep 时该行省略)
25
46
  * - 第 3 行:` file: <filePath>`
26
- * status === completed 时仍显示,但 footer 后缀改 done;widget 撤掉由 caller 决定(见 index.ts refreshManualWidget)。 */
47
+ * status === completed 时仍显示,但 footer 后缀改 done;widget 撤掉由 caller 决定(见 index.ts refreshManualWidget)。
48
+ * v15.x(issue pt-manual-completion-check-too-loose):pseudoComplete 时加 ⚠ 提示用户
49
+ * (status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome → 伪完成)。 */
27
50
  export declare function renderManualWidgetLines(filePath: string, p: ManualProgress): string[];
28
51
  /** footer 后缀:有 activeManual 时追加。
29
52
  * - in-progress → `· manual: <procedure> <done>/<total>`
30
- * - completed → `· manual: <procedure> done` */
53
+ * - completed → `· manual: <procedure> done`
54
+ * - pseudoComplete(status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome)→ `· manual: <procedure> ⚠ fake done` */
31
55
  export declare function renderManualFooterSuffix(p: ManualProgress): string;
32
- /** 文件存在 + status !== completed。配合 widget/footer 决定要不要展示。
56
+ /** 检查 manual 实例是否应视为 active(widget 继续显示 + footer 显示)。
57
+ * v15.x(issue pt-manual-completion-check-too-loose):硬校验 status=completed 也需 step 全勾 + outcome 非 —/INCONCLUSIVE。
58
+ * - status !== "completed" → true(active)
59
+ * - status="completed" 但 stepDone < stepTotal → true(伪完成)
60
+ * - status="completed" 但 hasUnfinishedOutcome → true(伪完成)
61
+ * - status="completed" 且 stepDone === stepTotal 且无 —/INCONCLUSIVE → false(真完成) */
62
+ export declare function checkManualCompletion(p: ManualProgress): boolean;
63
+ /** 文件存在 + checkManualCompletion(p) === true。配合 widget/footer 决定要不要展示。
33
64
  * - 用 fs.access 探测存在(轻量,不读全文)
34
- * - 真的 parse status 用 parseManualProgress(一次 readFile 取齐) */
65
+ * - 真的 parse status 用 parseManualProgress(一次 readFile 取齐)
66
+ * v15.x:status=completed 时不再直接判 active,需 checkManualCompletion 硬校验 */
35
67
  export declare function isManualActive(filePath: string): Promise<boolean>;
@@ -22,12 +22,28 @@ export async function parseManualProgress(filePath) {
22
22
  return null;
23
23
  }
24
24
  }
25
+ /** 规范化 Outcome 列字符串到 OutcomeKind。未识别值归 INCONCLUSIVE(保守按未完成处理)。 */
26
+ function normalizeOutcome(raw) {
27
+ const trimmed = raw.trim();
28
+ if (trimmed === "—" || trimmed === "-" || trimmed === "")
29
+ return "—";
30
+ if (trimmed === "COMPLETED")
31
+ return "COMPLETED";
32
+ if (trimmed === "DEVIATED")
33
+ return "DEVIATED";
34
+ if (trimmed === "INCONCLUSIVE")
35
+ return "INCONCLUSIVE";
36
+ return "INCONCLUSIVE";
37
+ }
25
38
  /** 内部:从字符串内容 parse(便于单测 & 未来内联输入)。
26
39
  * 算法:
27
40
  * 1. frontmatter:取首段 `---` ... `---` 之间的 YAML
28
41
  * 2. procedure / status:从 frontmatter 行解析(`key: value` 形式,宽松)
29
42
  * 3. checklist:全文正则 `/^- \[([ x])\]\s+(.+)$/gm` 数 done / total
30
- * 4. nextStep:第一个未完成项的文本(去前缀) */
43
+ * 4. nextStep:第一个未完成项的文本(去前缀)
44
+ * 5. stepOutcomes:从 ## 执行状态 段抽 `| N | <outcome> | <message> |` 行
45
+ * 6. hasUnfinishedOutcome:任一 outcome 是 "—" 或 "INCONCLUSIVE"
46
+ * 7. pseudoComplete:status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome */
31
47
  export function parseManualProgressFromContent(content) {
32
48
  const fm = extractFrontmatter(content);
33
49
  const procedure = fm?.procedure ?? "(unknown)";
@@ -48,7 +64,33 @@ export function parseManualProgressFromContent(content) {
48
64
  nextStep = text;
49
65
  }
50
66
  }
51
- return { procedure, stepDone, stepTotal, status, nextStep };
67
+ // 5. ## 执行状态 表的每行 outcome
68
+ const stepOutcomes = [];
69
+ // 表格行格式 `| N | <outcome> | <message> |`,message 列可空(`\s*` 兼容末列前的 0+ 空格)
70
+ const tableRe = /^\| (\d+) \| (.+?) \| [^\|]*\s*\|$/gm;
71
+ for (const m of content.matchAll(tableRe)) {
72
+ const idxStr = m[1] ?? "";
73
+ const rawOutcome = m[2] ?? "";
74
+ const idx = Number.parseInt(idxStr, 10);
75
+ if (Number.isFinite(idx) && idx > 0) {
76
+ stepOutcomes.push({
77
+ stepIndex: idx,
78
+ outcome: normalizeOutcome(rawOutcome),
79
+ });
80
+ }
81
+ }
82
+ const hasUnfinishedOutcome = stepOutcomes.some((o) => o.outcome === "—" || o.outcome === "INCONCLUSIVE");
83
+ const pseudoComplete = status === "completed" && (stepDone < stepTotal || hasUnfinishedOutcome);
84
+ return {
85
+ procedure,
86
+ stepDone,
87
+ stepTotal,
88
+ status,
89
+ nextStep,
90
+ stepOutcomes,
91
+ hasUnfinishedOutcome,
92
+ pseudoComplete,
93
+ };
52
94
  }
53
95
  function extractFrontmatter(content) {
54
96
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
@@ -69,15 +111,23 @@ function extractFrontmatter(content) {
69
111
  return out;
70
112
  }
71
113
  /** widget 渲染:3 行 string[],给 `ui.setWidget("pt-manual", lines, { placement: "aboveEditor" })` 用。
72
- * - 第 1 行:`pt ▶ <procedure> step <done>/<total> (<status>)`(completed 加 ` ✓ done`)
114
+ * - 第 1 行:`pt ▶ <procedure> step <done>/<total> (<status>)`(completed 加 ` ✓ done`;pseudoComplete 加 `⚠ fake done`)
73
115
  * - 第 2 行:` next: <nextStep 文本>`(无 nextStep 时该行省略)
74
116
  * - 第 3 行:` file: <filePath>`
75
- * status === completed 时仍显示,但 footer 后缀改 done;widget 撤掉由 caller 决定(见 index.ts refreshManualWidget)。 */
117
+ * status === completed 时仍显示,但 footer 后缀改 done;widget 撤掉由 caller 决定(见 index.ts refreshManualWidget)。
118
+ * v15.x(issue pt-manual-completion-check-too-loose):pseudoComplete 时加 ⚠ 提示用户
119
+ * (status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome → 伪完成)。 */
76
120
  export function renderManualWidgetLines(filePath, p) {
77
- const statusText = p.status === "completed" ? "✓ done" : `(${p.status})`;
121
+ let statusText;
122
+ if (p.status === "completed") {
123
+ statusText = p.pseudoComplete ? "⚠ fake done" : "✓ done";
124
+ }
125
+ else {
126
+ statusText = `(${p.status})`;
127
+ }
78
128
  const lines = [];
79
129
  lines.push(`pt ▶ ${p.procedure} step ${p.stepDone}/${p.stepTotal} ${statusText}`);
80
- if (p.nextStep && p.status !== "completed") {
130
+ if (p.nextStep && !p.pseudoComplete && p.status !== "completed") {
81
131
  lines.push(` next: ${p.nextStep}`);
82
132
  }
83
133
  lines.push(` file: ${filePath}`);
@@ -85,16 +135,35 @@ export function renderManualWidgetLines(filePath, p) {
85
135
  }
86
136
  /** footer 后缀:有 activeManual 时追加。
87
137
  * - in-progress → `· manual: <procedure> <done>/<total>`
88
- * - completed → `· manual: <procedure> done` */
138
+ * - completed → `· manual: <procedure> done`
139
+ * - pseudoComplete(status=completed 但 stepDone<stepTotal 或 hasUnfinishedOutcome)→ `· manual: <procedure> ⚠ fake done` */
89
140
  export function renderManualFooterSuffix(p) {
90
141
  if (p.status === "completed") {
91
- return `· manual: ${p.procedure} done`;
142
+ return p.pseudoComplete
143
+ ? `· manual: ${p.procedure} ⚠ fake done`
144
+ : `· manual: ${p.procedure} done`;
92
145
  }
93
146
  return `· manual: ${p.procedure} ${p.stepDone}/${p.stepTotal}`;
94
147
  }
95
- /** 文件存在 + status !== completed。配合 widget/footer 决定要不要展示。
148
+ /** 检查 manual 实例是否应视为 active(widget 继续显示 + footer 显示)。
149
+ * v15.x(issue pt-manual-completion-check-too-loose):硬校验 status=completed 也需 step 全勾 + outcome 非 —/INCONCLUSIVE。
150
+ * - status !== "completed" → true(active)
151
+ * - status="completed" 但 stepDone < stepTotal → true(伪完成)
152
+ * - status="completed" 但 hasUnfinishedOutcome → true(伪完成)
153
+ * - status="completed" 且 stepDone === stepTotal 且无 —/INCONCLUSIVE → false(真完成) */
154
+ export function checkManualCompletion(p) {
155
+ if (p.status !== "completed")
156
+ return true;
157
+ if (p.stepDone < p.stepTotal)
158
+ return true;
159
+ if (p.hasUnfinishedOutcome)
160
+ return true;
161
+ return false;
162
+ }
163
+ /** 文件存在 + checkManualCompletion(p) === true。配合 widget/footer 决定要不要展示。
96
164
  * - 用 fs.access 探测存在(轻量,不读全文)
97
- * - 真的 parse status 用 parseManualProgress(一次 readFile 取齐) */
165
+ * - 真的 parse status 用 parseManualProgress(一次 readFile 取齐)
166
+ * v15.x:status=completed 时不再直接判 active,需 checkManualCompletion 硬校验 */
98
167
  export async function isManualActive(filePath) {
99
168
  try {
100
169
  await access(filePath);
@@ -103,5 +172,7 @@ export async function isManualActive(filePath) {
103
172
  return false;
104
173
  }
105
174
  const p = await parseManualProgress(filePath);
106
- return p !== null && p.status !== "completed";
175
+ if (!p)
176
+ return false;
177
+ return checkManualCompletion(p);
107
178
  }